Compare commits
213 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5f69123d89 | ||
|
|
cf421b52a8 | ||
|
|
1b049d35b8 | ||
|
|
f6690a3566 | ||
|
|
cc97020216 | ||
|
|
0e1e3ba46e | ||
|
|
657c209827 | ||
|
|
6ec44bb526 | ||
|
|
0943505d90 | ||
|
|
f155f7615f | ||
|
|
e8850575f2 | ||
|
|
d1c4d0a621 | ||
|
|
ecf27abdc5 | ||
|
|
5f5142baa6 | ||
|
|
fa53a9fc5a | ||
|
|
9a0a7b4e5f | ||
|
|
1a43703db5 | ||
|
|
459589c51f | ||
|
|
9393fe7380 | ||
|
|
f62bd1c3c4 | ||
|
|
27ff992d97 | ||
|
|
cb4b8a7d5f | ||
|
|
671424b202 | ||
|
|
ce1a7da1f5 | ||
|
|
236aefa34d | ||
|
|
d179490891 | ||
|
|
91e4cb3f14 | ||
|
|
37f02f52e9 | ||
|
|
01f1ffb646 | ||
|
|
131c93c86b | ||
|
|
4dd1a648ce | ||
|
|
91df749bc4 | ||
|
|
20b04e55fb | ||
|
|
a94ad9e8c2 | ||
|
|
807ff50612 | ||
|
|
d46ff39c2b | ||
|
|
199de04562 | ||
|
|
f2a18b7677 | ||
|
|
aab816db32 | ||
|
|
66ad6b9931 | ||
|
|
beeefb2be1 | ||
|
|
110c418d01 | ||
|
|
1296e66902 | ||
|
|
31d969e071 | ||
|
|
32681f6ea8 | ||
|
|
d77f67aa91 | ||
|
|
be071cfa3a | ||
|
|
e098c70684 | ||
|
|
91509f5846 | ||
|
|
454cc44793 | ||
|
|
1baf94d788 | ||
|
|
b0d9317f73 | ||
|
|
9b80f03993 | ||
|
|
af0fd8bf69 | ||
|
|
f76b19e818 | ||
|
|
02b717c612 | ||
|
|
e29d40bca2 | ||
|
|
7f8403cfb4 | ||
|
|
d5168f12c6 | ||
|
|
9b94bbf098 | ||
|
|
34c83c2253 | ||
|
|
16cfa3b37b | ||
|
|
498dc63f91 | ||
|
|
c48330dc35 | ||
|
|
ca8abd9b12 | ||
|
|
72b2b34af3 | ||
|
|
6a7b7a7d39 | ||
|
|
c1af372a06 | ||
|
|
7946663299 | ||
|
|
232aecfe3b | ||
|
|
ac1db7f10d | ||
|
|
ef99441844 | ||
|
|
c4e3534682 | ||
|
|
d335d7cab0 | ||
|
|
f7c3faa7bd | ||
|
|
1338e2306e | ||
|
|
83a2b42408 | ||
|
|
0ce5938f5b | ||
|
|
5ab50e04ae | ||
|
|
ee0fd4e8d8 | ||
|
|
369b7d9410 | ||
|
|
c9a0b270cd | ||
|
|
c8aa64fa7c | ||
|
|
d5b18bd6fb | ||
|
|
eb7ade5e48 | ||
|
|
a59444e24b | ||
|
|
c6b7157d5b | ||
|
|
3d9a622f09 | ||
|
|
50bb245777 | ||
|
|
128cebfc20 | ||
|
|
c106bc40cc | ||
|
|
52af10d549 | ||
|
|
8c03d1e9eb | ||
|
|
f1d49e317d | ||
|
|
f3be8ed97b | ||
|
|
258edb87c9 | ||
|
|
703dd40879 | ||
|
|
18f7ed19e0 | ||
|
|
3d30e746a0 | ||
|
|
51d085dbb0 | ||
|
|
66b4627d21 | ||
|
|
99263e9a66 | ||
|
|
999c5b0836 | ||
|
|
52aa1b198d | ||
|
|
36a702a6f4 | ||
|
|
512be8b0c9 | ||
|
|
3dabd07969 | ||
|
|
29bec90001 | ||
|
|
1191ac732b | ||
|
|
a80fcebe94 | ||
|
|
35b21b10e3 | ||
|
|
22d8f9a628 | ||
|
|
42e8d999d3 | ||
|
|
cf4d18e23e | ||
|
|
bfa1c45ae6 | ||
|
|
6e888499c4 | ||
|
|
9223b70eba | ||
|
|
fe55855876 | ||
|
|
6aa8a3d7a2 | ||
|
|
887702b729 | ||
|
|
0417c4f1ae | ||
|
|
0b95785f49 | ||
|
|
2772e9ad4d | ||
|
|
8a16fa3a5c | ||
|
|
84d3127675 | ||
|
|
e684fbc0dc | ||
|
|
bb92d26be9 | ||
|
|
f74bb8e4a5 | ||
|
|
5d72be8e87 | ||
|
|
04e6f63cd7 | ||
|
|
ffb0b77f37 | ||
|
|
9d621c3149 | ||
|
|
7126c3c67c | ||
|
|
62197f99cb | ||
|
|
db68452f55 | ||
|
|
9ec5010495 | ||
|
|
5978b90da2 | ||
|
|
223d9d394f | ||
|
|
38b82abc48 | ||
|
|
aade4ec488 | ||
|
|
38a02f8304 | ||
|
|
8f7e1c5629 | ||
|
|
0be90d8801 | ||
|
|
4ca9cc68cb | ||
|
|
62e9acf9be | ||
|
|
6cb682f065 | ||
|
|
4cfd5c8ef2 | ||
|
|
24bf66ddbe | ||
|
|
a0b0339c8b | ||
|
|
2c9081b313 | ||
|
|
bd245801b0 | ||
|
|
11dac62b94 | ||
|
|
a8bac7875a | ||
|
|
dbba00b51b | ||
|
|
90cddce54c | ||
|
|
f0f6c08610 | ||
|
|
a2fe9a06c5 | ||
|
|
cb79f17c23 | ||
|
|
0009895537 | ||
|
|
df705670b1 | ||
|
|
c616e9fdbd | ||
|
|
c70a092d99 | ||
|
|
34fee47c08 | ||
|
|
bf97a14fe3 | ||
|
|
60d4d56f80 | ||
|
|
8bf33c7384 | ||
|
|
595e6e9a8f | ||
|
|
9efbdfc046 | ||
|
|
e1d4b6bcc5 | ||
|
|
a6db74488e | ||
|
|
821f5d61b5 | ||
|
|
f83ac17c83 | ||
|
|
3519c7f699 | ||
|
|
78d9706cb7 | ||
|
|
a593a8054b | ||
|
|
9556be6b85 | ||
|
|
e8669f8a3d | ||
|
|
6a887ee1e4 | ||
|
|
6dbaaa3099 | ||
|
|
7f9242da1e | ||
|
|
2c3151089f | ||
|
|
1ee388126f | ||
|
|
964cecdf66 | ||
|
|
7141eb5013 | ||
|
|
d7fcb35a4e | ||
|
|
56348905a6 | ||
|
|
3336faa953 | ||
|
|
e22e1521a4 | ||
|
|
e974acac4e | ||
|
|
8a13bfcd9e | ||
|
|
1e04d6e98a | ||
|
|
a0045fc6bb | ||
|
|
ec10b51387 | ||
|
|
b054464280 | ||
|
|
1a32159526 | ||
|
|
c6cc7102e6 | ||
|
|
6a5dc93fd8 | ||
|
|
a85966bb1d | ||
|
|
bf3c401c37 | ||
|
|
4da7e0b50d | ||
|
|
d78f2f08cb | ||
|
|
d2952766e4 | ||
|
|
40503b6bd2 | ||
|
|
e875e0ee42 | ||
|
|
6f9ea6c6e3 | ||
|
|
a79d013179 | ||
|
|
4b75389a31 | ||
|
|
1039251d63 | ||
|
|
2cd9e9d642 | ||
|
|
7d495468ea | ||
|
|
e47a82a4bc | ||
|
|
87421e450a | ||
|
|
479917fad1 |
3
.gitignore
vendored
@@ -11,4 +11,5 @@ apikeys.xml
|
||||
/app/**/*.aab
|
||||
/app/**/*.apk
|
||||
/_img/connectors/*.ai
|
||||
api-7125266970515251116-798419-8e2dda660c80.json
|
||||
api-7125266970515251116-798419-8e2dda660c80.json
|
||||
output-metadata.json
|
||||
23
.travis.yml
@@ -1,16 +1,25 @@
|
||||
language: android
|
||||
language: java
|
||||
dist: trusty
|
||||
android:
|
||||
components:
|
||||
- build-tools-29.0.3
|
||||
- android-29
|
||||
env:
|
||||
global:
|
||||
- secure: KYdFlMarsyXw+OHht1Atp+Kirbw9O09Ck14EjFuKb1eNtknurZ/tGEXuD+8xWh1W8W21kgHEG7s3rzru53t29buz+FW9f+ZmhEWXFP3OydyvXLw4BAVVOjm6xG2uHX/8MOGLJNM7cfaF25EPQ+kznHe84R29KaLH90mNRr2lPa4VnfbcnvDStiVaez/vJ72UoYSP5HICAzoF70yC3ZvvCK1hZv71UIysCbFE2IkxvMhG9OOGebdnRmFssaRCrvfRLjitobcLzkPWzZZIqdjNASf8/iAxX8VgGBYfVj8ID06AfMrtgXNJRCvcD0LICraQ+WPUbikMunRieGO8PNHSB5vKdPoC50aLUa0RoRb4G3QM1pR2A8xAFlIJFX2R7iY+2t24L9hRFqB98+QoQzutfkAI1T0rzem/wtpZpuan+bDawDJEHGCeYbE0aPDAl6lytgrEE9fRgV3c1jJLQzu0xIWG8YLl3iMg0hL+c0wCKXoeqrfCFS6kYmmG7W+rQp4tCZifvRbWfAXwIPQieffKxqdEuUwiUsYxdzCu9v9uU3nflEOLLuRgeMP3gV8mpur9b5GztpkfgfzcAqsF+NiY01kYgGtrgCYlMy0TxASE+UuALrtkQtU01wwhs9RH7Az0Ib3C+MT5DTjxHQCYETIViocmNEG2vfAbgHazCpGAhcY=
|
||||
- secure: Hoko50vP+Mwm/O4CWvPvjMxd1gGhi+Bultjyy1WpludSmZFCfKz45Mj1EqzeYk6MeMLvGOEkSLB5wjdXdgJ9j5gEOF5K34k4vESJA7+DqDO3I7Xw9cnWgOXdFqB0qGHar0TVP3Dfg7ZcRgtmeX6t2uoELFLiS9GvTnbZXk4PCUybd979Xi8XHjEQV7+3EZbSjtsI4GFeIK1rrjd0I+UM88zYrWnz1KhdCjWvQb4iZjo+ib6NmGGEMqR7jJPRZz3KB01Y+n5h21qq9/Nv31zQIqt2B5nRxy3vBvvqKapgprIk+hVOpNnBU8w89uWUU6tYUeFk0t7z1TYWjgaBrMmGCM+aKkQ2q5F/ygNzDwB+KkpJx709Yhf0ZX8g2yvkdz+Ok7moYuvmrOPOf4E/U3BlfZxbGtRD2bGYbDgHLFYlTn6v5J2kJHDJAz31yvF5jvJejDaPp2IBVfoMRy7ZJFmGUNHGd9Se6bRwxS++AoobP5WDrBiUXNe2KKDMs3e7vbaO+hLbZ9XHpjeWIJGUfvtTee8EHZqF/8A3ju53V4/R0ehlOv2UZbpYNqcmwrsy9/R4pgMfDkG3Q054LmYrxD8DIC9b8excVMwWRP5aQ1TqZnxO2B1/vJU87RcnGl3jekeHTdHXbRq9BMV4dAdPfB9X3nGIi2GgV0iTBk+24xccOc0=
|
||||
- secure: RsN/2nBVv0byMzwchuAhDti1AWKECg3Uzqk6Ahgaqg02zx8GHj60j0qNax7KuMUg70X3G4b3m8ZzndAU0wcJ98UyIku7ofUYgXHm0XYKTJwiyyhrKJ8pZ4qoeCoRkitIGIitlb84fSufVamcoLyLNbLUsO5OzL+Uscyhq/BwAhyOhj5FB9DM+GE9ntanQ4m3k4guMyMctR9CGS6Tk4LKJ1WCm0AnjTPalc+we+7yORY2J/9d6VHLKfaFXbpuWmrdnFfDZqxqcUODsxPVE0LuUtXyKQDTjjfQc8106Z/z19AJS+oLm/2UND84PD3MqsjX6EX0/9k3fY0OsoCuQAivDRmtevQ0bDQrTAyeBLcfnPCw/MYiJWyBcaYBAYK42EAfsFTDBRIduFB/Dpvg+8GuLZSdm4xVYpTlQ2pMtlGNWIGIRQsjk9LZ8swq8QBMiiF/bpMGKdfmnyQj8jkEOCWaAzkR9O+4E4Y7PuBENef9XuV0hLMryCrML2YXigxAKkEUOPhfnG+AbEY+g2kAMp+2EtXaG3tsGxoMhEYA+uRd3o2cacQMMwRpnePH5DYg6F05mrvdHPpt+P9UR1iHaHmjBPAYeksmrdP8bS088zcnePgiL6+6N0m3+l8Krihmxg5RyWjnH18IwX4RO+xg4x3cW+zaScCDbbotDMEYtChF+Hs=
|
||||
- secure: LQHMdhaPUlCuJPFrCPpUphJSY6xzAFI/7RrcAVLtLcPhGdS+MeNifIkkAH7MeitTHroOC0dGkZ4bg/8/7bKfgwY4vPH9P50kZcnX5mI6zfBHgNYJzuthj+vJH9RAtkdQOW9Fe1uPIx8R9GUWUOVnkoJh0PQ1gDXdZW5fePqUtn1kYrcCCBE+Bhe3wz6QzTBqGS1nsVRTxQfSJNGi9uH1oi9kQGgQFuCCiJ/P0A6MIhSItkOfuggx/iorA+iASbhWkB4nXYQBbFe/ZhFJWbVfgYlOM0HtpKh8B2AqKw21Em32JoovCbUof4adkY7cH8/4Rt9SujC9YOw+a6oM+e//jJT0sie77V7zl670j+qODTuNvV4qVUwtoxShyc1Sfbd+Xb0xn/OC7DzBg97YuYCF/84yyuq12rl/cofynWE1L5YvGNSJk241XUw98Bvl0MK4VIfQvG9zJP0HnQZcWKt6kFOIEJSCRbmkd2tPPAZFBXBQf/bvpULOoKwneGJZBSapRoCyGwemM+EAzVB9UOXAqsXZ4FHkt1SSJVrTVwgxvXpCfmF6LZPhbz6nvouRWGsC/GdWjrHtdW5lEOvS27qKEL5rXwQ0o+71ZICGo8j4E0GOHXyi857qZhvO7cbOnts+iiawXiWzPXv2gGGabuqPwcU8JPEoWdaiIaeGUczfjBU=
|
||||
- secure: fvPVjj3l+TZ7HF5aGn/pmrkipGIrz+MkKNy3I7pnCJSuD/oVp9nQ5ePP/dAhaRThaW+fQbq7hOmCquPAtfoN9CUnHNV2f2l9RavDQIxdqvpXqY13A0BFffZho6A6H2kO7k6kQQPQEhl4SMJjObnX12/YDaTVx3b7aIroEJ8DyY62xGTsjExtaAksuFwUEekjh0MoWICvyBoDfrYhpiEVI2721rGMHu7FIXwmE38+jj7wwZd3Bp37yI9NY/b3ZQ/HUKyYDuoAL0xl5/GaQlRepD0v2xWQUQ40NArHLfMoscXi55UaENuswCg7rt9os8jCcZ8FkZf1cVsQ71JrE0uxgs00Jfjy2QKM5u1XUZefl1Nw5cfCDTWXIEGsz9OGiidFLehWUupX/6C6wr1BStdlRt+6Pt/FXsYHxO/qog++cKqHjOJRXi+raGAb99HhQ/hLnLUMKl5DIWlKF9DImXiOpfYxrgCJc3y91vNX6noJyWYs6PvErMukTsXFHen+fM0NtfTFoKW682oILvXjoeFvuzKpk49+rcpkJbRi5+Zdo/duSPp/flwvC4LOMi0RZOO9TNMhWKdkyWweDr1HEpvQn6RS87rpHzQwRDvm85F+PkZLMMqyWpuxBWbJf0jVbew21KvTJWamuizsIgCebFh0SSxgObzmMbAIFCkzL0PRsms=
|
||||
- ANDROID_HOME=$HOME/android-sdk
|
||||
before_install:
|
||||
- openssl aes-256-cbc -K $encrypted_53968681344a_key -iv $encrypted_53968681344a_iv -in _ci/keystore.jks.enc -out _ci/keystore.jks -d
|
||||
install:
|
||||
# Download and unzip the Android command line tools (if not already there thanks to the cache mechanism)
|
||||
# Latest version of this file available here: https://developer.android.com/studio/#command-tools
|
||||
- if test ! -e $HOME/android-cmdline-tools/cmdline-tools.zip ; then curl https://dl.google.com/android/repository/commandlinetools-linux-6609375_latest.zip > $HOME/android-cmdline-tools/cmdline-tools.zip ; fi
|
||||
- unzip -qq -n $HOME/android-cmdline-tools/cmdline-tools.zip -d $HOME/android-cmdline-tools
|
||||
# Install or update Android SDK components (will not do anything if already up to date thanks to the cache mechanism)
|
||||
- echo y | $HOME/android-cmdline-tools/tools/bin/sdkmanager --sdk_root=$HOME/android-sdk 'platform-tools' > /dev/null
|
||||
# Latest version of build-tools available here: https://developer.android.com/studio/releases/build-tools.html
|
||||
- echo y | $HOME/android-cmdline-tools/tools/bin/sdkmanager --sdk_root=$HOME/android-sdk 'build-tools;29.0.3' > /dev/null
|
||||
- echo y | $HOME/android-cmdline-tools/tools/bin/sdkmanager --sdk_root=$HOME/android-sdk 'platforms;android-29' > /dev/null
|
||||
script:
|
||||
- "./gradlew lintFossDebug testFossDebugUnitTest lintGoogleDebug testGoogleDebugUnitTest"
|
||||
- "./gradlew assembleRelease"
|
||||
@@ -22,10 +31,12 @@ cache:
|
||||
- "$HOME/.gradle/caches/"
|
||||
- "$HOME/.gradle/wrapper/"
|
||||
- "$HOME/.android/build-cache"
|
||||
- "$HOME/android-cmdline-tools"
|
||||
- "$HOME/android-sdk"
|
||||
deploy:
|
||||
provider: releases
|
||||
api_key:
|
||||
secure: B+V5Fz8k9HbpecyMjpJuLr8aVBrdwtDBDkQh4YQ8nu+Da4AiYwEJZseWXhOWs+oms0gNen9bBxsakQQKu7GKYDs8gIXZZtANWsc0gse8xo+cYT7NqEM3jP6mM3ytAv7VNRX3N2cdL7xazELK3/5+mghfORAAdXXYKUFGG5eTKoML8zgdPVN8E9QFqiusLXqoKhxOMCSE4NS+Di7CGlUmnidRTWg6yxhE085zljmYv2owS0NRbr5a4/zW6Z9xZPALGAqsOvIvpZHuOC2s0eMJWMmYGkK/Ws/LAVxfj4U+YkFp9hlZC0zEg/JoS19Gf57QmEu+vsoQ3uOBYBFv9NPI/R9kVH6o0hcOxId3J0u+ewSGWuceGLRpizXuMxKIvLTS5j6GWkxdSieWjwh/OuVB+ciAHNM31B7GP4FWnfz0ZaEVxI/tPenNipZdl9oXdyyBQQ00vPlYp0jT80XhaMh5rDwWMUPaEjRafvymcNyqZ0iVOr0rq1CbdT92STMSmA1U3/rmhtCMD5IGD0b+gQl+VpPKe1QXViYftVxCGL+s4ke4DUZD7HR20fGs8zu61Elnwci1HufbetKFL5TmxoKSLkWFSkzrtBaJnEruZIxhNUMkUL2UPynaOcPNzLoumjHXrUb3m3s0yE4OFelmJ6mJfXswP38sS8kj3wB7R/gC4rw=
|
||||
secure: "XQR4GUrGkPKYVV0xMbJifX/ewKAnenBPlM/pPacQ9irAmYNYa/yEkySz4x1K6MP8cEnuJbxHFakcDqhNRCqD7Cq2NcnCi3qtTEXHK6ApLoVl/92eyiWxu/bYlidOEZb+YPcVNtTR253NiI8GYda+CrhLd4uCmsAgES+XPFJd/t2esMlDOSAp7xalZv/zFhhlB9+SevfPFMc6kkrqeHpKnMs9SK8ltVQmh3nch2KjtDvqgDW6d3nuwn7/HAer6/HY86hmA4Rh6Mo2cV6OloX0bdJ7hvA1GOT4p3+K3lWbTRxzE0o1DXAtT7+D158iKvxHFPuF3h+CTjSlLeiss6kQZL9nFjw/KhAvu+GJOp37PcMoI++mpMiFoWPlzKpp17BVKIDinYbgi8kiU4zG+QHhe2cY85SbfAplXUaysq7uzxEZwEUYHSAHNahshVooXRqvuzkthcH0/nvinfeXrzx2xDvQ3if1NENMRgttwewU0kvU61iKUwpcf/UN2bHK3DaPes0VzSH4PTHAGjoRpksDfqUwb7S8YxbYr+44aMbSPYN8Lbjda0BxPSKWwHM5/pi7FBJN1a1w3t7sV/EiACWUWr8OovmX4ljyCybbR0w9cPzRC1zAYeSUHslLXMTW2Pp9h594RnYh3q3VfeYlFCikFvuvrafwXmTkz35uhLb+2ws="
|
||||
file:
|
||||
- app/build/outputs/apk/foss/release/app-foss-release.apk
|
||||
- app/build/outputs/apk/google/release/app-google-release.apk
|
||||
|
||||
@@ -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
|
||||
|
||||
2
LICENSE
@@ -1,6 +1,6 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2020 Johan von Forstner
|
||||
Copyright (c) 2020-2021 Johan von Forstner
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
||||
28
README.md
@@ -1,23 +1,28 @@
|
||||
EVMap [](https://travis-ci.org/johan12345/EVMap)
|
||||
=====
|
||||
|
||||
<img src="https://raw.githubusercontent.com/johan12345/EVMap/master/_img/appicon_cropped.svg?sanitize=true" width=80 alt="Logo"/>
|
||||
<img src="https://raw.githubusercontent.com/johan12345/EVMap/master/_img/feature_graphic.svg" width=700 alt="Logo"/>
|
||||
|
||||
Android app to access the goingelectric.de electric vehicle charging station directory.
|
||||
Android app to find electric vehicle charging stations.
|
||||
|
||||
<a href="https://play.google.com/store/apps/details?id=net.vonforst.evmap" target="_blank">
|
||||
<img src="https://play.google.com/intl/en_us/badges/images/generic/en-play-badge.png" alt="Get it on Google Play" height="100"/></a>
|
||||
<a href="https://f-droid.org/repository/browse/?fdid=net.vonforst.evmap" target="_blank">
|
||||
<img src="https://f-droid.org/badge/get-it-on.png" alt="Get it on F-Droid" height="100"/></a>
|
||||
|
||||
Features
|
||||
--------
|
||||
|
||||
- [Material Design](https://material.io/)
|
||||
- Shows all charging stations from the community-maintained [GoingElectric.de](https://www.goingelectric.de/stromtankstellen/) directory
|
||||
- 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 (beta)
|
||||
- Search places
|
||||
- Favorites list, also with availability information
|
||||
- Charging price comparison, powered by [Chargeprice.app](https://chargeprice.app)
|
||||
- Android Auto integration
|
||||
- No ads, fully open source
|
||||
- Compatible with Android 5.0 and above
|
||||
- Can use Google Maps or Mapbox (OpenStreetMap) as map backends - the version available on F-Droid only uses Mapbox.
|
||||
|
||||
Screenshots
|
||||
-----------
|
||||
@@ -29,10 +34,12 @@ Development setup
|
||||
|
||||
The App is developed using Android Studio.
|
||||
|
||||
For testing the app, you need to obtain API Keys for the
|
||||
[GoingElectric API](https://www.goingelectric.de/stromtankstellen/api/)
|
||||
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 [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). 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:
|
||||
|
||||
@@ -41,8 +48,17 @@ following content:
|
||||
<string name="google_maps_key" templateMergeStrategy="preserve" translatable="false">
|
||||
insert your Google Maps key here
|
||||
</string>
|
||||
<string name="mapbox_key" translatable="false">
|
||||
insert your Mapbox key here
|
||||
</string>
|
||||
<string name="goingelectric_key" translatable="false">
|
||||
insert your GoingElectric key here
|
||||
</string>
|
||||
<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>
|
||||
```
|
||||
|
||||
25
_img/connectors/connector_ccs_typ1.svg
Normal 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 |
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!-- Generator: Adobe Illustrator 23.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" x="0px" y="0px" viewBox="0 0 1024 500"
|
||||
style="enable-background:new 0 0 1024 500;" xml:space="preserve">
|
||||
<?xml version="1.0" encoding="utf-8"?><!-- Generator: Adobe Illustrator 24.3.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<svg version="1.1" id="Ebene_1" xmlns="http://www.w3.org/2000/svg" x="0px" y="0px"
|
||||
viewBox="0 0 1024 500" style="enable-background:new 0 0 1024 500;" xml:space="preserve">
|
||||
<style type="text/css">
|
||||
.st0{fill:#E6E6E6;}
|
||||
.st1{fill:#DCDCDC;}
|
||||
@@ -15,7 +15,7 @@
|
||||
.st10{fill:#666666;}
|
||||
.st11{fill:#D1D1D1;}
|
||||
.st12{opacity:0.2;fill:#808080;enable-background:new ;}
|
||||
.st13{opacity:0.5;}
|
||||
.st13{opacity:0.5;enable-background:new ;}
|
||||
.st14{fill:#FFB300;}
|
||||
.st15{fill:#90A4AE;}
|
||||
.st16{fill:#546E7A;}
|
||||
@@ -23,10 +23,9 @@
|
||||
.st18{fill:#FFFFFF;fill-opacity:0.2;}
|
||||
.st19{fill:#3E2723;fill-opacity:0.2;}
|
||||
.st20{opacity:0.45;enable-background:new ;}
|
||||
.st21{font-family:'Roboto-Light';}
|
||||
.st22{font-size:136.5333px;}
|
||||
.st21{enable-background:new ;}
|
||||
</style>
|
||||
<g id="Ebene_1">
|
||||
<g id="Ebene_1_1_">
|
||||
<rect y="-34.4" class="st0" width="1024" height="568.9" />
|
||||
<g>
|
||||
<path class="st1"
|
||||
@@ -35,25 +34,25 @@
|
||||
d="M145.4,335.9L38.1,228.7c-6-6-6-15.4,0-21.3L91,154.1c5.7-5.7,15.4-5.7,21.3,0l107.2,107.2" />
|
||||
<path class="st2" d="M131.7,209.9L93.6,248c-2.8,2.8-7.7,2.8-10.5,0l-24.7-24.7c-2.8-2.8-2.8-7.7,0-10.5l38.1-38.1
|
||||
c2.8-2.8,7.7-2.8,10.5,0l24.7,24.7C134.8,202.2,134.5,207,131.7,209.9z" />
|
||||
<path class="st3" d="M223.3,265.1c-2,2-5.4,2-7.4,0l-107.2-107c-3.7-3.7-10.2-4-13.9,0L41.8,211c-3.7,3.7-3.7,10,0,13.9L149,332.2
|
||||
c2,2,2,5.4,0,7.4c-2,2-5.4,2-7.4,0L34.4,232.4c-8-8-8-20.8,0-28.7l52.9-53.2c8-8,20.8-8,28.7,0l107.2,107.2
|
||||
<path class="st3" d="M223.3,265.1c-2,2-5.4,2-7.4,0l-107.2-107c-3.7-3.7-10.2-4-13.9,0l-53,52.9c-3.7,3.7-3.7,10,0,13.9L149,332.2
|
||||
c2,2,2,5.4,0,7.4s-5.4,2-7.4,0L34.4,232.4c-8-8-8-20.8,0-28.7l52.9-53.2c8-8,20.8-8,28.7,0l107.2,107.2
|
||||
C225.3,260,225.3,263.1,223.3,265.1z" />
|
||||
<path class="st4" d="M131.7,209.9L93.6,248c-2.8,2.8-7.7,2.8-10.5,0l-24.7-24.7c-2.8-2.8-2.8-7.7,0-10.5l38.1-38.1
|
||||
c2.8-2.8,7.7-2.8,10.5,0l24.7,24.7C134.8,202.2,134.5,207,131.7,209.9z" />
|
||||
<path class="st3" d="M135.4,213.6l-38.1,38.1c-4.8,4.8-13.1,4.8-17.9,0L54.6,227c-4.8-4.8-4.8-13.1,0-17.9l38.1-38.1
|
||||
<path class="st3" d="M135.4,213.6l-38.1,38.1c-4.8,4.8-13.1,4.8-17.9,0L54.6,227c-4.8-4.8-4.8-13.1,0-17.9L92.7,171
|
||||
c4.8-4.8,13.1-4.8,17.9,0l24.7,24.7C140.5,200.5,140.5,208.5,135.4,213.6z M62,216.4c-0.9,0.9-0.9,2.3,0,3.1l24.7,24.7
|
||||
c0.9,0.9,2.3,0.9,3.1,0l38.1-38.1c0.9-0.9,0.9-2.3,0-3.1l-24.7-24.7c-0.9-0.9-2.3-0.9-3.1,0L62,216.4z M233.8,254.6l-95.3,95.3
|
||||
c-2,2-5.4,2-7.4,0c-2-2-2-5.4,0-7.4l95.3-95.3c2-2,5.4-2,7.4,0S235.8,252.6,233.8,254.6z M228.4,238.3c-4.8,4.8-13.1,4.8-17.9,0
|
||||
l-43-42.7c-0.9-0.9-2.3-0.9-3.1,0l-5.4,5.4c-2,2-5.4,2-7.4,0c-2-2-2-5.4,0-7.4l5.4-5.4c4.8-4.8,13.1-4.8,17.9,0l43,43
|
||||
c0.9,0.9,2.3,0.9,3.1,0c0.9-0.9,0.9-2.3,0-3.1l-58.9-59.2c-2-2-2-5.4,0-7.4c2-2,5.4-2,7.4,0l58.9,58.9
|
||||
c-2,2-5.4,2-7.4,0s-2-5.4,0-7.4l95.3-95.3c2-2,5.4-2,7.4,0S235.8,252.6,233.8,254.6z M228.4,238.3c-4.8,4.8-13.1,4.8-17.9,0
|
||||
l-43-42.7c-0.9-0.9-2.3-0.9-3.1,0L159,201c-2,2-5.4,2-7.4,0s-2-5.4,0-7.4l5.4-5.4c4.8-4.8,13.1-4.8,17.9,0l43,43
|
||||
c0.9,0.9,2.3,0.9,3.1,0c0.9-0.9,0.9-2.3,0-3.1l-58.9-59.2c-2-2-2-5.4,0-7.4s5.4-2,7.4,0l58.9,58.9
|
||||
C233.2,225.3,233.2,233.5,228.4,238.3z" />
|
||||
<path class="st3" d="M174.6,163.8l-10.5,10.5c-2,2-5.4,2-7.4,0l-21.6-21.6c-2-2-2-5.4,0-7.4l1.7-1.7l-4.8-4.6c-2-2-2-5.4,0-7.4
|
||||
c2-2,5.4-2,7.4,0l4.8,4.8l1.4-1.4c2-2,5.4-2,7.4,0l21.3,21.3C176.4,158.4,176.6,161.8,174.6,163.8z M160.4,163.2l3.1-3.1
|
||||
l-13.9-13.9l-3.1,3.1L160.4,163.2z" />
|
||||
<path class="st3" d="M174.6,163.8l-10.5,10.5c-2,2-5.4,2-7.4,0l-21.6-21.6c-2-2-2-5.4,0-7.4l1.7-1.7L132,139c-2-2-2-5.4,0-7.4
|
||||
s5.4-2,7.4,0l4.8,4.8l1.4-1.4c2-2,5.4-2,7.4,0l21.3,21.3C176.4,158.4,176.6,161.8,174.6,163.8z M160.4,163.2l3.1-3.1l-13.9-13.9
|
||||
l-3.1,3.1L160.4,163.2z" />
|
||||
<g>
|
||||
<path class="st5" d="M163.8,290.1c-0.6,0.6-1.4,1.1-2.6,1.4c-2.8,0.6-5.7-1.1-6.3-3.7l-3.7-16.2l-3.7,5.4c-1.1,1.7-3.1,2.6-5.1,2
|
||||
c-2-0.6-3.7-2-4-4l-6.5-27c-0.6-2.8,1.1-5.7,3.7-6.3c2.8-0.6,5.7,1.1,6.3,3.7l3.7,16.2l3.7-5.4c1.1-1.7,3.1-2.6,5.1-2
|
||||
c2,0.6,3.7,2,4,4l6.3,27C165.5,287.3,165,289,163.8,290.1z" />
|
||||
s-3.7-2-4-4l-6.5-27c-0.6-2.8,1.1-5.7,3.7-6.3c2.8-0.6,5.7,1.1,6.3,3.7l3.7,16.2l3.7-5.4c1.1-1.7,3.1-2.6,5.1-2s3.7,2,4,4l6.3,27
|
||||
C165.5,287.3,165,289,163.8,290.1z" />
|
||||
</g>
|
||||
</g>
|
||||
<g>
|
||||
@@ -66,11 +65,11 @@
|
||||
<path class="st3" d="M156.4,462.5l-40.1,40.1c-4.6,4.6-11.7,4.6-16.2,0l-68.3-68.3c-4.6-4.6-4.6-11.7,0-16.2L72,378
|
||||
c4.6-4.6,11.7-4.6,16.2,0l68.3,68.3C161,450.8,161,457.9,156.4,462.5z M37.8,424.4c-1.1,1.1-1.1,2.8,0,4l68.3,68.3
|
||||
c1.1,1.1,2.8,1.1,4,0l40.1-40.1c1.1-1.1,1.1-2.8,0-4l-68.3-68.3c-1.1-1.1-2.8-1.1-4,0L37.8,424.4z" />
|
||||
<path class="st2" d="M111.2,487.5c-1.7,1.7-4.3,1.7-6,0l-25-25c-1.7-1.7-1.7-4.3,0-6c1.7-1.7,4.3-1.7,6,0l25,25
|
||||
<path class="st2" d="M111.2,487.5c-1.7,1.7-4.3,1.7-6,0l-25-25c-1.7-1.7-1.7-4.3,0-6s4.3-1.7,6,0l25,25
|
||||
C112.9,483.2,112.9,485.8,111.2,487.5z M71.1,447.4c-0.9,0.9-2,1.4-3.1,1.1c-1.1,0-2.3-0.3-3.1-1.1c-0.9-0.9-1.4-2-1.1-3.1
|
||||
c0-0.3,0-0.6,0-0.9s0-0.6,0.3-0.9s0.3-0.6,0.3-0.9c0.9-1.1,2-2,3.4-2c1.1,0,2.3,0.3,3.1,1.1c0.3,0.3,0.3,0.3,0.6,0.6
|
||||
c0.3,0.3,0.3,0.6,0.3,0.9c0,0.3,0.3,0.6,0.3,0.9s0,0.6,0,0.9C72.2,445.4,71.7,446.6,71.1,447.4z" />
|
||||
<path class="st3" d="M68,393.9c-1.7,1.7-4.3,1.7-6,0l-9.1-9.1L39,398.8l1.1,1.1c1.7,1.7,1.7,4.3,0,6c-1.7,1.7-4.3,1.7-6,0l-4-4
|
||||
c0.3,0.3,0.3,0.6,0.3,0.9s0.3,0.6,0.3,0.9s0,0.6,0,0.9C72.2,445.4,71.7,446.6,71.1,447.4z" />
|
||||
<path class="st3" d="M68,393.9c-1.7,1.7-4.3,1.7-6,0l-9.1-9.1l-13.9,14l1.1,1.1c1.7,1.7,1.7,4.3,0,6s-4.3,1.7-6,0l-4-4
|
||||
c-1.7-1.7-1.7-4.3,0-6l20.2-20.2c1.7-1.7,4.3-1.7,6,0l11.9,11.9C69.7,389.7,69.7,392.2,68,393.9z" />
|
||||
</g>
|
||||
<g>
|
||||
@@ -78,35 +77,35 @@
|
||||
C390.3,425.8,354.1,476.4,333.4,497.2z" />
|
||||
<path class="st5" d="M293.5,387.7l48.1-11.7c1.7-0.3,2.6,1.7,1.1,2.6l-28.4,18.8l9.7,9.7c0.9,0.9,0.3,2.3-0.9,2.3l-43.8,6.8
|
||||
c-1.4,0.3-2.3-1.7-1.1-2.6l23.6-14.5l-9.4-9.4C292.4,389.1,292.7,388,293.5,387.7z" />
|
||||
<path class="st3" d="M374.6,440l-15.1-15.1c-7.1-7.1-7.1-18.8,0-26.2l8-8c1.7-1.7,4.3-1.7,6,0c1.7,1.7,1.7,4.3,0,6l-8,8
|
||||
c-4,4-4,10.2,0,13.9l15.1,15.1c1.7,1.7,1.7,4.3,0,6C378.9,441.4,376,441.4,374.6,440z" />
|
||||
<path class="st3" d="M374.6,440l-15.1-15.1c-7.1-7.1-7.1-18.8,0-26.2l8-8c1.7-1.7,4.3-1.7,6,0s1.7,4.3,0,6l-8,8
|
||||
c-4,4-4,10.2,0,13.9l15.1,15.1c1.7,1.7,1.7,4.3,0,6S376,441.4,374.6,440z" />
|
||||
<path class="st3" d="M327.4,503.2L226.7,402.5c-1.7-1.7-1.7-4.3,0-6l73.4-73.4c7.1-7.1,18.8-7.1,26.2,0l70.3,70.3l0,0l8,8
|
||||
c1.7,1.7,1.7,4.3,0,6c-1.7,1.7-4.3,1.7-6,0l-2.3-2.3c-8.2,31.3-41.5,76.2-60,94.7l-3.1,3.1C331.7,504.9,328.8,504.9,327.4,503.2z
|
||||
c1.7,1.7,1.7,4.3,0,6s-4.3,1.7-6,0l-2.3-2.3c-8.2,31.3-41.5,76.2-60,94.7l-3.1,3.1C331.7,504.9,328.8,504.9,327.4,503.2z
|
||||
M235.8,399.6l94.4,94.4c21.6-21.6,54.6-69.1,58.9-96.1l-68.8-68.8c-4-4-10.2-4-13.9,0C306.1,329.4,235.8,399.6,235.8,399.6z" />
|
||||
<path class="st3" d="M327.4,503.2L222.7,398.5c-1.7-1.7-1.7-4.3,0-6c1.7-1.7,4.3-1.7,6,0l104.7,104.7c1.7,1.7,1.7,4.3,0,6
|
||||
<path class="st3" d="M327.4,503.2L222.7,398.5c-1.7-1.7-1.7-4.3,0-6s4.3-1.7,6,0l104.7,104.7c1.7,1.7,1.7,4.3,0,6
|
||||
C331.7,504.9,328.8,504.9,327.4,503.2z" />
|
||||
<path class="st2" d="M251.2,410.1c-5.4-5.4-13.9-5.4-19.3,0s-5.4,13.9,0,19.3l0,0c5.4,5.4,13.9,5.4,19.3,0
|
||||
S256.6,415.3,251.2,410.1L251.2,410.1z M315.4,474.4c-5.4-5.4-13.9-5.4-19.3,0c-5.4,5.4-5.4,13.9,0,19.3l0,0
|
||||
c5.4,5.4,13.9,5.4,19.3,0S320.9,479.8,315.4,474.4L315.4,474.4z" />
|
||||
<path class="st3" d="M228.7,432.3c-7.1-7.1-6.8-18.5,0-25.3c6.8-6.8,18.5-6.8,25.3,0c6.8,6.8,6.8,18.5,0,25.3
|
||||
C247.2,439.4,235.8,439.4,228.7,432.3z M248,413c-3.7-3.7-9.7-3.7-13.4,0c-3.7,3.7-3.7,9.7,0,13.4s9.7,3.7,13.4,0
|
||||
S256.6,415.3,251.2,410.1L251.2,410.1z M315.4,474.4c-5.4-5.4-13.9-5.4-19.3,0s-5.4,13.9,0,19.3l0,0c5.4,5.4,13.9,5.4,19.3,0
|
||||
S320.9,479.8,315.4,474.4L315.4,474.4z" />
|
||||
<path class="st3" d="M228.7,432.3c-7.1-7.1-6.8-18.5,0-25.3s18.5-6.8,25.3,0c6.8,6.8,6.8,18.5,0,25.3
|
||||
C247.2,439.4,235.8,439.4,228.7,432.3z M248,413c-3.7-3.7-9.7-3.7-13.4,0s-3.7,9.7,0,13.4s9.7,3.7,13.4,0
|
||||
C251.7,422.7,251.7,416.7,248,413z" />
|
||||
<g>
|
||||
<path class="st3" d="M293.3,496.6c-7.1-7.1-6.8-18.5,0-25.3c6.8-6.8,18.5-6.8,25.3,0c7.1,7.1,6.8,18.5,0,25.3
|
||||
<path class="st3" d="M293.3,496.6c-7.1-7.1-6.8-18.5,0-25.3s18.5-6.8,25.3,0c7.1,7.1,6.8,18.5,0,25.3
|
||||
C311.5,503.7,300.1,503.7,293.3,496.6z M312.6,477.6c-3.7-3.7-9.7-3.7-13.4,0c-3.7,3.7-3.7,9.7,0,13.4s9.7,3.7,13.4,0
|
||||
C316.3,487.2,316.3,481.3,312.6,477.6z" />
|
||||
</g>
|
||||
<g>
|
||||
<path class="st3" d="M293.3,496.6l-84.5-84.5c-1.7-1.7-1.7-4.3,0-6c1.7-1.7,4.3-1.7,6,0l84.5,84.5c1.7,1.7,1.7,4.3,0,6
|
||||
<path class="st3" d="M293.3,496.6l-84.5-84.5c-1.7-1.7-1.7-4.3,0-6s4.3-1.7,6,0l84.5,84.5c1.7,1.7,1.7,4.3,0,6
|
||||
C297.5,498.3,295,498.3,293.3,496.6z" />
|
||||
</g>
|
||||
<g>
|
||||
<path class="st3" d="M194.6,398.2c-0.3-0.3-0.3-0.3-0.6-0.6c-0.3-0.3-0.3-0.6-0.3-0.9c0-0.3-0.3-0.6-0.3-0.9s0-0.6,0-0.9
|
||||
c0-0.3,0-0.6,0-0.9c0-0.3,0-0.6,0.3-0.9c0.3,0,0.3-0.3,0.6-0.6s0.3-0.6,0.6-0.6c0.3,0,0.3-0.3,0.6-0.6c0.3-0.3,0.6-0.3,0.9-0.3
|
||||
c0.3,0,0.6,0,0.9-0.3c0.3-0.3,0.6,0,0.9,0c0.3,0,0.6,0,0.9,0c0.3,0,0.6,0,0.9,0.3c0.3,0,0.6,0.3,0.9,0.3c0.6,0.3,0.9,0.6,1.1,1.1
|
||||
c0.3,0.3,0.3,0.6,0.3,0.9c0,0.3,0.3,0.6,0.3,0.9c0,0.3,0,0.6,0,0.9s0,0.6,0,0.9c0,0.3,0,0.6-0.3,0.9c-0.3,0.3-0.3,0.6-0.3,0.9
|
||||
c-0.3,0.3-0.3,0.6-0.6,0.6s-0.3,0.3-0.6,0.6c-0.3,0.3-0.6,0.3-0.9,0.3c-0.3,0-0.6,0.3-0.9,0.3s-0.6,0-0.9,0c-0.3,0-0.6,0-0.9,0
|
||||
c-0.3,0-0.6,0-0.9-0.3c-0.3,0-0.6-0.3-0.9-0.3C195.1,398.8,194.8,398.5,194.6,398.2z" />
|
||||
<path class="st3" d="M194.6,398.2c-0.3-0.3-0.3-0.3-0.6-0.6c-0.3-0.3-0.3-0.6-0.3-0.9s-0.3-0.6-0.3-0.9s0-0.6,0-0.9s0-0.6,0-0.9
|
||||
s0-0.6,0.3-0.9c0.3,0,0.3-0.3,0.6-0.6s0.3-0.6,0.6-0.6s0.3-0.3,0.6-0.6c0.3-0.3,0.6-0.3,0.9-0.3c0.3,0,0.6,0,0.9-0.3
|
||||
c0.3-0.3,0.6,0,0.9,0c0.3,0,0.6,0,0.9,0c0.3,0,0.6,0,0.9,0.3c0.3,0,0.6,0.3,0.9,0.3c0.6,0.3,0.9,0.6,1.1,1.1
|
||||
c0.3,0.3,0.3,0.6,0.3,0.9s0.3,0.6,0.3,0.9s0,0.6,0,0.9s0,0.6,0,0.9s0,0.6-0.3,0.9s-0.3,0.6-0.3,0.9c-0.3,0.3-0.3,0.6-0.6,0.6
|
||||
s-0.3,0.3-0.6,0.6c-0.3,0.3-0.6,0.3-0.9,0.3c-0.3,0-0.6,0.3-0.9,0.3s-0.6,0-0.9,0c-0.3,0-0.6,0-0.9,0c-0.3,0-0.6,0-0.9-0.3
|
||||
c-0.3,0-0.6-0.3-0.9-0.3C195.1,398.8,194.8,398.5,194.6,398.2z" />
|
||||
</g>
|
||||
</g>
|
||||
<g>
|
||||
@@ -121,46 +120,43 @@
|
||||
l0.3,0.3C940.1,42.4,940.1,60.8,928.7,72.2z M893.2,36.7c-8,8-8,21,0,29.3l0.3,0.3c8,8,21,8,29.3,0c8-8,8-21,0-29.3l-0.3-0.3
|
||||
C914.2,28.7,901.1,28.7,893.2,36.7z" />
|
||||
<path class="st3" d="M896.9,55.2c-2,2-2,5.1,0,7.1c2,2,5.1,2,7.1,0l0,0c2-2,2-5.1,0-7.1C902.3,53.2,898.8,53.2,896.9,55.2
|
||||
L896.9,55.2z M911.4,40.6c-2,2-2,5.1,0,7.1c2,2,5.1,2,7.1,0l0,0c2-2,2-5.1,0-7.1C916.8,38.7,913.4,38.7,911.4,40.6L911.4,40.6z" />
|
||||
L896.9,55.2z M911.4,40.6c-2,2-2,5.1,0,7.1s5.1,2,7.1,0l0,0c2-2,2-5.1,0-7.1C916.8,38.7,913.4,38.7,911.4,40.6L911.4,40.6z" />
|
||||
<path class="st2"
|
||||
d="M971.9,88.7l10.8,10.8c7.4,7.4,7.4,19.3,0,26.7l0,0c-7.4,7.4-19.3,7.4-26.7,0l-10.8-10.8L971.9,88.7z" />
|
||||
<path class="st3" d="M985.9,129.4c-9.1,9.1-23.6,9.1-32.7,0l-8.8-8.8c-1.7-1.7-1.7-4.3,0-6c1.7-1.7,4.3-1.7,6,0l8.8,8.8
|
||||
c5.7,5.7,15.1,5.7,20.8,0c5.7-5.7,5.7-15.1,0-20.8l-8.8-8.8c-1.7-1.7-1.7-4.3,0-6s4.3-1.7,6,0l8.8,8.8
|
||||
C995,105.8,995,120.3,985.9,129.4z" />
|
||||
<path class="st3" d="M953.2,112.9c-1.7,1.7-4.3,1.7-6,0l-9.4-9.4c-1.7-1.7-1.7-4.3,0-6c1.7-1.7,4.3-1.7,6,0l9.4,9.4
|
||||
C954.9,108.6,954.9,111.2,953.2,112.9z M969.4,97c-1.7,1.7-4.3,1.7-6,0l-9.4-9.4c-1.7-1.7-1.7-4.3,0-6c1.7-1.7,4.3-1.7,6,0
|
||||
l9.4,9.4C971.1,92.4,971.1,95.3,969.4,97z" />
|
||||
<path class="st3" d="M978.8,88.2l-34.1,34.1c-1.7,1.7-4.3,1.7-6,0c-1.7-1.7-1.7-4.3,0-6l34.1-34.1c1.7-1.7,4.3-1.7,6,0
|
||||
C980.5,83.9,980.5,86.4,978.8,88.2z M994.4,137.9c-1.7,1.7-4.3,1.7-6,0l-8.5-8.5c-1.7-1.7-1.7-4.3,0-6c1.7-1.7,4.3-1.7,6,0
|
||||
l8.5,8.5C995.8,133.4,996.1,136.2,994.4,137.9z" />
|
||||
<path class="st3" d="M985.9,129.4c-9.1,9.1-23.6,9.1-32.7,0l-8.8-8.8c-1.7-1.7-1.7-4.3,0-6s4.3-1.7,6,0l8.8,8.8
|
||||
c5.7,5.7,15.1,5.7,20.8,0s5.7-15.1,0-20.8l-8.8-8.8c-1.7-1.7-1.7-4.3,0-6s4.3-1.7,6,0l8.8,8.8C995,105.8,995,120.3,985.9,129.4z" />
|
||||
<path class="st3" d="M953.2,112.9c-1.7,1.7-4.3,1.7-6,0l-9.4-9.4c-1.7-1.7-1.7-4.3,0-6s4.3-1.7,6,0l9.4,9.4
|
||||
C954.9,108.6,954.9,111.2,953.2,112.9z M969.4,97c-1.7,1.7-4.3,1.7-6,0l-9.4-9.4c-1.7-1.7-1.7-4.3,0-6s4.3-1.7,6,0l9.4,9.4
|
||||
C971.1,92.4,971.1,95.3,969.4,97z" />
|
||||
<path class="st3" d="M978.8,88.2l-34.1,34.1c-1.7,1.7-4.3,1.7-6,0s-1.7-4.3,0-6l34.1-34.1c1.7-1.7,4.3-1.7,6,0
|
||||
C980.5,83.9,980.5,86.4,978.8,88.2z M994.4,137.9c-1.7,1.7-4.3,1.7-6,0l-8.5-8.5c-1.7-1.7-1.7-4.3,0-6s4.3-1.7,6,0l8.5,8.5
|
||||
C995.8,133.4,996.1,136.2,994.4,137.9z" />
|
||||
<g>
|
||||
<path class="st5" d="M966.5,110.1c-1.4,1.4-1.4,4,0,5.4c1.4,1.4,4,1.4,5.4,0l0,0c1.4-1.4,1.4-4,0-5.4
|
||||
C970.5,108.6,968,108.6,966.5,110.1L966.5,110.1z" />
|
||||
<path class="st5" d="M966.5,110.1c-1.4,1.4-1.4,4,0,5.4s4,1.4,5.4,0l0,0c1.4-1.4,1.4-4,0-5.4C970.5,108.6,968,108.6,966.5,110.1
|
||||
L966.5,110.1z" />
|
||||
</g>
|
||||
</g>
|
||||
<g>
|
||||
<path class="st7" d="M486.4,389.4l97.6,97.6l10.8-10.8c6-6,6-15.6,0-21.6l-38.7-38.7l-1.7-0.6c-6.5-2.3-8.5-10.5-3.7-15.6
|
||||
l-3.4-3.4C529.9,378.6,509.4,379.4,486.4,389.4" />
|
||||
<path class="st7" d="M486.4,389.4L584,487l10.8-10.8c6-6,6-15.6,0-21.6l-38.7-38.7l-1.7-0.6c-6.5-2.3-8.5-10.5-3.7-15.6l-3.4-3.4
|
||||
C529.9,378.6,509.4,379.4,486.4,389.4" />
|
||||
<path class="st8" d="M517.7,382.6C517.4,382.6,517.4,382.6,517.7,382.6c-1.1-0.3-2-0.3-2.8-0.3c-8.8,0.3-18.2,2.8-28.2,6.8l0,0
|
||||
l31,31C527.9,410.1,527.9,393.1,517.7,382.6 M599.3,465c-10.2-7.7-25-6.8-34.7,2.6l19.3,19.3l10.8-10.8
|
||||
C597.9,473,599.3,469,599.3,465" />
|
||||
<path class="st3" d="M581.1,490.1l-97.6-97.8c-1.1-1.1-1.4-2.3-1.1-3.7c0.3-1.4,1.1-2.6,2.6-3.1c27.9-11.9,48.6-9.4,65.7,8
|
||||
l0.3,0.3c1.7,1.7,1.7,4.3,0,6s-4.3,1.7-6,0l-0.3-0.3c-13.4-13.4-29.3-16.2-50.6-8.2L584,481l8-8c4.3-4.3,4.3-11.4,0-15.6
|
||||
l-34.4-34.4c-1.1-1.1-1.4-2.3-1.1-3.7l1.7-10.8c0.3-2.3,2.6-4,4.8-3.4c2.3,0.3,4,2.6,3.4,4.8l-1.4,8.5l33,33
|
||||
L557.6,423c-1.1-1.1-1.4-2.3-1.1-3.7l1.7-10.8c0.3-2.3,2.6-4,4.8-3.4c2.3,0.3,4,2.6,3.4,4.8l-1.4,8.5l33,33
|
||||
c7.7,7.7,7.7,20.2,0,27.9l-10.8,10.8C585.4,491.8,582.8,491.5,581.1,490.1z" />
|
||||
<path class="st2" d="M509.4,390.5c-6-6-15.6-6-21.6,0c-6,6-6,15.6,0,21.6l0,0c6,6,15.6,6,21.6,0
|
||||
C515.7,406.4,515.7,396.8,509.4,390.5L509.4,390.5z M565.8,434.6c-3.4-3.4-8.8-3.4-11.9,0c-3.1,3.4-3.4,8.8,0,11.9
|
||||
c3.4,3.1,8.8,3.4,11.9,0C569.2,443.4,569.2,438,565.8,434.6z" />
|
||||
<path class="st3" d="M484.7,415.3c-7.7-7.7-7.7-20.2,0-27.9s20.2-7.7,27.9,0c7.7,7.7,7.7,20.2,0,27.9
|
||||
C504.9,422.9,492.4,422.9,484.7,415.3z M506.6,393.6c-4.3-4.3-11.4-4.3-15.6,0c-4.3,4.3-4.3,11.4,0,15.6c4.3,4.3,11.4,4.3,15.6,0
|
||||
C510.9,405,510.9,397.9,506.6,393.6z" />
|
||||
<path class="st2" d="M509.4,390.5c-6-6-15.6-6-21.6,0s-6,15.6,0,21.6l0,0c6,6,15.6,6,21.6,0C515.7,406.4,515.7,396.8,509.4,390.5
|
||||
L509.4,390.5z M565.8,434.6c-3.4-3.4-8.8-3.4-11.9,0c-3.1,3.4-3.4,8.8,0,11.9s8.8,3.4,11.9,0C569.2,443.4,569.2,438,565.8,434.6z" />
|
||||
<path class="st3" d="M484.7,415.3c-7.7-7.7-7.7-20.2,0-27.9s20.2-7.7,27.9,0s7.7,20.2,0,27.9C504.9,422.9,492.4,422.9,484.7,415.3
|
||||
z M506.6,393.6c-4.3-4.3-11.4-4.3-15.6,0c-4.3,4.3-4.3,11.4,0,15.6c4.3,4.3,11.4,4.3,15.6,0C510.9,405,510.9,397.9,506.6,393.6z" />
|
||||
<path class="st2" d="M594.5,475.6c-6-6-15.6-6-21.6,0s-6,15.6,0,21.6l0,0c6,6,15.6,6,21.6,0S600.5,481.5,594.5,475.6L594.5,475.6z
|
||||
" />
|
||||
<path class="st3" d="M569.7,500.3c-7.7-7.7-7.7-20.2,0-27.9s20.2-7.7,27.9,0s7.7,20.2,0,27.9C589.9,508,577.4,508,569.7,500.3z
|
||||
M591.4,478.4c-4.3-4.3-11.4-4.3-15.6,0s-4.3,11.4,0,15.6s11.4,4.3,15.6,0S595.6,483,591.4,478.4z" />
|
||||
<path class="st3" d="M569.7,500.3l-98.1-98.1c-1.7-1.7-1.7-4.3,0-6c1.7-1.7,4.3-1.7,6,0l98.1,98.1c1.7,1.7,1.7,4.3,0,6
|
||||
C574,502,571.4,502,569.7,500.3z M459.7,390.2c-0.9-0.9-1.4-2-1.1-3.1c0-1.1,0.3-2.3,1.1-3.1c0.9-0.9,2-1.4,3.1-1.1
|
||||
c2.3,0,4.3,2,4.3,4.3c0,1.1-0.3,2.3-1.1,3.1c-0.9,0.9-2,1.4-3.1,1.1C461.4,391.4,460.5,391.1,459.7,390.2z" />
|
||||
<path class="st3" d="M569.7,500.3l-98.1-98.1c-1.7-1.7-1.7-4.3,0-6s4.3-1.7,6,0l98.1,98.1c1.7,1.7,1.7,4.3,0,6
|
||||
S571.4,502,569.7,500.3z M459.7,390.2c-0.9-0.9-1.4-2-1.1-3.1c0-1.1,0.3-2.3,1.1-3.1c0.9-0.9,2-1.4,3.1-1.1c2.3,0,4.3,2,4.3,4.3
|
||||
c0,1.1-0.3,2.3-1.1,3.1c-0.9,0.9-2,1.4-3.1,1.1C461.4,391.4,460.5,391.1,459.7,390.2z" />
|
||||
</g>
|
||||
<g>
|
||||
<path class="st2" d="M306.9,39.8l-35-35.3c-7.7-7.7-19.9-8.2-28.2-1.7L218.2,23l70.3,70.3L308.6,68
|
||||
@@ -174,17 +170,16 @@
|
||||
<path class="st3" d="M196.8,64.3c-1.7-1.7-1.7-4.3,0-6L215,40.1c4-4,4-10.2,0-13.9c-4-4-10.2-4-13.9,0l-18.2,18.2
|
||||
c-1.7,1.7-4.3,1.7-6,0s-1.7-4.3,0-6l18.2-18.2c7.1-7.1,18.8-7.1,26.2,0s7.1,18.8,0,26.2l-18.2,18.2C201.1,66,198.5,66,196.8,64.3z
|
||||
M267.1,134.5c-1.7-1.7-1.7-4.3,0-6l18.2-18.2c4-4,4-10.2,0-13.9c-4-3.7-10.2-4-13.9,0l-18.2,18.2c-1.7,1.7-4.3,1.7-6,0
|
||||
c-1.7-1.7-1.7-4.3,0-6l18.2-18.2c7.1-7.1,18.8-7.1,26.2,0c7.1,7.1,7.1,18.8,0,26.2l-18.2,18.2
|
||||
C271.6,136.2,268.8,136.2,267.1,134.5z" />
|
||||
s-1.7-4.3,0-6l18.2-18.2c7.1-7.1,18.8-7.1,26.2,0c7.1,7.1,7.1,18.8,0,26.2l-18.2,18.2C271.6,136.2,268.8,136.2,267.1,134.5z" />
|
||||
<path class="st3" d="M289.6,90.1l-0.3-0.3c-19.1-25.9-41.8-48.6-67.4-67.7c-1.7-1.4-2.3-4-0.9-6c1.4-1.7,4-2.3,6-0.9
|
||||
c26.5,19.6,49.8,43,69.4,69.4c1.4,1.7,1.1,4.6-0.9,6C293.5,91.6,291.3,91.6,289.6,90.1z" />
|
||||
<path class="st3" d="M215,25.9l-0.3-0.3c-1.4-1.7-1.1-4.6,0.6-6l25.6-20.2c10.2-8,24.7-7.1,34.1,2l35,35.3c9.4,9.4,10,23.9,2,34.1
|
||||
l-20.2,25.3c-1.4,1.7-4,2-6,0.6c-2-1.4-2-4-0.6-6l20.2-25.3c5.4-6.8,4.8-16.5-1.1-22.8L268.8,7.4c-6.3-6.3-15.9-6.8-22.8-1.4
|
||||
l-25.6,20.2C218.7,27.8,216.5,27.6,215,25.9z M264.2,125.7l-13.9,13.9l-8-8l13.9-13.9 M193.7,55.2l-13.9,13.9l-8-8l13.9-13.9" />
|
||||
l-20.2,25.3c-1.4,1.7-4,2-6,0.6s-2-4-0.6-6l20.2-25.3c5.4-6.8,4.8-16.5-1.1-22.8L268.8,7.4C262.5,1.1,252.9,0.6,246,6l-25.6,20.2
|
||||
C218.7,27.8,216.5,27.6,215,25.9z M264.2,125.7l-13.9,13.9l-8-8l13.9-13.9 M193.7,55.2l-13.9,13.9l-8-8l13.9-13.9" />
|
||||
<path class="st3" d="M247.2,142.8l-8.2-8.2c-1.7-1.7-1.7-4.3,0-6l13.9-13.9c1.7-1.7,4.3-1.7,6,0s1.7,4.3,0,6l-11.1,11.1l2,2
|
||||
l11.1-11.1c1.7-1.7,4.3-1.7,6,0c1.7,1.7,1.7,4.3,0,6l-13.7,14.2C251.4,144.5,248.9,144.5,247.2,142.8z M176.6,72.2l-8-8
|
||||
c-1.7-1.7-1.7-4.3,0-6l13.9-13.9c1.7-1.7,4.3-1.7,6,0s1.7,4.3,0,6l-11.1,11.1l2,2l11.1-11.1c1.7-1.7,4.3-1.7,6,0
|
||||
c1.7,1.7,1.7,4.3,0,6l-13.9,13.9C180.9,73.9,178.3,73.9,176.6,72.2z" />
|
||||
l11.1-11.1c1.7-1.7,4.3-1.7,6,0s1.7,4.3,0,6l-13.7,14.2C251.4,144.5,248.9,144.5,247.2,142.8z M176.6,72.2l-8-8
|
||||
c-1.7-1.7-1.7-4.3,0-6l13.9-13.9c1.7-1.7,4.3-1.7,6,0s1.7,4.3,0,6l-11.1,11.1l2,2l11.1-11.1c1.7-1.7,4.3-1.7,6,0s1.7,4.3,0,6
|
||||
l-13.9,13.9C180.9,73.9,178.3,73.9,176.6,72.2z" />
|
||||
<path class="st3" d="M270.2,137.6l-96.4-96.4c-1.7-1.7-1.7-4.3,0-6s4.3-1.7,6,0l96.4,96.4c1.7,1.7,1.7,4.3,0,6
|
||||
C274.5,139.4,271.9,139.4,270.2,137.6z" />
|
||||
<path class="st2" d="M239.5,88.7l-16.8-16.8c-1.7-1.7-1.7-4.3,0-6l10-10c6.3-6.3,16.5-6.3,23,0l0,0c6.3,6.3,6.3,16.5,0,23l-10,10
|
||||
@@ -194,7 +189,7 @@
|
||||
<path class="st2" d="M213,28.1c-2.8-2.8-7.4-2.8-10,0c-2.8,2.8-2.8,7.4,0,10c2.8,2.8,7.4,2.8,10,0C215.6,35.2,215.6,30.7,213,28.1
|
||||
z M283.3,98.4c-2.8-2.8-7.4-2.8-10,0s-2.8,7.4,0,10s7.4,2.8,10,0C285.9,105.5,286.2,101.2,283.3,98.4z" />
|
||||
<path class="st3" d="M253.2,148.7L169,64.5c-1.7-1.7-1.7-4.3,0-6s4.3-1.7,6,0l84.2,84.2c1.7,1.7,1.7,4.3,0,6
|
||||
C257.4,150.4,254.9,150.4,253.2,148.7z M274.2,157.8c-1.7-1.7-4.3-1.7-6,0s-1.7,4.3,0,6c1.7,1.7,4.3,1.7,6,0
|
||||
C257.4,150.4,254.9,150.4,253.2,148.7z M274.2,157.8c-1.7-1.7-4.3-1.7-6,0s-1.7,4.3,0,6s4.3,1.7,6,0
|
||||
C275.9,162.1,275.9,159.5,274.2,157.8z" />
|
||||
</g>
|
||||
<g>
|
||||
@@ -208,7 +203,7 @@
|
||||
<path class="st3" d="M467.1,235.8l-40.4,40.1c-4.6,4.6-11.7,4.6-16.2,0l-68.3-68.3c-4.6-4.6-4.6-11.7,0-16.2l40.1-40.1
|
||||
c4.6-4.6,11.7-4.6,16.2,0l68.3,68.3C471.3,224.1,471.3,231.2,467.1,235.8z M348.2,197.7c-1.1,1.1-1.1,2.8,0,4l68.3,68.3
|
||||
c1.1,1.1,2.8,1.1,4,0l40.4-40.1c1.1-1.1,1.1-2.8,0-4l-68.3-68.3c-1.1-1.1-2.8-1.1-4,0L348.2,197.7z" />
|
||||
<path class="st2" d="M421.5,260.8c-1.7,1.7-4.3,1.7-6,0l-25-25c-1.7-1.7-1.7-4.3,0-6c1.7-1.7,4.3-1.7,6,0l25,25
|
||||
<path class="st2" d="M421.5,260.8c-1.7,1.7-4.3,1.7-6,0l-25-25c-1.7-1.7-1.7-4.3,0-6s4.3-1.7,6,0l25,25
|
||||
C423.3,256.5,423.3,259.1,421.5,260.8z M381.4,220.7c-0.9,0.9-2,1.4-3.1,1.1c-1.1,0-2.3-0.3-3.1-1.1c-0.9-0.9-1.4-2-1.1-3.1
|
||||
c0-0.3,0-0.6,0-0.9c0-0.3,0-0.6,0.3-0.9c0.3-0.3,0.3-0.6,0.3-0.9c0.9-1.1,2-2,3.4-2c1.1,0,2.3,0.3,3.1,1.1
|
||||
c0.3,0.3,0.3,0.3,0.6,0.6c0.3,0.3,0.3,0.6,0.3,0.9c0,0.3,0.3,0.6,0.3,0.9c0,0.3,0,0.6,0,0.9C382.9,218.7,382.3,219.8,381.4,220.7z
|
||||
@@ -222,25 +217,25 @@
|
||||
d="M905.4,490.4L798.2,383.1c-6-6-6-15.4,0-21.3l53.2-53.2c5.7-5.7,15.4-5.7,21.3,0l107.2,107.2" />
|
||||
<path class="st2" d="M892,364.3l-38.1,38.1c-2.8,2.8-7.7,2.8-10.5,0l-24.7-24.7c-2.8-2.8-2.8-7.7,0-10.5l38.1-38.1
|
||||
c2.8-2.8,7.7-2.8,10.5,0l24.7,24.7C894.9,356.7,894.9,361.5,892,364.3z" />
|
||||
<path class="st3" d="M983.3,419.5c-2,2-5.4,2-7.4,0l-107-107c-3.7-3.7-10.2-4-13.9,0l-53.2,53.2c-3.7,3.7-3.7,10,0,13.9
|
||||
l107.2,107.2c2,2,2,5.4,0,7.4c-2,2-5.4,2-7.4,0L794.5,387.1c-8-8-8-20.8,0-28.7l53.2-53.2c8-8,20.8-8,28.7,0l107.2,107.2
|
||||
<path class="st3" d="M983.3,419.5c-2,2-5.4,2-7.4,0l-107-107c-3.7-3.7-10.2-4-13.9,0l-53.2,53.2c-3.7,3.7-3.7,10,0,13.9L909,486.8
|
||||
c2,2,2,5.4,0,7.4s-5.4,2-7.4,0L794.5,387.1c-8-8-8-20.8,0-28.7l53.2-53.2c8-8,20.8-8,28.7,0l107.2,107.2
|
||||
C985.6,414.4,985.3,417.5,983.3,419.5z" />
|
||||
<path class="st4" d="M892,364.3l-38.1,38.1c-2.8,2.8-7.7,2.8-10.5,0l-24.7-24.7c-2.8-2.8-2.8-7.7,0-10.5l38.1-38.1
|
||||
c2.8-2.8,7.7-2.8,10.5,0l24.7,24.7C894.9,356.7,894.9,361.5,892,364.3z" />
|
||||
<path class="st3" d="M895.7,368l-38.1,38.1c-4.8,4.8-13.1,4.8-17.9,0l-24.7-24.7c-4.8-4.8-4.8-13.1,0-17.9l38.1-38.1
|
||||
<path class="st3" d="M895.7,368l-38.1,38.1c-4.8,4.8-13.1,4.8-17.9,0L815,381.4c-4.8-4.8-4.8-13.1,0-17.9l38.1-38.1
|
||||
c4.8-4.8,13.1-4.8,17.9,0l24.7,24.7C900.6,355,900.6,362.9,895.7,368z M822.3,370.9c-0.9,0.9-0.9,2.3,0,3.1l24.7,24.7
|
||||
c0.9,0.9,2.3,0.9,3.1,0l38.1-38.1c0.9-0.9,0.9-2.3,0-3.1l-24.7-24.7c-0.9-0.9-2.3-0.9-3.1,0L822.3,370.9z M994.1,409l-95.3,95.3
|
||||
c-2,2-5.4,2-7.4,0c-2-2-2-5.4,0-7.4l95.3-95.3c2-2,5.4-2,7.4,0C996.1,403.6,996.1,407,994.1,409z M988.4,392.8
|
||||
c-4.8,4.8-13.1,4.8-17.9,0l-43-43c-0.9-0.9-2.3-0.9-3.1,0l-5.4,5.4c-2,2-5.4,2-7.4,0c-2-2-2-5.4,0-7.4l5.4-5.4
|
||||
c4.8-4.8,13.1-4.8,17.9,0l43,43c0.9,0.9,2.3,0.9,3.1,0c0.9-0.9,0.9-2.3,0-3.1l-58.9-58.9c-2-2-2-5.4,0-7.4c2-2,5.4-2,7.4,0
|
||||
l58.9,58.9C993.3,379.7,993.3,388,988.4,392.8z" />
|
||||
c-2,2-5.4,2-7.4,0s-2-5.4,0-7.4l95.3-95.3c2-2,5.4-2,7.4,0C996.1,403.6,996.1,407,994.1,409z M988.4,392.8
|
||||
c-4.8,4.8-13.1,4.8-17.9,0l-43-43c-0.9-0.9-2.3-0.9-3.1,0l-5.4,5.4c-2,2-5.4,2-7.4,0s-2-5.4,0-7.4l5.4-5.4
|
||||
c4.8-4.8,13.1-4.8,17.9,0l43,43c0.9,0.9,2.3,0.9,3.1,0c0.9-0.9,0.9-2.3,0-3.1l-58.9-58.9c-2-2-2-5.4,0-7.4s5.4-2,7.4,0l58.9,58.9
|
||||
C993.3,379.7,993.3,388,988.4,392.8z" />
|
||||
<path class="st3" d="M934.7,318.3l-10.5,10.5c-2,2-5.4,2-7.4,0l-21.6-21.6c-2-2-2-5.4,0-7.4l1.7-1.7l-4.8-4.8c-2-2-2-5.4,0-7.4
|
||||
c2-2,5.4-2,7.4,0l4.8,4.8l1.4-1.4c2-2,5.4-2,7.4,0l21.3,21.3C936.7,312.9,936.7,316.3,934.7,318.3z M920.5,317.7l3.1-3.1
|
||||
l-13.9-13.9l-3.1,3.1L920.5,317.7z" />
|
||||
s5.4-2,7.4,0l4.8,4.8l1.4-1.4c2-2,5.4-2,7.4,0l21.3,21.3C936.7,312.9,936.7,316.3,934.7,318.3z M920.5,317.7l3.1-3.1l-13.9-13.9
|
||||
l-3.1,3.1L920.5,317.7z" />
|
||||
<g>
|
||||
<path class="st5" d="M923.9,444.6c-0.6,0.6-1.4,1.1-2.6,1.4c-2.8,0.6-5.7-1.1-6.3-3.7l-3.7-16.2l-3.7,5.4c-1.1,1.7-3.1,2.6-5.1,2
|
||||
c-2-0.6-3.7-2-4-4l-6.5-27c-0.6-2.8,1.1-5.7,3.7-6.3c2.8-0.6,5.7,1.1,6.3,3.7l3.7,16.2l3.7-5.4c1.1-1.7,3.1-2.6,5.1-2
|
||||
c2,0.6,3.7,2,4,4l6.3,27C925.6,441.7,925.3,443.4,923.9,444.6z" />
|
||||
s-3.7-2-4-4l-6.5-27c-0.6-2.8,1.1-5.7,3.7-6.3c2.8-0.6,5.7,1.1,6.3,3.7l3.7,16.2l3.7-5.4c1.1-1.7,3.1-2.6,5.1-2s3.7,2,4,4l6.3,27
|
||||
C925.6,441.7,925.3,443.4,923.9,444.6z" />
|
||||
</g>
|
||||
</g>
|
||||
<g>
|
||||
@@ -251,9 +246,9 @@
|
||||
<path class="st12" d="M749.8,413.3l3.7,3.7c1.1,1.1,1.4,2.8,0.6,4.6L708.6,496l-10-10l51.8-68.3
|
||||
C751.2,416.1,750.9,414.4,749.8,413.3z" />
|
||||
<g>
|
||||
<path class="st3" d="M706.6,498L663,454.5c-0.6-0.6-0.9-1.4-0.9-2.3c0-0.9,0.6-1.7,1.4-2l74.5-45.2c2.6-1.4,5.7-1.1,8,0.9
|
||||
l9.4,9.4c2,2,2.6,5.4,0.9,8l-45.5,74.2c-0.6,0.9-1.1,1.1-2,1.4C708,498.9,707.1,498.6,706.6,498z M669.6,453.1l38.4,38.4
|
||||
l43.5-71.7c0.3-0.3,0.3-0.9,0-0.9l-9.4-9.4c-0.3-0.3-0.6-0.3-0.9,0L669.6,453.1z" />
|
||||
<path class="st3" d="M706.6,498L663,454.5c-0.6-0.6-0.9-1.4-0.9-2.3s0.6-1.7,1.4-2L738,405c2.6-1.4,5.7-1.1,8,0.9l9.4,9.4
|
||||
c2,2,2.6,5.4,0.9,8l-45.5,74.2c-0.6,0.9-1.1,1.1-2,1.4C708,498.9,707.1,498.6,706.6,498z M669.6,453.1l38.4,38.4l43.5-71.7
|
||||
c0.3-0.3,0.3-0.9,0-0.9l-9.4-9.4c-0.3-0.3-0.6-0.3-0.9,0L669.6,453.1z" />
|
||||
<path class="st3" d="M655.6,447.1c-1.1-1.1-1.1-2.8,0-4s2.8-1.1,4,0l58,58l0,0c1.1,1.1,1.1,2.8,0,4s-2.8,1.1-4,0L655.6,447.1
|
||||
L655.6,447.1z" />
|
||||
</g>
|
||||
@@ -274,22 +269,21 @@
|
||||
c0.3-0.6,0.3-1.1,0.6-2l-23.3-23.3c-2.8-2.8-7.1-2.8-10,0l-2.3,2.3c-2.8,2.8-2.8,7.1,0,10L664.2,347.8L664.2,347.8z" />
|
||||
<path class="st3" d="M623.8,389.9l-0.3-0.3c-2.8-2.8-2.6-7.4,0.3-10.2c1.7-1.7,4.3-1.7,6,0c0.6,0.6,1.1,1.4,1.1,2.3l3.1-2.6
|
||||
c14.5-11.7,25-27.3,31.3-44.9l1.1-3.4c2.8-8.8,0.6-18.2-6-24.7l-64.3-64.3c-9.4-9.4-24.7-9.4-34.1,0l-35.8,35.8
|
||||
c-1.7,1.7-4.3,1.7-6,0c-1.7-1.7-1.7-4.3,0-6l35.8-35.8c12.8-12.8,33.6-12.8,46.4,0l64.3,64.3c8.8,8.8,11.9,21.6,8,33.6l-1.1,3.4
|
||||
c-6.5,19.1-18.2,35.8-33.8,48.6l-5.7,4.8C630.6,392.8,626.6,392.5,623.8,389.9z M644.8,388.2c-1.7-1.7-1.7-4.3,0-6l18.2-18.2
|
||||
c1.7-1.7,4.3-1.7,6,0c1.7,1.7,1.7,4.3,0,6l-18.2,18.2C649.4,389.9,646.5,389.9,644.8,388.2z" />
|
||||
<path class="st3" d="M599.6,361.2l-46.9-46.9c-1.7-1.7-1.7-4.3,0-6c1.7-1.7,4.3-1.7,6,0l47.2,46.9c1.7,1.7,1.7,4.3,0,6
|
||||
c-1.7,1.7-4.3,1.7-6,0s-1.7-4.3,0-6l35.8-35.8c12.8-12.8,33.6-12.8,46.4,0l64.3,64.3c8.8,8.8,11.9,21.6,8,33.6l-1.1,3.4
|
||||
c-6.5,19.1-18.2,35.8-33.8,48.6l-5.7,4.8C630.6,392.8,626.6,392.5,623.8,389.9z M644.8,388.2c-1.7-1.7-1.7-4.3,0-6L663,364
|
||||
c1.7-1.7,4.3-1.7,6,0s1.7,4.3,0,6l-18.2,18.2C649.4,389.9,646.5,389.9,644.8,388.2z" />
|
||||
<path class="st3" d="M599.6,361.2l-46.9-46.9c-1.7-1.7-1.7-4.3,0-6s4.3-1.7,6,0l47.2,46.9c1.7,1.7,1.7,4.3,0,6
|
||||
C604.2,362.9,601.3,362.9,599.6,361.2z M531.3,292.7L516,277.3c-1.7-1.7-1.7-4.3,0-6s4.3-1.7,6,0l15.4,15.4c1.7,1.7,1.7,4.3,0,6
|
||||
C535.6,294.4,533,294.4,531.3,292.7z M671.9,299.2l-66.6-66.6c-1.7-1.7-1.7-4.3,0-6s4.3-1.7,6,0l66.6,66.6c1.7,1.7,1.7,4.3,0,6
|
||||
S673.6,300.9,671.9,299.2z M640.3,379.7c-1.7-1.7-1.7-4-0.3-6c7.7-8.8,5.4-26.5-4.6-36.4l-87.6-87.6c-1.7-1.7-1.7-4.3,0-6
|
||||
s4.3-1.7,6,0l87.6,87.6c13.1,13.1,15.4,35.8,4.8,48.1C644.8,381.1,642.3,381.4,640.3,379.7L640.3,379.7z M565.2,246.9l-6.3-6.3
|
||||
c-1.7-1.7-1.7-4.3,0-6c1.7-1.7,4.3-1.7,6,0l6.3,6.3c1.7,1.7,1.7,4.3,0,6C569.7,248.6,566.9,248.6,565.2,246.9z M577.4,242.9
|
||||
l-6.3-6.3c-1.7-1.7-1.7-4.3,0-6c1.7-1.7,4.3-1.7,6,0l6.3,6.3c1.7,1.7,1.7,4.3,0,6C581.7,244.6,579.1,244.6,577.4,242.9z" />
|
||||
c-1.7-1.7-1.7-4.3,0-6s4.3-1.7,6,0l6.3,6.3c1.7,1.7,1.7,4.3,0,6C569.7,248.6,566.9,248.6,565.2,246.9z M577.4,242.9l-6.3-6.3
|
||||
c-1.7-1.7-1.7-4.3,0-6s4.3-1.7,6,0l6.3,6.3c1.7,1.7,1.7,4.3,0,6C581.7,244.6,579.1,244.6,577.4,242.9z" />
|
||||
<path class="st2" d="M623.5,359.5c-5.4-5.4-13.9-5.4-19.3,0c-5.4,5.4-5.4,13.9,0,19.3l0,0c5.4,5.4,13.9,5.4,19.3,0
|
||||
C628.6,373.4,628.6,364.9,623.5,359.5L623.5,359.5z" />
|
||||
<path class="st3" d="M601,382c-7.1-7.1-6.8-18.5,0-25.3c6.8-6.8,18.5-6.8,25.3,0c6.8,6.8,7.1,18.5,0,25.3
|
||||
C619.5,389.1,608.1,388.8,601,382z M620.4,362.6c-3.7-3.7-9.7-3.7-13.4,0c-3.7,3.7-3.7,9.7,0,13.4c3.7,3.7,9.7,3.7,13.4,0
|
||||
C624.1,372.3,624.1,366.3,620.4,362.6z" />
|
||||
<path class="st2" d="M535.3,290.7c-5.4,5.4-5.4,13.9,0,19.3c5.4,5.4,13.9,5.4,19.3,0c2.3-2.3,3.7-5.1,4-8.2l-15.1-15.1
|
||||
<path class="st3" d="M601,382c-7.1-7.1-6.8-18.5,0-25.3s18.5-6.8,25.3,0s7.1,18.5,0,25.3C619.5,389.1,608.1,388.8,601,382z
|
||||
M620.4,362.6c-3.7-3.7-9.7-3.7-13.4,0s-3.7,9.7,0,13.4c3.7,3.7,9.7,3.7,13.4,0C624.1,372.3,624.1,366.3,620.4,362.6z" />
|
||||
<path class="st2" d="M535.3,290.7c-5.4,5.4-5.4,13.9,0,19.3s13.9,5.4,19.3,0c2.3-2.3,3.7-5.1,4-8.2l-15.1-15.1
|
||||
C540.4,287.3,537.6,288.4,535.3,290.7z" />
|
||||
<path class="st3" d="M532.5,313.1c-7.1-7.1-6.8-18.5,0-25.3c2.8-2.8,6.5-4.6,10.8-5.1c1.4,0,2.6,0.3,3.4,1.1l15.1,15.1
|
||||
c0.9,0.9,1.4,2.3,1.1,3.4c-0.3,4-2.3,8-5.1,10.8C550.7,320.3,539.3,320,532.5,313.1z M542.2,291.5c-1.4,0.3-2.6,1.1-3.7,2.3
|
||||
@@ -300,7 +294,7 @@
|
||||
</g>
|
||||
<g>
|
||||
<path class="st3" d="M623.8,385.4l-2.6-2.6c-1.7-1.7-1.7-4.3,0-6s4.3-1.7,6,0l2.6,2.6c1.7,1.7,1.7,4.3,0,6
|
||||
C628.1,387.1,625.5,387.1,623.8,385.4z" />
|
||||
S625.5,387.1,623.8,385.4z" />
|
||||
</g>
|
||||
</g>
|
||||
<g>
|
||||
@@ -308,35 +302,35 @@
|
||||
C611.3,101.5,575.1,152.2,554.4,172.9z" />
|
||||
<path class="st5" d="M514.8,63.4l48.1-11.7c1.7-0.3,2.6,1.7,1.1,2.6l-28.4,18.8l9.7,9.7c0.9,0.9,0.3,2.3-0.9,2.3l-43.8,6.8
|
||||
c-1.4,0.3-2.3-1.7-1.1-2.6l23.6-14.5l-9.4-9.4C513.4,64.8,513.7,63.7,514.8,63.4z" />
|
||||
<path class="st3" d="M595.6,115.5l-15.1-15.1c-7.1-7.1-7.1-18.8,0-26.2l8-8c1.7-1.7,4.3-1.7,6,0c1.7,1.7,1.7,4.3,0,6l-8,8
|
||||
<path class="st3" d="M595.6,115.5l-15.1-15.1c-7.1-7.1-7.1-18.8,0-26.2l8-8c1.7-1.7,4.3-1.7,6,0s1.7,4.3,0,6l-8,8
|
||||
c-4,4-4,10.2,0,13.9l15.1,15.1c1.7,1.7,1.7,4.3,0,6C599.9,117.2,597.3,117.2,595.6,115.5z" />
|
||||
<path class="st3" d="M548.4,178.9L447.7,78.2c-1.7-1.7-1.7-4.3,0-6l73.4-73.4c7.1-7.1,18.8-7.1,26.2,0l70.3,70.3l0,0l8,8
|
||||
c1.7,1.7,1.7,4.3,0,6c-1.7,1.7-4.3,1.7-6,0l-2.3-2.3c-8.2,31.3-41.5,76.2-60,94.7l-3.1,3.1C552.7,180.6,549.8,180.6,548.4,178.9z
|
||||
c1.7,1.7,1.7,4.3,0,6s-4.3,1.7-6,0l-2.3-2.3c-8.2,31.3-41.5,76.2-60,94.7l-3.1,3.1C552.7,180.6,549.8,180.6,548.4,178.9z
|
||||
M456.8,75.4l94.4,94.4c21.6-21.6,54.6-69.1,58.9-96.1L541.3,4.8c-4-4-10.2-4-13.9,0C527.1,5.1,456.8,75.4,456.8,75.4z" />
|
||||
<path class="st3" d="M548.4,178.9L443.7,74.2c-1.7-1.7-1.7-4.3,0-6c1.7-1.7,4.3-1.7,6,0l104.7,104.7c1.7,1.7,1.7,4.3,0,6
|
||||
<path class="st3" d="M548.4,178.9L443.7,74.2c-1.7-1.7-1.7-4.3,0-6s4.3-1.7,6,0l104.7,104.7c1.7,1.7,1.7,4.3,0,6
|
||||
C552.7,180.6,549.8,180.6,548.4,178.9z" />
|
||||
<path class="st2" d="M472.2,85.6c-5.4-5.4-13.9-5.4-19.3,0c-5.4,5.4-5.4,13.9,0,19.3l0,0c5.4,5.4,13.9,5.4,19.3,0
|
||||
<path class="st2" d="M472.2,85.6c-5.4-5.4-13.9-5.4-19.3,0s-5.4,13.9,0,19.3l0,0c5.4,5.4,13.9,5.4,19.3,0
|
||||
C477.6,99.8,477.6,91,472.2,85.6L472.2,85.6z M536.5,150.2c-5.4-5.4-13.9-5.4-19.3,0c-5.4,5.4-5.4,13.9,0,19.3l0,0
|
||||
c5.4,5.4,13.9,5.4,19.3,0C541.9,164.1,541.9,155.3,536.5,150.2L536.5,150.2z" />
|
||||
<path class="st3" d="M450,108.1c-7.1-7.1-6.8-18.5,0-25.3c6.8-6.8,18.5-6.8,25.3,0c6.8,6.8,6.8,18.5,0,25.3
|
||||
<path class="st3" d="M450,108.1c-7.1-7.1-6.8-18.5,0-25.3c6.8-6.8,18.5-6.8,25.3,0s6.8,18.5,0,25.3
|
||||
C468.2,114.9,456.8,115.2,450,108.1z M469,88.7c-3.7-3.7-9.7-3.7-13.4,0s-3.7,9.7,0,13.4c3.7,3.7,9.7,3.7,13.4,0
|
||||
C472.7,98.4,473,92.4,469,88.7z" />
|
||||
<g>
|
||||
<path class="st3" d="M514.3,172.3c-7.1-7.1-6.8-18.5,0-25.3c6.8-6.8,18.5-6.8,25.3,0c7.1,7.1,6.8,18.5,0,25.3
|
||||
C532.8,179.5,521.4,179.5,514.3,172.3z M533.6,153c-3.7-3.7-9.7-3.7-13.4,0c-3.7,3.7-3.7,9.7,0,13.4c3.7,3.7,9.7,3.7,13.4,0
|
||||
<path class="st3" d="M514.3,172.3c-7.1-7.1-6.8-18.5,0-25.3s18.5-6.8,25.3,0c7.1,7.1,6.8,18.5,0,25.3
|
||||
C532.8,179.5,521.4,179.5,514.3,172.3z M533.6,153c-3.7-3.7-9.7-3.7-13.4,0s-3.7,9.7,0,13.4s9.7,3.7,13.4,0
|
||||
C537,162.7,537.3,156.7,533.6,153z" />
|
||||
</g>
|
||||
<g>
|
||||
<path class="st3" d="M514.3,172.3l-84.5-84.5c-1.7-1.7-1.7-4.3,0-6c1.7-1.7,4.3-1.7,6,0l84.5,84.5c1.7,1.7,1.7,4.3,0,6
|
||||
<path class="st3" d="M514.3,172.3l-84.5-84.5c-1.7-1.7-1.7-4.3,0-6s4.3-1.7,6,0l84.5,84.5c1.7,1.7,1.7,4.3,0,6
|
||||
C518.5,174.1,516,174.1,514.3,172.3z" />
|
||||
</g>
|
||||
<g>
|
||||
<path class="st3" d="M415.6,73.9c-0.3-0.3-0.3-0.3-0.6-0.6c0-0.3-0.3-0.6-0.3-0.9s-0.3-0.6-0.3-0.9s0-0.6,0-0.9s0-0.6,0-0.9
|
||||
c0-0.3,0-0.6,0.3-0.9c0.3,0,0.3-0.3,0.6-0.6c0.3-0.3,0.3-0.6,0.6-0.6c0.3,0,0.3-0.3,0.6-0.6c0.3-0.3,0.6-0.3,0.9-0.3
|
||||
s0.6,0,0.9-0.3s0.6,0,0.9,0c0.3,0,0.6,0,0.9,0c0.3,0,0.6,0,0.9,0.3c0.3,0.3,0.6,0.3,0.9,0.3c0.6,0.3,0.9,0.6,1.1,1.1
|
||||
c0.3,0.3,0.3,0.6,0.3,0.9s0.3,0.6,0.3,0.9c0,0.3,0,0.6,0,0.9c0,0.3,0,0.6,0,0.9s0,0.6-0.3,0.9c0,0.3-0.3,0.6-0.3,0.9
|
||||
c-0.3,0.3-0.3,0.6-0.6,0.6c-0.3,0-0.3,0.3-0.6,0.6c-0.3,0.3-0.6,0.3-0.9,0.3s-0.6,0.3-0.9,0.3c-0.3,0-0.6,0-0.9,0
|
||||
c-0.3,0-0.6,0-0.9,0s-0.6,0-0.9-0.3c-0.3,0-0.6-0.3-0.9-0.3C416.1,74.5,415.9,73.9,415.6,73.9z" />
|
||||
s0-0.6,0.3-0.9c0.3,0,0.3-0.3,0.6-0.6c0.3-0.3,0.3-0.6,0.6-0.6c0.3,0,0.3-0.3,0.6-0.6c0.3-0.3,0.6-0.3,0.9-0.3s0.6,0,0.9-0.3
|
||||
s0.6,0,0.9,0s0.6,0,0.9,0s0.6,0,0.9,0.3s0.6,0.3,0.9,0.3c0.6,0.3,0.9,0.6,1.1,1.1c0.3,0.3,0.3,0.6,0.3,0.9s0.3,0.6,0.3,0.9
|
||||
s0,0.6,0,0.9s0,0.6,0,0.9s0,0.6-0.3,0.9c0,0.3-0.3,0.6-0.3,0.9c-0.3,0.3-0.3,0.6-0.6,0.6c-0.3,0-0.3,0.3-0.6,0.6
|
||||
c-0.3,0.3-0.6,0.3-0.9,0.3s-0.6,0.3-0.9,0.3s-0.6,0-0.9,0s-0.6,0-0.9,0s-0.6,0-0.9-0.3c-0.3,0-0.6-0.3-0.9-0.3
|
||||
C416.1,74.5,415.9,73.9,415.6,73.9z" />
|
||||
</g>
|
||||
</g>
|
||||
<path class="st3"
|
||||
@@ -350,8 +344,8 @@
|
||||
<path class="st3" d="M78.2,8.2l-8.8-8.5c-1.1-1.1-1.1-2.8,0-4c1.1-1.1,2.8-1.1,4,0l8.8,8.8c1.1,1.1,1.1,2.8,0,4
|
||||
C81.4,9.6,79.4,9.4,78.2,8.2z" />
|
||||
<g>
|
||||
<path class="st2" d="M57.7,69.9L44.7,56.6c-3.7-3.7-3.7-10,0-13.7L82.2,5.4c3.7-3.7,10-3.7,13.7,0l13.4,13.4
|
||||
c3.7,3.7,3.7,10,0,13.7L71.4,69.9C67.7,73.6,61.7,73.6,57.7,69.9z" />
|
||||
<path class="st2" d="M57.7,69.9l-13-13.3c-3.7-3.7-3.7-10,0-13.7L82.2,5.4c3.7-3.7,10-3.7,13.7,0l13.4,13.4c3.7,3.7,3.7,10,0,13.7
|
||||
L71.4,69.9C67.7,73.6,61.7,73.6,57.7,69.9z" />
|
||||
</g>
|
||||
<g>
|
||||
<path class="st11" d="M95.6,18.2c-4.3-4.3-11.1-4.3-15.1,0c-4.3,4.3-4.3,11.1,0,15.1l0,0c4.3,4.3,11.1,4.3,15.1,0
|
||||
@@ -371,8 +365,8 @@
|
||||
C32.4,85.3,30.4,85.3,29.3,84.2z" />
|
||||
</g>
|
||||
<g>
|
||||
<path class="st5" d="M802.4,224.7l-70.5-70.5l-46.9,46.9c-4.3,4.3-4.3,11.4,0,15.6l54.9,54.9c4.3,4.3,11.4,4.3,15.6,0L802.4,224.7
|
||||
z" />
|
||||
<path class="st5" d="M802.4,224.7l-70.5-70.5L685,201.1c-4.3,4.3-4.3,11.4,0,15.6l54.9,54.9c4.3,4.3,11.4,4.3,15.6,0L802.4,224.7z
|
||||
" />
|
||||
<path class="st2" d="M731,207.9l33.3-8.2c1.1-0.3,1.7,1.1,0.9,1.7l-19.6,13.1l6.8,6.8c0.6,0.6,0.3,1.4-0.6,1.7l-30.2,4.8
|
||||
c-1.1,0.3-1.4-1.1-0.6-1.7l16.2-9.7l-6.5-6.5C729.9,209,730.2,208.2,731,207.9z" />
|
||||
<path class="st6" d="M794.7,217l-45.5,45.5c-0.9,0.9-2.3,0.9-3.1,0L712.5,229c-2.3-2.3-5.7-2.3-8,0l0,0c-2.3,2.3-2.3,5.7,0,8
|
||||
@@ -383,11 +377,11 @@
|
||||
c-3.1,3.1-3.1,8,0,11.1l54.9,54.9c3.1,3.1,8,3.1,11.1,0l46.9-46.9c1.4-1.4,3.4-1.4,4.8,0c1.4,1.4,1.4,3.4,0,4.8l-46.9,46.9
|
||||
C752.1,279.6,743.3,279.6,737.6,274.2z" />
|
||||
<path class="st2" d="M714,188c-1.4-1.4-1.4-3.4,0-4.8l23.6-23.6c1.4-1.4,3.4-1.4,4.8,0c1.4,1.4,1.4,3.4,0,4.8L718.8,188
|
||||
C717.4,189.1,715.4,189.1,714,188z M702.3,199.7c-0.3-0.3-0.3-0.3-0.3-0.6c-0.3-0.3-0.3-0.3-0.3-0.6c0-0.3,0-0.3-0.3-0.6
|
||||
C717.4,189.1,715.4,189.1,714,188z M702.3,199.7c-0.3-0.3-0.3-0.3-0.3-0.6c-0.3-0.3-0.3-0.3-0.3-0.6s0-0.3-0.3-0.6
|
||||
c0-0.3,0-0.3,0-0.6s0-0.6,0-0.6c0-0.3,0-0.3,0.3-0.6c0-0.3,0.3-0.3,0.3-0.6s0.3,0,0.3-0.3c0.3-0.3,0.3-0.3,0.6-0.3
|
||||
c0.3,0,0.3-0.3,0.6-0.3s0.6-0.3,0.6-0.3c0.3,0,0.3,0,0.6,0s0.6,0,0.6,0s0.3,0,0.6,0.3c0.3,0,0.3,0.3,0.6,0.3
|
||||
c0.9,0.6,1.4,1.7,1.4,2.8c0,0.3,0,0.6,0,0.6c0,0.3,0,0.3-0.3,0.6c0,0.3-0.3,0.3-0.3,0.6s-0.6,0.3-0.6,0.3
|
||||
c-0.6,0.6-1.4,1.1-2.3,0.9C703.7,200.8,702.9,200.2,702.3,199.7z" />
|
||||
s0.3-0.3,0.6-0.3s0.6-0.3,0.6-0.3c0.3,0,0.3,0,0.6,0s0.6,0,0.6,0s0.3,0,0.6,0.3c0.3,0,0.3,0.3,0.6,0.3c0.9,0.6,1.4,1.7,1.4,2.8
|
||||
c0,0.3,0,0.6,0,0.6c0,0.3,0,0.3-0.3,0.6c0,0.3-0.3,0.3-0.3,0.6s-0.6,0.3-0.6,0.3c-0.6,0.6-1.4,1.1-2.3,0.9
|
||||
C703.7,200.8,702.9,200.2,702.3,199.7z" />
|
||||
<path class="st3" d="M804.1,230.9l-78.2-78.2c-1.4-1.4-1.4-3.4,0-4.8l9.4-9.4c1.4-1.4,3.4-1.4,4.8,0l78.2,78.2
|
||||
c1.4,1.4,1.4,3.4,0,4.8l-9.4,9.4C807.5,232.4,805.3,232.4,804.1,230.9z M732.7,150.4l73.7,73.7l4.8-4.8l-73.7-73.7L732.7,150.4z" />
|
||||
<path class="st3" d="M758.6,166.7c-1.4-1.4-1.4-3.4,0-4.8l7.1-7.1l-7.1-7.1l-0.9,0.9c-1.4,1.4-3.4,1.4-4.8,0
|
||||
@@ -401,20 +395,20 @@
|
||||
<path class="st8" d="M909.4,164.4C909.4,164.1,909.4,164.1,909.4,164.4c-1.1-0.3-2-0.3-2.8-0.3c-8.8,0.3-18.2,2.8-28.2,6.8l0,0
|
||||
l31,31C919.6,191.7,919.6,174.6,909.4,164.4 M991,246.6c-10.2-7.7-25-6.8-34.7,2.6l19.3,19.3l10.8-10.8
|
||||
C989.6,254.6,991,250.6,991,246.6" />
|
||||
<path class="st3" d="M972.8,271.6l-97.6-97.6c-1.1-1.1-1.4-2.3-1.1-3.7c0.3-1.4,1.1-2.6,2.6-3.1c27.9-11.9,48.6-9.4,65.7,8
|
||||
l0.3,0.3c1.7,1.7,1.7,4.3,0,6c-1.7,1.7-4.3,1.7-6,0l-0.3-0.3c-13.4-13.4-29.3-16.2-50.6-8.2l89.9,89.9l8-8
|
||||
c4.3-4.3,4.3-11.4,0-15.6l-34.4-34.7c-1.1-1.1-1.4-2.3-1.1-3.7l1.7-10.8c0.3-2.3,2.6-4,4.8-3.4c2.3,0.3,4,2.6,3.4,4.8l-1.4,8.5
|
||||
l33.3,33c7.7,7.7,7.7,20.2,0,27.9l-10.8,10.8C977.4,273.3,974.5,273.3,972.8,271.6z" />
|
||||
<path class="st2" d="M901.4,172.3c-6-6-15.6-6-21.6,0c-6,6-6,15.6,0,21.6l0,0c6,6,15.6,6,21.6,0
|
||||
C907.4,188,907.4,178.3,901.4,172.3L901.4,172.3z M957.7,216.4c-3.4-3.4-8.8-3.4-11.9,0c-3.4,3.4-3.4,8.8,0,11.9
|
||||
c3.4,3.1,8.8,3.4,11.9,0C960.9,225,960.9,219.6,957.7,216.4z" />
|
||||
<path class="st3" d="M876.7,197.1c-7.7-7.7-7.7-20.2,0-27.9c7.7-7.7,20.2-7.7,27.9,0s7.7,20.2,0,27.9
|
||||
C896.6,204.8,884.3,204.8,876.7,197.1z M898.3,175.2c-4.3-4.3-11.4-4.3-15.6,0c-4.3,4.3-4.3,11.4,0,15.6c4.3,4.3,11.4,4.3,15.6,0
|
||||
C902.5,186.6,902.5,179.7,898.3,175.2z" />
|
||||
<path class="st2" d="M986.2,257.1c-6-6-15.6-6-21.6,0c-6,6-6,15.6,0,21.6l0,0c6,6,15.6,6,21.6,0
|
||||
C992.1,272.8,992.1,263.1,986.2,257.1L986.2,257.1z" />
|
||||
<path class="st3" d="M972.8,271.6L875.2,174c-1.1-1.1-1.4-2.3-1.1-3.7c0.3-1.4,1.1-2.6,2.6-3.1c27.9-11.9,48.6-9.4,65.7,8l0.3,0.3
|
||||
c1.7,1.7,1.7,4.3,0,6s-4.3,1.7-6,0l-0.3-0.3c-13.4-13.4-29.3-16.2-50.6-8.2l89.9,89.9l8-8c4.3-4.3,4.3-11.4,0-15.6l-34.4-34.7
|
||||
c-1.1-1.1-1.4-2.3-1.1-3.7l1.7-10.8c0.3-2.3,2.6-4,4.8-3.4c2.3,0.3,4,2.6,3.4,4.8l-1.4,8.5l33.3,33c7.7,7.7,7.7,20.2,0,27.9
|
||||
l-10.8,10.8C977.4,273.3,974.5,273.3,972.8,271.6z" />
|
||||
<path class="st2" d="M901.4,172.3c-6-6-15.6-6-21.6,0s-6,15.6,0,21.6l0,0c6,6,15.6,6,21.6,0C907.4,188,907.4,178.3,901.4,172.3
|
||||
L901.4,172.3z M957.7,216.4c-3.4-3.4-8.8-3.4-11.9,0c-3.4,3.4-3.4,8.8,0,11.9c3.4,3.1,8.8,3.4,11.9,0
|
||||
C960.9,225,960.9,219.6,957.7,216.4z" />
|
||||
<path class="st3" d="M876.7,197.1c-7.7-7.7-7.7-20.2,0-27.9s20.2-7.7,27.9,0s7.7,20.2,0,27.9C896.6,204.8,884.3,204.8,876.7,197.1
|
||||
z M898.3,175.2c-4.3-4.3-11.4-4.3-15.6,0c-4.3,4.3-4.3,11.4,0,15.6c4.3,4.3,11.4,4.3,15.6,0C902.5,186.6,902.5,179.7,898.3,175.2z
|
||||
" />
|
||||
<path class="st2" d="M986.2,257.1c-6-6-15.6-6-21.6,0s-6,15.6,0,21.6l0,0c6,6,15.6,6,21.6,0C992.1,272.8,992.1,263.1,986.2,257.1
|
||||
L986.2,257.1z" />
|
||||
<path class="st3" d="M961.4,281.9c-7.7-7.7-7.7-20.2,0-27.9c7.7-7.7,20.2-7.7,27.9,0c7.7,7.7,7.7,20.2,0,27.9
|
||||
C981.6,289.5,969.1,289.5,961.4,281.9z M983.3,260.2c-4.3-4.3-11.4-4.3-15.6,0s-4.3,11.4,0,15.6c4.3,4.3,11.4,4.3,15.6,0
|
||||
C981.6,289.5,969.1,289.5,961.4,281.9z M983.3,260.2c-4.3-4.3-11.4-4.3-15.6,0c-4.2,4.3-4.3,11.4,0,15.6c4.3,4.3,11.4,4.3,15.6,0
|
||||
C987.6,271.6,987.6,264.5,983.3,260.2z" />
|
||||
<path class="st3" d="M961.4,281.9l-98.1-98.1c-1.7-1.7-1.7-4.3,0-6s4.3-1.7,6,0l98.1,98.1c1.7,1.7,1.7,4.3,0,6
|
||||
S963.1,283.6,961.4,281.9z M851.3,171.8c-0.9-0.9-1.4-2-1.1-3.1c0-1.1,0.3-2.3,1.1-3.1c0.9-0.9,2-1.4,3.1-1.1c2.3,0,4.3,2,4.3,4.3
|
||||
@@ -423,7 +417,7 @@
|
||||
<g>
|
||||
<path class="st3" d="M789.9,46.3c0-8.5,6.8-15.4,15.4-15.4c8.5,0,15.4,6.8,15.4,15.4s-6.8,15.4-15.4,15.4 M752.6,8.8
|
||||
c0-8.5,6.8-15.4,15.4-15.4s15.4,6.8,15.4,15.4s-6.8,15.4-15.4,15.4" />
|
||||
<path class="st11" d="M782.2,91.6l-59.7-59.7c-0.6-0.6-0.6-1.7,0-2.6l19.3-19.3c0.6-0.6,1.7-0.6,2.6,0l59.7,59.7
|
||||
<path class="st11" d="M782.2,91.6l-59.7-59.7c-0.6-0.6-0.6-1.7,0-2.6L741.8,10c0.6-0.6,1.7-0.6,2.6,0l59.7,59.7
|
||||
c0.6,0.6,0.6,1.7,0,2.6l-19.3,19.3C783.9,92.4,782.8,92.4,782.2,91.6z" />
|
||||
<path class="st2" d="M725.6,26.4l-3.1,3.1c-0.6,0.6-0.6,1.7,0,2.6l5.1,5.1l40.4-3.4l0,0L757.2,23L725.6,26.4z M737.3,46.9
|
||||
l-0.6-0.6l11.4,11.4l40.4-3.1l0,0l-10.8-10.8L737.3,46.9z M801,75.6l3.1-3.1c0.6-0.6,0.6-1.7,0-2.6l-5.1-5.1L758.6,68l0,0
|
||||
@@ -437,7 +431,7 @@
|
||||
</g>
|
||||
<rect y="-34.4" class="st13" width="1024" height="568.9" />
|
||||
</g>
|
||||
<g id="Ebene_2">
|
||||
<g id="Ebene_2_1_">
|
||||
<g>
|
||||
<g>
|
||||
<g>
|
||||
@@ -455,19 +449,36 @@
|
||||
</g>
|
||||
<g>
|
||||
<g>
|
||||
<path class="st17" d="M267.6,140.1c-37,0-67,30-67,67c0,50.5,56.4,76.9,63.2,148.9c0.2,2.1,1.9,3.6,4,3.6c2.1,0,3.8-1.5,4-3.6
|
||||
<path class="st17" d="M267.6,140.1c-37,0-67,30-67,67c0,50.5,56.4,76.9,63.2,148.9c0.2,2.1,1.9,3.6,4,3.6s3.8-1.5,4-3.6
|
||||
c6.8-72,63.2-98.4,63.2-148.9C334.5,169.9,304.5,140.1,267.6,140.1z" />
|
||||
<path class="st18" d="M267.6,141.6c36.8,0,66.5,29.6,67,66.1c0-0.2,0-0.4,0-0.6c0-37-30-67-67-67s-67,29.8-67,67
|
||||
c0,0.2,0,0.4,0,0.6C201,171.2,230.8,141.6,267.6,141.6L267.6,141.6z" />
|
||||
<path class="st19" d="M271.6,354.4c-0.2,2.1-1.9,3.6-4,3.6s-3.8-1.5-4-3.6c-6.5-71.8-62.5-98.2-63-148.1c0,0.4,0,0.6,0,1.1
|
||||
c0,50.5,56.4,76.9,63.2,148.9c0.2,2.1,1.9,3.6,4,3.6c2.1,0,3.8-1.5,4-3.6c6.8-72,63.2-98.4,63.2-148.9c0-0.4,0-0.6,0-1.1
|
||||
c0,50.5,56.4,76.9,63.2,148.9c0.2,2.1,1.9,3.6,4,3.6s3.8-1.5,4-3.6c6.8-72,63.2-98.4,63.2-148.9c0-0.4,0-0.6,0-1.1
|
||||
C334.1,256.1,278.1,282.5,271.6,354.4L271.6,354.4z" />
|
||||
</g>
|
||||
<path class="st20"
|
||||
d="M252.2,174.4v40.6h11v33.2l25.8-44.4h-14.8l14.8-29.6C289.1,174.4,252.2,174.4,252.2,174.4z" />
|
||||
d="M252.2,174.4V215h11v33.2l25.8-44.4h-14.8l14.8-29.6C289.1,174.4,252.2,174.4,252.2,174.4z" />
|
||||
</g>
|
||||
</g>
|
||||
<text transform="matrix(1 0 0 1 417.7245 289.5375)" class="st2 st21 st22">EVMap</text>
|
||||
<g class="st21">
|
||||
<path class="st2"
|
||||
d="M483.6,243h-45.4v39.6h52.2v6.9H430v-97.1h60.1v6.9h-51.9v36.7h45.4V243z" />
|
||||
<path class="st2"
|
||||
d="M536.9,277.5l0.5,2.1l0.6-2.1l30.5-85.1h9l-36.1,97.1h-7.9l-36.1-97.1h8.9L536.9,277.5z" />
|
||||
<path class="st2" d="M602.7,192.5l35.8,85.7l35.9-85.7h10.9v97.1h-8.2v-42.3l0.7-43.3l-36.1,85.6h-6.3l-36-85.3l0.7,42.7v42.5
|
||||
h-8.2v-97.1H602.7z" />
|
||||
<path class="st2" d="M753.7,289.5c-0.8-2.3-1.3-5.6-1.5-10.1c-2.8,3.6-6.4,6.5-10.7,8.4c-4.3,2-8.9,3-13.8,3
|
||||
c-6.9,0-12.5-1.9-16.8-5.8c-4.3-3.9-6.4-8.8-6.4-14.7c0-7,2.9-12.6,8.8-16.7c5.8-4.1,14-6.1,24.4-6.1h14.5v-8.2
|
||||
c0-5.2-1.6-9.2-4.8-12.2c-3.2-3-7.8-4.4-13.9-4.4c-5.6,0-10.2,1.4-13.8,4.3c-3.6,2.8-5.5,6.3-5.5,10.3l-8-0.1
|
||||
c0-5.7,2.7-10.7,8-14.9c5.3-4.2,11.9-6.3,19.7-6.3c8,0,14.4,2,19,6c4.6,4,7,9.6,7.2,16.8v34.1c0,7,0.7,12.2,2.2,15.7v0.8H753.7z
|
||||
M728.6,283.8c5.3,0,10.1-1.3,14.3-3.9c4.2-2.6,7.3-6,9.2-10.3v-15.9h-14.3c-8,0.1-14.2,1.5-18.7,4.4c-4.5,2.8-6.7,6.7-6.7,11.6
|
||||
c0,4,1.5,7.4,4.5,10.1S723.8,283.8,728.6,283.8z" />
|
||||
<path class="st2" d="M839.3,254.2c0,11.2-2.5,20.2-7.5,26.8c-5,6.6-11.6,9.9-20,9.9c-9.9,0-17.4-3.5-22.7-10.4v36.8h-7.9v-99.9
|
||||
h7.4l0.4,10.2c5.2-7.7,12.7-11.5,22.6-11.5c8.6,0,15.4,3.3,20.3,9.8c4.9,6.5,7.4,15.6,7.4,27.2V254.2z M831.3,252.8
|
||||
c0-9.2-1.9-16.5-5.7-21.8c-3.8-5.3-9-8-15.8-8c-4.9,0-9.1,1.2-12.6,3.5c-3.5,2.4-6.2,5.8-8.1,10.3v34.6c1.9,4.1,4.6,7.3,8.2,9.5
|
||||
c3.6,2.2,7.8,3.3,12.6,3.3c6.7,0,11.9-2.7,15.7-8C829.4,270.7,831.3,263,831.3,252.8z" />
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 37 KiB |
130
_img/powered_by_chargeprice.svg
Normal file
@@ -0,0 +1,130 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?><!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" width="363.80554mm"
|
||||
height="72.554214mm" viewBox="0 0 1289.0747 257.0819" id="svg4985" version="1.1"
|
||||
inkscape:version="0.92.3 (2405546, 2018-03-11)" sodipodi:docname="logo_text_small2.svg"
|
||||
inkscape:export-filename="/home/nih/Desktop/cp/logos/powered_by/logo_text_white_small.png"
|
||||
inkscape:export-xdpi="42.009968" inkscape:export-ydpi="42.009968">
|
||||
<defs id="defs4987">
|
||||
<inkscape:perspective sodipodi:type="inkscape:persp3d" inkscape:vp_x="0 : 152.57443 : 1"
|
||||
inkscape:vp_y="0 : 1000.0001 : 0" inkscape:vp_z="305.14877 : 152.57443 : 1"
|
||||
inkscape:persp3d-origin="152.57439 : 101.71629 : 1" id="perspective4145" />
|
||||
</defs>
|
||||
<sodipodi:namedview id="base" pagecolor="#ffffff" bordercolor="#666666" borderopacity="1.0"
|
||||
inkscape:pageopacity="0.0" inkscape:pageshadow="2" inkscape:zoom="1" inkscape:cx="572.73795"
|
||||
inkscape:cy="173.48708" inkscape:document-units="px" inkscape:current-layer="layer4"
|
||||
showgrid="false" fit-margin-top="0" fit-margin-left="0" fit-margin-right="0"
|
||||
fit-margin-bottom="0" inkscape:window-width="1857" inkscape:window-height="1052"
|
||||
inkscape:window-x="63" inkscape:window-y="0" inkscape:window-maximized="1" />
|
||||
<metadata id="metadata4990">
|
||||
<rdf:RDF>
|
||||
<cc:Work rdf:about="">
|
||||
<dc:format>image/svg+xml</dc:format>
|
||||
<dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
|
||||
<dc:title></dc:title>
|
||||
</cc:Work>
|
||||
</rdf:RDF>
|
||||
</metadata>
|
||||
<g inkscape:groupmode="layer" id="layer4" inkscape:label="Layer 2"
|
||||
transform="translate(-220.97188,-392.31605)">
|
||||
<g aria-label="chargeprice" transform="matrix(0.93750004,0,0,0.93750004,231.60533,392.30136)"
|
||||
style="font-style:normal;font-weight:normal;font-size:25px;line-height:125%;font-family:sans-serif;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
|
||||
id="flowRoot825">
|
||||
<path
|
||||
d="m 350.50175,132.92433 q 15.168,0 25.152,4.8 10.176,4.8 10.176,12.288 0,3.264 -2.112,5.952 -2.112,2.496 -5.376,2.496 -2.496,0 -4.032,-0.768 -1.344,-0.768 -3.84,-2.496 -1.152,-1.152 -3.648,-2.688 -2.304,-1.152 -6.528,-1.92 -4.224,-0.768 -7.68,-0.768 -9.984,0 -17.664,4.608 -7.68,4.608 -11.904,12.864 -4.224,8.064 -4.224,18.048 0,10.176 4.032,18.24 4.224,8.064 11.712,12.672 7.488,4.608 17.088,4.608 9.984,0 16.128,-3.072 1.344,-0.768 3.648,-2.496 1.92,-1.536 3.264,-2.304 1.536,-0.768 3.648,-0.768 3.84,0 5.952,2.496 2.304,2.304 2.304,6.144 0,4.032 -5.184,8.064 -4.992,3.84 -13.632,6.336 -8.448,2.496 -18.24,2.496 -14.592,0 -25.728,-6.72 -11.136,-6.912 -17.28,-18.816 -5.952,-12.096 -5.952,-26.88 0,-14.784 6.336,-26.688 6.336,-12.096 17.664,-18.816 11.328,-6.912 25.92,-6.912 z"
|
||||
style="font-style:normal;font-variant:normal;font-weight:500;font-stretch:normal;font-size:192px;font-family:Quicksand;-inkscape-font-specification:'Quicksand Medium'"
|
||||
id="path824" />
|
||||
<path
|
||||
d="m 455.46275,133.50033 q 33.792,0 33.792,41.856 v 51.264 q 0,3.84 -2.688,6.528 -2.496,2.688 -6.528,2.688 -3.84,0 -6.528,-2.688 -2.496,-2.688 -2.496,-6.528 v -51.264 q 0,-24.96 -21.12,-24.96 -11.328,0 -18.816,7.296 -7.488,7.104 -7.488,17.664 v 51.264 q 0,3.84 -2.688,6.528 -2.496,2.688 -6.528,2.688 -4.032,0 -6.528,-2.496 -2.496,-2.688 -2.496,-6.72 v -123.648 q 0,-3.840001 2.496,-6.528001 2.688,-2.688 6.528,-2.688 4.032,0 6.528,2.688 2.688,2.688 2.688,6.528001 v 48.576 q 4.8,-7.488 13.44,-12.672 8.64,-5.376 18.432,-5.376 z"
|
||||
style="font-style:normal;font-variant:normal;font-weight:500;font-stretch:normal;font-size:192px;font-family:Quicksand;-inkscape-font-specification:'Quicksand Medium'"
|
||||
id="path826" />
|
||||
<path
|
||||
d="m 597.95075,133.88433 q 4.032,0 6.528,2.688 2.688,2.496 2.688,6.72 v 83.328 q 0,3.84 -2.688,6.528 -2.496,2.688 -6.528,2.688 -4.032,0 -6.528,-2.496 -2.496,-2.688 -2.496,-6.72 v -4.992 q -4.992,6.72 -13.632,11.52 -8.64,4.608 -18.624,4.608 -13.056,0 -23.808,-6.72 -10.56,-6.72 -16.704,-18.624 -5.952,-12.096 -5.952,-27.072 0,-14.976 5.952,-26.88 6.144,-12.096 16.704,-18.816 10.56,-6.72 23.232,-6.72 10.176,0 18.816,4.224 8.832,4.224 14.016,10.752 v -4.608 q 0,-4.032 2.496,-6.72 2.496,-2.688 6.528,-2.688 z m -39.168,86.976 q 9.024,0 15.936,-4.608 7.104,-4.608 10.944,-12.672 4.032,-8.064 4.032,-18.24 0,-9.984 -4.032,-18.048 -3.84,-8.064 -10.944,-12.672 -6.912,-4.8 -15.936,-4.8 -9.024,0 -16.128,4.608 -6.912,4.608 -10.944,12.672 -3.84,8.064 -3.84,18.24 0,10.176 3.84,18.24 4.032,8.064 10.944,12.672 7.104,4.608 16.128,4.608 z"
|
||||
style="font-style:normal;font-variant:normal;font-weight:500;font-stretch:normal;font-size:192px;font-family:Quicksand;-inkscape-font-specification:'Quicksand Medium'"
|
||||
id="path828" />
|
||||
<path
|
||||
d="m 684.21275,132.92433 q 4.992,0 8.64,2.688 3.648,2.496 3.648,6.336 0,4.608 -2.496,7.104 -2.304,2.304 -5.76,2.304 -1.728,0 -5.184,-1.152 -4.032,-1.344 -6.336,-1.344 -5.952,0 -11.712,4.224 -5.568,4.032 -9.216,11.328 -3.456,7.104 -3.456,15.936 v 46.272 q 0,3.84 -2.688,6.528 -2.496,2.688 -6.528,2.688 -4.032,0 -6.528,-2.496 -2.496,-2.688 -2.496,-6.72 v -82.176 q 0,-3.84 2.496,-6.528 2.688,-2.688 6.528,-2.688 4.032,0 6.528,2.688 2.688,2.688 2.688,6.528 v 9.792 q 4.224,-9.408 12.672,-15.168 8.448,-5.952 19.2,-6.144 z"
|
||||
style="font-style:normal;font-variant:normal;font-weight:500;font-stretch:normal;font-size:192px;font-family:Quicksand;-inkscape-font-specification:'Quicksand Medium'"
|
||||
id="path830" />
|
||||
<path
|
||||
d="m 794.32175,133.88433 q 4.032,0 6.528,2.688 2.688,2.496 2.688,6.72 v 84.48 q 0,15.552 -6.72,25.92 -6.528,10.56 -17.856,15.552 -11.328,4.992 -25.536,4.992 -7.68,0 -18.048,-2.688 -10.176,-2.688 -13.056,-5.568 -5.952,-3.072 -5.952,-7.68 0,-1.152 0.768,-3.072 2.112,-4.8 7.104,-4.8 2.496,0 5.376,1.152 15.36,5.952 24,5.952 15.36,0 23.424,-7.488 8.256,-7.296 8.256,-20.16 v -10.368 q -4.032,7.488 -13.632,12.864 -9.408,5.376 -19.968,5.376 -13.248,0 -24.192,-6.72 -10.944,-6.72 -17.28,-18.624 -6.144,-12.096 -6.144,-27.072 0,-14.976 6.144,-26.88 6.336,-12.096 17.088,-18.816 10.944,-6.72 24,-6.72 10.56,0 19.584,4.8 9.216,4.8 14.4,11.712 v -6.144 q 0,-4.032 2.496,-6.72 2.496,-2.688 6.528,-2.688 z m -40.512,86.976 q 9.408,0 16.704,-4.416 7.296,-4.608 11.328,-12.672 4.224,-8.256 4.224,-18.432 0,-10.176 -4.224,-18.24 -4.032,-8.064 -11.328,-12.672 -7.296,-4.608 -16.704,-4.608 -9.216,0 -16.512,4.608 -7.296,4.608 -11.52,12.864 -4.032,8.064 -4.032,18.048 0,9.984 4.032,18.24 4.224,8.064 11.52,12.672 7.296,4.608 16.512,4.608 z"
|
||||
style="font-style:normal;font-variant:normal;font-weight:500;font-stretch:normal;font-size:192px;font-family:Quicksand;-inkscape-font-specification:'Quicksand Medium'"
|
||||
id="path832" />
|
||||
<path
|
||||
d="m 915.17075,181.69233 q -0.192,3.456 -2.88,5.952 -2.688,2.304 -6.336,2.304 h -67.584 q 1.344,14.016 10.56,22.464 9.408,8.448 22.848,8.448 9.216,0 14.976,-2.688 5.76,-2.688 10.176,-6.912 2.88,-1.728 5.568,-1.728 3.264,0 5.376,2.304 2.304,2.304 2.304,5.376 0,4.032 -3.84,7.296 -5.568,5.568 -14.784,9.408 -9.216,3.84 -18.816,3.84 -15.552,0 -27.456,-6.528 -11.712,-6.528 -18.24,-18.24 -6.336,-11.712 -6.336,-26.496 0,-16.128 6.528,-28.224 6.72,-12.288 17.472,-18.816 10.944,-6.528 23.424,-6.528 12.288,0 23.04,6.336 10.752,6.336 17.28,17.472 6.528,11.136 6.72,24.96 z m -47.04,-31.872 q -10.752,0 -18.624,6.144 -7.872,5.952 -10.368,18.624 h 56.64 v -1.536 q -0.96,-10.176 -9.216,-16.704 -8.064,-6.528 -18.432,-6.528 z"
|
||||
style="font-style:normal;font-variant:normal;font-weight:500;font-stretch:normal;font-size:192px;font-family:Quicksand;-inkscape-font-specification:'Quicksand Medium'"
|
||||
id="path834" />
|
||||
<path
|
||||
d="m 986.84675,133.50033 q 13.056,0 23.61605,6.72 10.5599,6.528 16.512,18.432 6.144,11.904 6.144,26.88 0,14.976 -6.144,26.88 -5.9521,11.712 -16.512,18.432 -10.56005,6.72 -23.23205,6.72 -9.984,0 -18.624,-4.416 -8.64,-4.416 -14.016,-10.752 v 42.624 q 0,3.84 -2.688,6.528 -2.496,2.688 -6.528,2.688 -3.84,0 -6.528,-2.688 -2.496,-2.496 -2.496,-6.528 v -120.768 q 0,-4.032 2.496,-6.72 2.496,-2.688 6.528,-2.688 4.032,0 6.528,2.688 2.688,2.688 2.688,6.72 v 5.568 q 4.608,-6.72 13.44,-11.52 8.832,-4.8 18.816,-4.8 z m -2.112,87.168 q 8.832,0 15.93595,-4.608 7.1041,-4.608 10.944,-12.48 4.032,-8.064 4.032,-18.048 0,-9.984 -4.032,-17.856 -3.8399,-8.064 -10.944,-12.672 -7.10395,-4.608 -15.93595,-4.608 -9.024,0 -16.128,4.608 -7.104,4.416 -11.136,12.48 -3.84,8.064 -3.84,18.048 0,9.984 3.84,18.048 4.032,8.064 11.136,12.672 7.104,4.416 16.128,4.416 z"
|
||||
style="font-style:normal;font-variant:normal;font-weight:500;font-stretch:normal;font-size:192px;font-family:Quicksand;-inkscape-font-specification:'Quicksand Medium'"
|
||||
id="path836" />
|
||||
<path
|
||||
d="m 1104.2128,132.92433 q 4.9919,0 8.64,2.688 3.648,2.496 3.648,6.336 0,4.608 -2.496,7.104 -2.304,2.304 -5.76,2.304 -1.728,0 -5.184,-1.152 -4.032,-1.344 -6.336,-1.344 -5.952,0 -11.712,4.224 -5.568,4.032 -9.216,11.328 -3.456,7.104 -3.456,15.936 v 46.272 q 0,3.84 -2.688,6.528 -2.496,2.688 -6.528,2.688 -4.032,0 -6.528,-2.496 -2.496,-2.688 -2.496,-6.72 v -82.176 q 0,-3.84 2.496,-6.528 2.688,-2.688 6.528,-2.688 4.032,0 6.528,2.688 2.688,2.688 2.688,6.528 v 9.792 q 4.224,-9.408 12.672,-15.168 8.448,-5.952 19.2,-6.144 z"
|
||||
style="font-style:normal;font-variant:normal;font-weight:500;font-stretch:normal;font-size:192px;font-family:Quicksand;-inkscape-font-specification:'Quicksand Medium'"
|
||||
id="path838" />
|
||||
<path
|
||||
d="m 1150.5058,226.62033 q 0,3.84 -2.688,6.528 -2.496,2.688 -6.528,2.688 -3.84,0 -6.5281,-2.688 -2.4959,-2.688 -2.4959,-6.528 v -83.136 q 0,-3.84 2.4959,-6.528 2.6881,-2.688 6.5281,-2.688 4.032,0 6.528,2.688 2.688,2.688 2.688,6.528 z m -9.216,-105.024 q -5.568,0 -8.064,-1.92 -2.304,-2.112 -2.304,-6.528 v -3.072 q 0,-4.608 2.496,-6.528 2.688,-1.92 8.064,-1.92 5.3759,0 7.68,2.112 2.496,1.92 2.496,6.336 v 3.072 q 0,4.608 -2.496,6.528 -2.496,1.92 -7.872,1.92 z"
|
||||
style="font-style:normal;font-variant:normal;font-weight:500;font-stretch:normal;font-size:192px;font-family:Quicksand;-inkscape-font-specification:'Quicksand Medium'"
|
||||
id="path840" />
|
||||
<path
|
||||
d="m 1220.5018,132.92433 q 15.168,0 25.152,4.8 10.1759,4.8 10.1759,12.288 0,3.264 -2.1119,5.952 -2.112,2.496 -5.376,2.496 -2.496,0 -4.0321,-0.768 -1.3439,-0.768 -3.8399,-2.496 -1.152,-1.152 -3.648,-2.688 -2.304,-1.152 -6.528,-1.92 -4.224,-0.768 -7.68,-0.768 -9.984,0 -17.664,4.608 -7.68,4.608 -11.904,12.864 -4.224,8.064 -4.224,18.048 0,10.176 4.032,18.24 4.224,8.064 11.712,12.672 7.488,4.608 17.088,4.608 9.984,0 16.128,-3.072 1.344,-0.768 3.648,-2.496 1.92,-1.536 3.264,-2.304 1.536,-0.768 3.648,-0.768 3.84,0 5.952,2.496 2.304,2.304 2.304,6.144 0,4.032 -5.184,8.064 -4.992,3.84 -13.632,6.336 -8.448,2.496 -18.24,2.496 -14.592,0 -25.728,-6.72 -11.136,-6.912 -17.28,-18.816 -5.952,-12.096 -5.952,-26.88 0,-14.784 6.336,-26.688 6.336,-12.096 17.664,-18.816 11.328,-6.912 25.92,-6.912 z"
|
||||
style="font-style:normal;font-variant:normal;font-weight:500;font-stretch:normal;font-size:192px;font-family:Quicksand;-inkscape-font-specification:'Quicksand Medium'"
|
||||
id="path842" />
|
||||
<path
|
||||
d="m 1363.6708,181.69233 q -0.192,3.456 -2.88,5.952 -2.688,2.304 -6.3361,2.304 h -67.5839 q 1.344,14.016 10.56,22.464 9.4079,8.448 22.848,8.448 9.216,0 14.976,-2.688 5.76,-2.688 10.176,-6.912 2.88,-1.728 5.568,-1.728 3.264,0 5.376,2.304 2.304,2.304 2.304,5.376 0,4.032 -3.8401,7.296 -5.5679,5.568 -14.7839,9.408 -9.2161,3.84 -18.816,3.84 -15.552,0 -27.456,-6.528 -11.712,-6.528 -18.24,-18.24 -6.336,-11.712 -6.336,-26.496 0,-16.128 6.528,-28.224 6.72,-12.288 17.472,-18.816 10.944,-6.528 23.424,-6.528 12.288,0 23.04,6.336 10.752,6.336 17.28,17.472 6.528,11.136 6.72,24.96 z m -47.04,-31.872 q -10.752,0 -18.624,6.144 -7.872,5.952 -10.368,18.624 h 56.64 v -1.536 q -0.96,-10.176 -9.216,-16.704 -8.064,-6.528 -18.432,-6.528 z"
|
||||
style="font-style:normal;font-variant:normal;font-weight:500;font-stretch:normal;font-size:192px;font-family:Quicksand;-inkscape-font-specification:'Quicksand Medium'"
|
||||
id="path844" />
|
||||
</g>
|
||||
<g aria-label="POWERED BY" transform="matrix(0.93750004,0,0,0.93750004,231.12524,266.21949)"
|
||||
style="font-style:normal;font-weight:normal;font-size:25px;line-height:125%;font-family:sans-serif;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
|
||||
id="flowRoot825-6">
|
||||
<path
|
||||
d="m 331.92042,135.56966 q 5.44,0 10.34666,3.30667 4.90667,3.2 7.89334,8.74667 2.98666,5.44 2.98666,11.94666 0,6.4 -2.98666,11.94667 -2.98667,5.54666 -7.89334,8.85333 -4.90666,3.2 -10.34666,3.2 h -18.56 v 20.16 q 0,2.88 -1.70667,4.69333 -1.70667,1.81334 -4.48,1.81334 -2.66667,0 -4.37333,-1.81334 -1.70667,-1.92 -1.70667,-4.69333 v -61.65333 q 0,-2.77333 1.81333,-4.58667 1.92,-1.92 4.69334,-1.92 z m 0,35.84 q 2.02666,0 3.94666,-1.70667 2.02667,-1.70666 3.2,-4.37333 1.28,-2.77333 1.28,-5.76 0,-2.98667 -1.28,-5.65333 -1.17333,-2.77333 -3.2,-4.37333 -1.92,-1.70667 -3.94666,-1.70667 h -18.56 v 23.57333 z"
|
||||
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:106.66666412px;font-family:Quicksand;-inkscape-font-specification:'Quicksand Bold'"
|
||||
id="path883" />
|
||||
<path
|
||||
d="m 434.16208,172.90299 q 0,10.56 -4.69333,19.41334 -4.69333,8.74666 -13.01333,13.86666 -8.21334,5.12 -18.56,5.12 -10.34667,0 -18.66667,-5.12 -8.21333,-5.12 -12.90667,-13.86666 -4.58666,-8.85334 -4.58666,-19.41334 0,-10.56 4.58666,-19.30666 4.69334,-8.85333 12.90667,-13.97333 8.32,-5.12 18.66667,-5.12 10.34666,0 18.56,5.12 8.32,5.12 13.01333,13.97333 4.69333,8.74666 4.69333,19.30666 z m -13.86666,0 q 0,-7.14666 -2.88,-12.90666 -2.88,-5.86667 -8,-9.28 -5.12,-3.41333 -11.52,-3.41333 -6.50667,0 -11.62667,3.41333 -5.01333,3.30666 -7.89333,9.17333 -2.77334,5.86667 -2.77334,13.01333 0,7.14667 2.77334,13.01334 2.88,5.86666 7.89333,9.28 5.12,3.30666 11.62667,3.30666 6.4,0 11.52,-3.41333 5.12,-3.41333 8,-9.17333 2.88,-5.86667 2.88,-13.01334 z"
|
||||
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:106.66666412px;font-family:Quicksand;-inkscape-font-specification:'Quicksand Bold'"
|
||||
id="path885" />
|
||||
<path
|
||||
d="m 529.83207,135.24966 q 2.56,0 4.69333,2.02667 2.24,1.92 2.24,4.90667 0,0.96 -0.32,2.13333 l -21.01333,61.86666 q -0.64,1.81334 -2.24,2.88 -1.6,1.06667 -3.52,1.17334 -1.92,0 -3.62667,-1.06667 -1.70666,-1.06667 -2.66666,-3.09333 l -15.14667,-34.45334 -15.25333,34.45334 q -0.96,2.02666 -2.66667,3.09333 -1.70666,1.06667 -3.62666,1.06667 -1.92,-0.10667 -3.52,-1.17334 -1.6,-1.06666 -2.24,-2.88 l -21.01334,-61.86666 q -0.32,-1.17333 -0.32,-2.13333 0,-2.98667 2.13334,-4.90667 2.24,-2.02667 4.90666,-2.02667 2.13334,0 3.84,1.17334 1.70667,1.06666 2.34667,2.98666 l 15.89333,48.21333 13.86667,-33.28 q 0.85333,-1.91999 2.45333,-2.98666 1.6,-1.17333 3.62667,-1.06667 2.02667,-0.10666 3.52,1.06667 1.6,1.06667 2.45333,2.98666 l 13.12,32.96 15.78667,-47.89333 q 0.64,-1.92 2.34666,-2.98666 1.81334,-1.17334 3.94667,-1.17334 z"
|
||||
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:106.66666412px;font-family:Quicksand;-inkscape-font-specification:'Quicksand Bold'"
|
||||
id="path887" />
|
||||
<path
|
||||
d="m 588.75041,197.96966 q 2.77333,0 4.58666,1.92 1.92,1.81333 1.92,4.26667 0,2.66666 -1.92,4.37333 -1.81333,1.70667 -4.58666,1.70667 h -35.73334 q -2.77333,0 -4.69333,-1.81334 -1.81333,-1.92 -1.81333,-4.69333 v -61.65333 q 0,-2.77333 1.81333,-4.58667 1.92,-1.92 4.69333,-1.92 h 35.73334 q 2.77333,0 4.58666,1.81334 1.92,1.70666 1.92,4.48 0,2.66666 -1.81333,4.37333 -1.81333,1.6 -4.69333,1.6 h -28.90667 v 18.13333 h 24.10667 q 2.77333,0 4.58666,1.81333 1.92,1.70667 1.92,4.48 0,2.66667 -1.81333,4.37334 -1.81333,1.6 -4.69333,1.6 h -24.10667 v 19.73333 z"
|
||||
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:106.66666412px;font-family:Quicksand;-inkscape-font-specification:'Quicksand Bold'"
|
||||
id="path889" />
|
||||
<path
|
||||
d="m 666.11206,199.78299 q 1.38667,0.85334 2.13333,2.24 0.85334,1.38667 0.85334,2.88 0,1.92 -1.28,3.52 -1.6,1.92 -4.90667,1.92 -2.56,0 -4.69333,-1.17333 -7.68,-4.37333 -7.68,-17.81333 0,-3.84 -2.56,-6.08 -2.45333,-2.24 -7.14667,-2.24 H 620.8854 v 20.69333 q 0,2.88 -1.6,4.69333 -1.49334,1.81334 -4.05334,1.81334 -3.09333,0 -5.44,-1.81334 -2.24,-1.92 -2.24,-4.69333 v -61.65333 q 0,-2.77333 1.81334,-4.58667 1.92,-1.92 4.69333,-1.92 h 30.72 q 5.54667,0 10.45333,2.98667 4.90667,2.98667 7.78667,8.21333 2.98666,5.22667 2.98666,11.73333 0,5.33334 -2.88,10.45334 -2.88,5.01333 -7.46666,8 6.72,4.69333 7.36,12.58666 0.32,1.70667 0.32,3.30667 0.42666,3.30667 0.85333,4.8 0.42667,1.38667 1.92,2.13333 z M 644.2454,172.04966 q 1.92,0 3.73333,-1.81333 1.81333,-1.81334 2.98667,-4.8 1.17333,-3.09334 1.17333,-6.61334 0,-2.98666 -1.17333,-5.43999 -1.17334,-2.56 -2.98667,-4.05334 -1.81333,-1.49333 -3.73333,-1.49333 h -23.36 v 24.21333 z"
|
||||
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:106.66666412px;font-family:Quicksand;-inkscape-font-specification:'Quicksand Bold'"
|
||||
id="path891" />
|
||||
<path
|
||||
d="m 723.12541,197.96966 q 2.77333,0 4.58666,1.92 1.92,1.81333 1.92,4.26667 0,2.66666 -1.92,4.37333 -1.81333,1.70667 -4.58666,1.70667 h -35.73334 q -2.77333,0 -4.69333,-1.81334 -1.81333,-1.92 -1.81333,-4.69333 v -61.65333 q 0,-2.77333 1.81333,-4.58667 1.92,-1.92 4.69333,-1.92 h 35.73334 q 2.77333,0 4.58666,1.81334 1.92,1.70666 1.92,4.48 0,2.66666 -1.81333,4.37333 -1.81333,1.6 -4.69333,1.6 h -28.90667 v 18.13333 h 24.10667 q 2.77333,0 4.58666,1.81333 1.92,1.70667 1.92,4.48 0,2.66667 -1.81333,4.37334 -1.81333,1.6 -4.69333,1.6 h -24.10667 v 19.73333 z"
|
||||
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:106.66666412px;font-family:Quicksand;-inkscape-font-specification:'Quicksand Bold'"
|
||||
id="path893" />
|
||||
<path
|
||||
d="m 773.92706,135.56966 q 10.02667,0 17.17333,5.01334 7.25334,4.90666 10.98667,13.43999 3.84,8.42667 3.84,18.88 0,10.45334 -3.84,18.98667 -3.73333,8.42667 -10.98667,13.44 -7.14666,4.90667 -17.17333,4.90667 h -25.49333 q -2.77333,0 -4.69333,-1.81334 -1.81334,-1.92 -1.81334,-4.69333 v -61.65333 q 0,-2.77333 1.81334,-4.58667 1.92,-1.92 4.69333,-1.92 z m -1.06666,62.4 q 9.6,0 14.4,-7.04 4.79999,-7.14667 4.79999,-18.02667 0,-10.88 -4.90666,-17.92 -4.8,-7.14666 -14.29333,-7.14666 h -17.6 v 50.13333 z"
|
||||
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:106.66666412px;font-family:Quicksand;-inkscape-font-specification:'Quicksand Bold'"
|
||||
id="path895" />
|
||||
<path
|
||||
d="m 892.76875,168.84966 q 5.65333,2.24 9.17333,6.82667 3.62667,4.58666 3.62667,11.84 0,12.69333 -7.25333,17.70666 -7.25334,5.01334 -17.28,5.01334 h -26.56 q -2.77334,0 -4.69334,-1.81334 -1.81333,-1.92 -1.81333,-4.69333 v -61.65333 q 0,-2.77333 1.81333,-4.58667 1.92,-1.92 4.69334,-1.92 h 26.88 q 20.26666,0 20.26666,18.98667 0,4.8 -2.34666,8.53333 -2.24,3.62667 -6.50667,5.76 z m -5.01333,-11.94667 q 0,-4.37333 -2.24,-6.50666 -2.13334,-2.24 -6.08,-2.24 h -17.6 v 16.64 h 17.92 q 3.2,0 5.54666,-2.13334 2.45334,-2.13333 2.45334,-5.76 z m -6.72,41.06667 q 5.01333,0 7.78666,-2.66667 2.88,-2.66666 2.88,-7.78666 0,-6.29334 -3.30666,-8.21334 -3.30667,-1.92 -8.10667,-1.92 h -18.45333 v 20.58667 z"
|
||||
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:106.66666412px;font-family:Quicksand;-inkscape-font-specification:'Quicksand Bold'"
|
||||
id="path897" />
|
||||
<path
|
||||
d="m 969.23371,141.863 q 0,2.13333 -1.17334,3.94666 l -22.29333,31.89333 v 26.02667 q 0,2.77333 -1.81333,4.69333 -1.81333,1.81334 -4.37333,1.81334 -2.66667,0 -4.58667,-1.81334 -1.81333,-1.92 -1.81333,-4.69333 v -27.52 l -22.18667,-29.44 q -1.92,-2.56 -1.92,-5.01333 0,-2.77333 2.13333,-4.58667 2.24,-1.92 4.69334,-1.92 2.98666,0 5.22666,2.98667 l 18.77334,25.92 17.59999,-25.70667 q 2.24,-3.2 5.33334,-3.2 2.56,0 4.48,1.92 1.92,1.92 1.92,4.69334 z"
|
||||
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:106.66666412px;font-family:Quicksand;-inkscape-font-specification:'Quicksand Bold'"
|
||||
id="path899" />
|
||||
</g>
|
||||
</g>
|
||||
<g inkscape:groupmode="layer" id="layer2" inkscape:label="Layer 3"
|
||||
transform="translate(-27.815468,21.198496)" />
|
||||
<g inkscape:label="Layer 1" inkscape:groupmode="layer" id="layer1"
|
||||
transform="translate(-220.97188,-392.31605)">
|
||||
<path inkscape:connector-curvature="0" style="fill:#000016;fill-opacity:1"
|
||||
d="m 467.91504,565.96351 -11.49602,-6.03301 10.02002,-5.23601 c 3.41201,-1.785 3.41501,-4.70701 0.01,-6.49601 l -10.76202,-5.64801 10.75602,-5.62101 c 3.41201,-1.78501 3.41501,-4.70701 0.01,-6.49602 L 380.3709,485.25335 c -3.40901,-1.789 -8.99702,-1.809 -12.41802,-0.041 L 223.53563,559.8065 c -3.422,1.766 -3.418,4.65001 0.01,6.41001 l 11.01802,5.66202 -11.02302,5.69301 c -3.422,1.766 -3.418,4.65001 0.01,6.41001 l 11.75602,6.04101 -10.28901,5.31401 c -3.42201,1.76801 -3.41801,4.65201 0.01,6.41201 l 88.01014,45.22309 c 3.42601,1.76001 9.02002,1.74001 12.43002,-0.043 l 142.46424,-74.46914 c 3.40901,-1.78301 3.41201,-4.70701 0,-6.49602 z m -93.03415,-58.62311 -21.59604,24.90005 c -1.08,1.246 -0.764,2.883 0.703,3.637 l 17.18203,8.82802 c 1.467,0.754 1.469,1.99201 0,2.74801 l -53.92109,27.85005 c -1.467,0.758 -1.781,0.357 -0.699,-0.889 l 21.59404,-24.90005 c 1.082,-1.246 0.766,-2.885 -0.70101,-3.63901 l -17.18202,-8.82801 c -1.46701,-0.75401 -1.46901,-1.99001 0,-2.74801 l 53.92209,-27.85005 c 1.465,-0.75601 1.78,-0.35501 0.699,0.891 z"
|
||||
id="path11" />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 22 KiB |
BIN
_img/screenshots/phone/11_android_auto_detail.png
Normal file
|
After Width: | Height: | Size: 80 KiB |
BIN
_img/screenshots/phone/11_android_auto_map.png
Normal file
|
After Width: | Height: | Size: 158 KiB |
115
app/build.gradle
@@ -1,20 +1,20 @@
|
||||
apply plugin: 'com.android.application'
|
||||
apply plugin: 'kotlin-android'
|
||||
apply plugin: 'kotlin-android-extensions'
|
||||
apply plugin: 'kotlin-parcelize'
|
||||
apply plugin: 'kotlin-kapt'
|
||||
apply plugin: 'androidx.navigation.safeargs.kotlin'
|
||||
apply plugin: 'com.mikepenz.aboutlibraries.plugin'
|
||||
|
||||
android {
|
||||
compileSdkVersion 29
|
||||
buildToolsVersion "29.0.3"
|
||||
compileSdkVersion 30
|
||||
buildToolsVersion "30.0.3"
|
||||
|
||||
defaultConfig {
|
||||
applicationId "net.vonforst.evmap"
|
||||
minSdkVersion 21
|
||||
targetSdkVersion 29
|
||||
versionCode 25
|
||||
versionName "0.3.4"
|
||||
targetSdkVersion 30
|
||||
versionCode 50
|
||||
versionName "0.8.2"
|
||||
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
@@ -48,7 +48,6 @@ android {
|
||||
productFlavors {
|
||||
foss {
|
||||
dimension "dependencies"
|
||||
versionNameSuffix "-foss"
|
||||
}
|
||||
google {
|
||||
dimension "dependencies"
|
||||
@@ -68,6 +67,7 @@ android {
|
||||
|
||||
buildFeatures {
|
||||
dataBinding = true
|
||||
viewBinding true
|
||||
}
|
||||
|
||||
// add API keys from environment variable if not set in apikeys.xml
|
||||
@@ -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
|
||||
@@ -85,38 +92,54 @@ android {
|
||||
if (mapboxKey != null) {
|
||||
variant.resValue "string", "mapbox_key", mapboxKey
|
||||
}
|
||||
def chargepriceKey = env.CHARGEPRICE_API_KEY ?: project.findProperty("CHARGEPRICE_API_KEY")
|
||||
if (chargepriceKey == null && project.hasProperty("CHARGEPRICE_API_KEY_ENCRYPTED")) {
|
||||
chargepriceKey = decode(project.findProperty("CHARGEPRICE_API_KEY_ENCRYPTED"), "FmK.d,-f*p+rD+WK!eds")
|
||||
}
|
||||
if (chargepriceKey != null) {
|
||||
variant.resValue "string", "chargeprice_key", chargepriceKey
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation fileTree(dir: 'libs', include: ['*.jar'])
|
||||
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
|
||||
implementation 'androidx.appcompat:appcompat:1.2.0'
|
||||
implementation 'androidx.core:core-ktx:1.3.1'
|
||||
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.core:core:1.3.1'
|
||||
implementation 'androidx.appcompat:appcompat:1.2.0'
|
||||
implementation 'androidx.preference:preference-ktx:1.1.1'
|
||||
implementation 'com.google.android.material:material:1.2.0'
|
||||
implementation 'androidx.constraintlayout:constraintlayout:1.1.3'
|
||||
implementation 'androidx.recyclerview:recyclerview:1.1.0'
|
||||
implementation 'androidx.browser:browser:1.2.0'
|
||||
implementation 'com.google.android.material:material:1.3.0'
|
||||
implementation 'androidx.constraintlayout:constraintlayout:2.0.4'
|
||||
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.7.2'
|
||||
implementation 'com.squareup.retrofit2:converter-moshi:2.7.2'
|
||||
implementation 'com.squareup.moshi:moshi-kotlin:1.9.2'
|
||||
implementation 'com.squareup.picasso:picasso:2.71828'
|
||||
implementation 'com.github.MikeOrtiz:TouchImageView:2.3.3'
|
||||
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.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.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'
|
||||
|
||||
// AnyMaps
|
||||
def anyMapsVersion = '894f6d62ac'
|
||||
def anyMapsVersion = '1f050d860f'
|
||||
implementation "com.github.johan12345.AnyMaps:anymaps-base:$anyMapsVersion"
|
||||
googleImplementation "com.github.johan12345.AnyMaps:anymaps-google:$anyMapsVersion"
|
||||
implementation "com.github.johan12345.AnyMaps:anymaps-mapbox:$anyMapsVersion"
|
||||
@@ -124,41 +147,41 @@ 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.google.android.gms:play-services-base:17.3.0'
|
||||
googleImplementation 'com.google.android.gms:play-services-basement:17.3.0'
|
||||
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'
|
||||
googleImplementation 'com.google.android.gms:play-services-location:17.0.0'
|
||||
googleImplementation 'com.google.android.gms:play-services-tasks:17.1.0'
|
||||
googleImplementation 'com.google.android.gms:play-services-location:17.1.0'
|
||||
googleImplementation 'com.google.android.gms:play-services-tasks:17.2.0'
|
||||
googleImplementation 'com.google.auto.value:auto-value-annotations:1.6.3'
|
||||
googleImplementation 'com.google.code.gson:gson:2.8.6'
|
||||
googleImplementation 'com.google.android.datatransport:transport-runtime:2.2.3'
|
||||
googleImplementation 'com.google.android.datatransport:transport-runtime:2.2.5'
|
||||
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.0"
|
||||
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.5"
|
||||
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.0"
|
||||
def billing_version = "4.0.0"
|
||||
googleImplementation "com.android.billingclient:billing:$billing_version"
|
||||
googleImplementation "com.android.billingclient:billing-ktx:$billing_version"
|
||||
|
||||
@@ -166,13 +189,25 @@ dependencies {
|
||||
implementation 'com.facebook.stetho:stetho:1.5.1'
|
||||
implementation 'com.facebook.stetho:stetho-okhttp3:1.5.1'
|
||||
testImplementation 'junit:junit:4.13'
|
||||
testImplementation "com.squareup.okhttp3:mockwebserver:3.14.7"
|
||||
testImplementation "com.squareup.okhttp3:mockwebserver:4.9.0"
|
||||
//noinspection GradleDependency
|
||||
testImplementation 'org.json:json:20080701'
|
||||
androidTestImplementation 'androidx.test.ext:junit:1.1.1'
|
||||
androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0'
|
||||
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.0.9'
|
||||
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.1.5'
|
||||
}
|
||||
|
||||
private static String decode(String s, String key) {
|
||||
return new String(xorWithKey(s.decodeBase64(), key.getBytes()), "UTF-8");
|
||||
}
|
||||
|
||||
private static byte[] xorWithKey(byte[] a, byte[] key) {
|
||||
byte[] out = new byte[a.length];
|
||||
for (int i = 0; i < a.length; i++) {
|
||||
out[i] = (byte) (a[i] ^ key[i%key.length]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">EV Map (debug)</string>
|
||||
<string name="app_name">EVMap (debug)</string>
|
||||
</resources>
|
||||
@@ -1,4 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">EV Map (debug)</string>
|
||||
<string name="app_name">EVMap (debug)</string>
|
||||
</resources>
|
||||
@@ -8,29 +8,32 @@ import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.navigation.ui.setupWithNavController
|
||||
import kotlinx.android.synthetic.foss.fragment_donate.*
|
||||
import net.vonforst.evmap.MapsActivity
|
||||
import net.vonforst.evmap.R
|
||||
import net.vonforst.evmap.databinding.FragmentDonateBinding
|
||||
|
||||
class DonateFragment : Fragment() {
|
||||
private lateinit var binding: FragmentDonateBinding
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View? {
|
||||
return inflater.inflate(R.layout.fragment_donate, container, false)
|
||||
binding = FragmentDonateBinding.inflate(inflater, container, false)
|
||||
return binding.root
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
(requireActivity() as AppCompatActivity).setSupportActionBar(toolbar)
|
||||
(requireActivity() as AppCompatActivity).setSupportActionBar(binding.toolbar)
|
||||
|
||||
val navController = findNavController()
|
||||
toolbar.setupWithNavController(
|
||||
binding.toolbar.setupWithNavController(
|
||||
navController,
|
||||
(requireActivity() as MapsActivity).appBarConfiguration
|
||||
)
|
||||
|
||||
btnDonate.setOnClickListener {
|
||||
binding.btnDonate.setOnClickListener {
|
||||
(activity as? MapsActivity)?.openUrl(getString(R.string.paypal_link))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,43 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
package="net.vonforst.evmap">
|
||||
|
||||
<meta-data
|
||||
android:name="com.google.android.geo.API_KEY"
|
||||
android:value="@string/google_maps_key" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="androidx.car.app.MAP_TEMPLATES" />
|
||||
|
||||
<uses-sdk tools:overrideLibrary="androidx.car.app" />
|
||||
|
||||
<application>
|
||||
<meta-data
|
||||
android:name="com.google.android.geo.API_KEY"
|
||||
android:value="@string/google_maps_key" />
|
||||
|
||||
<meta-data
|
||||
android:name="com.google.android.gms.car.application"
|
||||
android:resource="@xml/automotive_app_desc" />
|
||||
|
||||
<meta-data
|
||||
android:name="androidx.car.app.theme"
|
||||
android:resource="@style/CarAppTheme" />
|
||||
|
||||
<service
|
||||
android:name=".auto.CarAppService"
|
||||
android:label="@string/app_name"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action
|
||||
android:name="androidx.car.app.CarAppService"
|
||||
android:category="androidx.car.app.category.CHARGING" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
<service
|
||||
android:name=".auto.CarLocationService"
|
||||
android:foregroundServiceType="location"
|
||||
android:enabled="true" />
|
||||
|
||||
<activity android:name=".auto.PermissionActivity" />
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -17,7 +17,7 @@ fun checkPlayServices(activity: Activity): Boolean {
|
||||
val resultCode = apiAvailability.isGooglePlayServicesAvailable(activity)
|
||||
if (resultCode != ConnectionResult.SUCCESS) {
|
||||
if (apiAvailability.isUserResolvableError(resultCode)) {
|
||||
apiAvailability.getErrorDialog(activity, resultCode, request).show()
|
||||
apiAvailability.getErrorDialog(activity, resultCode, request)?.show()
|
||||
} else {
|
||||
Log.d("EVMap", "This device is not supported.")
|
||||
}
|
||||
|
||||
723
app/src/google/java/net/vonforst/evmap/auto/CarAppService.kt
Normal file
@@ -0,0 +1,723 @@
|
||||
package net.vonforst.evmap.auto
|
||||
|
||||
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.*
|
||||
import androidx.localbroadcastmanager.content.LocalBroadcastManager
|
||||
import coil.imageLoader
|
||||
import coil.request.ImageRequest
|
||||
import com.car2go.maps.model.LatLng
|
||||
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.createApi
|
||||
import net.vonforst.evmap.api.goingelectric.GoingElectricApiWrapper
|
||||
import net.vonforst.evmap.api.nameForPlugType
|
||||
import net.vonforst.evmap.api.openchargemap.OpenChargeMapApiWrapper
|
||||
import net.vonforst.evmap.api.stringProvider
|
||||
import net.vonforst.evmap.model.ChargeLocation
|
||||
import net.vonforst.evmap.model.ReferenceData
|
||||
import net.vonforst.evmap.storage.AppDatabase
|
||||
import net.vonforst.evmap.storage.GEReferenceDataRepository
|
||||
import net.vonforst.evmap.storage.OCMReferenceDataRepository
|
||||
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.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)
|
||||
}
|
||||
|
||||
class CarAppService : androidx.car.app.CarAppService() {
|
||||
override fun createHostValidator(): HostValidator {
|
||||
return if (applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE != 0) {
|
||||
HostValidator.ALLOW_ALL_HOSTS_VALIDATOR
|
||||
} else {
|
||||
HostValidator.Builder(applicationContext)
|
||||
.addAllowedHosts(androidx.car.app.R.array.hosts_allowlist_sample)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateSession(): Session {
|
||||
return EVMapSession(this)
|
||||
}
|
||||
}
|
||||
|
||||
class EVMapSession(val cas: CarAppService) : Session(), LifecycleObserver {
|
||||
var mapScreen: LocationAwareScreen? = null
|
||||
set(value) {
|
||||
field = value
|
||||
location?.let { value?.updateLocation(it) }
|
||||
}
|
||||
private var location: Location? = null
|
||||
private var locationService: CarLocationService? = null
|
||||
|
||||
private val serviceConnection = object : ServiceConnection {
|
||||
override fun onServiceConnected(name: ComponentName, ibinder: IBinder) {
|
||||
val binder: CarLocationService.LocalBinder = ibinder as CarLocationService.LocalBinder
|
||||
locationService = binder.service
|
||||
locationService?.requestLocationUpdates()
|
||||
}
|
||||
|
||||
override fun onServiceDisconnected(name: ComponentName?) {
|
||||
locationService = null
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
lifecycle.addObserver(this)
|
||||
}
|
||||
|
||||
override fun onCreateScreen(intent: Intent): Screen {
|
||||
return if (locationPermissionGranted()) {
|
||||
WelcomeScreen(carContext, this)
|
||||
} else {
|
||||
PermissionScreen(carContext, this)
|
||||
}
|
||||
}
|
||||
|
||||
private fun locationPermissionGranted() =
|
||||
ContextCompat.checkSelfPermission(
|
||||
carContext,
|
||||
Manifest.permission.ACCESS_FINE_LOCATION
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
|
||||
private val locationReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
val location = intent.getParcelableExtra(CarLocationService.EXTRA_LOCATION) as Location?
|
||||
val mapScreen = this@EVMapSession.mapScreen
|
||||
if (location != null && mapScreen != null) {
|
||||
mapScreen.updateLocation(location)
|
||||
}
|
||||
this@EVMapSession.location = location
|
||||
}
|
||||
}
|
||||
|
||||
@OnLifecycleEvent(Lifecycle.Event.ON_START)
|
||||
fun bindLocationService() {
|
||||
if (!locationPermissionGranted()) return
|
||||
cas.bindService(
|
||||
Intent(cas, CarLocationService::class.java),
|
||||
serviceConnection,
|
||||
Context.BIND_AUTO_CREATE
|
||||
)
|
||||
}
|
||||
|
||||
@OnLifecycleEvent(Lifecycle.Event.ON_STOP)
|
||||
private fun unbindLocationService() {
|
||||
locationService?.let { service ->
|
||||
service.removeLocationUpdates()
|
||||
cas.unbindService(serviceConnection)
|
||||
}
|
||||
}
|
||||
|
||||
@OnLifecycleEvent(Lifecycle.Event.ON_RESUME)
|
||||
private fun registerBroadcastReceiver() {
|
||||
LocalBroadcastManager.getInstance(cas).registerReceiver(
|
||||
locationReceiver,
|
||||
IntentFilter(CarLocationService.ACTION_BROADCAST)
|
||||
);
|
||||
}
|
||||
|
||||
@OnLifecycleEvent(Lifecycle.Event.ON_PAUSE)
|
||||
private fun unregisterBroadcastReceiver() {
|
||||
LocalBroadcastManager.getInstance(cas).unregisterReceiver(locationReceiver)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 var prefs = PreferenceDataSource(ctx)
|
||||
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
|
||||
|
||||
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(
|
||||
getReferenceData(),
|
||||
LatLng.fromLocation(location),
|
||||
searchRadius,
|
||||
zoom = 16f,
|
||||
null
|
||||
)
|
||||
chargers = response.data?.filterIsInstance(ChargeLocation::class.java)
|
||||
chargers?.let {
|
||||
if (it.size < 6) {
|
||||
// try again with larger radius
|
||||
val response = api.getChargepointsRadius(
|
||||
getReferenceData(),
|
||||
LatLng.fromLocation(location),
|
||||
searchRadius * 5,
|
||||
zoom = 16f,
|
||||
emptyList()
|
||||
)
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun getReferenceData(): ReferenceData {
|
||||
val api = api
|
||||
return when (api) {
|
||||
is GoingElectricApiWrapper -> {
|
||||
GEReferenceDataRepository(
|
||||
api,
|
||||
lifecycleScope,
|
||||
db.geReferenceDataDao(),
|
||||
prefs
|
||||
).getReferenceData().await()
|
||||
}
|
||||
is OpenChargeMapApiWrapper -> {
|
||||
OCMReferenceDataRepository(
|
||||
api,
|
||||
lifecycleScope,
|
||||
db.ocmReferenceDataDao(),
|
||||
prefs
|
||||
).getReferenceData().await()
|
||||
}
|
||||
else -> {
|
||||
throw RuntimeException("no reference data implemented")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 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.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() {
|
||||
lifecycleScope.launch {
|
||||
try {
|
||||
val response = api.getChargepointDetail(getReferenceData(), chargerSparse.id)
|
||||
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()
|
||||
} catch (e: IOException) {
|
||||
withContext(Dispatchers.Main) {
|
||||
CarToast.makeText(carContext, R.string.connection_error, CarToast.LENGTH_LONG)
|
||||
.show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun getReferenceData(): ReferenceData {
|
||||
val api = api
|
||||
return when (api) {
|
||||
is GoingElectricApiWrapper -> {
|
||||
GEReferenceDataRepository(
|
||||
api,
|
||||
lifecycleScope,
|
||||
db.geReferenceDataDao(),
|
||||
prefs
|
||||
).getReferenceData().await()
|
||||
}
|
||||
is OpenChargeMapApiWrapper -> {
|
||||
OCMReferenceDataRepository(
|
||||
api,
|
||||
lifecycleScope,
|
||||
db.ocmReferenceDataDao(),
|
||||
prefs
|
||||
).getReferenceData().await()
|
||||
}
|
||||
else -> {
|
||||
throw RuntimeException("no reference data implemented")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
package net.vonforst.evmap.auto
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.Service
|
||||
import android.content.Intent
|
||||
import android.location.Location
|
||||
import android.os.*
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.localbroadcastmanager.content.LocalBroadcastManager
|
||||
import com.google.android.gms.location.*
|
||||
import net.vonforst.evmap.BuildConfig
|
||||
import net.vonforst.evmap.R
|
||||
|
||||
|
||||
class CarLocationService : Service() {
|
||||
private lateinit var serviceHandler: Handler
|
||||
private lateinit var locationRequest: LocationRequest
|
||||
private lateinit var notificationManager: NotificationManager
|
||||
private lateinit var locationCallback: LocationCallback
|
||||
private lateinit var fusedLocationClient: FusedLocationProviderClient
|
||||
private val binder: IBinder = LocalBinder(this)
|
||||
private var location: Location? = null
|
||||
|
||||
private val UPDATE_INTERVAL_IN_MILLISECONDS = 10000L
|
||||
private val FASTEST_UPDATE_INTERVAL_IN_MILLISECONDS = UPDATE_INTERVAL_IN_MILLISECONDS / 2
|
||||
|
||||
private val CHANNEL_ID = "car_location"
|
||||
private val NOTIFICATION_ID = 1000
|
||||
private val TAG = "CarLocationService"
|
||||
|
||||
companion object {
|
||||
const val ACTION_BROADCAST: String = BuildConfig.APPLICATION_ID + ".car_location_broadcast"
|
||||
const val EXTRA_LOCATION: String = BuildConfig.APPLICATION_ID + ".location"
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
fusedLocationClient = LocationServices.getFusedLocationProviderClient(this)
|
||||
locationCallback = object : LocationCallback() {
|
||||
override fun onLocationResult(locationResult: LocationResult) {
|
||||
super.onLocationResult(locationResult)
|
||||
onNewLocation(locationResult.lastLocation)
|
||||
}
|
||||
}
|
||||
createLocationRequest()
|
||||
getLastLocation()
|
||||
val handlerThread = HandlerThread(TAG)
|
||||
handlerThread.start()
|
||||
serviceHandler = Handler(handlerThread.looper)
|
||||
notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
|
||||
|
||||
// Android O requires a Notification Channel.
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val name: CharSequence = getString(R.string.app_name)
|
||||
// Create the channel for the notification
|
||||
val mChannel =
|
||||
NotificationChannel(CHANNEL_ID, name, NotificationManager.IMPORTANCE_DEFAULT)
|
||||
|
||||
// Set the Notification Channel for the Notification Manager.
|
||||
notificationManager.createNotificationChannel(mChannel)
|
||||
}
|
||||
|
||||
startForeground(NOTIFICATION_ID, getNotification())
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the [NotificationCompat] used as part of the foreground service.
|
||||
*/
|
||||
private fun getNotification(): Notification {
|
||||
val builder: NotificationCompat.Builder = NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setContentText(getString(R.string.auto_location_service))
|
||||
.setContentTitle(getString(R.string.app_name))
|
||||
.setOngoing(true)
|
||||
.setPriority(NotificationManagerCompat.IMPORTANCE_HIGH)
|
||||
.setSmallIcon(R.mipmap.ic_launcher)
|
||||
.setTicker(getString(R.string.auto_location_service))
|
||||
.setWhen(System.currentTimeMillis())
|
||||
|
||||
return builder.build()
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder {
|
||||
return binder
|
||||
}
|
||||
|
||||
private fun createLocationRequest() {
|
||||
locationRequest = LocationRequest()
|
||||
locationRequest.interval = UPDATE_INTERVAL_IN_MILLISECONDS
|
||||
locationRequest.fastestInterval = FASTEST_UPDATE_INTERVAL_IN_MILLISECONDS
|
||||
locationRequest.priority = LocationRequest.PRIORITY_HIGH_ACCURACY
|
||||
}
|
||||
|
||||
private fun onNewLocation(location: Location) {
|
||||
Log.i(TAG, "New location: $location")
|
||||
this.location = location
|
||||
|
||||
// Notify anyone listening for broadcasts about the new location.
|
||||
val intent = Intent(ACTION_BROADCAST)
|
||||
intent.putExtra(EXTRA_LOCATION, location)
|
||||
LocalBroadcastManager.getInstance(applicationContext).sendBroadcast(intent)
|
||||
}
|
||||
|
||||
private fun getLastLocation() {
|
||||
try {
|
||||
fusedLocationClient.lastLocation
|
||||
.addOnCompleteListener { task ->
|
||||
if (task.isSuccessful && task.result != null) {
|
||||
location = task.result
|
||||
} else {
|
||||
Log.w(TAG, "Failed to get location.")
|
||||
}
|
||||
}
|
||||
} catch (unlikely: SecurityException) {
|
||||
Log.e(TAG, "Lost location permission.$unlikely")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes a request for location updates. Note that in this sample we merely log the
|
||||
* [SecurityException].
|
||||
*/
|
||||
fun requestLocationUpdates() {
|
||||
Log.i(TAG, "Requesting location updates")
|
||||
startService(Intent(applicationContext, CarLocationService::class.java))
|
||||
try {
|
||||
fusedLocationClient.requestLocationUpdates(
|
||||
locationRequest,
|
||||
locationCallback, Looper.myLooper()
|
||||
)
|
||||
} catch (unlikely: SecurityException) {
|
||||
Log.e(TAG, "Lost location permission. Could not request updates. $unlikely")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes location updates. Note that in this sample we merely log the
|
||||
* [SecurityException].
|
||||
*/
|
||||
fun removeLocationUpdates() {
|
||||
Log.i(TAG, "Removing location updates")
|
||||
try {
|
||||
fusedLocationClient.removeLocationUpdates(locationCallback)
|
||||
stopSelf()
|
||||
} catch (unlikely: SecurityException) {
|
||||
Log.e(TAG, "Lost location permission. Could not remove updates. $unlikely")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int {
|
||||
Log.i(TAG, "Service started")
|
||||
// Tells the system to not try to recreate the service after it has been killed.
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
serviceHandler.removeCallbacksAndMessages(null)
|
||||
}
|
||||
|
||||
class LocalBinder(val service: CarLocationService) : Binder()
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package net.vonforst.evmap.auto
|
||||
|
||||
import android.Manifest
|
||||
import android.app.Activity
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Bundle
|
||||
import android.os.ResultReceiver
|
||||
import androidx.core.app.ActivityCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
|
||||
|
||||
class PermissionActivity : Activity() {
|
||||
companion object {
|
||||
const val EXTRA_RESULT_RECEIVER = "result_receiver";
|
||||
const val RESULT_GRANTED = "granted"
|
||||
}
|
||||
|
||||
private lateinit var resultReceiver: ResultReceiver
|
||||
private val permissions = arrayOf(Manifest.permission.ACCESS_FINE_LOCATION)
|
||||
private val requestCode = 1
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
if (intent != null) {
|
||||
resultReceiver = intent.getParcelableExtra(EXTRA_RESULT_RECEIVER)!!
|
||||
if (!hasPermissions(permissions)) {
|
||||
ActivityCompat.requestPermissions(this, permissions, requestCode)
|
||||
} else {
|
||||
onComplete(
|
||||
requestCode,
|
||||
permissions,
|
||||
intArrayOf(PackageManager.PERMISSION_GRANTED)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
finish()
|
||||
}
|
||||
}
|
||||
|
||||
private fun onComplete(requestCode: Int, permissions: Array<String>?, grantResults: IntArray) {
|
||||
val bundle = Bundle()
|
||||
bundle.putBoolean(
|
||||
RESULT_GRANTED,
|
||||
grantResults.all { it == PackageManager.PERMISSION_GRANTED })
|
||||
resultReceiver.send(requestCode, bundle)
|
||||
finish()
|
||||
}
|
||||
|
||||
private fun hasPermissions(permissions: Array<String>): Boolean {
|
||||
var result = true
|
||||
for (permission in permissions) {
|
||||
if (ContextCompat.checkSelfPermission(
|
||||
this,
|
||||
permission
|
||||
) != PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
result = false
|
||||
break
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
override fun onRequestPermissionsResult(
|
||||
requestCode: Int,
|
||||
permissions: Array<String>,
|
||||
grantResults: IntArray
|
||||
) {
|
||||
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
|
||||
onComplete(requestCode, permissions, grantResults)
|
||||
}
|
||||
}
|
||||
@@ -5,4 +5,15 @@
|
||||
<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="auto_location_service">EVMap läuft unter Android Auto und nutzt dafür deinen Standort.</string>
|
||||
<string name="auto_no_chargers_found">Keine Ladestationen in der Nähe gefunden</string>
|
||||
<string name="auto_no_favorites_found">Keine Favoriten gefunden</string>
|
||||
<string name="open_in_app">In App öffnen</string>
|
||||
<string name="opened_on_phone">Auf dem Telefon geöffnet</string>
|
||||
<string name="auto_location_permission_needed">Um EVMap auf Android Auto zu nutzen, braucht die App Zugriff auf deinen Standort.</string>
|
||||
<string name="grant_on_phone">Auf Telefon zulassen</string>
|
||||
<string name="auto_chargers_closeby">In der Nähe</string>
|
||||
<string name="auto_favorites">Favoriten</string>
|
||||
<string name="auto_fault_report_date">⚠️ Störungsmeldung (%s)</string>
|
||||
<string name="auto_no_refresh_possible">Weitere Aktualisierung nicht möglich. Bitte zurück gehen und neu starten.</string>
|
||||
</resources>
|
||||
10
app/src/google/res/values/styles.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
|
||||
<style name="CarAppTheme">
|
||||
<item name="carColorPrimary">@color/colorPrimary</item>
|
||||
<item name="carColorPrimaryDark">@color/colorPrimaryVariant</item>
|
||||
<item name="carColorSecondary">@color/colorSecondary</item>
|
||||
<item name="carColorSecondaryDark">@color/colorSecondaryVariant</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -10,4 +10,15 @@
|
||||
</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="auto_location_service">EVMap is running on Android Auto and using your location.</string>
|
||||
<string name="auto_no_chargers_found">No nearby chargers found</string>
|
||||
<string name="auto_no_favorites_found">No favorites found</string>
|
||||
<string name="open_in_app">Open in app</string>
|
||||
<string name="opened_on_phone">Opened on phone</string>
|
||||
<string name="auto_location_permission_needed">To run EVMap on Android Auto, you need to grant access to your location.</string>
|
||||
<string name="grant_on_phone">Grant on phone</string>
|
||||
<string name="auto_chargers_closeby">Nearby chargers</string>
|
||||
<string name="auto_favorites">Favorites</string>
|
||||
<string name="auto_fault_report_date">⚠️ Fault report (%s)</string>
|
||||
<string name="auto_no_refresh_possible">Further updates not possible. Please go back and restart.</string>
|
||||
</resources>
|
||||
5
app/src/google/res/xml/automotive_app_desc.xml
Normal file
@@ -0,0 +1,5 @@
|
||||
<automotiveApp xmlns:tools="http://schemas.android.com/tools">
|
||||
<uses
|
||||
name="template"
|
||||
tools:ignore="InvalidUsesTagAttribute" />
|
||||
</automotiveApp>
|
||||
@@ -5,6 +5,17 @@
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||
|
||||
<queries>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<data android:scheme="geo" />
|
||||
</intent>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<data android:scheme="google.navigation" />
|
||||
</intent>
|
||||
</queries>
|
||||
|
||||
<application
|
||||
android:name=".EvMapApplication"
|
||||
android:allowBackup="true"
|
||||
@@ -27,7 +38,224 @@
|
||||
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<data android:scheme="geo" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
|
||||
<data
|
||||
android:host="www.goingelectric.de"
|
||||
android:pathPattern="/stromtankstellen/Deutschland/..*/..*/..*/"
|
||||
android:scheme="https" />
|
||||
<data
|
||||
android:host="www.goingelectric.de"
|
||||
android:pathPattern="/stromtankstellen/Oesterreich/..*/..*/..*/"
|
||||
android:scheme="https" />
|
||||
<data
|
||||
android:host="www.goingelectric.de"
|
||||
android:pathPattern="/stromtankstellen/Schweiz/..*/..*/..*/"
|
||||
android:scheme="https" />
|
||||
<data
|
||||
android:host="www.goingelectric.de"
|
||||
android:pathPattern="/stromtankstellen/Albanien/..*/..*/..*/"
|
||||
android:scheme="https" />
|
||||
<data
|
||||
android:host="www.goingelectric.de"
|
||||
android:pathPattern="/stromtankstellen/Andorra/..*/..*/..*/"
|
||||
android:scheme="https" />
|
||||
<data
|
||||
android:host="www.goingelectric.de"
|
||||
android:pathPattern="/stromtankstellen/Aruba/..*/..*/..*/"
|
||||
android:scheme="https" />
|
||||
<data
|
||||
android:host="www.goingelectric.de"
|
||||
android:pathPattern="/stromtankstellen/Belarus/..*/..*/..*/"
|
||||
android:scheme="https" />
|
||||
<data
|
||||
android:host="www.goingelectric.de"
|
||||
android:pathPattern="/stromtankstellen/Belgien/..*/..*/..*/"
|
||||
android:scheme="https" />
|
||||
<data
|
||||
android:host="www.goingelectric.de"
|
||||
android:pathPattern="/stromtankstellen/Bosnien-und-Herzegowina/..*/..*/..*/"
|
||||
android:scheme="https" />
|
||||
<data
|
||||
android:host="www.goingelectric.de"
|
||||
android:pathPattern="/stromtankstellen/Bulgarien/..*/..*/..*/"
|
||||
android:scheme="https" />
|
||||
<data
|
||||
android:host="www.goingelectric.de"
|
||||
android:pathPattern="/stromtankstellen/Daenemark/..*/..*/..*/"
|
||||
android:scheme="https" />
|
||||
<data
|
||||
android:host="www.goingelectric.de"
|
||||
android:pathPattern="/stromtankstellen/Estland/..*/..*/..*/"
|
||||
android:scheme="https" />
|
||||
<data
|
||||
android:host="www.goingelectric.de"
|
||||
android:pathPattern="/stromtankstellen/Faeroeer/..*/..*/..*/"
|
||||
android:scheme="https" />
|
||||
<data
|
||||
android:host="www.goingelectric.de"
|
||||
android:pathPattern="/stromtankstellen/Finnland/..*/..*/..*/"
|
||||
android:scheme="https" />
|
||||
<data
|
||||
android:host="www.goingelectric.de"
|
||||
android:pathPattern="/stromtankstellen/Frankreich/..*/..*/..*/"
|
||||
android:scheme="https" />
|
||||
<data
|
||||
android:host="www.goingelectric.de"
|
||||
android:pathPattern="/stromtankstellen/Gibraltar/..*/..*/..*/"
|
||||
android:scheme="https" />
|
||||
<data
|
||||
android:host="www.goingelectric.de"
|
||||
android:pathPattern="/stromtankstellen/Griechenland/..*/..*/..*/"
|
||||
android:scheme="https" />
|
||||
<data
|
||||
android:host="www.goingelectric.de"
|
||||
android:pathPattern="/stromtankstellen/Grossbritannien/..*/..*/..*/"
|
||||
android:scheme="https" />
|
||||
<data
|
||||
android:host="www.goingelectric.de"
|
||||
android:pathPattern="/stromtankstellen/Irland/..*/..*/..*/"
|
||||
android:scheme="https" />
|
||||
<data
|
||||
android:host="www.goingelectric.de"
|
||||
android:pathPattern="/stromtankstellen/Island/..*/..*/..*/"
|
||||
android:scheme="https" />
|
||||
<data
|
||||
android:host="www.goingelectric.de"
|
||||
android:pathPattern="/stromtankstellen/Italien/..*/..*/..*/"
|
||||
android:scheme="https" />
|
||||
<data
|
||||
android:host="www.goingelectric.de"
|
||||
android:pathPattern="/stromtankstellen/Jordanien/..*/..*/..*/"
|
||||
android:scheme="https" />
|
||||
<data
|
||||
android:host="www.goingelectric.de"
|
||||
android:pathPattern="/stromtankstellen/Kasachstan/..*/..*/..*/"
|
||||
android:scheme="https" />
|
||||
<data
|
||||
android:host="www.goingelectric.de"
|
||||
android:pathPattern="/stromtankstellen/Kroatien/..*/..*/..*/"
|
||||
android:scheme="https" />
|
||||
<data
|
||||
android:host="www.goingelectric.de"
|
||||
android:pathPattern="/stromtankstellen/Lettland/..*/..*/..*/"
|
||||
android:scheme="https" />
|
||||
<data
|
||||
android:host="www.goingelectric.de"
|
||||
android:pathPattern="/stromtankstellen/Liechtenstein/..*/..*/..*/"
|
||||
android:scheme="https" />
|
||||
<data
|
||||
android:host="www.goingelectric.de"
|
||||
android:pathPattern="/stromtankstellen/Litauen/..*/..*/..*/"
|
||||
android:scheme="https" />
|
||||
<data
|
||||
android:host="www.goingelectric.de"
|
||||
android:pathPattern="/stromtankstellen/Luxemburg/..*/..*/..*/"
|
||||
android:scheme="https" />
|
||||
<data
|
||||
android:host="www.goingelectric.de"
|
||||
android:pathPattern="/stromtankstellen/Malta/..*/..*/..*/"
|
||||
android:scheme="https" />
|
||||
<data
|
||||
android:host="www.goingelectric.de"
|
||||
android:pathPattern="/stromtankstellen/Marokko/..*/..*/..*/"
|
||||
android:scheme="https" />
|
||||
<data
|
||||
android:host="www.goingelectric.de"
|
||||
android:pathPattern="/stromtankstellen/Mazedonien/..*/..*/..*/"
|
||||
android:scheme="https" />
|
||||
<data
|
||||
android:host="www.goingelectric.de"
|
||||
android:pathPattern="/stromtankstellen/Moldawien/..*/..*/..*/"
|
||||
android:scheme="https" />
|
||||
<data
|
||||
android:host="www.goingelectric.de"
|
||||
android:pathPattern="/stromtankstellen/Monaco/..*/..*/..*/"
|
||||
android:scheme="https" />
|
||||
<data
|
||||
android:host="www.goingelectric.de"
|
||||
android:pathPattern="/stromtankstellen/Montenegro/..*/..*/..*/"
|
||||
android:scheme="https" />
|
||||
<data
|
||||
android:host="www.goingelectric.de"
|
||||
android:pathPattern="/stromtankstellen/Niederlande/..*/..*/..*/"
|
||||
android:scheme="https" />
|
||||
<data
|
||||
android:host="www.goingelectric.de"
|
||||
android:pathPattern="/stromtankstellen/Norwegen/..*/..*/..*/"
|
||||
android:scheme="https" />
|
||||
<data
|
||||
android:host="www.goingelectric.de"
|
||||
android:pathPattern="/stromtankstellen/Polen/..*/..*/..*/"
|
||||
android:scheme="https" />
|
||||
<data
|
||||
android:host="www.goingelectric.de"
|
||||
android:pathPattern="/stromtankstellen/Portugal/..*/..*/..*/"
|
||||
android:scheme="https" />
|
||||
<data
|
||||
android:host="www.goingelectric.de"
|
||||
android:pathPattern="/stromtankstellen/Rumaenien/..*/..*/..*/"
|
||||
android:scheme="https" />
|
||||
<data
|
||||
android:host="www.goingelectric.de"
|
||||
android:pathPattern="/stromtankstellen/Russland/..*/..*/..*/"
|
||||
android:scheme="https" />
|
||||
<data
|
||||
android:host="www.goingelectric.de"
|
||||
android:pathPattern="/stromtankstellen/San-Marino/..*/..*/..*/"
|
||||
android:scheme="https" />
|
||||
<data
|
||||
android:host="www.goingelectric.de"
|
||||
android:pathPattern="/stromtankstellen/Schweden/..*/..*/..*/"
|
||||
android:scheme="https" />
|
||||
<data
|
||||
android:host="www.goingelectric.de"
|
||||
android:pathPattern="/stromtankstellen/Serbien/..*/..*/..*/"
|
||||
android:scheme="https" />
|
||||
<data
|
||||
android:host="www.goingelectric.de"
|
||||
android:pathPattern="/stromtankstellen/Slowakei/..*/..*/..*/"
|
||||
android:scheme="https" />
|
||||
<data
|
||||
android:host="www.goingelectric.de"
|
||||
android:pathPattern="/stromtankstellen/Slowenien/..*/..*/..*/"
|
||||
android:scheme="https" />
|
||||
<data
|
||||
android:host="www.goingelectric.de"
|
||||
android:pathPattern="/stromtankstellen/Spanien/..*/..*/..*/"
|
||||
android:scheme="https" />
|
||||
<data
|
||||
android:host="www.goingelectric.de"
|
||||
android:pathPattern="/stromtankstellen/Tuerkei/..*/..*/..*/"
|
||||
android:scheme="https" />
|
||||
<data
|
||||
android:host="www.goingelectric.de"
|
||||
android:pathPattern="/stromtankstellen/Tschechien/..*/..*/..*/"
|
||||
android:scheme="https" />
|
||||
<data
|
||||
android:host="www.goingelectric.de"
|
||||
android:pathPattern="/stromtankstellen/USA/..*/..*/..*/"
|
||||
android:scheme="https" />
|
||||
<data
|
||||
android:host="www.goingelectric.de"
|
||||
android:pathPattern="/stromtankstellen/Ukraine/..*/..*/..*/"
|
||||
android:scheme="https" />
|
||||
<data
|
||||
android:host="www.goingelectric.de"
|
||||
android:pathPattern="/stromtankstellen/Ungarn/..*/..*/..*/"
|
||||
android:scheme="https" />
|
||||
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -1,13 +1,17 @@
|
||||
package net.vonforst.evmap
|
||||
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.browser.customtabs.CustomTabColorSchemeParams
|
||||
import androidx.browser.customtabs.CustomTabsIntent
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.drawerlayout.widget.DrawerLayout
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.findNavController
|
||||
@@ -15,12 +19,17 @@ 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
|
||||
const val EXTRA_CHARGER_ID = "chargerId"
|
||||
const val EXTRA_LAT = "lat"
|
||||
const val EXTRA_LON = "lon"
|
||||
|
||||
class MapsActivity : AppCompatActivity() {
|
||||
interface FragmentCallback {
|
||||
@@ -49,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,
|
||||
@@ -58,11 +68,74 @@ class MapsActivity : AppCompatActivity() {
|
||||
),
|
||||
findViewById<DrawerLayout>(R.id.drawer_layout)
|
||||
)
|
||||
findViewById<NavigationView>(R.id.nav_view).setupWithNavController(navController)
|
||||
val navView = findViewById<NavigationView>(R.id.nav_view)
|
||||
navView.setupWithNavController(navController)
|
||||
|
||||
val header = navView.getHeaderView(0)
|
||||
ViewCompat.setOnApplyWindowInsetsListener(header) { v, insets ->
|
||||
v.setPadding(0, insets.getInsets(WindowInsetsCompat.Type.statusBars()).top, 0, 0)
|
||||
insets
|
||||
}
|
||||
|
||||
prefs = PreferenceDataSource(this)
|
||||
|
||||
checkPlayServices(this)
|
||||
|
||||
|
||||
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]
|
||||
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()
|
||||
if (id != null) {
|
||||
val deepLink = navController.createDeepLink()
|
||||
.setGraph(R.navigation.nav_graph)
|
||||
.setDestination(R.id.map)
|
||||
.setArguments(MapFragment.showChargerById(id))
|
||||
.createPendingIntent()
|
||||
deepLink.send()
|
||||
}
|
||||
} else if (intent.hasExtra(EXTRA_CHARGER_ID)) {
|
||||
navController.createDeepLink()
|
||||
.setDestination(R.id.map)
|
||||
.setArguments(
|
||||
MapFragment.showCharger(
|
||||
intent.getLongExtra(EXTRA_CHARGER_ID, 0),
|
||||
intent.getDoubleExtra(EXTRA_LAT, 0.0),
|
||||
intent.getDoubleExtra(EXTRA_LON, 0.0)
|
||||
)
|
||||
)
|
||||
.createPendingIntent()
|
||||
.send()
|
||||
}
|
||||
}
|
||||
|
||||
fun navigateTo(charger: ChargeLocation) {
|
||||
@@ -90,15 +163,28 @@ class MapsActivity : AppCompatActivity() {
|
||||
cb.getRootView(),
|
||||
R.string.no_maps_app_found,
|
||||
Snackbar.LENGTH_SHORT
|
||||
)
|
||||
).show()
|
||||
}
|
||||
}
|
||||
|
||||
fun openUrl(url: String) {
|
||||
val intent = CustomTabsIntent.Builder()
|
||||
.setToolbarColor(ContextCompat.getColor(this, R.color.colorPrimary))
|
||||
.setDefaultColorSchemeParams(
|
||||
CustomTabColorSchemeParams.Builder()
|
||||
.setToolbarColor(ContextCompat.getColor(this, R.color.colorPrimary))
|
||||
.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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,30 @@
|
||||
package net.vonforst.evmap.adapter
|
||||
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.databinding.DataBindingUtil
|
||||
import androidx.databinding.ViewDataBinding
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.ListAdapter
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.google.android.material.chip.Chip
|
||||
import net.vonforst.evmap.BR
|
||||
import net.vonforst.evmap.R
|
||||
import net.vonforst.evmap.api.availability.ChargepointStatus
|
||||
import net.vonforst.evmap.api.goingelectric.Chargepoint
|
||||
import net.vonforst.evmap.viewmodel.FavoritesViewModel
|
||||
import net.vonforst.evmap.api.chargeprice.ChargePrice
|
||||
import net.vonforst.evmap.api.chargeprice.ChargepriceCar
|
||||
import net.vonforst.evmap.api.chargeprice.ChargepriceChargepointMeta
|
||||
import net.vonforst.evmap.api.chargeprice.ChargepriceTag
|
||||
import net.vonforst.evmap.databinding.ItemChargepriceBinding
|
||||
import net.vonforst.evmap.databinding.ItemChargepriceVehicleChipBinding
|
||||
import net.vonforst.evmap.databinding.ItemConnectorButtonBinding
|
||||
import net.vonforst.evmap.model.Chargepoint
|
||||
import net.vonforst.evmap.ui.CheckableConstraintLayout
|
||||
|
||||
interface Equatable {
|
||||
override fun equals(other: Any?): Boolean;
|
||||
override fun equals(other: Any?): Boolean
|
||||
}
|
||||
|
||||
abstract class DataBindingAdapter<T : Equatable>(getKey: ((T) -> Any)? = null) :
|
||||
@@ -78,14 +88,139 @@ class ConnectorAdapter : DataBindingAdapter<ConnectorAdapter.ChargepointWithAvai
|
||||
override fun getItemViewType(position: Int): Int = R.layout.item_connector
|
||||
}
|
||||
|
||||
class ChargepriceAdapter() :
|
||||
DataBindingAdapter<ChargePrice>() {
|
||||
|
||||
class FavoritesAdapter(val vm: FavoritesViewModel) :
|
||||
DataBindingAdapter<FavoritesViewModel.FavoritesListItem>() {
|
||||
init {
|
||||
setHasStableIds(true)
|
||||
val viewPool = RecyclerView.RecycledViewPool();
|
||||
var meta: ChargepriceChargepointMeta? = null
|
||||
set(value) {
|
||||
field = value
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
var myTariffs: Set<String>? = null
|
||||
set(value) {
|
||||
field = value
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
var myTariffsAll: Boolean? = null
|
||||
set(value) {
|
||||
field = value
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
|
||||
override fun getItemViewType(position: Int): Int = R.layout.item_chargeprice
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder<ChargePrice> {
|
||||
val holder = super.onCreateViewHolder(parent, viewType)
|
||||
val binding = holder.binding as ItemChargepriceBinding
|
||||
binding.rvTags.apply {
|
||||
adapter = ChargepriceTagsAdapter()
|
||||
layoutManager =
|
||||
LinearLayoutManager(context, LinearLayoutManager.VERTICAL, false).apply {
|
||||
recycleChildrenOnDetach = true
|
||||
}
|
||||
itemAnimator = null
|
||||
setRecycledViewPool(viewPool)
|
||||
}
|
||||
return holder
|
||||
}
|
||||
|
||||
override fun getItemViewType(position: Int): Int = R.layout.item_favorite
|
||||
override fun bind(holder: ViewHolder<ChargePrice>, item: ChargePrice) {
|
||||
super.bind(holder, item)
|
||||
(holder.binding as ItemChargepriceBinding).apply {
|
||||
this.meta = this@ChargepriceAdapter.meta
|
||||
this.myTariffs = this@ChargepriceAdapter.myTariffs
|
||||
this.myTariffsAll = this@ChargepriceAdapter.myTariffsAll
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun getItemId(position: Int): Long = getItem(position).charger.id
|
||||
class CheckableConnectorAdapter : DataBindingAdapter<Chargepoint>() {
|
||||
private var checkedItem: Int? = 0
|
||||
|
||||
var enabledConnectors: List<String>? = null
|
||||
get() = field
|
||||
set(value) {
|
||||
field = value
|
||||
checkedItem?.let {
|
||||
if (value != null && getItem(it).type !in value) {
|
||||
val index = currentList.indexOfFirst {
|
||||
it.type in value
|
||||
}
|
||||
checkedItem = if (index == -1) null else index
|
||||
onCheckedItemChangedListener?.invoke(getCheckedItem())
|
||||
}
|
||||
}
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
|
||||
override fun getItemViewType(position: Int): Int = R.layout.item_connector_button
|
||||
|
||||
override fun onBindViewHolder(holder: ViewHolder<Chargepoint>, position: Int) {
|
||||
val item = getItem(position)
|
||||
super.bind(holder, item)
|
||||
val binding = holder.binding as ItemConnectorButtonBinding
|
||||
binding.enabled = enabledConnectors?.let { item.type in it } ?: true
|
||||
val root = binding.root as CheckableConstraintLayout
|
||||
root.isChecked = checkedItem == position
|
||||
root.setOnClickListener {
|
||||
root.isChecked = true
|
||||
}
|
||||
root.setOnCheckedChangeListener { v: View, checked: Boolean ->
|
||||
if (checked) {
|
||||
checkedItem = position
|
||||
root.post {
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
onCheckedItemChangedListener?.invoke(getCheckedItem()!!)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getCheckedItem(): Chargepoint? = checkedItem?.let { getItem(it) }
|
||||
|
||||
fun setCheckedItem(item: Chargepoint?) {
|
||||
checkedItem = item?.let { currentList.indexOf(item) }
|
||||
}
|
||||
|
||||
var onCheckedItemChangedListener: ((Chargepoint?) -> Unit)? = null
|
||||
}
|
||||
|
||||
class ChargepriceTagsAdapter() :
|
||||
DataBindingAdapter<ChargepriceTag>() {
|
||||
override fun getItemViewType(position: Int): Int = R.layout.item_chargeprice_tag
|
||||
}
|
||||
|
||||
class CheckableChargepriceCarAdapter : DataBindingAdapter<ChargepriceCar>() {
|
||||
private var checkedItem: ChargepriceCar? = null
|
||||
|
||||
override fun getItemViewType(position: Int): Int = R.layout.item_chargeprice_vehicle_chip
|
||||
|
||||
override fun onBindViewHolder(holder: ViewHolder<ChargepriceCar>, position: Int) {
|
||||
val item = getItem(position)
|
||||
super.bind(holder, item)
|
||||
val binding = holder.binding as ItemChargepriceVehicleChipBinding
|
||||
val root = binding.root as Chip
|
||||
root.isChecked = checkedItem == item
|
||||
root.setOnClickListener {
|
||||
root.isChecked = true
|
||||
}
|
||||
root.setOnCheckedChangeListener { v: View, checked: Boolean ->
|
||||
if (checked && item != checkedItem) {
|
||||
checkedItem = item
|
||||
root.post {
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
onCheckedItemChangedListener?.invoke(getCheckedItem()!!)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getCheckedItem(): ChargepriceCar? = checkedItem
|
||||
|
||||
fun setCheckedItem(item: ChargepriceCar?) {
|
||||
checkedItem = item
|
||||
}
|
||||
|
||||
var onCheckedItemChangedListener: ((ChargepriceCar?) -> Unit)? = null
|
||||
}
|
||||
@@ -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,
|
||||
@@ -48,7 +48,8 @@ fun buildDetails(
|
||||
R.drawable.ic_address,
|
||||
R.string.address,
|
||||
loc.address.toString(),
|
||||
loc.locationDescription
|
||||
loc.locationDescription,
|
||||
clickable = true
|
||||
),
|
||||
if (loc.operator != null) DetailsAdapter.Detail(
|
||||
R.drawable.ic_operator,
|
||||
@@ -79,8 +80,11 @@ fun buildDetails(
|
||||
if (loc.openinghours != null && !loc.openinghours.isEmpty) DetailsAdapter.Detail(
|
||||
R.drawable.ic_hours,
|
||||
R.string.hours,
|
||||
loc.openinghours.getStatusText(ctx),
|
||||
loc.openinghours.description,
|
||||
if (loc.openinghours.days != null || loc.openinghours.twentyfourSeven)
|
||||
loc.openinghours.getStatusText(ctx)
|
||||
else
|
||||
loc.openinghours.description ?: "",
|
||||
if (loc.openinghours.days != null) loc.openinghours.description else null,
|
||||
hoursDays = loc.openinghours.days
|
||||
) else null,
|
||||
if (loc.cost != null) DetailsAdapter.Detail(
|
||||
@@ -90,16 +94,24 @@ fun buildDetails(
|
||||
loc.cost.descriptionLong ?: loc.cost.descriptionShort
|
||||
)
|
||||
else null,
|
||||
if (loc.chargecards != null && loc.chargecards.isNotEmpty()) DetailsAdapter.Detail(
|
||||
R.drawable.ic_payment,
|
||||
R.string.charge_cards,
|
||||
ctx.resources.getQuantityString(
|
||||
R.plurals.charge_cards_compatible_num,
|
||||
loc.chargecards.size, loc.chargecards.size
|
||||
),
|
||||
formatChargeCards(loc.chargecards, chargeCards, filteredChargeCards, ctx),
|
||||
clickable = true
|
||||
) else null,
|
||||
if (loc.chargecards != null && loc.chargecards.isNotEmpty() || loc.barrierFree == true)
|
||||
DetailsAdapter.Detail(
|
||||
R.drawable.ic_payment,
|
||||
R.string.charge_cards,
|
||||
listOfNotNull(
|
||||
if (loc.barrierFree == true) ctx.resources.getString(R.string.charging_barrierfree) else null,
|
||||
if (loc.chargecards != null && loc.chargecards.isNotEmpty()) {
|
||||
ctx.resources.getQuantityString(
|
||||
R.plurals.charge_cards_compatible_num,
|
||||
loc.chargecards.size, loc.chargecards.size
|
||||
)
|
||||
} else null
|
||||
).joinToString(", "),
|
||||
if (loc.chargecards != null && loc.chargecards.isNotEmpty()) {
|
||||
formatChargeCards(loc.chargecards, chargeCards, filteredChargeCards, ctx)
|
||||
} else null,
|
||||
clickable = true
|
||||
) else null,
|
||||
DetailsAdapter.Detail(
|
||||
R.drawable.ic_location,
|
||||
R.string.coordinates,
|
||||
@@ -107,7 +119,7 @@ fun buildDetails(
|
||||
loc.coordinates.formatDecimal(),
|
||||
links = false,
|
||||
clickable = true
|
||||
)
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package net.vonforst.evmap.adapter
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.view.MotionEvent
|
||||
import android.view.animation.AccelerateInterpolator
|
||||
import androidx.recyclerview.widget.ItemTouchHelper
|
||||
import net.vonforst.evmap.R
|
||||
import net.vonforst.evmap.databinding.ItemFilterProfileBinding
|
||||
import net.vonforst.evmap.storage.FilterProfile
|
||||
|
||||
class FilterProfilesAdapter(
|
||||
val dragHelper: ItemTouchHelper,
|
||||
val onDelete: (FilterProfile) -> Unit,
|
||||
val onRename: (FilterProfile) -> Unit
|
||||
) : DataBindingAdapter<FilterProfile>() {
|
||||
init {
|
||||
setHasStableIds(true)
|
||||
}
|
||||
|
||||
@SuppressLint("ClickableViewAccessibility")
|
||||
override fun bind(
|
||||
holder: ViewHolder<FilterProfile>,
|
||||
item: FilterProfile
|
||||
) {
|
||||
super.bind(holder, item)
|
||||
|
||||
val binding = holder.binding as ItemFilterProfileBinding
|
||||
binding.handle.setOnTouchListener { v, event ->
|
||||
if (event?.action == MotionEvent.ACTION_DOWN) {
|
||||
dragHelper.startDrag(holder)
|
||||
}
|
||||
false
|
||||
}
|
||||
binding.foreground.translationX = 0f
|
||||
binding.btnDelete.setOnClickListener {
|
||||
binding.foreground.animate()
|
||||
.translationX(binding.foreground.width.toFloat())
|
||||
.setDuration(250)
|
||||
.setInterpolator(AccelerateInterpolator())
|
||||
.withEndAction {
|
||||
onDelete(item)
|
||||
}
|
||||
.start()
|
||||
}
|
||||
binding.btnRename.setOnClickListener {
|
||||
onRename(item)
|
||||
}
|
||||
}
|
||||
|
||||
override fun getItemId(position: Int): Long {
|
||||
return getItem(position).id
|
||||
}
|
||||
|
||||
override fun getItemViewType(position: Int): Int = R.layout.item_filter_profile
|
||||
}
|
||||
@@ -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>>() {
|
||||
|
||||
@@ -2,121 +2,64 @@ 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
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.ortiz.touchview.TouchImageView
|
||||
import com.squareup.picasso.Callback
|
||||
import com.squareup.picasso.Picasso
|
||||
import coil.load
|
||||
import coil.memory.MemoryCache
|
||||
import coil.size.OriginalSize
|
||||
import coil.size.SizeResolver
|
||||
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 loadedListener: (() -> Unit)? = null
|
||||
) :
|
||||
class GalleryAdapter(context: Context, val itemClickListener: ItemClickListener? = null) :
|
||||
ListAdapter<ChargerPhoto, GalleryAdapter.ViewHolder>(ChargerPhotoDiffCallback()) {
|
||||
class ViewHolder(val view: ImageView) : RecyclerView.ViewHolder(view)
|
||||
|
||||
interface ItemClickListener {
|
||||
fun onItemClick(view: View, position: Int)
|
||||
fun onItemClick(view: View, position: Int, imageCacheKey: MemoryCache.Key?)
|
||||
}
|
||||
|
||||
val apikey = context.getString(R.string.goingelectric_key)
|
||||
var loaded = false
|
||||
val memoryKeys = HashMap<String, MemoryCache.Key?>()
|
||||
|
||||
@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 { view, 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 || view.canScrollHorizontally(1) && view.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()
|
||||
}
|
||||
Picasso.get()
|
||||
.load(
|
||||
"https://api.goingelectric.de/chargepoints/photo/?key=${apikey}" +
|
||||
"&id=${getItem(position).id}" +
|
||||
if (detailView) {
|
||||
"&size=1000"
|
||||
} else {
|
||||
"&height=${holder.view.height}"
|
||||
}
|
||||
val id = getItem(position).id
|
||||
val url = getItem(position).getUrl(height = holder.view.height)
|
||||
|
||||
holder.view.load(
|
||||
url
|
||||
) {
|
||||
size(SizeResolver(OriginalSize))
|
||||
allowHardware(false)
|
||||
listener(
|
||||
onSuccess = { _, metadata ->
|
||||
memoryKeys[id] = metadata.memoryCacheKey
|
||||
}
|
||||
)
|
||||
.into(holder.view, object : Callback {
|
||||
override fun onSuccess() {
|
||||
if (!loaded && loadedListener != null && pageToLoad == position) {
|
||||
holder.view.viewTreeObserver.addOnPreDrawListener(object :
|
||||
ViewTreeObserver.OnPreDrawListener {
|
||||
override fun onPreDraw(): Boolean {
|
||||
holder.view.viewTreeObserver.removeOnPreDrawListener(this)
|
||||
loadedListener.invoke()
|
||||
return true
|
||||
}
|
||||
})
|
||||
loaded = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onError(e: Exception?) {
|
||||
if (!loaded && loadedListener != null && pageToLoad == position) {
|
||||
loadedListener.invoke()
|
||||
loaded = true
|
||||
}
|
||||
}
|
||||
|
||||
})
|
||||
holder.view.transitionName = galleryTransitionName(position)
|
||||
if (itemClickListener != null) {
|
||||
holder.view.setOnClickListener {
|
||||
itemClickListener.onItemClick(holder.view, position)
|
||||
itemClickListener.onItemClick(holder.view, position, memoryKeys[id])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun galleryTransitionName(position: Int) = "gallery_$position"
|
||||
|
||||
class ChargerPhotoDiffCallback : DiffUtil.ItemCallback<ChargerPhoto>() {
|
||||
override fun areItemsTheSame(oldItem: ChargerPhoto, newItem: ChargerPhoto): Boolean {
|
||||
return oldItem.id == newItem.id
|
||||
|
||||
68
app/src/main/java/net/vonforst/evmap/api/ChargepointApi.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package net.vonforst.evmap.api
|
||||
|
||||
import com.google.common.util.concurrent.RateLimiter
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Response
|
||||
|
||||
|
||||
class RateLimitInterceptor : Interceptor {
|
||||
private val rateLimiter = RateLimiter.create(3.0)
|
||||
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val request = chain.request()
|
||||
if (request.url.host == "my.newmotion.com") {
|
||||
// limit requests sent to NewMotion to 3 per second
|
||||
rateLimiter.acquire(1)
|
||||
|
||||
var response: Response = chain.proceed(request)
|
||||
// 403 is how the NewMotion API indicates a rate limit error
|
||||
if (!response.isSuccessful && response.code == 403) {
|
||||
response.close()
|
||||
// wait & retry
|
||||
try {
|
||||
Thread.sleep(1000)
|
||||
} catch (e: InterruptedException) {
|
||||
}
|
||||
response = chain.proceed(request)
|
||||
}
|
||||
return response
|
||||
} else {
|
||||
return chain.proceed(request)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,23 @@
|
||||
package net.vonforst.evmap.api
|
||||
|
||||
import androidx.annotation.DrawableRes
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import net.vonforst.evmap.R
|
||||
import net.vonforst.evmap.model.Chargepoint
|
||||
import okhttp3.Call
|
||||
import okhttp3.Callback
|
||||
import okhttp3.Response
|
||||
import org.json.JSONArray
|
||||
import java.io.IOException
|
||||
import kotlin.coroutines.resumeWithException
|
||||
import kotlin.math.abs
|
||||
|
||||
operator fun <T> JSONArray.iterator(): Iterator<T> =
|
||||
(0 until length()).asSequence().map { get(it) as T }.iterator()
|
||||
(0 until length()).asSequence().map {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
get(it) as T
|
||||
}.iterator()
|
||||
|
||||
@ExperimentalCoroutinesApi
|
||||
suspend fun Call.await(): Response {
|
||||
@@ -36,26 +43,69 @@ suspend fun Call.await(): Response {
|
||||
}
|
||||
}
|
||||
|
||||
const val earthRadiusKm: Double = 6372.8
|
||||
private val plugNames = mapOf(
|
||||
Chargepoint.TYPE_1 to R.string.plug_type_1,
|
||||
Chargepoint.TYPE_2_UNKNOWN to R.string.plug_type_2,
|
||||
Chargepoint.TYPE_2_PLUG to R.string.plug_type_2,
|
||||
Chargepoint.TYPE_2_SOCKET to R.string.plug_type_2,
|
||||
Chargepoint.TYPE_3 to R.string.plug_type_3,
|
||||
Chargepoint.CCS_UNKNOWN to R.string.plug_ccs,
|
||||
Chargepoint.CCS_TYPE_1 to R.string.plug_ccs,
|
||||
Chargepoint.CCS_TYPE_2 to R.string.plug_ccs,
|
||||
Chargepoint.SCHUKO to R.string.plug_schuko,
|
||||
Chargepoint.CHADEMO to R.string.plug_chademo,
|
||||
Chargepoint.SUPERCHARGER to R.string.plug_supercharger,
|
||||
Chargepoint.CEE_BLAU to R.string.plug_cee_blau,
|
||||
Chargepoint.CEE_ROT to R.string.plug_cee_rot,
|
||||
Chargepoint.TESLA_ROADSTER_HPC to R.string.plug_roadster_hpc
|
||||
)
|
||||
|
||||
/**
|
||||
* Calculates the distance between two points on Earth in meters.
|
||||
* Latitude and longitude should be given in degrees.
|
||||
*/
|
||||
fun distanceBetween(
|
||||
startLatitude: Double, startLongitude: Double,
|
||||
endLatitude: Double, endLongitude: Double
|
||||
): Double {
|
||||
// see https://rosettacode.org/wiki/Haversine_formula#Java
|
||||
val dLat = Math.toRadians(endLatitude - startLatitude);
|
||||
val dLon = Math.toRadians(endLongitude - startLongitude);
|
||||
val originLat = Math.toRadians(startLatitude);
|
||||
val destinationLat = Math.toRadians(endLatitude);
|
||||
fun nameForPlugType(ctx: StringProvider, type: String): String =
|
||||
plugNames[type]?.let {
|
||||
ctx.getString(it)
|
||||
} ?: type
|
||||
|
||||
val a = Math.pow(Math.sin(dLat / 2), 2.toDouble()) + Math.pow(
|
||||
Math.sin(dLon / 2),
|
||||
2.toDouble()
|
||||
) * Math.cos(originLat) * Math.cos(destinationLat);
|
||||
val c = 2 * Math.asin(Math.sqrt(a));
|
||||
return earthRadiusKm * c * 1000;
|
||||
}
|
||||
fun equivalentPlugTypes(type: String): Set<String> {
|
||||
return when (type) {
|
||||
Chargepoint.CCS_TYPE_1 -> setOf(Chargepoint.CCS_UNKNOWN, Chargepoint.CCS_TYPE_1)
|
||||
Chargepoint.CCS_TYPE_2 -> setOf(Chargepoint.CCS_UNKNOWN, Chargepoint.CCS_TYPE_2)
|
||||
Chargepoint.CCS_UNKNOWN -> setOf(
|
||||
Chargepoint.CCS_UNKNOWN,
|
||||
Chargepoint.CCS_TYPE_1,
|
||||
Chargepoint.CCS_TYPE_2
|
||||
)
|
||||
Chargepoint.TYPE_2_PLUG -> setOf(Chargepoint.TYPE_2_UNKNOWN, Chargepoint.TYPE_2_PLUG)
|
||||
Chargepoint.TYPE_2_SOCKET -> setOf(Chargepoint.TYPE_2_UNKNOWN, Chargepoint.TYPE_2_SOCKET)
|
||||
Chargepoint.TYPE_2_UNKNOWN -> setOf(
|
||||
Chargepoint.TYPE_2_UNKNOWN,
|
||||
Chargepoint.TYPE_2_PLUG,
|
||||
Chargepoint.TYPE_2_SOCKET
|
||||
)
|
||||
else -> setOf(type)
|
||||
}
|
||||
}
|
||||
|
||||
@DrawableRes
|
||||
fun iconForPlugType(type: String): Int =
|
||||
when (type) {
|
||||
Chargepoint.CCS_TYPE_2 -> R.drawable.ic_connector_ccs_typ2
|
||||
Chargepoint.CCS_UNKNOWN -> R.drawable.ic_connector_ccs_typ2
|
||||
Chargepoint.CCS_TYPE_1 -> R.drawable.ic_connector_ccs_typ1
|
||||
Chargepoint.CHADEMO -> R.drawable.ic_connector_chademo
|
||||
Chargepoint.SCHUKO -> R.drawable.ic_connector_schuko
|
||||
Chargepoint.SUPERCHARGER -> R.drawable.ic_connector_supercharger
|
||||
Chargepoint.TYPE_2_UNKNOWN -> R.drawable.ic_connector_typ2
|
||||
Chargepoint.TYPE_2_SOCKET -> R.drawable.ic_connector_typ2
|
||||
Chargepoint.TYPE_2_PLUG -> R.drawable.ic_connector_typ2
|
||||
Chargepoint.CEE_BLAU -> R.drawable.ic_connector_cee_blau
|
||||
Chargepoint.CEE_ROT -> R.drawable.ic_connector_cee_rot
|
||||
Chargepoint.TYPE_1 -> R.drawable.ic_connector_typ1
|
||||
// TODO: add other connectors
|
||||
else -> 0
|
||||
}
|
||||
|
||||
val powerSteps = listOf(0, 2, 3, 7, 11, 22, 43, 50, 75, 100, 150, 200, 250, 300, 350)
|
||||
fun mapPower(i: Int) = powerSteps[i]
|
||||
fun mapPowerInverse(power: Int) = powerSteps
|
||||
.mapIndexed { index, v -> abs(v - power) to index }
|
||||
.minByOrNull { it.first }?.second ?: 0
|
||||
@@ -2,21 +2,29 @@ package net.vonforst.evmap.api.availability
|
||||
|
||||
import com.facebook.stetho.okhttp3.StethoInterceptor
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.withContext
|
||||
import net.vonforst.evmap.api.RateLimitInterceptor
|
||||
import net.vonforst.evmap.api.await
|
||||
import net.vonforst.evmap.api.goingelectric.ChargeLocation
|
||||
import net.vonforst.evmap.api.goingelectric.Chargepoint
|
||||
import net.vonforst.evmap.api.equivalentPlugTypes
|
||||
import net.vonforst.evmap.cartesianProduct
|
||||
import net.vonforst.evmap.model.ChargeLocation
|
||||
import net.vonforst.evmap.model.Chargepoint
|
||||
import net.vonforst.evmap.viewmodel.Resource
|
||||
import okhttp3.JavaNetCookieJar
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import retrofit2.HttpException
|
||||
import java.io.IOException
|
||||
import java.net.CookieManager
|
||||
import java.net.CookiePolicy
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
interface AvailabilityDetector {
|
||||
suspend fun getAvailability(location: ChargeLocation): ChargeLocationStatus
|
||||
}
|
||||
|
||||
@ExperimentalCoroutinesApi
|
||||
abstract class BaseAvailabilityDetector(private val client: OkHttpClient) : AvailabilityDetector {
|
||||
protected val radius = 150 // max radius in meters
|
||||
|
||||
@@ -24,9 +32,9 @@ abstract class BaseAvailabilityDetector(private val client: OkHttpClient) : Avai
|
||||
val request = Request.Builder().url(url).build()
|
||||
val response = client.newCall(request).await()
|
||||
|
||||
if (!response.isSuccessful) throw IOException(response.message())
|
||||
if (!response.isSuccessful) throw IOException(response.message)
|
||||
|
||||
val str = response.body()!!.string()
|
||||
val str = response.body!!.string()
|
||||
return str
|
||||
}
|
||||
|
||||
@@ -59,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 }
|
||||
@@ -68,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")
|
||||
@@ -107,7 +117,19 @@ abstract class BaseAvailabilityDetector(private val client: OkHttpClient) : Avai
|
||||
data class ChargeLocationStatus(
|
||||
val status: Map<Chargepoint, List<ChargepointStatus>>,
|
||||
val source: String
|
||||
)
|
||||
) {
|
||||
fun applyFilters(connectors: Set<String>?, minPower: Int?): ChargeLocationStatus {
|
||||
val statusFiltered = status.filterKeys {
|
||||
(connectors == null || connectors.map {
|
||||
equivalentPlugTypes(it)
|
||||
}.any { equivalent -> it.type in equivalent })
|
||||
&& (minPower == null || it.power > minPower)
|
||||
}
|
||||
return this.copy(status = statusFiltered)
|
||||
}
|
||||
|
||||
val totalChargepoints = status.map { it.key.count }.sum()
|
||||
}
|
||||
|
||||
enum class ChargepointStatus {
|
||||
AVAILABLE, UNKNOWN, CHARGING, OCCUPIED, FAULTED
|
||||
@@ -115,10 +137,16 @@ enum class ChargepointStatus {
|
||||
|
||||
class AvailabilityDetectorException(message: String) : Exception(message)
|
||||
|
||||
private val cookieManager = CookieManager().apply {
|
||||
setCookiePolicy(CookiePolicy.ACCEPT_ALL)
|
||||
}
|
||||
|
||||
private val okhttp = OkHttpClient.Builder()
|
||||
.addInterceptor(RateLimitInterceptor())
|
||||
.addNetworkInterceptor(StethoInterceptor())
|
||||
.readTimeout(10, TimeUnit.SECONDS)
|
||||
.connectTimeout(10, TimeUnit.SECONDS)
|
||||
.cookieJar(JavaNetCookieJar(cookieManager))
|
||||
.build()
|
||||
val availabilityDetectors = listOf(
|
||||
NewMotionAvailabilityDetector(okhttp)
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
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
|
||||
|
||||
@ExperimentalCoroutinesApi
|
||||
class ChargecloudAvailabilityDetector(
|
||||
client: OkHttpClient,
|
||||
private val operatorId: String
|
||||
@@ -84,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")
|
||||
}
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
package net.vonforst.evmap.api.availability
|
||||
|
||||
import com.squareup.moshi.JsonClass
|
||||
import net.vonforst.evmap.api.distanceBetween
|
||||
import net.vonforst.evmap.api.goingelectric.ChargeLocation
|
||||
import net.vonforst.evmap.api.goingelectric.Chargepoint
|
||||
import net.vonforst.evmap.model.ChargeLocation
|
||||
import net.vonforst.evmap.model.Chargepoint
|
||||
import net.vonforst.evmap.utils.distanceBetween
|
||||
import okhttp3.OkHttpClient
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.converter.moshi.MoshiConverterFactory
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.Path
|
||||
import java.util.*
|
||||
|
||||
private const val coordRange = 0.1 // range of latitude and longitude for loading the map
|
||||
private const val maxDistance = 15 // max distance between reported positions in meters
|
||||
@@ -95,7 +96,7 @@ class NewMotionAvailabilityDetector(client: OkHttpClient, baseUrl: String? = nul
|
||||
// find nearest station to this position
|
||||
var markers =
|
||||
api.getMarkers(lng - coordRange, lng + coordRange, lat - coordRange, lat + coordRange)
|
||||
val nearest = markers.minBy { marker ->
|
||||
val nearest = markers.minByOrNull { marker ->
|
||||
distanceBetween(marker.coordinates.latitude, marker.coordinates.longitude, lat, lng)
|
||||
} ?: throw AvailabilityDetectorException("no candidates found.")
|
||||
|
||||
@@ -138,15 +139,18 @@ class NewMotionAvailabilityDetector(client: OkHttpClient, baseUrl: String? = nul
|
||||
connectorStatus.forEach { (connector, statusStr) ->
|
||||
val id = connector.uid
|
||||
val power = connector.electricalProperties.getPower()
|
||||
val type = when (connector.connectorType) {
|
||||
"Type3" -> Chargepoint.TYPE_3
|
||||
"Type2" -> Chargepoint.TYPE_2
|
||||
"Type1" -> Chargepoint.TYPE_1
|
||||
"Domestic" -> Chargepoint.SCHUKO
|
||||
"Type2Combo" -> Chargepoint.CCS
|
||||
"TepcoCHAdeMO" -> Chargepoint.CHADEMO
|
||||
"Unspecified" -> "unspecified"
|
||||
else -> throw IllegalArgumentException("unrecognized type ${connector.connectorType}")
|
||||
val type = when (connector.connectorType.toLowerCase(Locale.ROOT)) {
|
||||
"type3" -> Chargepoint.TYPE_3
|
||||
"type2" -> Chargepoint.TYPE_2_UNKNOWN
|
||||
"type1" -> Chargepoint.TYPE_1
|
||||
"domestic" -> Chargepoint.SCHUKO
|
||||
"type1combo" -> Chargepoint.CCS_TYPE_1 // US CCS, aka type1_combo
|
||||
"type2combo" -> Chargepoint.CCS_TYPE_2 // EU CCS, aka type2_combo
|
||||
"tepcochademo" -> Chargepoint.CHADEMO
|
||||
"unspecified" -> "unknown"
|
||||
"unknown" -> "unknown"
|
||||
"saej1772" -> "unknown"
|
||||
else -> "unknown"
|
||||
}
|
||||
val status = when (statusStr) {
|
||||
"Unavailable" -> ChargepointStatus.FAULTED
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
package net.vonforst.evmap.api.chargeprice
|
||||
|
||||
import android.content.Context
|
||||
import com.facebook.stetho.okhttp3.StethoInterceptor
|
||||
import com.squareup.moshi.Moshi
|
||||
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
|
||||
import moe.banana.jsonapi2.ArrayDocument
|
||||
import moe.banana.jsonapi2.JsonApiConverterFactory
|
||||
import moe.banana.jsonapi2.ResourceAdapterFactory
|
||||
import net.vonforst.evmap.BuildConfig
|
||||
import okhttp3.Cache
|
||||
import okhttp3.OkHttpClient
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.Header
|
||||
import retrofit2.http.POST
|
||||
|
||||
interface ChargepriceApi {
|
||||
@POST("charge_prices")
|
||||
suspend fun getChargePrices(
|
||||
@Body request: ChargepriceRequest,
|
||||
@Header("Accept-Language") language: String
|
||||
): ArrayDocument<ChargePrice>
|
||||
|
||||
@GET("vehicles")
|
||||
suspend fun getVehicles(): ArrayDocument<ChargepriceCar>
|
||||
|
||||
@GET("tariffs")
|
||||
suspend fun getTariffs(): ArrayDocument<ChargepriceTariff>
|
||||
|
||||
companion object {
|
||||
private val cacheSize = 1L * 1024 * 1024 // 1MB
|
||||
val supportedLanguages = setOf("de", "en", "fr", "nl")
|
||||
|
||||
private val jsonApiAdapterFactory = ResourceAdapterFactory.builder()
|
||||
.add(ChargepriceRequest::class.java)
|
||||
.add(ChargepriceTariff::class.java)
|
||||
.add(ChargepriceBrand::class.java)
|
||||
.add(ChargePrice::class.java)
|
||||
.add(ChargepriceCar::class.java)
|
||||
.build()
|
||||
val moshi = Moshi.Builder()
|
||||
.add(jsonApiAdapterFactory)
|
||||
.add(KotlinJsonAdapterFactory())
|
||||
.build()
|
||||
fun create(
|
||||
apikey: String,
|
||||
baseurl: String = "https://api.chargeprice.app/v1/",
|
||||
context: Context? = null
|
||||
): ChargepriceApi {
|
||||
val client = OkHttpClient.Builder().apply {
|
||||
addInterceptor { chain ->
|
||||
// add API key to every request
|
||||
val original = chain.request()
|
||||
val new = original.newBuilder()
|
||||
.header("API-Key", apikey)
|
||||
.header("Content-Type", "application/json")
|
||||
.build()
|
||||
chain.proceed(new)
|
||||
}
|
||||
if (BuildConfig.DEBUG) {
|
||||
addNetworkInterceptor(StethoInterceptor())
|
||||
}
|
||||
if (context != null) {
|
||||
cache(Cache(context.getCacheDir(), cacheSize))
|
||||
}
|
||||
}.build()
|
||||
|
||||
val retrofit = Retrofit.Builder()
|
||||
.baseUrl(baseurl)
|
||||
.addConverterFactory(JsonApiConverterFactory.create(moshi))
|
||||
.client(client)
|
||||
.build()
|
||||
return retrofit.create(ChargepriceApi::class.java)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
package net.vonforst.evmap.api.chargeprice
|
||||
|
||||
import android.content.Context
|
||||
import com.squareup.moshi.Json
|
||||
import moe.banana.jsonapi2.HasMany
|
||||
import moe.banana.jsonapi2.HasOne
|
||||
|
||||
import moe.banana.jsonapi2.JsonApi
|
||||
import moe.banana.jsonapi2.Resource
|
||||
import net.vonforst.evmap.R
|
||||
import net.vonforst.evmap.adapter.Equatable
|
||||
import net.vonforst.evmap.api.equivalentPlugTypes
|
||||
import net.vonforst.evmap.model.ChargeLocation
|
||||
import net.vonforst.evmap.ui.currency
|
||||
import kotlin.math.ceil
|
||||
import kotlin.math.floor
|
||||
|
||||
|
||||
@JsonApi(type = "charge_price_request")
|
||||
class ChargepriceRequest : Resource() {
|
||||
@field:Json(name = "data_adapter")
|
||||
lateinit var dataAdapter: String
|
||||
lateinit var station: ChargepriceStation
|
||||
lateinit var options: ChargepriceOptions
|
||||
var tariffs: HasMany<ChargepriceTariff>? = null
|
||||
var vehicle: HasOne<ChargepriceCar>? = null
|
||||
}
|
||||
|
||||
data class ChargepriceStation(
|
||||
val longitude: Double,
|
||||
val latitude: Double,
|
||||
val country: String?,
|
||||
val network: String?,
|
||||
@Json(name = "charge_points") val chargePoints: List<ChargepriceChargepoint>
|
||||
) {
|
||||
companion object {
|
||||
fun fromEvmap(
|
||||
charger: ChargeLocation,
|
||||
compatibleConnectors: List<String>,
|
||||
): ChargepriceStation {
|
||||
if (charger.chargepriceData == null) throw IllegalArgumentException()
|
||||
|
||||
val plugTypes =
|
||||
charger.chargepriceData.plugTypes ?: charger.chargepoints.map { it.type }
|
||||
return ChargepriceStation(
|
||||
charger.coordinates.lng,
|
||||
charger.coordinates.lat,
|
||||
charger.chargepriceData.country,
|
||||
charger.chargepriceData.network,
|
||||
charger.chargepoints.zip(plugTypes).filter {
|
||||
equivalentPlugTypes(it.first.type).any { it in compatibleConnectors }
|
||||
}.map {
|
||||
ChargepriceChargepoint(it.first.power, it.second)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class ChargepriceChargepoint(
|
||||
val power: Double,
|
||||
val plug: String
|
||||
)
|
||||
|
||||
data class ChargepriceOptions(
|
||||
@Json(name = "max_monthly_fees") val maxMonthlyFees: Double? = null,
|
||||
val energy: Double? = null,
|
||||
val duration: Int? = null,
|
||||
@Json(name = "battery_range") val batteryRange: List<Double>? = null,
|
||||
@Json(name = "car_ac_phases") val carAcPhases: Int? = null,
|
||||
val currency: String? = null,
|
||||
@Json(name = "start_time") val startTime: Int? = null,
|
||||
@Json(name = "allow_unbalanced_load") val allowUnbalancedLoad: Boolean? = null,
|
||||
@Json(name = "provider_customer_tariffs") val providerCustomerTariffs: Boolean? = null
|
||||
)
|
||||
|
||||
@JsonApi(type = "tariff")
|
||||
class ChargepriceTariff() : Resource() {
|
||||
lateinit var provider: String
|
||||
lateinit var name: String
|
||||
@field:Json(name = "direct_payment")
|
||||
var directPayment: Boolean = false
|
||||
@field:Json(name = "provider_customer_tariff")
|
||||
var providerCustomerTariff: Boolean = false
|
||||
@field:Json(name = "supported_cuntries")
|
||||
lateinit var supportedCountries: Set<String>
|
||||
@field:Json(name = "charge_card_id")
|
||||
lateinit var chargeCardId: String // GE charge card ID
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (javaClass != other?.javaClass) return false
|
||||
if (!super.equals(other)) return false
|
||||
|
||||
other as ChargepriceTariff
|
||||
|
||||
if (provider != other.provider) return false
|
||||
if (name != other.name) return false
|
||||
if (directPayment != other.directPayment) return false
|
||||
if (providerCustomerTariff != other.providerCustomerTariff) return false
|
||||
if (supportedCountries != other.supportedCountries) return false
|
||||
if (chargeCardId != other.chargeCardId) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = super.hashCode()
|
||||
result = 31 * result + provider.hashCode()
|
||||
result = 31 * result + name.hashCode()
|
||||
result = 31 * result + directPayment.hashCode()
|
||||
result = 31 * result + providerCustomerTariff.hashCode()
|
||||
result = 31 * result + supportedCountries.hashCode()
|
||||
result = 31 * result + chargeCardId.hashCode()
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
@JsonApi(type = "car")
|
||||
class ChargepriceCar : Resource(), Equatable {
|
||||
lateinit var name: String
|
||||
lateinit var brand: String
|
||||
|
||||
@field:Json(name = "dc_charge_ports")
|
||||
lateinit var dcChargePorts: List<String>
|
||||
lateinit var manufacturer: HasOne<ChargepriceBrand>
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (javaClass != other?.javaClass) return false
|
||||
if (!super.equals(other)) return false
|
||||
|
||||
other as ChargepriceCar
|
||||
|
||||
if (name != other.name) return false
|
||||
if (brand != other.brand) return false
|
||||
if (dcChargePorts != other.dcChargePorts) return false
|
||||
if (manufacturer != other.manufacturer) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = super.hashCode()
|
||||
result = 31 * result + name.hashCode()
|
||||
result = 31 * result + brand.hashCode()
|
||||
result = 31 * result + dcChargePorts.hashCode()
|
||||
result = 31 * result + manufacturer.hashCode()
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
@JsonApi(type = "brand")
|
||||
class ChargepriceBrand : Resource()
|
||||
|
||||
@JsonApi(type = "charge_price")
|
||||
class ChargePrice : Resource(), Equatable, Cloneable {
|
||||
lateinit var provider: String
|
||||
|
||||
@field:Json(name = "tariff_name")
|
||||
lateinit var tariffName: String
|
||||
lateinit var url: String
|
||||
|
||||
@field:Json(name = "monthly_min_sales")
|
||||
var monthlyMinSales: Double = 0.0
|
||||
|
||||
@field:Json(name = "total_monthly_fee")
|
||||
var totalMonthlyFee: Double = 0.0
|
||||
|
||||
@field:Json(name = "flat_rate")
|
||||
var flatRate: Boolean = false
|
||||
|
||||
@field:Json(name = "direct_payment")
|
||||
var directPayment: Boolean = false
|
||||
|
||||
@field:Json(name = "provider_customer_tariff")
|
||||
var providerCustomerTariff: Boolean = false
|
||||
lateinit var currency: String
|
||||
|
||||
@field:Json(name = "start_time")
|
||||
var startTime: Int = 0
|
||||
lateinit var tags: List<ChargepriceTag>
|
||||
|
||||
@field:Json(name = "charge_point_prices")
|
||||
lateinit var chargepointPrices: List<ChargepointPrice>
|
||||
|
||||
var tariff: HasOne<ChargepriceTariff>? = null
|
||||
|
||||
|
||||
fun formatMonthlyFees(ctx: Context): String {
|
||||
return listOfNotNull(
|
||||
if (totalMonthlyFee > 0) {
|
||||
ctx.getString(R.string.chargeprice_base_fee, totalMonthlyFee, currency(currency))
|
||||
} else null,
|
||||
if (monthlyMinSales > 0) {
|
||||
ctx.getString(R.string.chargeprice_min_spend, monthlyMinSales, currency(currency))
|
||||
} else null
|
||||
).joinToString(", ")
|
||||
}
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (javaClass != other?.javaClass) return false
|
||||
if (!super.equals(other)) return false
|
||||
|
||||
other as ChargePrice
|
||||
|
||||
if (provider != other.provider) return false
|
||||
if (tariffName != other.tariffName) return false
|
||||
if (url != other.url) return false
|
||||
if (monthlyMinSales != other.monthlyMinSales) return false
|
||||
if (totalMonthlyFee != other.totalMonthlyFee) return false
|
||||
if (flatRate != other.flatRate) return false
|
||||
if (directPayment != other.directPayment) return false
|
||||
if (providerCustomerTariff != other.providerCustomerTariff) return false
|
||||
if (currency != other.currency) return false
|
||||
if (startTime != other.startTime) return false
|
||||
if (tags != other.tags) return false
|
||||
if (chargepointPrices != other.chargepointPrices) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = super.hashCode()
|
||||
result = 31 * result + provider.hashCode()
|
||||
result = 31 * result + tariffName.hashCode()
|
||||
result = 31 * result + url.hashCode()
|
||||
result = 31 * result + monthlyMinSales.hashCode()
|
||||
result = 31 * result + totalMonthlyFee.hashCode()
|
||||
result = 31 * result + flatRate.hashCode()
|
||||
result = 31 * result + directPayment.hashCode()
|
||||
result = 31 * result + providerCustomerTariff.hashCode()
|
||||
result = 31 * result + currency.hashCode()
|
||||
result = 31 * result + startTime
|
||||
result = 31 * result + tags.hashCode()
|
||||
result = 31 * result + chargepointPrices.hashCode()
|
||||
return result
|
||||
}
|
||||
|
||||
public override fun clone(): ChargePrice {
|
||||
return ChargePrice().apply {
|
||||
chargepointPrices = this@ChargePrice.chargepointPrices
|
||||
currency = this@ChargePrice.currency
|
||||
directPayment = this@ChargePrice.directPayment
|
||||
flatRate = this@ChargePrice.flatRate
|
||||
monthlyMinSales = this@ChargePrice.monthlyMinSales
|
||||
provider = this@ChargePrice.provider
|
||||
providerCustomerTariff = this@ChargePrice.providerCustomerTariff
|
||||
startTime = this@ChargePrice.startTime
|
||||
tags = this@ChargePrice.tags
|
||||
tariffName = this@ChargePrice.tariffName
|
||||
totalMonthlyFee = this@ChargePrice.totalMonthlyFee
|
||||
url = this@ChargePrice.url
|
||||
tariff = this@ChargePrice.tariff
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class ChargepointPrice(
|
||||
val power: Double,
|
||||
val plug: String,
|
||||
val price: Double,
|
||||
@Json(name = "price_distribution") val priceDistribution: PriceDistribution,
|
||||
@Json(name = "blocking_fee_start") val blockingFeeStart: Int?,
|
||||
@Json(name = "no_price_reason") var noPriceReason: String?
|
||||
) {
|
||||
fun formatDistribution(ctx: Context): String {
|
||||
fun percent(value: Double): String {
|
||||
return ctx.getString(R.string.percent_format, value * 100) + "\u00a0"
|
||||
}
|
||||
|
||||
fun time(value: Int): String {
|
||||
val h = floor(value.toDouble() / 60).toInt();
|
||||
val min = ceil(value.toDouble() % 60).toInt();
|
||||
if (h == 0 && min > 0) return "${min}min";
|
||||
// be slightly sloppy (3:01 is shown as 3h) to save space
|
||||
else if (h > 0 && (min == 0 || min == 1)) return "${h}h";
|
||||
else return "%d:%02dh".format(h, min);
|
||||
}
|
||||
|
||||
// based on https://github.com/chargeprice/chargeprice-client/blob/d420bb2f216d9ad91a210a36dd0859a368a8229a/src/views/priceList.js
|
||||
with(priceDistribution) {
|
||||
return listOfNotNull(
|
||||
if (session != null && session > 0.0) {
|
||||
(if (session < 1) percent(session) else "") + ctx.getString(R.string.chargeprice_session_fee)
|
||||
} else null,
|
||||
if (kwh != null && kwh > 0.0 && !isOnlyKwh) {
|
||||
(if (kwh < 1) percent(kwh) else "") + ctx.getString(R.string.chargeprice_per_kwh)
|
||||
} else null,
|
||||
if (minute != null && minute > 0.0) {
|
||||
(if (minute < 1) percent(minute) else "") + ctx.getString(R.string.chargeprice_per_minute) +
|
||||
if (blockingFeeStart != null) {
|
||||
" (${
|
||||
ctx.getString(
|
||||
R.string.chargeprice_blocking_fee,
|
||||
time(blockingFeeStart)
|
||||
)
|
||||
})"
|
||||
} else ""
|
||||
} else null,
|
||||
if ((minute == null || minute == 0.0) && blockingFeeStart != null) {
|
||||
ctx.getString(R.string.chargeprice_blocking_fee, time(blockingFeeStart))
|
||||
} else null
|
||||
).joinToString(" +\u00a0")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class PriceDistribution(val kwh: Double?, val session: Double?, val minute: Double?) {
|
||||
val isOnlyKwh =
|
||||
kwh != null && kwh > 0 && (session == null || session == 0.0) && (minute == null || minute == 0.0)
|
||||
}
|
||||
|
||||
data class ChargepriceTag(val kind: String, val text: String, val url: String?) : Equatable
|
||||
|
||||
data class ChargepriceMeta(
|
||||
@Json(name = "charge_points") val chargePoints: List<ChargepriceChargepointMeta>
|
||||
)
|
||||
|
||||
data class ChargepriceChargepointMeta(
|
||||
val power: Double,
|
||||
val plug: String,
|
||||
val energy: Double,
|
||||
val duration: Double
|
||||
)
|
||||
@@ -1,5 +1,6 @@
|
||||
package net.vonforst.evmap.api.goingelectric
|
||||
|
||||
import android.util.Log
|
||||
import com.squareup.moshi.*
|
||||
import java.lang.reflect.Type
|
||||
import java.time.Instant
|
||||
@@ -12,7 +13,7 @@ internal class ChargepointListItemJsonAdapterFactory : JsonAdapter.Factory {
|
||||
annotations: MutableSet<out Annotation>,
|
||||
moshi: Moshi
|
||||
): JsonAdapter<*>? {
|
||||
if (Types.getRawType(type) == ChargepointListItem::class.java) {
|
||||
if (Types.getRawType(type) == GEChargepointListItem::class.java) {
|
||||
return ChargepointListItemJsonAdapter(
|
||||
moshi
|
||||
)
|
||||
@@ -25,18 +26,18 @@ internal class ChargepointListItemJsonAdapterFactory : JsonAdapter.Factory {
|
||||
|
||||
|
||||
internal class ChargepointListItemJsonAdapter(val moshi: Moshi) :
|
||||
JsonAdapter<ChargepointListItem>() {
|
||||
JsonAdapter<GEChargepointListItem>() {
|
||||
private val clusterAdapter =
|
||||
moshi.adapter<ChargeLocationCluster>(
|
||||
ChargeLocationCluster::class.java
|
||||
moshi.adapter<GEChargeLocationCluster>(
|
||||
GEChargeLocationCluster::class.java
|
||||
)
|
||||
|
||||
private val locationAdapter = moshi.adapter<ChargeLocation>(
|
||||
ChargeLocation::class.java
|
||||
private val locationAdapter = moshi.adapter<GEChargeLocation>(
|
||||
GEChargeLocation::class.java
|
||||
)
|
||||
|
||||
@FromJson
|
||||
override fun fromJson(reader: JsonReader): ChargepointListItem {
|
||||
override fun fromJson(reader: JsonReader): GEChargepointListItem {
|
||||
var clustered = false
|
||||
reader.peekJson().use { peeked ->
|
||||
peeked.beginObject()
|
||||
@@ -60,7 +61,7 @@ internal class ChargepointListItemJsonAdapter(val moshi: Moshi) :
|
||||
val CLUSTERED: JsonReader.Options = JsonReader.Options.of("clustered")
|
||||
}
|
||||
|
||||
override fun toJson(writer: JsonWriter, value: ChargepointListItem?) {
|
||||
override fun toJson(writer: JsonWriter, value: GEChargepointListItem?) {
|
||||
TODO("not implemented") //To change body of created functions use File | Settings | File Templates.
|
||||
}
|
||||
}
|
||||
@@ -93,8 +94,8 @@ internal class JsonObjectOrFalseAdapter<T> private constructor(
|
||||
JsonReader.Token.BOOLEAN -> when (reader.nextBoolean()) {
|
||||
false -> null // Response was false
|
||||
else -> {
|
||||
if (this.clazz == FaultReport::class.java) {
|
||||
FaultReport(null, null) as T
|
||||
if (this.clazz == GEFaultReport::class.java) {
|
||||
GEFaultReport(null, null) as T
|
||||
} else {
|
||||
throw IllegalStateException("Non-false boolean for @JsonObjectOrFalse field")
|
||||
}
|
||||
@@ -125,21 +126,28 @@ internal class HoursAdapter {
|
||||
private val regex = Regex("from (.*) till (.*)")
|
||||
|
||||
@FromJson
|
||||
fun fromJson(str: String): Hours? {
|
||||
fun fromJson(str: String): GEHours? {
|
||||
if (str == "closed") {
|
||||
return Hours(null, null)
|
||||
return GEHours(null, null)
|
||||
} else {
|
||||
val match = regex.find(str)
|
||||
?: throw IllegalArgumentException("$str does not match hours format")
|
||||
return Hours(
|
||||
LocalTime.parse(match.groupValues[1]),
|
||||
LocalTime.parse(match.groupValues[2])
|
||||
)
|
||||
if (match != null) {
|
||||
return GEHours(
|
||||
LocalTime.parse(match.groupValues[1]),
|
||||
LocalTime.parse(match.groupValues[2])
|
||||
)
|
||||
} else {
|
||||
// I cannot reproduce this case, but it seems to occur once in a while
|
||||
Log.e("GoingElectricApi", "invalid hours value: " + str)
|
||||
return GEHours(
|
||||
LocalTime.MIN, LocalTime.MIN
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ToJson
|
||||
fun toJson(value: Hours): String {
|
||||
fun toJson(value: GEHours): String {
|
||||
if (value.start == null || value.end == null) {
|
||||
return "closed"
|
||||
} else {
|
||||
|
||||
@@ -1,22 +1,33 @@
|
||||
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.withContext
|
||||
import net.vonforst.evmap.BuildConfig
|
||||
import net.vonforst.evmap.R
|
||||
import net.vonforst.evmap.api.*
|
||||
import net.vonforst.evmap.model.*
|
||||
import net.vonforst.evmap.ui.cluster
|
||||
import net.vonforst.evmap.viewmodel.Resource
|
||||
import net.vonforst.evmap.viewmodel.getClusterDistance
|
||||
import okhttp3.Cache
|
||||
import okhttp3.OkHttpClient
|
||||
import retrofit2.Call
|
||||
import retrofit2.Response
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.converter.moshi.MoshiConverterFactory
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.Query
|
||||
import java.io.IOException
|
||||
|
||||
interface GoingElectricApi {
|
||||
@GET("chargepoints/")
|
||||
suspend fun getChargepoints(
|
||||
@Query("sw_lat") swlat: Double, @Query("sw_lng") sw_lng: Double,
|
||||
@Query("sw_lat") sw_lat: Double, @Query("sw_lng") sw_lng: Double,
|
||||
@Query("ne_lat") ne_lat: Double, @Query("ne_lng") ne_lng: Double,
|
||||
@Query("zoom") zoom: Float,
|
||||
@Query("clustering") clustering: Boolean = false,
|
||||
@@ -27,26 +38,55 @@ interface GoingElectricApi {
|
||||
@Query("plugs") plugs: String? = null,
|
||||
@Query("chargecards") chargecards: String? = null,
|
||||
@Query("networks") networks: String? = null,
|
||||
@Query("categories") categories: String? = null,
|
||||
@Query("startkey") startkey: Int? = null,
|
||||
@Query("open_twentyfourseven") open247: Boolean = false,
|
||||
@Query("barrierfree") barrierfree: Boolean = false,
|
||||
@Query("exclude_faults") excludeFaults: Boolean = false
|
||||
): Response<ChargepointList>
|
||||
): Response<GEChargepointList>
|
||||
|
||||
@GET("chargepoints/")
|
||||
fun getChargepointDetail(@Query("ge_id") id: Long): Call<ChargepointList>
|
||||
suspend fun getChargepointsRadius(
|
||||
@Query("lat") lat: Double, @Query("lng") lng: Double,
|
||||
@Query("radius") radius: Int,
|
||||
@Query("zoom") zoom: Float,
|
||||
@Query("orderby") orderby: String = "distance",
|
||||
@Query("clustering") clustering: Boolean = false,
|
||||
@Query("cluster_distance") clusterDistance: Int? = null,
|
||||
@Query("freecharging") freecharging: Boolean = false,
|
||||
@Query("freeparking") freeparking: Boolean = false,
|
||||
@Query("min_power") minPower: Int = 0,
|
||||
@Query("plugs") plugs: String? = null,
|
||||
@Query("chargecards") chargecards: String? = null,
|
||||
@Query("networks") networks: String? = null,
|
||||
@Query("categories") categories: String? = null,
|
||||
@Query("startkey") startkey: Int? = null,
|
||||
@Query("open_twentyfourseven") open247: Boolean = false,
|
||||
@Query("barrierfree") barrierfree: Boolean = false,
|
||||
@Query("exclude_faults") excludeFaults: Boolean = false
|
||||
): Response<GEChargepointList>
|
||||
|
||||
@GET("chargepoints/")
|
||||
suspend fun getChargepointDetail(@Query("ge_id") id: Long): Response<GEChargepointList>
|
||||
|
||||
@GET("chargepoints/pluglist/")
|
||||
suspend fun getPlugs(): Response<StringList>
|
||||
suspend fun getPlugs(): Response<GEStringList>
|
||||
|
||||
@GET("chargepoints/networklist/")
|
||||
suspend fun getNetworks(): Response<StringList>
|
||||
suspend fun getNetworks(): Response<GEStringList>
|
||||
|
||||
@GET("chargepoints/chargecardlist/")
|
||||
suspend fun getChargeCards(): Response<ChargeCardList>
|
||||
suspend fun getChargeCards(): Response<GEChargeCardList>
|
||||
|
||||
companion object {
|
||||
private val cacheSize = 10L * 1024 * 1024; // 10MB
|
||||
private val cacheSize = 10L * 1024 * 1024 // 10MB
|
||||
|
||||
private val moshi = Moshi.Builder()
|
||||
.add(ChargepointListItemJsonAdapterFactory())
|
||||
.add(JsonObjectOrFalseAdapter.Factory())
|
||||
.add(HoursAdapter())
|
||||
.add(InstantAdapter())
|
||||
.build()
|
||||
|
||||
fun create(
|
||||
apikey: String,
|
||||
@@ -57,7 +97,7 @@ interface GoingElectricApi {
|
||||
addInterceptor { chain ->
|
||||
// add API key to every request
|
||||
var original = chain.request()
|
||||
val url = original.url().newBuilder().addQueryParameter("key", apikey).build()
|
||||
val url = original.url.newBuilder().addQueryParameter("key", apikey).build()
|
||||
original = original.newBuilder().url(url).build()
|
||||
chain.proceed(original)
|
||||
}
|
||||
@@ -69,13 +109,6 @@ interface GoingElectricApi {
|
||||
}
|
||||
}.build()
|
||||
|
||||
val moshi = Moshi.Builder()
|
||||
.add(ChargepointListItemJsonAdapterFactory())
|
||||
.add(JsonObjectOrFalseAdapter.Factory())
|
||||
.add(HoursAdapter())
|
||||
.add(InstantAdapter())
|
||||
.build()
|
||||
|
||||
val retrofit = Retrofit.Builder()
|
||||
.baseUrl(baseurl)
|
||||
.addConverterFactory(MoshiConverterFactory.create(moshi))
|
||||
@@ -85,3 +118,362 @@ 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) {
|
||||
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")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,297 +1,241 @@
|
||||
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.android.parcel.Parcelize
|
||||
import net.vonforst.evmap.R
|
||||
import net.vonforst.evmap.adapter.Equatable
|
||||
import java.time.DayOfWeek
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import net.vonforst.evmap.model.*
|
||||
import java.time.Instant
|
||||
import java.time.LocalDate
|
||||
import java.time.LocalTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.format.FormatStyle
|
||||
import java.util.*
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.floor
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class ChargepointList(
|
||||
data class GEChargepointList(
|
||||
val status: String,
|
||||
val chargelocations: List<ChargepointListItem>,
|
||||
val chargelocations: List<GEChargepointListItem>,
|
||||
@JsonObjectOrFalse val startkey: Int?
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class StringList(
|
||||
data class GEStringList(
|
||||
val status: String,
|
||||
val result: List<String>
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class ChargeCardList(
|
||||
data class GEChargeCardList(
|
||||
val status: String,
|
||||
val result: List<ChargeCard>
|
||||
val result: List<GEChargeCard>
|
||||
)
|
||||
|
||||
sealed class ChargepointListItem
|
||||
sealed class GEChargepointListItem {
|
||||
abstract fun convert(apikey: String): ChargepointListItem
|
||||
}
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
@Entity
|
||||
data class ChargeLocation(
|
||||
@Json(name = "ge_id") @PrimaryKey val id: Long,
|
||||
data class GEChargeLocation(
|
||||
@Json(name = "ge_id") val id: Long,
|
||||
val name: String,
|
||||
@Embedded val coordinates: Coordinate,
|
||||
@Embedded val address: Address,
|
||||
val chargepoints: List<Chargepoint>,
|
||||
val coordinates: GECoordinate,
|
||||
val address: GEAddress,
|
||||
val chargepoints: List<GEChargepoint>,
|
||||
@JsonObjectOrFalse val network: String?,
|
||||
val url: String,
|
||||
@Embedded(prefix = "fault_report_") @JsonObjectOrFalse @Json(name = "fault_report") val faultReport: FaultReport?,
|
||||
@JsonObjectOrFalse @Json(name = "fault_report") val faultReport: GEFaultReport?,
|
||||
val verified: Boolean,
|
||||
@Json(name = "barrierfree") val barrierFree: Boolean?,
|
||||
// only shown in details:
|
||||
@JsonObjectOrFalse val operator: String?,
|
||||
@JsonObjectOrFalse @Json(name = "general_information") val generalInformation: String?,
|
||||
@JsonObjectOrFalse @Json(name = "ladeweile") val amenities: String?,
|
||||
@JsonObjectOrFalse @Json(name = "location_description") val locationDescription: String?,
|
||||
val photos: List<ChargerPhoto>?,
|
||||
@JsonObjectOrFalse val chargecards: List<ChargeCardId>?,
|
||||
@Embedded val openinghours: OpeningHours?,
|
||||
@Embedded val cost: Cost?
|
||||
) : ChargepointListItem(), Equatable {
|
||||
/**
|
||||
* maximum power available from this charger.
|
||||
*/
|
||||
val maxPower: Double
|
||||
get() {
|
||||
return maxPower()
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the maximum power available from certain connectors of this charger.
|
||||
*/
|
||||
fun maxPower(connectors: Set<String>? = null): Double {
|
||||
return chargepoints.filter { connectors?.contains(it.type) ?: true }
|
||||
.map { it.power }.max() ?: 0.0
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges chargepoints if they have the same plug and power
|
||||
*
|
||||
* This occurs e.g. for Type2 sockets and plugs, which are distinct on the GE website, but not
|
||||
* separable in the API
|
||||
*/
|
||||
val chargepointsMerged: List<Chargepoint>
|
||||
get() {
|
||||
val variants = chargepoints.distinctBy { it.power to it.type }
|
||||
return variants.map { variant ->
|
||||
val count = chargepoints
|
||||
.filter { it.type == variant.type && it.power == variant.power }
|
||||
.sumBy { it.count }
|
||||
Chargepoint(variant.type, variant.power, count)
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
name,
|
||||
coordinates.convert(),
|
||||
address.convert(),
|
||||
chargepoints.map { it.convert() },
|
||||
network,
|
||||
"https:${url}",
|
||||
"https:${url}edit/",
|
||||
faultReport?.convert(),
|
||||
verified,
|
||||
barrierFree,
|
||||
operator,
|
||||
generalInformation,
|
||||
amenities,
|
||||
locationDescription,
|
||||
photos?.map { it.convert(apikey) },
|
||||
chargecards?.map { it.convert() },
|
||||
openinghours?.convert(),
|
||||
cost?.convert(),
|
||||
null,
|
||||
ChargepriceData(address.country, network, chargepoints.map { it.type })
|
||||
)
|
||||
}
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class Cost(
|
||||
data class GECost(
|
||||
val freecharging: Boolean,
|
||||
val freeparking: Boolean,
|
||||
@JsonObjectOrFalse @Json(name = "description_short") val descriptionShort: String?,
|
||||
@JsonObjectOrFalse @Json(name = "description_long") val descriptionLong: String?
|
||||
) {
|
||||
fun getStatusText(ctx: Context): CharSequence {
|
||||
return HtmlCompat.fromHtml(
|
||||
ctx.getString(
|
||||
R.string.cost_detail,
|
||||
if (freecharging) ctx.getString(R.string.free) else ctx.getString(R.string.paid),
|
||||
if (freeparking) ctx.getString(R.string.free) else ctx.getString(R.string.paid)
|
||||
), 0
|
||||
)
|
||||
}
|
||||
fun convert() = Cost(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"
|
||||
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()
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
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,
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
package net.vonforst.evmap.fragment
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.appcompat.widget.Toolbar
|
||||
import androidx.databinding.DataBindingUtil
|
||||
import androidx.fragment.app.DialogFragment
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.lifecycle.Observer
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.navigation.ui.setupWithNavController
|
||||
import androidx.recyclerview.widget.DividerItemDecoration
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
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.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
|
||||
import java.text.NumberFormat
|
||||
|
||||
class ChargepriceFragment : DialogFragment() {
|
||||
private lateinit var binding: FragmentChargepriceBinding
|
||||
private var connectionErrorSnackbar: Snackbar? = null
|
||||
|
||||
private val vm: ChargepriceViewModel by viewModels(factoryProducer = {
|
||||
viewModelFactory {
|
||||
ChargepriceViewModel(
|
||||
requireActivity().application,
|
||||
getString(R.string.chargeprice_key)
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
override fun onActivityCreated(savedInstanceState: Bundle?) {
|
||||
super.onActivityCreated(savedInstanceState)
|
||||
dialog?.window?.attributes?.windowAnimations = R.style.ChargepriceDialogAnimation
|
||||
}
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View {
|
||||
binding = DataBindingUtil.inflate(
|
||||
inflater,
|
||||
R.layout.fragment_chargeprice, container, false
|
||||
)
|
||||
binding.lifecycleOwner = this
|
||||
binding.vm = vm
|
||||
|
||||
binding.toolbar.inflateMenu(R.menu.chargeprice)
|
||||
binding.toolbar.setTitle(R.string.chargeprice_title)
|
||||
|
||||
return binding.root
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
|
||||
// dialog with 95% screen height
|
||||
dialog?.window?.setLayout(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
(resources.displayMetrics.heightPixels * 0.95).toInt()
|
||||
)
|
||||
}
|
||||
|
||||
@SuppressLint("ClickableViewAccessibility")
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
val toolbar = view.findViewById(R.id.toolbar) as Toolbar
|
||||
|
||||
val navController = findNavController()
|
||||
toolbar.setupWithNavController(
|
||||
navController,
|
||||
(requireActivity() as MapsActivity).appBarConfiguration
|
||||
)
|
||||
|
||||
val charger = requireArguments().getParcelable<ChargeLocation>(ARG_CHARGER)!!
|
||||
val dataSource = requireArguments().getString(ARG_DATASOURCE)!!
|
||||
vm.charger.value = charger
|
||||
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)
|
||||
}
|
||||
}
|
||||
binding.chargePricesList.apply {
|
||||
adapter = chargepriceAdapter
|
||||
layoutManager =
|
||||
LinearLayoutManager(context, LinearLayoutManager.VERTICAL, false)
|
||||
addItemDecoration(
|
||||
DividerItemDecoration(
|
||||
context, LinearLayoutManager.VERTICAL
|
||||
)
|
||||
)
|
||||
}
|
||||
vm.chargepriceMetaForChargepoint.observe(viewLifecycleOwner) {
|
||||
chargepriceAdapter.meta = it?.data
|
||||
}
|
||||
vm.myTariffs.observe(viewLifecycleOwner) {
|
||||
chargepriceAdapter.myTariffs = it
|
||||
}
|
||||
vm.myTariffsAll.observe(viewLifecycleOwner) {
|
||||
chargepriceAdapter.myTariffsAll = it
|
||||
}
|
||||
|
||||
val connectorsAdapter = CheckableConnectorAdapter()
|
||||
|
||||
val observer: Observer<Chargepoint> = Observer {
|
||||
connectorsAdapter.setCheckedItem(it)
|
||||
}
|
||||
vm.chargepoint.observe(viewLifecycleOwner, observer)
|
||||
connectorsAdapter.onCheckedItemChangedListener = {
|
||||
vm.chargepoint.removeObserver(observer)
|
||||
vm.chargepoint.value = it
|
||||
vm.chargepoint.observe(viewLifecycleOwner, observer)
|
||||
}
|
||||
|
||||
vm.vehicleCompatibleConnectors.observe(viewLifecycleOwner) { plugs ->
|
||||
connectorsAdapter.enabledConnectors =
|
||||
plugs?.flatMap { plug -> equivalentPlugTypes(plug) }
|
||||
}
|
||||
|
||||
binding.connectorsList.apply {
|
||||
adapter = connectorsAdapter
|
||||
layoutManager = LinearLayoutManager(context, LinearLayoutManager.HORIZONTAL, false)
|
||||
}
|
||||
|
||||
binding.imgChargepriceLogo.setOnClickListener {
|
||||
(requireActivity() as MapsActivity).openUrl("https://www.chargeprice.app/?poi_id=${charger.id}&poi_source=going_electric")
|
||||
}
|
||||
|
||||
binding.btnSettings.setOnClickListener {
|
||||
navController.navigate(R.id.action_chargeprice_to_settingsFragment)
|
||||
}
|
||||
|
||||
binding.batteryRange.setLabelFormatter { value: Float ->
|
||||
val fmt = NumberFormat.getNumberInstance()
|
||||
fmt.maximumFractionDigits = 0
|
||||
fmt.format(value.toDouble()) + "%"
|
||||
}
|
||||
binding.batteryRange.setOnTouchListener { _: View, motionEvent: MotionEvent ->
|
||||
when (motionEvent.actionMasked) {
|
||||
MotionEvent.ACTION_DOWN -> vm.batteryRangeSliderDragging.value = true
|
||||
MotionEvent.ACTION_UP -> vm.batteryRangeSliderDragging.value = false
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
binding.toolbar.setOnMenuItemClickListener {
|
||||
when (it.itemId) {
|
||||
R.id.menu_close -> {
|
||||
dismiss()
|
||||
true
|
||||
}
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
vm.chargePricesForChargepoint.observe(viewLifecycleOwner) { res ->
|
||||
when (res?.status) {
|
||||
Status.ERROR -> {
|
||||
if (vm.vehicle.value == null) return@observe
|
||||
connectionErrorSnackbar?.dismiss()
|
||||
connectionErrorSnackbar = Snackbar
|
||||
.make(
|
||||
view,
|
||||
R.string.chargeprice_connection_error,
|
||||
Snackbar.LENGTH_INDEFINITE
|
||||
)
|
||||
.setAction(R.string.retry) {
|
||||
connectionErrorSnackbar?.dismiss()
|
||||
vm.loadPrices()
|
||||
}
|
||||
connectionErrorSnackbar!!.show()
|
||||
}
|
||||
Status.SUCCESS, null -> {
|
||||
connectionErrorSnackbar?.dismiss()
|
||||
}
|
||||
Status.LOADING -> {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val ARG_CHARGER = "charger"
|
||||
const val ARG_DATASOURCE = "datasource"
|
||||
|
||||
fun showCharger(
|
||||
charger: ChargeLocation,
|
||||
dataSource: Class<ChargepointApi<ReferenceData>>
|
||||
): Bundle {
|
||||
return Bundle().apply {
|
||||
putParcelable(
|
||||
ARG_CHARGER,
|
||||
charger
|
||||
)
|
||||
putString(
|
||||
ARG_DATASOURCE,
|
||||
when (dataSource) {
|
||||
GoingElectricApiWrapper::class.java -> "going_electric"
|
||||
OpenChargeMapApiWrapper::class.java -> "open_charge_map"
|
||||
else -> throw IllegalArgumentException("unsupported data source")
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,13 +95,15 @@ class FavoritesFragment : Fragment(), LostApiClient.ConnectionCallbacks {
|
||||
)
|
||||
)
|
||||
}
|
||||
createTouchHelper().attachToRecyclerView(binding.favsList)
|
||||
|
||||
locationClient.connect()
|
||||
}
|
||||
|
||||
override fun onConnected() {
|
||||
val context = this.context ?: return
|
||||
if (ContextCompat.checkSelfPermission(
|
||||
requireContext(),
|
||||
context,
|
||||
Manifest.permission.ACCESS_FINE_LOCATION
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
@@ -101,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
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -17,31 +17,27 @@ import net.vonforst.evmap.MapsActivity
|
||||
import net.vonforst.evmap.R
|
||||
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,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View? {
|
||||
): View {
|
||||
binding = DataBindingUtil.inflate(inflater, R.layout.fragment_filter, container, false)
|
||||
binding.lifecycleOwner = this
|
||||
binding.vm = vm
|
||||
|
||||
setHasOptionsMenu(true)
|
||||
|
||||
vm.filterProfile.observe(viewLifecycleOwner) {}
|
||||
|
||||
return binding.root
|
||||
}
|
||||
|
||||
@@ -85,6 +81,26 @@ class FilterFragment : Fragment() {
|
||||
}
|
||||
true
|
||||
}
|
||||
R.id.menu_save_profile -> {
|
||||
showEditTextDialog(requireContext()) { dialog, input ->
|
||||
vm.filterProfile.value?.let { profile ->
|
||||
input.setText(profile.name)
|
||||
}
|
||||
|
||||
dialog.setTitle(R.string.save_as_profile)
|
||||
.setMessage(R.string.save_profile_enter_name)
|
||||
.setPositiveButton(R.string.ok) { di, button ->
|
||||
lifecycleScope.launch {
|
||||
vm.saveAsProfile(input.text.toString())
|
||||
findNavController().popBackStack()
|
||||
}
|
||||
}
|
||||
.setNegativeButton(R.string.cancel) { di, button ->
|
||||
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
else -> super.onOptionsItemSelected(item)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
package net.vonforst.evmap.fragment
|
||||
|
||||
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.app.AppCompatActivity
|
||||
import androidx.appcompat.widget.Toolbar
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
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.google.android.material.snackbar.Snackbar
|
||||
import kotlinx.coroutines.launch
|
||||
import net.vonforst.evmap.MapsActivity
|
||||
import net.vonforst.evmap.R
|
||||
import net.vonforst.evmap.adapter.DataBindingAdapter
|
||||
import net.vonforst.evmap.adapter.FilterProfilesAdapter
|
||||
import net.vonforst.evmap.databinding.FragmentFilterProfilesBinding
|
||||
import net.vonforst.evmap.databinding.ItemFilterProfileBinding
|
||||
import net.vonforst.evmap.storage.FilterProfile
|
||||
import net.vonforst.evmap.ui.showEditTextDialog
|
||||
import net.vonforst.evmap.viewmodel.FilterProfilesViewModel
|
||||
import net.vonforst.evmap.viewmodel.viewModelFactory
|
||||
|
||||
|
||||
class FilterProfilesFragment : Fragment() {
|
||||
private lateinit var touchHelper: ItemTouchHelper
|
||||
private lateinit var adapter: FilterProfilesAdapter
|
||||
private lateinit var binding: FragmentFilterProfilesBinding
|
||||
private val vm: FilterProfilesViewModel by viewModels(factoryProducer = {
|
||||
viewModelFactory {
|
||||
FilterProfilesViewModel(requireActivity().application)
|
||||
}
|
||||
})
|
||||
private var deleteSnackbar: Snackbar? = null
|
||||
private var toDelete: FilterProfile? = null
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View {
|
||||
binding = FragmentFilterProfilesBinding.inflate(inflater, container, false)
|
||||
binding.lifecycleOwner = this
|
||||
binding.vm = vm
|
||||
|
||||
return binding.root
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
val toolbar = view.findViewById(R.id.toolbar) as Toolbar
|
||||
(requireActivity() as AppCompatActivity).setSupportActionBar(toolbar)
|
||||
|
||||
val navController = findNavController()
|
||||
toolbar.setupWithNavController(
|
||||
navController,
|
||||
(requireActivity() as MapsActivity).appBarConfiguration
|
||||
)
|
||||
|
||||
|
||||
touchHelper = ItemTouchHelper(object : ItemTouchHelper.SimpleCallback(
|
||||
ItemTouchHelper.UP or ItemTouchHelper.DOWN,
|
||||
ItemTouchHelper.RIGHT or ItemTouchHelper.LEFT
|
||||
) {
|
||||
override fun onMove(
|
||||
recyclerView: RecyclerView,
|
||||
viewHolder: RecyclerView.ViewHolder,
|
||||
target: RecyclerView.ViewHolder
|
||||
): Boolean {
|
||||
val fromPos = viewHolder.adapterPosition;
|
||||
val toPos = target.adapterPosition;
|
||||
|
||||
val list = vm.filterProfiles.value?.toMutableList()
|
||||
if (list != null) {
|
||||
val item = list[fromPos]
|
||||
list.removeAt(fromPos)
|
||||
list.add(toPos, item)
|
||||
list.forEachIndexed { index, filterProfile ->
|
||||
filterProfile.order = index
|
||||
}
|
||||
vm.reorderProfiles(list)
|
||||
}
|
||||
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
|
||||
val fp = vm.filterProfiles.value?.find { it.id == viewHolder.itemId }
|
||||
fp?.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 ItemFilterProfileBinding
|
||||
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 ItemFilterProfileBinding
|
||||
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 ItemFilterProfileBinding
|
||||
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 ItemFilterProfileBinding
|
||||
getDefaultUIUtil().onDraw(
|
||||
c, recyclerView, binding.foreground, dX, dY,
|
||||
actionState, isCurrentlyActive
|
||||
)
|
||||
} else {
|
||||
super.onChildDraw(
|
||||
c,
|
||||
recyclerView,
|
||||
viewHolder,
|
||||
dX,
|
||||
dY,
|
||||
actionState,
|
||||
isCurrentlyActive
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
adapter = FilterProfilesAdapter(touchHelper, onDelete = { fp ->
|
||||
delete(fp)
|
||||
}, onRename = { fp ->
|
||||
showEditTextDialog(requireContext()) { dialog, input ->
|
||||
input.setText(fp.name)
|
||||
|
||||
dialog.setTitle(R.string.rename)
|
||||
.setMessage(R.string.save_profile_enter_name)
|
||||
.setPositiveButton(R.string.ok) { di, button ->
|
||||
lifecycleScope.launch {
|
||||
vm.update(fp.copy(name = input.text.toString()))
|
||||
}
|
||||
}
|
||||
.setNegativeButton(R.string.cancel) { di, button ->
|
||||
|
||||
}
|
||||
}
|
||||
})
|
||||
binding.filterProfilesList.apply {
|
||||
this.adapter = this@FilterProfilesFragment.adapter
|
||||
layoutManager =
|
||||
LinearLayoutManager(context, LinearLayoutManager.VERTICAL, false)
|
||||
addItemDecoration(
|
||||
DividerItemDecoration(
|
||||
context, LinearLayoutManager.VERTICAL
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
touchHelper.attachToRecyclerView(binding.filterProfilesList)
|
||||
|
||||
toolbar.setNavigationOnClickListener {
|
||||
findNavController().popBackStack()
|
||||
}
|
||||
}
|
||||
|
||||
fun delete(fp: FilterProfile) {
|
||||
val position = vm.filterProfiles.value?.indexOf(fp) ?: return
|
||||
// if there is already a profile to delete, delete it now
|
||||
actuallyDelete()
|
||||
deleteSnackbar?.dismiss()
|
||||
|
||||
toDelete = fp
|
||||
|
||||
view?.let {
|
||||
val snackbar = Snackbar.make(
|
||||
it,
|
||||
getString(R.string.deleted_filterprofile, fp.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.delete(it.id) }
|
||||
toDelete = null
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
super.onStop()
|
||||
actuallyDelete()
|
||||
}
|
||||
}
|
||||
@@ -1,130 +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 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 SAVED_CURRENT_PAGE_POSITION = "current_page_position"
|
||||
|
||||
fun buildArgs(photos: List<ChargerPhoto>, position: Int): Bundle {
|
||||
return Bundle().apply {
|
||||
putParcelableArrayList(EXTRA_PHOTOS, ArrayList(photos))
|
||||
putInt(EXTRA_POSITION, position)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private lateinit var binding: FragmentGalleryBinding
|
||||
private var isReturning: Boolean = false
|
||||
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 {
|
||||
isReturning = true
|
||||
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) {
|
||||
startPostponedEnterTransition()
|
||||
}
|
||||
binding.gallery.setPageTransformer { page, position ->
|
||||
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>
|
||||
) {
|
||||
if (isReturning) {
|
||||
val currentPage = currentPage ?: return
|
||||
sharedElements[names[0]] = currentPage
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -7,17 +7,20 @@ import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.content.res.Configuration
|
||||
import android.graphics.Color
|
||||
import android.location.Geocoder
|
||||
import android.location.Location
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.view.*
|
||||
import android.widget.ImageView
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
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
|
||||
import androidx.databinding.DataBindingUtil
|
||||
import androidx.fragment.app.Fragment
|
||||
@@ -25,14 +28,18 @@ import androidx.fragment.app.activityViewModels
|
||||
import androidx.fragment.app.viewModels
|
||||
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
|
||||
@@ -47,48 +54,50 @@ import com.mahc.custombottomsheetbehavior.BottomSheetBehaviorGoogleMapsLike
|
||||
import com.mahc.custombottomsheetbehavior.BottomSheetBehaviorGoogleMapsLike.STATE_COLLAPSED
|
||||
import com.mahc.custombottomsheetbehavior.BottomSheetBehaviorGoogleMapsLike.STATE_HIDDEN
|
||||
import com.mahc.custombottomsheetbehavior.MergedAppBarLayoutBehavior
|
||||
import com.mapzen.android.lost.api.LocationListener
|
||||
import com.mapzen.android.lost.api.LocationRequest
|
||||
import com.mapzen.android.lost.api.LocationServices
|
||||
import com.mapzen.android.lost.api.LostApiClient
|
||||
import com.stfalcon.imageviewer.StfalconImageViewer
|
||||
import io.michaelrocks.bimap.HashBiMap
|
||||
import io.michaelrocks.bimap.MutableBiMap
|
||||
import kotlinx.android.synthetic.main.fragment_map.*
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
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.*
|
||||
|
||||
|
||||
const val REQUEST_AUTOCOMPLETE = 2
|
||||
const val ARG_CHARGER_ID = "chargerId"
|
||||
const val ARG_LAT = "lat"
|
||||
const val ARG_LON = "lon"
|
||||
const val ARG_LOCATION_NAME = "locationName"
|
||||
|
||||
class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallback,
|
||||
LostApiClient.ConnectionCallbacks {
|
||||
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 lateinit var mapFragment: MapFragment
|
||||
private var mapFragment: MapFragment? = null
|
||||
private var map: AnyMap? = null
|
||||
private lateinit var locationClient: LostApiClient
|
||||
private var requestingLocationUpdates = false
|
||||
private lateinit var bottomSheetBehavior: BottomSheetBehaviorGoogleMapsLike<View>
|
||||
private lateinit var detailAppBarBehavior: MergedAppBarLayoutBehavior
|
||||
private var markers: MutableBiMap<Marker, ChargeLocation> = HashBiMap()
|
||||
@@ -121,19 +130,29 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
locationClient = LostApiClient.Builder(requireContext())
|
||||
.addConnectionCallbacks(this)
|
||||
.build()
|
||||
locationClient.connect()
|
||||
clusterIconGenerator = ClusterIconGenerator(requireContext())
|
||||
}
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View? {
|
||||
): View {
|
||||
binding = DataBindingUtil.inflate(inflater, R.layout.fragment_map, container, false)
|
||||
binding.lifecycleOwner = this
|
||||
binding.vm = vm
|
||||
|
||||
mapFragment = MapFragment()
|
||||
val provider = PreferenceDataSource(requireContext()).mapProvider
|
||||
mapFragment.setPriority(
|
||||
arrayOf(
|
||||
if (mapFragment == null || mapFragment!!.priority[0] != provider) {
|
||||
mapFragment = MapFragment()
|
||||
mapFragment!!.priority = arrayOf(
|
||||
when (provider) {
|
||||
"mapbox" -> MapFragment.MAPBOX
|
||||
"google" -> MapFragment.GOOGLE
|
||||
@@ -142,37 +161,40 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
||||
MapFragment.GOOGLE,
|
||||
MapFragment.MAPBOX
|
||||
)
|
||||
)
|
||||
requireActivity().supportFragmentManager
|
||||
.beginTransaction()
|
||||
.replace(R.id.map, mapFragment)
|
||||
.commit()
|
||||
requireActivity().supportFragmentManager
|
||||
.beginTransaction()
|
||||
.replace(R.id.map, mapFragment!!)
|
||||
.commit()
|
||||
|
||||
// reset map-related stuff (map provider may have changed)
|
||||
map = null
|
||||
markers.clear()
|
||||
clusterMarkers = emptyList()
|
||||
searchResultMarker = null
|
||||
searchResultIcon = null
|
||||
|
||||
|
||||
locationClient = LostApiClient.Builder(requireContext())
|
||||
.addConnectionCallbacks(this)
|
||||
.build()
|
||||
locationClient.connect()
|
||||
clusterIconGenerator = ClusterIconGenerator(requireContext())
|
||||
// reset map-related stuff (map provider may have changed)
|
||||
map = null
|
||||
markers.clear()
|
||||
clusterMarkers = emptyList()
|
||||
searchResultMarker = null
|
||||
searchResultIcon = null
|
||||
}
|
||||
|
||||
setHasOptionsMenu(true)
|
||||
postponeEnterTransition()
|
||||
|
||||
binding.root.setOnApplyWindowInsetsListener { v, insets ->
|
||||
binding.root.setOnApplyWindowInsetsListener { _, insets ->
|
||||
binding.detailAppBar.toolbar.updateLayoutParams<ViewGroup.MarginLayoutParams> {
|
||||
topMargin = insets.systemWindowInsetTop
|
||||
}
|
||||
|
||||
// margin of layers button
|
||||
val density = resources.displayMetrics.density
|
||||
// status bar height + toolbar height + margin
|
||||
val margin =
|
||||
insets.systemWindowInsetTop + (48 * density).toInt() + (24 * density).toInt()
|
||||
binding.fabLayers.updateLayoutParams<ViewGroup.MarginLayoutParams> {
|
||||
topMargin = margin
|
||||
}
|
||||
binding.layersSheet.updateLayoutParams<ViewGroup.MarginLayoutParams> {
|
||||
topMargin = margin
|
||||
}
|
||||
insets
|
||||
}
|
||||
|
||||
setExitSharedElementCallback(exitElementCallback)
|
||||
exitTransition = TransitionInflater.from(requireContext())
|
||||
.inflateTransition(R.transition.map_exit_transition)
|
||||
|
||||
@@ -185,29 +207,48 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
mapFragment.getMapAsync(this)
|
||||
mapFragment!!.getMapAsync(this)
|
||||
bottomSheetBehavior = BottomSheetBehaviorGoogleMapsLike.from(binding.bottomSheet)
|
||||
detailAppBarBehavior = MergedAppBarLayoutBehavior.from(binding.detailAppBar)
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
/*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(),
|
||||
ACCESS_FINE_LOCATION
|
||||
) == PackageManager.PERMISSION_GRANTED && locationClient.isConnected
|
||||
) {
|
||||
requestLocationUpdates()
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupClickListeners() {
|
||||
@@ -236,16 +277,21 @@ 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
|
||||
(activity as? MapsActivity)?.openUrl(
|
||||
"https://www.chargeprice.app/?poi_id=${charger.id}&poi_source=going_electric")
|
||||
findNavController().navigate(
|
||||
R.id.action_map_to_chargepriceFragment,
|
||||
ChargepriceFragment.showCharger(charger, vm.apiType)
|
||||
)
|
||||
}
|
||||
binding.detailView.topPart.setOnClickListener {
|
||||
bottomSheetBehavior.state = BottomSheetBehaviorGoogleMapsLike.STATE_ANCHOR_POINT
|
||||
@@ -265,7 +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?.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
|
||||
}
|
||||
@@ -321,7 +382,12 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
||||
vm.bottomSheetState.value = newState
|
||||
updateBackPressedCallback()
|
||||
|
||||
if (vm.layersMenuOpen.value!! && newState !in listOf(BottomSheetBehaviorGoogleMapsLike.STATE_SETTLING, BottomSheetBehaviorGoogleMapsLike.STATE_HIDDEN, BottomSheetBehaviorGoogleMapsLike.STATE_COLLAPSED)) {
|
||||
if (vm.layersMenuOpen.value!! && newState !in listOf(
|
||||
BottomSheetBehaviorGoogleMapsLike.STATE_SETTLING,
|
||||
BottomSheetBehaviorGoogleMapsLike.STATE_HIDDEN,
|
||||
BottomSheetBehaviorGoogleMapsLike.STATE_COLLAPSED
|
||||
)
|
||||
) {
|
||||
closeLayersMenu()
|
||||
}
|
||||
}
|
||||
@@ -375,6 +441,8 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
||||
searchResultMarker = null
|
||||
|
||||
if (place != null) {
|
||||
// disable location following when search result is shown
|
||||
vm.myLocationEnabled.value = false
|
||||
if (place.viewport != null) {
|
||||
map.animateCamera(map.cameraUpdateFactory.newLatLngBounds(place.viewport, 0))
|
||||
} else {
|
||||
@@ -424,7 +492,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
||||
getMarkerTint(c, vm.filteredConnectors.value),
|
||||
highlight = false,
|
||||
fault = c.faultReport != null,
|
||||
multi = getMarkerMulti(c, vm.filteredConnectors.value)
|
||||
multi = c.isMulti(vm.filteredConnectors.value)
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -438,7 +506,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
||||
getMarkerTint(charger, vm.filteredConnectors.value),
|
||||
highlight = true,
|
||||
fault = charger.faultReport != null,
|
||||
multi = getMarkerMulti(charger, vm.filteredConnectors.value)
|
||||
multi = charger.isMulti(vm.filteredConnectors.value)
|
||||
)
|
||||
)
|
||||
animator.animateMarkerBounce(marker)
|
||||
@@ -451,28 +519,13 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
||||
getMarkerTint(c, vm.filteredConnectors.value),
|
||||
highlight = false,
|
||||
fault = c.faultReport != null,
|
||||
multi = getMarkerMulti(c, vm.filteredConnectors.value)
|
||||
multi = c.isMulti(vm.filteredConnectors.value)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getMarkerMulti(charger: ChargeLocation, filteredConnectors: Set<String>?): Boolean {
|
||||
var chargepoints = charger.chargepointsMerged
|
||||
.filter { filteredConnectors?.contains(it.type) ?: true }
|
||||
if (charger.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 }
|
||||
}
|
||||
|
||||
private fun updateFavoriteToggle() {
|
||||
val favs = vm.favorites.value ?: return
|
||||
val charger = vm.chargerSparse.value ?: return
|
||||
@@ -484,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) {
|
||||
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),
|
||||
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)
|
||||
@@ -509,18 +573,9 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
||||
DividerItemDecoration(
|
||||
context, LinearLayoutManager.HORIZONTAL
|
||||
).apply {
|
||||
setDrawable(context.getDrawable(R.drawable.gallery_divider)!!)
|
||||
setDrawable(ContextCompat.getDrawable(context, R.drawable.gallery_divider)!!)
|
||||
})
|
||||
}
|
||||
if (galleryPosition == null) {
|
||||
startPostponedEnterTransition()
|
||||
} else {
|
||||
binding.gallery.scrollToPosition(galleryPosition)
|
||||
// make sure that the app does not freeze waiting for a picture to load
|
||||
Handler().postDelayed({
|
||||
startPostponedEnterTransition()
|
||||
}, 500)
|
||||
}
|
||||
|
||||
binding.detailView.connectors.apply {
|
||||
adapter = ConnectorAdapter()
|
||||
@@ -535,11 +590,11 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
||||
val charger = vm.chargerDetails.value?.data
|
||||
if (charger != null) {
|
||||
when (it.icon) {
|
||||
R.drawable.ic_location -> {
|
||||
R.drawable.ic_location, R.drawable.ic_address -> {
|
||||
(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)
|
||||
@@ -587,6 +642,21 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
||||
override fun onMapReady(map: AnyMap) {
|
||||
this.map = map
|
||||
chargerIconGenerator = ChargerIconGenerator(requireContext(), map.bitmapDescriptorFactory)
|
||||
|
||||
if (BuildConfig.FLAVOR == "google" && mapFragment!!.priority[0] == MapFragment.GOOGLE) {
|
||||
// Google Maps: icons can be generated in background thread
|
||||
lifecycleScope.launch {
|
||||
withContext(Dispatchers.IO) {
|
||||
chargerIconGenerator.preloadCache()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Mapbox: needs to be run on main thread
|
||||
chargerIconGenerator.preloadCache()
|
||||
}
|
||||
|
||||
|
||||
|
||||
animator = MarkerAnimator(chargerIconGenerator)
|
||||
map.uiSettings.setTiltGesturesEnabled(false)
|
||||
map.setIndoorEnabled(false)
|
||||
@@ -595,6 +665,23 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
||||
vm.mapPosition.value = MapPosition(
|
||||
map.projection.visibleRegion.latLngBounds, map.cameraPosition.zoom
|
||||
)
|
||||
binding.scaleView.update(map.cameraPosition.zoom, map.cameraPosition.target.latitude)
|
||||
}
|
||||
map.setOnCameraMoveListener {
|
||||
binding.scaleView.update(map.cameraPosition.zoom, map.cameraPosition.target.latitude)
|
||||
}
|
||||
map.setOnCameraMoveStartedListener { reason ->
|
||||
if (reason == AnyMap.OnCameraMoveStartedListener.REASON_GESTURE) {
|
||||
if (vm.myLocationEnabled.value == true) {
|
||||
// disable location following when manually scrolling the map
|
||||
vm.myLocationEnabled.value = false
|
||||
removeLocationUpdates()
|
||||
}
|
||||
if (vm.layersMenuOpen.value == true) {
|
||||
// close layers menu if open
|
||||
closeLayersMenu()
|
||||
}
|
||||
}
|
||||
}
|
||||
map.setOnMarkerClickListener { marker ->
|
||||
when (marker) {
|
||||
@@ -621,6 +708,8 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
||||
backPressedCallback.handleOnBackPressed()
|
||||
}
|
||||
}
|
||||
map.setMapType(vm.mapType.value)
|
||||
map.setTrafficEnabled(vm.mapTrafficEnabled.value ?: false)
|
||||
|
||||
// set padding so that compass is not obstructed by toolbar
|
||||
map.setPadding(0, binding.toolbarContainer.height, 0, 0)
|
||||
@@ -634,6 +723,9 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
||||
val position = vm.mapPosition.value
|
||||
val lat = arguments?.optDouble(ARG_LAT)
|
||||
val lon = arguments?.optDouble(ARG_LON)
|
||||
val chargerId = arguments?.optLong(ARG_CHARGER_ID)
|
||||
val locationName = arguments?.getString(ARG_LOCATION_NAME)
|
||||
|
||||
var positionSet = false
|
||||
|
||||
if (position != null) {
|
||||
@@ -641,28 +733,72 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
||||
map.cameraUpdateFactory.newLatLngZoom(position.bounds.center, position.zoom)
|
||||
map.moveCamera(cameraUpdate)
|
||||
positionSet = true
|
||||
} else if (lat != null && lon != null) {
|
||||
// show given position
|
||||
val cameraUpdate = map.cameraUpdateFactory.newLatLngZoom(LatLng(lat, lon), 16f)
|
||||
map.moveCamera(cameraUpdate)
|
||||
|
||||
// show charger detail after chargers were loaded
|
||||
val chargerId = arguments?.optLong(ARG_CHARGER_ID)
|
||||
vm.chargepoints.observe(
|
||||
} else if (chargerId != null && (lat == null || lon == null)) {
|
||||
// show given charger ID
|
||||
vm.loadChargerById(chargerId)
|
||||
vm.chargerSparse.observe(
|
||||
viewLifecycleOwner,
|
||||
object : Observer<Resource<List<ChargepointListItem>>> {
|
||||
override fun onChanged(res: Resource<List<ChargepointListItem>>) {
|
||||
if (res.data == null) return
|
||||
for (item in res.data) {
|
||||
if (item is ChargeLocation && item.id == chargerId) {
|
||||
vm.chargerSparse.value = item
|
||||
vm.chargepoints.removeObserver(this)
|
||||
}
|
||||
object : Observer<ChargeLocation> {
|
||||
override fun onChanged(item: ChargeLocation?) {
|
||||
if (item?.id == chargerId) {
|
||||
val cameraUpdate = map.cameraUpdateFactory.newLatLngZoom(
|
||||
LatLng(item.coordinates.lat, item.coordinates.lng), 16f
|
||||
)
|
||||
map.moveCamera(cameraUpdate)
|
||||
vm.chargerSparse.removeObserver(this)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
positionSet = true
|
||||
} else if (lat != null && lon != null) {
|
||||
// show given position
|
||||
val latLng = LatLng(lat, lon)
|
||||
val cameraUpdate = map.cameraUpdateFactory.newLatLngZoom(latLng, 16f)
|
||||
map.moveCamera(cameraUpdate)
|
||||
|
||||
if (chargerId != null) {
|
||||
// show charger detail after chargers were loaded
|
||||
vm.chargepoints.observe(
|
||||
viewLifecycleOwner,
|
||||
object : Observer<Resource<List<ChargepointListItem>>> {
|
||||
override fun onChanged(res: Resource<List<ChargepointListItem>>) {
|
||||
if (res.data == null) return
|
||||
for (item in res.data) {
|
||||
if (item is ChargeLocation && item.id == chargerId) {
|
||||
vm.chargerSparse.value = item
|
||||
vm.chargepoints.removeObserver(this)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
} else {
|
||||
// mark location as search result
|
||||
vm.searchResult.value = PlaceWithBounds(latLng, boundingBox(latLng, 750.0))
|
||||
}
|
||||
|
||||
positionSet = true
|
||||
} else if (locationName != null) {
|
||||
lifecycleScope.launch {
|
||||
val address = withContext(Dispatchers.IO) {
|
||||
Geocoder(requireContext()).getFromLocationName(locationName, 1).getOrNull(0)
|
||||
}
|
||||
address?.let {
|
||||
val latLng = LatLng(it.latitude, it.longitude)
|
||||
val cameraUpdate = map.cameraUpdateFactory.newLatLngZoom(latLng, 16f)
|
||||
map.moveCamera(cameraUpdate)
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
if (ContextCompat.checkSelfPermission(
|
||||
requireContext(),
|
||||
@@ -688,18 +824,22 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
||||
private fun enableLocation(moveTo: Boolean, animate: Boolean) {
|
||||
val map = this.map ?: return
|
||||
map.setMyLocationEnabled(true)
|
||||
vm.myLocationEnabled.value = true
|
||||
map.uiSettings.setMyLocationButtonEnabled(false)
|
||||
if (moveTo && locationClient.isConnected) {
|
||||
moveToCurrentLocation(map, animate)
|
||||
if (moveTo) {
|
||||
vm.myLocationEnabled.value = true
|
||||
if (locationClient.isConnected) {
|
||||
moveToLastLocation(map, animate)
|
||||
requestLocationUpdates()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresPermission(ACCESS_FINE_LOCATION)
|
||||
private fun moveToCurrentLocation(map: AnyMap, animate: Boolean) {
|
||||
private fun moveToLastLocation(map: AnyMap, animate: Boolean) {
|
||||
val location = LocationServices.FusedLocationApi.getLastLocation(locationClient)
|
||||
if (location != null) {
|
||||
val latLng = LatLng(location.latitude, location.longitude)
|
||||
vm.location.value = latLng
|
||||
val camUpdate = map.cameraUpdateFactory.newLatLngZoom(latLng, 13f)
|
||||
if (animate) {
|
||||
map.animateCamera(camUpdate)
|
||||
@@ -726,7 +866,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
||||
getMarkerTint(charger, vm.filteredConnectors.value),
|
||||
highlight = charger == vm.chargerSparse.value,
|
||||
fault = charger.faultReport != null,
|
||||
multi = getMarkerMulti(charger, vm.filteredConnectors.value)
|
||||
multi = charger.isMulti(vm.filteredConnectors.value)
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -743,7 +883,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
||||
val tint = getMarkerTint(charger, vm.filteredConnectors.value)
|
||||
val highlight = charger == vm.chargerSparse.value
|
||||
val fault = charger.faultReport != null
|
||||
val multi = getMarkerMulti(charger, vm.filteredConnectors.value)
|
||||
val multi = charger.isMulti(vm.filteredConnectors.value)
|
||||
animator.animateMarkerDisappear(marker, tint, highlight, fault, multi)
|
||||
} else {
|
||||
animator.deleteMarker(marker)
|
||||
@@ -758,14 +898,14 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
||||
val tint = getMarkerTint(charger, vm.filteredConnectors.value)
|
||||
val highlight = charger == vm.chargerSparse.value
|
||||
val fault = charger.faultReport != null
|
||||
val multi = getMarkerMulti(charger, vm.filteredConnectors.value)
|
||||
val multi = charger.isMulti(vm.filteredConnectors.value)
|
||||
val marker = map.addMarker(
|
||||
MarkerOptions()
|
||||
.position(LatLng(charger.coordinates.lat, charger.coordinates.lng))
|
||||
.icon(
|
||||
chargerIconGenerator.getBitmapDescriptor(
|
||||
tint,
|
||||
0,
|
||||
0f,
|
||||
255,
|
||||
highlight,
|
||||
fault,
|
||||
@@ -827,34 +967,94 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
||||
})
|
||||
}
|
||||
filterView?.setOnClickListener {
|
||||
var profilesMap: MutableBiMap<Long, MenuItem> = HashBiMap()
|
||||
|
||||
val popup = PopupMenu(requireContext(), it, Gravity.END)
|
||||
popup.menuInflater.inflate(R.menu.popup_filter, popup.menu)
|
||||
MenuCompat.setGroupDividerEnabled(popup.menu, true)
|
||||
popup.setOnMenuItemClickListener {
|
||||
when (it.itemId) {
|
||||
R.id.menu_edit_filters -> {
|
||||
lifecycleScope.launch {
|
||||
vm.copyFiltersToCustom()
|
||||
requireView().findNavController().navigate(
|
||||
R.id.action_map_to_filterFragment
|
||||
)
|
||||
}
|
||||
true
|
||||
}
|
||||
R.id.menu_manage_filter_profiles -> {
|
||||
requireView().findNavController().navigate(
|
||||
R.id.action_map_to_filterFragment
|
||||
R.id.action_map_to_filterProfilesFragment
|
||||
)
|
||||
true
|
||||
}
|
||||
R.id.menu_filters_active -> {
|
||||
vm.filtersActive.value = !vm.filtersActive.value!!
|
||||
else -> {
|
||||
val profileId = profilesMap.inverse[it]
|
||||
if (profileId != null) {
|
||||
vm.filterStatus.value = profileId
|
||||
}
|
||||
true
|
||||
}
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
val checkItem = popup.menu.findItem(R.id.menu_filters_active)
|
||||
vm.filtersActive.observe(viewLifecycleOwner, Observer {
|
||||
checkItem.isChecked = it
|
||||
vm.filterProfiles.observe(viewLifecycleOwner, { profiles ->
|
||||
popup.menu.removeGroup(R.id.menu_group_filter_profiles)
|
||||
|
||||
val noFiltersItem = popup.menu.add(
|
||||
R.id.menu_group_filter_profiles,
|
||||
Menu.NONE, Menu.NONE, R.string.no_filters
|
||||
)
|
||||
profiles.forEach { profile ->
|
||||
val item = popup.menu.add(
|
||||
R.id.menu_group_filter_profiles,
|
||||
Menu.NONE,
|
||||
Menu.NONE,
|
||||
profile.name
|
||||
)
|
||||
profilesMap[profile.id] = item
|
||||
}
|
||||
val customItem = popup.menu.add(
|
||||
R.id.menu_group_filter_profiles,
|
||||
Menu.NONE, Menu.NONE, R.string.filter_custom
|
||||
)
|
||||
|
||||
profilesMap[FILTERS_DISABLED] = noFiltersItem
|
||||
profilesMap[FILTERS_CUSTOM] = customItem
|
||||
|
||||
popup.menu.setGroupCheckable(R.id.menu_group_filter_profiles, true, true);
|
||||
|
||||
val manageFiltersItem = popup.menu.findItem(R.id.menu_manage_filter_profiles)
|
||||
manageFiltersItem.isVisible = !profiles.isEmpty()
|
||||
|
||||
vm.filterStatus.observe(viewLifecycleOwner, Observer { id ->
|
||||
when (id) {
|
||||
FILTERS_DISABLED -> {
|
||||
customItem.isVisible = false
|
||||
noFiltersItem.isChecked = true
|
||||
}
|
||||
FILTERS_CUSTOM -> {
|
||||
customItem.isVisible = true
|
||||
customItem.isChecked = true
|
||||
}
|
||||
else -> {
|
||||
customItem.isVisible = false
|
||||
val item = profilesMap[id]
|
||||
if (item != null) {
|
||||
item.isChecked = true
|
||||
}
|
||||
// else unknown ID -> wait for filterProfiles to update
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
popup.show()
|
||||
}
|
||||
|
||||
filterView?.setOnLongClickListener {
|
||||
// enable/disable filters
|
||||
vm.filtersActive.value = !vm.filtersActive.value!!
|
||||
vm.toggleFilters()
|
||||
// haptic feedback
|
||||
filterView.performHapticFeedback(
|
||||
HapticFeedbackConstants.LONG_PRESS,
|
||||
@@ -862,7 +1062,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
||||
)
|
||||
// show snackbar
|
||||
Snackbar.make(
|
||||
requireView(), if (vm.filtersActive.value!!) {
|
||||
requireView(), if (vm.filterStatus.value != FILTERS_DISABLED) {
|
||||
R.string.filters_activated
|
||||
} else {
|
||||
R.string.filters_deactivated
|
||||
@@ -884,23 +1084,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
||||
}
|
||||
|
||||
override fun getRootView(): View {
|
||||
return root
|
||||
}
|
||||
|
||||
private val exitElementCallback: 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
|
||||
}
|
||||
return binding.root
|
||||
}
|
||||
|
||||
companion object {
|
||||
@@ -911,21 +1095,97 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
||||
putDouble(ARG_LON, charger.coordinates.lng)
|
||||
}
|
||||
}
|
||||
|
||||
fun showLocation(lat: Double, lon: Double): Bundle {
|
||||
return Bundle().apply {
|
||||
putDouble(ARG_LAT, lat)
|
||||
putDouble(ARG_LON, lon)
|
||||
}
|
||||
}
|
||||
|
||||
fun showChargerById(id: Long): Bundle {
|
||||
return Bundle().apply {
|
||||
putLong(ARG_CHARGER_ID, id)
|
||||
}
|
||||
}
|
||||
|
||||
fun showCharger(id: Long, lat: Double, lon: Double): Bundle {
|
||||
return Bundle().apply {
|
||||
putLong(ARG_CHARGER_ID, id)
|
||||
putDouble(ARG_LAT, lat)
|
||||
putDouble(ARG_LON, lon)
|
||||
}
|
||||
}
|
||||
|
||||
fun showLocationByName(query: String): Bundle {
|
||||
return Bundle().apply {
|
||||
putString(ARG_LOCATION_NAME, query)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onConnected() {
|
||||
val map = this.map ?: return
|
||||
val context = this.context ?: return
|
||||
if (vm.myLocationEnabled.value == true) {
|
||||
if (ActivityCompat.checkSelfPermission(
|
||||
requireContext(),
|
||||
context,
|
||||
ACCESS_FINE_LOCATION
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
moveToCurrentLocation(map, false)
|
||||
moveToLastLocation(map, false)
|
||||
requestLocationUpdates()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresPermission(ACCESS_FINE_LOCATION)
|
||||
private fun requestLocationUpdates() {
|
||||
val request: LocationRequest = LocationRequest.create()
|
||||
.setPriority(LocationRequest.PRIORITY_HIGH_ACCURACY)
|
||||
.setInterval(5000)
|
||||
LocationServices.FusedLocationApi.requestLocationUpdates(locationClient, request, this)
|
||||
requestingLocationUpdates = true
|
||||
}
|
||||
|
||||
private fun removeLocationUpdates() {
|
||||
if (locationClient.isConnected) {
|
||||
LocationServices.FusedLocationApi.removeLocationUpdates(locationClient, this)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onConnectionSuspended() {
|
||||
}
|
||||
|
||||
override fun onLocationChanged(location: Location?) {
|
||||
val map = this.map ?: return
|
||||
if (location == null || vm.myLocationEnabled.value == false) return
|
||||
|
||||
val latLng = LatLng(location.latitude, location.longitude)
|
||||
val oldLoc = vm.location.value
|
||||
if (latLng != oldLoc && (oldLoc == null || distanceBetween(
|
||||
latLng.latitude,
|
||||
latLng.longitude,
|
||||
oldLoc.latitude,
|
||||
oldLoc.longitude
|
||||
) > 1)
|
||||
) {
|
||||
// only update map if location changed by more than 1 meter
|
||||
vm.location.value = latLng
|
||||
val camUpdate = map.cameraUpdateFactory.newLatLng(latLng)
|
||||
map.animateCamera(camUpdate)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
removeLocationUpdates()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
if (locationClient.isConnected) {
|
||||
locationClient.disconnect()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,10 +7,10 @@ import android.view.ViewGroup
|
||||
import androidx.appcompat.app.AppCompatDialogFragment
|
||||
import androidx.core.widget.doAfterTextChanged
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import kotlinx.android.synthetic.main.dialog_multi_select.*
|
||||
import net.vonforst.evmap.R
|
||||
import net.vonforst.evmap.adapter.DataBindingAdapter
|
||||
import net.vonforst.evmap.adapter.Equatable
|
||||
import net.vonforst.evmap.databinding.DialogMultiSelectBinding
|
||||
import java.util.*
|
||||
import kotlin.collections.HashMap
|
||||
import kotlin.collections.HashSet
|
||||
@@ -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
|
||||
}
|
||||
@@ -37,13 +39,15 @@ class MultiSelectDialog : AppCompatDialogFragment() {
|
||||
var okListener: ((Set<String>) -> Unit)? = null
|
||||
var cancelListener: (() -> Unit)? = null
|
||||
private lateinit var items: List<MultiSelectItem>
|
||||
private lateinit var binding: DialogMultiSelectBinding
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View? {
|
||||
return inflater.inflate(R.layout.dialog_multi_select, container)
|
||||
): View {
|
||||
binding = DialogMultiSelectBinding.inflate(inflater, container, false)
|
||||
return binding.root
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
@@ -64,11 +68,14 @@ class MultiSelectDialog : AppCompatDialogFragment() {
|
||||
val commonChoices = if (args.containsKey("commonChoices")) {
|
||||
args.getSerializable("commonChoices") as HashSet<String>
|
||||
} else null
|
||||
val showAllButton = args.getBoolean("showAllButton")
|
||||
|
||||
dialogTitle.text = title
|
||||
binding.dialogTitle.text = title
|
||||
val adapter = Adapter()
|
||||
list.adapter = adapter
|
||||
list.layoutManager = LinearLayoutManager(view.context)
|
||||
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()) }
|
||||
@@ -76,30 +83,30 @@ class MultiSelectDialog : AppCompatDialogFragment() {
|
||||
.map { MultiSelectItem(it.key, it.value, it.key in selected) }
|
||||
adapter.submitList(items)
|
||||
|
||||
etSearch.doAfterTextChanged { text ->
|
||||
binding.etSearch.doAfterTextChanged { text ->
|
||||
adapter.submitList(search(items, text.toString()))
|
||||
}
|
||||
|
||||
btnCancel.setOnClickListener {
|
||||
binding.btnCancel.setOnClickListener {
|
||||
cancelListener?.let { listener ->
|
||||
listener()
|
||||
}
|
||||
dismiss()
|
||||
}
|
||||
btnOK.setOnClickListener {
|
||||
binding.btnOK.setOnClickListener {
|
||||
okListener?.let { listener ->
|
||||
val result = items.filter { it.selected }.map { it.key }.toSet()
|
||||
listener(result)
|
||||
}
|
||||
dismiss()
|
||||
}
|
||||
btnAll.setOnClickListener {
|
||||
binding.btnAll.setOnClickListener {
|
||||
items = items.map { MultiSelectItem(it.key, it.name, true) }
|
||||
adapter.submitList(search(items, etSearch.text.toString()))
|
||||
adapter.submitList(search(items, binding.etSearch.text.toString()))
|
||||
}
|
||||
btnNone.setOnClickListener {
|
||||
binding.btnNone.setOnClickListener {
|
||||
items = items.map { MultiSelectItem(it.key, it.name, false) }
|
||||
adapter.submitList(search(items, etSearch.text.toString()))
|
||||
adapter.submitList(search(items, binding.etSearch.text.toString()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -4,20 +4,36 @@ import android.content.SharedPreferences
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import androidx.appcompat.widget.Toolbar
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.navigation.ui.setupWithNavController
|
||||
import androidx.preference.MultiSelectListPreference
|
||||
import androidx.preference.Preference
|
||||
import androidx.preference.PreferenceFragmentCompat
|
||||
import net.vonforst.evmap.MapsActivity
|
||||
import net.vonforst.evmap.R
|
||||
import net.vonforst.evmap.storage.PreferenceDataSource
|
||||
import net.vonforst.evmap.ui.updateNightMode
|
||||
import net.vonforst.evmap.viewmodel.SettingsViewModel
|
||||
import net.vonforst.evmap.viewmodel.viewModelFactory
|
||||
|
||||
|
||||
class SettingsFragment : PreferenceFragmentCompat(),
|
||||
SharedPreferences.OnSharedPreferenceChangeListener {
|
||||
private lateinit var prefs: PreferenceDataSource
|
||||
|
||||
private val vm: SettingsViewModel by viewModels(factoryProducer = {
|
||||
viewModelFactory {
|
||||
SettingsViewModel(
|
||||
requireActivity().application,
|
||||
getString(R.string.chargeprice_key)
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
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
|
||||
@@ -28,6 +44,57 @@ class SettingsFragment : PreferenceFragmentCompat(),
|
||||
navController,
|
||||
(requireActivity() as MapsActivity).appBarConfiguration
|
||||
)
|
||||
|
||||
myVehiclePreference = findPreference("chargeprice_my_vehicle")!!
|
||||
myVehiclePreference.isEnabled = false
|
||||
vm.vehicles.observe(viewLifecycleOwner) { res ->
|
||||
res.data?.let { cars ->
|
||||
val sortedCars = cars.sortedBy { it.brand }
|
||||
myVehiclePreference.entryValues = sortedCars.map { it.id }.toTypedArray()
|
||||
myVehiclePreference.entries =
|
||||
sortedCars.map { "${it.brand} ${it.name}" }.toTypedArray()
|
||||
myVehiclePreference.isEnabled = true
|
||||
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()
|
||||
myTariffsPreference.entries = tariffs.map {
|
||||
if (!it.name.startsWith(it.provider)) {
|
||||
"${it.provider} ${it.name}"
|
||||
} else {
|
||||
it.name
|
||||
}
|
||||
}.toTypedArray()
|
||||
myTariffsPreference.isEnabled = true
|
||||
updateMyTariffsSummary()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateMyTariffsSummary() {
|
||||
myTariffsPreference.summary = if (prefs.chargepriceMyTariffsAll) {
|
||||
getString(R.string.chargeprice_all_tariffs_selected)
|
||||
} else {
|
||||
val n = prefs.chargepriceMyTariffs?.size ?: 0
|
||||
requireContext().resources
|
||||
.getQuantityString(R.plurals.chargeprice_some_tariffs_selected, n, n)
|
||||
}
|
||||
}
|
||||
|
||||
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?) {
|
||||
@@ -51,6 +118,12 @@ class SettingsFragment : PreferenceFragmentCompat(),
|
||||
"darkmode" -> {
|
||||
updateNightMode(prefs)
|
||||
}
|
||||
"chargeprice_my_vehicle" -> {
|
||||
updateMyVehiclesSummary()
|
||||
}
|
||||
"chargeprice_my_tariffs" -> {
|
||||
updateMyTariffsSummary()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
package net.vonforst.evmap.fragment.updatedialogs
|
||||
|
||||
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.DialogUpdate060AndroidautoBinding
|
||||
import net.vonforst.evmap.storage.PreferenceDataSource
|
||||
|
||||
class Update060AndroidAutoDialogFramgent : AppCompatDialogFragment() {
|
||||
private lateinit var binding: DialogUpdate060AndroidautoBinding
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View {
|
||||
binding = DialogUpdate060AndroidautoBinding.inflate(inflater, container, false)
|
||||
return binding.root
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
binding.btnOk.setOnClickListener {
|
||||
PreferenceDataSource(requireContext()).update060AndroidAutoDialogShown = true
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
dialog?.window?.setLayout(
|
||||
WindowManager.LayoutParams.MATCH_PARENT,
|
||||
WindowManager.LayoutParams.WRAP_CONTENT
|
||||
)
|
||||
}
|
||||
}
|
||||
328
app/src/main/java/net/vonforst/evmap/model/ChargersModel.kt
Normal file
@@ -0,0 +1,328 @@
|
||||
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
|
||||
@Parcelize
|
||||
data class ChargeLocation(
|
||||
@PrimaryKey val id: Long,
|
||||
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
|
||||
132
app/src/main/java/net/vonforst/evmap/model/FiltersModel.kt
Normal 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
|
||||
@@ -0,0 +1,3 @@
|
||||
package net.vonforst.evmap.model
|
||||
|
||||
abstract class ReferenceData
|
||||
@@ -5,6 +5,7 @@ import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.util.AttributeSet
|
||||
import androidx.browser.customtabs.CustomTabColorSchemeParams
|
||||
import androidx.browser.customtabs.CustomTabsIntent
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.withStyledAttributes
|
||||
@@ -12,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)
|
||||
@@ -27,23 +29,39 @@ class ChromeCustomTabsNavigator(
|
||||
navOptions: NavOptions?,
|
||||
navigatorExtras: Extras?
|
||||
): NavDestination? {
|
||||
val intent = CustomTabsIntent.Builder()
|
||||
.setToolbarColor(ContextCompat.getColor(context, R.color.colorPrimary))
|
||||
.build()
|
||||
intent.launchUrl(context, destination.url!!)
|
||||
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()
|
||||
.setToolbarColor(ContextCompat.getColor(context, R.color.colorPrimary))
|
||||
.build()
|
||||
)
|
||||
.build()
|
||||
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)!!
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ class NavHostFragment : NavHostFragment() {
|
||||
override fun onCreateNavController(navController: NavController) {
|
||||
super.onCreateNavController(navController)
|
||||
navController.navigatorProvider.addNavigator(
|
||||
ChromeCustomTabsNavigator(
|
||||
CustomNavigator(
|
||||
requireContext()
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@ 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 {
|
||||
@@ -14,4 +14,7 @@ interface ChargeLocationsDao {
|
||||
|
||||
@Query("SELECT * FROM chargelocation")
|
||||
fun getAllChargeLocations(): LiveData<List<ChargeLocation>>
|
||||
|
||||
@Query("SELECT * FROM chargelocation")
|
||||
suspend fun getAllChargeLocationsAsync(): List<ChargeLocation>
|
||||
}
|
||||
@@ -7,11 +7,11 @@ 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.MultipleChoiceFilterValue
|
||||
import net.vonforst.evmap.viewmodel.SliderFilterValue
|
||||
import net.vonforst.evmap.api.goingelectric.GEChargeCard
|
||||
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 = [
|
||||
@@ -19,18 +19,26 @@ import net.vonforst.evmap.viewmodel.SliderFilterValue
|
||||
BooleanFilterValue::class,
|
||||
MultipleChoiceFilterValue::class,
|
||||
SliderFilterValue::class,
|
||||
Plug::class,
|
||||
Network::class,
|
||||
ChargeCard::class
|
||||
], version = 8
|
||||
FilterProfile::class,
|
||||
GEPlug::class,
|
||||
GENetwork::class,
|
||||
GEChargeCard::class,
|
||||
OCMConnectionType::class,
|
||||
OCMCountry::class,
|
||||
OCMOperator::class
|
||||
], version = 12
|
||||
)
|
||||
@TypeConverters(Converters::class)
|
||||
abstract class AppDatabase : RoomDatabase() {
|
||||
abstract fun chargeLocationsDao(): ChargeLocationsDao
|
||||
abstract fun filterValueDao(): FilterValueDao
|
||||
abstract fun plugDao(): PlugDao
|
||||
abstract fun networkDao(): NetworkDao
|
||||
abstract fun chargeCardDao(): ChargeCardDao
|
||||
abstract fun filterProfileDao(): FilterProfileDao
|
||||
|
||||
// GoingElectric API specific
|
||||
abstract fun geReferenceDataDao(): GEReferenceDataDao
|
||||
|
||||
// OpenChargeMap API specific
|
||||
abstract fun ocmReferenceDataDao(): OCMReferenceDataDao
|
||||
|
||||
companion object {
|
||||
private lateinit var context: Context
|
||||
@@ -38,8 +46,16 @@ 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_7, MIGRATION_8, MIGRATION_9, MIGRATION_10, MIGRATION_11,
|
||||
MIGRATION_12
|
||||
)
|
||||
.addCallback(object : Callback() {
|
||||
override fun onCreate(db: SupportSQLiteDatabase) {
|
||||
// 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()
|
||||
}
|
||||
|
||||
@@ -120,5 +136,120 @@ abstract class AppDatabase : RoomDatabase() {
|
||||
db.execSQL("ALTER TABLE `ChargeLocation` ADD `chargecards` TEXT")
|
||||
}
|
||||
}
|
||||
|
||||
private val MIGRATION_9 = object : Migration(8, 9) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.beginTransaction()
|
||||
try {
|
||||
// create filter profiles table
|
||||
db.execSQL("CREATE TABLE IF NOT EXISTS `FilterProfile` (`name` TEXT NOT NULL, `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL)")
|
||||
db.execSQL("CREATE UNIQUE INDEX IF NOT EXISTS `index_FilterProfile_name` ON `FilterProfile` (`name`)")
|
||||
|
||||
// create default filter profile
|
||||
db.execSQL("INSERT INTO `FilterProfile` (`name`, `id`) VALUES ('FILTERS_CUSTOM', $FILTERS_CUSTOM)")
|
||||
|
||||
// add profile column to existing filtervalue tables
|
||||
db.execSQL("CREATE TABLE `BooleanFilterValueNew` (`key` TEXT NOT NULL, `value` INTEGER NOT NULL, `profile` INTEGER NOT NULL, PRIMARY KEY(`key`, `profile`), FOREIGN KEY(`profile`) REFERENCES `FilterProfile`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )")
|
||||
db.execSQL("CREATE TABLE `MultipleChoiceFilterValueNew` (`key` TEXT NOT NULL, `values` TEXT NOT NULL, `all` INTEGER NOT NULL, `profile` INTEGER NOT NULL, PRIMARY KEY(`key`, `profile`), FOREIGN KEY(`profile`) REFERENCES `FilterProfile`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )")
|
||||
db.execSQL("CREATE TABLE IF NOT EXISTS `SliderFilterValueNew` (`key` TEXT NOT NULL, `value` INTEGER NOT NULL, `profile` INTEGER NOT NULL, PRIMARY KEY(`key`, `profile`), FOREIGN KEY(`profile`) REFERENCES `FilterProfile`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )");
|
||||
|
||||
for (table in listOf(
|
||||
"BooleanFilterValue",
|
||||
"MultipleChoiceFilterValue",
|
||||
"SliderFilterValue"
|
||||
)) {
|
||||
db.execSQL("ALTER TABLE `$table` ADD COLUMN `profile` INTEGER NOT NULL DEFAULT $FILTERS_CUSTOM")
|
||||
db.execSQL("INSERT INTO `${table}New` SELECT * FROM `$table`")
|
||||
db.execSQL("DROP TABLE `$table`")
|
||||
db.execSQL("ALTER TABLE `${table}New` RENAME TO `$table`")
|
||||
}
|
||||
|
||||
db.setTransactionSuccessful()
|
||||
} finally {
|
||||
db.endTransaction()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private val MIGRATION_10 = object : Migration(9, 10) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL("ALTER TABLE `FilterProfile` ADD `order` INTEGER NOT NULL DEFAULT 0")
|
||||
}
|
||||
}
|
||||
|
||||
private val MIGRATION_11 = object : Migration(10, 11) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package net.vonforst.evmap.storage
|
||||
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.room.*
|
||||
import net.vonforst.evmap.adapter.Equatable
|
||||
import net.vonforst.evmap.model.FILTERS_CUSTOM
|
||||
|
||||
@Entity(
|
||||
indices = [Index(value = ["dataSource", "name"], unique = true)],
|
||||
primaryKeys = ["dataSource", "id"],
|
||||
)
|
||||
data class FilterProfile(
|
||||
val name: String,
|
||||
val dataSource: String,
|
||||
val id: Long,
|
||||
var order: Int = 0
|
||||
) : Equatable
|
||||
|
||||
@Dao
|
||||
interface FilterProfileDao {
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insert(profile: FilterProfile): Long
|
||||
|
||||
@Update
|
||||
suspend fun update(vararg profiles: FilterProfile)
|
||||
|
||||
@Delete
|
||||
suspend fun delete(vararg profiles: 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 dataSource = :dataSource AND name = :name")
|
||||
suspend fun getProfileByName(name: String, dataSource: String): 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
|
||||
}
|
||||
@@ -2,22 +2,29 @@ package net.vonforst.evmap.storage
|
||||
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MediatorLiveData
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.room.*
|
||||
import net.vonforst.evmap.viewmodel.BooleanFilterValue
|
||||
import net.vonforst.evmap.viewmodel.FilterValue
|
||||
import net.vonforst.evmap.viewmodel.MultipleChoiceFilterValue
|
||||
import net.vonforst.evmap.viewmodel.SliderFilterValue
|
||||
import net.vonforst.evmap.model.*
|
||||
|
||||
@Dao
|
||||
abstract class FilterValueDao {
|
||||
@Query("SELECT * FROM booleanfiltervalue")
|
||||
protected abstract fun getBooleanFilterValues(): 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")
|
||||
protected abstract fun getMultipleChoiceFilterValues(): 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")
|
||||
protected abstract fun getSliderFilterValues(): 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)
|
||||
@@ -28,16 +35,41 @@ abstract class FilterValueDao {
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
protected abstract suspend fun insert(vararg values: SliderFilterValue)
|
||||
|
||||
open fun getFilterValues(): LiveData<List<FilterValue>> =
|
||||
MediatorLiveData<List<FilterValue>>().apply {
|
||||
val sources = listOf(
|
||||
getBooleanFilterValues(),
|
||||
getMultipleChoiceFilterValues(),
|
||||
getSliderFilterValues()
|
||||
)
|
||||
for (source in sources) {
|
||||
addSource(source) {
|
||||
value = sources.mapNotNull { it.value }.flatten()
|
||||
@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 AND dataSource = :dataSource")
|
||||
protected abstract suspend fun deleteMultipleChoiceFilterValuesForProfile(
|
||||
profile: Long,
|
||||
dataSource: String
|
||||
)
|
||||
|
||||
@Query("DELETE FROM sliderfiltervalue WHERE profile = :profile AND dataSource = :dataSource")
|
||||
protected abstract suspend fun deleteSliderFilterValuesForProfile(
|
||||
profile: Long,
|
||||
dataSource: String
|
||||
)
|
||||
|
||||
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, dataSource),
|
||||
getMultipleChoiceFilterValues(filterStatus, dataSource),
|
||||
getSliderFilterValues(filterStatus, dataSource)
|
||||
)
|
||||
for (source in sources) {
|
||||
addSource(source) {
|
||||
val values = sources.map { it.value }
|
||||
if (values.all { it != null }) {
|
||||
value = values.filterNotNull().flatten()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -52,4 +84,12 @@ abstract class FilterValueDao {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Transaction
|
||||
open suspend fun deleteFilterValuesForProfile(profile: Long, dataSource: String) {
|
||||
deleteBooleanFilterValuesForProfile(profile, dataSource)
|
||||
deleteMultipleChoiceFilterValuesForProfile(profile, dataSource)
|
||||
deleteSliderFilterValuesForProfile(profile, dataSource)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,42 +2,72 @@ package net.vonforst.evmap.storage
|
||||
|
||||
import android.content.Context
|
||||
import androidx.preference.PreferenceManager
|
||||
import com.car2go.maps.AnyMap
|
||||
import net.vonforst.evmap.R
|
||||
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()
|
||||
sp.edit().putLong("last_ocm_reference_data_update", value.toEpochMilli()).apply()
|
||||
}
|
||||
|
||||
var lastChargeCardUpdate: Instant
|
||||
get() = Instant.ofEpochMilli(sp.getLong("last_chargecard_update", 0L))
|
||||
/**
|
||||
* Stores the current filtering status, which is either the ID of a filter profile or
|
||||
* one of FILTERS_DISABLED, FILTERS_CUSTOM
|
||||
*/
|
||||
var filterStatus: Long
|
||||
get() =
|
||||
sp.getLong(
|
||||
"filter_status",
|
||||
// migration from versions before filter profiles were implemented
|
||||
if (sp.getBoolean("filters_active", true))
|
||||
FILTERS_CUSTOM else FILTERS_DISABLED
|
||||
)
|
||||
set(value) {
|
||||
sp.edit().putLong("last_chargecard_update", value.toEpochMilli()).apply()
|
||||
sp.edit().putLong("filter_status", value).apply()
|
||||
}
|
||||
|
||||
var filtersActive: Boolean
|
||||
get() = sp.getBoolean("filters_active", true)
|
||||
/**
|
||||
* Stores the last filter profile which was selected
|
||||
* (excluding FILTERS_DISABLED, but including FILTERS_CUSTOM)
|
||||
*/
|
||||
var lastFilterProfile: Long
|
||||
get() = sp.getLong("last_filter_profile", FILTERS_CUSTOM)
|
||||
set(value) {
|
||||
sp.edit().putBoolean("filters_active", value).apply()
|
||||
sp.edit().putLong("last_filter_profile", value).apply()
|
||||
}
|
||||
|
||||
|
||||
val language: String
|
||||
get() = sp.getString("language", "default")!!
|
||||
|
||||
@@ -49,4 +79,86 @@ class PreferenceDataSource(val context: Context) {
|
||||
"map_provider",
|
||||
context.getString(R.string.pref_map_provider_default)
|
||||
)!!
|
||||
|
||||
var mapType: AnyMap.Type
|
||||
get() = AnyMap.Type.valueOf(sp.getString("map_type", null) ?: AnyMap.Type.NORMAL.toString())
|
||||
set(type) {
|
||||
sp.edit().putString("map_type", type.toString()).apply()
|
||||
}
|
||||
|
||||
var mapTrafficEnabled: Boolean
|
||||
get() = sp.getBoolean("map_traffic_enabled", false)
|
||||
set(value) {
|
||||
sp.edit().putBoolean("map_traffic_enabled", value).apply()
|
||||
}
|
||||
|
||||
var welcomeDialogShown: Boolean
|
||||
get() = sp.getBoolean("welcome_dialog_shown", false)
|
||||
set(value) {
|
||||
sp.edit().putBoolean("welcome_dialog_shown", value).apply()
|
||||
}
|
||||
|
||||
var update060AndroidAutoDialogShown: Boolean
|
||||
get() = sp.getBoolean("update_0.6.0_androidauto_dialog_shown", false)
|
||||
set(value) {
|
||||
sp.edit().putBoolean("update_0.6.0_androidauto_dialog_shown", value).apply()
|
||||
}
|
||||
|
||||
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().putStringSet("chargeprice_my_vehicle", value).apply()
|
||||
}
|
||||
|
||||
var chargepriceLastSelectedVehicle: String?
|
||||
get() = sp.getString("chargeprice_last_vehicle", null)
|
||||
set(value) {
|
||||
sp.edit().putString("chargeprice_last_vehicle", value).apply()
|
||||
}
|
||||
|
||||
var chargepriceMyTariffs: Set<String>?
|
||||
get() = sp.getStringSet("chargeprice_my_tariffs", null)
|
||||
set(value) {
|
||||
sp.edit().putStringSet("chargeprice_my_tariffs", value).apply()
|
||||
}
|
||||
|
||||
var chargepriceMyTariffsAll: Boolean
|
||||
get() = sp.getBoolean("chargeprice_my_tariffs_all", true)
|
||||
set(value) {
|
||||
sp.edit().putBoolean("chargeprice_my_tariffs_all", value).apply()
|
||||
}
|
||||
|
||||
var chargepriceNoBaseFee: Boolean
|
||||
get() = sp.getBoolean("chargeprice_no_base_fee", false)
|
||||
set(value) {
|
||||
sp.edit().putBoolean("chargeprice_no_base_fee", value).apply()
|
||||
}
|
||||
|
||||
var chargepriceShowProviderCustomerTariffs: Boolean
|
||||
get() = sp.getBoolean("chargeprice_show_provider_customer_tariffs", false)
|
||||
set(value) {
|
||||
sp.edit().putBoolean("chargeprice_show_provider_customer_tariffs", value).apply()
|
||||
}
|
||||
|
||||
var chargepriceCurrency: String
|
||||
get() = sp.getString("chargeprice_currency", null) ?: "EUR"
|
||||
set(value) {
|
||||
sp.edit().putString("chargeprice_currency", value).apply()
|
||||
}
|
||||
|
||||
var chargepriceBatteryRange: List<Float>
|
||||
get() = listOf(
|
||||
sp.getFloat("chargeprice_battery_range_min", 20f),
|
||||
sp.getFloat("chargeprice_battery_range_max", 80f),
|
||||
)
|
||||
set(value) {
|
||||
sp.edit().putFloat("chargeprice_battery_range_min", value[0])
|
||||
.putFloat("chargeprice_battery_range_max", value[1])
|
||||
.apply()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package net.vonforst.evmap.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.text.Layout
|
||||
import android.util.AttributeSet
|
||||
import androidx.appcompat.widget.AppCompatTextView
|
||||
import kotlin.math.ceil
|
||||
|
||||
class BalancedBreakingTextView(context: Context, attrs: AttributeSet) :
|
||||
AppCompatTextView(context, attrs) {
|
||||
|
||||
@Override
|
||||
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
|
||||
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
|
||||
|
||||
if (layout != null) {
|
||||
val width =
|
||||
ceil(getMaxLineWidth(layout)).toInt() + compoundPaddingLeft + compoundPaddingRight
|
||||
val height = measuredHeight
|
||||
setMeasuredDimension(width, height)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getMaxLineWidth(layout: Layout): Float {
|
||||
var maxWidth = 0.0f
|
||||
for (i in 0 until layout.lineCount) {
|
||||
if (layout.getLineWidth(i) > maxWidth) {
|
||||
maxWidth = layout.getLineWidth(i)
|
||||
}
|
||||
}
|
||||
return maxWidth
|
||||
}
|
||||
}
|
||||
@@ -2,21 +2,28 @@ package net.vonforst.evmap.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.content.res.ColorStateList
|
||||
import android.text.SpannableString
|
||||
import android.view.View
|
||||
import android.view.ViewGroup.MarginLayoutParams
|
||||
import android.widget.ImageView
|
||||
import android.widget.TextView
|
||||
import androidx.annotation.ColorInt
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.res.use
|
||||
import androidx.core.text.HtmlCompat
|
||||
import androidx.databinding.BindingAdapter
|
||||
import androidx.databinding.InverseBindingAdapter
|
||||
import androidx.databinding.InverseBindingListener
|
||||
import androidx.recyclerview.widget.ListAdapter
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.viewpager2.widget.ViewPager2
|
||||
import com.google.android.material.floatingactionbutton.FloatingActionButton
|
||||
import com.google.android.material.slider.RangeSlider
|
||||
import net.vonforst.evmap.R
|
||||
import net.vonforst.evmap.api.availability.ChargepointStatus
|
||||
import net.vonforst.evmap.api.goingelectric.Chargepoint
|
||||
import net.vonforst.evmap.api.iconForPlugType
|
||||
import kotlin.math.ceil
|
||||
import kotlin.math.floor
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
|
||||
@@ -25,11 +32,49 @@ fun goneUnless(view: View, visible: Boolean) {
|
||||
view.visibility = if (visible) View.VISIBLE else View.GONE
|
||||
}
|
||||
|
||||
@BindingAdapter("goneUnlessAnimated")
|
||||
fun goneUnlessAnimated(view: View, oldValue: Boolean, newValue: Boolean) {
|
||||
if (oldValue == newValue) return
|
||||
|
||||
view.animate().cancel()
|
||||
if (newValue) {
|
||||
view.visibility = View.VISIBLE
|
||||
view.alpha = 0f
|
||||
view.animate().alpha(1f).withEndAction {
|
||||
view.alpha = 1f
|
||||
}
|
||||
} else {
|
||||
view.animate().alpha(0f).withEndAction {
|
||||
view.alpha = 1f
|
||||
view.visibility = View.GONE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@BindingAdapter("invisibleUnless")
|
||||
fun invisibleUnless(view: View, visible: Boolean) {
|
||||
view.visibility = if (visible) View.VISIBLE else View.INVISIBLE
|
||||
}
|
||||
|
||||
@BindingAdapter("invisibleUnlessAnimated")
|
||||
fun invisibleUnlessAnimated(view: View, oldValue: Boolean, newValue: Boolean) {
|
||||
if (oldValue == newValue) return
|
||||
|
||||
view.animate().cancel()
|
||||
if (newValue) {
|
||||
view.visibility = View.VISIBLE
|
||||
view.alpha = 0f
|
||||
view.animate().alpha(1f).withEndAction {
|
||||
view.alpha = 1f
|
||||
}
|
||||
} else {
|
||||
view.animate().alpha(0f).withEndAction {
|
||||
view.alpha = 1f
|
||||
view.visibility = View.INVISIBLE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@BindingAdapter("isFabActive")
|
||||
fun isFabActive(view: FloatingActionButton, isColored: Boolean) {
|
||||
val color = view.context.theme.obtainStyledAttributes(
|
||||
@@ -60,20 +105,7 @@ fun <T> setRecyclerViewData(recyclerView: ViewPager2, items: List<T>?) {
|
||||
|
||||
@BindingAdapter("connectorIcon")
|
||||
fun getConnectorItem(view: ImageView, type: String) {
|
||||
view.setImageResource(
|
||||
when (type) {
|
||||
Chargepoint.CCS -> R.drawable.ic_connector_ccs
|
||||
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.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
|
||||
}
|
||||
)
|
||||
view.setImageResource(iconForPlugType(type))
|
||||
}
|
||||
|
||||
@BindingAdapter("srcCompat")
|
||||
@@ -131,19 +163,71 @@ fun setTopMargin(view: View, topMargin: Float) {
|
||||
view.layoutParams = layoutParams
|
||||
}
|
||||
|
||||
/**
|
||||
* Linkify is already possible using the autoLink and linksClickable attributes, but this does not
|
||||
* remove spans correctly. So we implement a new version that manually removes the spans.
|
||||
*/
|
||||
@BindingAdapter("linkify")
|
||||
fun setLinkify(textView: TextView, oldValue: Int, newValue: Int) {
|
||||
if (oldValue == newValue) return
|
||||
|
||||
textView.autoLinkMask = newValue
|
||||
textView.linksClickable = newValue != 0
|
||||
|
||||
// remove spans
|
||||
val text = textView.text
|
||||
if (newValue == 0 && text != null && text is SpannableString) {
|
||||
text.getSpans(0, text.length, Any::class.java).forEach {
|
||||
text.removeSpan(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@BindingAdapter("chargepriceTagColor")
|
||||
fun setChargepriceTagColor(view: TextView, kind: String) {
|
||||
view.backgroundTintList = ColorStateList.valueOf(
|
||||
ContextCompat.getColor(
|
||||
view.context,
|
||||
when (kind) {
|
||||
"star" -> R.color.chargeprice_star
|
||||
"alert" -> R.color.chargeprice_alert
|
||||
"info" -> R.color.chargeprice_info
|
||||
"lock" -> R.color.chargeprice_lock
|
||||
else -> R.color.chip_background
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@BindingAdapter("chargepriceTagIcon")
|
||||
fun setChargepriceTagIcon(view: TextView, kind: String) {
|
||||
view.setCompoundDrawablesRelativeWithIntrinsicBounds(
|
||||
when (kind) {
|
||||
"star" -> R.drawable.ic_chargeprice_star
|
||||
"alert" -> R.drawable.ic_chargeprice_alert
|
||||
"info" -> R.drawable.ic_chargeprice_info
|
||||
"lock" -> R.drawable.ic_chargeprice_lock
|
||||
else -> 0
|
||||
}, 0, 0, 0
|
||||
)
|
||||
}
|
||||
|
||||
private fun availabilityColor(
|
||||
status: List<ChargepointStatus>?,
|
||||
context: Context
|
||||
): Int = if (status != null) {
|
||||
val unknown = status.any { it == ChargepointStatus.UNKNOWN }
|
||||
val available = status.count { it == ChargepointStatus.AVAILABLE }
|
||||
val allFaulted = status.all { it == ChargepointStatus.FAULTED }
|
||||
|
||||
if (unknown) {
|
||||
ContextCompat.getColor(context, R.color.unknown)
|
||||
} else if (available > 0) {
|
||||
ContextCompat.getColor(context, R.color.available)
|
||||
} else {
|
||||
} else if (allFaulted) {
|
||||
ContextCompat.getColor(context, R.color.unavailable)
|
||||
} else {
|
||||
ContextCompat.getColor(context, R.color.charging)
|
||||
}
|
||||
} else {
|
||||
val ta = context.theme.obtainStyledAttributes(intArrayOf(R.attr.colorControlNormal))
|
||||
@@ -160,4 +244,71 @@ fun availabilityText(status: List<ChargepointStatus>?): String? {
|
||||
return if (unknown > 0) {
|
||||
if (unknown == total) "?" else "$available?"
|
||||
} else available.toString()
|
||||
}
|
||||
|
||||
fun flatten(it: Iterable<Iterable<ChargepointStatus>>?): List<ChargepointStatus>? {
|
||||
return it?.flatten()
|
||||
}
|
||||
|
||||
fun currency(currency: String): String {
|
||||
// shorthands for currencies
|
||||
return when (currency) {
|
||||
"EUR" -> "€"
|
||||
"USD" -> "$"
|
||||
"DKK", "SEK", "NOK" -> "kr."
|
||||
"PLN" -> "zł"
|
||||
"CHF" -> "Fr. "
|
||||
"CZK" -> "Kč"
|
||||
"GBP" -> "£"
|
||||
"HRK" -> "kn"
|
||||
"HUF" -> "Ft"
|
||||
"ISK" -> "Kr"
|
||||
else -> currency
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@BindingAdapter("app:valuesAttrChanged")
|
||||
fun setRangeSliderListeners(slider: RangeSlider, attrChange: InverseBindingListener) {
|
||||
slider.addOnChangeListener { _, _, _ ->
|
||||
attrChange.onChange()
|
||||
}
|
||||
}
|
||||
|
||||
@ColorInt
|
||||
fun colorEnabled(ctx: Context, enabled: Boolean): Int {
|
||||
val attr = if (enabled) {
|
||||
android.R.attr.textColorSecondary
|
||||
} else {
|
||||
android.R.attr.textColorHint
|
||||
}
|
||||
val typedValue = ctx.obtainStyledAttributes(intArrayOf(attr))
|
||||
val color = typedValue.getColor(0, 0)
|
||||
typedValue.recycle()
|
||||
return color
|
||||
}
|
||||
|
||||
@BindingAdapter("app:tint")
|
||||
fun setImageTintList(view: ImageView, @ColorInt color: Int) {
|
||||
view.imageTintList = ColorStateList.valueOf(color)
|
||||
}
|
||||
|
||||
@BindingAdapter("myTariffsBackground")
|
||||
fun myTariffsBackground(view: View, myTariff: Boolean) {
|
||||
if (myTariff) {
|
||||
view.background = ContextCompat.getDrawable(view.context, R.drawable.my_tariff_background)
|
||||
} else {
|
||||
view.context.obtainStyledAttributes(intArrayOf(R.attr.selectableItemBackground)).use {
|
||||
view.background = it.getDrawable(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package net.vonforst.evmap.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import android.view.View
|
||||
import android.widget.Checkable
|
||||
import androidx.constraintlayout.widget.ConstraintLayout
|
||||
|
||||
|
||||
class CheckableConstraintLayout(ctx: Context, attrs: AttributeSet) : ConstraintLayout(ctx, attrs),
|
||||
Checkable {
|
||||
private var onCheckedChangeListener: ((View, Boolean) -> Unit)? = null
|
||||
private var checked = false
|
||||
private val CHECKED_STATE_SET = intArrayOf(android.R.attr.state_checked)
|
||||
|
||||
override fun setChecked(b: Boolean) {
|
||||
if (b != checked) {
|
||||
checked = b;
|
||||
refreshDrawableState();
|
||||
onCheckedChangeListener?.invoke(this, checked);
|
||||
}
|
||||
}
|
||||
|
||||
override fun isChecked(): Boolean {
|
||||
return checked
|
||||
}
|
||||
|
||||
override fun toggle() {
|
||||
checked = !checked
|
||||
}
|
||||
|
||||
override fun onCreateDrawableState(extraSpace: Int): IntArray? {
|
||||
val drawableState = super.onCreateDrawableState(extraSpace + 1)
|
||||
if (isChecked) {
|
||||
mergeDrawableStates(drawableState, CHECKED_STATE_SET)
|
||||
}
|
||||
return drawableState
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a callback to be invoked when the checked state of this view changes.
|
||||
*
|
||||
* @param listener the callback to call on checked state change
|
||||
*/
|
||||
fun setOnCheckedChangeListener(listener: (View, Boolean) -> Unit) {
|
||||
onCheckedChangeListener = listener
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
63
app/src/main/java/net/vonforst/evmap/ui/Dialogs.kt
Normal file
@@ -0,0 +1,63 @@
|
||||
package net.vonforst.evmap.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.content.DialogInterface
|
||||
import android.view.Gravity
|
||||
import android.view.View
|
||||
import android.view.inputmethod.EditorInfo
|
||||
import android.view.inputmethod.InputMethodManager
|
||||
import android.widget.EditText
|
||||
import android.widget.FrameLayout
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
|
||||
private fun dialogEditText(ctx: Context): Pair<View, EditText> {
|
||||
val container = FrameLayout(ctx)
|
||||
container.setPadding(
|
||||
(16 * ctx.resources.displayMetrics.density).toInt(), 0,
|
||||
(16 * ctx.resources.displayMetrics.density).toInt(), 0
|
||||
)
|
||||
val input = EditText(ctx)
|
||||
input.isSingleLine = true
|
||||
container.addView(input)
|
||||
return container to input
|
||||
}
|
||||
|
||||
fun showEditTextDialog(
|
||||
ctx: Context,
|
||||
customize: (AlertDialog.Builder, EditText) -> Unit
|
||||
): AlertDialog {
|
||||
val (container, input) = dialogEditText(ctx)
|
||||
val dialogBuilder = AlertDialog.Builder(ctx)
|
||||
.setView(container)
|
||||
|
||||
customize(dialogBuilder, input)
|
||||
|
||||
val dialog = dialogBuilder.show()
|
||||
|
||||
|
||||
// move dialog to top
|
||||
val attrs = dialog.window?.attributes?.apply {
|
||||
gravity = Gravity.TOP
|
||||
}
|
||||
dialog.window?.attributes = attrs
|
||||
|
||||
// focus and show keyboard
|
||||
input.requestFocus()
|
||||
input.postDelayed({
|
||||
val imm =
|
||||
ctx.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
|
||||
imm.showSoftInput(input, InputMethodManager.SHOW_IMPLICIT)
|
||||
}, 100)
|
||||
input.setOnEditorActionListener { _, actionId, _ ->
|
||||
if (actionId == EditorInfo.IME_ACTION_DONE) {
|
||||
val text = input.text
|
||||
val button = dialog.getButton(DialogInterface.BUTTON_POSITIVE)
|
||||
if (text != null && button != null) {
|
||||
button.performClick()
|
||||
return@setOnEditorActionListener true
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
return dialog
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import com.car2go.maps.model.BitmapDescriptor
|
||||
import com.google.maps.android.ui.IconGenerator
|
||||
import com.google.maps.android.ui.SquareTextView
|
||||
import net.vonforst.evmap.R
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
class ClusterIconGenerator(context: Context) : IconGenerator(context) {
|
||||
init {
|
||||
@@ -41,8 +42,14 @@ class ClusterIconGenerator(context: Context) : IconGenerator(context) {
|
||||
}
|
||||
|
||||
|
||||
class ChargerIconGenerator(val context: Context, val factory: BitmapDescriptorFactory) {
|
||||
data class BitmapData(
|
||||
class ChargerIconGenerator(
|
||||
val context: Context,
|
||||
val factory: BitmapDescriptorFactory?,
|
||||
val scaleResolution: Int = 20,
|
||||
val oversize: Float = 1.4f, // increase to add padding for fault icon or scale > 1
|
||||
val height: Int = 44
|
||||
) {
|
||||
private data class BitmapData(
|
||||
val tint: Int,
|
||||
val scale: Int,
|
||||
val alpha: Int,
|
||||
@@ -51,20 +58,16 @@ class ChargerIconGenerator(val context: Context, val factory: BitmapDescriptorFa
|
||||
val multi: Boolean
|
||||
)
|
||||
|
||||
val cacheSize = 840; // 840 items: 21 sizes, 5 colors, highlight, fault, multi on/off
|
||||
val cache = LruCache<BitmapData, BitmapDescriptor>(cacheSize)
|
||||
val oversize = 1.4f // increase to add padding for fault icon or scale > 1
|
||||
val icon = R.drawable.ic_map_marker_charging
|
||||
val multiIcon = R.drawable.ic_map_marker_charging_multiple
|
||||
val highlightIcon = R.drawable.ic_map_marker_highlight
|
||||
val highlightIconMulti = R.drawable.ic_map_marker_charging_highlight_multiple
|
||||
val faultIcon = R.drawable.ic_map_marker_fault
|
||||
// 230 items: (21 sizes, 5 colors, multi on/off) + highlight + fault (only with scale = 1)
|
||||
private val cacheSize = (scaleResolution + 3) * 5 * 2;
|
||||
private val cache = LruCache<BitmapData, BitmapDescriptor>(cacheSize)
|
||||
private val icon = R.drawable.ic_map_marker_charging
|
||||
private val multiIcon = R.drawable.ic_map_marker_charging_multiple
|
||||
private val highlightIcon = R.drawable.ic_map_marker_highlight
|
||||
private val highlightIconMulti = R.drawable.ic_map_marker_charging_highlight_multiple
|
||||
private val faultIcon = R.drawable.ic_map_marker_fault
|
||||
|
||||
init {
|
||||
preloadCache()
|
||||
}
|
||||
|
||||
private fun preloadCache() {
|
||||
fun preloadCache() {
|
||||
// pre-generates images for scale from 0 to 255 for all possible tint colors
|
||||
val tints = listOf(
|
||||
R.color.charger_100kw,
|
||||
@@ -77,8 +80,11 @@ class ChargerIconGenerator(val context: Context, val factory: BitmapDescriptorFa
|
||||
for (highlight in listOf(false, true)) {
|
||||
for (multi in listOf(false, true)) {
|
||||
for (tint in tints) {
|
||||
for (scale in 0..20) {
|
||||
getBitmapDescriptor(tint, scale, 255, highlight, fault, multi)
|
||||
for (scale in 0..scaleResolution) {
|
||||
getBitmapDescriptor(
|
||||
tint, scale.toFloat() / scaleResolution,
|
||||
255, highlight, fault, multi
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -88,24 +94,48 @@ class ChargerIconGenerator(val context: Context, val factory: BitmapDescriptorFa
|
||||
|
||||
fun getBitmapDescriptor(
|
||||
@ColorRes tint: Int,
|
||||
scale: Int = 20,
|
||||
scale: Float = 1f,
|
||||
alpha: Int = 255,
|
||||
highlight: Boolean = false,
|
||||
fault: Boolean = false,
|
||||
multi: Boolean = false
|
||||
): BitmapDescriptor? {
|
||||
val data = BitmapData(tint, scale, alpha, highlight, fault, multi)
|
||||
val data = BitmapData(
|
||||
tint, (scale * scaleResolution).roundToInt(),
|
||||
alpha,
|
||||
if (scale == 1f) highlight else false,
|
||||
if (scale == 1f) fault else false,
|
||||
multi
|
||||
)
|
||||
val cachedImg = cache[data]
|
||||
return if (cachedImg != null) {
|
||||
cachedImg
|
||||
} else {
|
||||
val bitmap = generateBitmap(data)
|
||||
val bmd = factory.fromBitmap(bitmap)
|
||||
val bmd = factory!!.fromBitmap(bitmap)
|
||||
cache.put(data, bmd)
|
||||
bmd
|
||||
}
|
||||
}
|
||||
|
||||
fun getBitmap(
|
||||
@ColorRes tint: Int,
|
||||
scale: Float = 1f,
|
||||
alpha: Int = 255,
|
||||
highlight: Boolean = false,
|
||||
fault: Boolean = false,
|
||||
multi: Boolean = false
|
||||
): Bitmap {
|
||||
val data = BitmapData(
|
||||
tint, (scale * scaleResolution).roundToInt(),
|
||||
alpha,
|
||||
if (scale == 1f) highlight else false,
|
||||
if (scale == 1f) fault else false,
|
||||
multi
|
||||
)
|
||||
return generateBitmap(data)
|
||||
}
|
||||
|
||||
private fun generateBitmap(data: BitmapData): Bitmap {
|
||||
val icon = if (data.multi) multiIcon else icon
|
||||
val vd: Drawable = ContextCompat.getDrawable(context, icon)!!
|
||||
@@ -113,27 +143,32 @@ class ChargerIconGenerator(val context: Context, val factory: BitmapDescriptorFa
|
||||
DrawableCompat.setTint(vd, ContextCompat.getColor(context, data.tint));
|
||||
DrawableCompat.setTintMode(vd, PorterDuff.Mode.MULTIPLY);
|
||||
|
||||
val leftPadding = vd.intrinsicWidth * (oversize - 1) / 2
|
||||
val topPadding = vd.intrinsicHeight * (oversize - 1)
|
||||
val density = context.resources.displayMetrics.density
|
||||
val width =
|
||||
(height.toFloat() * density / vd.intrinsicHeight * vd.intrinsicWidth).roundToInt()
|
||||
val height = (height * density).roundToInt()
|
||||
|
||||
val leftPadding = width * (oversize - 1) / 2
|
||||
val topPadding = height * (oversize - 1)
|
||||
vd.setBounds(
|
||||
leftPadding.toInt(), topPadding.toInt(),
|
||||
leftPadding.toInt() + vd.intrinsicWidth,
|
||||
topPadding.toInt() + vd.intrinsicHeight
|
||||
leftPadding.toInt() + width,
|
||||
topPadding.toInt() + height
|
||||
)
|
||||
vd.alpha = data.alpha
|
||||
|
||||
val bm = Bitmap.createBitmap(
|
||||
(vd.intrinsicWidth * oversize).toInt(), (vd.intrinsicHeight * oversize).toInt(),
|
||||
(width * oversize).toInt(), (height * oversize).toInt(),
|
||||
Bitmap.Config.ARGB_8888
|
||||
)
|
||||
val canvas = Canvas(bm)
|
||||
|
||||
val scale = data.scale / 20f
|
||||
val scale = data.scale.toFloat() / scaleResolution
|
||||
canvas.scale(
|
||||
scale,
|
||||
scale,
|
||||
leftPadding + vd.intrinsicWidth / 2f,
|
||||
topPadding + vd.intrinsicHeight.toFloat()
|
||||
leftPadding + width / 2f,
|
||||
topPadding + height.toFloat()
|
||||
)
|
||||
|
||||
vd.draw(canvas)
|
||||
@@ -143,8 +178,8 @@ class ChargerIconGenerator(val context: Context, val factory: BitmapDescriptorFa
|
||||
val highlightDrawable = ContextCompat.getDrawable(context, hIcon)!!
|
||||
highlightDrawable.setBounds(
|
||||
leftPadding.toInt(), topPadding.toInt(),
|
||||
leftPadding.toInt() + vd.intrinsicWidth,
|
||||
topPadding.toInt() + vd.intrinsicHeight
|
||||
leftPadding.toInt() + width,
|
||||
topPadding.toInt() + height
|
||||
)
|
||||
highlightDrawable.alpha = data.alpha
|
||||
highlightDrawable.draw(canvas)
|
||||
@@ -154,7 +189,7 @@ class ChargerIconGenerator(val context: Context, val factory: BitmapDescriptorFa
|
||||
val faultDrawable = ContextCompat.getDrawable(context, faultIcon)!!
|
||||
val faultSize = 0.75
|
||||
val faultShift = 0.25
|
||||
val base = vd.intrinsicWidth
|
||||
val base = width
|
||||
faultDrawable.setBounds(
|
||||
(leftPadding.toInt() + base * (1 - faultSize + faultShift)).toInt(),
|
||||
(topPadding.toInt() - base * faultShift).toInt(),
|
||||
|
||||
@@ -7,12 +7,12 @@ 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(
|
||||
charger: ChargeLocation,
|
||||
connectors: Set<String>?
|
||||
connectors: Set<String>? = null
|
||||
): Int = when {
|
||||
charger.maxPower(connectors) >= 100 -> R.color.charger_100kw
|
||||
charger.maxPower(connectors) >= 43 -> R.color.charger_43kw
|
||||
@@ -36,11 +36,11 @@ class MarkerAnimator(val gen: ChargerIconGenerator) {
|
||||
animatingMarkers.remove(marker)
|
||||
}
|
||||
|
||||
val anim = ValueAnimator.ofInt(0, 20).apply {
|
||||
val anim = ValueAnimator.ofFloat(0f, 1f).apply {
|
||||
duration = 250
|
||||
interpolator = LinearOutSlowInInterpolator()
|
||||
addUpdateListener { animationState ->
|
||||
val scale = animationState.animatedValue as Int
|
||||
val scale = animationState.animatedValue as Float
|
||||
marker.setIcon(
|
||||
gen.getBitmapDescriptor(
|
||||
tint,
|
||||
@@ -73,11 +73,11 @@ class MarkerAnimator(val gen: ChargerIconGenerator) {
|
||||
animatingMarkers.remove(marker)
|
||||
}
|
||||
|
||||
val anim = ValueAnimator.ofInt(20, 0).apply {
|
||||
val anim = ValueAnimator.ofFloat(1f, 0f).apply {
|
||||
duration = 200
|
||||
interpolator = FastOutLinearInInterpolator()
|
||||
addUpdateListener { animationState ->
|
||||
val scale = animationState.animatedValue as Int
|
||||
val scale = animationState.animatedValue as Float
|
||||
marker.setIcon(
|
||||
gen.getBitmapDescriptor(
|
||||
tint,
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
package net.vonforst.evmap.ui
|
||||
|
||||
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(),
|
||||
showAllButton
|
||||
)
|
||||
dialog.okListener = { selected ->
|
||||
all = selected == entryValues.toSet()
|
||||
values = selected
|
||||
}
|
||||
dialog.show((context as AppCompatActivity).supportFragmentManager, null)
|
||||
}
|
||||
|
||||
var all: Boolean
|
||||
get() = sharedPreferences.getBoolean(key + "_all", defaultToAll)
|
||||
set(value) {
|
||||
sharedPreferences.edit().putBoolean(key + "_all", value).apply()
|
||||
}
|
||||
}
|
||||
@@ -13,8 +13,6 @@ class LocaleContextWrapper(base: Context?) : ContextWrapper(base) {
|
||||
fun wrap(context: Context, language: String): ContextWrapper {
|
||||
val sysConfig: Configuration = context.applicationContext.resources.configuration
|
||||
val appConfig: Configuration = context.resources.configuration
|
||||
var ctx = context
|
||||
|
||||
|
||||
if (language == "" || language == "default") {
|
||||
// set default locale
|
||||
@@ -37,8 +35,7 @@ class LocaleContextWrapper(base: Context?) : ContextWrapper(base) {
|
||||
}
|
||||
}
|
||||
|
||||
ctx = context.createConfigurationContext(appConfig)
|
||||
return LocaleContextWrapper(ctx)
|
||||
return LocaleContextWrapper(context.createConfigurationContext(appConfig))
|
||||
}
|
||||
}
|
||||
}
|
||||
77
app/src/main/java/net/vonforst/evmap/utils/LocationUtils.kt
Normal file
@@ -0,0 +1,77 @@
|
||||
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.*
|
||||
|
||||
/**
|
||||
* Adds a certain distance in meters to a location. Approximate calculation.
|
||||
*/
|
||||
fun Location.plusMeters(dx: Double, dy: Double): Pair<Double, Double> {
|
||||
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 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
|
||||
|
||||
/**
|
||||
* Calculates the distance between two points on Earth in meters.
|
||||
* Latitude and longitude should be given in degrees.
|
||||
*/
|
||||
fun distanceBetween(
|
||||
startLatitude: Double, startLongitude: Double,
|
||||
endLatitude: Double, endLongitude: Double
|
||||
): Double {
|
||||
// see https://rosettacode.org/wiki/Haversine_formula#Java
|
||||
val dLat = Math.toRadians(endLatitude - startLatitude)
|
||||
val dLon = Math.toRadians(endLongitude - startLongitude)
|
||||
val originLat = Math.toRadians(startLatitude)
|
||||
val destinationLat = Math.toRadians(endLatitude)
|
||||
|
||||
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)
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
package net.vonforst.evmap.viewmodel
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.*
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
import moe.banana.jsonapi2.HasOne
|
||||
import net.vonforst.evmap.api.chargeprice.*
|
||||
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.*
|
||||
|
||||
class ChargepriceViewModel(application: Application, chargepriceApiKey: String) :
|
||||
AndroidViewModel(application) {
|
||||
private var api = ChargepriceApi.create(chargepriceApiKey)
|
||||
private var prefs = PreferenceDataSource(application)
|
||||
|
||||
val charger: MutableLiveData<ChargeLocation> by lazy {
|
||||
MutableLiveData<ChargeLocation>()
|
||||
}
|
||||
|
||||
val dataSource: MutableLiveData<String> by lazy {
|
||||
MutableLiveData<String>()
|
||||
}
|
||||
|
||||
val chargepoint: MutableLiveData<Chargepoint> by lazy {
|
||||
MutableLiveData<Chargepoint>()
|
||||
}
|
||||
|
||||
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_UNKNOWN,
|
||||
Chargepoint.TYPE_2_SOCKET,
|
||||
Chargepoint.TYPE_2_PLUG
|
||||
)
|
||||
private val plugMapping = mapOf(
|
||||
"ccs" to Chargepoint.CCS_UNKNOWN,
|
||||
"tesla_suc" to Chargepoint.SUPERCHARGER,
|
||||
"tesla_ccs" to Chargepoint.CCS_UNKNOWN,
|
||||
"chademo" to Chargepoint.CHADEMO
|
||||
)
|
||||
val vehicleCompatibleConnectors: LiveData<List<String>> by lazy {
|
||||
MediatorLiveData<List<String>>().apply {
|
||||
addSource(vehicle) {
|
||||
value = it?.dcChargePorts?.map {
|
||||
plugMapping[it]
|
||||
}?.filterNotNull()?.plus(acConnectors)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val noCompatibleConnectors: LiveData<Boolean> by lazy {
|
||||
MediatorLiveData<Boolean>().apply {
|
||||
value = false
|
||||
listOf(charger, vehicleCompatibleConnectors).forEach {
|
||||
addSource(it) {
|
||||
val charger = charger.value ?: return@addSource
|
||||
val connectors = vehicleCompatibleConnectors.value ?: return@addSource
|
||||
value = !charger.chargepoints.flatMap { equivalentPlugTypes(it.type) }
|
||||
.any { it in connectors }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val batteryRange: MutableLiveData<List<Float>> by lazy {
|
||||
MutableLiveData<List<Float>>().apply {
|
||||
value = prefs.chargepriceBatteryRange
|
||||
observeForever {
|
||||
prefs.chargepriceBatteryRange = it
|
||||
}
|
||||
}
|
||||
}
|
||||
val batteryRangeSliderDragging: MutableLiveData<Boolean> by lazy {
|
||||
MutableLiveData<Boolean>().apply {
|
||||
value = false
|
||||
}
|
||||
}
|
||||
|
||||
val chargePrices: MediatorLiveData<Resource<List<ChargePrice>>> by lazy {
|
||||
MediatorLiveData<Resource<List<ChargePrice>>>().apply {
|
||||
value = Resource.loading(null)
|
||||
listOf(
|
||||
charger,
|
||||
dataSource,
|
||||
batteryRange,
|
||||
batteryRangeSliderDragging,
|
||||
vehicleCompatibleConnectors
|
||||
).forEach {
|
||||
addSource(it) {
|
||||
if (!batteryRangeSliderDragging.value!!) loadPrices()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val chargePriceMeta: MutableLiveData<Resource<ChargepriceMeta>> by lazy {
|
||||
MutableLiveData<Resource<ChargepriceMeta>>().apply {
|
||||
value = Resource.loading(null)
|
||||
}
|
||||
}
|
||||
|
||||
val chargePricesForChargepoint: MediatorLiveData<Resource<List<ChargePrice>>> by lazy {
|
||||
MediatorLiveData<Resource<List<ChargePrice>>>().apply {
|
||||
listOf(chargePrices, chargepoint).forEach {
|
||||
addSource(it) {
|
||||
val cps = chargePrices.value
|
||||
val chargepoint = chargepoint.value
|
||||
if (cps == null || chargepoint == null) {
|
||||
value = null
|
||||
} else if (cps.status == Status.ERROR) {
|
||||
value = Resource.error(cps.message, null)
|
||||
} else if (cps.status == Status.LOADING) {
|
||||
value = Resource.loading(null)
|
||||
} else {
|
||||
val myTariffs = prefs.chargepriceMyTariffs
|
||||
value = Resource.success(cps.data!!.map { cp ->
|
||||
val filteredPrices =
|
||||
cp.chargepointPrices.filter {
|
||||
it.plug == getChargepricePlugType(chargepoint) && it.power == chargepoint.power
|
||||
}
|
||||
if (filteredPrices.isEmpty()) {
|
||||
null
|
||||
} else {
|
||||
cp.clone().apply {
|
||||
chargepointPrices = filteredPrices
|
||||
}
|
||||
}
|
||||
}.filterNotNull()
|
||||
.sortedBy { it.chargepointPrices.first().price }
|
||||
.sortedByDescending {
|
||||
prefs.chargepriceMyTariffsAll ||
|
||||
myTariffs != null && it.tariff?.get()?.id in myTariffs
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
val myTariffsAll: LiveData<Boolean> by lazy {
|
||||
MutableLiveData<Boolean>().apply {
|
||||
value = prefs.chargepriceMyTariffsAll
|
||||
}
|
||||
}
|
||||
|
||||
val chargepriceMetaForChargepoint: MediatorLiveData<Resource<ChargepriceChargepointMeta>> by lazy {
|
||||
MediatorLiveData<Resource<ChargepriceChargepointMeta>>().apply {
|
||||
listOf(chargePriceMeta, chargepoint).forEach {
|
||||
addSource(it) {
|
||||
val cpMeta = chargePriceMeta.value
|
||||
val chargepoint = chargepoint.value
|
||||
if (cpMeta == null || chargepoint == null) {
|
||||
value = null
|
||||
} else if (cpMeta.status == Status.ERROR) {
|
||||
value = Resource.error(cpMeta.message, null)
|
||||
} else if (cpMeta.status == Status.LOADING) {
|
||||
value = Resource.loading(null)
|
||||
} else {
|
||||
value =
|
||||
Resource.success(cpMeta.data!!.chargePoints.filter {
|
||||
it.plug == getChargepricePlugType(
|
||||
chargepoint
|
||||
) && it.power == chargepoint.power
|
||||
}[0])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var loadPricesJob: Job? = null
|
||||
fun loadPrices() {
|
||||
chargePrices.value = Resource.loading(null)
|
||||
chargePriceMeta.value = Resource.loading(null)
|
||||
val charger = charger.value
|
||||
val car = vehicle.value
|
||||
val compatibleConnectors = vehicleCompatibleConnectors.value
|
||||
val dataSource = dataSource.value
|
||||
if (charger == null || car == null || compatibleConnectors == null || dataSource == null) {
|
||||
chargePrices.value = Resource.error(null, null)
|
||||
return
|
||||
}
|
||||
|
||||
val cpStation = ChargepriceStation.fromEvmap(charger, compatibleConnectors)
|
||||
|
||||
loadPricesJob?.cancel()
|
||||
loadPricesJob = viewModelScope.launch {
|
||||
try {
|
||||
val result = api.getChargePrices(ChargepriceRequest().apply {
|
||||
dataAdapter = dataSource
|
||||
station = cpStation
|
||||
vehicle = HasOne(car)
|
||||
options = ChargepriceOptions(
|
||||
batteryRange = batteryRange.value!!.map { it.toDouble() },
|
||||
providerCustomerTariffs = prefs.chargepriceShowProviderCustomerTariffs,
|
||||
maxMonthlyFees = if (prefs.chargepriceNoBaseFee) 0.0 else null,
|
||||
currency = prefs.chargepriceCurrency
|
||||
)
|
||||
}, getChargepriceLanguage())
|
||||
val meta =
|
||||
result.meta.get<ChargepriceMeta>(ChargepriceApi.moshi.adapter(ChargepriceMeta::class.java)) as ChargepriceMeta
|
||||
chargePrices.value = Resource.success(result)
|
||||
chargePriceMeta.value = Resource.success(meta)
|
||||
} 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getChargepriceLanguage(): String {
|
||||
val locale = Locale.getDefault().language
|
||||
return if (ChargepriceApi.supportedLanguages.contains(locale)) {
|
||||
locale
|
||||
} else {
|
||||
"en"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.distanceBetween
|
||||
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 {
|
||||
@@ -71,7 +69,7 @@ class FavoritesViewModel(application: Application, geApiKey: String) :
|
||||
) / 1000
|
||||
}
|
||||
})
|
||||
}
|
||||
}?.sortedBy { it.distance }
|
||||
}
|
||||
addSource(favorites, callback)
|
||||
addSource(location, callback)
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package net.vonforst.evmap.viewmodel
|
||||
|
||||
import android.app.Application
|
||||
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
|
||||
|
||||
class FilterProfilesViewModel(application: Application) : AndroidViewModel(application) {
|
||||
private var db = AppDatabase.getInstance(application)
|
||||
private var prefs = PreferenceDataSource(application)
|
||||
|
||||
val filterProfiles: LiveData<List<FilterProfile>> by lazy {
|
||||
db.filterProfileDao().getProfiles(prefs.dataSource)
|
||||
}
|
||||
|
||||
fun delete(itemId: Long) {
|
||||
viewModelScope.launch {
|
||||
val profile = db.filterProfileDao().getProfileById(itemId, prefs.dataSource)
|
||||
profile?.let { db.filterProfileDao().delete(it) }
|
||||
if (prefs.filterStatus == profile?.id) {
|
||||
prefs.filterStatus = FILTERS_DISABLED
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun insert(item: FilterProfile) {
|
||||
viewModelScope.launch {
|
||||
db.filterProfileDao().insert(item)
|
||||
}
|
||||
}
|
||||
|
||||
fun update(item: FilterProfile) {
|
||||
viewModelScope.launch {
|
||||
db.filterProfileDao().update(item)
|
||||
}
|
||||
}
|
||||
|
||||
fun reorderProfiles(list: List<FilterProfile>) {
|
||||
viewModelScope.launch {
|
||||
db.filterProfileDao().update(*list.toTypedArray())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,233 +1,130 @@
|
||||
package net.vonforst.evmap.viewmodel
|
||||
|
||||
import android.app.Application
|
||||
import androidx.databinding.BaseObservable
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MediatorLiveData
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
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 androidx.lifecycle.*
|
||||
import kotlinx.coroutines.launch
|
||||
import net.vonforst.evmap.api.ChargepointApi
|
||||
import net.vonforst.evmap.api.createApi
|
||||
import net.vonforst.evmap.api.goingelectric.GoingElectricApiWrapper
|
||||
import net.vonforst.evmap.api.openchargemap.OpenChargeMapApiWrapper
|
||||
import net.vonforst.evmap.api.stringProvider
|
||||
import net.vonforst.evmap.model.*
|
||||
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 }
|
||||
.minBy { 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 {
|
||||
val plugNames = mapOf(
|
||||
Chargepoint.TYPE_1 to application.getString(R.string.plug_type_1),
|
||||
Chargepoint.TYPE_2 to application.getString(R.string.plug_type_2),
|
||||
Chargepoint.TYPE_3 to application.getString(R.string.plug_type_3),
|
||||
Chargepoint.CCS to application.getString(R.string.plug_ccs),
|
||||
Chargepoint.SCHUKO to application.getString(R.string.plug_schuko),
|
||||
Chargepoint.CHADEMO to application.getString(R.string.plug_chademo),
|
||||
Chargepoint.SUPERCHARGER to application.getString(R.string.plug_supercharger),
|
||||
Chargepoint.CEE_BLAU to application.getString(R.string.plug_cee_blau),
|
||||
Chargepoint.CEE_ROT to application.getString(R.string.plug_cee_rot)
|
||||
)
|
||||
listOf(plugs, networks, chargeCards).forEach { source ->
|
||||
addSource(source) { _ ->
|
||||
buildFilters(plugs, plugNames, networks, chargeCards, application)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun MediatorLiveData<List<Filter<FilterValue>>>.buildFilters(
|
||||
plugs: LiveData<List<Plug>>,
|
||||
plugNames: Map<String, String>,
|
||||
networks: LiveData<List<Network>>,
|
||||
chargeCards: LiveData<List<ChargeCard>>,
|
||||
application: Application
|
||||
) {
|
||||
val plugMap = plugs.value?.map { plug ->
|
||||
plug.name to (plugNames[plug.name] ?: 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
|
||||
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
|
||||
),
|
||||
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")
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
internal fun filtersWithValue(
|
||||
filters: LiveData<List<Filter<FilterValue>>>,
|
||||
filterValues: LiveData<List<FilterValue>>,
|
||||
active: LiveData<Boolean>? = null
|
||||
): MediatorLiveData<List<FilterWithValue<out FilterValue>>> =
|
||||
MediatorLiveData<List<FilterWithValue<out FilterValue>>>().apply {
|
||||
listOf(filters, filterValues, active).forEach {
|
||||
if (it == null) return@forEach
|
||||
filterValues: LiveData<List<FilterValue>>
|
||||
): MediatorLiveData<FilterValues> =
|
||||
MediatorLiveData<FilterValues>().apply {
|
||||
listOf(filters, filterValues).forEach {
|
||||
addSource(it) {
|
||||
val filters = filters.value ?: return@addSource
|
||||
value = if (active != null && !active.value!!) {
|
||||
filters.map { filter ->
|
||||
FilterWithValue(filter, filter.defaultValue())
|
||||
}
|
||||
} else {
|
||||
val values = filterValues.value ?: return@addSource
|
||||
filters.map { filter ->
|
||||
val value =
|
||||
values.find { it.key == filter.key } ?: filter.defaultValue()
|
||||
FilterWithValue(filter, filter.valueClass.cast(value))
|
||||
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) : AndroidViewModel(application) {
|
||||
private var db = AppDatabase.getInstance(application)
|
||||
private var prefs = PreferenceDataSource(application)
|
||||
private var api: ChargepointApi<ReferenceData> = createApi(prefs.dataSource, application)
|
||||
|
||||
private val referenceData: LiveData<out ReferenceData> by lazy {
|
||||
val api = api
|
||||
when (api) {
|
||||
is GoingElectricApiWrapper -> {
|
||||
GEReferenceDataRepository(
|
||||
api,
|
||||
viewModelScope,
|
||||
db.geReferenceDataDao(),
|
||||
prefs
|
||||
).getReferenceData()
|
||||
}
|
||||
is OpenChargeMapApiWrapper -> {
|
||||
OCMReferenceDataRepository(
|
||||
api,
|
||||
viewModelScope,
|
||||
db.ocmReferenceDataDao(),
|
||||
prefs
|
||||
).getReferenceData()
|
||||
}
|
||||
else -> {
|
||||
throw RuntimeException("no reference data implemented")
|
||||
}
|
||||
}
|
||||
}
|
||||
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, prefs.dataSource)
|
||||
}
|
||||
|
||||
val filtersWithValue: LiveData<FilterValues> by lazy {
|
||||
filtersWithValue(filters, filterValues)
|
||||
}
|
||||
|
||||
private val filterStatus: LiveData<Long> by lazy {
|
||||
MutableLiveData<Long>().apply {
|
||||
value = prefs.filterStatus
|
||||
}
|
||||
}
|
||||
|
||||
val filterProfile: LiveData<FilterProfile> by lazy {
|
||||
MediatorLiveData<FilterProfile>().apply {
|
||||
addSource(filterStatus) { id ->
|
||||
when (id) {
|
||||
FILTERS_CUSTOM, FILTERS_DISABLED -> value = null
|
||||
else -> viewModelScope.launch {
|
||||
value = db.filterProfileDao().getProfileById(id, prefs.dataSource)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class FilterViewModel(application: Application, geApiKey: String) :
|
||||
AndroidViewModel(application) {
|
||||
private var api = GoingElectricApi.create(geApiKey, context = application)
|
||||
private var db = AppDatabase.getInstance(application)
|
||||
private var prefs = PreferenceDataSource(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 filterValues: LiveData<List<FilterValue>> by lazy {
|
||||
db.filterValueDao().getFilterValues()
|
||||
}
|
||||
|
||||
val filtersWithValue: LiveData<List<FilterWithValue<out FilterValue>>> by lazy {
|
||||
filtersWithValue(filters, filterValues)
|
||||
}
|
||||
|
||||
suspend fun saveFilterValues() {
|
||||
filtersWithValue.value?.forEach {
|
||||
db.filterValueDao().insert(it.value)
|
||||
filtersWithValue.value?.map {
|
||||
val value = it.value
|
||||
value.profile = FILTERS_CUSTOM
|
||||
value.dataSource = prefs.dataSource
|
||||
value
|
||||
}?.let {
|
||||
db.filterValueDao().insert(*it.toTypedArray())
|
||||
}
|
||||
|
||||
// set selected profile
|
||||
prefs.filterStatus = FILTERS_CUSTOM
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@Entity
|
||||
data class BooleanFilterValue(
|
||||
@PrimaryKey override val key: String,
|
||||
var value: Boolean
|
||||
) : FilterValue()
|
||||
|
||||
@Entity
|
||||
data class MultipleChoiceFilterValue(
|
||||
@PrimaryKey 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
|
||||
suspend fun saveAsProfile(name: String) {
|
||||
// get or create profile
|
||||
var profileId = db.filterProfileDao().getProfileByName(name, prefs.dataSource)?.id
|
||||
if (profileId == null) {
|
||||
profileId = db.filterProfileDao().getNewId(prefs.dataSource)
|
||||
db.filterProfileDao().insert(FilterProfile(name, prefs.dataSource, profileId))
|
||||
}
|
||||
|
||||
// save filter values
|
||||
filtersWithValue.value?.map {
|
||||
val value = it.value
|
||||
value.profile = profileId
|
||||
value.dataSource = prefs.dataSource
|
||||
value
|
||||
}?.let {
|
||||
db.filterValueDao().insert(*it.toTypedArray())
|
||||
}
|
||||
|
||||
// set selected profile
|
||||
prefs.filterStatus = profileId
|
||||
}
|
||||
}
|
||||
|
||||
@Entity
|
||||
data class SliderFilterValue(
|
||||
@PrimaryKey override val key: String,
|
||||
var value: Int
|
||||
) : FilterValue()
|
||||
|
||||
data class FilterWithValue<T : FilterValue>(val filter: Filter<T>, val value: T) : Equatable
|
||||
}
|
||||
@@ -5,17 +5,21 @@ 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.*
|
||||
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.*
|
||||
import net.vonforst.evmap.ui.cluster
|
||||
import retrofit2.Call
|
||||
import retrofit2.Callback
|
||||
import retrofit2.Response
|
||||
import net.vonforst.evmap.utils.distanceBetween
|
||||
import java.io.IOException
|
||||
|
||||
data class MapPosition(val bounds: LatLngBounds, val zoom: Float)
|
||||
@@ -32,11 +36,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 chargepointLoader: Job? = null
|
||||
private var api: ChargepointApi<ReferenceData> = createApi(prefs.dataSource, application)
|
||||
|
||||
val bottomSheetState: MutableLiveData<Int> by lazy {
|
||||
MutableLiveData<Int>()
|
||||
@@ -46,30 +54,67 @@ class MapViewModel(application: Application, geApiKey: String) : AndroidViewMode
|
||||
MutableLiveData<MapPosition>()
|
||||
}
|
||||
private val filterValues: LiveData<List<FilterValue>> by lazy {
|
||||
db.filterValueDao().getFilterValues()
|
||||
MediatorLiveData<List<FilterValue>>().apply {
|
||||
var source: LiveData<List<FilterValue>>? = null
|
||||
addSource(filterStatus) { status ->
|
||||
source?.let { removeSource(it) }
|
||||
source = db.filterValueDao().getFilterValues(status, prefs.dataSource)
|
||||
addSource(source!!) { result ->
|
||||
value = result
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
private val plugs: LiveData<List<Plug>> by lazy {
|
||||
PlugRepository(api, viewModelScope, db.plugDao(), prefs).getPlugs()
|
||||
private val referenceData: LiveData<out ReferenceData> by lazy {
|
||||
val api = api
|
||||
when (api) {
|
||||
is GoingElectricApiWrapper -> {
|
||||
GEReferenceDataRepository(
|
||||
api,
|
||||
viewModelScope,
|
||||
db.geReferenceDataDao(),
|
||||
prefs
|
||||
).getReferenceData()
|
||||
}
|
||||
is OpenChargeMapApiWrapper -> {
|
||||
OCMReferenceDataRepository(
|
||||
api,
|
||||
viewModelScope,
|
||||
db.ocmReferenceDataDao(),
|
||||
prefs
|
||||
).getReferenceData()
|
||||
}
|
||||
else -> {
|
||||
throw RuntimeException("no reference data implemented")
|
||||
}
|
||||
}
|
||||
}
|
||||
private val networks: LiveData<List<Network>> by lazy {
|
||||
NetworkRepository(api, viewModelScope, db.networkDao(), prefs).getNetworks()
|
||||
private val filters = MediatorLiveData<List<Filter<FilterValue>>>().apply {
|
||||
addSource(referenceData) { data ->
|
||||
val api = api
|
||||
value = api.getFilters(data, application.stringProvider())
|
||||
}
|
||||
}
|
||||
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 filtersWithValue: LiveData<List<FilterWithValue<out FilterValue>>> by lazy {
|
||||
filtersWithValue(filters, filterValues, filtersActive)
|
||||
private val filtersWithValue: LiveData<FilterValues> by lazy {
|
||||
filtersWithValue(filters, filterValues)
|
||||
}
|
||||
|
||||
val filterProfiles: LiveData<List<FilterProfile>> by lazy {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -79,7 +124,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())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -88,7 +133,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()
|
||||
}
|
||||
@@ -98,6 +143,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>>()
|
||||
}
|
||||
@@ -107,11 +155,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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -128,6 +180,27 @@ class MapViewModel(application: Application, geApiKey: String) : AndroidViewMode
|
||||
}
|
||||
}
|
||||
}
|
||||
val chargerDistance: MediatorLiveData<Double> by lazy {
|
||||
MediatorLiveData<Double>().apply {
|
||||
val callback = { _: Any? ->
|
||||
val loc = location.value
|
||||
val charger = chargerSparse.value
|
||||
value = if (loc != null && charger != null) {
|
||||
distanceBetween(
|
||||
loc.latitude,
|
||||
loc.longitude,
|
||||
charger.coordinates.lat,
|
||||
charger.coordinates.lng
|
||||
) / 1000
|
||||
} else null
|
||||
}
|
||||
addSource(chargerSparse, callback)
|
||||
addSource(location, callback)
|
||||
}
|
||||
}
|
||||
val location: MutableLiveData<LatLng> by lazy {
|
||||
MutableLiveData<LatLng>()
|
||||
}
|
||||
val availability: MediatorLiveData<Resource<ChargeLocationStatus>> by lazy {
|
||||
MediatorLiveData<Resource<ChargeLocationStatus>>().apply {
|
||||
addSource(chargerSparse) { charger ->
|
||||
@@ -141,6 +214,27 @@ class MapViewModel(application: Application, geApiKey: String) : AndroidViewMode
|
||||
}
|
||||
}
|
||||
}
|
||||
val filteredAvailability: MediatorLiveData<Resource<ChargeLocationStatus>> by lazy {
|
||||
MediatorLiveData<Resource<ChargeLocationStatus>>().apply {
|
||||
val callback = { _: Any? ->
|
||||
val av = availability.value
|
||||
val filters = filtersWithValue.value
|
||||
if (av?.status == Status.SUCCESS && filters != null) {
|
||||
value = Resource.success(
|
||||
av.data!!.applyFilters(
|
||||
filteredConnectors.value,
|
||||
filteredMinPower.value
|
||||
)
|
||||
)
|
||||
} else {
|
||||
value = av
|
||||
}
|
||||
}
|
||||
addSource(availability, callback)
|
||||
addSource(filteredConnectors, callback)
|
||||
addSource(filteredMinPower, callback)
|
||||
}
|
||||
}
|
||||
val myLocationEnabled: MutableLiveData<Boolean> by lazy {
|
||||
MutableLiveData<Boolean>()
|
||||
}
|
||||
@@ -160,25 +254,56 @@ class MapViewModel(application: Application, geApiKey: String) : AndroidViewMode
|
||||
|
||||
val mapType: MutableLiveData<AnyMap.Type> by lazy {
|
||||
MutableLiveData<AnyMap.Type>().apply {
|
||||
value = AnyMap.Type.NORMAL
|
||||
value = prefs.mapType
|
||||
observeForever {
|
||||
prefs.mapType = it
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val mapTrafficEnabled: MutableLiveData<Boolean> by lazy {
|
||||
MutableLiveData<Boolean>().apply {
|
||||
value = false
|
||||
value = prefs.mapTrafficEnabled
|
||||
observeForever {
|
||||
prefs.mapTrafficEnabled = it
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val filtersActive: MutableLiveData<Boolean> by lazy {
|
||||
MutableLiveData<Boolean>().apply {
|
||||
value = prefs.filtersActive
|
||||
val filterStatus: MutableLiveData<Long> by lazy {
|
||||
MutableLiveData<Long>().apply {
|
||||
value = prefs.filterStatus
|
||||
observeForever {
|
||||
prefs.filtersActive = it
|
||||
prefs.filterStatus = it
|
||||
if (it != FILTERS_DISABLED) prefs.lastFilterProfile = it
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun reloadPrefs() {
|
||||
filterStatus.value = prefs.filterStatus
|
||||
}
|
||||
|
||||
fun toggleFilters() {
|
||||
if (filterStatus.value == FILTERS_DISABLED) {
|
||||
filterStatus.value = prefs.lastFilterProfile
|
||||
} else {
|
||||
filterStatus.value = FILTERS_DISABLED
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun copyFiltersToCustom() {
|
||||
if (filterStatus.value == FILTERS_CUSTOM) return
|
||||
|
||||
db.filterValueDao().deleteFilterValuesForProfile(FILTERS_CUSTOM, prefs.dataSource)
|
||||
filterValues.value?.map {
|
||||
it.profile = FILTERS_CUSTOM
|
||||
it
|
||||
}?.let {
|
||||
db.filterValueDao().insert(*it.toTypedArray())
|
||||
}
|
||||
}
|
||||
|
||||
fun setMapType(type: AnyMap.Type) {
|
||||
mapType.value = type
|
||||
}
|
||||
@@ -198,182 +323,87 @@ 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 fun loadChargepoints(
|
||||
mapPosition: MapPosition,
|
||||
filters: List<FilterWithValue<out FilterValue>>
|
||||
) {
|
||||
chargepointLoader?.cancel()
|
||||
private var chargepointLoader =
|
||||
throttleLatest(
|
||||
500L,
|
||||
viewModelScope
|
||||
) { data: Triple<MapPosition, FilterValues, ReferenceData> ->
|
||||
chargepoints.value = Resource.loading(chargepoints.value?.data)
|
||||
filteredConnectors.value = null
|
||||
filteredMinPower.value = null
|
||||
filteredChargeCards.value = null
|
||||
|
||||
chargepoints.value = Resource.loading(chargepoints.value?.data)
|
||||
filteredConnectors.value = null
|
||||
filteredChargeCards.value = null
|
||||
val bounds = mapPosition.bounds
|
||||
val zoom = mapPosition.zoom
|
||||
chargepointLoader = viewModelScope.launch {
|
||||
val result = getChargepointsWithFilters(bounds, zoom, filters)
|
||||
filteredConnectors.value = result.second
|
||||
filteredChargeCards.value = result.third
|
||||
chargepoints.value = result.first
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun getChargepointsWithFilters(
|
||||
bounds: LatLngBounds,
|
||||
zoom: Float,
|
||||
filters: List<FilterWithValue<out FilterValue>>
|
||||
): Triple<Resource<List<ChargepointListItem>>, Set<String>?, Set<Long>?> {
|
||||
val freecharging = getBooleanValue(filters, "freecharging")
|
||||
val freeparking = getBooleanValue(filters, "freeparking")
|
||||
val open247 = getBooleanValue(filters, "open_247")
|
||||
val barrierfree = getBooleanValue(filters, "barrierfree")
|
||||
val excludeFaults = getBooleanValue(filters, "exclude_faults")
|
||||
val minPower = getSliderValue(filters, "min_power")
|
||||
val minConnectors = getSliderValue(filters, "min_connectors")
|
||||
|
||||
val connectorsVal = getMultipleChoiceValue(filters, "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 = getMultipleChoiceValue(filters, "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 = getMultipleChoiceValue(filters, "networks")
|
||||
if (networksVal.values.isEmpty() && !networksVal.all) {
|
||||
// no networks chosen
|
||||
return Triple(Resource.success(emptyList()), filteredConnectors, filteredChargeCards)
|
||||
}
|
||||
val networks = formatMultipleChoice(networksVal)
|
||||
|
||||
// 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,
|
||||
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 mapPosition = data.first
|
||||
val filters = data.second
|
||||
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 fun getBooleanValue(
|
||||
filters: List<FilterWithValue<out FilterValue>>,
|
||||
key: String
|
||||
) = (filters.find { it.value.key == key }!!.value as BooleanFilterValue).value
|
||||
|
||||
private fun getSliderValue(
|
||||
filters: List<FilterWithValue<out FilterValue>>,
|
||||
key: String
|
||||
) = (filters.find { it.value.key == key }!!.value as SliderFilterValue).value
|
||||
|
||||
private fun getMultipleChoiceFilter(
|
||||
filters: List<FilterWithValue<out FilterValue>>,
|
||||
key: String
|
||||
) = filters.find { it.value.key == key }!!.filter as MultipleChoiceFilter
|
||||
|
||||
private fun getMultipleChoiceValue(
|
||||
filters: List<FilterWithValue<out FilterValue>>,
|
||||
key: String
|
||||
) = filters.find { it.value.key == key }!!.value as MultipleChoiceFilterValue
|
||||
|
||||
private suspend fun loadAvailability(charger: ChargeLocation) {
|
||||
availability.value = Resource.loading(null)
|
||||
availability.value = getAvailability(charger)
|
||||
}
|
||||
|
||||
private fun loadChargerDetails(charger: ChargeLocation) {
|
||||
private fun loadChargerDetails(charger: ChargeLocation, referenceData: ReferenceData) {
|
||||
chargerDetails.value = Resource.loading(null)
|
||||
api.getChargepointDetail(charger.id).enqueue(object :
|
||||
Callback<ChargepointList> {
|
||||
override fun onFailure(call: Call<ChargepointList>, t: Throwable) {
|
||||
chargerDetails.value = Resource.error(t.message, null)
|
||||
t.printStackTrace()
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
chargerDetails.value = api.getChargepointDetail(referenceData, charger.id)
|
||||
} catch (e: IOException) {
|
||||
chargerDetails.value = Resource.error(e.message, null)
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResponse(
|
||||
call: Call<ChargepointList>,
|
||||
response: Response<ChargepointList>
|
||||
) {
|
||||
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)
|
||||
fun loadChargerById(chargerId: Long) {
|
||||
chargerDetails.value = Resource.loading(null)
|
||||
chargerSparse.value = 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
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
package net.vonforst.evmap.viewmodel
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.launch
|
||||
import net.vonforst.evmap.api.chargeprice.ChargepriceApi
|
||||
import net.vonforst.evmap.api.chargeprice.ChargepriceCar
|
||||
import net.vonforst.evmap.api.chargeprice.ChargepriceTariff
|
||||
import java.io.IOException
|
||||
|
||||
class SettingsViewModel(application: Application, chargepriceApiKey: String) :
|
||||
AndroidViewModel(application) {
|
||||
private var api = ChargepriceApi.create(chargepriceApiKey)
|
||||
|
||||
val vehicles: MutableLiveData<Resource<List<ChargepriceCar>>> by lazy {
|
||||
MutableLiveData<Resource<List<ChargepriceCar>>>().apply {
|
||||
value = Resource.loading(null)
|
||||
loadVehicles()
|
||||
}
|
||||
}
|
||||
|
||||
val tariffs: MutableLiveData<Resource<List<ChargepriceTariff>>> by lazy {
|
||||
MutableLiveData<Resource<List<ChargepriceTariff>>>().apply {
|
||||
value = Resource.loading(null)
|
||||
loadTariffs()
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadVehicles() {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val result = api.getVehicles()
|
||||
vehicles.value = Resource.success(result)
|
||||
} catch (e: IOException) {
|
||||
vehicles.value = Resource.error(e.message, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadTariffs() {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val result = api.getTariffs()
|
||||
tariffs.value = Resource.success(result)
|
||||
} catch (e: IOException) {
|
||||
tariffs.value = Resource.error(e.message, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,9 +3,14 @@ package net.vonforst.evmap.viewmodel
|
||||
import androidx.annotation.MainThread
|
||||
import androidx.annotation.Nullable
|
||||
import androidx.lifecycle.*
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
inline fun <VM : ViewModel> viewModelFactory(crossinline f: () -> VM) =
|
||||
object : ViewModelProvider.Factory {
|
||||
override fun <T : ViewModel> create(aClass: Class<T>): T = f() as T
|
||||
@@ -62,4 +67,27 @@ class SingleLiveEvent<T> : MutableLiveData<T>() {
|
||||
fun call() {
|
||||
value = null
|
||||
}
|
||||
}
|
||||
|
||||
fun <T> throttleLatest(
|
||||
skipMs: Long = 300L,
|
||||
coroutineScope: CoroutineScope,
|
||||
destinationFunction: suspend (T) -> Unit
|
||||
): (T) -> Unit {
|
||||
var throttleJob: Job? = null
|
||||
var waitingParam: T? = null
|
||||
return { param: T ->
|
||||
if (throttleJob?.isCompleted != false) {
|
||||
throttleJob = coroutineScope.launch {
|
||||
destinationFunction(param)
|
||||
delay(skipMs)
|
||||
waitingParam?.let { wParam ->
|
||||
waitingParam = null
|
||||
destinationFunction(wParam)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
waitingParam = param
|
||||
}
|
||||
}
|
||||
}
|
||||
17
app/src/main/res/anim/chargeprice_dialog_enter.xml
Normal file
@@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<set xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<scale
|
||||
android:duration="300"
|
||||
android:fromXScale="0.7"
|
||||
android:fromYScale="0.2"
|
||||
android:toXScale="1.0"
|
||||
android:toYScale="1.0"
|
||||
android:pivotX="50%"
|
||||
android:pivotY="50%"
|
||||
android:interpolator="@android:anim/decelerate_interpolator" />
|
||||
<alpha
|
||||
android:duration="250"
|
||||
android:fromAlpha="0.1"
|
||||
android:toAlpha="1.0"
|
||||
android:interpolator="@android:anim/decelerate_interpolator" />
|
||||
</set>
|
||||
17
app/src/main/res/anim/chargeprice_dialog_exit.xml
Normal file
@@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<set xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<scale
|
||||
android:duration="250"
|
||||
android:fromXScale="1.0"
|
||||
android:fromYScale="1.0"
|
||||
android:toXScale="0.7"
|
||||
android:toYScale="0.2"
|
||||
android:pivotX="50%"
|
||||
android:pivotY="50%"
|
||||
android:interpolator="@android:anim/accelerate_interpolator" />
|
||||
<alpha
|
||||
android:duration="250"
|
||||
android:fromAlpha="1.0"
|
||||
android:toAlpha="0.1"
|
||||
android:interpolator="@android:anim/accelerate_interpolator" />
|
||||
</set>
|
||||
BIN
app/src/main/res/drawable-nodpi/android_auto_screenshot.png
Normal file
|
After Width: | Height: | Size: 158 KiB |
42
app/src/main/res/drawable/android_auto.xml
Normal file
@@ -0,0 +1,42 @@
|
||||
<vector android:height="45.9264dp"
|
||||
android:viewportHeight="480"
|
||||
android:viewportWidth="501.334"
|
||||
android:width="48dp"
|
||||
xmlns:aapt="http://schemas.android.com/aapt"
|
||||
xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<path
|
||||
android:fillColor="#03a9f4"
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="m32,416c-17.68,0 -32,-14.32 -32,-32 0,-5.814 1.547,-11.28 4.267,-15.974l202.667,-352c5.52,-9.573 15.866,-16.026 27.733,-16.026s22.213,6.453 27.733,16.026l202.667,352c2.72,4.694 4.267,10.16 4.267,15.974 0,17.68 -14.32,32 -32,32z" />
|
||||
<path
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="m234.667,149.333v266.667h266.667z">
|
||||
<aapt:attr name="android:fillColor">
|
||||
<gradient
|
||||
android:endX="459.19528"
|
||||
android:endY="410.17865"
|
||||
android:startX="177.41093"
|
||||
android:startY="250.14912"
|
||||
android:type="linear">
|
||||
<item
|
||||
android:color="#331A237E"
|
||||
android:offset="0" />
|
||||
<item
|
||||
android:color="#051A237E"
|
||||
android:offset="1" />
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
<path
|
||||
android:fillColor="#039be5"
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="m206.934,16.026 l-202.667,352c-2.72,4.694 -4.267,10.16 -4.267,15.974 0,17.68 14.32,32 32,32h202.667v-416c-11.867,0 -22.213,6.453 -27.733,16.026z" />
|
||||
<path
|
||||
android:fillColor="#f1f1f1"
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="m234.667,149.333 l181.333,320 -10.666,10.667 -170.667,-64 -170.667,64 -10.666,-10.667z" />
|
||||
<path
|
||||
android:fillColor="#e1e1e1"
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="m234.667,149.333 l181.333,320 -10.666,10.667 -170.667,-64" />
|
||||
</vector>
|
||||
15
app/src/main/res/drawable/button_outline.xml
Normal file
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:state_checked="true">
|
||||
<shape android:shape="rectangle">
|
||||
<stroke android:color="?attr/colorPrimary" android:width="1dp" />
|
||||
<corners android:radius="4dp" />
|
||||
</shape>
|
||||
</item>
|
||||
<item android:state_checked="false">
|
||||
<shape android:shape="rectangle">
|
||||
<stroke android:color="#1F000000" android:width="1dp" />
|
||||
<corners android:radius="4dp" />
|
||||
</shape>
|
||||
</item>
|
||||
</selector>
|
||||
12
app/src/main/res/drawable/circle_bg_logo.xml
Normal file
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item>
|
||||
<shape android:shape="oval">
|
||||
<solid android:color="#66FFFFFF" />
|
||||
<size
|
||||
android:height="24dp"
|
||||
android:width="24dp" />
|
||||
</shape>
|
||||
</item>
|
||||
<item android:drawable="?attr/controlBackground" />
|
||||
</layer-list>
|
||||
10
app/src/main/res/drawable/ic_chargeprice_alert.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<vector android:height="20dp"
|
||||
android:tint="?attr/colorControlNormal"
|
||||
android:viewportHeight="24"
|
||||
android:viewportWidth="24"
|
||||
android:width="20dp"
|
||||
xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM13,17h-2v-2h2v2zM13,13h-2L11,7h2v6z" />
|
||||
</vector>
|
||||
10
app/src/main/res/drawable/ic_chargeprice_info.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<vector android:height="20dp"
|
||||
android:tint="?attr/colorControlNormal"
|
||||
android:viewportHeight="24"
|
||||
android:viewportWidth="24"
|
||||
android:width="20dp"
|
||||
xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM13,17h-2v-6h2v6zM13,9h-2L11,7h2v2z" />
|
||||
</vector>
|
||||
10
app/src/main/res/drawable/ic_chargeprice_lock.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<vector android:height="16dp"
|
||||
android:tint="?attr/colorControlNormal"
|
||||
android:viewportHeight="24"
|
||||
android:viewportWidth="24"
|
||||
android:width="16dp"
|
||||
xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M18,8h-1L17,6c0,-2.76 -2.24,-5 -5,-5S7,3.24 7,6v2L6,8c-1.1,0 -2,0.9 -2,2v10c0,1.1 0.9,2 2,2h12c1.1,0 2,-0.9 2,-2L20,10c0,-1.1 -0.9,-2 -2,-2zM12,17c-1.1,0 -2,-0.9 -2,-2s0.9,-2 2,-2 2,0.9 2,2 -0.9,2 -2,2zM15.1,8L8.9,8L8.9,6c0,-1.71 1.39,-3.1 3.1,-3.1 1.71,0 3.1,1.39 3.1,3.1v2z" />
|
||||
</vector>
|
||||