mirror of
https://github.com/ev-map/EVMap.git
synced 2025-12-25 08:07:46 -05:00
Compare commits
266 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
31fcee97e1 | ||
|
|
de8fd364f4 | ||
|
|
e3f271be5d | ||
|
|
99a2540398 | ||
|
|
85173b438b | ||
|
|
c288883572 | ||
|
|
c8f949da01 | ||
|
|
fe33dca1bc | ||
|
|
4fb5090e9b | ||
|
|
d9b8bf382a | ||
|
|
d69456dfd0 | ||
|
|
4da2a273c7 | ||
|
|
8e622c881d | ||
|
|
89b2175d89 | ||
|
|
3c30481821 | ||
|
|
385353689d | ||
|
|
7f9c838b9d | ||
|
|
205814e6f6 | ||
|
|
f30ae4a720 | ||
|
|
a6117d3484 | ||
|
|
f650def803 | ||
|
|
09ec0d1635 | ||
|
|
cfc98209a1 | ||
|
|
17dbab4659 | ||
|
|
f4ed7f7397 | ||
|
|
c26253fe44 | ||
|
|
1c7cc32427 | ||
|
|
4ff944c4e4 | ||
|
|
2ec437a14b | ||
|
|
ae05d44649 | ||
|
|
a0f90a8c94 | ||
|
|
e1b7463490 | ||
|
|
27cbb5e208 | ||
|
|
5526e5d97c | ||
|
|
fc92ee9cdc | ||
|
|
f20fab965c | ||
|
|
9146e7dab8 | ||
|
|
54936b6e1a | ||
|
|
f3245dc29b | ||
|
|
90968029ad | ||
|
|
97cab1d007 | ||
|
|
0d41fb2685 | ||
|
|
2c0a5085ab | ||
|
|
9e80270a78 | ||
|
|
1b374cda1c | ||
|
|
b82f6f68fb | ||
|
|
8e19399aaa | ||
|
|
e315da926e | ||
|
|
9450230856 | ||
|
|
81d62860e2 | ||
|
|
e825654b9c | ||
|
|
0a4878a129 | ||
|
|
af50a95abd | ||
|
|
fe58551de9 | ||
|
|
abc85c136b | ||
|
|
0ad4691d30 | ||
|
|
d85a64ec77 | ||
|
|
52fefb564a | ||
|
|
d18b2e26b8 | ||
|
|
d5f55366a9 | ||
|
|
2f93e92b57 | ||
|
|
24e5d072d6 | ||
|
|
a9e9055671 | ||
|
|
53ab8dc4e8 | ||
|
|
c0d7d59817 | ||
|
|
42cfdfee1d | ||
|
|
41cb6cf6b0 | ||
|
|
64f50cc5e6 | ||
|
|
c24c03bb32 | ||
|
|
bec25dd4d2 | ||
|
|
4e4c5a0e9a | ||
|
|
17bd7f024e | ||
|
|
fc5e77b01a | ||
|
|
6a114fc2ea | ||
|
|
6e32c6644c | ||
|
|
8fb34ae66f | ||
|
|
ae3489621e | ||
|
|
ff0a110f51 | ||
|
|
24ef4888a8 | ||
|
|
13916b0c8d | ||
|
|
efdd0d6bc5 | ||
|
|
155aca0041 | ||
|
|
6393eadc81 | ||
|
|
4319ece4f3 | ||
|
|
62f2002e5c | ||
|
|
4a82250a3d | ||
|
|
a8f23e9fb6 | ||
|
|
7da64fd566 | ||
|
|
09b5d536cb | ||
|
|
5e01200d96 | ||
|
|
c8d2e73218 | ||
|
|
d7b377ea56 | ||
|
|
edd35fba1b | ||
|
|
1f23080141 | ||
|
|
a3d9ecf49e | ||
|
|
6681d3cc17 | ||
|
|
a184b817bc | ||
|
|
b658e0183c | ||
|
|
6a0234ac2f | ||
|
|
d5ac35100b | ||
|
|
d3b4cb6a90 | ||
|
|
5d70d8c09a | ||
|
|
9642a58206 | ||
|
|
0e3280a119 | ||
|
|
c60043f925 | ||
|
|
b445be99bb | ||
|
|
02395dda7f | ||
|
|
c33c69db0b | ||
|
|
77fdfc7ccb | ||
|
|
bbb5c93132 | ||
|
|
2e8cdb01fd | ||
|
|
6b6c7da081 | ||
|
|
720d52285d | ||
|
|
e7efda2e90 | ||
|
|
ed80d7b968 | ||
|
|
8b1b971fad | ||
|
|
cf20ab8d82 | ||
|
|
581d0c07ec | ||
|
|
0b17821611 | ||
|
|
2493328715 | ||
|
|
f8abeed96c | ||
|
|
d9ca21c31e | ||
|
|
f6998382b1 | ||
|
|
5fc343d973 | ||
|
|
6b0a8bb506 | ||
|
|
93f379f4e2 | ||
|
|
00e555594a | ||
|
|
4ec5c8fb2e | ||
|
|
40b7ad8ef9 | ||
|
|
e1fed1ba26 | ||
|
|
d429ef88b3 | ||
|
|
9f0c5caf31 | ||
|
|
34b51a0742 | ||
|
|
a533fd315e | ||
|
|
d39d51d32c | ||
|
|
db11170967 | ||
|
|
4135740d07 | ||
|
|
b67bd12784 | ||
|
|
b0e000e936 | ||
|
|
1d8a7347c9 | ||
|
|
90f6cb65a8 | ||
|
|
5c57a5318b | ||
|
|
9456a6e8ef | ||
|
|
4846699f66 | ||
|
|
682f05b98b | ||
|
|
1f36ef6af8 | ||
|
|
032be00bcd | ||
|
|
3ac7b4aaee | ||
|
|
3386024acb | ||
|
|
ad2fb3063c | ||
|
|
caee3b1d67 | ||
|
|
60b151c690 | ||
|
|
e8873fa98c | ||
|
|
63740a8fe5 | ||
|
|
c80452a1fd | ||
|
|
7420101153 | ||
|
|
080d3d1080 | ||
|
|
d5ea8cfffa | ||
|
|
0676dcf31b | ||
|
|
0aef554395 | ||
|
|
35f5185893 | ||
|
|
f8378eb338 | ||
|
|
0bf56701cc | ||
|
|
aa5c36d1aa | ||
|
|
93787fae74 | ||
|
|
65b6c817fa | ||
|
|
f022823093 | ||
|
|
63bb161e09 | ||
|
|
d0de607222 | ||
|
|
abec208768 | ||
|
|
fa2b7bf180 | ||
|
|
258a04b14e | ||
|
|
1cedb2bccd | ||
|
|
20409343fd | ||
|
|
24720d7670 | ||
|
|
096ef902b7 | ||
|
|
e70ab68ff8 | ||
|
|
a69447bb95 | ||
|
|
326493f5c1 | ||
|
|
6adfda8c33 | ||
|
|
d02dd41127 | ||
|
|
41bafbcf46 | ||
|
|
c135e87be5 | ||
|
|
f6fd8866da | ||
|
|
3c485ff0c0 | ||
|
|
0ca8fb0eee | ||
|
|
dc9f47df8a | ||
|
|
4fab0fbf04 | ||
|
|
7bdd277c92 | ||
|
|
3c3d6de867 | ||
|
|
b9d79994f1 | ||
|
|
133a2be961 | ||
|
|
cd934ff448 | ||
|
|
518cf11dc8 | ||
|
|
2f3d4dd90e | ||
|
|
c8b2c34f47 | ||
|
|
57a16ec5f8 | ||
|
|
a4bbb15f64 | ||
|
|
a7c18fc325 | ||
|
|
13034df25e | ||
|
|
6e22f26e54 | ||
|
|
54332e8f77 | ||
|
|
c25190e340 | ||
|
|
fc38a9750a | ||
|
|
4765a18324 | ||
|
|
4f98dd1617 | ||
|
|
c35ca24906 | ||
|
|
8b8ba26a9f | ||
|
|
0b6647a539 | ||
|
|
b4184dc2de | ||
|
|
c105cbfff1 | ||
|
|
5d3633c33d | ||
|
|
c685086176 | ||
|
|
653a84037e | ||
|
|
e182f8ae82 | ||
|
|
8213aab375 | ||
|
|
9c0ffc9cc3 | ||
|
|
91be9ae0a6 | ||
|
|
15506ce007 | ||
|
|
83ef20d515 | ||
|
|
3c5201e5ca | ||
|
|
0db3d1939a | ||
|
|
09113591a7 | ||
|
|
c32580e0f5 | ||
|
|
b89a5a91ac | ||
|
|
6e178058d9 | ||
|
|
f10bda63e5 | ||
|
|
12b4bf359a | ||
|
|
95bff6d8d4 | ||
|
|
2434e5e75e | ||
|
|
ba55f1bc8a | ||
|
|
57747a9f01 | ||
|
|
f12ed008dd | ||
|
|
2f0543a639 | ||
|
|
d7a769a0c1 | ||
|
|
0f1d98bd61 | ||
|
|
9ce5685ca1 | ||
|
|
a364c27e2b | ||
|
|
870bcd25f5 | ||
|
|
84d8b6ca54 | ||
|
|
73968324b8 | ||
|
|
7d1093b8c0 | ||
|
|
dc7fe22614 | ||
|
|
f082165369 | ||
|
|
d5aa1da7ba | ||
|
|
ab30cd6eab | ||
|
|
322d6bea07 | ||
|
|
038da0856e | ||
|
|
9548748a64 | ||
|
|
392c2ecc9a | ||
|
|
04723a5c58 | ||
|
|
3352328930 | ||
|
|
8229b92d43 | ||
|
|
f73881a69a | ||
|
|
a53659f835 | ||
|
|
ea94d5bf03 | ||
|
|
85bf04504b | ||
|
|
f591829cb5 | ||
|
|
d69b5b3d3f | ||
|
|
d1f5714bf5 | ||
|
|
14229a9c90 | ||
|
|
e505fea043 | ||
|
|
ac3d0b0eb0 | ||
|
|
04aa8d1160 | ||
|
|
7b3735f8e8 | ||
|
|
9545d729f1 |
7
.github/workflows/apikeys-ci.xml
vendored
Normal file
7
.github/workflows/apikeys-ci.xml
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
<resources>
|
||||
<string name="google_maps_key" templateMergeStrategy="preserve" translatable="false">ci</string>
|
||||
<string name="mapbox_key" translatable="false">ci</string>
|
||||
<string name="goingelectric_key" translatable="false">ci</string>
|
||||
<string name="chargeprice_key" translatable="false">ci</string>
|
||||
<string name="openchargemap_key" translatable="false">ci</string>
|
||||
</resources>
|
||||
77
.github/workflows/release.yml
vendored
Normal file
77
.github/workflows/release.yml
vendored
Normal file
@@ -0,0 +1,77 @@
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- '*'
|
||||
|
||||
name: Release
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Build and upload release
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Set up Java environment
|
||||
uses: actions/setup-java@v2
|
||||
with:
|
||||
java-version: 11
|
||||
distribution: 'zulu'
|
||||
cache: 'gradle'
|
||||
- name: Decrypt keystore
|
||||
run: openssl aes-256-cbc -K ${{ secrets.encrypted_53968681344a_key }} -iv ${{ secrets.encrypted_53968681344a_iv }} -in _ci/keystore.jks.enc -out _ci/keystore.jks -d
|
||||
- name: Extract version code
|
||||
run: echo "VERSION_CODE=$(grep -o "^\s*versionCode\s\+[0-9]*" app/build.gradle | awk '{ print $2 }' | tr -d \''"\\')" >> $GITHUB_ENV
|
||||
|
||||
- name: Build app release
|
||||
env:
|
||||
GOINGELECTRIC_API_KEY: ${{ secrets.GOINGELECTRIC_API_KEY }}
|
||||
OPENCHARGEMAP_API_KEY: ${{ secrets.OPENCHARGEMAP_API_KEY }}
|
||||
CHARGEPRICE_API_KEY: ${{ secrets.CHARGEPRICE_API_KEY }}
|
||||
MAPBOX_API_KEY: ${{ secrets.MAPBOX_API_KEY }}
|
||||
GOOGLE_MAPS_API_KEY: ${{ secrets.GOOGLE_MAPS_API_KEY }}
|
||||
KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
|
||||
KEYSTORE_ALIAS: ${{ secrets.KEYSTORE_ALIAS }}
|
||||
KEYSTORE_ALIAS_PASSWORD: ${{ secrets.KEYSTORE_ALIAS_PASSWORD }}
|
||||
run: ./gradlew assembleRelease --no-daemon
|
||||
|
||||
- name: release
|
||||
uses: actions/create-release@v1
|
||||
id: create_release
|
||||
with:
|
||||
draft: false
|
||||
prerelease: false
|
||||
release_name: ${{ steps.version.outputs.version }}
|
||||
tag_name: ${{ github.ref }}
|
||||
body_path: fastlane/metadata/android/en-US/changelogs/${{ env.VERSION_CODE }}.txt
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
|
||||
- name: upload Google artifact
|
||||
uses: actions/upload-release-asset@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
with:
|
||||
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
||||
asset_path: app/build/outputs/apk/googleNormal/release/app-google-normal-release.apk
|
||||
asset_name: app-google-normal-release.apk
|
||||
asset_content_type: application/vnd.android.package-archive
|
||||
- name: upload Foss artifact
|
||||
uses: actions/upload-release-asset@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
with:
|
||||
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
||||
asset_path: app/build/outputs/apk/fossNormal/release/app-foss-normal-release.apk
|
||||
asset_name: app-foss-normal-release.apk
|
||||
asset_content_type: application/vnd.android.package-archive
|
||||
- name: upload Google Automotive artifact
|
||||
uses: actions/upload-release-asset@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
with:
|
||||
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
||||
asset_path: app/build/outputs/apk/googleAutomotive/release/app-google-automotive-release.apk
|
||||
asset_name: app-google-automotive-release.apk
|
||||
asset_content_type: application/vnd.android.package-archive
|
||||
36
.github/workflows/tests.yml
vendored
Normal file
36
.github/workflows/tests.yml
vendored
Normal file
@@ -0,0 +1,36 @@
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- '*'
|
||||
|
||||
name: Tests
|
||||
|
||||
jobs:
|
||||
|
||||
test:
|
||||
name: Build and Test (${{ matrix.buildvariant }})
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
buildvariant: [ FossNormal, GoogleNormal, GoogleAutomotive ]
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Set up Java environment
|
||||
uses: actions/setup-java@v2
|
||||
with:
|
||||
java-version: 11
|
||||
distribution: 'zulu'
|
||||
cache: 'gradle'
|
||||
|
||||
- name: Copy apikeys.xml
|
||||
run: cp .github/workflows/apikeys-ci.xml app/src/main/res/values/apikeys.xml
|
||||
|
||||
- name: Build app
|
||||
run: ./gradlew assemble${{ matrix.buildvariant }}Debug --no-daemon
|
||||
- name: Run unit tests
|
||||
run: ./gradlew test${{ matrix.buildvariant }}DebugUnitTest --no-daemon
|
||||
- name: Run Android Lint
|
||||
run: ./gradlew lint${{ matrix.buildvariant }}Debug --no-daemon
|
||||
46
.travis.yml
46
.travis.yml
@@ -1,46 +0,0 @@
|
||||
language: java
|
||||
dist: focal
|
||||
env:
|
||||
global:
|
||||
- secure: KYdFlMarsyXw+OHht1Atp+Kirbw9O09Ck14EjFuKb1eNtknurZ/tGEXuD+8xWh1W8W21kgHEG7s3rzru53t29buz+FW9f+ZmhEWXFP3OydyvXLw4BAVVOjm6xG2uHX/8MOGLJNM7cfaF25EPQ+kznHe84R29KaLH90mNRr2lPa4VnfbcnvDStiVaez/vJ72UoYSP5HICAzoF70yC3ZvvCK1hZv71UIysCbFE2IkxvMhG9OOGebdnRmFssaRCrvfRLjitobcLzkPWzZZIqdjNASf8/iAxX8VgGBYfVj8ID06AfMrtgXNJRCvcD0LICraQ+WPUbikMunRieGO8PNHSB5vKdPoC50aLUa0RoRb4G3QM1pR2A8xAFlIJFX2R7iY+2t24L9hRFqB98+QoQzutfkAI1T0rzem/wtpZpuan+bDawDJEHGCeYbE0aPDAl6lytgrEE9fRgV3c1jJLQzu0xIWG8YLl3iMg0hL+c0wCKXoeqrfCFS6kYmmG7W+rQp4tCZifvRbWfAXwIPQieffKxqdEuUwiUsYxdzCu9v9uU3nflEOLLuRgeMP3gV8mpur9b5GztpkfgfzcAqsF+NiY01kYgGtrgCYlMy0TxASE+UuALrtkQtU01wwhs9RH7Az0Ib3C+MT5DTjxHQCYETIViocmNEG2vfAbgHazCpGAhcY=
|
||||
- secure: Hoko50vP+Mwm/O4CWvPvjMxd1gGhi+Bultjyy1WpludSmZFCfKz45Mj1EqzeYk6MeMLvGOEkSLB5wjdXdgJ9j5gEOF5K34k4vESJA7+DqDO3I7Xw9cnWgOXdFqB0qGHar0TVP3Dfg7ZcRgtmeX6t2uoELFLiS9GvTnbZXk4PCUybd979Xi8XHjEQV7+3EZbSjtsI4GFeIK1rrjd0I+UM88zYrWnz1KhdCjWvQb4iZjo+ib6NmGGEMqR7jJPRZz3KB01Y+n5h21qq9/Nv31zQIqt2B5nRxy3vBvvqKapgprIk+hVOpNnBU8w89uWUU6tYUeFk0t7z1TYWjgaBrMmGCM+aKkQ2q5F/ygNzDwB+KkpJx709Yhf0ZX8g2yvkdz+Ok7moYuvmrOPOf4E/U3BlfZxbGtRD2bGYbDgHLFYlTn6v5J2kJHDJAz31yvF5jvJejDaPp2IBVfoMRy7ZJFmGUNHGd9Se6bRwxS++AoobP5WDrBiUXNe2KKDMs3e7vbaO+hLbZ9XHpjeWIJGUfvtTee8EHZqF/8A3ju53V4/R0ehlOv2UZbpYNqcmwrsy9/R4pgMfDkG3Q054LmYrxD8DIC9b8excVMwWRP5aQ1TqZnxO2B1/vJU87RcnGl3jekeHTdHXbRq9BMV4dAdPfB9X3nGIi2GgV0iTBk+24xccOc0=
|
||||
- secure: RsN/2nBVv0byMzwchuAhDti1AWKECg3Uzqk6Ahgaqg02zx8GHj60j0qNax7KuMUg70X3G4b3m8ZzndAU0wcJ98UyIku7ofUYgXHm0XYKTJwiyyhrKJ8pZ4qoeCoRkitIGIitlb84fSufVamcoLyLNbLUsO5OzL+Uscyhq/BwAhyOhj5FB9DM+GE9ntanQ4m3k4guMyMctR9CGS6Tk4LKJ1WCm0AnjTPalc+we+7yORY2J/9d6VHLKfaFXbpuWmrdnFfDZqxqcUODsxPVE0LuUtXyKQDTjjfQc8106Z/z19AJS+oLm/2UND84PD3MqsjX6EX0/9k3fY0OsoCuQAivDRmtevQ0bDQrTAyeBLcfnPCw/MYiJWyBcaYBAYK42EAfsFTDBRIduFB/Dpvg+8GuLZSdm4xVYpTlQ2pMtlGNWIGIRQsjk9LZ8swq8QBMiiF/bpMGKdfmnyQj8jkEOCWaAzkR9O+4E4Y7PuBENef9XuV0hLMryCrML2YXigxAKkEUOPhfnG+AbEY+g2kAMp+2EtXaG3tsGxoMhEYA+uRd3o2cacQMMwRpnePH5DYg6F05mrvdHPpt+P9UR1iHaHmjBPAYeksmrdP8bS088zcnePgiL6+6N0m3+l8Krihmxg5RyWjnH18IwX4RO+xg4x3cW+zaScCDbbotDMEYtChF+Hs=
|
||||
- secure: LQHMdhaPUlCuJPFrCPpUphJSY6xzAFI/7RrcAVLtLcPhGdS+MeNifIkkAH7MeitTHroOC0dGkZ4bg/8/7bKfgwY4vPH9P50kZcnX5mI6zfBHgNYJzuthj+vJH9RAtkdQOW9Fe1uPIx8R9GUWUOVnkoJh0PQ1gDXdZW5fePqUtn1kYrcCCBE+Bhe3wz6QzTBqGS1nsVRTxQfSJNGi9uH1oi9kQGgQFuCCiJ/P0A6MIhSItkOfuggx/iorA+iASbhWkB4nXYQBbFe/ZhFJWbVfgYlOM0HtpKh8B2AqKw21Em32JoovCbUof4adkY7cH8/4Rt9SujC9YOw+a6oM+e//jJT0sie77V7zl670j+qODTuNvV4qVUwtoxShyc1Sfbd+Xb0xn/OC7DzBg97YuYCF/84yyuq12rl/cofynWE1L5YvGNSJk241XUw98Bvl0MK4VIfQvG9zJP0HnQZcWKt6kFOIEJSCRbmkd2tPPAZFBXBQf/bvpULOoKwneGJZBSapRoCyGwemM+EAzVB9UOXAqsXZ4FHkt1SSJVrTVwgxvXpCfmF6LZPhbz6nvouRWGsC/GdWjrHtdW5lEOvS27qKEL5rXwQ0o+71ZICGo8j4E0GOHXyi857qZhvO7cbOnts+iiawXiWzPXv2gGGabuqPwcU8JPEoWdaiIaeGUczfjBU=
|
||||
- secure: fvPVjj3l+TZ7HF5aGn/pmrkipGIrz+MkKNy3I7pnCJSuD/oVp9nQ5ePP/dAhaRThaW+fQbq7hOmCquPAtfoN9CUnHNV2f2l9RavDQIxdqvpXqY13A0BFffZho6A6H2kO7k6kQQPQEhl4SMJjObnX12/YDaTVx3b7aIroEJ8DyY62xGTsjExtaAksuFwUEekjh0MoWICvyBoDfrYhpiEVI2721rGMHu7FIXwmE38+jj7wwZd3Bp37yI9NY/b3ZQ/HUKyYDuoAL0xl5/GaQlRepD0v2xWQUQ40NArHLfMoscXi55UaENuswCg7rt9os8jCcZ8FkZf1cVsQ71JrE0uxgs00Jfjy2QKM5u1XUZefl1Nw5cfCDTWXIEGsz9OGiidFLehWUupX/6C6wr1BStdlRt+6Pt/FXsYHxO/qog++cKqHjOJRXi+raGAb99HhQ/hLnLUMKl5DIWlKF9DImXiOpfYxrgCJc3y91vNX6noJyWYs6PvErMukTsXFHen+fM0NtfTFoKW682oILvXjoeFvuzKpk49+rcpkJbRi5+Zdo/duSPp/flwvC4LOMi0RZOO9TNMhWKdkyWweDr1HEpvQn6RS87rpHzQwRDvm85F+PkZLMMqyWpuxBWbJf0jVbew21KvTJWamuizsIgCebFh0SSxgObzmMbAIFCkzL0PRsms=
|
||||
- ANDROID_HOME=$HOME/android-sdk
|
||||
before_install:
|
||||
- openssl aes-256-cbc -K $encrypted_53968681344a_key -iv $encrypted_53968681344a_iv -in _ci/keystore.jks.enc -out _ci/keystore.jks -d
|
||||
install:
|
||||
# Download and unzip the Android command line tools (if not already there thanks to the cache mechanism)
|
||||
# Latest version of this file available here: https://developer.android.com/studio/#command-tools
|
||||
- if test ! -e $HOME/android-cmdline-tools/cmdline-tools.zip ; then curl https://dl.google.com/android/repository/commandlinetools-linux-6609375_latest.zip > $HOME/android-cmdline-tools/cmdline-tools.zip ; fi
|
||||
- unzip -qq -n $HOME/android-cmdline-tools/cmdline-tools.zip -d $HOME/android-cmdline-tools
|
||||
# Install or update Android SDK components (will not do anything if already up to date thanks to the cache mechanism)
|
||||
- echo y | $HOME/android-cmdline-tools/tools/bin/sdkmanager --sdk_root=$HOME/android-sdk 'platform-tools' > /dev/null
|
||||
# Latest version of build-tools available here: https://developer.android.com/studio/releases/build-tools.html
|
||||
- echo y | $HOME/android-cmdline-tools/tools/bin/sdkmanager --sdk_root=$HOME/android-sdk 'build-tools;29.0.3' > /dev/null
|
||||
- echo y | $HOME/android-cmdline-tools/tools/bin/sdkmanager --sdk_root=$HOME/android-sdk 'platforms;android-29' > /dev/null
|
||||
script:
|
||||
- "./gradlew lintFossDebug testFossDebugUnitTest lintGoogleDebug testGoogleDebugUnitTest"
|
||||
- "./gradlew assembleRelease"
|
||||
before_cache:
|
||||
- rm -f $HOME/.gradle/caches/modules-2/modules-2.lock
|
||||
- rm -fr $HOME/.gradle/caches/*/plugin-resolution/
|
||||
cache:
|
||||
directories:
|
||||
- "$HOME/.gradle/caches/"
|
||||
- "$HOME/.gradle/wrapper/"
|
||||
- "$HOME/.android/build-cache"
|
||||
- "$HOME/android-cmdline-tools"
|
||||
- "$HOME/android-sdk"
|
||||
deploy:
|
||||
provider: releases
|
||||
api_key:
|
||||
secure: "XQR4GUrGkPKYVV0xMbJifX/ewKAnenBPlM/pPacQ9irAmYNYa/yEkySz4x1K6MP8cEnuJbxHFakcDqhNRCqD7Cq2NcnCi3qtTEXHK6ApLoVl/92eyiWxu/bYlidOEZb+YPcVNtTR253NiI8GYda+CrhLd4uCmsAgES+XPFJd/t2esMlDOSAp7xalZv/zFhhlB9+SevfPFMc6kkrqeHpKnMs9SK8ltVQmh3nch2KjtDvqgDW6d3nuwn7/HAer6/HY86hmA4Rh6Mo2cV6OloX0bdJ7hvA1GOT4p3+K3lWbTRxzE0o1DXAtT7+D158iKvxHFPuF3h+CTjSlLeiss6kQZL9nFjw/KhAvu+GJOp37PcMoI++mpMiFoWPlzKpp17BVKIDinYbgi8kiU4zG+QHhe2cY85SbfAplXUaysq7uzxEZwEUYHSAHNahshVooXRqvuzkthcH0/nvinfeXrzx2xDvQ3if1NENMRgttwewU0kvU61iKUwpcf/UN2bHK3DaPes0VzSH4PTHAGjoRpksDfqUwb7S8YxbYr+44aMbSPYN8Lbjda0BxPSKWwHM5/pi7FBJN1a1w3t7sV/EiACWUWr8OovmX4ljyCybbR0w9cPzRC1zAYeSUHslLXMTW2Pp9h594RnYh3q3VfeYlFCikFvuvrafwXmTkz35uhLb+2ws="
|
||||
file:
|
||||
- app/build/outputs/apk/foss/release/app-foss-release.apk
|
||||
- app/build/outputs/apk/google/release/app-google-release.apk
|
||||
on:
|
||||
repo: johan12345/EVMap
|
||||
tags: true
|
||||
skip_cleanup: 'true'
|
||||
@@ -113,7 +113,7 @@ GEM
|
||||
http-cookie (1.0.3)
|
||||
domain_name (~> 0.5)
|
||||
httpclient (2.8.3)
|
||||
jmespath (1.4.0)
|
||||
jmespath (1.6.1)
|
||||
json (2.3.1)
|
||||
jwt (2.2.1)
|
||||
memoist (0.16.2)
|
||||
|
||||
2
LICENSE
2
LICENSE
@@ -1,6 +1,6 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2020-2021 Johan von Forstner
|
||||
Copyright (c) 2020-2022 Johan von Forstner
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
||||
25
README.md
25
README.md
@@ -1,4 +1,4 @@
|
||||
EVMap [](https://app.travis-ci.com/johan12345/EVMap)
|
||||
EVMap [](https://github.com/johan12345/EVMap/actions)
|
||||
=====
|
||||
|
||||
<img src="https://raw.githubusercontent.com/johan12345/EVMap/master/_img/feature_graphic.svg" width=700 alt="Logo"/>
|
||||
@@ -20,7 +20,7 @@ Features
|
||||
- Advanced filtering options, including saved filter profiles
|
||||
- Favorites list, also with availability information
|
||||
- Integrated price comparison using [Chargeprice.app](https://chargeprice.app) (only in Europe)
|
||||
- Android Auto integration
|
||||
- Android Auto & Android Automotive OS integration
|
||||
- No ads, fully open source
|
||||
- Compatible with Android 5.0 and above
|
||||
- Can use Google Maps or Mapbox (OpenStreetMap) as map backends - the version available on F-Droid only uses Mapbox.
|
||||
@@ -41,9 +41,24 @@ EVMap uses and put them into the app in the form of a resource file called `apik
|
||||
`app/src/main/res/values`. You can find more information on which API keys are necessary for which
|
||||
features and how they can be obtained in our [documentation page](doc/api_keys.md).
|
||||
|
||||
There are two different build flavors, `google` and `foss`, where only the `google` variant uses
|
||||
Google Maps data and provides the Android Auto integration. The `foss` variant only uses Mapbox data
|
||||
and should run on devices without Google Play Services.
|
||||
There are three different build flavors, `googleNormal`, `fossNormal` and `googleAutomotive`.
|
||||
- The `foss` variant only uses Mapbox data and should run on most Android devices, even without
|
||||
Google Play Services.
|
||||
- The `google` variants also include access to Google Maps data.
|
||||
- `googleNormal` is intended to run on smartphones and tablets, and also includes the Android
|
||||
Auto app for use on the car display.
|
||||
- `googleAutomotive` variant is intended to be installed directly on car infotainment systems
|
||||
using the Google-flavored Android Automotive OS. It does not provide the usual smartphone UI.
|
||||
|
||||
We also have a special [documentation page](doc/android_auto.md) on how to test the Android Auto
|
||||
app.
|
||||
|
||||
Translations
|
||||
------------
|
||||
|
||||
You can use our [Weblate page](https://hosted.weblate.org/projects/evmap/) to help translate EVMap
|
||||
into new languages.
|
||||
|
||||
<a href="https://hosted.weblate.org/engage/evmap/">
|
||||
<img src="https://hosted.weblate.org/widgets/evmap/-/open-graph.png" width="500" alt="Translation status" />
|
||||
</a>
|
||||
|
||||
Binary file not shown.
23
_img/appicon_notification.svg
Normal file
23
_img/appicon_notification.svg
Normal file
@@ -0,0 +1,23 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!-- Generator: Adobe Illustrator 24.3.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<svg version="1.1" id="Ebene_1" xmlns="http://www.w3.org/2000/svg" x="0px" y="0px"
|
||||
viewBox="0 0 120 120" style="enable-background:new 0 0 120 120;" xml:space="preserve">
|
||||
<style type="text/css">
|
||||
.st0{fill:#FFFFFF;}
|
||||
</style>
|
||||
<g>
|
||||
<g>
|
||||
<path class="st0"
|
||||
d="M27.1,88.3l-2.2-19.2l-3.3,0.3l2.2,19.2L27.1,88.3z M39,86.9l-2.2-19.2l-3.3,0.3l2.2,19.2L39,86.9z" />
|
||||
<path class="st0" d="M45.2,113c-1,1.3-1.8,2.1-2,2.2c-3,2.4-5.4,3.1-7.4,2.2c-3.5-1.7-3.2-8.2-3.1-8.9l2.4,0.1
|
||||
c-0.1,1.8,0.2,5.8,1.8,6.6c0.9,0.5,2.5-0.1,4.6-1.8l0,0c0,0,6.7-6.7,5.3-12c-1.6-6.4,5.8-15.5,8.2-18.6l0.3-0.3l2,1.5l-0.3,0.5
|
||||
c-7.5,9.2-8.3,14-7.7,16.4C50.5,105.4,47.4,110.4,45.2,113z" />
|
||||
<path class="st0" d="M19.7,88.1l0.9,7.9l7.3,4.9l9.8-1l6-6.4l-0.9-7.9L19.7,88.1z" />
|
||||
<g>
|
||||
<path class="st0"
|
||||
d="M37.6,99.7l-9.8,1l2.1,8.7l7.7-0.9V99.7L37.6,99.7z M44.6,79l0.8,7.2l-28.2,3.2l-0.8-7.2L44.6,79z" />
|
||||
</g>
|
||||
</g>
|
||||
<path class="st0" d="M66.7,0C46.5,0,30.1,16.4,30.1,36.6c0,27.6,30.8,42,34.5,81.4c0.1,1.2,1,2,2.2,2c1.2,0,2.1-0.8,2.2-2
|
||||
c3.7-39.4,34.5-53.8,34.5-81.4C103.3,16.2,86.9,0,66.7,0z M78.4,34.7L64.3,59V40.8h-6V18.7c0,0,20.2,0,20.1-0.1l-8.1,16.2H78.4z" />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -1,31 +1,25 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!-- Generator: Adobe Illustrator 23.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<?xml version="1.0" encoding="utf-8"?><!-- Generator: Adobe Illustrator 24.3.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<svg version="1.1" id="Ebene_1" xmlns="http://www.w3.org/2000/svg" x="0px" y="0px"
|
||||
viewBox="0 0 233.8 368.4" style="enable-background:new 0 0 233.8 368.4;" xml:space="preserve">
|
||||
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{display:none;}
|
||||
.st2{display:inline;fill:#802C27;}
|
||||
.st3{fill:#808080;}
|
||||
.st4{display:none;fill:#802C27;}
|
||||
.st1{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.6c4.4-21.1,15.4-40.6,30.6-55.7
|
||||
C53.3,14,81.1,1.8,109.8,0z" />
|
||||
</g>
|
||||
</g>
|
||||
<g class="st1">
|
||||
<path class="st2" d="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,6
|
||||
c-13.9-6.8-23-22.6-21.3-38.1C77.6,92,91.1,77.7,107.2,74.1z" />
|
||||
</g>
|
||||
<path class="st0" d="M117,367.4c-0.4-0.3-0.8-0.6-1.2-0.9c-1.6-1.2-3.1-2.3-4.2-3.7c-2.9-26.9-9.6-51.7-20.1-74
|
||||
c-12.4-27.3-30.1-52.4-47.1-75.8c-8.7-12-19.8-27.9-28.8-45.2C2.3,143.6-2.1,115.9,3.2,89.9c4.3-20.4,15-40,30.3-55.2
|
||||
C53.6,15.1,81.5,2.8,109.9,1l13.5,0c34.4,1.9,66.9,18.9,86.9,45.4c12.8,16.3,20.8,37.5,22.5,59.8l0,8
|
||||
c-0.7,38.8-23.7,70.9-45.9,101.9c-1.7,2.3-3.3,4.6-5,6.9c-24.4,34.5-50.3,76.1-57.3,123.3c-0.5,2-0.7,4.3-0.9,6.5
|
||||
C123.3,359,122.8,364.9,117,367.4z" />
|
||||
<path class="st1" d="M123.3,2c34.1,1.9,66.3,18.8,86.2,45c12.6,16.1,20.5,37.1,22.3,59.1l0,8c-0.7,38.5-23.6,70.5-45.7,101.3
|
||||
c-1.7,2.3-3.3,4.6-5,6.9c-24.5,34.6-50.5,76.3-57.4,123.7c-0.5,2.1-0.7,4.4-0.9,6.7c-0.5,5.9-1,11-5.8,13.4
|
||||
c-0.2-0.2-0.5-0.4-0.7-0.5c-1.5-1.1-2.9-2-3.8-3.3c-2.9-26.9-9.7-51.8-20.1-74C80,261,62.3,235.8,45.2,212.4
|
||||
c-8.7-11.9-19.8-27.8-28.8-45.1C3.3,143.3-1,115.9,4.2,90.1c4.2-20.2,14.9-39.6,30-54.7C54.2,16,81.7,3.8,109.9,2H123.3 M123.4,0
|
||||
h-13.6c-28.7,1.8-56.5,14-77,34C17.6,49.1,6.6,68.6,2.2,89.7c-5.4,26.7-0.5,54.8,12.5,78.6C23,184.2,33,199,43.6,213.6
|
||||
c17.5,24,34.7,48.5,47,75.6c10.9,23.2,17.3,48.4,20,73.9c1.5,2.3,4,3.7,6.2,5.4c9.3-3.5,7.1-14.3,8.9-22
|
||||
c6.7-45.6,30.9-85.9,57.1-122.9c23.3-32.8,50.2-67.3,51-109.4v-8.1c-1.7-21.7-9.2-43.1-22.7-60.3C190.5,18.5,157.3,1.9,123.4,0
|
||||
L123.4,0z" />
|
||||
</g>
|
||||
<path class="st3" d="M90.9,57.3v68.2h18.6v55.8l43.4-74.4h-24.8l24.8-49.6H90.9z" />
|
||||
<path class="st4" d="M159,85.3L159,85.3l-20.8-20.9l-5.9,5.9l11.8,11.8c-5.3,2-9,7.1-9,13.1c0,7.7,6.3,14,14,14c2,0,3.9-0.4,5.6-1.2
|
||||
v40.4c0,3.1-2.5,5.6-5.6,5.6s-5.6-2.5-5.6-5.6v-25.2c0-6.2-5-11.2-11.2-11.2h-5.6V72.8c0-6.2-5-11.2-11.2-11.2H81.8
|
||||
c-6.2,0-11.2,5-11.2,11.2v89.7h56.1v-42.1h8.4v28c0,7.7,6.3,14,14,14s14-6.3,14-14V95.2C163.1,91.3,161.6,87.8,159,85.3
|
||||
M149.1,100.8c-3.1,0-5.6-2.5-5.6-5.6c0-3.1,2.5-5.6,5.6-5.6s5.6,2.5,5.6,5.6C154.7,98.3,152.2,100.8,149.1,100.8 M93.1,145.6v-25.2
|
||||
H81.8l22.4-42.1v28h11.2L93.1,145.6z" />
|
||||
<path class="st1"
|
||||
d="M90.9,57.3v68.2h18.6v55.8l43.4-74.4h-24.8l24.8-49.6C152.9,57.3,90.9,57.3,90.9,57.3z" />
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.8 KiB |
@@ -1,27 +1,28 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!-- Generator: Adobe Illustrator 23.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<?xml version="1.0" encoding="utf-8"?><!-- Generator: Adobe Illustrator 24.3.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<svg version="1.1" id="Ebene_1" xmlns="http://www.w3.org/2000/svg" x="0px" y="0px"
|
||||
viewBox="0 0 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;}
|
||||
.st1{fill:#808080;}
|
||||
.st2{fill:#B5B5B5;}
|
||||
</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" />
|
||||
<path class="st0" d="M117,367.4c-0.4-0.3-0.8-0.6-1.2-0.9c-1.6-1.2-3.1-2.3-4.2-3.7c-2.9-26.9-9.6-51.7-20.1-74
|
||||
c-12.4-27.3-30.1-52.4-47.1-75.8c-8.7-12-19.8-27.9-28.8-45.2C2.3,143.6-2.1,115.9,3.2,89.9c4.3-20.4,15-40,30.3-55.2
|
||||
C53.6,15.1,81.5,2.8,109.9,1l13.5,0c34.4,1.9,66.9,18.9,86.9,45.4c12.8,16.3,20.8,37.5,22.5,59.8l0,8
|
||||
c-0.7,38.8-23.7,70.9-45.9,101.9c-1.7,2.3-3.3,4.6-5,6.9c-24.4,34.5-50.3,76.1-57.3,123.3c-0.5,2-0.7,4.3-0.9,6.5
|
||||
C123.3,359,122.8,364.9,117,367.4z" />
|
||||
<path class="st1" d="M123.3,2c34.1,1.9,66.3,18.8,86.2,45c12.6,16.1,20.5,37.1,22.3,59.1l0,8c-0.7,38.5-23.6,70.5-45.7,101.3
|
||||
c-1.7,2.3-3.3,4.6-5,6.9c-24.5,34.6-50.5,76.3-57.4,123.7c-0.5,2.1-0.7,4.4-0.9,6.7c-0.5,5.9-1,11-5.8,13.4
|
||||
c-0.2-0.2-0.5-0.4-0.7-0.5c-1.5-1.1-2.9-2-3.8-3.3c-2.9-26.9-9.7-51.8-20.1-74C80,261,62.3,235.8,45.2,212.4
|
||||
c-8.7-11.9-19.8-27.8-28.8-45.1C3.3,143.3-1,115.9,4.2,90.1c4.2-20.2,14.9-39.6,30-54.7C54.2,16,81.7,3.8,109.9,2H123.3 M123.4,0
|
||||
h-13.6c-28.7,1.8-56.5,14-77,34C17.6,49.1,6.6,68.6,2.2,89.7c-5.4,26.7-0.5,54.8,12.5,78.6C23,184.2,33,199,43.6,213.6
|
||||
c17.5,24,34.7,48.5,47,75.6c10.9,23.2,17.3,48.4,20,73.9c1.5,2.3,4,3.7,6.2,5.4c9.3-3.5,7.1-14.3,8.9-22
|
||||
c6.7-45.6,30.9-85.9,57.1-122.9c23.3-32.8,50.2-67.3,51-109.4v-8.1c-1.7-21.7-9.2-43.1-22.7-60.3C190.5,18.5,157.3,1.9,123.4,0
|
||||
L123.4,0z" />
|
||||
</g>
|
||||
<polygon class="st2" points="143.2,109.4 123.5,143.2 123.5,181.3 166.9,106.9 144.7,106.9 " />
|
||||
<path class="st2" 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="st1" 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" />
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 2.0 KiB |
135
app/build.gradle
135
app/build.gradle
@@ -1,33 +1,43 @@
|
||||
plugins {
|
||||
id 'com.adarshr.test-logger' version '3.1.0'
|
||||
}
|
||||
|
||||
apply plugin: 'com.android.application'
|
||||
apply plugin: 'kotlin-android'
|
||||
apply plugin: 'kotlin-parcelize'
|
||||
apply plugin: 'kotlin-kapt'
|
||||
apply plugin: 'androidx.navigation.safeargs.kotlin'
|
||||
apply plugin: 'com.mikepenz.aboutlibraries.plugin'
|
||||
apply plugin: 'de.timfreiheit.resourceplaceholders'
|
||||
|
||||
def supportedLocales = "en,de,fr,nb-rNO"
|
||||
|
||||
android {
|
||||
compileSdkVersion 31
|
||||
compileSdkVersion 33
|
||||
buildToolsVersion "30.0.3"
|
||||
|
||||
defaultConfig {
|
||||
applicationId "net.vonforst.evmap"
|
||||
minSdkVersion 21
|
||||
targetSdkVersion 31
|
||||
versionCode 68
|
||||
versionName "1.2.0"
|
||||
// NOTE: always increase versionCode by 2 since automotive flavor uses versionCode + 1
|
||||
versionCode 116
|
||||
versionName "1.3.11"
|
||||
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
resConfigs supportedLocales.split(",")
|
||||
buildConfigField("String", "supportedLocales", '"' + supportedLocales + '"')
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
release {
|
||||
def isRunningOnTravis = System.getenv("CI") == "true"
|
||||
if (isRunningOnTravis) {
|
||||
def isRunningOnCI = System.getenv("CI") == "true"
|
||||
if (isRunningOnCI) {
|
||||
// configure keystore
|
||||
storeFile = file("../_ci/keystore.jks")
|
||||
storePassword = System.getenv("keystore_password")
|
||||
keyAlias = System.getenv("keystore_alias")
|
||||
keyPassword = System.getenv("keystore_alias_password")
|
||||
storePassword = System.getenv("KEYSTORE_PASSWORD")
|
||||
keyAlias = System.getenv("KEYSTORE_ALIAS")
|
||||
keyPassword = System.getenv("KEYSTORE_ALIAS_PASSWORD")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -44,7 +54,7 @@ android {
|
||||
}
|
||||
}
|
||||
|
||||
flavorDimensions "dependencies"
|
||||
flavorDimensions "dependencies", "automotive"
|
||||
productFlavors {
|
||||
foss {
|
||||
dimension "dependencies"
|
||||
@@ -53,6 +63,22 @@ android {
|
||||
dimension "dependencies"
|
||||
versionNameSuffix "-google"
|
||||
}
|
||||
normal {
|
||||
dimension "automotive"
|
||||
}
|
||||
automotive {
|
||||
dimension "automotive"
|
||||
versionNameSuffix "-automotive"
|
||||
versionCode defaultConfig.versionCode + 1
|
||||
minSdkVersion 29
|
||||
}
|
||||
}
|
||||
variantFilter { variant ->
|
||||
def names = variant.flavors*.name
|
||||
// Android Automotive OS app is always based on Google variant
|
||||
if (names.contains("automotive") && !names.contains("google")) {
|
||||
setIgnore(true)
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
@@ -69,6 +95,18 @@ android {
|
||||
dataBinding = true
|
||||
viewBinding true
|
||||
}
|
||||
lint {
|
||||
disable 'NullSafeMutableLiveData'
|
||||
warning 'MissingTranslation'
|
||||
}
|
||||
|
||||
testOptions {
|
||||
unitTests.includeAndroidResources true
|
||||
}
|
||||
|
||||
resourcePlaceholders {
|
||||
files = ['xml/shortcuts.xml']
|
||||
}
|
||||
|
||||
// add API keys from environment variable if not set in apikeys.xml
|
||||
applicationVariants.all { variant ->
|
||||
@@ -85,7 +123,7 @@ android {
|
||||
variant.resValue "string", "openchargemap_key", openchargemapKey
|
||||
}
|
||||
def googleMapsKey = env.GOOGLE_MAPS_API_KEY ?: project.findProperty("GOOGLE_MAPS_API_KEY")
|
||||
if (googleMapsKey != null && variant.flavorName == 'google') {
|
||||
if (googleMapsKey != null && variant.flavorName.startsWith('google')) {
|
||||
variant.resValue "string", "google_maps_key", googleMapsKey
|
||||
}
|
||||
def mapboxKey = env.MAPBOX_API_KEY ?: project.findProperty("MAPBOX_API_KEY")
|
||||
@@ -104,58 +142,71 @@ android {
|
||||
}
|
||||
}
|
||||
|
||||
lintOptions {
|
||||
disable 'NullSafeMutableLiveData'
|
||||
}
|
||||
}
|
||||
|
||||
configurations {
|
||||
googleNormalImplementation {}
|
||||
googleAutomotiveImplementation {}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
|
||||
implementation 'androidx.appcompat:appcompat:1.4.0'
|
||||
implementation 'androidx.core:core-ktx:1.7.0'
|
||||
implementation 'androidx.core:core-splashscreen:1.0.0-alpha02'
|
||||
implementation "androidx.activity:activity-ktx:1.4.0"
|
||||
implementation "androidx.fragment:fragment-ktx:1.4.0"
|
||||
implementation 'androidx.appcompat:appcompat:1.6.0-beta01'
|
||||
implementation 'androidx.core:core-ktx:1.8.0'
|
||||
implementation 'androidx.core:core-splashscreen:1.0.0'
|
||||
implementation "androidx.activity:activity-ktx:1.5.1"
|
||||
implementation "androidx.fragment:fragment-ktx:1.5.1"
|
||||
implementation 'androidx.cardview:cardview:1.0.0'
|
||||
implementation 'androidx.preference:preference-ktx:1.1.1'
|
||||
implementation 'com.google.android.material:material:1.5.0-rc01'
|
||||
implementation 'androidx.constraintlayout:constraintlayout:2.1.1'
|
||||
implementation 'androidx.preference:preference-ktx:1.2.0'
|
||||
implementation 'com.google.android.material:material:1.6.1'
|
||||
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
|
||||
implementation 'androidx.recyclerview:recyclerview:1.2.1'
|
||||
implementation 'androidx.browser:browser:1.4.0'
|
||||
implementation 'com.github.johan12345:CustomBottomSheetBehavior:f69f532660'
|
||||
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
|
||||
implementation 'com.github.johan12345:CustomBottomSheetBehavior:dd0167dbff'
|
||||
implementation 'com.squareup.retrofit2:retrofit:2.9.0'
|
||||
implementation 'com.squareup.retrofit2:converter-moshi:2.9.0'
|
||||
implementation 'com.squareup.okhttp3:okhttp:4.9.0'
|
||||
implementation 'com.squareup.okhttp3:okhttp-urlconnection:4.9.0'
|
||||
implementation 'com.squareup.moshi:moshi-kotlin:1.13.0'
|
||||
implementation 'com.squareup.moshi:moshi-adapters:1.13.0'
|
||||
implementation 'moe.banana:moshi-jsonapi:3.5.0'
|
||||
implementation 'moe.banana:moshi-jsonapi-retrofit-converter:3.5.0'
|
||||
implementation 'com.github.johan12345:jsonapi:50d72e7e55' // patched version for jsonapi-adapters
|
||||
implementation('com.markomilos.jsonapi:jsonapi-retrofit:1.0.1') {
|
||||
exclude group: 'com.markomilos.jsonapi', module: 'jsonapi-adapters'
|
||||
}
|
||||
implementation 'io.coil-kt:coil:1.1.0'
|
||||
implementation 'com.github.johan12345:StfalconImageViewer:5082ebd392'
|
||||
implementation "com.mikepenz:aboutlibraries-core:$about_libs_version"
|
||||
implementation "com.mikepenz:aboutlibraries:$about_libs_version"
|
||||
implementation 'com.airbnb.android:lottie:4.1.0'
|
||||
implementation 'io.michaelrocks.bimap:bimap:1.1.0'
|
||||
implementation 'com.mapzen.android:lost:3.0.2'
|
||||
implementation 'com.google.guava:guava:29.0-android'
|
||||
implementation 'com.github.pengrad:mapscaleview:1.6.0'
|
||||
implementation 'com.github.romandanylyk:PageIndicatorView:b1bad589b5'
|
||||
|
||||
// Android Auto
|
||||
googleImplementation 'androidx.car.app:app:1.2.0-alpha02'
|
||||
googleImplementation 'androidx.car.app:app-projected:1.2.0-alpha02'
|
||||
def carAppVersion = '1.2.0-rc01'
|
||||
googleImplementation "androidx.car.app:app:$carAppVersion"
|
||||
googleNormalImplementation "androidx.car.app:app-projected:$carAppVersion"
|
||||
googleAutomotiveImplementation "androidx.car.app:app-automotive:$carAppVersion"
|
||||
|
||||
// AnyMaps
|
||||
def anyMapsVersion = '751daec281'
|
||||
def anyMapsVersion = 'a9b3dd7d99'
|
||||
implementation "com.github.johan12345.AnyMaps:anymaps-base:$anyMapsVersion"
|
||||
googleImplementation "com.github.johan12345.AnyMaps:anymaps-google:$anyMapsVersion"
|
||||
googleImplementation 'com.google.android.gms:play-services-maps:18.0.1'
|
||||
implementation "com.github.johan12345.AnyMaps:anymaps-mapbox:$anyMapsVersion"
|
||||
googleImplementation 'com.google.android.gms:play-services-maps:18.1.0'
|
||||
implementation("com.github.johan12345.AnyMaps:anymaps-mapbox:$anyMapsVersion") {
|
||||
exclude group: 'com.mapbox.mapboxsdk', module: 'mapbox-android-accounts'
|
||||
exclude group: 'com.mapbox.mapboxsdk', module: 'mapbox-android-telemetry'
|
||||
exclude group: 'com.google.android.gms', module: 'play-services-location'
|
||||
exclude group: 'com.mapbox.mapboxsdk', module: 'mapbox-android-core'
|
||||
}
|
||||
// patched version of mapbox-android-core that removes build-time dependency on GMS
|
||||
implementation 'com.github.johan12345:mapbox-events-android:a21c324501'
|
||||
|
||||
// Google Places
|
||||
implementation 'com.google.android.libraries.places:places:2.5.0'
|
||||
googleImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.4.1'
|
||||
googleImplementation 'com.google.android.libraries.places:places:2.6.0'
|
||||
googleImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.6.1'
|
||||
|
||||
// Mapbox Geocoding
|
||||
implementation 'com.mapbox.mapboxsdk:mapbox-sdk-services:5.5.0'
|
||||
@@ -165,18 +216,18 @@ dependencies {
|
||||
implementation "androidx.navigation:navigation-ui-ktx:$nav_version"
|
||||
|
||||
// viewmodel library
|
||||
def lifecycle_version = "2.4.0"
|
||||
def lifecycle_version = "2.5.1"
|
||||
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version"
|
||||
implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycle_version"
|
||||
|
||||
// room library
|
||||
def room_version = "2.4.0"
|
||||
def room_version = "2.4.3"
|
||||
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 = "4.0.0"
|
||||
def billing_version = "4.1.0"
|
||||
googleImplementation "com.android.billingclient:billing:$billing_version"
|
||||
googleImplementation "com.android.billingclient:billing-ktx:$billing_version"
|
||||
|
||||
@@ -187,12 +238,20 @@ dependencies {
|
||||
implementation("ch.acra:acra-limiter:$acraVersion")
|
||||
|
||||
// debug tools
|
||||
implementation 'com.facebook.stetho:stetho:1.5.1'
|
||||
implementation 'com.facebook.stetho:stetho-okhttp3:1.5.1'
|
||||
implementation 'com.facebook.stetho:stetho:1.6.0'
|
||||
implementation 'com.facebook.stetho:stetho-okhttp3:1.6.0'
|
||||
|
||||
// testing
|
||||
testImplementation 'junit:junit:4.13.2'
|
||||
testImplementation "com.squareup.okhttp3:mockwebserver:4.9.0"
|
||||
//noinspection GradleDependency
|
||||
testImplementation 'org.json:json:20080701'
|
||||
|
||||
// testing for car app
|
||||
testGoogleImplementation "androidx.car.app:app-testing:$carAppVersion"
|
||||
testGoogleImplementation 'org.robolectric:robolectric:4.8.1'
|
||||
testGoogleImplementation 'androidx.test:core:1.4.0'
|
||||
|
||||
androidTestImplementation 'androidx.test.ext:junit:1.1.3'
|
||||
androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
|
||||
|
||||
@@ -211,4 +270,4 @@ private static byte[] xorWithKey(byte[] a, byte[] key) {
|
||||
out[i] = (byte) (a[i] ^ key[i%key.length]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
5
app/src/debug/res/values/donottranslate.xml
Normal file
5
app/src/debug/res/values/donottranslate.xml
Normal file
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="chargeprice_api_url">https://staging-api.chargeprice.app/v1/</string>
|
||||
<string name="chargeprice_key">20c0d68918c9dc96c564784b711a6570</string>
|
||||
</resources>
|
||||
6
app/src/foss/res/values-de/strings.xml
Normal file
6
app/src/foss/res/values-de/strings.xml
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="donations_info" formatted="false">Findest du EVMap nützlich? Unterstütze die Weiterentwicklung der App mit einer Spende an den Entwickler.</string>
|
||||
<string name="donate_paypal">Mit PayPal spenden</string>
|
||||
<string name="data_sources_hint">Die Kartendaten für die App stammen von OpenStreetMap (Mapbox).</string>
|
||||
</resources>
|
||||
@@ -1,14 +0,0 @@
|
||||
<?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_search_provider_names">
|
||||
<item>OpenStreetMap (Mapbox)</item>
|
||||
</string-array>
|
||||
<string-array name="pref_search_provider_values" tranlatable="false">
|
||||
<item>mapbox</item>
|
||||
</string-array>
|
||||
<string name="donations_info" formatted="false">Findest du EVMap nützlich? Unterstütze die Weiterentwicklung der App mit einer Spende an den Entwickler.\n\nGoogle zieht von der Spende 30% Gebühren ab.</string>
|
||||
<string name="donate_paypal">Mit PayPal spenden</string>
|
||||
</resources>
|
||||
6
app/src/foss/res/values-fr/strings.xml
Normal file
6
app/src/foss/res/values-fr/strings.xml
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="donations_info" formatted="false">Trouvez-vous EVMap utile \? Soutenez son développement en envoyant un don au développeur.</string>
|
||||
<string name="data_sources_hint">Les données cartographiques de l\'application sont fournies par OpenStreetMap (Mapbox).</string>
|
||||
<string name="donate_paypal">Faire un don avec PayPal</string>
|
||||
</resources>
|
||||
6
app/src/foss/res/values-nb-rNO/strings.xml
Normal file
6
app/src/foss/res/values-nb-rNO/strings.xml
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="donate_paypal">Doner med PayPal</string>
|
||||
<string name="data_sources_hint">Kartdata i programmet tilbys av OpenStreetMap (Mapbox).</string>
|
||||
<string name="donations_info" formatted="false">Synes du EVMap er nyttig\? Støtt utviklingen ved å sende en slant til utvikleren.</string>
|
||||
</resources>
|
||||
15
app/src/foss/res/values/arrays.xml
Normal file
15
app/src/foss/res/values/arrays.xml
Normal file
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string-array name="pref_map_provider_names">
|
||||
<item>@string/pref_provider_osm_mapbox</item>
|
||||
</string-array>
|
||||
<string-array name="pref_map_provider_values" translatable="false">
|
||||
<item>mapbox</item>
|
||||
</string-array>
|
||||
<string-array name="pref_search_provider_names">
|
||||
<item>@string/pref_provider_osm_mapbox</item>
|
||||
</string-array>
|
||||
<string-array name="pref_search_provider_values" translatable="false">
|
||||
<item>mapbox</item>
|
||||
</string-array>
|
||||
</resources>
|
||||
6
app/src/foss/res/values/donottranslate.xml
Normal file
6
app/src/foss/res/values/donottranslate.xml
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="pref_search_provider_default" translatable="false">mapbox</string>
|
||||
<string name="pref_map_provider_default" translatable="false">mapbox</string>
|
||||
<string name="paypal_link" translatable="false">https://paypal.me/johan98</string>
|
||||
</resources>
|
||||
6
app/src/foss/res/values/strings.xml
Normal file
6
app/src/foss/res/values/strings.xml
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<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="data_sources_hint">Map data in the app is provided by OpenStreetMap (Mapbox).</string>
|
||||
</resources>
|
||||
@@ -1,20 +0,0 @@
|
||||
<?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-array name="pref_search_provider_names">
|
||||
<item>OpenStreetMap (Mapbox)</item>
|
||||
</string-array>
|
||||
<string-array name="pref_search_provider_values" tranlatable="false">
|
||||
<item>mapbox</item>
|
||||
</string-array>
|
||||
<string name="pref_search_provider_default" translatable="false">mapbox</string>
|
||||
<string name="pref_map_provider_default" translatable="false">mapbox</string>
|
||||
<string name="donations_info" formatted="false">Do you find EVMap useful? Support its development by sending a donation to the developer.</string>
|
||||
<string name="donate_paypal">Donate with PayPal</string>
|
||||
<string name="paypal_link" translatable="false">https://paypal.me/johan98</string>
|
||||
</resources>
|
||||
@@ -39,14 +39,8 @@
|
||||
<intent-filter>
|
||||
<action
|
||||
android:name="androidx.car.app.CarAppService"
|
||||
android:category="androidx.car.app.category.CHARGING" />
|
||||
android:category="androidx.car.app.category.POI" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
<service
|
||||
android:name=".auto.CarLocationService"
|
||||
android:foregroundServiceType="location"
|
||||
android:enabled="true"
|
||||
android:exported="false" />
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -7,16 +7,10 @@ import com.google.android.gms.common.ConnectionResult
|
||||
import com.google.android.gms.common.GoogleApiAvailability
|
||||
import com.google.android.gms.maps.MapsInitializer
|
||||
import com.google.android.libraries.places.api.Places
|
||||
import net.vonforst.evmap.storage.PreferenceDataSource
|
||||
import net.vonforst.evmap.utils.LocaleContextWrapper
|
||||
|
||||
fun init(context: Context) {
|
||||
Places.initialize(context, context.getString(R.string.google_maps_key))
|
||||
|
||||
val localeContext = LocaleContextWrapper.wrap(
|
||||
context.applicationContext, PreferenceDataSource(context).language
|
||||
)
|
||||
MapsInitializer.initialize(localeContext, MapsInitializer.Renderer.LATEST, null)
|
||||
MapsInitializer.initialize(context, MapsInitializer.Renderer.LATEST, null)
|
||||
}
|
||||
|
||||
fun checkPlayServices(activity: Activity): Boolean {
|
||||
|
||||
@@ -1,30 +1,84 @@
|
||||
package net.vonforst.evmap.auto
|
||||
|
||||
import android.content.*
|
||||
import android.Manifest
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.content.Intent
|
||||
import android.content.pm.ApplicationInfo
|
||||
import android.location.Location
|
||||
import android.os.IBinder
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import androidx.annotation.RequiresPermission
|
||||
import androidx.car.app.CarContext
|
||||
import androidx.car.app.Screen
|
||||
import androidx.car.app.ScreenManager
|
||||
import androidx.car.app.Session
|
||||
import androidx.car.app.annotations.ExperimentalCarApi
|
||||
import androidx.car.app.hardware.CarHardwareManager
|
||||
import androidx.car.app.hardware.common.CarValue
|
||||
import androidx.car.app.hardware.info.CarHardwareLocation
|
||||
import androidx.car.app.hardware.info.CarSensors
|
||||
import androidx.car.app.validation.HostValidator
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleObserver
|
||||
import androidx.lifecycle.OnLifecycleEvent
|
||||
import androidx.localbroadcastmanager.content.LocalBroadcastManager
|
||||
import net.vonforst.evmap.utils.checkAnyLocationPermission
|
||||
import androidx.core.location.LocationListenerCompat
|
||||
import androidx.lifecycle.DefaultLifecycleObserver
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import net.vonforst.evmap.R
|
||||
import net.vonforst.evmap.location.FusionEngine
|
||||
import net.vonforst.evmap.location.LocationEngine
|
||||
import net.vonforst.evmap.location.Priority
|
||||
import net.vonforst.evmap.utils.checkFineLocationPermission
|
||||
|
||||
|
||||
interface LocationAwareScreen {
|
||||
fun updateLocation(location: Location)
|
||||
}
|
||||
|
||||
@ExperimentalCarApi
|
||||
class CarAppService : androidx.car.app.CarAppService() {
|
||||
private val CHANNEL_ID = "car_location"
|
||||
private val NOTIFICATION_ID = 1000
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
|
||||
// we want to run as a foreground service to make sure we can use location
|
||||
createNotificationChannel()
|
||||
startForeground(NOTIFICATION_ID, getNotification())
|
||||
}
|
||||
|
||||
private fun createNotificationChannel() {
|
||||
val notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
|
||||
// Android O requires a Notification Channel.
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val name: CharSequence = getString(R.string.app_name)
|
||||
// Create the channel for the notification
|
||||
val mChannel =
|
||||
NotificationChannel(CHANNEL_ID, name, NotificationManager.IMPORTANCE_DEFAULT)
|
||||
|
||||
// Set the Notification Channel for the Notification Manager.
|
||||
notificationManager.createNotificationChannel(mChannel)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getNotification(): Notification {
|
||||
val builder: NotificationCompat.Builder = NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setContentText(getString(R.string.auto_location_service))
|
||||
.setContentTitle(getString(R.string.app_name))
|
||||
.setOngoing(true)
|
||||
.setPriority(NotificationManagerCompat.IMPORTANCE_HIGH)
|
||||
.setSmallIcon(R.drawable.ic_appicon_notification)
|
||||
.setColor(ContextCompat.getColor(this, R.color.colorPrimary))
|
||||
.setTicker(getString(R.string.auto_location_service))
|
||||
.setWhen(System.currentTimeMillis())
|
||||
|
||||
return builder.build()
|
||||
}
|
||||
|
||||
override fun createHostValidator(): HostValidator {
|
||||
return if (applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE != 0) {
|
||||
HostValidator.ALLOW_ALL_HOSTS_VALIDATOR
|
||||
@@ -40,49 +94,50 @@ class CarAppService : androidx.car.app.CarAppService() {
|
||||
}
|
||||
}
|
||||
|
||||
class EVMapSession(val cas: CarAppService) : Session(), LifecycleObserver {
|
||||
@ExperimentalCarApi
|
||||
class EVMapSession(val cas: CarAppService) : Session(), DefaultLifecycleObserver {
|
||||
private val TAG = "EVMapSession"
|
||||
var mapScreen: LocationAwareScreen? = null
|
||||
set(value) {
|
||||
field = value
|
||||
location?.let { value?.updateLocation(it) }
|
||||
}
|
||||
private var location: Location? = null
|
||||
private var locationService: CarLocationService? = null
|
||||
private val locationEngine: LocationEngine by lazy {
|
||||
FusionEngine(carContext)
|
||||
}
|
||||
|
||||
private val hardwareMan: CarHardwareManager by lazy {
|
||||
carContext.getCarService(CarContext.HARDWARE_SERVICE) as CarHardwareManager
|
||||
}
|
||||
|
||||
private val serviceConnection = object : ServiceConnection {
|
||||
override fun onServiceConnected(name: ComponentName, ibinder: IBinder) {
|
||||
val binder: CarLocationService.LocalBinder = ibinder as CarLocationService.LocalBinder
|
||||
locationService = binder.service
|
||||
locationService?.requestLocationUpdates()
|
||||
}
|
||||
|
||||
override fun onServiceDisconnected(name: ComponentName?) {
|
||||
locationService = null
|
||||
}
|
||||
}
|
||||
private var serviceBound = false
|
||||
|
||||
init {
|
||||
lifecycle.addObserver(this)
|
||||
}
|
||||
|
||||
override fun onCreateScreen(intent: Intent): Screen {
|
||||
return WelcomeScreen(carContext, this)
|
||||
}
|
||||
val mapScreen = MapScreen(carContext, this)
|
||||
|
||||
fun locationPermissionGranted() = carContext.checkAnyLocationPermission()
|
||||
|
||||
private val locationReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
val location = intent.getParcelableExtra(CarLocationService.EXTRA_LOCATION) as Location?
|
||||
updateLocation(location)
|
||||
if (!locationPermissionGranted()) {
|
||||
val screenManager = carContext.getCarService(ScreenManager::class.java)
|
||||
screenManager.push(mapScreen)
|
||||
return PermissionScreen(
|
||||
carContext,
|
||||
R.string.auto_location_permission_needed,
|
||||
listOf(
|
||||
Manifest.permission.ACCESS_FINE_LOCATION,
|
||||
Manifest.permission.ACCESS_COARSE_LOCATION
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
return mapScreen
|
||||
}
|
||||
|
||||
private fun locationPermissionGranted() = carContext.checkFineLocationPermission()
|
||||
|
||||
private fun updateLocation(location: Location?) {
|
||||
Log.d(TAG, "Received location: $location")
|
||||
val mapScreen = mapScreen
|
||||
if (location != null && mapScreen != null) {
|
||||
mapScreen.updateLocation(location)
|
||||
@@ -90,19 +145,24 @@ class EVMapSession(val cas: CarAppService) : Session(), LifecycleObserver {
|
||||
this.location = location
|
||||
}
|
||||
|
||||
private fun onCarHardwareLocationReceived(loc: CarHardwareLocation) {
|
||||
if (loc.location.status == CarValue.STATUS_SUCCESS && loc.location.value != null) {
|
||||
updateLocation(loc.location.value)
|
||||
|
||||
// we successfully received a location from the car hardware,
|
||||
// so we don't need the smartphone location anymore.
|
||||
unbindLocationService()
|
||||
}
|
||||
override fun onStart(owner: LifecycleOwner) {
|
||||
requestLocationUpdates()
|
||||
}
|
||||
|
||||
@OnLifecycleEvent(Lifecycle.Event.ON_START)
|
||||
fun bindLocationService() {
|
||||
override fun onStop(owner: LifecycleOwner) {
|
||||
removeLocationUpdates()
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
fun requestLocationUpdates() {
|
||||
if (!locationPermissionGranted()) return
|
||||
Log.i(TAG, "Requesting location updates")
|
||||
requestCarHardwareLocationUpdates()
|
||||
requestPhoneLocationUpdates()
|
||||
}
|
||||
|
||||
@RequiresPermission(anyOf = [Manifest.permission.ACCESS_FINE_LOCATION])
|
||||
private fun requestCarHardwareLocationUpdates() {
|
||||
if (supportsCarApiLevel3(carContext)) {
|
||||
val exec = ContextCompat.getMainExecutor(carContext)
|
||||
hardwareMan.carSensors.addCarHardwareLocationListener(
|
||||
@@ -111,40 +171,51 @@ class EVMapSession(val cas: CarAppService) : Session(), LifecycleObserver {
|
||||
::onCarHardwareLocationReceived
|
||||
)
|
||||
}
|
||||
serviceBound = cas.bindService(
|
||||
Intent(cas, CarLocationService::class.java),
|
||||
serviceConnection,
|
||||
Context.BIND_AUTO_CREATE
|
||||
}
|
||||
|
||||
private val phoneLocationListener = LocationListenerCompat {
|
||||
this.updateLocation(it)
|
||||
}
|
||||
|
||||
@RequiresPermission(anyOf = [Manifest.permission.ACCESS_COARSE_LOCATION, Manifest.permission.ACCESS_FINE_LOCATION])
|
||||
private fun requestPhoneLocationUpdates() {
|
||||
val location = locationEngine.getLastKnownLocation()
|
||||
updateLocation(location)
|
||||
locationEngine.requestLocationUpdates(
|
||||
Priority.HIGH_ACCURACY,
|
||||
1000,
|
||||
phoneLocationListener
|
||||
)
|
||||
}
|
||||
|
||||
@OnLifecycleEvent(Lifecycle.Event.ON_STOP)
|
||||
private fun onStop() {
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
private fun removeLocationUpdates() {
|
||||
if (!locationPermissionGranted()) return
|
||||
removeCarHardwareLocationUpdates()
|
||||
removePhoneLocationUpdates()
|
||||
}
|
||||
|
||||
private fun removeCarHardwareLocationUpdates() {
|
||||
if (supportsCarApiLevel3(carContext)) {
|
||||
hardwareMan.carSensors.removeCarHardwareLocationListener(::onCarHardwareLocationReceived)
|
||||
}
|
||||
unbindLocationService()
|
||||
}
|
||||
|
||||
private fun unbindLocationService() {
|
||||
locationService?.removeLocationUpdates()
|
||||
if (serviceBound) {
|
||||
cas.unbindService(serviceConnection)
|
||||
serviceBound = false
|
||||
@RequiresPermission(anyOf = [Manifest.permission.ACCESS_COARSE_LOCATION, Manifest.permission.ACCESS_FINE_LOCATION])
|
||||
private fun removePhoneLocationUpdates() {
|
||||
locationEngine.removeUpdates(phoneLocationListener)
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
private fun onCarHardwareLocationReceived(loc: CarHardwareLocation) {
|
||||
if (loc.location.status == CarValue.STATUS_SUCCESS && loc.location.value != null) {
|
||||
updateLocation(loc.location.value)
|
||||
|
||||
// we successfully received a location from the car hardware,
|
||||
// so we don't need the smartphone location anymore.
|
||||
removePhoneLocationUpdates()
|
||||
}
|
||||
}
|
||||
|
||||
@OnLifecycleEvent(Lifecycle.Event.ON_RESUME)
|
||||
private fun registerBroadcastReceiver() {
|
||||
LocalBroadcastManager.getInstance(cas).registerReceiver(
|
||||
locationReceiver,
|
||||
IntentFilter(CarLocationService.ACTION_BROADCAST)
|
||||
);
|
||||
}
|
||||
|
||||
@OnLifecycleEvent(Lifecycle.Event.ON_PAUSE)
|
||||
private fun unregisterBroadcastReceiver() {
|
||||
LocalBroadcastManager.getInstance(cas).unregisterReceiver(locationReceiver)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,163 +0,0 @@
|
||||
package net.vonforst.evmap.auto
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.Service
|
||||
import android.content.Intent
|
||||
import android.location.Location
|
||||
import android.os.*
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.localbroadcastmanager.content.LocalBroadcastManager
|
||||
import com.google.android.gms.location.*
|
||||
import net.vonforst.evmap.BuildConfig
|
||||
import net.vonforst.evmap.R
|
||||
|
||||
|
||||
class CarLocationService : Service() {
|
||||
private lateinit var serviceHandler: Handler
|
||||
private lateinit var locationRequest: LocationRequest
|
||||
private lateinit var notificationManager: NotificationManager
|
||||
private lateinit var locationCallback: LocationCallback
|
||||
private lateinit var fusedLocationClient: FusedLocationProviderClient
|
||||
private val binder: IBinder = LocalBinder(this)
|
||||
private var location: Location? = null
|
||||
|
||||
private val UPDATE_INTERVAL_IN_MILLISECONDS = 10000L
|
||||
private val FASTEST_UPDATE_INTERVAL_IN_MILLISECONDS = UPDATE_INTERVAL_IN_MILLISECONDS / 2
|
||||
|
||||
private val CHANNEL_ID = "car_location"
|
||||
private val NOTIFICATION_ID = 1000
|
||||
private val TAG = "CarLocationService"
|
||||
|
||||
companion object {
|
||||
const val ACTION_BROADCAST: String = BuildConfig.APPLICATION_ID + ".car_location_broadcast"
|
||||
const val EXTRA_LOCATION: String = BuildConfig.APPLICATION_ID + ".location"
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
fusedLocationClient = LocationServices.getFusedLocationProviderClient(this)
|
||||
locationCallback = object : LocationCallback() {
|
||||
override fun onLocationResult(locationResult: LocationResult) {
|
||||
super.onLocationResult(locationResult)
|
||||
onNewLocation(locationResult.lastLocation)
|
||||
}
|
||||
}
|
||||
createLocationRequest()
|
||||
getLastLocation()
|
||||
val handlerThread = HandlerThread(TAG)
|
||||
handlerThread.start()
|
||||
serviceHandler = Handler(handlerThread.looper)
|
||||
notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
|
||||
|
||||
// Android O requires a Notification Channel.
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val name: CharSequence = getString(R.string.app_name)
|
||||
// Create the channel for the notification
|
||||
val mChannel =
|
||||
NotificationChannel(CHANNEL_ID, name, NotificationManager.IMPORTANCE_DEFAULT)
|
||||
|
||||
// Set the Notification Channel for the Notification Manager.
|
||||
notificationManager.createNotificationChannel(mChannel)
|
||||
}
|
||||
|
||||
startForeground(NOTIFICATION_ID, getNotification())
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the [NotificationCompat] used as part of the foreground service.
|
||||
*/
|
||||
private fun getNotification(): Notification {
|
||||
val builder: NotificationCompat.Builder = NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setContentText(getString(R.string.auto_location_service))
|
||||
.setContentTitle(getString(R.string.app_name))
|
||||
.setOngoing(true)
|
||||
.setPriority(NotificationManagerCompat.IMPORTANCE_HIGH)
|
||||
.setSmallIcon(R.mipmap.ic_launcher)
|
||||
.setTicker(getString(R.string.auto_location_service))
|
||||
.setWhen(System.currentTimeMillis())
|
||||
|
||||
return builder.build()
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder {
|
||||
return binder
|
||||
}
|
||||
|
||||
private fun createLocationRequest() {
|
||||
locationRequest = LocationRequest()
|
||||
locationRequest.interval = UPDATE_INTERVAL_IN_MILLISECONDS
|
||||
locationRequest.fastestInterval = FASTEST_UPDATE_INTERVAL_IN_MILLISECONDS
|
||||
locationRequest.priority = LocationRequest.PRIORITY_HIGH_ACCURACY
|
||||
}
|
||||
|
||||
private fun onNewLocation(location: Location) {
|
||||
Log.i(TAG, "New location: $location")
|
||||
this.location = location
|
||||
|
||||
// Notify anyone listening for broadcasts about the new location.
|
||||
val intent = Intent(ACTION_BROADCAST)
|
||||
intent.putExtra(EXTRA_LOCATION, location)
|
||||
LocalBroadcastManager.getInstance(applicationContext).sendBroadcast(intent)
|
||||
}
|
||||
|
||||
private fun getLastLocation() {
|
||||
try {
|
||||
fusedLocationClient.lastLocation
|
||||
.addOnCompleteListener { task ->
|
||||
if (task.isSuccessful && task.result != null) {
|
||||
location = task.result
|
||||
} else {
|
||||
Log.w(TAG, "Failed to get location.")
|
||||
}
|
||||
}
|
||||
} catch (unlikely: SecurityException) {
|
||||
Log.e(TAG, "Lost location permission.$unlikely")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes a request for location updates. Note that in this sample we merely log the
|
||||
* [SecurityException].
|
||||
*/
|
||||
fun requestLocationUpdates() {
|
||||
Log.i(TAG, "Requesting location updates")
|
||||
startService(Intent(applicationContext, CarLocationService::class.java))
|
||||
try {
|
||||
fusedLocationClient.requestLocationUpdates(
|
||||
locationRequest,
|
||||
locationCallback, Looper.myLooper()
|
||||
)
|
||||
} catch (unlikely: SecurityException) {
|
||||
Log.e(TAG, "Lost location permission. Could not request updates. $unlikely")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes location updates. Note that in this sample we merely log the
|
||||
* [SecurityException].
|
||||
*/
|
||||
fun removeLocationUpdates() {
|
||||
Log.i(TAG, "Removing location updates")
|
||||
try {
|
||||
fusedLocationClient.removeLocationUpdates(locationCallback)
|
||||
stopSelf()
|
||||
} catch (unlikely: SecurityException) {
|
||||
Log.e(TAG, "Lost location permission. Could not remove updates. $unlikely")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int {
|
||||
Log.i(TAG, "Service started")
|
||||
// Tells the system to not try to recreate the service after it has been killed.
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
serviceHandler.removeCallbacksAndMessages(null)
|
||||
}
|
||||
|
||||
class LocalBinder(val service: CarLocationService) : Binder()
|
||||
}
|
||||
140
app/src/google/java/net/vonforst/evmap/auto/CarSensors.kt
Normal file
140
app/src/google/java/net/vonforst/evmap/auto/CarSensors.kt
Normal file
@@ -0,0 +1,140 @@
|
||||
package net.vonforst.evmap.auto
|
||||
|
||||
import android.content.Context
|
||||
import android.hardware.Sensor
|
||||
import android.hardware.SensorEvent
|
||||
import android.hardware.SensorEventListener
|
||||
import android.hardware.SensorManager
|
||||
import androidx.car.app.CarContext
|
||||
import androidx.car.app.hardware.CarHardwareManager
|
||||
import androidx.car.app.hardware.common.CarValue
|
||||
import androidx.car.app.hardware.common.OnCarDataAvailableListener
|
||||
import androidx.car.app.hardware.info.*
|
||||
import androidx.car.app.hardware.info.CarSensors.UpdateRate
|
||||
import net.vonforst.evmap.BuildConfig
|
||||
import java.util.concurrent.Executor
|
||||
|
||||
/**
|
||||
* CarSensors is not yet implemented for Android Automotive OS
|
||||
* (see docs at https://developer.android.com/reference/androidx/car/app/hardware/info/CarSensors)
|
||||
* so we provide our own implementation based on SensorManager APIs.
|
||||
*/
|
||||
val CarContext.patchedCarSensors: CarSensors
|
||||
get() = if (BuildConfig.FLAVOR_automotive != "automotive") {
|
||||
(this.getCarService(CarContext.HARDWARE_SERVICE) as CarHardwareManager).carSensors
|
||||
} else {
|
||||
CarSensorsWrapper(this)
|
||||
}
|
||||
|
||||
class CarSensorsWrapper(carContext: CarContext) :
|
||||
CarSensors {
|
||||
private val sensorManager = carContext.getSystemService(Context.SENSOR_SERVICE) as SensorManager
|
||||
private val compassListeners: MutableMap<OnCarDataAvailableListener<Compass>, SensorEventListener> =
|
||||
mutableMapOf()
|
||||
|
||||
override fun addAccelerometerListener(
|
||||
rate: Int,
|
||||
executor: Executor,
|
||||
listener: OnCarDataAvailableListener<Accelerometer>
|
||||
) {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
override fun removeAccelerometerListener(listener: OnCarDataAvailableListener<Accelerometer>) {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
override fun addGyroscopeListener(
|
||||
rate: Int,
|
||||
executor: Executor,
|
||||
listener: OnCarDataAvailableListener<Gyroscope>
|
||||
) {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
override fun removeGyroscopeListener(listener: OnCarDataAvailableListener<Gyroscope>) {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
override fun addCompassListener(
|
||||
rate: Int,
|
||||
executor: Executor,
|
||||
listener: OnCarDataAvailableListener<Compass>
|
||||
) {
|
||||
val magSensor = sensorManager.getDefaultSensor(Sensor.TYPE_MAGNETIC_FIELD)
|
||||
val accSensor = sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER)
|
||||
|
||||
if (magSensor == null) {
|
||||
executor.execute {
|
||||
listener.onCarDataAvailable(Compass(CarValue(null, 0, CarValue.STATUS_UNAVAILABLE)))
|
||||
}
|
||||
return
|
||||
}
|
||||
val sensorListener = object : SensorEventListener {
|
||||
var magValues: FloatArray? = null
|
||||
|
||||
// AAOS cars may not provide an acceleration sensor, so we assume acceleration based on
|
||||
// Earth's gravity. May not be correct when driving on other planets.
|
||||
var accValues = floatArrayOf(0f, 0f, SensorManager.GRAVITY_EARTH)
|
||||
val rotMatrix = FloatArray(9)
|
||||
val orientation = FloatArray(3)
|
||||
|
||||
override fun onSensorChanged(event: SensorEvent) {
|
||||
when (event.sensor) {
|
||||
magSensor -> magValues = event.values
|
||||
accSensor -> accValues = event.values
|
||||
}
|
||||
if (magValues == null) return
|
||||
|
||||
SensorManager.getRotationMatrix(rotMatrix, null, accValues, magValues)
|
||||
SensorManager.getOrientation(rotMatrix, orientation)
|
||||
val compassDegrees = orientation.map { Math.toDegrees(it.toDouble()).toFloat() }
|
||||
executor.execute {
|
||||
listener.onCarDataAvailable(
|
||||
Compass(
|
||||
CarValue(
|
||||
compassDegrees,
|
||||
event.timestamp,
|
||||
CarValue.STATUS_SUCCESS
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onAccuracyChanged(sensor: Sensor, accuracy: Int) {
|
||||
}
|
||||
|
||||
}
|
||||
compassListeners[listener] = sensorListener
|
||||
sensorManager.registerListener(sensorListener, magSensor, mapRate(rate))
|
||||
accSensor?.let { sensorManager.registerListener(sensorListener, it, mapRate(rate)) }
|
||||
}
|
||||
|
||||
private fun mapRate(@UpdateRate rate: Int): Int {
|
||||
return when (rate) {
|
||||
CarSensors.UPDATE_RATE_NORMAL -> SensorManager.SENSOR_DELAY_NORMAL
|
||||
CarSensors.UPDATE_RATE_UI -> SensorManager.SENSOR_DELAY_UI
|
||||
CarSensors.UPDATE_RATE_FASTEST -> SensorManager.SENSOR_DELAY_FASTEST
|
||||
else -> throw IllegalArgumentException()
|
||||
}
|
||||
}
|
||||
|
||||
override fun removeCompassListener(listener: OnCarDataAvailableListener<Compass>) {
|
||||
compassListeners[listener]?.let {
|
||||
sensorManager.unregisterListener(it)
|
||||
}
|
||||
}
|
||||
|
||||
override fun addCarHardwareLocationListener(
|
||||
rate: Int,
|
||||
executor: Executor,
|
||||
listener: OnCarDataAvailableListener<CarHardwareLocation>
|
||||
) {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
override fun removeCarHardwareLocationListener(listener: OnCarDataAvailableListener<CarHardwareLocation>) {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
}
|
||||
@@ -15,14 +15,14 @@ import androidx.car.app.model.*
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.graphics.drawable.IconCompat
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import jsonapi.Meta
|
||||
import jsonapi.Relationship
|
||||
import jsonapi.Relationships
|
||||
import jsonapi.ResourceIdentifier
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import moe.banana.jsonapi2.HasMany
|
||||
import moe.banana.jsonapi2.HasOne
|
||||
import moe.banana.jsonapi2.JsonBuffer
|
||||
import moe.banana.jsonapi2.ResourceIdentifier
|
||||
import net.vonforst.evmap.*
|
||||
import net.vonforst.evmap.R
|
||||
import net.vonforst.evmap.api.chargeprice.*
|
||||
import net.vonforst.evmap.model.ChargeLocation
|
||||
import net.vonforst.evmap.storage.AppDatabase
|
||||
@@ -34,7 +34,10 @@ class ChargepriceScreen(ctx: CarContext, val charger: ChargeLocation) : Screen(c
|
||||
private val prefs = PreferenceDataSource(ctx)
|
||||
private val db = AppDatabase.getInstance(carContext)
|
||||
private val api by lazy {
|
||||
ChargepriceApi.create(carContext.getString(R.string.chargeprice_key))
|
||||
ChargepriceApi.create(
|
||||
carContext.getString(R.string.chargeprice_key),
|
||||
carContext.getString(R.string.chargeprice_api_url)
|
||||
)
|
||||
}
|
||||
private var prices: List<ChargePrice>? = null
|
||||
private var meta: ChargepriceChargepointMeta? = null
|
||||
@@ -94,7 +97,7 @@ class ChargepriceScreen(ctx: CarContext, val charger: ChargeLocation) : Screen(c
|
||||
)
|
||||
.build().intent
|
||||
intent.data =
|
||||
Uri.parse("https://www.chargeprice.app/?poi_id=${charger.id}&poi_source=${getDataAdapter()}")
|
||||
Uri.parse(ChargepriceApi.getPoiUrl(charger))
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
try {
|
||||
carContext.startActivity(intent)
|
||||
@@ -169,38 +172,44 @@ class ChargepriceScreen(ctx: CarContext, val charger: ChargeLocation) : Screen(c
|
||||
}
|
||||
|
||||
private fun loadPrices(model: Model?) {
|
||||
val dataAdapter = getDataAdapter() ?: return
|
||||
val dataAdapter = ChargepriceApi.getDataAdapter(charger) ?: return
|
||||
val manufacturer = model?.manufacturer?.value
|
||||
val modelName = getVehicleModel(model?.manufacturer?.value, model?.name?.value)
|
||||
lifecycleScope.launch {
|
||||
try {
|
||||
val car = determineVehicle(manufacturer, modelName)
|
||||
val cpStation = ChargepriceStation.fromEvmap(charger, car.compatibleEvmapConnectors)
|
||||
val result = api.getChargePrices(ChargepriceRequest().apply {
|
||||
this.dataAdapter = dataAdapter
|
||||
station = cpStation
|
||||
vehicle = HasOne(car)
|
||||
tariffs = if (!prefs.chargepriceMyTariffsAll) {
|
||||
val myTariffs = prefs.chargepriceMyTariffs ?: emptySet()
|
||||
HasMany<ChargepriceTariff>(*myTariffs.map {
|
||||
ResourceIdentifier(
|
||||
"tariff",
|
||||
it
|
||||
val result = api.getChargePrices(
|
||||
ChargepriceRequest(
|
||||
dataAdapter = dataAdapter,
|
||||
station = cpStation,
|
||||
vehicle = car,
|
||||
options = ChargepriceOptions(
|
||||
batteryRange = batteryRange.map { it.toDouble() },
|
||||
providerCustomerTariffs = prefs.chargepriceShowProviderCustomerTariffs,
|
||||
maxMonthlyFees = if (prefs.chargepriceNoBaseFee) 0.0 else null,
|
||||
currency = prefs.chargepriceCurrency,
|
||||
allowUnbalancedLoad = prefs.chargepriceAllowUnbalancedLoad
|
||||
),
|
||||
relationships = if (!prefs.chargepriceMyTariffsAll) {
|
||||
val myTariffs = prefs.chargepriceMyTariffs ?: emptySet()
|
||||
Relationships(
|
||||
"tariffs" to Relationship.ToMany(
|
||||
myTariffs.map {
|
||||
ResourceIdentifier(
|
||||
"tariff",
|
||||
id = it
|
||||
)
|
||||
},
|
||||
meta = Meta.from(
|
||||
ChargepriceRequestTariffMeta(ChargepriceInclude.ALWAYS),
|
||||
ChargepriceApi.moshi
|
||||
)
|
||||
)
|
||||
)
|
||||
}.toTypedArray()).apply {
|
||||
meta = JsonBuffer.create(
|
||||
ChargepriceApi.moshi.adapter(ChargepriceRequestTariffMeta::class.java),
|
||||
ChargepriceRequestTariffMeta(ChargepriceInclude.ALWAYS)
|
||||
)
|
||||
}
|
||||
} else null
|
||||
options = ChargepriceOptions(
|
||||
batteryRange = batteryRange.map { it.toDouble() },
|
||||
providerCustomerTariffs = prefs.chargepriceShowProviderCustomerTariffs,
|
||||
maxMonthlyFees = if (prefs.chargepriceNoBaseFee) 0.0 else null,
|
||||
currency = prefs.chargepriceCurrency
|
||||
)
|
||||
}, ChargepriceApi.getChargepriceLanguage())
|
||||
} else null
|
||||
), ChargepriceApi.getChargepriceLanguage()
|
||||
)
|
||||
|
||||
val myTariffs = prefs.chargepriceMyTariffs
|
||||
|
||||
@@ -214,14 +223,16 @@ class ChargepriceScreen(ctx: CarContext, val charger: ChargeLocation) : Screen(c
|
||||
invalidate()
|
||||
return@launch
|
||||
}
|
||||
meta =
|
||||
(result.meta.get<ChargepriceMeta>(ChargepriceApi.moshi.adapter(ChargepriceMeta::class.java)) as ChargepriceMeta).chargePoints.filterIndexed { i, cp ->
|
||||
charger.chargepointsMerged[i].type in car.compatibleEvmapConnectors
|
||||
}.maxByOrNull {
|
||||
it.power
|
||||
}
|
||||
|
||||
prices = result.map { cp ->
|
||||
val metaMapped =
|
||||
result.meta!!.map(ChargepriceMeta::class.java, ChargepriceApi.moshi)!!
|
||||
meta = metaMapped.chargePoints.filterIndexed { i, cp ->
|
||||
charger.chargepointsMerged[i].type in car.compatibleEvmapConnectors
|
||||
}.maxByOrNull {
|
||||
it.power
|
||||
}
|
||||
|
||||
prices = result.data!!.map { cp ->
|
||||
val filteredPrices =
|
||||
cp.chargepointPrices.filter {
|
||||
it.plug == chargepoint.plug && it.power == chargepoint.power
|
||||
@@ -229,15 +240,15 @@ class ChargepriceScreen(ctx: CarContext, val charger: ChargeLocation) : Screen(c
|
||||
if (filteredPrices.isEmpty()) {
|
||||
null
|
||||
} else {
|
||||
cp.clone().apply {
|
||||
cp.copy(
|
||||
chargepointPrices = filteredPrices
|
||||
}
|
||||
)
|
||||
}
|
||||
}.filterNotNull()
|
||||
.sortedBy { it.chargepointPrices.first().price }
|
||||
.sortedByDescending {
|
||||
prefs.chargepriceMyTariffsAll ||
|
||||
myTariffs != null && it.tariff?.get()?.id in myTariffs
|
||||
myTariffs != null && it.tariffId in myTariffs
|
||||
}
|
||||
invalidate()
|
||||
} catch (e: IOException) {
|
||||
@@ -315,10 +326,4 @@ class ChargepriceScreen(ctx: CarContext, val charger: ChargeLocation) : Screen(c
|
||||
}
|
||||
return vehicles[0]
|
||||
}
|
||||
|
||||
private fun getDataAdapter(): String? = when (charger.dataSource) {
|
||||
"goingelectric" -> ChargepriceApi.DATA_SOURCE_GOINGELECTRIC
|
||||
"openchargemap" -> ChargepriceApi.DATA_SOURCE_OPENCHARGEMAP
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,8 @@ package net.vonforst.evmap.auto
|
||||
import android.content.Intent
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Matrix
|
||||
import android.graphics.RectF
|
||||
import android.graphics.drawable.BitmapDrawable
|
||||
import android.net.Uri
|
||||
import android.text.SpannableStringBuilder
|
||||
@@ -19,6 +21,7 @@ import androidx.lifecycle.lifecycleScope
|
||||
import coil.imageLoader
|
||||
import coil.request.ImageRequest
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import net.vonforst.evmap.*
|
||||
@@ -29,18 +32,21 @@ import net.vonforst.evmap.api.createApi
|
||||
import net.vonforst.evmap.api.nameForPlugType
|
||||
import net.vonforst.evmap.api.stringProvider
|
||||
import net.vonforst.evmap.model.ChargeLocation
|
||||
import net.vonforst.evmap.model.Favorite
|
||||
import net.vonforst.evmap.storage.AppDatabase
|
||||
import net.vonforst.evmap.storage.ChargeLocationsRepository
|
||||
import net.vonforst.evmap.storage.PreferenceDataSource
|
||||
import net.vonforst.evmap.ui.ChargerIconGenerator
|
||||
import net.vonforst.evmap.ui.availabilityText
|
||||
import net.vonforst.evmap.ui.getMarkerTint
|
||||
import net.vonforst.evmap.viewmodel.Status
|
||||
import net.vonforst.evmap.viewmodel.getReferenceData
|
||||
import net.vonforst.evmap.viewmodel.awaitFinished
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.format.FormatStyle
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
|
||||
class ChargerDetailScreen(ctx: CarContext, val chargerSparse: ChargeLocation) : Screen(ctx) {
|
||||
var charger: ChargeLocation? = null
|
||||
var photo: Bitmap? = null
|
||||
@@ -48,17 +54,14 @@ class ChargerDetailScreen(ctx: CarContext, val chargerSparse: ChargeLocation) :
|
||||
|
||||
val prefs = PreferenceDataSource(ctx)
|
||||
private val db = AppDatabase.getInstance(carContext)
|
||||
private val api by lazy {
|
||||
createApi(prefs.dataSource, ctx)
|
||||
}
|
||||
private val referenceData = api.getReferenceData(lifecycleScope, carContext)
|
||||
private val repo =
|
||||
ChargeLocationsRepository(createApi(prefs.dataSource, ctx), lifecycleScope, db, prefs)
|
||||
|
||||
private val imageSize = 128 // images should be 128dp according to docs
|
||||
private val imageHeightLarge = 480 // images should be 480 x 854 dp according to docs
|
||||
private val imageWidthLarge = 854
|
||||
private val imageSizeLarge = 480 // images should be 480 x 480 dp according to docs
|
||||
|
||||
private val iconGen =
|
||||
ChargerIconGenerator(carContext, null, oversize = 1.4f, height = imageSize)
|
||||
ChargerIconGenerator(carContext, null, height = imageSize)
|
||||
|
||||
private val maxRows = if (ctx.carAppApiLevel >= 2) {
|
||||
ctx.constraintManager.getContentLimit(ConstraintManager.CONTENT_LIMIT_TYPE_PANE)
|
||||
@@ -66,10 +69,11 @@ class ChargerDetailScreen(ctx: CarContext, val chargerSparse: ChargeLocation) :
|
||||
private val largeImageSupported =
|
||||
ctx.carAppApiLevel >= 4 // since API 4, Row.setImage is supported
|
||||
|
||||
private var favorite: Favorite? = null
|
||||
private var favoriteUpdateJob: Job? = null
|
||||
|
||||
init {
|
||||
referenceData.observe(this) {
|
||||
loadCharger()
|
||||
}
|
||||
loadCharger()
|
||||
}
|
||||
|
||||
override fun onGetTemplate(): Template {
|
||||
@@ -78,8 +82,10 @@ class ChargerDetailScreen(ctx: CarContext, val chargerSparse: ChargeLocation) :
|
||||
return PaneTemplate.Builder(
|
||||
Pane.Builder().apply {
|
||||
charger?.let { charger ->
|
||||
if (largeImageSupported && photo != null) {
|
||||
setImage(CarIcon.Builder(IconCompat.createWithBitmap(photo)).build())
|
||||
if (largeImageSupported) {
|
||||
photo?.let {
|
||||
setImage(CarIcon.Builder(IconCompat.createWithBitmap(it)).build())
|
||||
}
|
||||
}
|
||||
generateRows(charger).forEach { addRow(it) }
|
||||
addAction(
|
||||
@@ -121,31 +127,86 @@ class ChargerDetailScreen(ctx: CarContext, val chargerSparse: ChargeLocation) :
|
||||
).apply {
|
||||
setTitle(chargerSparse.name)
|
||||
setHeaderAction(Action.BACK)
|
||||
setActionStrip(
|
||||
ActionStrip.Builder().addAction(
|
||||
Action.Builder()
|
||||
.setTitle(carContext.getString(R.string.open_in_app))
|
||||
.setOnClickListener(ParkedOnlyOnClickListener.create {
|
||||
val intent = Intent(carContext, MapsActivity::class.java)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
.putExtra(EXTRA_CHARGER_ID, chargerSparse.id)
|
||||
.putExtra(EXTRA_LAT, chargerSparse.coordinates.lat)
|
||||
.putExtra(EXTRA_LON, chargerSparse.coordinates.lng)
|
||||
carContext.startActivity(intent)
|
||||
CarToast.makeText(
|
||||
carContext,
|
||||
R.string.opened_on_phone,
|
||||
CarToast.LENGTH_LONG
|
||||
).show()
|
||||
})
|
||||
.build()
|
||||
).build()
|
||||
)
|
||||
charger?.let { charger ->
|
||||
setActionStrip(
|
||||
ActionStrip.Builder().apply {
|
||||
if (BuildConfig.FLAVOR_automotive != "automotive") {
|
||||
// show "Open in app" action if not running on Android Automotive
|
||||
addAction(
|
||||
Action.Builder()
|
||||
.setTitle(carContext.getString(R.string.open_in_app))
|
||||
.setOnClickListener(ParkedOnlyOnClickListener.create {
|
||||
val intent = Intent(carContext, MapsActivity::class.java)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
.putExtra(EXTRA_CHARGER_ID, chargerSparse.id)
|
||||
.putExtra(EXTRA_LAT, chargerSparse.coordinates.lat)
|
||||
.putExtra(EXTRA_LON, chargerSparse.coordinates.lng)
|
||||
carContext.startActivity(intent)
|
||||
CarToast.makeText(
|
||||
carContext,
|
||||
R.string.opened_on_phone,
|
||||
CarToast.LENGTH_LONG
|
||||
).show()
|
||||
})
|
||||
.build()
|
||||
)
|
||||
}
|
||||
// show fav action
|
||||
addAction(Action.Builder()
|
||||
.setOnClickListener {
|
||||
favorite?.let {
|
||||
deleteFavorite(it)
|
||||
} ?: run {
|
||||
insertFavorite(charger)
|
||||
}
|
||||
}
|
||||
.setIcon(
|
||||
CarIcon.Builder(
|
||||
IconCompat.createWithResource(
|
||||
carContext,
|
||||
if (favorite != null) {
|
||||
R.drawable.ic_fav
|
||||
} else {
|
||||
R.drawable.ic_fav_no
|
||||
}
|
||||
)
|
||||
)
|
||||
.setTint(CarColor.DEFAULT).build()
|
||||
)
|
||||
.build())
|
||||
.build()
|
||||
}.build()
|
||||
)
|
||||
}
|
||||
}.build()
|
||||
}
|
||||
|
||||
private fun insertFavorite(charger: ChargeLocation) {
|
||||
if (favoriteUpdateJob?.isCompleted == false) return
|
||||
favoriteUpdateJob = lifecycleScope.launch {
|
||||
db.chargeLocationsDao().insert(charger)
|
||||
val fav = Favorite(
|
||||
chargerId = charger.id,
|
||||
chargerDataSource = charger.dataSource
|
||||
)
|
||||
val id = db.favoritesDao().insert(fav)[0]
|
||||
favorite = fav.copy(favoriteId = id)
|
||||
invalidate()
|
||||
}
|
||||
}
|
||||
|
||||
private fun deleteFavorite(fav: Favorite) {
|
||||
if (favoriteUpdateJob?.isCompleted == false) return
|
||||
favoriteUpdateJob = lifecycleScope.launch {
|
||||
db.favoritesDao().delete(fav)
|
||||
favorite = null
|
||||
invalidate()
|
||||
}
|
||||
}
|
||||
|
||||
private fun generateRows(charger: ChargeLocation): List<Row> {
|
||||
val rows = mutableListOf<Row>()
|
||||
val photo = photo
|
||||
|
||||
// Row 1: address + chargepoints
|
||||
rows.add(Row.Builder().apply {
|
||||
@@ -207,7 +268,7 @@ class ChargerDetailScreen(ctx: CarContext, val chargerSparse: ChargeLocation) :
|
||||
setTitle(operatorText)
|
||||
charger.cost?.let {
|
||||
addText(it.getStatusText(carContext, emoji = true))
|
||||
(it.descriptionShort ?: it.descriptionLong)?.let { addText(it) }
|
||||
it.getDetailText()?.let { addText(it) }
|
||||
}
|
||||
}.build())
|
||||
// row 3: fault report (if exists)
|
||||
@@ -232,8 +293,10 @@ class ChargerDetailScreen(ctx: CarContext, val chargerSparse: ChargeLocation) :
|
||||
}
|
||||
// row 4: opening hours + location description
|
||||
charger.openinghours?.let { hours ->
|
||||
val title =
|
||||
hours.getStatusText(carContext).ifEmpty { carContext.getString(R.string.hours) }
|
||||
rows.add(Row.Builder().apply {
|
||||
setTitle(hours.getStatusText(carContext))
|
||||
setTitle(title)
|
||||
hours.description?.let { addText(it) }
|
||||
charger.locationDescription?.let { addText(it) }
|
||||
}.build())
|
||||
@@ -293,25 +356,21 @@ class ChargerDetailScreen(ctx: CarContext, val chargerSparse: ChargeLocation) :
|
||||
}
|
||||
|
||||
private fun loadCharger() {
|
||||
val referenceData = referenceData.value ?: return
|
||||
lifecycleScope.launch {
|
||||
val response = api.getChargepointDetail(referenceData, chargerSparse.id)
|
||||
favorite = db.favoritesDao().findFavorite(chargerSparse.id, chargerSparse.dataSource)
|
||||
|
||||
val response = repo.getChargepointDetail(chargerSparse.id).awaitFinished()
|
||||
if (response.status == Status.SUCCESS) {
|
||||
val charger = response.data!!
|
||||
|
||||
val photo = charger.photos?.firstOrNull()
|
||||
photo?.let {
|
||||
val density = carContext.resources.displayMetrics.density
|
||||
val url = if (largeImageSupported) {
|
||||
photo.getUrl(
|
||||
width = (imageWidthLarge * density).roundToInt(),
|
||||
height = (imageHeightLarge * density).roundToInt()
|
||||
)
|
||||
} else {
|
||||
photo.getUrl(size = (imageSize * density).roundToInt())
|
||||
}
|
||||
val size =
|
||||
(density * if (largeImageSupported) imageSizeLarge else imageSize).roundToInt()
|
||||
val url = photo.getUrl(size = size)
|
||||
val request = ImageRequest.Builder(carContext).data(url).build()
|
||||
var img =
|
||||
val img =
|
||||
(carContext.imageLoader.execute(request).drawable as BitmapDrawable).bitmap
|
||||
|
||||
// draw icon on top of image
|
||||
@@ -321,19 +380,29 @@ class ChargerDetailScreen(ctx: CarContext, val chargerSparse: ChargeLocation) :
|
||||
multi = charger.isMulti()
|
||||
)
|
||||
|
||||
img = img.copy(Bitmap.Config.ARGB_8888, true)
|
||||
val outImg = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
|
||||
val iconSmall = icon.scale(
|
||||
(img.height * 0.4 / icon.height * icon.width).roundToInt(),
|
||||
(img.height * 0.4).roundToInt()
|
||||
(size * 0.4 / icon.height * icon.width).roundToInt(),
|
||||
(size * 0.4).roundToInt()
|
||||
)
|
||||
val canvas = Canvas(outImg)
|
||||
|
||||
val m = Matrix()
|
||||
m.setRectToRect(
|
||||
RectF(0f, 0f, img.width.toFloat(), img.height.toFloat()),
|
||||
RectF(0f, 0f, size.toFloat(), size.toFloat()),
|
||||
Matrix.ScaleToFit.CENTER
|
||||
)
|
||||
canvas.drawBitmap(
|
||||
img.copy(Bitmap.Config.ARGB_8888, false), m, null
|
||||
)
|
||||
val canvas = Canvas(img)
|
||||
canvas.drawBitmap(
|
||||
iconSmall,
|
||||
0f,
|
||||
(img.height - iconSmall.height * 1.1).toFloat(),
|
||||
(size - iconSmall.height * 1.1).toFloat(),
|
||||
null
|
||||
)
|
||||
this@ChargerDetailScreen.photo = img
|
||||
this@ChargerDetailScreen.photo = outImg
|
||||
}
|
||||
this@ChargerDetailScreen.charger = charger
|
||||
|
||||
|
||||
@@ -1,39 +1,35 @@
|
||||
package net.vonforst.evmap.auto
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.app.Application
|
||||
import android.text.SpannableStringBuilder
|
||||
import android.text.Spanned
|
||||
import androidx.car.app.CarContext
|
||||
import androidx.car.app.CarToast
|
||||
import androidx.car.app.Screen
|
||||
import androidx.car.app.constraints.ConstraintManager
|
||||
import androidx.car.app.model.*
|
||||
import androidx.core.graphics.drawable.IconCompat
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import kotlinx.coroutines.launch
|
||||
import net.vonforst.evmap.R
|
||||
import net.vonforst.evmap.model.FILTERS_CUSTOM
|
||||
import net.vonforst.evmap.model.FILTERS_DISABLED
|
||||
import net.vonforst.evmap.model.FILTERS_FAVORITES
|
||||
import net.vonforst.evmap.model.*
|
||||
import net.vonforst.evmap.storage.AppDatabase
|
||||
import net.vonforst.evmap.storage.FilterProfile
|
||||
import net.vonforst.evmap.storage.PreferenceDataSource
|
||||
import net.vonforst.evmap.viewmodel.FilterViewModel
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
class FilterScreen(ctx: CarContext) : Screen(ctx) {
|
||||
@androidx.car.app.annotations.ExperimentalCarApi
|
||||
class FilterScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx) {
|
||||
private val prefs = PreferenceDataSource(ctx)
|
||||
private val db = AppDatabase.getInstance(ctx)
|
||||
val filterProfiles: LiveData<List<FilterProfile>> by lazy {
|
||||
db.filterProfileDao().getProfiles(prefs.dataSource)
|
||||
}
|
||||
private val maxRows = 6
|
||||
private val checkIcon =
|
||||
CarIcon.Builder(IconCompat.createWithResource(carContext, R.drawable.ic_check)).build()
|
||||
private val emptyIcon: CarIcon
|
||||
|
||||
init {
|
||||
val size = (ctx.resources.displayMetrics.density * 24).roundToInt()
|
||||
emptyIcon = Bitmap.createBitmap(
|
||||
size,
|
||||
size,
|
||||
Bitmap.Config.ARGB_8888
|
||||
).asCarIcon()
|
||||
}
|
||||
private val maxRows = if (ctx.carAppApiLevel >= 2) {
|
||||
ctx.constraintManager.getContentLimit(ConstraintManager.CONTENT_LIMIT_TYPE_LIST)
|
||||
} else 6
|
||||
|
||||
init {
|
||||
filterProfiles.observe(this) {
|
||||
@@ -42,15 +38,59 @@ class FilterScreen(ctx: CarContext) : Screen(ctx) {
|
||||
}
|
||||
|
||||
override fun onGetTemplate(): Template {
|
||||
val filterStatus =
|
||||
prefs.filterStatus.takeUnless { it == FILTERS_CUSTOM || it == FILTERS_FAVORITES }
|
||||
?: FILTERS_DISABLED
|
||||
val filterStatus = prefs.filterStatus
|
||||
return ListTemplate.Builder().apply {
|
||||
filterProfiles.value?.let {
|
||||
setSingleList(buildFilterProfilesList(it.take(maxRows), filterStatus))
|
||||
setSingleList(buildFilterProfilesList(it, filterStatus))
|
||||
} ?: setLoading(true)
|
||||
setTitle(carContext.getString(R.string.menu_filter))
|
||||
setHeaderAction(Action.BACK)
|
||||
setActionStrip(
|
||||
ActionStrip.Builder().apply {
|
||||
addAction(Action.Builder().apply {
|
||||
setIcon(
|
||||
CarIcon.Builder(
|
||||
IconCompat.createWithResource(
|
||||
carContext,
|
||||
if (prefs.placeSearchResultAndroidAuto != null) {
|
||||
R.drawable.ic_search_off
|
||||
} else {
|
||||
R.drawable.ic_search
|
||||
}
|
||||
)
|
||||
).build()
|
||||
|
||||
)
|
||||
setOnClickListener(ParkedOnlyOnClickListener.create {
|
||||
if (prefs.placeSearchResultAndroidAuto != null) {
|
||||
prefs.placeSearchResultAndroidAutoName = null
|
||||
prefs.placeSearchResultAndroidAuto = null
|
||||
screenManager.pop()
|
||||
} else {
|
||||
screenManager.push(PlaceSearchScreen(carContext, session))
|
||||
}
|
||||
})
|
||||
}.build())
|
||||
addAction(Action.Builder().apply {
|
||||
setIcon(
|
||||
CarIcon.Builder(
|
||||
IconCompat.createWithResource(
|
||||
carContext,
|
||||
R.drawable.ic_edit
|
||||
)
|
||||
).build()
|
||||
|
||||
)
|
||||
setOnClickListener(ParkedOnlyOnClickListener.create {
|
||||
lifecycleScope.launch {
|
||||
db.filterValueDao()
|
||||
.copyFiltersToCustom(filterStatus, prefs.dataSource)
|
||||
screenManager.push(EditFiltersScreen(carContext))
|
||||
}
|
||||
})
|
||||
}.build())
|
||||
}.build()
|
||||
)
|
||||
}.build()
|
||||
}
|
||||
|
||||
@@ -58,36 +98,325 @@ class FilterScreen(ctx: CarContext) : Screen(ctx) {
|
||||
profiles: List<FilterProfile>,
|
||||
filterStatus: Long
|
||||
): ItemList {
|
||||
val extraRows = if (FILTERS_CUSTOM == filterStatus) 3 else 2
|
||||
val profilesToShow =
|
||||
profiles.sortedByDescending { it.id == filterStatus }.take(maxRows - extraRows)
|
||||
return ItemList.Builder().apply {
|
||||
addItem(Row.Builder().apply {
|
||||
setTitle(carContext.getString(R.string.no_filters))
|
||||
if (FILTERS_DISABLED == filterStatus) {
|
||||
setImage(checkIcon)
|
||||
} else {
|
||||
setImage(emptyIcon)
|
||||
}
|
||||
setOnClickListener {
|
||||
prefs.filterStatus = FILTERS_DISABLED
|
||||
screenManager.pop()
|
||||
}
|
||||
}.build())
|
||||
profiles.forEach {
|
||||
addItem(Row.Builder().apply {
|
||||
setTitle(carContext.getString(R.string.filter_favorites))
|
||||
}.build())
|
||||
profilesToShow.forEach {
|
||||
addItem(Row.Builder().apply {
|
||||
val name =
|
||||
it.name.ifEmpty { carContext.getString(R.string.unnamed_filter_profile) }
|
||||
setTitle(name)
|
||||
if (it.id == filterStatus) {
|
||||
setImage(checkIcon)
|
||||
} else {
|
||||
setImage(emptyIcon)
|
||||
}.build())
|
||||
}
|
||||
if (FILTERS_CUSTOM == filterStatus) {
|
||||
addItem(Row.Builder().apply {
|
||||
setTitle(carContext.getString(R.string.filter_custom))
|
||||
}.build())
|
||||
}
|
||||
setSelectedIndex(when (filterStatus) {
|
||||
FILTERS_DISABLED -> 0
|
||||
FILTERS_FAVORITES -> 1
|
||||
FILTERS_CUSTOM -> profilesToShow.size + 2
|
||||
else -> profilesToShow.indexOfFirst { it.id == filterStatus } + 2
|
||||
})
|
||||
setOnSelectedListener { index ->
|
||||
onItemClick(
|
||||
when (index) {
|
||||
0 -> FILTERS_DISABLED
|
||||
1 -> FILTERS_FAVORITES
|
||||
profilesToShow.size + 2 -> FILTERS_CUSTOM
|
||||
else -> profilesToShow[index - 2].id
|
||||
}
|
||||
setOnClickListener {
|
||||
prefs.filterStatus = it.id
|
||||
screenManager.pop()
|
||||
)
|
||||
}
|
||||
}.build()
|
||||
}
|
||||
|
||||
private fun onItemClick(id: Long) {
|
||||
prefs.filterStatus = id
|
||||
screenManager.pop()
|
||||
}
|
||||
}
|
||||
|
||||
@androidx.car.app.annotations.ExperimentalCarApi
|
||||
class EditFiltersScreen(ctx: CarContext) : Screen(ctx) {
|
||||
private val vm = FilterViewModel(carContext.applicationContext as Application)
|
||||
|
||||
private val maxRows = if (ctx.carAppApiLevel >= 2) {
|
||||
ctx.constraintManager.getContentLimit(ConstraintManager.CONTENT_LIMIT_TYPE_LIST)
|
||||
} else 6
|
||||
|
||||
init {
|
||||
vm.filtersWithValue.observe(this) {
|
||||
vm.filterProfile.observe(this) {
|
||||
invalidate()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onGetTemplate(): Template {
|
||||
val currentProfileName = vm.filterProfile.value?.name
|
||||
|
||||
return ListTemplate.Builder().apply {
|
||||
vm.filtersWithValue.value?.let { filtersWithValue ->
|
||||
setSingleList(buildFiltersList(filtersWithValue.take(maxRows)))
|
||||
} ?: setLoading(true)
|
||||
|
||||
setTitle(currentProfileName?.let {
|
||||
carContext.getString(
|
||||
R.string.edit_filter_profile,
|
||||
it
|
||||
)
|
||||
} ?: carContext.getString(R.string.menu_filter))
|
||||
|
||||
setHeaderAction(Action.BACK)
|
||||
setActionStrip(ActionStrip.Builder().apply {
|
||||
val currentProfile = vm.filterProfile.value
|
||||
if (currentProfile != null) {
|
||||
addAction(Action.Builder().apply {
|
||||
setIcon(
|
||||
CarIcon.Builder(
|
||||
IconCompat.createWithResource(
|
||||
carContext,
|
||||
R.drawable.ic_delete
|
||||
)
|
||||
).build()
|
||||
|
||||
)
|
||||
setOnClickListener {
|
||||
lifecycleScope.launch {
|
||||
vm.deleteCurrentProfile()
|
||||
CarToast.makeText(
|
||||
carContext,
|
||||
carContext.getString(
|
||||
R.string.deleted_filterprofile,
|
||||
currentProfile.name
|
||||
),
|
||||
CarToast.LENGTH_SHORT
|
||||
).show()
|
||||
invalidate()
|
||||
screenManager.pop()
|
||||
}
|
||||
}
|
||||
}.build())
|
||||
}
|
||||
addAction(
|
||||
Action.Builder()
|
||||
.setIcon(
|
||||
CarIcon.Builder(
|
||||
IconCompat.createWithResource(
|
||||
carContext,
|
||||
R.drawable.ic_save
|
||||
)
|
||||
).build()
|
||||
)
|
||||
.setOnClickListener {
|
||||
val textPromptScreen = TextPromptScreen(
|
||||
carContext,
|
||||
R.string.save_as_profile,
|
||||
R.string.save_profile_enter_name,
|
||||
currentProfileName
|
||||
)
|
||||
screenManager.pushForResult(textPromptScreen) { name ->
|
||||
if (name == null) return@pushForResult
|
||||
lifecycleScope.launch {
|
||||
vm.saveAsProfile(name as String)
|
||||
screenManager.popTo(MapScreen.MARKER)
|
||||
}
|
||||
}
|
||||
}
|
||||
.build()
|
||||
)
|
||||
}.build())
|
||||
}.build()
|
||||
}
|
||||
|
||||
private fun buildFiltersList(filters: List<FilterWithValue<out FilterValue>>): ItemList {
|
||||
return ItemList.Builder().apply {
|
||||
filters.forEach {
|
||||
val filter = it.filter
|
||||
val value = it.value
|
||||
addItem(Row.Builder().apply {
|
||||
setTitle(filter.name)
|
||||
when (filter) {
|
||||
is BooleanFilter -> {
|
||||
setToggle(Toggle.Builder {
|
||||
(value as BooleanFilterValue).value = it
|
||||
lifecycleScope.launch { vm.saveFilterValues() }
|
||||
}.setChecked((value as BooleanFilterValue).value).build())
|
||||
}
|
||||
is MultipleChoiceFilter -> {
|
||||
setBrowsable(true)
|
||||
setOnClickListener {
|
||||
screenManager.pushForResult(
|
||||
MultipleChoiceFilterScreen(
|
||||
carContext,
|
||||
filter,
|
||||
value as MultipleChoiceFilterValue
|
||||
)
|
||||
) {
|
||||
lifecycleScope.launch { vm.saveFilterValues() }
|
||||
}
|
||||
}
|
||||
addText(
|
||||
if ((value as MultipleChoiceFilterValue).all) {
|
||||
carContext.getString(R.string.all_selected)
|
||||
} else {
|
||||
carContext.getString(
|
||||
R.string.number_selected,
|
||||
value.values.size
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
is SliderFilter -> {
|
||||
setBrowsable(true)
|
||||
addText((value as SliderFilterValue).value.toString() + " " + filter.unit)
|
||||
setOnClickListener {
|
||||
screenManager.pushForResult(
|
||||
SliderFilterScreen(
|
||||
carContext,
|
||||
filter,
|
||||
value
|
||||
)
|
||||
) {
|
||||
lifecycleScope.launch { vm.saveFilterValues() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}.build())
|
||||
}
|
||||
setNoItemsMessage(carContext.getString(R.string.filterprofiles_empty_state))
|
||||
}.build()
|
||||
}
|
||||
}
|
||||
|
||||
class MultipleChoiceFilterScreen(
|
||||
ctx: CarContext,
|
||||
val filter: MultipleChoiceFilter,
|
||||
val value: MultipleChoiceFilterValue
|
||||
) : MultiSelectSearchScreen<Pair<String, String>>(ctx) {
|
||||
override val isMultiSelect = true
|
||||
override val shouldShowSelectAll = true
|
||||
|
||||
override fun isSelected(it: Pair<String, String>): Boolean =
|
||||
value.all || value.values.contains(it.first)
|
||||
|
||||
override fun toggleSelected(item: Pair<String, String>) {
|
||||
if (isSelected(item)) {
|
||||
val values = if (value.all) filter.choices.keys else value.values
|
||||
value.values = values.minus(item.first).toMutableSet()
|
||||
value.all = false
|
||||
} else {
|
||||
value.values.add(item.first)
|
||||
if (value.values == filter.choices.keys) {
|
||||
value.all = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun selectAll() {
|
||||
value.all = true
|
||||
super.selectAll()
|
||||
}
|
||||
|
||||
override fun selectNone() {
|
||||
value.all = false
|
||||
value.values = mutableSetOf()
|
||||
super.selectNone()
|
||||
}
|
||||
|
||||
override fun getLabel(it: Pair<String, String>): String = it.second
|
||||
|
||||
override suspend fun loadData(): List<Pair<String, String>> {
|
||||
return filter.choices.entries.map { it.toPair() }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class SliderFilterScreen(
|
||||
ctx: CarContext,
|
||||
val filter: SliderFilter,
|
||||
val value: SliderFilterValue
|
||||
) : Screen(ctx) {
|
||||
override fun onGetTemplate(): Template {
|
||||
return PaneTemplate.Builder(
|
||||
Pane.Builder().apply {
|
||||
addRow(Row.Builder().apply {
|
||||
setTitle(filter.name)
|
||||
addText(value.value.toString() + " " + filter.unit)
|
||||
addText(generateSlider())
|
||||
}.build())
|
||||
addAction(Action.Builder().apply {
|
||||
setIcon(
|
||||
CarIcon.Builder(
|
||||
IconCompat.createWithResource(
|
||||
carContext,
|
||||
R.drawable.ic_remove
|
||||
)
|
||||
).build()
|
||||
)
|
||||
setOnClickListener(::decrease)
|
||||
}.build())
|
||||
addAction(Action.Builder().apply {
|
||||
setIcon(
|
||||
CarIcon.Builder(
|
||||
IconCompat.createWithResource(
|
||||
carContext,
|
||||
R.drawable.ic_add
|
||||
)
|
||||
).build()
|
||||
)
|
||||
setOnClickListener(::increase)
|
||||
}.build())
|
||||
}.build()
|
||||
).apply {
|
||||
setHeaderAction(Action.BACK)
|
||||
}.build()
|
||||
}
|
||||
|
||||
private fun generateSlider(): CharSequence {
|
||||
val bar = "━"
|
||||
val dot = "⬤"
|
||||
val length = 30
|
||||
|
||||
val position =
|
||||
((filter.inverseMapping(value.value) - filter.min) / (filter.max - filter.min).toDouble() * length).roundToInt()
|
||||
|
||||
val text = SpannableStringBuilder()
|
||||
text.append(
|
||||
bar.repeat(position),
|
||||
ForegroundCarColorSpan.create(CarColor.SECONDARY),
|
||||
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
|
||||
)
|
||||
text.append(
|
||||
dot,
|
||||
ForegroundCarColorSpan.create(CarColor.SECONDARY),
|
||||
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
|
||||
)
|
||||
text.append(bar.repeat(length - position))
|
||||
|
||||
return text
|
||||
}
|
||||
|
||||
private fun increase() {
|
||||
var valueInternal = filter.inverseMapping(value.value)
|
||||
if (valueInternal < filter.max) valueInternal += 1
|
||||
value.value = filter.mapping(valueInternal)
|
||||
invalidate()
|
||||
}
|
||||
|
||||
private fun decrease() {
|
||||
var valueInternal = filter.inverseMapping(value.value)
|
||||
if (valueInternal > filter.min) valueInternal -= 1
|
||||
value.value = filter.mapping(valueInternal)
|
||||
invalidate()
|
||||
}
|
||||
}
|
||||
@@ -13,160 +13,195 @@ import androidx.car.app.hardware.info.EnergyLevel
|
||||
import androidx.car.app.model.*
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.graphics.drawable.IconCompat
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.OnLifecycleEvent
|
||||
import androidx.lifecycle.DefaultLifecycleObserver
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.car2go.maps.model.LatLng
|
||||
import kotlinx.coroutines.*
|
||||
import net.vonforst.evmap.BuildConfig
|
||||
import net.vonforst.evmap.R
|
||||
import net.vonforst.evmap.api.availability.ChargeLocationStatus
|
||||
import net.vonforst.evmap.api.availability.getAvailability
|
||||
import net.vonforst.evmap.api.createApi
|
||||
import net.vonforst.evmap.api.stringProvider
|
||||
import net.vonforst.evmap.model.ChargeLocation
|
||||
import net.vonforst.evmap.model.FILTERS_CUSTOM
|
||||
import net.vonforst.evmap.model.FILTERS_DISABLED
|
||||
import net.vonforst.evmap.model.FILTERS_FAVORITES
|
||||
import net.vonforst.evmap.model.FilterValue
|
||||
import net.vonforst.evmap.model.FilterWithValue
|
||||
import net.vonforst.evmap.storage.AppDatabase
|
||||
import net.vonforst.evmap.storage.ChargeLocationsRepository
|
||||
import net.vonforst.evmap.storage.PreferenceDataSource
|
||||
import net.vonforst.evmap.ui.availabilityText
|
||||
import net.vonforst.evmap.ui.getMarkerTint
|
||||
import net.vonforst.evmap.utils.distanceBetween
|
||||
import net.vonforst.evmap.viewmodel.Status
|
||||
import net.vonforst.evmap.viewmodel.awaitFinished
|
||||
import net.vonforst.evmap.viewmodel.filtersWithValue
|
||||
import net.vonforst.evmap.viewmodel.getFilterValues
|
||||
import net.vonforst.evmap.viewmodel.getFilters
|
||||
import net.vonforst.evmap.viewmodel.getReferenceData
|
||||
import java.io.IOException
|
||||
import java.time.Duration
|
||||
import java.time.Instant
|
||||
import java.time.ZonedDateTime
|
||||
import kotlin.collections.set
|
||||
import kotlin.math.min
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* Main map screen showing either nearby chargers or favorites
|
||||
*/
|
||||
class MapScreen(ctx: CarContext, val session: EVMapSession, val favorites: Boolean = false) :
|
||||
Screen(ctx), LocationAwareScreen {
|
||||
private var updateCoroutine: Job? = null
|
||||
private var numUpdates = 0
|
||||
@androidx.car.app.annotations.ExperimentalCarApi
|
||||
class MapScreen(ctx: CarContext, val session: EVMapSession) :
|
||||
Screen(ctx), LocationAwareScreen, OnContentRefreshListener,
|
||||
ItemList.OnItemVisibilityChangedListener, DefaultLifecycleObserver {
|
||||
companion object {
|
||||
val MARKER = "map"
|
||||
}
|
||||
|
||||
/* Updating map contents is disabled - if the user uses Chargeprice from the charger
|
||||
detail screen, this already means 4 steps, after which the app would crash.
|
||||
follow https://issuetracker.google.com/issues/176694222 for updates how to solve this. */
|
||||
private val maxNumUpdates = 1
|
||||
private var updateCoroutine: Job? = null
|
||||
private var availabilityUpdateCoroutine: Job? = null
|
||||
|
||||
private var visibleStart: Int? = null
|
||||
private var visibleEnd: Int? = null
|
||||
|
||||
private var location: Location? = null
|
||||
private var lastChargerUpdateLocation: Location? = null
|
||||
private var lastDistanceUpdateTime: Instant? = null
|
||||
private var chargers: List<ChargeLocation>? = null
|
||||
private var prefs = PreferenceDataSource(ctx)
|
||||
private val db = AppDatabase.getInstance(carContext)
|
||||
private val api by lazy {
|
||||
createApi(prefs.dataSource, ctx)
|
||||
}
|
||||
private val repo =
|
||||
ChargeLocationsRepository(createApi(prefs.dataSource, ctx), lifecycleScope, db, prefs)
|
||||
private val searchRadius = 5 // kilometers
|
||||
private val chargerUpdateThreshold = 2000 // meters
|
||||
private val distanceUpdateThreshold = Duration.ofSeconds(15)
|
||||
private val availabilityUpdateThreshold = Duration.ofMinutes(1)
|
||||
private var availabilities: MutableMap<Long, Pair<ZonedDateTime, ChargeLocationStatus>> =
|
||||
private var availabilities: MutableMap<Long, Pair<ZonedDateTime, ChargeLocationStatus?>> =
|
||||
HashMap()
|
||||
private val maxRows = if (ctx.carAppApiLevel >= 2) {
|
||||
ctx.constraintManager.getContentLimit(ConstraintManager.CONTENT_LIMIT_TYPE_PLACE_LIST)
|
||||
min(
|
||||
ctx.constraintManager.getContentLimit(ConstraintManager.CONTENT_LIMIT_TYPE_PLACE_LIST),
|
||||
25
|
||||
)
|
||||
} else 6
|
||||
|
||||
private val referenceData = api.getReferenceData(lifecycleScope, carContext)
|
||||
private val filterStatus = MutableLiveData<Long>().apply {
|
||||
value = prefs.filterStatus.takeUnless { it == FILTERS_CUSTOM || it == FILTERS_FAVORITES }
|
||||
?: FILTERS_DISABLED
|
||||
}
|
||||
private val filterValues = db.filterValueDao().getFilterValues(filterStatus, prefs.dataSource)
|
||||
private val filters = api.getFilters(referenceData, carContext.stringProvider())
|
||||
private val filtersWithValue = filtersWithValue(filters, filterValues)
|
||||
private var filterStatus = prefs.filterStatus
|
||||
private var filtersWithValue: List<FilterWithValue<FilterValue>>? = null
|
||||
|
||||
private val hardwareMan = ctx.getCarService(CarContext.HARDWARE_SERVICE) as CarHardwareManager
|
||||
private val hardwareMan: CarHardwareManager by lazy {
|
||||
ctx.getCarService(CarContext.HARDWARE_SERVICE) as CarHardwareManager
|
||||
}
|
||||
private var energyLevel: EnergyLevel? = null
|
||||
private val permissions = if (BuildConfig.FLAVOR_automotive == "automotive") {
|
||||
listOf(
|
||||
"android.car.permission.CAR_ENERGY",
|
||||
"android.car.permission.CAR_ENERGY_PORTS",
|
||||
"android.car.permission.READ_CAR_DISPLAY_UNITS",
|
||||
)
|
||||
} else {
|
||||
listOf(
|
||||
"com.google.android.gms.permission.CAR_FUEL"
|
||||
)
|
||||
}
|
||||
|
||||
private var searchLocation: LatLng? = null
|
||||
|
||||
init {
|
||||
filtersWithValue.observe(this) {
|
||||
loadChargers()
|
||||
}
|
||||
lifecycle.addObserver(this)
|
||||
marker = MARKER
|
||||
}
|
||||
|
||||
override fun onGetTemplate(): Template {
|
||||
session.requestLocationUpdates()
|
||||
|
||||
session.mapScreen = this
|
||||
return PlaceListMapTemplate.Builder().apply {
|
||||
setTitle(
|
||||
carContext.getString(
|
||||
if (favorites) {
|
||||
prefs.placeSearchResultAndroidAutoName?.let {
|
||||
carContext.getString(R.string.auto_chargers_near_location, it)
|
||||
} ?: carContext.getString(
|
||||
if (filterStatus == FILTERS_FAVORITES) {
|
||||
R.string.auto_favorites
|
||||
} else {
|
||||
R.string.auto_chargers_closeby
|
||||
}
|
||||
)
|
||||
)
|
||||
location?.let {
|
||||
setAnchor(Place.Builder(CarLocation.create(it)).build())
|
||||
searchLocation?.let {
|
||||
setAnchor(Place.Builder(CarLocation.create(it.latitude, it.longitude)).apply {
|
||||
if (prefs.placeSearchResultAndroidAutoName != null) {
|
||||
setMarker(
|
||||
PlaceMarker.Builder()
|
||||
.setColor(CarColor.PRIMARY)
|
||||
.build()
|
||||
)
|
||||
}
|
||||
}.build())
|
||||
} ?: setLoading(true)
|
||||
chargers?.take(maxRows)?.let { chargerList ->
|
||||
val builder = ItemList.Builder()
|
||||
// only show the city if not all chargers are in the same city
|
||||
val showCity = chargerList.map { it.address.city }.distinct().size > 1
|
||||
val showCity = chargerList.map { it.address?.city }.distinct().size > 1
|
||||
chargerList.forEach { charger ->
|
||||
builder.addItem(formatCharger(charger, showCity))
|
||||
}
|
||||
builder.setNoItemsMessage(
|
||||
carContext.getString(
|
||||
if (favorites) {
|
||||
if (filterStatus == FILTERS_FAVORITES) {
|
||||
R.string.auto_no_favorites_found
|
||||
} else {
|
||||
R.string.auto_no_chargers_found
|
||||
}
|
||||
)
|
||||
)
|
||||
builder.setOnItemsVisibilityChangedListener(this@MapScreen)
|
||||
setItemList(builder.build())
|
||||
} ?: setLoading(true)
|
||||
setCurrentLocationEnabled(true)
|
||||
setHeaderAction(Action.BACK)
|
||||
if (!favorites) {
|
||||
val filtersCount = filtersWithValue.value?.count {
|
||||
setHeaderAction(Action.APP_ICON)
|
||||
val filtersCount = if (filterStatus == FILTERS_FAVORITES) 1 else {
|
||||
filtersWithValue?.count {
|
||||
!it.value.hasSameValueAs(it.filter.defaultValue())
|
||||
}
|
||||
}
|
||||
|
||||
setActionStrip(
|
||||
ActionStrip.Builder()
|
||||
.addAction(
|
||||
Action.Builder()
|
||||
.setIcon(
|
||||
CarIcon.Builder(
|
||||
IconCompat.createWithResource(
|
||||
carContext,
|
||||
R.drawable.ic_filter
|
||||
)
|
||||
)
|
||||
.setTint(if (filtersCount != null && filtersCount > 0) CarColor.SECONDARY else CarColor.DEFAULT)
|
||||
.build()
|
||||
setActionStrip(
|
||||
ActionStrip.Builder()
|
||||
.addAction(
|
||||
Action.Builder()
|
||||
.setIcon(
|
||||
CarIcon.Builder(
|
||||
IconCompat.createWithResource(
|
||||
carContext,
|
||||
R.drawable.ic_settings
|
||||
)
|
||||
).setTint(CarColor.DEFAULT).build()
|
||||
)
|
||||
.setOnClickListener {
|
||||
screenManager.pushForResult(FilterScreen(carContext)) {
|
||||
chargers = null
|
||||
numUpdates = 0
|
||||
filterStatus.value =
|
||||
prefs.filterStatus.takeUnless { it == FILTERS_CUSTOM || it == FILTERS_FAVORITES }
|
||||
?: FILTERS_DISABLED
|
||||
}
|
||||
screenManager.push(SettingsScreen(carContext))
|
||||
session.mapScreen = null
|
||||
}
|
||||
.build())
|
||||
.addAction(
|
||||
Action.Builder()
|
||||
.setIcon(
|
||||
CarIcon.Builder(
|
||||
IconCompat.createWithResource(
|
||||
carContext,
|
||||
R.drawable.ic_filter
|
||||
)
|
||||
)
|
||||
.setTint(if (filtersCount != null && filtersCount > 0) CarColor.SECONDARY else CarColor.DEFAULT)
|
||||
.build()
|
||||
)
|
||||
.setOnClickListener {
|
||||
screenManager.push(FilterScreen(carContext, session))
|
||||
session.mapScreen = null
|
||||
}
|
||||
.build())
|
||||
.build())
|
||||
}
|
||||
build()
|
||||
setOnContentRefreshListener(this@MapScreen)
|
||||
}.build()
|
||||
}
|
||||
|
||||
private fun formatCharger(charger: ChargeLocation, showCity: Boolean): Row {
|
||||
val markerTint = if (charger.maxPower > 100) {
|
||||
val markerTint = if ((charger.maxPower ?: 0.0) > 100) {
|
||||
R.color.charger_100kw_dark // slightly darker color for better contrast
|
||||
} else {
|
||||
getMarkerTint(charger)
|
||||
@@ -184,7 +219,7 @@ class MapScreen(ctx: CarContext, val session: EVMapSession, val favorites: Boole
|
||||
return Row.Builder().apply {
|
||||
// only show the city if not all chargers are in the same city (-> showCity == true)
|
||||
// and the city is not already contained in the charger name
|
||||
if (showCity && charger.address.city != null && charger.address.city !in charger.name) {
|
||||
if (showCity && charger.address?.city != null && charger.address.city !in charger.name) {
|
||||
setTitle(
|
||||
CarText.Builder("${charger.name} · ${charger.address.city}")
|
||||
.addVariant(charger.name)
|
||||
@@ -214,8 +249,11 @@ class MapScreen(ctx: CarContext, val session: EVMapSession, val favorites: Boole
|
||||
}
|
||||
|
||||
// power
|
||||
if (text.isNotEmpty()) text.append(" · ")
|
||||
text.append("${charger.maxPower.roundToInt()} kW")
|
||||
val power = charger.maxPower;
|
||||
if (power != null) {
|
||||
if (text.isNotEmpty()) text.append(" · ")
|
||||
text.append("${power.roundToInt()} kW")
|
||||
}
|
||||
|
||||
// availability
|
||||
availabilities[charger.id]?.second?.let { av ->
|
||||
@@ -240,6 +278,7 @@ class MapScreen(ctx: CarContext, val session: EVMapSession, val favorites: Boole
|
||||
|
||||
setOnClickListener {
|
||||
screenManager.push(ChargerDetailScreen(carContext, charger))
|
||||
session.mapScreen = null
|
||||
}
|
||||
}.build()
|
||||
}
|
||||
@@ -250,10 +289,10 @@ class MapScreen(ctx: CarContext, val session: EVMapSession, val favorites: Boole
|
||||
) {
|
||||
return
|
||||
}
|
||||
val previousLocation = this.location
|
||||
this.location = location
|
||||
if (updateCoroutine != null) {
|
||||
// don't update while still loading last update
|
||||
return
|
||||
if (previousLocation == null) {
|
||||
loadChargers()
|
||||
}
|
||||
|
||||
val now = Instant.now()
|
||||
@@ -264,120 +303,182 @@ class MapScreen(ctx: CarContext, val session: EVMapSession, val favorites: Boole
|
||||
// update displayed distances
|
||||
invalidate()
|
||||
}
|
||||
|
||||
if (lastChargerUpdateLocation == null ||
|
||||
location.distanceTo(lastChargerUpdateLocation) > chargerUpdateThreshold
|
||||
) {
|
||||
lastChargerUpdateLocation = location
|
||||
// update displayed chargers
|
||||
loadChargers()
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadChargers() {
|
||||
val location = location ?: return
|
||||
val referenceData = referenceData.value ?: return
|
||||
val filters = filtersWithValue.value ?: return
|
||||
|
||||
numUpdates++
|
||||
println(numUpdates)
|
||||
if (numUpdates > maxNumUpdates) {
|
||||
/*CarToast.makeText(carContext, R.string.auto_no_refresh_possible, CarToast.LENGTH_LONG)
|
||||
.show()*/
|
||||
return
|
||||
}
|
||||
val searchLocation =
|
||||
prefs.placeSearchResultAndroidAuto ?: LatLng.fromLocation(location)
|
||||
this.searchLocation = searchLocation
|
||||
|
||||
updateCoroutine = lifecycleScope.launch {
|
||||
try {
|
||||
filterStatus = prefs.filterStatus
|
||||
val filterValues =
|
||||
db.filterValueDao().getFilterValuesAsync(filterStatus, prefs.dataSource)
|
||||
val filters = repo.getFiltersAsync(carContext.stringProvider())
|
||||
filtersWithValue = filtersWithValue(filters, filterValues)
|
||||
|
||||
// load chargers
|
||||
if (favorites) {
|
||||
chargers = db.chargeLocationsDao().getAllChargeLocationsAsync().sortedBy {
|
||||
distanceBetween(
|
||||
location.latitude, location.longitude,
|
||||
it.coordinates.lat, it.coordinates.lng
|
||||
)
|
||||
}
|
||||
if (filterStatus == FILTERS_FAVORITES) {
|
||||
chargers =
|
||||
db.favoritesDao().getAllFavoritesAsync().map { it.charger }.sortedBy {
|
||||
distanceBetween(
|
||||
location.latitude, location.longitude,
|
||||
it.coordinates.lat, it.coordinates.lng
|
||||
)
|
||||
}
|
||||
} else {
|
||||
val response = api.getChargepointsRadius(
|
||||
referenceData,
|
||||
LatLng.fromLocation(location),
|
||||
val response = repo.getChargepointsRadius(
|
||||
searchLocation,
|
||||
searchRadius,
|
||||
zoom = 16f,
|
||||
filters
|
||||
)
|
||||
chargers = response.data?.filterIsInstance(ChargeLocation::class.java)
|
||||
filtersWithValue
|
||||
).awaitFinished()
|
||||
if (response.status == Status.ERROR) {
|
||||
withContext(Dispatchers.Main) { showLoadingError() }
|
||||
return@launch
|
||||
}
|
||||
var chargers = response.data?.filterIsInstance(ChargeLocation::class.java)
|
||||
chargers?.let {
|
||||
if (it.size < maxRows) {
|
||||
// try again with larger radius
|
||||
val response = api.getChargepointsRadius(
|
||||
referenceData,
|
||||
LatLng.fromLocation(location),
|
||||
val response = repo.getChargepointsRadius(
|
||||
searchLocation,
|
||||
searchRadius * 10,
|
||||
zoom = 16f,
|
||||
filters
|
||||
)
|
||||
filtersWithValue
|
||||
).awaitFinished()
|
||||
if (response.status == Status.ERROR) {
|
||||
withContext(Dispatchers.Main) { showLoadingError() }
|
||||
return@launch
|
||||
}
|
||||
chargers =
|
||||
response.data?.filterIsInstance(ChargeLocation::class.java)
|
||||
}
|
||||
}
|
||||
this@MapScreen.chargers = chargers
|
||||
}
|
||||
|
||||
// remove outdated availabilities
|
||||
availabilities = availabilities.filter {
|
||||
Duration.between(
|
||||
it.value.first,
|
||||
ZonedDateTime.now()
|
||||
) > availabilityUpdateThreshold
|
||||
}.toMutableMap()
|
||||
|
||||
// update availabilities
|
||||
chargers?.take(maxRows)?.map {
|
||||
lifecycleScope.async {
|
||||
// update only if not yet stored
|
||||
if (!availabilities.containsKey(it.id)) {
|
||||
val date = ZonedDateTime.now()
|
||||
val availability = getAvailability(it).data
|
||||
if (availability != null) {
|
||||
availabilities[it.id] = date to availability
|
||||
}
|
||||
}
|
||||
}
|
||||
}?.awaitAll()
|
||||
|
||||
updateCoroutine = null
|
||||
lastDistanceUpdateTime = Instant.now()
|
||||
invalidate()
|
||||
} catch (e: IOException) {
|
||||
withContext(Dispatchers.Main) {
|
||||
CarToast.makeText(carContext, R.string.connection_error, CarToast.LENGTH_LONG)
|
||||
.show()
|
||||
}
|
||||
withContext(Dispatchers.Main) { showLoadingError() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun onEnergyLevelUpdated(energyLevel: EnergyLevel) {
|
||||
this.energyLevel = energyLevel
|
||||
invalidate()
|
||||
private fun showLoadingError() {
|
||||
CarToast.makeText(carContext, R.string.connection_error, CarToast.LENGTH_LONG)
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun onEnergyLevelUpdated(energyLevel: EnergyLevel) {
|
||||
val isUpdate = this.energyLevel == null
|
||||
this.energyLevel = energyLevel
|
||||
if (isUpdate) invalidate()
|
||||
}
|
||||
|
||||
override fun onStart(owner: LifecycleOwner) {
|
||||
setupListeners()
|
||||
|
||||
// Reloading chargers in onStart does not seem to count towards content limit.
|
||||
// So let's do this so the user gets fresh chargers when re-entering the app.
|
||||
if (prefs.dataSource != repo.api.value?.id) {
|
||||
repo.api.value = createApi(prefs.dataSource, carContext)
|
||||
}
|
||||
invalidate()
|
||||
loadChargers()
|
||||
}
|
||||
|
||||
@OnLifecycleEvent(Lifecycle.Event.ON_RESUME)
|
||||
private fun setupListeners() {
|
||||
if (ContextCompat.checkSelfPermission(
|
||||
carContext,
|
||||
"com.google.android.gms.permission.CAR_FUEL"
|
||||
) != PackageManager.PERMISSION_GRANTED
|
||||
)
|
||||
if (!permissions.all {
|
||||
ContextCompat.checkSelfPermission(
|
||||
carContext,
|
||||
it
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
})
|
||||
return
|
||||
|
||||
println("Setting up energy level listener")
|
||||
|
||||
val exec = ContextCompat.getMainExecutor(carContext)
|
||||
hardwareMan.carInfo.addEnergyLevelListener(exec, ::onEnergyLevelUpdated)
|
||||
if (supportsCarApiLevel3(carContext)) {
|
||||
println("Setting up energy level listener")
|
||||
val exec = ContextCompat.getMainExecutor(carContext)
|
||||
hardwareMan.carInfo.addEnergyLevelListener(exec, ::onEnergyLevelUpdated)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStop(owner: LifecycleOwner) {
|
||||
// Reloading chargers in onStart does not seem to count towards content limit.
|
||||
// So let's do this so the user gets fresh chargers when re-entering the app.
|
||||
// Deleting the data already in onStop makes sure that we show a loading screen directly
|
||||
// (i.e. onGetTemplate is not called while the old data is still there)
|
||||
chargers = null
|
||||
availabilities.clear()
|
||||
removeListeners()
|
||||
}
|
||||
|
||||
@OnLifecycleEvent(Lifecycle.Event.ON_PAUSE)
|
||||
private fun removeListeners() {
|
||||
println("Removing energy level listener")
|
||||
hardwareMan.carInfo.removeEnergyLevelListener(::onEnergyLevelUpdated)
|
||||
if (supportsCarApiLevel3(carContext)) {
|
||||
println("Removing energy level listener")
|
||||
hardwareMan.carInfo.removeEnergyLevelListener(::onEnergyLevelUpdated)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onContentRefreshRequested() {
|
||||
loadChargers()
|
||||
availabilities.clear()
|
||||
|
||||
val start = visibleStart
|
||||
val end = visibleEnd
|
||||
if (start != null && end != null) {
|
||||
onItemVisibilityChanged(start, end)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onItemVisibilityChanged(startIndex: Int, endIndex: Int) {
|
||||
// when the list is scrolled, load corresponding availabilities
|
||||
if (startIndex == visibleStart && endIndex == visibleEnd && !availabilities.isEmpty()) return
|
||||
if (startIndex == -1 || endIndex == -1) return
|
||||
if (availabilityUpdateCoroutine != null) return
|
||||
|
||||
visibleEnd = endIndex
|
||||
visibleStart = startIndex
|
||||
|
||||
// remove outdated availabilities
|
||||
availabilities = availabilities.filter {
|
||||
Duration.between(
|
||||
it.value.first,
|
||||
ZonedDateTime.now()
|
||||
) <= availabilityUpdateThreshold
|
||||
}.toMutableMap()
|
||||
|
||||
// update availabilities
|
||||
availabilityUpdateCoroutine = lifecycleScope.launch {
|
||||
delay(300L)
|
||||
|
||||
val chargers = chargers ?: return@launch
|
||||
if (chargers.isEmpty()) return@launch
|
||||
|
||||
val tasks = chargers.subList(
|
||||
min(startIndex, chargers.size - 1),
|
||||
min(endIndex, chargers.size - 1)
|
||||
).mapNotNull {
|
||||
// update only if not yet stored
|
||||
if (!availabilities.containsKey(it.id)) {
|
||||
lifecycleScope.async {
|
||||
val availability = getAvailability(it).data
|
||||
val date = ZonedDateTime.now()
|
||||
availabilities[it.id] = date to availability
|
||||
}
|
||||
} else null
|
||||
}
|
||||
if (tasks.isNotEmpty()) {
|
||||
tasks.awaitAll()
|
||||
invalidate()
|
||||
}
|
||||
availabilityUpdateCoroutine = null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,8 @@ import net.vonforst.evmap.R
|
||||
class PermissionScreen(
|
||||
ctx: CarContext,
|
||||
@StringRes val message: Int,
|
||||
val permissions: List<String>
|
||||
val permissions: List<String>,
|
||||
val finishApp: Boolean = true
|
||||
) : Screen(ctx) {
|
||||
override fun onGetTemplate(): Template {
|
||||
return MessageTemplate.Builder(carContext.getString(message))
|
||||
@@ -31,7 +32,13 @@ class PermissionScreen(
|
||||
Action.Builder()
|
||||
.setTitle(carContext.getString(R.string.cancel))
|
||||
.setOnClickListener {
|
||||
carContext.finishCarApp()
|
||||
if (finishApp) {
|
||||
carContext.finishCarApp()
|
||||
} else {
|
||||
// pop twice to get away from the screen that requires the permission
|
||||
screenManager.pop()
|
||||
screenManager.pop()
|
||||
}
|
||||
}
|
||||
.build(),
|
||||
)
|
||||
|
||||
234
app/src/google/java/net/vonforst/evmap/auto/PlaceSearchScreen.kt
Normal file
234
app/src/google/java/net/vonforst/evmap/auto/PlaceSearchScreen.kt
Normal file
@@ -0,0 +1,234 @@
|
||||
package net.vonforst.evmap.auto
|
||||
|
||||
import android.content.pm.PackageManager
|
||||
import android.location.Location
|
||||
import android.text.SpannableStringBuilder
|
||||
import android.text.Spanned
|
||||
import androidx.car.app.CarContext
|
||||
import androidx.car.app.CarToast
|
||||
import androidx.car.app.Screen
|
||||
import androidx.car.app.annotations.ExperimentalCarApi
|
||||
import androidx.car.app.constraints.ConstraintManager
|
||||
import androidx.car.app.hardware.CarHardwareManager
|
||||
import androidx.car.app.hardware.info.EnergyLevel
|
||||
import androidx.car.app.model.*
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.graphics.drawable.IconCompat
|
||||
import androidx.lifecycle.DefaultLifecycleObserver
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.car2go.maps.model.LatLng
|
||||
import kotlinx.coroutines.*
|
||||
import net.vonforst.evmap.BuildConfig
|
||||
import net.vonforst.evmap.R
|
||||
import net.vonforst.evmap.adapter.iconForPlaceType
|
||||
import net.vonforst.evmap.adapter.isSpecialPlace
|
||||
import net.vonforst.evmap.autocomplete.*
|
||||
import net.vonforst.evmap.storage.AppDatabase
|
||||
import net.vonforst.evmap.storage.PreferenceDataSource
|
||||
import net.vonforst.evmap.storage.RecentAutocompletePlace
|
||||
import java.io.IOException
|
||||
import java.time.Instant
|
||||
|
||||
@ExperimentalCarApi
|
||||
class PlaceSearchScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx),
|
||||
SearchTemplate.SearchCallback, LocationAwareScreen,
|
||||
DefaultLifecycleObserver {
|
||||
private val hardwareMan: CarHardwareManager by lazy {
|
||||
ctx.getCarService(CarContext.HARDWARE_SERVICE) as CarHardwareManager
|
||||
}
|
||||
private var resultList: List<AutocompletePlace>? = null
|
||||
private var recentResults = mutableListOf<RecentAutocompletePlace>()
|
||||
private var currentProvider: AutocompleteProvider? = null
|
||||
private val providers = getAutocompleteProviders(ctx)
|
||||
private val recents = AppDatabase.getInstance(ctx).recentAutocompletePlaceDao()
|
||||
private val maxItems = if (ctx.carAppApiLevel >= 2) {
|
||||
ctx.constraintManager.getContentLimit(ConstraintManager.CONTENT_LIMIT_TYPE_LIST)
|
||||
} else 6
|
||||
private var location: Location? = null
|
||||
private var energyLevel: EnergyLevel? = null
|
||||
private var updateJob: Job? = null
|
||||
private val prefs = PreferenceDataSource(ctx)
|
||||
|
||||
private val permissions = if (BuildConfig.FLAVOR_automotive == "automotive") {
|
||||
listOf(
|
||||
"android.car.permission.CAR_ENERGY",
|
||||
"android.car.permission.CAR_ENERGY_PORTS",
|
||||
"android.car.permission.READ_CAR_DISPLAY_UNITS",
|
||||
)
|
||||
} else {
|
||||
listOf(
|
||||
"com.google.android.gms.permission.CAR_FUEL"
|
||||
)
|
||||
}
|
||||
|
||||
init {
|
||||
lifecycle.addObserver(this)
|
||||
update("")
|
||||
}
|
||||
|
||||
override fun onGetTemplate(): Template {
|
||||
return SearchTemplate.Builder(this).apply {
|
||||
setHeaderAction(Action.BACK)
|
||||
setSearchHint(carContext.getString(R.string.search))
|
||||
resultList?.let {
|
||||
setItemList(buildItemList(it))
|
||||
} ?: setLoading(true)
|
||||
}.build()
|
||||
}
|
||||
|
||||
private fun buildItemList(results: List<AutocompletePlace>): ItemList {
|
||||
return ItemList.Builder().apply {
|
||||
results.forEach { place ->
|
||||
addItem(Row.Builder().apply {
|
||||
setTitle(place.primaryText)
|
||||
addText(place.secondaryText)
|
||||
|
||||
val icon = iconForPlaceType(place.types)
|
||||
setImage(
|
||||
CarIcon.Builder(IconCompat.createWithResource(carContext, icon))
|
||||
.setTint(if (isSpecialPlace(place.types)) CarColor.PRIMARY else CarColor.DEFAULT)
|
||||
.build()
|
||||
)
|
||||
|
||||
// distance
|
||||
place.distanceMeters?.let {
|
||||
val text = SpannableStringBuilder()
|
||||
text.append(
|
||||
"distance",
|
||||
DistanceSpan.create(
|
||||
roundValueToDistance(
|
||||
it,
|
||||
energyLevel?.distanceDisplayUnit?.value
|
||||
)
|
||||
),
|
||||
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
|
||||
)
|
||||
addText(text)
|
||||
|
||||
setOnClickListener {
|
||||
lifecycleScope.launch {
|
||||
val placeDetails = getDetails(place.id)
|
||||
prefs.placeSearchResultAndroidAuto = placeDetails.latLng
|
||||
prefs.placeSearchResultAndroidAutoName =
|
||||
place.primaryText.toString()
|
||||
screenManager.popTo(MapScreen.MARKER)
|
||||
}
|
||||
}
|
||||
}
|
||||
}.build())
|
||||
}
|
||||
}.build()
|
||||
}
|
||||
|
||||
override fun onSearchTextChanged(searchText: String) {
|
||||
update(searchText)
|
||||
}
|
||||
|
||||
override fun onSearchSubmitted(searchText: String) {
|
||||
update(searchText)
|
||||
}
|
||||
|
||||
private fun update(searchText: String) {
|
||||
updateJob?.cancel()
|
||||
updateJob = lifecycleScope.launch {
|
||||
if (prefs.searchProvider == "mapbox" && !isShortQuery(searchText)) {
|
||||
delay(500L)
|
||||
}
|
||||
try {
|
||||
loadNewList(searchText)
|
||||
} catch (e: IOException) {
|
||||
CarToast.makeText(
|
||||
carContext,
|
||||
R.string.autocomplete_connection_error,
|
||||
CarToast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun loadNewList(query: String) {
|
||||
for (provider in providers) {
|
||||
try {
|
||||
recentResults.clear()
|
||||
currentProvider = provider
|
||||
|
||||
// first search in recent places
|
||||
val recentPlaces = if (query.isEmpty()) {
|
||||
recents.getAllAsync(provider.id, limit = maxItems)
|
||||
} else {
|
||||
recents.searchAsync(query, provider.id, limit = maxItems)
|
||||
}
|
||||
recentResults.addAll(recentPlaces)
|
||||
resultList =
|
||||
recentPlaces.map { it.asAutocompletePlace(LatLng.fromLocation(location)) }
|
||||
invalidate()
|
||||
|
||||
// if we already have enough results or the query is short, stop here
|
||||
if (isShortQuery(query) || recentResults.size >= maxItems) break
|
||||
|
||||
// then search online
|
||||
val recentIds = recentPlaces.map { it.id }
|
||||
resultList = withContext(Dispatchers.IO) {
|
||||
(resultList!! + provider.autocomplete(query, LatLng.fromLocation(location))
|
||||
.filter { !recentIds.contains(it.id) }).take(maxItems)
|
||||
}
|
||||
invalidate()
|
||||
break
|
||||
} catch (e: ApiUnavailableException) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun isShortQuery(query: CharSequence) = query.length < 3
|
||||
|
||||
override fun updateLocation(location: Location) {
|
||||
this.location = location
|
||||
}
|
||||
|
||||
override fun onResume(owner: LifecycleOwner) {
|
||||
session.requestLocationUpdates()
|
||||
session.mapScreen = this
|
||||
|
||||
if (supportsCarApiLevel3(carContext) && permissions.all {
|
||||
ContextCompat.checkSelfPermission(
|
||||
carContext,
|
||||
it
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
}) {
|
||||
|
||||
println("Setting up energy level listener")
|
||||
val exec = ContextCompat.getMainExecutor(carContext)
|
||||
hardwareMan.carInfo.addEnergyLevelListener(exec, ::onEnergyLevelUpdated)
|
||||
}
|
||||
}
|
||||
|
||||
private fun onEnergyLevelUpdated(energyLevel: EnergyLevel) {
|
||||
val isUpdate = this.energyLevel == null
|
||||
this.energyLevel = energyLevel
|
||||
if (isUpdate) invalidate()
|
||||
}
|
||||
|
||||
override fun onPause(owner: LifecycleOwner) {
|
||||
session.mapScreen = null
|
||||
|
||||
if (supportsCarApiLevel3(carContext)) {
|
||||
hardwareMan.carInfo.removeEnergyLevelListener(::onEnergyLevelUpdated)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getDetails(id: String): PlaceWithBounds {
|
||||
val provider = currentProvider!!
|
||||
val result = resultList!!.find { it.id == id }!!
|
||||
|
||||
val recentPlace = recentResults.find { it.id == id }
|
||||
if (recentPlace != null) return recentPlace.asPlaceWithBounds()
|
||||
|
||||
val details = provider.getDetails(id)
|
||||
|
||||
recents.insert(RecentAutocompletePlace(result, details, provider.id, Instant.now()))
|
||||
|
||||
return details
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package net.vonforst.evmap.auto
|
||||
|
||||
import androidx.car.app.CarContext
|
||||
import androidx.car.app.CarToast
|
||||
import androidx.car.app.Screen
|
||||
import androidx.car.app.constraints.ConstraintManager
|
||||
import androidx.car.app.model.*
|
||||
import androidx.core.graphics.drawable.IconCompat
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import kotlinx.coroutines.launch
|
||||
import net.vonforst.evmap.R
|
||||
|
||||
abstract class MultiSelectSearchScreen<T>(ctx: CarContext) : Screen(ctx),
|
||||
SearchTemplate.SearchCallback {
|
||||
protected var fullList: List<T>? = null
|
||||
private var currentList: List<T> = emptyList()
|
||||
private var query: String = ""
|
||||
private val maxRows = if (ctx.carAppApiLevel >= 2) {
|
||||
ctx.constraintManager.getContentLimit(ConstraintManager.CONTENT_LIMIT_TYPE_LIST)
|
||||
} else 6
|
||||
protected abstract val isMultiSelect: Boolean
|
||||
protected abstract val shouldShowSelectAll: Boolean
|
||||
|
||||
override fun onGetTemplate(): Template {
|
||||
if (fullList == null) {
|
||||
lifecycleScope.launch {
|
||||
fullList = loadData()
|
||||
filterList()
|
||||
invalidate()
|
||||
}
|
||||
}
|
||||
|
||||
return SearchTemplate.Builder(this).apply {
|
||||
setHeaderAction(Action.BACK)
|
||||
fullList?.let {
|
||||
setItemList(buildItemList())
|
||||
} ?: run {
|
||||
setLoading(true)
|
||||
}
|
||||
if (isMultiSelect) {
|
||||
setActionStrip(ActionStrip.Builder().apply {
|
||||
addAction(
|
||||
Action.Builder().setIcon(
|
||||
CarIcon.Builder(
|
||||
IconCompat.createWithResource(
|
||||
carContext,
|
||||
R.drawable.ic_select_all
|
||||
)
|
||||
).build()
|
||||
).setOnClickListener(::selectAll).build()
|
||||
)
|
||||
addAction(
|
||||
Action.Builder().setIcon(
|
||||
CarIcon.Builder(
|
||||
IconCompat.createWithResource(
|
||||
carContext,
|
||||
R.drawable.ic_select_none
|
||||
)
|
||||
).build()
|
||||
).setOnClickListener(::selectNone).build()
|
||||
)
|
||||
}.build())
|
||||
}
|
||||
}.build()
|
||||
}
|
||||
|
||||
private fun filterList() {
|
||||
currentList = fullList?.let {
|
||||
it.sortedBy { getLabel(it).lowercase() }
|
||||
.sortedBy { !isSelected(it) }
|
||||
.filter { getLabel(it).lowercase().contains(query.lowercase()) }
|
||||
.take(maxRows)
|
||||
} ?: emptyList()
|
||||
}
|
||||
|
||||
private val checkedIcon =
|
||||
CarIcon.Builder(IconCompat.createWithResource(carContext, R.drawable.ic_checkbox_checked))
|
||||
.setTint(CarColor.PRIMARY)
|
||||
.build()
|
||||
private val uncheckedIcon =
|
||||
CarIcon.Builder(IconCompat.createWithResource(carContext, R.drawable.ic_checkbox_unchecked))
|
||||
.setTint(CarColor.PRIMARY)
|
||||
.build()
|
||||
|
||||
private fun buildItemList(): ItemList {
|
||||
return ItemList.Builder().apply {
|
||||
currentList.forEach { item ->
|
||||
addItem(
|
||||
Row.Builder()
|
||||
.setTitle(getLabel(item))
|
||||
.setImage(if (isSelected(item)) checkedIcon else uncheckedIcon)
|
||||
.setOnClickListener {
|
||||
toggleSelected(item)
|
||||
if (isMultiSelect) {
|
||||
invalidate()
|
||||
} else {
|
||||
setResult(item)
|
||||
screenManager.pop()
|
||||
}
|
||||
}
|
||||
.build()
|
||||
)
|
||||
}
|
||||
}.build()
|
||||
}
|
||||
|
||||
override fun onSearchTextChanged(searchText: String) {
|
||||
query = searchText
|
||||
filterList()
|
||||
invalidate()
|
||||
}
|
||||
|
||||
override fun onSearchSubmitted(searchText: String) {
|
||||
query = searchText
|
||||
filterList()
|
||||
invalidate()
|
||||
}
|
||||
|
||||
abstract fun toggleSelected(item: T)
|
||||
|
||||
open fun selectAll() {
|
||||
CarToast.makeText(carContext, R.string.selecting_all, CarToast.LENGTH_SHORT).show()
|
||||
invalidate()
|
||||
}
|
||||
|
||||
open fun selectNone() {
|
||||
CarToast.makeText(carContext, R.string.selecting_none, CarToast.LENGTH_SHORT).show()
|
||||
invalidate()
|
||||
}
|
||||
|
||||
abstract fun isSelected(it: T): Boolean
|
||||
|
||||
abstract fun getLabel(it: T): String
|
||||
|
||||
abstract suspend fun loadData(): List<T>
|
||||
}
|
||||
510
app/src/google/java/net/vonforst/evmap/auto/SettingsScreens.kt
Normal file
510
app/src/google/java/net/vonforst/evmap/auto/SettingsScreens.kt
Normal file
@@ -0,0 +1,510 @@
|
||||
package net.vonforst.evmap.auto
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.car.app.CarContext
|
||||
import androidx.car.app.CarToast
|
||||
import androidx.car.app.Screen
|
||||
import androidx.car.app.constraints.ConstraintManager
|
||||
import androidx.car.app.model.*
|
||||
import androidx.core.graphics.drawable.IconCompat
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import kotlinx.coroutines.launch
|
||||
import net.vonforst.evmap.R
|
||||
import net.vonforst.evmap.api.chargeprice.ChargepriceApi
|
||||
import net.vonforst.evmap.api.chargeprice.ChargepriceCar
|
||||
import net.vonforst.evmap.api.chargeprice.ChargepriceTariff
|
||||
import net.vonforst.evmap.storage.AppDatabase
|
||||
import net.vonforst.evmap.storage.PreferenceDataSource
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
|
||||
class SettingsScreen(ctx: CarContext) : Screen(ctx) {
|
||||
override fun onGetTemplate(): Template {
|
||||
return ListTemplate.Builder().apply {
|
||||
setTitle(carContext.getString(R.string.auto_settings))
|
||||
setHeaderAction(Action.BACK)
|
||||
setSingleList(ItemList.Builder().apply {
|
||||
addItem(Row.Builder().apply {
|
||||
setTitle(carContext.getString(R.string.settings_data_sources))
|
||||
setImage(
|
||||
CarIcon.Builder(
|
||||
IconCompat.createWithResource(
|
||||
carContext,
|
||||
R.drawable.ic_settings_data_source
|
||||
)
|
||||
).setTint(
|
||||
CarColor.DEFAULT
|
||||
).build()
|
||||
)
|
||||
setBrowsable(true)
|
||||
setOnClickListener {
|
||||
screenManager.push(DataSettingsScreen(carContext))
|
||||
}
|
||||
}.build())
|
||||
addItem(Row.Builder().apply {
|
||||
setTitle(carContext.getString(R.string.settings_chargeprice))
|
||||
setImage(
|
||||
CarIcon.Builder(
|
||||
IconCompat.createWithResource(
|
||||
carContext,
|
||||
R.drawable.ic_chargeprice
|
||||
)
|
||||
).setTint(
|
||||
CarColor.DEFAULT
|
||||
).build()
|
||||
)
|
||||
setBrowsable(true)
|
||||
setOnClickListener {
|
||||
screenManager.push(ChargepriceSettingsScreen(carContext))
|
||||
}
|
||||
}.build())
|
||||
if (supportsCarApiLevel3(carContext)) {
|
||||
addItem(
|
||||
Row.Builder()
|
||||
.setTitle(carContext.getString(R.string.auto_vehicle_data))
|
||||
.setImage(
|
||||
CarIcon.Builder(
|
||||
IconCompat.createWithResource(carContext, R.drawable.ic_car)
|
||||
).setTint(CarColor.DEFAULT).build()
|
||||
)
|
||||
.setBrowsable(true)
|
||||
.setOnClickListener {
|
||||
screenManager.push(VehicleDataScreen(carContext))
|
||||
}
|
||||
.build()
|
||||
)
|
||||
}
|
||||
}.build())
|
||||
}.build()
|
||||
}
|
||||
}
|
||||
|
||||
class DataSettingsScreen(ctx: CarContext) : Screen(ctx) {
|
||||
val prefs = PreferenceDataSource(ctx)
|
||||
val db = AppDatabase.getInstance(ctx)
|
||||
val dataSourceNames = carContext.resources.getStringArray(R.array.pref_data_source_names)
|
||||
val dataSourceValues = carContext.resources.getStringArray(R.array.pref_data_source_values)
|
||||
val dataSourceDescriptions = listOf(
|
||||
carContext.getString(R.string.data_source_goingelectric_desc),
|
||||
carContext.getString(R.string.data_source_openchargemap_desc)
|
||||
)
|
||||
val searchProviderNames =
|
||||
carContext.resources.getStringArray(R.array.pref_search_provider_names)
|
||||
val searchProviderValues =
|
||||
carContext.resources.getStringArray(R.array.pref_search_provider_values)
|
||||
|
||||
override fun onGetTemplate(): Template {
|
||||
return ListTemplate.Builder().apply {
|
||||
setTitle(carContext.getString(R.string.settings_data_sources))
|
||||
setHeaderAction(Action.BACK)
|
||||
setSingleList(ItemList.Builder().apply {
|
||||
addItem(Row.Builder().apply {
|
||||
setTitle(carContext.getString(R.string.pref_data_source))
|
||||
setBrowsable(true)
|
||||
val dataSourceId = prefs.dataSource
|
||||
val dataSourceDesc = dataSourceNames[dataSourceValues.indexOf(dataSourceId)]
|
||||
addText(dataSourceDesc)
|
||||
setOnClickListener {
|
||||
screenManager.push(
|
||||
ChooseDataSourceScreen(
|
||||
carContext,
|
||||
R.string.pref_data_source,
|
||||
dataSourceNames,
|
||||
dataSourceValues,
|
||||
prefs.dataSource,
|
||||
dataSourceDescriptions
|
||||
) {
|
||||
prefs.dataSource = it
|
||||
})
|
||||
}
|
||||
}.build())
|
||||
addItem(Row.Builder().apply {
|
||||
setTitle(carContext.getString(R.string.pref_search_provider))
|
||||
setBrowsable(true)
|
||||
val searchProviderId = prefs.searchProvider
|
||||
val searchProviderDesc =
|
||||
searchProviderNames[searchProviderValues.indexOf(searchProviderId)]
|
||||
addText(searchProviderDesc)
|
||||
setOnClickListener {
|
||||
screenManager.push(
|
||||
ChooseDataSourceScreen(
|
||||
carContext,
|
||||
R.string.pref_search_provider,
|
||||
searchProviderNames,
|
||||
searchProviderValues,
|
||||
prefs.searchProvider
|
||||
) {
|
||||
prefs.searchProvider = it
|
||||
})
|
||||
}
|
||||
}.build())
|
||||
addItem(Row.Builder().apply {
|
||||
setTitle(carContext.getString(R.string.pref_search_delete_recent))
|
||||
setOnClickListener {
|
||||
lifecycleScope.launch {
|
||||
db.recentAutocompletePlaceDao().deleteAll()
|
||||
CarToast.makeText(
|
||||
carContext,
|
||||
R.string.deleted_recent_search_results,
|
||||
CarToast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
}
|
||||
}.build())
|
||||
}.build())
|
||||
}.build()
|
||||
}
|
||||
}
|
||||
|
||||
class ChooseDataSourceScreen(
|
||||
ctx: CarContext,
|
||||
@StringRes val title: Int,
|
||||
val names: Array<String>,
|
||||
val values: Array<String>,
|
||||
val currentValue: String,
|
||||
val descriptions: List<String>? = null,
|
||||
val callback: (String) -> Unit
|
||||
) : Screen(ctx) {
|
||||
val prefs = PreferenceDataSource(carContext)
|
||||
|
||||
override fun onGetTemplate(): Template {
|
||||
return ListTemplate.Builder().apply {
|
||||
setTitle(carContext.getString(title))
|
||||
setHeaderAction(Action.BACK)
|
||||
setSingleList(ItemList.Builder().apply {
|
||||
for (i in names.indices) {
|
||||
addItem(Row.Builder().apply {
|
||||
setTitle(names[i])
|
||||
descriptions?.let { addText(it[i]) }
|
||||
}.build())
|
||||
}
|
||||
setOnSelectedListener {
|
||||
callback(values[it])
|
||||
screenManager.pop()
|
||||
}
|
||||
setSelectedIndex(values.indexOf(currentValue))
|
||||
}.build())
|
||||
}.build()
|
||||
}
|
||||
}
|
||||
|
||||
class ChargepriceSettingsScreen(ctx: CarContext) : Screen(ctx) {
|
||||
val prefs = PreferenceDataSource(carContext)
|
||||
private val maxRows = if (ctx.carAppApiLevel >= 2) {
|
||||
ctx.constraintManager.getContentLimit(ConstraintManager.CONTENT_LIMIT_TYPE_LIST)
|
||||
} else 6
|
||||
|
||||
override fun onGetTemplate(): Template {
|
||||
return ListTemplate.Builder().apply {
|
||||
setTitle(carContext.getString(R.string.settings_chargeprice))
|
||||
setHeaderAction(Action.BACK)
|
||||
setSingleList(ItemList.Builder().apply {
|
||||
addItem(Row.Builder().apply {
|
||||
setTitle(carContext.getString(R.string.pref_my_vehicle))
|
||||
setBrowsable(true)
|
||||
setOnClickListener {
|
||||
screenManager.push(SelectVehiclesScreen(carContext))
|
||||
}
|
||||
}.build())
|
||||
addItem(Row.Builder().apply {
|
||||
setTitle(carContext.getString(R.string.pref_my_tariffs))
|
||||
setBrowsable(true)
|
||||
setOnClickListener {
|
||||
screenManager.push(SelectTariffsScreen(carContext))
|
||||
}
|
||||
addText(
|
||||
if (prefs.chargepriceMyTariffsAll) {
|
||||
carContext.getString(R.string.chargeprice_all_tariffs_selected)
|
||||
} else {
|
||||
val n = prefs.chargepriceMyTariffs?.size ?: 0
|
||||
carContext.resources
|
||||
.getQuantityString(
|
||||
R.plurals.chargeprice_some_tariffs_selected,
|
||||
n,
|
||||
n
|
||||
) + "\n" + carContext.resources.getQuantityString(
|
||||
R.plurals.pref_my_tariffs_summary,
|
||||
n
|
||||
)
|
||||
}
|
||||
)
|
||||
}.build())
|
||||
addItem(Row.Builder().apply {
|
||||
setTitle(carContext.getString(R.string.settings_android_auto_chargeprice_range))
|
||||
setBrowsable(true)
|
||||
|
||||
val range = prefs.chargepriceBatteryRangeAndroidAuto
|
||||
addText(
|
||||
carContext.getString(
|
||||
R.string.chargeprice_battery_range,
|
||||
range[0],
|
||||
range[1]
|
||||
)
|
||||
)
|
||||
|
||||
setOnClickListener {
|
||||
screenManager.push(SelectChargingRangeScreen(carContext))
|
||||
}
|
||||
}.build())
|
||||
addItem(Row.Builder().apply {
|
||||
setTitle(carContext.getString(R.string.pref_chargeprice_currency))
|
||||
|
||||
val names =
|
||||
carContext.resources.getStringArray(R.array.pref_chargeprice_currency_names)
|
||||
val values =
|
||||
carContext.resources.getStringArray(R.array.pref_chargeprice_currency_values)
|
||||
val index = values.indexOf(prefs.chargepriceCurrency)
|
||||
addText(if (index >= 0) names[index] else "")
|
||||
|
||||
setBrowsable(true)
|
||||
setOnClickListener {
|
||||
screenManager.push(SelectCurrencyScreen(carContext))
|
||||
}
|
||||
}.build())
|
||||
addItem(Row.Builder().apply {
|
||||
setTitle(carContext.getString(R.string.pref_chargeprice_no_base_fee))
|
||||
setToggle(Toggle.Builder {
|
||||
prefs.chargepriceNoBaseFee = it
|
||||
}.setChecked(prefs.chargepriceNoBaseFee).build())
|
||||
}.build())
|
||||
addItem(Row.Builder().apply {
|
||||
setTitle(carContext.getString(R.string.pref_chargeprice_show_provider_customer_tariffs))
|
||||
addText(carContext.getString(R.string.pref_chargeprice_show_provider_customer_tariffs_summary))
|
||||
setToggle(Toggle.Builder {
|
||||
prefs.chargepriceShowProviderCustomerTariffs = it
|
||||
}.setChecked(prefs.chargepriceShowProviderCustomerTariffs).build())
|
||||
}.build())
|
||||
if (maxRows > 6) {
|
||||
addItem(Row.Builder().apply {
|
||||
setTitle(carContext.getString(R.string.pref_chargeprice_allow_unbalanced_load))
|
||||
addText(carContext.getString(R.string.pref_chargeprice_allow_unbalanced_load_summary))
|
||||
setToggle(Toggle.Builder {
|
||||
prefs.chargepriceAllowUnbalancedLoad = it
|
||||
}.setChecked(prefs.chargepriceAllowUnbalancedLoad).build())
|
||||
}.build())
|
||||
}
|
||||
}.build())
|
||||
}.build()
|
||||
}
|
||||
}
|
||||
|
||||
class SelectVehiclesScreen(ctx: CarContext) : MultiSelectSearchScreen<ChargepriceCar>(ctx) {
|
||||
private val prefs = PreferenceDataSource(carContext)
|
||||
private var api = ChargepriceApi.create(
|
||||
carContext.getString(R.string.chargeprice_key),
|
||||
carContext.getString(R.string.chargeprice_api_url)
|
||||
)
|
||||
override val isMultiSelect = true
|
||||
override val shouldShowSelectAll = false
|
||||
|
||||
override fun isSelected(it: ChargepriceCar): Boolean {
|
||||
return prefs.chargepriceMyVehicles.contains(it.id)
|
||||
}
|
||||
|
||||
override fun toggleSelected(item: ChargepriceCar) {
|
||||
if (isSelected(item)) {
|
||||
prefs.chargepriceMyVehicles = prefs.chargepriceMyVehicles.minus(item.id)
|
||||
} else {
|
||||
prefs.chargepriceMyVehicles = prefs.chargepriceMyVehicles.plus(item.id)
|
||||
}
|
||||
}
|
||||
|
||||
override fun getLabel(it: ChargepriceCar) = "${it.brand} ${it.name}"
|
||||
|
||||
override suspend fun loadData(): List<ChargepriceCar> {
|
||||
return api.getVehicles()
|
||||
}
|
||||
}
|
||||
|
||||
class SelectTariffsScreen(ctx: CarContext) : MultiSelectSearchScreen<ChargepriceTariff>(ctx) {
|
||||
private val prefs = PreferenceDataSource(carContext)
|
||||
private var api = ChargepriceApi.create(
|
||||
carContext.getString(R.string.chargeprice_key),
|
||||
carContext.getString(R.string.chargeprice_api_url)
|
||||
)
|
||||
override val isMultiSelect = true
|
||||
override val shouldShowSelectAll = true
|
||||
|
||||
override fun isSelected(it: ChargepriceTariff): Boolean {
|
||||
return prefs.chargepriceMyTariffsAll or (prefs.chargepriceMyTariffs?.contains(it.id)
|
||||
?: false)
|
||||
}
|
||||
|
||||
override fun toggleSelected(item: ChargepriceTariff) {
|
||||
val tariffs = prefs.chargepriceMyTariffs ?: if (prefs.chargepriceMyTariffsAll) {
|
||||
fullList!!.map { it.id }.toSet()
|
||||
} else {
|
||||
emptySet()
|
||||
}
|
||||
if (isSelected(item)) {
|
||||
prefs.chargepriceMyTariffs = tariffs.minus(item.id)
|
||||
prefs.chargepriceMyTariffsAll = false
|
||||
} else {
|
||||
prefs.chargepriceMyTariffs = tariffs.plus(item.id)
|
||||
if (prefs.chargepriceMyTariffs == fullList!!.map { it.id }.toSet()) {
|
||||
prefs.chargepriceMyTariffsAll = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun selectAll() {
|
||||
prefs.chargepriceMyTariffsAll = true
|
||||
super.selectAll()
|
||||
}
|
||||
|
||||
override fun selectNone() {
|
||||
prefs.chargepriceMyTariffsAll = false
|
||||
prefs.chargepriceMyTariffs = emptySet()
|
||||
super.selectNone()
|
||||
}
|
||||
|
||||
override fun getLabel(it: ChargepriceTariff): String {
|
||||
return if (!it.name.lowercase().startsWith(it.provider.lowercase())) {
|
||||
"${it.provider} ${it.name}"
|
||||
} else {
|
||||
it.name
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun loadData(): List<ChargepriceTariff> {
|
||||
return api.getTariffs()
|
||||
}
|
||||
}
|
||||
|
||||
class SelectCurrencyScreen(ctx: CarContext) : MultiSelectSearchScreen<Pair<String, String>>(ctx) {
|
||||
private val prefs = PreferenceDataSource(carContext)
|
||||
override val isMultiSelect = false
|
||||
override val shouldShowSelectAll = false
|
||||
|
||||
override fun isSelected(it: Pair<String, String>): Boolean =
|
||||
prefs.chargepriceCurrency == it.second
|
||||
|
||||
override fun toggleSelected(item: Pair<String, String>) {
|
||||
prefs.chargepriceCurrency = item.second
|
||||
}
|
||||
|
||||
override fun getLabel(it: Pair<String, String>): String = it.first
|
||||
|
||||
override suspend fun loadData(): List<Pair<String, String>> {
|
||||
val names = carContext.resources.getStringArray(R.array.pref_chargeprice_currency_names)
|
||||
val values = carContext.resources.getStringArray(R.array.pref_chargeprice_currency_values)
|
||||
return names.zip(values)
|
||||
}
|
||||
}
|
||||
|
||||
class SelectChargingRangeScreen(ctx: CarContext) : Screen(ctx) {
|
||||
val prefs = PreferenceDataSource(carContext)
|
||||
private val maxItems = if (ctx.carAppApiLevel >= 2) {
|
||||
ctx.constraintManager.getContentLimit(ConstraintManager.CONTENT_LIMIT_TYPE_GRID)
|
||||
} else 6
|
||||
|
||||
override fun onGetTemplate(): Template {
|
||||
return GridTemplate.Builder().apply {
|
||||
setTitle(carContext.getString(R.string.settings_android_auto_chargeprice_range))
|
||||
setHeaderAction(Action.BACK)
|
||||
setSingleList(
|
||||
ItemList.Builder().apply {
|
||||
addItem(GridItem.Builder().apply {
|
||||
setTitle(carContext.getString(R.string.chargeprice_battery_range_from))
|
||||
setText(
|
||||
carContext.getString(
|
||||
R.string.percent_format,
|
||||
prefs.chargepriceBatteryRangeAndroidAuto[0]
|
||||
)
|
||||
)
|
||||
setImage(
|
||||
CarIcon.Builder(
|
||||
IconCompat.createWithResource(
|
||||
carContext,
|
||||
R.drawable.ic_add
|
||||
)
|
||||
).build()
|
||||
)
|
||||
setOnClickListener {
|
||||
prefs.chargepriceBatteryRangeAndroidAuto =
|
||||
prefs.chargepriceBatteryRangeAndroidAuto.toMutableList().apply {
|
||||
this[0] = min(this[1] - 5, this[0] + 5)
|
||||
}
|
||||
invalidate()
|
||||
}
|
||||
}.build())
|
||||
addItem(GridItem.Builder().apply {
|
||||
setTitle(carContext.getString(R.string.chargeprice_battery_range_to))
|
||||
setText(
|
||||
carContext.getString(
|
||||
R.string.percent_format,
|
||||
prefs.chargepriceBatteryRangeAndroidAuto[1]
|
||||
)
|
||||
)
|
||||
setImage(
|
||||
CarIcon.Builder(
|
||||
IconCompat.createWithResource(
|
||||
carContext,
|
||||
R.drawable.ic_add
|
||||
)
|
||||
).build()
|
||||
)
|
||||
setOnClickListener {
|
||||
prefs.chargepriceBatteryRangeAndroidAuto =
|
||||
prefs.chargepriceBatteryRangeAndroidAuto.toMutableList().apply {
|
||||
this[1] = min(100f, this[1] + 5)
|
||||
}
|
||||
invalidate()
|
||||
}
|
||||
}.build())
|
||||
|
||||
val nSpacers = when {
|
||||
maxItems % 3 == 0 -> 1
|
||||
maxItems == 100 -> 0 // AA has increased the limit to 100 and changed the way items are laid out
|
||||
maxItems % 4 == 0 -> 2
|
||||
else -> 0
|
||||
}
|
||||
|
||||
for (i in 0..nSpacers) {
|
||||
addItem(GridItem.Builder().apply {
|
||||
setTitle(" ")
|
||||
setImage(emptyCarIcon)
|
||||
}.build())
|
||||
}
|
||||
|
||||
addItem(GridItem.Builder().apply {
|
||||
setTitle(" ")
|
||||
setImage(
|
||||
CarIcon.Builder(
|
||||
IconCompat.createWithResource(
|
||||
carContext,
|
||||
R.drawable.ic_remove
|
||||
)
|
||||
).build()
|
||||
)
|
||||
setOnClickListener {
|
||||
prefs.chargepriceBatteryRangeAndroidAuto =
|
||||
prefs.chargepriceBatteryRangeAndroidAuto.toMutableList().apply {
|
||||
this[0] = max(0f, this[0] - 5)
|
||||
}
|
||||
invalidate()
|
||||
}
|
||||
}.build())
|
||||
addItem(GridItem.Builder().apply {
|
||||
setTitle(" ")
|
||||
setImage(
|
||||
CarIcon.Builder(
|
||||
IconCompat.createWithResource(
|
||||
carContext,
|
||||
R.drawable.ic_remove
|
||||
)
|
||||
).build()
|
||||
)
|
||||
setOnClickListener {
|
||||
prefs.chargepriceBatteryRangeAndroidAuto =
|
||||
prefs.chargepriceBatteryRangeAndroidAuto.toMutableList().apply {
|
||||
this[1] = max(this[0] + 5, this[1] - 5)
|
||||
}
|
||||
invalidate()
|
||||
}
|
||||
}.build())
|
||||
}.build()
|
||||
)
|
||||
}.build()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package net.vonforst.evmap.auto
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.car.app.CarContext
|
||||
import androidx.car.app.Screen
|
||||
import androidx.car.app.model.*
|
||||
import androidx.car.app.model.signin.InputSignInMethod
|
||||
import androidx.car.app.model.signin.SignInTemplate
|
||||
import net.vonforst.evmap.R
|
||||
|
||||
class TextPromptScreen(
|
||||
ctx: CarContext,
|
||||
@StringRes val title: Int,
|
||||
@StringRes val prompt: Int,
|
||||
val initialValue: String? = null,
|
||||
val cancelable: Boolean = true
|
||||
) : Screen(ctx),
|
||||
InputCallback {
|
||||
private var inputText = ""
|
||||
|
||||
override fun onGetTemplate(): Template {
|
||||
val signInMethod = InputSignInMethod.Builder(this).apply {
|
||||
initialValue?.let {
|
||||
setDefaultValue(it)
|
||||
inputText = initialValue
|
||||
}
|
||||
setShowKeyboardByDefault(true)
|
||||
}.build()
|
||||
return SignInTemplate.Builder(signInMethod).apply {
|
||||
setHeaderAction(Action.BACK)
|
||||
setInstructions(carContext.getString(prompt))
|
||||
setTitle(carContext.getString(title))
|
||||
if (cancelable) {
|
||||
addAction(
|
||||
Action.Builder()
|
||||
.setTitle(carContext.getString(R.string.cancel))
|
||||
.setOnClickListener(ParkedOnlyOnClickListener.create {
|
||||
screenManager.pop()
|
||||
})
|
||||
.build()
|
||||
)
|
||||
}
|
||||
addAction(
|
||||
Action.Builder()
|
||||
.setTitle(carContext.getString(R.string.ok))
|
||||
.setBackgroundColor(CarColor.PRIMARY)
|
||||
.setOnClickListener(ParkedOnlyOnClickListener.create {
|
||||
onInputSubmitted(inputText)
|
||||
})
|
||||
.build()
|
||||
)
|
||||
}.build()
|
||||
}
|
||||
|
||||
override fun onInputTextChanged(text: String) {
|
||||
inputText = text
|
||||
}
|
||||
|
||||
override fun onInputSubmitted(text: String) {
|
||||
setResult(text)
|
||||
screenManager.pop()
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,12 @@ val CarContext.constraintManager
|
||||
|
||||
fun Bitmap.asCarIcon(): CarIcon = CarIcon.Builder(IconCompat.createWithBitmap(this)).build()
|
||||
|
||||
val emptyCarIcon = Bitmap.createBitmap(
|
||||
1,
|
||||
1,
|
||||
Bitmap.Config.ARGB_8888
|
||||
).asCarIcon()
|
||||
|
||||
private const val kmPerMile = 1.609344
|
||||
private const val ftPerMile = 5280
|
||||
private const val ydPerMile = 1760
|
||||
|
||||
@@ -6,32 +6,46 @@ import android.os.Looper
|
||||
import androidx.car.app.CarContext
|
||||
import androidx.car.app.Screen
|
||||
import androidx.car.app.hardware.CarHardwareManager
|
||||
import androidx.car.app.hardware.info.EnergyLevel
|
||||
import androidx.car.app.hardware.info.Model
|
||||
import androidx.car.app.hardware.info.Speed
|
||||
import androidx.car.app.hardware.info.*
|
||||
import androidx.car.app.model.*
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.graphics.drawable.IconCompat
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleObserver
|
||||
import androidx.lifecycle.OnLifecycleEvent
|
||||
import androidx.lifecycle.DefaultLifecycleObserver
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import net.vonforst.evmap.BuildConfig
|
||||
import net.vonforst.evmap.R
|
||||
import net.vonforst.evmap.ui.CompassNeedle
|
||||
import net.vonforst.evmap.ui.Gauge
|
||||
import kotlin.math.min
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
class VehicleDataScreen(ctx: CarContext) : Screen(ctx), LifecycleObserver {
|
||||
private val hardwareMan = ctx.getCarService(CarContext.HARDWARE_SERVICE) as CarHardwareManager
|
||||
class VehicleDataScreen(ctx: CarContext) : Screen(ctx), DefaultLifecycleObserver {
|
||||
private val carInfo =
|
||||
(ctx.getCarService(CarContext.HARDWARE_SERVICE) as CarHardwareManager).carInfo
|
||||
private val carSensors = carContext.patchedCarSensors
|
||||
private var model: Model? = null
|
||||
private var energyLevel: EnergyLevel? = null
|
||||
private var speed: Speed? = null
|
||||
private var heading: Compass? = null
|
||||
private var gauge = Gauge((ctx.resources.displayMetrics.density * 128).roundToInt(), ctx)
|
||||
private var compass =
|
||||
CompassNeedle((ctx.resources.displayMetrics.density * 128).roundToInt(), ctx)
|
||||
private val maxSpeed = 160f / 3.6f // m/s, speed gauge will show max if speed is higher
|
||||
|
||||
private val permissions = listOf(
|
||||
"com.google.android.gms.permission.CAR_FUEL",
|
||||
"com.google.android.gms.permission.CAR_SPEED"
|
||||
)
|
||||
private val permissions = if (BuildConfig.FLAVOR_automotive == "automotive") {
|
||||
listOf(
|
||||
"android.car.permission.CAR_INFO",
|
||||
"android.car.permission.CAR_ENERGY",
|
||||
"android.car.permission.CAR_ENERGY_PORTS",
|
||||
"android.car.permission.READ_CAR_DISPLAY_UNITS",
|
||||
"android.car.permission.CAR_SPEED"
|
||||
)
|
||||
} else {
|
||||
listOf(
|
||||
"com.google.android.gms.permission.CAR_FUEL",
|
||||
"com.google.android.gms.permission.CAR_SPEED"
|
||||
)
|
||||
}
|
||||
|
||||
init {
|
||||
lifecycle.addObserver(this)
|
||||
@@ -44,7 +58,8 @@ class VehicleDataScreen(ctx: CarContext) : Screen(ctx), LifecycleObserver {
|
||||
PermissionScreen(
|
||||
carContext,
|
||||
R.string.auto_vehicle_data_permission_needed,
|
||||
permissions
|
||||
permissions,
|
||||
finishApp = false
|
||||
)
|
||||
) {
|
||||
setupListeners()
|
||||
@@ -55,6 +70,7 @@ class VehicleDataScreen(ctx: CarContext) : Screen(ctx), LifecycleObserver {
|
||||
val energyLevel = energyLevel
|
||||
val model = model
|
||||
val speed = speed
|
||||
val heading = heading
|
||||
|
||||
return GridTemplate.Builder().apply {
|
||||
setTitle(
|
||||
@@ -171,6 +187,25 @@ class VehicleDataScreen(ctx: CarContext) : Screen(ctx), LifecycleObserver {
|
||||
}
|
||||
}
|
||||
}.build())
|
||||
addItem(GridItem.Builder().apply {
|
||||
setTitle(carContext.getString(R.string.auto_heading))
|
||||
if (heading == null) {
|
||||
setLoading(true)
|
||||
} else {
|
||||
val heading = heading.orientations.value
|
||||
if (heading != null) {
|
||||
setText(
|
||||
"${heading[0].roundToInt()}°"
|
||||
)
|
||||
|
||||
} else {
|
||||
setText(carContext.getString(R.string.auto_no_data))
|
||||
}
|
||||
setImage(
|
||||
compass.draw(heading?.get(0)).asCarIcon()
|
||||
)
|
||||
}
|
||||
}.build())
|
||||
}.build()
|
||||
)
|
||||
}
|
||||
@@ -187,27 +222,44 @@ class VehicleDataScreen(ctx: CarContext) : Screen(ctx), LifecycleObserver {
|
||||
invalidate()
|
||||
}
|
||||
|
||||
@OnLifecycleEvent(Lifecycle.Event.ON_RESUME)
|
||||
private fun onCompassUpdated(compass: Compass) {
|
||||
this.heading = compass
|
||||
invalidate()
|
||||
}
|
||||
|
||||
override fun onResume(owner: LifecycleOwner) {
|
||||
setupListeners()
|
||||
}
|
||||
|
||||
private fun setupListeners() {
|
||||
if (!permissionsGranted()) return
|
||||
|
||||
println("Setting up energy level listener")
|
||||
|
||||
val exec = ContextCompat.getMainExecutor(carContext)
|
||||
hardwareMan.carInfo.addEnergyLevelListener(exec, ::onEnergyLevelUpdated)
|
||||
hardwareMan.carInfo.addSpeedListener(exec, ::onSpeedUpdated)
|
||||
carInfo.addEnergyLevelListener(exec, ::onEnergyLevelUpdated)
|
||||
carInfo.addSpeedListener(exec, ::onSpeedUpdated)
|
||||
carSensors.addCompassListener(
|
||||
CarSensors.UPDATE_RATE_NORMAL,
|
||||
exec,
|
||||
::onCompassUpdated
|
||||
)
|
||||
|
||||
hardwareMan.carInfo.fetchModel(exec) {
|
||||
carInfo.fetchModel(exec) {
|
||||
this.model = it
|
||||
invalidate()
|
||||
}
|
||||
}
|
||||
|
||||
@OnLifecycleEvent(Lifecycle.Event.ON_PAUSE)
|
||||
override fun onPause(owner: LifecycleOwner) {
|
||||
removeListeners()
|
||||
}
|
||||
|
||||
private fun removeListeners() {
|
||||
println("Removing energy level listener")
|
||||
hardwareMan.carInfo.removeEnergyLevelListener(::onEnergyLevelUpdated)
|
||||
hardwareMan.carInfo.removeSpeedListener(::onSpeedUpdated)
|
||||
carInfo.removeEnergyLevelListener(::onEnergyLevelUpdated)
|
||||
carInfo.removeSpeedListener(::onSpeedUpdated)
|
||||
carSensors.removeCompassListener(::onCompassUpdated)
|
||||
}
|
||||
|
||||
private fun permissionsGranted(): Boolean =
|
||||
|
||||
@@ -1,121 +0,0 @@
|
||||
package net.vonforst.evmap.auto
|
||||
|
||||
import android.Manifest
|
||||
import android.location.Location
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import androidx.car.app.CarContext
|
||||
import androidx.car.app.Screen
|
||||
import androidx.car.app.model.*
|
||||
import androidx.core.graphics.drawable.IconCompat
|
||||
import net.vonforst.evmap.R
|
||||
|
||||
/**
|
||||
* Welcome screen with selection between favorites and nearby chargers
|
||||
*/
|
||||
class WelcomeScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx), LocationAwareScreen {
|
||||
private var location: Location? = null
|
||||
|
||||
override fun onGetTemplate(): Template {
|
||||
if (!session.locationPermissionGranted()) {
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
screenManager.pushForResult(
|
||||
PermissionScreen(
|
||||
carContext,
|
||||
R.string.auto_location_permission_needed,
|
||||
listOf(
|
||||
Manifest.permission.ACCESS_FINE_LOCATION,
|
||||
Manifest.permission.ACCESS_COARSE_LOCATION
|
||||
)
|
||||
)
|
||||
) {
|
||||
session.bindLocationService()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
session.mapScreen = this
|
||||
return PlaceListMapTemplate.Builder().apply {
|
||||
setTitle(carContext.getString(R.string.app_name))
|
||||
if (!session.locationPermissionGranted()) {
|
||||
setLoading(true)
|
||||
} else {
|
||||
location?.let {
|
||||
setAnchor(Place.Builder(CarLocation.create(it)).build())
|
||||
}
|
||||
setItemList(ItemList.Builder().apply {
|
||||
addItem(
|
||||
Row.Builder()
|
||||
.setTitle(carContext.getString(R.string.auto_chargers_closeby))
|
||||
.setImage(
|
||||
CarIcon.Builder(
|
||||
IconCompat.createWithResource(
|
||||
carContext,
|
||||
R.drawable.ic_address
|
||||
)
|
||||
)
|
||||
.setTint(CarColor.DEFAULT).build()
|
||||
)
|
||||
.setBrowsable(true)
|
||||
.setOnClickListener {
|
||||
screenManager.push(
|
||||
MapScreen(
|
||||
carContext,
|
||||
session,
|
||||
favorites = false
|
||||
)
|
||||
)
|
||||
}
|
||||
.build())
|
||||
addItem(
|
||||
Row.Builder()
|
||||
.setTitle(carContext.getString(R.string.auto_favorites))
|
||||
.setImage(
|
||||
CarIcon.Builder(
|
||||
IconCompat.createWithResource(
|
||||
carContext,
|
||||
R.drawable.ic_fav
|
||||
)
|
||||
)
|
||||
.setTint(CarColor.DEFAULT).build()
|
||||
)
|
||||
.setBrowsable(true)
|
||||
.setOnClickListener {
|
||||
screenManager.push(MapScreen(carContext, session, favorites = true))
|
||||
}
|
||||
.build())
|
||||
if (supportsCarApiLevel3(carContext)) {
|
||||
addItem(
|
||||
Row.Builder()
|
||||
.setTitle(carContext.getString(R.string.auto_vehicle_data))
|
||||
.setImage(
|
||||
CarIcon.Builder(
|
||||
IconCompat.createWithResource(carContext, R.drawable.ic_car)
|
||||
).setTint(CarColor.DEFAULT).build()
|
||||
)
|
||||
.setBrowsable(true)
|
||||
.setOnClickListener {
|
||||
session.mapScreen = null
|
||||
screenManager.push(VehicleDataScreen(carContext))
|
||||
}
|
||||
.build()
|
||||
)
|
||||
}
|
||||
}.build())
|
||||
setCurrentLocationEnabled(true)
|
||||
}
|
||||
setHeaderAction(Action.APP_ICON)
|
||||
build()
|
||||
}.build()
|
||||
}
|
||||
|
||||
override fun updateLocation(location: Location) {
|
||||
if (location.latitude == this.location?.latitude
|
||||
&& location.longitude == this.location?.longitude
|
||||
) {
|
||||
return
|
||||
}
|
||||
this.location = location
|
||||
invalidate()
|
||||
}
|
||||
}
|
||||
33
app/src/google/java/net/vonforst/evmap/ui/CompassNeedle.kt
Normal file
33
app/src/google/java/net/vonforst/evmap/ui/CompassNeedle.kt
Normal file
@@ -0,0 +1,33 @@
|
||||
package net.vonforst.evmap.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Color
|
||||
import android.graphics.PorterDuff
|
||||
import androidx.core.content.ContextCompat
|
||||
import net.vonforst.evmap.R
|
||||
|
||||
class CompassNeedle(val size: Int, ctx: Context) {
|
||||
val image = ContextCompat.getDrawable(ctx, R.drawable.ic_navigation)!!
|
||||
|
||||
init {
|
||||
image.setTint(Color.WHITE)
|
||||
image.setBounds(0, 0, size, size)
|
||||
}
|
||||
|
||||
fun draw(angle: Float?): Bitmap {
|
||||
val bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
|
||||
val canvas = Canvas(bitmap)
|
||||
|
||||
canvas.drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR)
|
||||
|
||||
if (angle != null) {
|
||||
canvas.save()
|
||||
canvas.rotate(-angle, size / 2f, size / 2f)
|
||||
image.draw(canvas)
|
||||
canvas.restore()
|
||||
}
|
||||
return bitmap
|
||||
}
|
||||
}
|
||||
10
app/src/google/res/drawable/ic_checkbox_checked.xml
Normal file
10
app/src/google/res/drawable/ic_checkbox_checked.xml
Normal 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,3L5,3c-1.11,0 -2,0.9 -2,2v14c0,1.1 0.89,2 2,2h14c1.11,0 2,-0.9 2,-2L21,5c0,-1.1 -0.89,-2 -2,-2zM10,17l-5,-5 1.41,-1.41L10,14.17l7.59,-7.59L19,8l-9,9z" />
|
||||
</vector>
|
||||
10
app/src/google/res/drawable/ic_checkbox_unchecked.xml
Normal file
10
app/src/google/res/drawable/ic_checkbox_unchecked.xml
Normal 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,5v14H5V5h14m0,-2H5c-1.1,0 -2,0.9 -2,2v14c0,1.1 0.9,2 2,2h14c1.1,0 2,-0.9 2,-2V5c0,-1.1 -0.9,-2 -2,-2z" />
|
||||
</vector>
|
||||
13
app/src/google/res/drawable/ic_search_off.xml
Normal file
13
app/src/google/res/drawable/ic_search_off.xml
Normal file
@@ -0,0 +1,13 @@
|
||||
<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="M15.5,14h-0.79l-0.28,-0.27C15.41,12.59 16,11.11 16,9.5C16,5.91 13.09,3 9.5,3C6.08,3 3.28,5.64 3.03,9h2.02C5.3,6.75 7.18,5 9.5,5C11.99,5 14,7.01 14,9.5S11.99,14 9.5,14c-0.17,0 -0.33,-0.03 -0.5,-0.05v2.02C9.17,15.99 9.33,16 9.5,16c1.61,0 3.09,-0.59 4.23,-1.57L14,14.71v0.79l5,4.99L20.49,19L15.5,14z" />
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M6.47,10.82l-2.47,2.47l-2.47,-2.47l-0.71,0.71l2.47,2.47l-2.47,2.47l0.71,0.71l2.47,-2.47l2.47,2.47l0.71,-0.71l-2.47,-2.47l2.47,-2.47z" />
|
||||
</vector>
|
||||
@@ -1,13 +1,5 @@
|
||||
<?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_search_provider_names">
|
||||
<item>Google Maps</item>
|
||||
<item>OpenStreetMap (Mapbox)</item>
|
||||
</string-array>
|
||||
<string name="donations_info" formatted="false">Findest du EVMap nützlich? Unterstütze die Weiterentwicklung der App mit einer Spende an den Entwickler.\n\nGoogle zieht von der Spende 15% Gebühren ab.</string>
|
||||
<string name="auto_location_service">EVMap läuft unter Android Auto und nutzt dafür deinen Standort.</string>
|
||||
<string name="auto_no_chargers_found">Keine Ladestationen in der Nähe gefunden</string>
|
||||
@@ -19,6 +11,7 @@
|
||||
<string name="grant_on_phone">Auf Telefon zulassen</string>
|
||||
<string name="auto_chargers_closeby">In der Nähe</string>
|
||||
<string name="auto_favorites">Favoriten</string>
|
||||
<string name="auto_chargers_near_location">Nahe %s</string>
|
||||
<string name="auto_fault_report_date">⚠️ Störungsmeldung (%s)</string>
|
||||
<string name="auto_no_refresh_possible">Weitere Aktualisierung nicht möglich. Bitte zurück gehen und neu starten.</string>
|
||||
<string name="auto_prices">Preise</string>
|
||||
@@ -27,6 +20,8 @@
|
||||
<string name="auto_no_data">Nicht verfügbar</string>
|
||||
<string name="auto_range">Reichweite</string>
|
||||
<string name="auto_speed">Geschwindigkeit</string>
|
||||
<string name="auto_heading">Fahrtrichtung</string>
|
||||
<string name="auto_settings">Einstellungen</string>
|
||||
<string name="welcome_android_auto">Android Auto-Unterstützung</string>
|
||||
<string name="welcome_android_auto_detail">Auf unterstützen Autos kannst du EVMap auch mit Android Auto nutzen. Öffne dazu einfach die EVMap-App aus dem Menü von Android Auto.</string>
|
||||
<string name="sounds_cool">klingt cool</string>
|
||||
@@ -34,4 +29,7 @@
|
||||
<string name="auto_chargeprice_vehicle_unknown">Keins der in der App ausgewählten Fahrzeuge passt zu diesem Fahrzeug (%1$s %2$s).</string>
|
||||
<string name="auto_chargeprice_vehicle_ambiguous">Mehrere der in der App ausgewählten Fahrzeuge passen zu diesem Fahrzeug (%1$s %2$s).</string>
|
||||
<string name="settings_android_auto_chargeprice_range">Ladebereich für Preisvergleich</string>
|
||||
<string name="data_sources_hint">In den Einstellungen kannst du auch zwischen Google Maps und OpenStreetMap (Mapbox) für die Kartendaten wechseln.</string>
|
||||
<string name="selecting_all">alle Einträge ausgewählt</string>
|
||||
<string name="selecting_none">alle Einträge abgewählt</string>
|
||||
</resources>
|
||||
37
app/src/google/res/values-fr/strings.xml
Normal file
37
app/src/google/res/values-fr/strings.xml
Normal file
@@ -0,0 +1,37 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="donations_info" formatted="false">Trouvez-vous EVMap utile \? Soutenez son développement en envoyant un don au développeur.
|
||||
\n
|
||||
\nGoogle prend 15% sur chaque don.</string>
|
||||
<string name="auto_location_service">EVMap fonctionne sur Android Auto et utilise votre position.</string>
|
||||
<string name="open_in_app">Ouvrir dans l\'application</string>
|
||||
<string name="opened_on_phone">Ouvert sur le téléphone</string>
|
||||
<string name="auto_location_permission_needed">Pour exécuter EVMap sur Android Auto, vous devez autoriser l\'accès à votre emplacement.</string>
|
||||
<string name="grant_on_phone">Grant au téléphone</string>
|
||||
<string name="auto_prices">Prix</string>
|
||||
<string name="auto_vehicle_data">Données sur le véhicule</string>
|
||||
<string name="auto_range">Autonomie</string>
|
||||
<string name="auto_speed">Vitesse</string>
|
||||
<string name="welcome_android_auto">Prise en charge d’Android Auto</string>
|
||||
<string name="sounds_cool">ça a l\'air cool</string>
|
||||
<string name="auto_chargeprice_vehicle_unknown">Aucun des véhicules sélectionnés dans l\'application ne correspond à ce véhicule (%1$s %2$s).</string>
|
||||
<string name="auto_chargeprice_vehicle_ambiguous">Plusieurs véhicules sélectionnés dans l\'application correspondent à ce véhicule (%1$s %2$s).</string>
|
||||
<string name="selecting_all">tous les éléments sélectionnés</string>
|
||||
<string name="data_sources_hint">Dans les paramètres, vous pouvez également choisir entre Google Maps et OpenStreetMap (Mapbox) pour les données cartographiques.</string>
|
||||
<string name="auto_chargeprice_vehicle_unavailable">EVMap n\'a pas pu déterminer le modèle de votre véhicule.</string>
|
||||
<string name="auto_no_chargers_found">Aucun chargeur à proximité n\'a été trouvé</string>
|
||||
<string name="auto_no_favorites_found">Pas de favoris trouvés</string>
|
||||
<string name="auto_charging_level">Niveau de charge</string>
|
||||
<string name="auto_chargers_closeby">Chargeurs à proximité</string>
|
||||
<string name="auto_chargers_near_location">Près de %s</string>
|
||||
<string name="auto_fault_report_date">⚠️ Rapport d\'anomalie (%s)</string>
|
||||
<string name="auto_no_data">Indisponible</string>
|
||||
<string name="auto_settings">Paramètres</string>
|
||||
<string name="selecting_none">désélectionner tous les éléments</string>
|
||||
<string name="auto_vehicle_data_permission_needed">Pour cette fonction, EVMap doit avoir accès aux données de votre véhicule.</string>
|
||||
<string name="auto_heading">Direction</string>
|
||||
<string name="auto_favorites">Favoris</string>
|
||||
<string name="auto_no_refresh_possible">D\'autres mises à jour ne sont pas possibles. Veuillez revenir en arrière et redémarrer.</string>
|
||||
<string name="settings_android_auto_chargeprice_range">Plage de charge pour la comparaison des prix</string>
|
||||
<string name="welcome_android_auto_detail">Vous pouvez également utiliser EVMap à partir d\'Android Auto sur les voitures prises en charge. Il suffit de sélectionner l\'application EVMap dans le menu Android Auto.</string>
|
||||
</resources>
|
||||
37
app/src/google/res/values-nb-rNO/strings.xml
Normal file
37
app/src/google/res/values-nb-rNO/strings.xml
Normal file
@@ -0,0 +1,37 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="donations_info" formatted="false">Synes du EVMap er nyttig\? Støtt utviklingen ved å sende penger til utvikleren.
|
||||
\n
|
||||
\nGoogle tar 15% av alle donasjoner.</string>
|
||||
<string name="auto_favorites">Favoritter</string>
|
||||
<string name="auto_charging_level">Ladingsnivå</string>
|
||||
<string name="auto_chargeprice_vehicle_unavailable">EVMap kunne ikke fastsette kjøretøymodellen.</string>
|
||||
<string name="selecting_none">fravalgte alle elementer</string>
|
||||
<string name="grant_on_phone">Innvilg på mobilenheten</string>
|
||||
<string name="auto_chargers_closeby">Ladere i nærheten</string>
|
||||
<string name="auto_prices">Pris</string>
|
||||
<string name="auto_no_chargers_found">Ingen ladere i nærheten</string>
|
||||
<string name="auto_no_favorites_found">Fant ikke noen favoritter</string>
|
||||
<string name="open_in_app">Åpne i programmet</string>
|
||||
<string name="auto_location_service">EVMap kjører på Android Auto og bruker posisjonen din.</string>
|
||||
<string name="auto_heading">Fartsretning</string>
|
||||
<string name="opened_on_phone">Åpnet på mobilenheten</string>
|
||||
<string name="auto_location_permission_needed">Innvilg posisjonstilgang for å bruke EVMap på Android Auto.</string>
|
||||
<string name="auto_chargers_near_location">Nær %s</string>
|
||||
<string name="auto_fault_report_date">⚠️ Feilrapport (%s)</string>
|
||||
<string name="auto_vehicle_data">Kjøretøydata</string>
|
||||
<string name="auto_no_data">Utilgjengelig</string>
|
||||
<string name="auto_speed">Hastighet</string>
|
||||
<string name="auto_settings">Innstillinger</string>
|
||||
<string name="auto_chargeprice_vehicle_unknown">Ingen av kjøretøyene valgt i programmet samsvarer med dette kjøretøyet (%1$s %2$s).</string>
|
||||
<string name="welcome_android_auto">Android Auto-støtte</string>
|
||||
<string name="auto_chargeprice_vehicle_ambiguous">Flere kjøretøy valgt i programmet samsvarer med dette kjøretøyet (%1$s %2$s).</string>
|
||||
<string name="auto_vehicle_data_permission_needed">EvMap trenger tilgang til kjøretøydata for å bruke denne funksjonen.</string>
|
||||
<string name="auto_no_refresh_possible">Videre oppdateringer er ikke mulig. Gå tilbake og start på ny.</string>
|
||||
<string name="auto_range">Rekkevidde</string>
|
||||
<string name="welcome_android_auto_detail">Du kan også bruke EVMap inne i Android Auto på bilder som støtter dette ved å velge det i Android Auto-menyen.</string>
|
||||
<string name="settings_android_auto_chargeprice_range">Prissammenligning for laderekkevidde fordelt på pris</string>
|
||||
<string name="data_sources_hint">I innstillingene kan du også bytte mellom Google Maps og OpenStreetMap (Mapbox) for kartdata.</string>
|
||||
<string name="selecting_all">valgte alle elementene</string>
|
||||
<string name="sounds_cool">den er grei</string>
|
||||
</resources>
|
||||
19
app/src/google/res/values/arrays.xml
Normal file
19
app/src/google/res/values/arrays.xml
Normal file
@@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string-array name="pref_map_provider_names">
|
||||
<item>@string/pref_provider_google_maps</item>
|
||||
<item>@string/pref_provider_osm_mapbox</item>
|
||||
</string-array>
|
||||
<string-array name="pref_map_provider_values" translatable="false">
|
||||
<item>google</item>
|
||||
<item>mapbox</item>
|
||||
</string-array>
|
||||
<string-array name="pref_search_provider_names">
|
||||
<item>@string/pref_provider_google_maps</item>
|
||||
<item>@string/pref_provider_osm_mapbox</item>
|
||||
</string-array>
|
||||
<string-array name="pref_search_provider_values" translatable="false">
|
||||
<item>google</item>
|
||||
<item>mapbox</item>
|
||||
</string-array>
|
||||
</resources>
|
||||
@@ -3,5 +3,5 @@
|
||||
<color name="gauge_active">#00e676</color>
|
||||
<color name="gauge_middle">#087f23</color>
|
||||
<color name="gauge_inactive">#9e9e9e</color>
|
||||
<color name="charger_100kw_dark">#fdd835</color>
|
||||
<color name="charger_100kw_dark">#FBC02D</color>
|
||||
</resources>
|
||||
5
app/src/google/res/values/donottranslate.xml
Normal file
5
app/src/google/res/values/donottranslate.xml
Normal file
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="pref_map_provider_default" translatable="false">google</string>
|
||||
<string name="pref_search_provider_default" translatable="false">mapbox</string>
|
||||
</resources>
|
||||
@@ -1,23 +1,5 @@
|
||||
<?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-array name="pref_search_provider_names">
|
||||
<item>Google Maps</item>
|
||||
<item>OpenStreetMap (Mapbox)</item>
|
||||
</string-array>
|
||||
<string-array name="pref_search_provider_values" tranlatable="false">
|
||||
<item>google</item>
|
||||
<item>mapbox</item>
|
||||
</string-array>
|
||||
<string name="pref_map_provider_default" translatable="false">google</string>
|
||||
<string name="pref_search_provider_default" translatable="false">mapbox</string>
|
||||
<string name="donations_info" formatted="false">Do you find EVMap useful? Support its development by sending a donation to the developer.\n\nGoogle takes 15% off every donation.</string>
|
||||
<string name="auto_location_service">EVMap is running on Android Auto and using your location.</string>
|
||||
<string name="auto_no_chargers_found">No nearby chargers found</string>
|
||||
@@ -29,6 +11,7 @@
|
||||
<string name="grant_on_phone">Grant on phone</string>
|
||||
<string name="auto_chargers_closeby">Nearby chargers</string>
|
||||
<string name="auto_favorites">Favorites</string>
|
||||
<string name="auto_chargers_near_location">Near %s</string>
|
||||
<string name="auto_fault_report_date">⚠️ Fault report (%s)</string>
|
||||
<string name="auto_no_refresh_possible">Further updates not possible. Please go back and restart.</string>
|
||||
<string name="auto_prices">Pricing</string>
|
||||
@@ -37,11 +20,16 @@
|
||||
<string name="auto_no_data">Unavailable</string>
|
||||
<string name="auto_range">Range</string>
|
||||
<string name="auto_speed">Speed</string>
|
||||
<string name="auto_heading">Heading</string>
|
||||
<string name="auto_settings">Settings</string>
|
||||
<string name="welcome_android_auto">Android Auto support</string>
|
||||
<string name="welcome_android_auto_detail">You can also use EVMap from within Android Auto on supported cars. Simply select the EVMap app in the Android Auto menu.</string>
|
||||
<string name="sounds_cool">sounds cool</string>
|
||||
<string name="auto_chargeprice_vehicle_unavailable">EVMap could not determine your vehicle model.</string>
|
||||
<string name="auto_chargeprice_vehicle_unknown">None of the vehicles selected in the app matches this vehicle (%1$s %2$s).</string>
|
||||
<string name="auto_chargeprice_vehicle_ambiguous">Mehrere der in der App ausgewählten Fahrzeuge passen zu diesem Fahrzeug (%1$s %2$s).</string>
|
||||
<string name="auto_chargeprice_vehicle_ambiguous">Multiple vehicles selected in the app match this vehicle (%1$s %2$s).</string>
|
||||
<string name="settings_android_auto_chargeprice_range">Charging range for price comparison</string>
|
||||
<string name="data_sources_hint">In the settings you can also switch between Google Maps and OpenStreetMap (Mapbox) for the map data.</string>
|
||||
<string name="selecting_all">selected all items</string>
|
||||
<string name="selecting_none">deselected all items</string>
|
||||
</resources>
|
||||
65
app/src/googleAutomotive/AndroidManifest.xml
Normal file
65
app/src/googleAutomotive/AndroidManifest.xml
Normal file
@@ -0,0 +1,65 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
package="net.vonforst.evmap">
|
||||
|
||||
<uses-permission android:name="android.car.permission.CAR_INFO" />
|
||||
<uses-permission android:name="android.car.permission.CAR_ENERGY" />
|
||||
<uses-permission android:name="android.car.permission.CAR_ENERGY_PORTS" />
|
||||
<uses-permission android:name="android.car.permission.READ_CAR_DISPLAY_UNITS" />
|
||||
<uses-permission android:name="android.car.permission.CAR_SPEED" />
|
||||
|
||||
<uses-feature
|
||||
android:name="android.hardware.type.automotive"
|
||||
android:required="true" />
|
||||
|
||||
<uses-feature
|
||||
android:name="android.software.car.templates_host"
|
||||
android:required="true" />
|
||||
|
||||
<uses-feature
|
||||
android:name="android.hardware.wifi"
|
||||
tools:replace="required"
|
||||
android:required="false" />
|
||||
<uses-feature
|
||||
android:name="android.hardware.screen.portrait"
|
||||
android:required="false" />
|
||||
<uses-feature
|
||||
android:name="android.hardware.screen.landscape"
|
||||
android:required="false" />
|
||||
<uses-feature
|
||||
android:name="android.hardware.camera"
|
||||
android:required="false" />
|
||||
|
||||
<application>
|
||||
<meta-data
|
||||
android:name="com.android.automotive"
|
||||
android:resource="@xml/automotive_app_desc" />
|
||||
|
||||
<meta-data
|
||||
android:name="com.google.android.gms.car.application"
|
||||
tools:node="remove" />
|
||||
|
||||
<activity
|
||||
android:name=".MapsActivity"
|
||||
tools:node="remove" />
|
||||
|
||||
<activity
|
||||
android:exported="true"
|
||||
android:theme="@android:style/Theme.DeviceDefault.NoActionBar"
|
||||
android:name="androidx.car.app.activity.CarAppActivity"
|
||||
android:launchMode="singleTask"
|
||||
android:label="@string/app_name">
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
|
||||
<meta-data
|
||||
android:name="distractionOptimized"
|
||||
android:value="true" />
|
||||
|
||||
</activity>
|
||||
</application>
|
||||
</manifest>
|
||||
5
app/src/googleAutomotive/res/values-de/strings.xml
Normal file
5
app/src/googleAutomotive/res/values-de/strings.xml
Normal file
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="grant_on_phone">Zulassen</string>
|
||||
<string name="auto_location_permission_needed">Um EVMap auf deinem Fahrzeug zu nutzen, braucht die App Zugriff auf deinen Standort.</string>
|
||||
</resources>
|
||||
5
app/src/googleAutomotive/res/values-fr/strings.xml
Normal file
5
app/src/googleAutomotive/res/values-fr/strings.xml
Normal file
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="grant_on_phone">Autoriser</string>
|
||||
<string name="auto_location_permission_needed">Pour exécuter EVMap sur Android Auto, vous devez autoriser l\'accès à votre emplacement.</string>
|
||||
</resources>
|
||||
5
app/src/googleAutomotive/res/values-nb-rNO/strings.xml
Normal file
5
app/src/googleAutomotive/res/values-nb-rNO/strings.xml
Normal file
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="auto_location_permission_needed">Du må du innvilge posisjonstilgang for å kjøre EVMap i bilen din.</string>
|
||||
<string name="grant_on_phone">Tillat</string>
|
||||
</resources>
|
||||
5
app/src/googleAutomotive/res/values/strings.xml
Normal file
5
app/src/googleAutomotive/res/values/strings.xml
Normal file
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="grant_on_phone">Allow</string>
|
||||
<string name="auto_location_permission_needed">To run EVMap on your car, you need to grant access to your location.</string>
|
||||
</resources>
|
||||
@@ -24,7 +24,8 @@
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/AppTheme">
|
||||
android:theme="@style/AppTheme"
|
||||
android:localeConfig="@xml/locales_config">
|
||||
|
||||
<meta-data
|
||||
android:name="com.mapbox.ACCESS_TOKEN"
|
||||
@@ -262,16 +263,14 @@
|
||||
android:resource="@xml/shortcuts" />
|
||||
</activity>
|
||||
|
||||
<!-- Override services of the com.mapzen.android.lost library with exported:false
|
||||
until https://github.com/lostzen/lost/pull/270 is merged -->
|
||||
<service
|
||||
android:name="com.mapzen.android.lost.internal.GeofencingIntentService"
|
||||
android:name="androidx.appcompat.app.AppLocalesMetadataHolderService"
|
||||
android:enabled="false"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="com.mapzen.lost.action.ACTION_GEOFENCING_SERVICE" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="autoStoreLocales"
|
||||
android:value="true" />
|
||||
</service>
|
||||
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -3,6 +3,7 @@ package net.vonforst.evmap
|
||||
import android.app.Application
|
||||
import com.facebook.stetho.Stetho
|
||||
import net.vonforst.evmap.storage.PreferenceDataSource
|
||||
import net.vonforst.evmap.ui.updateAppLocale
|
||||
import net.vonforst.evmap.ui.updateNightMode
|
||||
import org.acra.config.dialog
|
||||
import org.acra.config.limiter
|
||||
@@ -13,7 +14,16 @@ import org.acra.ktx.initAcra
|
||||
class EvMapApplication : Application() {
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
updateNightMode(PreferenceDataSource(this))
|
||||
val prefs = PreferenceDataSource(this)
|
||||
updateNightMode(prefs)
|
||||
|
||||
// Convert to new AppCompat storage for app language
|
||||
val lang = prefs.language
|
||||
if (lang != null && lang !in listOf("", "default")) {
|
||||
updateAppLocale(lang)
|
||||
prefs.language = null
|
||||
}
|
||||
|
||||
Stetho.initializeWithDefaults(this);
|
||||
init(applicationContext)
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ package net.vonforst.evmap
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
@@ -32,11 +31,9 @@ import net.vonforst.evmap.fragment.MapFragmentArgs
|
||||
import net.vonforst.evmap.model.ChargeLocation
|
||||
import net.vonforst.evmap.navigation.NavHostFragment
|
||||
import net.vonforst.evmap.storage.PreferenceDataSource
|
||||
import net.vonforst.evmap.utils.LocaleContextWrapper
|
||||
import net.vonforst.evmap.utils.getLocationFromIntent
|
||||
|
||||
|
||||
const val REQUEST_LOCATION_PERMISSION = 1
|
||||
const val EXTRA_CHARGER_ID = "chargerId"
|
||||
const val EXTRA_LAT = "lat"
|
||||
const val EXTRA_LON = "lon"
|
||||
@@ -54,14 +51,6 @@ class MapsActivity : AppCompatActivity(),
|
||||
var fragmentCallback: FragmentCallback? = null
|
||||
private lateinit var prefs: PreferenceDataSource
|
||||
|
||||
override fun attachBaseContext(newBase: Context) {
|
||||
return super.attachBaseContext(
|
||||
LocaleContextWrapper.wrap(
|
||||
newBase, PreferenceDataSource(newBase).language
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
val splashScreen = installSplashScreen()
|
||||
@@ -84,9 +73,9 @@ class MapsActivity : AppCompatActivity(),
|
||||
val navView = findViewById<NavigationView>(R.id.nav_view)
|
||||
navView.setupWithNavController(navController)
|
||||
|
||||
val header = navView.getHeaderView(0)
|
||||
ViewCompat.setOnApplyWindowInsetsListener(header) { v, insets ->
|
||||
v.setPadding(0, insets.getInsets(WindowInsetsCompat.Type.statusBars()).top, 0, 0)
|
||||
ViewCompat.setOnApplyWindowInsetsListener(navView) { v, insets ->
|
||||
val header = navView.getHeaderView(0)
|
||||
header.setPadding(0, insets.getInsets(WindowInsetsCompat.Type.statusBars()).top, 0, 0)
|
||||
insets
|
||||
}
|
||||
|
||||
@@ -98,7 +87,7 @@ class MapsActivity : AppCompatActivity(),
|
||||
if (!prefs.welcomeDialogShown || !prefs.dataSourceSet) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
// wait for splash screen animation to finish on first start
|
||||
splashScreen.setKeepVisibleCondition(object : SplashScreen.KeepOnScreenCondition {
|
||||
splashScreen.setKeepOnScreenCondition(object : SplashScreen.KeepOnScreenCondition {
|
||||
var startTime: Long? = null
|
||||
|
||||
override fun shouldKeepOnScreen(): Boolean {
|
||||
@@ -163,6 +152,7 @@ class MapsActivity : AppCompatActivity(),
|
||||
.createPendingIntent()
|
||||
} else if (intent.hasExtra(EXTRA_FAVORITES)) {
|
||||
deepLink = navController.createDeepLink()
|
||||
.setGraph(navGraph)
|
||||
.setDestination(R.id.favs)
|
||||
.createPendingIntent()
|
||||
}
|
||||
|
||||
@@ -6,11 +6,6 @@ import android.graphics.Typeface
|
||||
import android.os.Bundle
|
||||
import android.text.*
|
||||
import android.text.style.StyleSpan
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.Observer
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.util.*
|
||||
|
||||
fun Bundle.optDouble(name: String): Double? {
|
||||
@@ -85,25 +80,6 @@ fun max(a: Int?, b: Int?): Int? {
|
||||
|
||||
fun <T> List<T>.containsAny(vararg values: T) = values.any { this.contains(it) }
|
||||
|
||||
public suspend fun <T> LiveData<T>.await(): T {
|
||||
return withContext(Dispatchers.Main.immediate) {
|
||||
suspendCancellableCoroutine { continuation ->
|
||||
val observer = object : Observer<T> {
|
||||
override fun onChanged(value: T) {
|
||||
removeObserver(this)
|
||||
continuation.resume(value, null)
|
||||
}
|
||||
}
|
||||
|
||||
observeForever(observer)
|
||||
|
||||
continuation.invokeOnCancellation {
|
||||
removeObserver(observer)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun Context.isDarkMode() =
|
||||
(resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES
|
||||
|
||||
|
||||
@@ -171,7 +171,7 @@ class CheckableConnectorAdapter : DataBindingAdapter<Chargepoint>() {
|
||||
root.post {
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
onCheckedItemChangedListener?.invoke(getCheckedItem()!!)
|
||||
getCheckedItem()?.let { onCheckedItemChangedListener?.invoke(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,13 +44,13 @@ fun buildDetails(
|
||||
if (loc == null) return emptyList()
|
||||
|
||||
return listOfNotNull(
|
||||
DetailsAdapter.Detail(
|
||||
if (loc.address != null) DetailsAdapter.Detail(
|
||||
R.drawable.ic_address,
|
||||
R.string.address,
|
||||
loc.address.toString(),
|
||||
loc.locationDescription,
|
||||
clickable = true
|
||||
),
|
||||
) else null,
|
||||
if (loc.operator != null) DetailsAdapter.Detail(
|
||||
R.drawable.ic_operator,
|
||||
R.string.operator,
|
||||
@@ -91,7 +91,7 @@ fun buildDetails(
|
||||
R.drawable.ic_cost,
|
||||
R.string.cost,
|
||||
loc.cost.getStatusText(ctx),
|
||||
loc.cost.descriptionLong ?: loc.cost.descriptionShort
|
||||
loc.cost.getDetailText()
|
||||
)
|
||||
else null,
|
||||
if (loc.chargecards != null && loc.chargecards.isNotEmpty() || loc.barrierFree == true)
|
||||
|
||||
@@ -14,7 +14,7 @@ class FavoritesAdapter(val onDelete: (FavoritesViewModel.FavoritesListItem) -> U
|
||||
|
||||
override fun getItemViewType(position: Int): Int = R.layout.item_favorite
|
||||
|
||||
override fun getItemId(position: Int): Long = getItem(position).charger.id
|
||||
override fun getItemId(position: Int): Long = getItem(position).fav.favorite.favoriteId
|
||||
|
||||
@SuppressLint("ClickableViewAccessibility")
|
||||
override fun bind(
|
||||
|
||||
@@ -62,6 +62,8 @@ class FiltersAdapter : DataBindingAdapter<FilterWithValue<FilterValue>>() {
|
||||
)
|
||||
}
|
||||
}
|
||||
is BooleanFilterValue -> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -34,7 +34,8 @@ interface ChargepointApi<out T : ReferenceData> {
|
||||
|
||||
fun getFilters(referenceData: ReferenceData, sp: StringProvider): List<Filter<FilterValue>>
|
||||
|
||||
fun getName(): String
|
||||
val name: String
|
||||
val id: String
|
||||
}
|
||||
|
||||
interface StringProvider {
|
||||
|
||||
@@ -21,6 +21,14 @@ import java.util.concurrent.TimeUnit
|
||||
|
||||
interface AvailabilityDetector {
|
||||
suspend fun getAvailability(location: ChargeLocation): ChargeLocationStatus
|
||||
|
||||
/**
|
||||
* Get a rough estimate whether this charger is supported by this provider.
|
||||
*
|
||||
* This might be done by checking supported countries, or even by matching the operator
|
||||
* for operator-specific availability detectors.
|
||||
*/
|
||||
fun isChargerSupported(charger: ChargeLocation): Boolean
|
||||
}
|
||||
|
||||
abstract class BaseAvailabilityDetector(private val client: OkHttpClient) : AvailabilityDetector {
|
||||
@@ -86,7 +94,7 @@ abstract class BaseAvailabilityDetector(private val client: OkHttpClient) : Avai
|
||||
// find corresponding powers in GE data
|
||||
val gePowers =
|
||||
chargepoints.filter { equivalentPlugTypes(it.type).any { it == type } }
|
||||
.map { it.power }.distinct().sorted()
|
||||
.mapNotNull { it.power }.distinct().sorted()
|
||||
|
||||
// if the distinct number of powers is the same, try to match.
|
||||
if (powers.size == gePowers.size) {
|
||||
@@ -131,7 +139,7 @@ data class ChargeLocationStatus(
|
||||
(connectors == null || connectors.map {
|
||||
equivalentPlugTypes(it)
|
||||
}.any { equivalent -> it.type in equivalent })
|
||||
&& (minPower == null || it.power > minPower)
|
||||
&& (minPower == null || (it.power != null && it.power >= minPower))
|
||||
}
|
||||
return this.copy(status = statusFiltered)
|
||||
}
|
||||
@@ -157,21 +165,16 @@ private val okhttp = OkHttpClient.Builder()
|
||||
.cookieJar(JavaNetCookieJar(cookieManager))
|
||||
.build()
|
||||
val availabilityDetectors = listOf(
|
||||
RheinenergieAvailabilityDetector(okhttp),
|
||||
EnBwAvailabilityDetector(okhttp),
|
||||
NewMotionAvailabilityDetector(okhttp)
|
||||
/*ChargecloudAvailabilityDetector(
|
||||
okhttp,
|
||||
"606a0da0dfdd338ee4134605653d4fd8"
|
||||
), // Maingau
|
||||
ChargecloudAvailabilityDetector(
|
||||
okhttp,
|
||||
"6336fe713f2eb7fa04b97ff6651b76f8"
|
||||
) // SW Kiel*/
|
||||
)
|
||||
|
||||
suspend fun getAvailability(charger: ChargeLocation): Resource<ChargeLocationStatus> {
|
||||
var value: Resource<ChargeLocationStatus>? = null
|
||||
withContext(Dispatchers.IO) {
|
||||
for (ad in availabilityDetectors) {
|
||||
if (!ad.isChargerSupported(charger)) continue
|
||||
try {
|
||||
value = Resource.success(ad.getAvailability(charger))
|
||||
break
|
||||
@@ -188,4 +191,4 @@ suspend fun getAvailability(charger: ChargeLocation): Resource<ChargeLocationSta
|
||||
}
|
||||
}
|
||||
return value ?: Resource.error(null, null)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,83 +1,110 @@
|
||||
package net.vonforst.evmap.api.availability
|
||||
|
||||
import net.vonforst.evmap.api.iterator
|
||||
import com.squareup.moshi.Json
|
||||
import com.squareup.moshi.JsonClass
|
||||
import net.vonforst.evmap.model.ChargeLocation
|
||||
import net.vonforst.evmap.model.Chargepoint
|
||||
import okhttp3.OkHttpClient
|
||||
import org.json.JSONObject
|
||||
import java.io.IOException
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.converter.moshi.MoshiConverterFactory
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.Query
|
||||
|
||||
class ChargecloudAvailabilityDetector(
|
||||
client: OkHttpClient,
|
||||
private val operatorId: String
|
||||
interface ChargecloudApi {
|
||||
@GET("locations")
|
||||
suspend fun getData(
|
||||
@Query("latitude") latitude: Double,
|
||||
@Query("longitude") longitude: Double,
|
||||
@Query("radius") radius: Int,
|
||||
@Query("offset") offset: Int = 0,
|
||||
@Query("limit") limit: Int = 10
|
||||
): ChargecloudResponse
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class ChargecloudResponse(
|
||||
val data: List<ChargecloudLocation>
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class ChargecloudLocation(
|
||||
val coordinates: ChargecloudCoordinates,
|
||||
val evses: List<ChargecloudEvse>,
|
||||
@Json(name = "distance_in_m") val distanceInM: String
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class ChargecloudCoordinates(val latitude: Double, val longitude: Double)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class ChargecloudEvse(
|
||||
val id: String,
|
||||
val status: String,
|
||||
val connectors: List<ChargecloudConnector>
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class ChargecloudConnector(
|
||||
val id: Long,
|
||||
val standard: String,
|
||||
@Json(name = "max_power") val maxPower: Double,
|
||||
val status: String
|
||||
)
|
||||
|
||||
companion object {
|
||||
fun create(client: OkHttpClient, baseUrl: String? = null): ChargecloudApi {
|
||||
val retrofit = Retrofit.Builder()
|
||||
.baseUrl(baseUrl)
|
||||
.addConverterFactory(MoshiConverterFactory.create())
|
||||
.client(client)
|
||||
.build()
|
||||
return retrofit.create(ChargecloudApi::class.java)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
abstract class ChargecloudAvailabilityDetector(
|
||||
client: OkHttpClient
|
||||
) : BaseAvailabilityDetector(client) {
|
||||
protected abstract val operatorId: String
|
||||
|
||||
private val api: ChargecloudApi by lazy {
|
||||
val baseUrl = "https://app.chargecloud.de/emobility:ocpi/$operatorId/app/2.0/"
|
||||
ChargecloudApi.create(client, baseUrl)
|
||||
}
|
||||
|
||||
override suspend fun getAvailability(location: ChargeLocation): ChargeLocationStatus {
|
||||
val url =
|
||||
"https://app.chargecloud.de/emobility:ocpi/$operatorId/app/2.0/locations?latitude=${location.coordinates.lat}&longitude=${location.coordinates.lng}&radius=$radius&offset=0&limit=10"
|
||||
val json = JSONObject(httpGet(url))
|
||||
val data = api.getData(location.coordinates.lat, location.coordinates.lng, radius)
|
||||
|
||||
val statusMessage = json.getString("status_message")
|
||||
if (statusMessage != "Success") throw IOException(statusMessage)
|
||||
val nearest = data.data.minByOrNull { it.distanceInM.toDouble() }
|
||||
?: throw AvailabilityDetectorException("no candidates found.")
|
||||
|
||||
val data = json.getJSONArray("data")
|
||||
if (data.length() > 1) throw AvailabilityDetectorException(
|
||||
"found multiple candidates."
|
||||
)
|
||||
if (data.length() == 0) throw AvailabilityDetectorException(
|
||||
"no candidates found."
|
||||
)
|
||||
val chargecloudConnectors = mutableMapOf<Long, Pair<Double, String>>()
|
||||
val chargecloudStatus = mutableMapOf<Long, ChargepointStatus>()
|
||||
|
||||
val evses = data.getJSONObject(0).getJSONArray("evses")
|
||||
val chargepointStatus = mutableMapOf<Chargepoint, List<ChargepointStatus>>()
|
||||
evses.iterator<JSONObject>().forEach { evse ->
|
||||
evse.getJSONArray("connectors").iterator<JSONObject>().forEach connector@{ connector ->
|
||||
val type = getType(connector.getString("standard"))
|
||||
val power = connector.getDouble("max_power")
|
||||
val status = ChargepointStatus.valueOf(connector.getString("status"))
|
||||
|
||||
var chargepoint = getCorrespondingChargepoint(chargepointStatus.keys, type, power)
|
||||
val statusList: List<ChargepointStatus>
|
||||
if (chargepoint == null) {
|
||||
// find corresponding chargepoint from goingelectric to get correct power
|
||||
val geChargepoint =
|
||||
getCorrespondingChargepoint(location.chargepointsMerged, type, power)
|
||||
?: throw AvailabilityDetectorException(
|
||||
"Chargepoints from chargecloud API and goingelectric do not match."
|
||||
)
|
||||
chargepoint = Chargepoint(
|
||||
type,
|
||||
geChargepoint.power,
|
||||
1
|
||||
)
|
||||
statusList = listOf(status)
|
||||
} else {
|
||||
val previousStatus = chargepointStatus[chargepoint]!!
|
||||
statusList = previousStatus + listOf(status)
|
||||
chargepointStatus.remove(chargepoint)
|
||||
chargepoint =
|
||||
Chargepoint(
|
||||
chargepoint.type,
|
||||
chargepoint.power,
|
||||
chargepoint.count + 1
|
||||
)
|
||||
}
|
||||
|
||||
chargepointStatus[chargepoint] = statusList
|
||||
nearest.evses.flatMap { it.connectors }.forEach {
|
||||
val id = it.id
|
||||
val power = it.maxPower
|
||||
val type = getType(it.standard)
|
||||
val status = when (it.status) {
|
||||
"OUTOFORDER" -> ChargepointStatus.FAULTED
|
||||
"AVAILABLE" -> ChargepointStatus.AVAILABLE
|
||||
"CHARGING" -> ChargepointStatus.CHARGING
|
||||
"UNKNOWN" -> ChargepointStatus.UNKNOWN
|
||||
else -> ChargepointStatus.UNKNOWN
|
||||
}
|
||||
chargecloudConnectors.put(id, power to type)
|
||||
chargecloudStatus.put(id, status)
|
||||
}
|
||||
|
||||
|
||||
|
||||
if (chargepointStatus.keys == location.chargepointsMerged.toSet()) {
|
||||
return ChargeLocationStatus(
|
||||
chargepointStatus,
|
||||
"chargecloud.de"
|
||||
)
|
||||
} else {
|
||||
throw AvailabilityDetectorException(
|
||||
"Chargepoints from chargecloud API and goingelectric do not match."
|
||||
)
|
||||
val match = matchChargepoints(chargecloudConnectors, location.chargepointsMerged)
|
||||
val chargepointStatus = match.mapValues { entry ->
|
||||
entry.value.map { chargecloudStatus[it]!! }
|
||||
}
|
||||
|
||||
return ChargeLocationStatus(
|
||||
chargepointStatus,
|
||||
"chargecloud.de"
|
||||
)
|
||||
}
|
||||
|
||||
private fun getType(string: String): String {
|
||||
@@ -86,7 +113,24 @@ class ChargecloudAvailabilityDetector(
|
||||
"DOMESTIC_F" -> Chargepoint.SCHUKO
|
||||
"IEC_62196_T2_COMBO" -> Chargepoint.CCS_TYPE_2
|
||||
"CHADEMO" -> Chargepoint.CHADEMO
|
||||
else -> throw IllegalArgumentException("unrecognized type $string")
|
||||
else -> "unknown"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class RheinenergieAvailabilityDetector(client: OkHttpClient) :
|
||||
ChargecloudAvailabilityDetector(client) {
|
||||
override val operatorId = "c4ce9bb82a86766833df8a4818fa1b5c"
|
||||
|
||||
override fun isChargerSupported(charger: ChargeLocation): Boolean {
|
||||
val network = charger.chargepriceData?.network ?: charger.network ?: return false
|
||||
return when (charger.dataSource) {
|
||||
"goingelectric" -> network == "RheinEnergie"
|
||||
"openchargemap" -> network == "72"
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// "606a0da0dfdd338ee4134605653d4fd8" Maingau
|
||||
// "6336fe713f2eb7fa04b97ff6651b76f8" SW Kiel*/
|
||||
@@ -0,0 +1,227 @@
|
||||
package net.vonforst.evmap.api.availability
|
||||
|
||||
import com.squareup.moshi.JsonClass
|
||||
import net.vonforst.evmap.model.ChargeLocation
|
||||
import net.vonforst.evmap.model.Chargepoint
|
||||
import net.vonforst.evmap.utils.distanceBetween
|
||||
import okhttp3.OkHttpClient
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.converter.moshi.MoshiConverterFactory
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.Path
|
||||
import retrofit2.http.Query
|
||||
|
||||
private const val coordRange = 0.005 // range of latitude and longitude for loading the map
|
||||
private const val maxDistance = 40 // max distance between reported positions in meters
|
||||
|
||||
interface EnBwApi {
|
||||
@GET("chargestations?grouping=false")
|
||||
suspend fun getMarkers(
|
||||
@Query("fromLon") fromLon: Double,
|
||||
@Query("toLon") toLon: Double,
|
||||
@Query("fromLat") fromLat: Double,
|
||||
@Query("toLat") toLat: Double,
|
||||
): List<EnBwLocation>
|
||||
|
||||
@GET("chargestations/{id}")
|
||||
suspend fun getLocation(@Path("id") id: Long): EnBwLocationDetail
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class EnBwLocation(
|
||||
val lat: Double,
|
||||
val lon: Double,
|
||||
val stationId: Long?,
|
||||
val grouped: Boolean,
|
||||
val availableChargePoints: Int,
|
||||
val numberOfChargePoints: Int,
|
||||
val operator: String,
|
||||
val viewPort: EnBwViewport
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class EnBwLocationDetail(
|
||||
val lat: Double,
|
||||
val lon: Double,
|
||||
val stationId: Long,
|
||||
val availableChargePoints: Int,
|
||||
val numberOfChargePoints: Int,
|
||||
val operator: String,
|
||||
val chargePoints: List<EnBwChargePoint>
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class EnBwChargePoint(
|
||||
val evseId: String?,
|
||||
val status: String,
|
||||
val connectors: List<EnBwConnector>
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class EnBwConnector(
|
||||
val plugTypeName: String,
|
||||
val maxPowerInKw: Double?,
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class EnBwViewport(
|
||||
val lowerLeftLat: Double,
|
||||
val lowerLeftLon: Double,
|
||||
val upperRightLat: Double,
|
||||
val upperRightLon: Double
|
||||
)
|
||||
|
||||
companion object {
|
||||
fun create(client: OkHttpClient, baseUrl: String? = null): EnBwApi {
|
||||
val clientWithInterceptor = client.newBuilder()
|
||||
.addInterceptor { chain ->
|
||||
// add API key to every request
|
||||
val request = chain.request().newBuilder()
|
||||
.header("Ocp-Apim-Subscription-Key", "d4954e8b2e444fc89a89a463788c0a72")
|
||||
.header("Origin", "https://www.enbw.com")
|
||||
.header("Referer", "https://www.enbw.com/")
|
||||
.header("Accept", "application/json")
|
||||
.build()
|
||||
chain.proceed(request)
|
||||
}.build()
|
||||
val retrofit = Retrofit.Builder()
|
||||
.baseUrl(baseUrl ?: "https://enbw-emp.azure-api.net/emobility-public-api/api/v1/")
|
||||
.addConverterFactory(MoshiConverterFactory.create())
|
||||
.client(clientWithInterceptor)
|
||||
.build()
|
||||
return retrofit.create(EnBwApi::class.java)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class EnBwAvailabilityDetector(client: OkHttpClient, baseUrl: String? = null) :
|
||||
BaseAvailabilityDetector(client) {
|
||||
val api = EnBwApi.create(client, baseUrl)
|
||||
|
||||
override suspend fun getAvailability(location: ChargeLocation): ChargeLocationStatus {
|
||||
val lat = location.coordinates.lat
|
||||
val lng = location.coordinates.lng
|
||||
|
||||
// find nearest station to this position
|
||||
var markers =
|
||||
api.getMarkers(lng - coordRange, lng + coordRange, lat - coordRange, lat + coordRange)
|
||||
|
||||
markers = markers.flatMap {
|
||||
if (it.grouped) {
|
||||
api.getMarkers(
|
||||
it.viewPort.lowerLeftLon,
|
||||
it.viewPort.upperRightLon,
|
||||
it.viewPort.lowerLeftLat,
|
||||
it.viewPort.upperRightLat
|
||||
)
|
||||
} else {
|
||||
listOf(it)
|
||||
}
|
||||
}
|
||||
|
||||
val nearest = markers.minByOrNull { marker ->
|
||||
distanceBetween(marker.lat, marker.lon, lat, lng)
|
||||
} ?: throw AvailabilityDetectorException("no candidates found.")
|
||||
|
||||
if (distanceBetween(
|
||||
nearest.lat,
|
||||
nearest.lon,
|
||||
lat,
|
||||
lng
|
||||
) > radius
|
||||
) {
|
||||
throw AvailabilityDetectorException("no candidates found")
|
||||
}
|
||||
|
||||
// combine related stations
|
||||
markers = markers.filter { marker ->
|
||||
distanceBetween(
|
||||
marker.lat,
|
||||
marker.lon,
|
||||
nearest.lat,
|
||||
nearest.lon
|
||||
) < maxDistance
|
||||
}
|
||||
|
||||
var details = markers.filter {
|
||||
// only include stations from same operator
|
||||
it.operator == nearest.operator && it.stationId != null
|
||||
}.map {
|
||||
// load details
|
||||
api.getLocation(it.stationId!!)
|
||||
}
|
||||
|
||||
val connectorStatus = details.flatMap { it.chargePoints }.flatMap { cp ->
|
||||
cp.connectors.map { connector ->
|
||||
connector to cp.status
|
||||
}
|
||||
}
|
||||
|
||||
val enbwConnectors = mutableMapOf<Long, Pair<Double, String>>()
|
||||
val enbwStatus = mutableMapOf<Long, ChargepointStatus>()
|
||||
connectorStatus.forEachIndexed { index, (connector, statusStr) ->
|
||||
val id = index.toLong()
|
||||
val power = connector.maxPowerInKw ?: 0.0
|
||||
val type = when (connector.plugTypeName) {
|
||||
"Typ 3A" -> Chargepoint.TYPE_3
|
||||
"Typ 2" -> Chargepoint.TYPE_2_UNKNOWN
|
||||
"Typ 1" -> Chargepoint.TYPE_1
|
||||
"Steckdose(D)" -> Chargepoint.SCHUKO
|
||||
"CCS (Typ 1)" -> Chargepoint.CCS_TYPE_1 // US CCS, aka type1_combo
|
||||
"CCS (Typ 2)" -> Chargepoint.CCS_TYPE_2 // EU CCS, aka type2_combo
|
||||
"CHAdeMO" -> Chargepoint.CHADEMO
|
||||
else -> "unknown"
|
||||
}
|
||||
val status = when (statusStr) {
|
||||
"UNAVAILABLE" -> ChargepointStatus.FAULTED
|
||||
"OUT_OF_SERVICE" -> ChargepointStatus.FAULTED
|
||||
"AVAILABLE" -> ChargepointStatus.AVAILABLE
|
||||
"OCCUPIED" -> ChargepointStatus.CHARGING
|
||||
"UNSPECIFIED" -> ChargepointStatus.UNKNOWN
|
||||
else -> ChargepointStatus.UNKNOWN
|
||||
}
|
||||
enbwConnectors.put(id, power to type)
|
||||
enbwStatus.put(id, status)
|
||||
}
|
||||
|
||||
val match = matchChargepoints(enbwConnectors, location.chargepointsMerged)
|
||||
val chargepointStatus = match.mapValues { entry ->
|
||||
entry.value.map { enbwStatus[it]!! }
|
||||
}
|
||||
return ChargeLocationStatus(
|
||||
chargepointStatus,
|
||||
"EnBW"
|
||||
)
|
||||
}
|
||||
|
||||
override fun isChargerSupported(charger: ChargeLocation): Boolean {
|
||||
val country = charger.chargepriceData?.country
|
||||
?: charger.address?.country ?: return false
|
||||
return when (charger.dataSource) {
|
||||
// list of countries as of 2021/06/30, according to
|
||||
// https://www.electrive.net/2021/06/30/enbw-expandiert-mit-ladenetz-in-drei-weitere-laender/
|
||||
"goingelectric" -> country in listOf(
|
||||
"Deutschland",
|
||||
"Österreich",
|
||||
"Schweiz",
|
||||
"Frankreich",
|
||||
"Belgien",
|
||||
"Niederlande",
|
||||
"Luxemburg",
|
||||
"Liechtenstein",
|
||||
"Italien",
|
||||
)
|
||||
"openchargemap" -> country in listOf(
|
||||
"DE",
|
||||
"AT",
|
||||
"CH",
|
||||
"FR",
|
||||
"BE",
|
||||
"NE",
|
||||
"LU",
|
||||
"LI",
|
||||
"IT"
|
||||
)
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,8 +11,8 @@ 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
|
||||
private const val coordRange = 0.005 // range of latitude and longitude for loading the map
|
||||
private const val maxDistance = 40 // max distance between reported positions in meters
|
||||
|
||||
interface NewMotionApi {
|
||||
@GET("markers/{lngMin}/{lngMax}/{latMin}/{latMax}")
|
||||
@@ -173,4 +173,9 @@ class NewMotionAvailabilityDetector(client: OkHttpClient, baseUrl: String? = nul
|
||||
)
|
||||
}
|
||||
|
||||
override fun isChargerSupported(charger: ChargeLocation): Boolean {
|
||||
// NewMotion is our fallback
|
||||
return true
|
||||
}
|
||||
|
||||
}
|
||||
@@ -3,14 +3,16 @@ package net.vonforst.evmap.api.chargeprice
|
||||
import android.content.Context
|
||||
import com.facebook.stetho.okhttp3.StethoInterceptor
|
||||
import com.squareup.moshi.Moshi
|
||||
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
|
||||
import moe.banana.jsonapi2.ArrayDocument
|
||||
import moe.banana.jsonapi2.JsonApiConverterFactory
|
||||
import moe.banana.jsonapi2.ResourceAdapterFactory
|
||||
import com.squareup.moshi.adapters.PolymorphicJsonAdapterFactory
|
||||
import jsonapi.Document
|
||||
import jsonapi.JsonApiFactory
|
||||
import jsonapi.retrofit.DocumentConverterFactory
|
||||
import net.vonforst.evmap.BuildConfig
|
||||
import net.vonforst.evmap.model.ChargeLocation
|
||||
import okhttp3.Cache
|
||||
import okhttp3.OkHttpClient
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.converter.moshi.MoshiConverterFactory
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.Header
|
||||
@@ -20,34 +22,45 @@ import java.util.*
|
||||
interface ChargepriceApi {
|
||||
@POST("charge_prices")
|
||||
suspend fun getChargePrices(
|
||||
@Body request: ChargepriceRequest,
|
||||
@Body @jsonapi.retrofit.Document request: ChargepriceRequest,
|
||||
@Header("Accept-Language") language: String
|
||||
): ArrayDocument<ChargePrice>
|
||||
): Document<List<ChargePrice>>
|
||||
|
||||
@GET("vehicles")
|
||||
suspend fun getVehicles(): ArrayDocument<ChargepriceCar>
|
||||
@jsonapi.retrofit.Document
|
||||
suspend fun getVehicles(): List<ChargepriceCar>
|
||||
|
||||
@GET("tariffs")
|
||||
suspend fun getTariffs(): ArrayDocument<ChargepriceTariff>
|
||||
@jsonapi.retrofit.Document
|
||||
suspend fun getTariffs(): List<ChargepriceTariff>
|
||||
|
||||
@POST("user_feedback")
|
||||
suspend fun userFeedback(@Body @jsonapi.retrofit.Document feedback: ChargepriceUserFeedback)
|
||||
|
||||
companion object {
|
||||
private val cacheSize = 1L * 1024 * 1024 // 1MB
|
||||
val supportedLanguages = setOf("de", "en", "fr", "nl")
|
||||
|
||||
val DATA_SOURCE_GOINGELECTRIC = "going_electric"
|
||||
val DATA_SOURCE_OPENCHARGEMAP = "open_charge_map"
|
||||
private val DATA_SOURCE_GOINGELECTRIC = "going_electric"
|
||||
private val DATA_SOURCE_OPENCHARGEMAP = "open_charge_map"
|
||||
|
||||
private val jsonApiAdapterFactory = ResourceAdapterFactory.builder()
|
||||
.add(ChargepriceRequest::class.java)
|
||||
.add(ChargepriceTariff::class.java)
|
||||
.add(ChargepriceBrand::class.java)
|
||||
.add(ChargePrice::class.java)
|
||||
.add(ChargepriceCar::class.java)
|
||||
private val jsonApiAdapterFactory = JsonApiFactory.Builder()
|
||||
.addType(ChargepriceRequest::class.java)
|
||||
.addType(ChargepriceTariff::class.java)
|
||||
.addType(ChargepriceBrand::class.java)
|
||||
.addType(ChargePrice::class.java)
|
||||
.addType(ChargepriceCar::class.java)
|
||||
.build()
|
||||
val moshi = Moshi.Builder()
|
||||
.add(jsonApiAdapterFactory)
|
||||
.add(KotlinJsonAdapterFactory())
|
||||
.add(
|
||||
PolymorphicJsonAdapterFactory.of(ChargepriceUserFeedback::class.java, "type")
|
||||
.withSubtype(ChargepriceMissingPriceFeedback::class.java, "missing_price")
|
||||
.withSubtype(ChargepriceWrongPriceFeedback::class.java, "wrong_price")
|
||||
.withSubtype(ChargepriceMissingVehicleFeedback::class.java, "missing_vehicle")
|
||||
)
|
||||
.build()
|
||||
|
||||
fun create(
|
||||
apikey: String,
|
||||
baseurl: String = "https://api.chargeprice.app/v1/",
|
||||
@@ -73,7 +86,8 @@ interface ChargepriceApi {
|
||||
|
||||
val retrofit = Retrofit.Builder()
|
||||
.baseUrl(baseurl)
|
||||
.addConverterFactory(JsonApiConverterFactory.create(moshi))
|
||||
.addConverterFactory(DocumentConverterFactory.create())
|
||||
.addConverterFactory(MoshiConverterFactory.create(moshi))
|
||||
.client(client)
|
||||
.build()
|
||||
return retrofit.create(ChargepriceApi::class.java)
|
||||
@@ -89,6 +103,15 @@ interface ChargepriceApi {
|
||||
}
|
||||
}
|
||||
|
||||
fun getPoiUrl(charger: ChargeLocation) =
|
||||
"https://www.chargeprice.app/?poi_id=${charger.id}&poi_source=${getDataAdapter(charger)}"
|
||||
|
||||
fun getDataAdapter(charger: ChargeLocation) = when (charger.dataSource) {
|
||||
"goingelectric" -> DATA_SOURCE_GOINGELECTRIC
|
||||
"openchargemap" -> DATA_SOURCE_OPENCHARGEMAP
|
||||
else -> throw IllegalArgumentException()
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun isCountrySupported(country: String, dataSource: String): Boolean = when (dataSource) {
|
||||
// list of countries updated 2021/08/24
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
package net.vonforst.evmap.api.chargeprice
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Parcelable
|
||||
import android.util.Patterns
|
||||
import com.squareup.moshi.Json
|
||||
import moe.banana.jsonapi2.HasMany
|
||||
import moe.banana.jsonapi2.HasOne
|
||||
|
||||
import moe.banana.jsonapi2.JsonApi
|
||||
import moe.banana.jsonapi2.Resource
|
||||
import com.squareup.moshi.JsonClass
|
||||
import jsonapi.*
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import net.vonforst.evmap.R
|
||||
import net.vonforst.evmap.adapter.Equatable
|
||||
import net.vonforst.evmap.api.equivalentPlugTypes
|
||||
@@ -17,16 +17,21 @@ import kotlin.math.ceil
|
||||
import kotlin.math.floor
|
||||
|
||||
|
||||
@JsonApi(type = "charge_price_request")
|
||||
class ChargepriceRequest : Resource() {
|
||||
@field:Json(name = "data_adapter")
|
||||
lateinit var dataAdapter: String
|
||||
lateinit var station: ChargepriceStation
|
||||
lateinit var options: ChargepriceOptions
|
||||
var tariffs: HasMany<ChargepriceTariff>? = null
|
||||
var vehicle: HasOne<ChargepriceCar>? = null
|
||||
}
|
||||
@Resource("charge_price_request")
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class ChargepriceRequest(
|
||||
@Json(name = "data_adapter")
|
||||
val dataAdapter: String,
|
||||
val station: ChargepriceStation,
|
||||
val options: ChargepriceOptions,
|
||||
@ToMany("tariffs")
|
||||
val tariffs: List<ChargepriceTariff>? = null,
|
||||
@ToOne("vehicle")
|
||||
val vehicle: ChargepriceCar? = null,
|
||||
@RelationshipsObject var relationships: Relationships? = null
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class ChargepriceStation(
|
||||
val longitude: Double,
|
||||
val latitude: Double,
|
||||
@@ -48,21 +53,21 @@ data class ChargepriceStation(
|
||||
charger.coordinates.lat,
|
||||
charger.chargepriceData.country,
|
||||
charger.chargepriceData.network,
|
||||
charger.chargepoints.zip(plugTypes).filter {
|
||||
equivalentPlugTypes(it.first.type).any { it in compatibleConnectors }
|
||||
}.map {
|
||||
ChargepriceChargepoint(it.first.power, it.second)
|
||||
}
|
||||
charger.chargepoints.zip(plugTypes)
|
||||
.filter { equivalentPlugTypes(it.first.type).any { it in compatibleConnectors } }
|
||||
.map { ChargepriceChargepoint(it.first.power ?: 0.0, it.second) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class ChargepriceChargepoint(
|
||||
val power: Double,
|
||||
val plug: String
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class ChargepriceOptions(
|
||||
@Json(name = "max_monthly_fees") val maxMonthlyFees: Double? = null,
|
||||
val energy: Double? = null,
|
||||
@@ -75,142 +80,107 @@ data class ChargepriceOptions(
|
||||
@Json(name = "provider_customer_tariffs") val providerCustomerTariffs: Boolean? = null
|
||||
)
|
||||
|
||||
@JsonApi(type = "tariff")
|
||||
class ChargepriceTariff() : Resource() {
|
||||
lateinit var provider: String
|
||||
lateinit var name: String
|
||||
@field:Json(name = "direct_payment")
|
||||
var directPayment: Boolean = false
|
||||
@field:Json(name = "provider_customer_tariff")
|
||||
var providerCustomerTariff: Boolean = false
|
||||
@field:Json(name = "supported_cuntries")
|
||||
lateinit var supportedCountries: Set<String>
|
||||
@field:Json(name = "charge_card_id")
|
||||
lateinit var chargeCardId: String // GE charge card ID
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (javaClass != other?.javaClass) return false
|
||||
if (!super.equals(other)) return false
|
||||
|
||||
other as ChargepriceTariff
|
||||
|
||||
if (provider != other.provider) return false
|
||||
if (name != other.name) return false
|
||||
if (directPayment != other.directPayment) return false
|
||||
if (providerCustomerTariff != other.providerCustomerTariff) return false
|
||||
if (supportedCountries != other.supportedCountries) return false
|
||||
if (chargeCardId != other.chargeCardId) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = super.hashCode()
|
||||
result = 31 * result + provider.hashCode()
|
||||
result = 31 * result + name.hashCode()
|
||||
result = 31 * result + directPayment.hashCode()
|
||||
result = 31 * result + providerCustomerTariff.hashCode()
|
||||
result = 31 * result + supportedCountries.hashCode()
|
||||
result = 31 * result + chargeCardId.hashCode()
|
||||
return result
|
||||
}
|
||||
@Resource("tariff")
|
||||
@Parcelize
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class ChargepriceTariff(
|
||||
@Id val id_: String?,
|
||||
val provider: String,
|
||||
val name: String,
|
||||
@Json(name = "direct_payment")
|
||||
val directPayment: Boolean = false,
|
||||
@Json(name = "provider_customer_tariff")
|
||||
val providerCustomerTariff: Boolean = false,
|
||||
@Json(name = "supported_countries")
|
||||
val supportedCountries: Set<String>,
|
||||
@Json(name = "charge_card_id")
|
||||
val chargeCardId: String?, // GE charge card ID
|
||||
) : Parcelable {
|
||||
val id: String
|
||||
get() = id_!!
|
||||
}
|
||||
|
||||
@JsonApi(type = "car")
|
||||
class ChargepriceCar : Resource(), Equatable {
|
||||
lateinit var name: String
|
||||
lateinit var brand: String
|
||||
@JsonClass(generateAdapter = true)
|
||||
@Resource("car")
|
||||
@Parcelize
|
||||
data class ChargepriceCar(
|
||||
@Id val id_: String?,
|
||||
val name: String,
|
||||
val brand: String,
|
||||
|
||||
@field:Json(name = "dc_charge_ports")
|
||||
lateinit var dcChargePorts: List<String>
|
||||
lateinit var manufacturer: HasOne<ChargepriceBrand>
|
||||
@Json(name = "dc_charge_ports")
|
||||
val dcChargePorts: List<String>
|
||||
) : Equatable, Parcelable {
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (javaClass != other?.javaClass) return false
|
||||
if (!super.equals(other)) return false
|
||||
|
||||
other as ChargepriceCar
|
||||
|
||||
if (name != other.name) return false
|
||||
if (brand != other.brand) return false
|
||||
if (dcChargePorts != other.dcChargePorts) return false
|
||||
if (manufacturer != other.manufacturer) return false
|
||||
|
||||
return true
|
||||
companion object {
|
||||
private val acConnectors = listOf(
|
||||
Chargepoint.CEE_BLAU,
|
||||
Chargepoint.CEE_ROT,
|
||||
Chargepoint.SCHUKO,
|
||||
Chargepoint.TYPE_1,
|
||||
Chargepoint.TYPE_2_UNKNOWN,
|
||||
Chargepoint.TYPE_2_SOCKET,
|
||||
Chargepoint.TYPE_2_PLUG
|
||||
)
|
||||
private val plugMapping = mapOf(
|
||||
"ccs" to Chargepoint.CCS_UNKNOWN,
|
||||
"tesla_suc" to Chargepoint.SUPERCHARGER,
|
||||
"tesla_ccs" to Chargepoint.CCS_UNKNOWN,
|
||||
"chademo" to Chargepoint.CHADEMO
|
||||
)
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = super.hashCode()
|
||||
result = 31 * result + name.hashCode()
|
||||
result = 31 * result + brand.hashCode()
|
||||
result = 31 * result + dcChargePorts.hashCode()
|
||||
result = 31 * result + manufacturer.hashCode()
|
||||
return result
|
||||
}
|
||||
val id: String
|
||||
get() = id_!!
|
||||
|
||||
private val acConnectors = listOf(
|
||||
Chargepoint.CEE_BLAU,
|
||||
Chargepoint.CEE_ROT,
|
||||
Chargepoint.SCHUKO,
|
||||
Chargepoint.TYPE_1,
|
||||
Chargepoint.TYPE_2_UNKNOWN,
|
||||
Chargepoint.TYPE_2_SOCKET,
|
||||
Chargepoint.TYPE_2_PLUG
|
||||
)
|
||||
private val plugMapping = mapOf(
|
||||
"ccs" to Chargepoint.CCS_UNKNOWN,
|
||||
"tesla_suc" to Chargepoint.SUPERCHARGER,
|
||||
"tesla_ccs" to Chargepoint.CCS_UNKNOWN,
|
||||
"chademo" to Chargepoint.CHADEMO
|
||||
)
|
||||
val compatibleEvmapConnectors: List<String>
|
||||
get() = dcChargePorts.map {
|
||||
plugMapping[it]
|
||||
}.filterNotNull().plus(acConnectors)
|
||||
}
|
||||
|
||||
@JsonApi(type = "brand")
|
||||
class ChargepriceBrand : Resource()
|
||||
@JsonClass(generateAdapter = true)
|
||||
@Resource("brand")
|
||||
@Parcelize
|
||||
data class ChargepriceBrand(
|
||||
@Id val id: String?
|
||||
) : Parcelable
|
||||
|
||||
@JsonApi(type = "charge_price")
|
||||
class ChargePrice : Resource(), Equatable, Cloneable {
|
||||
lateinit var provider: String
|
||||
@JsonClass(generateAdapter = true)
|
||||
@Resource("charge_price")
|
||||
@Parcelize
|
||||
data class ChargePrice(
|
||||
val provider: String,
|
||||
@Json(name = "tariff_name")
|
||||
val tariffName: String,
|
||||
val url: String,
|
||||
@Json(name = "monthly_min_sales")
|
||||
val monthlyMinSales: Double = 0.0,
|
||||
@Json(name = "total_monthly_fee")
|
||||
val totalMonthlyFee: Double = 0.0,
|
||||
@Json(name = "flat_rate")
|
||||
val flatRate: Boolean = false,
|
||||
|
||||
@field:Json(name = "tariff_name")
|
||||
lateinit var tariffName: String
|
||||
lateinit var url: String
|
||||
@Json(name = "direct_payment")
|
||||
val directPayment: Boolean = false,
|
||||
|
||||
@field:Json(name = "monthly_min_sales")
|
||||
var monthlyMinSales: Double = 0.0
|
||||
@Json(name = "provider_customer_tariff")
|
||||
val providerCustomerTariff: Boolean = false,
|
||||
val currency: String,
|
||||
|
||||
@field:Json(name = "total_monthly_fee")
|
||||
var totalMonthlyFee: Double = 0.0
|
||||
@Json(name = "start_time")
|
||||
val startTime: Int = 0,
|
||||
val tags: List<ChargepriceTag>,
|
||||
|
||||
@field:Json(name = "flat_rate")
|
||||
var flatRate: Boolean = false
|
||||
|
||||
@field:Json(name = "direct_payment")
|
||||
var directPayment: Boolean = false
|
||||
|
||||
@field:Json(name = "provider_customer_tariff")
|
||||
var providerCustomerTariff: Boolean = false
|
||||
lateinit var currency: String
|
||||
|
||||
@field:Json(name = "start_time")
|
||||
var startTime: Int = 0
|
||||
lateinit var tags: List<ChargepriceTag>
|
||||
|
||||
@field:Json(name = "charge_point_prices")
|
||||
lateinit var chargepointPrices: List<ChargepointPrice>
|
||||
|
||||
@field:Json(name = "branding")
|
||||
var branding: ChargepriceBranding? = null
|
||||
|
||||
var tariff: HasOne<ChargepriceTariff>? = null
|
||||
@Json(name = "charge_point_prices")
|
||||
val chargepointPrices: List<ChargepointPrice>,
|
||||
|
||||
@Json(name = "branding")
|
||||
val branding: ChargepriceBranding? = null,
|
||||
|
||||
@ToOne("tariff")
|
||||
val tariffId: String?
|
||||
) : Equatable, Cloneable, Parcelable {
|
||||
fun formatMonthlyFees(ctx: Context): String {
|
||||
return listOfNotNull(
|
||||
if (totalMonthlyFee > 0) {
|
||||
@@ -221,69 +191,10 @@ class ChargePrice : Resource(), Equatable, Cloneable {
|
||||
} else null
|
||||
).joinToString(", ")
|
||||
}
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (javaClass != other?.javaClass) return false
|
||||
if (!super.equals(other)) return false
|
||||
|
||||
other as ChargePrice
|
||||
|
||||
if (provider != other.provider) return false
|
||||
if (tariffName != other.tariffName) return false
|
||||
if (url != other.url) return false
|
||||
if (monthlyMinSales != other.monthlyMinSales) return false
|
||||
if (totalMonthlyFee != other.totalMonthlyFee) return false
|
||||
if (flatRate != other.flatRate) return false
|
||||
if (directPayment != other.directPayment) return false
|
||||
if (providerCustomerTariff != other.providerCustomerTariff) return false
|
||||
if (currency != other.currency) return false
|
||||
if (startTime != other.startTime) return false
|
||||
if (tags != other.tags) return false
|
||||
if (chargepointPrices != other.chargepointPrices) return false
|
||||
if (branding != other.branding) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = super.hashCode()
|
||||
result = 31 * result + provider.hashCode()
|
||||
result = 31 * result + tariffName.hashCode()
|
||||
result = 31 * result + url.hashCode()
|
||||
result = 31 * result + monthlyMinSales.hashCode()
|
||||
result = 31 * result + totalMonthlyFee.hashCode()
|
||||
result = 31 * result + flatRate.hashCode()
|
||||
result = 31 * result + directPayment.hashCode()
|
||||
result = 31 * result + providerCustomerTariff.hashCode()
|
||||
result = 31 * result + currency.hashCode()
|
||||
result = 31 * result + startTime
|
||||
result = 31 * result + tags.hashCode()
|
||||
result = 31 * result + chargepointPrices.hashCode()
|
||||
result = 31 * result + branding.hashCode()
|
||||
return result
|
||||
}
|
||||
|
||||
public override fun clone(): ChargePrice {
|
||||
return ChargePrice().apply {
|
||||
chargepointPrices = this@ChargePrice.chargepointPrices
|
||||
currency = this@ChargePrice.currency
|
||||
directPayment = this@ChargePrice.directPayment
|
||||
flatRate = this@ChargePrice.flatRate
|
||||
monthlyMinSales = this@ChargePrice.monthlyMinSales
|
||||
provider = this@ChargePrice.provider
|
||||
providerCustomerTariff = this@ChargePrice.providerCustomerTariff
|
||||
startTime = this@ChargePrice.startTime
|
||||
tags = this@ChargePrice.tags
|
||||
tariffName = this@ChargePrice.tariffName
|
||||
totalMonthlyFee = this@ChargePrice.totalMonthlyFee
|
||||
url = this@ChargePrice.url
|
||||
tariff = this@ChargePrice.tariff
|
||||
branding = this@ChargePrice.branding
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
@Parcelize
|
||||
data class ChargepointPrice(
|
||||
val power: Double,
|
||||
val plug: String,
|
||||
@@ -291,7 +202,7 @@ data class ChargepointPrice(
|
||||
@Json(name = "price_distribution") val priceDistribution: PriceDistribution,
|
||||
@Json(name = "blocking_fee_start") val blockingFeeStart: Int?,
|
||||
@Json(name = "no_price_reason") var noPriceReason: String?
|
||||
) {
|
||||
) : Parcelable {
|
||||
fun formatDistribution(ctx: Context): String {
|
||||
fun percent(value: Double): String {
|
||||
return ctx.getString(R.string.percent_format, value * 100) + "\u00a0"
|
||||
@@ -334,19 +245,28 @@ data class ChargepointPrice(
|
||||
}
|
||||
}
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
@Parcelize
|
||||
data class ChargepriceBranding(
|
||||
@Json(name = "background_color") val backgroundColor: String,
|
||||
@Json(name = "text_color") val textColor: String,
|
||||
@Json(name = "logo_url") val logoUrl: String
|
||||
)
|
||||
) : Parcelable
|
||||
|
||||
data class PriceDistribution(val kwh: Double?, val session: Double?, val minute: Double?) {
|
||||
val isOnlyKwh =
|
||||
kwh != null && kwh > 0 && (session == null || session == 0.0) && (minute == null || minute == 0.0)
|
||||
@JsonClass(generateAdapter = true)
|
||||
@Parcelize
|
||||
data class PriceDistribution(val kwh: Double?, val session: Double?, val minute: Double?) :
|
||||
Parcelable {
|
||||
val isOnlyKwh
|
||||
get() = kwh != null && kwh > 0 && (session == null || session == 0.0) && (minute == null || minute == 0.0)
|
||||
}
|
||||
|
||||
data class ChargepriceTag(val kind: String, val text: String, val url: String?) : Equatable
|
||||
@JsonClass(generateAdapter = true)
|
||||
@Parcelize
|
||||
data class ChargepriceTag(val kind: String, val text: String, val url: String?) : Equatable,
|
||||
Parcelable
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class ChargepriceMeta(
|
||||
@Json(name = "charge_points") val chargePoints: List<ChargepriceChargepointMeta>
|
||||
)
|
||||
@@ -360,13 +280,97 @@ enum class ChargepriceInclude {
|
||||
EXCLUSIVE
|
||||
}
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
@Parcelize
|
||||
data class ChargepriceRequestTariffMeta(
|
||||
val include: ChargepriceInclude
|
||||
)
|
||||
) : Parcelable
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class ChargepriceChargepointMeta(
|
||||
val power: Double,
|
||||
val plug: String,
|
||||
val energy: Double,
|
||||
val duration: Double
|
||||
)
|
||||
)
|
||||
|
||||
@Resource("user_feedback")
|
||||
sealed class ChargepriceUserFeedback(
|
||||
val notes: String,
|
||||
val email: String,
|
||||
val context: String,
|
||||
val language: String
|
||||
) {
|
||||
init {
|
||||
if (email.isBlank() || email.length > 100 || !Patterns.EMAIL_ADDRESS.matcher(email)
|
||||
.matches()
|
||||
) {
|
||||
throw IllegalArgumentException("invalid email")
|
||||
}
|
||||
if (!ChargepriceApi.supportedLanguages.contains(language)) {
|
||||
throw IllegalArgumentException("invalid language")
|
||||
}
|
||||
if (context.length > 500) throw IllegalArgumentException("invalid context")
|
||||
if (notes.length > 1000) throw IllegalArgumentException("invalid notes")
|
||||
}
|
||||
}
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
@Resource(type = "missing_price")
|
||||
class ChargepriceMissingPriceFeedback(
|
||||
val tariff: String,
|
||||
val cpo: String,
|
||||
val price: String,
|
||||
@Json(name = "poi_link") val poiLink: String,
|
||||
notes: String,
|
||||
email: String,
|
||||
context: String,
|
||||
language: String
|
||||
) : ChargepriceUserFeedback(notes, email, context, language) {
|
||||
init {
|
||||
if (tariff.isBlank() || tariff.length > 100) throw IllegalArgumentException("invalid tariff")
|
||||
if (cpo.length > 200) throw IllegalArgumentException("invalid cpo")
|
||||
if (price.isBlank() || price.length > 100) throw IllegalArgumentException("invalid price")
|
||||
if (poiLink.isBlank() || poiLink.length > 200) throw IllegalArgumentException("invalid poiLink")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
@Resource(type = "wrong_price")
|
||||
class ChargepriceWrongPriceFeedback(
|
||||
val tariff: String,
|
||||
val cpo: String,
|
||||
@Json(name = "displayed_price") val displayedPrice: String,
|
||||
@Json(name = "actual_price") val actualPrice: String,
|
||||
@Json(name = "poi_link") val poiLink: String,
|
||||
notes: String,
|
||||
email: String,
|
||||
context: String,
|
||||
language: String,
|
||||
) : ChargepriceUserFeedback(notes, email, context, language) {
|
||||
init {
|
||||
if (tariff.length > 100) throw IllegalArgumentException("invalid tariff")
|
||||
if (cpo.length > 200) throw IllegalArgumentException("invalid cpo")
|
||||
if (displayedPrice.length > 100) throw IllegalArgumentException("invalid displayedPrice")
|
||||
if (actualPrice.length > 100) throw IllegalArgumentException("invalid actualPrice")
|
||||
if (poiLink.length > 200) throw IllegalArgumentException("invalid poiLink")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
@Resource(type = "missing_vehicle")
|
||||
class ChargepriceMissingVehicleFeedback(
|
||||
val brand: String,
|
||||
val model: String,
|
||||
notes: String,
|
||||
email: String,
|
||||
context: String,
|
||||
language: String,
|
||||
) : ChargepriceUserFeedback(notes, email, context, language) {
|
||||
init {
|
||||
if (brand.length > 100) throw IllegalArgumentException("invalid brand")
|
||||
if (model.length > 100) throw IllegalArgumentException("invalid model")
|
||||
}
|
||||
}
|
||||
@@ -21,50 +21,51 @@ import okhttp3.OkHttpClient
|
||||
import retrofit2.Response
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.converter.moshi.MoshiConverterFactory
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.Query
|
||||
import retrofit2.http.*
|
||||
import java.io.IOException
|
||||
|
||||
interface GoingElectricApi {
|
||||
@GET("chargepoints/")
|
||||
@FormUrlEncoded
|
||||
@POST("chargepoints/")
|
||||
suspend fun getChargepoints(
|
||||
@Query("sw_lat") sw_lat: Double, @Query("sw_lng") sw_lng: Double,
|
||||
@Query("ne_lat") ne_lat: Double, @Query("ne_lng") ne_lng: Double,
|
||||
@Query("zoom") zoom: Float,
|
||||
@Query("clustering") clustering: Boolean = false,
|
||||
@Query("cluster_distance") clusterDistance: Int? = null,
|
||||
@Query("freecharging") freecharging: Boolean = false,
|
||||
@Query("freeparking") freeparking: Boolean = false,
|
||||
@Query("min_power") minPower: Int = 0,
|
||||
@Query("plugs") plugs: String? = null,
|
||||
@Query("chargecards") chargecards: String? = null,
|
||||
@Query("networks") networks: String? = null,
|
||||
@Query("categories") categories: String? = null,
|
||||
@Query("startkey") startkey: Int? = null,
|
||||
@Query("open_twentyfourseven") open247: Boolean = false,
|
||||
@Query("barrierfree") barrierfree: Boolean = false,
|
||||
@Query("exclude_faults") excludeFaults: Boolean = false
|
||||
@Field("sw_lat") sw_lat: Double, @Field("sw_lng") sw_lng: Double,
|
||||
@Field("ne_lat") ne_lat: Double, @Field("ne_lng") ne_lng: Double,
|
||||
@Field("zoom") zoom: Float,
|
||||
@Field("clustering") clustering: Boolean = false,
|
||||
@Field("cluster_distance") clusterDistance: Int? = null,
|
||||
@Field("freecharging") freecharging: Boolean = false,
|
||||
@Field("freeparking") freeparking: Boolean = false,
|
||||
@Field("min_power") minPower: Int = 0,
|
||||
@Field("plugs") plugs: String? = null,
|
||||
@Field("chargecards") chargecards: String? = null,
|
||||
@Field("networks") networks: String? = null,
|
||||
@Field("categories") categories: String? = null,
|
||||
@Field("startkey") startkey: Int? = null,
|
||||
@Field("open_twentyfourseven") open247: Boolean = false,
|
||||
@Field("barrierfree") barrierfree: Boolean = false,
|
||||
@Field("exclude_faults") excludeFaults: Boolean = false
|
||||
): Response<GEChargepointList>
|
||||
|
||||
@GET("chargepoints/")
|
||||
@FormUrlEncoded
|
||||
@POST("chargepoints/")
|
||||
suspend fun getChargepointsRadius(
|
||||
@Query("lat") lat: Double, @Query("lng") lng: Double,
|
||||
@Query("radius") radius: Int,
|
||||
@Query("zoom") zoom: Float,
|
||||
@Query("orderby") orderby: String = "distance",
|
||||
@Query("clustering") clustering: Boolean = false,
|
||||
@Query("cluster_distance") clusterDistance: Int? = null,
|
||||
@Query("freecharging") freecharging: Boolean = false,
|
||||
@Query("freeparking") freeparking: Boolean = false,
|
||||
@Query("min_power") minPower: Int = 0,
|
||||
@Query("plugs") plugs: String? = null,
|
||||
@Query("chargecards") chargecards: String? = null,
|
||||
@Query("networks") networks: String? = null,
|
||||
@Query("categories") categories: String? = null,
|
||||
@Query("startkey") startkey: Int? = null,
|
||||
@Query("open_twentyfourseven") open247: Boolean = false,
|
||||
@Query("barrierfree") barrierfree: Boolean = false,
|
||||
@Query("exclude_faults") excludeFaults: Boolean = false
|
||||
@Field("lat") lat: Double, @Field("lng") lng: Double,
|
||||
@Field("radius") radius: Int,
|
||||
@Field("zoom") zoom: Float,
|
||||
@Field("orderby") orderby: String = "distance",
|
||||
@Field("clustering") clustering: Boolean = false,
|
||||
@Field("cluster_distance") clusterDistance: Int? = null,
|
||||
@Field("freecharging") freecharging: Boolean = false,
|
||||
@Field("freeparking") freeparking: Boolean = false,
|
||||
@Field("min_power") minPower: Int = 0,
|
||||
@Field("plugs") plugs: String? = null,
|
||||
@Field("chargecards") chargecards: String? = null,
|
||||
@Field("networks") networks: String? = null,
|
||||
@Field("categories") categories: String? = null,
|
||||
@Field("startkey") startkey: Int? = null,
|
||||
@Field("open_twentyfourseven") open247: Boolean = false,
|
||||
@Field("barrierfree") barrierfree: Boolean = false,
|
||||
@Field("exclude_faults") excludeFaults: Boolean = false
|
||||
): Response<GEChargepointList>
|
||||
|
||||
@GET("chargepoints/")
|
||||
@@ -125,9 +126,11 @@ class GoingElectricApiWrapper(
|
||||
baseurl: String = "https://api.goingelectric.de",
|
||||
context: Context? = null
|
||||
) : ChargepointApi<GEReferenceData> {
|
||||
private val clusterThreshold = 11f
|
||||
val api = GoingElectricApi.create(apikey, baseurl, context)
|
||||
|
||||
override fun getName() = "GoingElectric.de"
|
||||
override val name = "GoingElectric.de"
|
||||
override val id = "going_electric"
|
||||
|
||||
override suspend fun getChargepoints(
|
||||
referenceData: ReferenceData,
|
||||
@@ -172,7 +175,7 @@ class GoingElectricApiWrapper(
|
||||
val categories = formatMultipleChoice(categoriesVal)
|
||||
|
||||
// do not use clustering if filters need to be applied locally.
|
||||
val useClustering = zoom < 13
|
||||
val useClustering = zoom < clusterThreshold
|
||||
val geClusteringAvailable = minConnectors == null || minConnectors <= 1
|
||||
val useGeClustering = useClustering && geClusteringAvailable
|
||||
val clusterDistance = if (useClustering) getClusterDistance(zoom) else null
|
||||
@@ -266,7 +269,7 @@ class GoingElectricApiWrapper(
|
||||
val categories = formatMultipleChoice(categoriesVal)
|
||||
|
||||
// do not use clustering if filters need to be applied locally.
|
||||
val useClustering = zoom < 13
|
||||
val useClustering = zoom < clusterThreshold
|
||||
val geClusteringAvailable = minConnectors == null || minConnectors <= 1
|
||||
val useGeClustering = useClustering && geClusteringAvailable
|
||||
val clusterDistance = if (useClustering) getClusterDistance(zoom) else null
|
||||
@@ -326,10 +329,10 @@ class GoingElectricApiWrapper(
|
||||
} else {
|
||||
true
|
||||
}
|
||||
}.map { it.convert(apikey) }
|
||||
}.map { it.convert(apikey, false) }
|
||||
|
||||
// apply clustering
|
||||
val useClustering = zoom < 13
|
||||
val useClustering = zoom < clusterThreshold
|
||||
val geClusteringAvailable = minConnectors == null || minConnectors <= 1
|
||||
val clusterDistance = if (useClustering) getClusterDistance(zoom) else null
|
||||
if (!geClusteringAvailable && useClustering) {
|
||||
@@ -350,7 +353,7 @@ class GoingElectricApiWrapper(
|
||||
return if (response.isSuccessful && response.body()!!.status == "ok" && response.body()!!.chargelocations.size == 1) {
|
||||
Resource.success(
|
||||
(response.body()!!.chargelocations[0] as GEChargeLocation).convert(
|
||||
apikey
|
||||
apikey, true
|
||||
)
|
||||
)
|
||||
} else {
|
||||
@@ -465,17 +468,17 @@ class GoingElectricApiWrapper(
|
||||
sp.getString(R.string.filter_networks), "networks",
|
||||
networkMap, manyChoices = true
|
||||
),
|
||||
MultipleChoiceFilter(
|
||||
sp.getString(R.string.categories), "categories",
|
||||
categoryMap,
|
||||
manyChoices = true
|
||||
),
|
||||
BooleanFilter(sp.getString(R.string.filter_exclude_faults), "exclude_faults"),
|
||||
BooleanFilter(sp.getString(R.string.filter_barrierfree), "barrierfree"),
|
||||
MultipleChoiceFilter(
|
||||
sp.getString(R.string.filter_chargecards), "chargecards",
|
||||
chargecardMap, manyChoices = true
|
||||
),
|
||||
BooleanFilter(sp.getString(R.string.filter_exclude_faults), "exclude_faults")
|
||||
MultipleChoiceFilter(
|
||||
sp.getString(R.string.categories), "categories",
|
||||
categoryMap,
|
||||
manyChoices = true
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ data class GEChargeCardList(
|
||||
)
|
||||
|
||||
sealed class GEChargepointListItem {
|
||||
abstract fun convert(apikey: String): ChargepointListItem
|
||||
abstract fun convert(apikey: String, isDetailed: Boolean): ChargepointListItem
|
||||
}
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
@@ -54,7 +54,7 @@ data class GEChargeLocation(
|
||||
val openinghours: GEOpeningHours?,
|
||||
val cost: GECost?
|
||||
) : GEChargepointListItem() {
|
||||
override fun convert(apikey: String) = ChargeLocation(
|
||||
override fun convert(apikey: String, isDetailed: Boolean) = ChargeLocation(
|
||||
id,
|
||||
"goingelectric",
|
||||
name,
|
||||
@@ -76,7 +76,9 @@ data class GEChargeLocation(
|
||||
openinghours?.convert(),
|
||||
cost?.convert(),
|
||||
null,
|
||||
ChargepriceData(address.country, network, chargepoints.map { it.type })
|
||||
ChargepriceData(address.country, network, chargepoints.map { it.type }),
|
||||
Instant.now(),
|
||||
isDetailed
|
||||
)
|
||||
}
|
||||
|
||||
@@ -161,7 +163,7 @@ data class GEChargeLocationCluster(
|
||||
val clusterCount: Int,
|
||||
val coordinates: GECoordinate
|
||||
) : GEChargepointListItem() {
|
||||
override fun convert(apikey: String) =
|
||||
override fun convert(apikey: String, isDetailed: Boolean) =
|
||||
ChargeLocationCluster(clusterCount, coordinates.convert())
|
||||
}
|
||||
|
||||
|
||||
@@ -2,12 +2,20 @@ package net.vonforst.evmap.api.openchargemap
|
||||
|
||||
import com.squareup.moshi.FromJson
|
||||
import com.squareup.moshi.ToJson
|
||||
import java.time.LocalDateTime
|
||||
import java.time.ZoneOffset
|
||||
import java.time.ZonedDateTime
|
||||
import java.time.format.DateTimeParseException
|
||||
|
||||
internal class ZonedDateTimeAdapter {
|
||||
@FromJson
|
||||
fun fromJson(value: String?): ZonedDateTime? = value?.let {
|
||||
ZonedDateTime.parse(value)
|
||||
try {
|
||||
ZonedDateTime.parse(value)
|
||||
} catch (e: DateTimeParseException) {
|
||||
val dt: LocalDateTime = LocalDateTime.parse(value)
|
||||
dt.atZone(ZoneOffset.UTC)
|
||||
}
|
||||
}
|
||||
|
||||
@ToJson
|
||||
|
||||
@@ -105,9 +105,11 @@ class OpenChargeMapApiWrapper(
|
||||
baseurl: String = "https://api.openchargemap.io/v3/",
|
||||
context: Context? = null
|
||||
) : ChargepointApi<OCMReferenceData> {
|
||||
private val clusterThreshold = 11
|
||||
val api = OpenChargeMapApi.create(apikey, baseurl, context)
|
||||
|
||||
override fun getName() = "OpenChargeMap.org"
|
||||
override val name = "OpenChargeMap.org"
|
||||
override val id = "open_charge_map"
|
||||
|
||||
private fun formatMultipleChoice(value: MultipleChoiceFilterValue?) =
|
||||
if (value == null || value.all) null else value.values.joinToString(",")
|
||||
@@ -235,10 +237,10 @@ class OpenChargeMapApiWrapper(
|
||||
.filter { it.power == null || it.power >= (minPower ?: 0.0) }
|
||||
.filter { if (connectorsVal != null && !connectorsVal.all) it.connectionTypeId in connectorsVal.values.map { it.toLong() } else true }
|
||||
.sumOf { it.quantity ?: 1 } >= (minConnectors ?: 0)
|
||||
}.map { it.convert(referenceData) }.distinct() as List<ChargepointListItem>
|
||||
}.map { it.convert(referenceData, false) }.distinct() as List<ChargepointListItem>
|
||||
|
||||
// apply clustering
|
||||
val useClustering = zoom < 13
|
||||
val useClustering = zoom < clusterThreshold
|
||||
if (useClustering) {
|
||||
val clusterDistance = getClusterDistance(zoom)
|
||||
Dispatchers.IO.run {
|
||||
@@ -256,7 +258,7 @@ class OpenChargeMapApiWrapper(
|
||||
try {
|
||||
val response = api.getChargepointDetail(id)
|
||||
if (response.isSuccessful && response.body()?.size == 1) {
|
||||
return Resource.success(response.body()!![0].convert(referenceData))
|
||||
return Resource.success(response.body()!![0].convert(referenceData, true))
|
||||
} else {
|
||||
return Resource.error(response.message(), null)
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import com.squareup.moshi.JsonClass
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import net.vonforst.evmap.max
|
||||
import net.vonforst.evmap.model.*
|
||||
import java.time.Instant
|
||||
import java.time.ZonedDateTime
|
||||
|
||||
// Unknown, Currently Available, Currently In Use, Operational
|
||||
@@ -44,7 +45,7 @@ data class OCMChargepoint(
|
||||
@Json(name = "UserComments") val userComments: List<OCMUserComment>?,
|
||||
@Json(name = "DateLastStatusUpdate") val lastStatusUpdateDate: ZonedDateTime?
|
||||
) {
|
||||
fun convert(refData: OCMReferenceData) = ChargeLocation(
|
||||
fun convert(refData: OCMReferenceData, isDetailed: Boolean) = ChargeLocation(
|
||||
id,
|
||||
"openchargemap",
|
||||
addressInfo.title,
|
||||
@@ -69,7 +70,9 @@ data class OCMChargepoint(
|
||||
ChargepriceData(
|
||||
addressInfo.countryISOCode(refData),
|
||||
operatorId?.toString(),
|
||||
connections.map { "${it.connectionTypeId},${it.currentTypeId}" })
|
||||
connections.map { "${it.connectionTypeId},${it.currentTypeId}" }),
|
||||
Instant.now(),
|
||||
isDetailed
|
||||
)
|
||||
|
||||
private fun convertFaultReport(): FaultReport? {
|
||||
@@ -138,7 +141,7 @@ data class OCMConnection(
|
||||
) {
|
||||
fun convert(refData: OCMReferenceData) = Chargepoint(
|
||||
convertConnectionTypeFromOCM(connectionTypeId, refData),
|
||||
power ?: 0.0,
|
||||
power,
|
||||
quantity ?: 1
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
package net.vonforst.evmap.api.openstreetmap
|
||||
|
||||
import com.squareup.moshi.Json
|
||||
import com.squareup.moshi.JsonClass
|
||||
import net.vonforst.evmap.model.*
|
||||
import okhttp3.internal.immutableListOf
|
||||
import java.time.Instant
|
||||
import java.time.ZonedDateTime
|
||||
|
||||
private data class OsmSocket(
|
||||
// The OSM socket name (e.g. "type2_combo")
|
||||
val osmSocketName: String,
|
||||
// The socket identifier used in EVMap.
|
||||
// TODO: This should probably be a separate enum-like type, not a string.
|
||||
val evmapKey: String?,
|
||||
) {
|
||||
/**
|
||||
* Return the OSM socket base tag (e.g. "socket:type2_combo").
|
||||
*/
|
||||
fun osmSocketBaseTag(): String {
|
||||
return "socket:${this.osmSocketName}"
|
||||
}
|
||||
}
|
||||
|
||||
// List of all OSM socket types that are relevant for EVs:
|
||||
// https://wiki.openstreetmap.org/wiki/Key:socket
|
||||
private val SOCKET_TYPES = immutableListOf(
|
||||
// Type 1
|
||||
OsmSocket("type1", Chargepoint.TYPE_1),
|
||||
OsmSocket("type1_combo", Chargepoint.CCS_TYPE_1),
|
||||
|
||||
// Type 2
|
||||
OsmSocket("type2", Chargepoint.TYPE_2_SOCKET), // Type2 socket (or unknown)
|
||||
OsmSocket("type2_cable", Chargepoint.TYPE_2_PLUG), // Type2 plug
|
||||
OsmSocket("type2_combo", Chargepoint.CCS_TYPE_2), // CCS
|
||||
|
||||
// CHAdeMO
|
||||
OsmSocket("chademo", Chargepoint.CHADEMO),
|
||||
|
||||
// Tesla
|
||||
OsmSocket("tesla_standard", null),
|
||||
OsmSocket("tesla_supercharger", Chargepoint.SUPERCHARGER),
|
||||
|
||||
// CEE
|
||||
OsmSocket("cee_blue", Chargepoint.CEE_BLAU), // Also known as "caravan socket"
|
||||
OsmSocket("cee_red_16a", Chargepoint.CEE_ROT),
|
||||
OsmSocket("cee_red_32a", Chargepoint.CEE_ROT),
|
||||
OsmSocket("cee_red_63a", Chargepoint.CEE_ROT),
|
||||
OsmSocket("cee_red_125a", Chargepoint.CEE_ROT),
|
||||
|
||||
// Europe
|
||||
OsmSocket("schuko", Chargepoint.SCHUKO),
|
||||
|
||||
// Switzerland
|
||||
OsmSocket("sev1011_t13", null),
|
||||
OsmSocket("sev1011_t15", null),
|
||||
OsmSocket("sev1011_t23", null),
|
||||
OsmSocket("sev1011_t25", null),
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class OSMChargingStation(
|
||||
// Unique numeric ID
|
||||
val id: Long,
|
||||
// Latitude (WGS84)
|
||||
val lat: Double,
|
||||
// Longitude (WGS84)
|
||||
val lon: Double,
|
||||
// Timestamp of last update
|
||||
@Json(name = "timestamp") val lastUpdateTimestamp: ZonedDateTime,
|
||||
// Numeric, monotonically increasing version number
|
||||
val version: Int,
|
||||
// User that last modified this POI
|
||||
val user: String,
|
||||
// Raw key-value OSM tags
|
||||
val tags: Map<String, String>,
|
||||
) {
|
||||
/**
|
||||
* Convert the [OSMChargingStation] to a generic [ChargeLocation].
|
||||
*
|
||||
* The [dataFetchTimestamp] should be set to the timestamp when the data was last
|
||||
* refreshed / fetched from OSM. It will always be later than the [lastUpdateTimestamp],
|
||||
* which contains the timestamp when the data was last _edited_ in OSM.
|
||||
*/
|
||||
fun convert(dataFetchTimestamp: Instant) = ChargeLocation(
|
||||
id,
|
||||
"openstreetmap",
|
||||
getName(),
|
||||
Coordinate(lat, lon),
|
||||
null, // TODO: Can we determine this with overpass?
|
||||
getChargepoints(),
|
||||
tags["network"],
|
||||
"https://www.openstreetmap.org/node/$id",
|
||||
"https://www.openstreetmap.org/edit?node=$id",
|
||||
null,
|
||||
false, // We don't know
|
||||
tags["authentication:none"] == "yes",
|
||||
tags["operator"],
|
||||
tags["description"],
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
getOpeningHours(),
|
||||
getCost(),
|
||||
"© OpenStreetMap contributors",
|
||||
null,
|
||||
dataFetchTimestamp,
|
||||
true,
|
||||
)
|
||||
|
||||
/**
|
||||
* Return the name for this charging station.
|
||||
*/
|
||||
private fun getName(): String {
|
||||
// Ideally this station has a name.
|
||||
// If not, fall back to the operator.
|
||||
// If that is missing as well, use a generic "Charging Station" string.
|
||||
return tags["name"]
|
||||
?: tags["operator"]
|
||||
?: "Charging Station";
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the chargepoints for this charging station.
|
||||
*/
|
||||
private fun getChargepoints(): List<Chargepoint> {
|
||||
// Note: In OSM, the chargepoints are mapped as "socket:<type> = <count>"
|
||||
val chargepoints = mutableListOf<Chargepoint>()
|
||||
for (socket in SOCKET_TYPES) {
|
||||
val count = try {
|
||||
(this.tags[socket.osmSocketBaseTag()] ?: "0").toInt()
|
||||
} catch (e: NumberFormatException) {
|
||||
0
|
||||
}
|
||||
if (count > 0) {
|
||||
if (socket.evmapKey != null) {
|
||||
val outputPower = parseOutputPower(this.tags["${socket.osmSocketBaseTag()}:output"])
|
||||
chargepoints.add(Chargepoint(socket.evmapKey, outputPower, count))
|
||||
}
|
||||
}
|
||||
}
|
||||
return chargepoints
|
||||
}
|
||||
|
||||
private fun getOpeningHours(): OpeningHours? {
|
||||
val rawOpeningHours = tags["opening_hours"] ?: return null
|
||||
|
||||
// Handle the simple 24/7 case
|
||||
if (rawOpeningHours == "24/7") {
|
||||
return OpeningHours(true, null, null)
|
||||
}
|
||||
|
||||
// TODO: Try to convert other formats as well?
|
||||
//
|
||||
// Note: The current {@link OpeningHours} format is not flexible enough to handle
|
||||
// all rules that OSM can represent and might need to be updated.
|
||||
// This library could help: https://github.com/simonpoole/OpeningHoursParser
|
||||
//
|
||||
// Alternatively, with the opening-hours-evaluator library
|
||||
// https://github.com/leonardehrenfried/opening-hours-evaluator
|
||||
// we could implement an "open now" feature.
|
||||
return null
|
||||
}
|
||||
|
||||
private fun getCost(): Cost? {
|
||||
val freecharging = when (tags["fee"]?.lowercase()) {
|
||||
"yes", "y" -> false
|
||||
"no", "n" -> true
|
||||
else -> null
|
||||
}
|
||||
val freeparking = when (tags["parking:fee"]?.lowercase()) {
|
||||
"no", "n" -> true
|
||||
"yes", "y", "interval" -> false
|
||||
else -> null
|
||||
}
|
||||
return Cost(freecharging, freeparking)
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Parse raw OSM output power.
|
||||
*
|
||||
* The proper format to map output power for an EV charging station is "<amount> kW",
|
||||
* for example "22 kW" or "3.7 kW". Some fields in the wild are tagged with the unit "kVA"
|
||||
* instead of "kW", those can be treated as equivalent.
|
||||
*
|
||||
* Sometimes people also mapped plain numbers (e.g. 7000, I assume that's 7 kW),
|
||||
* ranges (5,5 - 11 kW, huh?) or even current (32 A), which is wrong. If we cannot parse,
|
||||
* just ignore the field.
|
||||
*/
|
||||
fun parseOutputPower(rawOutput: String?): Double? {
|
||||
if (rawOutput == null) {
|
||||
return null;
|
||||
}
|
||||
val pattern = Regex("([0-9.,]+)\\s*(kW|kVA)", setOf(RegexOption.IGNORE_CASE))
|
||||
val matchResult = pattern.matchEntire(rawOutput) ?: return null
|
||||
val numberString = matchResult.groupValues[1].replace(',', '.')
|
||||
return numberString.toDoubleOrNull()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -29,7 +29,7 @@ class MapboxAutocompleteProvider(val context: Context) : AutocompleteProvider {
|
||||
location?.let {
|
||||
proximity(Point.fromLngLat(location.longitude, location.latitude))
|
||||
}
|
||||
languages(ConfigurationCompat.getLocales(context.resources.configuration)[0].language)
|
||||
languages(ConfigurationCompat.getLocales(context.resources.configuration)[0]?.language)
|
||||
accessToken(context.getString(R.string.mapbox_key))
|
||||
autocomplete(true)
|
||||
this.query(query)
|
||||
|
||||
@@ -2,7 +2,10 @@ package net.vonforst.evmap.fragment
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.os.Bundle
|
||||
import android.view.*
|
||||
import android.view.LayoutInflater
|
||||
import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.databinding.DataBindingUtil
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.viewModels
|
||||
@@ -13,6 +16,7 @@ import androidx.navigation.ui.setupWithNavController
|
||||
import androidx.recyclerview.widget.DividerItemDecoration
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import com.google.android.material.color.MaterialColors
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import com.google.android.material.transition.MaterialContainerTransform
|
||||
import net.vonforst.evmap.MapsActivity
|
||||
@@ -20,13 +24,15 @@ import net.vonforst.evmap.R
|
||||
import net.vonforst.evmap.adapter.ChargepriceAdapter
|
||||
import net.vonforst.evmap.adapter.CheckableChargepriceCarAdapter
|
||||
import net.vonforst.evmap.adapter.CheckableConnectorAdapter
|
||||
import net.vonforst.evmap.api.chargeprice.ChargepriceApi
|
||||
import net.vonforst.evmap.api.chargeprice.ChargepriceCar
|
||||
import net.vonforst.evmap.api.equivalentPlugTypes
|
||||
import net.vonforst.evmap.databinding.FragmentChargepriceBinding
|
||||
import net.vonforst.evmap.model.Chargepoint
|
||||
import net.vonforst.evmap.storage.PreferenceDataSource
|
||||
import net.vonforst.evmap.viewmodel.ChargepriceViewModel
|
||||
import net.vonforst.evmap.viewmodel.Status
|
||||
import net.vonforst.evmap.viewmodel.viewModelFactory
|
||||
import net.vonforst.evmap.viewmodel.savedStateViewModelFactory
|
||||
import java.text.NumberFormat
|
||||
|
||||
class ChargepriceFragment : Fragment() {
|
||||
@@ -34,10 +40,12 @@ class ChargepriceFragment : Fragment() {
|
||||
private var connectionErrorSnackbar: Snackbar? = null
|
||||
|
||||
private val vm: ChargepriceViewModel by viewModels(factoryProducer = {
|
||||
viewModelFactory {
|
||||
savedStateViewModelFactory { state ->
|
||||
ChargepriceViewModel(
|
||||
requireActivity().application,
|
||||
getString(R.string.chargeprice_key)
|
||||
getString(R.string.chargeprice_key),
|
||||
getString(R.string.chargeprice_api_url),
|
||||
state
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -45,6 +53,33 @@ class ChargepriceFragment : Fragment() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
sharedElementEnterTransition = MaterialContainerTransform()
|
||||
|
||||
if (savedInstanceState == null) {
|
||||
val prefs = PreferenceDataSource(requireContext())
|
||||
prefs.chargepriceCounter += 1
|
||||
if ((prefs.chargepriceCounter - 30).mod(50) == 0) {
|
||||
showDonationDialog()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
vm.reloadPrefs()
|
||||
}
|
||||
|
||||
private fun showDonationDialog() {
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle(R.string.chargeprice_donation_dialog_title)
|
||||
.setMessage(R.string.chargeprice_donation_dialog_detail)
|
||||
.setNegativeButton(R.string.ok) { di, _ ->
|
||||
di.cancel()
|
||||
}
|
||||
.setPositiveButton(R.string.donate) { di, _ ->
|
||||
di.dismiss()
|
||||
findNavController().navigate(R.id.action_chargeprice_to_donateFragment)
|
||||
}
|
||||
.show()
|
||||
}
|
||||
|
||||
override fun onCreateView(
|
||||
@@ -76,9 +111,7 @@ class ChargepriceFragment : Fragment() {
|
||||
|
||||
val fragmentArgs: ChargepriceFragmentArgs by navArgs()
|
||||
val charger = fragmentArgs.charger
|
||||
val dataSource = fragmentArgs.dataSource
|
||||
vm.charger.value = charger
|
||||
vm.dataSource.value = dataSource
|
||||
if (vm.chargepoint.value == null) {
|
||||
vm.chargepoint.value = charger.chargepointsMerged.get(0)
|
||||
}
|
||||
@@ -143,11 +176,11 @@ class ChargepriceFragment : Fragment() {
|
||||
}
|
||||
|
||||
binding.imgChargepriceLogo.setOnClickListener {
|
||||
(requireActivity() as MapsActivity).openUrl("https://www.chargeprice.app/?poi_id=${charger.id}&poi_source=${dataSource}")
|
||||
(requireActivity() as MapsActivity).openUrl(ChargepriceApi.getPoiUrl(charger))
|
||||
}
|
||||
|
||||
binding.btnSettings.setOnClickListener {
|
||||
findNavController().navigate(R.id.action_chargeprice_to_settingsFragment)
|
||||
findNavController().navigate(R.id.action_chargeprice_to_chargepriceSettingsFragment)
|
||||
}
|
||||
|
||||
binding.batteryRange.setLabelFormatter { value: Float ->
|
||||
|
||||
@@ -4,13 +4,12 @@ import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.appcompat.app.AppCompatDialogFragment
|
||||
import net.vonforst.evmap.databinding.DialogDataSourceSelectBinding
|
||||
import net.vonforst.evmap.model.FILTERS_DISABLED
|
||||
import net.vonforst.evmap.storage.PreferenceDataSource
|
||||
import java.util.*
|
||||
import net.vonforst.evmap.ui.MaterialDialogFragment
|
||||
|
||||
class DataSourceSelectDialog : AppCompatDialogFragment() {
|
||||
class DataSourceSelectDialog : MaterialDialogFragment() {
|
||||
private lateinit var binding: DialogDataSourceSelectBinding
|
||||
var okListener: ((String) -> Unit)? = null
|
||||
|
||||
@@ -28,7 +27,7 @@ class DataSourceSelectDialog : AppCompatDialogFragment() {
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateView(
|
||||
override fun createView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
@@ -41,16 +40,12 @@ class DataSourceSelectDialog : AppCompatDialogFragment() {
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
|
||||
// dialog with 95% screen height
|
||||
dialog?.window?.setLayout(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
(resources.displayMetrics.heightPixels * 0.95).toInt()
|
||||
)
|
||||
setFullSize()
|
||||
}
|
||||
|
||||
private lateinit var prefs: PreferenceDataSource
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
override fun initView(view: View, savedInstanceState: Bundle?) {
|
||||
val args = requireArguments()
|
||||
binding.btnCancel.visibility =
|
||||
if (args.getBoolean("cancel_enabled")) View.VISIBLE else View.GONE
|
||||
|
||||
@@ -20,23 +20,24 @@ import com.car2go.maps.model.LatLng
|
||||
import com.google.android.material.color.MaterialColors
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import com.google.android.material.transition.MaterialFadeThrough
|
||||
import com.mapzen.android.lost.api.LocationServices
|
||||
import com.mapzen.android.lost.api.LostApiClient
|
||||
import net.vonforst.evmap.MapsActivity
|
||||
import net.vonforst.evmap.R
|
||||
import net.vonforst.evmap.adapter.DataBindingAdapter
|
||||
import net.vonforst.evmap.adapter.FavoritesAdapter
|
||||
import net.vonforst.evmap.databinding.FragmentFavoritesBinding
|
||||
import net.vonforst.evmap.databinding.ItemFavoriteBinding
|
||||
import net.vonforst.evmap.model.ChargeLocation
|
||||
import net.vonforst.evmap.location.FusionEngine
|
||||
import net.vonforst.evmap.location.LocationEngine
|
||||
import net.vonforst.evmap.model.Favorite
|
||||
import net.vonforst.evmap.model.FavoriteWithDetail
|
||||
import net.vonforst.evmap.utils.checkAnyLocationPermission
|
||||
import net.vonforst.evmap.viewmodel.FavoritesViewModel
|
||||
import net.vonforst.evmap.viewmodel.viewModelFactory
|
||||
|
||||
class FavoritesFragment : Fragment(), LostApiClient.ConnectionCallbacks {
|
||||
class FavoritesFragment : Fragment() {
|
||||
private lateinit var binding: FragmentFavoritesBinding
|
||||
private var locationClient: LostApiClient? = null
|
||||
private var toDelete: ChargeLocation? = null
|
||||
private lateinit var locationEngine: LocationEngine
|
||||
private var toDelete: Favorite? = null
|
||||
private var deleteSnackbar: Snackbar? = null
|
||||
private lateinit var adapter: FavoritesAdapter
|
||||
|
||||
@@ -51,8 +52,8 @@ class FavoritesFragment : Fragment(), LostApiClient.ConnectionCallbacks {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
locationClient = LostApiClient.Builder(requireContext())
|
||||
.addConnectionCallbacks(this).build()
|
||||
|
||||
locationEngine = FusionEngine(requireContext())
|
||||
|
||||
enterTransition = MaterialFadeThrough()
|
||||
exitTransition = MaterialFadeThrough()
|
||||
@@ -84,7 +85,7 @@ class FavoritesFragment : Fragment(), LostApiClient.ConnectionCallbacks {
|
||||
)
|
||||
|
||||
adapter = FavoritesAdapter(onDelete = {
|
||||
delete(it.charger)
|
||||
delete(it.fav)
|
||||
}).apply {
|
||||
onClickListener = {
|
||||
findNavController().navigate(
|
||||
@@ -108,42 +109,38 @@ class FavoritesFragment : Fragment(), LostApiClient.ConnectionCallbacks {
|
||||
}
|
||||
createTouchHelper().attachToRecyclerView(binding.favsList)
|
||||
|
||||
locationClient!!.connect()
|
||||
}
|
||||
|
||||
override fun onConnected() {
|
||||
val context = this.context ?: return
|
||||
if (context.checkAnyLocationPermission()) {
|
||||
val location = LocationServices.FusedLocationApi.getLastLocation(locationClient!!)
|
||||
if (location != null) {
|
||||
vm.location.value = LatLng(location.latitude, location.longitude)
|
||||
binding.swipeRefresh.setOnRefreshListener {
|
||||
vm.reloadAvailability() {
|
||||
binding.swipeRefresh.isRefreshing = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onConnectionSuspended() {
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
locationClient?.let {
|
||||
if (it.isConnected) it.disconnect()
|
||||
if (requireContext().checkAnyLocationPermission()) {
|
||||
val location = locationEngine.getLastKnownLocation()
|
||||
location?.let {
|
||||
vm.location.value = LatLng(it.latitude, it.longitude)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun delete(fav: ChargeLocation) {
|
||||
val position = vm.listData.value?.indexOfFirst { it.charger == fav } ?: return
|
||||
fun delete(fav: FavoriteWithDetail) {
|
||||
val position =
|
||||
vm.listData.value?.indexOfFirst { it.fav.favorite.favoriteId == fav.favorite.favoriteId }
|
||||
?: return
|
||||
// if there is already a profile to delete, delete it now
|
||||
actuallyDelete()
|
||||
deleteSnackbar?.dismiss()
|
||||
|
||||
toDelete = fav
|
||||
toDelete = fav.favorite
|
||||
|
||||
view?.let {
|
||||
val snackbar = Snackbar.make(
|
||||
it,
|
||||
getString(R.string.deleted_filterprofile, fav.name),
|
||||
getString(R.string.deleted_filterprofile, fav.charger.name),
|
||||
Snackbar.LENGTH_LONG
|
||||
).setAction(R.string.undo) {
|
||||
toDelete = null
|
||||
@@ -182,7 +179,7 @@ class FavoritesFragment : Fragment(), LostApiClient.ConnectionCallbacks {
|
||||
}
|
||||
|
||||
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
|
||||
val fav = vm.favorites.value?.find { it.id == viewHolder.itemId }
|
||||
val fav = vm.favorites.value?.find { it.favorite.favoriteId == viewHolder.itemId }
|
||||
fav?.let { delete(it) }
|
||||
}
|
||||
|
||||
|
||||
@@ -3,9 +3,11 @@ package net.vonforst.evmap.fragment
|
||||
import android.os.Bundle
|
||||
import android.view.*
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.view.MenuProvider
|
||||
import androidx.databinding.DataBindingUtil
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.navigation.ui.setupWithNavController
|
||||
@@ -22,7 +24,7 @@ import net.vonforst.evmap.ui.showEditTextDialog
|
||||
import net.vonforst.evmap.viewmodel.FilterViewModel
|
||||
|
||||
|
||||
class FilterFragment : Fragment() {
|
||||
class FilterFragment : Fragment(), MenuProvider {
|
||||
private lateinit var binding: FragmentFilterBinding
|
||||
private val vm: FilterViewModel by viewModels()
|
||||
|
||||
@@ -40,9 +42,6 @@ class FilterFragment : Fragment() {
|
||||
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
|
||||
@@ -50,6 +49,7 @@ class FilterFragment : Fragment() {
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
(requireActivity() as AppCompatActivity).setSupportActionBar(binding.toolbar)
|
||||
requireActivity().addMenuProvider(this, viewLifecycleOwner, Lifecycle.State.RESUMED)
|
||||
|
||||
binding.toolbar.setupWithNavController(
|
||||
findNavController(),
|
||||
@@ -81,12 +81,11 @@ class FilterFragment : Fragment() {
|
||||
view.setBackgroundColor(MaterialColors.getColor(view, android.R.attr.windowBackground))
|
||||
}
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
|
||||
override fun onCreateMenu(menu: Menu, inflater: MenuInflater) {
|
||||
inflater.inflate(R.menu.filter, menu)
|
||||
super.onCreateOptionsMenu(menu, inflater)
|
||||
}
|
||||
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||
override fun onMenuItemSelected(item: MenuItem): Boolean {
|
||||
return when (item.itemId) {
|
||||
R.id.menu_apply -> {
|
||||
lifecycleScope.launch {
|
||||
@@ -99,7 +98,7 @@ class FilterFragment : Fragment() {
|
||||
saveProfile()
|
||||
true
|
||||
}
|
||||
else -> super.onOptionsItemSelected(item)
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,11 +4,9 @@ import android.Manifest.permission.ACCESS_COARSE_LOCATION
|
||||
import android.Manifest.permission.ACCESS_FINE_LOCATION
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.content.res.Configuration
|
||||
import android.graphics.Color
|
||||
import android.location.Geocoder
|
||||
import android.location.Location
|
||||
import android.os.Bundle
|
||||
import android.text.method.KeyListener
|
||||
import android.view.*
|
||||
@@ -18,11 +16,11 @@ import android.widget.ImageView
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.annotation.RequiresPermission
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.appcompat.widget.PopupMenu
|
||||
import androidx.core.app.ActivityCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.location.LocationListenerCompat
|
||||
import androidx.core.view.*
|
||||
import androidx.databinding.DataBindingUtil
|
||||
import androidx.fragment.app.Fragment
|
||||
@@ -51,6 +49,7 @@ 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.dialog.MaterialAlertDialogBuilder
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import com.google.android.material.transition.MaterialArcMotion
|
||||
import com.google.android.material.transition.MaterialContainerTransform
|
||||
@@ -60,48 +59,48 @@ import com.mahc.custombottomsheetbehavior.BottomSheetBehaviorGoogleMapsLike
|
||||
import com.mahc.custombottomsheetbehavior.BottomSheetBehaviorGoogleMapsLike.STATE_COLLAPSED
|
||||
import com.mahc.custombottomsheetbehavior.BottomSheetBehaviorGoogleMapsLike.STATE_HIDDEN
|
||||
import com.mahc.custombottomsheetbehavior.MergedAppBarLayoutBehavior
|
||||
import com.mapzen.android.lost.api.LocationListener
|
||||
import com.mapzen.android.lost.api.LocationRequest
|
||||
import com.mapzen.android.lost.api.LocationServices
|
||||
import com.mapzen.android.lost.api.LostApiClient
|
||||
import com.stfalcon.imageviewer.StfalconImageViewer
|
||||
import io.michaelrocks.bimap.HashBiMap
|
||||
import io.michaelrocks.bimap.MutableBiMap
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import net.vonforst.evmap.*
|
||||
import net.vonforst.evmap.BuildConfig
|
||||
import net.vonforst.evmap.MapsActivity
|
||||
import net.vonforst.evmap.R
|
||||
import net.vonforst.evmap.adapter.ConnectorAdapter
|
||||
import net.vonforst.evmap.adapter.DetailsAdapter
|
||||
import net.vonforst.evmap.adapter.GalleryAdapter
|
||||
import net.vonforst.evmap.adapter.PlaceAutocompleteAdapter
|
||||
import net.vonforst.evmap.api.goingelectric.GoingElectricApiWrapper
|
||||
import net.vonforst.evmap.api.openchargemap.OpenChargeMapApiWrapper
|
||||
import net.vonforst.evmap.autocomplete.ApiUnavailableException
|
||||
import net.vonforst.evmap.autocomplete.PlaceWithBounds
|
||||
import net.vonforst.evmap.bold
|
||||
import net.vonforst.evmap.databinding.FragmentMapBinding
|
||||
import net.vonforst.evmap.location.FusionEngine
|
||||
import net.vonforst.evmap.location.LocationEngine
|
||||
import net.vonforst.evmap.location.Priority
|
||||
import net.vonforst.evmap.model.*
|
||||
import net.vonforst.evmap.storage.PreferenceDataSource
|
||||
import net.vonforst.evmap.ui.ChargerIconGenerator
|
||||
import net.vonforst.evmap.ui.ClusterIconGenerator
|
||||
import net.vonforst.evmap.ui.MarkerAnimator
|
||||
import net.vonforst.evmap.ui.getMarkerTint
|
||||
import net.vonforst.evmap.ui.*
|
||||
import net.vonforst.evmap.utils.boundingBox
|
||||
import net.vonforst.evmap.utils.checkAnyLocationPermission
|
||||
import net.vonforst.evmap.utils.checkFineLocationPermission
|
||||
import net.vonforst.evmap.utils.distanceBetween
|
||||
import net.vonforst.evmap.viewmodel.*
|
||||
import java.io.IOException
|
||||
import kotlin.collections.component1
|
||||
import kotlin.collections.component2
|
||||
import kotlin.collections.contains
|
||||
import kotlin.collections.set
|
||||
|
||||
|
||||
class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallback,
|
||||
LostApiClient.ConnectionCallbacks, LocationListener {
|
||||
class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallback, MenuProvider {
|
||||
private lateinit var binding: FragmentMapBinding
|
||||
private val vm: MapViewModel by viewModels()
|
||||
private val galleryVm: GalleryViewModel by activityViewModels()
|
||||
private var mapFragment: MapFragment? = null
|
||||
private var map: AnyMap? = null
|
||||
private lateinit var locationClient: LostApiClient
|
||||
private lateinit var locationEngine: LocationEngine
|
||||
private var requestingLocationUpdates = false
|
||||
private lateinit var bottomSheetBehavior: BottomSheetBehaviorGoogleMapsLike<View>
|
||||
private lateinit var detailAppBarBehavior: MergedAppBarLayoutBehavior
|
||||
@@ -112,6 +111,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
||||
private var searchResultIcon: BitmapDescriptor? = null
|
||||
private var connectionErrorSnackbar: Snackbar? = null
|
||||
private var previousChargepointIds: Set<Long>? = null
|
||||
private var mapTopPadding: Int = 0
|
||||
|
||||
private lateinit var clusterIconGenerator: ClusterIconGenerator
|
||||
private lateinit var chargerIconGenerator: ChargerIconGenerator
|
||||
@@ -145,10 +145,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
||||
|
||||
prefs = PreferenceDataSource(requireContext())
|
||||
|
||||
locationClient = LostApiClient.Builder(requireContext())
|
||||
.addConnectionCallbacks(this)
|
||||
.build()
|
||||
locationClient.connect()
|
||||
locationEngine = FusionEngine(requireContext())
|
||||
clusterIconGenerator = ClusterIconGenerator(requireContext())
|
||||
|
||||
enterTransition = MaterialFadeThrough()
|
||||
@@ -169,7 +166,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
||||
val provider = prefs.mapProvider
|
||||
if (mapFragment == null) {
|
||||
mapFragment =
|
||||
requireActivity().supportFragmentManager.findFragmentByTag(mapFragmentTag) as MapFragment?
|
||||
childFragmentManager.findFragmentByTag(mapFragmentTag) as MapFragment?
|
||||
}
|
||||
if (mapFragment == null || mapFragment!!.priority[0] != provider) {
|
||||
mapFragment = MapFragment()
|
||||
@@ -182,7 +179,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
||||
MapFragment.GOOGLE,
|
||||
MapFragment.MAPBOX
|
||||
)
|
||||
requireActivity().supportFragmentManager
|
||||
childFragmentManager
|
||||
.beginTransaction()
|
||||
.replace(R.id.map, mapFragment!!, mapFragmentTag)
|
||||
.commit()
|
||||
@@ -195,21 +192,23 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
||||
searchResultIcon = null
|
||||
}
|
||||
|
||||
setHasOptionsMenu(true)
|
||||
ViewCompat.setOnApplyWindowInsetsListener(
|
||||
binding.root
|
||||
) { v, insets ->
|
||||
ViewCompat.onApplyWindowInsets(binding.root, insets)
|
||||
|
||||
binding.root.setOnApplyWindowInsetsListener { _, insets ->
|
||||
val systemWindowInsetTop = insets.getInsets(WindowInsetsCompat.Type.statusBars()).top
|
||||
binding.detailAppBar.toolbar.updateLayoutParams<ViewGroup.MarginLayoutParams> {
|
||||
topMargin = insets.systemWindowInsetTop
|
||||
topMargin = systemWindowInsetTop
|
||||
}
|
||||
|
||||
// margin of layers button
|
||||
// margin of layers button: status bar height + toolbar height + margin
|
||||
val density = resources.displayMetrics.density
|
||||
// status bar height + toolbar height + margin
|
||||
val margin =
|
||||
if (binding.toolbarContainer.layoutParams.width == ViewGroup.LayoutParams.MATCH_PARENT) {
|
||||
insets.systemWindowInsetTop + (48 * density).toInt() + (28 * density).toInt()
|
||||
systemWindowInsetTop + (48 * density).toInt() + (28 * density).toInt()
|
||||
} else {
|
||||
insets.systemWindowInsetTop + (12 * density).toInt()
|
||||
systemWindowInsetTop + (12 * density).toInt()
|
||||
}
|
||||
binding.fabLayers.updateLayoutParams<ViewGroup.MarginLayoutParams> {
|
||||
topMargin = margin
|
||||
@@ -217,6 +216,13 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
||||
binding.layersSheet.updateLayoutParams<ViewGroup.MarginLayoutParams> {
|
||||
topMargin = margin
|
||||
}
|
||||
|
||||
// set map padding so that compass is not obstructed by toolbar
|
||||
mapTopPadding = systemWindowInsetTop + (48 * density).toInt() + (16 * density).toInt()
|
||||
// if we actually use map.setPadding here, Mapbox will re-trigger onApplyWindowInsets
|
||||
// and cause an infinite loop. So we rely on onMapReady being called later than
|
||||
// onApplyWindowInsets.
|
||||
|
||||
insets
|
||||
}
|
||||
|
||||
@@ -232,6 +238,8 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
requireActivity().addMenuProvider(this, viewLifecycleOwner, Lifecycle.State.RESUMED)
|
||||
|
||||
mapFragment!!.getMapAsync(this)
|
||||
bottomSheetBehavior = BottomSheetBehaviorGoogleMapsLike.from(binding.bottomSheet)
|
||||
detailAppBarBehavior = MergedAppBarLayoutBehavior.from(binding.detailAppBar)
|
||||
@@ -306,19 +314,25 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
||||
|
||||
vm.reloadPrefs()
|
||||
if (requestingLocationUpdates && requireContext().checkAnyLocationPermission()
|
||||
&& locationClient.isConnected
|
||||
) {
|
||||
requestLocationUpdates()
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
private val requestPermissionLauncher =
|
||||
registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) {
|
||||
val context = context ?: return@registerForActivityResult
|
||||
if (context.checkAnyLocationPermission()) {
|
||||
enableLocation(moveTo = true, animate = true)
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupClickListeners() {
|
||||
binding.fabLocate.setOnClickListener {
|
||||
if (!requireContext().checkFineLocationPermission()) {
|
||||
ActivityCompat.requestPermissions(
|
||||
requireActivity(),
|
||||
arrayOf(ACCESS_FINE_LOCATION, ACCESS_COARSE_LOCATION),
|
||||
REQUEST_LOCATION_PERMISSION
|
||||
requestPermissionLauncher.launch(
|
||||
arrayOf(ACCESS_FINE_LOCATION, ACCESS_COARSE_LOCATION)
|
||||
)
|
||||
}
|
||||
if (requireContext().checkAnyLocationPermission()) {
|
||||
@@ -347,16 +361,11 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
||||
}
|
||||
binding.detailView.btnChargeprice.setOnClickListener {
|
||||
val charger = vm.charger.value?.data ?: return@setOnClickListener
|
||||
val dataSource = when (vm.apiType) {
|
||||
GoingElectricApiWrapper::class.java -> "going_electric"
|
||||
OpenChargeMapApiWrapper::class.java -> "open_charge_map"
|
||||
else -> throw IllegalArgumentException("unsupported data source")
|
||||
}
|
||||
val extras =
|
||||
FragmentNavigatorExtras(binding.detailView.btnChargeprice to getString(R.string.shared_element_chargeprice))
|
||||
findNavController().navigate(
|
||||
R.id.action_map_to_chargepriceFragment,
|
||||
ChargepriceFragmentArgs(charger, dataSource).toBundle(),
|
||||
ChargepriceFragmentArgs(charger).toBundle(),
|
||||
null, extras
|
||||
)
|
||||
}
|
||||
@@ -384,7 +393,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
||||
val charger = vm.charger.value?.data
|
||||
if (charger?.editUrl != null) {
|
||||
(activity as? MapsActivity)?.openUrl(charger.editUrl)
|
||||
if (vm.apiType == GoingElectricApiWrapper::class.java) {
|
||||
if (vm.apiId == "going_electric") {
|
||||
// instructions specific to GoingElectric
|
||||
Toast.makeText(
|
||||
requireContext(),
|
||||
@@ -398,6 +407,9 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
binding.detailView.btnRefreshLiveData.setOnClickListener {
|
||||
vm.reloadAvailability()
|
||||
}
|
||||
}
|
||||
|
||||
var searchKeyListener: KeyListener? = null
|
||||
@@ -499,9 +511,9 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
||||
private fun toggleFavorite() {
|
||||
val favs = vm.favorites.value ?: return
|
||||
val charger = vm.chargerSparse.value ?: return
|
||||
val isFav = favs.find { it.id == charger.id } != null
|
||||
if (isFav) {
|
||||
vm.deleteFavorite(charger)
|
||||
val fav = favs.find { it.charger.id == charger.id }
|
||||
if (fav != null) {
|
||||
vm.deleteFavorite(fav.favorite)
|
||||
} else {
|
||||
vm.insertFavorite(charger)
|
||||
}
|
||||
@@ -511,7 +523,8 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
||||
highlight = true,
|
||||
fault = charger.faultReport != null,
|
||||
multi = charger.isMulti(vm.filteredConnectors.value),
|
||||
fav = !isFav
|
||||
fav = fav == null,
|
||||
mini = vm.useMiniMarkers.value == true
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -578,38 +591,14 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
||||
updateMap(chargepoints)
|
||||
}
|
||||
})
|
||||
vm.useMiniMarkers.observe(viewLifecycleOwner) {
|
||||
vm.chargepoints.value?.data?.let { updateMap(it) }
|
||||
}
|
||||
vm.favorites.observe(viewLifecycleOwner, Observer {
|
||||
updateFavoriteToggle()
|
||||
})
|
||||
vm.searchResult.observe(viewLifecycleOwner, Observer { place ->
|
||||
val map = this.map ?: return@Observer
|
||||
searchResultMarker?.remove()
|
||||
searchResultMarker = null
|
||||
|
||||
if (place != null) {
|
||||
// disable location following when search result is shown
|
||||
vm.myLocationEnabled.value = false
|
||||
if (place.viewport != null) {
|
||||
map.animateCamera(map.cameraUpdateFactory.newLatLngBounds(place.viewport, 0))
|
||||
} else {
|
||||
map.animateCamera(map.cameraUpdateFactory.newLatLngZoom(place.latLng, 12f))
|
||||
}
|
||||
|
||||
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)
|
||||
)
|
||||
} else {
|
||||
binding.search.setText("")
|
||||
}
|
||||
|
||||
updateBackPressedCallback()
|
||||
displaySearchResult(place, moveCamera = true)
|
||||
})
|
||||
vm.layersMenuOpen.observe(viewLifecycleOwner, Observer { open ->
|
||||
binding.fabLayers.visibility = if (open) View.INVISIBLE else View.VISIBLE
|
||||
@@ -626,6 +615,40 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
||||
updateBackPressedCallback()
|
||||
}
|
||||
|
||||
private fun displaySearchResult(place: PlaceWithBounds?, moveCamera: Boolean) {
|
||||
val map = this.map ?: return
|
||||
searchResultMarker?.remove()
|
||||
searchResultMarker = null
|
||||
|
||||
if (place != null) {
|
||||
// disable location following when search result is shown
|
||||
if (moveCamera) {
|
||||
vm.myLocationEnabled.value = false
|
||||
if (place.viewport != null) {
|
||||
map.animateCamera(map.cameraUpdateFactory.newLatLngBounds(place.viewport, 0))
|
||||
} else {
|
||||
map.animateCamera(map.cameraUpdateFactory.newLatLngZoom(place.latLng, 12f))
|
||||
}
|
||||
}
|
||||
|
||||
if (searchResultIcon == null) {
|
||||
searchResultIcon =
|
||||
map.bitmapDescriptorFactory.fromResource(R.drawable.ic_map_marker)
|
||||
}
|
||||
searchResultMarker = map.addMarker(
|
||||
MarkerOptions()
|
||||
.z(placeSearchZ)
|
||||
.position(place.latLng)
|
||||
.icon(searchResultIcon)
|
||||
.anchor(0.5f, 1f)
|
||||
)
|
||||
} else {
|
||||
binding.search.setText("")
|
||||
}
|
||||
|
||||
updateBackPressedCallback()
|
||||
}
|
||||
|
||||
private fun updateBackPressedCallback() {
|
||||
backPressedCallback.isEnabled =
|
||||
vm.bottomSheetState.value != null && vm.bottomSheetState.value != STATE_HIDDEN
|
||||
@@ -642,7 +665,8 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
||||
highlight = false,
|
||||
fault = c.faultReport != null,
|
||||
multi = c.isMulti(vm.filteredConnectors.value),
|
||||
fav = c.id in vm.favorites.value?.map { it.id } ?: emptyList()
|
||||
fav = c.id in vm.favorites.value?.map { it.charger.id } ?: emptyList(),
|
||||
mini = vm.useMiniMarkers.value == true
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -657,10 +681,11 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
||||
highlight = true,
|
||||
fault = charger.faultReport != null,
|
||||
multi = charger.isMulti(vm.filteredConnectors.value),
|
||||
fav = charger.id in vm.favorites.value?.map { it.id } ?: emptyList()
|
||||
fav = charger.id in vm.favorites.value?.map { it.charger.id } ?: emptyList(),
|
||||
mini = vm.useMiniMarkers.value == true
|
||||
)
|
||||
)
|
||||
animator.animateMarkerBounce(marker)
|
||||
animator.animateMarkerBounce(marker, vm.useMiniMarkers.value == true)
|
||||
|
||||
// un-highlight all other markers
|
||||
markers.forEach { (m, c) ->
|
||||
@@ -671,7 +696,8 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
||||
highlight = false,
|
||||
fault = c.faultReport != null,
|
||||
multi = c.isMulti(vm.filteredConnectors.value),
|
||||
fav = c.id in vm.favorites.value?.map { it.id } ?: emptyList()
|
||||
fav = c.id in vm.favorites.value?.map { it.charger.id } ?: emptyList(),
|
||||
mini = vm.useMiniMarkers.value == true
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -681,7 +707,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
||||
private fun updateFavoriteToggle() {
|
||||
val favs = vm.favorites.value ?: return
|
||||
val charger = vm.chargerSparse.value ?: return
|
||||
if (favs.find { it.id == charger.id } != null) {
|
||||
if (favs.find { it.charger.id == charger.id } != null) {
|
||||
favToggle.setIcon(R.drawable.ic_fav)
|
||||
} else {
|
||||
favToggle.setIcon(R.drawable.ic_fav_no)
|
||||
@@ -711,6 +737,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
||||
}
|
||||
}
|
||||
.withStartPosition(position)
|
||||
.withHiddenStatusBar(false)
|
||||
.show()
|
||||
|
||||
}
|
||||
@@ -783,7 +810,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
||||
it.name
|
||||
}
|
||||
}
|
||||
AlertDialog.Builder(activity)
|
||||
MaterialAlertDialogBuilder(activity)
|
||||
.setTitle(R.string.charge_cards)
|
||||
.setItems(names.toTypedArray()) { _, i ->
|
||||
val card = data[i]
|
||||
@@ -793,9 +820,10 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
||||
|
||||
override fun onMapReady(map: AnyMap) {
|
||||
this.map = map
|
||||
chargerIconGenerator = ChargerIconGenerator(requireContext(), map.bitmapDescriptorFactory)
|
||||
val context = this.context ?: return
|
||||
chargerIconGenerator = ChargerIconGenerator(context, map.bitmapDescriptorFactory)
|
||||
|
||||
if (BuildConfig.FLAVOR == "google" && mapFragment!!.priority[0] == MapFragment.GOOGLE) {
|
||||
if (BuildConfig.FLAVOR.contains("google") && mapFragment!!.priority[0] == MapFragment.GOOGLE) {
|
||||
// Google Maps: icons can be generated in background thread
|
||||
lifecycleScope.launch {
|
||||
withContext(Dispatchers.IO) {
|
||||
@@ -811,17 +839,25 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
||||
|
||||
animator = MarkerAnimator(chargerIconGenerator)
|
||||
map.uiSettings.setTiltGesturesEnabled(false)
|
||||
map.uiSettings.setRotateGesturesEnabled(prefs.mapRotateGesturesEnabled)
|
||||
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)
|
||||
vm.reloadChargepoints()
|
||||
}
|
||||
map.setOnCameraMoveListener {
|
||||
vm.mapPosition.value = MapPosition(
|
||||
map.projection.visibleRegion.latLngBounds, map.cameraPosition.zoom
|
||||
)
|
||||
}
|
||||
vm.mapPosition.observe(viewLifecycleOwner) {
|
||||
binding.scaleView.update(map.cameraPosition.zoom, map.cameraPosition.target.latitude)
|
||||
}
|
||||
|
||||
map.setOnCameraMoveStartedListener { reason ->
|
||||
if (reason == AnyMap.OnCameraMoveStartedListener.REASON_GESTURE) {
|
||||
if (vm.myLocationEnabled.value == true) {
|
||||
@@ -864,7 +900,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
||||
map.setTrafficEnabled(vm.mapTrafficEnabled.value ?: false)
|
||||
|
||||
// set padding so that compass is not obstructed by toolbar
|
||||
map.setPadding(0, binding.toolbarContainer.height, 0, 0)
|
||||
map.setPadding(0, mapTopPadding, 0, 0)
|
||||
|
||||
val mode = resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK
|
||||
map.setMapStyle(
|
||||
@@ -933,7 +969,8 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
||||
lifecycleScope.launch {
|
||||
val address = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
Geocoder(requireContext()).getFromLocationName(locationName, 1).getOrNull(0)
|
||||
Geocoder(requireContext()).getFromLocationName(locationName, 1)
|
||||
?.getOrNull(0)
|
||||
} catch (e: IOException) {
|
||||
null
|
||||
}
|
||||
@@ -955,7 +992,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
||||
}
|
||||
}
|
||||
}
|
||||
if (context?.checkAnyLocationPermission() ?: false) {
|
||||
if (context.checkAnyLocationPermission()) {
|
||||
enableLocation(!positionSet, false)
|
||||
positionSet = true
|
||||
}
|
||||
@@ -972,7 +1009,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
||||
|
||||
if (vm.searchResult.value != null) {
|
||||
// show search result (after configuration change)
|
||||
vm.searchResult.postValue(vm.searchResult.value)
|
||||
displaySearchResult(vm.searchResult.value, moveCamera = !positionSet)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -983,16 +1020,14 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
||||
map.uiSettings.setMyLocationButtonEnabled(false)
|
||||
if (moveTo) {
|
||||
vm.myLocationEnabled.value = true
|
||||
if (locationClient.isConnected) {
|
||||
moveToLastLocation(map, animate)
|
||||
requestLocationUpdates()
|
||||
}
|
||||
moveToLastLocation(map, animate)
|
||||
requestLocationUpdates()
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresPermission(anyOf = [ACCESS_FINE_LOCATION, ACCESS_COARSE_LOCATION])
|
||||
private fun moveToLastLocation(map: AnyMap, animate: Boolean) {
|
||||
val location = LocationServices.FusedLocationApi.getLastLocation(locationClient)
|
||||
val location = locationEngine.getLastKnownLocation()
|
||||
if (location != null) {
|
||||
val latLng = LatLng(location.latitude, location.longitude)
|
||||
vm.location.value = latLng
|
||||
@@ -1017,15 +1052,18 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
||||
|
||||
// update icons of existing markers (connector filter may have changed)
|
||||
for ((marker, charger) in markers) {
|
||||
val highlight = charger.id == vm.chargerSparse.value?.id
|
||||
marker.setIcon(
|
||||
chargerIconGenerator.getBitmapDescriptor(
|
||||
getMarkerTint(charger, vm.filteredConnectors.value),
|
||||
highlight = charger == vm.chargerSparse.value,
|
||||
highlight = highlight,
|
||||
fault = charger.faultReport != null,
|
||||
multi = charger.isMulti(vm.filteredConnectors.value),
|
||||
fav = charger.id in vm.favorites.value?.map { it.id } ?: emptyList()
|
||||
fav = charger.id in vm.favorites.value?.map { it.charger.id } ?: emptyList(),
|
||||
mini = vm.useMiniMarkers.value == true
|
||||
)
|
||||
)
|
||||
marker.setAnchor(0.5f, if (vm.useMiniMarkers.value == true) 0.5f else 1f)
|
||||
}
|
||||
|
||||
if (chargers.toSet() != markers.values) {
|
||||
@@ -1038,11 +1076,15 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
||||
// animate marker if it is visible, otherwise remove immediately
|
||||
if (bounds.contains(marker.position)) {
|
||||
val tint = getMarkerTint(charger, vm.filteredConnectors.value)
|
||||
val highlight = charger == vm.chargerSparse.value
|
||||
val highlight = charger.id == vm.chargerSparse.value?.id
|
||||
val fault = charger.faultReport != null
|
||||
val multi = charger.isMulti(vm.filteredConnectors.value)
|
||||
val fav = charger.id in vm.favorites.value?.map { it.id } ?: emptyList()
|
||||
animator.animateMarkerDisappear(marker, tint, highlight, fault, multi, fav)
|
||||
val fav =
|
||||
charger.id in vm.favorites.value?.map { it.charger.id } ?: emptyList()
|
||||
animator.animateMarkerDisappear(
|
||||
marker, tint, highlight, fault, multi, fav,
|
||||
vm.useMiniMarkers.value == true
|
||||
)
|
||||
} else {
|
||||
animator.deleteMarker(marker)
|
||||
}
|
||||
@@ -1054,13 +1096,14 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
||||
for (charger in chargers) {
|
||||
if (!map1.contains(charger.id)) {
|
||||
val tint = getMarkerTint(charger, vm.filteredConnectors.value)
|
||||
val highlight = charger == vm.chargerSparse.value
|
||||
val highlight = charger.id == vm.chargerSparse.value?.id
|
||||
val fault = charger.faultReport != null
|
||||
val multi = charger.isMulti(vm.filteredConnectors.value)
|
||||
val fav = charger.id in vm.favorites.value?.map { it.id } ?: emptyList()
|
||||
val fav = charger.id in vm.favorites.value?.map { it.charger.id } ?: emptyList()
|
||||
val marker = map.addMarker(
|
||||
MarkerOptions()
|
||||
.position(LatLng(charger.coordinates.lat, charger.coordinates.lng))
|
||||
.z(chargerZ)
|
||||
.icon(
|
||||
chargerIconGenerator.getBitmapDescriptor(
|
||||
tint,
|
||||
@@ -1069,12 +1112,16 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
||||
highlight,
|
||||
fault,
|
||||
multi,
|
||||
fav
|
||||
fav,
|
||||
vm.useMiniMarkers.value == true
|
||||
)
|
||||
)
|
||||
.anchor(0.5f, 1f)
|
||||
.anchor(0.5f, if (vm.useMiniMarkers.value == true) 0.5f else 1f)
|
||||
)
|
||||
animator.animateMarkerAppear(
|
||||
marker, tint, highlight, fault, multi, fav,
|
||||
vm.useMiniMarkers.value == true
|
||||
)
|
||||
animator.animateMarkerAppear(marker, tint, highlight, fault, multi, fav)
|
||||
markers[marker] = charger
|
||||
}
|
||||
}
|
||||
@@ -1084,6 +1131,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
||||
map.addMarker(
|
||||
MarkerOptions()
|
||||
.position(LatLng(cluster.coordinates.lat, cluster.coordinates.lng))
|
||||
.z(clusterZ)
|
||||
.icon(
|
||||
map.bitmapDescriptorFactory.fromBitmap(
|
||||
clusterIconGenerator.makeIcon(
|
||||
@@ -1096,23 +1144,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
override fun onRequestPermissionsResult(
|
||||
requestCode: Int,
|
||||
permissions: Array<out String>,
|
||||
grantResults: IntArray
|
||||
) {
|
||||
when (requestCode) {
|
||||
REQUEST_LOCATION_PERMISSION -> {
|
||||
if ((grantResults.isNotEmpty() && grantResults.any { it == PackageManager.PERMISSION_GRANTED })) {
|
||||
enableLocation(moveTo = true, animate = true)
|
||||
}
|
||||
}
|
||||
else -> super.onRequestPermissionsResult(requestCode, permissions, grantResults)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
|
||||
override fun onCreateMenu(menu: Menu, inflater: MenuInflater) {
|
||||
inflater.inflate(R.menu.map, menu)
|
||||
|
||||
val filterItem = menu.findItem(R.id.menu_filter)
|
||||
@@ -1227,6 +1259,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
||||
}
|
||||
})
|
||||
})
|
||||
popup.setTouchModal(false)
|
||||
popup.show()
|
||||
}
|
||||
|
||||
@@ -1250,42 +1283,35 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
override fun getRootView(): View {
|
||||
return binding.root
|
||||
}
|
||||
|
||||
override fun onConnected() {
|
||||
val map = this.map ?: return
|
||||
val context = this.context ?: return
|
||||
if (vm.myLocationEnabled.value == true) {
|
||||
if (context.checkAnyLocationPermission()) {
|
||||
moveToLastLocation(map, false)
|
||||
requestLocationUpdates()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresPermission(ACCESS_FINE_LOCATION)
|
||||
@RequiresPermission(anyOf = [ACCESS_FINE_LOCATION, ACCESS_COARSE_LOCATION])
|
||||
private fun requestLocationUpdates() {
|
||||
val request: LocationRequest = LocationRequest.create()
|
||||
.setPriority(LocationRequest.PRIORITY_HIGH_ACCURACY)
|
||||
.setInterval(5000)
|
||||
LocationServices.FusedLocationApi.requestLocationUpdates(locationClient, request, this)
|
||||
locationEngine.requestLocationUpdates(
|
||||
Priority.HIGH_ACCURACY,
|
||||
5000,
|
||||
locationListener
|
||||
)
|
||||
requestingLocationUpdates = true
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
private fun removeLocationUpdates() {
|
||||
if (locationClient.isConnected) {
|
||||
LocationServices.FusedLocationApi.removeLocationUpdates(locationClient, this)
|
||||
if (context?.checkAnyLocationPermission() == true) {
|
||||
locationEngine.removeUpdates(locationListener)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onConnectionSuspended() {
|
||||
}
|
||||
|
||||
override fun onLocationChanged(location: Location?) {
|
||||
val map = this.map ?: return
|
||||
if (location == null || vm.myLocationEnabled.value == false) return
|
||||
private val locationListener = LocationListenerCompat { location ->
|
||||
val map = this.map ?: return@LocationListenerCompat
|
||||
if (vm.myLocationEnabled.value == false) return@LocationListenerCompat
|
||||
|
||||
val latLng = LatLng(location.latitude, location.longitude)
|
||||
val oldLoc = vm.location.value
|
||||
@@ -1310,8 +1336,5 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
if (locationClient.isConnected) {
|
||||
locationClient.disconnect()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,20 +4,16 @@ 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 androidx.core.widget.doAfterTextChanged
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import net.vonforst.evmap.R
|
||||
import net.vonforst.evmap.adapter.DataBindingAdapter
|
||||
import net.vonforst.evmap.adapter.Equatable
|
||||
import net.vonforst.evmap.databinding.DialogMultiSelectBinding
|
||||
import net.vonforst.evmap.ui.MaterialDialogFragment
|
||||
import java.util.*
|
||||
import kotlin.collections.HashMap
|
||||
import kotlin.collections.HashSet
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
class MultiSelectDialog : AppCompatDialogFragment() {
|
||||
class MultiSelectDialog : MaterialDialogFragment() {
|
||||
companion object {
|
||||
fun getInstance(
|
||||
title: String,
|
||||
@@ -43,7 +39,7 @@ class MultiSelectDialog : AppCompatDialogFragment() {
|
||||
private lateinit var items: List<MultiSelectItem>
|
||||
private lateinit var binding: DialogMultiSelectBinding
|
||||
|
||||
override fun onCreateView(
|
||||
override fun createView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
@@ -54,19 +50,10 @@ class MultiSelectDialog : AppCompatDialogFragment() {
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
|
||||
val density = resources.displayMetrics.density
|
||||
val width = resources.displayMetrics.widthPixels
|
||||
val maxWidth = (500 * density).roundToInt()
|
||||
|
||||
// dialog with 95% screen height
|
||||
dialog?.window?.setLayout(
|
||||
if (width < maxWidth) WindowManager.LayoutParams.MATCH_PARENT else maxWidth,
|
||||
(resources.displayMetrics.heightPixels * 0.95).toInt()
|
||||
)
|
||||
setFullSize(maxWidthDp = 500)
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
override fun initView(view: View, savedInstanceState: Bundle?) {
|
||||
val args = requireArguments()
|
||||
val data = args.getSerializable("data") as HashMap<String, String>
|
||||
val selected = args.getSerializable("selected") as HashSet<String>
|
||||
|
||||
@@ -8,6 +8,7 @@ import androidx.navigation.ui.setupWithNavController
|
||||
import androidx.preference.Preference
|
||||
import androidx.preference.PreferenceFragmentCompat
|
||||
import com.google.android.material.color.MaterialColors
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import com.google.android.material.transition.MaterialFadeThrough
|
||||
import com.google.android.material.transition.MaterialSharedAxis
|
||||
import com.mikepenz.aboutlibraries.LibsBuilder
|
||||
@@ -42,8 +43,16 @@ class AboutFragment : PreferenceFragmentCompat() {
|
||||
findPreference<Preference>("version")?.summary = BuildConfig.VERSION_NAME
|
||||
}
|
||||
|
||||
override fun onPreferenceTreeClick(preference: Preference?): Boolean {
|
||||
return when (preference?.key) {
|
||||
override fun onPreferenceTreeClick(preference: Preference): Boolean {
|
||||
return when (preference.key) {
|
||||
"contributors" -> {
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle(R.string.about_contributors)
|
||||
.setMessage(getString(R.string.about_contributors_text) + "\n\n" + getString(R.string.about_contributors_list))
|
||||
.setPositiveButton(R.string.ok) { _, _ -> }
|
||||
.show()
|
||||
true
|
||||
}
|
||||
"github_link" -> {
|
||||
(activity as? MapsActivity)?.openUrl(getString(R.string.github_link))
|
||||
true
|
||||
|
||||
@@ -48,12 +48,11 @@ abstract class BaseSettingsFragment : PreferenceFragmentCompat(),
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
preferenceManager.sharedPreferences.registerOnSharedPreferenceChangeListener(this)
|
||||
preferenceManager.sharedPreferences?.registerOnSharedPreferenceChangeListener(this)
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
preferenceManager.sharedPreferences
|
||||
.unregisterOnSharedPreferenceChangeListener(this)
|
||||
preferenceManager.sharedPreferences?.unregisterOnSharedPreferenceChangeListener(this)
|
||||
super.onPause()
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,8 @@ class ChargepriceSettingsFragment : BaseSettingsFragment() {
|
||||
viewModelFactory {
|
||||
SettingsViewModel(
|
||||
requireActivity().application,
|
||||
getString(R.string.chargeprice_key)
|
||||
getString(R.string.chargeprice_key),
|
||||
getString(R.string.chargeprice_api_url)
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -69,7 +70,8 @@ class ChargepriceSettingsFragment : BaseSettingsFragment() {
|
||||
R.plurals.chargeprice_some_tariffs_selected,
|
||||
n,
|
||||
n
|
||||
) + "\n" + getString(R.string.pref_my_tariffs_summary)
|
||||
) + "\n" + requireContext().resources
|
||||
.getQuantityString(R.plurals.pref_my_tariffs_summary, n)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,8 @@ class DataSettingsFragment : BaseSettingsFragment() {
|
||||
viewModelFactory {
|
||||
SettingsViewModel(
|
||||
requireActivity().application,
|
||||
getString(R.string.chargeprice_key)
|
||||
getString(R.string.chargeprice_key),
|
||||
getString(R.string.chargeprice_api_url)
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -47,8 +48,8 @@ class DataSettingsFragment : BaseSettingsFragment() {
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPreferenceTreeClick(preference: Preference?): Boolean {
|
||||
return when (preference?.key) {
|
||||
override fun onPreferenceTreeClick(preference: Preference): Boolean {
|
||||
return when (preference.key) {
|
||||
"search_delete_recent" -> {
|
||||
Snackbar.make(
|
||||
requireView(),
|
||||
|
||||
@@ -2,24 +2,33 @@ package net.vonforst.evmap.fragment.preference
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import android.os.Bundle
|
||||
import androidx.preference.ListPreference
|
||||
import net.vonforst.evmap.R
|
||||
import net.vonforst.evmap.ui.getAppLocale
|
||||
import net.vonforst.evmap.ui.updateAppLocale
|
||||
import net.vonforst.evmap.ui.updateNightMode
|
||||
|
||||
class UiSettingsFragment : BaseSettingsFragment() {
|
||||
override val isTopLevel = false
|
||||
lateinit var langPref: ListPreference
|
||||
|
||||
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
||||
setPreferencesFromResource(R.xml.settings_ui, rootKey)
|
||||
|
||||
langPref = findPreference("language")!!
|
||||
langPref.setOnPreferenceChangeListener { _, newValue ->
|
||||
updateAppLocale(newValue as String)
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
langPref.value = getAppLocale()
|
||||
}
|
||||
|
||||
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String) {
|
||||
when (key) {
|
||||
"language" -> {
|
||||
activity?.let {
|
||||
it.finish();
|
||||
it.startActivity(it.intent);
|
||||
}
|
||||
}
|
||||
"darkmode" -> {
|
||||
updateNightMode(prefs)
|
||||
}
|
||||
|
||||
@@ -4,18 +4,16 @@ 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 androidx.navigation.fragment.findNavController
|
||||
import net.vonforst.evmap.R
|
||||
import net.vonforst.evmap.databinding.DialogOpensourceDonationsBinding
|
||||
import net.vonforst.evmap.storage.PreferenceDataSource
|
||||
import kotlin.math.roundToInt
|
||||
import net.vonforst.evmap.ui.MaterialDialogFragment
|
||||
|
||||
class OpensourceDonationsDialogFramgent : AppCompatDialogFragment() {
|
||||
class OpensourceDonationsDialogFragment : MaterialDialogFragment() {
|
||||
private lateinit var binding: DialogOpensourceDonationsBinding
|
||||
|
||||
override fun onCreateView(
|
||||
override fun createView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
@@ -24,9 +22,7 @@ class OpensourceDonationsDialogFramgent : AppCompatDialogFragment() {
|
||||
return binding.root
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
override fun initView(view: View, savedInstanceState: Bundle?) {
|
||||
val prefs = PreferenceDataSource(requireContext())
|
||||
binding.btnOk.setOnClickListener {
|
||||
prefs.opensourceDonationsDialogShown = true
|
||||
@@ -44,14 +40,5 @@ class OpensourceDonationsDialogFramgent : AppCompatDialogFragment() {
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
|
||||
val density = resources.displayMetrics.density
|
||||
val width = resources.displayMetrics.widthPixels
|
||||
val maxWidth = (500 * density).roundToInt()
|
||||
|
||||
dialog?.window?.setLayout(
|
||||
if (width < maxWidth) WindowManager.LayoutParams.MATCH_PARENT else maxWidth,
|
||||
WindowManager.LayoutParams.WRAP_CONTENT
|
||||
)
|
||||
}
|
||||
}
|
||||
271
app/src/main/java/net/vonforst/evmap/location/FusionEngine.kt
Normal file
271
app/src/main/java/net/vonforst/evmap/location/FusionEngine.kt
Normal file
@@ -0,0 +1,271 @@
|
||||
package net.vonforst.evmap.location
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.location.Location
|
||||
import android.location.LocationManager
|
||||
import android.os.Build
|
||||
import android.os.SystemClock
|
||||
import android.util.Log
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.annotation.RequiresPermission
|
||||
import androidx.core.location.LocationListenerCompat
|
||||
|
||||
/**
|
||||
* Location engine that fuses GPS and network locations.
|
||||
*
|
||||
* Simplified version of
|
||||
* https://github.com/lostzen/lost/blob/master/lost/src/main/java/com/mapzen/android/lost/internal/FusionEngine.java
|
||||
*/
|
||||
class FusionEngine(context: Context) : LocationEngine(context),
|
||||
LocationListenerCompat {
|
||||
|
||||
/**
|
||||
* Location updates more than 60 seconds old are considered stale.
|
||||
*/
|
||||
private val RECENT_UPDATE_THRESHOLD_IN_MILLIS = (60 * 1000).toLong()
|
||||
private val RECENT_UPDATE_THRESHOLD_IN_NANOS = RECENT_UPDATE_THRESHOLD_IN_MILLIS * 1000000
|
||||
private val TAG = FusionEngine::class.java.simpleName
|
||||
|
||||
private val locationManager =
|
||||
context.getSystemService(Context.LOCATION_SERVICE) as LocationManager
|
||||
private var gpsLocation: Location? = null
|
||||
private var networkLocation: Location? = null
|
||||
|
||||
private val supportsSystemFusedProvider: Boolean
|
||||
get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && locationManager.allProviders.contains(
|
||||
LocationManager.FUSED_PROVIDER
|
||||
)
|
||||
|
||||
@RequiresPermission(anyOf = [Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION])
|
||||
override fun getLastKnownLocation(): Location? {
|
||||
if (supportsSystemFusedProvider) {
|
||||
try {
|
||||
return locationManager.getLastKnownLocation(LocationManager.FUSED_PROVIDER)
|
||||
} catch (e: SecurityException) {
|
||||
Log.e(TAG, "Permissions not granted for fused provider", e)
|
||||
}
|
||||
}
|
||||
|
||||
val minTime = SystemClock.elapsedRealtimeNanos() - RECENT_UPDATE_THRESHOLD_IN_NANOS
|
||||
var bestLocation: Location? = null
|
||||
var bestAccuracy = Float.MAX_VALUE
|
||||
var bestTime = Long.MIN_VALUE
|
||||
for (provider in locationManager.allProviders) {
|
||||
try {
|
||||
val location = locationManager.getLastKnownLocation(provider)
|
||||
if (location != null) {
|
||||
val accuracy = location.accuracy
|
||||
val time = location.elapsedRealtimeNanos
|
||||
if (time > minTime && accuracy < bestAccuracy) {
|
||||
bestLocation = location
|
||||
bestAccuracy = accuracy
|
||||
bestTime = time
|
||||
} else if (time < minTime && bestAccuracy == Float.MAX_VALUE && time > bestTime) {
|
||||
bestLocation = location
|
||||
bestTime = time
|
||||
}
|
||||
}
|
||||
} catch (e: SecurityException) {
|
||||
Log.e(TAG, "Permissions not granted for provider: $provider", e)
|
||||
}
|
||||
}
|
||||
return bestLocation
|
||||
}
|
||||
|
||||
@RequiresPermission(anyOf = [Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION])
|
||||
override fun enable() {
|
||||
var networkInterval = Long.MAX_VALUE
|
||||
var gpsInterval = Long.MAX_VALUE
|
||||
var passiveInterval = Long.MAX_VALUE
|
||||
for ((priority, interval) in requests) {
|
||||
when (priority) {
|
||||
Priority.HIGH_ACCURACY -> {
|
||||
if (interval < gpsInterval) {
|
||||
gpsInterval = interval
|
||||
}
|
||||
if (interval < networkInterval) {
|
||||
networkInterval = interval
|
||||
}
|
||||
}
|
||||
Priority.BALANCED_POWER_ACCURACY, Priority.LOW_POWER -> if (interval < networkInterval) {
|
||||
networkInterval = interval
|
||||
}
|
||||
Priority.NO_POWER -> if (interval < passiveInterval) {
|
||||
passiveInterval = interval
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (supportsSystemFusedProvider && gpsInterval < Long.MAX_VALUE) {
|
||||
try {
|
||||
enableFused(gpsInterval)
|
||||
checkLastKnownFused()
|
||||
return
|
||||
} catch (e: SecurityException) {
|
||||
Log.e(TAG, "Permissions not granted for fused provider", e)
|
||||
}
|
||||
}
|
||||
|
||||
var checkGps = false
|
||||
if (gpsInterval < Long.MAX_VALUE) {
|
||||
enableGps(gpsInterval)
|
||||
checkGps = true
|
||||
}
|
||||
if (networkInterval < Long.MAX_VALUE) {
|
||||
enableNetwork(networkInterval)
|
||||
if (checkGps) {
|
||||
val lastGps = locationManager.getLastKnownLocation(LocationManager.GPS_PROVIDER)
|
||||
val lastNetwork =
|
||||
locationManager.getLastKnownLocation(LocationManager.NETWORK_PROVIDER)
|
||||
if (lastGps != null && lastNetwork != null) {
|
||||
val useGps = lastGps.isBetterThan(lastNetwork)
|
||||
if (useGps) {
|
||||
checkLastKnownGps()
|
||||
} else {
|
||||
checkLastKnownNetwork()
|
||||
}
|
||||
} else if (lastGps != null) {
|
||||
checkLastKnownGps()
|
||||
} else {
|
||||
checkLastKnownNetwork()
|
||||
}
|
||||
} else {
|
||||
checkLastKnownNetwork()
|
||||
}
|
||||
}
|
||||
if (passiveInterval < Long.MAX_VALUE) {
|
||||
enablePassive(passiveInterval)
|
||||
checkLastKnownPassive()
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresPermission(anyOf = [Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION])
|
||||
override fun disable() {
|
||||
locationManager.removeUpdates(this)
|
||||
}
|
||||
|
||||
@RequiresPermission(anyOf = [Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION])
|
||||
private fun enableGps(interval: Long) {
|
||||
try {
|
||||
locationManager.requestLocationUpdates(
|
||||
LocationManager.GPS_PROVIDER,
|
||||
interval,
|
||||
0f,
|
||||
this,
|
||||
looper
|
||||
)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
Log.e(TAG, "Unable to register for GPS updates.", e)
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresPermission(anyOf = [Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION])
|
||||
private fun enableNetwork(interval: Long) {
|
||||
try {
|
||||
locationManager.requestLocationUpdates(
|
||||
LocationManager.NETWORK_PROVIDER,
|
||||
interval,
|
||||
0f,
|
||||
this,
|
||||
looper
|
||||
)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
Log.e(TAG, "Unable to register for network updates.", e)
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresPermission(anyOf = [Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION])
|
||||
private fun enablePassive(interval: Long) {
|
||||
try {
|
||||
locationManager.requestLocationUpdates(
|
||||
LocationManager.PASSIVE_PROVIDER,
|
||||
interval,
|
||||
0f,
|
||||
this,
|
||||
looper
|
||||
)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
Log.e(TAG, "Unable to register for passive updates.", e)
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.S)
|
||||
@RequiresPermission(anyOf = [Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION])
|
||||
private fun enableFused(interval: Long) {
|
||||
try {
|
||||
locationManager.requestLocationUpdates(
|
||||
LocationManager.FUSED_PROVIDER,
|
||||
interval,
|
||||
0f,
|
||||
this,
|
||||
looper
|
||||
)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
Log.e(TAG, "Unable to register for passive updates.", e)
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresPermission(anyOf = [Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION])
|
||||
private fun checkLastKnownGps() {
|
||||
checkLastKnownAndNotify(LocationManager.GPS_PROVIDER)
|
||||
}
|
||||
|
||||
@RequiresPermission(anyOf = [Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION])
|
||||
private fun checkLastKnownNetwork() {
|
||||
checkLastKnownAndNotify(LocationManager.NETWORK_PROVIDER)
|
||||
}
|
||||
|
||||
@RequiresPermission(anyOf = [Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION])
|
||||
private fun checkLastKnownPassive() {
|
||||
checkLastKnownAndNotify(LocationManager.PASSIVE_PROVIDER)
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.S)
|
||||
@RequiresPermission(anyOf = [Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION])
|
||||
private fun checkLastKnownFused() {
|
||||
checkLastKnownAndNotify(LocationManager.FUSED_PROVIDER)
|
||||
}
|
||||
|
||||
@RequiresPermission(anyOf = [Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION])
|
||||
private fun checkLastKnownAndNotify(provider: String) {
|
||||
val location = locationManager.getLastKnownLocation(provider)
|
||||
location?.let { onLocationChanged(it) }
|
||||
}
|
||||
|
||||
override fun onLocationChanged(location: Location) {
|
||||
if (LocationManager.FUSED_PROVIDER == location.provider) {
|
||||
requests.forEach { it.listener.onLocationChanged(location) }
|
||||
} else if (LocationManager.GPS_PROVIDER == location.provider) {
|
||||
gpsLocation = location
|
||||
if (gpsLocation.isBetterThan(networkLocation)) {
|
||||
requests.forEach { it.listener.onLocationChanged(location) }
|
||||
}
|
||||
} else if (LocationManager.NETWORK_PROVIDER == location.provider) {
|
||||
networkLocation = location
|
||||
if (networkLocation.isBetterThan(gpsLocation)) {
|
||||
requests.forEach { it.listener.onLocationChanged(location) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun Location?.isBetterThan(other: Location?): Boolean {
|
||||
if (this == null) {
|
||||
return false
|
||||
}
|
||||
if (other == null) {
|
||||
return true
|
||||
}
|
||||
if (this.elapsedRealtimeNanos
|
||||
> other.elapsedRealtimeNanos + RECENT_UPDATE_THRESHOLD_IN_NANOS
|
||||
) {
|
||||
return true
|
||||
}
|
||||
if (!this.hasAccuracy()) {
|
||||
return false
|
||||
}
|
||||
return if (!other.hasAccuracy()) {
|
||||
true
|
||||
} else this.accuracy < other.accuracy
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package net.vonforst.evmap.location
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.location.Location
|
||||
import android.location.LocationListener
|
||||
import android.os.Looper
|
||||
import androidx.annotation.RequiresPermission
|
||||
|
||||
/**
|
||||
* Base class for [com.mapzen.android.lost.internal.FusionEngine].
|
||||
*/
|
||||
abstract class LocationEngine(protected val context: Context) {
|
||||
protected val requests: MutableList<LocationRequest> = mutableListOf()
|
||||
|
||||
/**
|
||||
* Return most best recent location available.
|
||||
*/
|
||||
@RequiresPermission(anyOf = [Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION])
|
||||
abstract fun getLastKnownLocation(): Location?
|
||||
|
||||
/**
|
||||
* Enables the engine on receiving a valid location request.
|
||||
*
|
||||
* @param request Valid location request to enable.
|
||||
*/
|
||||
@RequiresPermission(anyOf = [Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION])
|
||||
fun requestLocationUpdates(priority: Priority, intervalMs: Long, listener: LocationListener) {
|
||||
requests.add(LocationRequest(priority, intervalMs, listener))
|
||||
enable()
|
||||
}
|
||||
|
||||
/**
|
||||
* Disables the engine when no requests remain, otherwise updates the engine's configuration.
|
||||
*
|
||||
* @param requests Valid location request to enable.
|
||||
*/
|
||||
@RequiresPermission(anyOf = [Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION])
|
||||
fun removeUpdates(listener: LocationListener) {
|
||||
this.requests.removeIf { it.listener == listener }
|
||||
disable()
|
||||
if (this.requests.isNotEmpty()) enable()
|
||||
}
|
||||
|
||||
@RequiresPermission(anyOf = [Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION])
|
||||
fun removeAllRequests() {
|
||||
requests.clear()
|
||||
disable()
|
||||
}
|
||||
|
||||
/**
|
||||
* Subclass should perform all operations required to enable the engine. (ex. Register for
|
||||
* location updates.)
|
||||
*/
|
||||
@RequiresPermission(anyOf = [Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION])
|
||||
protected abstract fun enable()
|
||||
|
||||
/**
|
||||
* Subclass should perform all operations required to disable the engine. (ex. Remove location
|
||||
* updates.)
|
||||
*/
|
||||
@RequiresPermission(anyOf = [Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION])
|
||||
protected abstract fun disable()
|
||||
protected val looper: Looper
|
||||
get() = context.mainLooper
|
||||
|
||||
interface Callback {
|
||||
fun reportLocation(location: Location)
|
||||
}
|
||||
}
|
||||
|
||||
data class LocationRequest(
|
||||
val priority: Priority,
|
||||
val intervalMs: Long,
|
||||
val listener: LocationListener
|
||||
)
|
||||
|
||||
enum class Priority {
|
||||
HIGH_ACCURACY,
|
||||
BALANCED_POWER_ACCURACY,
|
||||
LOW_POWER,
|
||||
NO_POWER
|
||||
}
|
||||
@@ -24,6 +24,36 @@ import kotlin.math.floor
|
||||
|
||||
sealed class ChargepointListItem
|
||||
|
||||
|
||||
/**
|
||||
* A whole charging site (potentially with multiple chargepoints).
|
||||
*
|
||||
* @param id A unique number per charging site
|
||||
* @param dataSource The name of the data source
|
||||
* @param coordinates The latitude / longitude of this charge location
|
||||
* @param address The charge location address
|
||||
* @param chargepoints List of chargepoints at this location
|
||||
* @param network The charging network (Mobility Service Provider, MSP)
|
||||
* @param url A link to this charging site
|
||||
* @param editUrl A link to a website where this charging site can be edited
|
||||
* @param faultReport Set this if the charging site is reported to be out of service
|
||||
* @param verified For crowdsourced data sources, this means that the data has been verified
|
||||
* by an independent person
|
||||
* @param barrierFree Whether this charge location can be used without prior registration
|
||||
* @param operator The operator of this charge location (Charge Point Operator, CPO)
|
||||
* @param generalInformation General information about this charging site that does not fit anywhere else
|
||||
* @param amenities Description of amenities available at or near the charging site (toilets, food, accommodation, landmarks, etc.)
|
||||
* @param locationDescription Directions on how to find the charger (e.g. "In the parking garage on level 5")
|
||||
* @param photos List of photos of this charging site
|
||||
* @param chargecards List of charge cards accepted here
|
||||
* @param openinghours List of times when this charging site can be accessed / used
|
||||
* @param cost The cost for charging and/or parking
|
||||
* @param license How the data about this chargepoint is licensed
|
||||
* @param chargepriceData Additional data needed for the Chargeprice implementation
|
||||
* @param timeRetrieved Time when this information was retrieved from the data source
|
||||
* @param isDetailed Whether this data includes all available details (for many data sources,
|
||||
* API calls that return a list may only give a compact representation)
|
||||
*/
|
||||
@Entity(primaryKeys = ["id", "dataSource"])
|
||||
@Parcelize
|
||||
data class ChargeLocation(
|
||||
@@ -31,7 +61,7 @@ data class ChargeLocation(
|
||||
val dataSource: String,
|
||||
val name: String,
|
||||
@Embedded val coordinates: Coordinate,
|
||||
@Embedded val address: Address,
|
||||
@Embedded val address: Address?,
|
||||
val chargepoints: List<Chargepoint>,
|
||||
val network: String?,
|
||||
val url: String,
|
||||
@@ -49,12 +79,14 @@ data class ChargeLocation(
|
||||
@Embedded val openinghours: OpeningHours?,
|
||||
@Embedded val cost: Cost?,
|
||||
val license: String?,
|
||||
@Embedded(prefix = "chargeprice") val chargepriceData: ChargepriceData?
|
||||
@Embedded(prefix = "chargeprice") val chargepriceData: ChargepriceData?,
|
||||
val timeRetrieved: Instant,
|
||||
val isDetailed: Boolean
|
||||
) : ChargepointListItem(), Equatable, Parcelable {
|
||||
/**
|
||||
* maximum power available from this charger.
|
||||
*/
|
||||
val maxPower: Double
|
||||
val maxPower: Double?
|
||||
get() {
|
||||
return maxPower()
|
||||
}
|
||||
@@ -62,17 +94,20 @@ data class ChargeLocation(
|
||||
/**
|
||||
* Gets the maximum power available from certain connectors of this charger.
|
||||
*/
|
||||
fun maxPower(connectors: Set<String>? = null): Double {
|
||||
return chargepoints.filter { connectors?.contains(it.type) ?: true }
|
||||
.map { it.power }.maxOrNull() ?: 0.0
|
||||
fun maxPower(connectors: Set<String>? = null): Double? {
|
||||
return chargepoints
|
||||
.filter { connectors?.contains(it.type) ?: true }
|
||||
.mapNotNull { it.power }
|
||||
.maxOrNull()
|
||||
}
|
||||
|
||||
fun isMulti(filteredConnectors: Set<String>? = null): Boolean {
|
||||
var chargepoints = chargepointsMerged
|
||||
.filter { filteredConnectors?.contains(it.type) ?: true }
|
||||
if (maxPower(filteredConnectors) >= 43) {
|
||||
val chargepointMaxPower = maxPower(filteredConnectors)
|
||||
if (chargepointMaxPower != null && chargepointMaxPower >= 43) {
|
||||
// fast charger -> only count fast chargers
|
||||
chargepoints = chargepoints.filter { it.power >= 43 }
|
||||
chargepoints = chargepoints.filter {it.power != null && it.power >= 43 }
|
||||
}
|
||||
val connectors = chargepoints.map { it.type }.distinct().toSet()
|
||||
|
||||
@@ -132,9 +167,9 @@ data class Cost(
|
||||
fun getStatusText(ctx: Context, emoji: Boolean = false): CharSequence {
|
||||
if (freecharging != null && freeparking != null) {
|
||||
val charging =
|
||||
if (freecharging) ctx.getString(R.string.free) else ctx.getString(R.string.paid)
|
||||
if (freecharging) ctx.getString(R.string.charging_free) else ctx.getString(R.string.charging_paid)
|
||||
val parking =
|
||||
if (freeparking) ctx.getString(R.string.free) else ctx.getString(R.string.paid)
|
||||
if (freeparking) ctx.getString(R.string.parking_free) else ctx.getString(R.string.parking_paid)
|
||||
return if (emoji) {
|
||||
"⚡ $charging · \uD83C\uDD7F️ $parking"
|
||||
} else {
|
||||
@@ -142,7 +177,7 @@ data class Cost(
|
||||
}
|
||||
} else if (freecharging != null) {
|
||||
val charging =
|
||||
if (freecharging) ctx.getString(R.string.free) else ctx.getString(R.string.paid)
|
||||
if (freecharging) ctx.getString(R.string.charging_free) else ctx.getString(R.string.charging_paid)
|
||||
return if (emoji) {
|
||||
"⚡ $charging"
|
||||
} else {
|
||||
@@ -150,7 +185,7 @@ data class Cost(
|
||||
}
|
||||
} else if (freeparking != null) {
|
||||
val parking =
|
||||
if (freeparking) ctx.getString(R.string.free) else ctx.getString(R.string.paid)
|
||||
if (freeparking) ctx.getString(R.string.parking_free) else ctx.getString(R.string.parking_paid)
|
||||
return if (emoji) {
|
||||
"\uD83C\uDD7F $parking"
|
||||
} else {
|
||||
@@ -164,6 +199,18 @@ data class Cost(
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
fun getDetailText(): CharSequence? {
|
||||
return if (freecharging == null && freeparking == null) {
|
||||
if (descriptionShort != null && descriptionLong != descriptionShort) {
|
||||
descriptionLong
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} else {
|
||||
descriptionLong ?: descriptionShort
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Parcelize
|
||||
@@ -180,26 +227,63 @@ data class OpeningHours(
|
||||
if (twentyfourSeven) {
|
||||
return HtmlCompat.fromHtml(ctx.getString(R.string.open_247), 0)
|
||||
} else if (days != null) {
|
||||
val hours = days.getHoursForDate(LocalDate.now())
|
||||
?: return HtmlCompat.fromHtml(ctx.getString(R.string.closed), 0)
|
||||
val today = LocalDate.now()
|
||||
val hours = days.getHoursForDate(today)
|
||||
val nextDayHours = days.getHoursForDate(today.plusDays(1))
|
||||
val previousDayHours = days.getHoursForDate(today.minusDays(1))
|
||||
|
||||
val now = LocalTime.now()
|
||||
if (hours.start.isBefore(now) && hours.end.isAfter(now)) {
|
||||
val fmt = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT)
|
||||
|
||||
if (previousDayHours != null && previousDayHours.end.isBefore(previousDayHours.start) && previousDayHours.end.isAfter(
|
||||
now
|
||||
)
|
||||
) {
|
||||
// previous day has opening hours that go past midnight
|
||||
return HtmlCompat.fromHtml(
|
||||
ctx.getString(
|
||||
R.string.open_closesat,
|
||||
hours.end.toString()
|
||||
previousDayHours.end.format(fmt)
|
||||
), 0
|
||||
)
|
||||
} else if (hours.end.isBefore(now)) {
|
||||
return HtmlCompat.fromHtml(ctx.getString(R.string.closed), 0)
|
||||
} else {
|
||||
} else if (hours != null && hours.start.isBefore(hours.end)
|
||||
&& hours.start.isBefore(now) && hours.end.isAfter(now)
|
||||
) {
|
||||
// current day has opening hours that do not go past midnight
|
||||
return HtmlCompat.fromHtml(
|
||||
ctx.getString(
|
||||
R.string.open_closesat,
|
||||
hours.end.format(fmt)
|
||||
), 0
|
||||
)
|
||||
} else if (hours != null && hours.end.isBefore(hours.start)
|
||||
&& hours.start.isBefore(now)
|
||||
) {
|
||||
// current day has opening hours that go past midnight
|
||||
return HtmlCompat.fromHtml(
|
||||
ctx.getString(
|
||||
R.string.open_closesat,
|
||||
hours.end.format(fmt)
|
||||
), 0
|
||||
)
|
||||
} else if (hours != null && !hours.start.isBefore(now)) {
|
||||
// currently closed, will still open on this day
|
||||
return HtmlCompat.fromHtml(
|
||||
ctx.getString(
|
||||
R.string.closed_opensat,
|
||||
hours.start.toString()
|
||||
hours.start.format(fmt)
|
||||
), 0
|
||||
)
|
||||
} else if (nextDayHours != null) {
|
||||
// currently closed, will open next day
|
||||
return HtmlCompat.fromHtml(
|
||||
ctx.getString(
|
||||
R.string.closed_opensat,
|
||||
nextDayHours.start.format(fmt)
|
||||
), 0
|
||||
)
|
||||
} else {
|
||||
return HtmlCompat.fromHtml(ctx.getString(R.string.closed), 0)
|
||||
}
|
||||
} else {
|
||||
return ""
|
||||
@@ -294,11 +378,29 @@ data class Address(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One socket with a certain power, which may be available multiple times at a ChargeLocation.
|
||||
*/
|
||||
@Parcelize
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class Chargepoint(val type: String, val power: Double, val count: Int) : Equatable,
|
||||
Parcelable {
|
||||
fun formatPower(): String {
|
||||
data class Chargepoint(
|
||||
// The chargepoint type (use one of the constants in the companion object)
|
||||
val type: String,
|
||||
// Power in kW (or null if unknown)
|
||||
val power: Double?,
|
||||
// How many instances of this plug/socket are available?
|
||||
val count: Int,
|
||||
) : Equatable, Parcelable {
|
||||
fun hasKnownPower(): Boolean = power != null
|
||||
|
||||
/**
|
||||
* If chargepoint power is defined, format it into a string.
|
||||
* Otherwise, return null.
|
||||
*/
|
||||
fun formatPower(): String? {
|
||||
if (power == null) {
|
||||
return null
|
||||
}
|
||||
val powerFmt = if (power - power.toInt() == 0.0) {
|
||||
"%.0f".format(power)
|
||||
} else {
|
||||
|
||||
28
app/src/main/java/net/vonforst/evmap/model/FavoritesModel.kt
Normal file
28
app/src/main/java/net/vonforst/evmap/model/FavoritesModel.kt
Normal file
@@ -0,0 +1,28 @@
|
||||
package net.vonforst.evmap.model
|
||||
|
||||
import androidx.room.*
|
||||
|
||||
@Entity(
|
||||
foreignKeys = [
|
||||
ForeignKey(
|
||||
entity = ChargeLocation::class,
|
||||
parentColumns = arrayOf("id", "dataSource"),
|
||||
childColumns = arrayOf("chargerId", "chargerDataSource"),
|
||||
onDelete = ForeignKey.NO_ACTION,
|
||||
)
|
||||
],
|
||||
indices = [
|
||||
Index(value = ["chargerId", "chargerDataSource"])
|
||||
]
|
||||
)
|
||||
data class Favorite(
|
||||
@PrimaryKey(autoGenerate = true)
|
||||
val favoriteId: Long = 0,
|
||||
val chargerId: Long,
|
||||
val chargerDataSource: String
|
||||
)
|
||||
|
||||
data class FavoriteWithDetail(
|
||||
@Embedded() val favorite: Favorite,
|
||||
@Embedded val charger: ChargeLocation
|
||||
)
|
||||
@@ -3,6 +3,7 @@ package net.vonforst.evmap.model
|
||||
import androidx.databinding.BaseObservable
|
||||
import androidx.room.Entity
|
||||
import androidx.room.ForeignKey
|
||||
import androidx.room.Index
|
||||
import net.vonforst.evmap.adapter.Equatable
|
||||
import net.vonforst.evmap.storage.FilterProfile
|
||||
import kotlin.reflect.KClass
|
||||
@@ -59,7 +60,10 @@ sealed class FilterValue : BaseObservable(), Equatable {
|
||||
childColumns = arrayOf("profile", "dataSource"),
|
||||
onDelete = ForeignKey.CASCADE
|
||||
)],
|
||||
primaryKeys = ["key", "profile", "dataSource"]
|
||||
primaryKeys = ["key", "profile", "dataSource"],
|
||||
indices = [
|
||||
Index(value = ["profile", "dataSource"])
|
||||
]
|
||||
)
|
||||
data class BooleanFilterValue(
|
||||
override val key: String,
|
||||
@@ -77,7 +81,10 @@ data class BooleanFilterValue(
|
||||
childColumns = arrayOf("profile", "dataSource"),
|
||||
onDelete = ForeignKey.CASCADE
|
||||
)],
|
||||
primaryKeys = ["key", "profile", "dataSource"]
|
||||
primaryKeys = ["key", "profile", "dataSource"],
|
||||
indices = [
|
||||
Index(value = ["profile", "dataSource"])
|
||||
]
|
||||
)
|
||||
data class MultipleChoiceFilterValue(
|
||||
override val key: String,
|
||||
@@ -101,7 +108,10 @@ data class MultipleChoiceFilterValue(
|
||||
childColumns = arrayOf("profile", "dataSource"),
|
||||
onDelete = ForeignKey.CASCADE
|
||||
)],
|
||||
primaryKeys = ["key", "profile", "dataSource"]
|
||||
primaryKeys = ["key", "profile", "dataSource"],
|
||||
indices = [
|
||||
Index(value = ["profile", "dataSource"])
|
||||
]
|
||||
)
|
||||
data class SliderFilterValue(
|
||||
override val key: String,
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
package net.vonforst.evmap.navigation
|
||||
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.navigation.fragment.NavHostFragment
|
||||
|
||||
class NavHostFragment : NavHostFragment() {
|
||||
override fun onCreateNavController(navController: NavController) {
|
||||
super.onCreateNavController(navController)
|
||||
navController.navigatorProvider.addNavigator(
|
||||
override fun onCreateNavHostController(navHostController: NavHostController) {
|
||||
super.onCreateNavHostController(navHostController)
|
||||
navHostController.navigatorProvider.addNavigator(
|
||||
CustomNavigator(
|
||||
requireContext()
|
||||
)
|
||||
|
||||
@@ -1,34 +1,124 @@
|
||||
package net.vonforst.evmap.storage
|
||||
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.room.*
|
||||
import net.vonforst.evmap.model.ChargeLocation
|
||||
import androidx.lifecycle.*
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Delete
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import com.car2go.maps.model.LatLng
|
||||
import com.car2go.maps.model.LatLngBounds
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import net.vonforst.evmap.api.ChargepointApi
|
||||
import net.vonforst.evmap.api.StringProvider
|
||||
import net.vonforst.evmap.api.goingelectric.GEReferenceData
|
||||
import net.vonforst.evmap.api.goingelectric.GoingElectricApiWrapper
|
||||
import net.vonforst.evmap.api.openchargemap.OpenChargeMapApiWrapper
|
||||
import net.vonforst.evmap.model.*
|
||||
import net.vonforst.evmap.viewmodel.Resource
|
||||
import net.vonforst.evmap.viewmodel.await
|
||||
|
||||
@Dao
|
||||
interface ChargeLocationsDao {
|
||||
abstract class ChargeLocationsDao {
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insert(vararg locations: ChargeLocation)
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
fun insertBlocking(vararg locations: ChargeLocation)
|
||||
abstract suspend fun insert(vararg locations: ChargeLocation)
|
||||
|
||||
@Delete
|
||||
suspend fun delete(vararg locations: ChargeLocation)
|
||||
abstract suspend fun delete(vararg locations: ChargeLocation)
|
||||
}
|
||||
|
||||
@Query("SELECT * FROM chargelocation")
|
||||
fun getAllChargeLocations(): LiveData<List<ChargeLocation>>
|
||||
/**
|
||||
* The ChargeLocationsRepository wraps the ChargepointApi and the DB to provide caching
|
||||
* functionality.
|
||||
*/
|
||||
class ChargeLocationsRepository(
|
||||
api: ChargepointApi<ReferenceData>, private val scope: CoroutineScope,
|
||||
private val db: AppDatabase, private val prefs: PreferenceDataSource
|
||||
) {
|
||||
val api = MutableLiveData<ChargepointApi<ReferenceData>>().apply { value = api }
|
||||
|
||||
@Query("SELECT * FROM chargelocation")
|
||||
suspend fun getAllChargeLocationsAsync(): List<ChargeLocation>
|
||||
val referenceData = this.api.switchMap { api ->
|
||||
when (api) {
|
||||
is GoingElectricApiWrapper -> {
|
||||
GEReferenceDataRepository(
|
||||
api,
|
||||
scope,
|
||||
db.geReferenceDataDao(),
|
||||
prefs
|
||||
).getReferenceData()
|
||||
}
|
||||
is OpenChargeMapApiWrapper -> {
|
||||
OCMReferenceDataRepository(
|
||||
api,
|
||||
scope,
|
||||
db.ocmReferenceDataDao(),
|
||||
prefs
|
||||
).getReferenceData()
|
||||
}
|
||||
else -> {
|
||||
throw RuntimeException("no reference data implemented")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Query("SELECT * FROM chargelocation")
|
||||
fun getAllChargeLocationsBlocking(): List<ChargeLocation>
|
||||
private val chargeLocationsDao = db.chargeLocationsDao()
|
||||
|
||||
@Query("SELECT * FROM chargelocation WHERE lat >= :lat1 AND lat <= :lat2 AND lng >= :lng1 AND lng <= :lng2")
|
||||
suspend fun getChargeLocationsInBoundsAsync(
|
||||
lat1: Double,
|
||||
lat2: Double,
|
||||
lng1: Double,
|
||||
lng2: Double
|
||||
): List<ChargeLocation>
|
||||
fun getChargepoints(
|
||||
bounds: LatLngBounds,
|
||||
zoom: Float,
|
||||
filters: FilterValues?
|
||||
): LiveData<Resource<List<ChargepointListItem>>> {
|
||||
return liveData {
|
||||
val refData = referenceData.await()
|
||||
val result = api.value!!.getChargepoints(refData, bounds, zoom, filters)
|
||||
|
||||
emit(result)
|
||||
}
|
||||
}
|
||||
|
||||
fun getChargepointsRadius(
|
||||
location: LatLng,
|
||||
radius: Int,
|
||||
zoom: Float,
|
||||
filters: FilterValues?
|
||||
): LiveData<Resource<List<ChargepointListItem>>> {
|
||||
return liveData {
|
||||
val refData = referenceData.await()
|
||||
val result = api.value!!.getChargepointsRadius(refData, location, radius, zoom, filters)
|
||||
|
||||
emit(result)
|
||||
}
|
||||
}
|
||||
|
||||
fun getChargepointDetail(
|
||||
id: Long
|
||||
): LiveData<Resource<ChargeLocation>> {
|
||||
return liveData {
|
||||
val refData = referenceData.await()
|
||||
val result = api.value!!.getChargepointDetail(refData, id)
|
||||
emit(result)
|
||||
}
|
||||
}
|
||||
|
||||
fun getFilters(sp: StringProvider) = MediatorLiveData<List<Filter<FilterValue>>>().apply {
|
||||
addSource(referenceData) { refData: ReferenceData? ->
|
||||
refData?.let { value = api.value!!.getFilters(refData, sp) }
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getFiltersAsync(sp: StringProvider): List<Filter<FilterValue>> {
|
||||
val refData = referenceData.await()
|
||||
return api.value!!.getFilters(refData, sp)
|
||||
}
|
||||
|
||||
val chargeCardMap by lazy {
|
||||
referenceData.map { refData: ReferenceData? ->
|
||||
if (refData is GEReferenceData) {
|
||||
refData.chargecards.associate {
|
||||
it.id to it.convert()
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import net.vonforst.evmap.model.*
|
||||
@Database(
|
||||
entities = [
|
||||
ChargeLocation::class,
|
||||
Favorite::class,
|
||||
BooleanFilterValue::class,
|
||||
MultipleChoiceFilterValue::class,
|
||||
SliderFilterValue::class,
|
||||
@@ -31,11 +32,12 @@ import net.vonforst.evmap.model.*
|
||||
OCMConnectionType::class,
|
||||
OCMCountry::class,
|
||||
OCMOperator::class
|
||||
], version = 14
|
||||
], version = 18
|
||||
)
|
||||
@TypeConverters(Converters::class)
|
||||
abstract class AppDatabase : RoomDatabase() {
|
||||
abstract fun chargeLocationsDao(): ChargeLocationsDao
|
||||
abstract fun favoritesDao(): FavoritesDao
|
||||
abstract fun filterValueDao(): FilterValueDao
|
||||
abstract fun filterProfileDao(): FilterProfileDao
|
||||
abstract fun recentAutocompletePlaceDao(): RecentAutocompletePlaceDao
|
||||
@@ -53,7 +55,8 @@ abstract class AppDatabase : RoomDatabase() {
|
||||
.addMigrations(
|
||||
MIGRATION_2, MIGRATION_3, MIGRATION_4, MIGRATION_5, MIGRATION_6,
|
||||
MIGRATION_7, MIGRATION_8, MIGRATION_9, MIGRATION_10, MIGRATION_11,
|
||||
MIGRATION_12, MIGRATION_13, MIGRATION_14
|
||||
MIGRATION_12, MIGRATION_13, MIGRATION_14, MIGRATION_15, MIGRATION_16,
|
||||
MIGRATION_17, MIGRATION_18
|
||||
)
|
||||
.addCallback(object : Callback() {
|
||||
override fun onCreate(db: SupportSQLiteDatabase) {
|
||||
@@ -312,5 +315,66 @@ abstract class AppDatabase : RoomDatabase() {
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private val MIGRATION_15 = object : Migration(14, 15) {
|
||||
@SuppressLint("Range")
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
try {
|
||||
db.beginTransaction()
|
||||
db.execSQL("CREATE TABLE IF NOT EXISTS `Favorite` (`favoriteId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `chargerId` INTEGER NOT NULL, `chargerDataSource` TEXT NOT NULL, FOREIGN KEY(`chargerId`, `chargerDataSource`) REFERENCES `ChargeLocation`(`id`, `dataSource`) ON UPDATE NO ACTION ON DELETE RESTRICT )");
|
||||
|
||||
val cursor = db.query("SELECT * FROM `ChargeLocation`")
|
||||
while (cursor.moveToNext()) {
|
||||
val id = cursor.getLong(cursor.getColumnIndex("id"))
|
||||
val dataSource = cursor.getString(cursor.getColumnIndex("dataSource"))
|
||||
val values = ContentValues().apply {
|
||||
put("chargerId", id)
|
||||
put("chargerDataSource", dataSource)
|
||||
}
|
||||
db.insert("favorite", SQLiteDatabase.CONFLICT_ROLLBACK, values)
|
||||
}
|
||||
|
||||
db.setTransactionSuccessful()
|
||||
} finally {
|
||||
db.endTransaction()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val MIGRATION_16 = object : Migration(15, 16) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL("ALTER TABLE `ChargeLocation` ADD `timeRetrieved` INTEGER NOT NULL DEFAULT 0")
|
||||
db.execSQL("ALTER TABLE `ChargeLocation` ADD `isDetailed` INTEGER NOT NULL DEFAULT 0")
|
||||
}
|
||||
}
|
||||
|
||||
private val MIGRATION_17 = object : Migration(16, 17) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL("CREATE INDEX IF NOT EXISTS `index_Favorite_chargerId_chargerDataSource` ON `Favorite` (`chargerId`, `chargerDataSource`)")
|
||||
db.execSQL("CREATE INDEX IF NOT EXISTS `index_BooleanFilterValue_profile_dataSource` ON `BooleanFilterValue` (`profile`, `dataSource`)")
|
||||
db.execSQL("CREATE INDEX IF NOT EXISTS `index_MultipleChoiceFilterValue_profile_dataSource` ON `MultipleChoiceFilterValue` (`profile`, `dataSource`)")
|
||||
db.execSQL("CREATE INDEX IF NOT EXISTS `index_SliderFilterValue_profile_dataSource` ON `SliderFilterValue` (`profile`, `dataSource`)")
|
||||
}
|
||||
}
|
||||
|
||||
private val MIGRATION_18 = object : Migration(17, 18) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
try {
|
||||
db.beginTransaction()
|
||||
db.execSQL("CREATE TABLE IF NOT EXISTS `FavoriteNew` (`favoriteId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `chargerId` INTEGER NOT NULL, `chargerDataSource` TEXT NOT NULL, FOREIGN KEY(`chargerId`, `chargerDataSource`) REFERENCES `ChargeLocation`(`id`, `dataSource`) ON UPDATE NO ACTION ON DELETE NO ACTION )");
|
||||
val columnList =
|
||||
"`favoriteId`,`chargerId`,`chargerDataSource`"
|
||||
db.execSQL("INSERT INTO `FavoriteNew`($columnList) SELECT $columnList FROM `Favorite`")
|
||||
db.execSQL("DROP TABLE `Favorite`")
|
||||
db.execSQL("ALTER TABLE `FavoriteNew` RENAME TO `Favorite`")
|
||||
db.execSQL("CREATE INDEX IF NOT EXISTS `index_Favorite_chargerId_chargerDataSource` ON `Favorite` (`chargerId`, `chargerDataSource`)")
|
||||
|
||||
db.setTransactionSuccessful()
|
||||
} finally {
|
||||
db.endTransaction()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
32
app/src/main/java/net/vonforst/evmap/storage/FavoritesDao.kt
Normal file
32
app/src/main/java/net/vonforst/evmap/storage/FavoritesDao.kt
Normal file
@@ -0,0 +1,32 @@
|
||||
package net.vonforst.evmap.storage
|
||||
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.room.*
|
||||
import net.vonforst.evmap.model.Favorite
|
||||
import net.vonforst.evmap.model.FavoriteWithDetail
|
||||
|
||||
@Dao
|
||||
interface FavoritesDao {
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insert(vararg favorites: Favorite): List<Long>
|
||||
|
||||
@Delete
|
||||
suspend fun delete(vararg favorites: Favorite)
|
||||
|
||||
@Query("SELECT * FROM favorite LEFT JOIN chargelocation ON favorite.chargerDataSource = chargelocation.dataSource AND favorite.chargerId = chargelocation.id")
|
||||
fun getAllFavorites(): LiveData<List<FavoriteWithDetail>>
|
||||
|
||||
@Query("SELECT * FROM favorite LEFT JOIN chargelocation ON favorite.chargerDataSource = chargelocation.dataSource AND favorite.chargerId = chargelocation.id")
|
||||
suspend fun getAllFavoritesAsync(): List<FavoriteWithDetail>
|
||||
|
||||
@Query("SELECT * FROM favorite LEFT JOIN chargelocation ON favorite.chargerDataSource = chargelocation.dataSource AND favorite.chargerId = chargelocation.id WHERE lat >= :lat1 AND lat <= :lat2 AND lng >= :lng1 AND lng <= :lng2")
|
||||
suspend fun getFavoritesInBoundsAsync(
|
||||
lat1: Double,
|
||||
lat2: Double,
|
||||
lng1: Double,
|
||||
lng2: Double
|
||||
): List<FavoriteWithDetail>
|
||||
|
||||
@Query("SELECT * FROM favorite WHERE chargerDataSource == :dataSource AND chargerId == :chargerId")
|
||||
suspend fun findFavorite(chargerId: Long, dataSource: String): Favorite?
|
||||
}
|
||||
@@ -1,30 +1,28 @@
|
||||
package net.vonforst.evmap.storage
|
||||
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MediatorLiveData
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.liveData
|
||||
import androidx.room.*
|
||||
import net.vonforst.evmap.model.*
|
||||
|
||||
@Dao
|
||||
abstract class FilterValueDao {
|
||||
@Query("SELECT * FROM booleanfiltervalue WHERE profile = :profile AND dataSource = :dataSource")
|
||||
protected abstract fun getBooleanFilterValues(
|
||||
protected abstract suspend fun getBooleanFilterValues(
|
||||
profile: Long,
|
||||
dataSource: String
|
||||
): LiveData<List<BooleanFilterValue>>
|
||||
): List<BooleanFilterValue>
|
||||
|
||||
@Query("SELECT * FROM multiplechoicefiltervalue WHERE profile = :profile AND dataSource = :dataSource")
|
||||
protected abstract fun getMultipleChoiceFilterValues(
|
||||
protected abstract suspend fun getMultipleChoiceFilterValues(
|
||||
profile: Long,
|
||||
dataSource: String
|
||||
): LiveData<List<MultipleChoiceFilterValue>>
|
||||
): List<MultipleChoiceFilterValue>
|
||||
|
||||
@Query("SELECT * FROM sliderfiltervalue WHERE profile = :profile AND dataSource = :dataSource")
|
||||
protected abstract fun getSliderFilterValues(
|
||||
protected abstract suspend fun getSliderFilterValues(
|
||||
profile: Long,
|
||||
dataSource: String
|
||||
): LiveData<List<SliderFilterValue>>
|
||||
): List<SliderFilterValue>
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
protected abstract suspend fun insert(vararg values: BooleanFilterValue)
|
||||
@@ -53,27 +51,22 @@ abstract class FilterValueDao {
|
||||
dataSource: String
|
||||
)
|
||||
|
||||
open fun getFilterValues(filterStatus: Long, dataSource: String): LiveData<List<FilterValue>> =
|
||||
open suspend fun getFilterValuesAsync(
|
||||
filterStatus: Long,
|
||||
dataSource: String
|
||||
): List<FilterValue> =
|
||||
if (filterStatus == FILTERS_DISABLED || filterStatus == FILTERS_FAVORITES) {
|
||||
MutableLiveData(emptyList())
|
||||
emptyList()
|
||||
} else {
|
||||
MediatorLiveData<List<FilterValue>>().apply {
|
||||
val sources = listOf(
|
||||
getBooleanFilterValues(filterStatus, dataSource),
|
||||
getMultipleChoiceFilterValues(filterStatus, dataSource),
|
||||
getBooleanFilterValues(filterStatus, dataSource) +
|
||||
getMultipleChoiceFilterValues(filterStatus, dataSource) +
|
||||
getSliderFilterValues(filterStatus, dataSource)
|
||||
)
|
||||
for (source in sources) {
|
||||
addSource(source) {
|
||||
val values = sources.map { it.value }
|
||||
if (values.all { it != null }) {
|
||||
value = values.filterNotNull().flatten()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
open fun getFilterValues(filterStatus: Long, dataSource: String) = liveData {
|
||||
emit(getFilterValuesAsync(filterStatus, dataSource))
|
||||
}
|
||||
|
||||
@Transaction
|
||||
open suspend fun insert(vararg values: FilterValue) {
|
||||
values.forEach {
|
||||
@@ -92,4 +85,15 @@ abstract class FilterValueDao {
|
||||
deleteSliderFilterValuesForProfile(profile, dataSource)
|
||||
}
|
||||
|
||||
@Transaction
|
||||
open suspend fun copyFiltersToCustom(filterStatus: Long, dataSource: String) {
|
||||
if (filterStatus == FILTERS_CUSTOM) return
|
||||
|
||||
deleteFilterValuesForProfile(FILTERS_CUSTOM, dataSource)
|
||||
val values = getFilterValuesAsync(filterStatus, dataSource).onEach {
|
||||
it.profile = FILTERS_CUSTOM
|
||||
}
|
||||
insert(*values.toTypedArray())
|
||||
}
|
||||
|
||||
}
|
||||
@@ -87,6 +87,7 @@ class GEReferenceDataRepository(
|
||||
val networks = dao.getAllNetworks()
|
||||
val chargeCards = dao.getAllChargeCards()
|
||||
return MediatorLiveData<GEReferenceData>().apply {
|
||||
value = null
|
||||
listOf(chargeCards, networks, plugs).map { source ->
|
||||
addSource(source) { _ ->
|
||||
val p = plugs.value ?: return@addSource
|
||||
|
||||
@@ -79,6 +79,7 @@ class OCMReferenceDataRepository(
|
||||
val countries = dao.getAllCountries()
|
||||
val operators = dao.getAllOperators()
|
||||
return MediatorLiveData<OCMReferenceData>().apply {
|
||||
value = null
|
||||
listOf(countries, connectionTypes, operators).map { source ->
|
||||
addSource(source) { _ ->
|
||||
val ct = connectionTypes.value
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user