Compare commits

..

62 Commits

Author SHA1 Message Date
johan12345
b99e2ea2c8 WIP: replace CustomBottomSheetBehavior 2025-07-12 18:06:51 +02:00
johan12345
d2ae3733d1 Big toolchain update
- Gradle + AGP
- Java 17
- compile/targetSdk 35
- Room & Moshi use KSP, not KAPT
2025-07-12 18:06:27 +02:00
johan12345
72845da4b5 Release 1.9.18 2025-06-14 17:40:09 +02:00
johan12345
51b57433a8 TeslaAvailabilityDetector: Fix nullability bug 2025-06-14 17:36:47 +02:00
johan12345
3202f821d1 always show current location on start, even if we were not following the location before 2025-06-14 17:32:24 +02:00
johan12345
b7e1ff09db FilterScreen: remove unnecessary invalidate() calls
we are already observing filterProfiles
2025-06-13 22:13:05 +02:00
Licaon_Kter
feabf49b8d Remove some non-determinism 2025-05-30 12:03:49 +02:00
johan12345
dcbe4c6325 Release 1.9.17 2025-05-29 00:41:28 +02:00
johan12345
dcff74c125 update TeslaOwnerApi 2025-05-28 23:58:01 +02:00
johan12345
d8f7d77a36 Release 1.9.16 2025-05-17 19:41:36 +02:00
johan12345
d03cf70499 capture (but ignore) clicks on searchResultMarker 2025-05-17 19:36:22 +02:00
johan12345
7a6bebd143 RTL and Arabic locale fixes 2025-05-17 19:24:39 +02:00
johan12345
66d68ca68e disable extendBounds if map is zoomed out far 2025-05-17 18:04:50 +02:00
johan12345
772885a8eb add "zoom in to see all charging stations" snackbar if response is not complete 2025-05-17 17:53:15 +02:00
johan12345
6b07ce012a Rework showLocation function
avoid opening within EVMap itself
2025-05-15 23:15:51 +02:00
johan12345
29dbc202d8 Rework openUrl function
- use preferBrowser only when needed (when opening charger URLs, which might otherwise open in EVMap itself)
- make preferBrowser work even if the default browser does not support Custom Tabs

#313

Cherry-picked from 17efe71
2025-05-15 22:53:19 +02:00
johan12345
cf8371d095 allow Unicode license 2025-05-07 23:38:47 +02:00
johan12345
01cb551cbc Use CarAppService for startActivity instead of CarContext
fixes #375 for startActivity and openUrl

https://issuetracker.google.com/issues/372055514
Warning: You must update to androidx.car.app:1.7.0-alpha01 or later for the permissions dialog to show up on the phone screen when your app is used on a device running Android 14 or higher.
2025-05-07 23:28:54 +02:00
johan12345
45fe297616 Update car app library
fixes #375 for permission request

https://developer.android.com/training/cars/apps#permissions

Warning: You must update to androidx.car.app:1.7.0-alpha01 or later for the permissions dialog to show up on the phone screen when your app is used on a device running Android 14 or higher.
2025-05-07 22:35:02 +02:00
johan12345
32cabefe7d Fix touch targets for privacy policy link on API < 34
https://github.com/material-components/material-components-android/issues/2100#issuecomment-2234437889

fixes #374
2025-04-26 22:35:38 +02:00
johan12345
9ff8329171 Release 1.9.15 2025-03-29 12:15:44 +01:00
johan12345
e9b70a2f00 fix bug in extendBounds
introduced in 890af2ddef
fixes #373
2025-03-29 12:15:33 +01:00
johan12345
c4c3aba7c7 Release 1.9.14 2025-03-15 15:48:53 +01:00
johan12345
890af2ddef try to better handle situations where map bounds cross the Antimeridian 2025-03-12 22:04:12 +01:00
johan12345
ba0b36b3ec update AnyMaps 2025-03-12 21:11:32 +01:00
johan12345
161b48789f Chargeprice: reset to default charging range when tapping title 2025-03-09 23:07:37 +01:00
johan12345
042b983aa3 CI: run checksec on release APKs 2025-03-04 22:11:32 +01:00
johan12345
1c21da7be0 CI: move apikeys-ci.xml to _ci folder 2025-03-04 21:24:19 +01:00
johan12345
405baed0f7 upgrade android-spatialite 2025-03-04 21:24:05 +01:00
johan12345
19c0d57f2b upgrade maplibre 2025-03-04 20:21:19 +01:00
johan12345
42c2a2f72a improve setLinkify BindingAdapter
fixes #371
2025-02-26 21:21:12 +01:00
johan12345
36ee3ff231 CarAppService: ignore if starting foreground service fails
this happens on AAOS API 34+ due to https://developer.android.com/develop/background-work/services/fgs/restrictions-bg-start. However, the app still works even without the foreground service.
2025-02-26 20:08:23 +01:00
johan12345
883735ef05 FusionEngine: change log level 2025-02-26 20:00:22 +01:00
johan12345
4c68356ae9 Release 1.9.13 2025-02-18 21:56:07 +01:00
johan12345
7fde5b50aa fix disappearing markers
fixes #368
2025-02-18 21:49:55 +01:00
johan12345
7c4136c66d Release 1.9.12 2025-02-07 22:08:41 +01:00
johan12345
6e56f5c3ff update social links 2025-02-07 22:05:23 +01:00
johan12345
017be6f31a increase heap space 2025-02-07 22:02:11 +01:00
johan12345
b398a5dc81 embed referral links as webpage instead of native Android buttons 2025-02-07 19:13:58 +01:00
johan12345
3fb0dec868 Release 1.9.11 (only for Harman Ignite) 2025-01-28 20:58:23 +01:00
johan12345
8c4de115ec remove setting for fronyx predictions in AAOS app
see also e7c9432191
2025-01-28 20:51:05 +01:00
johan12345
334b68cf5e SearchSelectScreen: fix displaying selectAll buttons 2025-01-27 23:40:37 +01:00
johan12345
788c68c9dd Onboarding Android Auto page: fix icon disappearing 2025-01-18 23:43:14 +01:00
johan12345
7842a15529 fix more possible memory leak issues 2025-01-07 20:42:22 +01:00
johan12345
e7c9432191 Disable fronyx predictions
API has been broken for >4 months now
2024-12-03 22:18:47 +01:00
johan12345
76b6abd3ca Release 1.9.10 (only for Faurecia Aptoide) 2024-11-24 18:32:51 +01:00
johan12345
752c184146 fix crash on first start 2024-11-24 18:13:39 +01:00
johan12345
5471ac5073 Release 1.9.9 (only for Faurecia Aptoide) 2024-11-13 20:31:33 +01:00
johan12345
69ae13a199 fix crash on first start 2024-11-13 20:30:58 +01:00
johan12345
8a2e2d9a25 release 1.9.8 (only for Faurecia Aptoide) 2024-11-09 13:56:06 +01:00
johan12345
fe69a78b94 fix possible memory leak issues 2024-11-08 20:50:06 +01:00
johan12345
2663bd7964 update MapLibre & AnyMaps 2024-10-26 22:30:25 +02:00
johan12345
3b54b2799f Databinding: use viewLifecycleOwner instead of Fragment as lifecycle owner 2024-10-26 22:25:08 +02:00
johan12345
3a24711626 translations: move nb-rNO to nb 2024-10-25 22:03:52 +02:00
johan12345
c158744bc2 fix unnecessary recreation of MapFragment 2024-10-23 22:51:15 +02:00
johan12345
c01033a036 upgrade AnyMaps 2024-10-23 22:07:33 +02:00
Hosted Weblate
16474c3864 Translated using Weblate (Portuguese)
Currently translated at 100.0% (358 of 358 strings)

Co-authored-by: Celso Azevedo <mail@celsoazevedo.com>
Translate-URL: https://hosted.weblate.org/projects/evmap/android/pt/
Translation: EVMap/Android
2024-10-17 18:55:26 +02:00
Hosted Weblate
7ce2f8d452 Translated using Weblate (Czech)
Currently translated at 100.0% (358 of 358 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (356 of 356 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (355 of 355 strings)

Co-authored-by: Fjuro <fjuro@alius.cz>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/evmap/android/cs/
Translation: EVMap/Android
2024-10-17 18:55:26 +02:00
Hosted Weblate
28df158d94 Translated using Weblate (German)
Currently translated at 100.0% (358 of 358 strings)

Co-authored-by: mcliquid <info@mcliquid.de>
Translate-URL: https://hosted.weblate.org/projects/evmap/android/de/
Translation: EVMap/Android
2024-10-17 18:43:40 +02:00
johan12345
90b3645a0b fix crash when trying to rename a filter profile to a name that already exists 2024-10-15 20:20:27 +02:00
johan12345
de901aa825 Release 1.9.7 (only for Faurecia Aptoide) 2024-08-16 19:13:30 +02:00
johan12345
2ce61f2f6b AAOS: more navigateToCharger fixes 2024-08-16 19:12:34 +02:00
98 changed files with 3165 additions and 3079 deletions

View File

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

View File

@@ -92,8 +92,4 @@ free, i.e. the background map displayed in the app if OpenStreetMap is selected
<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>
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.
price for EVMap. This data is used in EVMap's price comparison feature.

View File

@@ -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"
versionCode = 256
versionName = "1.9.18"
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
@@ -85,18 +96,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 +262,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,19 +289,19 @@ 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")
implementation("com.squareup.retrofit2:retrofit:2.9.0")
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 +315,13 @@ dependencies {
implementation("com.github.erfansn:locale-config-x:1.0.1")
// Android Auto
val carAppVersion = "1.7.0-beta01"
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 = "010de4e275"
val anyMapsVersion = "a3290b148d"
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 +329,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 +350,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 +376,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 +394,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")
}

View 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')"
]
}
}

View File

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

View File

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

View File

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

View File

@@ -9,7 +9,6 @@
<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" />
@@ -25,6 +24,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" />
@@ -42,7 +45,8 @@
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/AppTheme">
android:theme="@style/AppTheme"
android:enableOnBackInvokedCallback="true">
<meta-data
android:name="com.mapbox.ACCESS_TOKEN"

View File

@@ -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 = true) {
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()

View File

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

View File

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

View File

@@ -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
@@ -59,7 +58,7 @@ class TeslaOwnerAvailabilityDetector(
val details = api.getChargingSiteInformation(
TeslaChargingOwnershipGraphQlApi.GetChargingSiteInformationRequest(
TeslaChargingOwnershipGraphQlApi.GetChargingSiteInformationVariables(
TeslaChargingOwnershipGraphQlApi.ChargingSiteIdentifier(result.id.text),
TeslaChargingOwnershipGraphQlApi.ChargingSiteIdentifier(result.locationGUID),
TeslaChargingOwnershipGraphQlApi.VehicleMakeType.NON_TESLA
)
)

View File

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

View File

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

View File

@@ -29,7 +29,6 @@ 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
@@ -46,14 +45,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)
}
}
@@ -126,11 +131,8 @@ class EVMapSession(val cas: CarAppService) : Session(), DefaultLifecycleObserver
}
override fun onCreateScreen(intent: Intent): Screen {
val mapScreen = if (supportsNewMapScreen(carContext)) {
MapScreen(carContext, this)
} else {
LegacyMapScreen(carContext, this)
}
val mapScreen = MapScreen(carContext, this)
val screens = mutableListOf<Screen>(mapScreen)
handleActionsIntent(intent)?.let {
@@ -160,7 +162,7 @@ class EVMapSession(val cas: CarAppService) : Session(), DefaultLifecycleObserver
}
if (!prefs.privacyAccepted) {
screens.add(
AcceptPrivacyScreen(carContext)
AcceptPrivacyScreen(carContext, this)
)
}
handleACRAIntent(intent)?.let {
@@ -190,7 +192,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 = PlaceWithBounds(LatLng(lat, lon), null)
prefs.placeSearchResultAndroidAuto = LatLng(lat, lon)
prefs.placeSearchResultAndroidAutoName = name ?: "%.4f,%.4f".format(lat, lon)
return null
} else if (name != null) {

View File

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

View File

@@ -3,6 +3,7 @@ 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
@@ -10,9 +11,12 @@ 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
@@ -31,6 +35,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
@@ -48,9 +53,10 @@ 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.Cost
import net.vonforst.evmap.model.FaultReport
@@ -60,6 +66,7 @@ import net.vonforst.evmap.storage.AppDatabase
import net.vonforst.evmap.storage.ChargeLocationsRepository
import net.vonforst.evmap.storage.PreferenceDataSource
import net.vonforst.evmap.ui.ChargerIconGenerator
import net.vonforst.evmap.ui.availabilityText
import net.vonforst.evmap.ui.getMarkerTint
import net.vonforst.evmap.viewmodel.Status
import net.vonforst.evmap.viewmodel.awaitFinished
@@ -69,7 +76,14 @@ import java.time.format.FormatStyle
import kotlin.math.floor
import kotlin.math.roundToInt
class ChargerDetailScreen(ctx: CarContext, val chargerSparse: ChargeLocation) : Screen(ctx) {
private const val TAG = "ChargerDetailScreen"
@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
@@ -82,7 +96,8 @@ 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 predictionRepo = PredictionRepository(ctx)
private val timeFormat = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT)
private val imageSize = 128 // images should be 128dp according to docs
@@ -128,7 +143,7 @@ class ChargerDetailScreen(ctx: CarContext, val chargerSparse: ChargeLocation) :
.setFlags(Action.FLAG_PRIMARY)
.setBackgroundColor(CarColor.PRIMARY)
.setOnClickListener {
navigateToCharger(carContext, charger)
navigateToCharger(charger)
}
.build())
if (ChargepriceApi.isChargerSupported(charger)) {
@@ -145,14 +160,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())
@@ -171,12 +192,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,
@@ -265,7 +286,7 @@ class ChargerDetailScreen(ctx: CarContext, val chargerSparse: ChargeLocation) :
Row.IMAGE_TYPE_LARGE
)
}
addText(generateChargepointsText(charger, availability, carContext))
addText(generateChargepointsText(charger))
}.build())
if (maxRows <= 3) {
// row 2: operator + cost + fault report
@@ -358,7 +379,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())
@@ -478,6 +499,47 @@ 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(carContext.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
}
private fun generateOperatorText(charger: ChargeLocation) =
if (charger.operator != null && charger.network != null) {
if (charger.operator.contains(charger.network)) {
@@ -495,6 +557,58 @@ class ChargerDetailScreen(ctx: CarContext, val chargerSparse: ChargeLocation) :
carContext.getString(R.string.unknown_operator)
}
private fun navigateToCharger(charger: ChargeLocation) {
var success = navigateCarApp(charger)
if (!success && BuildConfig.FLAVOR_automotive == "automotive") {
// on AAOS, some OEMs' navigation apps might not support
success = navigateRegularApp(charger)
}
if (!success) {
CarToast.makeText(carContext, R.string.no_maps_app_found, CarToast.LENGTH_SHORT).show()
}
}
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)
})"
)
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
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)
@@ -546,12 +660,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()
@@ -560,7 +674,7 @@ class ChargerDetailScreen(ctx: CarContext, val chargerSparse: ChargeLocation) :
invalidate()
prediction = predictionRepo.getPredictionData(charger, availability)
//prediction = predictionRepo.getPredictionData(charger, availability)
invalidate()
} else {

View File

@@ -1,242 +0,0 @@
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.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 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)
}
class ChargerListFormatter(val carContext: CarContext, val screen: ChargerListDelegate) {
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())
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, charger)
}
}.build())
}.build()
}

View File

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

View File

@@ -1,533 +0,0 @@
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)
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.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,
zoom = 16f,
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?.filterIsInstance(ChargeLocation::class.java)
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
}
}
}

View File

@@ -1,43 +0,0 @@
package net.vonforst.evmap.auto
import androidx.car.app.CarContext
import androidx.car.app.Screen
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
class MapAttributionScreen(
ctx: CarContext,
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, attr.url)
}).build()
)
}
}.build())
.build()
}
}

View File

