mirror of
https://github.com/ev-map/EVMap.git
synced 2025-12-25 16:17:45 -05:00
Compare commits
73 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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
|
||||
|
||||
@@ -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"/>
|
||||
|
||||
Binary file not shown.
@@ -1,3 +1,7 @@
|
||||
plugins {
|
||||
id 'com.adarshr.test-logger' version '3.1.0'
|
||||
}
|
||||
|
||||
apply plugin: 'com.android.application'
|
||||
apply plugin: 'kotlin-android'
|
||||
apply plugin: 'kotlin-parcelize'
|
||||
@@ -13,21 +17,22 @@ android {
|
||||
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 78
|
||||
versionName "1.3.1"
|
||||
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
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 +49,7 @@ android {
|
||||
}
|
||||
}
|
||||
|
||||
flavorDimensions "dependencies"
|
||||
flavorDimensions "dependencies", "automotive"
|
||||
productFlavors {
|
||||
foss {
|
||||
dimension "dependencies"
|
||||
@@ -53,6 +58,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 +90,9 @@ android {
|
||||
dataBinding = true
|
||||
viewBinding true
|
||||
}
|
||||
lint {
|
||||
disable 'NullSafeMutableLiveData'
|
||||
}
|
||||
|
||||
// add API keys from environment variable if not set in apikeys.xml
|
||||
applicationVariants.all { variant ->
|
||||
@@ -85,7 +109,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,22 +128,24 @@ 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.appcompat:appcompat:1.4.1'
|
||||
implementation 'androidx.core:core-ktx:1.7.0'
|
||||
implementation 'androidx.core:core-splashscreen:1.0.0-alpha02'
|
||||
implementation 'androidx.core:core-splashscreen:1.0.0-beta01'
|
||||
implementation "androidx.activity:activity-ktx:1.4.0"
|
||||
implementation "androidx.fragment:fragment-ktx:1.4.0"
|
||||
implementation "androidx.fragment:fragment-ktx:1.4.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.5.0'
|
||||
implementation 'androidx.constraintlayout:constraintlayout:2.1.3'
|
||||
implementation 'androidx.recyclerview:recyclerview:1.2.1'
|
||||
implementation 'androidx.browser:browser:1.4.0'
|
||||
implementation 'com.github.johan12345:CustomBottomSheetBehavior:f69f532660'
|
||||
@@ -143,15 +169,19 @@ dependencies {
|
||||
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'
|
||||
googleImplementation 'androidx.car.app:app:1.2.0-rc01'
|
||||
googleNormalImplementation 'androidx.car.app:app-projected:1.2.0-rc01'
|
||||
googleAutomotiveImplementation 'androidx.car.app:app-automotive:1.2.0-rc01'
|
||||
|
||||
// AnyMaps
|
||||
def anyMapsVersion = '751daec281'
|
||||
def anyMapsVersion = '3c67d7a1dc'
|
||||
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.0.2'
|
||||
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'
|
||||
}
|
||||
|
||||
// Google Places
|
||||
implementation 'com.google.android.libraries.places:places:2.5.0'
|
||||
@@ -165,18 +195,18 @@ dependencies {
|
||||
implementation "androidx.navigation:navigation-ui-ktx:$nav_version"
|
||||
|
||||
// viewmodel library
|
||||
def lifecycle_version = "2.4.0"
|
||||
def lifecycle_version = "2.4.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.2"
|
||||
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"
|
||||
|
||||
@@ -211,4 +241,4 @@ private static byte[] xorWithKey(byte[] a, byte[] key) {
|
||||
out[i] = (byte) (a[i] ^ key[i%key.length]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,4 +11,5 @@
|
||||
</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>
|
||||
<string name="data_sources_hint">Die Kartendaten für die App stammen von OpenStreetMap (Mapbox).</string>
|
||||
</resources>
|
||||
@@ -17,4 +17,5 @@
|
||||
<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>
|
||||
<string name="data_sources_hint">Map data in the app is provided by OpenStreetMap (Mapbox).</string>
|
||||
</resources>
|
||||
@@ -39,7 +39,7 @@
|
||||
<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>
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ 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
|
||||
@@ -198,7 +198,8 @@ class ChargepriceScreen(ctx: CarContext, val charger: ChargeLocation) : Screen(c
|
||||
batteryRange = batteryRange.map { it.toDouble() },
|
||||
providerCustomerTariffs = prefs.chargepriceShowProviderCustomerTariffs,
|
||||
maxMonthlyFees = if (prefs.chargepriceNoBaseFee) 0.0 else null,
|
||||
currency = prefs.chargepriceCurrency
|
||||
currency = prefs.chargepriceCurrency,
|
||||
allowUnbalancedLoad = prefs.chargepriceAllowUnbalancedLoad
|
||||
)
|
||||
}, ChargepriceApi.getChargepriceLanguage())
|
||||
|
||||
|
||||
@@ -19,6 +19,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,6 +30,7 @@ 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.PreferenceDataSource
|
||||
import net.vonforst.evmap.ui.ChargerIconGenerator
|
||||
@@ -66,6 +68,9 @@ 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()
|
||||
@@ -121,29 +126,83 @@ 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>()
|
||||
|
||||
@@ -232,8 +291,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())
|
||||
@@ -295,6 +356,8 @@ class ChargerDetailScreen(ctx: CarContext, val chargerSparse: ChargeLocation) :
|
||||
private fun loadCharger() {
|
||||
val referenceData = referenceData.value ?: return
|
||||
lifecycleScope.launch {
|
||||
favorite = db.favoritesDao().findFavorite(chargerSparse.id, chargerSparse.dataSource)
|
||||
|
||||
val response = api.getChargepointDetail(referenceData, chargerSparse.id)
|
||||
if (response.status == Status.SUCCESS) {
|
||||
val charger = response.data!!
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package net.vonforst.evmap.auto
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import androidx.car.app.CarContext
|
||||
import androidx.car.app.Screen
|
||||
import androidx.car.app.model.*
|
||||
@@ -13,7 +12,6 @@ import net.vonforst.evmap.model.FILTERS_FAVORITES
|
||||
import net.vonforst.evmap.storage.AppDatabase
|
||||
import net.vonforst.evmap.storage.FilterProfile
|
||||
import net.vonforst.evmap.storage.PreferenceDataSource
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
class FilterScreen(ctx: CarContext) : Screen(ctx) {
|
||||
private val prefs = PreferenceDataSource(ctx)
|
||||
@@ -24,16 +22,6 @@ class FilterScreen(ctx: CarContext) : Screen(ctx) {
|
||||
private val maxRows = 6
|
||||
private val checkIcon =
|
||||
CarIcon.Builder(IconCompat.createWithResource(carContext, R.drawable.ic_check)).build()
|
||||
private val emptyIcon: CarIcon
|
||||
|
||||
init {
|
||||
val size = (ctx.resources.displayMetrics.density * 24).roundToInt()
|
||||
emptyIcon = Bitmap.createBitmap(
|
||||
size,
|
||||
size,
|
||||
Bitmap.Config.ARGB_8888
|
||||
).asCarIcon()
|
||||
}
|
||||
|
||||
init {
|
||||
filterProfiles.observe(this) {
|
||||
@@ -64,7 +52,7 @@ class FilterScreen(ctx: CarContext) : Screen(ctx) {
|
||||
if (FILTERS_DISABLED == filterStatus) {
|
||||
setImage(checkIcon)
|
||||
} else {
|
||||
setImage(emptyIcon)
|
||||
setImage(emptyCarIcon)
|
||||
}
|
||||
setOnClickListener {
|
||||
prefs.filterStatus = FILTERS_DISABLED
|
||||
@@ -79,7 +67,7 @@ class FilterScreen(ctx: CarContext) : Screen(ctx) {
|
||||
if (it.id == filterStatus) {
|
||||
setImage(checkIcon)
|
||||
} else {
|
||||
setImage(emptyIcon)
|
||||
setImage(emptyCarIcon)
|
||||
}
|
||||
setOnClickListener {
|
||||
prefs.filterStatus = it.id
|
||||
|
||||
@@ -13,12 +13,10 @@ import androidx.car.app.hardware.info.EnergyLevel
|
||||
import androidx.car.app.model.*
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.graphics.drawable.IconCompat
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.OnLifecycleEvent
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.*
|
||||
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
|
||||
@@ -35,7 +33,6 @@ import net.vonforst.evmap.ui.getMarkerTint
|
||||
import net.vonforst.evmap.utils.distanceBetween
|
||||
import net.vonforst.evmap.viewmodel.filtersWithValue
|
||||
import net.vonforst.evmap.viewmodel.getFilterValues
|
||||
import net.vonforst.evmap.viewmodel.getFilters
|
||||
import net.vonforst.evmap.viewmodel.getReferenceData
|
||||
import java.io.IOException
|
||||
import java.time.Duration
|
||||
@@ -47,18 +44,17 @@ import kotlin.math.roundToInt
|
||||
/**
|
||||
* Main map screen showing either nearby chargers or favorites
|
||||
*/
|
||||
@androidx.car.app.annotations.ExperimentalCarApi
|
||||
class MapScreen(ctx: CarContext, val session: EVMapSession, val favorites: Boolean = false) :
|
||||
Screen(ctx), LocationAwareScreen {
|
||||
Screen(ctx), LocationAwareScreen, OnContentRefreshListener,
|
||||
ItemList.OnItemVisibilityChangedListener {
|
||||
private var updateCoroutine: Job? = null
|
||||
private var numUpdates = 0
|
||||
private var availabilityUpdateCoroutine: Job? = null
|
||||
|
||||
/* 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 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)
|
||||
@@ -67,10 +63,9 @@ class MapScreen(ctx: CarContext, val session: EVMapSession, val favorites: Boole
|
||||
createApi(prefs.dataSource, ctx)
|
||||
}
|
||||
private val searchRadius = 5 // kilometers
|
||||
private val chargerUpdateThreshold = 2000 // meters
|
||||
private val distanceUpdateThreshold = Duration.ofSeconds(15)
|
||||
private val availabilityUpdateThreshold = Duration.ofMinutes(1)
|
||||
private var availabilities: MutableMap<Long, Pair<ZonedDateTime, ChargeLocationStatus>> =
|
||||
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)
|
||||
@@ -82,11 +77,23 @@ class MapScreen(ctx: CarContext, val session: EVMapSession, val favorites: Boole
|
||||
?: FILTERS_DISABLED
|
||||
}
|
||||
private val filterValues = db.filterValueDao().getFilterValues(filterStatus, prefs.dataSource)
|
||||
private val filters = api.getFilters(referenceData, carContext.stringProvider())
|
||||
private val filters =
|
||||
Transformations.map(referenceData) { api.getFilters(it, carContext.stringProvider()) }
|
||||
private val filtersWithValue = filtersWithValue(filters, filterValues)
|
||||
|
||||
private val hardwareMan = 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"
|
||||
)
|
||||
}
|
||||
|
||||
init {
|
||||
filtersWithValue.observe(this) {
|
||||
@@ -112,7 +119,7 @@ class MapScreen(ctx: CarContext, val session: EVMapSession, val favorites: Boole
|
||||
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))
|
||||
}
|
||||
@@ -125,6 +132,7 @@ class MapScreen(ctx: CarContext, val session: EVMapSession, val favorites: Boole
|
||||
}
|
||||
)
|
||||
)
|
||||
builder.setOnItemsVisibilityChangedListener(this@MapScreen)
|
||||
setItemList(builder.build())
|
||||
} ?: setLoading(true)
|
||||
setCurrentLocationEnabled(true)
|
||||
@@ -151,7 +159,6 @@ class MapScreen(ctx: CarContext, val session: EVMapSession, val favorites: Boole
|
||||
.setOnClickListener {
|
||||
screenManager.pushForResult(FilterScreen(carContext)) {
|
||||
chargers = null
|
||||
numUpdates = 0
|
||||
filterStatus.value =
|
||||
prefs.filterStatus.takeUnless { it == FILTERS_CUSTOM || it == FILTERS_FAVORITES }
|
||||
?: FILTERS_DISABLED
|
||||
@@ -161,12 +168,12 @@ class MapScreen(ctx: CarContext, val session: EVMapSession, val favorites: Boole
|
||||
.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 +191,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 +221,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 ->
|
||||
@@ -239,7 +249,13 @@ class MapScreen(ctx: CarContext, val session: EVMapSession, val favorites: Boole
|
||||
)
|
||||
|
||||
setOnClickListener {
|
||||
screenManager.push(ChargerDetailScreen(carContext, charger))
|
||||
screenManager.pushForResult(ChargerDetailScreen(carContext, charger)) {
|
||||
if (favorites) {
|
||||
// favorites list may have been updated
|
||||
chargers = null
|
||||
loadChargers()
|
||||
}
|
||||
}
|
||||
}
|
||||
}.build()
|
||||
}
|
||||
@@ -264,14 +280,6 @@ 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() {
|
||||
@@ -279,23 +287,17 @@ class MapScreen(ctx: CarContext, val session: EVMapSession, val favorites: Boole
|
||||
val referenceData = referenceData.value ?: return
|
||||
val filters = filtersWithValue.value ?: return
|
||||
|
||||
numUpdates++
|
||||
println(numUpdates)
|
||||
if (numUpdates > maxNumUpdates) {
|
||||
/*CarToast.makeText(carContext, R.string.auto_no_refresh_possible, CarToast.LENGTH_LONG)
|
||||
.show()*/
|
||||
return
|
||||
}
|
||||
updateCoroutine = lifecycleScope.launch {
|
||||
try {
|
||||
// load chargers
|
||||
if (favorites) {
|
||||
chargers = db.chargeLocationsDao().getAllChargeLocationsAsync().sortedBy {
|
||||
distanceBetween(
|
||||
location.latitude, location.longitude,
|
||||
it.coordinates.lat, it.coordinates.lng
|
||||
)
|
||||
}
|
||||
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,
|
||||
@@ -321,28 +323,6 @@ class MapScreen(ctx: CarContext, val session: EVMapSession, val favorites: Boole
|
||||
}
|
||||
}
|
||||
|
||||
// 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()
|
||||
@@ -362,11 +342,12 @@ class MapScreen(ctx: CarContext, val session: EVMapSession, val favorites: Boole
|
||||
|
||||
@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")
|
||||
@@ -380,4 +361,45 @@ class MapScreen(ctx: CarContext, val session: EVMapSession, val favorites: Boole
|
||||
println("Removing energy level listener")
|
||||
hardwareMan.carInfo.removeEnergyLevelListener(::onEnergyLevelUpdated)
|
||||
}
|
||||
|
||||
override fun onContentRefreshRequested() {
|
||||
loadChargers()
|
||||
}
|
||||
|
||||
override fun onItemVisibilityChanged(startIndex: Int, endIndex: Int) {
|
||||
// when the list is scrolled, load corresponding availabilities
|
||||
if (startIndex == visibleStart && endIndex == visibleEnd) 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 tasks = chargers?.subList(startIndex, endIndex)?.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.isNullOrEmpty()) {
|
||||
tasks.awaitAll()
|
||||
invalidate()
|
||||
}
|
||||
availabilityUpdateCoroutine = null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package net.vonforst.evmap.auto
|
||||
|
||||
import androidx.car.app.CarContext
|
||||
import androidx.car.app.Screen
|
||||
import androidx.car.app.constraints.ConstraintManager
|
||||
import androidx.car.app.model.*
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
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
|
||||
|
||||
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)
|
||||
}
|
||||
}.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 fun buildItemList(): ItemList {
|
||||
return ItemList.Builder().apply {
|
||||
currentList.forEach { item ->
|
||||
addItem(
|
||||
Row.Builder()
|
||||
.setTitle(
|
||||
if (isSelected(item)) {
|
||||
"☑ " + getLabel(item)
|
||||
} else {
|
||||
"☐ " + getLabel(item)
|
||||
}
|
||||
)
|
||||
.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)
|
||||
|
||||
abstract fun isSelected(it: T): Boolean
|
||||
|
||||
abstract fun getLabel(it: T): String
|
||||
|
||||
abstract suspend fun loadData(): List<T>
|
||||
}
|
||||
376
app/src/google/java/net/vonforst/evmap/auto/SettingsScreens.kt
Normal file
376
app/src/google/java/net/vonforst/evmap/auto/SettingsScreens.kt
Normal file
@@ -0,0 +1,376 @@
|
||||
package net.vonforst.evmap.auto
|
||||
|
||||
import androidx.car.app.CarContext
|
||||
import androidx.car.app.Screen
|
||||
import androidx.car.app.constraints.ConstraintManager
|
||||
import androidx.car.app.model.*
|
||||
import androidx.core.graphics.drawable.IconCompat
|
||||
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.PreferenceDataSource
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
|
||||
class SettingsScreen(ctx: CarContext) : Screen(ctx) {
|
||||
val prefs = PreferenceDataSource(carContext)
|
||||
val dataSourceNames = carContext.resources.getStringArray(R.array.pref_data_source_names)
|
||||
val dataSourceValues = carContext.resources.getStringArray(R.array.pref_data_source_values)
|
||||
|
||||
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.pref_data_source))
|
||||
val dataSourceId = prefs.dataSource
|
||||
val dataSourceDesc = dataSourceNames[dataSourceValues.indexOf(dataSourceId)]
|
||||
addText(dataSourceDesc)
|
||||
setImage(
|
||||
CarIcon.Builder(
|
||||
IconCompat.createWithResource(
|
||||
carContext,
|
||||
R.drawable.ic_settings_data_source
|
||||
)
|
||||
).setTint(
|
||||
CarColor.DEFAULT
|
||||
).build()
|
||||
)
|
||||
setBrowsable(true)
|
||||
setOnClickListener {
|
||||
screenManager.push(ChooseDataSourceScreen(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())
|
||||
}.build())
|
||||
}.build()
|
||||
}
|
||||
}
|
||||
|
||||
class ChooseDataSourceScreen(ctx: CarContext) : Screen(ctx) {
|
||||
val prefs = PreferenceDataSource(carContext)
|
||||
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)
|
||||
)
|
||||
|
||||
override fun onGetTemplate(): Template {
|
||||
return ListTemplate.Builder().apply {
|
||||
setTitle(carContext.getString(R.string.pref_data_source))
|
||||
setHeaderAction(Action.BACK)
|
||||
setSingleList(ItemList.Builder().apply {
|
||||
for (i in dataSourceNames.indices) {
|
||||
addItem(Row.Builder().apply {
|
||||
setTitle(dataSourceNames[i])
|
||||
addText(dataSourceDescriptions[i])
|
||||
}.build())
|
||||
}
|
||||
setOnSelectedListener {
|
||||
prefs.dataSource = dataSourceValues[it]
|
||||
screenManager.pop()
|
||||
}
|
||||
setSelectedIndex(dataSourceValues.indexOf(prefs.dataSource))
|
||||
}.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))
|
||||
}
|
||||
}.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))
|
||||
override val isMultiSelect = true
|
||||
|
||||
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))
|
||||
override val isMultiSelect = 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)
|
||||
} else {
|
||||
prefs.chargepriceMyTariffs = tariffs.plus(item.id)
|
||||
}
|
||||
}
|
||||
|
||||
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 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 % 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()
|
||||
}
|
||||
}
|
||||
@@ -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,16 +6,16 @@ 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 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
|
||||
@@ -25,13 +25,26 @@ class VehicleDataScreen(ctx: CarContext) : Screen(ctx), LifecycleObserver {
|
||||
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)
|
||||
@@ -55,6 +68,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 +185,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,6 +220,11 @@ class VehicleDataScreen(ctx: CarContext) : Screen(ctx), LifecycleObserver {
|
||||
invalidate()
|
||||
}
|
||||
|
||||
private fun onCompassUpdated(compass: Compass) {
|
||||
this.heading = compass
|
||||
invalidate()
|
||||
}
|
||||
|
||||
@OnLifecycleEvent(Lifecycle.Event.ON_RESUME)
|
||||
private fun setupListeners() {
|
||||
if (!permissionsGranted()) return
|
||||
@@ -196,6 +234,11 @@ class VehicleDataScreen(ctx: CarContext) : Screen(ctx), LifecycleObserver {
|
||||
val exec = ContextCompat.getMainExecutor(carContext)
|
||||
hardwareMan.carInfo.addEnergyLevelListener(exec, ::onEnergyLevelUpdated)
|
||||
hardwareMan.carInfo.addSpeedListener(exec, ::onSpeedUpdated)
|
||||
hardwareMan.carSensors.addCompassListener(
|
||||
CarSensors.UPDATE_RATE_NORMAL,
|
||||
exec,
|
||||
::onCompassUpdated
|
||||
)
|
||||
|
||||
hardwareMan.carInfo.fetchModel(exec) {
|
||||
this.model = it
|
||||
@@ -208,6 +251,7 @@ class VehicleDataScreen(ctx: CarContext) : Screen(ctx), LifecycleObserver {
|
||||
println("Removing energy level listener")
|
||||
hardwareMan.carInfo.removeEnergyLevelListener(::onEnergyLevelUpdated)
|
||||
hardwareMan.carInfo.removeSpeedListener(::onSpeedUpdated)
|
||||
hardwareMan.carSensors.removeCompassListener(::onCompassUpdated)
|
||||
}
|
||||
|
||||
private fun permissionsGranted(): Boolean =
|
||||
|
||||
@@ -16,6 +16,7 @@ import net.vonforst.evmap.R
|
||||
class WelcomeScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx), LocationAwareScreen {
|
||||
private var location: Location? = null
|
||||
|
||||
@androidx.car.app.annotations.ExperimentalCarApi
|
||||
override fun onGetTemplate(): Template {
|
||||
if (!session.locationPermissionGranted()) {
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
@@ -101,6 +102,24 @@ class WelcomeScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx), L
|
||||
.build()
|
||||
)
|
||||
}
|
||||
addItem(
|
||||
Row.Builder()
|
||||
.setTitle(carContext.getString(R.string.auto_settings))
|
||||
.setImage(
|
||||
CarIcon.Builder(
|
||||
IconCompat.createWithResource(
|
||||
carContext,
|
||||
R.drawable.ic_settings
|
||||
)
|
||||
).setTint(CarColor.DEFAULT).build()
|
||||
)
|
||||
.setBrowsable(true)
|
||||
.setOnClickListener(ParkedOnlyOnClickListener.create {
|
||||
session.mapScreen = null
|
||||
screenManager.push(SettingsScreen(carContext))
|
||||
})
|
||||
.build()
|
||||
)
|
||||
}.build())
|
||||
setCurrentLocationEnabled(true)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,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 +36,5 @@
|
||||
<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>
|
||||
</resources>
|
||||
@@ -37,6 +37,8 @@
|
||||
<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>
|
||||
@@ -44,4 +46,5 @@
|
||||
<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="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>
|
||||
</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/values.xml
Normal file
5
app/src/googleAutomotive/res/values-de/values.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/values.xml
Normal file
5
app/src/googleAutomotive/res/values/values.xml
Normal file
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="auto_location_permission_needed">To run EVMap on your car, you need to grant access to your location.</string>
|
||||
<string name="grant_on_phone">Allow</string>
|
||||
</resources>
|
||||
@@ -98,7 +98,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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -21,6 +21,7 @@ import java.util.concurrent.TimeUnit
|
||||
|
||||
interface AvailabilityDetector {
|
||||
suspend fun getAvailability(location: ChargeLocation): ChargeLocationStatus
|
||||
fun isCountrySupported(country: String, dataSource: String): Boolean
|
||||
}
|
||||
|
||||
abstract class BaseAvailabilityDetector(private val client: OkHttpClient) : AvailabilityDetector {
|
||||
@@ -86,7 +87,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 +132,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,6 +158,7 @@ private val okhttp = OkHttpClient.Builder()
|
||||
.cookieJar(JavaNetCookieJar(cookieManager))
|
||||
.build()
|
||||
val availabilityDetectors = listOf(
|
||||
EnBwAvailabilityDetector(okhttp),
|
||||
NewMotionAvailabilityDetector(okhttp)
|
||||
/*ChargecloudAvailabilityDetector(
|
||||
okhttp,
|
||||
@@ -170,8 +172,12 @@ val availabilityDetectors = listOf(
|
||||
|
||||
suspend fun getAvailability(charger: ChargeLocation): Resource<ChargeLocationStatus> {
|
||||
var value: Resource<ChargeLocationStatus>? = null
|
||||
val country = charger.chargepriceData?.country
|
||||
?: charger.address?.country
|
||||
?: return Resource.error(null, null)
|
||||
withContext(Dispatchers.IO) {
|
||||
for (ad in availabilityDetectors) {
|
||||
if (!ad.isCountrySupported(country, charger.dataSource)) continue
|
||||
try {
|
||||
value = Resource.success(ad.getAvailability(charger))
|
||||
break
|
||||
|
||||
@@ -80,6 +80,10 @@ class ChargecloudAvailabilityDetector(
|
||||
}
|
||||
}
|
||||
|
||||
override fun isCountrySupported(country: String, dataSource: String): Boolean {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
private fun getType(string: String): String {
|
||||
return when (string) {
|
||||
"IEC_62196_T2" -> Chargepoint.TYPE_2_UNKNOWN
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
println(markers)
|
||||
|
||||
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
|
||||
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
|
||||
"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 isCountrySupported(country: String, dataSource: String): Boolean =
|
||||
when (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 isCountrySupported(country: String, dataSource: String): Boolean {
|
||||
// NewMotion is our fallback
|
||||
return true
|
||||
}
|
||||
|
||||
}
|
||||
@@ -48,11 +48,9 @@ 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) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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/")
|
||||
@@ -326,7 +327,7 @@ class GoingElectricApiWrapper(
|
||||
} else {
|
||||
true
|
||||
}
|
||||
}.map { it.convert(apikey) }
|
||||
}.map { it.convert(apikey, false) }
|
||||
|
||||
// apply clustering
|
||||
val useClustering = zoom < 13
|
||||
@@ -350,7 +351,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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -235,7 +235,7 @@ 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
|
||||
@@ -256,7 +256,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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -28,7 +28,8 @@ 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.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
|
||||
@@ -36,7 +37,7 @@ import net.vonforst.evmap.viewmodel.viewModelFactory
|
||||
class FavoritesFragment : Fragment(), LostApiClient.ConnectionCallbacks {
|
||||
private lateinit var binding: FragmentFavoritesBinding
|
||||
private var locationClient: LostApiClient? = null
|
||||
private var toDelete: ChargeLocation? = null
|
||||
private var toDelete: Favorite? = null
|
||||
private var deleteSnackbar: Snackbar? = null
|
||||
private lateinit var adapter: FavoritesAdapter
|
||||
|
||||
@@ -84,7 +85,7 @@ class FavoritesFragment : Fragment(), LostApiClient.ConnectionCallbacks {
|
||||
)
|
||||
|
||||
adapter = FavoritesAdapter(onDelete = {
|
||||
delete(it.charger)
|
||||
delete(it.fav)
|
||||
}).apply {
|
||||
onClickListener = {
|
||||
findNavController().navigate(
|
||||
@@ -132,18 +133,20 @@ class FavoritesFragment : Fragment(), LostApiClient.ConnectionCallbacks {
|
||||
}
|
||||
}
|
||||
|
||||
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 +185,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) }
|
||||
}
|
||||
|
||||
|
||||
@@ -23,7 +23,10 @@ import androidx.appcompat.app.AlertDialog
|
||||
import androidx.appcompat.widget.PopupMenu
|
||||
import androidx.core.app.ActivityCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.view.*
|
||||
import androidx.core.view.MenuCompat
|
||||
import androidx.core.view.doOnLayout
|
||||
import androidx.core.view.doOnNextLayout
|
||||
import androidx.core.view.updateLayoutParams
|
||||
import androidx.databinding.DataBindingUtil
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.activityViewModels
|
||||
@@ -92,6 +95,28 @@ import net.vonforst.evmap.utils.checkFineLocationPermission
|
||||
import net.vonforst.evmap.utils.distanceBetween
|
||||
import net.vonforst.evmap.viewmodel.*
|
||||
import java.io.IOException
|
||||
import kotlin.collections.List
|
||||
import kotlin.collections.Set
|
||||
import kotlin.collections.any
|
||||
import kotlin.collections.component1
|
||||
import kotlin.collections.component2
|
||||
import kotlin.collections.contains
|
||||
import kotlin.collections.emptyList
|
||||
import kotlin.collections.filterIsInstance
|
||||
import kotlin.collections.find
|
||||
import kotlin.collections.forEach
|
||||
import kotlin.collections.getOrNull
|
||||
import kotlin.collections.isNotEmpty
|
||||
import kotlin.collections.iterator
|
||||
import kotlin.collections.listOf
|
||||
import kotlin.collections.map
|
||||
import kotlin.collections.mapNotNull
|
||||
import kotlin.collections.set
|
||||
import kotlin.collections.sortedBy
|
||||
import kotlin.collections.sortedByDescending
|
||||
import kotlin.collections.toList
|
||||
import kotlin.collections.toSet
|
||||
import kotlin.collections.toTypedArray
|
||||
|
||||
|
||||
class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallback,
|
||||
@@ -499,9 +524,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 +536,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
||||
highlight = true,
|
||||
fault = charger.faultReport != null,
|
||||
multi = charger.isMulti(vm.filteredConnectors.value),
|
||||
fav = !isFav
|
||||
fav = fav == null
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -642,7 +667,7 @@ 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()
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -657,7 +682,7 @@ 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()
|
||||
)
|
||||
)
|
||||
animator.animateMarkerBounce(marker)
|
||||
@@ -671,7 +696,7 @@ 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()
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -681,7 +706,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)
|
||||
@@ -811,6 +836,7 @@ 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 {
|
||||
@@ -1023,7 +1049,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
||||
highlight = charger == vm.chargerSparse.value,
|
||||
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()
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -1041,7 +1067,8 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
||||
val highlight = charger == vm.chargerSparse.value
|
||||
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()
|
||||
animator.animateMarkerDisappear(marker, tint, highlight, fault, multi, fav)
|
||||
} else {
|
||||
animator.deleteMarker(marker)
|
||||
@@ -1057,7 +1084,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
||||
val highlight = charger == vm.chargerSparse.value
|
||||
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))
|
||||
|
||||
@@ -42,8 +42,8 @@ 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) {
|
||||
"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()
|
||||
}
|
||||
}
|
||||
@@ -47,8 +47,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(),
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -294,11 +329,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,
|
||||
|
||||
@@ -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?
|
||||
}
|
||||
@@ -29,6 +29,12 @@ class PreferenceDataSource(val context: Context) {
|
||||
sp.edit().putBoolean("navigate_use_maps", value).apply()
|
||||
}
|
||||
|
||||
var mapRotateGesturesEnabled: Boolean
|
||||
get() = sp.getBoolean("map_rotate_gestures_enabled", true)
|
||||
set(value) {
|
||||
sp.edit().putBoolean("map_rotate_gestures_enabled", value).apply()
|
||||
}
|
||||
|
||||
var lastGeReferenceDataUpdate: Instant
|
||||
get() = Instant.ofEpochMilli(sp.getLong("last_ge_reference_data_update", 0L))
|
||||
set(value) {
|
||||
@@ -151,6 +157,12 @@ class PreferenceDataSource(val context: Context) {
|
||||
sp.edit().putBoolean("chargeprice_show_provider_customer_tariffs", value).apply()
|
||||
}
|
||||
|
||||
var chargepriceAllowUnbalancedLoad: Boolean
|
||||
get() = sp.getBoolean("chargeprice_allow_unbalanced_load", false)
|
||||
set(value) {
|
||||
sp.edit().putBoolean("chargeprice_allow_unbalanced_load", value).apply()
|
||||
}
|
||||
|
||||
var chargepriceCurrency: String
|
||||
get() = sp.getString("chargeprice_currency", null) ?: "EUR"
|
||||
set(value) {
|
||||
|
||||
@@ -64,7 +64,14 @@ fun invisibleUnless(view: View, visible: Boolean) {
|
||||
|
||||
@BindingAdapter("invisibleUnlessAnimated")
|
||||
fun invisibleUnlessAnimated(view: View, oldValue: Boolean, newValue: Boolean) {
|
||||
if (oldValue == newValue) return
|
||||
if (oldValue == newValue) {
|
||||
if (!newValue && view.visibility == View.VISIBLE && view.alpha == 1f) {
|
||||
// view is initially invisible
|
||||
view.visibility = View.GONE
|
||||
} else {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
view.animate().cancel()
|
||||
if (newValue) {
|
||||
|
||||
@@ -13,12 +13,16 @@ import kotlin.math.max
|
||||
fun getMarkerTint(
|
||||
charger: ChargeLocation,
|
||||
connectors: Set<String>? = null
|
||||
): Int = when {
|
||||
charger.maxPower(connectors) >= 100 -> R.color.charger_100kw
|
||||
charger.maxPower(connectors) >= 43 -> R.color.charger_43kw
|
||||
charger.maxPower(connectors) >= 20 -> R.color.charger_20kw
|
||||
charger.maxPower(connectors) >= 11 -> R.color.charger_11kw
|
||||
else -> R.color.charger_low
|
||||
): Int {
|
||||
val maxPower = charger.maxPower(connectors)
|
||||
return when {
|
||||
maxPower == null -> R.color.charger_low
|
||||
maxPower >= 100 -> R.color.charger_100kw
|
||||
maxPower >= 43 -> R.color.charger_43kw
|
||||
maxPower >= 20 -> R.color.charger_20kw
|
||||
maxPower >= 11 -> R.color.charger_11kw
|
||||
else -> R.color.charger_low
|
||||
}
|
||||
}
|
||||
|
||||
class MarkerAnimator(val gen: ChargerIconGenerator) {
|
||||
|
||||
@@ -26,7 +26,7 @@ class MultiSelectDialogPreference(ctx: Context, attrs: AttributeSet) :
|
||||
// backwards compatibility when changing a ListPreference into a MultiSelectListPreference
|
||||
val value =
|
||||
getPersistedString(null)?.let { setOf(it) } ?: (defaultValue as Set<String>?)
|
||||
sharedPreferences.edit()
|
||||
sharedPreferences!!.edit()
|
||||
.remove(key)
|
||||
.putStringSet(key, value)
|
||||
.apply()
|
||||
@@ -51,8 +51,8 @@ class MultiSelectDialogPreference(ctx: Context, attrs: AttributeSet) :
|
||||
}
|
||||
|
||||
var all: Boolean
|
||||
get() = sharedPreferences.getBoolean(key + "_all", defaultToAll)
|
||||
get() = sharedPreferences!!.getBoolean(key + "_all", defaultToAll)
|
||||
set(value) {
|
||||
sharedPreferences.edit().putBoolean(key + "_all", value).apply()
|
||||
sharedPreferences!!.edit().putBoolean(key + "_all", value).apply()
|
||||
}
|
||||
}
|
||||
@@ -46,14 +46,14 @@ class RangeSliderPreference(context: Context, attrs: AttributeSet) : Preference(
|
||||
private var dragging = false
|
||||
|
||||
var values: List<Float>
|
||||
get() = if ((sharedPreferences.contains(key + "_min") && sharedPreferences.contains(key + "_max"))) {
|
||||
get() = if ((sharedPreferences!!.contains(key + "_min") && sharedPreferences!!.contains(key + "_max"))) {
|
||||
listOf(
|
||||
sharedPreferences.getFloat(key + "_min", 0f),
|
||||
sharedPreferences.getFloat(key + "_max", 0f)
|
||||
sharedPreferences!!.getFloat(key + "_min", 0f),
|
||||
sharedPreferences!!.getFloat(key + "_max", 0f)
|
||||
)
|
||||
} else defaultValue
|
||||
set(value) {
|
||||
sharedPreferences.edit()
|
||||
sharedPreferences!!.edit()
|
||||
.putFloat(key + "_min", value[0])
|
||||
.putFloat(key + "_max", value[1])
|
||||
.apply()
|
||||
|
||||
@@ -15,7 +15,6 @@ import net.vonforst.evmap.model.Chargepoint
|
||||
import net.vonforst.evmap.storage.PreferenceDataSource
|
||||
import retrofit2.HttpException
|
||||
import java.io.IOException
|
||||
import java.util.*
|
||||
|
||||
class ChargepriceViewModel(application: Application, chargepriceApiKey: String) :
|
||||
AndroidViewModel(application) {
|
||||
@@ -245,7 +244,8 @@ class ChargepriceViewModel(application: Application, chargepriceApiKey: String)
|
||||
batteryRange = batteryRange.value!!.map { it.toDouble() },
|
||||
providerCustomerTariffs = prefs.chargepriceShowProviderCustomerTariffs,
|
||||
maxMonthlyFees = if (prefs.chargepriceNoBaseFee) 0.0 else null,
|
||||
currency = prefs.chargepriceCurrency
|
||||
currency = prefs.chargepriceCurrency,
|
||||
allowUnbalancedLoad = prefs.chargepriceAllowUnbalancedLoad
|
||||
)
|
||||
}, ChargepriceApi.getChargepriceLanguage())
|
||||
val meta =
|
||||
|
||||
@@ -5,7 +5,6 @@ import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MediatorLiveData
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import net.vonforst.evmap.api.ChargepointApi
|
||||
import net.vonforst.evmap.api.StringProvider
|
||||
import net.vonforst.evmap.api.goingelectric.GoingElectricApiWrapper
|
||||
import net.vonforst.evmap.api.openchargemap.OpenChargeMapApiWrapper
|
||||
import net.vonforst.evmap.model.*
|
||||
@@ -59,15 +58,6 @@ fun filtersWithValue(
|
||||
}
|
||||
}
|
||||
|
||||
fun ChargepointApi<ReferenceData>.getFilters(
|
||||
referenceData: LiveData<out ReferenceData>,
|
||||
stringProvider: StringProvider
|
||||
) = MediatorLiveData<List<Filter<FilterValue>>>().apply {
|
||||
addSource(referenceData) { data ->
|
||||
value = getFilters(data, stringProvider)
|
||||
}
|
||||
}
|
||||
|
||||
fun FilterValueDao.getFilterValues(filterStatus: LiveData<Long>, dataSource: String) =
|
||||
MediatorLiveData<List<FilterValue>>().apply {
|
||||
var source: LiveData<List<FilterValue>>? = null
|
||||
|
||||
@@ -11,6 +11,8 @@ import net.vonforst.evmap.api.availability.ChargeLocationStatus
|
||||
import net.vonforst.evmap.api.availability.ChargepointStatus
|
||||
import net.vonforst.evmap.api.availability.getAvailability
|
||||
import net.vonforst.evmap.model.ChargeLocation
|
||||
import net.vonforst.evmap.model.Favorite
|
||||
import net.vonforst.evmap.model.FavoriteWithDetail
|
||||
import net.vonforst.evmap.storage.AppDatabase
|
||||
import net.vonforst.evmap.utils.distanceBetween
|
||||
|
||||
@@ -18,8 +20,8 @@ class FavoritesViewModel(application: Application, geApiKey: String) :
|
||||
AndroidViewModel(application) {
|
||||
private var db = AppDatabase.getInstance(application)
|
||||
|
||||
val favorites: LiveData<List<ChargeLocation>> by lazy {
|
||||
db.chargeLocationsDao().getAllChargeLocations()
|
||||
val favorites: LiveData<List<FavoriteWithDetail>> by lazy {
|
||||
db.favoritesDao().getAllFavorites()
|
||||
}
|
||||
|
||||
val location: MutableLiveData<LatLng> by lazy {
|
||||
@@ -28,8 +30,9 @@ class FavoritesViewModel(application: Application, geApiKey: String) :
|
||||
|
||||
val availability: MediatorLiveData<Map<Long, Resource<ChargeLocationStatus>>> by lazy {
|
||||
MediatorLiveData<Map<Long, Resource<ChargeLocationStatus>>>().apply {
|
||||
addSource(favorites) { chargers ->
|
||||
if (chargers != null) {
|
||||
addSource(favorites) { favorites ->
|
||||
if (favorites != null) {
|
||||
val chargers = favorites.map { it.charger }
|
||||
viewModelScope.launch {
|
||||
val data = hashMapOf<Long, Resource<ChargeLocationStatus>>()
|
||||
chargers.forEach { charger ->
|
||||
@@ -54,9 +57,10 @@ class FavoritesViewModel(application: Application, geApiKey: String) :
|
||||
val listData: MediatorLiveData<List<FavoritesListItem>> by lazy {
|
||||
MediatorLiveData<List<FavoritesListItem>>().apply {
|
||||
val callback = { _: Any ->
|
||||
listData.value = favorites.value?.map { charger ->
|
||||
listData.value = favorites.value?.map { favorite ->
|
||||
val charger = favorite.charger
|
||||
FavoritesListItem(
|
||||
charger,
|
||||
favorite,
|
||||
totalAvailable(charger.id),
|
||||
charger.chargepoints.sumBy { it.count },
|
||||
location.value.let { loc ->
|
||||
@@ -78,11 +82,14 @@ class FavoritesViewModel(application: Application, geApiKey: String) :
|
||||
}
|
||||
|
||||
data class FavoritesListItem(
|
||||
val charger: ChargeLocation,
|
||||
val fav: FavoriteWithDetail,
|
||||
val available: Resource<List<ChargepointStatus>>,
|
||||
val total: Int,
|
||||
val distance: Double?
|
||||
) : Equatable
|
||||
) : Equatable {
|
||||
val charger
|
||||
get() = fav.charger
|
||||
}
|
||||
|
||||
private fun totalAvailable(id: Long): Resource<List<ChargepointStatus>> {
|
||||
val availability = availability.value?.get(id) ?: return Resource.error(null, null)
|
||||
@@ -97,12 +104,14 @@ class FavoritesViewModel(application: Application, geApiKey: String) :
|
||||
fun insertFavorite(charger: ChargeLocation) {
|
||||
viewModelScope.launch {
|
||||
db.chargeLocationsDao().insert(charger)
|
||||
db.favoritesDao()
|
||||
.insert(Favorite(chargerId = charger.id, chargerDataSource = charger.dataSource))
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteFavorite(charger: ChargeLocation) {
|
||||
fun deleteFavorite(fav: Favorite) {
|
||||
viewModelScope.launch {
|
||||
db.chargeLocationsDao().delete(charger)
|
||||
db.favoritesDao().delete(fav)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -45,13 +45,15 @@ internal fun getClusterDistance(zoom: Float): Int? {
|
||||
class MapViewModel(application: Application, private val state: SavedStateHandle) :
|
||||
AndroidViewModel(application) {
|
||||
val apiType: Class<ChargepointApi<ReferenceData>>
|
||||
get() = api.javaClass
|
||||
get() = api.value!!.javaClass
|
||||
val apiName: String
|
||||
get() = api.getName()
|
||||
get() = api.value!!.getName()
|
||||
|
||||
private var db = AppDatabase.getInstance(application)
|
||||
private var prefs = PreferenceDataSource(application)
|
||||
private var api: ChargepointApi<ReferenceData> = createApi(prefs.dataSource, application)
|
||||
private var api = MutableLiveData<ChargepointApi<ReferenceData>>().apply {
|
||||
value = createApi(prefs.dataSource, application)
|
||||
}
|
||||
|
||||
val bottomSheetState: MutableLiveData<Int> by lazy {
|
||||
state.getLiveData("bottomSheetState")
|
||||
@@ -71,8 +73,14 @@ class MapViewModel(application: Application, private val state: SavedStateHandle
|
||||
}
|
||||
private val filterValues: LiveData<List<FilterValue>> =
|
||||
db.filterValueDao().getFilterValues(filterStatus, prefs.dataSource)
|
||||
private val referenceData = api.getReferenceData(viewModelScope, application)
|
||||
private val filters = api.getFilters(referenceData, application.stringProvider())
|
||||
private val referenceData =
|
||||
Transformations.switchMap(api) { it.getReferenceData(viewModelScope, application) }
|
||||
private val filters = Transformations.map(referenceData) {
|
||||
api.value!!.getFilters(
|
||||
it,
|
||||
application.stringProvider()
|
||||
)
|
||||
}
|
||||
|
||||
private val filtersWithValue: LiveData<FilterValues> by lazy {
|
||||
filtersWithValue(filters, filterValues)
|
||||
@@ -222,8 +230,8 @@ class MapViewModel(application: Application, private val state: SavedStateHandle
|
||||
}
|
||||
}
|
||||
|
||||
val favorites: LiveData<List<ChargeLocation>> by lazy {
|
||||
db.chargeLocationsDao().getAllChargeLocations()
|
||||
val favorites: LiveData<List<FavoriteWithDetail>> by lazy {
|
||||
db.favoritesDao().getAllFavorites()
|
||||
}
|
||||
|
||||
val searchResult: MutableLiveData<PlaceWithBounds> by lazy {
|
||||
@@ -250,6 +258,7 @@ class MapViewModel(application: Application, private val state: SavedStateHandle
|
||||
|
||||
fun reloadPrefs() {
|
||||
filterStatus.value = prefs.filterStatus
|
||||
api.value = createApi(prefs.dataSource, getApplication())
|
||||
}
|
||||
|
||||
fun toggleFilters() {
|
||||
@@ -279,12 +288,14 @@ class MapViewModel(application: Application, private val state: SavedStateHandle
|
||||
fun insertFavorite(charger: ChargeLocation) {
|
||||
viewModelScope.launch {
|
||||
db.chargeLocationsDao().insert(charger)
|
||||
db.favoritesDao()
|
||||
.insert(Favorite(chargerId = charger.id, chargerDataSource = charger.dataSource))
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteFavorite(charger: ChargeLocation) {
|
||||
fun deleteFavorite(favorite: Favorite) {
|
||||
viewModelScope.launch {
|
||||
db.chargeLocationsDao().delete(charger)
|
||||
db.favoritesDao().delete(favorite)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -304,18 +315,18 @@ class MapViewModel(application: Application, private val state: SavedStateHandle
|
||||
|
||||
val mapPosition = data.first
|
||||
val filters = data.second
|
||||
val api = api
|
||||
val api = api.value!!
|
||||
val refData = data.third
|
||||
|
||||
if (filterStatus.value == FILTERS_FAVORITES) {
|
||||
// load favorites from local DB
|
||||
val b = mapPosition.bounds
|
||||
var chargers = db.chargeLocationsDao().getChargeLocationsInBoundsAsync(
|
||||
var chargers = db.favoritesDao().getFavoritesInBoundsAsync(
|
||||
b.southwest.latitude,
|
||||
b.northeast.latitude,
|
||||
b.southwest.longitude,
|
||||
b.northeast.longitude
|
||||
) as List<ChargepointListItem>
|
||||
).map { it.charger } as List<ChargepointListItem>
|
||||
|
||||
val clusterDistance = getClusterDistance(mapPosition.zoom)
|
||||
clusterDistance?.let {
|
||||
@@ -377,7 +388,7 @@ class MapViewModel(application: Application, private val state: SavedStateHandle
|
||||
chargerLoadingTask?.cancel()
|
||||
chargerLoadingTask = viewModelScope.launch {
|
||||
try {
|
||||
chargerDetails.value = api.getChargepointDetail(referenceData, charger.id)
|
||||
chargerDetails.value = api.value!!.getChargepointDetail(referenceData, charger.id)
|
||||
} catch (e: IOException) {
|
||||
chargerDetails.value = Resource.error(e.message, null)
|
||||
e.printStackTrace()
|
||||
@@ -392,7 +403,7 @@ class MapViewModel(application: Application, private val state: SavedStateHandle
|
||||
override fun onChanged(refData: ReferenceData) {
|
||||
referenceData.removeObserver(this)
|
||||
viewModelScope.launch {
|
||||
val response = api.getChargepointDetail(refData, chargerId)
|
||||
val response = api.value!!.getChargepointDetail(refData, chargerId)
|
||||
chargerDetails.value = response
|
||||
if (response.status == Status.SUCCESS) {
|
||||
chargerSparse.value = response.data
|
||||
|
||||
10
app/src/main/res/drawable/ic_remove.xml
Normal file
10
app/src/main/res/drawable/ic_remove.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,13H5v-2h14v2z" />
|
||||
</vector>
|
||||
@@ -100,6 +100,7 @@
|
||||
android:maxLines="1"
|
||||
android:text="@{charger.data.address.toString()}"
|
||||
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
|
||||
app:invisibleUnless="@{charger.data.address != null}"
|
||||
app:layout_constraintEnd_toStartOf="@+id/guideline2"
|
||||
app:layout_constraintStart_toStartOf="@+id/guideline"
|
||||
app:layout_constraintTop_toBottomOf="@+id/txtName"
|
||||
@@ -277,7 +278,7 @@
|
||||
|
||||
<Button
|
||||
android:id="@+id/sourceButton"
|
||||
style="@style/Widget.Material3.Button.TextButton"
|
||||
style="@style/Widget.Material3.Button.OutlinedButton"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="16dp"
|
||||
|
||||
@@ -102,20 +102,17 @@
|
||||
app:layout_constraintTop_toBottomOf="@+id/connectors_list"
|
||||
tools:text="Charge from 20% to 80%" />
|
||||
|
||||
|
||||
<TextView
|
||||
android:id="@+id/textView4"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="4dp"
|
||||
android:layout_marginTop="8dp"
|
||||
android:text="@{@string/chargeprice_duration(BindingAdaptersKt.time((int) Math.round(vm.chargepriceMetaForChargepoint.data.duration)))}"
|
||||
android:textAppearance="@style/TextAppearance.Material3.TitleSmall"
|
||||
android:textColor="?colorPrimary"
|
||||
android:text="@{@string/chargeprice_stats(vm.chargepriceMetaForChargepoint.data.energy, BindingAdaptersKt.time((int) Math.round(vm.chargepriceMetaForChargepoint.data.duration)), vm.chargepriceMetaForChargepoint.data.power)}"
|
||||
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
|
||||
app:invisibleUnlessAnimated="@{!vm.batteryRangeSliderDragging && vm.chargepriceMetaForChargepoint.status == Status.SUCCESS}"
|
||||
app:layout_constraintStart_toEndOf="@+id/textView2"
|
||||
app:layout_constraintTop_toBottomOf="@+id/connectors_list"
|
||||
tools:text="(25 min)" />
|
||||
|
||||
app:layout_constraintStart_toStartOf="@+id/textView2"
|
||||
app:layout_constraintTop_toBottomOf="@+id/textView2"
|
||||
tools:text="(18 kWh, approx. 23 min, ⌀ 50 kW)" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvVehicleHeader"
|
||||
@@ -155,8 +152,9 @@
|
||||
android:valueFrom="0.0"
|
||||
android:valueTo="100.0"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintHorizontal_bias="0.0"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/textView2"
|
||||
app:layout_constraintTop_toBottomOf="@+id/textView4"
|
||||
app:values="@={vm.batteryRange}" />
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
|
||||
@@ -16,8 +16,8 @@
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="24dp"
|
||||
android:layout_marginEnd="24dp"
|
||||
android:layout_marginBottom="56dp"
|
||||
app:layout_constraintBottom_toTopOf="@+id/btnGetStarted"
|
||||
android:layout_marginBottom="32dp"
|
||||
app:layout_constraintBottom_toTopOf="@+id/dataSourceHint"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintHorizontal_bias="0.5"
|
||||
app:layout_constraintStart_toStartOf="parent" />
|
||||
@@ -65,5 +65,22 @@
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/dataSourceHint"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="32dp"
|
||||
android:layout_marginEnd="32dp"
|
||||
android:layout_marginBottom="32dp"
|
||||
android:gravity="center"
|
||||
android:text="@string/data_sources_hint"
|
||||
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
|
||||
android:textColor="?android:textColorSecondary"
|
||||
android:breakStrategy="balanced"
|
||||
app:layout_constraintBottom_toTopOf="@+id/btnGetStarted"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintHorizontal_bias="0.5"
|
||||
app:layout_constraintStart_toStartOf="parent" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
</ScrollView>
|
||||
@@ -4,7 +4,6 @@
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<data>
|
||||
|
||||
<import type="net.vonforst.evmap.adapter.ConnectorAdapter.ChargepointWithAvailability" />
|
||||
<import type="net.vonforst.evmap.ui.BindingAdaptersKt" />
|
||||
|
||||
@@ -74,6 +73,7 @@
|
||||
android:layout_marginBottom="8dp"
|
||||
android:text="@{item.chargepoint.formatPower()}"
|
||||
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
|
||||
app:goneUnless="@{item.chargepoint.hasKnownPower()}"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
|
||||
@@ -67,6 +67,7 @@
|
||||
android:maxLines="1"
|
||||
android:text="@{item.charger.address.toString()}"
|
||||
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
|
||||
app:invisibleUnless="@{item.charger.address != null}"
|
||||
app:layout_constraintEnd_toStartOf="@+id/textView7"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/textView15"
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:paddingTop="24dp"
|
||||
android:fitsSystemWindows="true"
|
||||
android:id="@+id/nav_header">
|
||||
|
||||
<include layout="@layout/app_logo" />
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
<string name="settings_ui">Oberfläche</string>
|
||||
<string name="settings_map">Karte</string>
|
||||
<string name="copyright">Copyright</string>
|
||||
<string name="copyright_summary">©2020–2021 Johan von Forstner</string>
|
||||
<string name="copyright_summary">©2020–2022 Johan von Forstner</string>
|
||||
<string name="other">Sonstiges</string>
|
||||
<string name="privacy">Datenschutzerklärung</string>
|
||||
<string name="fav_add">Zu Favoriten hinzufügen</string>
|
||||
@@ -190,7 +190,9 @@
|
||||
<string name="pref_chargeprice_show_provider_customer_tariffs_summary">Einige Anbieter bieten für ihre Kunden (z.B. Haushaltsstrom, Gas) günstigere Tarife an</string>
|
||||
<string name="chargeprice_select_car_first">Bitte wähle zuerst dein Auto in den Einstellungen aus.</string>
|
||||
<string name="chargeprice_battery_range">Laden von %1$.0f%% bis %2$.0f%%</string>
|
||||
<string name="chargeprice_duration">(ca. %s)</string>
|
||||
<string name="chargeprice_battery_range_from">Laden von</string>
|
||||
<string name="chargeprice_battery_range_to">bis</string>
|
||||
<string name="chargeprice_stats">(%1$.0f kWh, ca. %2$s, ⌀ %3$.0f kW)</string>
|
||||
<string name="chargeprice_vehicle">Fahrzeug</string>
|
||||
<string name="edit_on_goingelectric_info">Falls hier nur eine leere Seite erscheint, logge dich bitte zuerst bei GoingElectric.de ein.</string>
|
||||
<string name="close">schließen</string>
|
||||
@@ -223,7 +225,7 @@
|
||||
<item quantity="other">%d Tarife ausgewählt</item>
|
||||
</plurals>
|
||||
<string name="unknown_operator">Unbekannter Betreiber</string>
|
||||
<string name="data_sources_description">EVMap unterstützt verschiedene Datenquellen. Bitte wähle aus, welche du nutzen möchtest. Du kannst sie später in den Einstellungen der App ändern.</string>
|
||||
<string name="data_sources_description">EVMap unterstützt verschiedene Datenquellen für Ladestationen. Bitte wähle aus, welche du nutzen möchtest. Du kannst sie später in den Einstellungen der App ändern.</string>
|
||||
<string name="data_source_goingelectric">GoingElectric.de</string>
|
||||
<string name="data_source_openchargemap">Open Charge Map</string>
|
||||
<string name="data_source_goingelectric_desc">Sehr gute Abdeckung in Deutschland, Österreich, Schweiz und vielen angrenzenden Ländern. Beschreibungen in Deutsch. Von der Community gepflegt.</string>
|
||||
@@ -251,4 +253,9 @@
|
||||
<string name="settings_data_sources">Datenquellen</string>
|
||||
<string name="help">Hilfe</string>
|
||||
<string name="settings_android_auto">Android Auto</string>
|
||||
<string name="pref_chargeprice_allow_unbalanced_load">Schieflast erlauben</string>
|
||||
<string name="pref_chargeprice_allow_unbalanced_load_summary"><![CDATA[Erlaubt das Laden mit >4.5 kW an AC-Stationen für Autos mit 1-phasigem Lader]]></string>
|
||||
<string name="pref_map_rotate_gestures_enabled">Kartenrotation erlauben</string>
|
||||
<string name="pref_map_rotate_gestures_on">Karte kann mit Zweifingergeste rotiert werden</string>
|
||||
<string name="pref_map_rotate_gestures_off">Karte bleibt fest nach Norden ausgerichtet</string>
|
||||
</resources>
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
<string name="settings_ui">User Interface</string>
|
||||
<string name="settings_map">Map</string>
|
||||
<string name="copyright">Copyright</string>
|
||||
<string name="copyright_summary">©2020–2021 Johan von Forstner</string>
|
||||
<string name="copyright_summary">©2020–2022 Johan von Forstner</string>
|
||||
<string name="other">Other</string>
|
||||
<string name="privacy">Privacy Notice</string>
|
||||
<string name="fav_add">Add to favorites</string>
|
||||
@@ -189,7 +189,9 @@
|
||||
<string name="pref_chargeprice_show_provider_customer_tariffs">Show customer-exclusive plans</string>
|
||||
<string name="chargeprice_select_car_first">Please first select your car model in the settings.</string>
|
||||
<string name="chargeprice_battery_range">Charge from %1$.0f%% to %2$.0f%%</string>
|
||||
<string name="chargeprice_duration">(approx. %s)</string>
|
||||
<string name="chargeprice_battery_range_from">Charge from</string>
|
||||
<string name="chargeprice_battery_range_to">to</string>
|
||||
<string name="chargeprice_stats">(%1$.0f kWh, approx. %2$s, ⌀ %3$.0f kW)</string>
|
||||
<string name="chargeprice_vehicle">Vehicle</string>
|
||||
<string name="pref_chargeprice_show_provider_customer_tariffs_summary">Some providers offer cheaper plans exclusively to their customers (e.g., household electricity, gas)</string>
|
||||
<string name="close">close</string>
|
||||
@@ -208,7 +210,7 @@
|
||||
<item quantity="other">%d plans selected</item>
|
||||
</plurals>
|
||||
<string name="unknown_operator">Unknown operator</string>
|
||||
<string name="data_sources_description">EVMap supports multiple data sources. Please select the one you would like to use. You can always change it later in the app\'s settings.</string>
|
||||
<string name="data_sources_description">EVMap supports multiple data sources for charging stations. Please select the one you would like to use. You can always change it later in the app\'s settings.</string>
|
||||
<string name="data_source_goingelectric">GoingElectric.de</string>
|
||||
<string name="data_source_openchargemap">Open Charge Map</string>
|
||||
<string name="data_source_goingelectric_desc">Very good coverage in Germany, Austria and Switzerland and many neighboring countries. Descriptions in German. Community-maintained.</string>
|
||||
@@ -236,4 +238,9 @@
|
||||
<string name="settings_data_sources">Data sources</string>
|
||||
<string name="help">Help</string>
|
||||
<string name="settings_android_auto">Android Auto</string>
|
||||
<string name="pref_chargeprice_allow_unbalanced_load">Enable unbalanced load</string>
|
||||
<string name="pref_chargeprice_allow_unbalanced_load_summary"><![CDATA[Allow charging with >4.5 kW at AC stations for cars with single-phase charger]]></string>
|
||||
<string name="pref_map_rotate_gestures_enabled">Enable map rotation</string>
|
||||
<string name="pref_map_rotate_gestures_on">Map can be rotated with two-finger gesture</string>
|
||||
<string name="pref_map_rotate_gestures_off">Map will be fixed to north-up</string>
|
||||
</resources>
|
||||
|
||||
@@ -28,4 +28,10 @@
|
||||
android:summary="@string/pref_chargeprice_show_provider_customer_tariffs_summary"
|
||||
android:defaultValue="false"
|
||||
app:singleLineTitle="false" />
|
||||
<CheckBoxPreference
|
||||
android:key="chargeprice_allow_unbalanced_load"
|
||||
android:title="@string/pref_chargeprice_allow_unbalanced_load"
|
||||
android:summary="@string/pref_chargeprice_allow_unbalanced_load_summary"
|
||||
android:defaultValue="false"
|
||||
app:singleLineTitle="false" />
|
||||
</PreferenceScreen>
|
||||
@@ -15,6 +15,12 @@
|
||||
android:entryValues="@array/pref_darkmode_values"
|
||||
android:defaultValue="default"
|
||||
android:summary="@string/pref_darkmode_summary" />
|
||||
<CheckBoxPreference
|
||||
android:key="map_rotate_gestures_enabled"
|
||||
android:title="@string/pref_map_rotate_gestures_enabled"
|
||||
android:summaryOn="@string/pref_map_rotate_gestures_on"
|
||||
android:summaryOff="@string/pref_map_rotate_gestures_off"
|
||||
android:defaultValue="true" />
|
||||
<CheckBoxPreference
|
||||
android:key="navigate_use_maps"
|
||||
android:title="@string/pref_navigate_use_maps"
|
||||
|
||||
@@ -43,8 +43,8 @@ class NewMotionAvailabilityDetectorTest {
|
||||
"nm/markers" -> {
|
||||
val urlTail = segments.subList(2, segments.size).joinToString("/")
|
||||
val id = when (urlTail) {
|
||||
"9.47108/9.67108/54.4116/54.6116" -> 2105
|
||||
"9.444284/9.644283999999999/54.376699/54.576699000000005" -> 18284
|
||||
"9.56608/9.576080000000001/54.5066/54.516600000000004" -> 2105
|
||||
"9.539283999999999/9.549284/54.471699/54.481699000000006" -> 18284
|
||||
else -> -1
|
||||
}
|
||||
return okResponse("/newmotion/$id/markers.json")
|
||||
@@ -67,7 +67,7 @@ class NewMotionAvailabilityDetectorTest {
|
||||
fun apiTest() {
|
||||
for (chargepoint in listOf(2105L, 18284L)) {
|
||||
val charger = runBlocking { api.getChargepointDetail(chargepoint).body()!! }
|
||||
.chargelocations[0].convert("") as ChargeLocation
|
||||
.chargelocations[0].convert("", true) as ChargeLocation
|
||||
println(charger)
|
||||
|
||||
runBlocking {
|
||||
|
||||
@@ -58,7 +58,7 @@ class ChargepriceApiTest {
|
||||
fun apiTest() {
|
||||
for (chargepoint in listOf(2105L, 18284L)) {
|
||||
val charger = runBlocking { ge.getChargepointDetail(chargepoint).body()!! }
|
||||
.chargelocations[0].convert("") as ChargeLocation
|
||||
.chargelocations[0].convert("", true) as ChargeLocation
|
||||
println(charger)
|
||||
|
||||
runBlocking {
|
||||
|
||||
@@ -3,6 +3,7 @@ package net.vonforst.evmap.api.goingelectric
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import net.vonforst.evmap.notFoundResponse
|
||||
import net.vonforst.evmap.okResponse
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.mockwebserver.Dispatcher
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
@@ -33,10 +34,13 @@ class GoingElectricApiTest {
|
||||
if (id != null) {
|
||||
return okResponse("/chargers/$id.json")
|
||||
} else {
|
||||
val body = request.body.readUtf8()
|
||||
val bodyQuery = "http://host?$body".toHttpUrl()
|
||||
|
||||
val freeparking =
|
||||
request.requestUrl!!.queryParameter("freeparking")!!.toBoolean()
|
||||
bodyQuery.queryParameter("freeparking")!!.toBoolean()
|
||||
val freecharging =
|
||||
request.requestUrl!!.queryParameter("freecharging")!!.toBoolean()
|
||||
bodyQuery.queryParameter("freecharging")!!.toBoolean()
|
||||
return if (freeparking && freecharging) {
|
||||
okResponse("/chargers/list-empty.json")
|
||||
} else if (freecharging) {
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
package net.vonforst.evmap.api.openchargemap
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
import java.time.ZoneOffset
|
||||
import java.time.ZonedDateTime
|
||||
|
||||
class OpenChargeMapAdaptersTest {
|
||||
@Test
|
||||
fun testZonedDateTimeAdapter() {
|
||||
val adapter = ZonedDateTimeAdapter()
|
||||
assertEquals(
|
||||
ZonedDateTime.of(2022, 3, 19, 23, 24, 0, 0, ZoneOffset.UTC),
|
||||
adapter.fromJson("2022-03-19T23:24:00Z")
|
||||
)
|
||||
assertEquals(
|
||||
ZonedDateTime.of(2022, 3, 19, 23, 24, 0, 0, ZoneOffset.UTC),
|
||||
adapter.fromJson("2022-03-19T23:24:00")
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package net.vonforst.evmap.api.openstreetmap
|
||||
|
||||
import com.squareup.moshi.Moshi
|
||||
import net.vonforst.evmap.api.openchargemap.ZonedDateTimeAdapter
|
||||
import net.vonforst.evmap.model.Chargepoint
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
import java.time.Instant
|
||||
import java.time.Month
|
||||
import java.time.ZoneOffset
|
||||
|
||||
const val JSON_SINGLE = "{\n" +
|
||||
" \"id\": 9084665785,\n" +
|
||||
" \"lat\": 46.1137872,\n" +
|
||||
" \"lon\": 7.0778715,\n" +
|
||||
" \"timestamp\": \"2021-09-12T19:36:56Z\",\n" +
|
||||
" \"version\": 1,\n" +
|
||||
" \"user\": \"Voonosm\",\n" +
|
||||
" \"tags\": {\n" +
|
||||
" \"amenity\": \"charging_station\",\n" +
|
||||
" \"authentication:app\": \"yes\",\n" +
|
||||
" \"authentication:contactless\": \"yes\",\n" +
|
||||
" \"bicycle\": \"no\",\n" +
|
||||
" \"capacity\": \"2\",\n" +
|
||||
" \"cover\": \"no\",\n" +
|
||||
" \"fee\": \"yes\",\n" +
|
||||
" \"motorcar\": \"yes\",\n" +
|
||||
" \"network\": \"Swisscharge\",\n" +
|
||||
" \"opening_hours\": \"24/7\",\n" +
|
||||
" \"operator\": \"GOFAST\",\n" +
|
||||
" \"parking:fee\": \"no\",\n" +
|
||||
" \"payment:credit_cards\": \"yes\",\n" +
|
||||
" \"socket:chademo\": \"2\",\n" +
|
||||
" \"socket:chademo:output\": \"60 kW\",\n" +
|
||||
" \"socket:type2\": \"1\",\n" +
|
||||
" \"socket:type2:output\": \"22 kW\",\n" +
|
||||
" \"socket:type2_combo\": \"2\",\n" +
|
||||
" \"socket:type2_combo:output\": \"150 kW\"\n" +
|
||||
" }\n" +
|
||||
"}"
|
||||
|
||||
class OpenStreetMapModelTest {
|
||||
@Test
|
||||
fun parseFromJson() {
|
||||
val moshi = Moshi.Builder()
|
||||
.add(ZonedDateTimeAdapter())
|
||||
.build()
|
||||
val deserialized = moshi
|
||||
.adapter(OSMChargingStation::class.java)
|
||||
.fromJson(JSON_SINGLE)!!
|
||||
assertEquals(9084665785, deserialized.id)
|
||||
assertEquals(1, deserialized.version)
|
||||
assertEquals(12, deserialized.lastUpdateTimestamp.dayOfMonth)
|
||||
assertEquals(Month.SEPTEMBER, deserialized.lastUpdateTimestamp.month)
|
||||
assertEquals(36, deserialized.lastUpdateTimestamp.minute)
|
||||
assertEquals(ZoneOffset.UTC, deserialized.lastUpdateTimestamp.offset)
|
||||
assertEquals("Swisscharge", deserialized.tags["network"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun convert() {
|
||||
val osmChargingStation = Moshi.Builder()
|
||||
.add(ZonedDateTimeAdapter())
|
||||
.build()
|
||||
.adapter(OSMChargingStation::class.java)
|
||||
.fromJson(JSON_SINGLE)!!
|
||||
val now = Instant.now()
|
||||
val chargeLocation = osmChargingStation.convert(now)
|
||||
|
||||
// Basics
|
||||
assertEquals("openstreetmap", chargeLocation.dataSource)
|
||||
assertEquals("https://www.openstreetmap.org/node/9084665785", chargeLocation.url)
|
||||
assertEquals(true, chargeLocation.openinghours?.twentyfourSeven)
|
||||
assertEquals("GOFAST", chargeLocation.name) // Fallback to operator because name is not set
|
||||
assertEquals(false, chargeLocation.barrierFree) // False because `authentication:none` isn't set
|
||||
assertEquals(now, chargeLocation.timeRetrieved)
|
||||
|
||||
// Cost
|
||||
assertEquals(false, chargeLocation.cost?.freecharging)
|
||||
assertEquals(true, chargeLocation.cost?.freeparking)
|
||||
|
||||
// Chargepoints
|
||||
assertEquals(3, chargeLocation.chargepoints.size)
|
||||
val ccs = chargeLocation.chargepoints.single { it.type == Chargepoint.CCS_TYPE_2 }
|
||||
val type2 = chargeLocation.chargepoints.single { it.type == Chargepoint.TYPE_2_SOCKET }
|
||||
val chademo = chargeLocation.chargepoints.single { it.type == Chargepoint.CHADEMO }
|
||||
assertEquals(2, ccs.count)
|
||||
assertEquals(150.0, ccs.power)
|
||||
assertEquals(1, type2.count)
|
||||
assertEquals(22.0, type2.power)
|
||||
assertEquals(2, chademo.count)
|
||||
assertEquals(60.0, chademo.power)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseOutputPower() {
|
||||
// Null input -> null output
|
||||
assertNull(OSMChargingStation.parseOutputPower(null))
|
||||
|
||||
// Invalid input -> null output
|
||||
assertNull(OSMChargingStation.parseOutputPower(""))
|
||||
assertNull(OSMChargingStation.parseOutputPower("a"))
|
||||
assertNull(OSMChargingStation.parseOutputPower("22 A"))
|
||||
|
||||
// Invalid number -> null output
|
||||
assertNull(OSMChargingStation.parseOutputPower("22.0.1 kW"))
|
||||
|
||||
// Valid output power values
|
||||
assertEquals(22.0, OSMChargingStation.parseOutputPower("22 kW"))
|
||||
assertEquals(22.0, OSMChargingStation.parseOutputPower("22 kVA"))
|
||||
assertEquals(22.0, OSMChargingStation.parseOutputPower("22. kW"))
|
||||
assertEquals(22.0, OSMChargingStation.parseOutputPower("22.0 kW"))
|
||||
assertEquals(22.0, OSMChargingStation.parseOutputPower("22,0 kW"))
|
||||
assertEquals(22.0, OSMChargingStation.parseOutputPower("22kW"))
|
||||
assertEquals(22.0, OSMChargingStation.parseOutputPower("22 kW"))
|
||||
}
|
||||
}
|
||||
@@ -3,14 +3,14 @@
|
||||
buildscript {
|
||||
ext.kotlin_version = '1.5.31'
|
||||
ext.about_libs_version = '8.9.4'
|
||||
ext.nav_version = '2.4.0-rc01'
|
||||
ext.nav_version = '2.4.1'
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
dependencies {
|
||||
classpath 'com.android.tools.build:gradle:7.0.4'
|
||||
classpath 'com.android.tools.build:gradle:7.1.3'
|
||||
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
|
||||
classpath "com.mikepenz.aboutlibraries.plugin:aboutlibraries-plugin:$about_libs_version"
|
||||
classpath "androidx.navigation:navigation-safe-args-gradle-plugin:$nav_version"
|
||||
|
||||
11
fastlane/metadata/android/de-DE/changelogs/76.txt
Normal file
11
fastlane/metadata/android/de-DE/changelogs/76.txt
Normal file
@@ -0,0 +1,11 @@
|
||||
Verbesserungen:
|
||||
- Unterstützung für Fahrzeuge mit Android Automotive OS (z.B. Volvo XC40, Polestar 2, Renault Mégane)
|
||||
- Android Auto: schnellere Ladezeiten durch dynamisches Laden des Echtzeitstatus
|
||||
- Android Auto: zusätzlicher Bildschirm für Einstellungen
|
||||
- Android Auto: Favoriten speichern
|
||||
- Zusätzliche Datenquelle für Livedaten
|
||||
- Anpassungen im Hintergrund in Vorbereitung auf zukünftige neue Funktionen
|
||||
|
||||
Fehler behoben:
|
||||
- Abstürze unter Android Auto behoben
|
||||
- Wechsel der Datenquelle wurde erst nach Neustart der App übernommen
|
||||
6
fastlane/metadata/android/de-DE/changelogs/78.txt
Normal file
6
fastlane/metadata/android/de-DE/changelogs/78.txt
Normal file
@@ -0,0 +1,6 @@
|
||||
Verbesserungen:
|
||||
- Neue Einstellung zum Deaktivieren der Kartenrotation
|
||||
|
||||
Fehler behoben:
|
||||
- Fehler bei Filter nach vielen Verbünden behoben (GoingElectric)
|
||||
- geringfügige Verbesserung bei Echtzeitdaten
|
||||
11
fastlane/metadata/android/en-US/changelogs/76.txt
Normal file
11
fastlane/metadata/android/en-US/changelogs/76.txt
Normal file
@@ -0,0 +1,11 @@
|
||||
Improvements:
|
||||
- Support for Android Automotive OS vehicles (e.g. Volvo XC40, Polestar 2, Renault Mégane)
|
||||
- Android Auto: faster loading times by loading real-time status dynamically
|
||||
- Android Auto: new settings screen
|
||||
- Android Auto: save favorites
|
||||
- Additional data source for real-time data
|
||||
- Backend changes in preparation for future new features
|
||||
|
||||
Bugfixes:
|
||||
- Fixed crashes in Android Auto
|
||||
- Changing data source was only applied after app restart
|
||||
6
fastlane/metadata/android/en-US/changelogs/78.txt
Normal file
6
fastlane/metadata/android/en-US/changelogs/78.txt
Normal file
@@ -0,0 +1,6 @@
|
||||
Improvements:
|
||||
- New option to disable map rotation
|
||||
|
||||
Bugfixes:
|
||||
- Fixed bug when filtering for many networks (GoingElectric)
|
||||
- Minor improvements for realtime data
|
||||
2
gradle/wrapper/gradle-wrapper.properties
vendored
2
gradle/wrapper/gradle-wrapper.properties
vendored
@@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-7.0.2-bin.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-7.2-bin.zip
|
||||
|
||||
Reference in New Issue
Block a user