mirror of
https://github.com/ev-map/EVMap.git
synced 2025-12-24 07:37:46 -05:00
Compare commits
134 Commits
1.9.18
...
openstreet
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
93ff5592e6 | ||
|
|
fbdda89219 | ||
|
|
f409ded73a | ||
|
|
d5a0e16675 | ||
|
|
99a93b202b | ||
|
|
32726993de | ||
|
|
2218cc6b3a | ||
|
|
66564d81d7 | ||
|
|
eb819a793e | ||
|
|
deb101bcee | ||
|
|
176664d7ab | ||
|
|
7327943ec4 | ||
|
|
61a7b358c0 | ||
|
|
1845508512 | ||
|
|
5e13134ee7 | ||
|
|
b193e27022 | ||
|
|
ba601a8027 | ||
|
|
26de099baa | ||
|
|
4cc54bd376 | ||
|
|
4c5cc187cd | ||
|
|
5a6c185a31 | ||
|
|
4f02935b45 | ||
|
|
e7ecb37040 | ||
|
|
cc40b7e988 | ||
|
|
8b02660c34 | ||
|
|
6e5f894c8a | ||
|
|
2d8327f6a6 | ||
|
|
964a65611b | ||
|
|
1f804efc04 | ||
|
|
44c7e1a705 | ||
|
|
528454cd2c | ||
|
|
868901d592 | ||
|
|
e934c2b7d9 | ||
|
|
08ccd593e6 | ||
|
|
9b924af4ff | ||
|
|
3d16a90579 | ||
|
|
36916fa8e3 | ||
|
|
3ef69ebb02 | ||
|
|
33fe7c0da3 | ||
|
|
1fee260d1c | ||
|
|
f1bb37ea68 | ||
|
|
0680bdab21 | ||
|
|
d044de1231 | ||
|
|
667914f9d7 | ||
|
|
8de16d1130 | ||
|
|
68ca30ef4e | ||
|
|
a0c4a8d4c1 | ||
|
|
18b7127034 | ||
|
|
5913d6a912 | ||
|
|
0615d06680 | ||
|
|
463de7365d | ||
|
|
aa00f8b7f0 | ||
|
|
31754eca0c | ||
|
|
ac8fc813f8 | ||
|
|
4c202d7ff2 | ||
|
|
2e29e1f108 | ||
|
|
363345f9c2 | ||
|
|
2074c73bf7 | ||
|
|
fae373d125 | ||
|
|
ff388a0d9b | ||
|
|
002a6c4b3b | ||
|
|
911358eca0 | ||
|
|
a43a8c8e60 | ||
|
|
380150c851 | ||
|
|
e6c3ebdcde | ||
|
|
5fb58d25bc | ||
|
|
948fb137c1 | ||
|
|
4578311d1f | ||
|
|
7a9cdcac21 | ||
|
|
9df61042af | ||
|
|
7ff21d9b5f | ||
|
|
c6490471de | ||
|
|
75ba9d9978 | ||
|
|
1637d20806 | ||
|
|
5f60b73712 | ||
|
|
60871904be | ||
|
|
bb240afd21 | ||
|
|
8c6c27fd28 | ||
|
|
03728f0677 | ||
|
|
cfe40e1972 | ||
|
|
a51052d9a3 | ||
|
|
36af713252 | ||
|
|
77245c1e10 | ||
|
|
bc90d23bd3 | ||
|
|
a6c482d5d3 | ||
|
|
ca829be72b | ||
|
|
09980598f7 | ||
|
|
08f617d23b | ||
|
|
968bffd3aa | ||
|
|
0ea0a2454e | ||
|
|
6fc7652079 | ||
|
|
eacdb7294f | ||
|
|
9e63c0dcf8 | ||
|
|
cf20dc4089 | ||
|
|
bfe1667a69 | ||
|
|
069b825ee7 | ||
|
|
b058b3399c | ||
|
|
fcad13758a | ||
|
|
5f2bf26bc2 | ||
|
|
1e7bcd73a9 | ||
|
|
5ebd0a8abe | ||
|
|
a1fb480ff0 | ||
|
|
cafc477964 | ||
|
|
c90f32f1ff | ||
|
|
0b266ee0aa | ||
|
|
d0e8a24e5c | ||
|
|
8cda9ee469 | ||
|
|
ea3c552bca | ||
|
|
ffdcecd6ab | ||
|
|
2650086594 | ||
|
|
aa07da1002 | ||
|
|
ce618f81e4 | ||
|
|
9d196f645b | ||
|
|
d28f14b932 | ||
|
|
35002aad86 | ||
|
|
7601140bb8 | ||
|
|
dde3ff26ba | ||
|
|
8cbf0a0589 | ||
|
|
0382f63e72 | ||
|
|
23d47142c0 | ||
|
|
5130b93e99 | ||
|
|
520d753b16 | ||
|
|
be4115af33 | ||
|
|
17efe716d5 | ||
|
|
32c32ed934 | ||
|
|
e911eb78c4 | ||
|
|
efacd462db | ||
|
|
41be3e0180 | ||
|
|
8a5ad2b1e8 | ||
|
|
b723556dd9 | ||
|
|
17a82591a5 | ||
|
|
fa040928e8 | ||
|
|
c11178810e | ||
|
|
ef8bce5197 |
52
.github/workflows/tests.yml
vendored
52
.github/workflows/tests.yml
vendored
@@ -26,7 +26,7 @@ jobs:
|
||||
cache: 'gradle'
|
||||
|
||||
- name: Copy apikeys.xml
|
||||
run: cp .github/workflows/apikeys-ci.xml app/src/main/res/values/apikeys.xml
|
||||
run: cp _ci/apikeys-ci.xml app/src/main/res/values/apikeys.xml
|
||||
|
||||
- name: Build app
|
||||
run: ./gradlew assemble${{ matrix.buildvariant }}Debug --no-daemon
|
||||
@@ -36,3 +36,53 @@ jobs:
|
||||
run: ./gradlew lint${{ matrix.buildvariant }}Debug --no-daemon
|
||||
- name: Check licenses
|
||||
run: ./gradlew exportLibraryDefinitions --no-daemon
|
||||
|
||||
apk_check:
|
||||
name: Release APK checks (${{ matrix.buildvariant }})
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
buildvariant: [ FossNormal, FossAutomotive, GoogleNormal, GoogleAutomotive ]
|
||||
|
||||
steps:
|
||||
- name: Install checksec
|
||||
run: sudo apt install -y checksec
|
||||
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Java environment
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
java-version: 17
|
||||
distribution: 'zulu'
|
||||
cache: 'gradle'
|
||||
|
||||
- name: Copy apikeys.xml
|
||||
run: cp _ci/apikeys-ci.xml app/src/main/res/values/apikeys.xml
|
||||
|
||||
- name: Build app
|
||||
run: ./gradlew assemble${{ matrix.buildvariant }}Release --no-daemon
|
||||
|
||||
- name: Unpack native libraries from APK
|
||||
run: |
|
||||
VARIANT_FILENAME=$(echo ${{ matrix.buildvariant }} | sed -E 's/([a-z])([A-Z])/\1-\2/g' | tr 'A-Z' 'a-z')
|
||||
VARIANT_FOLDER=$(echo ${{ matrix.buildvariant }} | sed -E 's/^([A-Z])/\L\1/')
|
||||
APK_FILE="app/build/outputs/apk/$VARIANT_FOLDER/release/app-$VARIANT_FILENAME-release-unsigned.apk"
|
||||
unzip $APK_FILE "lib/*"
|
||||
|
||||
- name: Run checksec on native libraries
|
||||
run: |
|
||||
checksec --output=json --dir=lib > checksec_output.json
|
||||
jq --argjson exceptions '[
|
||||
"lib/armeabi-v7a/libc++_shared.so",
|
||||
"lib/x86/libc++_shared.so"
|
||||
]' '
|
||||
to_entries
|
||||
| map(select(.value.fortify_source == "no" and (.key as $lib | $exceptions | index($lib) | not)))
|
||||
| if length > 0 then
|
||||
error("The following libraries do not have fortify enabled (and are not in the exception list): " + (map(.key) | join(", ")))
|
||||
else
|
||||
"All libraries have fortify enabled or are in the exception list."
|
||||
end
|
||||
' checksec_output.json
|
||||
|
||||
@@ -86,14 +86,13 @@ Sponsors
|
||||
Many users currently support the development EVMap with their donations. You can find more
|
||||
information on the [Donate page](https://ev-map.app/donate/) on the EVMap website.
|
||||
|
||||
<a href="https://www.jawg.io"><img src="https://www.jawg.io/static/Blue@10x-9cdc4596e4e59acbd9ead55e9c28613e.png" alt="JawgMaps" height="58"/></a><br>
|
||||
<a href="https://www.jawg.io"><img src="https://www.jawg.io/static/Blue@10x-9cdc4596e4e59acbd9ead55e9c28613e.png" alt="JawgMaps" height="38"/></a><br>
|
||||
Since May 2024, **JawgMaps** provides their OpenStreetMap vector map tiles service to EVMap for
|
||||
free, i.e. the background map displayed in the app if OpenStreetMap is selected as the data source.
|
||||
|
||||
<a href="https://chargeprice.app"><img src="https://raw.githubusercontent.com/ev-map/EVMap/master/_img/powered_by_chargeprice.svg" alt="Powered by Chargeprice" height="58"/></a><br>
|
||||
<a href="https://chargeprice.app"><img src="https://raw.githubusercontent.com/ev-map/EVMap/master/_img/powered_by_chargeprice.svg" alt="Powered by Chargeprice" height="38"/></a><br>
|
||||
Since April 2021, **Chargeprice.app** provide their price comparison API at a greatly reduced
|
||||
price for EVMap. This data is used in EVMap's price comparison feature.
|
||||
|
||||
<a href="https://fronyx.io/"><img src="https://github.com/ev-map/EVMap/blob/master/_img/powered_by_fronyx.svg" alt="Powered by Fronyx" height="68"/></a><br>
|
||||
Since September 2022, for certain charging stations, **Fronyx** provide us free access to their API
|
||||
for availability predictions.
|
||||
<a href="https://www.jetbrains.com/community/opensource/"><img src="https://resources.jetbrains.com/storage/products/company/brand/logos/jetbrains.svg" alt="JetBrains logo" height="38"/></a><br>
|
||||
As part of its support program for Open-source projects, **JetBrains** supports the development of EVMap since December 2023 with a license of their software suite.
|
||||
|
||||
14
_img/appicon_monochrome.svg
Normal file
14
_img/appicon_monochrome.svg
Normal file
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Ebene_1" data-name="Ebene 1" xmlns="http://www.w3.org/2000/svg" version="1.1"
|
||||
viewBox="0 0 108 108">
|
||||
<defs>
|
||||
<style>
|
||||
.cls-1 {
|
||||
fill: #000;
|
||||
stroke-width: 0px;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<path class="cls-1"
|
||||
d="M53.9,28c-8.8,0-15.9,7.1-15.9,15.9s13.4,18.2,15,35.3c0,.5.5.9,1,.9s.9-.4,1-.9c1.6-17.1,15-23.3,15-35.3-.1-8.8-7.2-15.9-16-15.9ZM59,43.1l-6.1,10.5v-7.9h-2.6v-9.6s8.8,0,8.7,0l-3.5,7h3.5Z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 529 B |
4
_img/connectors/connector_unknown.svg
Normal file
4
_img/connectors/connector_unknown.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M11,18H13V16H11V18M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2M12,20C7.59,20 4,16.41 4,12C4,7.59 7.59,4 12,4C16.41,4 20,7.59 20,12C20,16.41 16.41,20 12,20M12,6A4,4 0 0,0 8,10H10A2,2 0 0,1 12,8A2,2 0 0,1 14,10C14,12 11,11.75 11,15H13C13,12.75 16,12.5 16,10A4,4 0 0,0 12,6Z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 395 B |
@@ -6,6 +6,7 @@ plugins {
|
||||
id("kotlin-android")
|
||||
id("kotlin-parcelize")
|
||||
id("kotlin-kapt")
|
||||
id("com.google.devtools.ksp").version("2.0.21-1.0.28")
|
||||
id("androidx.navigation.safeargs.kotlin")
|
||||
id("com.mikepenz.aboutlibraries.plugin")
|
||||
}
|
||||
@@ -16,20 +17,26 @@ android {
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "net.vonforst.evmap"
|
||||
compileSdk = 34
|
||||
compileSdk = 35
|
||||
minSdk = 21
|
||||
targetSdk = 34
|
||||
targetSdk = 35
|
||||
// NOTE: always increase versionCode by 2 since automotive flavor uses versionCode + 1
|
||||
versionCode = 230
|
||||
versionName = "1.9.6"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
|
||||
ksp {
|
||||
arg("room.schemaLocation", "$projectDir/schemas")
|
||||
}
|
||||
}
|
||||
|
||||
val isRunningOnCI = System.getenv("CI") == "true"
|
||||
val isCIKeystoreAvailable = System.getenv("KEYSTORE_PASSWORD") != null
|
||||
|
||||
signingConfigs {
|
||||
create("release") {
|
||||
val isRunningOnCI = System.getenv("CI") == "true"
|
||||
if (isRunningOnCI) {
|
||||
if (isRunningOnCI && isCIKeystoreAvailable) {
|
||||
// configure keystore
|
||||
storeFile = file("../_ci/keystore.jks")
|
||||
storePassword = System.getenv("KEYSTORE_PASSWORD")
|
||||
@@ -46,7 +53,11 @@ android {
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
)
|
||||
signingConfig = signingConfigs.getByName("release")
|
||||
signingConfig = if (isRunningOnCI && !isCIKeystoreAvailable) {
|
||||
null
|
||||
} else {
|
||||
signingConfigs.getByName("release")
|
||||
}
|
||||
}
|
||||
create("releaseAutomotivePackageName") {
|
||||
// Faurecia Aptoide requires the automotive variant to use a separate package name
|
||||
@@ -67,6 +78,7 @@ android {
|
||||
productFlavors {
|
||||
create("foss") {
|
||||
dimension = "dependencies"
|
||||
isDefault = true
|
||||
}
|
||||
create("google") {
|
||||
dimension = "dependencies"
|
||||
@@ -74,6 +86,7 @@ android {
|
||||
}
|
||||
create("normal") {
|
||||
dimension = "automotive"
|
||||
isDefault = true
|
||||
}
|
||||
create("automotive") {
|
||||
dimension = "automotive"
|
||||
@@ -85,18 +98,12 @@ android {
|
||||
|
||||
compileOptions {
|
||||
isCoreLibraryDesugaringEnabled = true
|
||||
targetCompatibility = JavaVersion.VERSION_1_8
|
||||
sourceCompatibility = JavaVersion.VERSION_1_8
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = JavaVersion.VERSION_1_8.toString()
|
||||
}
|
||||
|
||||
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KaptGenerateStubs>().configureEach {
|
||||
kotlinOptions {
|
||||
jvmTarget = "1.8"
|
||||
}
|
||||
jvmTarget = JavaVersion.VERSION_17.toString()
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
@@ -257,10 +264,11 @@ aboutLibraries {
|
||||
"Dual OpenSSL and SSLeay License", // Android NDK OpenSSL
|
||||
"Google Maps Platform Terms of Service", // Google Maps SDK
|
||||
"provided without support or warranty", // org.json
|
||||
"Unicode/ICU License", // icu4j
|
||||
"Unicode/ICU License", "Unicode-3.0", // icu4j
|
||||
"Bouncy Castle Licence", // bcprov
|
||||
"CDDL + GPLv2 with classpath exception", // javax.annotation-api
|
||||
)
|
||||
excludeFields = arrayOf("generated")
|
||||
strictMode = com.mikepenz.aboutlibraries.plugin.StrictMode.FAIL
|
||||
}
|
||||
|
||||
@@ -283,10 +291,11 @@ dependencies {
|
||||
implementation("androidx.cardview:cardview:1.0.0")
|
||||
implementation("androidx.preference:preference-ktx:1.2.1")
|
||||
implementation("com.google.android.material:material:1.12.0")
|
||||
implementation("androidx.constraintlayout:constraintlayout:2.1.4")
|
||||
implementation("androidx.constraintlayout:constraintlayout:2.2.1")
|
||||
implementation("androidx.recyclerview:recyclerview:1.3.2")
|
||||
implementation("androidx.browser:browser:1.8.0")
|
||||
implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0")
|
||||
implementation("androidx.viewpager2:viewpager2:1.1.0")
|
||||
implementation("androidx.security:security-crypto:1.1.0-alpha06")
|
||||
implementation("androidx.work:work-runtime-ktx:2.9.0")
|
||||
implementation("com.github.ev-map:CustomBottomSheetBehavior:e48f73ea7b")
|
||||
@@ -294,8 +303,8 @@ dependencies {
|
||||
implementation("com.squareup.retrofit2:converter-moshi:2.9.0")
|
||||
implementation("com.squareup.okhttp3:okhttp:4.12.0")
|
||||
implementation("com.squareup.okhttp3:okhttp-urlconnection:4.12.0")
|
||||
implementation("com.squareup.moshi:moshi-kotlin:1.15.0")
|
||||
implementation("com.squareup.moshi:moshi-adapters:1.15.0")
|
||||
implementation("com.squareup.moshi:moshi-kotlin:1.15.2")
|
||||
implementation("com.squareup.moshi:moshi-adapters:1.15.2")
|
||||
implementation("com.markomilos.jsonapi:jsonapi-retrofit:1.1.0")
|
||||
implementation("io.coil-kt:coil:2.6.0")
|
||||
implementation("com.github.ev-map:StfalconImageViewer:5082ebd392")
|
||||
@@ -309,13 +318,13 @@ dependencies {
|
||||
implementation("com.github.erfansn:locale-config-x:1.0.1")
|
||||
|
||||
// Android Auto
|
||||
val carAppVersion = "1.4.0"
|
||||
val carAppVersion = "1.7.0-rc01"
|
||||
implementation("androidx.car.app:app:$carAppVersion")
|
||||
normalImplementation("androidx.car.app:app-projected:$carAppVersion")
|
||||
automotiveImplementation("androidx.car.app:app-automotive:$carAppVersion")
|
||||
|
||||
// AnyMaps
|
||||
val anyMapsVersion = "3e6c71410f"
|
||||
val anyMapsVersion = "1174ef9375"
|
||||
implementation("com.github.ev-map.AnyMaps:anymaps-base:$anyMapsVersion")
|
||||
googleImplementation("com.github.ev-map.AnyMaps:anymaps-google:$anyMapsVersion")
|
||||
googleImplementation("com.google.android.gms:play-services-maps:19.0.0")
|
||||
@@ -323,7 +332,7 @@ dependencies {
|
||||
// duplicates classes from mapbox-sdk-services
|
||||
exclude("org.maplibre.gl", "android-sdk-geojson")
|
||||
}
|
||||
implementation("org.maplibre.gl:android-sdk:10.3.2-pre3") {
|
||||
implementation("org.maplibre.gl:android-sdk:10.3.4") {
|
||||
exclude("org.maplibre.gl", "android-sdk-geojson")
|
||||
}
|
||||
|
||||
@@ -344,11 +353,16 @@ dependencies {
|
||||
implementation("androidx.lifecycle:lifecycle-livedata-ktx:$lifecycle_version")
|
||||
|
||||
// room library
|
||||
val room_version = "2.6.1"
|
||||
val room_version = "2.7.1"
|
||||
implementation("androidx.room:room-runtime:$room_version")
|
||||
kapt("androidx.room:room-compiler:$room_version")
|
||||
ksp("androidx.room:room-compiler:$room_version")
|
||||
implementation("androidx.room:room-ktx:$room_version")
|
||||
implementation("com.github.anboralabs:spatia-room:0.3.0")
|
||||
implementation("com.github.anboralabs:spatia-room:0.3.0") {
|
||||
exclude("com.github.dalgarins", "android-spatialite")
|
||||
}
|
||||
// forked version with upgraded sqlite & libxml
|
||||
// https://github.com/dalgarins/android-spatialite/pull/10
|
||||
implementation("com.github.ev-map:android-spatialite:31495dcd81")
|
||||
|
||||
// billing library
|
||||
val billing_version = "7.0.0"
|
||||
@@ -365,6 +379,8 @@ dependencies {
|
||||
debugImplementation("com.facebook.flipper:flipper:0.238.0")
|
||||
debugImplementation("com.facebook.soloader:soloader:0.10.5")
|
||||
debugImplementation("com.facebook.flipper:flipper-network-plugin:0.238.0")
|
||||
debugImplementation("com.jakewharton.timber:timber:5.0.1")
|
||||
debugImplementation("com.squareup.leakcanary:leakcanary-android:2.14")
|
||||
|
||||
// testing
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
@@ -381,7 +397,7 @@ dependencies {
|
||||
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
|
||||
androidTestImplementation("androidx.arch.core:core-testing:2.2.0")
|
||||
|
||||
kapt("com.squareup.moshi:moshi-kotlin-codegen:1.15.0")
|
||||
ksp("com.squareup.moshi:moshi-kotlin-codegen:1.15.2")
|
||||
|
||||
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.4")
|
||||
}
|
||||
|
||||
904
app/schemas/net.vonforst.evmap.storage.AppDatabase/22.json
Normal file
904
app/schemas/net.vonforst.evmap.storage.AppDatabase/22.json
Normal file
@@ -0,0 +1,904 @@
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 22,
|
||||
"identityHash": "5dbaaa5adf8cb9b6e8a8314bb7766447",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "ChargeLocation",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `dataSource` TEXT NOT NULL, `name` TEXT NOT NULL, `coordinates` BLOB NOT NULL, `chargepoints` TEXT NOT NULL, `network` TEXT, `url` TEXT NOT NULL, `editUrl` TEXT, `verified` INTEGER NOT NULL, `barrierFree` INTEGER, `operator` TEXT, `generalInformation` TEXT, `amenities` TEXT, `locationDescription` TEXT, `photos` TEXT, `chargecards` TEXT, `license` TEXT, `networkUrl` TEXT, `chargerUrl` TEXT, `timeRetrieved` INTEGER NOT NULL, `isDetailed` INTEGER NOT NULL, `city` TEXT, `country` TEXT, `postcode` TEXT, `street` TEXT, `fault_report_created` INTEGER, `fault_report_description` TEXT, `twentyfourSeven` INTEGER, `description` TEXT, `mostart` TEXT, `moend` TEXT, `tustart` TEXT, `tuend` TEXT, `westart` TEXT, `weend` TEXT, `thstart` TEXT, `thend` TEXT, `frstart` TEXT, `frend` TEXT, `sastart` TEXT, `saend` TEXT, `sustart` TEXT, `suend` TEXT, `hostart` TEXT, `hoend` TEXT, `freecharging` INTEGER, `freeparking` INTEGER, `descriptionShort` TEXT, `descriptionLong` TEXT, `chargepricecountry` TEXT, `chargepricenetwork` TEXT, `chargepriceplugTypes` TEXT, PRIMARY KEY(`id`, `dataSource`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "dataSource",
|
||||
"columnName": "dataSource",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "name",
|
||||
"columnName": "name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "coordinates",
|
||||
"columnName": "coordinates",
|
||||
"affinity": "BLOB",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "chargepoints",
|
||||
"columnName": "chargepoints",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "network",
|
||||
"columnName": "network",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "url",
|
||||
"columnName": "url",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "editUrl",
|
||||
"columnName": "editUrl",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "verified",
|
||||
"columnName": "verified",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "barrierFree",
|
||||
"columnName": "barrierFree",
|
||||
"affinity": "INTEGER"
|
||||
},
|
||||
{
|
||||
"fieldPath": "operator",
|
||||
"columnName": "operator",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "generalInformation",
|
||||
"columnName": "generalInformation",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "amenities",
|
||||
"columnName": "amenities",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "locationDescription",
|
||||
"columnName": "locationDescription",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "photos",
|
||||
"columnName": "photos",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "chargecards",
|
||||
"columnName": "chargecards",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "license",
|
||||
"columnName": "license",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "networkUrl",
|
||||
"columnName": "networkUrl",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "chargerUrl",
|
||||
"columnName": "chargerUrl",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "timeRetrieved",
|
||||
"columnName": "timeRetrieved",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "isDetailed",
|
||||
"columnName": "isDetailed",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "address.city",
|
||||
"columnName": "city",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "address.country",
|
||||
"columnName": "country",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "address.postcode",
|
||||
"columnName": "postcode",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "address.street",
|
||||
"columnName": "street",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "faultReport.created",
|
||||
"columnName": "fault_report_created",
|
||||
"affinity": "INTEGER"
|
||||
},
|
||||
{
|
||||
"fieldPath": "faultReport.description",
|
||||
"columnName": "fault_report_description",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "openinghours.twentyfourSeven",
|
||||
"columnName": "twentyfourSeven",
|
||||
"affinity": "INTEGER"
|
||||
},
|
||||
{
|
||||
"fieldPath": "openinghours.description",
|
||||
"columnName": "description",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "openinghours.days.monday.start",
|
||||
"columnName": "mostart",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "openinghours.days.monday.end",
|
||||
"columnName": "moend",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "openinghours.days.tuesday.start",
|
||||
"columnName": "tustart",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "openinghours.days.tuesday.end",
|
||||
"columnName": "tuend",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "openinghours.days.wednesday.start",
|
||||
"columnName": "westart",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "openinghours.days.wednesday.end",
|
||||
"columnName": "weend",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "openinghours.days.thursday.start",
|
||||
"columnName": "thstart",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "openinghours.days.thursday.end",
|
||||
"columnName": "thend",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "openinghours.days.friday.start",
|
||||
"columnName": "frstart",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "openinghours.days.friday.end",
|
||||
"columnName": "frend",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "openinghours.days.saturday.start",
|
||||
"columnName": "sastart",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "openinghours.days.saturday.end",
|
||||
"columnName": "saend",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "openinghours.days.sunday.start",
|
||||
"columnName": "sustart",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "openinghours.days.sunday.end",
|
||||
"columnName": "suend",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "openinghours.days.holiday.start",
|
||||
"columnName": "hostart",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "openinghours.days.holiday.end",
|
||||
"columnName": "hoend",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "cost.freecharging",
|
||||
"columnName": "freecharging",
|
||||
"affinity": "INTEGER"
|
||||
},
|
||||
{
|
||||
"fieldPath": "cost.freeparking",
|
||||
"columnName": "freeparking",
|
||||
"affinity": "INTEGER"
|
||||
},
|
||||
{
|
||||
"fieldPath": "cost.descriptionShort",
|
||||
"columnName": "descriptionShort",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "cost.descriptionLong",
|
||||
"columnName": "descriptionLong",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "chargepriceData.country",
|
||||
"columnName": "chargepricecountry",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "chargepriceData.network",
|
||||
"columnName": "chargepricenetwork",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "chargepriceData.plugTypes",
|
||||
"columnName": "chargepriceplugTypes",
|
||||
"affinity": "TEXT"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id",
|
||||
"dataSource"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "Favorite",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`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 )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "favoriteId",
|
||||
"columnName": "favoriteId",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "chargerId",
|
||||
"columnName": "chargerId",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "chargerDataSource",
|
||||
"columnName": "chargerDataSource",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"favoriteId"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_Favorite_chargerId_chargerDataSource",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"chargerId",
|
||||
"chargerDataSource"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_Favorite_chargerId_chargerDataSource` ON `${TABLE_NAME}` (`chargerId`, `chargerDataSource`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "ChargeLocation",
|
||||
"onDelete": "NO ACTION",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"chargerId",
|
||||
"chargerDataSource"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id",
|
||||
"dataSource"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "BooleanFilterValue",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` INTEGER NOT NULL, `dataSource` TEXT NOT NULL, `profile` INTEGER NOT NULL, PRIMARY KEY(`key`, `profile`, `dataSource`), FOREIGN KEY(`profile`, `dataSource`) REFERENCES `FilterProfile`(`id`, `dataSource`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "key",
|
||||
"columnName": "key",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "value",
|
||||
"columnName": "value",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "dataSource",
|
||||
"columnName": "dataSource",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "profile",
|
||||
"columnName": "profile",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"key",
|
||||
"profile",
|
||||
"dataSource"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_BooleanFilterValue_profile_dataSource",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"profile",
|
||||
"dataSource"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_BooleanFilterValue_profile_dataSource` ON `${TABLE_NAME}` (`profile`, `dataSource`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "FilterProfile",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"profile",
|
||||
"dataSource"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id",
|
||||
"dataSource"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "MultipleChoiceFilterValue",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `values` TEXT NOT NULL, `all` INTEGER NOT NULL, `dataSource` TEXT NOT NULL, `profile` INTEGER NOT NULL, PRIMARY KEY(`key`, `profile`, `dataSource`), FOREIGN KEY(`profile`, `dataSource`) REFERENCES `FilterProfile`(`id`, `dataSource`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "key",
|
||||
"columnName": "key",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "values",
|
||||
"columnName": "values",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "all",
|
||||
"columnName": "all",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "dataSource",
|
||||
"columnName": "dataSource",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "profile",
|
||||
"columnName": "profile",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"key",
|
||||
"profile",
|
||||
"dataSource"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_MultipleChoiceFilterValue_profile_dataSource",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"profile",
|
||||
"dataSource"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_MultipleChoiceFilterValue_profile_dataSource` ON `${TABLE_NAME}` (`profile`, `dataSource`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "FilterProfile",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"profile",
|
||||
"dataSource"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id",
|
||||
"dataSource"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "SliderFilterValue",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` INTEGER NOT NULL, `dataSource` TEXT NOT NULL, `profile` INTEGER NOT NULL, PRIMARY KEY(`key`, `profile`, `dataSource`), FOREIGN KEY(`profile`, `dataSource`) REFERENCES `FilterProfile`(`id`, `dataSource`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "key",
|
||||
"columnName": "key",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "value",
|
||||
"columnName": "value",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "dataSource",
|
||||
"columnName": "dataSource",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "profile",
|
||||
"columnName": "profile",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"key",
|
||||
"profile",
|
||||
"dataSource"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_SliderFilterValue_profile_dataSource",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"profile",
|
||||
"dataSource"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_SliderFilterValue_profile_dataSource` ON `${TABLE_NAME}` (`profile`, `dataSource`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "FilterProfile",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"profile",
|
||||
"dataSource"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id",
|
||||
"dataSource"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "FilterProfile",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `dataSource` TEXT NOT NULL, `id` INTEGER NOT NULL, `order` INTEGER NOT NULL, PRIMARY KEY(`dataSource`, `id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "name",
|
||||
"columnName": "name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "dataSource",
|
||||
"columnName": "dataSource",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "order",
|
||||
"columnName": "order",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"dataSource",
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_FilterProfile_dataSource_name",
|
||||
"unique": true,
|
||||
"columnNames": [
|
||||
"dataSource",
|
||||
"name"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_FilterProfile_dataSource_name` ON `${TABLE_NAME}` (`dataSource`, `name`)"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "RecentAutocompletePlace",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `dataSource` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `primaryText` TEXT NOT NULL, `secondaryText` TEXT NOT NULL, `latLng` TEXT NOT NULL, `viewport` TEXT, `types` TEXT NOT NULL, PRIMARY KEY(`id`, `dataSource`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "dataSource",
|
||||
"columnName": "dataSource",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "timestamp",
|
||||
"columnName": "timestamp",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "primaryText",
|
||||
"columnName": "primaryText",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "secondaryText",
|
||||
"columnName": "secondaryText",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "latLng",
|
||||
"columnName": "latLng",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "viewport",
|
||||
"columnName": "viewport",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "types",
|
||||
"columnName": "types",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id",
|
||||
"dataSource"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "GEPlug",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, PRIMARY KEY(`name`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "name",
|
||||
"columnName": "name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"name"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "GENetwork",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, PRIMARY KEY(`name`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "name",
|
||||
"columnName": "name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"name"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "GEChargeCard",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `url` TEXT NOT NULL, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "name",
|
||||
"columnName": "name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "url",
|
||||
"columnName": "url",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "OCMConnectionType",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `title` TEXT NOT NULL, `formalName` TEXT, `discontinued` INTEGER, `obsolete` INTEGER, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "title",
|
||||
"columnName": "title",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "formalName",
|
||||
"columnName": "formalName",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "discontinued",
|
||||
"columnName": "discontinued",
|
||||
"affinity": "INTEGER"
|
||||
},
|
||||
{
|
||||
"fieldPath": "obsolete",
|
||||
"columnName": "obsolete",
|
||||
"affinity": "INTEGER"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "OCMCountry",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `isoCode` TEXT NOT NULL, `continentCode` TEXT, `title` TEXT NOT NULL, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "isoCode",
|
||||
"columnName": "isoCode",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "continentCode",
|
||||
"columnName": "continentCode",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "title",
|
||||
"columnName": "title",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "OCMOperator",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `websiteUrl` TEXT, `title` TEXT NOT NULL, `contactEmail` TEXT, `contactTelephone1` TEXT, `contactTelephone2` TEXT, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "websiteUrl",
|
||||
"columnName": "websiteUrl",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "title",
|
||||
"columnName": "title",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "contactEmail",
|
||||
"columnName": "contactEmail",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "contactTelephone1",
|
||||
"columnName": "contactTelephone1",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "contactTelephone2",
|
||||
"columnName": "contactTelephone2",
|
||||
"affinity": "TEXT"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "SavedRegion",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`region` BLOB NOT NULL, `dataSource` TEXT NOT NULL, `timeRetrieved` INTEGER NOT NULL, `filters` TEXT, `isDetailed` INTEGER NOT NULL, `id` INTEGER PRIMARY KEY AUTOINCREMENT)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "region",
|
||||
"columnName": "region",
|
||||
"affinity": "BLOB",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "dataSource",
|
||||
"columnName": "dataSource",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "timeRetrieved",
|
||||
"columnName": "timeRetrieved",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "filters",
|
||||
"columnName": "filters",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "isDetailed",
|
||||
"columnName": "isDetailed",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_SavedRegion_filters_dataSource",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"filters",
|
||||
"dataSource"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_SavedRegion_filters_dataSource` ON `${TABLE_NAME}` (`filters`, `dataSource`)"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"setupQueries": [
|
||||
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '5dbaaa5adf8cb9b6e8a8314bb7766447')"
|
||||
]
|
||||
}
|
||||
}
|
||||
997
app/schemas/net.vonforst.evmap.storage.AppDatabase/23.json
Normal file
997
app/schemas/net.vonforst.evmap.storage.AppDatabase/23.json
Normal file
@@ -0,0 +1,997 @@
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 23,
|
||||
"identityHash": "e9e169ba4257824c82e4acb030730e97",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "ChargeLocation",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `dataSource` TEXT NOT NULL, `name` TEXT NOT NULL, `coordinates` BLOB NOT NULL, `chargepoints` TEXT NOT NULL, `network` TEXT, `url` TEXT NOT NULL, `editUrl` TEXT, `verified` INTEGER NOT NULL, `barrierFree` INTEGER, `operator` TEXT, `generalInformation` TEXT, `amenities` TEXT, `locationDescription` TEXT, `photos` TEXT, `chargecards` TEXT, `license` TEXT, `networkUrl` TEXT, `chargerUrl` TEXT, `timeRetrieved` INTEGER NOT NULL, `isDetailed` INTEGER NOT NULL, `city` TEXT, `country` TEXT, `postcode` TEXT, `street` TEXT, `fault_report_created` INTEGER, `fault_report_description` TEXT, `twentyfourSeven` INTEGER, `description` TEXT, `mostart` TEXT, `moend` TEXT, `tustart` TEXT, `tuend` TEXT, `westart` TEXT, `weend` TEXT, `thstart` TEXT, `thend` TEXT, `frstart` TEXT, `frend` TEXT, `sastart` TEXT, `saend` TEXT, `sustart` TEXT, `suend` TEXT, `hostart` TEXT, `hoend` TEXT, `freecharging` INTEGER, `freeparking` INTEGER, `descriptionShort` TEXT, `descriptionLong` TEXT, `chargepricecountry` TEXT, `chargepricenetwork` TEXT, `chargepriceplugTypes` TEXT, PRIMARY KEY(`id`, `dataSource`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "dataSource",
|
||||
"columnName": "dataSource",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "name",
|
||||
"columnName": "name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "coordinates",
|
||||
"columnName": "coordinates",
|
||||
"affinity": "BLOB",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "chargepoints",
|
||||
"columnName": "chargepoints",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "network",
|
||||
"columnName": "network",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "url",
|
||||
"columnName": "url",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "editUrl",
|
||||
"columnName": "editUrl",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "verified",
|
||||
"columnName": "verified",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "barrierFree",
|
||||
"columnName": "barrierFree",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "operator",
|
||||
"columnName": "operator",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "generalInformation",
|
||||
"columnName": "generalInformation",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "amenities",
|
||||
"columnName": "amenities",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "locationDescription",
|
||||
"columnName": "locationDescription",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "photos",
|
||||
"columnName": "photos",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "chargecards",
|
||||
"columnName": "chargecards",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "license",
|
||||
"columnName": "license",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "networkUrl",
|
||||
"columnName": "networkUrl",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "chargerUrl",
|
||||
"columnName": "chargerUrl",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "timeRetrieved",
|
||||
"columnName": "timeRetrieved",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "isDetailed",
|
||||
"columnName": "isDetailed",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "address.city",
|
||||
"columnName": "city",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "address.country",
|
||||
"columnName": "country",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "address.postcode",
|
||||
"columnName": "postcode",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "address.street",
|
||||
"columnName": "street",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "faultReport.created",
|
||||
"columnName": "fault_report_created",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "faultReport.description",
|
||||
"columnName": "fault_report_description",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "openinghours.twentyfourSeven",
|
||||
"columnName": "twentyfourSeven",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "openinghours.description",
|
||||
"columnName": "description",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "openinghours.days.monday.start",
|
||||
"columnName": "mostart",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "openinghours.days.monday.end",
|
||||
"columnName": "moend",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "openinghours.days.tuesday.start",
|
||||
"columnName": "tustart",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "openinghours.days.tuesday.end",
|
||||
"columnName": "tuend",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "openinghours.days.wednesday.start",
|
||||
"columnName": "westart",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "openinghours.days.wednesday.end",
|
||||
"columnName": "weend",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "openinghours.days.thursday.start",
|
||||
"columnName": "thstart",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "openinghours.days.thursday.end",
|
||||
"columnName": "thend",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "openinghours.days.friday.start",
|
||||
"columnName": "frstart",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "openinghours.days.friday.end",
|
||||
"columnName": "frend",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "openinghours.days.saturday.start",
|
||||
"columnName": "sastart",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "openinghours.days.saturday.end",
|
||||
"columnName": "saend",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "openinghours.days.sunday.start",
|
||||
"columnName": "sustart",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "openinghours.days.sunday.end",
|
||||
"columnName": "suend",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "openinghours.days.holiday.start",
|
||||
"columnName": "hostart",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "openinghours.days.holiday.end",
|
||||
"columnName": "hoend",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "cost.freecharging",
|
||||
"columnName": "freecharging",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "cost.freeparking",
|
||||
"columnName": "freeparking",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "cost.descriptionShort",
|
||||
"columnName": "descriptionShort",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "cost.descriptionLong",
|
||||
"columnName": "descriptionLong",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "chargepriceData.country",
|
||||
"columnName": "chargepricecountry",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "chargepriceData.network",
|
||||
"columnName": "chargepricenetwork",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "chargepriceData.plugTypes",
|
||||
"columnName": "chargepriceplugTypes",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id",
|
||||
"dataSource"
|
||||
]
|
||||
},
|
||||
"indices": [],
|
||||
"foreignKeys": []
|
||||
},
|
||||
{
|
||||
"tableName": "Favorite",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`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 )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "favoriteId",
|
||||
"columnName": "favoriteId",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "chargerId",
|
||||
"columnName": "chargerId",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "chargerDataSource",
|
||||
"columnName": "chargerDataSource",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"favoriteId"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_Favorite_chargerId_chargerDataSource",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"chargerId",
|
||||
"chargerDataSource"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_Favorite_chargerId_chargerDataSource` ON `${TABLE_NAME}` (`chargerId`, `chargerDataSource`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "ChargeLocation",
|
||||
"onDelete": "NO ACTION",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"chargerId",
|
||||
"chargerDataSource"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id",
|
||||
"dataSource"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "BooleanFilterValue",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` INTEGER NOT NULL, `dataSource` TEXT NOT NULL, `profile` INTEGER NOT NULL, PRIMARY KEY(`key`, `profile`, `dataSource`), FOREIGN KEY(`profile`, `dataSource`) REFERENCES `FilterProfile`(`id`, `dataSource`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "key",
|
||||
"columnName": "key",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "value",
|
||||
"columnName": "value",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "dataSource",
|
||||
"columnName": "dataSource",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "profile",
|
||||
"columnName": "profile",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"key",
|
||||
"profile",
|
||||
"dataSource"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_BooleanFilterValue_profile_dataSource",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"profile",
|
||||
"dataSource"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_BooleanFilterValue_profile_dataSource` ON `${TABLE_NAME}` (`profile`, `dataSource`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "FilterProfile",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"profile",
|
||||
"dataSource"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id",
|
||||
"dataSource"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "MultipleChoiceFilterValue",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `values` TEXT NOT NULL, `all` INTEGER NOT NULL, `dataSource` TEXT NOT NULL, `profile` INTEGER NOT NULL, PRIMARY KEY(`key`, `profile`, `dataSource`), FOREIGN KEY(`profile`, `dataSource`) REFERENCES `FilterProfile`(`id`, `dataSource`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "key",
|
||||
"columnName": "key",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "values",
|
||||
"columnName": "values",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "all",
|
||||
"columnName": "all",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "dataSource",
|
||||
"columnName": "dataSource",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "profile",
|
||||
"columnName": "profile",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"key",
|
||||
"profile",
|
||||
"dataSource"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_MultipleChoiceFilterValue_profile_dataSource",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"profile",
|
||||
"dataSource"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_MultipleChoiceFilterValue_profile_dataSource` ON `${TABLE_NAME}` (`profile`, `dataSource`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "FilterProfile",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"profile",
|
||||
"dataSource"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id",
|
||||
"dataSource"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "SliderFilterValue",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` INTEGER NOT NULL, `dataSource` TEXT NOT NULL, `profile` INTEGER NOT NULL, PRIMARY KEY(`key`, `profile`, `dataSource`), FOREIGN KEY(`profile`, `dataSource`) REFERENCES `FilterProfile`(`id`, `dataSource`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "key",
|
||||
"columnName": "key",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "value",
|
||||
"columnName": "value",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "dataSource",
|
||||
"columnName": "dataSource",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "profile",
|
||||
"columnName": "profile",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"key",
|
||||
"profile",
|
||||
"dataSource"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_SliderFilterValue_profile_dataSource",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"profile",
|
||||
"dataSource"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_SliderFilterValue_profile_dataSource` ON `${TABLE_NAME}` (`profile`, `dataSource`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "FilterProfile",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"profile",
|
||||
"dataSource"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id",
|
||||
"dataSource"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "FilterProfile",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `dataSource` TEXT NOT NULL, `id` INTEGER NOT NULL, `order` INTEGER NOT NULL, PRIMARY KEY(`dataSource`, `id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "name",
|
||||
"columnName": "name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "dataSource",
|
||||
"columnName": "dataSource",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "order",
|
||||
"columnName": "order",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"dataSource",
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_FilterProfile_dataSource_name",
|
||||
"unique": true,
|
||||
"columnNames": [
|
||||
"dataSource",
|
||||
"name"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_FilterProfile_dataSource_name` ON `${TABLE_NAME}` (`dataSource`, `name`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": []
|
||||
},
|
||||
{
|
||||
"tableName": "RecentAutocompletePlace",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `dataSource` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `primaryText` TEXT NOT NULL, `secondaryText` TEXT NOT NULL, `latLng` TEXT NOT NULL, `viewport` TEXT, `types` TEXT NOT NULL, PRIMARY KEY(`id`, `dataSource`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "dataSource",
|
||||
"columnName": "dataSource",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "timestamp",
|
||||
"columnName": "timestamp",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "primaryText",
|
||||
"columnName": "primaryText",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "secondaryText",
|
||||
"columnName": "secondaryText",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "latLng",
|
||||
"columnName": "latLng",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "viewport",
|
||||
"columnName": "viewport",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "types",
|
||||
"columnName": "types",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id",
|
||||
"dataSource"
|
||||
]
|
||||
},
|
||||
"indices": [],
|
||||
"foreignKeys": []
|
||||
},
|
||||
{
|
||||
"tableName": "GEPlug",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, PRIMARY KEY(`name`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "name",
|
||||
"columnName": "name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"name"
|
||||
]
|
||||
},
|
||||
"indices": [],
|
||||
"foreignKeys": []
|
||||
},
|
||||
{
|
||||
"tableName": "GENetwork",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, PRIMARY KEY(`name`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "name",
|
||||
"columnName": "name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"name"
|
||||
]
|
||||
},
|
||||
"indices": [],
|
||||
"foreignKeys": []
|
||||
},
|
||||
{
|
||||
"tableName": "GEChargeCard",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `url` TEXT NOT NULL, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "name",
|
||||
"columnName": "name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "url",
|
||||
"columnName": "url",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [],
|
||||
"foreignKeys": []
|
||||
},
|
||||
{
|
||||
"tableName": "OCMConnectionType",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `title` TEXT NOT NULL, `formalName` TEXT, `discontinued` INTEGER, `obsolete` INTEGER, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "title",
|
||||
"columnName": "title",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "formalName",
|
||||
"columnName": "formalName",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "discontinued",
|
||||
"columnName": "discontinued",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "obsolete",
|
||||
"columnName": "obsolete",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [],
|
||||
"foreignKeys": []
|
||||
},
|
||||
{
|
||||
"tableName": "OCMCountry",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `isoCode` TEXT NOT NULL, `continentCode` TEXT, `title` TEXT NOT NULL, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "isoCode",
|
||||
"columnName": "isoCode",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "continentCode",
|
||||
"columnName": "continentCode",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "title",
|
||||
"columnName": "title",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [],
|
||||
"foreignKeys": []
|
||||
},
|
||||
{
|
||||
"tableName": "OCMOperator",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `websiteUrl` TEXT, `title` TEXT NOT NULL, `contactEmail` TEXT, `contactTelephone1` TEXT, `contactTelephone2` TEXT, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "websiteUrl",
|
||||
"columnName": "websiteUrl",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "title",
|
||||
"columnName": "title",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "contactEmail",
|
||||
"columnName": "contactEmail",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "contactTelephone1",
|
||||
"columnName": "contactTelephone1",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "contactTelephone2",
|
||||
"columnName": "contactTelephone2",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [],
|
||||
"foreignKeys": []
|
||||
},
|
||||
{
|
||||
"tableName": "OSMNetwork",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, PRIMARY KEY(`name`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "name",
|
||||
"columnName": "name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"name"
|
||||
]
|
||||
},
|
||||
"indices": [],
|
||||
"foreignKeys": []
|
||||
},
|
||||
{
|
||||
"tableName": "SavedRegion",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`region` BLOB NOT NULL, `dataSource` TEXT NOT NULL, `timeRetrieved` INTEGER NOT NULL, `filters` TEXT, `isDetailed` INTEGER NOT NULL, `id` INTEGER PRIMARY KEY AUTOINCREMENT)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "region",
|
||||
"columnName": "region",
|
||||
"affinity": "BLOB",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "dataSource",
|
||||
"columnName": "dataSource",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "timeRetrieved",
|
||||
"columnName": "timeRetrieved",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "filters",
|
||||
"columnName": "filters",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "isDetailed",
|
||||
"columnName": "isDetailed",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_SavedRegion_filters_dataSource",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"filters",
|
||||
"dataSource"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_SavedRegion_filters_dataSource` ON `${TABLE_NAME}` (`filters`, `dataSource`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": []
|
||||
}
|
||||
],
|
||||
"views": [],
|
||||
"setupQueries": [
|
||||
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'e9e169ba4257824c82e4acb030730e97')"
|
||||
]
|
||||
}
|
||||
}
|
||||
1003
app/schemas/net.vonforst.evmap.storage.AppDatabase/24.json
Normal file
1003
app/schemas/net.vonforst.evmap.storage.AppDatabase/24.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,114 @@
|
||||
package net.vonforst.evmap.storage
|
||||
|
||||
import android.content.Context
|
||||
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import net.vonforst.evmap.model.ChargeLocation
|
||||
import net.vonforst.evmap.model.ChargeLocationCluster
|
||||
import net.vonforst.evmap.model.ChargepointListItem
|
||||
import net.vonforst.evmap.model.Coordinate
|
||||
import net.vonforst.evmap.ui.cluster
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import java.time.Instant
|
||||
import kotlin.random.Random
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class ChargeLocationsDaoTest {
|
||||
private lateinit var database: AppDatabase
|
||||
private lateinit var dao: ChargeLocationsDao
|
||||
|
||||
@get:Rule
|
||||
var instantExecutorRule = InstantTaskExecutorRule()
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
database = AppDatabase.createInMemory(context)
|
||||
dao = database.chargeLocationsDao()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
database.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testClustering() {
|
||||
val lat1 = 53.0
|
||||
val lng1 = 9.0
|
||||
val lat2 = 54.0
|
||||
val lng2 = 10.0
|
||||
|
||||
val chargeLocations = (0..100).map { i ->
|
||||
val lat = Random.nextDouble(lat1, lat2)
|
||||
val lng = Random.nextDouble(lng1, lng2)
|
||||
ChargeLocation(
|
||||
i.toLong(),
|
||||
"test",
|
||||
"test",
|
||||
Coordinate(lat, lng),
|
||||
null,
|
||||
emptyList(),
|
||||
null,
|
||||
"https://google.com",
|
||||
null,
|
||||
null,
|
||||
false,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null, null, null, null, null, null, null, Instant.now(), true
|
||||
)
|
||||
}
|
||||
runBlocking {
|
||||
dao.insert(*chargeLocations.toTypedArray())
|
||||
}
|
||||
|
||||
val zoom = 10f
|
||||
|
||||
val clusteredInMemory = cluster(chargeLocations, zoom).sorted()
|
||||
val clusteredInDB = runBlocking {
|
||||
dao.getChargeLocationsClustered(lat1, lat2, lng1, lng2, "test", 0L, zoom)
|
||||
}.sorted()
|
||||
assertEquals(clusteredInMemory.size, clusteredInDB.size)
|
||||
clusteredInDB.zip(clusteredInMemory).forEach { (a, b) ->
|
||||
when (a) {
|
||||
is ChargeLocation -> {
|
||||
assertTrue(b is ChargeLocation)
|
||||
assertEquals(a, b)
|
||||
}
|
||||
is ChargeLocationCluster -> {
|
||||
assertTrue(b is ChargeLocationCluster)
|
||||
assertEquals(a.clusterCount, (b as ChargeLocationCluster).clusterCount)
|
||||
assertEquals(a.coordinates.lat, b.coordinates.lat, 1e-5)
|
||||
assertEquals(a.coordinates.lng, b.coordinates.lng, 1e-5)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun List<ChargepointListItem>.sorted() = sortedBy {
|
||||
when (it) {
|
||||
is ChargeLocationCluster -> it.coordinates.lat
|
||||
is ChargeLocation -> it.coordinates.lat
|
||||
else -> 0.0
|
||||
}
|
||||
}.sortedBy {
|
||||
when (it) {
|
||||
is ChargeLocationCluster -> it.coordinates.lng
|
||||
is ChargeLocation -> it.coordinates.lng
|
||||
else -> 0.0
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.johan.evmap.storage
|
||||
package net.vonforst.evmap.storage
|
||||
|
||||
import android.content.Context
|
||||
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
|
||||
@@ -2,4 +2,4 @@
|
||||
<resources>
|
||||
<string name="grant_on_phone">Povolit</string>
|
||||
<string name="auto_location_permission_needed">Pro spuštění aplikace EVMap ve vašem autě musíte povolit přístup k vaší poloze.</string>
|
||||
</resources>
|
||||
</resources>
|
||||
|
||||
@@ -2,4 +2,4 @@
|
||||
<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>
|
||||
</resources>
|
||||
|
||||
5
app/src/automotive/res/values-et/strings.xml
Normal file
5
app/src/automotive/res/values-et/strings.xml
Normal file
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="grant_on_phone">Luba</string>
|
||||
<string name="auto_location_permission_needed">Et EVMap toimiks sinu autos, palun luba tal asukohta tuvastada.</string>
|
||||
</resources>
|
||||
@@ -2,4 +2,4 @@
|
||||
<resources>
|
||||
<string name="grant_on_phone">Autoriser</string>
|
||||
<string name="auto_location_permission_needed">Pour exécuter EVMap sur Android Auto, vous devez autoriser l\'accès à votre emplacement.</string>
|
||||
</resources>
|
||||
</resources>
|
||||
|
||||
5
app/src/automotive/res/values-it/strings.xml
Normal file
5
app/src/automotive/res/values-it/strings.xml
Normal file
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="grant_on_phone">Consenti</string>
|
||||
<string name="auto_location_permission_needed">Per eseguire EVMap sulla propria auto, è necessario concedere l\'accesso alla propria posizione.</string>
|
||||
</resources>
|
||||
@@ -2,4 +2,4 @@
|
||||
<resources>
|
||||
<string name="auto_location_permission_needed">Du må du innvilge posisjonstilgang for å kjøre EVMap i bilen din.</string>
|
||||
<string name="grant_on_phone">Tillat</string>
|
||||
</resources>
|
||||
</resources>
|
||||
@@ -2,4 +2,4 @@
|
||||
<resources>
|
||||
<string name="grant_on_phone">Toestaan</string>
|
||||
<string name="auto_location_permission_needed">Om EVmap te gebruiken in je wagen, moet je toegang geven tot je locatie.</string>
|
||||
</resources>
|
||||
</resources>
|
||||
|
||||
@@ -2,4 +2,4 @@
|
||||
<resources>
|
||||
<string name="grant_on_phone">Permitir</string>
|
||||
<string name="auto_location_permission_needed">Para usar o EVMap no seu carro, permita o acesso à sua localização.</string>
|
||||
</resources>
|
||||
</resources>
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources></resources>
|
||||
<resources>
|
||||
</resources>
|
||||
|
||||
@@ -11,6 +11,7 @@ import com.facebook.flipper.plugins.network.NetworkFlipperPlugin
|
||||
import com.facebook.flipper.plugins.sharedpreferences.SharedPreferencesFlipperPlugin
|
||||
import com.facebook.soloader.SoLoader
|
||||
import okhttp3.OkHttpClient
|
||||
import timber.log.Timber
|
||||
|
||||
private val networkFlipperPlugin = NetworkFlipperPlugin()
|
||||
|
||||
@@ -24,6 +25,8 @@ fun addDebugInterceptors(context: Context) {
|
||||
client.addPlugin(DatabasesFlipperPlugin(context))
|
||||
client.addPlugin(SharedPreferencesFlipperPlugin(context))
|
||||
client.start()
|
||||
|
||||
Timber.plant(Timber.DebugTree())
|
||||
}
|
||||
|
||||
fun OkHttpClient.Builder.addDebugInterceptors(): OkHttpClient.Builder {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">EVMap (debug)</string>
|
||||
<string name="app_name">EVMap</string>
|
||||
</resources>
|
||||
@@ -5,7 +5,6 @@ import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.navigation.ui.setupWithNavController
|
||||
import com.google.android.material.transition.MaterialSharedAxis
|
||||
@@ -43,7 +42,7 @@ class DonateFragment : DonateFragmentBase() {
|
||||
)
|
||||
|
||||
binding.btnDonate.setOnClickListener {
|
||||
(activity as? MapsActivity)?.openUrl(getString(R.string.paypal_link))
|
||||
(activity as? MapsActivity)?.openUrl(getString(R.string.paypal_link), binding.root)
|
||||
}
|
||||
|
||||
setupReferrals(referrals)
|
||||
|
||||
@@ -3,4 +3,4 @@
|
||||
<string name="donations_info" formatted="false">Pomohla vám EVMap? Podpořte její vývoj zasláním finančního daru vývojáři.</string>
|
||||
<string name="donate_paypal">Přispět pomocí PayPalu</string>
|
||||
<string name="data_sources_hint">Mapová data v aplikaci poskytuje služba OpenStreetMap.</string>
|
||||
</resources>
|
||||
</resources>
|
||||
|
||||
@@ -3,4 +3,4 @@
|
||||
<string name="donations_info" formatted="false">Findest du EVMap nützlich? Unterstütze die Weiterentwicklung der App mit einer Spende an den Entwickler.</string>
|
||||
<string name="donate_paypal">Mit PayPal spenden</string>
|
||||
<string name="data_sources_hint">Die Kartendaten für die App stammen von OpenStreetMap.</string>
|
||||
</resources>
|
||||
</resources>
|
||||
|
||||
6
app/src/foss/res/values-et/strings.xml
Normal file
6
app/src/foss/res/values-et/strings.xml
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="donations_info" formatted="false">Kas EVMap on sulle kasulik? Oma arendajale saadetava rahalise toetusega edendad ka arendustegevust.</string>
|
||||
<string name="donate_paypal">Toeta PayPali abil</string>
|
||||
<string name="data_sources_hint">Selles rakenduses näidatavad kaardiandmed on pärit OpenStreetMapist.</string>
|
||||
</resources>
|
||||
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="donations_info" formatted="false">Trouvez-vous EVMap utile \? Soutenez son développement en envoyant un don au développeur.</string>
|
||||
<string name="donations_info" formatted="false">Trouvez-vous EVMap utile ? Soutenez son développement en envoyant un don au développeur.</string>
|
||||
<string name="data_sources_hint">Les données cartographiques de l\'application sont fournies par OpenStreetMap.</string>
|
||||
<string name="donate_paypal">Faire un don avec PayPal</string>
|
||||
</resources>
|
||||
</resources>
|
||||
|
||||
6
app/src/foss/res/values-it/strings.xml
Normal file
6
app/src/foss/res/values-it/strings.xml
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="donations_info" formatted="false">Trovi utile EVMap? Sostieni il suo sviluppo inviando una donazione allo sviluppatore.</string>
|
||||
<string name="donate_paypal">Dona attraverso PayPal</string>
|
||||
<string name="data_sources_hint">I dati cartografici dell\'applicazione sono forniti da OpenStreetMap.</string>
|
||||
</resources>
|
||||
@@ -2,5 +2,5 @@
|
||||
<resources>
|
||||
<string name="donate_paypal">Doner med PayPal</string>
|
||||
<string name="data_sources_hint">Kartdata i programmet tilbys av OpenStreetMap.</string>
|
||||
<string name="donations_info" formatted="false">Synes du EVMap er nyttig\? Støtt utviklingen ved å sende en slant til utvikleren.</string>
|
||||
</resources>
|
||||
<string name="donations_info" formatted="false">Synes du EVMap er nyttig? Støtt utviklingen ved å sende en slant til utvikleren.</string>
|
||||
</resources>
|
||||
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="donations_info" formatted="false">Vond je EVMap nuttig\? Je kan de ontwikkeling ondersteunen door een donatie te sturen naar de ontwikkelaar.</string>
|
||||
<string name="donations_info" formatted="false">Vond je EVMap nuttig? Je kan de ontwikkeling ondersteunen door een donatie te sturen naar de ontwikkelaar.</string>
|
||||
<string name="donate_paypal">Doneer via PayPal</string>
|
||||
<string name="data_sources_hint">De kaartgegevens zijn afkomstig van OpenStreetMap.</string>
|
||||
</resources>
|
||||
</resources>
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
<resources>
|
||||
<string name="data_sources_hint">Os dados do mapa são fornecidos pelo OpenStreetMap.</string>
|
||||
<string name="donate_paypal">Doar com o PayPal</string>
|
||||
<string name="donations_info" formatted="false">Acha que o EVMap é útil\? Apoie a manutenção e desenvolvimento com uma doação para o desenvolvedor da app.</string>
|
||||
</resources>
|
||||
<string name="donations_info" formatted="false">Acha que o EVMap é útil? Apoie a manutenção e desenvolvimento com uma doação para o desenvolvedor da app.</string>
|
||||
</resources>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string-array name="pref_map_provider_names">
|
||||
<item>@string/pref_provider_osm_mapbox</item>
|
||||
<item>@string/pref_provider_osm</item>
|
||||
</string-array>
|
||||
<string-array name="pref_map_provider_values" translatable="false">
|
||||
<item>mapbox</item>
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="donations_info" formatted="false">Pomohla vám EVMap? Podpořte její vývoj posláním finančního daru vývojáři.
|
||||
\n
|
||||
\nGoogle si z každého daru strhne 15 %.</string>
|
||||
<string name="donations_info" formatted="false">Pomohla vám EVMap? Podpořte její vývoj posláním finančního daru vývojáři. \n \nGoogle si z každého daru strhne 15 %.</string>
|
||||
<string name="data_sources_hint">V nastavení můžete také pro mapová data přepínat mezi službami Mapy Google a OpenStreetMap.</string>
|
||||
</resources>
|
||||
</resources>
|
||||
|
||||
@@ -2,4 +2,4 @@
|
||||
<resources>
|
||||
<string name="donations_info" formatted="false">Findest du EVMap nützlich? Unterstütze die Weiterentwicklung der App mit einer Spende an den Entwickler.\n\nGoogle zieht von der Spende 15% Gebühren ab.</string>
|
||||
<string name="data_sources_hint">In den Einstellungen kannst du auch zwischen Google Maps und OpenStreetMap für die Kartendaten wechseln.</string>
|
||||
</resources>
|
||||
</resources>
|
||||
|
||||
5
app/src/google/res/values-et/strings.xml
Normal file
5
app/src/google/res/values-et/strings.xml
Normal file
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="data_sources_hint">Seadistustes saad valida kahe kaardiandmete allika vahel: Google Maps ja OpenStreetMap.</string>
|
||||
<string name="donations_info" formatted="false">EVMap on sinu jaoks kasulik? Toeta edasist arendust oma rahalise panusega.\n\nGoogle võtab igast toestussummast teenustasuna 15%.</string>
|
||||
</resources>
|
||||
@@ -1,7 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="donations_info" formatted="false">Trouvez-vous EVMap utile \? Soutenez son développement en envoyant un don au développeur.
|
||||
\n
|
||||
\nGoogle prend 15% sur chaque don.</string>
|
||||
<string name="donations_info" formatted="false">Trouvez-vous EVMap utile ? Soutenez son développement en envoyant un don au développeur. \n \nGoogle prend 15% sur chaque don.</string>
|
||||
<string name="data_sources_hint">Dans les paramètres, vous pouvez également choisir entre Google Maps et OpenStreetMap pour les données cartographiques.</string>
|
||||
</resources>
|
||||
</resources>
|
||||
|
||||
5
app/src/google/res/values-it/strings.xml
Normal file
5
app/src/google/res/values-it/strings.xml
Normal file
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="donations_info" formatted="false">Trovi utile EVMap? Sostieni il suo sviluppo inviando una donazione allo sviluppatore.\n\nGoogle si prende il 15% su ogni donazione.</string>
|
||||
<string name="data_sources_hint">Nelle impostazioni si può anche scegliere tra Google Maps e OpenStreetMap per i dati cartografici.</string>
|
||||
</resources>
|
||||
@@ -1,7 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="donations_info" formatted="false">Synes du EVMap er nyttig\? Støtt utviklingen ved å sende penger til utvikleren.
|
||||
\n
|
||||
\nGoogle tar 15% av alle donasjoner.</string>
|
||||
<string name="donations_info" formatted="false">Synes du EVMap er nyttig? Støtt utviklingen ved å sende penger til utvikleren. \n \nGoogle tar 15% av alle donasjoner.</string>
|
||||
<string name="data_sources_hint">I innstillingene kan du også bytte mellom Google Maps og OpenStreetMap for kartdata.</string>
|
||||
</resources>
|
||||
</resources>
|
||||
@@ -1,7 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="donations_info" formatted="false">Vind je EVMap nuttig\? Je kan de ontwikkeling steunen via een donatie aan de ontwikkelaar.
|
||||
\n
|
||||
\nGoogle houdt 15% in van elke donatie.</string>
|
||||
<string name="donations_info" formatted="false">Vind je EVMap nuttig? Je kan de ontwikkeling steunen via een donatie aan de ontwikkelaar. \n \nGoogle houdt 15% in van elke donatie.</string>
|
||||
<string name="data_sources_hint">In de instellingen kan je ook wisselen tussen Google Maps en OpenStreetMap voor de kaartgegevens.</string>
|
||||
</resources>
|
||||
</resources>
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="donations_info" formatted="false">Acha que o EVMap é útil\? Apoie a manutenção e desenvolvimento com uma doação para o desenvolvedor da app.
|
||||
\n
|
||||
\nA Google cobra 15% de cada doação.</string>
|
||||
<string name="donations_info" formatted="false">Acha que o EVMap é útil? Apoie a manutenção e desenvolvimento com uma doação para o desenvolvedor da app. \n \nA Google cobra 15% de cada doação.</string>
|
||||
<string name="data_sources_hint">Também pode mudar entre o Google Maps e OpenStreetMap nas definições da app.</string>
|
||||
</resources>
|
||||
</resources>
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources></resources>
|
||||
<resources>
|
||||
</resources>
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
|
||||
<uses-permission android:name="androidx.car.app.MAP_TEMPLATES" />
|
||||
<uses-permission android:name="androidx.car.app.ACCESS_SURFACE" />
|
||||
<uses-permission android:name="com.google.android.gms.permission.CAR_FUEL" />
|
||||
<uses-permission android:name="com.google.android.gms.permission.CAR_SPEED" />
|
||||
|
||||
@@ -24,6 +25,10 @@
|
||||
<intent>
|
||||
<action android:name="android.support.customtabs.action.CustomTabsService" />
|
||||
</intent>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<data android:scheme="http" />
|
||||
</intent>
|
||||
|
||||
<package android:name="com.google.android.projection.gearhead" />
|
||||
<package android:name="com.google.android.apps.automotive.templates.host" />
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
/*
|
||||
* Copyright 2013 Google Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.google.maps.android.clustering;
|
||||
|
||||
import com.car2go.maps.model.LatLng;
|
||||
|
||||
import java.util.Collection;
|
||||
|
||||
/**
|
||||
* A collection of ClusterItems that are nearby each other.
|
||||
*/
|
||||
public interface Cluster<T extends ClusterItem> {
|
||||
LatLng getPosition();
|
||||
|
||||
Collection<T> getItems();
|
||||
|
||||
int getSize();
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
/*
|
||||
* Copyright 2013 Google Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.google.maps.android.clustering;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import com.car2go.maps.model.LatLng;
|
||||
|
||||
/**
|
||||
* ClusterItem represents a marker on the map.
|
||||
*/
|
||||
public interface ClusterItem {
|
||||
|
||||
/**
|
||||
* The position of this marker. This must always return the same value.
|
||||
*/
|
||||
@NonNull
|
||||
LatLng getPosition();
|
||||
|
||||
/**
|
||||
* The title of this marker.
|
||||
*/
|
||||
@Nullable
|
||||
String getTitle();
|
||||
|
||||
/**
|
||||
* The description of this marker.
|
||||
*/
|
||||
@Nullable
|
||||
String getSnippet();
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
/*
|
||||
* Copyright 2020 Google Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package com.google.maps.android.clustering.algo;
|
||||
|
||||
import com.google.maps.android.clustering.ClusterItem;
|
||||
|
||||
import java.util.concurrent.locks.ReadWriteLock;
|
||||
import java.util.concurrent.locks.ReentrantReadWriteLock;
|
||||
|
||||
/**
|
||||
* Base Algorithm class that implements lock/unlock functionality.
|
||||
*/
|
||||
public abstract class AbstractAlgorithm<T extends ClusterItem> implements Algorithm<T> {
|
||||
|
||||
private final ReadWriteLock mLock = new ReentrantReadWriteLock();
|
||||
|
||||
@Override
|
||||
public void lock() {
|
||||
mLock.writeLock().lock();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void unlock() {
|
||||
mLock.writeLock().unlock();
|
||||
}
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
/*
|
||||
* Copyright 2013 Google Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.google.maps.android.clustering.algo;
|
||||
|
||||
import com.google.maps.android.clustering.Cluster;
|
||||
import com.google.maps.android.clustering.ClusterItem;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Logic for computing clusters
|
||||
*/
|
||||
public interface Algorithm<T extends ClusterItem> {
|
||||
|
||||
/**
|
||||
* Adds an item to the algorithm
|
||||
*
|
||||
* @param item the item to be added
|
||||
* @return true if the algorithm contents changed as a result of the call
|
||||
*/
|
||||
boolean addItem(T item);
|
||||
|
||||
/**
|
||||
* Adds a collection of items to the algorithm
|
||||
*
|
||||
* @param items the items to be added
|
||||
* @return true if the algorithm contents changed as a result of the call
|
||||
*/
|
||||
boolean addItems(Collection<T> items);
|
||||
|
||||
void clearItems();
|
||||
|
||||
/**
|
||||
* Removes an item from the algorithm
|
||||
*
|
||||
* @param item the item to be removed
|
||||
* @return true if this algorithm contained the specified element (or equivalently, if this
|
||||
* algorithm changed as a result of the call).
|
||||
*/
|
||||
boolean removeItem(T item);
|
||||
|
||||
/**
|
||||
* Updates the provided item in the algorithm
|
||||
*
|
||||
* @param item the item to be updated
|
||||
* @return true if the item existed in the algorithm and was updated, or false if the item did
|
||||
* not exist in the algorithm and the algorithm contents remain unchanged.
|
||||
*/
|
||||
boolean updateItem(T item);
|
||||
|
||||
/**
|
||||
* Removes a collection of items from the algorithm
|
||||
*
|
||||
* @param items the items to be removed
|
||||
* @return true if this algorithm contents changed as a result of the call
|
||||
*/
|
||||
boolean removeItems(Collection<T> items);
|
||||
|
||||
Set<? extends Cluster<T>> getClusters(float zoom);
|
||||
|
||||
Collection<T> getItems();
|
||||
|
||||
void setMaxDistanceBetweenClusteredItems(int maxDistance);
|
||||
|
||||
int getMaxDistanceBetweenClusteredItems();
|
||||
|
||||
void lock();
|
||||
|
||||
void unlock();
|
||||
}
|
||||
@@ -1,314 +0,0 @@
|
||||
/*
|
||||
* Copyright 2013 Google Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.google.maps.android.clustering.algo;
|
||||
|
||||
import com.car2go.maps.model.LatLng;
|
||||
import com.google.maps.android.clustering.Cluster;
|
||||
import com.google.maps.android.clustering.ClusterItem;
|
||||
import com.google.maps.android.geometry.Bounds;
|
||||
import com.google.maps.android.geometry.Point;
|
||||
import com.google.maps.android.projection.SphericalMercatorProjection;
|
||||
import com.google.maps.android.quadtree.PointQuadTree;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* A simple clustering algorithm with O(nlog n) performance. Resulting clusters are not
|
||||
* hierarchical.
|
||||
* <p/>
|
||||
* High level algorithm:<br>
|
||||
* 1. Iterate over items in the order they were added (candidate clusters).<br>
|
||||
* 2. Create a cluster with the center of the item. <br>
|
||||
* 3. Add all items that are within a certain distance to the cluster. <br>
|
||||
* 4. Move any items out of an existing cluster if they are closer to another cluster. <br>
|
||||
* 5. Remove those items from the list of candidate clusters.
|
||||
* <p/>
|
||||
* Clusters have the center of the first element (not the centroid of the items within it).
|
||||
*/
|
||||
public class NonHierarchicalDistanceBasedAlgorithm<T extends ClusterItem> extends AbstractAlgorithm<T> {
|
||||
private static final int DEFAULT_MAX_DISTANCE_AT_ZOOM = 100; // essentially 100 dp.
|
||||
|
||||
private int mMaxDistance = DEFAULT_MAX_DISTANCE_AT_ZOOM;
|
||||
|
||||
/**
|
||||
* Any modifications should be synchronized on mQuadTree.
|
||||
*/
|
||||
private final Collection<QuadItem<T>> mItems = new LinkedHashSet<>();
|
||||
|
||||
/**
|
||||
* Any modifications should be synchronized on mQuadTree.
|
||||
*/
|
||||
private final PointQuadTree<QuadItem<T>> mQuadTree = new PointQuadTree<>(0, 1, 0, 1);
|
||||
|
||||
private static final SphericalMercatorProjection PROJECTION = new SphericalMercatorProjection(1);
|
||||
|
||||
/**
|
||||
* Adds an item to the algorithm
|
||||
*
|
||||
* @param item the item to be added
|
||||
* @return true if the algorithm contents changed as a result of the call
|
||||
*/
|
||||
@Override
|
||||
public boolean addItem(T item) {
|
||||
boolean result;
|
||||
final QuadItem<T> quadItem = new QuadItem<>(item);
|
||||
synchronized (mQuadTree) {
|
||||
result = mItems.add(quadItem);
|
||||
if (result) {
|
||||
mQuadTree.add(quadItem);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a collection of items to the algorithm
|
||||
*
|
||||
* @param items the items to be added
|
||||
* @return true if the algorithm contents changed as a result of the call
|
||||
*/
|
||||
@Override
|
||||
public boolean addItems(Collection<T> items) {
|
||||
boolean result = false;
|
||||
for (T item : items) {
|
||||
boolean individualResult = addItem(item);
|
||||
if (individualResult) {
|
||||
result = true;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void clearItems() {
|
||||
synchronized (mQuadTree) {
|
||||
mItems.clear();
|
||||
mQuadTree.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes an item from the algorithm
|
||||
*
|
||||
* @param item the item to be removed
|
||||
* @return true if this algorithm contained the specified element (or equivalently, if this
|
||||
* algorithm changed as a result of the call).
|
||||
*/
|
||||
@Override
|
||||
public boolean removeItem(T item) {
|
||||
boolean result;
|
||||
// QuadItem delegates hashcode() and equals() to its item so,
|
||||
// removing any QuadItem to that item will remove the item
|
||||
final QuadItem<T> quadItem = new QuadItem<>(item);
|
||||
synchronized (mQuadTree) {
|
||||
result = mItems.remove(quadItem);
|
||||
if (result) {
|
||||
mQuadTree.remove(quadItem);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a collection of items from the algorithm
|
||||
*
|
||||
* @param items the items to be removed
|
||||
* @return true if this algorithm contents changed as a result of the call
|
||||
*/
|
||||
@Override
|
||||
public boolean removeItems(Collection<T> items) {
|
||||
boolean result = false;
|
||||
synchronized (mQuadTree) {
|
||||
for (T item : items) {
|
||||
// QuadItem delegates hashcode() and equals() to its item so,
|
||||
// removing any QuadItem to that item will remove the item
|
||||
final QuadItem<T> quadItem = new QuadItem<>(item);
|
||||
boolean individualResult = mItems.remove(quadItem);
|
||||
if (individualResult) {
|
||||
mQuadTree.remove(quadItem);
|
||||
result = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the provided item in the algorithm
|
||||
*
|
||||
* @param item the item to be updated
|
||||
* @return true if the item existed in the algorithm and was updated, or false if the item did
|
||||
* not exist in the algorithm and the algorithm contents remain unchanged.
|
||||
*/
|
||||
@Override
|
||||
public boolean updateItem(T item) {
|
||||
// TODO - Can this be optimized to update the item in-place if the location hasn't changed?
|
||||
boolean result;
|
||||
synchronized (mQuadTree) {
|
||||
result = removeItem(item);
|
||||
if (result) {
|
||||
// Only add the item if it was removed (to help prevent accidental duplicates on map)
|
||||
result = addItem(item);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Set<? extends Cluster<T>> getClusters(float zoom) {
|
||||
final int discreteZoom = (int) zoom;
|
||||
|
||||
final double zoomSpecificSpan = mMaxDistance / Math.pow(2, discreteZoom) / 256;
|
||||
|
||||
final Set<QuadItem<T>> visitedCandidates = new HashSet<>();
|
||||
final Set<Cluster<T>> results = new HashSet<>();
|
||||
final Map<QuadItem<T>, Double> distanceToCluster = new HashMap<>();
|
||||
final Map<QuadItem<T>, StaticCluster<T>> itemToCluster = new HashMap<>();
|
||||
|
||||
synchronized (mQuadTree) {
|
||||
for (QuadItem<T> candidate : getClusteringItems(mQuadTree, zoom)) {
|
||||
if (visitedCandidates.contains(candidate)) {
|
||||
// Candidate is already part of another cluster.
|
||||
continue;
|
||||
}
|
||||
|
||||
Bounds searchBounds = createBoundsFromSpan(candidate.getPoint(), zoomSpecificSpan);
|
||||
Collection<QuadItem<T>> clusterItems;
|
||||
clusterItems = mQuadTree.search(searchBounds);
|
||||
if (clusterItems.size() == 1) {
|
||||
// Only the current marker is in range. Just add the single item to the results.
|
||||
results.add(candidate);
|
||||
visitedCandidates.add(candidate);
|
||||
distanceToCluster.put(candidate, 0d);
|
||||
continue;
|
||||
}
|
||||
StaticCluster<T> cluster = new StaticCluster<>(candidate.mClusterItem.getPosition());
|
||||
results.add(cluster);
|
||||
|
||||
for (QuadItem<T> clusterItem : clusterItems) {
|
||||
Double existingDistance = distanceToCluster.get(clusterItem);
|
||||
double distance = distanceSquared(clusterItem.getPoint(), candidate.getPoint());
|
||||
if (existingDistance != null) {
|
||||
// Item already belongs to another cluster. Check if it's closer to this cluster.
|
||||
if (existingDistance < distance) {
|
||||
continue;
|
||||
}
|
||||
// Move item to the closer cluster.
|
||||
itemToCluster.get(clusterItem).remove(clusterItem.mClusterItem);
|
||||
}
|
||||
distanceToCluster.put(clusterItem, distance);
|
||||
cluster.add(clusterItem.mClusterItem);
|
||||
itemToCluster.put(clusterItem, cluster);
|
||||
}
|
||||
visitedCandidates.addAll(clusterItems);
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
protected Collection<QuadItem<T>> getClusteringItems(PointQuadTree<QuadItem<T>> quadTree, float zoom) {
|
||||
return mItems;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Collection<T> getItems() {
|
||||
final Set<T> items = new LinkedHashSet<>();
|
||||
synchronized (mQuadTree) {
|
||||
for (QuadItem<T> quadItem : mItems) {
|
||||
items.add(quadItem.mClusterItem);
|
||||
}
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setMaxDistanceBetweenClusteredItems(int maxDistance) {
|
||||
mMaxDistance = maxDistance;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getMaxDistanceBetweenClusteredItems() {
|
||||
return mMaxDistance;
|
||||
}
|
||||
|
||||
private double distanceSquared(Point a, Point b) {
|
||||
return (a.x - b.x) * (a.x - b.x) + (a.y - b.y) * (a.y - b.y);
|
||||
}
|
||||
|
||||
private Bounds createBoundsFromSpan(Point p, double span) {
|
||||
// TODO: Use a span that takes into account the visual size of the marker, not just its
|
||||
// LatLng.
|
||||
double halfSpan = span / 2;
|
||||
return new Bounds(
|
||||
p.x - halfSpan, p.x + halfSpan,
|
||||
p.y - halfSpan, p.y + halfSpan);
|
||||
}
|
||||
|
||||
protected static class QuadItem<T extends ClusterItem> implements PointQuadTree.Item, Cluster<T> {
|
||||
private final T mClusterItem;
|
||||
private final Point mPoint;
|
||||
private final LatLng mPosition;
|
||||
private Set<T> singletonSet;
|
||||
|
||||
private QuadItem(T item) {
|
||||
mClusterItem = item;
|
||||
mPosition = item.getPosition();
|
||||
mPoint = PROJECTION.toPoint(mPosition);
|
||||
singletonSet = Collections.singleton(mClusterItem);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Point getPoint() {
|
||||
return mPoint;
|
||||
}
|
||||
|
||||
@Override
|
||||
public LatLng getPosition() {
|
||||
return mPosition;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Set<T> getItems() {
|
||||
return singletonSet;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getSize() {
|
||||
return 1;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return mClusterItem.hashCode();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object other) {
|
||||
if (!(other instanceof QuadItem<?>)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return ((QuadItem<?>) other).mClusterItem.equals(mClusterItem);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
/*
|
||||
* Copyright 2013 Google Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.google.maps.android.clustering.algo;
|
||||
|
||||
import com.car2go.maps.model.LatLng;
|
||||
import com.google.maps.android.clustering.Cluster;
|
||||
import com.google.maps.android.clustering.ClusterItem;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* A cluster whose center is determined upon creation.
|
||||
*/
|
||||
public class StaticCluster<T extends ClusterItem> implements Cluster<T> {
|
||||
private final LatLng mCenter;
|
||||
private final List<T> mItems = new ArrayList<T>();
|
||||
|
||||
public StaticCluster(LatLng center) {
|
||||
mCenter = center;
|
||||
}
|
||||
|
||||
public boolean add(T t) {
|
||||
return mItems.add(t);
|
||||
}
|
||||
|
||||
@Override
|
||||
public LatLng getPosition() {
|
||||
return mCenter;
|
||||
}
|
||||
|
||||
public boolean remove(T t) {
|
||||
return mItems.remove(t);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Collection<T> getItems() {
|
||||
return mItems;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getSize() {
|
||||
return mItems.size();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "StaticCluster{" +
|
||||
"mCenter=" + mCenter +
|
||||
", mItems.size=" + mItems.size() +
|
||||
'}';
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return mCenter.hashCode() + mItems.hashCode();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object other) {
|
||||
if (!(other instanceof StaticCluster<?>)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return ((StaticCluster<?>) other).mCenter.equals(mCenter)
|
||||
&& ((StaticCluster<?>) other).mItems.equals(mItems);
|
||||
}
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
/*
|
||||
* Copyright 2013 Google Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.google.maps.android.geometry;
|
||||
|
||||
/**
|
||||
* Represents an area in the cartesian plane.
|
||||
*/
|
||||
public class Bounds {
|
||||
public final double minX;
|
||||
public final double minY;
|
||||
|
||||
public final double maxX;
|
||||
public final double maxY;
|
||||
|
||||
public final double midX;
|
||||
public final double midY;
|
||||
|
||||
public Bounds(double minX, double maxX, double minY, double maxY) {
|
||||
this.minX = minX;
|
||||
this.minY = minY;
|
||||
this.maxX = maxX;
|
||||
this.maxY = maxY;
|
||||
|
||||
midX = (minX + maxX) / 2;
|
||||
midY = (minY + maxY) / 2;
|
||||
}
|
||||
|
||||
public boolean contains(double x, double y) {
|
||||
return minX <= x && x <= maxX && minY <= y && y <= maxY;
|
||||
}
|
||||
|
||||
public boolean contains(Point point) {
|
||||
return contains(point.x, point.y);
|
||||
}
|
||||
|
||||
public boolean intersects(double minX, double maxX, double minY, double maxY) {
|
||||
return minX < this.maxX && this.minX < maxX && minY < this.maxY && this.minY < maxY;
|
||||
}
|
||||
|
||||
public boolean intersects(Bounds bounds) {
|
||||
return intersects(bounds.minX, bounds.maxX, bounds.minY, bounds.maxY);
|
||||
}
|
||||
|
||||
public boolean contains(Bounds bounds) {
|
||||
return bounds.minX >= minX && bounds.maxX <= maxX && bounds.minY >= minY && bounds.maxY <= maxY;
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
/*
|
||||
* Copyright 2013 Google Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.google.maps.android.geometry;
|
||||
|
||||
public class Point {
|
||||
public final double x;
|
||||
public final double y;
|
||||
|
||||
public Point(double x, double y) {
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "Point{" +
|
||||
"x=" + x +
|
||||
", y=" + y +
|
||||
'}';
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
/*
|
||||
* Copyright 2013 Google Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.google.maps.android.projection;
|
||||
|
||||
/**
|
||||
* @deprecated since 0.2. Use {@link com.google.maps.android.geometry.Point} instead.
|
||||
*/
|
||||
@Deprecated
|
||||
public class Point extends com.google.maps.android.geometry.Point {
|
||||
public Point(double x, double y) {
|
||||
super(x, y);
|
||||
}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
/*
|
||||
* Copyright 2013 Google Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.google.maps.android.projection;
|
||||
|
||||
import com.car2go.maps.model.LatLng;
|
||||
|
||||
public class SphericalMercatorProjection {
|
||||
final double mWorldWidth;
|
||||
|
||||
public SphericalMercatorProjection(final double worldWidth) {
|
||||
mWorldWidth = worldWidth;
|
||||
}
|
||||
|
||||
@SuppressWarnings("deprecation")
|
||||
public Point toPoint(final LatLng latLng) {
|
||||
final double x = latLng.longitude / 360 + .5;
|
||||
final double siny = Math.sin(Math.toRadians(latLng.latitude));
|
||||
final double y = 0.5 * Math.log((1 + siny) / (1 - siny)) / -(2 * Math.PI) + .5;
|
||||
|
||||
return new Point(x * mWorldWidth, y * mWorldWidth);
|
||||
}
|
||||
|
||||
public LatLng toLatLng(com.google.maps.android.geometry.Point point) {
|
||||
final double x = point.x / mWorldWidth - 0.5;
|
||||
final double lng = x * 360;
|
||||
|
||||
double y = .5 - (point.y / mWorldWidth);
|
||||
final double lat = 90 - Math.toDegrees(Math.atan(Math.exp(-y * 2 * Math.PI)) * 2);
|
||||
|
||||
return new LatLng(lat, lng);
|
||||
}
|
||||
}
|
||||
@@ -1,226 +0,0 @@
|
||||
/*
|
||||
* Copyright 2013 Google Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.google.maps.android.quadtree;
|
||||
|
||||
import com.google.maps.android.geometry.Bounds;
|
||||
import com.google.maps.android.geometry.Point;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* A quad tree which tracks items with a Point geometry.
|
||||
* See http://en.wikipedia.org/wiki/Quadtree for details on the data structure.
|
||||
* This class is not thread safe.
|
||||
*/
|
||||
public class PointQuadTree<T extends PointQuadTree.Item> {
|
||||
public interface Item {
|
||||
Point getPoint();
|
||||
}
|
||||
|
||||
/**
|
||||
* The bounds of this quad.
|
||||
*/
|
||||
private final Bounds mBounds;
|
||||
|
||||
/**
|
||||
* The depth of this quad in the tree.
|
||||
*/
|
||||
private final int mDepth;
|
||||
|
||||
/**
|
||||
* Maximum number of elements to store in a quad before splitting.
|
||||
*/
|
||||
private final static int MAX_ELEMENTS = 50;
|
||||
|
||||
/**
|
||||
* The elements inside this quad, if any.
|
||||
*/
|
||||
private Set<T> mItems;
|
||||
|
||||
/**
|
||||
* Maximum depth.
|
||||
*/
|
||||
private final static int MAX_DEPTH = 40;
|
||||
|
||||
/**
|
||||
* Child quads.
|
||||
*/
|
||||
private List<PointQuadTree<T>> mChildren = null;
|
||||
|
||||
/**
|
||||
* Creates a new quad tree with specified bounds.
|
||||
*
|
||||
* @param minX
|
||||
* @param maxX
|
||||
* @param minY
|
||||
* @param maxY
|
||||
*/
|
||||
public PointQuadTree(double minX, double maxX, double minY, double maxY) {
|
||||
this(new Bounds(minX, maxX, minY, maxY));
|
||||
}
|
||||
|
||||
public PointQuadTree(Bounds bounds) {
|
||||
this(bounds, 0);
|
||||
}
|
||||
|
||||
private PointQuadTree(double minX, double maxX, double minY, double maxY, int depth) {
|
||||
this(new Bounds(minX, maxX, minY, maxY), depth);
|
||||
}
|
||||
|
||||
private PointQuadTree(Bounds bounds, int depth) {
|
||||
mBounds = bounds;
|
||||
mDepth = depth;
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert an item.
|
||||
*/
|
||||
public void add(T item) {
|
||||
Point point = item.getPoint();
|
||||
if (this.mBounds.contains(point.x, point.y)) {
|
||||
insert(point.x, point.y, item);
|
||||
}
|
||||
}
|
||||
|
||||
private void insert(double x, double y, T item) {
|
||||
if (this.mChildren != null) {
|
||||
if (y < mBounds.midY) {
|
||||
if (x < mBounds.midX) { // top left
|
||||
mChildren.get(0).insert(x, y, item);
|
||||
} else { // top right
|
||||
mChildren.get(1).insert(x, y, item);
|
||||
}
|
||||
} else {
|
||||
if (x < mBounds.midX) { // bottom left
|
||||
mChildren.get(2).insert(x, y, item);
|
||||
} else {
|
||||
mChildren.get(3).insert(x, y, item);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (mItems == null) {
|
||||
mItems = new LinkedHashSet<>();
|
||||
}
|
||||
mItems.add(item);
|
||||
if (mItems.size() > MAX_ELEMENTS && mDepth < MAX_DEPTH) {
|
||||
split();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Split this quad.
|
||||
*/
|
||||
private void split() {
|
||||
mChildren = new ArrayList<PointQuadTree<T>>(4);
|
||||
mChildren.add(new PointQuadTree<T>(mBounds.minX, mBounds.midX, mBounds.minY, mBounds.midY, mDepth + 1));
|
||||
mChildren.add(new PointQuadTree<T>(mBounds.midX, mBounds.maxX, mBounds.minY, mBounds.midY, mDepth + 1));
|
||||
mChildren.add(new PointQuadTree<T>(mBounds.minX, mBounds.midX, mBounds.midY, mBounds.maxY, mDepth + 1));
|
||||
mChildren.add(new PointQuadTree<T>(mBounds.midX, mBounds.maxX, mBounds.midY, mBounds.maxY, mDepth + 1));
|
||||
|
||||
Set<T> items = mItems;
|
||||
mItems = null;
|
||||
|
||||
for (T item : items) {
|
||||
// re-insert items into child quads.
|
||||
insert(item.getPoint().x, item.getPoint().y, item);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the given item from the set.
|
||||
*
|
||||
* @return whether the item was removed.
|
||||
*/
|
||||
public boolean remove(T item) {
|
||||
Point point = item.getPoint();
|
||||
if (this.mBounds.contains(point.x, point.y)) {
|
||||
return remove(point.x, point.y, item);
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private boolean remove(double x, double y, T item) {
|
||||
if (this.mChildren != null) {
|
||||
if (y < mBounds.midY) {
|
||||
if (x < mBounds.midX) { // top left
|
||||
return mChildren.get(0).remove(x, y, item);
|
||||
} else { // top right
|
||||
return mChildren.get(1).remove(x, y, item);
|
||||
}
|
||||
} else {
|
||||
if (x < mBounds.midX) { // bottom left
|
||||
return mChildren.get(2).remove(x, y, item);
|
||||
} else {
|
||||
return mChildren.get(3).remove(x, y, item);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (mItems == null) {
|
||||
return false;
|
||||
} else {
|
||||
return mItems.remove(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes all points from the quadTree
|
||||
*/
|
||||
public void clear() {
|
||||
mChildren = null;
|
||||
if (mItems != null) {
|
||||
mItems.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for all items within a given bounds.
|
||||
*/
|
||||
public Collection<T> search(Bounds searchBounds) {
|
||||
final List<T> results = new ArrayList<T>();
|
||||
search(searchBounds, results);
|
||||
return results;
|
||||
}
|
||||
|
||||
private void search(Bounds searchBounds, Collection<T> results) {
|
||||
if (!mBounds.intersects(searchBounds)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.mChildren != null) {
|
||||
for (PointQuadTree<T> quad : mChildren) {
|
||||
quad.search(searchBounds, results);
|
||||
}
|
||||
} else if (mItems != null) {
|
||||
if (searchBounds.contains(mBounds)) {
|
||||
results.addAll(mItems);
|
||||
} else {
|
||||
for (T item : mItems) {
|
||||
if (searchBounds.contains(item.getPoint())) {
|
||||
results.add(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,10 +6,12 @@ import android.os.Build
|
||||
import androidx.work.Configuration
|
||||
import androidx.work.Constraints
|
||||
import androidx.work.ExistingPeriodicWorkPolicy
|
||||
import androidx.work.NetworkType
|
||||
import androidx.work.PeriodicWorkRequestBuilder
|
||||
import androidx.work.WorkManager
|
||||
import net.vonforst.evmap.storage.CleanupCacheWorker
|
||||
import net.vonforst.evmap.storage.PreferenceDataSource
|
||||
import net.vonforst.evmap.storage.UpdateFullDownloadWorker
|
||||
import net.vonforst.evmap.ui.updateAppLocale
|
||||
import net.vonforst.evmap.ui.updateNightMode
|
||||
import org.acra.config.dialog
|
||||
@@ -68,6 +70,7 @@ class EvMapApplication : Application(), Configuration.Provider {
|
||||
}
|
||||
}
|
||||
|
||||
val workManager = WorkManager.getInstance(this)
|
||||
val cleanupCacheRequest = PeriodicWorkRequestBuilder<CleanupCacheWorker>(Duration.ofDays(1))
|
||||
.setConstraints(Constraints.Builder().apply {
|
||||
setRequiresBatteryNotLow(true)
|
||||
@@ -75,9 +78,24 @@ class EvMapApplication : Application(), Configuration.Provider {
|
||||
setRequiresDeviceIdle(true)
|
||||
}
|
||||
}.build()).build()
|
||||
WorkManager.getInstance(this).enqueueUniquePeriodicWork(
|
||||
workManager.enqueueUniquePeriodicWork(
|
||||
"CleanupCacheWorker", ExistingPeriodicWorkPolicy.UPDATE, cleanupCacheRequest
|
||||
)
|
||||
|
||||
val updateFullDownloadRequest =
|
||||
PeriodicWorkRequestBuilder<UpdateFullDownloadWorker>(Duration.ofDays(7))
|
||||
.setConstraints(Constraints.Builder().apply {
|
||||
setRequiresBatteryNotLow(true)
|
||||
setRequiredNetworkType(NetworkType.UNMETERED)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
setRequiresDeviceIdle(true)
|
||||
}
|
||||
}.build()).build()
|
||||
workManager.enqueueUniquePeriodicWork(
|
||||
"UpdateOsmWorker",
|
||||
ExistingPeriodicWorkPolicy.UPDATE,
|
||||
updateFullDownloadRequest
|
||||
)
|
||||
}
|
||||
|
||||
override val workManagerConfiguration = Configuration.Builder().build()
|
||||
|
||||
@@ -3,6 +3,8 @@ package net.vonforst.evmap
|
||||
import android.app.PendingIntent
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.content.pm.ResolveInfo
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
@@ -11,7 +13,6 @@ import android.view.View
|
||||
import android.widget.Toast
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.browser.customtabs.CustomTabColorSchemeParams
|
||||
import androidx.browser.customtabs.CustomTabsClient
|
||||
import androidx.browser.customtabs.CustomTabsIntent
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.splashscreen.SplashScreen
|
||||
@@ -44,14 +45,11 @@ const val EXTRA_DONATE = "donate"
|
||||
|
||||
class MapsActivity : AppCompatActivity(),
|
||||
PreferenceFragmentCompat.OnPreferenceStartFragmentCallback {
|
||||
interface FragmentCallback {
|
||||
fun getRootView(): View
|
||||
}
|
||||
|
||||
private var reenterState: Bundle? = null
|
||||
private lateinit var navController: NavController
|
||||
private lateinit var navHostFragment: NavHostFragment
|
||||
lateinit var appBarConfiguration: AppBarConfiguration
|
||||
var fragmentCallback: FragmentCallback? = null
|
||||
private lateinit var prefs: PreferenceDataSource
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
@@ -70,7 +68,7 @@ class MapsActivity : AppCompatActivity(),
|
||||
),
|
||||
drawerLayout
|
||||
)
|
||||
val navHostFragment =
|
||||
navHostFragment =
|
||||
supportFragmentManager.findFragmentById(R.id.nav_host_fragment) as NavHostFragment
|
||||
navController = navHostFragment.navController
|
||||
val navGraph = navController.navInflater.inflate(R.navigation.nav_graph)
|
||||
@@ -237,7 +235,7 @@ class MapsActivity : AppCompatActivity(),
|
||||
deepLink?.send()
|
||||
}
|
||||
|
||||
fun navigateTo(charger: ChargeLocation) {
|
||||
fun navigateTo(charger: ChargeLocation, rootView: View) {
|
||||
// google maps navigation
|
||||
val coord = charger.coordinates
|
||||
val intent = Intent(Intent.ACTION_VIEW)
|
||||
@@ -247,11 +245,11 @@ class MapsActivity : AppCompatActivity(),
|
||||
startActivity(intent)
|
||||
} else {
|
||||
// fallback: generic geo intent
|
||||
showLocation(charger)
|
||||
showLocation(charger, rootView)
|
||||
}
|
||||
}
|
||||
|
||||
fun showLocation(charger: ChargeLocation) {
|
||||
fun showLocation(charger: ChargeLocation, rootView: View) {
|
||||
val coord = charger.coordinates
|
||||
val intent = Intent(Intent.ACTION_VIEW)
|
||||
intent.data = Uri.parse(
|
||||
@@ -259,20 +257,33 @@ class MapsActivity : AppCompatActivity(),
|
||||
Uri.encode(charger.name)
|
||||
})"
|
||||
)
|
||||
if (intent.resolveActivity(packageManager) != null) {
|
||||
|
||||
val resolveInfo =
|
||||
packageManager.resolveActivity(intent, PackageManager.MATCH_DEFAULT_ONLY)
|
||||
val pkg =
|
||||
resolveInfo?.activityInfo?.packageName.takeIf { it != "android" && it != packageName }
|
||||
if (pkg == null) {
|
||||
// There is no default maps app or EVMap itself is the current default, fall back to app chooser
|
||||
val chooserIntent = Intent.createChooser(intent, null).apply {
|
||||
putExtra(Intent.EXTRA_EXCLUDE_COMPONENTS, arrayOf(componentName))
|
||||
}
|
||||
startActivity(chooserIntent)
|
||||
return
|
||||
}
|
||||
intent.setPackage(pkg)
|
||||
|
||||
try {
|
||||
startActivity(intent)
|
||||
} else {
|
||||
val cb = fragmentCallback ?: return
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
Snackbar.make(
|
||||
cb.getRootView(),
|
||||
rootView,
|
||||
R.string.no_maps_app_found,
|
||||
Snackbar.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
}
|
||||
|
||||
fun openUrl(url: String, preferBrowser: Boolean = true) {
|
||||
val pkg = CustomTabsClient.getPackageName(this, null)
|
||||
fun openUrl(url: String, rootView: View, preferBrowser: Boolean = false) {
|
||||
val intent = CustomTabsIntent.Builder()
|
||||
.setDefaultColorSchemeParams(
|
||||
CustomTabColorSchemeParams.Builder()
|
||||
@@ -280,17 +291,49 @@ class MapsActivity : AppCompatActivity(),
|
||||
.build()
|
||||
)
|
||||
.build()
|
||||
pkg?.let {
|
||||
// prefer to open URL in custom tab, even if native app
|
||||
// available (such as EVMap itself)
|
||||
if (preferBrowser) intent.intent.setPackage(pkg)
|
||||
|
||||
val uri = Uri.parse(url)
|
||||
val viewIntent = Intent(Intent.ACTION_VIEW, uri)
|
||||
if (preferBrowser) {
|
||||
// EVMap may be set as default app for this link, but we want to open it in a browser
|
||||
// try to find default web browser
|
||||
val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse("http://"))
|
||||
val resolveInfo =
|
||||
packageManager.resolveActivity(browserIntent, PackageManager.MATCH_DEFAULT_ONLY)
|
||||
val pkg = resolveInfo?.activityInfo?.packageName.takeIf { it != "android" }
|
||||
if (pkg == null) {
|
||||
// There is no default browser, fall back to app chooser
|
||||
val chooserIntent = Intent.createChooser(viewIntent, null).apply {
|
||||
putExtra(Intent.EXTRA_EXCLUDE_COMPONENTS, arrayOf(componentName))
|
||||
}
|
||||
val targets: List<ResolveInfo> = packageManager.queryIntentActivities(
|
||||
viewIntent,
|
||||
PackageManager.MATCH_DEFAULT_ONLY
|
||||
)
|
||||
|
||||
// add missing browsers (if EVMap is already set as default, Android might not find other browsers with the specific intent)
|
||||
val browsers = packageManager.queryIntentActivities(
|
||||
browserIntent,
|
||||
PackageManager.MATCH_DEFAULT_ONLY
|
||||
)
|
||||
val extraIntents = browsers.filter { browser ->
|
||||
targets.find { it.activityInfo.packageName == browser.activityInfo.packageName } == null
|
||||
}.map { browser ->
|
||||
Intent(Intent.ACTION_VIEW, uri).apply {
|
||||
setPackage(browser.activityInfo.packageName)
|
||||
}
|
||||
}
|
||||
chooserIntent.putExtra(Intent.EXTRA_INITIAL_INTENTS, extraIntents.toTypedArray())
|
||||
startActivity(chooserIntent)
|
||||
return
|
||||
}
|
||||
intent.intent.setPackage(pkg)
|
||||
}
|
||||
try {
|
||||
intent.launchUrl(this, Uri.parse(url))
|
||||
intent.launchUrl(this, uri)
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
val cb = fragmentCallback ?: return
|
||||
Snackbar.make(
|
||||
cb.getRootView(),
|
||||
rootView,
|
||||
R.string.no_browser_app_found,
|
||||
Snackbar.LENGTH_SHORT
|
||||
).show()
|
||||
|
||||
@@ -16,6 +16,8 @@ import android.text.SpannableStringBuilder
|
||||
import android.text.SpannedString
|
||||
import android.text.TextUtils
|
||||
import android.text.style.StyleSpan
|
||||
import android.view.View
|
||||
import android.view.ViewTreeObserver
|
||||
import net.vonforst.evmap.storage.PreferenceDataSource
|
||||
import java.util.Currency
|
||||
import java.util.Locale
|
||||
@@ -142,4 +144,12 @@ fun PackageManager.isAppInstalled(packageName: String): Boolean {
|
||||
}
|
||||
}
|
||||
|
||||
fun currencyDisplayName(code: String) = "${Currency.getInstance(code).displayName} ($code)"
|
||||
fun currencyDisplayName(code: String) = "${Currency.getInstance(code).displayName} ($code)"
|
||||
|
||||
inline fun View.waitForLayout(crossinline f: () -> Unit) =
|
||||
viewTreeObserver.addOnGlobalLayoutListener(object : ViewTreeObserver.OnGlobalLayoutListener {
|
||||
override fun onGlobalLayout() {
|
||||
viewTreeObserver.removeOnGlobalLayoutListener(this)
|
||||
f()
|
||||
}
|
||||
})
|
||||
@@ -14,6 +14,7 @@ import net.vonforst.evmap.joinToSpannedString
|
||||
import net.vonforst.evmap.model.ChargeCard
|
||||
import net.vonforst.evmap.model.ChargeCardId
|
||||
import net.vonforst.evmap.model.ChargeLocation
|
||||
import net.vonforst.evmap.model.Coordinate
|
||||
import net.vonforst.evmap.model.OpeningHoursDays
|
||||
import net.vonforst.evmap.plus
|
||||
import net.vonforst.evmap.ui.currency
|
||||
|
||||
@@ -5,7 +5,6 @@ import android.content.Context
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.view.ViewTreeObserver.OnGlobalLayoutListener
|
||||
import android.widget.ImageView
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
import androidx.recyclerview.widget.ListAdapter
|
||||
@@ -14,6 +13,7 @@ import coil.load
|
||||
import coil.memory.MemoryCache
|
||||
import net.vonforst.evmap.R
|
||||
import net.vonforst.evmap.model.ChargerPhoto
|
||||
import net.vonforst.evmap.waitForLayout
|
||||
|
||||
|
||||
class GalleryAdapter(context: Context, val itemClickListener: ItemClickListener? = null) :
|
||||
@@ -39,12 +39,9 @@ class GalleryAdapter(context: Context, val itemClickListener: ItemClickListener?
|
||||
val item = getItem(position)
|
||||
|
||||
if (holder.view.height == 0) {
|
||||
holder.view.viewTreeObserver.addOnGlobalLayoutListener(object : OnGlobalLayoutListener {
|
||||
override fun onGlobalLayout() {
|
||||
holder.view.viewTreeObserver.removeOnGlobalLayoutListener(this)
|
||||
loadImage(item, holder)
|
||||
}
|
||||
})
|
||||
holder.view.waitForLayout {
|
||||
loadImage(item, holder)
|
||||
}
|
||||
} else {
|
||||
loadImage(item, holder)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import com.car2go.maps.model.LatLngBounds
|
||||
import net.vonforst.evmap.R
|
||||
import net.vonforst.evmap.api.goingelectric.GoingElectricApiWrapper
|
||||
import net.vonforst.evmap.api.openchargemap.OpenChargeMapApiWrapper
|
||||
import net.vonforst.evmap.api.openstreetmap.OpenStreetMapApiWrapper
|
||||
import net.vonforst.evmap.model.*
|
||||
import net.vonforst.evmap.viewmodel.Resource
|
||||
import java.time.Duration
|
||||
@@ -58,6 +59,27 @@ interface ChargepointApi<out T : ReferenceData> {
|
||||
* Duration we are limited to if there is a required API local cache time limit.
|
||||
*/
|
||||
val cacheLimit: Duration
|
||||
|
||||
/**
|
||||
* Whether this API supports querying for chargers at the backend
|
||||
*
|
||||
* This determines whether the getChargepoints, getChargepointsRadius and getChargepointDetail functions are supported.
|
||||
*/
|
||||
val supportsOnlineQueries: Boolean
|
||||
|
||||
/**
|
||||
* Whether this API supports downloading the whole dataset into local storage
|
||||
*
|
||||
* This determines whether the getAllChargepoints function is supported.
|
||||
*/
|
||||
val supportsFullDownload: Boolean
|
||||
|
||||
/**
|
||||
* Fetches all available chargers from this API.
|
||||
*
|
||||
* This may take a long time and should only be used when the user explicitly wants to download all chargers.
|
||||
*/
|
||||
suspend fun fullDownload(): FullDownloadResult<T>
|
||||
}
|
||||
|
||||
interface StringProvider {
|
||||
@@ -79,6 +101,7 @@ fun createApi(type: String, ctx: Context): ChargepointApi<ReferenceData> {
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
"goingelectric" -> {
|
||||
GoingElectricApiWrapper(
|
||||
ctx.getString(
|
||||
@@ -86,6 +109,11 @@ fun createApi(type: String, ctx: Context): ChargepointApi<ReferenceData> {
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
"openstreetmap" -> {
|
||||
OpenStreetMapApiWrapper()
|
||||
}
|
||||
|
||||
else -> throw IllegalArgumentException()
|
||||
}
|
||||
}
|
||||
@@ -100,4 +128,20 @@ data class ChargepointList(val items: List<ChargepointListItem>, val isComplete:
|
||||
companion object {
|
||||
fun empty() = ChargepointList(emptyList(), true)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Result returned from fullDownload() function.
|
||||
*
|
||||
* Note that [chargers] is implemented as a [Sequence] so that downloaded chargers can be saved
|
||||
* while they are being parsed instead of having to keep all of them in RAM at once.
|
||||
*
|
||||
* [progress] is updated regularly to indicate the current download progress.
|
||||
* [referenceData] will typically only be available once the download is completed, i.e. you have
|
||||
* iterated over the whole sequence of [chargers].
|
||||
*/
|
||||
interface FullDownloadResult<out T : ReferenceData> {
|
||||
val chargers: Sequence<ChargeLocation>
|
||||
val progress: Float
|
||||
val referenceData: T
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import okhttp3.Response
|
||||
import org.json.JSONArray
|
||||
import java.io.IOException
|
||||
import kotlin.coroutines.resumeWithException
|
||||
import kotlin.experimental.ExperimentalTypeInference
|
||||
import kotlin.math.abs
|
||||
|
||||
operator fun <T> JSONArray.iterator(): Iterator<T> =
|
||||
@@ -48,7 +49,8 @@ private val plugNames = mapOf(
|
||||
Chargepoint.TYPE_2_UNKNOWN to R.string.plug_type_2,
|
||||
Chargepoint.TYPE_2_PLUG to R.string.plug_type_2,
|
||||
Chargepoint.TYPE_2_SOCKET to R.string.plug_type_2,
|
||||
Chargepoint.TYPE_3 to R.string.plug_type_3,
|
||||
Chargepoint.TYPE_3A to R.string.plug_type_3a,
|
||||
Chargepoint.TYPE_3C to R.string.plug_type_3c,
|
||||
Chargepoint.CCS_UNKNOWN to R.string.plug_ccs,
|
||||
Chargepoint.CCS_TYPE_1 to R.string.plug_ccs,
|
||||
Chargepoint.CCS_TYPE_2 to R.string.plug_ccs,
|
||||
@@ -101,7 +103,7 @@ fun iconForPlugType(type: String): Int =
|
||||
Chargepoint.CEE_ROT -> R.drawable.ic_connector_cee_rot
|
||||
Chargepoint.TYPE_1 -> R.drawable.ic_connector_typ1
|
||||
// TODO: add other connectors
|
||||
else -> 0
|
||||
else -> R.drawable.ic_connector_unknown
|
||||
}
|
||||
|
||||
val powerSteps = listOf(0, 2, 3, 7, 11, 22, 43, 50, 75, 100, 150, 200, 250, 300, 350)
|
||||
|
||||
@@ -4,7 +4,6 @@ import com.squareup.moshi.FromJson
|
||||
import com.squareup.moshi.JsonClass
|
||||
import com.squareup.moshi.Moshi
|
||||
import com.squareup.moshi.ToJson
|
||||
import net.vonforst.evmap.api.availability.tesla.LocalTimeAdapter
|
||||
import net.vonforst.evmap.model.ChargeLocation
|
||||
import net.vonforst.evmap.model.Chargepoint
|
||||
import net.vonforst.evmap.utils.distanceBetween
|
||||
@@ -15,7 +14,6 @@ import retrofit2.http.GET
|
||||
import retrofit2.http.Path
|
||||
import retrofit2.http.Query
|
||||
import java.time.Instant
|
||||
import java.time.LocalTime
|
||||
|
||||
private const val coordRange = 0.005 // range of latitude and longitude for loading the map
|
||||
private const val maxDistance = 60 // max distance between reported positions in meters
|
||||
@@ -202,7 +200,8 @@ class EnBwAvailabilityDetector(client: OkHttpClient, baseUrl: String? = null) :
|
||||
val id = index.toLong()
|
||||
val power = connector.maxPowerInKw ?: 0.0
|
||||
val type = when (connector.plugTypeName) {
|
||||
"Typ 3A" -> Chargepoint.TYPE_3
|
||||
"Typ 3A" -> Chargepoint.TYPE_3A
|
||||
"Typ 3C \"Scame\"" -> Chargepoint.TYPE_3C
|
||||
"Typ 2" -> Chargepoint.TYPE_2_UNKNOWN
|
||||
"Typ 1" -> Chargepoint.TYPE_1
|
||||
"Steckdose(D)" -> Chargepoint.SCHUKO
|
||||
@@ -243,8 +242,8 @@ class EnBwAvailabilityDetector(client: OkHttpClient, baseUrl: String? = null) :
|
||||
}
|
||||
|
||||
override fun isChargerSupported(charger: ChargeLocation): Boolean {
|
||||
val country = charger.chargepriceData?.country
|
||||
?: charger.address?.country ?: return false
|
||||
val country = charger.chargepriceData?.country ?: charger.address?.country
|
||||
|
||||
return when (charger.dataSource) {
|
||||
// list of countries as of 2023/04/14, according to
|
||||
// https://www.enbw.com/elektromobilitaet/produkte/ladetarife
|
||||
@@ -286,6 +285,12 @@ class EnBwAvailabilityDetector(client: OkHttpClient, baseUrl: String? = null) :
|
||||
"ES",
|
||||
"CZ"
|
||||
) && charger.chargepriceData?.network !in listOf("23", "3534")
|
||||
/* TODO: OSM usually does not have the country tagged. Therefore we currently just use
|
||||
a bounding box to determine whether the charger is roughly in Europe */
|
||||
"openstreetmap" -> charger.coordinates.lat in 35.0..72.0
|
||||
&& charger.coordinates.lng in 25.0..65.0
|
||||
&& charger.operator !in listOf("Tesla, Inc.", "Tesla")
|
||||
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package net.vonforst.evmap.api.availability
|
||||
|
||||
import androidx.car.app.model.DateTimeWithZone
|
||||
import com.squareup.moshi.FromJson
|
||||
import com.squareup.moshi.JsonClass
|
||||
import com.squareup.moshi.Moshi
|
||||
@@ -13,12 +12,8 @@ import retrofit2.Retrofit
|
||||
import retrofit2.converter.moshi.MoshiConverterFactory
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.Path
|
||||
import java.time.Instant
|
||||
import java.time.LocalDateTime
|
||||
import java.time.ZoneOffset
|
||||
import java.time.ZonedDateTime
|
||||
import java.time.format.DateTimeParseException
|
||||
import java.util.*
|
||||
import java.util.Locale
|
||||
|
||||
private const val coordRange = 0.005 // range of latitude and longitude for loading the map
|
||||
private const val maxDistance = 60 // max distance between reported positions in meters
|
||||
@@ -180,7 +175,7 @@ class NewMotionAvailabilityDetector(client: OkHttpClient, baseUrl: String? = nul
|
||||
val id = connector.uid
|
||||
val power = connector.electricalProperties.getPower()
|
||||
val type = when (connector.connectorType.lowercase(Locale.ROOT)) {
|
||||
"type3" -> Chargepoint.TYPE_3
|
||||
"type3" -> Chargepoint.TYPE_3C
|
||||
"type2" -> Chargepoint.TYPE_2_UNKNOWN
|
||||
"type1" -> Chargepoint.TYPE_1
|
||||
"domestic" -> Chargepoint.SCHUKO
|
||||
@@ -226,6 +221,7 @@ class NewMotionAvailabilityDetector(client: OkHttpClient, baseUrl: String? = nul
|
||||
return when (charger.dataSource) {
|
||||
"goingelectric" -> charger.network != "Tesla Supercharger"
|
||||
"openchargemap" -> charger.chargepriceData?.network !in listOf("23", "3534")
|
||||
"openstreetmap" -> charger.operator !in listOf("Tesla, Inc.", "Tesla")
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,10 @@ class TeslaGuestAvailabilityDetector(
|
||||
private var api = TeslaChargingGuestGraphQlApi.create(client, baseUrl)
|
||||
|
||||
override suspend fun getAvailability(location: ChargeLocation): ChargeLocationStatus {
|
||||
if (location.chargepoints.isEmpty() || location.chargepoints.any { !it.hasKnownPower() }) {
|
||||
throw AvailabilityDetectorException("no candidates found.")
|
||||
}
|
||||
|
||||
val results = cuaApi.getTeslaLocations()
|
||||
|
||||
val result =
|
||||
@@ -149,7 +153,7 @@ class TeslaGuestAvailabilityDetector(
|
||||
val statusMap = detailsMap.mapValues { it.value.map { it.availability.toStatus() } }
|
||||
val labelsMap = detailsMap.mapValues { it.value.map { it.label } }
|
||||
|
||||
val pricing = details.pricing.copy(memberRates = guestPricing.await()?.userRates)
|
||||
val pricing = details.pricing?.copy(memberRates = guestPricing.await()?.userRates)
|
||||
|
||||
return ChargeLocationStatus(
|
||||
statusMap,
|
||||
@@ -163,6 +167,7 @@ class TeslaGuestAvailabilityDetector(
|
||||
return when (charger.dataSource) {
|
||||
"goingelectric" -> charger.network == "Tesla Supercharger"
|
||||
"openchargemap" -> charger.chargepriceData?.network in listOf("23", "3534")
|
||||
"openstreetmap" -> charger.operator in listOf("Tesla, Inc.", "Tesla")
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ package net.vonforst.evmap.api.availability
|
||||
|
||||
import net.vonforst.evmap.api.availability.tesla.ChargerAvailability
|
||||
import net.vonforst.evmap.api.availability.tesla.TeslaAuthenticationApi
|
||||
import net.vonforst.evmap.api.availability.tesla.TeslaChargingGuestGraphQlApi
|
||||
import net.vonforst.evmap.api.availability.tesla.TeslaChargingOwnershipGraphQlApi
|
||||
import net.vonforst.evmap.api.availability.tesla.asTeslaCoord
|
||||
import net.vonforst.evmap.model.ChargeLocation
|
||||
@@ -30,6 +29,10 @@ class TeslaOwnerAvailabilityDetector(
|
||||
}
|
||||
|
||||
override suspend fun getAvailability(location: ChargeLocation): ChargeLocationStatus {
|
||||
if (location.chargepoints.isEmpty() || location.chargepoints.any { !it.hasKnownPower() }) {
|
||||
throw AvailabilityDetectorException("no candidates found.")
|
||||
}
|
||||
|
||||
val api = initApi()
|
||||
val req = TeslaChargingOwnershipGraphQlApi.GetNearbyChargingSitesRequest(
|
||||
TeslaChargingOwnershipGraphQlApi.GetNearbyChargingSitesVariables(
|
||||
@@ -42,8 +45,7 @@ class TeslaOwnerAvailabilityDetector(
|
||||
TeslaChargingOwnershipGraphQlApi.Coordinate(
|
||||
location.coordinates.lat - coordRange,
|
||||
location.coordinates.lng + coordRange
|
||||
),
|
||||
TeslaChargingOwnershipGraphQlApi.OpenToNonTeslasFilterValue(false)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
@@ -59,7 +61,7 @@ class TeslaOwnerAvailabilityDetector(
|
||||
val details = api.getChargingSiteInformation(
|
||||
TeslaChargingOwnershipGraphQlApi.GetChargingSiteInformationRequest(
|
||||
TeslaChargingOwnershipGraphQlApi.GetChargingSiteInformationVariables(
|
||||
TeslaChargingOwnershipGraphQlApi.ChargingSiteIdentifier(result.id.text),
|
||||
TeslaChargingOwnershipGraphQlApi.ChargingSiteIdentifier(result.locationGUID),
|
||||
TeslaChargingOwnershipGraphQlApi.VehicleMakeType.NON_TESLA
|
||||
)
|
||||
)
|
||||
@@ -164,6 +166,7 @@ class TeslaOwnerAvailabilityDetector(
|
||||
return when (charger.dataSource) {
|
||||
"goingelectric" -> charger.network == "Tesla Supercharger"
|
||||
"openchargemap" -> charger.chargepriceData?.network in listOf("23", "3534")
|
||||
"openstreetmap" -> charger.operator in listOf("Tesla, Inc.", "Tesla")
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@ data class Rates(
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class Pricebook(
|
||||
val charging: PricebookDetails,
|
||||
val parking: PricebookDetails,
|
||||
val parking: PricebookDetails?,
|
||||
val priceBookID: Long?
|
||||
)
|
||||
|
||||
|
||||
@@ -117,7 +117,7 @@ interface TeslaChargingGuestGraphQlApi {
|
||||
val trtId: Long,
|
||||
val maxPowerKw: Int,
|
||||
val name: String,
|
||||
val pricing: Pricing,
|
||||
val pricing: Pricing?,
|
||||
val publicStallCount: Int
|
||||
)
|
||||
|
||||
|
||||
@@ -16,7 +16,6 @@ import retrofit2.http.POST
|
||||
import retrofit2.http.Query
|
||||
import java.security.MessageDigest
|
||||
import java.security.SecureRandom
|
||||
import java.time.LocalTime
|
||||
|
||||
interface TeslaAuthenticationApi {
|
||||
@POST("oauth2/v3/token")
|
||||
@@ -131,8 +130,8 @@ interface TeslaOwnerApi {
|
||||
// add API key to every request
|
||||
val request = chain.request().newBuilder()
|
||||
.header("Authorization", "Bearer $token")
|
||||
.header("User-Agent", "okhttp/4.9.2")
|
||||
.header("x-tesla-user-agent", "TeslaApp/4.19.5-1667/3a5d531cc3/android/27")
|
||||
.header("User-Agent", "okhttp/4.11.0")
|
||||
.header("x-tesla-user-agent", "TeslaApp/4.44.5-3304/3a5d531cc3/android/27")
|
||||
.header("Accept", "*/*")
|
||||
.build()
|
||||
chain.proceed(request)
|
||||
@@ -173,7 +172,7 @@ interface TeslaChargingOwnershipGraphQlApi {
|
||||
override val variables: GetNearbyChargingSitesVariables,
|
||||
override val operationName: String = "GetNearbyChargingSites",
|
||||
override val query: String =
|
||||
"\n query GetNearbyChargingSites(\$args: GetNearbyChargingSitesRequestType!) {\n charging {\n nearbySites(args: \$args) {\n sitesAndDistances {\n ...ChargingNearbySitesFragment\n }\n }\n }\n}\n \n fragment ChargingNearbySitesFragment on ChargerSiteAndDistanceType {\n activeOutages {\n message\n nonTeslasAffectedOnly {\n value\n }\n }\n availableStalls {\n value\n }\n centroid {\n ...EnergySvcCoordinateTypeFields\n }\n drivingDistanceMiles {\n value\n }\n entryPoint {\n ...EnergySvcCoordinateTypeFields\n }\n haversineDistanceMiles {\n value\n }\n id {\n text\n }\n localizedSiteName {\n value\n }\n maxPowerKw {\n value\n }\n trtId {\n value\n }\n totalStalls {\n value\n }\n siteType\n accessType\n waitEstimateBucket\n hasHighCongestion\n}\n \n fragment EnergySvcCoordinateTypeFields on EnergySvcCoordinateType {\n latitude\n longitude\n}\n "
|
||||
"\n query GetNearbyChargingSites(\$args: GetNearbyChargingSitesRequestType!) {\n charging {\n nearbySites(args: \$args) {\n sitesAndDistances {\n ...ChargingNearbySitesFragment\n }\n }\n }\n}\n \n fragment ChargingNearbySitesFragment on ChargerSiteAndDistanceType {\n availableStalls {\n value\n }\n centroid {\n ...EnergySvcCoordinateTypeFields\n }\n drivingDistanceMiles {\n value\n }\n entryPoint {\n ...EnergySvcCoordinateTypeFields\n }\n haversineDistanceMiles {\n value\n }\n id {\n text\n }\n locationGUID\n localizedSiteName {\n value\n }\n maxPowerKw {\n value\n }\n trtId {\n value\n }\n totalStalls {\n value\n }\n siteType\n accessType\n waitEstimateBucket\n hasHighCongestion\n teslaExclusive\n amenities\n chargingAccessibility\n ownerType\n isThirdPartySite\n usabilityArchetype\n accessHours {\n shouldDisplay\n openNow\n hour\n }\n isMagicDockSupportedSite\n hasParkingBenefit\n hasTou\n}\n \n fragment EnergySvcCoordinateTypeFields on EnergySvcCoordinateType {\n latitude\n longitude\n}\n"
|
||||
) : GraphQlRequest()
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
@@ -184,7 +183,7 @@ interface TeslaChargingOwnershipGraphQlApi {
|
||||
val userLocation: Coordinate,
|
||||
val northwestCorner: Coordinate,
|
||||
val southeastCorner: Coordinate,
|
||||
val openToNonTeslasFilter: OpenToNonTeslasFilterValue,
|
||||
val filters: List<String> = emptyList(),
|
||||
val languageCode: String = "en",
|
||||
val countryCode: String = "US",
|
||||
//val vin: String = "",
|
||||
@@ -202,7 +201,7 @@ interface TeslaChargingOwnershipGraphQlApi {
|
||||
override val variables: GetChargingSiteInformationVariables,
|
||||
override val operationName: String = "getChargingSiteInformation",
|
||||
override val query: String =
|
||||
"\n query getChargingSiteInformation(\$id: ChargingSiteIdentifierInputType!, \$vehicleMakeType: ChargingVehicleMakeTypeEnum, \$deviceCountry: String!, \$deviceLanguage: String!) {\n charging {\n site(\n id: \$id\n deviceCountry: \$deviceCountry\n deviceLanguage: \$deviceLanguage\n vehicleMakeType: \$vehicleMakeType\n ) {\n siteStatic {\n ...SiteStaticFragmentNoHoldAmt\n }\n siteDynamic {\n ...SiteDynamicFragment\n }\n pricing(vehicleMakeType: \$vehicleMakeType) {\n userRates {\n ...ChargingActiveRateFragment\n }\n memberRates {\n ...ChargingActiveRateFragment\n }\n hasMembershipPricing\n hasMSPPricing\n canDisplayCombinedComparison\n }\n holdAmount(vehicleMakeType: \$vehicleMakeType) {\n currencyCode\n holdAmount\n }\n congestionPriceHistogram(vehicleMakeType: \$vehicleMakeType) {\n ...CongestionPriceHistogramFragment\n }\n }\n }\n}\n \n fragment SiteStaticFragmentNoHoldAmt on ChargingSiteStaticType {\n address {\n ...AddressFragment\n }\n amenities\n centroid {\n ...EnergySvcCoordinateTypeFields\n }\n entryPoint {\n ...EnergySvcCoordinateTypeFields\n }\n id {\n text\n }\n accessCode {\n value\n }\n localizedSiteName {\n value\n }\n maxPowerKw {\n value\n }\n name\n openToPublic\n chargers {\n id {\n text\n }\n label {\n value\n }\n }\n publicStallCount\n timeZone {\n id\n version\n }\n fastchargeSiteId {\n value\n }\n siteType\n accessType\n isMagicDockSupportedSite\n trtId {\n value\n }\n}\n \n fragment AddressFragment on EnergySvcAddressType {\n streetNumber {\n value\n }\n street {\n value\n }\n district {\n value\n }\n city {\n value\n }\n state {\n value\n }\n postalCode {\n value\n }\n country\n}\n \n\n fragment EnergySvcCoordinateTypeFields on EnergySvcCoordinateType {\n latitude\n longitude\n}\n \n\n fragment SiteDynamicFragment on ChargingSiteDynamicType {\n id {\n text\n }\n activeOutages {\n message\n nonTeslasAffectedOnly {\n value\n }\n }\n chargersAvailable {\n value\n }\n chargerDetails {\n charger {\n id {\n text\n }\n label {\n value\n }\n name\n }\n availability\n }\n waitEstimateBucket\n currentCongestion\n}\n \n\n fragment ChargingActiveRateFragment on ChargingActiveRateType {\n activePricebook {\n charging {\n ...ChargingUserRateFragment\n }\n parking {\n ...ChargingUserRateFragment\n }\n priceBookID\n }\n}\n \n fragment ChargingUserRateFragment on ChargingUserRateType {\n currencyCode\n programType\n rates\n buckets {\n start\n end\n }\n bucketUom\n touRates {\n enabled\n activeRatesByTime {\n startTime\n endTime\n rates\n }\n }\n uom\n vehicleMakeType\n}\n \n\n fragment CongestionPriceHistogramFragment on HistogramData {\n axisLabels {\n index\n value\n }\n regionLabels {\n index\n value {\n ...ChargingPriceFragment\n ... on HistogramRegionLabelValueString {\n value\n }\n }\n }\n chargingUom\n parkingUom\n parkingRate {\n ...ChargingPriceFragment\n }\n data\n activeBar\n maxRateIndex\n whenRateChanges\n dataAttributes {\n congestionThreshold\n label\n }\n}\n \n fragment ChargingPriceFragment on ChargingPrice {\n currencyCode\n price\n}\n"
|
||||
"\n query getChargingSiteInformation(\$id: ChargingSiteIdentifierInputType!, \$vehicleMakeType: ChargingVehicleMakeTypeEnum, \$deviceCountry: String!, \$deviceLanguage: String!) {\n charging {\n site(\n id: \$id\n deviceCountry: \$deviceCountry\n deviceLanguage: \$deviceLanguage\n vehicleMakeType: \$vehicleMakeType\n ) {\n siteStatic {\n ...SiteStaticFragmentNoHoldAmt\n }\n siteDynamic {\n ...SiteDynamicFragment\n }\n pricing(vehicleMakeType: \$vehicleMakeType) {\n userRates {\n ...ChargingActiveRateFragment\n }\n memberRates {\n ...ChargingActiveRateFragment\n }\n hasMembershipPricing\n hasMSPPricing\n canDisplayCombinedComparison\n }\n holdAmount(vehicleMakeType: \$vehicleMakeType) {\n currencyCode\n holdAmount\n }\n congestionPriceHistogram(vehicleMakeType: \$vehicleMakeType) {\n ...CongestionPriceHistogramFragment\n }\n upsellingBanner(vehicleMakeType: \$vehicleMakeType) {\n header\n caption\n backgroundImageUrl\n routeName\n }\n nacsOnlyAssets {\n banner {\n header\n caption\n link\n }\n disclaimer {\n text\n sheetTitle\n sheetContent\n }\n }\n enableChargingSiteReportIssue\n }\n }\n}\n \n fragment SiteStaticFragmentNoHoldAmt on ChargingSiteStaticType {\n address {\n ...AddressFragment\n }\n amenities\n centroid {\n ...EnergySvcCoordinateTypeFields\n }\n entryPoint {\n ...EnergySvcCoordinateTypeFields\n }\n id {\n text\n }\n locationGUID\n accessCode {\n value\n }\n localizedSiteName {\n value\n }\n maxPowerKw {\n value\n }\n name\n openToPublic\n chargers {\n id {\n text\n }\n label {\n value\n }\n }\n publicStallCount\n timeZone {\n id\n version\n }\n fastchargeSiteId {\n value\n }\n siteType\n accessType\n isThirdPartySite\n isMagicDockSupportedSite\n trtId {\n value\n }\n siteDisclaimer\n chargingAccessibility\n accessHours {\n shouldDisplay\n openNow\n hour\n }\n isCanvasSite\n ownerDisclaimer\n chargingFeesDisclaimer {\n title\n description\n }\n idleFeesDisclaimer {\n title\n description\n }\n}\n \n fragment AddressFragment on EnergySvcAddressType {\n streetNumber {\n value\n }\n street {\n value\n }\n district {\n value\n }\n city {\n value\n }\n state {\n value\n }\n postalCode {\n value\n }\n country\n}\n \n\n fragment EnergySvcCoordinateTypeFields on EnergySvcCoordinateType {\n latitude\n longitude\n}\n \n\n fragment SiteDynamicFragment on ChargingSiteDynamicType {\n id {\n text\n }\n chargersAvailable {\n value\n }\n chargerDetails {\n charger {\n id {\n text\n }\n label {\n value\n }\n name\n }\n availability\n stateOfCharge\n chargerDisabled\n }\n waitEstimateBucket\n currentCongestion\n usabilityArchetype\n}\n \n\n fragment ChargingActiveRateFragment on ChargingActiveRateType {\n activePricebook {\n charging {\n dynamicRates {\n enabled\n }\n ...ChargingUserRateFragment\n }\n parking {\n ...ChargingUserRateFragment\n }\n congestion {\n ...ChargingUserRateFragment\n }\n service {\n ...ChargingUserRateFragment\n }\n electricity {\n ...ChargingUserRateFragment\n }\n priceBookID\n }\n}\n \n fragment ChargingUserRateFragment on ChargingUserRateType {\n currencyCode\n programType\n rates\n buckets {\n start\n end\n }\n bucketUom\n touRates {\n enabled\n activeRatesByTime {\n startTime\n endTime\n rates\n }\n }\n uom\n vehicleMakeType\n stateOfCharge\n congestionGracePeriodSecs\n congestionPercent\n}\n \n\n fragment CongestionPriceHistogramFragment on HistogramData {\n axisLabels {\n index\n value\n }\n regionLabels {\n index\n value {\n ...ChargingPriceFragment\n ... on HistogramRegionLabelValueString {\n value\n }\n }\n }\n chargingUom\n parkingUom\n parkingRate {\n ...ChargingPriceFragment\n }\n data\n activeBar\n maxRateIndex\n whenRateChanges\n dataAttributes {\n congestionThreshold\n label\n }\n}\n \n fragment ChargingPriceFragment on ChargingPrice {\n currencyCode\n price\n}\n"
|
||||
) : GraphQlRequest()
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
@@ -217,11 +216,11 @@ interface TeslaChargingOwnershipGraphQlApi {
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class ChargingSiteIdentifier(
|
||||
val id: String,
|
||||
val type: ChargingSiteIdentifierType = ChargingSiteIdentifierType.SITE_ID
|
||||
val type: ChargingSiteIdentifierType = ChargingSiteIdentifierType.LOCATION_GUID
|
||||
)
|
||||
|
||||
enum class ChargingSiteIdentifierType {
|
||||
SITE_ID
|
||||
SITE_ID, LOCATION_GUID
|
||||
}
|
||||
|
||||
enum class VehicleMakeType {
|
||||
@@ -242,7 +241,6 @@ interface TeslaChargingOwnershipGraphQlApi {
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class ChargingSite(
|
||||
val activeOutages: List<Outage>,
|
||||
val availableStalls: Value<Int>?,
|
||||
val centroid: Coordinate,
|
||||
val drivingDistanceMiles: Value<Double>?,
|
||||
@@ -251,7 +249,8 @@ interface TeslaChargingOwnershipGraphQlApi {
|
||||
val id: Text,
|
||||
val localizedSiteName: Value<String>,
|
||||
val maxPowerKw: Value<Int>,
|
||||
val totalStalls: Value<Int>
|
||||
val totalStalls: Value<Int>,
|
||||
val locationGUID: String
|
||||
// TODO: siteType, accessType
|
||||
)
|
||||
|
||||
@@ -274,7 +273,6 @@ interface TeslaChargingOwnershipGraphQlApi {
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class SiteDynamic(
|
||||
val activeOutages: List<Outage>,
|
||||
val chargerDetails: List<ChargerDetail>,
|
||||
val chargersAvailable: Value<Int>?,
|
||||
val currentCongestion: Double,
|
||||
@@ -373,8 +371,8 @@ interface TeslaChargingOwnershipGraphQlApi {
|
||||
// add API key to every request
|
||||
val request = chain.request().newBuilder()
|
||||
.header("Authorization", "Bearer $t")
|
||||
.header("User-Agent", "okhttp/4.9.2")
|
||||
.header("x-tesla-user-agent", "TeslaApp/4.19.5-1667/3a5d531cc3/android/27")
|
||||
.header("User-Agent", "okhttp/4.11.0")
|
||||
.header("x-tesla-user-agent", "TeslaApp/4.44.5-3304/3a5d531cc3/android/27")
|
||||
.header("Accept", "*/*")
|
||||
.build()
|
||||
chain.proceed(request)
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
package net.vonforst.evmap.api.fronyx
|
||||
|
||||
import android.content.Context
|
||||
import com.squareup.moshi.JsonDataException
|
||||
import net.vonforst.evmap.R
|
||||
import net.vonforst.evmap.api.availability.AvailabilityDetectorException
|
||||
import net.vonforst.evmap.api.availability.AvailabilityRepository
|
||||
import net.vonforst.evmap.api.availability.ChargeLocationStatus
|
||||
import net.vonforst.evmap.api.equivalentPlugTypes
|
||||
import net.vonforst.evmap.api.nameForPlugType
|
||||
@@ -13,8 +10,6 @@ import net.vonforst.evmap.model.ChargeLocation
|
||||
import net.vonforst.evmap.model.Chargepoint
|
||||
import net.vonforst.evmap.storage.PreferenceDataSource
|
||||
import net.vonforst.evmap.viewmodel.Resource
|
||||
import retrofit2.HttpException
|
||||
import java.io.IOException
|
||||
import java.time.LocalDate
|
||||
import java.time.LocalTime
|
||||
import java.time.ZoneId
|
||||
@@ -55,9 +50,9 @@ class PredictionRepository(private val context: Context) {
|
||||
evseIds: Map<Chargepoint, List<String>>,
|
||||
filteredConnectors: Set<String>?
|
||||
): Resource<List<FronyxEvseIdResponse>> {
|
||||
if (!prefs.predictionEnabled) return Resource.success(null)
|
||||
return Resource.success(null)
|
||||
|
||||
val allEvseIds =
|
||||
/*val allEvseIds =
|
||||
evseIds.filterKeys {
|
||||
FronyxApi.isChargepointSupported(charger, it) &&
|
||||
filteredConnectors?.let { filtered ->
|
||||
@@ -89,7 +84,7 @@ class PredictionRepository(private val context: Context) {
|
||||
// malformed JSON response from fronyx API
|
||||
e.printStackTrace()
|
||||
return Resource.error(e.message, null)
|
||||
}
|
||||
}*/
|
||||
}
|
||||
|
||||
private fun buildPredictionGraph(
|
||||
|
||||
@@ -15,6 +15,7 @@ import net.vonforst.evmap.addDebugInterceptors
|
||||
import net.vonforst.evmap.api.ChargepointApi
|
||||
import net.vonforst.evmap.api.ChargepointList
|
||||
import net.vonforst.evmap.api.FiltersSQLQuery
|
||||
import net.vonforst.evmap.api.FullDownloadResult
|
||||
import net.vonforst.evmap.api.StringProvider
|
||||
import net.vonforst.evmap.api.mapPower
|
||||
import net.vonforst.evmap.api.mapPowerInverse
|
||||
@@ -159,6 +160,12 @@ class GoingElectricApiWrapper(
|
||||
override val name = "GoingElectric.de"
|
||||
override val id = "goingelectric"
|
||||
override val cacheLimit = Duration.ofDays(1)
|
||||
override val supportsOnlineQueries = true
|
||||
override val supportsFullDownload = false
|
||||
|
||||
override suspend fun fullDownload(): FullDownloadResult<GEReferenceData> {
|
||||
throw NotImplementedError()
|
||||
}
|
||||
|
||||
override suspend fun getChargepoints(
|
||||
referenceData: ReferenceData,
|
||||
|
||||
@@ -208,7 +208,7 @@ data class GEChargepoint(val type: String, val power: Double, val count: Int) {
|
||||
return when (type) {
|
||||
Chargepoint.TYPE_1 -> "Typ1"
|
||||
Chargepoint.TYPE_2_UNKNOWN -> "Typ2"
|
||||
Chargepoint.TYPE_3 -> "Typ3"
|
||||
Chargepoint.TYPE_3C -> "Typ3"
|
||||
Chargepoint.CCS_UNKNOWN -> "CCS"
|
||||
Chargepoint.CCS_TYPE_2 -> "Typ2"
|
||||
Chargepoint.SCHUKO -> "Schuko"
|
||||
@@ -225,7 +225,7 @@ data class GEChargepoint(val type: String, val power: Double, val count: Int) {
|
||||
return when (type) {
|
||||
"Typ1" -> Chargepoint.TYPE_1
|
||||
"Typ2" -> Chargepoint.TYPE_2_UNKNOWN
|
||||
"Typ3" -> Chargepoint.TYPE_3
|
||||
"Typ3" -> Chargepoint.TYPE_3C
|
||||
"Tesla Supercharger CCS" -> Chargepoint.CCS_UNKNOWN
|
||||
"CCS" -> Chargepoint.CCS_UNKNOWN
|
||||
"Schuko" -> Chargepoint.SCHUKO
|
||||
|
||||
@@ -11,6 +11,7 @@ import net.vonforst.evmap.addDebugInterceptors
|
||||
import net.vonforst.evmap.api.ChargepointApi
|
||||
import net.vonforst.evmap.api.ChargepointList
|
||||
import net.vonforst.evmap.api.FiltersSQLQuery
|
||||
import net.vonforst.evmap.api.FullDownloadResult
|
||||
import net.vonforst.evmap.api.StringProvider
|
||||
import net.vonforst.evmap.api.mapPower
|
||||
import net.vonforst.evmap.api.mapPowerInverse
|
||||
@@ -130,6 +131,12 @@ class OpenChargeMapApiWrapper(
|
||||
|
||||
override val name = "OpenChargeMap.org"
|
||||
override val id = "openchargemap"
|
||||
override val supportsOnlineQueries = true
|
||||
override val supportsFullDownload = false
|
||||
|
||||
override suspend fun fullDownload(): FullDownloadResult<OCMReferenceData> {
|
||||
throw NotImplementedError()
|
||||
}
|
||||
|
||||
private fun formatMultipleChoice(value: MultipleChoiceFilterValue?) =
|
||||
if (value == null || value.all) null else value.values.joinToString(",")
|
||||
|
||||
@@ -76,7 +76,7 @@ data class OCMChargepoint(
|
||||
mediaItems?.mapNotNull { it.convert() },
|
||||
null,
|
||||
null,
|
||||
cost?.let { Cost(descriptionShort = it) },
|
||||
cost?.takeIf { it.isNotBlank() }.let { Cost(descriptionShort = it) },
|
||||
dataProvider?.let { "© ${it.title}" + if (it.license != null) ". ${it.license}" else "" },
|
||||
ChargepriceData(
|
||||
addressInfo.countryISOCode(refData),
|
||||
@@ -180,8 +180,8 @@ data class OCMConnection(
|
||||
25L -> Chargepoint.TYPE_2_SOCKET
|
||||
1036L -> Chargepoint.TYPE_2_PLUG
|
||||
1L -> Chargepoint.TYPE_1
|
||||
36L -> Chargepoint.TYPE_3
|
||||
26L -> Chargepoint.TYPE_3
|
||||
36L -> Chargepoint.TYPE_3A
|
||||
26L -> Chargepoint.TYPE_3C
|
||||
else -> title ?: ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
package net.vonforst.evmap.api.openstreetmap
|
||||
|
||||
import com.squareup.moshi.FromJson
|
||||
import com.squareup.moshi.JsonReader
|
||||
import com.squareup.moshi.Moshi
|
||||
import com.squareup.moshi.ToJson
|
||||
import com.squareup.moshi.rawType
|
||||
import okhttp3.ResponseBody
|
||||
import retrofit2.Converter
|
||||
import retrofit2.Retrofit
|
||||
import java.lang.reflect.Type
|
||||
import java.time.Instant
|
||||
import kotlin.math.floor
|
||||
|
||||
internal class InstantAdapter {
|
||||
@FromJson
|
||||
fun fromJson(value: Double?): Instant? = value?.let {
|
||||
val seconds = floor(it).toLong()
|
||||
val nanos = ((value - seconds) * 1e9).toLong()
|
||||
Instant.ofEpochSecond(seconds, nanos)
|
||||
}
|
||||
|
||||
@ToJson
|
||||
fun toJson(value: Instant?): Double? = value?.let {
|
||||
it.epochSecond.toDouble() + it.nano / 1e9
|
||||
}
|
||||
}
|
||||
|
||||
internal class OSMConverterFactory(val moshi: Moshi) : Converter.Factory() {
|
||||
override fun responseBodyConverter(
|
||||
type: Type,
|
||||
annotations: Array<out Annotation>,
|
||||
retrofit: Retrofit
|
||||
): Converter<ResponseBody, *>? {
|
||||
if (type.rawType != OSMDocument::class.java) return null
|
||||
|
||||
val instantAdapter = moshi.adapter(Instant::class.java)
|
||||
val osmChargingStationAdapter = moshi.adapter(OSMChargingStation::class.java)
|
||||
val longAdapter = moshi.adapter(Long::class.java)
|
||||
return Converter<ResponseBody, OSMDocument> { body ->
|
||||
val reader = JsonReader.of(body.source())
|
||||
reader.beginObject()
|
||||
|
||||
var timestamp: Instant? = null
|
||||
var doc: Sequence<OSMChargingStation>? = null
|
||||
var count: Long? = null
|
||||
while (reader.hasNext()) {
|
||||
when (reader.nextName()) {
|
||||
"timestamp" -> timestamp = instantAdapter.fromJson(reader)!!
|
||||
"count" -> count = longAdapter.fromJson(reader)!!
|
||||
"elements" -> {
|
||||
doc = sequence {
|
||||
reader.beginArray()
|
||||
while (reader.hasNext()) {
|
||||
yield(osmChargingStationAdapter.fromJson(reader)!!)
|
||||
}
|
||||
reader.endArray()
|
||||
reader.close()
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
OSMDocument(timestamp!!, count!!, doc!!)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
package net.vonforst.evmap.api.openstreetmap
|
||||
|
||||
import android.database.DatabaseUtils
|
||||
import com.car2go.maps.model.LatLng
|
||||
import com.car2go.maps.model.LatLngBounds
|
||||
import com.squareup.moshi.Moshi
|
||||
import net.vonforst.evmap.BuildConfig
|
||||
import net.vonforst.evmap.R
|
||||
import net.vonforst.evmap.addDebugInterceptors
|
||||
import net.vonforst.evmap.api.ChargepointApi
|
||||
import net.vonforst.evmap.api.ChargepointList
|
||||
import net.vonforst.evmap.api.FiltersSQLQuery
|
||||
import net.vonforst.evmap.api.FullDownloadResult
|
||||
import net.vonforst.evmap.api.StringProvider
|
||||
import net.vonforst.evmap.api.mapPower
|
||||
import net.vonforst.evmap.api.mapPowerInverse
|
||||
import net.vonforst.evmap.api.nameForPlugType
|
||||
import net.vonforst.evmap.api.openchargemap.ZonedDateTimeAdapter
|
||||
import net.vonforst.evmap.api.powerSteps
|
||||
import net.vonforst.evmap.model.BooleanFilter
|
||||
import net.vonforst.evmap.model.ChargeLocation
|
||||
import net.vonforst.evmap.model.Chargepoint
|
||||
import net.vonforst.evmap.model.Filter
|
||||
import net.vonforst.evmap.model.FilterValue
|
||||
import net.vonforst.evmap.model.FilterValues
|
||||
import net.vonforst.evmap.model.MultipleChoiceFilter
|
||||
import net.vonforst.evmap.model.ReferenceData
|
||||
import net.vonforst.evmap.model.SliderFilter
|
||||
import net.vonforst.evmap.model.getBooleanValue
|
||||
import net.vonforst.evmap.model.getMultipleChoiceValue
|
||||
import net.vonforst.evmap.model.getSliderValue
|
||||
import net.vonforst.evmap.viewmodel.Resource
|
||||
import okhttp3.OkHttpClient
|
||||
import retrofit2.Response
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.http.GET
|
||||
import java.io.IOException
|
||||
import java.time.Duration
|
||||
|
||||
interface OpenStreetMapApi {
|
||||
@GET("charging-stations-osm.json")
|
||||
suspend fun getAllChargingStations(): Response<OSMDocument>
|
||||
|
||||
companion object {
|
||||
private val moshi = Moshi.Builder()
|
||||
.add(ZonedDateTimeAdapter())
|
||||
.add(InstantAdapter())
|
||||
.build()
|
||||
|
||||
fun create(
|
||||
baseurl: String = "https://osm.ev-map.app/"
|
||||
): OpenStreetMapApi {
|
||||
val client = OkHttpClient.Builder().apply {
|
||||
if (BuildConfig.DEBUG) addDebugInterceptors()
|
||||
}.build()
|
||||
|
||||
val retrofit = Retrofit.Builder()
|
||||
.baseUrl(baseurl)
|
||||
.addConverterFactory(OSMConverterFactory(moshi))
|
||||
.client(client)
|
||||
.build()
|
||||
return retrofit.create(OpenStreetMapApi::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class OpenStreetMapApiWrapper(baseurl: String = "https://osm.ev-map.app/") :
|
||||
ChargepointApi<OSMReferenceData> {
|
||||
override val name = "OpenStreetMap"
|
||||
override val id = "openstreetmap"
|
||||
override val cacheLimit = Duration.ofDays(300L)
|
||||
override val supportsOnlineQueries = false
|
||||
override val supportsFullDownload = true
|
||||
|
||||
val api = OpenStreetMapApi.create(baseurl)
|
||||
|
||||
override suspend fun getChargepoints(
|
||||
referenceData: ReferenceData,
|
||||
bounds: LatLngBounds,
|
||||
zoom: Float,
|
||||
useClustering: Boolean,
|
||||
filters: FilterValues?
|
||||
): Resource<ChargepointList> {
|
||||
throw NotImplementedError()
|
||||
}
|
||||
|
||||
override suspend fun getChargepointsRadius(
|
||||
referenceData: ReferenceData,
|
||||
location: LatLng,
|
||||
radius: Int,
|
||||
zoom: Float,
|
||||
useClustering: Boolean,
|
||||
filters: FilterValues?
|
||||
): Resource<ChargepointList> {
|
||||
throw NotImplementedError()
|
||||
}
|
||||
|
||||
override suspend fun getChargepointDetail(
|
||||
referenceData: ReferenceData,
|
||||
id: Long
|
||||
): Resource<ChargeLocation> {
|
||||
throw NotImplementedError()
|
||||
}
|
||||
|
||||
override suspend fun getReferenceData(): Resource<OSMReferenceData> {
|
||||
throw NotImplementedError()
|
||||
}
|
||||
|
||||
override fun getFilters(
|
||||
referenceData: ReferenceData,
|
||||
sp: StringProvider
|
||||
): List<Filter<FilterValue>> {
|
||||
|
||||
val plugs = listOf(
|
||||
Chargepoint.TYPE_1,
|
||||
Chargepoint.CCS_TYPE_1,
|
||||
Chargepoint.TYPE_2_SOCKET,
|
||||
Chargepoint.TYPE_2_PLUG,
|
||||
Chargepoint.CCS_TYPE_2,
|
||||
Chargepoint.CHADEMO,
|
||||
Chargepoint.SUPERCHARGER,
|
||||
Chargepoint.CEE_BLAU,
|
||||
Chargepoint.CEE_ROT,
|
||||
Chargepoint.SCHUKO
|
||||
)
|
||||
val plugMap = plugs.associateWith { plug ->
|
||||
nameForPlugType(sp, plug)
|
||||
}
|
||||
|
||||
val refData = referenceData as OSMReferenceData
|
||||
val networkMap = refData.networks.associateWith { it }
|
||||
|
||||
return listOf(
|
||||
BooleanFilter(sp.getString(R.string.filter_free), "freecharging"),
|
||||
BooleanFilter(sp.getString(R.string.filter_free_parking), "freeparking"),
|
||||
BooleanFilter(sp.getString(R.string.filter_open_247), "open_247"),
|
||||
SliderFilter(
|
||||
sp.getString(R.string.filter_min_power), "min_power",
|
||||
powerSteps.size - 1,
|
||||
mapping = ::mapPower,
|
||||
inverseMapping = ::mapPowerInverse,
|
||||
unit = "kW"
|
||||
),
|
||||
MultipleChoiceFilter(
|
||||
sp.getString(R.string.filter_connectors), "connectors",
|
||||
plugMap,
|
||||
commonChoices = setOf(
|
||||
Chargepoint.TYPE_1,
|
||||
Chargepoint.TYPE_2_SOCKET,
|
||||
Chargepoint.TYPE_2_PLUG,
|
||||
Chargepoint.CCS_TYPE_1,
|
||||
Chargepoint.CCS_TYPE_2,
|
||||
Chargepoint.CHADEMO
|
||||
),
|
||||
manyChoices = true
|
||||
),
|
||||
MultipleChoiceFilter(
|
||||
sp.getString(R.string.filter_networks), "networks",
|
||||
networkMap, manyChoices = true
|
||||
),
|
||||
SliderFilter(
|
||||
sp.getString(R.string.filter_min_connectors),
|
||||
"min_connectors",
|
||||
10,
|
||||
min = 1
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
override fun convertFiltersToSQL(
|
||||
filters: FilterValues,
|
||||
referenceData: ReferenceData
|
||||
): FiltersSQLQuery {
|
||||
if (filters.isEmpty()) return FiltersSQLQuery("", false, false)
|
||||
var requiresChargepointQuery = false
|
||||
|
||||
val result = StringBuilder()
|
||||
if (filters.getBooleanValue("freecharging") == true) {
|
||||
result.append(" AND freecharging IS 1")
|
||||
}
|
||||
if (filters.getBooleanValue("freeparking") == true) {
|
||||
result.append(" AND freeparking IS 1")
|
||||
}
|
||||
if (filters.getBooleanValue("open_247") == true) {
|
||||
result.append(" AND twentyfourSeven IS 1")
|
||||
}
|
||||
|
||||
val minPower = filters.getSliderValue("min_power")
|
||||
if (minPower != null && minPower > 0) {
|
||||
result.append(" AND json_extract(cp.value, '$.power') >= ${minPower}")
|
||||
requiresChargepointQuery = true
|
||||
}
|
||||
|
||||
val connectors = filters.getMultipleChoiceValue("connectors")
|
||||
if (connectors != null && !connectors.all) {
|
||||
val connectorsList = if (connectors.values.size == 0) {
|
||||
""
|
||||
} else {
|
||||
connectors.values.joinToString(",") {
|
||||
DatabaseUtils.sqlEscapeString(it)
|
||||
}
|
||||
}
|
||||
result.append(" AND json_extract(cp.value, '$.type') IN (${connectorsList})")
|
||||
requiresChargepointQuery = true
|
||||
}
|
||||
|
||||
val minConnectors = filters.getSliderValue("min_connectors")
|
||||
if (minConnectors != null && minConnectors > 1) {
|
||||
result.append(" GROUP BY ChargeLocation.id HAVING SUM(json_extract(cp.value, '$.count')) >= $minConnectors")
|
||||
requiresChargepointQuery = true
|
||||
}
|
||||
|
||||
val networks = filters.getMultipleChoiceValue("networks")
|
||||
if (networks != null && !networks.all) {
|
||||
val networksList = if (networks.values.size == 0) {
|
||||
""
|
||||
} else {
|
||||
networks.values.joinToString(",") { DatabaseUtils.sqlEscapeString(it) }
|
||||
}
|
||||
result.append(" AND network IN (${networksList})")
|
||||
}
|
||||
|
||||
return FiltersSQLQuery(result.toString(), requiresChargepointQuery, false)
|
||||
}
|
||||
|
||||
override fun filteringInSQLRequiresDetails(filters: FilterValues): Boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
override suspend fun fullDownload(): FullDownloadResult<OSMReferenceData> {
|
||||
val response = api.getAllChargingStations()
|
||||
if (!response.isSuccessful) {
|
||||
throw IOException(response.message())
|
||||
} else {
|
||||
val body = response.body()!!
|
||||
return OSMFullDownloadResult(body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class OSMReferenceData(val networks: List<String>) : ReferenceData()
|
||||
|
||||
class OSMFullDownloadResult(private val body: OSMDocument) : FullDownloadResult<OSMReferenceData> {
|
||||
private var downloadProgress = 0f
|
||||
private var refData: OSMReferenceData? = null
|
||||
|
||||
override val chargers: Sequence<ChargeLocation>
|
||||
get() {
|
||||
val time = body.timestamp
|
||||
val networks = mutableListOf<String>()
|
||||
|
||||
return sequence {
|
||||
body.elements.forEachIndexed { i, it ->
|
||||
val charger = it.convert(time)
|
||||
yield(charger)
|
||||
downloadProgress = i.toFloat() / body.count
|
||||
charger.network?.let { networks.add(it) }
|
||||
}
|
||||
refData = OSMReferenceData(networks)
|
||||
}
|
||||
}
|
||||
override val progress: Float
|
||||
get() = downloadProgress
|
||||
override val referenceData: OSMReferenceData
|
||||
get() = refData
|
||||
?: throw UnsupportedOperationException("referenceData is only available once download is complete")
|
||||
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package net.vonforst.evmap.api.openstreetmap
|
||||
|
||||
import com.squareup.moshi.Json
|
||||
import com.squareup.moshi.JsonClass
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import net.vonforst.evmap.model.*
|
||||
import okhttp3.internal.immutableListOf
|
||||
import java.time.Instant
|
||||
@@ -40,6 +41,7 @@ private val SOCKET_TYPES = immutableListOf(
|
||||
// Tesla
|
||||
OsmSocket("tesla_standard", null),
|
||||
OsmSocket("tesla_supercharger", Chargepoint.SUPERCHARGER),
|
||||
OsmSocket("tesla_supercharger_ccs", Chargepoint.CCS_UNKNOWN),
|
||||
|
||||
// CEE
|
||||
OsmSocket("cee_blue", Chargepoint.CEE_BLAU), // Also known as "caravan socket"
|
||||
@@ -58,6 +60,12 @@ private val SOCKET_TYPES = immutableListOf(
|
||||
OsmSocket("sev1011_t25", null),
|
||||
)
|
||||
|
||||
data class OSMDocument(
|
||||
val timestamp: Instant,
|
||||
val count: Long,
|
||||
val elements: Sequence<OSMChargingStation>
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class OSMChargingStation(
|
||||
// Unique numeric ID
|
||||
@@ -87,7 +95,7 @@ data class OSMChargingStation(
|
||||
"openstreetmap",
|
||||
getName(),
|
||||
Coordinate(lat, lon),
|
||||
null, // TODO: Can we determine this with overpass?
|
||||
getAddress(),
|
||||
getChargepoints(),
|
||||
tags["network"],
|
||||
"https://www.openstreetmap.org/node/$id",
|
||||
@@ -99,18 +107,31 @@ data class OSMChargingStation(
|
||||
tags["description"],
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
getPhotos(),
|
||||
null,
|
||||
getOpeningHours(),
|
||||
getCost(),
|
||||
"© OpenStreetMap contributors",
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
tags["website"],
|
||||
dataFetchTimestamp,
|
||||
true,
|
||||
)
|
||||
|
||||
private fun getAddress(): Address? {
|
||||
val city = tags["addr:city"]
|
||||
val country = tags["addr:country"]
|
||||
val postcode = tags["addr:postcode"]
|
||||
val street = tags["addr:street"]
|
||||
val housenumber = tags["addr:housenumber"] ?: tags["addr:housename"]
|
||||
return if (listOf(city, country, postcode, street, housenumber).any { it != null }) {
|
||||
Address(city, country, postcode, "$street $housenumber")
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the name for this charging station.
|
||||
*/
|
||||
@@ -165,7 +186,7 @@ data class OSMChargingStation(
|
||||
return null
|
||||
}
|
||||
|
||||
private fun getCost(): Cost? {
|
||||
private fun getCost(): Cost {
|
||||
val freecharging = when (tags["fee"]?.lowercase()) {
|
||||
"yes", "y" -> false
|
||||
"no", "n" -> true
|
||||
@@ -176,7 +197,28 @@ data class OSMChargingStation(
|
||||
"yes", "y", "interval" -> false
|
||||
else -> null
|
||||
}
|
||||
return Cost(freecharging, freeparking)
|
||||
val description = listOfNotNull(tags["charge"], tags["charge:conditional"]).ifEmpty { null }
|
||||
?.joinToString("\n")
|
||||
return Cost(freecharging, freeparking, null, description)
|
||||
}
|
||||
|
||||
private fun getPhotos(): List<ChargerPhoto> {
|
||||
val photos = mutableListOf<ChargerPhoto>()
|
||||
for (i in -1..9) {
|
||||
val url = tags["image" + if (i >= 0) ":$i" else ""]
|
||||
if (url != null) {
|
||||
if (url.startsWith("https://i.imgur.com")) {
|
||||
ImgurChargerPhoto.create(url)?.let { photos.add(it) }
|
||||
}
|
||||
/*
|
||||
TODO: Imgur seems to be by far the most common image hoster (650 images),
|
||||
followed by Mapillary (450, requires an API key to retrieve images)
|
||||
Other than that, we have Google Photos, Wikimedia Commons (100-150 images each).
|
||||
And there are some other links to various sites, but not all are valid links pointing directly to a JPEG file...
|
||||
*/
|
||||
}
|
||||
}
|
||||
return photos
|
||||
}
|
||||
|
||||
companion object {
|
||||
@@ -201,4 +243,26 @@ data class OSMChargingStation(
|
||||
return numberString.toDoubleOrNull()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Parcelize
|
||||
@JsonClass(generateAdapter = true)
|
||||
class ImgurChargerPhoto(override val id: String) : ChargerPhoto(id) {
|
||||
override fun getUrl(height: Int?, width: Int?, size: Int?, allowOriginal: Boolean): String {
|
||||
return if (allowOriginal) {
|
||||
"https://i.imgur.com/$id.jpg"
|
||||
} else {
|
||||
val value = width ?: size ?: height
|
||||
"https://i.imgur.com/${id}_d.jpg?maxwidth=$value"
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val regex = Regex("https?://i.imgur.com/([\\w\\d]+)(?:_d)?.(?:webp|jpg)")
|
||||
|
||||
fun create(url: String): ImgurChargerPhoto? {
|
||||
val id = regex.find(url)?.groups?.get(1)?.value
|
||||
return id?.let { ImgurChargerPhoto(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ import androidx.lifecycle.DefaultLifecycleObserver
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import com.car2go.maps.model.LatLng
|
||||
import net.vonforst.evmap.R
|
||||
import net.vonforst.evmap.autocomplete.PlaceWithBounds
|
||||
import net.vonforst.evmap.location.FusionEngine
|
||||
import net.vonforst.evmap.location.LocationEngine
|
||||
import net.vonforst.evmap.location.Priority
|
||||
@@ -45,14 +46,20 @@ interface LocationAwareScreen {
|
||||
class CarAppService : androidx.car.app.CarAppService() {
|
||||
private val CHANNEL_ID = "car_location"
|
||||
private val NOTIFICATION_ID = 1000
|
||||
private val TAG = "CarAppService"
|
||||
private var foregroundStarted = false
|
||||
|
||||
fun ensureForegroundService() {
|
||||
// we want to run as a foreground service to make sure we can use location
|
||||
if (!foregroundStarted) {
|
||||
createNotificationChannel()
|
||||
startForeground(NOTIFICATION_ID, getNotification())
|
||||
foregroundStarted = true
|
||||
try {
|
||||
if (!foregroundStarted) {
|
||||
createNotificationChannel()
|
||||
startForeground(NOTIFICATION_ID, getNotification())
|
||||
foregroundStarted = true
|
||||
Log.i(TAG, "Started foreground service")
|
||||
}
|
||||
} catch (e: SecurityException) {
|
||||
Log.w(TAG, "Failed to start foreground service: ", e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,8 +132,11 @@ class EVMapSession(val cas: CarAppService) : Session(), DefaultLifecycleObserver
|
||||
}
|
||||
|
||||
override fun onCreateScreen(intent: Intent): Screen {
|
||||
|
||||
val mapScreen = MapScreen(carContext, this)
|
||||
val mapScreen = if (supportsNewMapScreen(carContext)) {
|
||||
MapScreen(carContext, this)
|
||||
} else {
|
||||
LegacyMapScreen(carContext, this)
|
||||
}
|
||||
val screens = mutableListOf<Screen>(mapScreen)
|
||||
|
||||
handleActionsIntent(intent)?.let {
|
||||
@@ -156,7 +166,7 @@ class EVMapSession(val cas: CarAppService) : Session(), DefaultLifecycleObserver
|
||||
}
|
||||
if (!prefs.privacyAccepted) {
|
||||
screens.add(
|
||||
AcceptPrivacyScreen(carContext)
|
||||
AcceptPrivacyScreen(carContext, this)
|
||||
)
|
||||
}
|
||||
handleACRAIntent(intent)?.let {
|
||||
@@ -186,7 +196,7 @@ class EVMapSession(val cas: CarAppService) : Session(), DefaultLifecycleObserver
|
||||
val lon = it.getQueryParameter("longitude")?.toDouble()
|
||||
val name = it.getQueryParameter("name")
|
||||
if (lat != null && lon != null) {
|
||||
prefs.placeSearchResultAndroidAuto = LatLng(lat, lon)
|
||||
prefs.placeSearchResultAndroidAuto = PlaceWithBounds(LatLng(lat, lon), null)
|
||||
prefs.placeSearchResultAndroidAutoName = name ?: "%.4f,%.4f".format(lat, lon)
|
||||
return null
|
||||
} else if (name != null) {
|
||||
|
||||
@@ -3,13 +3,22 @@ package net.vonforst.evmap.auto
|
||||
import androidx.car.app.CarContext
|
||||
import androidx.car.app.CarToast
|
||||
import androidx.car.app.Screen
|
||||
import androidx.car.app.annotations.ExperimentalCarApi
|
||||
import androidx.car.app.constraints.ConstraintManager
|
||||
import androidx.car.app.hardware.CarHardwareManager
|
||||
import androidx.car.app.hardware.info.Model
|
||||
import androidx.car.app.model.*
|
||||
import androidx.car.app.model.Action
|
||||
import androidx.car.app.model.ActionStrip
|
||||
import androidx.car.app.model.CarIcon
|
||||
import androidx.car.app.model.ItemList
|
||||
import androidx.car.app.model.ListTemplate
|
||||
import androidx.car.app.model.Row
|
||||
import androidx.car.app.model.SectionedItemList
|
||||
import androidx.car.app.model.Template
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.graphics.drawable.IconCompat
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.github.erfansn.localeconfigx.currentOrDefaultLocale
|
||||
import jsonapi.Meta
|
||||
import jsonapi.Relationship
|
||||
import jsonapi.Relationships
|
||||
@@ -18,7 +27,16 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import net.vonforst.evmap.R
|
||||
import net.vonforst.evmap.api.chargeprice.*
|
||||
import net.vonforst.evmap.api.chargeprice.ChargePrice
|
||||
import net.vonforst.evmap.api.chargeprice.ChargepriceApi
|
||||
import net.vonforst.evmap.api.chargeprice.ChargepriceCar
|
||||
import net.vonforst.evmap.api.chargeprice.ChargepriceChargepointMeta
|
||||
import net.vonforst.evmap.api.chargeprice.ChargepriceInclude
|
||||
import net.vonforst.evmap.api.chargeprice.ChargepriceMeta
|
||||
import net.vonforst.evmap.api.chargeprice.ChargepriceOptions
|
||||
import net.vonforst.evmap.api.chargeprice.ChargepriceRequest
|
||||
import net.vonforst.evmap.api.chargeprice.ChargepriceRequestTariffMeta
|
||||
import net.vonforst.evmap.api.chargeprice.ChargepriceStation
|
||||
import net.vonforst.evmap.api.equivalentPlugTypes
|
||||
import net.vonforst.evmap.api.nameForPlugType
|
||||
import net.vonforst.evmap.api.stringProvider
|
||||
@@ -32,7 +50,9 @@ import retrofit2.HttpException
|
||||
import java.io.IOException
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
class ChargepriceScreen(ctx: CarContext, val charger: ChargeLocation) : Screen(ctx) {
|
||||
@ExperimentalCarApi
|
||||
class ChargepriceScreen(ctx: CarContext, val session: EVMapSession, val charger: ChargeLocation) :
|
||||
Screen(ctx) {
|
||||
private val prefs = PreferenceDataSource(ctx)
|
||||
private val db = AppDatabase.getInstance(carContext)
|
||||
private val api by lazy {
|
||||
@@ -70,7 +90,7 @@ class ChargepriceScreen(ctx: CarContext, val charger: ChargeLocation) : Screen(c
|
||||
carContext.stringProvider(),
|
||||
chargepoint.type
|
||||
)
|
||||
} ${chargepoint.formatPower()} ${
|
||||
} ${chargepoint.formatPower(carContext.currentOrDefaultLocale)} ${
|
||||
carContext.getString(
|
||||
R.string.chargeprice_stats,
|
||||
meta.energy,
|
||||
@@ -130,7 +150,7 @@ class ChargepriceScreen(ctx: CarContext, val charger: ChargeLocation) : Screen(c
|
||||
)
|
||||
).build()
|
||||
).setOnClickListener {
|
||||
openUrl(carContext, ChargepriceApi.getPoiUrl(charger))
|
||||
openUrl(carContext, session.cas, ChargepriceApi.getPoiUrl(charger))
|
||||
}.build()
|
||||
).build()
|
||||
)
|
||||
|
||||
@@ -3,7 +3,6 @@ package net.vonforst.evmap.auto
|
||||
import android.content.Intent
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Color
|
||||
import android.graphics.Matrix
|
||||
import android.graphics.RectF
|
||||
import android.graphics.drawable.BitmapDrawable
|
||||
@@ -11,11 +10,10 @@ import android.net.Uri
|
||||
import android.text.SpannableString
|
||||
import android.text.SpannableStringBuilder
|
||||
import android.text.Spanned
|
||||
import android.util.Log
|
||||
import androidx.car.app.CarContext
|
||||
import androidx.car.app.CarToast
|
||||
import androidx.car.app.HostException
|
||||
import androidx.car.app.Screen
|
||||
import androidx.car.app.annotations.ExperimentalCarApi
|
||||
import androidx.car.app.constraints.ConstraintManager
|
||||
import androidx.car.app.model.Action
|
||||
import androidx.car.app.model.ActionStrip
|
||||
@@ -34,6 +32,7 @@ import androidx.core.text.HtmlCompat
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import coil.imageLoader
|
||||
import coil.request.ImageRequest
|
||||
import com.github.erfansn.localeconfigx.currentOrDefaultLocale
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -51,14 +50,9 @@ import net.vonforst.evmap.api.availability.ChargeLocationStatus
|
||||
import net.vonforst.evmap.api.availability.tesla.Pricing
|
||||
import net.vonforst.evmap.api.chargeprice.ChargepriceApi
|
||||
import net.vonforst.evmap.api.createApi
|
||||
import net.vonforst.evmap.api.fronyx.FronyxApi
|
||||
import net.vonforst.evmap.api.fronyx.PredictionData
|
||||
import net.vonforst.evmap.api.fronyx.PredictionRepository
|
||||
import net.vonforst.evmap.api.iconForPlugType
|
||||
import net.vonforst.evmap.api.nameForPlugType
|
||||
import net.vonforst.evmap.api.stringProvider
|
||||
import net.vonforst.evmap.model.ChargeLocation
|
||||
import net.vonforst.evmap.model.Coordinate
|
||||
import net.vonforst.evmap.model.Cost
|
||||
import net.vonforst.evmap.model.FaultReport
|
||||
import net.vonforst.evmap.model.Favorite
|
||||
@@ -67,8 +61,8 @@ import net.vonforst.evmap.storage.AppDatabase
|
||||
import net.vonforst.evmap.storage.ChargeLocationsRepository
|
||||
import net.vonforst.evmap.storage.PreferenceDataSource
|
||||
import net.vonforst.evmap.ui.ChargerIconGenerator
|
||||
import net.vonforst.evmap.ui.availabilityText
|
||||
import net.vonforst.evmap.ui.getMarkerTint
|
||||
import net.vonforst.evmap.utils.formatDMS
|
||||
import net.vonforst.evmap.viewmodel.Status
|
||||
import net.vonforst.evmap.viewmodel.awaitFinished
|
||||
import java.time.ZoneId
|
||||
@@ -77,9 +71,12 @@ import java.time.format.FormatStyle
|
||||
import kotlin.math.floor
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
private const val TAG = "ChargerDetailScreen"
|
||||
|
||||
class ChargerDetailScreen(ctx: CarContext, val chargerSparse: ChargeLocation) : Screen(ctx) {
|
||||
@ExperimentalCarApi
|
||||
class ChargerDetailScreen(
|
||||
ctx: CarContext,
|
||||
val chargerSparse: ChargeLocation,
|
||||
val session: EVMapSession
|
||||
) : Screen(ctx) {
|
||||
var charger: ChargeLocation? = null
|
||||
var photo: Bitmap? = null
|
||||
private var availability: ChargeLocationStatus? = null
|
||||
@@ -92,6 +89,7 @@ class ChargerDetailScreen(ctx: CarContext, val chargerSparse: ChargeLocation) :
|
||||
private val repo =
|
||||
ChargeLocationsRepository(createApi(prefs.dataSource, ctx), lifecycleScope, db, prefs)
|
||||
private val availabilityRepo = AvailabilityRepository(ctx)
|
||||
|
||||
private val predictionRepo = PredictionRepository(ctx)
|
||||
private val timeFormat = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT)
|
||||
|
||||
@@ -138,7 +136,7 @@ class ChargerDetailScreen(ctx: CarContext, val chargerSparse: ChargeLocation) :
|
||||
.setFlags(Action.FLAG_PRIMARY)
|
||||
.setBackgroundColor(CarColor.PRIMARY)
|
||||
.setOnClickListener {
|
||||
navigateToCharger(charger)
|
||||
navigateToCharger(carContext, session.cas, charger)
|
||||
}
|
||||
.build())
|
||||
if (ChargepriceApi.isChargerSupported(charger)) {
|
||||
@@ -155,14 +153,20 @@ class ChargerDetailScreen(ctx: CarContext, val chargerSparse: ChargeLocation) :
|
||||
.setTitle(carContext.getString(R.string.auto_prices))
|
||||
.setOnClickListener {
|
||||
if (prefs.chargepriceNativeIntegration) {
|
||||
screenManager.push(ChargepriceScreen(carContext, charger))
|
||||
screenManager.push(
|
||||
ChargepriceScreen(
|
||||
carContext,
|
||||
session,
|
||||
charger
|
||||
)
|
||||
)
|
||||
} else {
|
||||
val intent = Intent(
|
||||
Intent.ACTION_VIEW,
|
||||
Uri.parse(ChargepriceApi.getPoiUrl(charger))
|
||||
)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
carContext.startActivity(intent)
|
||||
session.cas.startActivity(intent)
|
||||
}
|
||||
}
|
||||
.build())
|
||||
@@ -181,12 +185,12 @@ class ChargerDetailScreen(ctx: CarContext, val chargerSparse: ChargeLocation) :
|
||||
Action.Builder()
|
||||
.setTitle(carContext.getString(R.string.open_in_app))
|
||||
.setOnClickListener(ParkedOnlyOnClickListener.create {
|
||||
val intent = Intent(carContext, MapsActivity::class.java)
|
||||
val intent = Intent(session.cas, 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)
|
||||
session.cas.startActivity(intent)
|
||||
CarToast.makeText(
|
||||
carContext,
|
||||
R.string.opened_on_phone,
|
||||
@@ -255,7 +259,7 @@ class ChargerDetailScreen(ctx: CarContext, val chargerSparse: ChargeLocation) :
|
||||
|
||||
// Row 1: address + chargepoints
|
||||
rows.add(Row.Builder().apply {
|
||||
setTitle(charger.address.toString())
|
||||
setTitle(charger.address?.toString() ?: charger.coordinates.formatDMS())
|
||||
|
||||
if (photo == null) {
|
||||
// show just the icon
|
||||
@@ -275,7 +279,7 @@ class ChargerDetailScreen(ctx: CarContext, val chargerSparse: ChargeLocation) :
|
||||
Row.IMAGE_TYPE_LARGE
|
||||
)
|
||||
}
|
||||
addText(generateChargepointsText(charger))
|
||||
addText(generateChargepointsText(charger, availability, carContext))
|
||||
}.build())
|
||||
if (maxRows <= 3) {
|
||||
// row 2: operator + cost + fault report
|
||||
@@ -368,7 +372,7 @@ class ChargerDetailScreen(ctx: CarContext, val chargerSparse: ChargeLocation) :
|
||||
var text = formatTeslaPricing(teslaPricing, carContext) as CharSequence
|
||||
formatTeslaParkingFee(teslaPricing, carContext)?.let { text += "\n\n" + it }
|
||||
addText(text)
|
||||
} ?: {
|
||||
} ?: run {
|
||||
addText(carContext.getString(if (prediction != null) R.string.auto_no_data else R.string.loading))
|
||||
}
|
||||
}.build())
|
||||
@@ -488,47 +492,6 @@ class ChargerDetailScreen(ctx: CarContext, val chargerSparse: ChargeLocation) :
|
||||
return string
|
||||
}
|
||||
|
||||
private fun generateChargepointsText(charger: ChargeLocation): SpannableStringBuilder {
|
||||
val chargepointsText = SpannableStringBuilder()
|
||||
charger.chargepointsMerged.forEachIndexed { i, cp ->
|
||||
chargepointsText.apply {
|
||||
if (i > 0) append(" · ")
|
||||
append("${cp.count}× ")
|
||||
val plugIcon = iconForPlugType(cp.type)
|
||||
if (plugIcon != 0) {
|
||||
append(
|
||||
nameForPlugType(carContext.stringProvider(), cp.type),
|
||||
CarIconSpan.create(
|
||||
CarIcon.Builder(
|
||||
IconCompat.createWithResource(
|
||||
carContext,
|
||||
plugIcon
|
||||
)
|
||||
).setTint(
|
||||
CarColor.createCustom(Color.WHITE, Color.BLACK)
|
||||
).build()
|
||||
),
|
||||
Spanned.SPAN_INCLUSIVE_EXCLUSIVE
|
||||
)
|
||||
} else {
|
||||
append(nameForPlugType(carContext.stringProvider(), cp.type))
|
||||
}
|
||||
cp.formatPower()?.let {
|
||||
append(" ")
|
||||
append(it)
|
||||
}
|
||||
}
|
||||
availability?.status?.get(cp)?.let { status ->
|
||||
chargepointsText.append(
|
||||
" (${availabilityText(status)}/${cp.count})",
|
||||
ForegroundCarColorSpan.create(carAvailabilityColor(status)),
|
||||
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
|
||||
)
|
||||
}
|
||||
}
|
||||
return chargepointsText
|
||||
}
|
||||
|
||||
private fun generateOperatorText(charger: ChargeLocation) =
|
||||
if (charger.operator != null && charger.network != null) {
|
||||
if (charger.operator.contains(charger.network)) {
|
||||
@@ -546,54 +509,6 @@ class ChargerDetailScreen(ctx: CarContext, val chargerSparse: ChargeLocation) :
|
||||
carContext.getString(R.string.unknown_operator)
|
||||
}
|
||||
|
||||
private fun navigateToCharger(charger: ChargeLocation) {
|
||||
val success = navigateCarApp(charger)
|
||||
if (!success && BuildConfig.FLAVOR_automotive == "automotive") {
|
||||
// on AAOS, some OEMs' navigation apps might not support
|
||||
navigateRegularApp(charger)
|
||||
}
|
||||
}
|
||||
|
||||
private fun navigateCarApp(charger: ChargeLocation): Boolean {
|
||||
val coord = charger.coordinates
|
||||
val intent =
|
||||
Intent(
|
||||
CarContext.ACTION_NAVIGATE,
|
||||
Uri.parse("geo:${coord.lat},${coord.lng}")
|
||||
)
|
||||
try {
|
||||
carContext.startCarApp(intent)
|
||||
return true
|
||||
} catch (e: HostException) {
|
||||
Log.w(TAG, "Could not start navigation using car app intent")
|
||||
Log.w(TAG, intent.toString())
|
||||
e.printStackTrace()
|
||||
} catch (e: SecurityException) {
|
||||
Log.w(TAG, "Could not start navigation using car app intent")
|
||||
Log.w(TAG, intent.toString())
|
||||
e.printStackTrace()
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private fun navigateRegularApp(charger: ChargeLocation): Boolean {
|
||||
val coord = charger.coordinates
|
||||
val intent = Intent(Intent.ACTION_VIEW)
|
||||
intent.data = Uri.parse(
|
||||
"geo:${coord.lat},${coord.lng}?q=${coord.lat},${coord.lng}(${
|
||||
Uri.encode(charger.name)
|
||||
})"
|
||||
)
|
||||
if (intent.resolveActivity(carContext.packageManager) != null) {
|
||||
carContext.startActivity(intent)
|
||||
return true
|
||||
} else {
|
||||
Log.w(TAG, "Could not start navigation using regular intent")
|
||||
Log.w(TAG, intent.toString())
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private fun loadCharger() {
|
||||
lifecycleScope.launch {
|
||||
favorite = db.favoritesDao().findFavorite(chargerSparse.id, chargerSparse.dataSource)
|
||||
@@ -645,12 +560,12 @@ class ChargerDetailScreen(ctx: CarContext, val chargerSparse: ChargeLocation) :
|
||||
)
|
||||
this@ChargerDetailScreen.photo = outImg
|
||||
}
|
||||
fronyxSupported = charger.chargepoints.any {
|
||||
fronyxSupported = false /*charger.chargepoints.any {
|
||||
FronyxApi.isChargepointSupported(
|
||||
charger,
|
||||
it
|
||||
)
|
||||
} && !availabilityRepo.isSupercharger(charger)
|
||||
} && !availabilityRepo.isSupercharger(charger)*/
|
||||
teslaSupported = availabilityRepo.isTeslaSupported(charger)
|
||||
|
||||
invalidate()
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
package net.vonforst.evmap.auto
|
||||
|
||||
import android.location.Location
|
||||
import android.text.SpannableString
|
||||
import android.text.SpannableStringBuilder
|
||||
import android.text.Spanned
|
||||
import androidx.car.app.CarContext
|
||||
import androidx.car.app.annotations.ExperimentalCarApi
|
||||
import androidx.car.app.hardware.info.EnergyLevel
|
||||
import androidx.car.app.model.Action
|
||||
import androidx.car.app.model.CarColor
|
||||
import androidx.car.app.model.CarIcon
|
||||
import androidx.car.app.model.CarIconSpan
|
||||
import androidx.car.app.model.CarLocation
|
||||
import androidx.car.app.model.CarText
|
||||
import androidx.car.app.model.DistanceSpan
|
||||
import androidx.car.app.model.ForegroundCarColorSpan
|
||||
import androidx.car.app.model.ItemList
|
||||
import androidx.car.app.model.Metadata
|
||||
import androidx.car.app.model.Pane
|
||||
import androidx.car.app.model.Place
|
||||
import androidx.car.app.model.PlaceMarker
|
||||
import androidx.car.app.model.Row
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.graphics.drawable.IconCompat
|
||||
import net.vonforst.evmap.R
|
||||
import net.vonforst.evmap.api.availability.ChargeLocationStatus
|
||||
import net.vonforst.evmap.model.ChargeLocation
|
||||
import net.vonforst.evmap.model.FILTERS_FAVORITES
|
||||
import net.vonforst.evmap.ui.ChargerIconGenerator
|
||||
import net.vonforst.evmap.ui.availabilityText
|
||||
import net.vonforst.evmap.ui.getMarkerTint
|
||||
import net.vonforst.evmap.utils.distanceBetween
|
||||
import net.vonforst.evmap.utils.formatDecimal
|
||||
import java.time.ZonedDateTime
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
interface ChargerListDelegate : ItemList.OnItemVisibilityChangedListener {
|
||||
val locationError: Boolean
|
||||
val loadingError: Boolean
|
||||
val maxRows: Int
|
||||
val filterStatus: Long
|
||||
val location: Location?
|
||||
val energyLevel: EnergyLevel?
|
||||
fun onChargerClick(charger: ChargeLocation)
|
||||
}
|
||||
|
||||
@ExperimentalCarApi
|
||||
class ChargerListFormatter(
|
||||
val carContext: CarContext,
|
||||
val screen: ChargerListDelegate,
|
||||
val cas: CarAppService
|
||||
) {
|
||||
private val iconGen = ChargerIconGenerator(carContext, null, height = 96)
|
||||
var favorites: Set<Long> = emptySet()
|
||||
|
||||
fun buildChargerList(
|
||||
chargers: List<ChargeLocation>?,
|
||||
availabilities: Map<Long, Pair<ZonedDateTime, ChargeLocationStatus?>>
|
||||
): ItemList? {
|
||||
return if (chargers != null) {
|
||||
val chargerList = chargers.take(screen.maxRows)
|
||||
val builder = ItemList.Builder()
|
||||
// only show the city if not all chargers are in the same city
|
||||
val showCity = chargerList.map { it.address?.city }.distinct().size > 1
|
||||
chargerList.forEach { charger ->
|
||||
builder.addItem(
|
||||
formatCharger(
|
||||
charger,
|
||||
availabilities,
|
||||
showCity,
|
||||
charger.id in favorites
|
||||
)
|
||||
)
|
||||
}
|
||||
builder.setNoItemsMessage(
|
||||
carContext.getString(
|
||||
if (screen.filterStatus == FILTERS_FAVORITES) {
|
||||
R.string.auto_no_favorites_found
|
||||
} else {
|
||||
R.string.auto_no_chargers_found
|
||||
}
|
||||
)
|
||||
)
|
||||
builder.setOnItemsVisibilityChangedListener(screen)
|
||||
builder.build()
|
||||
} else {
|
||||
if (screen.loadingError) {
|
||||
val builder = ItemList.Builder()
|
||||
builder.setNoItemsMessage(
|
||||
carContext.getString(R.string.connection_error)
|
||||
)
|
||||
builder.build()
|
||||
} else if (screen.locationError) {
|
||||
val builder = ItemList.Builder()
|
||||
builder.setNoItemsMessage(
|
||||
carContext.getString(R.string.location_error)
|
||||
)
|
||||
builder.build()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatCharger(
|
||||
charger: ChargeLocation,
|
||||
availabilities: Map<Long, Pair<ZonedDateTime, ChargeLocationStatus?>>,
|
||||
showCity: Boolean,
|
||||
isFavorite: Boolean
|
||||
): Row {
|
||||
val markerTint = getMarkerTint(charger)
|
||||
val backgroundTint = if ((charger.maxPower ?: 0.0) > 100) {
|
||||
R.color.charger_100kw_dark // slightly darker color for better contrast
|
||||
} else {
|
||||
markerTint
|
||||
}
|
||||
val color = ContextCompat.getColor(carContext, backgroundTint)
|
||||
val place =
|
||||
Place.Builder(CarLocation.create(charger.coordinates.lat, charger.coordinates.lng))
|
||||
.setMarker(
|
||||
PlaceMarker.Builder()
|
||||
.setColor(CarColor.createCustom(color, color))
|
||||
.build()
|
||||
)
|
||||
.build()
|
||||
|
||||
val icon = iconGen.getBitmap(
|
||||
markerTint,
|
||||
fault = charger.faultReport != null,
|
||||
multi = charger.isMulti(),
|
||||
fav = isFavorite
|
||||
)
|
||||
val iconSpan =
|
||||
CarIconSpan.create(CarIcon.Builder(IconCompat.createWithBitmap(icon)).build())
|
||||
|
||||
return Row.Builder().apply {
|
||||
// only show the city if not all chargers are in the same city (-> showCity == true)
|
||||
// and the city is not already contained in the charger name
|
||||
val title = SpannableStringBuilder().apply {
|
||||
append(" ", iconSpan, SpannableString.SPAN_INCLUSIVE_EXCLUSIVE)
|
||||
append(" ")
|
||||
append(charger.name)
|
||||
}
|
||||
if (showCity && charger.address?.city != null && charger.address.city !in charger.name) {
|
||||
val titleWithCity = SpannableStringBuilder().apply {
|
||||
append("", iconSpan, SpannableString.SPAN_INCLUSIVE_EXCLUSIVE)
|
||||
append(" ")
|
||||
append("${charger.name} · ${charger.address.city}")
|
||||
}
|
||||
setTitle(CarText.Builder(titleWithCity).addVariant(title).build())
|
||||
} else {
|
||||
setTitle(title)
|
||||
}
|
||||
|
||||
val text = SpannableStringBuilder()
|
||||
|
||||
// distance
|
||||
screen.location?.let {
|
||||
val distanceMeters = distanceBetween(
|
||||
it.latitude, it.longitude,
|
||||
charger.coordinates.lat, charger.coordinates.lng
|
||||
)
|
||||
text.append(
|
||||
"distance",
|
||||
DistanceSpan.create(
|
||||
roundValueToDistance(
|
||||
distanceMeters,
|
||||
screen.energyLevel?.distanceDisplayUnit?.value,
|
||||
carContext
|
||||
)
|
||||
),
|
||||
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
|
||||
)
|
||||
}
|
||||
|
||||
// power
|
||||
val power = charger.maxPower
|
||||
if (power != null) {
|
||||
if (text.isNotEmpty()) text.append(" · ")
|
||||
text.append("${power.roundToInt()} kW")
|
||||
}
|
||||
|
||||
// availability
|
||||
availabilities[charger.id]?.second?.let { av ->
|
||||
val status = av.status.values.flatten()
|
||||
val available = availabilityText(status)
|
||||
val total = charger.chargepoints.sumOf { it.count }
|
||||
|
||||
if (text.isNotEmpty()) text.append(" · ")
|
||||
text.append(
|
||||
"$available/$total",
|
||||
ForegroundCarColorSpan.create(carAvailabilityColor(status)),
|
||||
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
|
||||
)
|
||||
}
|
||||
|
||||
addText(text)
|
||||
setMetadata(
|
||||
Metadata.Builder()
|
||||
.setPlace(place)
|
||||
.build()
|
||||
)
|
||||
|
||||
setOnClickListener {
|
||||
screen.onChargerClick(charger)
|
||||
}
|
||||
}.build()
|
||||
}
|
||||
|
||||
fun buildSingleCharger(
|
||||
charger: ChargeLocation,
|
||||
availability: ChargeLocationStatus?,
|
||||
onClick: () -> Unit
|
||||
) = Pane.Builder().apply {
|
||||
val icon = iconGen.getBitmap(
|
||||
getMarkerTint(charger),
|
||||
fault = charger.faultReport != null,
|
||||
multi = charger.isMulti(),
|
||||
fav = charger.id in favorites
|
||||
)
|
||||
|
||||
|
||||
addRow(Row.Builder().apply {
|
||||
setImage(CarIcon.Builder(IconCompat.createWithBitmap(icon)).build())
|
||||
setTitle(charger.address?.toString() ?: charger.coordinates.formatDecimal())
|
||||
addText(generateChargepointsText(charger, availability, carContext))
|
||||
}.build())
|
||||
addAction(Action.Builder().apply {
|
||||
setTitle(carContext.getString(R.string.show_more))
|
||||
setOnClickListener(onClick)
|
||||
}.build())
|
||||
addAction(Action.Builder().apply {
|
||||
setIcon(
|
||||
CarIcon.Builder(
|
||||
IconCompat.createWithResource(
|
||||
carContext,
|
||||
R.drawable.ic_navigation
|
||||
)
|
||||
).build()
|
||||
)
|
||||
setTitle(carContext.getString(R.string.navigate))
|
||||
setBackgroundColor(CarColor.PRIMARY)
|
||||
setOnClickListener {
|
||||
navigateToCharger(carContext, cas, charger)
|
||||
}
|
||||
}.build())
|
||||
}.build()
|
||||
}
|
||||
@@ -9,14 +9,36 @@ import androidx.car.app.CarContext
|
||||
import androidx.car.app.CarToast
|
||||
import androidx.car.app.Screen
|
||||
import androidx.car.app.constraints.ConstraintManager
|
||||
import androidx.car.app.model.*
|
||||
import androidx.car.app.model.Action
|
||||
import androidx.car.app.model.ActionStrip
|
||||
import androidx.car.app.model.CarColor
|
||||
import androidx.car.app.model.CarIcon
|
||||
import androidx.car.app.model.CarText
|
||||
import androidx.car.app.model.ForegroundCarColorSpan
|
||||
import androidx.car.app.model.ItemList
|
||||
import androidx.car.app.model.ListTemplate
|
||||
import androidx.car.app.model.Pane
|
||||
import androidx.car.app.model.PaneTemplate
|
||||
import androidx.car.app.model.ParkedOnlyOnClickListener
|
||||
import androidx.car.app.model.Row
|
||||
import androidx.car.app.model.Template
|
||||
import androidx.car.app.model.Toggle
|
||||
import androidx.core.graphics.drawable.IconCompat
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.map
|
||||
import kotlinx.coroutines.launch
|
||||
import net.vonforst.evmap.R
|
||||
import net.vonforst.evmap.model.*
|
||||
import net.vonforst.evmap.model.BooleanFilter
|
||||
import net.vonforst.evmap.model.BooleanFilterValue
|
||||
import net.vonforst.evmap.model.FILTERS_CUSTOM
|
||||
import net.vonforst.evmap.model.FILTERS_DISABLED
|
||||
import net.vonforst.evmap.model.FILTERS_FAVORITES
|
||||
import net.vonforst.evmap.model.FilterValues
|
||||
import net.vonforst.evmap.model.MultipleChoiceFilter
|
||||
import net.vonforst.evmap.model.MultipleChoiceFilterValue
|
||||
import net.vonforst.evmap.model.SliderFilter
|
||||
import net.vonforst.evmap.model.SliderFilterValue
|
||||
import net.vonforst.evmap.storage.AppDatabase
|
||||
import net.vonforst.evmap.storage.FilterProfile
|
||||
import net.vonforst.evmap.storage.PreferenceDataSource
|
||||
@@ -232,7 +254,6 @@ class FilterScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx) {
|
||||
),
|
||||
CarToast.LENGTH_SHORT
|
||||
).show()
|
||||
invalidate()
|
||||
}
|
||||
}
|
||||
}.build())
|
||||
@@ -349,7 +370,6 @@ class EditFiltersScreen(ctx: CarContext) : Screen(ctx) {
|
||||
),
|
||||
CarToast.LENGTH_SHORT
|
||||
).show()
|
||||
invalidate()
|
||||
screenManager.pop()
|
||||
}
|
||||
}
|
||||
@@ -381,7 +401,6 @@ class EditFiltersScreen(ctx: CarContext) : Screen(ctx) {
|
||||
}
|
||||
if (!saveSuccess) return@pushForResult
|
||||
}
|
||||
invalidate()
|
||||
}
|
||||
.build()
|
||||
)
|
||||
|
||||
532
app/src/main/java/net/vonforst/evmap/auto/LegacyMapScreen.kt
Normal file
532
app/src/main/java/net/vonforst/evmap/auto/LegacyMapScreen.kt
Normal file
@@ -0,0 +1,532 @@
|
||||
package net.vonforst.evmap.auto
|
||||
|
||||
import android.content.pm.PackageManager
|
||||
import android.location.Location
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import androidx.car.app.CarContext
|
||||
import androidx.car.app.Screen
|
||||
import androidx.car.app.constraints.ConstraintManager
|
||||
import androidx.car.app.hardware.CarHardwareManager
|
||||
import androidx.car.app.hardware.info.CarInfo
|
||||
import androidx.car.app.hardware.info.CarSensors
|
||||
import androidx.car.app.hardware.info.Compass
|
||||
import androidx.car.app.hardware.info.EnergyLevel
|
||||
import androidx.car.app.model.Action
|
||||
import androidx.car.app.model.ActionStrip
|
||||
import androidx.car.app.model.CarColor
|
||||
import androidx.car.app.model.CarIcon
|
||||
import androidx.car.app.model.CarLocation
|
||||
import androidx.car.app.model.OnContentRefreshListener
|
||||
import androidx.car.app.model.Place
|
||||
import androidx.car.app.model.PlaceListMapTemplate
|
||||
import androidx.car.app.model.PlaceMarker
|
||||
import androidx.car.app.model.Template
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.graphics.drawable.IconCompat
|
||||
import androidx.lifecycle.DefaultLifecycleObserver
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.car2go.maps.model.LatLng
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import net.vonforst.evmap.BuildConfig
|
||||
import net.vonforst.evmap.R
|
||||
import net.vonforst.evmap.api.availability.AvailabilityRepository
|
||||
import net.vonforst.evmap.api.availability.ChargeLocationStatus
|
||||
import net.vonforst.evmap.api.createApi
|
||||
import net.vonforst.evmap.api.stringProvider
|
||||
import net.vonforst.evmap.model.ChargeLocation
|
||||
import net.vonforst.evmap.model.FILTERS_FAVORITES
|
||||
import net.vonforst.evmap.model.FilterValue
|
||||
import net.vonforst.evmap.model.FilterWithValue
|
||||
import net.vonforst.evmap.storage.AppDatabase
|
||||
import net.vonforst.evmap.storage.ChargeLocationsRepository
|
||||
import net.vonforst.evmap.storage.PreferenceDataSource
|
||||
import net.vonforst.evmap.utils.bearingBetween
|
||||
import net.vonforst.evmap.utils.distanceBetween
|
||||
import net.vonforst.evmap.utils.headingDiff
|
||||
import net.vonforst.evmap.viewmodel.Status
|
||||
import net.vonforst.evmap.viewmodel.await
|
||||
import net.vonforst.evmap.viewmodel.awaitFinished
|
||||
import net.vonforst.evmap.viewmodel.filtersWithValue
|
||||
import retrofit2.HttpException
|
||||
import java.io.IOException
|
||||
import java.time.Duration
|
||||
import java.time.Instant
|
||||
import java.time.ZonedDateTime
|
||||
import kotlin.collections.set
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.min
|
||||
|
||||
/**
|
||||
* Main map screen showing either nearby chargers or favorites
|
||||
*
|
||||
* Legacy implementation for Car App API level < 7
|
||||
*/
|
||||
@androidx.car.app.annotations.ExperimentalCarApi
|
||||
class LegacyMapScreen(ctx: CarContext, val session: EVMapSession) :
|
||||
Screen(ctx), LocationAwareScreen, OnContentRefreshListener,
|
||||
ChargerListDelegate, DefaultLifecycleObserver {
|
||||
|
||||
private val db = AppDatabase.getInstance(carContext)
|
||||
private var prefs = PreferenceDataSource(ctx)
|
||||
private val repo =
|
||||
ChargeLocationsRepository(createApi(prefs.dataSource, ctx), lifecycleScope, db, prefs)
|
||||
private val availabilityRepo = AvailabilityRepository(ctx)
|
||||
|
||||
private var updateCoroutine: Job? = null
|
||||
private var availabilityUpdateCoroutine: Job? = null
|
||||
|
||||
private var visibleStart: Int? = null
|
||||
private var visibleEnd: Int? = null
|
||||
|
||||
override var location: Location? = null
|
||||
private var lastDistanceUpdateTime: Instant? = null
|
||||
private var lastChargersUpdateTime: Instant? = null
|
||||
private var chargers: List<ChargeLocation>? = null
|
||||
private val favorites = db.favoritesDao().getAllFavorites()
|
||||
override var loadingError = false
|
||||
override var locationError = false
|
||||
private val searchRadius = 5 // kilometers
|
||||
private val distanceUpdateThreshold = Duration.ofSeconds(15)
|
||||
private val availabilityUpdateThreshold = Duration.ofMinutes(1)
|
||||
private val chargersUpdateThresholdDistance = 500 // meters
|
||||
private val chargersUpdateThresholdTime = Duration.ofSeconds(30)
|
||||
private var availabilities: MutableMap<Long, Pair<ZonedDateTime, ChargeLocationStatus?>> =
|
||||
HashMap()
|
||||
override val maxRows =
|
||||
min(ctx.getContentLimit(ConstraintManager.CONTENT_LIMIT_TYPE_PLACE_LIST), 25)
|
||||
private val supportsRefresh = ctx.isAppDrivenRefreshSupported
|
||||
|
||||
override var filterStatus = prefs.filterStatus
|
||||
|
||||
private var filtersWithValue: List<FilterWithValue<FilterValue>>? = null
|
||||
|
||||
private val carInfo: CarInfo by lazy {
|
||||
(ctx.getCarService(CarContext.HARDWARE_SERVICE) as CarHardwareManager).carInfo
|
||||
}
|
||||
private val carSensors: CarSensors by lazy { carContext.patchedCarSensors }
|
||||
override var energyLevel: EnergyLevel? = null
|
||||
private var heading: Compass? = null
|
||||
private val permissions = if (BuildConfig.FLAVOR_automotive == "automotive") {
|
||||
listOf(
|
||||
"android.car.permission.CAR_ENERGY",
|
||||
"android.car.permission.CAR_ENERGY_PORTS",
|
||||
"android.car.permission.READ_CAR_DISPLAY_UNITS",
|
||||
)
|
||||
} else {
|
||||
listOf(
|
||||
"com.google.android.gms.permission.CAR_FUEL"
|
||||
)
|
||||
}
|
||||
|
||||
private var searchLocation: LatLng? = null
|
||||
|
||||
private val formatter = ChargerListFormatter(ctx, this, session.cas)
|
||||
|
||||
init {
|
||||
lifecycle.addObserver(this)
|
||||
marker = MapScreen.MARKER
|
||||
}
|
||||
|
||||
override fun onGetTemplate(): Template {
|
||||
session.mapScreen = this
|
||||
return PlaceListMapTemplate.Builder().apply {
|
||||
setTitle(
|
||||
prefs.placeSearchResultAndroidAutoName?.let {
|
||||
carContext.getString(R.string.auto_chargers_near_location, it)
|
||||
} ?: carContext.getString(
|
||||
if (filterStatus == FILTERS_FAVORITES) {
|
||||
R.string.auto_favorites
|
||||
} else {
|
||||
R.string.auto_chargers_closeby
|
||||
}
|
||||
)
|
||||
)
|
||||
if (prefs.placeSearchResultAndroidAutoName != null) {
|
||||
searchLocation?.let {
|
||||
setAnchor(Place.Builder(CarLocation.create(it.latitude, it.longitude)).apply {
|
||||
if (prefs.placeSearchResultAndroidAutoName != null) {
|
||||
setMarker(
|
||||
PlaceMarker.Builder()
|
||||
.setColor(CarColor.PRIMARY)
|
||||
.build()
|
||||
)
|
||||
}
|
||||
}.build())
|
||||
}
|
||||
} else {
|
||||
location?.let {
|
||||
setAnchor(Place.Builder(CarLocation.create(it.latitude, it.longitude)).build())
|
||||
}
|
||||
}
|
||||
formatter.buildChargerList(chargers, availabilities)?.let {
|
||||
setItemList(it)
|
||||
} ?: setLoading(true)
|
||||
setCurrentLocationEnabled(true)
|
||||
setHeaderAction(Action.APP_ICON)
|
||||
val filtersCount = if (filterStatus == FILTERS_FAVORITES) 1 else {
|
||||
filtersWithValue?.count {
|
||||
!it.value.hasSameValueAs(it.filter.defaultValue())
|
||||
}
|
||||
}
|
||||
|
||||
setActionStrip(
|
||||
ActionStrip.Builder()
|
||||
.addAction(
|
||||
Action.Builder()
|
||||
.setIcon(
|
||||
CarIcon.Builder(
|
||||
IconCompat.createWithResource(
|
||||
carContext,
|
||||
R.drawable.ic_settings
|
||||
)
|
||||
).setTint(CarColor.DEFAULT).build()
|
||||
)
|
||||
.setOnClickListener {
|
||||
screenManager.push(SettingsScreen(carContext, session))
|
||||
session.mapScreen = null
|
||||
}
|
||||
.build())
|
||||
.addAction(Action.Builder().apply {
|
||||
setIcon(
|
||||
CarIcon.Builder(
|
||||
IconCompat.createWithResource(
|
||||
carContext,
|
||||
if (prefs.placeSearchResultAndroidAuto != null) {
|
||||
R.drawable.ic_search_off
|
||||
} else {
|
||||
R.drawable.ic_search
|
||||
}
|
||||
)
|
||||
).build()
|
||||
|
||||
)
|
||||
setOnClickListener {
|
||||
if (prefs.placeSearchResultAndroidAuto != null) {
|
||||
prefs.placeSearchResultAndroidAutoName = null
|
||||
prefs.placeSearchResultAndroidAuto = null
|
||||
if (!supportsRefresh) {
|
||||
screenManager.pushForResult(DummyReturnScreen(carContext)) {
|
||||
chargers = null
|
||||
loadChargers()
|
||||
}
|
||||
} else {
|
||||
chargers = null
|
||||
loadChargers()
|
||||
}
|
||||
} else {
|
||||
screenManager.pushForResult(
|
||||
PlaceSearchScreen(
|
||||
carContext,
|
||||
session
|
||||
)
|
||||
) {
|
||||
chargers = null
|
||||
loadChargers()
|
||||
}
|
||||
session.mapScreen = null
|
||||
}
|
||||
}
|
||||
}.build())
|
||||
.addAction(
|
||||
Action.Builder()
|
||||
.setIcon(
|
||||
CarIcon.Builder(
|
||||
IconCompat.createWithResource(
|
||||
carContext,
|
||||
R.drawable.ic_filter
|
||||
)
|
||||
)
|
||||
.setTint(if (filtersCount != null && filtersCount > 0) CarColor.SECONDARY else CarColor.DEFAULT)
|
||||
.build()
|
||||
)
|
||||
.setOnClickListener {
|
||||
screenManager.push(FilterScreen(carContext, session))
|
||||
session.mapScreen = null
|
||||
}
|
||||
.build())
|
||||
.build())
|
||||
if (carContext.carAppApiLevel >= 5 ||
|
||||
(BuildConfig.FLAVOR_automotive == "automotive" && carContext.carAppApiLevel >= 4)
|
||||
) {
|
||||
setOnContentRefreshListener(this@LegacyMapScreen)
|
||||
}
|
||||
}.build()
|
||||
}
|
||||
|
||||
override fun onChargerClick(charger: ChargeLocation) {
|
||||
screenManager.push(ChargerDetailScreen(carContext, charger, session))
|
||||
session.mapScreen = null
|
||||
}
|
||||
|
||||
override fun updateLocation(location: Location) {
|
||||
if (location.latitude == this.location?.latitude
|
||||
&& location.longitude == this.location?.longitude
|
||||
) {
|
||||
return
|
||||
}
|
||||
val previousLocation = this.location
|
||||
this.location = location
|
||||
if (previousLocation == null) {
|
||||
loadChargers()
|
||||
return
|
||||
}
|
||||
|
||||
val now = Instant.now()
|
||||
if (lastDistanceUpdateTime == null ||
|
||||
Duration.between(lastDistanceUpdateTime, now) > distanceUpdateThreshold
|
||||
) {
|
||||
lastDistanceUpdateTime = now
|
||||
// update displayed distances
|
||||
invalidate()
|
||||
}
|
||||
|
||||
// if chargers are searched around current location, consider app-driven refresh
|
||||
val searchLocation =
|
||||
if (prefs.placeSearchResultAndroidAuto == null) searchLocation else null
|
||||
val distance = searchLocation?.let {
|
||||
distanceBetween(
|
||||
it.latitude, it.longitude, location.latitude, location.longitude
|
||||
)
|
||||
} ?: 0.0
|
||||
if (supportsRefresh && (lastChargersUpdateTime == null ||
|
||||
Duration.between(
|
||||
lastChargersUpdateTime,
|
||||
now
|
||||
) > chargersUpdateThresholdTime) && (distance > chargersUpdateThresholdDistance)
|
||||
) {
|
||||
onContentRefreshRequested()
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadChargers() {
|
||||
val location = location ?: return
|
||||
|
||||
val searchLocation =
|
||||
prefs.placeSearchResultAndroidAuto?.latLng ?: LatLng.fromLocation(location)
|
||||
this.searchLocation = searchLocation
|
||||
|
||||
updateCoroutine = lifecycleScope.launch {
|
||||
loadingError = false
|
||||
try {
|
||||
filterStatus = prefs.filterStatus
|
||||
val filterValues =
|
||||
db.filterValueDao().getFilterValuesAsync(filterStatus, prefs.dataSource)
|
||||
val filters = repo.getFiltersAsync(carContext.stringProvider())
|
||||
filtersWithValue = filtersWithValue(filters, filterValues)
|
||||
|
||||
val apiId = repo.api.value!!.id
|
||||
|
||||
// load chargers
|
||||
if (filterStatus == FILTERS_FAVORITES) {
|
||||
val chargers = favorites.await().map { it.charger }.sortedBy {
|
||||
distanceBetween(
|
||||
location.latitude, location.longitude,
|
||||
it.coordinates.lat, it.coordinates.lng
|
||||
)
|
||||
}
|
||||
this@LegacyMapScreen.chargers = chargers
|
||||
} else {
|
||||
// try multiple search radii until we have enough chargers
|
||||
var chargers: List<ChargeLocation>? = null
|
||||
val radiusValues = listOf(searchRadius, searchRadius * 10, searchRadius * 50)
|
||||
for (radius in radiusValues) {
|
||||
val response = repo.getChargepointsRadius(
|
||||
searchLocation,
|
||||
radius,
|
||||
filtersWithValue
|
||||
).awaitFinished()
|
||||
if (response.status == Status.ERROR && if (radius == radiusValues.last()) response.data.isNullOrEmpty() else response.data == null) {
|
||||
loadingError = true
|
||||
this@LegacyMapScreen.chargers = null
|
||||
invalidate()
|
||||
return@launch
|
||||
}
|
||||
chargers = response.data
|
||||
if (prefs.placeSearchResultAndroidAutoName == null) {
|
||||
chargers = headingFilter(
|
||||
chargers,
|
||||
searchLocation
|
||||
)
|
||||
}
|
||||
if (chargers == null || chargers.size >= maxRows) {
|
||||
break
|
||||
}
|
||||
}
|
||||
this@LegacyMapScreen.chargers = chargers
|
||||
}
|
||||
|
||||
updateCoroutine = null
|
||||
lastChargersUpdateTime = Instant.now()
|
||||
lastDistanceUpdateTime = Instant.now()
|
||||
invalidate()
|
||||
} catch (e: IOException) {
|
||||
loadingError = true
|
||||
invalidate()
|
||||
} catch (e: HttpException) {
|
||||
loadingError = true
|
||||
invalidate()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters by heading if heading available and enabled
|
||||
*/
|
||||
private fun headingFilter(
|
||||
chargers: List<ChargeLocation>?,
|
||||
searchLocation: LatLng
|
||||
): List<ChargeLocation>? {
|
||||
// use compass heading if available, otherwise fall back to GPS
|
||||
val location = location
|
||||
val heading = heading?.orientations?.value?.get(0)
|
||||
?: if (location?.hasBearing() == true) location.bearing else null
|
||||
return heading?.let {
|
||||
if (!prefs.showChargersAheadAndroidAuto) return@let chargers
|
||||
|
||||
chargers?.filter {
|
||||
val bearing = bearingBetween(
|
||||
searchLocation.latitude,
|
||||
searchLocation.longitude,
|
||||
it.coordinates.lat,
|
||||
it.coordinates.lng
|
||||
)
|
||||
val diff = headingDiff(bearing, heading.toDouble())
|
||||
abs(diff) < 30
|
||||
}
|
||||
} ?: chargers
|
||||
}
|
||||
|
||||
private fun onEnergyLevelUpdated(energyLevel: EnergyLevel) {
|
||||
val isUpdate = this.energyLevel == null
|
||||
this.energyLevel = energyLevel
|
||||
if (isUpdate) invalidate()
|
||||
}
|
||||
|
||||
private fun onCompassUpdated(compass: Compass) {
|
||||
this.heading = compass
|
||||
}
|
||||
|
||||
override fun onStart(owner: LifecycleOwner) {
|
||||
setupListeners()
|
||||
session.requestLocationUpdates()
|
||||
locationError = false
|
||||
Handler(Looper.getMainLooper()).postDelayed({
|
||||
if (location == null) {
|
||||
locationError = true
|
||||
invalidate()
|
||||
}
|
||||
}, 5000)
|
||||
|
||||
// Reloading chargers in onStart does not seem to count towards content limit.
|
||||
// So let's do this so the user gets fresh chargers when re-entering the app.
|
||||
if (prefs.dataSource != repo.api.value?.id) {
|
||||
repo.api.value = createApi(prefs.dataSource, carContext)
|
||||
}
|
||||
invalidate()
|
||||
loadChargers()
|
||||
}
|
||||
|
||||
private fun setupListeners() {
|
||||
val exec = ContextCompat.getMainExecutor(carContext)
|
||||
if (supportsCarApiLevel3(carContext)) {
|
||||
carSensors.addCompassListener(
|
||||
CarSensors.UPDATE_RATE_NORMAL,
|
||||
exec,
|
||||
::onCompassUpdated
|
||||
)
|
||||
}
|
||||
if (!permissions.all {
|
||||
ContextCompat.checkSelfPermission(
|
||||
carContext,
|
||||
it
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
})
|
||||
return
|
||||
|
||||
if (supportsCarApiLevel3(carContext)) {
|
||||
println("Setting up energy level listener")
|
||||
carInfo.addEnergyLevelListener(exec, ::onEnergyLevelUpdated)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStop(owner: LifecycleOwner) {
|
||||
// Reloading chargers in onStart does not seem to count towards content limit.
|
||||
// So let's do this so the user gets fresh chargers when re-entering the app.
|
||||
// Deleting the data already in onStop makes sure that we show a loading screen directly
|
||||
// (i.e. onGetTemplate is not called while the old data is still there)
|
||||
chargers = null
|
||||
availabilities.clear()
|
||||
location = null
|
||||
removeListeners()
|
||||
}
|
||||
|
||||
private fun removeListeners() {
|
||||
if (supportsCarApiLevel3(carContext)) {
|
||||
println("Removing energy level listener")
|
||||
carInfo.removeEnergyLevelListener(::onEnergyLevelUpdated)
|
||||
carSensors.removeCompassListener(::onCompassUpdated)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onContentRefreshRequested() {
|
||||
loadChargers()
|
||||
availabilities.clear()
|
||||
|
||||
val start = visibleStart
|
||||
val end = visibleEnd
|
||||
if (start != null && end != null) {
|
||||
onItemVisibilityChanged(start, end)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onItemVisibilityChanged(startIndex: Int, endIndex: Int) {
|
||||
// when the list is scrolled, load corresponding availabilities
|
||||
if (startIndex == visibleStart && endIndex == visibleEnd && availabilities.isNotEmpty()) return
|
||||
if (startIndex == -1 || endIndex == -1) return
|
||||
if (availabilityUpdateCoroutine != null) return
|
||||
|
||||
visibleEnd = endIndex
|
||||
visibleStart = startIndex
|
||||
|
||||
// remove outdated availabilities
|
||||
availabilities = availabilities.filter {
|
||||
Duration.between(
|
||||
it.value.first,
|
||||
ZonedDateTime.now()
|
||||
) <= availabilityUpdateThreshold
|
||||
}.toMutableMap()
|
||||
|
||||
// update availabilities
|
||||
availabilityUpdateCoroutine = lifecycleScope.launch {
|
||||
delay(300L)
|
||||
|
||||
val chargers = chargers ?: return@launch
|
||||
if (chargers.isEmpty()) return@launch
|
||||
|
||||
val tasks = chargers.subList(
|
||||
min(startIndex, chargers.size - 1),
|
||||
min(endIndex, chargers.size - 1)
|
||||
).mapNotNull {
|
||||
// update only if not yet stored
|
||||
if (!availabilities.containsKey(it.id)) {
|
||||
lifecycleScope.async {
|
||||
val availability = availabilityRepo.getAvailability(it).data
|
||||
val date = ZonedDateTime.now()
|
||||
availabilities[it.id] = date to availability
|
||||
}
|
||||
} else null
|
||||
}
|
||||
if (tasks.isNotEmpty()) {
|
||||
tasks.awaitAll()
|
||||
invalidate()
|
||||
}
|
||||
availabilityUpdateCoroutine = null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package net.vonforst.evmap.auto
|
||||
|
||||
import androidx.car.app.CarContext
|
||||
import androidx.car.app.Screen
|
||||
import androidx.car.app.annotations.ExperimentalCarApi
|
||||
import androidx.car.app.model.Action
|
||||
import androidx.car.app.model.Header
|
||||
import androidx.car.app.model.ItemList
|
||||
import androidx.car.app.model.ListTemplate
|
||||
import androidx.car.app.model.ParkedOnlyOnClickListener
|
||||
import androidx.car.app.model.Row
|
||||
import androidx.car.app.model.Template
|
||||
import com.car2go.maps.AttributionClickListener
|
||||
import net.vonforst.evmap.R
|
||||
|
||||
@ExperimentalCarApi
|
||||
class MapAttributionScreen(
|
||||
ctx: CarContext,
|
||||
val session: EVMapSession,
|
||||
val attributions: List<AttributionClickListener.Attribution>
|
||||
) : Screen(ctx) {
|
||||
override fun onGetTemplate(): Template {
|
||||
return ListTemplate.Builder()
|
||||
.setHeader(
|
||||
Header.Builder()
|
||||
.setStartHeaderAction(Action.BACK)
|
||||
.setTitle(carContext.getString(R.string.maplibre_attributionsDialogTitle))
|
||||
.build()
|
||||
)
|
||||
.setSingleList(ItemList.Builder().apply {
|
||||
attributions.forEach { attr ->
|
||||
addItem(
|
||||
Row.Builder()
|
||||
.setTitle(attr.title)
|
||||
.setBrowsable(true)
|
||||
.setOnClickListener(
|
||||
ParkedOnlyOnClickListener.create {
|
||||
openUrl(carContext, session.cas, attr.url)
|
||||
}).build()
|
||||
)
|
||||
}
|
||||
}.build())
|
||||
.build()
|
||||
}
|
||||
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
284
app/src/main/java/net/vonforst/evmap/auto/MapSurfaceCallback.kt
Normal file
284
app/src/main/java/net/vonforst/evmap/auto/MapSurfaceCallback.kt
Normal file
@@ -0,0 +1,284 @@
|
||||
package net.vonforst.evmap.auto
|
||||
|
||||
import android.animation.ValueAnimator
|
||||
import android.app.Presentation
|
||||
import android.content.Context
|
||||
import android.graphics.Rect
|
||||
import android.hardware.display.DisplayManager
|
||||
import android.hardware.display.VirtualDisplay
|
||||
import android.os.Build
|
||||
import android.os.SystemClock
|
||||
import android.util.Log
|
||||
import android.view.MotionEvent
|
||||
import androidx.car.app.CarContext
|
||||
import androidx.car.app.SurfaceCallback
|
||||
import androidx.car.app.SurfaceContainer
|
||||
import androidx.car.app.annotations.RequiresCarApi
|
||||
import androidx.core.animation.doOnEnd
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.interpolator.view.animation.LinearOutSlowInInterpolator
|
||||
import androidx.lifecycle.LifecycleCoroutineScope
|
||||
import com.car2go.maps.AnyMap
|
||||
import com.car2go.maps.AnyMap.CancelableCallback
|
||||
import com.car2go.maps.CameraUpdate
|
||||
import com.car2go.maps.MapContainerView
|
||||
import com.car2go.maps.MapFactory
|
||||
import com.car2go.maps.OnMapReadyCallback
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import net.vonforst.evmap.BuildConfig
|
||||
import net.vonforst.evmap.R
|
||||
import net.vonforst.evmap.storage.PreferenceDataSource
|
||||
import kotlin.math.hypot
|
||||
import kotlin.math.roundToInt
|
||||
import kotlin.math.roundToLong
|
||||
|
||||
|
||||
class MapSurfaceCallback(val ctx: CarContext, val lifecycleScope: LifecycleCoroutineScope) :
|
||||
SurfaceCallback, OnMapReadyCallback {
|
||||
private val VIRTUAL_DISPLAY_NAME = "evmap_map"
|
||||
private val VELOCITY_THRESHOLD_IGNORE_FLING = 1000
|
||||
private val STATUSBAR_OFFSET_SYSTEMS = listOf(
|
||||
"VolvoCars/ihu_emulator_volvo_car/ihu_emulator:11",
|
||||
"Google/sdk_gcar_x86_64/generic_64bitonly_x86_64:11"
|
||||
)
|
||||
|
||||
private val prefs = PreferenceDataSource(ctx)
|
||||
|
||||
private lateinit var virtualDisplay: VirtualDisplay
|
||||
lateinit var presentation: Presentation
|
||||
private lateinit var mapView: MapContainerView
|
||||
private var width: Int = 0
|
||||
private var height: Int = 0
|
||||
private var visibleArea: Rect? = null
|
||||
private var map: AnyMap? = null
|
||||
private val mapCallbacks = mutableListOf<OnMapReadyCallback>()
|
||||
|
||||
private var flingAnimator: ValueAnimator? = null
|
||||
private var idle = true
|
||||
private var idleDelay: Job? = null
|
||||
var cameraMoveStartedListener: (() -> Unit)? = null
|
||||
var cameraIdleListener: (() -> Unit)? = null
|
||||
|
||||
|
||||
override fun onSurfaceAvailable(surfaceContainer: SurfaceContainer) {
|
||||
if (surfaceContainer.surface == null || surfaceContainer.dpi == 0 || surfaceContainer.height == 0 || surfaceContainer.width == 0) {
|
||||
return
|
||||
}
|
||||
|
||||
if (Build.FINGERPRINT.contains("emulator") || Build.FINGERPRINT.contains("sdk_gcar")) {
|
||||
// fix for MapLibre in Android Automotive Emulators
|
||||
System.setProperty("ro.kernel.qemu", "1")
|
||||
}
|
||||
|
||||
width = surfaceContainer.width
|
||||
height = surfaceContainer.height
|
||||
virtualDisplay = ContextCompat
|
||||
.getSystemService(ctx, DisplayManager::class.java)!!
|
||||
.createVirtualDisplay(
|
||||
VIRTUAL_DISPLAY_NAME,
|
||||
width,
|
||||
height,
|
||||
(surfaceContainer.dpi * when (getMapProvider()) {
|
||||
"mapbox" -> 1.6
|
||||
"google" -> 1.0
|
||||
else -> 1.0
|
||||
}).roundToInt(),
|
||||
surfaceContainer.surface,
|
||||
DisplayManager.VIRTUAL_DISPLAY_FLAG_OWN_CONTENT_ONLY
|
||||
)
|
||||
presentation = Presentation(ctx, virtualDisplay.display, R.style.AppTheme)
|
||||
|
||||
mapView = createMap(presentation.context)
|
||||
mapView.onCreate(null)
|
||||
mapView.onResume()
|
||||
|
||||
presentation.setContentView(mapView)
|
||||
presentation.show()
|
||||
|
||||
mapView.getMapAsync(this)
|
||||
}
|
||||
|
||||
private fun getMapProvider(): String = if (BuildConfig.FLAVOR_automotive == "automotive") {
|
||||
// Google Maps SDK is not available on AAOS (not even AAOS with GAS, so far)
|
||||
"mapbox"
|
||||
} else prefs.mapProvider
|
||||
|
||||
override fun onVisibleAreaChanged(visibleArea: Rect) {
|
||||
Log.d("MapSurfaceCallback", "visible area: $visibleArea")
|
||||
this.visibleArea = visibleArea
|
||||
updateVisibleArea()
|
||||
}
|
||||
|
||||
override fun onStableAreaChanged(stableArea: Rect) {
|
||||
Log.d("MapSurfaceCallback", "stable area: $stableArea")
|
||||
}
|
||||
|
||||
override fun onSurfaceDestroyed(surfaceContainer: SurfaceContainer) {
|
||||
mapView.onPause()
|
||||
mapView.onStop()
|
||||
mapView.onDestroy()
|
||||
map = null
|
||||
|
||||
presentation.dismiss()
|
||||
virtualDisplay.release()
|
||||
}
|
||||
|
||||
@RequiresCarApi(2)
|
||||
override fun onScroll(distanceX: Float, distanceY: Float) {
|
||||
flingAnimator?.cancel()
|
||||
val map = map ?: return
|
||||
map.moveCamera(map.cameraUpdateFactory.scrollBy(distanceX, distanceY))
|
||||
dispatchCameraMoveStarted()
|
||||
}
|
||||
|
||||
@RequiresCarApi(2)
|
||||
override fun onFling(velocityX: Float, velocityY: Float) {
|
||||
val map = map ?: return
|
||||
val screenDensity: Float = presentation.resources.displayMetrics.density
|
||||
|
||||
// calculate velocity vector for xy dimensions, independent from screen size
|
||||
val velocityXY =
|
||||
hypot((velocityX / screenDensity).toDouble(), (velocityY / screenDensity).toDouble())
|
||||
if (velocityXY < VELOCITY_THRESHOLD_IGNORE_FLING) {
|
||||
// ignore short flings, these can occur when other gestures just have finished executing
|
||||
return
|
||||
}
|
||||
|
||||
idleDelay?.cancel()
|
||||
|
||||
val offsetX = velocityX / 10
|
||||
val offsetY = velocityY / 10
|
||||
val animationTime = (velocityXY / 10).roundToLong()
|
||||
|
||||
flingAnimator = ValueAnimator.ofFloat(0f, 1f).apply {
|
||||
duration = animationTime
|
||||
interpolator = LinearOutSlowInInterpolator()
|
||||
|
||||
var last = 0f
|
||||
addUpdateListener {
|
||||
val current = it.animatedFraction
|
||||
val diff = last - current
|
||||
map.moveCamera(map.cameraUpdateFactory.scrollBy(diff * offsetX, diff * offsetY))
|
||||
last = current
|
||||
}
|
||||
start()
|
||||
|
||||
doOnEnd { dispatchCameraIdle() }
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresCarApi(2)
|
||||
override fun onScale(focusX: Float, focusY: Float, scaleFactor: Float) {
|
||||
flingAnimator?.cancel()
|
||||
val map = map ?: return
|
||||
if (scaleFactor == 2f) return
|
||||
|
||||
val offsetX = (focusX - mapView.width / 2) * (scaleFactor - 1f)
|
||||
val offsetY = (offsetY(focusY) - mapView.height / 2) * (scaleFactor - 1f)
|
||||
|
||||
Log.i("MapSurfaceCallback", "focus: $focusX, $focusY, scaleFactor: $scaleFactor")
|
||||
map.moveCamera(map.cameraUpdateFactory.zoomBy(scaleFactor - 1))
|
||||
map.moveCamera(map.cameraUpdateFactory.scrollBy(offsetX, offsetY))
|
||||
dispatchCameraMoveStarted()
|
||||
}
|
||||
|
||||
fun animateCamera(update: CameraUpdate) {
|
||||
val map = map ?: return
|
||||
map.animateCamera(update, object : CancelableCallback {
|
||||
override fun onFinish() {
|
||||
dispatchCameraIdle()
|
||||
}
|
||||
|
||||
override fun onCancel() {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private fun dispatchCameraMoveStarted() {
|
||||
if (idle) {
|
||||
idle = false
|
||||
cameraMoveStartedListener?.invoke()
|
||||
}
|
||||
idleDelay?.cancel()
|
||||
idleDelay = lifecycleScope.launch {
|
||||
delay(500)
|
||||
dispatchCameraIdle()
|
||||
}
|
||||
}
|
||||
|
||||
private fun dispatchCameraIdle() {
|
||||
idle = true
|
||||
cameraIdleListener?.invoke()
|
||||
}
|
||||
|
||||
@RequiresCarApi(5)
|
||||
override fun onClick(x: Float, y: Float) {
|
||||
flingAnimator?.cancel()
|
||||
val downTime: Long = SystemClock.uptimeMillis()
|
||||
val eventTime: Long = downTime + 100
|
||||
val yOffset = offsetY(y)
|
||||
|
||||
val downEvent = MotionEvent.obtain(
|
||||
downTime,
|
||||
downTime,
|
||||
MotionEvent.ACTION_DOWN,
|
||||
x,
|
||||
yOffset,
|
||||
0
|
||||
)
|
||||
mapView.dispatchTouchEvent(downEvent)
|
||||
downEvent.recycle()
|
||||
val upEvent = MotionEvent.obtain(
|
||||
downTime,
|
||||
eventTime,
|
||||
MotionEvent.ACTION_UP,
|
||||
x,
|
||||
yOffset,
|
||||
0
|
||||
)
|
||||
mapView.dispatchTouchEvent(upEvent)
|
||||
upEvent.recycle()
|
||||
}
|
||||
|
||||
private fun offsetY(y: Float): Float {
|
||||
if (!STATUSBAR_OFFSET_SYSTEMS.any { Build.FINGERPRINT.startsWith(it) }) return y
|
||||
|
||||
// In some emulators, touch locations are offset by the status bar height
|
||||
// related: https://issuetracker.google.com/issues/256905247
|
||||
val resId = ctx.resources.getIdentifier("status_bar_height", "dimen", "android")
|
||||
val offset = resId.takeIf { it > 0 }?.let { ctx.resources.getDimensionPixelSize(it) } ?: 0
|
||||
return y + offset
|
||||
}
|
||||
|
||||
private fun createMap(ctx: Context): MapContainerView {
|
||||
val priority = arrayOf(
|
||||
when (getMapProvider()) {
|
||||
"mapbox" -> MapFactory.MAPLIBRE
|
||||
"google" -> MapFactory.GOOGLE
|
||||
else -> null
|
||||
},
|
||||
MapFactory.GOOGLE,
|
||||
MapFactory.MAPLIBRE
|
||||
)
|
||||
return MapFactory.createMap(ctx, priority).view
|
||||
}
|
||||
|
||||
override fun onMapReady(anyMap: AnyMap) {
|
||||
this.map = anyMap
|
||||
updateVisibleArea()
|
||||
mapCallbacks.forEach { it.onMapReady(anyMap) }
|
||||
mapCallbacks.clear()
|
||||
}
|
||||
|
||||
private fun updateVisibleArea() {
|
||||
visibleArea?.let {
|
||||
map?.setPadding(it.left, it.top, width - it.right, height - it.bottom)
|
||||
}
|
||||
}
|
||||
|
||||
fun getMapAsync(callback: OnMapReadyCallback) {
|
||||
mapCallbacks.add(callback)
|
||||
}
|
||||
}
|
||||
@@ -11,19 +11,34 @@ import androidx.car.app.annotations.ExperimentalCarApi
|
||||
import androidx.car.app.constraints.ConstraintManager
|
||||
import androidx.car.app.hardware.CarHardwareManager
|
||||
import androidx.car.app.hardware.info.EnergyLevel
|
||||
import androidx.car.app.model.*
|
||||
import androidx.car.app.model.Action
|
||||
import androidx.car.app.model.CarColor
|
||||
import androidx.car.app.model.CarIcon
|
||||
import androidx.car.app.model.DistanceSpan
|
||||
import androidx.car.app.model.ItemList
|
||||
import androidx.car.app.model.Row
|
||||
import androidx.car.app.model.SearchTemplate
|
||||
import androidx.car.app.model.Template
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.graphics.drawable.IconCompat
|
||||
import androidx.lifecycle.DefaultLifecycleObserver
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.car2go.maps.model.LatLng
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import net.vonforst.evmap.BuildConfig
|
||||
import net.vonforst.evmap.R
|
||||
import net.vonforst.evmap.adapter.iconForPlaceType
|
||||
import net.vonforst.evmap.adapter.isSpecialPlace
|
||||
import net.vonforst.evmap.autocomplete.*
|
||||
import net.vonforst.evmap.autocomplete.ApiUnavailableException
|
||||
import net.vonforst.evmap.autocomplete.AutocompletePlace
|
||||
import net.vonforst.evmap.autocomplete.AutocompleteProvider
|
||||
import net.vonforst.evmap.autocomplete.PlaceWithBounds
|
||||
import net.vonforst.evmap.autocomplete.getAutocompleteProviders
|
||||
import net.vonforst.evmap.storage.AppDatabase
|
||||
import net.vonforst.evmap.storage.PreferenceDataSource
|
||||
import net.vonforst.evmap.storage.RecentAutocompletePlace
|
||||
@@ -117,7 +132,7 @@ class PlaceSearchScreen(
|
||||
setOnClickListener {
|
||||
lifecycleScope.launch {
|
||||
val placeDetails = getDetails(place.id) ?: return@launch
|
||||
prefs.placeSearchResultAndroidAuto = placeDetails.latLng
|
||||
prefs.placeSearchResultAndroidAuto = placeDetails
|
||||
prefs.placeSearchResultAndroidAutoName =
|
||||
place.primaryText.toString()
|
||||
screenManager.popTo(MapScreen.MARKER)
|
||||
|
||||
@@ -4,7 +4,14 @@ import androidx.car.app.CarContext
|
||||
import androidx.car.app.CarToast
|
||||
import androidx.car.app.Screen
|
||||
import androidx.car.app.constraints.ConstraintManager
|
||||
import androidx.car.app.model.*
|
||||
import androidx.car.app.model.Action
|
||||
import androidx.car.app.model.ActionStrip
|
||||
import androidx.car.app.model.CarColor
|
||||
import androidx.car.app.model.CarIcon
|
||||
import androidx.car.app.model.ItemList
|
||||
import androidx.car.app.model.Row
|
||||
import androidx.car.app.model.SearchTemplate
|
||||
import androidx.car.app.model.Template
|
||||
import androidx.core.graphics.drawable.IconCompat
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -45,7 +52,7 @@ abstract class MultiSelectSearchScreen<T>(ctx: CarContext) : Screen(ctx),
|
||||
} ?: run {
|
||||
setLoading(true)
|
||||
}
|
||||
if (isMultiSelect) {
|
||||
if (isMultiSelect && shouldShowSelectAll) {
|
||||
setActionStrip(ActionStrip.Builder().apply {
|
||||
addAction(
|
||||
Action.Builder().setIcon(
|
||||
|
||||
@@ -30,6 +30,8 @@ import androidx.car.app.model.Toggle
|
||||
import androidx.core.content.IntentCompat
|
||||
import androidx.core.graphics.drawable.IconCompat
|
||||
import androidx.core.text.HtmlCompat
|
||||
import androidx.lifecycle.DefaultLifecycleObserver
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.localbroadcastmanager.content.LocalBroadcastManager
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -79,7 +81,7 @@ class SettingsScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx) {
|
||||
)
|
||||
setBrowsable(true)
|
||||
setOnClickListener {
|
||||
screenManager.push(DataSettingsScreen(carContext))
|
||||
screenManager.push(DataSettingsScreen(carContext, session))
|
||||
}
|
||||
}.build())
|
||||
addItem(Row.Builder().apply {
|
||||
@@ -114,22 +116,25 @@ class SettingsScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx) {
|
||||
}
|
||||
.build()
|
||||
)
|
||||
addItem(
|
||||
Row.Builder()
|
||||
.setTitle(carContext.getString(R.string.auto_chargers_ahead))
|
||||
.setToggle(Toggle.Builder {
|
||||
prefs.showChargersAheadAndroidAuto = it
|
||||
}.setChecked(prefs.showChargersAheadAndroidAuto).build())
|
||||
.setImage(
|
||||
CarIcon.Builder(
|
||||
IconCompat.createWithResource(
|
||||
carContext,
|
||||
R.drawable.ic_navigation
|
||||
)
|
||||
).setTint(CarColor.DEFAULT).build()
|
||||
)
|
||||
.build()
|
||||
)
|
||||
if (carContext.carAppApiLevel < 7 || !carContext.isAppDrivenRefreshSupported) {
|
||||
// this option is only supported in LegacyMapScreen
|
||||
addItem(
|
||||
Row.Builder()
|
||||
.setTitle(carContext.getString(R.string.auto_chargers_ahead))
|
||||
.setToggle(Toggle.Builder {
|
||||
prefs.showChargersAheadAndroidAuto = it
|
||||
}.setChecked(prefs.showChargersAheadAndroidAuto).build())
|
||||
.setImage(
|
||||
CarIcon.Builder(
|
||||
IconCompat.createWithResource(
|
||||
carContext,
|
||||
R.drawable.ic_navigation
|
||||
)
|
||||
).setTint(CarColor.DEFAULT).build()
|
||||
)
|
||||
.build()
|
||||
)
|
||||
}
|
||||
}
|
||||
addItem(
|
||||
Row.Builder()
|
||||
@@ -144,7 +149,7 @@ class SettingsScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx) {
|
||||
)
|
||||
.setBrowsable(true)
|
||||
.setOnClickListener {
|
||||
screenManager.push(AboutScreen(carContext))
|
||||
screenManager.push(AboutScreen(carContext, session))
|
||||
}
|
||||
.build()
|
||||
)
|
||||
@@ -153,7 +158,8 @@ class SettingsScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx) {
|
||||
}
|
||||
}
|
||||
|
||||
class DataSettingsScreen(ctx: CarContext) : Screen(ctx) {
|
||||
@ExperimentalCarApi
|
||||
class DataSettingsScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx), DefaultLifecycleObserver {
|
||||
val prefs = PreferenceDataSource(ctx)
|
||||
val encryptedPrefs = EncryptedPreferenceDataStore(ctx)
|
||||
val db = AppDatabase.getInstance(ctx)
|
||||
@@ -164,14 +170,22 @@ class DataSettingsScreen(ctx: CarContext) : Screen(ctx) {
|
||||
carContext.resources.getStringArray(R.array.pref_search_provider_names)
|
||||
val searchProviderValues =
|
||||
carContext.resources.getStringArray(R.array.pref_search_provider_values)
|
||||
val mapProviderNames =
|
||||
carContext.resources.getStringArray(R.array.pref_map_provider_names)
|
||||
val mapProviderValues =
|
||||
carContext.resources.getStringArray(R.array.pref_map_provider_values)
|
||||
|
||||
var teslaLoggingIn = false
|
||||
|
||||
init {
|
||||
lifecycle.addObserver(this)
|
||||
}
|
||||
|
||||
override fun onGetTemplate(): Template {
|
||||
return ListTemplate.Builder().apply {
|
||||
setTitle(carContext.getString(R.string.settings_data_sources))
|
||||
setHeaderAction(Action.BACK)
|
||||
setSingleList(ItemList.Builder().apply {
|
||||
addSectionedList(SectionedItemList.create(ItemList.Builder().apply {
|
||||
addItem(Row.Builder().apply {
|
||||
setTitle(carContext.getString(R.string.pref_data_source))
|
||||
setBrowsable(true)
|
||||
@@ -187,36 +201,7 @@ class DataSettingsScreen(ctx: CarContext) : Screen(ctx) {
|
||||
)
|
||||
}
|
||||
}.build())
|
||||
addItem(Row.Builder().apply {
|
||||
setTitle(carContext.getString(R.string.pref_search_provider))
|
||||
setBrowsable(true)
|
||||
val searchProviderId = prefs.searchProvider
|
||||
val searchProviderDesc =
|
||||
searchProviderNames[searchProviderValues.indexOf(searchProviderId)]
|
||||
addText(searchProviderDesc)
|
||||
setOnClickListener {
|
||||
screenManager.push(
|
||||
ChooseDataSourceScreen(
|
||||
carContext,
|
||||
ChooseDataSourceScreen.Type.SEARCH_PROVIDER
|
||||
)
|
||||
)
|
||||
}
|
||||
}.build())
|
||||
addItem(Row.Builder().apply {
|
||||
setTitle(carContext.getString(R.string.pref_search_delete_recent))
|
||||
setOnClickListener {
|
||||
lifecycleScope.launch {
|
||||
db.recentAutocompletePlaceDao().deleteAll()
|
||||
CarToast.makeText(
|
||||
carContext,
|
||||
R.string.deleted_recent_search_results,
|
||||
CarToast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
}
|
||||
}.build())
|
||||
addItem(
|
||||
/*addItem(
|
||||
Row.Builder()
|
||||
.setTitle(carContext.getString(R.string.pref_prediction_enabled))
|
||||
.addText(carContext.getString(R.string.pref_prediction_enabled_summary))
|
||||
@@ -224,7 +209,7 @@ class DataSettingsScreen(ctx: CarContext) : Screen(ctx) {
|
||||
prefs.predictionEnabled = it
|
||||
}.setChecked(prefs.predictionEnabled).build())
|
||||
.build()
|
||||
)
|
||||
)*/
|
||||
addItem(Row.Builder().apply {
|
||||
setTitle(carContext.getString(R.string.pref_tesla_account))
|
||||
addText(
|
||||
@@ -249,10 +234,104 @@ class DataSettingsScreen(ctx: CarContext) : Screen(ctx) {
|
||||
})
|
||||
}
|
||||
}.build())
|
||||
}.build())
|
||||
}.build(), carContext.getString(R.string.settings_charger_data)))
|
||||
addSectionedList(SectionedItemList.create(ItemList.Builder().apply {
|
||||
addItem(Row.Builder().apply {
|
||||
setTitle(carContext.getString(R.string.pref_search_provider))
|
||||
setBrowsable(true)
|
||||
val searchProviderId = prefs.searchProvider
|
||||
val searchProviderDesc =
|
||||
searchProviderNames[searchProviderValues.indexOf(searchProviderId)]
|
||||
addText(searchProviderDesc)
|
||||
setOnClickListener {
|
||||
screenManager.push(
|
||||
ChooseDataSourceScreen(
|
||||
carContext,
|
||||
ChooseDataSourceScreen.Type.SEARCH_PROVIDER
|
||||
)
|
||||
)
|
||||
}
|
||||
}.build())
|
||||
if (supportsNewMapScreen(carContext) && BuildConfig.FLAVOR_automotive != "automotive") {
|
||||
// Google Maps SDK is not available on AAOS (not even AAOS with GAS, so far)
|
||||
addItem(Row.Builder().apply {
|
||||
setTitle(carContext.getString(R.string.pref_map_provider))
|
||||
setBrowsable(true)
|
||||
val mapProviderId = prefs.mapProvider
|
||||
val mapProviderDesc =
|
||||
mapProviderNames[mapProviderValues.indexOf(mapProviderId)]
|
||||
addText(mapProviderDesc)
|
||||
setOnClickListener {
|
||||
screenManager.push(
|
||||
ChooseDataSourceScreen(
|
||||
carContext,
|
||||
ChooseDataSourceScreen.Type.MAP_PROVIDER
|
||||
)
|
||||
)
|
||||
}
|
||||
}.build())
|
||||
}
|
||||
addItem(Row.Builder().apply {
|
||||
setTitle(carContext.getString(R.string.pref_search_delete_recent))
|
||||
setOnClickListener {
|
||||
lifecycleScope.launch {
|
||||
db.recentAutocompletePlaceDao().deleteAll()
|
||||
CarToast.makeText(
|
||||
carContext,
|
||||
R.string.deleted_recent_search_results,
|
||||
CarToast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
}
|
||||
}.build())
|
||||
}.build(), carContext.getString(R.string.settings_map)))
|
||||
addSectionedList(SectionedItemList.create(ItemList.Builder().apply {
|
||||
addItem(Row.Builder().apply {
|
||||
setTitle(carContext.getString(R.string.settings_cache_count))
|
||||
cacheCount?.let { count ->
|
||||
cacheSize?.let { size ->
|
||||
val sizeMb = size.toFloat() / 1024 / 1024
|
||||
addText(
|
||||
carContext.getString(
|
||||
R.string.settings_cache_count_summary,
|
||||
count,
|
||||
sizeMb
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}.build())
|
||||
addItem(Row.Builder().apply {
|
||||
setTitle(carContext.getString(R.string.settings_cache_clear))
|
||||
addText(carContext.getString(R.string.settings_cache_clear_summary))
|
||||
setOnClickListener {
|
||||
lifecycleScope.launch {
|
||||
db.savedRegionDao().deleteAll()
|
||||
db.chargeLocationsDao().deleteAllIfNotFavorite()
|
||||
loadCacheSize()
|
||||
}
|
||||
}
|
||||
}.build())
|
||||
}.build(), carContext.getString(R.string.settings_caching)))
|
||||
}.build()
|
||||
}
|
||||
|
||||
var cacheCount: Long? = null
|
||||
var cacheSize: Long? = null
|
||||
|
||||
private suspend fun loadCacheSize() {
|
||||
cacheCount = db.chargeLocationsDao().getCountAsync()
|
||||
cacheSize = db.chargeLocationsDao().getSize()
|
||||
invalidate()
|
||||
}
|
||||
|
||||
override fun onStart(owner: LifecycleOwner) {
|
||||
super.onStart(owner)
|
||||
lifecycleScope.launch {
|
||||
loadCacheSize()
|
||||
}
|
||||
}
|
||||
|
||||
private fun teslaLogin() {
|
||||
val codeVerifier = TeslaAuthenticationApi.generateCodeVerifier()
|
||||
val codeChallenge = TeslaAuthenticationApi.generateCodeChallenge(codeVerifier)
|
||||
@@ -279,7 +358,7 @@ class DataSettingsScreen(ctx: CarContext) : Screen(ctx) {
|
||||
}
|
||||
}, IntentFilter(OAuthLoginFragment.ACTION_OAUTH_RESULT))
|
||||
|
||||
carContext.startActivity(intent)
|
||||
session.cas.startActivity(intent)
|
||||
|
||||
if (BuildConfig.FLAVOR_automotive != "automotive") {
|
||||
CarToast.makeText(
|
||||
@@ -341,32 +420,42 @@ class ChooseDataSourceScreen(
|
||||
@StringRes val extraDesc: Int? = null
|
||||
) : Screen(ctx) {
|
||||
enum class Type {
|
||||
CHARGER_DATA_SOURCE, SEARCH_PROVIDER
|
||||
CHARGER_DATA_SOURCE, SEARCH_PROVIDER, MAP_PROVIDER
|
||||
}
|
||||
|
||||
val prefs = PreferenceDataSource(carContext)
|
||||
val title = when (type) {
|
||||
Type.CHARGER_DATA_SOURCE -> R.string.pref_data_source
|
||||
Type.SEARCH_PROVIDER -> R.string.pref_search_provider
|
||||
Type.MAP_PROVIDER -> R.string.pref_map_provider
|
||||
}
|
||||
val names = when (type) {
|
||||
Type.CHARGER_DATA_SOURCE -> carContext.resources.getStringArray(R.array.pref_data_source_names)
|
||||
Type.SEARCH_PROVIDER -> carContext.resources.getStringArray(R.array.pref_search_provider_names)
|
||||
}
|
||||
val values = when (type) {
|
||||
Type.CHARGER_DATA_SOURCE -> carContext.resources.getStringArray(R.array.pref_data_source_values)
|
||||
Type.SEARCH_PROVIDER -> carContext.resources.getStringArray(R.array.pref_search_provider_values)
|
||||
}
|
||||
val names = carContext.resources.getStringArray(
|
||||
when (type) {
|
||||
Type.CHARGER_DATA_SOURCE -> R.array.pref_data_source_names
|
||||
Type.SEARCH_PROVIDER -> R.array.pref_search_provider_names
|
||||
Type.MAP_PROVIDER -> R.array.pref_map_provider_names
|
||||
}
|
||||
)
|
||||
val values = carContext.resources.getStringArray(
|
||||
when (type) {
|
||||
Type.CHARGER_DATA_SOURCE -> R.array.pref_data_source_values
|
||||
Type.SEARCH_PROVIDER -> R.array.pref_search_provider_values
|
||||
Type.MAP_PROVIDER -> R.array.pref_map_provider_values
|
||||
}
|
||||
)
|
||||
val currentValue: String = when (type) {
|
||||
Type.CHARGER_DATA_SOURCE -> prefs.dataSource
|
||||
Type.SEARCH_PROVIDER -> prefs.searchProvider
|
||||
Type.MAP_PROVIDER -> prefs.mapProvider
|
||||
}
|
||||
val descriptions = when (type) {
|
||||
Type.CHARGER_DATA_SOURCE -> listOf(
|
||||
carContext.getString(R.string.data_source_goingelectric_desc),
|
||||
carContext.getString(R.string.data_source_openchargemap_desc)
|
||||
carContext.getString(R.string.data_source_openchargemap_desc),
|
||||
carContext.getString(R.string.data_source_openstreetmap_desc)
|
||||
)
|
||||
Type.SEARCH_PROVIDER -> null
|
||||
Type.MAP_PROVIDER -> null
|
||||
}
|
||||
val callback: (String) -> Unit = when (type) {
|
||||
Type.CHARGER_DATA_SOURCE -> { it ->
|
||||
@@ -376,6 +465,9 @@ class ChooseDataSourceScreen(
|
||||
Type.SEARCH_PROVIDER -> { it ->
|
||||
prefs.searchProvider = it
|
||||
}
|
||||
Type.MAP_PROVIDER -> { it ->
|
||||
prefs.mapProvider = it
|
||||
}
|
||||
}
|
||||
|
||||
override fun onGetTemplate(): Template {
|
||||
@@ -752,7 +844,8 @@ class SelectChargingRangeScreen(ctx: CarContext) : Screen(ctx) {
|
||||
}
|
||||
}
|
||||
|
||||
class AboutScreen(ctx: CarContext) : Screen(ctx) {
|
||||
@ExperimentalCarApi
|
||||
class AboutScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx) {
|
||||
val prefs = PreferenceDataSource(ctx)
|
||||
var developerOptionsCounter = 0
|
||||
private val maxRows = ctx.getContentLimit(ConstraintManager.CONTENT_LIMIT_TYPE_LIST)
|
||||
@@ -797,7 +890,11 @@ class AboutScreen(ctx: CarContext) : Screen(ctx) {
|
||||
.setTitle(carContext.getString(R.string.faq))
|
||||
.setBrowsable(true)
|
||||
.setOnClickListener(ParkedOnlyOnClickListener.create {
|
||||
openUrl(carContext, carContext.getString(R.string.faq_link))
|
||||
openUrl(
|
||||
carContext,
|
||||
session.cas,
|
||||
carContext.getString(R.string.faq_link)
|
||||
)
|
||||
}).build()
|
||||
)
|
||||
addItem(
|
||||
@@ -808,12 +905,16 @@ class AboutScreen(ctx: CarContext) : Screen(ctx) {
|
||||
.setOnClickListener(ParkedOnlyOnClickListener.create {
|
||||
if (BuildConfig.FLAVOR_automotive == "automotive") {
|
||||
// we can't open the donation page on the phone in this case
|
||||
openUrl(carContext, carContext.getString(R.string.donate_link))
|
||||
openUrl(
|
||||
carContext,
|
||||
session.cas,
|
||||
carContext.getString(R.string.donate_link)
|
||||
)
|
||||
} else {
|
||||
val intent = Intent(carContext, MapsActivity::class.java)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
.putExtra(EXTRA_DONATE, true)
|
||||
carContext.startActivity(intent)
|
||||
session.cas.startActivity(intent)
|
||||
CarToast.makeText(
|
||||
carContext,
|
||||
R.string.opened_on_phone,
|
||||
@@ -825,39 +926,75 @@ class AboutScreen(ctx: CarContext) : Screen(ctx) {
|
||||
}.build(), carContext.getString(R.string.about)))
|
||||
addSectionedList(SectionedItemList.create(ItemList.Builder().apply {
|
||||
addItem(Row.Builder()
|
||||
.setTitle(carContext.getString(R.string.twitter))
|
||||
.addText(carContext.getString(R.string.twitter_handle))
|
||||
.setTitle(carContext.getString(R.string.mastodon))
|
||||
.addText(carContext.getString(R.string.mastodon_handle))
|
||||
.setBrowsable(true)
|
||||
.setOnClickListener(ParkedOnlyOnClickListener.create {
|
||||
openUrl(carContext, carContext.getString(R.string.twitter_url))
|
||||
openUrl(
|
||||
carContext,
|
||||
session.cas,
|
||||
carContext.getString(R.string.mastodon_url)
|
||||
)
|
||||
}).build()
|
||||
)
|
||||
if (maxRows > 8) {
|
||||
addItem(
|
||||
Row.Builder()
|
||||
.setTitle(carContext.getString(R.string.twitter))
|
||||
.addText(carContext.getString(R.string.twitter_handle))
|
||||
.setBrowsable(true)
|
||||
.setOnClickListener(ParkedOnlyOnClickListener.create {
|
||||
openUrl(
|
||||
carContext,
|
||||
session.cas,
|
||||
carContext.getString(R.string.twitter_url)
|
||||
)
|
||||
}).build()
|
||||
)
|
||||
}
|
||||
if (maxRows > 6) {
|
||||
addItem(Row.Builder()
|
||||
.setTitle(carContext.getString(R.string.goingelectric_forum))
|
||||
.setBrowsable(true)
|
||||
.setOnClickListener(ParkedOnlyOnClickListener.create {
|
||||
openUrl(
|
||||
carContext,
|
||||
carContext, session.cas,
|
||||
carContext.getString(R.string.goingelectric_forum_url)
|
||||
)
|
||||
}).build()
|
||||
)
|
||||
}
|
||||
if (maxRows > 7) {
|
||||
addItem(
|
||||
Row.Builder()
|
||||
.setTitle(carContext.getString(R.string.tff_forum))
|
||||
.setBrowsable(true)
|
||||
.setOnClickListener(ParkedOnlyOnClickListener.create {
|
||||
openUrl(
|
||||
carContext, session.cas,
|
||||
carContext.getString(R.string.tff_forum_url)
|
||||
)
|
||||
}).build()
|
||||
)
|
||||
}
|
||||
}.build(), carContext.getString(R.string.contact)))
|
||||
addSectionedList(SectionedItemList.create(ItemList.Builder().apply {
|
||||
addItem(Row.Builder()
|
||||
.setTitle(carContext.getString(R.string.github_link_title))
|
||||
.setBrowsable(true)
|
||||
.setOnClickListener(ParkedOnlyOnClickListener.create {
|
||||
openUrl(carContext, carContext.getString(R.string.github_link))
|
||||
openUrl(carContext, session.cas, carContext.getString(R.string.github_link))
|
||||
}).build()
|
||||
)
|
||||
addItem(Row.Builder()
|
||||
.setTitle(carContext.getString(R.string.privacy))
|
||||
.setBrowsable(true)
|
||||
.setOnClickListener(ParkedOnlyOnClickListener.create {
|
||||
openUrl(carContext, carContext.getString(R.string.privacy_link))
|
||||
openUrl(
|
||||
carContext,
|
||||
session.cas,
|
||||
carContext.getString(R.string.privacy_link)
|
||||
)
|
||||
}).build()
|
||||
)
|
||||
}.build(), carContext.getString(R.string.other)))
|
||||
@@ -865,7 +1002,8 @@ class AboutScreen(ctx: CarContext) : Screen(ctx) {
|
||||
}
|
||||
}
|
||||
|
||||
class AcceptPrivacyScreen(ctx: CarContext) : Screen(ctx) {
|
||||
@ExperimentalCarApi
|
||||
class AcceptPrivacyScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx) {
|
||||
val prefs = PreferenceDataSource(ctx)
|
||||
override fun onGetTemplate(): Template {
|
||||
val textWithoutLink = HtmlCompat.fromHtml(
|
||||
@@ -886,7 +1024,7 @@ class AcceptPrivacyScreen(ctx: CarContext) : Screen(ctx) {
|
||||
addAction(Action.Builder()
|
||||
.setTitle(carContext.getString(R.string.privacy))
|
||||
.setOnClickListener(ParkedOnlyOnClickListener.create {
|
||||
openUrl(carContext, carContext.getString(R.string.privacy_link))
|
||||
openUrl(carContext, session.cas, carContext.getString(R.string.privacy_link))
|
||||
}).build()
|
||||
)
|
||||
}.build()
|
||||
|
||||
@@ -4,31 +4,46 @@ import android.content.ActivityNotFoundException
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Color
|
||||
import android.graphics.Typeface
|
||||
import android.net.Uri
|
||||
import android.text.SpannableStringBuilder
|
||||
import android.text.Spanned
|
||||
import android.text.TextPaint
|
||||
import android.util.Log
|
||||
import androidx.browser.customtabs.CustomTabColorSchemeParams
|
||||
import androidx.browser.customtabs.CustomTabsIntent
|
||||
import androidx.car.app.CarContext
|
||||
import androidx.car.app.CarToast
|
||||
import androidx.car.app.HostException
|
||||
import androidx.car.app.Screen
|
||||
import androidx.car.app.annotations.ExperimentalCarApi
|
||||
import androidx.car.app.constraints.ConstraintManager
|
||||
import androidx.car.app.hardware.common.CarUnit
|
||||
import androidx.car.app.model.CarColor
|
||||
import androidx.car.app.model.CarIcon
|
||||
import androidx.car.app.model.CarIconSpan
|
||||
import androidx.car.app.model.Distance
|
||||
import androidx.car.app.model.ForegroundCarColorSpan
|
||||
import androidx.car.app.model.MessageTemplate
|
||||
import androidx.car.app.model.Template
|
||||
import androidx.car.app.versioning.CarAppApiLevels
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.graphics.drawable.IconCompat
|
||||
import com.github.erfansn.localeconfigx.currentOrDefaultLocale
|
||||
import net.vonforst.evmap.BuildConfig
|
||||
import net.vonforst.evmap.R
|
||||
import net.vonforst.evmap.api.availability.ChargeLocationStatus
|
||||
import net.vonforst.evmap.api.availability.ChargepointStatus
|
||||
import net.vonforst.evmap.api.iconForPlugType
|
||||
import net.vonforst.evmap.api.nameForPlugType
|
||||
import net.vonforst.evmap.api.stringProvider
|
||||
import net.vonforst.evmap.ftPerMile
|
||||
import net.vonforst.evmap.getPackageInfoCompat
|
||||
import net.vonforst.evmap.kmPerMile
|
||||
import net.vonforst.evmap.model.ChargeLocation
|
||||
import net.vonforst.evmap.shouldUseImperialUnits
|
||||
import net.vonforst.evmap.ui.availabilityText
|
||||
import net.vonforst.evmap.ydPerMile
|
||||
import java.util.Locale
|
||||
import kotlin.math.roundToInt
|
||||
@@ -201,7 +216,7 @@ fun <T> List<T>.paginate(nSingle: Int, nFirst: Int, nOther: Int, nLast: Int): Li
|
||||
|
||||
fun getAndroidAutoVersion(ctx: Context): List<String> {
|
||||
val info = ctx.packageManager.getPackageInfoCompat("com.google.android.projection.gearhead", 0)
|
||||
return info.versionName.split(".")
|
||||
return info.versionName!!.split(".")
|
||||
}
|
||||
|
||||
fun supportsCarApiLevel3(ctx: CarContext): Boolean {
|
||||
@@ -221,13 +236,17 @@ fun supportsCarApiLevel3(ctx: CarContext): Boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
fun openUrl(carContext: CarContext, url: String) {
|
||||
fun supportsNewMapScreen(ctx: CarContext) =
|
||||
ctx.carAppApiLevel >= 7 && ctx.isAppDrivenRefreshSupported
|
||||
|
||||
@ExperimentalCarApi
|
||||
fun openUrl(carContext: CarContext, cas: CarAppService, url: String) {
|
||||
val intent = CustomTabsIntent.Builder()
|
||||
.setDefaultColorSchemeParams(
|
||||
CustomTabColorSchemeParams.Builder()
|
||||
.setToolbarColor(
|
||||
ContextCompat.getColor(
|
||||
carContext,
|
||||
cas,
|
||||
R.color.colorPrimary
|
||||
)
|
||||
)
|
||||
@@ -237,7 +256,7 @@ fun openUrl(carContext: CarContext, url: String) {
|
||||
intent.data = Uri.parse(url)
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
try {
|
||||
carContext.startActivity(intent)
|
||||
cas.startActivity(intent)
|
||||
if (BuildConfig.FLAVOR_automotive != "automotive") {
|
||||
// only show the toast "opened on phone" if we're running on a phone
|
||||
CarToast.makeText(
|
||||
@@ -255,6 +274,64 @@ fun openUrl(carContext: CarContext, url: String) {
|
||||
}
|
||||
}
|
||||
|
||||
@ExperimentalCarApi
|
||||
fun navigateToCharger(ctx: CarContext, cas: CarAppService, charger: ChargeLocation) {
|
||||
var success = navigateCarApp(ctx, charger)
|
||||
if (!success && BuildConfig.FLAVOR_automotive == "automotive") {
|
||||
// on AAOS, some OEMs' navigation apps might not support
|
||||
success = navigateRegularApp(ctx, cas, charger)
|
||||
}
|
||||
if (!success) {
|
||||
CarToast.makeText(ctx, R.string.no_maps_app_found, CarToast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
private fun navigateCarApp(ctx: CarContext, charger: ChargeLocation): Boolean {
|
||||
val coord = charger.coordinates
|
||||
val intent =
|
||||
Intent(
|
||||
CarContext.ACTION_NAVIGATE,
|
||||
Uri.parse("geo:${coord.lat},${coord.lng}")
|
||||
)
|
||||
try {
|
||||
ctx.startCarApp(intent)
|
||||
return true
|
||||
} catch (e: HostException) {
|
||||
Log.w("navigateToCharger", "Could not start navigation using car app intent")
|
||||
Log.w("navigateToCharger", intent.toString())
|
||||
e.printStackTrace()
|
||||
} catch (e: SecurityException) {
|
||||
Log.w("navigateToCharger", "Could not start navigation using car app intent")
|
||||
Log.w("navigateToCharger", intent.toString())
|
||||
e.printStackTrace()
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@ExperimentalCarApi
|
||||
private fun navigateRegularApp(
|
||||
ctx: CarContext,
|
||||
cas: CarAppService,
|
||||
charger: ChargeLocation
|
||||
): Boolean {
|
||||
val coord = charger.coordinates
|
||||
val intent = Intent(Intent.ACTION_VIEW)
|
||||
intent.data = Uri.parse(
|
||||
"geo:${coord.lat},${coord.lng}?q=${coord.lat},${coord.lng}(${
|
||||
Uri.encode(charger.name)
|
||||
})"
|
||||
)
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
if (intent.resolveActivity(ctx.packageManager) != null) {
|
||||
cas.startActivity(intent)
|
||||
return true
|
||||
} else {
|
||||
Log.w("navigateToCharger", "Could not start navigation using regular intent")
|
||||
Log.w("navigateToCharger", intent.toString())
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
class DummyReturnScreen(ctx: CarContext) : Screen(ctx) {
|
||||
/*
|
||||
Dummy screen to get around template refresh limitations.
|
||||
@@ -279,4 +356,49 @@ class TextMeasurer(ctx: CarContext) {
|
||||
fun measureText(text: CharSequence): Float {
|
||||
return textPaint.measureText(text, 0, text.length)
|
||||
}
|
||||
}
|
||||
|
||||
fun generateChargepointsText(
|
||||
charger: ChargeLocation,
|
||||
availability: ChargeLocationStatus?,
|
||||
ctx: Context
|
||||
): SpannableStringBuilder {
|
||||
val chargepointsText = SpannableStringBuilder()
|
||||
charger.chargepointsMerged.forEachIndexed { i, cp ->
|
||||
chargepointsText.apply {
|
||||
if (i > 0) append(" · ")
|
||||
append("${cp.count}× ")
|
||||
val plugIcon = iconForPlugType(cp.type)
|
||||
if (plugIcon != 0) {
|
||||
append(
|
||||
nameForPlugType(ctx.stringProvider(), cp.type),
|
||||
CarIconSpan.create(
|
||||
CarIcon.Builder(
|
||||
IconCompat.createWithResource(
|
||||
ctx,
|
||||
plugIcon
|
||||
)
|
||||
).setTint(
|
||||
CarColor.createCustom(Color.WHITE, Color.BLACK)
|
||||
).build()
|
||||
),
|
||||
Spanned.SPAN_INCLUSIVE_EXCLUSIVE
|
||||
)
|
||||
} else {
|
||||
append(nameForPlugType(ctx.stringProvider(), cp.type))
|
||||
}
|
||||
cp.formatPower(ctx.currentOrDefaultLocale)?.let {
|
||||
append(" ")
|
||||
append(it)
|
||||
}
|
||||
}
|
||||
availability?.status?.get(cp)?.let { status ->
|
||||
chargepointsText.append(
|
||||
" (${availabilityText(status)}/${cp.count})",
|
||||
ForegroundCarColorSpan.create(carAvailabilityColor(status)),
|
||||
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
|
||||
)
|
||||
}
|
||||
}
|
||||
return chargepointsText
|
||||
}
|
||||
@@ -100,9 +100,9 @@ class ChargepriceFragment : Fragment() {
|
||||
inflater,
|
||||
R.layout.fragment_chargeprice_header, container, false
|
||||
)
|
||||
binding.lifecycleOwner = this
|
||||
binding.lifecycleOwner = viewLifecycleOwner
|
||||
binding.vm = vm
|
||||
headerBinding.lifecycleOwner = this
|
||||
headerBinding.lifecycleOwner = viewLifecycleOwner
|
||||
headerBinding.vm = vm
|
||||
|
||||
binding.toolbar.inflateMenu(R.menu.chargeprice)
|
||||
@@ -141,7 +141,7 @@ class ChargepriceFragment : Fragment() {
|
||||
|
||||
val chargepriceAdapter = ChargepriceAdapter().apply {
|
||||
onClickListener = {
|
||||
(requireActivity() as MapsActivity).openUrl(it.url)
|
||||
(requireActivity() as MapsActivity).openUrl(it.url, binding.root)
|
||||
}
|
||||
}
|
||||
val joinedAdapter = ConcatAdapter(
|
||||
@@ -194,7 +194,10 @@ class ChargepriceFragment : Fragment() {
|
||||
}
|
||||
|
||||
binding.imgChargepriceLogo.setOnClickListener {
|
||||
(requireActivity() as MapsActivity).openUrl(ChargepriceApi.getPoiUrl(charger))
|
||||
(requireActivity() as MapsActivity).openUrl(
|
||||
ChargepriceApi.getPoiUrl(charger),
|
||||
binding.root
|
||||
)
|
||||
}
|
||||
|
||||
binding.btnSettings.setOnClickListener {
|
||||
@@ -213,11 +216,19 @@ class ChargepriceFragment : Fragment() {
|
||||
}
|
||||
false
|
||||
}
|
||||
headerBinding.tvChargeFromTo.setOnClickListener {
|
||||
it.postDelayed({
|
||||
vm.resetBatteryRangeToDefault()
|
||||
}, 250)
|
||||
}
|
||||
|
||||
binding.toolbar.setOnMenuItemClickListener {
|
||||
when (it.itemId) {
|
||||
R.id.menu_help -> {
|
||||
(activity as? MapsActivity)?.openUrl(getString(R.string.chargeprice_faq_link))
|
||||
(activity as? MapsActivity)?.openUrl(
|
||||
getString(R.string.chargeprice_faq_link),
|
||||
binding.root
|
||||
)
|
||||
true
|
||||
}
|
||||
else -> false
|
||||
|
||||
@@ -14,14 +14,14 @@ import net.vonforst.evmap.api.availability.ChargeLocationStatus
|
||||
import net.vonforst.evmap.databinding.DialogConnectorDetailsBinding
|
||||
import net.vonforst.evmap.databinding.DialogConnectorDetailsHeaderBinding
|
||||
import net.vonforst.evmap.model.Chargepoint
|
||||
import net.vonforst.evmap.storage.PreferenceDataSource
|
||||
|
||||
class ConnectorDetailsDialog(
|
||||
val binding: DialogConnectorDetailsBinding,
|
||||
binding: DialogConnectorDetailsBinding,
|
||||
context: Context,
|
||||
onClose: () -> Unit
|
||||
) {
|
||||
private val headerBinding: DialogConnectorDetailsHeaderBinding
|
||||
private var headerBinding_: DialogConnectorDetailsHeaderBinding? = null
|
||||
private val headerBinding get() = headerBinding_!!
|
||||
private val detailsAdapter = ConnectorDetailsAdapter()
|
||||
|
||||
init {
|
||||
@@ -30,7 +30,7 @@ class ConnectorDetailsDialog(
|
||||
layoutManager =
|
||||
LinearLayoutManager(context, LinearLayoutManager.VERTICAL, false)
|
||||
}
|
||||
headerBinding = DataBindingUtil.inflate(
|
||||
headerBinding_ = DataBindingUtil.inflate(
|
||||
LayoutInflater.from(context),
|
||||
R.layout.dialog_connector_details_header, binding.list, false
|
||||
)
|
||||
@@ -60,4 +60,8 @@ class ConnectorDetailsDialog(
|
||||
headerBinding.divider.visibility = if (items.isEmpty()) View.GONE else View.VISIBLE
|
||||
headerBinding.item = ConnectorAdapter.ChargepointWithAvailability(cp, cpStatus)
|
||||
}
|
||||
|
||||
fun onDestroy() {
|
||||
headerBinding_ = null
|
||||
}
|
||||
}
|
||||
@@ -54,6 +54,7 @@ class DataSourceSelectDialog : MaterialDialogFragment() {
|
||||
when (prefs.dataSource) {
|
||||
"goingelectric" -> binding.rgDataSource.rbGoingElectric.isChecked = true
|
||||
"openchargemap" -> binding.rgDataSource.rbOpenChargeMap.isChecked = true
|
||||
"openstreetmap" -> binding.rgDataSource.rbOpenStreetMap.isChecked = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,6 +66,8 @@ class DataSourceSelectDialog : MaterialDialogFragment() {
|
||||
"goingelectric"
|
||||
} else if (binding.rgDataSource.rbOpenChargeMap.isChecked) {
|
||||
"openchargemap"
|
||||
} else if (binding.rgDataSource.rbOpenStreetMap.isChecked) {
|
||||
"openstreetmap"
|
||||
} else {
|
||||
return@setOnClickListener
|
||||
}
|
||||
|
||||
@@ -1,29 +1,26 @@
|
||||
package net.vonforst.evmap.fragment
|
||||
|
||||
import android.content.Intent
|
||||
import android.webkit.WebResourceRequest
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
import androidx.fragment.app.Fragment
|
||||
import net.vonforst.evmap.MapsActivity
|
||||
import net.vonforst.evmap.R
|
||||
import net.vonforst.evmap.databinding.FragmentDonateReferralBinding
|
||||
|
||||
abstract class DonateFragmentBase : Fragment() {
|
||||
fun setupReferrals(referrals: FragmentDonateReferralBinding) {
|
||||
referrals.referralTesla.setOnClickListener {
|
||||
(requireActivity() as MapsActivity).openUrl(getString(R.string.tesla_referral_link))
|
||||
}
|
||||
referrals.referralJuicify.setOnClickListener {
|
||||
(requireActivity() as MapsActivity).openUrl(getString(R.string.juicify_referral_link))
|
||||
}
|
||||
referrals.referralGeldfuereauto.setOnClickListener {
|
||||
(requireActivity() as MapsActivity).openUrl(getString(R.string.geldfuereauto_referral_link))
|
||||
}
|
||||
referrals.referralMaingau.setOnClickListener {
|
||||
(requireActivity() as MapsActivity).openUrl(getString(R.string.maingau_referral_link))
|
||||
}
|
||||
referrals.referralEwieeinfach.setOnClickListener {
|
||||
(requireActivity() as MapsActivity).openUrl(getString(R.string.ewieeinfach_referral_link))
|
||||
}
|
||||
referrals.referralEprimo.setOnClickListener {
|
||||
(requireActivity() as MapsActivity).openUrl(getString(R.string.eprimo_referral_link))
|
||||
referrals.referralWebView.loadUrl(getString(R.string.referral_link))
|
||||
referrals.referralWebView.webViewClient = object : WebViewClient() {
|
||||
override fun shouldOverrideUrlLoading(
|
||||
view: WebView,
|
||||
request: WebResourceRequest
|
||||
): Boolean {
|
||||
Intent(Intent.ACTION_VIEW, request.url).apply {
|
||||
startActivity(this)
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -65,7 +65,7 @@ class FavoritesFragment : Fragment() {
|
||||
inflater,
|
||||
R.layout.fragment_favorites, container, false
|
||||
)
|
||||
binding.lifecycleOwner = this
|
||||
binding.lifecycleOwner = viewLifecycleOwner
|
||||
binding.vm = vm
|
||||
|
||||
return binding.root
|
||||
|
||||
@@ -45,7 +45,7 @@ class FilterFragment : Fragment(), MenuProvider {
|
||||
savedInstanceState: Bundle?
|
||||
): View {
|
||||
binding = DataBindingUtil.inflate(inflater, R.layout.fragment_filter, container, false)
|
||||
binding.lifecycleOwner = this
|
||||
binding.lifecycleOwner = viewLifecycleOwner
|
||||
binding.vm = vm
|
||||
vm.filterProfile.observe(viewLifecycleOwner) {}
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ class FilterProfilesFragment : Fragment() {
|
||||
savedInstanceState: Bundle?
|
||||
): View {
|
||||
binding = FragmentFilterProfilesBinding.inflate(inflater, container, false)
|
||||
binding.lifecycleOwner = this
|
||||
binding.lifecycleOwner = viewLifecycleOwner
|
||||
binding.vm = vm
|
||||
|
||||
return binding.root
|
||||
@@ -188,9 +188,17 @@ class FilterProfilesFragment : Fragment() {
|
||||
|
||||
dialog.setTitle(R.string.rename)
|
||||
.setMessage(R.string.save_profile_enter_name)
|
||||
}, {
|
||||
}, { newName ->
|
||||
lifecycleScope.launch {
|
||||
vm.update(fp.copy(name = it))
|
||||
if (vm.filterProfiles.value?.find { it.name == newName } != null) {
|
||||
Snackbar.make(
|
||||
view,
|
||||
R.string.filterprofile_name_not_unique,
|
||||
Snackbar.LENGTH_LONG
|
||||
).show()
|
||||
} else {
|
||||
vm.update(fp.copy(name = newName))
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -55,12 +55,10 @@ import androidx.transition.TransitionManager
|
||||
import coil.load
|
||||
import coil.memory.MemoryCache
|
||||
import com.car2go.maps.AnyMap
|
||||
import com.car2go.maps.MapFactory
|
||||
import com.car2go.maps.MapFragment
|
||||
import com.car2go.maps.OnMapReadyCallback
|
||||
import com.car2go.maps.model.BitmapDescriptor
|
||||
import com.car2go.maps.model.LatLng
|
||||
import com.car2go.maps.model.Marker
|
||||
import com.car2go.maps.model.MarkerOptions
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import com.google.android.material.transition.MaterialArcMotion
|
||||
@@ -79,10 +77,7 @@ import com.mahc.custombottomsheetbehavior.MergedAppBarLayoutBehavior
|
||||
import com.stfalcon.imageviewer.StfalconImageViewer
|
||||
import io.michaelrocks.bimap.HashBiMap
|
||||
import io.michaelrocks.bimap.MutableBiMap
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import net.vonforst.evmap.BuildConfig
|
||||
import net.vonforst.evmap.MapsActivity
|
||||
import net.vonforst.evmap.R
|
||||
import net.vonforst.evmap.adapter.ConnectorAdapter
|
||||
@@ -98,7 +93,6 @@ import net.vonforst.evmap.location.FusionEngine
|
||||
import net.vonforst.evmap.location.LocationEngine
|
||||
import net.vonforst.evmap.location.Priority
|
||||
import net.vonforst.evmap.model.ChargeLocation
|
||||
import net.vonforst.evmap.model.ChargeLocationCluster
|
||||
import net.vonforst.evmap.model.ChargepointListItem
|
||||
import net.vonforst.evmap.model.ChargerPhoto
|
||||
import net.vonforst.evmap.model.FILTERS_CUSTOM
|
||||
@@ -107,14 +101,8 @@ import net.vonforst.evmap.model.FILTERS_FAVORITES
|
||||
import net.vonforst.evmap.navigation.safeNavigate
|
||||
import net.vonforst.evmap.shouldUseImperialUnits
|
||||
import net.vonforst.evmap.storage.PreferenceDataSource
|
||||
import net.vonforst.evmap.ui.ChargerIconGenerator
|
||||
import net.vonforst.evmap.ui.ClusterIconGenerator
|
||||
import net.vonforst.evmap.ui.HideOnScrollFabBehavior
|
||||
import net.vonforst.evmap.ui.MarkerAnimator
|
||||
import net.vonforst.evmap.ui.chargerZ
|
||||
import net.vonforst.evmap.ui.clusterZ
|
||||
import net.vonforst.evmap.ui.getMarkerTint
|
||||
import net.vonforst.evmap.ui.placeSearchZ
|
||||
import net.vonforst.evmap.ui.MarkerManager
|
||||
import net.vonforst.evmap.ui.setTouchModal
|
||||
import net.vonforst.evmap.utils.boundingBox
|
||||
import net.vonforst.evmap.utils.checkAnyLocationPermission
|
||||
@@ -129,37 +117,27 @@ import net.vonforst.evmap.viewmodel.Status
|
||||
import java.io.IOException
|
||||
import java.time.Duration
|
||||
import java.time.Instant
|
||||
import kotlin.collections.component1
|
||||
import kotlin.collections.component2
|
||||
import kotlin.collections.contains
|
||||
import kotlin.collections.set
|
||||
import kotlin.math.min
|
||||
|
||||
|
||||
class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallback, MenuProvider {
|
||||
private lateinit var binding: FragmentMapBinding
|
||||
class MapFragment : Fragment(), OnMapReadyCallback, MenuProvider {
|
||||
private var _binding: FragmentMapBinding? = null
|
||||
private val binding get() = _binding!!
|
||||
private val vm: MapViewModel by viewModels()
|
||||
private val galleryVm: GalleryViewModel by activityViewModels()
|
||||
private var mapFragment: MapFragment? = null
|
||||
private var map: AnyMap? = null
|
||||
private var markerManager: MarkerManager? = null
|
||||
private lateinit var locationEngine: LocationEngine
|
||||
private var requestingLocationUpdates = false
|
||||
private lateinit var bottomSheetBehavior: BottomSheetBehaviorGoogleMapsLike<View>
|
||||
private lateinit var detailAppBarBehavior: MergedAppBarLayoutBehavior
|
||||
private lateinit var detailsDialog: ConnectorDetailsDialog
|
||||
private lateinit var prefs: PreferenceDataSource
|
||||
private var markers: MutableBiMap<Marker, ChargeLocation> = HashBiMap()
|
||||
private var clusterMarkers: List<Marker> = emptyList()
|
||||
private var searchResultMarker: Marker? = null
|
||||
private var searchResultIcon: BitmapDescriptor? = null
|
||||
private var connectionErrorSnackbar: Snackbar? = null
|
||||
private var previousChargepointIds: Set<Long>? = null
|
||||
private var mapTopPadding: Int = 0
|
||||
private var popupMenu: PopupMenu? = null
|
||||
|
||||
private lateinit var clusterIconGenerator: ClusterIconGenerator
|
||||
private lateinit var chargerIconGenerator: ChargerIconGenerator
|
||||
private lateinit var animator: MarkerAnimator
|
||||
private lateinit var favToggle: MenuItem
|
||||
private val backPressedCallback = object : OnBackPressedCallback(false) {
|
||||
override fun handleOnBackPressed() {
|
||||
@@ -198,7 +176,6 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
||||
prefs = PreferenceDataSource(requireContext())
|
||||
|
||||
locationEngine = FusionEngine(requireContext())
|
||||
clusterIconGenerator = ClusterIconGenerator(requireContext())
|
||||
|
||||
enterTransition = MaterialFadeThrough()
|
||||
exitTransition = MaterialFadeThrough()
|
||||
@@ -211,9 +188,9 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View {
|
||||
binding = DataBindingUtil.inflate(inflater, R.layout.fragment_map, container, false)
|
||||
_binding = DataBindingUtil.inflate(inflater, R.layout.fragment_map, container, false)
|
||||
println(binding.detailView.sourceButton)
|
||||
binding.lifecycleOwner = this
|
||||
binding.lifecycleOwner = viewLifecycleOwner
|
||||
binding.vm = vm
|
||||
|
||||
val provider = prefs.mapProvider
|
||||
@@ -221,28 +198,20 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
||||
mapFragment =
|
||||
childFragmentManager.findFragmentByTag(mapFragmentTag) as MapFragment?
|
||||
}
|
||||
if (mapFragment == null || mapFragment!!.priority[0] != provider) {
|
||||
if (mapFragment == null || mapFragment!!.priority[0] != getMapProvider(provider)) {
|
||||
mapFragment = MapFragment()
|
||||
mapFragment!!.priority = arrayOf(
|
||||
when (provider) {
|
||||
"mapbox" -> MapFragment.MAPLIBRE
|
||||
"google" -> MapFragment.GOOGLE
|
||||
else -> null
|
||||
},
|
||||
MapFragment.GOOGLE,
|
||||
MapFragment.MAPLIBRE
|
||||
getMapProvider(provider),
|
||||
MapFactory.GOOGLE,
|
||||
MapFactory.MAPLIBRE
|
||||
)
|
||||
childFragmentManager
|
||||
.beginTransaction()
|
||||
.replace(R.id.map, mapFragment!!, mapFragmentTag)
|
||||
.commit()
|
||||
|
||||
// reset map-related stuff (map provider may have changed)
|
||||
map = null
|
||||
markers.clear()
|
||||
clusterMarkers = emptyList()
|
||||
searchResultMarker = null
|
||||
searchResultIcon = null
|
||||
markerManager = null
|
||||
}
|
||||
|
||||
binding.detailAppBar.toolbar.popupTheme =
|
||||
@@ -293,6 +262,12 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
||||
return binding.root
|
||||
}
|
||||
|
||||
private fun getMapProvider(provider: String) = when (provider) {
|
||||
"mapbox" -> MapFactory.MAPLIBRE
|
||||
"google" -> MapFactory.GOOGLE
|
||||
else -> null
|
||||
}
|
||||
|
||||
val bottomSheetCollapsible
|
||||
get() = resources.getBoolean(R.bool.bottom_sheet_collapsible)
|
||||
|
||||
@@ -362,9 +337,11 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
||||
|
||||
binding.appLogo.root.animate().alpha(1f)
|
||||
.withEndAction {
|
||||
if (_binding == null) return@withEndAction
|
||||
binding.appLogo.root.animate().alpha(0f).apply {
|
||||
startDelay = 1000
|
||||
}.withEndAction {
|
||||
if (_binding == null) return@withEndAction
|
||||
binding.appLogo.root.visibility = View.GONE
|
||||
binding.search.visibility = View.VISIBLE
|
||||
binding.search.alpha = 0f
|
||||
@@ -388,9 +365,6 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
val hostActivity = activity as? MapsActivity ?: return
|
||||
hostActivity.fragmentCallback = this
|
||||
|
||||
vm.reloadPrefs()
|
||||
if (requestingLocationUpdates && requireContext().checkAnyLocationPermission()
|
||||
) {
|
||||
@@ -422,7 +396,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
||||
val charger = vm.charger.value?.data
|
||||
if (charger != null) {
|
||||
if (lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) {
|
||||
(requireActivity() as MapsActivity).navigateTo(charger)
|
||||
(requireActivity() as MapsActivity).navigateTo(charger, binding.root)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -435,7 +409,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
||||
binding.detailView.sourceButton.setOnClickListener {
|
||||
val charger = vm.charger.value?.data
|
||||
if (charger != null) {
|
||||
(activity as? MapsActivity)?.openUrl(charger.url)
|
||||
(activity as? MapsActivity)?.openUrl(charger.url, binding.root, true)
|
||||
}
|
||||
}
|
||||
binding.detailView.btnChargeprice.setOnClickListener {
|
||||
@@ -448,12 +422,15 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
||||
extras
|
||||
)
|
||||
} else {
|
||||
(activity as? MapsActivity)?.openUrl(ChargepriceApi.getPoiUrl(charger), false)
|
||||
(activity as? MapsActivity)?.openUrl(
|
||||
ChargepriceApi.getPoiUrl(charger),
|
||||
binding.root
|
||||
)
|
||||
}
|
||||
}
|
||||
binding.detailView.btnChargerWebsite.setOnClickListener {
|
||||
val charger = vm.charger.value?.data ?: return@setOnClickListener
|
||||
charger.chargerUrl?.let { (activity as? MapsActivity)?.openUrl(it) }
|
||||
charger.chargerUrl?.let { (activity as? MapsActivity)?.openUrl(it, binding.root) }
|
||||
}
|
||||
binding.detailView.btnLogin.setOnClickListener {
|
||||
findNavController().safeNavigate(
|
||||
@@ -461,7 +438,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
||||
)
|
||||
}
|
||||
binding.detailView.imgPredictionSource.setOnClickListener {
|
||||
(activity as? MapsActivity)?.openUrl(getString(R.string.fronyx_url))
|
||||
(activity as? MapsActivity)?.openUrl(getString(R.string.fronyx_url), binding.root)
|
||||
}
|
||||
binding.detailView.btnPredictionHelp.setOnClickListener {
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
@@ -501,7 +478,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
||||
R.id.menu_edit -> {
|
||||
val charger = vm.charger.value?.data
|
||||
if (charger?.editUrl != null) {
|
||||
(activity as? MapsActivity)?.openUrl(charger.editUrl)
|
||||
(activity as? MapsActivity)?.openUrl(charger.editUrl, binding.root, true)
|
||||
if (vm.apiId.value == "goingelectric") {
|
||||
// instructions specific to GoingElectric
|
||||
Toast.makeText(
|
||||
@@ -632,16 +609,6 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
||||
} else {
|
||||
vm.insertFavorite(charger)
|
||||
}
|
||||
markers.inverse[charger]?.setIcon(
|
||||
chargerIconGenerator.getBitmapDescriptor(
|
||||
getMarkerTint(charger, vm.filteredConnectors.value),
|
||||
highlight = true,
|
||||
fault = charger.faultReport != null,
|
||||
multi = charger.isMulti(vm.filteredConnectors.value),
|
||||
fav = fav == null,
|
||||
mini = vm.useMiniMarkers.value == true
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun setupObservers() {
|
||||
@@ -693,16 +660,17 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
||||
binding.fabDirections.show()
|
||||
detailAppBarBehavior.setToolbarTitle(it.name)
|
||||
updateFavoriteToggle()
|
||||
highlightMarker(it)
|
||||
markerManager?.highlighedCharger = it
|
||||
markerManager?.animateBounce(it)
|
||||
} else {
|
||||
bottomSheetBehavior.state = STATE_HIDDEN
|
||||
unhighlightAllMarkers()
|
||||
markerManager?.highlighedCharger = null
|
||||
}
|
||||
}
|
||||
vm.chargepoints.observe(viewLifecycleOwner, Observer { res ->
|
||||
val chargepoints = res.data
|
||||
if (chargepoints != null) {
|
||||
updateMap(chargepoints)
|
||||
markerManager?.chargepoints = chargepoints
|
||||
}
|
||||
when (res.status) {
|
||||
Status.ERROR -> {
|
||||
@@ -725,10 +693,14 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
||||
}
|
||||
})
|
||||
vm.useMiniMarkers.observe(viewLifecycleOwner) {
|
||||
vm.chargepoints.value?.data?.let { updateMap(it) }
|
||||
markerManager?.mini = it
|
||||
}
|
||||
vm.filteredConnectors.observe(viewLifecycleOwner) {
|
||||
markerManager?.filteredConnectors = it
|
||||
}
|
||||
vm.favorites.observe(viewLifecycleOwner) {
|
||||
updateFavoriteToggle()
|
||||
markerManager?.favorites = it.map { it.favorite.chargerId }.toSet()
|
||||
}
|
||||
vm.searchResult.observe(viewLifecycleOwner) { place ->
|
||||
displaySearchResult(place, moveCamera = true)
|
||||
@@ -759,8 +731,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
||||
|
||||
private fun displaySearchResult(place: PlaceWithBounds?, moveCamera: Boolean) {
|
||||
val map = this.map ?: return
|
||||
searchResultMarker?.remove()
|
||||
searchResultMarker = null
|
||||
markerManager?.searchResult = place
|
||||
|
||||
if (place != null) {
|
||||
// disable location following when search result is shown
|
||||
@@ -772,18 +743,6 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
||||
map.animateCamera(map.cameraUpdateFactory.newLatLngZoom(place.latLng, 12f))
|
||||
}
|
||||
}
|
||||
|
||||
if (searchResultIcon == null) {
|
||||
searchResultIcon =
|
||||
map.bitmapDescriptorFactory.fromResource(R.drawable.ic_map_marker)
|
||||
}
|
||||
searchResultMarker = map.addMarker(
|
||||
MarkerOptions()
|
||||
.z(placeSearchZ)
|
||||
.position(place.latLng)
|
||||
.icon(searchResultIcon)
|
||||
.anchor(0.5f, 1f)
|
||||
)
|
||||
} else {
|
||||
binding.search.setText("")
|
||||
}
|
||||
@@ -800,53 +759,6 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
||||
|| vm.selectedChargepoint.value != null
|
||||
}
|
||||
|
||||
private fun unhighlightAllMarkers() {
|
||||
markers.forEach { (m, c) ->
|
||||
m.setIcon(
|
||||
chargerIconGenerator.getBitmapDescriptor(
|
||||
getMarkerTint(c, vm.filteredConnectors.value),
|
||||
highlight = false,
|
||||
fault = c.faultReport != null,
|
||||
multi = c.isMulti(vm.filteredConnectors.value),
|
||||
fav = c.id in (vm.favorites.value?.map { it.charger.id } ?: emptyList()),
|
||||
mini = vm.useMiniMarkers.value == true
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun highlightMarker(charger: ChargeLocation) {
|
||||
val marker = markers.inverse[charger] ?: return
|
||||
// highlight this marker
|
||||
marker.setIcon(
|
||||
chargerIconGenerator.getBitmapDescriptor(
|
||||
getMarkerTint(charger, vm.filteredConnectors.value),
|
||||
highlight = true,
|
||||
fault = charger.faultReport != null,
|
||||
multi = charger.isMulti(vm.filteredConnectors.value),
|
||||
fav = charger.id in (vm.favorites.value?.map { it.charger.id } ?: emptyList()),
|
||||
mini = vm.useMiniMarkers.value == true
|
||||
)
|
||||
)
|
||||
animator.animateMarkerBounce(marker, vm.useMiniMarkers.value == true)
|
||||
|
||||
// un-highlight all other markers
|
||||
markers.forEach { (m, c) ->
|
||||
if (m != marker) {
|
||||
m.setIcon(
|
||||
chargerIconGenerator.getBitmapDescriptor(
|
||||
getMarkerTint(c, vm.filteredConnectors.value),
|
||||
highlight = false,
|
||||
fault = c.faultReport != null,
|
||||
multi = c.isMulti(vm.filteredConnectors.value),
|
||||
fav = c.id in (vm.favorites.value?.map { it.charger.id } ?: emptyList()),
|
||||
mini = vm.useMiniMarkers.value == true
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateFavoriteToggle() {
|
||||
val favs = vm.favorites.value ?: return
|
||||
val charger = vm.chargerSparse.value ?: return
|
||||
@@ -917,10 +829,14 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
||||
if (charger != null) {
|
||||
when (it.icon) {
|
||||
R.drawable.ic_location, R.drawable.ic_address -> {
|
||||
(activity as? MapsActivity)?.showLocation(charger)
|
||||
(activity as? MapsActivity)?.showLocation(charger, binding.root)
|
||||
}
|
||||
R.drawable.ic_fault_report -> {
|
||||
(activity as? MapsActivity)?.openUrl(charger.url)
|
||||
(activity as? MapsActivity)?.openUrl(
|
||||
charger.url,
|
||||
binding.root,
|
||||
true
|
||||
)
|
||||
}
|
||||
|
||||
R.drawable.ic_payment -> {
|
||||
@@ -928,7 +844,12 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
||||
}
|
||||
|
||||
R.drawable.ic_network -> {
|
||||
charger.networkUrl?.let { (activity as? MapsActivity)?.openUrl(it) }
|
||||
charger.networkUrl?.let {
|
||||
(activity as? MapsActivity)?.openUrl(
|
||||
it,
|
||||
binding.root
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1047,50 +968,46 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
||||
.setTitle(R.string.charge_cards)
|
||||
.setItems(names.toTypedArray()) { _, i ->
|
||||
val card = data[i]
|
||||
(activity as? MapsActivity)?.openUrl("https:${card.url}")
|
||||
(activity as? MapsActivity)?.openUrl("https:${card.url}", binding.root)
|
||||
}.show()
|
||||
}
|
||||
|
||||
override fun onMapReady(map: AnyMap) {
|
||||
this.map = map
|
||||
vm.mapProjection = map.projection
|
||||
val context = this.context ?: return
|
||||
view ?: return
|
||||
|
||||
chargerIconGenerator = ChargerIconGenerator(context, map.bitmapDescriptorFactory)
|
||||
|
||||
vm.mapTrafficSupported.value =
|
||||
mapFragment?.let { AnyMap.Feature.TRAFFIC_LAYER in it.supportedFeatures } ?: false
|
||||
|
||||
if (BuildConfig.FLAVOR.contains("google") && mapFragment!!.priority[0] == MapFragment.GOOGLE) {
|
||||
// Google Maps: icons can be generated in background thread
|
||||
lifecycleScope.launch {
|
||||
withContext(Dispatchers.Default) {
|
||||
chargerIconGenerator.preloadCache()
|
||||
}
|
||||
markerManager = MarkerManager(context, map, this).apply {
|
||||
onChargerClick = {
|
||||
vm.chargerSparse.value = it
|
||||
}
|
||||
} else {
|
||||
// MapLibre: needs to be run on main thread
|
||||
chargerIconGenerator.preloadCache()
|
||||
onClusterClick = {
|
||||
val newZoom = map.cameraPosition.zoom + 2
|
||||
map.animateCamera(
|
||||
map.cameraUpdateFactory.newLatLngZoom(
|
||||
LatLng(it.coordinates.lat, it.coordinates.lng),
|
||||
newZoom
|
||||
)
|
||||
)
|
||||
}
|
||||
chargepoints = vm.chargepoints.value?.data ?: emptyList()
|
||||
highlighedCharger = vm.chargerSparse.value
|
||||
searchResult = vm.searchResult.value
|
||||
favorites = vm.favorites.value?.map { it.favorite.chargerId }?.toSet() ?: emptySet()
|
||||
}
|
||||
|
||||
|
||||
|
||||
animator = MarkerAnimator(chargerIconGenerator)
|
||||
map.uiSettings.setTiltGesturesEnabled(false)
|
||||
map.uiSettings.setRotateGesturesEnabled(prefs.mapRotateGesturesEnabled)
|
||||
map.setIndoorEnabled(false)
|
||||
map.uiSettings.setIndoorLevelPickerEnabled(false)
|
||||
map.uiSettings.setMapToolbarEnabled(false)
|
||||
|
||||
map.setOnCameraIdleListener {
|
||||
vm.mapProjection = map.projection
|
||||
vm.mapPosition.value = MapPosition(
|
||||
map.projection.visibleRegion.latLngBounds, map.cameraPosition.zoom
|
||||
)
|
||||
vm.reloadChargepoints()
|
||||
}
|
||||
map.setOnCameraMoveListener {
|
||||
vm.mapProjection = map.projection
|
||||
vm.mapPosition.value = MapPosition(
|
||||
map.projection.visibleRegion.latLngBounds, map.cameraPosition.zoom
|
||||
)
|
||||
@@ -1113,7 +1030,8 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
||||
}
|
||||
}
|
||||
vm.mapPosition.observe(viewLifecycleOwner) {
|
||||
binding.scaleView.update(map.cameraPosition.zoom, map.cameraPosition.target.latitude)
|
||||
val target = map.cameraPosition.target ?: return@observe
|
||||
binding.scaleView.update(map.cameraPosition.zoom, target.latitude)
|
||||
}
|
||||
|
||||
map.setOnCameraMoveStartedListener { reason ->
|
||||
@@ -1129,26 +1047,6 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
||||
}
|
||||
}
|
||||
}
|
||||
map.setOnMarkerClickListener { marker ->
|
||||
when (marker) {
|
||||
in markers -> {
|
||||
vm.chargerSparse.value = markers[marker]
|
||||
true
|
||||
}
|
||||
in clusterMarkers -> {
|
||||
val newZoom = map.cameraPosition.zoom + 2
|
||||
map.animateCamera(
|
||||
map.cameraUpdateFactory.newLatLngZoom(
|
||||
marker.position,
|
||||
newZoom
|
||||
)
|
||||
)
|
||||
true
|
||||
}
|
||||
else -> false
|
||||
}
|
||||
|
||||
}
|
||||
map.setOnMapClickListener {
|
||||
if (backPressedCallback.isEnabled) {
|
||||
backPressedCallback.handleOnBackPressed()
|
||||
@@ -1229,9 +1127,13 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
||||
binding.search.requestFocus()
|
||||
binding.search.setSelection(locationName.length)
|
||||
}
|
||||
if (context.checkAnyLocationPermission() && prefs.currentMapMyLocationEnabled) {
|
||||
enableLocation(!positionSet, false)
|
||||
positionSet = true
|
||||
if (context.checkAnyLocationPermission()) {
|
||||
if (prefs.currentMapMyLocationEnabled && !positionSet) {
|
||||
enableLocation(true, false)
|
||||
positionSet = true
|
||||
} else {
|
||||
enableLocation(false, false)
|
||||
}
|
||||
}
|
||||
if (!positionSet) {
|
||||
// use position saved in preferences, fall back to default (Europe)
|
||||
@@ -1280,111 +1182,6 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun updateMap(chargepoints: List<ChargepointListItem>) {
|
||||
val map = this.map ?: return
|
||||
clusterMarkers.forEach { it.remove() }
|
||||
|
||||
val clusters = chargepoints.filterIsInstance<ChargeLocationCluster>()
|
||||
val chargers = chargepoints.filterIsInstance<ChargeLocation>()
|
||||
|
||||
val chargepointIds = chargers.map { it.id }.toSet()
|
||||
|
||||
// update icons of existing markers (connector filter may have changed)
|
||||
for ((marker, charger) in markers) {
|
||||
val highlight = charger.id == vm.chargerSparse.value?.id
|
||||
marker.setIcon(
|
||||
chargerIconGenerator.getBitmapDescriptor(
|
||||
getMarkerTint(charger, vm.filteredConnectors.value),
|
||||
highlight = highlight,
|
||||
fault = charger.faultReport != null,
|
||||
multi = charger.isMulti(vm.filteredConnectors.value),
|
||||
fav = charger.id in (vm.favorites.value?.map { it.charger.id } ?: emptyList()),
|
||||
mini = vm.useMiniMarkers.value == true
|
||||
)
|
||||
)
|
||||
marker.setAnchor(0.5f, if (vm.useMiniMarkers.value == true) 0.5f else 1f)
|
||||
}
|
||||
|
||||
if (chargers.toSet() != markers.values) {
|
||||
// remove markers that disappeared
|
||||
val bounds = map.projection.visibleRegion.latLngBounds
|
||||
markers.entries.toList().forEach {
|
||||
val marker = it.key
|
||||
val charger = it.value
|
||||
if (!chargepointIds.contains(charger.id)) {
|
||||
// animate marker if it is visible, otherwise remove immediately
|
||||
if (bounds.contains(marker.position)) {
|
||||
val tint = getMarkerTint(charger, vm.filteredConnectors.value)
|
||||
val highlight = charger.id == vm.chargerSparse.value?.id
|
||||
val fault = charger.faultReport != null
|
||||
val multi = charger.isMulti(vm.filteredConnectors.value)
|
||||
val fav =
|
||||
charger.id in (vm.favorites.value?.map { it.charger.id } ?: emptyList())
|
||||
animator.animateMarkerDisappear(
|
||||
marker, tint, highlight, fault, multi, fav,
|
||||
vm.useMiniMarkers.value == true
|
||||
)
|
||||
} else {
|
||||
animator.deleteMarker(marker)
|
||||
}
|
||||
markers.remove(marker)
|
||||
}
|
||||
}
|
||||
// add new markers
|
||||
val map1 = markers.values.map { it.id }
|
||||
for (charger in chargers) {
|
||||
if (!map1.contains(charger.id)) {
|
||||
val tint = getMarkerTint(charger, vm.filteredConnectors.value)
|
||||
val highlight = charger.id == vm.chargerSparse.value?.id
|
||||
val fault = charger.faultReport != null
|
||||
val multi = charger.isMulti(vm.filteredConnectors.value)
|
||||
val fav =
|
||||
charger.id in (vm.favorites.value?.map { it.charger.id } ?: emptyList())
|
||||
val marker = map.addMarker(
|
||||
MarkerOptions()
|
||||
.position(LatLng(charger.coordinates.lat, charger.coordinates.lng))
|
||||
.z(chargerZ)
|
||||
.icon(
|
||||
chargerIconGenerator.getBitmapDescriptor(
|
||||
tint,
|
||||
0f,
|
||||
255,
|
||||
highlight,
|
||||
fault,
|
||||
multi,
|
||||
fav,
|
||||
vm.useMiniMarkers.value == true
|
||||
)
|
||||
)
|
||||
.anchor(0.5f, if (vm.useMiniMarkers.value == true) 0.5f else 1f)
|
||||
)
|
||||
animator.animateMarkerAppear(
|
||||
marker, tint, highlight, fault, multi, fav,
|
||||
vm.useMiniMarkers.value == true
|
||||
)
|
||||
markers[marker] = charger
|
||||
}
|
||||
}
|
||||
previousChargepointIds = chargepointIds
|
||||
}
|
||||
clusterMarkers = clusters.map { cluster ->
|
||||
map.addMarker(
|
||||
MarkerOptions()
|
||||
.position(LatLng(cluster.coordinates.lat, cluster.coordinates.lng))
|
||||
.z(clusterZ)
|
||||
.icon(
|
||||
map.bitmapDescriptorFactory.fromBitmap(
|
||||
clusterIconGenerator.makeIcon(
|
||||
cluster.clusterCount.toString()
|
||||
)
|
||||
)
|
||||
)
|
||||
.anchor(0.5f, 0.5f)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateMenu(menu: Menu, inflater: MenuInflater) {
|
||||
inflater.inflate(R.menu.map, menu)
|
||||
|
||||
@@ -1536,10 +1333,6 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
||||
else -> false
|
||||
}
|
||||
|
||||
override fun getRootView(): View {
|
||||
return binding.root
|
||||
}
|
||||
|
||||
@RequiresPermission(anyOf = [ACCESS_FINE_LOCATION, ACCESS_COARSE_LOCATION])
|
||||
private fun requestLocationUpdates() {
|
||||
locationEngine.requestLocationUpdates(
|
||||
@@ -1589,8 +1382,14 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
detailsDialog.onDestroy()
|
||||
|
||||
map = null
|
||||
mapFragment = null
|
||||
_binding = null
|
||||
markerManager = null
|
||||
/* if we don't dismiss the popup menu, it will be recreated in some cases
|
||||
(split-screen mode) and then have references to a destroyed fragment. */
|
||||
popupMenu?.dismiss()
|
||||
|
||||
@@ -6,8 +6,6 @@ import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.graphics.drawable.AnimatedVectorDrawable
|
||||
import android.os.Bundle
|
||||
import android.text.Html
|
||||
import android.text.method.LinkMovementMethod
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
@@ -15,15 +13,21 @@ import android.view.animation.DecelerateInterpolator
|
||||
import android.widget.ImageView
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.text.HtmlCompat
|
||||
import androidx.core.text.method.LinkMovementMethodCompat
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.viewpager2.adapter.FragmentStateAdapter
|
||||
import androidx.viewpager2.widget.ViewPager2
|
||||
import net.vonforst.evmap.R
|
||||
import net.vonforst.evmap.databinding.*
|
||||
import net.vonforst.evmap.databinding.FragmentOnboardingAndroidAutoBinding
|
||||
import net.vonforst.evmap.databinding.FragmentOnboardingBinding
|
||||
import net.vonforst.evmap.databinding.FragmentOnboardingDataSourceBinding
|
||||
import net.vonforst.evmap.databinding.FragmentOnboardingIconsBinding
|
||||
import net.vonforst.evmap.databinding.FragmentOnboardingWelcomeBinding
|
||||
import net.vonforst.evmap.model.FILTERS_DISABLED
|
||||
import net.vonforst.evmap.navigation.safeNavigate
|
||||
import net.vonforst.evmap.storage.PreferenceDataSource
|
||||
import net.vonforst.evmap.waitForLayout
|
||||
|
||||
class OnboardingFragment : Fragment() {
|
||||
private lateinit var binding: FragmentOnboardingBinding
|
||||
@@ -59,7 +63,6 @@ class OnboardingFragment : Fragment() {
|
||||
}
|
||||
|
||||
override fun onPageSelected(position: Int) {
|
||||
binding.pageIndicatorView.selection = position
|
||||
binding.forward?.visibility =
|
||||
if (position == adapter.itemCount - 1) View.INVISIBLE else View.VISIBLE
|
||||
binding.backward?.visibility = if (position == 0) View.INVISIBLE else View.VISIBLE
|
||||
@@ -76,9 +79,13 @@ class OnboardingFragment : Fragment() {
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
if (prefs.welcomeDialogShown) {
|
||||
// skip to last page for selecting data source or accepting the privacy policy
|
||||
binding.viewPager.currentItem = adapter.itemCount - 1
|
||||
binding.root.waitForLayout {
|
||||
binding.viewPager.currentItem = if (prefs.welcomeDialogShown) {
|
||||
// skip to last page for selecting data source or accepting the privacy policy
|
||||
adapter.itemCount - 1
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -211,6 +218,8 @@ class DataSourceSelectFragment : OnboardingPageFragment() {
|
||||
binding.rgDataSource.textView27,
|
||||
binding.rgDataSource.rbOpenChargeMap,
|
||||
binding.rgDataSource.textView28,
|
||||
binding.rgDataSource.rbOpenStreetMap,
|
||||
binding.rgDataSource.textView29,
|
||||
binding.dataSourceHint,
|
||||
binding.cbAcceptPrivacy
|
||||
)
|
||||
@@ -234,12 +243,13 @@ class DataSourceSelectFragment : OnboardingPageFragment() {
|
||||
), HtmlCompat.FROM_HTML_MODE_LEGACY
|
||||
)
|
||||
binding.cbAcceptPrivacy.linksClickable = true
|
||||
binding.cbAcceptPrivacy.movementMethod = LinkMovementMethod.getInstance()
|
||||
binding.cbAcceptPrivacy.movementMethod = LinkMovementMethodCompat.getInstance()
|
||||
binding.btnGetStarted.visibility = View.INVISIBLE
|
||||
|
||||
for (rb in listOf(
|
||||
binding.rgDataSource.rbGoingElectric,
|
||||
binding.rgDataSource.rbOpenChargeMap
|
||||
binding.rgDataSource.rbOpenChargeMap,
|
||||
binding.rgDataSource.rbOpenStreetMap
|
||||
)) {
|
||||
rb.setOnCheckedChangeListener { _, _ ->
|
||||
if (binding.btnGetStarted.visibility == View.INVISIBLE) {
|
||||
@@ -254,6 +264,7 @@ class DataSourceSelectFragment : OnboardingPageFragment() {
|
||||
when (prefs.dataSource) {
|
||||
"goingelectric" -> binding.rgDataSource.rbGoingElectric.isChecked = true
|
||||
"openchargemap" -> binding.rgDataSource.rbOpenChargeMap.isChecked = true
|
||||
"openstreetmap" -> binding.rgDataSource.rbOpenStreetMap.isChecked = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -272,6 +283,8 @@ class DataSourceSelectFragment : OnboardingPageFragment() {
|
||||
"goingelectric"
|
||||
} else if (binding.rgDataSource.rbOpenChargeMap.isChecked) {
|
||||
"openchargemap"
|
||||
} else if (binding.rgDataSource.rbOpenStreetMap.isChecked) {
|
||||
"openstreetmap"
|
||||
} else {
|
||||
return@setOnClickListener
|
||||
}
|
||||
|
||||
@@ -78,22 +78,25 @@ class AboutFragment : PreferenceFragmentCompat() {
|
||||
}
|
||||
|
||||
"website" -> {
|
||||
(activity as? MapsActivity)?.openUrl(getString(R.string.website_url))
|
||||
(activity as? MapsActivity)?.openUrl(getString(R.string.website_url), requireView())
|
||||
true
|
||||
}
|
||||
|
||||
"github_link" -> {
|
||||
(activity as? MapsActivity)?.openUrl(getString(R.string.github_link))
|
||||
(activity as? MapsActivity)?.openUrl(getString(R.string.github_link), requireView())
|
||||
true
|
||||
}
|
||||
|
||||
"privacy" -> {
|
||||
(activity as? MapsActivity)?.openUrl(getString(R.string.privacy_link))
|
||||
(activity as? MapsActivity)?.openUrl(
|
||||
getString(R.string.privacy_link),
|
||||
requireView()
|
||||
)
|
||||
true
|
||||
}
|
||||
|
||||
"faq" -> {
|
||||
(activity as? MapsActivity)?.openUrl(getString(R.string.faq_link))
|
||||
(activity as? MapsActivity)?.openUrl(getString(R.string.faq_link), requireView())
|
||||
true
|
||||
}
|
||||
"oss_licenses" -> {
|
||||
@@ -115,12 +118,29 @@ class AboutFragment : PreferenceFragmentCompat() {
|
||||
findNavController().safeNavigate(AboutFragmentDirections.actionAboutToGithubSponsors())
|
||||
true
|
||||
}
|
||||
"mastodon" -> {
|
||||
(activity as? MapsActivity)?.openUrl(
|
||||
getString(R.string.mastodon_url),
|
||||
requireView()
|
||||
)
|
||||
true
|
||||
}
|
||||
"twitter" -> {
|
||||
(activity as? MapsActivity)?.openUrl(getString(R.string.twitter_url))
|
||||
(activity as? MapsActivity)?.openUrl(getString(R.string.twitter_url), requireView())
|
||||
true
|
||||
}
|
||||
"goingelectric" -> {
|
||||
(activity as? MapsActivity)?.openUrl(getString(R.string.goingelectric_forum_url))
|
||||
(activity as? MapsActivity)?.openUrl(
|
||||
getString(R.string.goingelectric_forum_url),
|
||||
requireView()
|
||||
)
|
||||
true
|
||||
}
|
||||
"tffforum" -> {
|
||||
(activity as? MapsActivity)?.openUrl(
|
||||
getString(R.string.tff_forum_url),
|
||||
requireView()
|
||||
)
|
||||
true
|
||||
}
|
||||
else -> super.onPreferenceTreeClick(preference)
|
||||
|
||||
@@ -44,7 +44,7 @@ class FusionEngine(context: Context) : LocationEngine(context),
|
||||
try {
|
||||
return locationManager.getLastKnownLocation(LocationManager.FUSED_PROVIDER)
|
||||
} catch (e: SecurityException) {
|
||||
Log.e(TAG, "Permissions not granted for fused provider", e)
|
||||
Log.w(TAG, "Permissions not granted for fused provider", e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,7 +68,7 @@ class FusionEngine(context: Context) : LocationEngine(context),
|
||||
}
|
||||
}
|
||||
} catch (e: SecurityException) {
|
||||
Log.e(TAG, "Permissions not granted for provider: $provider", e)
|
||||
Log.w(TAG, "Permissions not granted for provider: $provider", e)
|
||||
}
|
||||
}
|
||||
return bestLocation
|
||||
@@ -103,7 +103,7 @@ class FusionEngine(context: Context) : LocationEngine(context),
|
||||
enableFused(gpsInterval)
|
||||
checkLastKnownFused()
|
||||
} catch (e: SecurityException) {
|
||||
Log.e(TAG, "Permissions not granted for fused provider", e)
|
||||
Log.w(TAG, "Permissions not granted for fused provider", e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,7 +159,7 @@ class FusionEngine(context: Context) : LocationEngine(context),
|
||||
looper
|
||||
)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
Log.e(TAG, "Unable to register for GPS updates.", e)
|
||||
Log.w(TAG, "Unable to register for GPS updates.", e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,7 +174,7 @@ class FusionEngine(context: Context) : LocationEngine(context),
|
||||
looper
|
||||
)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
Log.e(TAG, "Unable to register for network updates.", e)
|
||||
Log.w(TAG, "Unable to register for network updates.", e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -189,7 +189,7 @@ class FusionEngine(context: Context) : LocationEngine(context),
|
||||
looper
|
||||
)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
Log.e(TAG, "Unable to register for passive updates.", e)
|
||||
Log.w(TAG, "Unable to register for passive updates.", e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -205,7 +205,7 @@ class FusionEngine(context: Context) : LocationEngine(context),
|
||||
looper
|
||||
)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
Log.e(TAG, "Unable to register for passive updates.", e)
|
||||
Log.w(TAG, "Unable to register for passive updates.", e)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,21 +3,26 @@ package net.vonforst.evmap.model
|
||||
import android.content.Context
|
||||
import android.os.Parcelable
|
||||
import androidx.core.text.HtmlCompat
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Embedded
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
import co.anbora.labs.spatia.geometry.Point
|
||||
import com.squareup.moshi.JsonClass
|
||||
import kotlinx.parcelize.IgnoredOnParcel
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import net.vonforst.evmap.R
|
||||
import net.vonforst.evmap.adapter.Equatable
|
||||
import net.vonforst.evmap.api.StringProvider
|
||||
import net.vonforst.evmap.api.nameForPlugType
|
||||
import net.vonforst.evmap.utils.SphericalMercatorProjection
|
||||
import java.time.DayOfWeek
|
||||
import java.time.Instant
|
||||
import java.time.LocalDate
|
||||
import java.time.LocalTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.format.FormatStyle
|
||||
import java.util.Locale
|
||||
import kotlin.math.abs
|
||||
|
||||
sealed class ChargepointListItem
|
||||
@@ -131,8 +136,8 @@ data class ChargeLocation(
|
||||
.filter { it.type == variant.type && it.power == variant.power }
|
||||
val count = filtered.sumOf { it.count }
|
||||
Chargepoint(variant.type, variant.power, count,
|
||||
filtered.map { it.voltage }.distinct().singleOrNull(),
|
||||
filtered.map { it.current }.distinct().singleOrNull()
|
||||
filtered.map { it.current }.distinct().singleOrNull(),
|
||||
filtered.map { it.voltage }.distinct().singleOrNull()
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -140,11 +145,18 @@ data class ChargeLocation(
|
||||
val totalChargepoints: Int
|
||||
get() = chargepoints.sumOf { it.count }
|
||||
|
||||
fun formatChargepoints(sp: StringProvider): String {
|
||||
fun formatChargepoints(sp: StringProvider, locale: Locale): String {
|
||||
return chargepointsMerged.joinToString(" · ") {
|
||||
"${it.count} × ${nameForPlugType(sp, it.type)} ${it.formatPower()}"
|
||||
"${it.count} × ${nameForPlugType(sp, it.type)}${
|
||||
it.formatPower(locale)?.let { " $it" } ?: ""
|
||||
}"
|
||||
}
|
||||
}
|
||||
|
||||
// used to store projected coordinates in DB
|
||||
@IgnoredOnParcel
|
||||
var coordinatesProjected: Point =
|
||||
SphericalMercatorProjection.project(Point(coordinates.lng, coordinates.lat))
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -353,8 +365,8 @@ abstract class ChargerPhoto(open val id: String) : Parcelable {
|
||||
}
|
||||
|
||||
data class ChargeLocationCluster(
|
||||
val clusterCount: Int,
|
||||
val coordinates: Coordinate,
|
||||
@ColumnInfo("clusterCount") val clusterCount: Int,
|
||||
@ColumnInfo("coordinates") val coordinates: Coordinate,
|
||||
val items: List<ChargeLocation>? = null
|
||||
) : ChargepointListItem()
|
||||
|
||||
@@ -371,14 +383,15 @@ data class Address(
|
||||
override fun toString(): String {
|
||||
// TODO: the order here follows a German-style format (i.e. street, postcode city).
|
||||
// in principle this should be country-dependent (e.g. UK has postcode after city)
|
||||
// maybe use https://github.com/bettermile/address-formatter-kotlin
|
||||
return buildString {
|
||||
street?.let {
|
||||
append(it)
|
||||
append(", ")
|
||||
if (postcode != null || city != null) append(", ")
|
||||
}
|
||||
postcode?.let {
|
||||
append(it)
|
||||
append(" ")
|
||||
if (city != null) append(" ")
|
||||
}
|
||||
city?.let {
|
||||
append(it)
|
||||
@@ -413,12 +426,12 @@ data class Chargepoint(
|
||||
* If chargepoint power is defined, format it into a string.
|
||||
* Otherwise, return null.
|
||||
*/
|
||||
fun formatPower(): String? {
|
||||
fun formatPower(locale: Locale): String? {
|
||||
if (power == null) return null
|
||||
val powerFmt = if (abs(power - power.toInt()) < 0.1) {
|
||||
"%.0f".format(power)
|
||||
"%.0f".format(locale, power)
|
||||
} else {
|
||||
"%.1f".format(power)
|
||||
"%.1f".format(locale, power)
|
||||
}
|
||||
return "$powerFmt kW"
|
||||
}
|
||||
@@ -434,7 +447,8 @@ data class Chargepoint(
|
||||
const val TYPE_2_UNKNOWN = "Type 2 (either plug or socket)"
|
||||
const val TYPE_2_SOCKET = "Type 2 socket"
|
||||
const val TYPE_2_PLUG = "Type 2 plug"
|
||||
const val TYPE_3 = "Type 3"
|
||||
const val TYPE_3A = "Type 3A"
|
||||
const val TYPE_3C = "Type 3C"
|
||||
const val CCS_TYPE_2 = "CCS Type 2"
|
||||
const val CCS_TYPE_1 = "CCS Type 1"
|
||||
const val CCS_UNKNOWN = "CCS (either Type 1 or Type 2)"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user