Compare commits

...

104 Commits
0.2.1 ... 0.4.2

Author SHA1 Message Date
Johan von Forstner
c70a092d99 Release 0.4.2 2021-01-03 16:47:15 +01:00
Johan von Forstner
34fee47c08 Fix incorrect linking of text (fixes #29) 2021-01-03 16:23:07 +01:00
Johan von Forstner
bf97a14fe3 add station availability in map screen (fixes #52) 2021-01-03 15:28:58 +01:00
Johan von Forstner
60d4d56f80 Fix links to Google Maps
(maps app was not found due to https://developer.android.com/training/basics/intents/package-visibility)
2021-01-03 11:00:22 +01:00
Johan von Forstner
8bf33c7384 FilterProfilesFragment: Add rename and delete buttons + undo function 2021-01-03 10:45:56 +01:00
Johan von Forstner
595e6e9a8f Welcome dialog: replace > with ≥ 2021-01-03 09:52:07 +01:00
Johan von Forstner
9efbdfc046 Fix typo in welcome page 2021-01-02 22:38:54 +01:00
Johan von Forstner
e1d4b6bcc5 welcome dialog: fix height on small screens 2021-01-02 20:09:12 +01:00
Johan von Forstner
a6db74488e release 0.4.1 2020-12-31 20:29:48 +01:00
Johan von Forstner
821f5d61b5 add welcome dialog on first start (fixes #66) 2020-12-31 19:18:58 +01:00
Johan von Forstner
f83ac17c83 don't generate icons in background for Mapbox 2020-12-30 20:10:52 +01:00
Johan von Forstner
3519c7f699 decrease memory usage of charger icons
by allowing "fault" and "highlight" only with scale == 1f
refs #59
2020-12-30 20:01:47 +01:00
Licaon_Kter
78d9706cb7 Remove suffix for fdroid (#67)
...as you'd know it's not-google 👍 
Also, breaks AutoUpdate
2020-12-30 19:01:03 +01:00
Johan von Forstner
a593a8054b fix some gallery glitches (#61) 2020-12-30 18:58:55 +01:00
Johan von Forstner
9556be6b85 Gallery fixes 2020-12-29 20:24:22 +01:00
Johan von Forstner
e8669f8a3d Gallery: replace Picasso with Coil 2020-12-29 18:09:29 +01:00
Johan von Forstner
6a887ee1e4 NewMotionAvailabilityDetector: add some more plug types
(rarely occurring)
2020-12-29 18:08:37 +01:00
Johan von Forstner
6dbaaa3099 travis CI: use latest android SDK commandline tools 2020-12-28 11:14:24 +01:00
Johan von Forstner
7f9242da1e fix license file 2020-12-28 10:58:07 +01:00
Johan von Forstner
2c3151089f CI: accept android licenses 2020-12-28 10:53:42 +01:00
Johan von Forstner
1ee388126f travis: build-tools;30.0.3 2020-12-28 10:46:18 +01:00
Johan von Forstner
964cecdf66 travis CI: use Android 30 sdk 2020-12-28 10:38:15 +01:00
Johan von Forstner
7141eb5013 update to Android 11 SDK 2020-12-27 17:15:18 +01:00
Johan von Forstner
d7fcb35a4e Release 0.4.0 2020-12-26 19:15:07 +01:00
Johan von Forstner
56348905a6 fix DB migration 2020-12-26 16:59:28 +01:00
Johan von Forstner
3336faa953 fix crash on first start 2020-12-26 16:45:09 +01:00
Johan von Forstner
e22e1521a4 fix display of 24/7 opening hours
(regression introduced in 2cd9e9d6)
2020-12-26 16:41:53 +01:00
Johan von Forstner
e974acac4e Implement filter profiles (#37)
* start to work on filter profiles

* fix migration

* add "save as profile" button

* try to make profile a primary key

* start to create preliminary filter profile saving dialog

* implement saving and selecting filter profiles

* fix selection of filter profiles after creation, improve UX

* facilitate editing of existing filter profiles

* implement list of filter profiles with swipe-to-delete

* improve UX for deleting filter profiles

* add possibility to reorder filter profiles

* add empty state for filter profiles
2020-12-26 16:36:43 +01:00
Johan von Forstner
8a13bfcd9e fix compilation for foss variant 2020-12-24 15:46:11 +01:00
Johan von Forstner
1e04d6e98a implement hashCode for MultipleChoiceFilterValue 2020-12-24 15:38:58 +01:00
Johan von Forstner
a0045fc6bb add filter by categories (fixes #64) 2020-12-24 15:37:13 +01:00
Johan von Forstner
ec10b51387 fix crash caused by switching to view binding from android-ktx-extensions 2020-12-24 15:33:00 +01:00
Johan von Forstner
b054464280 fix some deprecations / warnings 2020-12-23 16:29:37 +01:00
Johan von Forstner
1a32159526 Kotlin version and various library upgrades 2020-12-23 16:12:49 +01:00
Johan von Forstner
c6cc7102e6 update Gradle and Android plugin 2020-12-23 14:58:39 +01:00
johan12345
6a5dc93fd8 show distance of charging stations to current location 2020-10-27 22:52:03 +01:00
johan12345
a85966bb1d Add button to edit a station on GoingElectric.de (fixes #62) 2020-10-27 22:28:23 +01:00
johan12345
bf3c401c37 add map scale (fixes #38) 2020-10-26 23:11:59 +01:00
johan12345
4da7e0b50d Don't highlight "Report new station" in drawer (fixes #60) 2020-10-26 22:51:45 +01:00
johan12345
d78f2f08cb update AnyMaps 2020-10-22 08:48:14 +02:00
johan12345
d2952766e4 update OkHttp mockwebserver 2020-09-20 23:05:28 +02:00
johan12345
40503b6bd2 handle rate limiting by NewMotion API 2020-09-20 23:02:23 +02:00
johan12345
e875e0ee42 fix tests 2020-09-20 22:48:52 +02:00
johan12345
6f9ea6c6e3 add cookieManager to HTTP client used by AvailabilityDetector 2020-09-20 22:37:23 +02:00
johan12345
a79d013179 upgrade Retrofit and OkHttp 2020-09-20 22:36:55 +02:00
johan12345
4b75389a31 favorites: sort by distance (fixes #57) 2020-09-20 22:20:57 +02:00
johan12345
1039251d63 normalize app name: EV Map -> EVMap 2020-09-20 22:11:02 +02:00
Johan von Forstner
2cd9e9d642 correct display of opening hours description if there are no opening hours 2020-09-12 17:14:21 +02:00
Johan von Forstner
7d495468ea Add better description for "Tesla HPC" connector 2020-09-12 17:12:35 +02:00
johan12345
e47a82a4bc add F-Droid badge to README.md 2020-09-08 21:53:51 +02:00
Johan von Forstner
87421e450a fix missing API key in Google variant, hotfix release 2020-08-28 15:17:05 +02:00
johan12345
479917fad1 fix wrong position of layers button in case of display cutout (fixes #51) 2020-08-24 22:57:02 +02:00
johan12345
dfaf841160 Release 0.3.4 2020-08-24 22:20:43 +02:00
johan12345
c18ea5b15d add link “report new station” to main menu (fixes #53) 2020-08-24 20:11:44 +02:00
johan12345
62116473c8 Navigation component: add settings as top-level destination 2020-08-24 19:46:32 +02:00
johan12345
bc8106bd81 add Google Maps API key only to Google variant 2020-08-23 23:35:46 +02:00
johan12345
7bd89b9ecb get rid of Mapbox telemetry dependency 2020-08-23 23:31:01 +02:00
Johan von Forstner
898b61945e Make links under "general information" and "amenities" clickable 2020-08-23 12:25:38 +02:00
Johan von Forstner
38e022b547 Add links to Twitter account and GoingElectric.de forum thread 2020-08-22 20:07:33 +02:00
Johan von Forstner
b8c438503c add new icon for "more than one connector" 2020-08-22 09:20:05 +02:00
Johan von Forstner
2ca6a8e3e8 fix vertical position of markers 2020-08-22 08:21:13 +02:00
Johan von Forstner
0ae201e363 MultiSelectDialog: case-insensitive sorting (fixes #44) 2020-08-22 08:11:50 +02:00
Johan von Forstner
9e0f535a13 Release 0.3.3 2020-08-16 13:47:23 +02:00
Johan von Forstner
d4a6789b00 Dark mode: fix icon tint for layers FAB 2020-08-16 13:46:00 +02:00
Johan von Forstner
d9415ed7a0 fix LocaleContextWrapper 2020-08-16 13:42:13 +02:00
Johan von Forstner
778d7293f4 set up fastlane and download metadata 2020-08-13 21:18:38 +02:00
Johan von Forstner
19a8b5c9fe Release 0.3.2 2020-08-12 19:50:54 +02:00
Johan von Forstner
7f3c481dcb allow to configure API keys with Gradle properties
(necessary for F-Droid)
2020-08-12 19:50:31 +02:00
Johan von Forstner
8a54b5cb05 SliderFilter: fix default value for min > 0 2020-08-12 19:30:57 +02:00
johan12345
91b3234a45 fix URL of sonatype snapshots repo 2020-08-12 08:23:16 +02:00
johan12345
ab7cbc981b fix URL of sonatype snapshots repo 2020-08-12 08:12:02 +02:00
johan12345
a2c1a2cf82 move signingConfigs configuration for F-Droid 2020-08-11 20:10:14 +02:00
johan12345
167ede4e62 Release 0.3.1 2020-08-11 19:41:38 +02:00
johan12345
63900996e7 update .gitignore 2020-08-11 19:41:05 +02:00
johan12345
c626f3d5a5 update AnyMaps (fixes crash) 2020-08-11 19:40:22 +02:00
johan12345
8779e65846 set default map provider in google flavor back to google 2020-08-11 19:23:34 +02:00
johan12345
0c8bf84e56 adjust signingConfig configuration for compatibility with F-Droid 2020-08-11 19:18:46 +02:00
johan12345
90972cf933 fix lint errors 2020-08-10 20:49:18 +02:00
johan12345
7d9a9605fb Release 0.3.0 2020-08-10 20:43:04 +02:00
johan12345
a0bc0f2981 update dependencies 2020-08-10 20:35:53 +02:00
johan12345
f3b4c8a8ff implement donations for FOSS version (PayPal) 2020-08-10 20:31:35 +02:00
Johan von Forstner
6a8220c1c2 implement autocomplete for Mapbox 2020-08-09 17:35:31 +02:00
Johan von Forstner
84c28748a4 update travis configuration with build flavors 2020-08-09 13:21:55 +02:00
Johan von Forstner
7c29b619a5 implement switching between map providers in settings
Google Maps and Mapbox
2020-08-09 13:09:48 +02:00
Johan von Forstner
ccfdbbe826 update AnyMaps 2020-08-09 12:37:31 +02:00
Johan von Forstner
7052ce3c3c fixes for Google Maps and OSM variants 2020-08-09 12:22:58 +02:00
Johan von Forstner
d73ca8aa9d Travis CI: add Mapbox API Key 2020-08-08 19:50:42 +02:00
Johan von Forstner
64703a8c28 update AnyMaps 2020-08-08 19:46:28 +02:00
Johan von Forstner
eb54658bf4 update Gradle plugin 2020-08-06 19:54:20 +02:00
Johan von Forstner
54d1c8ba61 update anymap with mapbox fixes 2020-08-06 19:54:08 +02:00
Johan von Forstner
1c04f6211f update anymap, use mapbox 2020-07-31 18:40:46 +02:00
Johan von Forstner
45497f9208 OSM: implement night mode 2020-07-24 20:27:12 +02:00
Johan von Forstner
140c634397 start splitting app in FOSS and Google variants 2020-07-23 12:20:09 +02:00
johan12345
be1b3813a9 update AnyMaps 2020-07-20 22:52:50 +02:00
johan12345
f7ed7f1e93 use AnyMaps to make the map view able to use OSM maps 2020-07-20 22:39:22 +02:00
johan12345
0df72ac4ad update Material Components library 2020-07-19 21:03:43 +02:00
johan12345
d041513516 Release 0.2.2 2020-07-13 19:42:25 +02:00
johan12345
1effba77d1 update JUnit 2020-07-13 19:37:41 +02:00
Johan von Forstner
df79f02e1d fix crashes with missing internet connection 2020-07-11 18:25:29 +02:00
Johan von Forstner
c4d44f9ddf switch connectors filter to MultiSelectDialog
because Chip interface is buggy
2020-07-05 12:38:58 +02:00
Johan von Forstner
6bec397133 try to improve MultipleChoiceFilter 2020-07-05 11:20:32 +02:00
Johan von Forstner
474b621af0 fix imports 2020-07-05 11:13:23 +02:00
Johan von Forstner
36aeb201ca move some adapters out of DataBindingAdapters.kt 2020-07-05 11:07:36 +02:00
Johan von Forstner
76a241d691 add missing German translations for "map" and "favorites" 2020-07-02 19:55:41 +02:00
147 changed files with 5083 additions and 1121 deletions

7
.gitignore vendored
View File

@@ -8,5 +8,8 @@
.externalNativeBuild
.cxx
apikeys.xml
/app/release/app-release.aab
/_img/connectors/*.ai
/app/**/*.aab
/app/**/*.apk
/_img/connectors/*.ai
api-7125266970515251116-798419-8e2dda660c80.json
output-metadata.json

View File

@@ -1,17 +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=
- 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 lintDebug testDebugUnitTest"
- "./gradlew lintFossDebug testFossDebugUnitTest lintGoogleDebug testGoogleDebugUnitTest"
- "./gradlew assembleRelease"
before_cache:
- rm -f $HOME/.gradle/caches/modules-2/modules-2.lock
@@ -21,11 +29,15 @@ 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=
file: app/build/outputs/apk/release/app-release.apk
file:
- app/build/outputs/apk/foss/release/app-foss-release.apk
- app/build/outputs/apk/google/release/app-google-release.apk
on:
repo: johan12345/EVMap
tags: true

3
Gemfile Normal file
View File

@@ -0,0 +1,3 @@
source "https://rubygems.org"
gem "fastlane"

178
Gemfile.lock Normal file
View File

@@ -0,0 +1,178 @@
GEM
remote: https://rubygems.org/
specs:
CFPropertyList (3.0.2)
addressable (2.7.0)
public_suffix (>= 2.0.2, < 5.0)
atomos (0.1.3)
aws-eventstream (1.1.0)
aws-partitions (1.354.0)
aws-sdk-core (3.104.3)
aws-eventstream (~> 1, >= 1.0.2)
aws-partitions (~> 1, >= 1.239.0)
aws-sigv4 (~> 1.1)
jmespath (~> 1.0)
aws-sdk-kms (1.36.0)
aws-sdk-core (~> 3, >= 3.99.0)
aws-sigv4 (~> 1.1)
aws-sdk-s3 (1.78.0)
aws-sdk-core (~> 3, >= 3.104.3)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.1)
aws-sigv4 (1.2.1)
aws-eventstream (~> 1, >= 1.0.2)
babosa (1.0.3)
claide (1.0.3)
colored (1.2)
colored2 (3.1.2)
commander-fastlane (4.4.6)
highline (~> 1.7.2)
declarative (0.0.20)
declarative-option (0.1.0)
digest-crc (0.6.1)
rake (~> 13.0)
domain_name (0.5.20190701)
unf (>= 0.0.5, < 1.0.0)
dotenv (2.7.6)
emoji_regex (3.0.0)
excon (0.76.0)
faraday (1.0.1)
multipart-post (>= 1.2, < 3)
faraday-cookie_jar (0.0.6)
faraday (>= 0.7.4)
http-cookie (~> 1.0.0)
faraday_middleware (1.0.0)
faraday (~> 1.0)
fastimage (2.2.0)
fastlane (2.156.1)
CFPropertyList (>= 2.3, < 4.0.0)
addressable (>= 2.3, < 3.0.0)
aws-sdk-s3 (~> 1.0)
babosa (>= 1.0.3, < 2.0.0)
bundler (>= 1.12.0, < 3.0.0)
colored
commander-fastlane (>= 4.4.6, < 5.0.0)
dotenv (>= 2.1.1, < 3.0.0)
emoji_regex (>= 0.1, < 4.0)
excon (>= 0.71.0, < 1.0.0)
faraday (~> 1.0)
faraday-cookie_jar (~> 0.0.6)
faraday_middleware (~> 1.0)
fastimage (>= 2.1.0, < 3.0.0)
gh_inspector (>= 1.1.2, < 2.0.0)
google-api-client (>= 0.37.0, < 0.39.0)
google-cloud-storage (>= 1.15.0, < 2.0.0)
highline (>= 1.7.2, < 2.0.0)
json (< 3.0.0)
jwt (>= 2.1.0, < 3)
mini_magick (>= 4.9.4, < 5.0.0)
multipart-post (~> 2.0.0)
plist (>= 3.1.0, < 4.0.0)
rubyzip (>= 2.0.0, < 3.0.0)
security (= 0.1.3)
simctl (~> 1.6.3)
slack-notifier (>= 2.0.0, < 3.0.0)
terminal-notifier (>= 2.0.0, < 3.0.0)
terminal-table (>= 1.4.5, < 2.0.0)
tty-screen (>= 0.6.3, < 1.0.0)
tty-spinner (>= 0.8.0, < 1.0.0)
word_wrap (~> 1.0.0)
xcodeproj (>= 1.13.0, < 2.0.0)
xcpretty (~> 0.3.0)
xcpretty-travis-formatter (>= 0.0.3)
gh_inspector (1.1.3)
google-api-client (0.38.0)
addressable (~> 2.5, >= 2.5.1)
googleauth (~> 0.9)
httpclient (>= 2.8.1, < 3.0)
mini_mime (~> 1.0)
representable (~> 3.0)
retriable (>= 2.0, < 4.0)
signet (~> 0.12)
google-cloud-core (1.5.0)
google-cloud-env (~> 1.0)
google-cloud-errors (~> 1.0)
google-cloud-env (1.3.3)
faraday (>= 0.17.3, < 2.0)
google-cloud-errors (1.0.1)
google-cloud-storage (1.27.0)
addressable (~> 2.5)
digest-crc (~> 0.4)
google-api-client (~> 0.33)
google-cloud-core (~> 1.2)
googleauth (~> 0.9)
mini_mime (~> 1.0)
googleauth (0.13.1)
faraday (>= 0.17.3, < 2.0)
jwt (>= 1.4, < 3.0)
memoist (~> 0.16)
multi_json (~> 1.11)
os (>= 0.9, < 2.0)
signet (~> 0.14)
highline (1.7.10)
http-cookie (1.0.3)
domain_name (~> 0.5)
httpclient (2.8.3)
jmespath (1.4.0)
json (2.3.1)
jwt (2.2.1)
memoist (0.16.2)
mini_magick (4.10.1)
mini_mime (1.0.2)
multi_json (1.15.0)
multipart-post (2.0.0)
nanaimo (0.3.0)
naturally (2.2.0)
os (1.1.1)
plist (3.5.0)
public_suffix (4.0.5)
rake (13.0.1)
representable (3.0.4)
declarative (< 0.1.0)
declarative-option (< 0.2.0)
uber (< 0.2.0)
retriable (3.1.2)
rouge (2.0.7)
rubyzip (2.3.0)
security (0.1.3)
signet (0.14.0)
addressable (~> 2.3)
faraday (>= 0.17.3, < 2.0)
jwt (>= 1.5, < 3.0)
multi_json (~> 1.10)
simctl (1.6.8)
CFPropertyList
naturally
slack-notifier (2.3.2)
terminal-notifier (2.0.0)
terminal-table (1.8.0)
unicode-display_width (~> 1.1, >= 1.1.1)
tty-cursor (0.7.1)
tty-screen (0.8.1)
tty-spinner (0.9.3)
tty-cursor (~> 0.7)
uber (0.1.0)
unf (0.1.4)
unf_ext
unf_ext (0.0.7.7-x64-mingw32)
unicode-display_width (1.7.0)
word_wrap (1.0.0)
xcodeproj (1.18.0)
CFPropertyList (>= 2.3.3, < 4.0)
atomos (~> 0.1.3)
claide (>= 1.0.2, < 2.0)
colored2 (~> 3.1)
nanaimo (~> 0.3.0)
xcpretty (0.3.0)
rouge (~> 2.0.7)
xcpretty-travis-formatter (1.0.0)
xcpretty (~> 0.2, >= 0.0.7)
PLATFORMS
x64-mingw32
DEPENDENCIES
fastlane
BUNDLED WITH
2.1.2

View File

@@ -7,6 +7,8 @@ Android app to access the goingelectric.de electric vehicle charging station dir
<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
--------

View File

@@ -0,0 +1,27 @@
<?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" id="Ebene_1" xmlns="http://www.w3.org/2000/svg" x="0px" y="0px"
viewBox="0 0 233.8 368.4" style="enable-background:new 0 0 233.8 368.4;" xml:space="preserve">
<style type="text/css">
.st0{fill:#FFFFFF;}
.st1{fill:#B5B5B5;}
.st2{fill:#808080;}
</style>
<g>
<g>
<g>
<path class="st0" d="M109.8,0h13.6c33.9,1.9,67.1,18.5,87.7,45.8c13.5,17.2,21,38.6,22.7,60.3v8.1c-0.8,42.1-27.7,76.6-51,109.4
c-26.2,37-50.4,77.3-57.1,122.9c-1.8,7.7,0.4,18.5-8.9,22c-2.2-1.7-4.7-3.1-6.2-5.4c-2.7-25.5-9.1-50.7-20-73.9
c-12.3-27.1-29.5-51.6-47-75.6C33,199,23,184.2,14.7,168.3c-13-23.8-17.9-51.9-12.5-78.6C6.6,68.6,17.6,49.1,32.8,34
C53.3,14,81.1,1.8,109.8,0z" />
</g>
</g>
</g>
<g>
<polygon class="st1"
points="143.2,109.4 123.5,143.2 123.5,181.3 166.9,106.9 144.7,106.9 " />
<path class="st1"
d="M122.2,101.9h16.7h5.7l22.3-44.6c0,0-10.2,0-22.4,0l-1.1,2.2L122.2,101.9z" />
<path class="st2" d="M138.9,57.3c-9.7,0-19.8,0-26.4,0c-2.5,0-5.1,0-7.6,0c-8.2,0-16.1,0-21.4,0c-4.1,0-6.6,0-6.6,0v68.2h18.6v55.8
l43.4-74.4h-24.8L138.9,57.3z" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

6
_img/paypal.svg Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0"?>
<svg fill="#000000" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24px"
height="24px">
<path
d="M20.055,7.713c-0.677-0.842-1.673-1.41-2.764-1.615C16.773,3.971,14.877,3,13.045,3H7.057C6.425,3,5.878,3.409,5.689,4.009 c-0.015,0.04-0.026,0.082-0.036,0.125L3.034,16.262c-0.009,0.041-0.015,0.083-0.019,0.125C3.006,16.449,3,16.513,3,16.56 C3,17.354,3.648,18,4.444,18h2.316l-0.267,1.262c-0.008,0.04-0.014,0.081-0.018,0.121c-0.009,0.063-0.016,0.126-0.016,0.173 C6.461,20.353,7.109,21,7.905,21h3.259c0.056,0,0.111-0.005,0.166-0.015c0.549-0.063,1.011-0.437,1.191-0.963 c0.021-0.05,0.038-0.103,0.05-0.156L13.475,16h1.398c3.365,0,5.38-1.445,5.989-4.295C21.278,9.752,20.653,8.456,20.055,7.713z M5.137,16L7.512,5h5.533c0.293,0,1.5,0.061,2.078,1.013h-4.706c-0.626,0-1.17,0.401-1.363,0.99c-0.019,0.049-0.033,0.1-0.043,0.151 l-1.034,5.093L7.183,16H5.137z M18.906,11.287C18.5,13.188,17.293,14,14.873,14h-1.857c-0.823,0-1.338,0.652-1.405,1.198L10.721,19 H8.594l1.271-6h1.444c4.259,0,5.665-2.394,6.094-4.402c0.027-0.128,0.045-0.256,0.057-0.382c0.378,0.151,0.749,0.393,1.038,0.751 C18.971,9.557,19.108,10.337,18.906,11.287z" />
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -1,26 +1,35 @@
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 19
versionName "0.2.1"
targetSdkVersion 30
versionCode 34
versionName "0.4.2"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
signingConfigs {
release
release {
def isRunningOnTravis = System.getenv("CI") == "true"
if (isRunningOnTravis) {
// configure keystore
storeFile = file("../_ci/keystore.jks")
storePassword = System.getenv("keystore_password")
keyAlias = System.getenv("keystore_alias")
keyPassword = System.getenv("keystore_alias_password")
}
}
}
buildTypes {
@@ -35,13 +44,15 @@ android {
}
}
def isRunningOnTravis = System.getenv("CI") == "true"
if (isRunningOnTravis) {
// configure keystore
signingConfigs.release.storeFile = file("../_ci/keystore.jks")
signingConfigs.release.storePassword = System.getenv("keystore_password")
signingConfigs.release.keyAlias = System.getenv("keystore_alias")
signingConfigs.release.keyPassword = System.getenv("keystore_alias_password")
flavorDimensions "dependencies"
productFlavors {
foss {
dimension "dependencies"
}
google {
dimension "dependencies"
versionNameSuffix "-google"
}
}
compileOptions {
@@ -56,65 +67,86 @@ android {
buildFeatures {
dataBinding = true
viewBinding true
}
// add API keys from environment variable if not set in apikeys.xml
applicationVariants.all { variant ->
ext.env = System.getenv()
def goingelectricKey = env.GOINGELECTRIC_API_KEY
def goingelectricKey = env.GOINGELECTRIC_API_KEY ?: project.findProperty("GOINGELECTRIC_API_KEY")
if (goingelectricKey != null) {
variant.resValue "string", "goingelectric_key", goingelectricKey
}
def googleMapsKey = env.GOOGLE_MAPS_API_KEY
if (googleMapsKey != null) {
def googleMapsKey = env.GOOGLE_MAPS_API_KEY ?: project.findProperty("GOOGLE_MAPS_API_KEY")
if (googleMapsKey != null && variant.flavorName == 'google') {
variant.resValue "string", "google_maps_key", googleMapsKey
}
def mapboxKey = env.MAPBOX_API_KEY ?: project.findProperty("MAPBOX_API_KEY")
if (mapboxKey != null) {
variant.resValue "string", "mapbox_key", mapboxKey
}
}
}
dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar'])
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
implementation 'androidx.appcompat:appcompat:1.1.0'
implementation 'androidx.core:core-ktx:1.3.0'
implementation 'androidx.appcompat:appcompat:1.2.0'
implementation 'androidx.core:core-ktx:1.3.2'
implementation "androidx.activity:activity-ktx:1.1.0"
implementation "androidx.fragment:fragment-ktx:1.2.5"
implementation 'androidx.cardview:cardview:1.0.0'
implementation 'androidx.core:core:1.3.0'
implementation 'androidx.appcompat:appcompat:1.1.0'
implementation 'androidx.core:core:1.3.2'
implementation 'androidx.appcompat:appcompat:1.2.0'
implementation 'androidx.preference:preference-ktx:1.1.1'
implementation 'com.google.android.material:material:1.2.0-beta01'
implementation 'androidx.constraintlayout:constraintlayout:1.1.3'
implementation 'com.google.android.material:material:1.2.1'
implementation 'androidx.constraintlayout:constraintlayout:2.0.4'
implementation 'androidx.recyclerview:recyclerview:1.1.0'
implementation 'androidx.browser:browser:1.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.retrofit2:retrofit:2.9.0'
implementation 'com.squareup.retrofit2:converter-moshi:2.9.0'
implementation 'com.squareup.okhttp3:okhttp:4.9.0'
implementation 'com.squareup.okhttp3:okhttp-urlconnection:4.9.0'
implementation 'com.squareup.moshi:moshi-kotlin:1.9.2'
implementation 'com.squareup.picasso:picasso:2.71828'
implementation 'com.github.MikeOrtiz:TouchImageView:2.3.3'
implementation 'io.coil-kt:coil:1.1.0'
implementation 'com.github.MikeOrtiz:TouchImageView:3.0.3'
implementation "com.mikepenz:aboutlibraries-core:$about_libs_version"
implementation "com.mikepenz:aboutlibraries:$about_libs_version"
implementation 'com.airbnb.android:lottie:3.4.0'
implementation 'io.michaelrocks:bimap:1.0.2'
implementation 'com.mapzen.android:lost:3.0.2'
implementation 'com.google.guava:guava:29.0-android'
implementation 'com.github.pengrad:mapscaleview:1.6.0'
// AnyMaps
def anyMapsVersion = '7753eeb7b0'
implementation "com.github.johan12345.AnyMaps:anymaps-base:$anyMapsVersion"
googleImplementation "com.github.johan12345.AnyMaps:anymaps-google:$anyMapsVersion"
implementation "com.github.johan12345.AnyMaps:anymaps-mapbox:$anyMapsVersion"
// Google Maps v3 Beta
implementation 'com.google.android.libraries.maps:maps:3.1.0-beta'
implementation name:'places-maps-sdk-3.1.0-beta', ext:'aar'
implementation 'com.google.maps.android:android-maps-utils-v3:1.3.3'
implementation 'com.android.volley:volley:1.1.1'
implementation 'com.google.android.gms:play-services-base:17.3.0'
implementation 'com.google.android.gms:play-services-basement:17.3.0'
implementation 'com.google.android.gms:play-services-gcm:17.0.0'
implementation 'com.google.android.gms:play-services-location:17.0.0'
implementation 'com.google.android.gms:play-services-tasks:17.1.0'
implementation 'com.google.auto.value:auto-value-annotations:1.6.3'
implementation 'com.google.code.gson:gson:2.8.6'
implementation 'com.google.android.datatransport:transport-runtime:2.2.3'
implementation 'androidx.lifecycle:lifecycle-extensions:2.2.0'
googleImplementation 'com.google.android.libraries.maps:maps:3.1.0-beta'
googleImplementation name:'places-maps-sdk-3.1.0-beta', ext:'aar'
googleImplementation 'com.android.volley:volley:1.1.1'
googleImplementation 'com.google.android.gms:play-services-base:17.5.0'
googleImplementation 'com.google.android.gms:play-services-basement:17.5.0'
googleImplementation 'com.google.android.gms:play-services-gcm:17.0.0'
googleImplementation 'com.google.android.gms:play-services-location:17.1.0'
googleImplementation 'com.google.android.gms:play-services-tasks:17.2.0'
googleImplementation 'com.google.auto.value:auto-value-annotations:1.6.3'
googleImplementation 'com.google.code.gson:gson:2.8.6'
googleImplementation 'com.google.android.datatransport:transport-runtime:2.2.5'
googleImplementation 'androidx.lifecycle:lifecycle-extensions:2.2.0'
// Mapbox places (autocomplete)
implementation('com.mapbox.mapboxsdk:mapbox-android-plugin-places-v9:0.12.0') {
exclude group: 'com.mapbox.mapboxsdk', module: 'mapbox-android-accounts'
exclude group: 'com.mapbox.mapboxsdk', module: 'mapbox-android-telemetry'
}
// navigation library
def nav_version = "2.3.0"
def nav_version = "2.3.2"
implementation "androidx.navigation:navigation-fragment-ktx:$nav_version"
implementation "androidx.navigation:navigation-ui-ktx:$nav_version"
@@ -124,27 +156,27 @@ dependencies {
implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycle_version"
// room library
def room_version = "2.2.5"
def room_version = "2.2.6"
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"
implementation "com.android.billingclient:billing:$billing_version"
implementation "com.android.billingclient:billing-ktx:$billing_version"
def billing_version = "3.0.2"
googleImplementation "com.android.billingclient:billing:$billing_version"
googleImplementation "com.android.billingclient:billing-ktx:$billing_version"
// debug tools
implementation 'com.facebook.stetho:stetho:1.5.1'
implementation 'com.facebook.stetho:stetho-okhttp3:1.5.1'
testImplementation 'junit:junit:4.12'
testImplementation "com.squareup.okhttp3:mockwebserver:3.14.7"
testImplementation 'junit:junit:4.13'
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"
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.0.9'
}
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.1.1'
}

View File

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

View File

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

View File

@@ -0,0 +1,12 @@
package net.vonforst.evmap
import android.app.Activity
import android.content.Context
fun init(context: Context) {
}
fun checkPlayServices(activity: Activity): Boolean {
return true
}

View File

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

View File

@@ -0,0 +1,40 @@
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.AppCompatActivity
import androidx.fragment.app.Fragment
import androidx.navigation.fragment.findNavController
import androidx.navigation.ui.setupWithNavController
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? {
binding = FragmentDonateBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
(requireActivity() as AppCompatActivity).setSupportActionBar(binding.toolbar)
val navController = findNavController()
binding.toolbar.setupWithNavController(
navController,
(requireActivity() as MapsActivity).appBarConfiguration
)
binding.btnDonate.setOnClickListener {
(activity as? MapsActivity)?.openUrl(getString(R.string.paypal_link))
}
}
}

View File

@@ -0,0 +1,51 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/linearLayout2"
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.google.android.material.appbar.AppBarLayout
android:id="@+id/toolbar_container"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:fitsSystemWindows="true"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<androidx.appcompat.widget.Toolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
</com.google.android.material.appbar.AppBarLayout>
<Button
android:id="@+id/btnDonate"
style="@style/Widget.MaterialComponents.Button.Icon"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:text="@string/donate_paypal"
app:icon="@drawable/ic_paypal"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/textView20" />
<TextView
android:id="@+id/textView20"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="16dp"
android:layout_marginEnd="16dp"
android:text="@string/donations_info"
app:layout_constraintBottom_toTopOf="@+id/btnDonate"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.5"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/toolbar_container"
app:layout_constraintVertical_chainStyle="packed" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string-array name="pref_map_provider_names">
<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="donate_paypal">Mit PayPal spenden</string>
</resources>

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string-array name="pref_map_provider_names">
<item>OpenStreetMap (Mapbox)</item>
</string-array>
<string-array name="pref_map_provider_values" tranlatable="false">
<item>mapbox</item>
</string-array>
<string name="pref_map_provider_default" translatable="false">mapbox</string>
<string name="donations_info" formatted="false">Do you find EVMap useful? Support its development by sending a donation to the developer.</string>
<string name="donate_paypal">Donate with PayPal</string>
<string name="paypal_link" translatable="false">https://paypal.me/johan98</string>
</resources>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="net.vonforst.evmap">
<application>
<meta-data
android:name="com.google.android.geo.API_KEY"
android:value="@string/google_maps_key" />
</application>
</manifest>

View File

@@ -0,0 +1,27 @@
package net.vonforst.evmap
import android.app.Activity
import android.content.Context
import android.util.Log
import com.google.android.gms.common.ConnectionResult
import com.google.android.gms.common.GoogleApiAvailability
import com.google.android.libraries.places.api.Places
fun init(context: Context) {
Places.initialize(context, context.getString(R.string.google_maps_key));
}
fun checkPlayServices(activity: Activity): Boolean {
val request = 9000
val apiAvailability = GoogleApiAvailability.getInstance()
val resultCode = apiAvailability.isGooglePlayServicesAvailable(activity)
if (resultCode != ConnectionResult.SUCCESS) {
if (apiAvailability.isUserResolvableError(resultCode)) {
apiAvailability.getErrorDialog(activity, resultCode, request)?.show()
} else {
Log.d("EVMap", "This device is not supported.")
}
return false
}
return true
}

View File

@@ -0,0 +1,8 @@
package net.vonforst.evmap.adapter
import net.vonforst.evmap.R
import net.vonforst.evmap.viewmodel.DonationItem
class DonationAdapter() : DataBindingAdapter<DonationItem>() {
override fun getItemViewType(position: Int): Int = R.layout.item_donation
}

View File

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

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string-array name="pref_map_provider_names">
<item>Google Maps</item>
<item>OpenStreetMap (Mapbox)</item>
</string-array>
<string 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>
</resources>

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string-array name="pref_map_provider_names">
<item>Google Maps</item>
<item>OpenStreetMap (Mapbox)</item>
</string-array>
<string-array name="pref_map_provider_values" tranlatable="false">
<item>google</item>
<item>mapbox</item>
</string-array>
<string 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>
</resources>

View File

@@ -2,8 +2,20 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="net.vonforst.evmap">
<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"
@@ -13,17 +25,9 @@
android:supportsRtl="true"
android:theme="@style/AppTheme">
<!--
The API key for Google Maps-based APIs is defined as a string resource.
(See the file "res/values/apikeys.xml").
Note that the API key is linked to the encryption key used to sign the APK.
You need a different API key for each encryption key, including the release key that is used to
sign the APK for publishing.
You can define the keys for the debug and release targets in src/debug/ and src/release/.
-->
<meta-data
android:name="com.google.android.geo.API_KEY"
android:value="@string/google_maps_key" />
android:name="com.mapbox.ACCESS_TOKEN"
android:value="@string/mapbox_key" />
<activity
android:name=".MapsActivity"

View File

@@ -0,0 +1,32 @@
/*
* Copyright 2013 Google Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.maps.android.clustering;
import com.car2go.maps.model.LatLng;
import java.util.Collection;
/**
* A collection of ClusterItems that are nearby each other.
*/
public interface Cluster<T extends ClusterItem> {
LatLng getPosition();
Collection<T> getItems();
int getSize();
}

View File

@@ -0,0 +1,46 @@
/*
* Copyright 2013 Google Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.maps.android.clustering;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.car2go.maps.model.LatLng;
/**
* ClusterItem represents a marker on the map.
*/
public interface ClusterItem {
/**
* The position of this marker. This must always return the same value.
*/
@NonNull
LatLng getPosition();
/**
* The title of this marker.
*/
@Nullable
String getTitle();
/**
* The description of this marker.
*/
@Nullable
String getSnippet();
}

View File

@@ -0,0 +1,39 @@
/*
* Copyright 2020 Google Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.maps.android.clustering.algo;
import com.google.maps.android.clustering.ClusterItem;
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;
/**
* Base Algorithm class that implements lock/unlock functionality.
*/
public abstract class AbstractAlgorithm<T extends ClusterItem> implements Algorithm<T> {
private final ReadWriteLock mLock = new ReentrantReadWriteLock();
@Override
public void lock() {
mLock.writeLock().lock();
}
@Override
public void unlock() {
mLock.writeLock().unlock();
}
}

View File

@@ -0,0 +1,85 @@
/*
* Copyright 2013 Google Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.maps.android.clustering.algo;
import com.google.maps.android.clustering.Cluster;
import com.google.maps.android.clustering.ClusterItem;
import java.util.Collection;
import java.util.Set;
/**
* Logic for computing clusters
*/
public interface Algorithm<T extends ClusterItem> {
/**
* Adds an item to the algorithm
*
* @param item the item to be added
* @return true if the algorithm contents changed as a result of the call
*/
boolean addItem(T item);
/**
* Adds a collection of items to the algorithm
*
* @param items the items to be added
* @return true if the algorithm contents changed as a result of the call
*/
boolean addItems(Collection<T> items);
void clearItems();
/**
* Removes an item from the algorithm
*
* @param item the item to be removed
* @return true if this algorithm contained the specified element (or equivalently, if this
* algorithm changed as a result of the call).
*/
boolean removeItem(T item);
/**
* Updates the provided item in the algorithm
*
* @param item the item to be updated
* @return true if the item existed in the algorithm and was updated, or false if the item did
* not exist in the algorithm and the algorithm contents remain unchanged.
*/
boolean updateItem(T item);
/**
* Removes a collection of items from the algorithm
*
* @param items the items to be removed
* @return true if this algorithm contents changed as a result of the call
*/
boolean removeItems(Collection<T> items);
Set<? extends Cluster<T>> getClusters(float zoom);
Collection<T> getItems();
void setMaxDistanceBetweenClusteredItems(int maxDistance);
int getMaxDistanceBetweenClusteredItems();
void lock();
void unlock();
}

View File

@@ -0,0 +1,314 @@
/*
* Copyright 2013 Google Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.maps.android.clustering.algo;
import com.car2go.maps.model.LatLng;
import com.google.maps.android.clustering.Cluster;
import com.google.maps.android.clustering.ClusterItem;
import com.google.maps.android.geometry.Bounds;
import com.google.maps.android.geometry.Point;
import com.google.maps.android.projection.SphericalMercatorProjection;
import com.google.maps.android.quadtree.PointQuadTree;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.Map;
import java.util.Set;
/**
* A simple clustering algorithm with O(nlog n) performance. Resulting clusters are not
* hierarchical.
* <p/>
* High level algorithm:<br>
* 1. Iterate over items in the order they were added (candidate clusters).<br>
* 2. Create a cluster with the center of the item. <br>
* 3. Add all items that are within a certain distance to the cluster. <br>
* 4. Move any items out of an existing cluster if they are closer to another cluster. <br>
* 5. Remove those items from the list of candidate clusters.
* <p/>
* Clusters have the center of the first element (not the centroid of the items within it).
*/
public class NonHierarchicalDistanceBasedAlgorithm<T extends ClusterItem> extends AbstractAlgorithm<T> {
private static final int DEFAULT_MAX_DISTANCE_AT_ZOOM = 100; // essentially 100 dp.
private int mMaxDistance = DEFAULT_MAX_DISTANCE_AT_ZOOM;
/**
* Any modifications should be synchronized on mQuadTree.
*/
private final Collection<QuadItem<T>> mItems = new LinkedHashSet<>();
/**
* Any modifications should be synchronized on mQuadTree.
*/
private final PointQuadTree<QuadItem<T>> mQuadTree = new PointQuadTree<>(0, 1, 0, 1);
private static final SphericalMercatorProjection PROJECTION = new SphericalMercatorProjection(1);
/**
* Adds an item to the algorithm
*
* @param item the item to be added
* @return true if the algorithm contents changed as a result of the call
*/
@Override
public boolean addItem(T item) {
boolean result;
final QuadItem<T> quadItem = new QuadItem<>(item);
synchronized (mQuadTree) {
result = mItems.add(quadItem);
if (result) {
mQuadTree.add(quadItem);
}
}
return result;
}
/**
* Adds a collection of items to the algorithm
*
* @param items the items to be added
* @return true if the algorithm contents changed as a result of the call
*/
@Override
public boolean addItems(Collection<T> items) {
boolean result = false;
for (T item : items) {
boolean individualResult = addItem(item);
if (individualResult) {
result = true;
}
}
return result;
}
@Override
public void clearItems() {
synchronized (mQuadTree) {
mItems.clear();
mQuadTree.clear();
}
}
/**
* Removes an item from the algorithm
*
* @param item the item to be removed
* @return true if this algorithm contained the specified element (or equivalently, if this
* algorithm changed as a result of the call).
*/
@Override
public boolean removeItem(T item) {
boolean result;
// QuadItem delegates hashcode() and equals() to its item so,
// removing any QuadItem to that item will remove the item
final QuadItem<T> quadItem = new QuadItem<>(item);
synchronized (mQuadTree) {
result = mItems.remove(quadItem);
if (result) {
mQuadTree.remove(quadItem);
}
}
return result;
}
/**
* Removes a collection of items from the algorithm
*
* @param items the items to be removed
* @return true if this algorithm contents changed as a result of the call
*/
@Override
public boolean removeItems(Collection<T> items) {
boolean result = false;
synchronized (mQuadTree) {
for (T item : items) {
// QuadItem delegates hashcode() and equals() to its item so,
// removing any QuadItem to that item will remove the item
final QuadItem<T> quadItem = new QuadItem<>(item);
boolean individualResult = mItems.remove(quadItem);
if (individualResult) {
mQuadTree.remove(quadItem);
result = true;
}
}
}
return result;
}
/**
* Updates the provided item in the algorithm
*
* @param item the item to be updated
* @return true if the item existed in the algorithm and was updated, or false if the item did
* not exist in the algorithm and the algorithm contents remain unchanged.
*/
@Override
public boolean updateItem(T item) {
// TODO - Can this be optimized to update the item in-place if the location hasn't changed?
boolean result;
synchronized (mQuadTree) {
result = removeItem(item);
if (result) {
// Only add the item if it was removed (to help prevent accidental duplicates on map)
result = addItem(item);
}
}
return result;
}
@Override
public Set<? extends Cluster<T>> getClusters(float zoom) {
final int discreteZoom = (int) zoom;
final double zoomSpecificSpan = mMaxDistance / Math.pow(2, discreteZoom) / 256;
final Set<QuadItem<T>> visitedCandidates = new HashSet<>();
final Set<Cluster<T>> results = new HashSet<>();
final Map<QuadItem<T>, Double> distanceToCluster = new HashMap<>();
final Map<QuadItem<T>, StaticCluster<T>> itemToCluster = new HashMap<>();
synchronized (mQuadTree) {
for (QuadItem<T> candidate : getClusteringItems(mQuadTree, zoom)) {
if (visitedCandidates.contains(candidate)) {
// Candidate is already part of another cluster.
continue;
}
Bounds searchBounds = createBoundsFromSpan(candidate.getPoint(), zoomSpecificSpan);
Collection<QuadItem<T>> clusterItems;
clusterItems = mQuadTree.search(searchBounds);
if (clusterItems.size() == 1) {
// Only the current marker is in range. Just add the single item to the results.
results.add(candidate);
visitedCandidates.add(candidate);
distanceToCluster.put(candidate, 0d);
continue;
}
StaticCluster<T> cluster = new StaticCluster<>(candidate.mClusterItem.getPosition());
results.add(cluster);
for (QuadItem<T> clusterItem : clusterItems) {
Double existingDistance = distanceToCluster.get(clusterItem);
double distance = distanceSquared(clusterItem.getPoint(), candidate.getPoint());
if (existingDistance != null) {
// Item already belongs to another cluster. Check if it's closer to this cluster.
if (existingDistance < distance) {
continue;
}
// Move item to the closer cluster.
itemToCluster.get(clusterItem).remove(clusterItem.mClusterItem);
}
distanceToCluster.put(clusterItem, distance);
cluster.add(clusterItem.mClusterItem);
itemToCluster.put(clusterItem, cluster);
}
visitedCandidates.addAll(clusterItems);
}
}
return results;
}
protected Collection<QuadItem<T>> getClusteringItems(PointQuadTree<QuadItem<T>> quadTree, float zoom) {
return mItems;
}
@Override
public Collection<T> getItems() {
final Set<T> items = new LinkedHashSet<>();
synchronized (mQuadTree) {
for (QuadItem<T> quadItem : mItems) {
items.add(quadItem.mClusterItem);
}
}
return items;
}
@Override
public void setMaxDistanceBetweenClusteredItems(int maxDistance) {
mMaxDistance = maxDistance;
}
@Override
public int getMaxDistanceBetweenClusteredItems() {
return mMaxDistance;
}
private double distanceSquared(Point a, Point b) {
return (a.x - b.x) * (a.x - b.x) + (a.y - b.y) * (a.y - b.y);
}
private Bounds createBoundsFromSpan(Point p, double span) {
// TODO: Use a span that takes into account the visual size of the marker, not just its
// LatLng.
double halfSpan = span / 2;
return new Bounds(
p.x - halfSpan, p.x + halfSpan,
p.y - halfSpan, p.y + halfSpan);
}
protected static class QuadItem<T extends ClusterItem> implements PointQuadTree.Item, Cluster<T> {
private final T mClusterItem;
private final Point mPoint;
private final LatLng mPosition;
private Set<T> singletonSet;
private QuadItem(T item) {
mClusterItem = item;
mPosition = item.getPosition();
mPoint = PROJECTION.toPoint(mPosition);
singletonSet = Collections.singleton(mClusterItem);
}
@Override
public Point getPoint() {
return mPoint;
}
@Override
public LatLng getPosition() {
return mPosition;
}
@Override
public Set<T> getItems() {
return singletonSet;
}
@Override
public int getSize() {
return 1;
}
@Override
public int hashCode() {
return mClusterItem.hashCode();
}
@Override
public boolean equals(Object other) {
if (!(other instanceof QuadItem<?>)) {
return false;
}
return ((QuadItem<?>) other).mClusterItem.equals(mClusterItem);
}
}
}

View File

@@ -0,0 +1,83 @@
/*
* Copyright 2013 Google Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.maps.android.clustering.algo;
import com.car2go.maps.model.LatLng;
import com.google.maps.android.clustering.Cluster;
import com.google.maps.android.clustering.ClusterItem;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
/**
* A cluster whose center is determined upon creation.
*/
public class StaticCluster<T extends ClusterItem> implements Cluster<T> {
private final LatLng mCenter;
private final List<T> mItems = new ArrayList<T>();
public StaticCluster(LatLng center) {
mCenter = center;
}
public boolean add(T t) {
return mItems.add(t);
}
@Override
public LatLng getPosition() {
return mCenter;
}
public boolean remove(T t) {
return mItems.remove(t);
}
@Override
public Collection<T> getItems() {
return mItems;
}
@Override
public int getSize() {
return mItems.size();
}
@Override
public String toString() {
return "StaticCluster{" +
"mCenter=" + mCenter +
", mItems.size=" + mItems.size() +
'}';
}
@Override
public int hashCode() {
return mCenter.hashCode() + mItems.hashCode();
}
@Override
public boolean equals(Object other) {
if (!(other instanceof StaticCluster<?>)) {
return false;
}
return ((StaticCluster<?>) other).mCenter.equals(mCenter)
&& ((StaticCluster<?>) other).mItems.equals(mItems);
}
}

View File

@@ -0,0 +1,61 @@
/*
* Copyright 2013 Google Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.maps.android.geometry;
/**
* Represents an area in the cartesian plane.
*/
public class Bounds {
public final double minX;
public final double minY;
public final double maxX;
public final double maxY;
public final double midX;
public final double midY;
public Bounds(double minX, double maxX, double minY, double maxY) {
this.minX = minX;
this.minY = minY;
this.maxX = maxX;
this.maxY = maxY;
midX = (minX + maxX) / 2;
midY = (minY + maxY) / 2;
}
public boolean contains(double x, double y) {
return minX <= x && x <= maxX && minY <= y && y <= maxY;
}
public boolean contains(Point point) {
return contains(point.x, point.y);
}
public boolean intersects(double minX, double maxX, double minY, double maxY) {
return minX < this.maxX && this.minX < maxX && minY < this.maxY && this.minY < maxY;
}
public boolean intersects(Bounds bounds) {
return intersects(bounds.minX, bounds.maxX, bounds.minY, bounds.maxY);
}
public boolean contains(Bounds bounds) {
return bounds.minX >= minX && bounds.maxX <= maxX && bounds.minY >= minY && bounds.maxY <= maxY;
}
}

View File

@@ -0,0 +1,35 @@
/*
* Copyright 2013 Google Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.maps.android.geometry;
public class Point {
public final double x;
public final double y;
public Point(double x, double y) {
this.x = x;
this.y = y;
}
@Override
public String toString() {
return "Point{" +
"x=" + x +
", y=" + y +
'}';
}
}

View File

@@ -0,0 +1,27 @@
/*
* Copyright 2013 Google Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.maps.android.projection;
/**
* @deprecated since 0.2. Use {@link com.google.maps.android.geometry.Point} instead.
*/
@Deprecated
public class Point extends com.google.maps.android.geometry.Point {
public Point(double x, double y) {
super(x, y);
}
}

View File

@@ -0,0 +1,46 @@
/*
* Copyright 2013 Google Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.maps.android.projection;
import com.car2go.maps.model.LatLng;
public class SphericalMercatorProjection {
final double mWorldWidth;
public SphericalMercatorProjection(final double worldWidth) {
mWorldWidth = worldWidth;
}
@SuppressWarnings("deprecation")
public Point toPoint(final LatLng latLng) {
final double x = latLng.longitude / 360 + .5;
final double siny = Math.sin(Math.toRadians(latLng.latitude));
final double y = 0.5 * Math.log((1 + siny) / (1 - siny)) / -(2 * Math.PI) + .5;
return new Point(x * mWorldWidth, y * mWorldWidth);
}
public LatLng toLatLng(com.google.maps.android.geometry.Point point) {
final double x = point.x / mWorldWidth - 0.5;
final double lng = x * 360;
double y = .5 - (point.y / mWorldWidth);
final double lat = 90 - Math.toDegrees(Math.atan(Math.exp(-y * 2 * Math.PI)) * 2);
return new LatLng(lat, lng);
}
}

View File

@@ -0,0 +1,226 @@
/*
* Copyright 2013 Google Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.maps.android.quadtree;
import com.google.maps.android.geometry.Bounds;
import com.google.maps.android.geometry.Point;
import java.util.ArrayList;
import java.util.Collection;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
/**
* A quad tree which tracks items with a Point geometry.
* See http://en.wikipedia.org/wiki/Quadtree for details on the data structure.
* This class is not thread safe.
*/
public class PointQuadTree<T extends PointQuadTree.Item> {
public interface Item {
Point getPoint();
}
/**
* The bounds of this quad.
*/
private final Bounds mBounds;
/**
* The depth of this quad in the tree.
*/
private final int mDepth;
/**
* Maximum number of elements to store in a quad before splitting.
*/
private final static int MAX_ELEMENTS = 50;
/**
* The elements inside this quad, if any.
*/
private Set<T> mItems;
/**
* Maximum depth.
*/
private final static int MAX_DEPTH = 40;
/**
* Child quads.
*/
private List<PointQuadTree<T>> mChildren = null;
/**
* Creates a new quad tree with specified bounds.
*
* @param minX
* @param maxX
* @param minY
* @param maxY
*/
public PointQuadTree(double minX, double maxX, double minY, double maxY) {
this(new Bounds(minX, maxX, minY, maxY));
}
public PointQuadTree(Bounds bounds) {
this(bounds, 0);
}
private PointQuadTree(double minX, double maxX, double minY, double maxY, int depth) {
this(new Bounds(minX, maxX, minY, maxY), depth);
}
private PointQuadTree(Bounds bounds, int depth) {
mBounds = bounds;
mDepth = depth;
}
/**
* Insert an item.
*/
public void add(T item) {
Point point = item.getPoint();
if (this.mBounds.contains(point.x, point.y)) {
insert(point.x, point.y, item);
}
}
private void insert(double x, double y, T item) {
if (this.mChildren != null) {
if (y < mBounds.midY) {
if (x < mBounds.midX) { // top left
mChildren.get(0).insert(x, y, item);
} else { // top right
mChildren.get(1).insert(x, y, item);
}
} else {
if (x < mBounds.midX) { // bottom left
mChildren.get(2).insert(x, y, item);
} else {
mChildren.get(3).insert(x, y, item);
}
}
return;
}
if (mItems == null) {
mItems = new LinkedHashSet<>();
}
mItems.add(item);
if (mItems.size() > MAX_ELEMENTS && mDepth < MAX_DEPTH) {
split();
}
}
/**
* Split this quad.
*/
private void split() {
mChildren = new ArrayList<PointQuadTree<T>>(4);
mChildren.add(new PointQuadTree<T>(mBounds.minX, mBounds.midX, mBounds.minY, mBounds.midY, mDepth + 1));
mChildren.add(new PointQuadTree<T>(mBounds.midX, mBounds.maxX, mBounds.minY, mBounds.midY, mDepth + 1));
mChildren.add(new PointQuadTree<T>(mBounds.minX, mBounds.midX, mBounds.midY, mBounds.maxY, mDepth + 1));
mChildren.add(new PointQuadTree<T>(mBounds.midX, mBounds.maxX, mBounds.midY, mBounds.maxY, mDepth + 1));
Set<T> items = mItems;
mItems = null;
for (T item : items) {
// re-insert items into child quads.
insert(item.getPoint().x, item.getPoint().y, item);
}
}
/**
* Remove the given item from the set.
*
* @return whether the item was removed.
*/
public boolean remove(T item) {
Point point = item.getPoint();
if (this.mBounds.contains(point.x, point.y)) {
return remove(point.x, point.y, item);
} else {
return false;
}
}
private boolean remove(double x, double y, T item) {
if (this.mChildren != null) {
if (y < mBounds.midY) {
if (x < mBounds.midX) { // top left
return mChildren.get(0).remove(x, y, item);
} else { // top right
return mChildren.get(1).remove(x, y, item);
}
} else {
if (x < mBounds.midX) { // bottom left
return mChildren.get(2).remove(x, y, item);
} else {
return mChildren.get(3).remove(x, y, item);
}
}
} else {
if (mItems == null) {
return false;
} else {
return mItems.remove(item);
}
}
}
/**
* Removes all points from the quadTree
*/
public void clear() {
mChildren = null;
if (mItems != null) {
mItems.clear();
}
}
/**
* Search for all items within a given bounds.
*/
public Collection<T> search(Bounds searchBounds) {
final List<T> results = new ArrayList<T>();
search(searchBounds, results);
return results;
}
private void search(Bounds searchBounds, Collection<T> results) {
if (!mBounds.intersects(searchBounds)) {
return;
}
if (this.mChildren != null) {
for (PointQuadTree<T> quad : mChildren) {
quad.search(searchBounds, results);
}
} else if (mItems != null) {
if (searchBounds.contains(mBounds)) {
results.addAll(mItems);
} else {
for (T item : mItems) {
if (searchBounds.contains(item.getPoint())) {
results.add(item);
}
}
}
}
}
}

View File

@@ -0,0 +1,268 @@
/*
* Copyright 2013 Google Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.maps.android.ui;
import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Rect;
import android.graphics.drawable.Drawable;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.TextView;
import net.vonforst.evmap.R;
/**
* IconGenerator generates icons that contain text (or custom content) within an info
* window-like shape.
* <p/>
* The icon {@link Bitmap}s generated by the factory should be used in conjunction with a
* BitmapDescriptorFactory.
* <p/>
* This class is not thread safe.
*/
public class IconGenerator {
private final Context mContext;
private ViewGroup mContainer;
private RotationLayout mRotationLayout;
private TextView mTextView;
private View mContentView;
private int mRotation;
private float mAnchorU = 0.5f;
private float mAnchorV = 1f;
/**
* Creates a new IconGenerator with the default style.
*/
public IconGenerator(Context context) {
mContext = context;
mContainer = (ViewGroup) LayoutInflater.from(mContext).inflate(R.layout.amu_text_bubble, null);
mRotationLayout = (RotationLayout) mContainer.getChildAt(0);
mContentView = mTextView = (TextView) mRotationLayout.findViewById(R.id.amu_text);
}
/**
* Sets the text content, then creates an icon with the current style.
*
* @param text the text content to display inside the icon.
*/
public Bitmap makeIcon(CharSequence text) {
if (mTextView != null) {
mTextView.setText(text);
}
return makeIcon();
}
/**
* Creates an icon with the current content and style.
* <p/>
* This method is useful if a custom view has previously been set, or if text content is not
* applicable.
*/
public Bitmap makeIcon() {
int measureSpec = View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED);
mContainer.measure(measureSpec, measureSpec);
int measuredWidth = mContainer.getMeasuredWidth();
int measuredHeight = mContainer.getMeasuredHeight();
mContainer.layout(0, 0, measuredWidth, measuredHeight);
if (mRotation == 1 || mRotation == 3) {
measuredHeight = mContainer.getMeasuredWidth();
measuredWidth = mContainer.getMeasuredHeight();
}
Bitmap r = Bitmap.createBitmap(measuredWidth, measuredHeight, Bitmap.Config.ARGB_8888);
r.eraseColor(Color.TRANSPARENT);
Canvas canvas = new Canvas(r);
switch (mRotation) {
case 0:
// do nothing
break;
case 1:
canvas.translate(measuredWidth, 0);
canvas.rotate(90);
break;
case 2:
canvas.rotate(180, measuredWidth / 2, measuredHeight / 2);
break;
case 3:
canvas.translate(0, measuredHeight);
canvas.rotate(270);
break;
}
mContainer.draw(canvas);
return r;
}
/**
* Sets the child view for the icon.
* <p/>
* If the view contains a {@link TextView} with the id "text", operations such as {@link
* #setTextAppearance} and {@link #makeIcon(CharSequence)} will operate upon that {@link TextView}.
*/
public void setContentView(View contentView) {
mRotationLayout.removeAllViews();
mRotationLayout.addView(contentView);
mContentView = contentView;
final View view = mRotationLayout.findViewById(R.id.amu_text);
mTextView = view instanceof TextView ? (TextView) view : null;
}
/**
* Rotates the contents of the icon.
*
* @param degrees the amount the contents should be rotated, as a multiple of 90 degrees.
*/
public void setContentRotation(int degrees) {
mRotationLayout.setViewRotation(degrees);
}
/**
* Rotates the icon.
*
* @param degrees the amount the icon should be rotated, as a multiple of 90 degrees.
*/
public void setRotation(int degrees) {
mRotation = ((degrees + 360) % 360) / 90;
}
/**
* @return u coordinate of the anchor, with rotation applied.
*/
public float getAnchorU() {
return rotateAnchor(mAnchorU, mAnchorV);
}
/**
* @return v coordinate of the anchor, with rotation applied.
*/
public float getAnchorV() {
return rotateAnchor(mAnchorV, mAnchorU);
}
/**
* Rotates the anchor around (u, v) = (0, 0).
*/
private float rotateAnchor(float u, float v) {
switch (mRotation) {
case 0:
return u;
case 1:
return 1 - v;
case 2:
return 1 - u;
case 3:
return v;
}
throw new IllegalStateException();
}
/**
* Sets the text color, size, style, hint color, and highlight color from the specified
* <code>TextAppearance</code> resource.
*
* @param resid the identifier of the resource.
*/
public void setTextAppearance(Context context, int resid) {
if (mTextView != null) {
mTextView.setTextAppearance(context, resid);
}
}
/**
* Sets the text color, size, style, hint color, and highlight color from the specified
* <code>TextAppearance</code> resource.
*
* @param resid the identifier of the resource.
*/
public void setTextAppearance(int resid) {
setTextAppearance(mContext, resid);
}
/**
* Set the background to a given Drawable, or remove the background.
*
* @param background the Drawable to use as the background, or null to remove the background.
*/
@SuppressWarnings("deprecation")
// View#setBackgroundDrawable is compatible with pre-API level 16 (Jelly Bean).
public void setBackground(Drawable background) {
mContainer.setBackgroundDrawable(background);
// Force setting of padding.
// setBackgroundDrawable does not call setPadding if the background has 0 padding.
if (background != null) {
Rect rect = new Rect();
background.getPadding(rect);
mContainer.setPadding(rect.left, rect.top, rect.right, rect.bottom);
} else {
mContainer.setPadding(0, 0, 0, 0);
}
}
/**
* Sets the padding of the content view. The default padding of the content view (i.e. text
* view) is 5dp top/bottom and 10dp left/right.
*
* @param left the left padding in pixels.
* @param top the top padding in pixels.
* @param right the right padding in pixels.
* @param bottom the bottom padding in pixels.
*/
public void setContentPadding(int left, int top, int right, int bottom) {
mContentView.setPadding(left, top, right, bottom);
}
public static final int STYLE_DEFAULT = 1;
public static final int STYLE_WHITE = 2;
public static final int STYLE_RED = 3;
public static final int STYLE_BLUE = 4;
public static final int STYLE_GREEN = 5;
public static final int STYLE_PURPLE = 6;
public static final int STYLE_ORANGE = 7;
private static int getStyleColor(int style) {
switch (style) {
default:
case STYLE_DEFAULT:
case STYLE_WHITE:
return 0xffffffff;
case STYLE_RED:
return 0xffcc0000;
case STYLE_BLUE:
return 0xff0099cc;
case STYLE_GREEN:
return 0xff669900;
case STYLE_PURPLE:
return 0xff9933cc;
case STYLE_ORANGE:
return 0xffff8800;
}
}
}

View File

@@ -0,0 +1,83 @@
/*
* Copyright 2013 Google Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.maps.android.ui;
import android.content.Context;
import android.graphics.Canvas;
import android.util.AttributeSet;
import android.widget.FrameLayout;
/**
* RotationLayout rotates the contents of the layout by multiples of 90 degrees.
* <p/>
* May not work with padding.
*/
public class RotationLayout extends FrameLayout {
private int mRotation;
public RotationLayout(Context context) {
super(context);
}
public RotationLayout(Context context, AttributeSet attrs) {
super(context, attrs);
}
public RotationLayout(Context context, AttributeSet attrs, int defStyle) {
super(context, attrs, defStyle);
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
if (mRotation == 1 || mRotation == 3) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
setMeasuredDimension(getMeasuredHeight(), getMeasuredWidth());
} else {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
}
}
/**
* @param degrees the rotation, in degrees.
*/
public void setViewRotation(int degrees) {
mRotation = ((degrees + 360) % 360) / 90;
}
@Override
public void dispatchDraw(Canvas canvas) {
if (mRotation == 0) {
super.dispatchDraw(canvas);
return;
}
if (mRotation == 1) {
canvas.translate(getWidth(), 0);
canvas.rotate(90, getWidth() / 2, 0);
canvas.translate(getHeight() / 2, getWidth() / 2);
} else if (mRotation == 2) {
canvas.rotate(180, getWidth() / 2, getHeight() / 2);
} else {
canvas.translate(0, getHeight());
canvas.rotate(270, getWidth() / 2, 0);
canvas.translate(getHeight() / 2, -getWidth() / 2);
}
super.dispatchDraw(canvas);
}
}

View File

@@ -0,0 +1,62 @@
/*
* Copyright 2013 Google Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.maps.android.ui;
import android.content.Context;
import android.graphics.Canvas;
import android.util.AttributeSet;
import androidx.appcompat.widget.AppCompatTextView;
public class SquareTextView extends AppCompatTextView {
private int mOffsetTop = 0;
private int mOffsetLeft = 0;
public SquareTextView(Context context) {
super(context);
}
public SquareTextView(Context context, AttributeSet attrs) {
super(context, attrs);
}
public SquareTextView(Context context, AttributeSet attrs, int defStyle) {
super(context, attrs, defStyle);
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
int width = getMeasuredWidth();
int height = getMeasuredHeight();
int dimension = Math.max(width, height);
if (width > height) {
mOffsetTop = width - height;
mOffsetLeft = 0;
} else {
mOffsetTop = 0;
mOffsetLeft = height - width;
}
setMeasuredDimension(dimension, dimension);
}
@Override
public void draw(Canvas canvas) {
canvas.translate(mOffsetLeft / 2, mOffsetTop / 2);
super.draw(canvas);
}
}

View File

@@ -2,7 +2,6 @@ package net.vonforst.evmap
import android.app.Application
import com.facebook.stetho.Stetho
import com.google.android.libraries.places.api.Places
import net.vonforst.evmap.storage.PreferenceDataSource
import net.vonforst.evmap.ui.updateNightMode
@@ -11,6 +10,6 @@ class EvMapApplication : Application() {
super.onCreate()
updateNightMode(PreferenceDataSource(this))
Stetho.initializeWithDefaults(this);
Places.initialize(applicationContext, getString(R.string.google_maps_key));
init(applicationContext)
}
}

View File

@@ -4,9 +4,9 @@ import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.util.Log
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.drawerlayout.widget.DrawerLayout
@@ -14,8 +14,6 @@ import androidx.navigation.NavController
import androidx.navigation.findNavController
import androidx.navigation.ui.AppBarConfiguration
import androidx.navigation.ui.setupWithNavController
import com.google.android.gms.common.ConnectionResult
import com.google.android.gms.common.GoogleApiAvailability
import com.google.android.material.navigation.NavigationView
import com.google.android.material.snackbar.Snackbar
import net.vonforst.evmap.api.goingelectric.ChargeLocation
@@ -56,7 +54,8 @@ class MapsActivity : AppCompatActivity() {
setOf(
R.id.map,
R.id.favs,
R.id.about
R.id.about,
R.id.settings
),
findViewById<DrawerLayout>(R.id.drawer_layout)
)
@@ -64,7 +63,7 @@ class MapsActivity : AppCompatActivity() {
prefs = PreferenceDataSource(this)
checkPlayServices()
checkPlayServices(this)
}
fun navigateTo(charger: ChargeLocation) {
@@ -92,13 +91,17 @@ 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))
}
@@ -110,19 +113,4 @@ class MapsActivity : AppCompatActivity() {
}
startActivity(intent)
}
private fun checkPlayServices(): Boolean {
val request = 9000
val apiAvailability = GoogleApiAvailability.getInstance()
val resultCode = apiAvailability.isGooglePlayServicesAvailable(this)
if (resultCode != ConnectionResult.SUCCESS) {
if (apiAvailability.isUserResolvableError(resultCode)) {
apiAvailability.getErrorDialog(this, resultCode, request).show()
} else {
Log.d("EVMap", "This device is not supported.")
}
return false
}
return true
}
}

View File

@@ -1,31 +1,17 @@
package net.vonforst.evmap.adapter
import android.content.Context
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.appcompat.app.AppCompatActivity
import androidx.core.text.HtmlCompat
import androidx.core.view.children
import androidx.databinding.DataBindingUtil
import androidx.databinding.Observable
import androidx.databinding.ViewDataBinding
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.chip.Chip
import net.vonforst.evmap.*
import net.vonforst.evmap.BR
import net.vonforst.evmap.R
import net.vonforst.evmap.api.availability.ChargepointStatus
import net.vonforst.evmap.api.goingelectric.*
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 java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
import kotlin.math.max
import net.vonforst.evmap.api.goingelectric.Chargepoint
import net.vonforst.evmap.viewmodel.FavoritesViewModel
interface Equatable {
override fun equals(other: Any?): Boolean;
@@ -92,130 +78,6 @@ class ConnectorAdapter : DataBindingAdapter<ConnectorAdapter.ChargepointWithAvai
override fun getItemViewType(position: Int): Int = R.layout.item_connector
}
class DetailAdapter : DataBindingAdapter<DetailAdapter.Detail>() {
data class Detail(
val icon: Int,
val contentDescription: Int,
val text: CharSequence,
val detailText: CharSequence? = null,
val links: Boolean = true,
val clickable: Boolean = false,
val hoursDays: OpeningHoursDays? = null
) : Equatable
override fun getItemViewType(position: Int): Int {
val item = getItem(position)
if (item.hoursDays != null) {
return R.layout.item_detail_openinghours
} else {
return R.layout.item_detail
}
}
}
fun buildDetails(
loc: ChargeLocation?,
chargeCards: Map<Long, ChargeCard>?,
filteredChargeCards: Set<Long>?,
ctx: Context
): List<DetailAdapter.Detail> {
if (loc == null) return emptyList()
return listOfNotNull(
DetailAdapter.Detail(
R.drawable.ic_address,
R.string.address,
loc.address.toString(),
loc.locationDescription
),
if (loc.operator != null) DetailAdapter.Detail(
R.drawable.ic_operator,
R.string.operator,
loc.operator
) else null,
if (loc.network != null) DetailAdapter.Detail(
R.drawable.ic_network,
R.string.network,
loc.network
) else null,
if (loc.faultReport != null) DetailAdapter.Detail(
R.drawable.ic_fault_report,
R.string.fault_report,
loc.faultReport.created?.let {
ctx.getString(
R.string.fault_report_date,
loc.faultReport.created
.atZone(ZoneId.systemDefault())
.format(DateTimeFormatter.ofLocalizedDate(FormatStyle.SHORT))
)
} ?: "",
loc.faultReport.description?.let {
HtmlCompat.fromHtml(it, HtmlCompat.FROM_HTML_MODE_LEGACY)
} ?: "",
clickable = true
) else null,
if (loc.openinghours != null && !loc.openinghours.isEmpty) DetailAdapter.Detail(
R.drawable.ic_hours,
R.string.hours,
loc.openinghours.getStatusText(ctx),
loc.openinghours.description,
hoursDays = loc.openinghours.days
) else null,
if (loc.cost != null) DetailAdapter.Detail(
R.drawable.ic_cost,
R.string.cost,
loc.cost.getStatusText(ctx),
loc.cost.descriptionLong ?: loc.cost.descriptionShort
)
else null,
if (loc.chargecards != null && loc.chargecards.isNotEmpty()) DetailAdapter.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,
DetailAdapter.Detail(
R.drawable.ic_location,
R.string.coordinates,
loc.coordinates.formatDMS(),
loc.coordinates.formatDecimal(),
links = false,
clickable = true
)
)
}
fun formatChargeCards(
chargecards: List<ChargeCardId>,
chargecardData: Map<Long, ChargeCard>?,
filteredChargeCards: Set<Long>?,
ctx: Context
): CharSequence {
if (chargecardData == null) return ""
val maxItems = 5
var result = chargecards
.sortedByDescending { filteredChargeCards?.contains(it.id) }
.take(maxItems)
.mapNotNull {
val name = chargecardData[it.id]?.name ?: return@mapNotNull null
if (filteredChargeCards?.contains(it.id) == true) {
name.bold()
} else {
name
}
}.joinToSpannedString()
if (chargecards.size > maxItems) {
result += " " + ctx.getString(R.string.and_n_others, chargecards.size - maxItems)
}
return result
}
class FavoritesAdapter(val vm: FavoritesViewModel) :
DataBindingAdapter<FavoritesViewModel.FavoritesListItem>() {
@@ -226,196 +88,4 @@ class FavoritesAdapter(val vm: FavoritesViewModel) :
override fun getItemViewType(position: Int): Int = R.layout.item_favorite
override fun getItemId(position: Int): Long = getItem(position).charger.id
}
class FiltersAdapter : DataBindingAdapter<FilterWithValue<FilterValue>>() {
init {
setHasStableIds(true)
}
val itemids = mutableMapOf<String, Long>()
var maxId = 0L
override fun getItemViewType(position: Int): Int =
when (val filter = getItem(position).filter) {
is BooleanFilter -> R.layout.item_filter_boolean
is MultipleChoiceFilter -> {
if (filter.manyChoices) {
R.layout.item_filter_multiple_choice_large
} else {
R.layout.item_filter_multiple_choice
}
}
is SliderFilter -> R.layout.item_filter_slider
}
override fun bind(
holder: ViewHolder<FilterWithValue<FilterValue>>,
item: FilterWithValue<FilterValue>
) {
super.bind(holder, item)
when (item.value) {
is SliderFilterValue -> {
setupSlider(
holder.binding as ItemFilterSliderBinding,
item.filter as SliderFilter, item.value
)
}
is MultipleChoiceFilterValue -> {
val filter = item.filter as MultipleChoiceFilter
if (filter.manyChoices) {
setupMultipleChoiceMany(
holder.binding as ItemFilterMultipleChoiceLargeBinding,
filter, item.value
)
} else {
setupMultipleChoice(
holder.binding as ItemFilterMultipleChoiceBinding,
filter, item.value
)
}
}
}
}
private fun setupMultipleChoice(
binding: ItemFilterMultipleChoiceBinding,
filter: MultipleChoiceFilter,
value: MultipleChoiceFilterValue
) {
val inflater = LayoutInflater.from(binding.root.context)
value.values.toList().forEach {
// delete values that cannot be selected anymore
if (it !in filter.choices.keys) value.values.remove(it)
}
fun updateButtons() {
value.all = value.values == filter.choices.keys
binding.btnAll.isEnabled = !value.all
binding.btnNone.isEnabled = value.values.isNotEmpty()
}
val chips = mutableMapOf<String, Chip>()
binding.chipGroup.children.forEach {
if (it.id != R.id.chipMore) binding.chipGroup.removeView(it)
}
filter.choices.entries.sortedByDescending {
it.key in value.values
}.sortedByDescending {
if (filter.commonChoices != null) it.key in filter.commonChoices else false
}.forEach { choice ->
val chip = inflater.inflate(
R.layout.item_filter_multiple_choice_chip,
binding.chipGroup,
false
) as Chip
chip.text = choice.value
chip.isChecked = choice.key in value.values || value.all
if (value.all && choice.key !in value.values) value.values.add(choice.key)
chip.setOnCheckedChangeListener { _, isChecked ->
if (isChecked) {
value.values.add(choice.key)
} else {
value.values.remove(choice.key)
}
updateButtons()
}
if (filter.commonChoices != null && choice.key !in filter.commonChoices
&& !(chip.isChecked && !value.all) && !binding.showingAll
) {
chip.visibility = View.GONE
} else {
chip.visibility = View.VISIBLE
}
binding.chipGroup.addView(chip, binding.chipGroup.childCount - 1)
chips[choice.key] = chip
}
binding.btnAll.setOnClickListener {
value.all = true
value.values.addAll(filter.choices.keys)
chips.values.forEach { it.isChecked = true }
updateButtons()
}
binding.btnNone.setOnClickListener {
value.all = true
value.values.addAll(filter.choices.keys)
chips.values.forEach { it.isChecked = false }
updateButtons()
}
binding.chipMore.setOnClickListener {
binding.showingAll = !binding.showingAll
chips.forEach { (key, chip) ->
if (filter.commonChoices != null && key !in filter.commonChoices
&& !(chip.isChecked && !value.all) && !binding.showingAll
) {
chip.visibility = View.GONE
} else {
chip.visibility = View.VISIBLE
}
}
}
updateButtons()
}
private fun setupMultipleChoiceMany(
binding: ItemFilterMultipleChoiceLargeBinding,
filter: MultipleChoiceFilter,
value: MultipleChoiceFilterValue
) {
if (value.all) {
value.values = filter.choices.keys.toMutableSet()
binding.notifyPropertyChanged(BR.item)
}
binding.btnEdit.setOnClickListener {
val dialog = MultiSelectDialog.getInstance(filter.name, filter.choices, value.values)
dialog.okListener = { selected ->
value.values = selected.toMutableSet()
value.all = value.values == filter.choices.keys
binding.item = binding.item
}
dialog.show((binding.root.context as AppCompatActivity).supportFragmentManager, null)
}
}
private fun setupSlider(
binding: ItemFilterSliderBinding,
filter: SliderFilter,
value: SliderFilterValue
) {
binding.progress = max(filter.inverseMapping(value.value) - filter.min, 0)
binding.mappedValue = filter.mapping(binding.progress + filter.min)
binding.addOnPropertyChangedCallback(object :
Observable.OnPropertyChangedCallback() {
override fun onPropertyChanged(sender: Observable?, propertyId: Int) {
when (propertyId) {
BR.progress -> {
val mapped = filter.mapping(binding.progress + filter.min)
value.value = mapped
binding.mappedValue = mapped
}
}
}
})
}
override fun getItemId(position: Int): Long {
val key = getItem(position).filter.key
var value = itemids[key]
if (value == null) {
maxId++
value = maxId
itemids[key] = maxId
}
return value
}
}
class DonationAdapter() : DataBindingAdapter<DonationItem>() {
override fun getItemViewType(position: Int): Int = R.layout.item_donation
}

View File

@@ -0,0 +1,143 @@
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.plus
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
class DetailsAdapter : DataBindingAdapter<DetailsAdapter.Detail>() {
data class Detail(
val icon: Int,
val contentDescription: Int,
val text: CharSequence,
val detailText: CharSequence? = null,
val links: Boolean = true,
val clickable: Boolean = false,
val hoursDays: OpeningHoursDays? = null
) : Equatable
override fun getItemViewType(position: Int): Int {
val item = getItem(position)
if (item.hoursDays != null) {
return R.layout.item_detail_openinghours
} else {
return R.layout.item_detail
}
}
}
fun buildDetails(
loc: ChargeLocation?,
chargeCards: Map<Long, ChargeCard>?,
filteredChargeCards: Set<Long>?,
ctx: Context
): List<DetailsAdapter.Detail> {
if (loc == null) return emptyList()
return listOfNotNull(
DetailsAdapter.Detail(
R.drawable.ic_address,
R.string.address,
loc.address.toString(),
loc.locationDescription,
clickable = true
),
if (loc.operator != null) DetailsAdapter.Detail(
R.drawable.ic_operator,
R.string.operator,
loc.operator
) else null,
if (loc.network != null) DetailsAdapter.Detail(
R.drawable.ic_network,
R.string.network,
loc.network
) else null,
if (loc.faultReport != null) DetailsAdapter.Detail(
R.drawable.ic_fault_report,
R.string.fault_report,
loc.faultReport.created?.let {
ctx.getString(
R.string.fault_report_date,
loc.faultReport.created
.atZone(ZoneId.systemDefault())
.format(DateTimeFormatter.ofLocalizedDate(FormatStyle.SHORT))
)
} ?: "",
loc.faultReport.description?.let {
HtmlCompat.fromHtml(it, HtmlCompat.FROM_HTML_MODE_LEGACY)
} ?: "",
clickable = true
) else null,
if (loc.openinghours != null && !loc.openinghours.isEmpty) DetailsAdapter.Detail(
R.drawable.ic_hours,
R.string.hours,
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(
R.drawable.ic_cost,
R.string.cost,
loc.cost.getStatusText(ctx),
loc.cost.descriptionLong ?: loc.cost.descriptionShort
)
else null,
if (loc.chargecards != null && loc.chargecards.isNotEmpty()) DetailsAdapter.Detail(
R.drawable.ic_payment,
R.string.charge_cards,
ctx.resources.getQuantityString(
R.plurals.charge_cards_compatible_num,
loc.chargecards.size, loc.chargecards.size
),
formatChargeCards(loc.chargecards, chargeCards, filteredChargeCards, ctx),
clickable = true
) else null,
DetailsAdapter.Detail(
R.drawable.ic_location,
R.string.coordinates,
loc.coordinates.formatDMS(),
loc.coordinates.formatDecimal(),
links = false,
clickable = true
)
)
}
fun formatChargeCards(
chargecards: List<ChargeCardId>,
chargecardData: Map<Long, ChargeCard>?,
filteredChargeCards: Set<Long>?,
ctx: Context
): CharSequence {
if (chargecardData == null) return ""
val maxItems = 5
var result = chargecards
.sortedByDescending { filteredChargeCards?.contains(it.id) }
.take(maxItems)
.mapNotNull {
val name = chargecardData[it.id]?.name ?: return@mapNotNull null
if (filteredChargeCards?.contains(it.id) == true) {
name.bold()
} else {
name
}
}.joinToSpannedString()
if (chargecards.size > maxItems) {
result += " " + ctx.getString(R.string.and_n_others, chargecards.size - maxItems)
}
return result
}

View File

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

View File

@@ -0,0 +1,227 @@
package net.vonforst.evmap.adapter
import android.view.LayoutInflater
import android.view.View
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.children
import androidx.databinding.Observable
import com.google.android.material.chip.Chip
import net.vonforst.evmap.BR
import net.vonforst.evmap.R
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 kotlin.math.max
class FiltersAdapter : DataBindingAdapter<FilterWithValue<FilterValue>>() {
init {
setHasStableIds(true)
}
val itemids = mutableMapOf<String, Long>()
var maxId = 0L
override fun getItemViewType(position: Int): Int =
when (val filter = getItem(position).filter) {
is BooleanFilter -> R.layout.item_filter_boolean
is MultipleChoiceFilter -> {
if (filter.manyChoices) {
R.layout.item_filter_multiple_choice_large
} else {
R.layout.item_filter_multiple_choice
}
}
is SliderFilter -> R.layout.item_filter_slider
}
override fun bind(
holder: ViewHolder<FilterWithValue<FilterValue>>,
item: FilterWithValue<FilterValue>
) {
super.bind(holder, item)
when (item.value) {
is SliderFilterValue -> {
setupSlider(
holder.binding as ItemFilterSliderBinding,
item.filter as SliderFilter, item.value
)
}
is MultipleChoiceFilterValue -> {
val filter = item.filter as MultipleChoiceFilter
if (filter.manyChoices) {
setupMultipleChoiceMany(
holder.binding as ItemFilterMultipleChoiceLargeBinding,
filter, item.value
)
} else {
setupMultipleChoice(
holder.binding as ItemFilterMultipleChoiceBinding,
filter, item.value
)
}
}
}
}
private fun setupMultipleChoice(
binding: ItemFilterMultipleChoiceBinding,
filter: MultipleChoiceFilter,
value: MultipleChoiceFilterValue
) {
// TODO: this implementation seems to be buggy
val inflater = LayoutInflater.from(binding.root.context)
value.values.toList().forEach {
// delete values that cannot be selected anymore
if (it !in filter.choices.keys) value.values.remove(it)
}
fun updateButtons() {
value.all = value.values == filter.choices.keys
binding.btnAll.isEnabled = !value.all
binding.btnNone.isEnabled = value.values.isNotEmpty()
}
val chips = mutableMapOf<String, Chip>()
// reuse existing chips in layout
val reuseChips = binding.chipGroup.children.filter {
it.id != R.id.chipMore
}.toMutableList()
binding.chipGroup.children.forEach {
if (it.id != R.id.chipMore) binding.chipGroup.removeView(it)
}
filter.choices.entries.sortedByDescending {
it.key in value.values
}.sortedByDescending {
if (filter.commonChoices != null) it.key in filter.commonChoices else false
}.forEach { choice ->
var reused = false
val chip = if (reuseChips.size > 0) {
reused = true
reuseChips.removeAt(0) as Chip
} else {
inflater.inflate(
R.layout.item_filter_multiple_choice_chip,
binding.chipGroup,
false
) as Chip
}
chip.text = choice.value
chip.isChecked = choice.key in value.values || value.all
if (value.all && choice.key !in value.values) value.values.add(choice.key)
chip.setOnCheckedChangeListener { _, isChecked ->
if (isChecked) {
value.values.add(choice.key)
} else {
value.values.remove(choice.key)
}
updateButtons()
}
if (filter.commonChoices != null && choice.key !in filter.commonChoices
&& !(chip.isChecked && !value.all) && !binding.showingAll
) {
chip.visibility = View.GONE
} else {
chip.visibility = View.VISIBLE
}
if (!reused) binding.chipGroup.addView(chip, binding.chipGroup.childCount - 1)
chips[choice.key] = chip
}
// delete surplus reusable chips
reuseChips.forEach {
binding.chipGroup.removeView(it)
}
binding.btnAll.setOnClickListener {
value.all = true
value.values.addAll(filter.choices.keys)
chips.values.forEach { it.isChecked = true }
updateButtons()
}
binding.btnNone.setOnClickListener {
value.all = true
value.values.addAll(filter.choices.keys)
chips.values.forEach { it.isChecked = false }
updateButtons()
}
binding.chipMore.setOnClickListener {
binding.showingAll = !binding.showingAll
chips.forEach { (key, chip) ->
if (filter.commonChoices != null && key !in filter.commonChoices
&& !(chip.isChecked && !value.all) && !binding.showingAll
) {
chip.visibility = View.GONE
} else {
chip.visibility = View.VISIBLE
}
}
}
updateButtons()
}
private fun setupMultipleChoiceMany(
binding: ItemFilterMultipleChoiceLargeBinding,
filter: MultipleChoiceFilter,
value: MultipleChoiceFilterValue
) {
if (value.all) {
value.values = filter.choices.keys.toMutableSet()
binding.notifyPropertyChanged(BR.item)
}
binding.btnEdit.setOnClickListener {
val dialog =
MultiSelectDialog.getInstance(
filter.name,
filter.choices,
value.values,
commonChoices = filter.commonChoices
)
dialog.okListener = { selected ->
value.values = selected.toMutableSet()
value.all = value.values == filter.choices.keys
binding.item = binding.item
}
dialog.show((binding.root.context as AppCompatActivity).supportFragmentManager, null)
}
}
private fun setupSlider(
binding: ItemFilterSliderBinding,
filter: SliderFilter,
value: SliderFilterValue
) {
binding.progress =
max(filter.inverseMapping(value.value) - filter.min, 0)
binding.mappedValue = filter.mapping(binding.progress + filter.min)
binding.addOnPropertyChangedCallback(object :
Observable.OnPropertyChangedCallback() {
override fun onPropertyChanged(sender: Observable?, propertyId: Int) {
when (propertyId) {
BR.progress -> {
val mapped = filter.mapping(binding.progress + filter.min)
value.value = mapped
binding.mappedValue = mapped
}
}
}
})
}
override fun getItemId(position: Int): Long {
val key = getItem(position).filter.key
var value = itemids[key]
if (value == null) {
maxId++
value = maxId
itemids[key] = maxId
}
return value
}
}

View File

@@ -7,9 +7,11 @@ import android.widget.ImageView
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import coil.load
import coil.memory.MemoryCache
import coil.size.OriginalSize
import coil.size.SizeResolver
import com.ortiz.touchview.TouchImageView
import com.squareup.picasso.Callback
import com.squareup.picasso.Picasso
import net.vonforst.evmap.R
import net.vonforst.evmap.api.goingelectric.ChargerPhoto
@@ -19,17 +21,19 @@ class GalleryAdapter(
val itemClickListener: ItemClickListener? = null,
val detailView: Boolean = false,
val pageToLoad: Int? = null,
val imageCacheKey: MemoryCache.Key? = null,
val loadedListener: (() -> Unit)? = 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 {
@@ -37,11 +41,11 @@ class GalleryAdapter(
val view: ImageView
if (detailView) {
view = inflater.inflate(R.layout.gallery_item_fullscreen, parent, false) as ImageView
view.setOnTouchListener { view, event ->
view.setOnTouchListener { v, event ->
var result = true
//can scroll horizontally checks if there's still a part of the image
//that can be scrolled until you reach the edge
if (event.pointerCount >= 2 || view.canScrollHorizontally(1) && view.canScrollHorizontally(
if (event.pointerCount >= 2 || v.canScrollHorizontally(1) && v.canScrollHorizontally(
-1
)
) {
@@ -73,46 +77,63 @@ class GalleryAdapter(
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}"
}
)
.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
}
val id = getItem(position).id
val url = "https://api.goingelectric.de/chargepoints/photo/?key=${apikey}" +
"&id=$id" +
if (detailView) {
"&size=1000"
} else {
"&height=${holder.view.height}"
}
override fun onError(e: Exception?) {
holder.view.load(
url
) {
if (pageToLoad == position && imageCacheKey != null) {
placeholderMemoryCacheKey(imageCacheKey)
}
size(SizeResolver(OriginalSize))
allowHardware(false)
listener(
onSuccess = { _, metadata ->
memoryKeys[id] = metadata.memoryCacheKey
if (pageToLoad == position) invokeLoadedListener(holder.view)
},
onError = { _, _ ->
if (!loaded && loadedListener != null && pageToLoad == position) {
loadedListener.invoke()
loaded = true
}
}
})
)
}
if (pageToLoad == position && imageCacheKey != null) {
// start transition immediately
if (pageToLoad == position) invokeLoadedListener(holder.view)
}
holder.view.transitionName = galleryTransitionName(position)
if (itemClickListener != null) {
holder.view.setOnClickListener {
itemClickListener.onItemClick(holder.view, position)
itemClickListener.onItemClick(holder.view, position, memoryKeys[id])
}
}
}
private fun invokeLoadedListener(
view: ImageView
) {
if (!loaded && loadedListener != null) {
view.viewTreeObserver.addOnPreDrawListener(object :
ViewTreeObserver.OnPreDrawListener {
override fun onPreDraw(): Boolean {
view.viewTreeObserver.removeOnPreDrawListener(this)
loadedListener.invoke()
return true
}
})
loaded = true
}
}
}
fun galleryTransitionName(position: Int) = "gallery_$position"

View File

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

View File

@@ -10,7 +10,10 @@ import java.io.IOException
import kotlin.coroutines.resumeWithException
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 {

View File

@@ -2,21 +2,27 @@ 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.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 +30,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
}
@@ -115,10 +121,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)

View File

@@ -8,6 +8,7 @@ import okhttp3.OkHttpClient
import org.json.JSONObject
import java.io.IOException
@ExperimentalCoroutinesApi
class ChargecloudAvailabilityDetector(
client: OkHttpClient,
private val operatorId: String

View File

@@ -9,6 +9,7 @@ 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,14 +139,17 @@ 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"
val type = when (connector.connectorType.toLowerCase(Locale.ROOT)) {
"type3" -> Chargepoint.TYPE_3
"type2" -> Chargepoint.TYPE_2
"type1" -> Chargepoint.TYPE_1
"domestic" -> Chargepoint.SCHUKO
"type1combo" -> Chargepoint.CCS // US CCS, aka type1_combo
"type2combo" -> Chargepoint.CCS // EU CCS, aka type2_combo
"tepcochademo" -> Chargepoint.CHADEMO
"unspecified" -> "unknown"
"unknown" -> "unknown"
"saej1772" -> "unknown"
else -> throw IllegalArgumentException("unrecognized type ${connector.connectorType}")
}
val status = when (statusStr) {

View File

@@ -27,6 +27,7 @@ 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,
@@ -46,7 +47,7 @@ interface GoingElectricApi {
suspend fun getChargeCards(): Response<ChargeCardList>
companion object {
private val cacheSize = 10L * 1024 * 1024; // 10MB
private val cacheSize = 10L * 1024 * 1024 // 10MB
fun create(
apikey: String,
@@ -57,7 +58,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)
}

View File

@@ -8,7 +8,7 @@ import androidx.room.Entity
import androidx.room.PrimaryKey
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import kotlinx.android.parcel.Parcelize
import kotlinx.parcelize.Parcelize
import net.vonforst.evmap.R
import net.vonforst.evmap.adapter.Equatable
import java.time.DayOfWeek
@@ -77,7 +77,7 @@ data class ChargeLocation(
*/
fun maxPower(connectors: Set<String>? = null): Double {
return chargepoints.filter { connectors?.contains(it.type) ?: true }
.map { it.power }.max() ?: 0.0
.map { it.power }.maxOrNull() ?: 0.0
}
/**
@@ -97,6 +97,9 @@ data class ChargeLocation(
}
}
val totalChargepoints: Int
get() = chargepoints.sumBy { it.count }
fun formatChargepoints(): String {
return chargepointsMerged.map {
"${it.count} × ${it.type} ${it.formatPower()}"
@@ -277,6 +280,7 @@ data class Chargepoint(val type: String, val power: Double, val count: Int) : Eq
const val SUPERCHARGER = "Tesla Supercharger"
const val CEE_BLAU = "CEE Blau"
const val CEE_ROT = "CEE Rot"
const val TESLA_ROADSTER_HPC = "Tesla HPC"
}
}

View File

@@ -59,6 +59,14 @@ class AboutFragment : PreferenceFragmentCompat() {
findNavController().navigate(R.id.action_about_to_donateFragment)
true
}
"twitter" -> {
(activity as? MapsActivity)?.openUrl(getString(R.string.twitter_url))
true
}
"goingelectric" -> {
(activity as? MapsActivity)?.openUrl(getString(R.string.goingelectric_forum_url))
true
}
else -> super.onPreferenceTreeClick(preference)
}
}

View File

@@ -2,7 +2,6 @@ package net.vonforst.evmap.fragment
import android.Manifest
import android.content.pm.PackageManager
import android.location.Location
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
@@ -16,9 +15,9 @@ import androidx.navigation.fragment.findNavController
import androidx.navigation.ui.setupWithNavController
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
import com.google.android.gms.location.FusedLocationProviderClient
import com.google.android.gms.location.LocationServices
import com.google.android.libraries.maps.model.LatLng
import com.car2go.maps.model.LatLng
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.FavoritesAdapter
@@ -26,9 +25,9 @@ import net.vonforst.evmap.databinding.FragmentFavoritesBinding
import net.vonforst.evmap.viewmodel.FavoritesViewModel
import net.vonforst.evmap.viewmodel.viewModelFactory
class FavoritesFragment : Fragment() {
class FavoritesFragment : Fragment(), LostApiClient.ConnectionCallbacks {
private lateinit var binding: FragmentFavoritesBinding
private lateinit var fusedLocationClient: FusedLocationProviderClient
private lateinit var locationClient: LostApiClient
private val vm: FavoritesViewModel by viewModels(factoryProducer = {
viewModelFactory {
@@ -51,7 +50,8 @@ class FavoritesFragment : Fragment() {
binding.lifecycleOwner = this
binding.vm = vm
fusedLocationClient = LocationServices.getFusedLocationProviderClient(requireContext())
locationClient = LostApiClient.Builder(requireContext())
.addConnectionCallbacks(this).build()
return binding.root
}
@@ -82,16 +82,23 @@ class FavoritesFragment : Fragment() {
)
}
locationClient.connect()
}
override fun onConnected() {
if (ContextCompat.checkSelfPermission(
requireContext(),
Manifest.permission.ACCESS_FINE_LOCATION
) == PackageManager.PERMISSION_GRANTED
) {
fusedLocationClient.lastLocation.addOnSuccessListener { location: Location? ->
if (location != null) {
vm.location.value = LatLng(location.latitude, location.longitude)
}
val location = LocationServices.FusedLocationApi.getLastLocation(locationClient)
if (location != null) {
vm.location.value = LatLng(location.latitude, location.longitude)
}
}
}
override fun onConnectionSuspended() {
}
}

View File

@@ -17,9 +17,11 @@ 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 = {
@@ -35,13 +37,15 @@ class FilterFragment : Fragment() {
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 +89,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)
}
}

View File

@@ -0,0 +1,228 @@
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 binding: FragmentFilterProfilesBinding
private val vm: FilterProfilesViewModel by viewModels(factoryProducer = {
viewModelFactory {
FilterProfilesViewModel(requireActivity().application)
}
})
private var deleteSnackbar: Snackbar? = 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
)
val 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
)
}
}
})
val 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.insert(fp.copy(name = input.text.toString()))
}
}
.setNegativeButton(R.string.cancel) { di, button ->
}
}
})
binding.filterProfilesList.apply {
this.adapter = adapter
layoutManager =
LinearLayoutManager(context, LinearLayoutManager.VERTICAL, false)
addItemDecoration(
DividerItemDecoration(
context, LinearLayoutManager.VERTICAL
)
)
}
touchHelper.attachToRecyclerView(binding.filterProfilesList)
toolbar.setNavigationOnClickListener {
findNavController().popBackStack()
}
}
fun delete(fp: FilterProfile) {
vm.delete(fp.id)
deleteSnackbar?.dismiss()
view?.let {
val snackbar = Snackbar.make(
it,
getString(R.string.deleted_filterprofile, fp.name),
Snackbar.LENGTH_LONG
).setAction(R.string.undo) {
vm.insert(fp.copy(id = 0))
}
deleteSnackbar = snackbar
snackbar.show()
}
}
}

View File

@@ -12,6 +12,7 @@ import androidx.fragment.app.activityViewModels
import androidx.navigation.fragment.findNavController
import androidx.transition.TransitionInflater
import androidx.viewpager2.widget.ViewPager2
import coil.memory.MemoryCache
import com.ortiz.touchview.TouchImageView
import net.vonforst.evmap.R
import net.vonforst.evmap.adapter.GalleryAdapter
@@ -24,18 +25,23 @@ class GalleryFragment : Fragment() {
companion object {
private const val EXTRA_POSITION = "position"
private const val EXTRA_PHOTOS = "photos"
private const val EXTRA_IMAGE_CACHE_KEY = "image_cache_key"
private const val SAVED_CURRENT_PAGE_POSITION = "current_page_position"
fun buildArgs(photos: List<ChargerPhoto>, position: Int): Bundle {
fun buildArgs(
photos: List<ChargerPhoto>,
position: Int,
imageCacheKey: MemoryCache.Key?
): Bundle {
return Bundle().apply {
putParcelableArrayList(EXTRA_PHOTOS, ArrayList(photos))
putInt(EXTRA_POSITION, position)
putParcelable(EXTRA_IMAGE_CACHE_KEY, imageCacheKey)
}
}
}
private lateinit var binding: FragmentGalleryBinding
private var isReturning: Boolean = false
private var startingPosition: Int = 0
private var currentPosition: Int = 0
private lateinit var galleryAdapter: GalleryAdapter
@@ -49,7 +55,6 @@ class GalleryFragment : Fragment() {
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()
}
@@ -73,10 +78,13 @@ class GalleryFragment : Fragment() {
savedInstanceState?.getInt(SAVED_CURRENT_PAGE_POSITION) ?: startingPosition
galleryAdapter =
GalleryAdapter(requireContext(), detailView = true, pageToLoad = currentPosition) {
GalleryAdapter(
requireContext(), detailView = true, pageToLoad = currentPosition,
imageCacheKey = args.getParcelable(EXTRA_IMAGE_CACHE_KEY)
) {
startPostponedEnterTransition()
}
binding.gallery.setPageTransformer { page, position ->
binding.gallery.setPageTransformer { page, _ ->
val v = page as TouchImageView
currentPage = v
}
@@ -120,10 +128,8 @@ class GalleryFragment : Fragment() {
names: MutableList<String>,
sharedElements: MutableMap<String, View>
) {
if (isReturning) {
val currentPage = currentPage ?: return
sharedElements[names[0]] = currentPage
}
val currentPage = currentPage ?: return
sharedElements[names[0]] = currentPage
}
}

View File

@@ -1,9 +1,8 @@
package net.vonforst.evmap.fragment
import android.Manifest
import android.Manifest.permission.ACCESS_FINE_LOCATION
import android.annotation.SuppressLint
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.content.res.Configuration
@@ -11,13 +10,15 @@ import android.graphics.Color
import android.os.Bundle
import android.os.Handler
import android.view.*
import android.view.inputmethod.InputMethodManager
import android.widget.TextView
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,6 +26,7 @@ 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
@@ -33,16 +35,14 @@ import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.transition.TransitionInflater
import androidx.transition.TransitionManager
import com.google.android.gms.location.FusedLocationProviderClient
import com.google.android.gms.location.LocationServices
import com.google.android.libraries.maps.CameraUpdateFactory
import com.google.android.libraries.maps.GoogleMap
import com.google.android.libraries.maps.OnMapReadyCallback
import com.google.android.libraries.maps.SupportMapFragment
import com.google.android.libraries.maps.model.*
import com.google.android.libraries.places.api.model.Place
import com.google.android.libraries.places.widget.Autocomplete
import com.google.android.libraries.places.widget.model.AutocompleteActivityMode
import coil.memory.MemoryCache
import com.car2go.maps.AnyMap
import com.car2go.maps.MapFragment
import com.car2go.maps.OnMapReadyCallback
import com.car2go.maps.model.BitmapDescriptor
import com.car2go.maps.model.LatLng
import com.car2go.maps.model.Marker
import com.car2go.maps.model.MarkerOptions
import com.google.android.material.snackbar.Snackbar
import com.google.android.material.transition.MaterialArcMotion
import com.google.android.material.transition.MaterialContainerTransform
@@ -50,17 +50,24 @@ 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.LocationServices
import com.mapzen.android.lost.api.LostApiClient
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.DetailAdapter
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.autocomplete.handleAutocompleteResult
import net.vonforst.evmap.autocomplete.launchAutocomplete
import net.vonforst.evmap.databinding.FragmentMapBinding
import net.vonforst.evmap.storage.PreferenceDataSource
import net.vonforst.evmap.ui.ChargerIconGenerator
import net.vonforst.evmap.ui.ClusterIconGenerator
import net.vonforst.evmap.ui.MarkerAnimator
@@ -72,7 +79,8 @@ const val ARG_CHARGER_ID = "chargerId"
const val ARG_LAT = "lat"
const val ARG_LON = "lon"
class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallback {
class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallback,
LostApiClient.ConnectionCallbacks {
private lateinit var binding: FragmentMapBinding
private val vm: MapViewModel by viewModels(factoryProducer = {
viewModelFactory {
@@ -83,13 +91,15 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
}
})
private val galleryVm: GalleryViewModel by activityViewModels()
private var map: GoogleMap? = null
private lateinit var fusedLocationClient: FusedLocationProviderClient
private var mapFragment: MapFragment? = null
private var map: AnyMap? = null
private lateinit var locationClient: LostApiClient
private lateinit var bottomSheetBehavior: BottomSheetBehaviorGoogleMapsLike<View>
private lateinit var detailAppBarBehavior: MergedAppBarLayoutBehavior
private var markers: MutableBiMap<Marker, ChargeLocation> = HashBiMap()
private var clusterMarkers: List<Marker> = emptyList()
private var searchResultMarker: Marker? = null
private var searchResultIcon: BitmapDescriptor? = null
private var connectionErrorSnackbar: Snackbar? = null
private var previousChargepointIds: Set<Long>? = null
@@ -116,31 +126,73 @@ 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
fusedLocationClient = LocationServices.getFusedLocationProviderClient(requireContext())
clusterIconGenerator = ClusterIconGenerator(requireContext())
chargerIconGenerator = ChargerIconGenerator(requireContext())
animator = MarkerAnimator(chargerIconGenerator)
val provider = PreferenceDataSource(requireContext()).mapProvider
if (mapFragment == null || mapFragment!!.priority[0] != provider) {
mapFragment = MapFragment()
mapFragment!!.priority = arrayOf(
when (provider) {
"mapbox" -> MapFragment.MAPBOX
"google" -> MapFragment.GOOGLE
else -> null
},
MapFragment.GOOGLE,
MapFragment.MAPBOX
)
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
}
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)
setExitSharedElementCallback(reenterSharedElementCallback)
exitTransition = TransitionInflater.from(requireContext())
.inflateTransition(R.transition.map_exit_transition)
@@ -153,8 +205,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val mapFragment = childFragmentManager.findFragmentById(R.id.map) as SupportMapFragment
mapFragment.getMapAsync(this)
mapFragment!!.getMapAsync(this)
bottomSheetBehavior = BottomSheetBehaviorGoogleMapsLike.from(binding.bottomSheet)
detailAppBarBehavior = MergedAppBarLayoutBehavior.from(binding.detailAppBar)
@@ -171,23 +222,32 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
navController,
(requireActivity() as MapsActivity).appBarConfiguration
)
if (!PreferenceDataSource(requireContext()).welcomeDialogShown) {
navController.navigate(R.id.action_map_to_welcome)
}
}
override fun onResume() {
super.onResume()
val hostActivity = activity as? MapsActivity ?: return
hostActivity.fragmentCallback = this
vm.reloadPrefs()
}
private fun setupClickListeners() {
binding.fabLocate.setOnClickListener {
if (!hasLocationPermission()) {
if (ContextCompat.checkSelfPermission(
requireContext(),
ACCESS_FINE_LOCATION
) != PackageManager.PERMISSION_GRANTED
) {
requestPermissions(
arrayOf(Manifest.permission.ACCESS_FINE_LOCATION),
arrayOf(ACCESS_FINE_LOCATION),
REQUEST_LOCATION_PERMISSION
)
} else {
enableLocation(true, true)
enableLocation(moveTo = true, animate = true)
}
}
binding.fabDirections.setOnClickListener {
@@ -216,17 +276,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
bottomSheetBehavior.state = BottomSheetBehaviorGoogleMapsLike.STATE_ANCHOR_POINT
}
binding.search.setOnClickListener {
val fields = listOf(Place.Field.LAT_LNG, Place.Field.VIEWPORT)
val intent: Intent = Autocomplete.IntentBuilder(
AutocompleteActivityMode.OVERLAY, fields
)
.build(requireContext())
.addFlags(Intent.FLAG_ACTIVITY_NO_ANIMATION)
startActivityForResult(intent, REQUEST_AUTOCOMPLETE)
val imm =
requireContext().getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
imm.toggleSoftInput(0, 0)
launchAutocomplete(this)
}
binding.detailAppBar.toolbar.setNavigationOnClickListener {
bottomSheetBehavior.state = STATE_COLLAPSED
@@ -244,6 +294,13 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
}
true
}
R.id.menu_edit -> {
val charger = vm.charger.value?.data
if (charger != null) {
(activity as? MapsActivity)?.openUrl("https:${charger.url}edit/")
}
true
}
else -> false
}
}
@@ -351,12 +408,21 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
if (place != null) {
if (place.viewport != null) {
map.animateCamera(CameraUpdateFactory.newLatLngBounds(place.viewport, 0))
map.animateCamera(map.cameraUpdateFactory.newLatLngBounds(place.viewport, 0))
} else {
map.animateCamera(CameraUpdateFactory.newLatLngZoom(place.latLng, 12f))
map.animateCamera(map.cameraUpdateFactory.newLatLngZoom(place.latLng, 12f))
}
searchResultMarker = map.addMarker(MarkerOptions().position(place.latLng!!))
if (searchResultIcon == null) {
searchResultIcon =
map.bitmapDescriptorFactory.fromResource(R.drawable.ic_map_marker)
}
searchResultMarker = map.addMarker(
MarkerOptions()
.position(place.latLng)
.icon(searchResultIcon)
.anchor(0.5f, 1f)
)
}
updateBackPressedCallback()
@@ -367,10 +433,10 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
updateBackPressedCallback()
})
vm.mapType.observe(viewLifecycleOwner, Observer {
map?.mapType = it
map?.setMapType(it)
})
vm.mapTrafficEnabled.observe(viewLifecycleOwner, Observer {
map?.isTrafficEnabled = it
map?.setTrafficEnabled(it)
})
updateBackPressedCallback()
@@ -387,7 +453,10 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
markers.forEach { (m, c) ->
m.setIcon(
chargerIconGenerator.getBitmapDescriptor(
getMarkerTint(c, vm.filteredConnectors.value), fault = c.faultReport != null
getMarkerTint(c, vm.filteredConnectors.value),
highlight = false,
fault = c.faultReport != null,
multi = getMarkerMulti(c, vm.filteredConnectors.value)
)
)
}
@@ -400,7 +469,8 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
chargerIconGenerator.getBitmapDescriptor(
getMarkerTint(charger, vm.filteredConnectors.value),
highlight = true,
fault = charger.faultReport != null
fault = charger.faultReport != null,
multi = getMarkerMulti(charger, vm.filteredConnectors.value)
)
)
animator.animateMarkerBounce(marker)
@@ -410,13 +480,31 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
if (m != marker) {
m.setIcon(
chargerIconGenerator.getBitmapDescriptor(
getMarkerTint(c, vm.filteredConnectors.value), fault = c.faultReport != null
getMarkerTint(c, vm.filteredConnectors.value),
highlight = false,
fault = c.faultReport != null,
multi = getMarkerMulti(c, 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
@@ -429,12 +517,12 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
private fun setupAdapters() {
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),
GalleryFragment.buildArgs(photos, position, imageCacheKey),
null,
extras
)
@@ -453,17 +541,43 @@ 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)
binding.gallery.addOnLayoutChangeListener(object : View.OnLayoutChangeListener {
override fun onLayoutChange(
v: View,
left: Int,
top: Int,
right: Int,
bottom: Int,
oldLeft: Int,
oldTop: Int,
oldRight: Int,
oldBottom: Int
) {
v.removeOnLayoutChangeListener(this)
val layoutManager = binding.gallery.layoutManager!!
val viewAtPosition = layoutManager.findViewByPosition(galleryPosition)
if (viewAtPosition == null || layoutManager.isViewPartiallyVisible(
viewAtPosition,
false,
true
)
) {
binding.gallery.post {
layoutManager.scrollToPosition(galleryPosition)
}
}
}
})
// make sure that the app does not freeze waiting for a picture to load
Handler().postDelayed({
startPostponedEnterTransition()
}, 500)
}, 100)
}
binding.detailView.connectors.apply {
@@ -474,12 +588,12 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
}
binding.detailView.details.apply {
adapter = DetailAdapter().apply {
adapter = DetailsAdapter().apply {
onClickListener = {
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 -> {
@@ -528,15 +642,36 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
}.show()
}
override fun onMapReady(map: GoogleMap) {
override fun onMapReady(map: AnyMap) {
this.map = map
map.uiSettings.isTiltGesturesEnabled = false
map.isIndoorEnabled = false
map.uiSettings.isIndoorLevelPickerEnabled = false
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)
map.uiSettings.setIndoorLevelPickerEnabled(false)
map.setOnCameraIdleListener {
vm.mapPosition.value = MapPosition(
map.projection.visibleRegion.latLngBounds, map.cameraPosition.zoom
)
binding.scaleView.update(map.cameraPosition.zoom, map.cameraPosition.target.latitude)
}
map.setOnCameraMoveListener {
binding.scaleView.update(map.cameraPosition.zoom, map.cameraPosition.target.latitude)
}
map.setOnMarkerClickListener { marker ->
when (marker) {
@@ -546,7 +681,12 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
}
in clusterMarkers -> {
val newZoom = map.cameraPosition.zoom + 2
map.animateCamera(CameraUpdateFactory.newLatLngZoom(marker.position, newZoom))
map.animateCamera(
map.cameraUpdateFactory.newLatLngZoom(
marker.position,
newZoom
)
)
true
}
else -> false
@@ -564,9 +704,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
val mode = resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK
map.setMapStyle(
if (mode == Configuration.UI_MODE_NIGHT_YES) {
MapStyleOptions.loadRawResourceStyle(context, R.raw.maps_night_mode)
} else null
if (mode == Configuration.UI_MODE_NIGHT_YES) AnyMap.Style.DARK else AnyMap.Style.NORMAL
)
@@ -577,12 +715,12 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
if (position != null) {
val cameraUpdate =
CameraUpdateFactory.newLatLngZoom(position.bounds.center, position.zoom)
map.cameraUpdateFactory.newLatLngZoom(position.bounds.center, position.zoom)
map.moveCamera(cameraUpdate)
positionSet = true
} else if (lat != null && lon != null) {
// show given position
val cameraUpdate = CameraUpdateFactory.newLatLngZoom(LatLng(lat, lon), 16f)
val cameraUpdate = map.cameraUpdateFactory.newLatLngZoom(LatLng(lat, lon), 16f)
map.moveCamera(cameraUpdate)
// show charger detail after chargers were loaded
@@ -603,13 +741,18 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
positionSet = true
}
if (hasLocationPermission()) {
if (ContextCompat.checkSelfPermission(
requireContext(),
ACCESS_FINE_LOCATION
) == PackageManager.PERMISSION_GRANTED
) {
enableLocation(!positionSet, false)
positionSet = true
}
if (!positionSet) {
// center the camera on Europe
val cameraUpdate = CameraUpdateFactory.newLatLngZoom(LatLng(50.113388, 9.252536), 3.5f)
val cameraUpdate =
map.cameraUpdateFactory.newLatLngZoom(LatLng(50.113388, 9.252536), 3.5f)
map.moveCamera(cameraUpdate)
}
@@ -618,33 +761,30 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
)
}
@SuppressLint("MissingPermission")
@RequiresPermission(ACCESS_FINE_LOCATION)
private fun enableLocation(moveTo: Boolean, animate: Boolean) {
val map = this.map ?: return
map.isMyLocationEnabled = true
map.setMyLocationEnabled(true)
vm.myLocationEnabled.value = true
map.uiSettings.isMyLocationButtonEnabled = false
if (moveTo) {
fusedLocationClient.lastLocation.addOnSuccessListener { location ->
if (location != null) {
val latLng = LatLng(location.latitude, location.longitude)
val camUpdate = CameraUpdateFactory.newLatLngZoom(latLng, 13f)
if (animate) {
map.animateCamera(camUpdate)
} else {
map.moveCamera(camUpdate)
}
}
}
map.uiSettings.setMyLocationButtonEnabled(false)
if (moveTo && locationClient.isConnected) {
moveToCurrentLocation(map, animate)
}
}
private fun hasLocationPermission(): Boolean {
val context = context ?: return false
return ContextCompat.checkSelfPermission(
context,
Manifest.permission.ACCESS_FINE_LOCATION
) == PackageManager.PERMISSION_GRANTED
@RequiresPermission(ACCESS_FINE_LOCATION)
private fun moveToCurrentLocation(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)
} else {
map.moveCamera(camUpdate)
}
}
}
@Synchronized
@@ -663,7 +803,8 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
chargerIconGenerator.getBitmapDescriptor(
getMarkerTint(charger, vm.filteredConnectors.value),
highlight = charger == vm.chargerSparse.value,
fault = charger.faultReport != null
fault = charger.faultReport != null,
multi = getMarkerMulti(charger, vm.filteredConnectors.value)
)
)
}
@@ -680,7 +821,8 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
val tint = getMarkerTint(charger, vm.filteredConnectors.value)
val highlight = charger == vm.chargerSparse.value
val fault = charger.faultReport != null
animator.animateMarkerDisappear(marker, tint, highlight, fault)
val multi = getMarkerMulti(charger, vm.filteredConnectors.value)
animator.animateMarkerDisappear(marker, tint, highlight, fault, multi)
} else {
animator.deleteMarker(marker)
}
@@ -694,12 +836,23 @@ 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 marker = map.addMarker(
MarkerOptions()
.position(LatLng(charger.coordinates.lat, charger.coordinates.lng))
.visible(false)
.icon(
chargerIconGenerator.getBitmapDescriptor(
tint,
0f,
255,
highlight,
fault,
multi
)
)
.anchor(0.5f, 1f)
)
animator.animateMarkerAppear(marker, tint, highlight, fault)
animator.animateMarkerAppear(marker, tint, highlight, fault, multi)
markers[marker] = charger
}
}
@@ -709,11 +862,19 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
map.addMarker(
MarkerOptions()
.position(LatLng(cluster.coordinates.lat, cluster.coordinates.lng))
.icon(BitmapDescriptorFactory.fromBitmap(clusterIconGenerator.makeIcon(cluster.clusterCount.toString())))
.icon(
map.bitmapDescriptorFactory.fromBitmap(
clusterIconGenerator.makeIcon(
cluster.clusterCount.toString()
)
)
)
.anchor(0.5f, 0.5f)
)
}
}
@SuppressLint("MissingPermission")
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<out String>,
@@ -722,7 +883,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
when (requestCode) {
REQUEST_LOCATION_PERMISSION -> {
if ((grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED)) {
enableLocation(true, true)
enableLocation(moveTo = true, animate = true)
}
}
else -> super.onRequestPermissionsResult(requestCode, permissions, grantResults)
@@ -744,34 +905,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,
@@ -779,7 +1000,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
@@ -792,8 +1013,8 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
when (requestCode) {
REQUEST_AUTOCOMPLETE -> {
if (resultCode == Activity.RESULT_OK) {
vm.searchResult.value = Autocomplete.getPlaceFromIntent(data!!)
if (resultCode == Activity.RESULT_OK && data != null) {
vm.searchResult.value = handleAutocompleteResult(data)
}
}
else -> super.onActivityResult(requestCode, resultCode, data)
@@ -801,19 +1022,20 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
}
override fun getRootView(): View {
return root
return binding.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
private val reenterSharedElementCallback: SharedElementCallback =
object : SharedElementCallback() {
override fun onMapSharedElements(
names: MutableList<String>,
sharedElements: MutableMap<String, View>
) {
// Locate the ViewHolder for the clicked position.
val position = galleryVm.galleryPosition.value ?: return
val vh = binding.gallery.findViewHolderForAdapterPosition(position)
if (vh?.itemView == null) return
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
@@ -829,4 +1051,20 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
}
}
}
override fun onConnected() {
val map = this.map ?: return
if (vm.myLocationEnabled.value == true) {
if (ActivityCompat.checkSelfPermission(
requireContext(),
ACCESS_FINE_LOCATION
) == PackageManager.PERMISSION_GRANTED
) {
moveToCurrentLocation(map, false)
}
}
}
override fun onConnectionSuspended() {
}
}

View File

@@ -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
@@ -20,13 +20,15 @@ class MultiSelectDialog : AppCompatDialogFragment() {
fun getInstance(
title: String,
data: Map<String, String>,
selected: Set<String>
selected: Set<String>,
commonChoices: Set<String>?
): MultiSelectDialog {
val dialog = MultiSelectDialog()
dialog.arguments = Bundle().apply {
putString("title", title)
putSerializable("data", HashMap(data))
putSerializable("selected", HashSet(selected))
if (commonChoices != null) putSerializable("commonChoices", HashSet(commonChoices))
}
return dialog
}
@@ -35,13 +37,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() {
@@ -55,60 +59,65 @@ class MultiSelectDialog : AppCompatDialogFragment() {
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val data = requireArguments().getSerializable("data") as HashMap<String, String>
val selected = requireArguments().getSerializable("selected") as HashSet<String>
val title = requireArguments().getString("title")
val args = requireArguments()
val data = args.getSerializable("data") as HashMap<String, String>
val selected = args.getSerializable("selected") as HashSet<String>
val title = args.getString("title")
val commonChoices = if (args.containsKey("commonChoices")) {
args.getSerializable("commonChoices") as HashSet<String>
} else null
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)
items = data.entries.toList().sortedBy { it.value }.map {
MultiSelectItem(it.key, it.value, it.key in selected)
}
items = data.entries.toList()
.sortedBy { it.value.toLowerCase(Locale.getDefault()) }
.sortedByDescending { commonChoices?.contains(it.key) == true }
.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()))
}
}
private fun search(
items: List<MultiSelectItem>,
text: String
): List<MultiSelectItem> {
return items.filter { item ->
// search for string within name
text.toLowerCase(Locale.getDefault()) in item.name.toLowerCase(Locale.getDefault())
}
}
class Adapter() : DataBindingAdapter<MultiSelectItem>({ it.key }) {
override fun getItemViewType(position: Int) = R.layout.dialog_multi_select_item
}
}
private fun search(
items: List<MultiSelectItem>,
text: String
): List<MultiSelectItem> {
return items.filter { item ->
// search for string within name
text.toLowerCase(Locale.getDefault()) in item.name.toLowerCase(Locale.getDefault())
}
}
class Adapter() : DataBindingAdapter<MultiSelectItem>({ it.key }) {
override fun getItemViewType(position: Int) = R.layout.dialog_multi_select_item
}
data class MultiSelectItem(val key: String, val name: String, var selected: Boolean) : Equatable

View File

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

View File

@@ -0,0 +1,55 @@
package net.vonforst.evmap.navigation
import android.app.Activity
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
import androidx.navigation.NavDestination
import androidx.navigation.NavOptions
import androidx.navigation.Navigator
import net.vonforst.evmap.R
@Navigator.Name("chrome")
class ChromeCustomTabsNavigator(
private val context: Context
) : Navigator<ChromeCustomTabsNavigator.Destination>() {
override fun createDestination() =
Destination(this)
override fun navigate(
destination: Destination,
args: Bundle?,
navOptions: NavOptions?,
navigatorExtras: Extras?
): NavDestination? {
val intent = CustomTabsIntent.Builder()
.setDefaultColorSchemeParams(
CustomTabColorSchemeParams.Builder()
.setToolbarColor(ContextCompat.getColor(context, R.color.colorPrimary))
.build()
)
.build()
intent.launchUrl(context, destination.url!!)
return null // Do not add to the back stack, managed by Chrome Custom Tabs
}
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
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))
}
}
}
}

View File

@@ -0,0 +1,15 @@
package net.vonforst.evmap.navigation
import androidx.navigation.NavController
import androidx.navigation.fragment.NavHostFragment
class NavHostFragment : NavHostFragment() {
override fun onCreateNavController(navController: NavController) {
super.onCreateNavController(navController)
navController.navigatorProvider.addNavigator(
ChromeCustomTabsNavigator(
requireContext()
)
)
}
}

View File

@@ -6,6 +6,7 @@ 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
@@ -35,13 +36,19 @@ class ChargeCardRepository(
private suspend fun updateChargeCards() {
if (Duration.between(prefs.lastChargeCardUpdate, Instant.now()) < Duration.ofDays(1)) return
val response = api.getChargeCards()
if (!response.isSuccessful) return
try {
val response = api.getChargeCards()
if (!response.isSuccessful) return
for (card in response.body()!!.result) {
dao.insert(card)
for (card in response.body()!!.result) {
dao.insert(card)
}
prefs.lastChargeCardUpdate = Instant.now()
} catch (e: IOException) {
// ignore, and retry next time
e.printStackTrace()
return
}
prefs.lastChargeCardUpdate = Instant.now()
}
}

View File

@@ -10,6 +10,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase
import net.vonforst.evmap.api.goingelectric.ChargeCard
import net.vonforst.evmap.api.goingelectric.ChargeLocation
import net.vonforst.evmap.viewmodel.BooleanFilterValue
import net.vonforst.evmap.viewmodel.FILTERS_CUSTOM
import net.vonforst.evmap.viewmodel.MultipleChoiceFilterValue
import net.vonforst.evmap.viewmodel.SliderFilterValue
@@ -19,15 +20,17 @@ import net.vonforst.evmap.viewmodel.SliderFilterValue
BooleanFilterValue::class,
MultipleChoiceFilterValue::class,
SliderFilterValue::class,
FilterProfile::class,
Plug::class,
Network::class,
ChargeCard::class
], version = 8
], version = 10
)
@TypeConverters(Converters::class)
abstract class AppDatabase : RoomDatabase() {
abstract fun chargeLocationsDao(): ChargeLocationsDao
abstract fun filterValueDao(): FilterValueDao
abstract fun filterProfileDao(): FilterProfileDao
abstract fun plugDao(): PlugDao
abstract fun networkDao(): NetworkDao
abstract fun chargeCardDao(): ChargeCardDao
@@ -38,8 +41,14 @@ abstract class AppDatabase : RoomDatabase() {
Room.databaseBuilder(context, AppDatabase::class.java, "evmap.db")
.addMigrations(
MIGRATION_2, MIGRATION_3, MIGRATION_4, MIGRATION_5, MIGRATION_6,
MIGRATION_7, MIGRATION_8
MIGRATION_7, MIGRATION_8, MIGRATION_9, MIGRATION_10
)
.addCallback(object : Callback() {
override fun onCreate(db: SupportSQLiteDatabase) {
// create default filter profile
db.execSQL("INSERT INTO `FilterProfile` (`name`, `id`, `order`) VALUES ('FILTERS_CUSTOM', $FILTERS_CUSTOM, 0)")
}
})
.build()
}
@@ -120,5 +129,46 @@ 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")
}
}
}
}

View File

@@ -0,0 +1,36 @@
package net.vonforst.evmap.storage
import androidx.lifecycle.LiveData
import androidx.room.*
import net.vonforst.evmap.adapter.Equatable
import net.vonforst.evmap.viewmodel.FILTERS_CUSTOM
@Entity(
indices = [Index(value = ["name"], unique = true)]
)
data class FilterProfile(
val name: String,
@PrimaryKey(autoGenerate = true) val id: Long = 0,
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 id != $FILTERS_CUSTOM ORDER BY `order` ASC, `name` ASC")
fun getProfiles(): LiveData<List<FilterProfile>>
@Query("SELECT * FROM filterProfile WHERE name = :name")
suspend fun getProfileByName(name: String): FilterProfile?
@Query("SELECT * FROM filterProfile WHERE id = :id")
suspend fun getProfileById(id: Long): FilterProfile?
}

View File

@@ -2,22 +2,20 @@ 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.viewmodel.*
@Dao
abstract class FilterValueDao {
@Query("SELECT * FROM booleanfiltervalue")
protected abstract fun getBooleanFilterValues(): LiveData<List<BooleanFilterValue>>
@Query("SELECT * FROM booleanfiltervalue WHERE profile = :profile")
protected abstract fun getBooleanFilterValues(profile: Long): LiveData<List<BooleanFilterValue>>
@Query("SELECT * FROM multiplechoicefiltervalue")
protected abstract fun getMultipleChoiceFilterValues(): LiveData<List<MultipleChoiceFilterValue>>
@Query("SELECT * FROM multiplechoicefiltervalue WHERE profile = :profile")
protected abstract fun getMultipleChoiceFilterValues(profile: Long): LiveData<List<MultipleChoiceFilterValue>>
@Query("SELECT * FROM sliderfiltervalue")
protected abstract fun getSliderFilterValues(): LiveData<List<SliderFilterValue>>
@Query("SELECT * FROM sliderfiltervalue WHERE profile = :profile")
protected abstract fun getSliderFilterValues(profile: Long): LiveData<List<SliderFilterValue>>
@Insert(onConflict = OnConflictStrategy.REPLACE)
protected abstract suspend fun insert(vararg values: BooleanFilterValue)
@@ -28,16 +26,29 @@ 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")
protected abstract suspend fun deleteBooleanFilterValuesForProfile(profile: Long)
@Query("DELETE FROM multiplechoicefiltervalue WHERE profile = :profile")
protected abstract suspend fun deleteMultipleChoiceFilterValuesForProfile(profile: Long)
@Query("DELETE FROM sliderfiltervalue WHERE profile = :profile")
protected abstract suspend fun deleteSliderFilterValuesForProfile(profile: Long)
open fun getFilterValues(filterStatus: Long): LiveData<List<FilterValue>> =
if (filterStatus == FILTERS_DISABLED) {
MutableLiveData(emptyList())
} else {
MediatorLiveData<List<FilterValue>>().apply {
val sources = listOf(
getBooleanFilterValues(filterStatus),
getMultipleChoiceFilterValues(filterStatus),
getSliderFilterValues(filterStatus)
)
for (source in sources) {
addSource(source) {
value = sources.mapNotNull { it.value }.flatten()
}
}
}
}
@@ -52,4 +63,12 @@ abstract class FilterValueDao {
}
}
}
@Transaction
open suspend fun deleteFilterValuesForProfile(profile: Long) {
deleteBooleanFilterValuesForProfile(profile)
deleteMultipleChoiceFilterValuesForProfile(profile)
deleteSliderFilterValuesForProfile(profile)
}
}

View File

@@ -5,6 +5,7 @@ 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
@@ -37,13 +38,19 @@ class NetworkRepository(
private suspend fun updateNetworks() {
if (Duration.between(prefs.lastNetworkUpdate, Instant.now()) < Duration.ofDays(1)) return
val response = api.getNetworks()
if (!response.isSuccessful) return
try {
val response = api.getNetworks()
if (!response.isSuccessful) return
for (name in response.body()!!.result) {
dao.insert(Network(name))
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
}
prefs.lastNetworkUpdate = Instant.now()
}
}

View File

@@ -5,6 +5,7 @@ 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
@@ -37,13 +38,19 @@ class PlugRepository(
private suspend fun updatePlugs() {
if (Duration.between(prefs.lastPlugUpdate, Instant.now()) < Duration.ofDays(1)) return
val response = api.getPlugs()
if (!response.isSuccessful) return
try {
val response = api.getPlugs()
if (!response.isSuccessful) return
for (name in response.body()!!.result) {
dao.insert(Plug(name))
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
}
prefs.lastPlugUpdate = Instant.now()
}
}

View File

@@ -2,9 +2,12 @@ package net.vonforst.evmap.storage
import android.content.Context
import androidx.preference.PreferenceManager
import net.vonforst.evmap.R
import net.vonforst.evmap.viewmodel.FILTERS_CUSTOM
import net.vonforst.evmap.viewmodel.FILTERS_DISABLED
import java.time.Instant
class PreferenceDataSource(context: Context) {
class PreferenceDataSource(val context: Context) {
val sp = PreferenceManager.getDefaultSharedPreferences(context)
var navigateUseMaps: Boolean
@@ -31,15 +34,48 @@ class PreferenceDataSource(context: Context) {
sp.edit().putLong("last_chargecard_update", value.toEpochMilli()).apply()
}
var filtersActive: Boolean
get() = sp.getBoolean("filters_active", true)
/**
* 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().putBoolean("filters_active", value).apply()
sp.edit().putLong("filter_status", value).apply()
}
/**
* 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().putLong("last_filter_profile", value).apply()
}
val language: String
get() = sp.getString("language", "default")!!
val darkmode: String
get() = sp.getString("darkmode", "default")!!
val mapProvider: String
get() = sp.getString(
"map_provider",
context.getString(R.string.pref_map_provider_default)
)!!
var welcomeDialogShown: Boolean
get() = sp.getBoolean("welcome_dialog_shown", false)
set(value) {
sp.edit().putBoolean("welcome_dialog_shown", value).apply()
}
}

View File

@@ -2,6 +2,7 @@ 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
@@ -25,6 +26,25 @@ 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
@@ -131,6 +151,26 @@ 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
if (newValue == 0) {
val text = textView.text as SpannableString
text.getSpans(0, text.length, Any::class.java).forEach {
text.removeSpan(it)
}
}
}
private fun availabilityColor(
status: List<ChargepointStatus>?,
context: Context
@@ -160,4 +200,8 @@ 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()
}

View File

@@ -1,6 +1,6 @@
package net.vonforst.evmap.ui;
import com.google.android.libraries.maps.model.LatLng
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

View 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
}

View File

@@ -11,11 +11,12 @@ import androidx.annotation.ColorRes
import androidx.core.content.ContextCompat
import androidx.core.graphics.drawable.DrawableCompat
import androidx.core.widget.TextViewCompat
import com.google.android.libraries.maps.model.BitmapDescriptor
import com.google.android.libraries.maps.model.BitmapDescriptorFactory
import com.car2go.maps.BitmapDescriptorFactory
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 {
@@ -32,7 +33,7 @@ class ClusterIconGenerator(context: Context) : IconGenerator(context) {
ViewGroup.LayoutParams.WRAP_CONTENT,
ViewGroup.LayoutParams.WRAP_CONTENT
)
id = com.google.maps.android.R.id.amu_text
id = R.id.amu_text
setPadding(twelveDpi, twelveDpi, twelveDpi, twelveDpi)
TextViewCompat.setTextAppearance(this, R.style.TextAppearance_AppCompat)
setTextColor(ContextCompat.getColor(context, android.R.color.white))
@@ -41,27 +42,30 @@ class ClusterIconGenerator(context: Context) : IconGenerator(context) {
}
class ChargerIconGenerator(val context: Context) {
data class BitmapData(
class ChargerIconGenerator(
val context: Context, val factory: BitmapDescriptorFactory,
val scaleResolution: Int = 20
) {
private data class BitmapData(
val tint: Int,
val scale: Int,
val alpha: Int,
val highlight: Boolean,
val fault: Boolean
val fault: Boolean,
val multi: Boolean
)
val cacheSize = 420; // 420 items: 21 sizes, 5 colors, highlight on/off, fault 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 highlightIcon = R.drawable.ic_map_marker_highlight
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 oversize = 1.4f // increase to add padding for fault icon or scale > 1
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,
@@ -72,9 +76,14 @@ class ChargerIconGenerator(val context: Context) {
)
for (fault in listOf(false, true)) {
for (highlight in listOf(false, true)) {
for (tint in tints) {
for (scale in 0..20) {
getBitmapDescriptor(tint, scale, 255, highlight, fault)
for (multi in listOf(false, true)) {
for (tint in tints) {
for (scale in 0..scaleResolution) {
getBitmapDescriptor(
tint, scale.toFloat() / scaleResolution,
255, highlight, fault, multi
)
}
}
}
}
@@ -83,31 +92,39 @@ class ChargerIconGenerator(val context: Context) {
fun getBitmapDescriptor(
@ColorRes tint: Int,
scale: Int = 20,
scale: Float = 1f,
alpha: Int = 255,
highlight: Boolean = false,
fault: Boolean = false
fault: Boolean = false,
multi: Boolean = false
): BitmapDescriptor? {
val data = BitmapData(tint, scale, alpha, highlight, fault)
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 = BitmapDescriptorFactory.fromBitmap(bitmap)
val bmd = factory.fromBitmap(bitmap)
cache.put(data, bmd)
bmd
}
}
private fun generateBitmap(data: BitmapData): Bitmap {
val vd: Drawable = context.getDrawable(icon)!!
val icon = if (data.multi) multiIcon else icon
val vd: Drawable = ContextCompat.getDrawable(context, icon)!!
DrawableCompat.setTint(vd, ContextCompat.getColor(context, data.tint));
DrawableCompat.setTintMode(vd, PorterDuff.Mode.MULTIPLY);
val leftPadding = vd.intrinsicWidth * (oversize - 1) / 2
val topPadding = vd.intrinsicWidth * (oversize - 1)
val topPadding = vd.intrinsicHeight * (oversize - 1)
vd.setBounds(
leftPadding.toInt(), topPadding.toInt(),
leftPadding.toInt() + vd.intrinsicWidth,
@@ -121,7 +138,7 @@ class ChargerIconGenerator(val context: Context) {
)
val canvas = Canvas(bm)
val scale = data.scale / 20f
val scale = data.scale.toFloat() / scaleResolution
canvas.scale(
scale,
scale,
@@ -132,7 +149,8 @@ class ChargerIconGenerator(val context: Context) {
vd.draw(canvas)
if (data.highlight) {
val highlightDrawable = context.getDrawable(highlightIcon)!!
val hIcon = if (data.multi) highlightIconMulti else highlightIcon
val highlightDrawable = ContextCompat.getDrawable(context, hIcon)!!
highlightDrawable.setBounds(
leftPadding.toInt(), topPadding.toInt(),
leftPadding.toInt() + vd.intrinsicWidth,
@@ -143,7 +161,7 @@ class ChargerIconGenerator(val context: Context) {
}
if (data.fault) {
val faultDrawable = context.getDrawable(faultIcon)!!
val faultDrawable = ContextCompat.getDrawable(context, faultIcon)!!
val faultSize = 0.75
val faultShift = 0.25
val base = vd.intrinsicWidth

View File

@@ -5,7 +5,7 @@ import android.view.animation.BounceInterpolator
import androidx.core.animation.addListener
import androidx.interpolator.view.animation.FastOutLinearInInterpolator
import androidx.interpolator.view.animation.LinearOutSlowInInterpolator
import com.google.android.libraries.maps.model.Marker
import com.car2go.maps.model.Marker
import net.vonforst.evmap.R
import net.vonforst.evmap.api.goingelectric.ChargeLocation
import kotlin.math.max
@@ -22,41 +22,42 @@ fun getMarkerTint(
}
class MarkerAnimator(val gen: ChargerIconGenerator) {
private val animatingMarkers = hashMapOf<String, ValueAnimator>()
private val animatingMarkers = hashMapOf<Marker, ValueAnimator>()
fun animateMarkerAppear(
marker: Marker,
tint: Int,
highlight: Boolean,
fault: Boolean
fault: Boolean,
multi: Boolean
) {
animatingMarkers[marker.id]?.let {
animatingMarkers[marker]?.let {
it.cancel()
animatingMarkers.remove(marker.id)
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,
scale = scale,
highlight = highlight,
fault = fault
fault = fault,
multi = multi
)
)
marker.isVisible = true
}
addListener(onEnd = {
animatingMarkers.remove(marker.id)
animatingMarkers.remove(marker)
}, onCancel = {
animatingMarkers.remove(marker.id)
animatingMarkers.remove(marker)
})
}
animatingMarkers[marker.id] = anim
animatingMarkers[marker] = anim
anim.start()
}
@@ -64,51 +65,53 @@ class MarkerAnimator(val gen: ChargerIconGenerator) {
marker: Marker,
tint: Int,
highlight: Boolean,
fault: Boolean
fault: Boolean,
multi: Boolean
) {
animatingMarkers[marker.id]?.let {
animatingMarkers[marker]?.let {
it.cancel()
animatingMarkers.remove(marker.id)
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,
scale = scale,
highlight = highlight,
fault = fault
fault = fault,
multi = multi
)
)
}
addListener(onEnd = {
marker.remove()
animatingMarkers.remove(marker.id)
animatingMarkers.remove(marker)
}, onCancel = {
marker.remove()
animatingMarkers.remove(marker.id)
animatingMarkers.remove(marker)
})
}
animatingMarkers[marker.id] = anim
animatingMarkers[marker] = anim
anim.start()
}
fun deleteMarker(marker: Marker) {
animatingMarkers[marker.id]?.let {
animatingMarkers[marker]?.let {
it.cancel()
animatingMarkers.remove(marker.id)
animatingMarkers.remove(marker)
}
marker.remove()
}
fun animateMarkerBounce(marker: Marker) {
animatingMarkers[marker.id]?.let {
animatingMarkers[marker]?.let {
it.cancel()
animatingMarkers.remove(marker.id)
animatingMarkers.remove(marker)
}
val anim = ValueAnimator.ofFloat(0f, 1f).apply {
@@ -119,12 +122,12 @@ class MarkerAnimator(val gen: ChargerIconGenerator) {
marker.setAnchor(0.5f, 1.0f + t)
}
addListener(onEnd = {
animatingMarkers.remove(marker.id)
animatingMarkers.remove(marker)
}, onCancel = {
animatingMarkers.remove(marker.id)
animatingMarkers.remove(marker)
})
}
animatingMarkers[marker.id] = anim
animatingMarkers[marker] = anim
anim.start()
}
}

View File

@@ -4,33 +4,38 @@ import android.content.Context
import android.content.ContextWrapper
import android.content.res.Configuration
import android.os.Build
import androidx.core.os.ConfigurationCompat
import java.util.*
class LocaleContextWrapper(base: Context?) : ContextWrapper(base) {
companion object {
fun wrap(context: Context, language: String): ContextWrapper {
val config: Configuration = context.resources.configuration
var sysLocale: Locale? = null
sysLocale = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
config.locales.get(0)
val sysConfig: Configuration = context.applicationContext.resources.configuration
val appConfig: Configuration = context.resources.configuration
if (language == "" || language == "default") {
// set default locale
Locale.setDefault(ConfigurationCompat.getLocales(sysConfig)[0])
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
appConfig.setLocales(sysConfig.locales)
} else {
@Suppress("DEPRECATION")
appConfig.locale = sysConfig.locale
}
} else {
@Suppress("DEPRECATION")
config.locale
}
var ctx = context
if (language != "" && language != "default" && sysLocale.language != language) {
// set selected locale
val locale = Locale(language)
Locale.setDefault(locale)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
config.setLocale(locale)
appConfig.setLocale(locale)
} else {
@Suppress("DEPRECATION")
config.locale = locale
appConfig.locale = locale
}
ctx = context.createConfigurationContext(config)
}
return LocaleContextWrapper(ctx)
return LocaleContextWrapper(context.createConfigurationContext(appConfig))
}
}
}

View File

@@ -2,7 +2,7 @@ package net.vonforst.evmap.viewmodel
import android.app.Application
import androidx.lifecycle.*
import com.google.android.libraries.maps.model.LatLng
import com.car2go.maps.model.LatLng
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.launch
@@ -71,7 +71,7 @@ class FavoritesViewModel(application: Application, geApiKey: String) :
) / 1000
}
})
}
}?.sortedBy { it.distance }
}
addSource(favorites, callback)
addSource(location, callback)

View File

@@ -0,0 +1,41 @@
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.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()
}
fun delete(itemId: Long) {
viewModelScope.launch {
val profile = db.filterProfileDao().getProfileById(itemId)
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 reorderProfiles(list: List<FilterProfile>) {
viewModelScope.launch {
db.filterProfileDao().update(*list.toTypedArray())
}
}
}

View File

@@ -2,12 +2,11 @@ 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.lifecycle.*
import androidx.room.Entity
import androidx.room.PrimaryKey
import androidx.room.ForeignKey
import androidx.room.ForeignKey.CASCADE
import kotlinx.coroutines.launch
import net.vonforst.evmap.R
import net.vonforst.evmap.adapter.Equatable
import net.vonforst.evmap.api.goingelectric.ChargeCard
@@ -22,7 +21,7 @@ val powerSteps = listOf(0, 2, 3, 7, 11, 22, 43, 50, 75, 100, 150, 200, 250, 300,
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
.minByOrNull { it.first }?.second ?: 0
internal fun getFilters(
application: Application,
@@ -40,7 +39,8 @@ internal fun getFilters(
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)
Chargepoint.CEE_ROT to application.getString(R.string.plug_cee_rot),
Chargepoint.TESLA_ROADSTER_HPC to application.getString(R.string.plug_roadster_hpc)
)
listOf(plugs, networks, chargeCards).forEach { source ->
addSource(source) { _ ->
@@ -62,6 +62,34 @@ private fun MediatorLiveData<List<Filter<FilterValue>>>.buildFilters(
}?.toMap() ?: return
val networkMap = networks.value?.map { it.name to it.name }?.toMap() ?: return
val chargecardMap = chargeCards.value?.map { it.id.toString() to it.name }?.toMap() ?: return
val categoryMap = mapOf(
"Autohaus" to application.getString(R.string.category_car_dealership),
"Autobahnraststätte" to application.getString(R.string.category_service_on_motorway),
"Autohof" to application.getString(R.string.category_service_off_motorway),
"Bahnhof" to application.getString(R.string.category_railway_station),
"Behörde" to application.getString(R.string.category_public_authorities),
"Campingplatz" to application.getString(R.string.category_camping),
"Einkaufszentrum" to application.getString(R.string.category_shopping_mall),
"Ferienwohnung" to application.getString(R.string.category_holiday_home),
"Flughafen" to application.getString(R.string.category_airport),
"Freizeitpark" to application.getString(R.string.category_amusement_park),
"Hotel" to application.getString(R.string.category_hotel),
"Kino" to application.getString(R.string.category_cinema),
"Kirche" to application.getString(R.string.category_church),
"Krankenhaus" to application.getString(R.string.category_hospital),
"Museum" to application.getString(R.string.category_museum),
"Parkhaus" to application.getString(R.string.category_parking_multi),
"Parkplatz" to application.getString(R.string.category_parking),
"Privater Ladepunkt" to application.getString(R.string.category_private_charger),
"Rastplatz" to application.getString(R.string.category_rest_area),
"Restaurant" to application.getString(R.string.category_restaurant),
"Schwimmbad" to application.getString(R.string.category_swimming_pool),
"Supermarkt" to application.getString(R.string.category_supermarket),
"Tankstelle" to application.getString(R.string.category_petrol_station),
"Tiefgarage" to application.getString(R.string.category_parking_underground),
"Tierpark" to application.getString(R.string.category_zoo),
"Wohnmobilstellplatz" to application.getString(R.string.category_caravan_site)
)
value = listOf(
BooleanFilter(application.getString(R.string.filter_free), "freecharging"),
BooleanFilter(application.getString(R.string.filter_free_parking), "freeparking"),
@@ -76,7 +104,8 @@ private fun MediatorLiveData<List<Filter<FilterValue>>>.buildFilters(
MultipleChoiceFilter(
application.getString(R.string.filter_connectors), "connectors",
plugMap,
commonChoices = setOf(Chargepoint.TYPE_2, Chargepoint.CCS, Chargepoint.CHADEMO)
commonChoices = setOf(Chargepoint.TYPE_2, Chargepoint.CCS, Chargepoint.CHADEMO),
manyChoices = true
),
SliderFilter(
application.getString(R.string.filter_min_connectors),
@@ -88,6 +117,11 @@ private fun MediatorLiveData<List<Filter<FilterValue>>>.buildFilters(
application.getString(R.string.filter_networks), "networks",
networkMap, manyChoices = true
),
MultipleChoiceFilter(
application.getString(R.string.categories), "categories",
categoryMap,
manyChoices = true
),
BooleanFilter(application.getString(R.string.filter_barrierfree), "barrierfree"),
MultipleChoiceFilter(
application.getString(R.string.filter_chargecards), "chargecards",
@@ -100,25 +134,17 @@ private fun MediatorLiveData<List<Filter<FilterValue>>>.buildFilters(
internal fun filtersWithValue(
filters: LiveData<List<Filter<FilterValue>>>,
filterValues: LiveData<List<FilterValue>>,
active: LiveData<Boolean>? = null
filterValues: LiveData<List<FilterValue>>
): MediatorLiveData<List<FilterWithValue<out FilterValue>>> =
MediatorLiveData<List<FilterWithValue<out FilterValue>>>().apply {
listOf(filters, filterValues, active).forEach {
if (it == null) return@forEach
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))
}
}
}
@@ -144,17 +170,59 @@ class FilterViewModel(application: Application, geApiKey: String) :
}
private val filterValues: LiveData<List<FilterValue>> by lazy {
db.filterValueDao().getFilterValues()
db.filterValueDao().getFilterValues(FILTERS_CUSTOM)
}
val filtersWithValue: LiveData<List<FilterWithValue<out FilterValue>>> 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)
}
}
}
}
}
suspend fun saveFilterValues() {
filtersWithValue.value?.forEach {
db.filterValueDao().insert(it.value)
val value = it.value
value.profile = FILTERS_CUSTOM
db.filterValueDao().insert(value)
}
// set selected profile
prefs.filterStatus = FILTERS_CUSTOM
}
suspend fun saveAsProfile(name: String) {
// get or create profile
var profileId = db.filterProfileDao().getProfileByName(name)?.id
if (profileId == null) {
profileId = db.filterProfileDao().insert(FilterProfile(name))
}
// save filter values
filtersWithValue.value?.forEach {
val value = it.value
value.profile = profileId
db.filterValueDao().insert(value)
}
// set selected profile
prefs.filterStatus = profileId
}
}
@@ -192,22 +260,39 @@ data class SliderFilter(
val unit: String? = ""
) : Filter<SliderFilterValue>() {
override val valueClass: KClass<SliderFilterValue> = SliderFilterValue::class
override fun defaultValue() = SliderFilterValue(key, 0)
override fun defaultValue() = SliderFilterValue(key, min)
}
sealed class FilterValue : BaseObservable(), Equatable {
abstract val key: String
var profile: Long = FILTERS_CUSTOM
}
@Entity
@Entity(
foreignKeys = [ForeignKey(
entity = FilterProfile::class,
parentColumns = arrayOf("id"),
childColumns = arrayOf("profile"),
onDelete = CASCADE
)],
primaryKeys = ["key", "profile"]
)
data class BooleanFilterValue(
@PrimaryKey override val key: String,
override val key: String,
var value: Boolean
) : FilterValue()
@Entity
@Entity(
foreignKeys = [ForeignKey(
entity = FilterProfile::class,
parentColumns = arrayOf("id"),
childColumns = arrayOf("profile"),
onDelete = CASCADE
)],
primaryKeys = ["key", "profile"]
)
data class MultipleChoiceFilterValue(
@PrimaryKey override val key: String,
override val key: String,
var values: MutableSet<String>,
var all: Boolean
) : FilterValue() {
@@ -221,12 +306,30 @@ data class MultipleChoiceFilterValue(
!other.all && values == other.values
}
}
override fun hashCode(): Int {
var result = key.hashCode()
result = 31 * result + all.hashCode()
result = 31 * result + if (all) 0 else values.hashCode()
return result
}
}
@Entity
@Entity(
foreignKeys = [ForeignKey(
entity = FilterProfile::class,
parentColumns = arrayOf("id"),
childColumns = arrayOf("profile"),
onDelete = CASCADE
)],
primaryKeys = ["key", "profile"]
)
data class SliderFilterValue(
@PrimaryKey override val key: String,
override val key: String,
var value: Int
) : FilterValue()
data class FilterWithValue<T : FilterValue>(val filter: Filter<T>, val value: T) : Equatable
data class FilterWithValue<T : FilterValue>(val filter: Filter<T>, val value: T) : Equatable
const val FILTERS_DISABLED = -2L
const val FILTERS_CUSTOM = -1L

View File

@@ -2,14 +2,15 @@ package net.vonforst.evmap.viewmodel
import android.app.Application
import androidx.lifecycle.*
import com.google.android.libraries.maps.GoogleMap
import com.google.android.libraries.maps.model.LatLngBounds
import com.google.android.libraries.places.api.model.Place
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.availability.ChargeLocationStatus
import net.vonforst.evmap.api.availability.getAvailability
import net.vonforst.evmap.api.distanceBetween
import net.vonforst.evmap.api.goingelectric.*
import net.vonforst.evmap.storage.*
import net.vonforst.evmap.ui.cluster
@@ -20,6 +21,8 @@ import java.io.IOException
data class MapPosition(val bounds: LatLngBounds, val zoom: Float)
data class PlaceWithBounds(val latLng: LatLng, val viewport: LatLngBounds?)
internal fun getClusterDistance(zoom: Float): Int? {
return when (zoom) {
in 0.0..7.0 -> 100
@@ -44,7 +47,16 @@ 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)
addSource(source!!) { result ->
value = result
}
}
}
}
private val plugs: LiveData<List<Plug>> by lazy {
PlugRepository(api, viewModelScope, db.plugDao(), prefs).getPlugs()
@@ -58,7 +70,11 @@ class MapViewModel(application: Application, geApiKey: String) : AndroidViewMode
private val filters = getFilters(application, plugs, networks, chargeCards)
private val filtersWithValue: LiveData<List<FilterWithValue<out FilterValue>>> by lazy {
filtersWithValue(filters, filterValues, filtersActive)
filtersWithValue(filters, filterValues)
}
val filterProfiles: LiveData<List<FilterProfile>> by lazy {
db.filterProfileDao().getProfiles()
}
val chargeCardMap: LiveData<Map<Long, ChargeCard>> by lazy {
@@ -126,6 +142,28 @@ 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 && myLocationEnabled.value == true) {
distanceBetween(
loc.latitude,
loc.longitude,
charger.coordinates.lat,
charger.coordinates.lng
) / 1000
} else null
}
addSource(chargerSparse, callback)
addSource(location, callback)
addSource(myLocationEnabled, callback)
}
}
val location: MutableLiveData<LatLng> by lazy {
MutableLiveData<LatLng>()
}
val availability: MediatorLiveData<Resource<ChargeLocationStatus>> by lazy {
MediatorLiveData<Resource<ChargeLocationStatus>>().apply {
addSource(chargerSparse) { charger ->
@@ -152,13 +190,13 @@ class MapViewModel(application: Application, geApiKey: String) : AndroidViewMode
db.chargeLocationsDao().getAllChargeLocations()
}
val searchResult: MutableLiveData<Place> by lazy {
MutableLiveData<Place>()
val searchResult: MutableLiveData<PlaceWithBounds> by lazy {
MutableLiveData<PlaceWithBounds>()
}
val mapType: MutableLiveData<Int> by lazy {
MutableLiveData<Int>().apply {
value = GoogleMap.MAP_TYPE_NORMAL
val mapType: MutableLiveData<AnyMap.Type> by lazy {
MutableLiveData<AnyMap.Type>().apply {
value = AnyMap.Type.NORMAL
}
}
@@ -168,16 +206,39 @@ class MapViewModel(application: Application, geApiKey: String) : AndroidViewMode
}
}
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 setMapType(type: Int) {
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)
filterValues.value?.forEach {
it.profile = FILTERS_CUSTOM
db.filterValueDao().insert(it)
}
}
fun setMapType(type: AnyMap.Type) {
mapType.value = type
}
@@ -255,6 +316,13 @@ class MapViewModel(application: Application, geApiKey: String) : AndroidViewMode
}
val networks = formatMultipleChoice(networksVal)
val categoriesVal = getMultipleChoiceValue(filters, "categories")
if (categoriesVal.values.isEmpty() && !categoriesVal.all) {
// no categories chosen
return Triple(Resource.success(emptyList()), filteredConnectors, filteredChargeCards)
}
val categories = formatMultipleChoice(categoriesVal)
// do not use clustering if filters need to be applied locally.
val useClustering = zoom < 13
val geClusteringAvailable = minConnectors <= 1
@@ -283,6 +351,7 @@ class MapViewModel(application: Application, geApiKey: String) : AndroidViewMode
plugs = connectors,
chargecards = chargeCards,
networks = networks,
categories = categories,
startkey = startkey
)
if (!response.isSuccessful || response.body()!!.status != "ok") {

View File

@@ -6,6 +6,7 @@ import androidx.lifecycle.*
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

View File

@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M19,13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z" />
</vector>

View File

@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M6,19c0,1.1 0.9,2 2,2h8c1.1,0 2,-0.9 2,-2V7H6v12zM19,4h-3.5l-1,-1h-5l-1,1H5v2h14V4z" />
</vector>

View File

@@ -0,0 +1,12 @@
<vector android:height="44.11976dp"
android:viewportHeight="368.4"
android:viewportWidth="233.8"
android:width="28dp"
xmlns:android="http://schemas.android.com/apk/res/android">
<path
android:fillColor="#00e676"
android:pathData="M109.8,0h13.6c33.9,1.9 67.1,18.5 87.7,45.8c13.5,17.2 21,38.6 22.7,60.3v8.1c-0.8,42.1 -27.7,76.6 -51,109.4c-26.2,37 -50.4,77.3 -57.1,122.9c-1.8,7.7 0.4,18.5 -8.9,22c-2.2,-1.7 -4.7,-3.1 -6.2,-5.4c-2.7,-25.5 -9.1,-50.7 -20,-73.9c-12.3,-27.1 -29.5,-51.6 -47,-75.6C33,199 23,184.2 14.7,168.3c-13,-23.8 -17.9,-51.9 -12.5,-78.6c4.4,-21.1 15.4,-40.6 30.6,-55.7C53.3,14 81.1,1.8 109.8,0zM107.2,74.1c-16.1,3.6 -29.6,17.8 -31,34.5c-1.7,15.5 7.4,31.3 21.3,38.1c15.1,8 34.9,5.5 47.5,-6c11.8,-10.4 16,-28.3 9.9,-42.9C147.6,79.7 126,69.3 107.2,74.1z" />
<path
android:fillColor="#007e41"
android:pathData="M107.2,74.1c18.9,-4.8 40.4,5.5 47.7,23.7c6.1,14.5 1.9,32.5 -9.9,42.9c-12.6,11.5 -32.4,14 -47.5,6c-13.9,-6.8 -23,-22.6 -21.3,-38.1C77.6,92 91.1,77.7 107.2,74.1z" />
</vector>

View File

@@ -0,0 +1,17 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="28dp"
android:height="44.11976dp"
android:viewportWidth="233.8"
android:viewportHeight="368.4">
<path
android:fillColor="#FFFFFF"
android:fillAlpha="0.8"
android:pathData="M143.2,109.4l-19.7,33.8l0,38.1l43.4,-74.4l-22.2,0z" />
<path
android:fillColor="#FFFFFF"
android:fillAlpha="0.8"
android:pathData="M122.2,101.9h16.7h5.7l22.3,-44.6c0,0 -10.2,0 -22.4,0l-1.1,2.2L122.2,101.9z" />
<path
android:fillColor="#FFFFFF"
android:pathData="M138.9,57.3c-9.7,0 -19.8,0 -26.4,0c-2.5,0 -5.1,0 -7.6,0c-8.2,0 -16.1,0 -21.4,0c-4.1,0 -6.6,0 -6.6,0v68.2h18.6v55.8l43.4,-74.4h-24.8L138.9,57.3z" />
</vector>

View File

@@ -0,0 +1,18 @@
<vector android:height="44.11976dp"
android:viewportHeight="368.4"
android:viewportWidth="233.8"
android:width="28dp"
xmlns:android="http://schemas.android.com/apk/res/android">
<path
android:fillColor="#FFFFFF"
android:pathData="M109.8,0h13.6c33.9,1.9 67.1,18.5 87.7,45.8c13.5,17.2 21,38.6 22.7,60.3v8.1c-0.8,42.1 -27.7,76.6 -51,109.4c-26.2,37 -50.4,77.3 -57.1,122.9c-1.8,7.7 0.4,18.5 -8.9,22c-2.2,-1.7 -4.7,-3.1 -6.2,-5.4c-2.7,-25.5 -9.1,-50.7 -20,-73.9c-12.3,-27.1 -29.5,-51.6 -47,-75.6C33,199 23,184.2 14.7,168.3c-13,-23.8 -17.9,-51.9 -12.5,-78.6C6.6,68.6 17.6,49.1 32.8,34C53.3,14 81.1,1.8 109.8,0z" />
<path
android:fillColor="#B5B5B5"
android:pathData="M143.2,109.4l-19.7,33.8l0,38.1l43.4,-74.4l-22.2,0z" />
<path
android:fillColor="#B5B5B5"
android:pathData="M122.2,101.9h16.7h5.7l22.3,-44.6c0,0 -10.2,0 -22.4,0l-1.1,2.2L122.2,101.9z" />
<path
android:fillColor="#808080"
android:pathData="M138.9,57.3c-9.7,0 -19.8,0 -26.4,0c-2.5,0 -5.1,0 -7.6,0c-8.2,0 -16.1,0 -21.4,0c-4.1,0 -6.6,0 -6.6,0v68.2h18.6v55.8l43.4,-74.4h-24.8L138.9,57.3z" />
</vector>

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M20.055,7.713c-0.677,-0.842 -1.673,-1.41 -2.764,-1.615C16.773,3.971 14.877,3 13.045,3H7.057C6.425,3 5.878,3.409 5.689,4.009c-0.015,0.04 -0.026,0.082 -0.036,0.125L3.034,16.262c-0.009,0.041 -0.015,0.083 -0.019,0.125C3.006,16.449 3,16.513 3,16.56C3,17.354 3.648,18 4.444,18h2.316l-0.267,1.262c-0.008,0.04 -0.014,0.081 -0.018,0.121c-0.009,0.063 -0.016,0.126 -0.016,0.173C6.461,20.353 7.109,21 7.905,21h3.259c0.056,0 0.111,-0.005 0.166,-0.015c0.549,-0.063 1.011,-0.437 1.191,-0.963c0.021,-0.05 0.038,-0.103 0.05,-0.156L13.475,16h1.398c3.365,0 5.38,-1.445 5.989,-4.295C21.278,9.752 20.653,8.456 20.055,7.713zM5.137,16L7.512,5h5.533c0.293,0 1.5,0.061 2.078,1.013h-4.706c-0.626,0 -1.17,0.401 -1.363,0.99c-0.019,0.049 -0.033,0.1 -0.043,0.151l-1.034,5.093L7.183,16H5.137zM18.906,11.287C18.5,13.188 17.293,14 14.873,14h-1.857c-0.823,0 -1.338,0.652 -1.405,1.198L10.721,19H8.594l1.271,-6h1.444c4.259,0 5.665,-2.394 6.094,-4.402c0.027,-0.128 0.045,-0.256 0.057,-0.382c0.378,0.151 0.749,0.393 1.038,0.751C18.971,9.557 19.108,10.337 18.906,11.287z"
android:fillColor="#000000" />
</vector>

View File

@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M3,15h18v-2L3,13v2zM3,19h18v-2L3,17v2zM3,11h18L21,9L3,9v2zM3,5v2h18L21,5L3,5z" />
</vector>

View File

@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M17,3L5,3c-1.11,0 -2,0.9 -2,2v14c0,1.1 0.89,2 2,2h14c1.1,0 2,-0.9 2,-2L21,7l-4,-4zM12,19c-1.66,0 -3,-1.34 -3,-3s1.34,-3 3,-3 3,1.34 3,3 -1.34,3 -3,3zM15,9L5,9L5,5h10v4z" />
</vector>

View File

@@ -7,7 +7,7 @@
<fragment
android:id="@+id/nav_host_fragment"
android:name="androidx.navigation.fragment.NavHostFragment"
android:name="net.vonforst.evmap.navigation.NavHostFragment"
android:layout_height="match_parent"
android:layout_width="match_parent"
android:fitsSystemWindows="true"

View File

@@ -0,0 +1,39 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ Copyright 2020 Google Inc.
~
~ Licensed under the Apache License, Version 2.0 (the "License");
~ you may not use this file except in compliance with the License.
~ You may obtain a copy of the License at
~
~ http://www.apache.org/licenses/LICENSE-2.0
~
~ Unless required by applicable law or agreed to in writing, software
~ distributed under the License is distributed on an "AS IS" BASIS,
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
~ See the License for the specific language governing permissions and
~ limitations under the License.
-->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical">
<com.google.maps.android.ui.RotationLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical">
<TextView
android:id="@id/amu_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:paddingBottom="5dp"
android:paddingLeft="10dp"
android:paddingRight="10dp"
android:paddingTop="5dp" />
</com.google.maps.android.ui.RotationLayout>
</LinearLayout>

View File

@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<ImageView
android:id="@+id/imageView2"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:srcCompat="@drawable/ic_launcher_foreground" />
<TextView
android:id="@+id/textView14"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="@string/app_name"
android:textAppearance="@style/TextAppearance.MaterialComponents.Headline4"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.5"
app:layout_constraintStart_toEndOf="@+id/imageView2"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -15,14 +15,22 @@
<import type="net.vonforst.evmap.adapter.DataBindingAdaptersKt" />
<import type="net.vonforst.evmap.adapter.DetailsAdapterKt" />
<import type="net.vonforst.evmap.viewmodel.Resource" />
<import type="net.vonforst.evmap.viewmodel.Status" />
<import type="net.vonforst.evmap.ui.BindingAdaptersKt" />
<variable
name="charger"
type="Resource&lt;ChargeLocation&gt;" />
<variable
name="distance"
type="Double" />
<variable
name="availability"
type="Resource&lt;ChargeLocationStatus&gt;" />
@@ -35,6 +43,10 @@
name="filteredChargeCards"
type="java.util.Set&lt;Long&gt;" />
<variable
name="expanded"
type="Boolean" />
</data>
<androidx.cardview.widget.CardView
@@ -53,14 +65,15 @@
android:paddingBottom="8dp">
<TextView
android:id="@+id/textView"
android:id="@+id/txtName"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
android:ellipsize="end"
android:maxLines="1"
android:text="@{charger.data.name}"
android:textAppearance="@style/TextAppearance.MaterialComponents.Body1"
app:layout_constraintEnd_toStartOf="@+id/guideline2"
app:layout_constraintEnd_toStartOf="@+id/txtAvailability"
app:layout_constraintStart_toStartOf="@+id/guideline"
app:layout_constraintTop_toTopOf="parent"
tools:text="Parkhaus" />
@@ -75,18 +88,55 @@
android:textAppearance="@style/TextAppearance.MaterialComponents.Caption"
app:layout_constraintEnd_toStartOf="@+id/guideline2"
app:layout_constraintStart_toStartOf="@+id/guideline"
app:layout_constraintTop_toBottomOf="@+id/textView"
app:layout_constraintTop_toBottomOf="@+id/txtName"
tools:text="Beispielstraße 10, 12345 Berlin" />
<TextView
android:id="@+id/textView3"
android:id="@+id/txtDistance"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
android:ellipsize="end"
android:gravity="end"
android:maxLines="1"
android:minWidth="50dp"
android:text="@{@string/distance_format(distance)}"
android:textAppearance="@style/TextAppearance.MaterialComponents.Caption"
app:layout_constraintBottom_toBottomOf="@+id/topPart"
app:layout_constraintEnd_toStartOf="@+id/guideline2"
tools:text="10 km" />
<TextView
android:id="@+id/txtAvailability"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="72dp"
android:background="@drawable/rounded_rect"
android:ellipsize="end"
android:gravity="end"
android:maxLines="1"
android:padding="2dp"
android:text="@{String.format(&quot;%s/%d&quot;, BindingAdaptersKt.availabilityText(BindingAdaptersKt.flatten(availability.data.status.values())), charger.data.totalChargepoints)}"
android:textAppearance="@style/TextAppearance.MaterialComponents.Caption"
android:textColor="@android:color/white"
app:backgroundTintAvailability="@{BindingAdaptersKt.flatten(availability.data.status.values())}"
app:goneUnlessAnimated="@{availability.data != null &amp;&amp; !expanded}"
app:goneUnless="@{availability.data != null}"
app:layout_constraintEnd_toStartOf="@+id/guideline2"
app:layout_constraintTop_toTopOf="@+id/txtName"
tools:backgroundTint="@color/available"
tools:text="2/2" />
<TextView
android:id="@+id/txtConnectors"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
android:ellipsize="end"
android:maxLines="1"
android:text="@{charger.data.formatChargepoints()}"
android:textAppearance="@style/TextAppearance.MaterialComponents.Caption"
app:layout_constraintEnd_toStartOf="@+id/guideline2"
app:layout_constraintEnd_toStartOf="@+id/txtDistance"
app:layout_constraintStart_toStartOf="@+id/guideline"
app:layout_constraintTop_toBottomOf="@+id/textView2"
tools:text="2x Typ 2 22 kW" />
@@ -114,7 +164,7 @@
android:textColor="?colorPrimary"
app:layout_constraintEnd_toStartOf="@+id/guideline2"
app:layout_constraintStart_toStartOf="@+id/guideline"
app:layout_constraintTop_toBottomOf="@+id/textView3" />
app:layout_constraintTop_toBottomOf="@+id/txtConnectors" />
<TextView
android:id="@+id/textView12"
@@ -137,6 +187,8 @@
android:layout_marginTop="2dp"
android:text="@{charger.data.amenities}"
android:textAppearance="@style/TextAppearance.MaterialComponents.Body2"
android:autoLink="web"
android:linksClickable="true"
app:goneUnless="@{charger.data.amenities != null}"
app:layout_constraintEnd_toStartOf="@+id/guideline2"
app:layout_constraintHorizontal_bias="0.0"
@@ -165,6 +217,8 @@
android:layout_marginTop="2dp"
android:text="@{charger.data.generalInformation}"
android:textAppearance="@style/TextAppearance.MaterialComponents.Body2"
android:autoLink="web"
android:linksClickable="true"
app:goneUnless="@{charger.data.generalInformation != null}"
app:layout_constraintEnd_toStartOf="@+id/guideline2"
app:layout_constraintHorizontal_bias="0.0"
@@ -192,7 +246,7 @@
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:nestedScrollingEnabled="false"
app:data="@{DataBindingAdaptersKt.buildDetails(charger.data, chargeCards, filteredChargeCards, context)}"
app:data="@{DetailsAdapterKt.buildDetails(charger.data, chargeCards, filteredChargeCards, context)}"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="1.0"
app:layout_constraintStart_toStartOf="parent"
@@ -237,10 +291,10 @@
android:layout_width="0dp"
android:layout_height="0dp"
android:text="TextView"
app:layout_constraintBottom_toBottomOf="@+id/textView3"
app:layout_constraintBottom_toBottomOf="@+id/txtConnectors"
app:layout_constraintEnd_toStartOf="@+id/guideline2"
app:layout_constraintStart_toStartOf="@+id/guideline"
app:layout_constraintTop_toTopOf="@+id/textView" />
app:layout_constraintTop_toTopOf="@+id/txtName" />
<Button
android:id="@+id/btnChargeprice"

View File

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

View File

@@ -0,0 +1,68 @@
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:tools="http://schemas.android.com/tools"
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<data>
<import type="net.vonforst.evmap.viewmodel.FilterProfilesViewModel" />
<variable
name="vm"
type="FilterProfilesViewModel" />
</data>
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/linearLayout3"
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.google.android.material.appbar.AppBarLayout
android:id="@+id/toolbar_container"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:fitsSystemWindows="true"
app:layout_constraintBottom_toTopOf="@+id/filter_profiles_list"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<androidx.appcompat.widget.Toolbar
android:id="@+id/toolbar"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
</com.google.android.material.appbar.AppBarLayout>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/filter_profiles_list"
android:layout_width="0dp"
android:layout_height="0dp"
app:data="@{vm.filterProfiles}"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/toolbar_container"
tools:itemCount="3"
tools:listitem="@layout/item_filter_boolean" />
<TextView
android:id="@+id/textView19"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
android:breakStrategy="balanced"
android:gravity="center_horizontal"
android:text="@string/filterprofiles_empty_state"
android:textAppearance="@style/TextAppearance.MaterialComponents.Body1"
android:textColor="?android:textColorSecondary"
app:goneUnless="@{vm.filterProfiles.size() == 0}"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.5"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>

View File

@@ -9,6 +9,8 @@
<import type="net.vonforst.evmap.viewmodel.Status" />
<import type="com.mahc.custombottomsheetbehavior.BottomSheetBehaviorGoogleMapsLike" />
<variable
name="vm"
type="net.vonforst.evmap.viewmodel.MapViewModel" />
@@ -19,12 +21,24 @@
android:layout_width="match_parent"
android:layout_height="match_parent">
<fragment
<FrameLayout
android:id="@+id/map"
android:name="com.google.android.libraries.maps.SupportMapFragment"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MapsActivity" />
android:layout_height="match_parent" />
<FrameLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_anchor="@id/fab_locate"
app:layout_anchorGravity="start|center_vertical"
android:layout_gravity="start|center_vertical">
<com.github.pengrad.mapscaleview.MapScaleView
android:id="@+id/scaleView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="16dp" />
</FrameLayout>
<FrameLayout
android:id="@+id/toolbar_container"
@@ -76,7 +90,6 @@
android:layout_width="match_parent"
android:layout_height="@dimen/gallery_height_with_margin"
android:background="?android:colorBackground"
android:fitsSystemWindows="true"
app:layout_behavior="@string/BackDropBottomSheetBehavior">
<androidx.recyclerview.widget.RecyclerView
@@ -112,7 +125,6 @@
android:id="@+id/bottom_sheet"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fitsSystemWindows="true"
android:fillViewport="true"
android:orientation="vertical"
app:bottomsheetbehavior_anchorPoint="@dimen/gallery_height"
@@ -120,7 +132,7 @@
app:behavior_peekHeight="@dimen/peek_height"
app:bottomsheetbehavior_defaultState="stateHidden"
app:layout_behavior="@string/BottomSheetBehaviorGoogleMapsLike"
tools:bottomsheetbehavior_defaultState="stateHidden">
tools:bottomsheetbehavior_defaultState="stateCollapsed">
<include
android:id="@+id/detail_view"
@@ -128,7 +140,9 @@
app:charger="@{vm.charger}"
app:availability="@{vm.availability}"
app:chargeCards="@{vm.chargeCardMap}"
app:filteredChargeCards="@{vm.filteredChargeCards}" />
app:filteredChargeCards="@{vm.filteredChargeCards}"
app:distance="@{vm.chargerDistance}"
app:expanded="@{vm.bottomSheetState != BottomSheetBehaviorGoogleMapsLike.STATE_COLLAPSED &amp;&amp; vm.bottomSheetState != BottomSheetBehaviorGoogleMapsLike.STATE_HIDDEN}" />
</androidx.core.widget.NestedScrollView>
@@ -160,8 +174,8 @@
android:layout_gravity="top|end"
android:layout_marginEnd="12dp"
android:layout_marginTop="96dp"
android:tint="?colorControlNormal"
android:elevation="-1dp"
app:tint="?android:colorControlNormal"
app:backgroundTint="?android:colorBackground"
app:borderWidth="0dp"
app:fabSize="mini"

View File

@@ -8,7 +8,7 @@
<variable
name="item"
type="net.vonforst.evmap.adapter.DetailAdapter.Detail" />
type="net.vonforst.evmap.adapter.DetailsAdapter.Detail" />
</data>
<androidx.constraintlayout.widget.ConstraintLayout
@@ -18,7 +18,7 @@
app:selectableItemBackground="@{item.clickable}">
<TextView
android:id="@+id/textView9"
android:id="@+id/txtTitle"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="24dp"
@@ -41,14 +41,14 @@
android:layout_marginStart="16dp"
android:layout_marginTop="16dp"
android:contentDescription="@{item.contentDescription}"
android:tint="?colorPrimary"
app:tint="?colorPrimary"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:srcCompat="@{item.icon}"
tools:srcCompat="@drawable/ic_address" />
<TextView
android:id="@+id/textView8"
android:id="@+id/txtContent"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
@@ -56,13 +56,12 @@
android:layout_marginBottom="14dp"
android:text="@{item.detailText}"
android:textAppearance="@style/TextAppearance.MaterialComponents.Caption"
android:autoLink="@{item.links ? Linkify.WEB_URLS | Linkify.PHONE_NUMBERS : 0}"
android:linksClickable="@{item.links}"
app:linkify="@{item.links ? Linkify.WEB_URLS | Linkify.PHONE_NUMBERS : 0}"
app:goneUnless="@{item.detailText != null}"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="@+id/textView9"
app:layout_constraintTop_toBottomOf="@+id/textView9"
app:layout_constraintStart_toStartOf="@+id/txtTitle"
app:layout_constraintTop_toBottomOf="@+id/txtTitle"
tools:text="Lorem ipsum" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -13,7 +13,7 @@
<variable
name="item"
type="net.vonforst.evmap.adapter.DetailAdapter.Detail" />
type="net.vonforst.evmap.adapter.DetailsAdapter.Detail" />
</data>
<androidx.constraintlayout.widget.ConstraintLayout
@@ -24,7 +24,7 @@
app:selectableItemBackground="@{item.clickable}">
<TextView
android:id="@+id/textView9"
android:id="@+id/txtTitle"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="24dp"
@@ -47,28 +47,27 @@
android:layout_marginStart="16dp"
android:layout_marginTop="16dp"
android:contentDescription="@{item.contentDescription}"
android:tint="?colorPrimary"
app:tint="?colorPrimary"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:srcCompat="@{item.icon}"
tools:srcCompat="@drawable/ic_address" />
<TextView
android:id="@+id/textView8"
android:id="@+id/txtContent"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:layout_marginEnd="8dp"
android:layout_marginBottom="16dp"
android:autoLink="@{item.links ? Linkify.WEB_URLS | Linkify.PHONE_NUMBERS : 0}"
android:linksClickable="@{item.links}"
app:linkify="@{item.links ? Linkify.WEB_URLS | Linkify.PHONE_NUMBERS : 0}"
android:text="@{item.detailText}"
android:textAppearance="@style/TextAppearance.MaterialComponents.Caption"
app:goneUnless="@{item.detailText != null}"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/expandToggle"
app:layout_constraintStart_toStartOf="@+id/textView9"
app:layout_constraintTop_toBottomOf="@+id/textView9"
app:layout_constraintStart_toStartOf="@+id/txtTitle"
app:layout_constraintTop_toBottomOf="@+id/txtTitle"
app:layout_constraintVertical_bias="0.0"
tools:text="Lorem ipsum" />
@@ -83,8 +82,8 @@
app:goneUnless="@{expandToggle.checked}"
app:hours="@{item.hoursDays}"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="@+id/textView9"
app:layout_constraintTop_toBottomOf="@+id/textView8" />
app:layout_constraintStart_toStartOf="@+id/txtTitle"
app:layout_constraintTop_toBottomOf="@+id/txtContent" />
<include
android:id="@+id/hours_tue"
@@ -97,7 +96,7 @@
app:dayOfWeek="@{DayOfWeek.TUESDAY}"
app:hours="@{item.hoursDays}"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="@+id/textView9"
app:layout_constraintStart_toStartOf="@+id/txtTitle"
app:layout_constraintTop_toBottomOf="@id/hours_mon" />
<include
@@ -111,7 +110,7 @@
app:dayOfWeek="@{DayOfWeek.WEDNESDAY}"
app:hours="@{item.hoursDays}"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="@+id/textView9"
app:layout_constraintStart_toStartOf="@+id/txtTitle"
app:layout_constraintTop_toBottomOf="@id/hours_tue" />
<include
@@ -125,7 +124,7 @@
app:dayOfWeek="@{DayOfWeek.THURSDAY}"
app:hours="@{item.hoursDays}"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="@+id/textView9"
app:layout_constraintStart_toStartOf="@+id/txtTitle"
app:layout_constraintTop_toBottomOf="@id/hours_wed" />
<include
@@ -139,7 +138,7 @@
app:dayOfWeek="@{DayOfWeek.FRIDAY}"
app:hours="@{item.hoursDays}"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="@+id/textView9"
app:layout_constraintStart_toStartOf="@+id/txtTitle"
app:layout_constraintTop_toBottomOf="@id/hours_thu" />
<include
@@ -153,7 +152,7 @@
app:dayOfWeek="@{DayOfWeek.SATURDAY}"
app:hours="@{item.hoursDays}"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="@+id/textView9"
app:layout_constraintStart_toStartOf="@+id/txtTitle"
app:layout_constraintTop_toBottomOf="@id/hours_fri" />
<include
@@ -167,7 +166,7 @@
app:dayOfWeek="@{DayOfWeek.SUNDAY}"
app:hours="@{item.hoursDays}"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="@+id/textView9"
app:layout_constraintStart_toStartOf="@+id/txtTitle"
app:layout_constraintTop_toBottomOf="@id/hours_sat" />
<include
@@ -183,7 +182,7 @@
app:hours="@{item.hoursDays}"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="@+id/textView9"
app:layout_constraintStart_toStartOf="@+id/txtTitle"
app:layout_constraintTop_toBottomOf="@id/hours_sun" />
<ToggleButton

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