@@ -1,14 +1,14 @@
package net.vonforst.evmap.auto
import android.content.pm.PackageManager
import android.content.res.Configuration
import android.location.Location
import androidx.activity.OnBackPressedCallback
import androidx.car.app.AppManager
import android.os.Handler
import android.os.Looper
import android.text.SpannableString
import android.text.SpannableStringBuilder
import android.text.Spanned
import androidx.car.app.CarContext
import androidx.car.app.Screen
import androidx.car.app.annotations.ExperimentalCarApi
import androidx.car.app.annotations.RequiresCarApi
import androidx.car.app.constraints.ConstraintManager
import androidx.car.app.hardware.CarHardwareManager
import androidx.car.app.hardware.info.CarInfo
@@ -19,20 +19,24 @@ 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.Header
import androidx.car.app.model.ListTemplate
import androidx.car.app.model.MessageTemplate
import androidx.car.app.model.PaneTemplate
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.OnContentRefreshListener
import androidx.car.app.model.Place
import androidx.car.app.model.PlaceListMapTemplate
import androidx.car.app.model.PlaceMarker
import androidx.car.app.model.Row
import androidx.car.app.model.Template
import androidx.car.app.navigation.model.MapController
import androidx.car.app.navigation.model.MapWithContentTemplate
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.AnyMap
import com.car2go.maps.OnMapReadyCallback
import com.car2go.maps.model.LatLng
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
@@ -46,18 +50,19 @@ 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.ChargeLocationCluster
import net.vonforst.evmap.model.ChargepointListItem
import net.vonforst.evmap.model.FILTERS_FAVORITES
import net.vonforst.evmap.model.FilterValue
import net.vonforst.evmap.model.FilterWithValue
import net.vonforst.evmap.storage.AppDatabase
import net.vonforst.evmap.storage.ChargeLocationsRepository
import net.vonforst.evmap.storage.PreferenceDataSource
import net.vonforst.evmap.ui.MarkerManager
import net.vonforst.evmap.ui.ChargerIconGenerator
import net.vonforst.evmap.ui.availabilityText
import net.vonforst.evmap.ui.getMarkerTint
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
@@ -66,62 +71,58 @@ import java.time.Duration
import java.time.Instant
import java.time.ZonedDateTime
import kotlin.collections.set
import kotlin.math.abs
import kotlin.math.min
import kotlin.math.roundToInt
/**
* Main map screen showing either nearby chargers or favorites.
*
* New implementation for Car App API Level >= 7 with interactive map using MapSurfaceCallback
* Main map screen showing either nearby chargers or favorites
*/
@RequiresCarApi(7)
@ExperimentalCarApi
@androidx.car.app.annotations.ExperimentalCarApi
class MapScreen(ctx: CarContext, val session: EVMapSession) :
Screen(ctx), LocationAwareScreen, ChargerListDelegate,
DefaultLifecycleObserver, OnMapReadyCallback {
Screen(ctx), LocationAwareScreen, OnContentRefreshListener,
ItemList.OnItemVisibilityChangedListener, DefaultLifecycleObserver {
companion object {
val MARKER = "map"
}
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 location: Location? = null
private var lastDistanceUpdateTime: Instant? = null
private var chargers: List<ChargepointListItem>? = null
private var selectedCharger: ChargeLocation? = null
private val favorites = db.favoritesDao().getAllFavorites()
override var loadingError = false
override val locationError = false
private val mapSurfaceCallback = MapSurfaceCallback(carContext, lifecycleScope)
private var lastChargersUpdateTime: Instant? = null
private var chargers: List<ChargeLocation>? = null
private var isFavorite: List<Boolean>? = null
private var loadingError = false
private var locationError = false
private var prefs = PreferenceDataSource(ctx)
private val db = AppDatabase.getInstance(carContext)
private val repo =
ChargeLocationsRepository(createApi(prefs.dataSource, ctx), lifecycleScope, db, prefs)
private val availabilityRepo = AvailabilityRepository(ctx)
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 =
private val maxRows =
min(ctx.getContentLimit(ConstraintManager.CONTENT_LIMIT_TYPE_PLACE_LIST), 25)
private val supportsRefresh = ctx.isAppDrivenRefreshSupported
override var filterStatus = prefs.filterStatus
private 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 energyLevel: EnergyLevel? = null
private var heading: Compass? = null
private val permissions = if (BuildConfig.FLAVOR_automotive == "automotive") {
listOf(
@@ -135,234 +136,280 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) :
)
}
private var map: AnyMap? = null
private var markerManager: MarkerManager? = null
private var myLocationEnabled = false
private var myLocationNeedsUpdate = false
private var searchLocation: LatLng? = null
private val formatter = ChargerListFormatter(ctx, this)
private val backPressedCallback = object : OnBackPressedCallback(false) {
override fun handleOnBackPressed() {
clearSelectedCharger()
}
}
private val iconGen =
ChargerIconGenerator(carContext, null, height = 96)
init {
lifecycle.addObserver(this)
marker = MARKER
favorites.observe(this) {
val favoriteIds = it.map { it.favorite.chargerId }.toSet()
markerManager?.favorites = favoriteIds
formatter.favorites = favoriteIds
}
}
override fun onCreate(owner: LifecycleOwner) {
carContext.getCarService(AppManager::class.java)
.setSurfaceCallback(mapSurfaceCallback)
carContext.onBackPressedDispatcher.addCallback(this, backPressedCallback)
}
override fun onGetTemplate(): Template {
session.mapScreen = this
val map = map
val title = prefs.placeSearchResultAndroidAutoName ?: carContext.getString(
if (filterStatus == FILTERS_FAVORITES) {
R.string.auto_favorites
} else if (myLocationEnabled) {
R.string.auto_chargers_closeby
} else {
R.string.app_name
}
)
val actionStrip = buildActionStrip()
val selectedCharger = selectedCharger
val contentTemplate = if (selectedCharger != null) {
PaneTemplate.Builder(
formatter.buildSingleCharger(
selectedCharger,
availabilities.get(selectedCharger.id)?.second
) {
screenManager.push(ChargerDetailScreen(carContext, selectedCharger))
session.mapScreen = null
}).apply {
setHeader(Header.Builder().apply {
setTitle(selectedCharger.name)
setStartHeaderAction(Action.BACK)
}.build())
}.build()
} else if (chargers?.filterIsInstance<ChargeLocationCluster>()?.isNotEmpty() == true) {
MessageTemplate.Builder(carContext.getString(R.string.auto_zoom_for_details))
.apply {
setHeader(Header.Builder().apply {
setTitle(title)
setStartHeaderAction(Action.APP_ICON)
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())
}.build()
} else {
ListTemplate.Builder().apply {
setHeader(Header.Builder().apply {
setTitle(title)
setStartHeaderAction(Action.APP_ICON)
}.build())
}
} else {
location?.let {
setAnchor(Place.Builder(CarLocation.create(it.latitude, it.longitude)).build())
}
}
chargers?.take(maxRows)?.let { chargerList ->
val builder = ItemList.Builder()
// only show the city if not all chargers are in the same city
val showCity = chargerList.map { it.address?.city }.distinct().size > 1
chargerList.forEachIndexed { i, charger ->
builder.addItem(formatCharger(charger, showCity, isFavorite?.get(i) ?: false))
}
builder.setNoItemsMessage(
carContext.getString(
if (filterStatus == FILTERS_FAVORITES) {
R.string.auto_no_favorites_found
} else {
R.string.auto_no_chargers_found
}
)
)
builder.setOnItemsVisibilityChangedListener(this@MapScreen)
setItemList(builder.build())
} ?: run {
if (loadingError) {
val builder = ItemList.Builder()
builder.setNoItemsMessage(
carContext.getString(R.string.connection_error)
)
setItemList(builder.build())
} else if (locationError) {
val builder = ItemList.Builder()
builder.setNoItemsMessage(
carContext.getString(R.string.location_error)
)
setItemList(builder.build())
} else {
setLoading(true)
}
}
setCurrentLocationEnabled(true)
setHeaderAction(Action.APP_ICON)
val filtersCount = if (filterStatus == FILTERS_FAVORITES) 1 else {
filtersWithValue?.count {
!it.value.hasSameValueAs(it.filter.defaultValue())
}
}
formatter.buildChargerList(
chargers?.filterIsInstance<ChargeLocation>(),
availabilities
)?.let {
setSingleList(it)
} ?: setLoading(true)
}.build()
}
return MapWithContentTemplate.Builder().apply {
setContentTemplate(contentTemplate)
setActionStrip(actionStrip)
setMapController(MapController.Builder().apply {
setMapActionStrip(buildMapActionStrip())
setPanModeListener { }
}.build())
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
isFavorite = null
loadChargers()
}
} else {
chargers = null
isFavorite = null
loadChargers()
}
} else {
screenManager.pushForResult(
PlaceSearchScreen(
carContext,
session
)
) {
chargers = null
isFavorite = 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@MapScreen)
}
}.build()
}
private fun buildMapActionStrip() = ActionStrip.Builder()
.addAction(Action.PAN)
.addAction(
Action.Builder().setIcon(
CarIcon.Builder(IconCompat.createWithResource(carContext, R.drawable.ic_location))
.setTint(if (myLocationEnabled) CarColor.SECONDARY else CarColor.DEFAULT)
.build()
).setOnClickListener {
enableLocation(true)
}.build()
)
.addAction(
Action.Builder().setIcon(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
R.drawable.ic_add
)
).setTint(CarColor.DEFAULT).build()
).setOnClickListener {
val map = map ?: return@setOnClickListener
mapSurfaceCallback.animateCamera(map.cameraUpdateFactory.zoomBy(0.5f))
}.build()
)
.addAction(
Action.Builder().setIcon(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
R.drawable.ic_remove
)
).setTint(CarColor.DEFAULT).build()
).setOnClickListener {
val map = map ?: return@setOnClickListener
mapSurfaceCallback.animateCamera(map.cameraUpdateFactory.zoomBy(-0.5f))
}.build()
).build()
private fun buildActionStrip(): ActionStrip {
val filtersCount = if (filterStatus == FILTERS_FAVORITES) 1 else {
filtersWithValue?.count {
!it.value.hasSameValueAs(it.filter.defaultValue())
}
private fun formatCharger(
charger: ChargeLocation,
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
}
return 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()
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()
)
setOnClickListener {
if (prefs.placeSearchResultAndroidAuto != null) {
prefs.placeSearchResultAndroidAutoName = null
prefs.placeSearchResultAndroidAuto = null
markerManager?.searchResult = null
invalidate()
} else {
screenManager.pushForResult(
PlaceSearchScreen(
carContext,
session
)
) {
chargers = null
loadChargers()
}
session.mapScreen = null
}
.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}")
}
}.build())
.addAction(
Action.Builder()
.setIcon(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
R.drawable.ic_filter
)
setTitle(CarText.Builder(titleWithCity).addVariant(title).build())
} else {
setTitle(title)
}
val text = SpannableStringBuilder()
// distance
location?.let {
val distanceMeters = distanceBetween(
it.latitude, it.longitude,
charger.coordinates.lat, charger.coordinates.lng
)
text.append(
"distance",
DistanceSpan.create(
roundValueToDistance(
distanceMeters,
energyLevel?.distanceDisplayUnit?.value,
carContext
)
.setTint(if (filtersCount != null && filtersCount > 0) CarColor.SECONDARY else CarColor.DEFAULT)
.build()
)
.setOnClickListener {
screenManager.push(FilterScreen(carContext, session))
session.mapScreen = null
}
.build())
.build()
}
),
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
)
}
override fun onChargerClick(charger: ChargeLocation) {
selectedCharger = charger
markerManager?.highlighedCharger = charger
markerManager?.animateBounce(charger)
backPressedCallback.isEnabled = true
invalidate()
// load availability
lifecycleScope.launch {
val availability = availabilityRepo.getAvailability(charger).data
val date = ZonedDateTime.now()
availabilities[charger.id] = date to availability
invalidate()
}
}
// power
val power = charger.maxPower
if (power != null) {
if (text.isNotEmpty()) text.append(" · ")
text.append("${power.roundToInt()} kW")
}
fun clearSelectedCharger() {
selectedCharger = null
markerManager?.highlighedCharger = null
backPressedCallback.isEnabled = false
invalidate()
// 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 {
screenManager.push(ChargerDetailScreen(carContext, charger, session))
session.mapScreen = null
}
}.build()
}
override fun updateLocation(location: Location) {
@@ -371,25 +418,11 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) :
) {
return
}
val oldLoc = this.location?.let { LatLng.fromLocation(it) }
val latLng = LatLng.fromLocation(location)
val previousLocation = this.location
this.location = location
val map = map ?: return
if (myLocationEnabled) {
if (oldLoc == null) {
mapSurfaceCallback.animateCamera(map.cameraUpdateFactory.newLatLngZoom(latLng, 13f))
} else if (latLng != oldLoc && distanceBetween(
latLng.latitude,
latLng.longitude,
oldLoc.latitude,
oldLoc.longitude
) > 1
) {
// only update map if location changed by more than 1 meter
val camUpdate = map.cameraUpdateFactory.newLatLng(latLng)
mapSurfaceCallback.animateCamera(camUpdate)
}
if (previousLocation == null) {
loadChargers()
return
}
val now = Instant.now()
@@ -400,11 +433,31 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) :
// 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 map = map ?: return
val searchLocation =
prefs.placeSearchResultAndroidAuto ?: LatLng.fromLocation(location)
this.searchLocation = searchLocation
updateCoroutine = lifecycleScope.launch {
loadingError = false
@@ -415,33 +468,56 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) :
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
)
val chargers =
db.favoritesDao().getAllFavoritesAsync().map { it.charger }.sortedBy {
distanceBetween(
location.latitude, location.longitude,
it.coordinates.lat, it.coordinates.lng
)
}
this@MapScreen.chargers = chargers
isFavorite = List(chargers.size) { true }
} 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,
zoom = 16f,
filtersWithValue
).awaitFinished()
if (response.status == Status.ERROR && if (radius == radiusValues.last()) response.data?.items.isNullOrEmpty() else response.data == null) {
loadingError = true
this@MapScreen.chargers = null
invalidate()
return@launch
}
chargers = response.data?.items?.filterIsInstance<ChargeLocation>()
if (prefs.placeSearchResultAndroidAutoName == null) {
chargers = headingFilter(
chargers,
searchLocation
)
}
if (chargers == null || chargers.size >= maxRows) {
break
}
}
val isFavorite = chargers?.map {
db.favoritesDao().findFavorite(it.id, apiId) != null
}
this@MapScreen.chargers = chargers
} else {
val response = repo.getChargepoints(
map.projection.visibleRegion.latLngBounds,
map.cameraPosition.zoom,
filtersWithValue,
false
).awaitFinished()
if (response.status == Status.ERROR || response.data == null) {
loadingError = true
this@MapScreen.chargers = null
invalidate()
return@launch
}
this@MapScreen.chargers = response.data
markerManager?.chargepoints = response.data
this@MapScreen.isFavorite = isFavorite
}
updateCoroutine = null
lastChargersUpdateTime = Instant.now()
lastDistanceUpdateTime = Instant.now()
invalidate()
} catch (e: IOException) {
@@ -454,6 +530,33 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) :
}
}
/**
* 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
@@ -465,9 +568,15 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) :
}
override fun onStart(owner: LifecycleOwner) {
mapSurfaceCallback.getMapAsync(this)
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.
@@ -475,6 +584,7 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) :
repo.api.value = createApi(prefs.dataSource, carContext)
}
invalidate()
loadChargers()
}
private fun setupListeners() {
@@ -508,20 +618,9 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) :
chargers = null
availabilities.clear()
location = null
myLocationEnabled = false
removeListeners()
}
override fun onPause(owner: LifecycleOwner) {
super.onPause(owner)
map?.let {
prefs.currentMapLocation = it.cameraPosition.target
prefs.currentMapZoom = it.cameraPosition.zoom
}
prefs.currentMapMyLocationEnabled = myLocationEnabled
}
private fun removeListeners() {
if (supportsCarApiLevel3(carContext)) {
println("Removing energy level listener")
@@ -530,6 +629,17 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) :
}
}
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
@@ -551,7 +661,7 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) :
availabilityUpdateCoroutine = lifecycleScope.launch {
delay(300L)
val chargers = chargers?.filterIsInstance(ChargeLocation::class.java) ?: return@launch
val chargers = chargers ?: return@launch
if (chargers.isEmpty()) return@launch
val tasks = chargers.subList(
@@ -574,97 +684,4 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) :
availabilityUpdateCoroutine = null
}
}
override fun onMapReady(map: AnyMap) {
this.map = map
this.markerManager =
MarkerManager(
mapSurfaceCallback.presentation.context,
map,
this,
markerHeight = if (BuildConfig.FLAVOR_automotive == "automotive") 36 else 64
).apply {
this@MapScreen.chargers?.let { chargepoints = it }
onChargerClick = this@MapScreen::onChargerClick
onClusterClick = {
val newZoom = map.cameraPosition.zoom + 2
mapSurfaceCallback.animateCamera(
map.cameraUpdateFactory.newLatLngZoom(
LatLng(it.coordinates.lat, it.coordinates.lng),
newZoom
)
)
}
searchResult = prefs.placeSearchResultAndroidAuto
highlighedCharger = selectedCharger
}
map.setMyLocationEnabled(true)
map.uiSettings.setMyLocationButtonEnabled(false)
map.setAttributionClickListener { attributions ->
screenManager.push(MapAttributionScreen(carContext, attributions))
}
map.setOnMapClickListener {
clearSelectedCharger()
}
val mode = carContext.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK
map.setMapStyle(
if (mode == Configuration.UI_MODE_NIGHT_YES) AnyMap.Style.DARK else AnyMap.Style.NORMAL
)
prefs.placeSearchResultAndroidAuto?.let { place ->
// move to the location of the search result
myLocationEnabled = false
markerManager?.searchResult = place
if (place.viewport != null) {
map.moveCamera(map.cameraUpdateFactory.newLatLngBounds(place.viewport, 0))
} else {
map.moveCamera(map.cameraUpdateFactory.newLatLngZoom(place.latLng, 12f))
}
} ?: if (prefs.currentMapMyLocationEnabled) {
enableLocation(false)
} else {
// use position saved in preferences, fall back to default (Europe)
val cameraUpdate =
map.cameraUpdateFactory.newLatLngZoom(
prefs.currentMapLocation,
prefs.currentMapZoom
)
map.moveCamera(cameraUpdate)
}
mapSurfaceCallback.cameraMoveStartedListener = {
if (myLocationEnabled) {
myLocationEnabled = false
myLocationNeedsUpdate = true
}
}
mapSurfaceCallback.cameraIdleListener = {
loadChargers()
if (myLocationNeedsUpdate) {
invalidate()
myLocationNeedsUpdate = false
}
}
loadChargers()
}
private fun enableLocation(animated: Boolean) {
myLocationEnabled = true
myLocationNeedsUpdate = true
if (location != null) {
val map = map ?: return
val update = map.cameraUpdateFactory.newLatLngZoom(
LatLng.fromLocation(location),
13f
)
if (animated) {
mapSurfaceCallback.animateCamera(update)
} else {
map.moveCamera(update)
}
}
}
}

View File

@@ -1,284 +0,0 @@
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)
}
}

View File

@@ -11,34 +11,19 @@ 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.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.car.app.model.*
import androidx.core.content.ContextCompat
import androidx.core.graphics.drawable.IconCompat
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope
import com.car2go.maps.model.LatLng
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.*
import net.vonforst.evmap.BuildConfig
import net.vonforst.evmap.R
import net.vonforst.evmap.adapter.iconForPlaceType
import net.vonforst.evmap.adapter.isSpecialPlace
import net.vonforst.evmap.autocomplete.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.autocomplete.*
import net.vonforst.evmap.storage.AppDatabase
import net.vonforst.evmap.storage.PreferenceDataSource
import net.vonforst.evmap.storage.RecentAutocompletePlace
@@ -132,7 +117,7 @@ class PlaceSearchScreen(
setOnClickListener {
lifecycleScope.launch {
val placeDetails = getDetails(place.id) ?: return@launch
prefs.placeSearchResultAndroidAuto = placeDetails
prefs.placeSearchResultAndroidAuto = placeDetails.latLng
prefs.placeSearchResultAndroidAutoName =
place.primaryText.toString()
screenManager.popTo(MapScreen.MARKER)

View File

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

View File

@@ -79,7 +79,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,25 +114,22 @@ class SettingsScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx) {
}
.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()
.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()
@@ -147,7 +144,7 @@ class SettingsScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx) {
)
.setBrowsable(true)
.setOnClickListener {
screenManager.push(AboutScreen(carContext))
screenManager.push(AboutScreen(carContext, session))
}
.build()
)
@@ -156,7 +153,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) {
val prefs = PreferenceDataSource(ctx)
val encryptedPrefs = EncryptedPreferenceDataStore(ctx)
val db = AppDatabase.getInstance(ctx)
@@ -167,10 +165,6 @@ 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
@@ -210,25 +204,6 @@ class DataSettingsScreen(ctx: CarContext) : Screen(ctx) {
)
}
}.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 {
@@ -242,7 +217,7 @@ class DataSettingsScreen(ctx: CarContext) : Screen(ctx) {
}
}
}.build())
addItem(
/*addItem(
Row.Builder()
.setTitle(carContext.getString(R.string.pref_prediction_enabled))
.addText(carContext.getString(R.string.pref_prediction_enabled_summary))
@@ -250,7 +225,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(
@@ -305,7 +280,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(
@@ -367,33 +342,25 @@ class ChooseDataSourceScreen(
@StringRes val extraDesc: Int? = null
) : Screen(ctx) {
enum class Type {
CHARGER_DATA_SOURCE, SEARCH_PROVIDER, MAP_PROVIDER
CHARGER_DATA_SOURCE, SEARCH_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 = 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 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 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(
@@ -401,7 +368,6 @@ class ChooseDataSourceScreen(
carContext.getString(R.string.data_source_openchargemap_desc)
)
Type.SEARCH_PROVIDER -> null
Type.MAP_PROVIDER -> null
}
val callback: (String) -> Unit = when (type) {
Type.CHARGER_DATA_SOURCE -> { it ->
@@ -411,9 +377,6 @@ class ChooseDataSourceScreen(
Type.SEARCH_PROVIDER -> { it ->
prefs.searchProvider = it
}
Type.MAP_PROVIDER -> { it ->
prefs.mapProvider = it
}
}
override fun onGetTemplate(): Template {
@@ -790,7 +753,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)
@@ -835,7 +799,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(
@@ -846,12 +814,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,
@@ -863,39 +835,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)))
@@ -903,7 +911,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(
@@ -924,7 +933,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()

View File

@@ -4,26 +4,20 @@ 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
@@ -31,17 +25,11 @@ import androidx.core.content.ContextCompat
import androidx.core.graphics.drawable.IconCompat
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
@@ -214,7 +202,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 {
@@ -234,16 +222,14 @@ fun supportsCarApiLevel3(ctx: CarContext): Boolean {
return true
}
fun supportsNewMapScreen(ctx: CarContext) =
ctx.carAppApiLevel >= 7 && ctx.isAppDrivenRefreshSupported
fun openUrl(carContext: CarContext, url: String) {
@ExperimentalCarApi
fun openUrl(carContext: CarContext, cas: CarAppService, url: String) {
val intent = CustomTabsIntent.Builder()
.setDefaultColorSchemeParams(
CustomTabColorSchemeParams.Builder()
.setToolbarColor(
ContextCompat.getColor(
carContext,
cas,
R.color.colorPrimary
)
)
@@ -253,7 +239,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(
@@ -271,54 +257,6 @@ fun openUrl(carContext: CarContext, url: String) {
}
}
fun navigateToCharger(ctx: CarContext, charger: ChargeLocation) {
val success = navigateCarApp(ctx, charger)
if (!success && BuildConfig.FLAVOR_automotive == "automotive") {
// on AAOS, some OEMs' navigation apps might not support
navigateRegularApp(ctx, charger)
}
}
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
}
private fun navigateRegularApp(ctx: CarContext, 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(ctx.packageManager) != null) {
ctx.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.
@@ -343,49 +281,4 @@ 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()?.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
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -24,6 +24,7 @@ import android.widget.AdapterView
import android.widget.ImageView
import android.widget.TextView
import android.widget.Toast
import androidx.activity.BackEventCompat
import androidx.activity.OnBackPressedCallback
import androidx.activity.result.contract.ActivityResultContracts
import androidx.annotation.RequiresPermission
@@ -35,7 +36,6 @@ import androidx.core.view.MenuProvider
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.doOnLayout
import androidx.core.view.doOnNextLayout
import androidx.core.view.updateLayoutParams
import androidx.databinding.DataBindingUtil
import androidx.fragment.app.Fragment
@@ -58,7 +58,15 @@ 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.bottomsheet.BottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_COLLAPSED
import com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_HALF_EXPANDED
import com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_HIDDEN
import com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_SETTLING
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar
import com.google.android.material.transition.MaterialArcMotion
@@ -66,24 +74,20 @@ import com.google.android.material.transition.MaterialContainerTransform
import com.google.android.material.transition.MaterialContainerTransform.FADE_MODE_CROSS
import com.google.android.material.transition.MaterialFadeThrough
import com.google.android.material.transition.MaterialSharedAxis
import com.mahc.custombottomsheetbehavior.BottomSheetBehaviorGoogleMapsLike
import com.mahc.custombottomsheetbehavior.BottomSheetBehaviorGoogleMapsLike.BottomSheetCallback
import com.mahc.custombottomsheetbehavior.BottomSheetBehaviorGoogleMapsLike.STATE_ANCHOR_POINT
import com.mahc.custombottomsheetbehavior.BottomSheetBehaviorGoogleMapsLike.STATE_COLLAPSED
import com.mahc.custombottomsheetbehavior.BottomSheetBehaviorGoogleMapsLike.STATE_HIDDEN
import com.mahc.custombottomsheetbehavior.BottomSheetBehaviorGoogleMapsLike.STATE_SETTLING
import com.mahc.custombottomsheetbehavior.BottomSheetBehaviorGoogleMapsLike.from
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
import net.vonforst.evmap.adapter.DetailsAdapter
import net.vonforst.evmap.adapter.GalleryAdapter
import net.vonforst.evmap.adapter.PlaceAutocompleteAdapter
import net.vonforst.evmap.api.ChargepointList
import net.vonforst.evmap.api.chargeprice.ChargepriceApi
import net.vonforst.evmap.autocomplete.ApiUnavailableException
import net.vonforst.evmap.autocomplete.PlaceWithBounds
@@ -93,6 +97,7 @@ 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
@@ -101,8 +106,14 @@ 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.MarkerManager
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.setTouchModal
import net.vonforst.evmap.utils.boundingBox
import net.vonforst.evmap.utils.checkAnyLocationPermission
@@ -117,27 +128,68 @@ 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 bottomSheetBehavior: BottomSheetBehavior<View>
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 zoomInSnackbar: 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 bottomSheetBackPressedCallback = object : OnBackPressedCallback(false) {
override fun handleOnBackPressed() {
val state = bottomSheetBehavior.state
when (state) {
STATE_COLLAPSED -> vm.chargerSparse.value = null
STATE_HIDDEN -> return
else -> if (bottomSheetCollapsible) {
bottomSheetBehavior.state = STATE_COLLAPSED
} else {
vm.chargerSparse.value = null
}
}
bottomSheetBehavior.cancelBackProgress()
}
override fun handleOnBackStarted(backEvent: BackEventCompat) {
bottomSheetBehavior.startBackProgress(backEvent)
}
override fun handleOnBackProgressed(backEvent: BackEventCompat) {
bottomSheetBehavior.updateBackProgress(backEvent)
}
override fun handleOnBackCancelled() {
bottomSheetBehavior.cancelBackProgress()
}
}
private val backPressedCallback = object : OnBackPressedCallback(false) {
override fun handleOnBackPressed() {
val value = vm.layersMenuOpen.value
@@ -154,18 +206,10 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
if (binding.search.hasFocus()) {
removeSearchFocus()
return
}
val state = bottomSheetBehavior.state
when (state) {
STATE_COLLAPSED -> vm.chargerSparse.value = null
STATE_HIDDEN -> vm.searchResult.value = null
else -> if (bottomSheetCollapsible) {
bottomSheetBehavior.state = STATE_COLLAPSED
} else {
vm.chargerSparse.value = null
}
}
vm.searchResult.value = null
}
}
@@ -175,6 +219,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
prefs = PreferenceDataSource(requireContext())
locationEngine = FusionEngine(requireContext())
clusterIconGenerator = ClusterIconGenerator(requireContext())
enterTransition = MaterialFadeThrough()
exitTransition = MaterialFadeThrough()
@@ -187,9 +232,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
@@ -197,14 +242,10 @@ 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" -> MapFactory.MAPLIBRE
"google" -> MapFactory.GOOGLE
else -> null
},
getMapProvider(provider),
MapFactory.GOOGLE,
MapFactory.MAPLIBRE
)
@@ -213,11 +254,15 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
.replace(R.id.map, mapFragment!!, mapFragmentTag)
.commit()
// reset map-related stuff (map provider may have changed)
map = null
markerManager = null
markers.clear()
clusterMarkers = emptyList()
searchResultMarker = null
searchResultIcon = null
}
binding.detailAppBar.toolbar.popupTheme =
binding.detailView.toolbar.popupTheme =
com.google.android.material.R.style.ThemeOverlay_AppCompat_DayNight
ViewCompat.setOnApplyWindowInsetsListener(
@@ -226,9 +271,12 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
ViewCompat.onApplyWindowInsets(binding.root, insets)
val systemWindowInsetTop = insets.getInsets(WindowInsetsCompat.Type.statusBars()).top
binding.detailAppBar.toolbar.updateLayoutParams<ViewGroup.MarginLayoutParams> {
/*binding.detailView.toolbar.updateLayoutParams<ViewGroup.MarginLayoutParams> {
topMargin = systemWindowInsetTop
}
}*/
val insetsBottom = insets.getInsets(WindowInsetsCompat.Type.navigationBars()).bottom
bottomSheetBehavior.peekHeight = binding.detailView.topPart.bottom + insetsBottom
// margin of layers button: status bar height + toolbar height + margin
val density = resources.displayMetrics.density
@@ -261,10 +309,20 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
viewLifecycleOwner,
backPressedCallback
)
requireActivity().onBackPressedDispatcher.addCallback(
viewLifecycleOwner,
bottomSheetBackPressedCallback
)
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)
@@ -276,22 +334,20 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
requireActivity().addMenuProvider(this, viewLifecycleOwner, Lifecycle.State.RESUMED)
mapFragment!!.getMapAsync(this)
bottomSheetBehavior = from(binding.bottomSheet)
detailAppBarBehavior = MergedAppBarLayoutBehavior.from(binding.detailAppBar)
bottomSheetBehavior = BottomSheetBehavior.from(binding.detailView.root)
//detailAppBarBehavior = MergedAppBarLayoutBehavior.from(binding.detailAppBar)
binding.detailAppBar.toolbar.inflateMenu(R.menu.detail)
favToggle = binding.detailAppBar.toolbar.menu.findItem(R.id.menu_fav)
binding.detailView.toolbar.inflateMenu(R.menu.detail)
favToggle = binding.detailView.toolbar.menu.findItem(R.id.menu_fav)
vm.apiName.observe(viewLifecycleOwner) {
binding.detailAppBar.toolbar.menu.findItem(R.id.menu_edit).title =
binding.detailView.toolbar.menu.findItem(R.id.menu_edit).title =
getString(R.string.edit_at_datasource, it)
}
binding.detailView.topPart.doOnNextLayout {
bottomSheetBehavior.peekHeight = binding.detailView.topPart.bottom
vm.bottomSheetState.value?.let { bottomSheetBehavior.state = it }
}
bottomSheetBehavior.isCollapsible = bottomSheetCollapsible
vm.bottomSheetState.value?.let { bottomSheetBehavior.state = it }
bottomSheetBehavior.skipCollapsed = !bottomSheetCollapsible
bottomSheetBehavior.state = STATE_HIDDEN
binding.detailView.connectorDetails
setupObservers()
@@ -334,9 +390,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
@@ -360,9 +418,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()
) {
@@ -394,7 +449,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)
}
}
}
@@ -407,7 +462,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 {
@@ -420,12 +475,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(
@@ -433,7 +491,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())
@@ -442,7 +500,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
.show()
}
binding.detailView.topPart.setOnClickListener {
bottomSheetBehavior.state = STATE_ANCHOR_POINT
bottomSheetBehavior.state = STATE_HALF_EXPANDED
}
binding.detailView.topPart.setOnLongClickListener {
val charger = vm.charger.value?.data ?: return@setOnLongClickListener false
@@ -450,14 +508,14 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
return@setOnLongClickListener true
}
setupSearchAutocomplete()
binding.detailAppBar.toolbar.setNavigationOnClickListener {
binding.detailView.toolbar.setNavigationOnClickListener {
if (bottomSheetCollapsible) {
bottomSheetBehavior.state = STATE_COLLAPSED
} else {
vm.chargerSparse.value = null
}
}
binding.detailAppBar.toolbar.setOnMenuItemClickListener {
binding.detailView.toolbar.setOnMenuItemClickListener {
when (it.itemId) {
R.id.menu_fav -> {
toggleFavorite()
@@ -473,7 +531,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(
@@ -604,11 +662,21 @@ 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() {
bottomSheetBehavior.addBottomSheetCallback(object :
BottomSheetCallback() {
BottomSheetBehavior.BottomSheetCallback() {
override fun onSlide(bottomSheet: View, slideOffset: Float) {
if (bottomSheetBehavior.state == STATE_HIDDEN) {
map?.setPadding(0, mapTopPadding, 0, 0)
@@ -625,7 +693,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
override fun onStateChanged(bottomSheet: View, newState: Int) {
vm.bottomSheetState.value = newState
updateBackPressedCallback()
bottomSheetBackPressedCallback.isEnabled = newState != STATE_HIDDEN
if (vm.layersMenuOpen.value!! && newState !in listOf(
STATE_SETTLING,
@@ -637,7 +705,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
}
if (vm.selectedChargepoint.value != null && newState in listOf(
STATE_ANCHOR_POINT, STATE_COLLAPSED
STATE_HALF_EXPANDED, STATE_COLLAPSED
)
) {
closeConnectorDetailsDialog()
@@ -647,30 +715,29 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
})
vm.chargerSparse.observe(viewLifecycleOwner) {
if (it != null) {
if (vm.bottomSheetState.value != STATE_ANCHOR_POINT) {
if (vm.bottomSheetState.value != STATE_HALF_EXPANDED) {
bottomSheetBehavior.state =
if (bottomSheetCollapsible) STATE_COLLAPSED else STATE_ANCHOR_POINT
if (bottomSheetCollapsible) STATE_COLLAPSED else STATE_HALF_EXPANDED
}
removeSearchFocus()
binding.fabDirections.show()
detailAppBarBehavior.setToolbarTitle(it.name)
//detailAppBarBehavior.setToolbarTitle(it.name)
updateFavoriteToggle()
markerManager?.highlighedCharger = it
markerManager?.animateBounce(it)
highlightMarker(it)
} else {
bottomSheetBehavior.state = STATE_HIDDEN
markerManager?.highlighedCharger = null
unhighlightAllMarkers()
}
}
vm.chargepoints.observe(viewLifecycleOwner, Observer { res ->
val chargepoints = res.data
if (chargepoints != null) {
markerManager?.chargepoints = chargepoints
updateMap(chargepoints.items)
}
val view = view ?: return@Observer
when (res.status) {
Status.ERROR -> {
val view = view ?: return@Observer
zoomInSnackbar?.dismiss()
connectionErrorSnackbar?.dismiss()
connectionErrorSnackbar = Snackbar
.make(view, R.string.connection_error, Snackbar.LENGTH_INDEFINITE)
@@ -682,20 +749,23 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
}
Status.SUCCESS -> {
connectionErrorSnackbar?.dismiss()
if (res.data != null && !res.data.isComplete) {
zoomInSnackbar?.dismiss()
zoomInSnackbar = Snackbar
.make(view, R.string.zoom_in_to_see_more, Snackbar.LENGTH_INDEFINITE)
zoomInSnackbar!!.show()
}
}
Status.LOADING -> {
zoomInSnackbar?.dismiss()
}
}
})
vm.useMiniMarkers.observe(viewLifecycleOwner) {
markerManager?.mini = it
}
vm.filteredConnectors.observe(viewLifecycleOwner) {
markerManager?.filteredConnectors = it
vm.chargepoints.value?.data?.let { updateMap(it.items) }
}
vm.favorites.observe(viewLifecycleOwner) {
updateFavoriteToggle()
markerManager?.favorites = it.map { it.favorite.chargerId }.toSet()
}
vm.searchResult.observe(viewLifecycleOwner) { place ->
displaySearchResult(place, moveCamera = true)
@@ -718,7 +788,6 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
if (it != null) {
detailsDialog.setData(it, vm.availability.value?.data)
}
updateBackPressedCallback()
}
updateBackPressedCallback()
@@ -726,7 +795,8 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
private fun displaySearchResult(place: PlaceWithBounds?, moveCamera: Boolean) {
val map = this.map ?: return
markerManager?.searchResult = place
searchResultMarker?.remove()
searchResultMarker = null
if (place != null) {
// disable location following when search result is shown
@@ -738,6 +808,18 @@ 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("")
}
@@ -746,12 +828,56 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
}
private fun updateBackPressedCallback() {
backPressedCallback.isEnabled =
vm.bottomSheetState.value != null && vm.bottomSheetState.value != STATE_HIDDEN
|| vm.searchResult.value != null
backPressedCallback.isEnabled = vm.searchResult.value != null
|| (vm.layersMenuOpen.value ?: false)
|| binding.search.hasFocus()
|| 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() {
@@ -824,10 +950,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 -> {
@@ -835,7 +965,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
)
}
}
}
}
@@ -954,48 +1089,47 @@ 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
markerManager = MarkerManager(context, map, this).apply {
onChargerClick = {
vm.chargerSparse.value = it
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] == MapFactory.GOOGLE) {
// Google Maps: icons can be generated in background thread
lifecycleScope.launch {
withContext(Dispatchers.Default) {
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()
} else {
// MapLibre: needs to be run on main thread
chargerIconGenerator.preloadCache()
}
animator = MarkerAnimator(chargerIconGenerator)
map.uiSettings.setTiltGesturesEnabled(false)
map.uiSettings.setRotateGesturesEnabled(prefs.mapRotateGesturesEnabled)
map.setIndoorEnabled(false)
map.uiSettings.setIndoorLevelPickerEnabled(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
)
@@ -1018,7 +1152,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 ->
@@ -1034,9 +1169,33 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
}
}
}
map.setOnMarkerClickListener { marker ->
val map = this@MapFragment.map ?: return@setOnMarkerClickListener false
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
}
searchResultMarker -> true
else -> false
}
}
map.setOnMapClickListener {
if (backPressedCallback.isEnabled) {
backPressedCallback.handleOnBackPressed()
} else if (bottomSheetBackPressedCallback.isEnabled) {
bottomSheetBackPressedCallback.handleOnBackPressed()
}
}
map.setMapType(vm.mapType.value)
@@ -1091,10 +1250,10 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
// show charger detail after chargers were loaded
vm.chargepoints.observe(
viewLifecycleOwner,
object : Observer<Resource<List<ChargepointListItem>>> {
override fun onChanged(value: Resource<List<ChargepointListItem>>) {
object : Observer<Resource<ChargepointList>> {
override fun onChanged(value: Resource<ChargepointList>) {
if (value.data == null) return
for (item in value.data) {
for (item in value.data.items) {
if (item is ChargeLocation && item.id == chargerId) {
vm.chargerSparse.value = item
vm.chargepoints.removeObserver(this)
@@ -1114,9 +1273,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)
@@ -1165,6 +1328,111 @@ 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)
@@ -1316,10 +1584,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(
@@ -1369,8 +1633,17 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
}
}
override fun onDestroy() {
super.onDestroy()
override fun onDestroyView() {
super.onDestroyView()
detailsDialog.onDestroy()
map = null
mapFragment = null
_binding = null
markers.clear()
clusterMarkers = emptyList()
searchResultMarker = null
searchResultIcon = 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()

View File

@@ -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
}
}
}
@@ -234,7 +241,7 @@ 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(

View File

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

View File

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

View File

@@ -18,6 +18,7 @@ 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
@@ -140,9 +141,9 @@ 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)}"
}
}
}
@@ -413,12 +414,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"
}

View File

@@ -19,6 +19,8 @@ import net.vonforst.evmap.api.goingelectric.GoingElectricApiWrapper
import net.vonforst.evmap.api.openchargemap.OpenChargeMapApiWrapper
import net.vonforst.evmap.model.*
import net.vonforst.evmap.ui.cluster
import net.vonforst.evmap.utils.crossesAntimeridian
import net.vonforst.evmap.utils.splitAtAntimeridian
import net.vonforst.evmap.viewmodel.Resource
import net.vonforst.evmap.viewmodel.Status
import net.vonforst.evmap.viewmodel.await
@@ -144,7 +146,15 @@ class ChargeLocationsRepository(
zoom: Float,
filters: FilterValues?,
overrideCache: Boolean = false
): LiveData<Resource<List<ChargepointListItem>>> {
): LiveData<Resource<ChargepointList>> {
if (bounds.crossesAntimeridian()) {
val (a, b) = bounds.splitAtAntimeridian()
val liveDataA = getChargepoints(a, zoom, filters, overrideCache)
val liveDataB = getChargepoints(b, zoom, filters, overrideCache)
return combineLiveData(liveDataA, liveDataB)
}
val api = api.value!!
val dbResult = if (filters == null) {
@@ -158,7 +168,7 @@ class ChargeLocationsRepository(
)
} else {
queryWithFilters(api, filters, bounds)
}.map { applyLocalClustering(it, zoom) }
}.map { ChargepointList(applyLocalClustering(it, zoom), true) }
val filtersSerialized =
filters?.filter { it.value != it.filter.defaultValue() }?.takeIf { it.isNotEmpty() }
?.serialize()
@@ -208,12 +218,41 @@ class ChargeLocationsRepository(
}
}
private fun combineLiveData(
liveDataA: LiveData<Resource<ChargepointList>>,
liveDataB: LiveData<Resource<ChargepointList>>
) = MediatorLiveData<Resource<ChargepointList>>().apply {
listOf(liveDataA, liveDataB).forEach {
addSource(it) {
val valA = liveDataA.value
val valB = liveDataB.value
val combinedList = if (valA?.data != null && valB?.data != null) {
ChargepointList(
valA.data.items + valB.data.items,
valA.data.isComplete && valB.data.isComplete
)
} else if (valA?.data != null) {
ChargepointList(valA.data.items, false)
} else if (valB?.data != null) {
ChargepointList(valB.data.items, false)
} else null
if (valA?.status == Status.SUCCESS && valB?.status == Status.SUCCESS) {
Resource.success(combinedList)
} else if (valA?.status == Status.ERROR || valB?.status == Status.ERROR) {
Resource.error(valA?.message ?: valB?.message, combinedList)
} else {
Resource.loading(combinedList)
}
}
}
}
fun getChargepointsRadius(
location: LatLng,
radius: Int,
zoom: Float,
filters: FilterValues?
): LiveData<Resource<List<ChargepointListItem>>> {
): LiveData<Resource<ChargepointList>> {
val api = api.value!!
val radiusMeters = radius.toDouble() * 1000
@@ -227,7 +266,7 @@ class ChargeLocationsRepository(
)
} else {
queryWithFilters(api, filters, location, radiusMeters)
}.map { applyLocalClustering(it, zoom) }
}.map { ChargepointList(applyLocalClustering(it, zoom), true) }
val filtersSerialized =
filters?.filter { it.value != it.filter.defaultValue() }?.takeIf { it.isNotEmpty() }
?.serialize()
@@ -277,18 +316,18 @@ class ChargeLocationsRepository(
private fun applyLocalClustering(
result: Resource<ChargepointList>,
zoom: Float
): Resource<List<ChargepointListItem>> {
): Resource<ChargepointList> {
val list = result.data ?: return Resource(result.status, null, result.message)
val chargers = list.items.filterIsInstance<ChargeLocation>()
if (chargers.size != list.items.size) return Resource(
result.status,
list.items,
list,
result.message
) // list already contains clusters
val clustered = applyLocalClustering(chargers, zoom)
return Resource(result.status, clustered, result.message)
return Resource(result.status, ChargepointList(clustered, list.isComplete), result.message)
}
private fun applyLocalClustering(

View File

@@ -6,9 +6,7 @@ import android.content.SharedPreferences.Editor
import androidx.preference.PreferenceManager
import com.car2go.maps.AnyMap
import com.car2go.maps.model.LatLng
import com.car2go.maps.model.LatLngBounds
import net.vonforst.evmap.R
import net.vonforst.evmap.autocomplete.PlaceWithBounds
import net.vonforst.evmap.model.FILTERS_CUSTOM
import net.vonforst.evmap.model.FILTERS_DISABLED
import java.time.Instant
@@ -110,14 +108,11 @@ class PreferenceDataSource(val context: Context) {
val darkmode: String
get() = sp.getString("darkmode", "default")!!
var mapProvider: String
val mapProvider: String
get() = sp.getString(
"map_provider",
context.getString(R.string.pref_map_provider_default)
)!!
set(value) {
sp.edit().putString("map_provider", value).apply()
}
var searchProvider: String
get() = sp.getString(
@@ -255,16 +250,10 @@ class PreferenceDataSource(val context: Context) {
.apply()
}
var placeSearchResultAndroidAuto: PlaceWithBounds?
get() {
val latLng = sp.getLatLng("place_search_result_android_auto")
val bounds = sp.getLatLngBounds("place_search_result_android_auto_viewport")
return latLng?.let { PlaceWithBounds(latLng, bounds) }
}
var placeSearchResultAndroidAuto: LatLng?
get() = sp.getLatLng("place_search_result_android_auto")
set(value) {
sp.edit().putLatLng("place_search_result_android_auto", value?.latLng).apply()
sp.edit().putLatLngBounds("place_search_result_android_auto_viewport", value?.viewport)
.apply()
sp.edit().putLatLng("place_search_result_android_auto", value).apply()
}
var placeSearchResultAndroidAutoName: String?
@@ -326,7 +315,7 @@ class PreferenceDataSource(val context: Context) {
}
fun SharedPreferences.getLatLng(key: String): LatLng? =
if (containsLatLng(key)) {
if (contains("${key}_lat") && contains("${key}_lng")) {
LatLng(
Double.fromBits(getLong("${key}_lat", 0L)),
Double.fromBits(getLong("${key}_lng", 0L))
@@ -343,23 +332,3 @@ fun Editor.putLatLng(key: String, value: LatLng?): Editor {
}
return this
}
fun SharedPreferences.containsLatLng(key: String) = contains("${key}_lat") && contains("${key}_lng")
fun SharedPreferences.getLatLngBounds(key: String): LatLngBounds? =
if (containsLatLng("${key}_sw") && containsLatLng("${key}_ne")) {
LatLngBounds(
getLatLng("${key}_sw"), getLatLng("${key}_ne")
)
} else null
fun Editor.putLatLngBounds(key: String, value: LatLngBounds?): Editor {
if (value == null) {
putLatLng("${key}_sw", null)
putLatLng("${key}_ne", null)
} else {
putLatLng("${key}_sw", value.southwest)
putLatLng("${key}_ne", value.northeast)
}
return this
}

View File

@@ -117,6 +117,16 @@ class Converters {
return stringSetAdapter.fromJson(value)
}
@TypeConverter
fun fromStringMutableSet(value: MutableSet<String>?): String {
return stringSetAdapter.toJson(value)
}
@TypeConverter
fun toStringMutableSet(value: String): MutableSet<String>? {
return stringSetAdapter.fromJson(value)?.toMutableSet()
}
@TypeConverter
fun fromStringList(value: List<String>?): String {
return stringListAdapter.toJson(value)

View File

@@ -6,8 +6,9 @@ import android.graphics.Color
import android.graphics.drawable.ColorDrawable
import android.graphics.drawable.Drawable
import android.graphics.drawable.LayerDrawable
import android.text.SpannableString
import android.text.format.DateUtils
import android.text.method.LinkMovementMethod
import android.text.util.Linkify
import android.view.View
import android.view.ViewGroup.MarginLayoutParams
import android.widget.ImageView
@@ -215,21 +216,25 @@ fun setTopMargin(view: View, topMargin: Float) {
/**
* Linkify is already possible using the autoLink and linksClickable attributes, but this does not
* remove spans correctly. So we implement a new version that manually removes the spans.
* remove spans correctly after autoLink is set to false.
* So we implement a new version that manually uses Linkify to create links if necessary.
*/
@BindingAdapter("linkify")
fun setLinkify(textView: TextView, oldValue: Int, newValue: Int) {
if (oldValue == newValue) return
@BindingAdapter(value = ["linkify", "android:text"])
fun setLinkify(
textView: TextView,
oldLinkify: Int,
oldText: CharSequence?,
newLinkify: Int,
newText: CharSequence?
) {
if (oldLinkify == newLinkify && oldText == newText) return
textView.autoLinkMask = newValue
textView.linksClickable = newValue != 0
// remove spans
val text = textView.text
if (newValue == 0 && text != null && text is SpannableString) {
text.getSpans(0, text.length, Any::class.java).forEach {
text.removeSpan(it)
}
textView.text = newText
if (newLinkify != 0) {
Linkify.addLinks(textView, newLinkify)
textView.movementMethod = LinkMovementMethod.getInstance()
} else {
textView.movementMethod = null
}
}

View File

@@ -1,112 +0,0 @@
package net.vonforst.evmap.ui
import android.content.Context
import android.util.AttributeSet
import android.view.View
import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.core.view.ViewCompat
import androidx.core.widget.NestedScrollView
import com.google.android.material.floatingactionbutton.FloatingActionButton
import com.mahc.custombottomsheetbehavior.BottomSheetBehaviorGoogleMapsLike
class HideOnExpandFabBehavior(context: Context, attrs: AttributeSet) :
FloatingActionButton.Behavior(context, attrs) {
override fun onStartNestedScroll(
coordinatorLayout: CoordinatorLayout,
child: FloatingActionButton,
directTargetChild: View,
target: View,
axes: Int,
type: Int
): Boolean {
return axes == ViewCompat.SCROLL_AXIS_VERTICAL || super.onStartNestedScroll(
coordinatorLayout,
child,
directTargetChild,
target,
axes,
type
)
}
override fun layoutDependsOn(
parent: CoordinatorLayout,
child: FloatingActionButton,
dependency: View
): Boolean {
if (dependency is NestedScrollView) {
try {
val behavior = BottomSheetBehaviorGoogleMapsLike.from<View>(dependency)
behavior.addBottomSheetCallback(object :
BottomSheetBehaviorGoogleMapsLike.BottomSheetCallback() {
override fun onSlide(bottomSheet: View, slideOffset: Float) {
}
override fun onStateChanged(bottomSheet: View, newState: Int) {
onDependentViewChanged(parent, child, dependency)
}
})
return true
} catch (e: IllegalArgumentException) {
}
}
return false
}
override fun onDependentViewChanged(
parent: CoordinatorLayout,
child: FloatingActionButton,
dependency: View
): Boolean {
val behavior = BottomSheetBehaviorGoogleMapsLike.from<View>(dependency)
when (behavior.state) {
BottomSheetBehaviorGoogleMapsLike.STATE_SETTLING -> {
}
BottomSheetBehaviorGoogleMapsLike.STATE_HIDDEN -> {
if (child.tag as? Boolean != false) child.show()
}
BottomSheetBehaviorGoogleMapsLike.STATE_COLLAPSED -> {
if (child.tag as? Boolean != false) child.show()
}
else -> {
child.hide()
}
}
return false
}
override fun onNestedScroll(
coordinatorLayout: CoordinatorLayout,
child: FloatingActionButton,
target: View,
dxConsumed: Int,
dyConsumed: Int,
dxUnconsumed: Int,
dyUnconsumed: Int,
type: Int,
consumed: IntArray
) {
super.onNestedScroll(
coordinatorLayout,
child,
target,
dxConsumed,
dyConsumed,
dxUnconsumed,
dyUnconsumed,
type,
consumed
)
if (dyConsumed > 0 && child.visibility == View.VISIBLE) {
// User scrolled down and the FAB is currently visible -> hide the FAB
child.hide()
} else if (dyConsumed < 0 && child.visibility != View.VISIBLE) {
// User scrolled up and the FAB is currently not visible -> show the FAB
child.show()
}
}
}

View File

@@ -6,8 +6,8 @@ import android.view.View
import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.core.view.ViewCompat
import androidx.core.widget.NestedScrollView
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.floatingactionbutton.FloatingActionButton
import com.mahc.custombottomsheetbehavior.BottomSheetBehaviorGoogleMapsLike
class HideOnScrollFabBehavior(context: Context, attrs: AttributeSet) :
@@ -45,9 +45,9 @@ class HideOnScrollFabBehavior(context: Context, attrs: AttributeSet) :
): Boolean {
if (dependency is NestedScrollView) {
try {
val behavior = BottomSheetBehaviorGoogleMapsLike.from<View>(dependency)
val behavior = BottomSheetBehavior.from<View>(dependency)
behavior.addBottomSheetCallback(object :
BottomSheetBehaviorGoogleMapsLike.BottomSheetCallback() {
BottomSheetBehavior.BottomSheetCallback() {
override fun onSlide(bottomSheet: View, slideOffset: Float) {
}
@@ -68,12 +68,13 @@ class HideOnScrollFabBehavior(context: Context, attrs: AttributeSet) :
child: FloatingActionButton,
dependency: View
): Boolean {
val behavior = BottomSheetBehaviorGoogleMapsLike.from(dependency)
val behavior = BottomSheetBehavior.from(dependency)
when (behavior.state) {
BottomSheetBehaviorGoogleMapsLike.STATE_SETTLING -> {
BottomSheetBehavior.STATE_SETTLING -> {
}
BottomSheetBehaviorGoogleMapsLike.STATE_HIDDEN -> {
BottomSheetBehavior.STATE_HIDDEN -> {
if (!hidden) child.show()
}
else -> {

View File

@@ -1,29 +1,13 @@
package net.vonforst.evmap.ui
import android.animation.ValueAnimator
import android.content.Context
import android.view.animation.BounceInterpolator
import androidx.core.animation.addListener
import androidx.interpolator.view.animation.FastOutLinearInInterpolator
import androidx.interpolator.view.animation.LinearOutSlowInInterpolator
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope
import com.car2go.maps.AnyMap
import com.car2go.maps.model.LatLng
import com.car2go.maps.model.Marker
import com.car2go.maps.model.MarkerOptions
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.R
import net.vonforst.evmap.autocomplete.PlaceWithBounds
import net.vonforst.evmap.model.ChargeLocation
import net.vonforst.evmap.model.ChargeLocationCluster
import net.vonforst.evmap.model.ChargepointListItem
import net.vonforst.evmap.storage.PreferenceDataSource
import kotlin.math.max
fun getMarkerTint(
@@ -45,217 +29,6 @@ val chargerZ = 1
val clusterZ = chargerZ + 1
val placeSearchZ = clusterZ + 1
class MarkerManager(
val context: Context,
val map: AnyMap,
val lifecycle: LifecycleOwner,
markerHeight: Int = 48
) {
private val clusterIconGenerator = ClusterIconGenerator(context)
private val chargerIconGenerator =
ChargerIconGenerator(context, map.bitmapDescriptorFactory, height = markerHeight)
private val prefs = PreferenceDataSource(context)
private val animator = MarkerAnimator(chargerIconGenerator)
private var markers: MutableBiMap<Marker, ChargeLocation> = HashBiMap()
private var clusterMarkers: MutableBiMap<Marker, ChargeLocationCluster> = HashBiMap()
private var searchResultMarker: Marker? = null
private var searchResultIcon =
map.bitmapDescriptorFactory.fromResource(R.drawable.ic_map_marker)
var mini = false
var filteredConnectors: Set<String>? = null
var onChargerClick: ((ChargeLocation) -> Unit)? = null
var onClusterClick: ((ChargeLocationCluster) -> Unit)? = null
var chargepoints: List<ChargepointListItem> = emptyList()
@Synchronized set(value) {
field = value
updateChargepoints()
}
var highlighedCharger: ChargeLocation? = null
set(value) {
field = value
updateChargerIcons()
}
var searchResult: PlaceWithBounds? = null
set(value) {
field = value
updateSearchResultMarker()
}
var favorites: Set<Long> = emptySet()
set(value) {
field = value
updateChargerIcons()
}
init {
map.setOnMarkerClickListener { marker ->
when (marker) {
in markers -> {
val charger = markers[marker] ?: return@setOnMarkerClickListener false
onChargerClick?.invoke(charger)
true
}
in clusterMarkers -> {
val cluster = clusterMarkers[marker] ?: return@setOnMarkerClickListener false
onClusterClick?.invoke(cluster)
true
}
else -> false
}
}
if (BuildConfig.FLAVOR.contains("google") && prefs.mapProvider == "google") {
// Google Maps: icons can be generated in background thread
lifecycle.lifecycleScope.launch {
withContext(Dispatchers.Default) {
chargerIconGenerator.preloadCache()
}
}
} else {
// MapLibre: needs to be run on main thread
chargerIconGenerator.preloadCache()
}
}
fun animateBounce(charger: ChargeLocation) {
val marker = markers.inverse[charger] ?: return
animator.animateMarkerBounce(marker, mini)
}
private fun updateSearchResultMarker() {
searchResultMarker?.remove()
searchResultMarker = null
searchResult?.let {
searchResultMarker = map.addMarker(
MarkerOptions()
.z(placeSearchZ)
.position(it.latLng)
.icon(searchResultIcon)
.anchor(0.5f, 1f)
)
}
}
private fun updateChargepoints() {
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)
updateChargerIcons()
if (chargers.toSet() != markers.values) {
// remove markers that disappeared
val bounds = map.projection.visibleRegion.latLngBounds
markers.entries.toList().forEach { (marker, charger) ->
if (!chargepointIds.contains(charger.id)) {
// animate marker if it is visible, otherwise remove immediately
if (bounds.contains(marker.position)) {
animateMarker(charger, marker, false)
} 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 marker = map.addMarker(
MarkerOptions()
.position(LatLng(charger.coordinates.lat, charger.coordinates.lng))
.z(chargerZ)
.icon(makeIcon(charger))
.anchor(0.5f, if (mini) 0.5f else 1f)
)
animateMarker(charger, marker, true)
markers[marker] = charger
}
}
}
if (clusters.toSet() != clusterMarkers.values) {
// remove clusters that disappeared
clusterMarkers.entries.toList().forEach { (marker, cluster) ->
if (!clusters.contains(cluster)) {
marker.remove()
clusterMarkers.remove(marker)
}
}
// add new clusters
clusters.forEach { cluster ->
if (!clusterMarkers.inverse.contains(cluster)) {
val marker = 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)
)
clusterMarkers[marker] = cluster
}
}
}
}
private fun updateChargerIcons() {
markers.forEach { (m, c) ->
m.setIcon(makeIcon(c))
m.setAnchor(0.5f, if (mini) 0.5f else 1f)
}
}
private fun updateSingleChargerIcon(charger: ChargeLocation) {
markers.inverse[charger]?.apply {
setIcon(makeIcon(charger))
setAnchor(0.5f, if (mini) 0.5f else 1f)
}
}
private fun makeIcon(
charger: ChargeLocation,
scale: Float = 1f
) = chargerIconGenerator.getBitmapDescriptor(
getMarkerTint(charger, filteredConnectors),
scale = scale,
highlight = charger.id == highlighedCharger?.id,
fault = charger.faultReport != null,
multi = charger.isMulti(filteredConnectors),
fav = charger.id in favorites,
mini = mini
)
private fun animateMarker(charger: ChargeLocation, marker: Marker, appear: Boolean) {
val tint = getMarkerTint(charger, filteredConnectors)
val highlight = charger.id == highlighedCharger?.id
val fault = charger.faultReport != null
val multi = charger.isMulti(filteredConnectors)
val fav = charger.id in favorites
if (appear) {
animator.animateMarkerAppear(marker, tint, highlight, fault, multi, fav, mini)
} else {
animator.animateMarkerDisappear(marker, tint, highlight, fault, multi, fav, mini)
}
}
}
class MarkerAnimator(val gen: ChargerIconGenerator) {
private val animatingMarkers = hashMapOf<Marker, ValueAnimator>()

View File

@@ -5,12 +5,20 @@ import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.location.Location
import android.text.BidiFormatter
import androidx.core.content.ContextCompat
import com.car2go.maps.model.LatLng
import com.car2go.maps.model.LatLngBounds
import net.vonforst.evmap.model.Coordinate
import java.util.*
import kotlin.math.*
import java.util.Locale
import kotlin.math.abs
import kotlin.math.asin
import kotlin.math.atan2
import kotlin.math.cos
import kotlin.math.floor
import kotlin.math.pow
import kotlin.math.sin
import kotlin.math.sqrt
/**
* Adds a certain distance in meters to a location. Approximate calculation.
@@ -147,9 +155,32 @@ private fun dms(value: Double, lon: Boolean): String {
}
fun Coordinate.formatDecimal(accuracy: Int = 6): String {
return "%.${accuracy}f, %.${accuracy}f".format(Locale.ENGLISH, lat, lng)
return BidiFormatter.getInstance()
.unicodeWrap("%.${accuracy}f, %.${accuracy}f".format(Locale.ENGLISH, lat, lng))
}
fun Location.formatDecimal(accuracy: Int = 6): String {
return "%.${accuracy}f, %.${accuracy}f".format(Locale.ENGLISH, latitude, longitude)
return BidiFormatter.getInstance()
.unicodeWrap("%.${accuracy}f, %.${accuracy}f".format(Locale.ENGLISH, latitude, longitude))
}
fun LatLngBounds.normalize() = LatLngBounds(
LatLng(southwest.latitude, normalizeLongitude(southwest.longitude)),
LatLng(northeast.latitude, normalizeLongitude(northeast.longitude)),
)
private fun normalizeLongitude(long: Double) =
if (-180.0 <= long && long <= 180.0) long else (long + 180) % 360 - 180
fun LatLngBounds.crossesAntimeridian() = southwest.longitude > 0 && northeast.longitude < 0
fun LatLngBounds.splitAtAntimeridian(): Pair<LatLngBounds, LatLngBounds> {
if (!crossesAntimeridian()) throw IllegalArgumentException("does not cross antimeridian")
return LatLngBounds(
LatLng(southwest.latitude, southwest.longitude),
LatLng(northeast.latitude, 180.0),
) to LatLngBounds(
LatLng(southwest.latitude, -180.0),
LatLng(northeast.latitude, northeast.longitude),
)
}

View File

@@ -1,14 +1,29 @@
package net.vonforst.evmap.viewmodel
import android.app.Application
import androidx.lifecycle.*
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.LiveData
import androidx.lifecycle.MediatorLiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.distinctUntilChanged
import androidx.lifecycle.viewModelScope
import jsonapi.Meta
import jsonapi.Relationship
import jsonapi.Relationships
import jsonapi.ResourceIdentifier
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
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.model.ChargeLocation
import net.vonforst.evmap.model.Chargepoint
@@ -298,4 +313,8 @@ class ChargepriceViewModel(
}
}
}
fun resetBatteryRangeToDefault() {
batteryRange.value = prefs.chargepriceBatteryRangeAndroidAuto
}
}

View File

@@ -1,7 +1,6 @@
package net.vonforst.evmap.viewmodel
import android.app.Application
import android.graphics.Point
import android.os.Parcelable
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.LiveData
@@ -14,20 +13,22 @@ import androidx.lifecycle.map
import androidx.lifecycle.switchMap
import androidx.lifecycle.viewModelScope
import com.car2go.maps.AnyMap
import com.car2go.maps.Projection
import com.car2go.maps.model.LatLng
import com.car2go.maps.model.LatLngBounds
import com.mahc.custombottomsheetbehavior.BottomSheetBehaviorGoogleMapsLike
import com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_COLLAPSED
import com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_EXPANDED
import com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_HALF_EXPANDED
import com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_HIDDEN
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.parcelize.Parcelize
import net.vonforst.evmap.api.ChargepointList
import net.vonforst.evmap.api.availability.AvailabilityRepository
import net.vonforst.evmap.api.availability.ChargeLocationStatus
import net.vonforst.evmap.api.availability.tesla.Pricing
import net.vonforst.evmap.api.createApi
import net.vonforst.evmap.api.fronyx.PredictionData
import net.vonforst.evmap.api.fronyx.PredictionRepository
import net.vonforst.evmap.api.goingelectric.GEChargepoint
import net.vonforst.evmap.api.openchargemap.OCMConnection
import net.vonforst.evmap.api.openchargemap.OCMReferenceData
@@ -35,7 +36,6 @@ import net.vonforst.evmap.api.stringProvider
import net.vonforst.evmap.autocomplete.PlaceWithBounds
import net.vonforst.evmap.model.ChargeLocation
import net.vonforst.evmap.model.Chargepoint
import net.vonforst.evmap.model.ChargepointListItem
import net.vonforst.evmap.model.FILTERS_DISABLED
import net.vonforst.evmap.model.FILTERS_FAVORITES
import net.vonforst.evmap.model.Favorite
@@ -51,7 +51,8 @@ import net.vonforst.evmap.storage.FilterProfile
import net.vonforst.evmap.storage.PreferenceDataSource
import net.vonforst.evmap.ui.cluster
import net.vonforst.evmap.utils.distanceBetween
import kotlin.math.roundToInt
import net.vonforst.evmap.utils.normalize
import kotlin.math.cos
@Parcelize
data class MapPosition(val bounds: LatLngBounds, val zoom: Float) : Parcelable
@@ -77,7 +78,6 @@ class MapViewModel(application: Application, private val state: SavedStateHandle
prefs
)
private val availabilityRepo = AvailabilityRepository(application)
var mapProjection: Projection? = null
val apiId = repo.api.map { it.id }
@@ -95,12 +95,13 @@ class MapViewModel(application: Application, private val state: SavedStateHandle
val bottomSheetExpanded = MediatorLiveData<Boolean>().apply {
addSource(bottomSheetState) {
when (it) {
BottomSheetBehaviorGoogleMapsLike.STATE_COLLAPSED,
BottomSheetBehaviorGoogleMapsLike.STATE_HIDDEN -> {
STATE_COLLAPSED,
STATE_HIDDEN -> {
value = false
}
BottomSheetBehaviorGoogleMapsLike.STATE_EXPANDED,
BottomSheetBehaviorGoogleMapsLike.STATE_ANCHOR_POINT -> {
STATE_EXPANDED,
STATE_HALF_EXPANDED -> {
value = true
}
}
@@ -144,10 +145,10 @@ class MapViewModel(application: Application, private val state: SavedStateHandle
}
}
}
val chargepoints: MediatorLiveData<Resource<List<ChargepointListItem>>> by lazy {
MediatorLiveData<Resource<List<ChargepointListItem>>>()
val chargepoints: MediatorLiveData<Resource<ChargepointList>> by lazy {
MediatorLiveData<Resource<ChargepointList>>()
.apply {
value = Resource.loading(emptyList())
value = Resource.loading(ChargepointList(emptyList(), false))
// this is not automatically updated with mapPosition, as we only want to update
// when map is idle.
listOf(filtersWithValue, repo.api).forEach {
@@ -266,13 +267,14 @@ class MapViewModel(application: Application, private val state: SavedStateHandle
it.data?.extraData as? Pricing
}
private val predictionRepository = PredictionRepository(application)
//private val predictionRepository = PredictionRepository(application)
val predictionData: LiveData<PredictionData> = availability.switchMap { av ->
liveData {
/*liveData {
val charger = charger.value?.data ?: return@liveData
emit(predictionRepository.getPredictionData(charger, av.data, filteredConnectors.value))
}
}*/
MutableLiveData()
}
val myLocationEnabled: MutableLiveData<Boolean> by lazy {
@@ -285,7 +287,7 @@ class MapViewModel(application: Application, private val state: SavedStateHandle
}
val favorites: LiveData<List<FavoriteWithDetail>> by lazy {
db.favoritesDao().getAllFavorites().distinctUntilChanged()
db.favoritesDao().getAllFavorites()
}
val searchResult: MutableLiveData<PlaceWithBounds> by lazy {
@@ -393,7 +395,7 @@ class MapViewModel(application: Application, private val state: SavedStateHandle
}
}.distinctUntilChanged()
private var chargepointsInternal: LiveData<Resource<List<ChargepointListItem>>>? = null
private var chargepointsInternal: LiveData<Resource<ChargepointList>>? = null
private var chargepointLoader =
throttleLatest(
500L,
@@ -420,13 +422,13 @@ class MapViewModel(application: Application, private val state: SavedStateHandle
filteredConnectors.value = null
filteredMinPower.value = null
filteredChargeCards.value = null
chargepoints.value = Resource.success(chargersClustered)
chargepoints.value = Resource.success(ChargepointList(chargersClustered, true))
return@throttleLatest
}
val result = repo.getChargepoints(bounds, mapPosition.zoom, filters, overrideCache)
chargepointsInternal?.let { chargepoints.removeSource(it) }
chargepointsInternal = result
chargepointsInternal
chargepoints.addSource(result) {
val apiId = apiId.value
when (apiId) {
@@ -471,14 +473,26 @@ class MapViewModel(application: Application, private val state: SavedStateHandle
* expands LatLngBounds beyond the viewport (1.5x the width and height)
*/
private fun extendBounds(bounds: LatLngBounds): LatLngBounds {
val mapProjection = mapProjection ?: return bounds
val swPoint = mapProjection.toScreenLocation(bounds.southwest)
val nePoint = mapProjection.toScreenLocation(bounds.northeast)
val dx = ((nePoint.x - swPoint.x) * 0.25).roundToInt()
val dy = ((nePoint.y - swPoint.y) * 0.25).roundToInt()
val newSw = mapProjection.fromScreenLocation(Point(swPoint.x - dx, swPoint.y - dy))
val newNe = mapProjection.fromScreenLocation(Point(nePoint.x + dx, nePoint.y + dy))
return LatLngBounds(newSw, newNe)
val sw = bounds.southwest
val ne = bounds.northeast
// do not expand bounds if the map area shown is very large
val expansion = if (ne.longitude - sw.longitude > 10) 1.0 else 1.5
val factor = (expansion - 1.0) * 0.5
var west = sw.longitude - (ne.longitude - sw.longitude) * factor
var east = ne.longitude + (ne.longitude - sw.longitude) * factor
val south =
sw.latitude - (ne.latitude - sw.latitude) * factor * cos(Math.toRadians(sw.latitude))
val north =
ne.latitude + (ne.latitude - sw.latitude) * factor * cos(Math.toRadians(ne.latitude))
if (east - west >= 360) {
west = -180.0
east = 180.0
}
return LatLngBounds(LatLng(south, west), LatLng(north, east)).normalize()
}
fun reloadAvailability() {

View File

@@ -32,6 +32,7 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
app:piv_rtl_mode="auto"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/card"

View File

@@ -59,8 +59,6 @@
app:layout_constraintBottom_toTopOf="@+id/welcomeTitle"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.7"
app:srcCompat="@drawable/android_auto" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -19,6 +19,7 @@
android:layout_height="wrap_content"
android:text="@string/app_name"
android:textAppearance="@style/TextAppearance.Material3.HeadlineLarge"
android:textAlignment="viewStart"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.5"

View File

@@ -6,7 +6,7 @@
<RadioButton
android:id="@+id/rbGoingElectric"
android:layout_width="match_parent"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/data_source_goingelectric"
android:textColor="#098ac7"
@@ -21,6 +21,7 @@
android:layout_marginTop="-8dp"
android:layout_marginBottom="8dp"
android:layout_marginStart="32dp"
android:textAlignment="viewStart"
android:text="@string/data_source_goingelectric_desc" />
<RadioButton
@@ -39,6 +40,7 @@
android:layout_height="wrap_content"
android:layout_marginTop="-8dp"
android:layout_marginStart="32dp"
android:textAlignment="viewStart"
android:text="@string/data_source_openchargemap_desc" />
</RadioGroup>

View File

@@ -7,6 +7,8 @@
<import type="java.util.Map" />
<import type="com.github.erfansn.localeconfigx.LocaleConfigXKt" />
<import type="java.time.ZonedDateTime" />
<import type="net.vonforst.evmap.model.ChargeLocation" />
@@ -90,495 +92,517 @@
android:paddingBottom="@dimen/detail_corner_radius"
app:cardElevation="6dp">
<androidx.constraintlayout.widget.ConstraintLayout
<com.google.android.material.bottomsheet.BottomSheetDragHandleView
android:id="@+id/dragHandle"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<androidx.coordinatorlayout.widget.CoordinatorLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.google.android.material.appbar.AppBarLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?android:colorBackground"
android:paddingTop="8dp"
android:paddingBottom="16dp">
android:theme="@style/ThemeOverlay.Material3.Dark.ActionBar"
android:background="@null"
app:liftOnScroll="true">
<TextView
android:id="@+id/txtName"
android:layout_width="wrap_content"
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end"
android:hyphenationFrequency="normal"
android:maxLines="@{expanded ? 3 : 1}"
android:text="@{charger.data.name}"
android:textAppearance="@style/TextAppearance.Material3.BodyLarge"
app:layout_constrainedWidth="true"
app:layout_constraintEnd_toStartOf="@+id/imgFaultReport"
app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintHorizontal_chainStyle="packed"
app:layout_constraintStart_toStartOf="@+id/guideline"
app:layout_constraintTop_toTopOf="parent"
tools:text="Parkhaus" />
app:layout_scrollFlags="scroll|exitUntilCollapsed">
<TextView
android:id="@+id/textView2"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="1"
android:text="@{charger.data.address.toString()}"
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
app:invisibleUnless="@{charger.data.address != null}"
app:layout_constraintEnd_toStartOf="@+id/guideline2"
app:layout_constraintStart_toStartOf="@+id/guideline"
app:layout_constraintTop_toBottomOf="@+id/txtName"
tools:text="Beispielstraße 10, 12345 Berlin" />
<TextView
android:id="@+id/txtDistance"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
android:ellipsize="end"
android:gravity="end"
android:maxLines="1"
android:minWidth="50dp"
android:text="@{BindingAdaptersKt.distance(distance, context)}"
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
app:layout_constraintBottom_toBottomOf="@+id/txtConnectors"
app:layout_constraintEnd_toStartOf="@+id/guideline2"
tools:text="10 km" />
<TextView
android:id="@+id/txtAvailability"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="72dp"
android:background="@drawable/rounded_rect"
android:ellipsize="end"
android:gravity="end"
android:maxLines="1"
android:padding="2dp"
android:text="@{String.format(&quot;%s/%d&quot;, BindingAdaptersKt.availabilityText(BindingAdaptersKt.flatten(filteredAvailability.data.status.values())), filteredAvailability.data.totalChargepoints)}"
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
android:textColor="@android:color/white"
app:backgroundTintAvailability="@{BindingAdaptersKt.flatten(filteredAvailability.data.status.values())}"
app:invisibleUnless="@{filteredAvailability.data != null &amp;&amp; !expanded}"
app:layout_constraintEnd_toStartOf="@+id/guideline2"
app:layout_constraintTop_toTopOf="@+id/txtName"
tools:backgroundTint="@color/available"
tools:text="2/2" />
<TextView
android:id="@+id/txtConnectors"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
android:ellipsize="end"
android:maxLines="1"
android:text="@{charger.data.formatChargepoints(ChargepointApiKt.stringProvider(context))}"
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
app:layout_constraintEnd_toStartOf="@+id/txtDistance"
app:layout_constraintStart_toStartOf="@+id/guideline"
app:layout_constraintTop_toBottomOf="@+id/textView2"
tools:text="2x Typ 2 22 kW" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/connectors"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:data="@{DataBindingAdaptersKt.chargepointWithAvailability(charger.data.chargepointsMerged, availability.data.status)}"
app:layout_constraintEnd_toStartOf="@+id/guideline2"
app:layout_constraintStart_toStartOf="@+id/guideline"
app:layout_constraintTop_toBottomOf="@+id/textView7"
tools:itemCount="3"
tools:layoutManager="LinearLayoutManager"
tools:listitem="@layout/item_connector"
tools:orientation="horizontal" />
<TextView
android:id="@+id/textView7"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:layout_marginEnd="8dp"
android:text="@string/connectors"
android:textAppearance="@style/TextAppearance.Material3.TitleSmall"
android:textColor="?colorPrimary"
app:layout_constraintEnd_toStartOf="@+id/btnRefreshLiveData"
app:layout_constraintStart_toStartOf="@+id/guideline"
app:layout_constraintTop_toBottomOf="@+id/txtConnectors" />
<TextView
android:id="@+id/textView12"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/amenities"
android:textAppearance="@style/TextAppearance.Material3.TitleSmall"
android:textColor="?colorPrimary"
app:goneUnless="@{charger.data.amenities != null}"
app:layout_constraintEnd_toStartOf="@+id/guideline2"
app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintStart_toStartOf="@+id/guideline"
app:layout_constraintTop_toBottomOf="@+id/details" />
<TextView
android:id="@+id/textView11"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:autoLink="web"
android:linksClickable="true"
android:text="@{charger.data.amenities}"
android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
app:goneUnless="@{charger.data.amenities != null}"
app:layout_constraintEnd_toStartOf="@+id/guideline2"
app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintStart_toStartOf="@+id/guideline"
app:layout_constraintTop_toBottomOf="@+id/textView12"
tools:text="Toilet" />
<TextView
android:id="@+id/textView10"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/general_info"
android:textAppearance="@style/TextAppearance.Material3.TitleSmall"
android:textColor="?colorPrimary"
app:goneUnless="@{charger.data.generalInformation != null}"
app:layout_constraintEnd_toStartOf="@+id/guideline2"
app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintStart_toStartOf="@+id/guideline"
app:layout_constraintTop_toBottomOf="@+id/textView11" />
<TextView
android:id="@+id/textView4"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:autoLink="web"
android:linksClickable="true"
android:text="@{charger.data.generalInformation}"
android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
app:goneUnless="@{charger.data.generalInformation != null}"
app:layout_constraintEnd_toStartOf="@+id/guideline2"
app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintStart_toStartOf="@+id/guideline3"
app:layout_constraintTop_toBottomOf="@+id/textView10"
tools:text="Only for guests" />
<androidx.constraintlayout.widget.Guideline
android:id="@+id/guideline"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical"
app:layout_constraintGuide_begin="16dp" />
<androidx.constraintlayout.widget.Guideline
android:id="@+id/guideline2"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical"
app:layout_constraintGuide_end="16dp" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/details"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:nestedScrollingEnabled="false"
app:data="@{DetailsAdapterKt.buildDetails(charger.data, chargeCards, filteredChargeCards, teslaPricing, context)}"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="1.0"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/divider3"
tools:itemCount="3"
tools:listitem="@layout/item_detail" />
<androidx.constraintlayout.widget.Guideline
android:id="@+id/guideline3"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical"
app:layout_constraintGuide_begin="16dp" />
<Button
android:id="@+id/sourceButton"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:layout_marginBottom="16dp"
android:text="@{@string/source(apiName)}"
app:layout_constraintEnd_toStartOf="@+id/guideline2"
app:layout_constraintStart_toStartOf="@+id/guideline"
app:layout_constraintTop_toBottomOf="@+id/textView4"
tools:text="Source: DataSource" />
<TextView
android:id="@+id/textView13"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
android:gravity="right|end"
android:text="@{availability.status == Status.SUCCESS ? @string/realtime_data_source(availability.data.source) : availability.status == Status.LOADING ? @string/realtime_data_loading : availability.message == &quot;not signed in&quot; ? @string/realtime_data_login_needed : @string/realtime_data_unavailable}"
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
app:layout_constraintEnd_toStartOf="@+id/btnLogin"
app:layout_constraintStart_toStartOf="@+id/guideline"
app:layout_constraintTop_toBottomOf="@+id/connectors"
tools:text="Echtzeitdaten nicht verfügbar" />
<View
android:id="@+id/topPart"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_marginBottom="-10dp"
android:text="TextView"
app:layout_constraintBottom_toBottomOf="@+id/txtConnectors"
app:layout_constraintEnd_toStartOf="@+id/guideline2"
app:layout_constraintStart_toStartOf="@+id/guideline"
app:layout_constraintTop_toTopOf="@+id/txtName" />
<View
android:id="@+id/divider2"
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_marginTop="8dp"
android:background="?android:attr/listDivider"
app:layout_constraintTop_toBottomOf="@+id/textView13" />
<View
android:id="@+id/divider3"
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_marginTop="8dp"
android:background="?android:attr/listDivider"
app:goneUnless="@{charger.data != null &amp;&amp; ChargepriceApi.isChargerSupported(charger.data)}"
app:layout_constraintTop_toBottomOf="@+id/buttonsScroller" />
<TextView
android:id="@+id/textView8"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@{predictionData.isPercentage ? @string/average_utilization : @string/utilization_prediction}"
tools:text="@string/utilization_prediction"
android:textAppearance="@style/TextAppearance.Material3.TitleSmall"
android:textColor="?colorPrimary"
app:goneUnless="@{predictionData.predictionGraph != null}"
app:layout_constraintStart_toStartOf="@+id/guideline"
app:layout_constraintTop_toBottomOf="@+id/divider2" />
<TextView
android:id="@+id/textView29"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginEnd="8dp"
android:text="@{predictionData.description}"
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
app:goneUnless="@{predictionData.predictionGraph != null &amp;&amp; !predictionData.isPercentage}"
app:layout_constraintBaseline_toBaselineOf="@+id/textView8"
app:layout_constraintEnd_toStartOf="@+id/btnPredictionHelp"
app:layout_constraintStart_toEndOf="@+id/textView8"
tools:text="(DC plugs only)" />
<Button
android:id="@+id/btnPredictionHelp"
style="@style/Widget.Material3.Button.IconButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:contentDescription="@string/help"
app:goneUnless="@{predictionData.predictionGraph != null &amp;&amp; !predictionData.isPercentage}"
app:icon="@drawable/ic_help"
app:iconTint="?android:textColorSecondary"
app:layout_constraintBottom_toBottomOf="@+id/textView8"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="@+id/textView8" />
<net.vonforst.evmap.ui.BarGraphView
android:id="@+id/prediction"
android:layout_width="0dp"
android:layout_height="100dp"
android:layout_marginTop="8dp"
app:data="@{predictionData.predictionGraph}"
app:goneUnless="@{predictionData.predictionGraph != null}"
app:layout_constraintEnd_toStartOf="@+id/guideline2"
app:layout_constraintStart_toStartOf="@+id/guideline"
app:layout_constraintTop_toBottomOf="@+id/textView8"
app:maxValue="@{predictionData.maxValue}"
app:isPercentage="@{predictionData.isPercentage}"
tools:itemCount="3"
tools:layoutManager="LinearLayoutManager"
tools:listitem="@layout/item_connector"
tools:orientation="horizontal" />
<ImageView
android:id="@+id/imgPredictionSource"
android:layout_width="wrap_content"
android:layout_height="24dp"
android:layout_marginTop="4dp"
android:adjustViewBounds="true"
android:background="?selectableItemBackgroundBorderless"
android:scaleType="fitCenter"
app:goneUnless="@{predictionData.predictionGraph != null &amp;&amp; !predictionData.isPercentage}"
app:layout_constraintEnd_toStartOf="@+id/guideline2"
app:layout_constraintTop_toBottomOf="@+id/prediction"
app:srcCompat="@drawable/ic_powered_by_fronyx"
app:tint="@color/logo_tint_night" />
<View
android:id="@+id/divider1"
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_marginTop="8dp"
android:background="?android:attr/listDivider"
app:goneUnless="@{predictionData.predictionGraph != null}"
app:layout_constraintTop_toBottomOf="@+id/imgPredictionSource" />
<ImageView
android:id="@+id/imgVerified"
android:layout_width="18dp"
android:layout_height="18dp"
android:layout_marginStart="4dp"
android:layout_marginTop="2dp"
android:layout_marginEnd="8dp"
android:contentDescription="@string/verified"
app:goneUnless="@{ charger.data.verified }"
app:layout_constraintEnd_toStartOf="@+id/txtAvailability"
app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintStart_toEndOf="@+id/imgFaultReport"
app:layout_constraintTop_toTopOf="@+id/txtName"
app:srcCompat="@drawable/ic_verified"
app:tint="@color/available"
app:tooltipTextCompat="@{@string/verified_desc(apiName)}"
tools:targetApi="o" />
<ImageView
android:id="@+id/imgFaultReport"
android:layout_width="18dp"
android:layout_height="18dp"
android:layout_marginStart="8dp"
android:layout_marginTop="2dp"
android:contentDescription="@string/fault_report"
app:goneUnless="@{ charger.data.faultReport != null }"
app:layout_constraintEnd_toStartOf="@+id/imgVerified"
app:layout_constraintStart_toEndOf="@+id/txtName"
app:layout_constraintTop_toTopOf="@+id/txtName"
app:srcCompat="@drawable/ic_map_marker_fault"
app:tooltipTextCompat="@{@string/fault_report}"
tools:targetApi="o" />
<TextView
android:id="@+id/txtTimeRetrieved"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:breakStrategy="balanced"
android:text="@{@string/data_retrieved_at(DateUtils.getRelativeTimeSpanString(charger.data.timeRetrieved.toEpochMilli(), Instant.now().toEpochMilli(), 0))}"
android:textAlignment="center"
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
android:textStyle="italic"
app:goneUnless="@{charger.data.timeRetrieved == null || Duration.between(charger.data.timeRetrieved, Instant.now()).compareTo(Duration.ofHours(1)) > 0}"
app:layout_constraintEnd_toStartOf="@+id/guideline2"
app:layout_constraintStart_toStartOf="@+id/guideline"
app:layout_constraintTop_toBottomOf="@+id/sourceButton"
tools:text="Data retrieved 4 hours ago" />
<TextView
android:id="@+id/txtLicense"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:breakStrategy="balanced"
android:text="@{charger.data.license}"
android:textAlignment="center"
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
android:textStyle="italic"
app:goneUnless="@{charger.data.license != null}"
app:layout_constraintEnd_toStartOf="@+id/guideline2"
app:layout_constraintStart_toStartOf="@+id/guideline"
app:layout_constraintTop_toBottomOf="@+id/txtTimeRetrieved"
tools:text="The data is provided under the National Oman Open Data LicensE (NOODLE), Version 3.14, and may be used for any purpose whatsoever." />
<Button
android:id="@+id/btnRefreshLiveData"
style="@style/Widget.Material3.Button.IconButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:contentDescription="@string/refresh_live_data"
android:enabled="@{availability.status != Status.LOADING}"
app:icon="@drawable/ic_refresh"
app:iconTint="?android:textColorSecondary"
app:layout_constraintBottom_toBottomOf="@+id/textView7"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="@+id/textView7" />
<HorizontalScrollView
android:id="@+id/buttonsScroller"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
app:layout_constraintEnd_toStartOf="@+id/guideline2"
app:layout_constraintStart_toStartOf="@+id/guideline"
app:layout_constraintTop_toBottomOf="@+id/divider1"
app:layout_constrainedWidth="true"
android:fillViewport="true"
app:goneUnless="@{charger.data != null &amp;&amp; (ChargepriceApi.isChargerSupported(charger.data) || charger.data.chargerUrl != null)}">
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal">
<Button
android:id="@+id/btnChargeprice"
style="@style/Widget.Material3.Button.TonalButton"
<TextView
android:id="@+id/txtName"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/go_to_chargeprice"
android:transitionName="@string/shared_element_chargeprice"
app:goneUnless="@{charger.data != null &amp;&amp; ChargepriceApi.isChargerSupported(charger.data)}"
app:icon="@drawable/ic_chargeprice" />
android:ellipsize="end"
android:hyphenationFrequency="normal"
android:maxLines="@{expanded ? 3 : 1}"
android:text="@{charger.data.name}"
android:textAppearance="@style/TextAppearance.Material3.BodyLarge"
tools:text="Parkhaus" />
<ImageView
android:id="@+id/imgVerified"
android:layout_width="18dp"
android:layout_height="18dp"
android:layout_marginStart="8dp"
android:contentDescription="@string/verified"
app:goneUnless="@{ charger.data.verified }"
app:srcCompat="@drawable/ic_verified"
app:tint="@color/available"
app:tooltipTextCompat="@{@string/verified_desc(apiName)}"
tools:targetApi="o" />
<ImageView
android:id="@+id/imgFaultReport"
android:layout_width="18dp"
android:layout_height="18dp"
android:layout_marginStart="8dp"
android:contentDescription="@string/fault_report"
app:goneUnless="@{ charger.data.faultReport != null }"
app:srcCompat="@drawable/ic_map_marker_fault"
app:tooltipTextCompat="@{@string/fault_report}"
tools:targetApi="o" />
</com.google.android.material.appbar.MaterialToolbar>
</com.google.android.material.appbar.AppBarLayout>
<androidx.core.widget.NestedScrollView
android:id="@+id/bottom_sheet"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fillViewport="true"
android:orientation="vertical"
android:clipToPadding="false"
app:layout_behavior="com.google.android.material.appbar.AppBarLayout$ScrollingViewBehavior">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingBottom="16dp">
<TextView
android:id="@+id/textView2"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="1"
android:textAlignment="viewStart"
android:text="@{charger.data.address.toString()}"
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
app:invisibleUnless="@{charger.data.address != null}"
app:layout_constraintEnd_toStartOf="@+id/guideline2"
app:layout_constraintStart_toStartOf="@+id/guideline"
app:layout_constraintTop_toTopOf="parent"
tools:text="Beispielstraße 10, 12345 Berlin" />
<TextView
android:id="@+id/txtDistance"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
android:ellipsize="end"
android:textAlignment="viewEnd"
android:maxLines="1"
android:minWidth="50dp"
android:text="@{BindingAdaptersKt.distance(distance, context)}"
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
app:layout_constraintBottom_toBottomOf="@+id/txtConnectors"
app:layout_constraintEnd_toStartOf="@+id/guideline2"
tools:text="10 km" />
<TextView
android:id="@+id/txtAvailability"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="72dp"
android:background="@drawable/rounded_rect"
android:ellipsize="end"
android:gravity="end"
android:maxLines="1"
android:padding="2dp"
android:text="@{String.format(LocaleConfigXKt.getCurrentOrDefaultLocale(context), &quot;%s/%d&quot;, BindingAdaptersKt.availabilityText(BindingAdaptersKt.flatten(filteredAvailability.data.status.values())), filteredAvailability.data.totalChargepoints)}"
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
android:textColor="@android:color/white"
app:backgroundTintAvailability="@{BindingAdaptersKt.flatten(filteredAvailability.data.status.values())}"
app:invisibleUnless="@{filteredAvailability.data != null &amp;&amp; !expanded}"
app:layout_constraintEnd_toStartOf="@+id/guideline2"
app:layout_constraintTop_toTopOf="parent"
tools:backgroundTint="@color/available"
tools:text="2/2" />
<TextView
android:id="@+id/txtConnectors"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
android:ellipsize="end"
android:maxLines="1"
android:textAlignment="viewStart"
android:text="@{charger.data.formatChargepoints(ChargepointApiKt.stringProvider(context), LocaleConfigXKt.getCurrentOrDefaultLocale(context))}"
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
app:layout_constraintEnd_toStartOf="@+id/txtDistance"
app:layout_constraintStart_toStartOf="@+id/guideline"
app:layout_constraintTop_toBottomOf="@+id/textView2"
tools:text="2x Typ 2 22 kW" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/connectors"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:data="@{DataBindingAdaptersKt.chargepointWithAvailability(charger.data.chargepointsMerged, availability.data.status)}"
app:layout_constraintEnd_toStartOf="@+id/guideline2"
app:layout_constraintStart_toStartOf="@+id/guideline"
app:layout_constraintTop_toBottomOf="@+id/textView7"
tools:itemCount="3"
tools:layoutManager="LinearLayoutManager"
tools:listitem="@layout/item_connector"
tools:orientation="horizontal" />
<TextView
android:id="@+id/textView7"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:layout_marginEnd="8dp"
android:text="@string/connectors"
android:textAppearance="@style/TextAppearance.Material3.TitleSmall"
android:textColor="?colorPrimary"
android:textAlignment="viewStart"
app:layout_constraintEnd_toStartOf="@+id/btnRefreshLiveData"
app:layout_constraintStart_toStartOf="@+id/guideline"
app:layout_constraintTop_toBottomOf="@+id/txtConnectors" />
<TextView
android:id="@+id/textView12"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/amenities"
android:textAppearance="@style/TextAppearance.Material3.TitleSmall"
android:textColor="?colorPrimary"
app:goneUnless="@{charger.data.amenities != null}"
app:layout_constraintEnd_toStartOf="@+id/guideline2"
app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintStart_toStartOf="@+id/guideline"
app:layout_constraintTop_toBottomOf="@+id/details" />
<TextView
android:id="@+id/textView11"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:autoLink="web"
android:linksClickable="true"
android:text="@{charger.data.amenities}"
android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
app:goneUnless="@{charger.data.amenities != null}"
app:layout_constraintEnd_toStartOf="@+id/guideline2"
app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintStart_toStartOf="@+id/guideline"
app:layout_constraintTop_toBottomOf="@+id/textView12"
tools:text="Toilet" />
<TextView
android:id="@+id/textView10"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/general_info"
android:textAppearance="@style/TextAppearance.Material3.TitleSmall"
android:textColor="?colorPrimary"
app:goneUnless="@{charger.data.generalInformation != null}"
app:layout_constraintEnd_toStartOf="@+id/guideline2"
app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintStart_toStartOf="@+id/guideline"
app:layout_constraintTop_toBottomOf="@+id/textView11" />
<TextView
android:id="@+id/textView4"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:autoLink="web"
android:linksClickable="true"
android:text="@{charger.data.generalInformation}"
android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
app:goneUnless="@{charger.data.generalInformation != null}"
app:layout_constraintEnd_toStartOf="@+id/guideline2"
app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintStart_toStartOf="@+id/guideline3"
app:layout_constraintTop_toBottomOf="@+id/textView10"
tools:text="Only for guests" />
<androidx.constraintlayout.widget.Guideline
android:id="@+id/guideline"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical"
app:layout_constraintGuide_begin="16dp" />
<androidx.constraintlayout.widget.Guideline
android:id="@+id/guideline2"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical"
app:layout_constraintGuide_end="16dp" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/details"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:nestedScrollingEnabled="false"
app:data="@{DetailsAdapterKt.buildDetails(charger.data, chargeCards, filteredChargeCards, teslaPricing, context)}"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="1.0"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/divider3"
tools:itemCount="3"
tools:listitem="@layout/item_detail" />
<androidx.constraintlayout.widget.Guideline
android:id="@+id/guideline3"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical"
app:layout_constraintGuide_begin="16dp" />
<Button
android:id="@+id/btnChargerWebsite"
android:id="@+id/sourceButton"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:layout_marginBottom="16dp"
android:text="@{@string/source(apiName)}"
app:layout_constraintEnd_toStartOf="@+id/guideline2"
app:layout_constraintStart_toStartOf="@+id/guideline"
app:layout_constraintTop_toBottomOf="@+id/textView4"
tools:text="Source: DataSource" />
<TextView
android:id="@+id/textView13"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
android:gravity="end"
android:text="@{availability.status == Status.SUCCESS ? @string/realtime_data_source(availability.data.source) : availability.status == Status.LOADING ? @string/realtime_data_loading : availability.message == &quot;not signed in&quot; ? @string/realtime_data_login_needed : @string/realtime_data_unavailable}"
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
app:layout_constraintEnd_toStartOf="@+id/btnLogin"
app:layout_constraintStart_toStartOf="@+id/guideline"
app:layout_constraintTop_toBottomOf="@+id/connectors"
tools:text="Echtzeitdaten nicht verfügbar" />
<View
android:id="@+id/topPart"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_marginBottom="-10dp"
android:text="TextView"
app:layout_constraintBottom_toBottomOf="@+id/txtConnectors"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<View
android:id="@+id/divider2"
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_marginTop="8dp"
android:background="?android:attr/listDivider"
app:layout_constraintTop_toBottomOf="@+id/textView13" />
<View
android:id="@+id/divider3"
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_marginTop="8dp"
android:background="?android:attr/listDivider"
app:goneUnless="@{charger.data != null &amp;&amp; ChargepriceApi.isChargerSupported(charger.data)}"
app:layout_constraintTop_toBottomOf="@+id/buttonsScroller" />
<TextView
android:id="@+id/textView8"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@{predictionData.isPercentage ? @string/average_utilization : @string/utilization_prediction}"
tools:text="@string/utilization_prediction"
android:textAppearance="@style/TextAppearance.Material3.TitleSmall"
android:textColor="?colorPrimary"
app:goneUnless="@{predictionData.predictionGraph != null}"
app:layout_constraintStart_toStartOf="@+id/guideline"
app:layout_constraintTop_toBottomOf="@+id/divider2" />
<TextView
android:id="@+id/textView29"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:text="@string/charger_website"
app:goneUnless="@{charger.data != null &amp;&amp; charger.data.chargerUrl != null}"
app:icon="@drawable/ic_link" />
android:layout_marginEnd="8dp"
android:text="@{predictionData.description}"
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
app:goneUnless="@{predictionData.predictionGraph != null &amp;&amp; !predictionData.isPercentage}"
app:layout_constraintBaseline_toBaselineOf="@+id/textView8"
app:layout_constraintEnd_toStartOf="@+id/btnPredictionHelp"
app:layout_constraintStart_toEndOf="@+id/textView8"
tools:text="(DC plugs only)" />
</LinearLayout>
</HorizontalScrollView>
<Button
android:id="@+id/btnPredictionHelp"
style="@style/Widget.Material3.Button.IconButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:contentDescription="@string/help"
app:goneUnless="@{predictionData.predictionGraph != null &amp;&amp; !predictionData.isPercentage}"
app:icon="@drawable/ic_help"
app:iconTint="?android:textColorSecondary"
app:layout_constraintBottom_toBottomOf="@+id/textView8"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="@+id/textView8" />
<Button
android:id="@+id/btnLogin"
style="@style/Widget.Material3.Button.TextButton.Dialog"
android:layout_width="wrap_content"
android:layout_height="40dp"
android:text="@string/login"
app:goneUnless="@{availability.status == Status.ERROR &amp;&amp; availability.message == &quot;not signed in&quot;}"
app:layout_constraintBottom_toBottomOf="@+id/textView13"
app:layout_constraintEnd_toStartOf="@+id/guideline2"
app:layout_constraintTop_toTopOf="@+id/textView13" />
<com.google.android.material.card.MaterialCardView
style="?attr/materialCardViewElevatedStyle"
android:id="@+id/connector_details_card"
app:layout_constraintStart_toStartOf="@+id/guideline"
app:layout_constraintEnd_toStartOf="@+id/guideline2"
app:layout_constraintTop_toTopOf="@id/connectors"
android:layout_width="0dp"
android:layout_height="wrap_content"
app:cardCornerRadius="24dp"
android:layout_marginBottom="@dimen/detail_corner_radius_negative"
android:paddingBottom="@dimen/detail_corner_radius"
app:cardElevation="6dp"
android:visibility="gone">
<net.vonforst.evmap.ui.BarGraphView
android:id="@+id/prediction"
android:layout_width="0dp"
android:layout_height="100dp"
android:layout_marginTop="8dp"
app:data="@{predictionData.predictionGraph}"
app:goneUnless="@{predictionData.predictionGraph != null}"
app:layout_constraintEnd_toStartOf="@+id/guideline2"
app:layout_constraintStart_toStartOf="@+id/guideline"
app:layout_constraintTop_toBottomOf="@+id/textView8"
app:maxValue="@{predictionData.maxValue}"
app:isPercentage="@{predictionData.isPercentage}"
tools:itemCount="3"
tools:layoutManager="LinearLayoutManager"
tools:listitem="@layout/item_connector"
tools:orientation="horizontal" />
<include
layout="@layout/dialog_connector_details"
android:id="@+id/connector_details" />
</com.google.android.material.card.MaterialCardView>
<ImageView
android:id="@+id/imgPredictionSource"
android:layout_width="wrap_content"
android:layout_height="24dp"
android:layout_marginTop="4dp"
android:adjustViewBounds="true"
android:background="?selectableItemBackgroundBorderless"
android:scaleType="fitCenter"
app:goneUnless="@{predictionData.predictionGraph != null &amp;&amp; !predictionData.isPercentage}"
app:layout_constraintEnd_toStartOf="@+id/guideline2"
app:layout_constraintTop_toBottomOf="@+id/prediction"
app:srcCompat="@drawable/ic_powered_by_fronyx"
app:tint="@color/logo_tint_night" />
</androidx.constraintlayout.widget.ConstraintLayout>
<View
android:id="@+id/divider1"
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_marginTop="8dp"
android:background="?android:attr/listDivider"
app:goneUnless="@{predictionData.predictionGraph != null}"
app:layout_constraintTop_toBottomOf="@+id/imgPredictionSource" />
<TextView
android:id="@+id/txtTimeRetrieved"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:breakStrategy="balanced"
android:text="@{@string/data_retrieved_at(DateUtils.getRelativeTimeSpanString(charger.data.timeRetrieved.toEpochMilli(), Instant.now().toEpochMilli(), 0))}"
android:textAlignment="center"
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
android:textStyle="italic"
app:goneUnless="@{charger.data.timeRetrieved == null || Duration.between(charger.data.timeRetrieved, Instant.now()).compareTo(Duration.ofHours(1)) > 0}"
app:layout_constraintEnd_toStartOf="@+id/guideline2"
app:layout_constraintStart_toStartOf="@+id/guideline"
app:layout_constraintTop_toBottomOf="@+id/sourceButton"
tools:text="Data retrieved 4 hours ago" />
<TextView
android:id="@+id/txtLicense"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:breakStrategy="balanced"
android:text="@{charger.data.license}"
android:textAlignment="center"
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
android:textStyle="italic"
app:goneUnless="@{charger.data.license != null}"
app:layout_constraintEnd_toStartOf="@+id/guideline2"
app:layout_constraintStart_toStartOf="@+id/guideline"
app:layout_constraintTop_toBottomOf="@+id/txtTimeRetrieved"
tools:text="The data is provided under the National Oman Open Data LicensE (NOODLE), Version 3.14, and may be used for any purpose whatsoever." />
<Button
android:id="@+id/btnRefreshLiveData"
style="@style/Widget.Material3.Button.IconButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:contentDescription="@string/refresh_live_data"
android:enabled="@{availability.status != Status.LOADING}"
app:icon="@drawable/ic_refresh"
app:iconTint="?android:textColorSecondary"
app:layout_constraintBottom_toBottomOf="@+id/textView7"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="@+id/textView7" />
<HorizontalScrollView
android:id="@+id/buttonsScroller"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
app:layout_constraintEnd_toStartOf="@+id/guideline2"
app:layout_constraintStart_toStartOf="@+id/guideline"
app:layout_constraintTop_toBottomOf="@+id/divider1"
app:layout_constrainedWidth="true"
android:fillViewport="true"
app:goneUnless="@{charger.data != null &amp;&amp; (ChargepriceApi.isChargerSupported(charger.data) || charger.data.chargerUrl != null)}">
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal">
<Button
android:id="@+id/btnChargeprice"
style="@style/Widget.Material3.Button.TonalButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/go_to_chargeprice"
android:transitionName="@string/shared_element_chargeprice"
app:goneUnless="@{charger.data != null &amp;&amp; ChargepriceApi.isChargerSupported(charger.data)}"
app:icon="@drawable/ic_chargeprice" />
<Button
android:id="@+id/btnChargerWebsite"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:text="@string/charger_website"
app:goneUnless="@{charger.data != null &amp;&amp; charger.data.chargerUrl != null}"
app:icon="@drawable/ic_link" />
</LinearLayout>
</HorizontalScrollView>
<Button
android:id="@+id/btnLogin"
style="@style/Widget.Material3.Button.TextButton.Dialog"
android:layout_width="wrap_content"
android:layout_height="40dp"
android:text="@string/login"
app:goneUnless="@{availability.status == Status.ERROR &amp;&amp; availability.message == &quot;not signed in&quot;}"
app:layout_constraintBottom_toBottomOf="@+id/textView13"
app:layout_constraintEnd_toStartOf="@+id/guideline2"
app:layout_constraintTop_toTopOf="@+id/textView13" />
<com.google.android.material.card.MaterialCardView
style="?attr/materialCardViewElevatedStyle"
android:id="@+id/connector_details_card"
app:layout_constraintStart_toStartOf="@+id/guideline"
app:layout_constraintEnd_toStartOf="@+id/guideline2"
app:layout_constraintTop_toTopOf="@id/connectors"
android:layout_width="0dp"
android:layout_height="wrap_content"
app:cardCornerRadius="24dp"
android:layout_marginBottom="@dimen/detail_corner_radius_negative"
android:paddingBottom="@dimen/detail_corner_radius"
app:cardElevation="6dp"
android:visibility="gone">
<include
layout="@layout/dialog_connector_details"
android:id="@+id/connector_details" />
</com.google.android.material.card.MaterialCardView>
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.core.widget.NestedScrollView>
</androidx.coordinatorlayout.widget.CoordinatorLayout>
</com.google.android.material.card.MaterialCardView>
</layout>

View File

@@ -7,6 +7,8 @@
<import type="net.vonforst.evmap.adapter.ConnectorAdapter.ChargepointWithAvailability" />
<import type="com.github.erfansn.localeconfigx.LocaleConfigXKt" />
<import type="net.vonforst.evmap.ui.BindingAdaptersKt" />
<import type="net.vonforst.evmap.api.UtilsKt" />
@@ -47,7 +49,7 @@
android:layout_height="wrap_content"
android:layout_marginStart="38dp"
android:layout_marginTop="38dp"
android:text="@{String.format(&quot;\u00D7 %d&quot;, item.chargepoint.count)}"
android:text="@{String.format(LocaleConfigXKt.getCurrentOrDefaultLocale(context), &quot;\u00D7 %d&quot;, item.chargepoint.count)}"
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
app:goneUnless="@{item.status == null}"
app:layout_constraintStart_toStartOf="@+id/imageView"
@@ -63,7 +65,7 @@
android:layout_marginTop="30dp"
android:background="@drawable/rounded_rect"
android:padding="2dp"
android:text="@{String.format(&quot;%s/%d&quot;, BindingAdaptersKt.availabilityText(item.status), item.chargepoint.count)}"
android:text="@{String.format(LocaleConfigXKt.getCurrentOrDefaultLocale(context), &quot;%s/%d&quot;, BindingAdaptersKt.availabilityText(item.status), item.chargepoint.count)}"
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
android:textColor="@android:color/white"
app:backgroundTintAvailability="@{item.status}"
@@ -79,7 +81,7 @@
android:layout_height="wrap_content"
android:layout_marginStart="36dp"
android:layout_marginTop="4dp"
android:text="@{item != null ? UtilsKt.nameForPlugType(ChargepointApiKt.stringProvider(context), item.chargepoint.type) + &quot; · &quot; + item.chargepoint.formatPower() : null}"
android:text="@{item != null ? UtilsKt.nameForPlugType(ChargepointApiKt.stringProvider(context), item.chargepoint.type) + &quot; · &quot; + item.chargepoint.formatPower(LocaleConfigXKt.getCurrentOrDefaultLocale(context)) : null}"
android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
app:goneUnless="@{item.chargepoint.hasKnownPower()}"
app:layout_constraintBottom_toTopOf="@id/textView8"

View File

@@ -48,11 +48,14 @@
tools:orientation="horizontal" />
<TextView
android:id="@+id/textView2"
android:id="@+id/tvChargeFromTo"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="8dp"
android:clickable="true"
android:focusable="true"
android:background="?selectableItemBackground"
android:text="@{String.format(@string/chargeprice_battery_range, vm.batteryRange[0], vm.batteryRange[1])}"
android:textAppearance="@style/TextAppearance.Material3.TitleSmall"
android:textColor="?colorPrimary"
@@ -68,8 +71,8 @@
android:text="@{@string/chargeprice_stats(vm.chargepriceMetaForChargepoint.data.energy, BindingAdaptersKt.time((int) Math.round(vm.chargepriceMetaForChargepoint.data.duration)), vm.chargepriceMetaForChargepoint.data.energy / vm.chargepriceMetaForChargepoint.data.duration * 60)}"
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
app:invisibleUnlessAnimated="@{!vm.batteryRangeSliderDragging &amp;&amp; vm.chargepriceMetaForChargepoint.status == Status.SUCCESS}"
app:layout_constraintStart_toStartOf="@+id/textView2"
app:layout_constraintTop_toBottomOf="@+id/textView2"
app:layout_constraintStart_toStartOf="@+id/tvChargeFromTo"
app:layout_constraintTop_toBottomOf="@+id/tvChargeFromTo"
tools:text="(18 kWh, approx. 23 min, ⌀ 50 kW)" />
<TextView

View File

@@ -8,7 +8,8 @@
android:layout_marginStart="16dp"
android:layout_marginTop="16dp"
android:layout_marginEnd="16dp"
android:layout_marginBottom="16dp">
android:layout_marginBottom="16dp"
tools:ignore="WebViewLayout">
<TextView
android:id="@+id/textView20"
@@ -18,76 +19,27 @@
android:text="@string/referrals"
android:textAppearance="@style/TextAppearance.Material3.TitleSmall"
android:textColor="?colorPrimary"
app:layout_constraintBottom_toTopOf="@+id/textView21"
app:layout_constraintBottom_toTopOf="@+id/referralWebView"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_chainStyle="packed" />
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/textView21"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
android:text="@string/referrals_info"
app:layout_constraintBottom_toTopOf="@+id/referral_tesla"
app:layout_constraintStart_toStartOf="@+id/textView20"
app:layout_constraintTop_toBottomOf="@+id/textView20" />
<androidx.constraintlayout.helper.widget.Flow
android:layout_width="0dp"
android:layout_height="wrap_content"
android:orientation="horizontal"
app:constraint_referenced_ids="referral_tesla,referral_juicify,referral_geldfuereauto,referral_maingau,referral_eprimo,referral_ewieeinfach"
app:flow_horizontalGap="16dp"
app:flow_horizontalStyle="packed"
app:flow_verticalAlign="baseline"
app:flow_wrapMode="chain"
app:layout_constraintBottom_toTopOf="@+id/referralWebView"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/textView21" />
app:layout_constraintTop_toBottomOf="@+id/textView20" />
<Button
android:id="@+id/referral_tesla"
style="@style/Widget.Material3.Button.TonalButton"
android:layout_width="wrap_content"
<WebView
android:id="@+id/referralWebView"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/referral_tesla"
android:visibility="gone"
app:icon="@drawable/ic_tesla" />
<Button
android:id="@+id/referral_juicify"
style="@style/Widget.Material3.Button.TonalButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/referral_juicify" />
<Button
android:id="@+id/referral_geldfuereauto"
style="@style/Widget.Material3.Button.TonalButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/referral_geldfuereauto" />
<Button
android:id="@+id/referral_maingau"
style="@style/Widget.Material3.Button.TonalButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/referral_maingau" />
<Button
android:id="@+id/referral_eprimo"
style="@style/Widget.Material3.Button.TonalButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/referral_eprimo" />
<Button
android:id="@+id/referral_ewieeinfach"
style="@style/Widget.Material3.Button.TonalButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/referral_ewieeinfach" />
android:layout_marginTop="8dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/textView21" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -9,8 +9,6 @@
<import type="net.vonforst.evmap.viewmodel.Status" />
<import type="com.mahc.custombottomsheetbehavior.BottomSheetBehaviorGoogleMapsLike" />
<variable
name="vm"
type="net.vonforst.evmap.viewmodel.MapViewModel" />
@@ -46,7 +44,7 @@
android:layout_width="@dimen/map_toolbar_width"
android:layout_height="wrap_content"
android:fitsSystemWindows="true"
app:layout_behavior="@string/ScrollingAppBarLayoutBehavior">
app:layout_behavior="com.google.android.material.appbar.AppBarLayout$ScrollingViewBehavior">
<com.google.android.material.card.MaterialCardView
style="?attr/materialCardViewElevatedStyle"
@@ -146,7 +144,7 @@
android:layout_width="@dimen/map_toolbar_width"
android:layout_height="@dimen/gallery_height_with_margin"
android:background="?android:colorBackground"
app:layout_behavior="@string/BackDropBottomSheetBehavior">
android:visibility="gone">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/gallery"
@@ -177,35 +175,23 @@
app:isFabActive="@{ vm.myLocationEnabled }"
app:layout_behavior="@string/hide_on_scroll_fab_behavior" />
<androidx.core.widget.NestedScrollView
android:id="@+id/bottom_sheet"
<include
android:id="@+id/detail_view"
layout="@layout/detail_view"
android:layout_width="@dimen/map_toolbar_width"
android:layout_height="match_parent"
android:fillViewport="true"
android:orientation="vertical"
app:bottomsheetbehavior_anchorPoint="@dimen/gallery_height"
android:layout_height="wrap_content"
app:layout_behavior="com.google.android.material.bottomsheet.BottomSheetBehavior"
app:behavior_hideable="true"
app:behavior_peekHeight="@dimen/peek_height"
app:bottomsheetbehavior_defaultState="stateHidden"
app:layout_behavior="@string/BottomSheetBehaviorGoogleMapsLike"
android:clipToPadding="false"
tools:bottomsheetbehavior_defaultState="stateCollapsed">
<include
android:id="@+id/detail_view"
layout="@layout/detail_view"
app:charger="@{vm.charger}"
app:availability="@{vm.availability}"
app:filteredAvailability="@{vm.filteredAvailability}"
app:predictionData="@{vm.predictionData}"
app:chargeCards="@{vm.chargeCardMap}"
app:filteredChargeCards="@{vm.filteredChargeCards}"
app:distance="@{vm.chargerDistance}"
app:expanded="@{vm.bottomSheetExpanded}"
app:apiName="@{vm.apiName}"
app:teslaPricing="@{vm.teslaPricing}" />
</androidx.core.widget.NestedScrollView>
app:charger="@{vm.charger}"
app:availability="@{vm.availability}"
app:filteredAvailability="@{vm.filteredAvailability}"
app:predictionData="@{vm.predictionData}"
app:chargeCards="@{vm.chargeCardMap}"
app:filteredChargeCards="@{vm.filteredChargeCards}"
app:distance="@{vm.chargerDistance}"
app:expanded="@{vm.bottomSheetExpanded}"
app:apiName="@{vm.apiName}"
app:teslaPricing="@{vm.teslaPricing}" />
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/fab_directions"
@@ -218,16 +204,8 @@
android:translationX="@dimen/directions_fab_translationx"
app:layout_anchor="@id/bottom_sheet"
app:layout_anchorGravity="top|right|end"
app:layout_behavior="@string/ScrollAwareFABBehavior"
android:theme="@style/NoElevationOverlay" />
<com.mahc.custombottomsheetbehavior.MergedAppBarLayout
android:id="@+id/detail_app_bar"
android:layout_width="@dimen/map_toolbar_width"
android:layout_height="wrap_content"
app:layout_behavior="@string/MergedAppBarLayoutBehavior"
android:theme="@style/ThemeOverlay.Material3.Dark.ActionBar" />
<com.google.android.material.floatingactionbutton.FloatingActionButton
style="@style/Widget.Material3.FloatingActionButton.Small.Surface"
android:id="@+id/fab_layers"

View File

@@ -21,6 +21,7 @@
android:layout_height="wrap_content"
android:layout_margin="24dp"
android:layout_gravity="center_horizontal"
app:piv_rtl_mode="auto"
app:piv_animationType="worm"
app:piv_dynamicCount="true"
app:piv_interactiveAnimation="true"

View File

@@ -96,6 +96,7 @@
android:layout_marginBottom="24dp"
android:paddingStart="16dp"
android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
android:textAlignment="viewStart"
app:layout_constraintBottom_toTopOf="@+id/btnGetStarted"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.5"

View File

@@ -4,6 +4,8 @@
xmlns:tools="http://schemas.android.com/tools">
<data>
<import type="com.github.erfansn.localeconfigx.LocaleConfigXKt" />
<import type="net.vonforst.evmap.adapter.ConnectorAdapter.ChargepointWithAvailability" />
<import type="net.vonforst.evmap.ui.BindingAdaptersKt" />
@@ -40,7 +42,7 @@
android:layout_height="wrap_content"
android:layout_marginStart="38dp"
android:layout_marginTop="38dp"
android:text="@{String.format(&quot;\u00D7 %d&quot;, item.chargepoint.count)}"
android:text="@{String.format(LocaleConfigXKt.getCurrentOrDefaultLocale(context), &quot;\u00D7 %d&quot;, item.chargepoint.count)}"
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
app:layout_constraintStart_toStartOf="@+id/imageView"
app:layout_constraintTop_toTopOf="@+id/imageView"
@@ -54,7 +56,7 @@
android:layout_height="wrap_content"
android:layout_marginStart="30dp"
android:layout_marginTop="30dp"
android:text="@{String.format(&quot;%s/%d&quot;, BindingAdaptersKt.availabilityText(item.status), item.chargepoint.count)}"
android:text="@{String.format(LocaleConfigXKt.getCurrentOrDefaultLocale(context), &quot;%s/%d&quot;, BindingAdaptersKt.availabilityText(item.status), item.chargepoint.count)}"
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
android:background="@drawable/rounded_rect"
android:padding="2dp"
@@ -72,7 +74,7 @@
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:layout_marginBottom="8dp"
android:text="@{item.chargepoint.formatPower()}"
android:text="@{item.chargepoint.formatPower(LocaleConfigXKt.getCurrentOrDefaultLocale(context))}"
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
app:goneUnless="@{item.chargepoint.hasKnownPower()}"
app:layout_constraintBottom_toBottomOf="parent"

View File

@@ -9,6 +9,8 @@
<import type="net.vonforst.evmap.ui.BindingAdaptersKt" />
<import type="com.github.erfansn.localeconfigx.LocaleConfigXKt" />
<variable
name="item"
type="Chargepoint" />
@@ -51,7 +53,7 @@
android:layout_marginStart="38dp"
android:layout_marginTop="38dp"
android:layout_marginEnd="4dp"
android:text="@{String.format(&quot;× %d&quot;, item.count)}"
android:text="@{String.format(LocaleConfigXKt.getCurrentOrDefaultLocale(context), &quot;× %d&quot;, item.count)}"
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
android:textColor="@{BindingAdaptersKt.colorEnabled(context, enabled)}"
app:layout_constraintEnd_toEndOf="parent"
@@ -65,7 +67,7 @@
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:layout_marginBottom="4dp"
android:text="@{item.formatPower()}"
android:text="@{item.formatPower(LocaleConfigXKt.getCurrentOrDefaultLocale(context))}"
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
android:textColor="@{BindingAdaptersKt.colorEnabled(context, enabled)}"
app:layout_constraintBottom_toBottomOf="parent"

View File

@@ -26,6 +26,7 @@
android:layout_marginEnd="16dp"
android:layout_marginBottom="14dp"
android:text="@{item.text}"
android:textAlignment="viewStart"
android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
@@ -55,6 +56,7 @@
android:layout_marginEnd="16dp"
android:layout_marginBottom="14dp"
android:text="@{item.detailText}"
android:textAlignment="viewStart"
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
app:linkify="@{item.links ? Linkify.WEB_URLS | Linkify.PHONE_NUMBERS : 0}"
app:goneUnless="@{item.detailText != null}"

View File

@@ -7,6 +7,8 @@
<import type="net.vonforst.evmap.api.UtilsKt" />
<import type="com.github.erfansn.localeconfigx.LocaleConfigXKt" />
<import type="net.vonforst.evmap.viewmodel.Status" />
<import type="net.vonforst.evmap.ui.BindingAdaptersKt" />
@@ -61,6 +63,7 @@
app:layout_constraintEnd_toStartOf="@+id/textView16"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
android:textAlignment="viewStart"
tools:text="Nikola-Tesla-Parkhaus mit extra langem Namen, der auf mehrere Zeilen umbricht" />
<TextView
@@ -72,6 +75,7 @@
android:maxLines="1"
android:text="@{item.charger.address.toString()}"
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
android:textAlignment="viewStart"
app:invisibleUnless="@{item.charger.address != null}"
app:layout_constraintEnd_toStartOf="@+id/textView7"
app:layout_constraintStart_toStartOf="parent"
@@ -85,8 +89,9 @@
android:layout_marginEnd="8dp"
android:ellipsize="end"
android:maxLines="1"
android:text="@{item.charger.formatChargepoints(ChargepointApiKt.stringProvider(context))}"
android:text="@{item.charger.formatChargepoints(ChargepointApiKt.stringProvider(context), LocaleConfigXKt.getCurrentOrDefaultLocale(context))}"
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
android:textAlignment="viewStart"
app:layout_constraintEnd_toStartOf="@+id/textView7"
app:layout_constraintStart_toStartOf="@+id/textView2"
app:layout_constraintTop_toBottomOf="@+id/textView2"
@@ -111,7 +116,7 @@
android:layout_marginEnd="16dp"
android:background="@drawable/rounded_rect"
android:padding="2dp"
android:text="@{String.format(&quot;%s/%d&quot;, BindingAdaptersKt.availabilityText(item.available.data), item.total)}"
android:text="@{String.format(LocaleConfigXKt.getCurrentOrDefaultLocale(context), &quot;%s/%d&quot;, BindingAdaptersKt.availabilityText(item.available.data), item.total)}"
android:textAppearance="@style/TextAppearance.AppCompat.Body1"
android:textColor="@android:color/white"
app:backgroundTintAvailability="@{item.available.data}"

View File

@@ -29,6 +29,7 @@
android:layout_marginBottom="16dp"
android:text="@{item.filter.name}"
android:textAppearance="@style/TextAppearance.Material3.BodyLarge"
android:textAlignment="viewStart"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/switch1"
app:layout_constraintStart_toStartOf="parent"

View File

@@ -32,6 +32,7 @@
android:layout_marginTop="16dp"
android:text="@{item.filter.name}"
android:textAppearance="@style/TextAppearance.Material3.BodyLarge"
android:textAlignment="viewStart"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="Connectors" />

View File

@@ -33,6 +33,7 @@
android:layout_marginEnd="8dp"
android:text="@{item.filter.name}"
android:textAppearance="@style/TextAppearance.Material3.BodyLarge"
android:textAlignment="viewStart"
app:layout_constraintEnd_toStartOf="@+id/btnEdit"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
@@ -61,6 +62,7 @@
android:layout_marginTop="4dp"
android:layout_marginEnd="8dp"
android:text="@{item.value.all ? @string/all_selected : @string/number_selected(item.value.values.size())}"
android:textAlignment="viewStart"
app:layout_constraintEnd_toStartOf="@+id/btnEdit"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/textView17"

View File

@@ -30,6 +30,7 @@
android:layout_marginEnd="16dp"
android:text="@string/map_type"
android:textAppearance="@style/TextAppearance.Material3.TitleSmall"
android:textAlignment="viewStart"
app:layout_constraintEnd_toStartOf="@id/btnClose"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
@@ -52,7 +53,8 @@
android:layout_height="wrap_content"
android:checked="@{vm.mapType.equals(AnyMap.Type.NORMAL)}"
android:onClick="@{() -> vm.setMapType(AnyMap.Type.NORMAL)}"
android:text="@string/map_type_normal" />
android:text="@string/map_type_normal"
android:textAlignment="viewStart" />
<RadioButton
android:id="@+id/rbSatellite"
@@ -60,7 +62,8 @@
android:layout_height="wrap_content"
android:checked="@{vm.mapType.equals(AnyMap.Type.HYBRID)}"
android:onClick="@{() -> vm.setMapType(AnyMap.Type.HYBRID)}"
android:text="@string/map_type_satellite" />
android:text="@string/map_type_satellite"
android:textAlignment="viewStart" />
<RadioButton
android:id="@+id/rbTerrain"
@@ -68,7 +71,8 @@
android:layout_height="wrap_content"
android:checked="@{vm.mapType.equals(AnyMap.Type.TERRAIN)}"
android:onClick="@{() -> vm.setMapType(AnyMap.Type.TERRAIN)}"
android:text="@string/map_type_terrain" />
android:text="@string/map_type_terrain"
android:textAlignment="viewStart" />
</RadioGroup>
<TextView
@@ -80,6 +84,7 @@
android:layout_marginEnd="16dp"
android:text="@string/map_details"
android:textAppearance="@style/TextAppearance.Material3.TitleSmall"
android:textAlignment="viewStart"
app:goneUnless="@{vm.mapTrafficSupported}"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.0"
@@ -95,6 +100,7 @@
android:layout_marginEnd="16dp"
android:text="@string/map_traffic"
android:checked="@={vm.mapTrafficEnabled}"
android:textAlignment="viewStart"
app:goneUnless="@{vm.mapTrafficSupported}"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"

View File

@@ -3,14 +3,14 @@
<string name="no_browser_app_found">Nejprve si nainstalujte webový prohlížeč</string>
<string name="address">Adresa</string>
<string name="hours">Otevírací doba</string>
<string name="open_247"><b>Otevřeno 24/7</b></string>
<string name="closed"><b>Zavřeno</b></string>
<string name="open_closesat"><b>Otevřeno</b> · Zavírá v %s</string>
<string name="closed_opensat"><b>Zavřeno</b> · Otevírá v %s</string>
<string name="open_247"><![CDATA[<b>Otevřeno 24/7</b>]]></string>
<string name="closed"><![CDATA[<b>Zavřeno</b>]]></string>
<string name="open_closesat"><![CDATA[<b>Otevřeno</b> · Zavírá v %s]]></string>
<string name="closed_opensat"><![CDATA[<b>Zavřeno</b> · Otevírá v %s]]></string>
<string name="cost">Cena</string>
<string name="cost_detail"><b>Nabíjení:</b> %1$s · <b>Parkování:</b> %2$s</string>
<string name="cost_detail_charging"><b>%s nabíjení</b></string>
<string name="cost_detail_parking"><b>%s parkování</b></string>
<string name="cost_detail"><![CDATA[<b>Nabíjení:</b> %1$s · <b>Parkování:</b> %2$s]]></string>
<string name="cost_detail_charging"><![CDATA[<b>%s nabíjení</b>]]></string>
<string name="cost_detail_parking"><![CDATA[<b>%s parkování</b>]]></string>
<string name="charging_free">Bezplatné</string>
<string name="charging_paid">Placené</string>
<string name="parking_free">Bezplatné</string>
@@ -177,7 +177,7 @@
<string name="crash_report_comment_prompt">Níže můžete přidat komentář:</string>
<string name="powered_by_mapbox">používá službu Mapbox</string>
<string name="pref_search_provider">Poskytovatel vyhledávání</string>
<string name="pref_search_provider_info">Načtení dat pro vyhledávání bývá drahé, obzvláště z Map Google. Zvažte prosím poslání finančního daru v nabídce „O aplikaci“ → „Přispět“.</string>
<string name="pref_search_provider_info"><![CDATA[Načtení dat pro vyhledávání bývá drahé, obzvláště z Map Google. Zvažte prosím poslání finančního daru v nabídce „O aplikaci“ → „Přispět“.]]></string>
<string name="github_sponsors">GitHub Sponsors</string>
<string name="donate_desc">Podpořte vývoj aplikace EVMap jednorázovým darem</string>
<string name="github_sponsors_desc">Podpořte EVMap ve službě GitHub Sponsors</string>
@@ -281,7 +281,7 @@
<string name="loading">Načítání…</string>
<string name="auto_multipage_goto">Stránka %d</string>
<string name="reload">Obnovit</string>
<string name="accept_privacy"><![CDATA[Přečetl/a jsem si a souhlasím se <a href="%s">zásadami ochrany osobních údajů</a> aplikace EVMap.]]></string>
<string name="accept_privacy"><![CDATA[Přečetl/a jsem si a souhlasím se <a href=\"%s\">zásadami ochrany osobních údajů</a> aplikace EVMap.]]></string>
<string name="referrals">Referenční odkazy</string>
<string name="referrals_info">Pro podpoření vývojáře svým nákupem můžete také použít jeden z referenčních odkazů níže.</string>
<string name="generic_connection_error">Nepodařilo se načíst data</string>
@@ -344,7 +344,7 @@
<string name="chargeprice_connection_error">Nepodařilo se načíst ceny</string>
<string name="unknown_operator">Neznámý operátor</string>
<string name="data_source_goingelectric">GoingElectric.de</string>
<string name="data_source_openchargemap_desc">Celosvětové, s různou kvalitou. Popisy jsou v angličtině nebo v místním jazyce. Spravováno komunitou, v některých zemích obsahuje vládní data (např. Severní Amerika, Spojené království, Francie, Norsko).</string>
<string name="data_source_openchargemap_desc"><![CDATA[Celosvětové, s různou kvalitou. Popisy jsou v angličtině nebo v místním jazyce. Spravováno komunitou, v některých zemích obsahuje vládní data (např. Severní Amerika, Spojené království, Francie, Norsko).]]></string>
<string name="privacy_link">https://ev-map.app/privacypolicy/</string>
<string name="chargeprice_faq_link">https://ev-map.app/faq/#price-comparison-feature</string>
<string name="pref_darkmode_always_on">vždy zapnut</string>
@@ -372,4 +372,5 @@
<string name="pref_chargeprice_native_integration_on">Data o cenách budou zobrazena přímo v EVMap</string>
<string name="pref_chargeprice_native_integration_off">Tlačítko porovnání cen bude odkazovat na aplikaci nebo web Chargeprice</string>
<string name="pref_provider_osm">OpenStreetMap</string>
<string name="filterprofile_name_not_unique">Již existuje profil filtru s tímto názvem</string>
</resources>

View File

@@ -101,6 +101,7 @@
<string name="pref_language">App-Sprache</string>
<string name="pref_darkmode">Dunkles Design</string>
<string name="connection_error">Ladesäulen konnten nicht geladen werden</string>
<string name="zoom_in_to_see_more">Hineinzoomen um alle Ladestationen zu sehen</string>
<string name="location_error">Standort nicht erkannt. Bitte Systemeinstellungen prüfen</string>
<string name="retry">Wiederholen</string>
<string name="filter_open_247">24 Stunden geöffnet</string>
@@ -110,7 +111,9 @@
<string name="and_n_others">und %d weitere</string>
<string name="pref_map_provider">Kartenanbieter</string>
<string name="twitter">Twitter</string>
<string name="mastodon">Mastodon</string>
<string name="goingelectric_forum">Forenthread bei GoingElectric.de</string>
<string name="tff_forum">Forenthread im TFF-Forum</string>
<string name="contact">Kontakt</string>
<string name="menu_report_new_charger">Ladesäule melden</string>
<string name="edit_at_datasource">Bei %s bearbeiten</string>
@@ -151,6 +154,7 @@
<string name="delete">Löschen</string>
<string name="save_as_profile">Als Profil speichern</string>
<string name="save_profile_enter_name">Gib den Namen des Filterprofils ein:</string>
<string name="filterprofile_name_not_unique">Ein Filterprofil mit diesem Namen existiert bereits</string>
<string name="filterprofiles_empty_state">Du hast keine Filterprofile gespeichert</string>
<string name="welcome_to_evmap">Willkommen bei EVMap</string>
<string name="welcome_1">Finde Ladestationen für Elektroautos in deiner Nähe</string>
@@ -232,7 +236,7 @@
<string name="crash_report_comment_prompt">Du kannst unten noch einen Kommentar hinzufügen:</string>
<string name="powered_by_mapbox">powered by Mapbox</string>
<string name="pref_search_provider">Anbieter für Ortssuche</string>
<string name="pref_search_provider_info">Die Daten für die Ortssuche, vor allem von Google Maps, sind relativ teuer. Über eine Spende unter \"Über EVMap -&gt; Spenden\" würde ich mich sehr freuen.</string>
<string name="pref_search_provider_info"><![CDATA[Die Daten für die Ortssuche, vor allem von Google Maps, sind relativ teuer. Über eine Spende unter Über EVMap Spenden würde ich mich sehr freuen.]]></string>
<string name="github_sponsors">GitHub Sponsors</string>
<string name="donate_desc">Unterstütze die Weiterentwicklung von EVMap mit einer einmaligen Spende</string>
<string name="github_sponsors_desc">Unterstütze EVMap über GitHub Sponsors</string>
@@ -240,6 +244,7 @@
<string name="privacy_link">https://ev-map.app/de/privacypolicy/</string>
<string name="faq_link">https://ev-map.app/de/faq/</string>
<string name="chargeprice_faq_link">https://ev-map.app/de/faq/#preisvergleichsfunktion</string>
<string name="referral_link">https://ev-map.app/de/referrals/</string>
<string name="required">erforderlich</string>
<string name="edit_filter_profile">„%s“ bearbeiten</string>
<string name="pref_search_delete_recent">Suchverlauf löschen</string>
@@ -367,5 +372,4 @@
<string name="pref_chargeprice_native_integration">Preisvergleich in EVMap</string>
<string name="pref_chargeprice_native_integration_on">Preise werden direkt in EVMap angezeigt</string>
<string name="pref_chargeprice_native_integration_off">Preisvergleich verlinkt auf die App oder Website von Chargeprice</string>
<string name="auto_zoom_for_details">Für Details hineinzoomen</string>
</resources>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<dimen name="directions_fab_translationx">44dp</dimen>
</resources>

View File

@@ -148,7 +148,7 @@
<string name="pref_data_source">Fonte da informação</string>
<string name="data_source_openchargemap">Open Charge Map</string>
<string name="next">próximo</string>
<string name="data_source_openchargemap_desc">Mundial, com vários níveis de qualidade. Descrições em inglês ou língua local. Mantido pela comunidade e usa informação governamental publica em alguns países (ex: América do Norte, Reino Unido, França, Noruega, etc).</string>
<string name="data_source_openchargemap_desc"><![CDATA[Mundial, com vários níveis de qualidade. Descrições em inglês ou língua local. Mantido pela comunidade e usa informação governamental publica em alguns países (ex: América do Norte, Reino Unido, França, Noruega, etc).]]></string>
<string name="get_started">Começar</string>
<string name="lets_go">Vamos lá</string>
<string name="crash_report_text">O EVMap encontrou um problema. Por favor envie um relatório do erro para o criador da app.</string>
@@ -160,7 +160,7 @@
<string name="pref_map_rotate_gestures_on">Use dois dedos para girar o mapa</string>
<string name="pref_map_rotate_gestures_off">Rotação desligada (norte sempre para cima)</string>
<string name="refresh_live_data">atualizar estado em tempo real</string>
<string name="pref_search_provider_info">As pesquisas são caras, especialmente se o Google Maps for utilizado. Por favor considere doar através de \"Sobre\" → \"Doar\".</string>
<string name="pref_search_provider_info"><![CDATA[As pesquisas são caras, especialmente quando o Google Maps é utilizado. Por favor considere doar através de "Sobre" → "Doar".]]></string>
<string name="github_sponsors_desc">Apoie o EVMap através do GitHub</string>
<string name="unnamed_filter_profile">Filtro sem nome</string>
<string name="deleted_recent_search_results">As pesquisas recentes foram eliminadas</string>
@@ -205,18 +205,18 @@
<string name="operator">Operador</string>
<string name="network">Rede</string>
<string name="hours">Horário de abertura</string>
<string name="open_247"><b>Aberto 24/7</b></string>
<string name="closed"><b>Fechado</b></string>
<string name="open_closesat"><b>Aberto</b> · Fecha às %s</string>
<string name="closed_opensat"><b>Fechado</b> · Abre às %s</string>
<string name="open_247"><![CDATA[<b>Aberto 24/7</b>]]></string>
<string name="closed"><![CDATA[<b>Fechado</b>]]></string>
<string name="open_closesat"><![CDATA[<b>Aberto</b> · Fecha às %s]]></string>
<string name="closed_opensat"><![CDATA[<b>Fechado</b> · Abre às %s]]></string>
<string name="app_name">EVMap</string>
<string name="title_activity_maps">EVMap</string>
<string name="closed_unfmt">Fechado</string>
<string name="holiday">Feriado</string>
<string name="cost">Custo</string>
<string name="cost_detail"><b>Carregamento:</b> %1$s · <b>Parque:</b> %2$s</string>
<string name="cost_detail_charging"><b>Carregamento %s</b></string>
<string name="cost_detail_parking"><b>Parque %s</b></string>
<string name="cost_detail"><![CDATA[<b>Carregamento:</b> %1$s · <b>Parque:</b> %2$s]]></string>
<string name="cost_detail_charging"><![CDATA[<b>Carregamento %s</b>]]></string>
<string name="cost_detail_parking"><![CDATA[<b>Parque %s</b>]]></string>
<string name="charging_free">Gratuito</string>
<string name="charging_paid">Pago</string>
<string name="parking_free">Gratuito</string>
@@ -372,4 +372,5 @@
<string name="pref_chargeprice_native_integration_on">Os preços serão exibidos diretamente no EVMap</string>
<string name="pref_chargeprice_native_integration_off">O botão de comparação de preços abrirá a app ou site do Chargeprice</string>
<string name="pref_provider_osm">OpenStreetMap</string>
<string name="filterprofile_name_not_unique">Já existe um filtro com este nome</string>
</resources>

View File

@@ -12,4 +12,5 @@
<dimen name="map_toolbar_width">@dimen/match_parent</dimen>
<dimen name="layers_fab_top_padding">100dp</dimen>
<dimen name="directions_fab_translationx">0dp</dimen>
<dimen name="fab_margin">16dp</dimen>
</resources>

View File

@@ -5,7 +5,10 @@
<string name="github_link">https://github.com/ev-map/EVMap</string>
<string name="twitter_handle">\@ev_map</string>
<string name="twitter_url">https://twitter.com/ev_map</string>
<string name="mastodon_handle">\@evmap\@electroverse.tech</string>
<string name="mastodon_url">https://electroverse.tech/@evmap</string>
<string name="goingelectric_forum_url"><![CDATA[https://www.goingelectric.de/forum/viewtopic.php?f=5&t=56342]]></string>
<string name="tff_forum_url"><![CDATA[https://tff-forum.de/t/283834]]></string>
<string name="github_sponsors_link">https://github.com/sponsors/johan12345/</string>
<string name="chargeprice_api_url">https://api.chargeprice.app/v1/</string>
<string name="fronyx_url">https://fronyx.io/</string>
@@ -27,18 +30,6 @@
<string name="hide_on_scroll_fab_behavior">net.vonforst.evmap.ui.HideOnScrollFabBehavior</string>
<string name="paypal_link" translatable="false">https://paypal.me/johan98</string>
<string name="donate_link" translatable="false">https://ev-map.app/donate/</string>
<string name="tesla_referral_link" translatable="false">http://ts.la/johan94494</string>
<string name="juicify_referral_link" translatable="false">https://trck.juicify.green/trck/eclick/9dba357fbfed1e82fb05c7ec004ee2972ea174ce46d8ae0d</string>
<string name="geldfuereauto_referral_link" translatable="false">https://trck.geld-fuer-eauto.de/trck/eclick/c4713e9520bdb8842a3f1fbfa3a0669b3e58421043df78ad</string>
<string name="maingau_referral_link" translatable="false">https://trck.maingau-energie.de/trck/eclick/799b39cda39575dab1dcd3351abeb77b62dc33e4f9558a57</string>
<string name="ewieeinfach_referral_link" translatable="false">https://trck.e-wie-einfach.de/trck/eclick/fca74c186b54e7287a62102a13e073be4fc963825b85f7df</string>
<string name="eprimo_referral_link" translatable="false">https://netzwerk.uppr.de/trck/eclick/781768d2e779806b5e09229932662c14adddd69323594c52</string>
<string name="referral_juicify" translatable="false">Juicify</string>
<string name="referral_geldfuereauto" translatable="false">Geld für eAuto</string>
<string name="referral_maingau" translatable="false">Maingau</string>
<string name="referral_ewieeinfach" translatable="false">E wie einfach</string>
<string name="referral_eprimo" translatable="false">eprimo</string>
<string name="copyright_summary">©20202024 Johan von Forstner and contributors</string>
<string name="acra_backend_url" translatable="false">https://acra.muc.vonforst.net/report</string>
<string name="maplibre_attributionsDialogTitle">MapLibre Maps SDK for Android</string>
</resources>

View File

@@ -101,6 +101,7 @@
<string name="pref_language">App language</string>
<string name="pref_darkmode">Dark mode</string>
<string name="connection_error">Could not load charging stations</string>
<string name="zoom_in_to_see_more">Zoom in to see all charging stations</string>
<string name="location_error">Failed to detect location. Please check system settings</string>
<string name="retry">Retry</string>
<string name="filter_open_247">Available 24/7</string>
@@ -110,7 +111,9 @@
<string name="and_n_others">and %d others</string>
<string name="pref_map_provider">Map provider</string>
<string name="twitter">Twitter</string>
<string name="mastodon">Mastodon</string>
<string name="goingelectric_forum">Forum thread at GoingElectric.de</string>
<string name="tff_forum">Forum thread at TFF-Forum.de</string>
<string name="contact">Contact</string>
<string name="menu_report_new_charger">New charger</string>
<string name="edit_at_datasource">Edit at %s</string>
@@ -151,6 +154,7 @@
<string name="delete">Delete</string>
<string name="save_as_profile">Save as profile</string>
<string name="save_profile_enter_name">Enter the name of the filter profile:</string>
<string name="filterprofile_name_not_unique">There is already a filter profile with that name</string>
<string name="filterprofiles_empty_state">You have no filter profiles saved</string>
<string name="welcome_to_evmap">Welcome to EVMap</string>
<string name="welcome_1">Find electric vehicle chargers around you</string>
@@ -240,6 +244,7 @@
<string name="privacy_link">https://ev-map.app/privacypolicy/</string>
<string name="faq_link">https://ev-map.app/faq/</string>
<string name="chargeprice_faq_link">https://ev-map.app/faq/#price-comparison-feature</string>
<string name="referral_link">https://ev-map.app/referrals/</string>
<string name="required">required</string>
<string name="edit_filter_profile">Edit “%s”</string>
<string name="pref_search_delete_recent">Delete recent search results</string>
@@ -367,5 +372,4 @@
<string name="pref_chargeprice_native_integration">Price comparison within EVMap</string>
<string name="pref_chargeprice_native_integration_on">Pricing data will be shown directly in EVMap</string>
<string name="pref_chargeprice_native_integration_off">Price comparison button will refer to the Chargeprice app or website</string>
<string name="auto_zoom_for_details">Zoom in to see details</string>
</resources>

View File

@@ -39,6 +39,10 @@
</PreferenceCategory>
<PreferenceCategory android:title="@string/contact">
<Preference
android:key="mastodon"
android:title="@string/mastodon"
android:summary="@string/mastodon_handle" />
<Preference
android:key="twitter"
@@ -49,6 +53,10 @@
android:key="goingelectric"
android:title="@string/goingelectric_forum" />
<Preference
android:key="tffforum"
android:title="@string/tff_forum" />
</PreferenceCategory>
<PreferenceCategory android:title="@string/other">

View File

@@ -10,11 +10,11 @@
android:defaultValue="goingelectric"
app:useSimpleSummaryProvider="true" />
<CheckBoxPreference
<!--<CheckBoxPreference
android:key="prediction_enabled"
android:title="@string/pref_prediction_enabled"
android:defaultValue="true"
android:summary="@string/pref_prediction_enabled_summary" />
android:summary="@string/pref_prediction_enabled_summary" />-->
<Preference
android:key="tesla_account"

View File

@@ -1,7 +1,7 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript {
val kotlinVersion by extra("1.9.24")
val kotlinVersion by extra("2.0.21")
val aboutLibsVersion by extra("10.9.1")
val navVersion by extra("2.7.7")
repositories {
@@ -10,7 +10,7 @@ buildscript {
gradlePluginPortal()
}
dependencies {
classpath("com.android.tools.build:gradle:8.4.2")
classpath("com.android.tools.build:gradle:8.9.3")
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion")
classpath("com.mikepenz.aboutlibraries.plugin:aboutlibraries-plugin:$aboutLibsVersion")
classpath("androidx.navigation:navigation-safe-args-gradle-plugin:$navVersion")

View File

@@ -0,0 +1,2 @@
Fehler behoben:
- Abstürze behoben

View File

@@ -0,0 +1,2 @@
Fehler behoben:
- Anzeigefehler behoben

View File

@@ -0,0 +1,5 @@
Verbesserungen:
- Preisvergleich: Zurücksetzen der Ladebereichsauswahl durch Tippen auf den Titel darüber
Fehler behoben:
- Anzeigefehler behoben

View File

@@ -0,0 +1,2 @@
Fehler behoben:
- Anzeigefehler behoben

View File

@@ -0,0 +1,3 @@
Fehler behoben:
- Anzeigefehler behoben
- Abstürze behoben

View File

@@ -0,0 +1,2 @@
Fehler behoben:
- Echtzeitdaten für Tesla Supercharger repariert

View File

@@ -0,0 +1,3 @@
Fehler behoben:
- Abstürze behoben
- Aktueller Standort wurde nicht immer angezeigt, obwohl verfügbar

View File

@@ -0,0 +1,2 @@
Bugfixes:
- Fixed crashes

View File

@@ -0,0 +1,2 @@
Bugfixes:
- Fixed display errors

View File

@@ -0,0 +1,5 @@
Improvements:
- Price comparison: Reset charging range by tapping on title above it
Bugfixes:
- Fixed display errors

View File

@@ -0,0 +1,2 @@
Bugfixes:
- Fixed display errors

View File

@@ -0,0 +1,3 @@
Bugfixes:
- Fixed display errors
- Fixed crashes

View File

@@ -0,0 +1,2 @@
Bugfixes:
- Fixed realtime data for Tesla Superchargers

View File

@@ -0,0 +1,3 @@
Bugfixes:
- Fixed crashes
- Fixed current location not being shown despite it being available

View File

@@ -12,7 +12,7 @@
# org.gradle.parallel=true
#Sun Jul 24 11:49:27 CEST 2022
kotlin.code.style=official
org.gradle.jvmargs=-Xmx2048M -Dkotlin.daemon.jvm.options\="-Xmx2048M"
org.gradle.jvmargs=-Xmx4096M -Dkotlin.daemon.jvm.options\="-Xmx4096M"
android.useAndroidX=true
android.nonTransitiveRClass=true
android.nonFinalResIds=true

View File

@@ -1,6 +1,6 @@
#Sat Aug 06 15:33:46 CEST 2022
distributionBase=GRADLE_USER_HOME
distributionUrl=https\://services.gradle.org/distributions/gradle-8.6-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip
distributionPath=wrapper/dists
zipStorePath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME