Compare commits

..

1 Commits
1.1.2 ... 0.9.0

Author SHA1 Message Date
johan12345
c2292ad7fa Release 0.9.0 2021-08-09 19:01:35 +02:00
206 changed files with 1232 additions and 5947 deletions

2
.gitattributes vendored
View File

@@ -1,2 +0,0 @@
_img/screenshots/**/*.png filter=lfs diff=lfs merge=lfs -text
fastlane/metadata/android/**/images/**/*.png filter=lfs diff=lfs merge=lfs -text

2
.github/FUNDING.yml vendored
View File

@@ -1,2 +0,0 @@
github: johan12345
custom: 'https://paypal.me/johan98'

View File

@@ -1,4 +1,4 @@
EVMap [![Build Status](https://app.travis-ci.com/johan12345/EVMap.svg?branch=master)](https://app.travis-ci.com/johan12345/EVMap)
EVMap [![Build Status](https://travis-ci.org/johan12345/EVMap.svg?branch=master)](https://travis-ci.org/johan12345/EVMap)
=====
<img src="https://raw.githubusercontent.com/johan12345/EVMap/master/_img/feature_graphic.svg" width=700 alt="Logo"/>
@@ -19,7 +19,7 @@ Features
- Search for places
- Advanced filtering options, including saved filter profiles
- Favorites list, also with availability information
- Integrated price comparison using [Chargeprice.app](https://chargeprice.app) (only in Europe)
- Integrated price comparison using Chargeprice.app [Chargeprice.app](https://chargeprice.app) (only in Europe)
- Android Auto integration
- No ads, fully open source
- Compatible with Android 5.0 and above
@@ -28,22 +28,38 @@ Features
Screenshots
-----------
<img src="https://raw.githubusercontent.com/johan12345/EVMap/master/_img/screenshots/phone/en/mapbox/01_map.png" width=250 alt="Screenshot 1"/><img src="https://raw.githubusercontent.com/johan12345/EVMap/master/_img/screenshots/phone/en/mapbox/02_detail.png" width=250 alt="Screenshot 2"/>
<img src="https://raw.githubusercontent.com/johan12345/EVMap/master/_img/screenshots/phone/01_main.png" width=250 alt="Screenshot 1"/><img src="https://raw.githubusercontent.com/johan12345/EVMap/master/_img/screenshots/phone/02_detail.png" width=250 alt="Screenshot 2"/>
Development setup
-----------------
The App is developed using Android Studio and should pretty much work out-of-the-box when you clone
the Git repository and open the project with Android Studio.
The App is developed using Android Studio.
The only exception is that you need to obtain some free API keys for the different data sources that
EVMap uses and put them into the app in the form of a resource file called `apikeys.xml` under
`app/src/main/res/values`. You can find more information on which API keys are necessary for which
features and how they can be obtained in our [documentation page](doc/api_keys.md).
For testing the app, you need to obtain free API Keys for the
[GoingElectric API](https://www.goingelectric.de/stromtankstellen/api/),
the [Chargeprice API](https://github.com/chargeprice/chargeprice-api-docs),
the [OpenChargeMap API](https://openchargemap.org/site/profile/appedit),
as well as for [Google APIs](https://console.developers.google.com/)
("Maps SDK for Android" and "Places API" need to be activated) and/or [Mapbox](https://www.mapbox.com/). These API keys need to be put into the
app in the form of a resource file called `apikeys.xml` under `app/src/main/res/values`, with the
following content:
There are two different build flavors, `google` and `foss`, where only the `google` variant uses
Google Maps data and provides the Android Auto integration. The `foss` variant only uses Mapbox data
and should run on devices without Google Play Services.
We also have a special [documentation page](doc/android_auto.md) on how to test the Android Auto
app.
```xml
<resources>
<string name="google_maps_key" templateMergeStrategy="preserve" translatable="false">
insert your Google Maps key here
</string>
<string name="mapbox_key" translatable="false">
insert your Mapbox key here
</string>
<string name="goingelectric_key" translatable="false">
insert your GoingElectric key here
</string>
<string name="chargeprice_key" translatable="false">
insert your Chargeprice key here
</string>
<string name="openchargemap_key" translatable="false">
insert your OpenChargeMap key here
</string>
</resources>
```

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 194 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 242 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 909 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 943 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 972 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 352 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 113 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 875 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 837 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 341 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 972 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 343 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 864 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 837 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 330 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 122 KiB

View File

@@ -6,15 +6,15 @@ apply plugin: 'androidx.navigation.safeargs.kotlin'
apply plugin: 'com.mikepenz.aboutlibraries.plugin'
android {
compileSdkVersion 31
compileSdkVersion 30
buildToolsVersion "30.0.3"
defaultConfig {
applicationId "net.vonforst.evmap"
minSdkVersion 21
targetSdkVersion 31
versionCode 66
versionName "1.1.2"
targetSdkVersion 30
versionCode 53
versionName "0.9.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
@@ -89,9 +89,6 @@ android {
variant.resValue "string", "google_maps_key", googleMapsKey
}
def mapboxKey = env.MAPBOX_API_KEY ?: project.findProperty("MAPBOX_API_KEY")
if (mapboxKey == null && project.hasProperty("MAPBOX_API_KEY_ENCRYPTED")) {
mapboxKey = decode(project.findProperty("MAPBOX_API_KEY_ENCRYPTED"), "FmK.d,-f*p+rD+WK!eds")
}
if (mapboxKey != null) {
variant.resValue "string", "mapbox_key", mapboxKey
}
@@ -103,25 +100,21 @@ android {
variant.resValue "string", "chargeprice_key", chargepriceKey
}
}
lintOptions {
disable 'NullSafeMutableLiveData'
}
}
dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar'])
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
implementation 'androidx.appcompat:appcompat:1.3.1'
implementation 'androidx.core:core-ktx:1.7.0'
implementation 'androidx.core:core-splashscreen:1.0.0-alpha02'
implementation "androidx.activity:activity-ktx:1.4.0"
implementation "androidx.fragment:fragment-ktx:1.3.6"
implementation 'androidx.appcompat:appcompat:1.3.0'
implementation 'androidx.core:core-ktx:1.5.0'
implementation "androidx.activity:activity-ktx:1.2.3"
implementation "androidx.fragment:fragment-ktx:1.3.4"
implementation 'androidx.cardview:cardview:1.0.0'
implementation 'androidx.preference:preference-ktx:1.1.1'
implementation 'com.google.android.material:material:1.4.0'
implementation 'androidx.constraintlayout:constraintlayout:2.1.1'
implementation 'androidx.recyclerview:recyclerview:1.2.1'
implementation 'androidx.browser:browser:1.4.0'
implementation 'com.google.android.material:material:1.3.0'
implementation 'androidx.constraintlayout:constraintlayout:2.0.4'
implementation 'androidx.recyclerview:recyclerview:1.2.0'
implementation 'androidx.browser:browser:1.3.0'
implementation 'com.github.johan12345:CustomBottomSheetBehavior:f69f532660'
implementation 'com.squareup.retrofit2:retrofit:2.9.0'
implementation 'com.squareup.retrofit2:converter-moshi:2.9.0'
@@ -135,7 +128,7 @@ dependencies {
implementation 'com.github.johan12345:StfalconImageViewer:5082ebd392'
implementation "com.mikepenz:aboutlibraries-core:$about_libs_version"
implementation "com.mikepenz:aboutlibraries:$about_libs_version"
implementation 'com.airbnb.android:lottie:4.1.0'
implementation 'com.airbnb.android:lottie:3.4.0'
implementation 'io.michaelrocks.bimap:bimap:1.1.0'
implementation 'com.mapzen.android:lost:3.0.2'
implementation 'com.google.guava:guava:29.0-android'
@@ -143,29 +136,41 @@ dependencies {
implementation 'com.github.romandanylyk:PageIndicatorView:b1bad589b5'
// Android Auto
googleImplementation 'androidx.car.app:app:1.1.0-rc01'
googleImplementation 'androidx.car.app:app-projected:1.1.0-rc01'
googleImplementation 'androidx.car.app:app:1.0.0'
// AnyMaps
def anyMapsVersion = '751daec281'
def anyMapsVersion = '95ddd6c083'
implementation "com.github.johan12345.AnyMaps:anymaps-base:$anyMapsVersion"
googleImplementation "com.github.johan12345.AnyMaps:anymaps-google:$anyMapsVersion"
googleImplementation 'com.google.android.gms:play-services-maps:18.0.0'
implementation "com.github.johan12345.AnyMaps:anymaps-mapbox:$anyMapsVersion"
// Google Places
implementation 'com.google.android.libraries.places:places:2.5.0'
googleImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.4.1'
// Google Maps v3 Beta
googleImplementation 'com.google.android.libraries.maps:maps:3.1.0-beta'
googleImplementation name:'places-maps-sdk-3.1.0-beta', ext:'aar'
googleImplementation 'com.android.volley:volley:1.2.0'
googleImplementation 'com.google.android.gms:play-services-base:17.5.0'
googleImplementation 'com.google.android.gms:play-services-basement:17.5.0'
googleImplementation 'com.google.android.gms:play-services-gcm:17.0.0'
googleImplementation 'com.google.android.gms:play-services-location:17.1.0'
googleImplementation 'com.google.android.gms:play-services-tasks:17.2.0'
googleImplementation 'com.google.auto.value:auto-value-annotations:1.6.3'
googleImplementation 'com.google.code.gson:gson:2.8.6'
googleImplementation 'com.google.android.datatransport:transport-runtime:2.2.5'
googleImplementation 'androidx.lifecycle:lifecycle-extensions:2.2.0'
// Mapbox Geocoding
implementation 'com.mapbox.mapboxsdk:mapbox-sdk-services:5.5.0'
// Mapbox places (autocomplete)
// forked this library and included through JitPack to fix https://github.com/mapbox/mapbox-plugins-android/issues/1011
implementation('com.github.johan12345.mapbox-plugins-android:mapbox-android-plugin-places-v9:922bf877f6') {
exclude group: 'com.mapbox.mapboxsdk', module: 'mapbox-android-accounts'
exclude group: 'com.mapbox.mapboxsdk', module: 'mapbox-android-telemetry'
}
// navigation library
implementation "androidx.navigation:navigation-fragment-ktx:$nav_version"
implementation "androidx.navigation:navigation-ui-ktx:$nav_version"
// viewmodel library
def lifecycle_version = "2.4.0"
def lifecycle_version = "2.3.1"
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version"
implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycle_version"
@@ -180,21 +185,15 @@ dependencies {
googleImplementation "com.android.billingclient:billing:$billing_version"
googleImplementation "com.android.billingclient:billing-ktx:$billing_version"
// ACRA (crash reporting)
def acraVersion = "5.8.4"
implementation("ch.acra:acra-mail:$acraVersion")
implementation("ch.acra:acra-dialog:$acraVersion")
implementation("ch.acra:acra-limiter:$acraVersion")
// debug tools
implementation 'com.facebook.stetho:stetho:1.5.1'
implementation 'com.facebook.stetho:stetho-okhttp3:1.5.1'
testImplementation 'junit:junit:4.13.2'
testImplementation 'junit:junit:4.13'
testImplementation "com.squareup.okhttp3:mockwebserver:4.9.0"
//noinspection GradleDependency
testImplementation 'org.json:json:20080701'
androidTestImplementation 'androidx.test.ext:junit:1.1.3'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
androidTestImplementation 'androidx.test.ext:junit:1.1.2'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.3.0'
kapt "com.squareup.moshi:moshi-kotlin-codegen:1.12.0"

View File

Binary file not shown.

View File

@@ -1,107 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="198.3471"
android:viewportHeight="198.3471">
<group
android:translateX="3.1735537"
android:translateY="3.1735537">
<group>
<clip-path android:pathData="M122.5,57.1c-5.8,-7.7 -15.2,-12.4 -24.7,-12.9H94c-8.1,0.5 -15.9,3.9 -21.7,9.6c-4.3,4.3 -7.4,9.7 -8.6,15.7c-1.5,7.5 -0.1,15.4 3.5,22.1c2.3,4.5 5.2,8.6 8.1,12.8c4.9,6.8 9.8,13.7 13.2,21.3c3.1,6.5 4.9,13.6 5.6,20.8c0.4,0.6 1.1,1 1.7,1.5c2.6,-1 2,-4 2.5,-6.2c1.9,-12.8 8.7,-24.2 16.1,-34.6c6.6,-9.2 14.1,-19 14.4,-30.8V74C128.5,67.9 126.3,61.9 122.5,57.1zM106.2,74.3l-12.2,21V79.5h-5.2V60.3h17.5l-7,14H106.2z" />
<path
android:pathData="M106.2,74.3h-7"
android:strokeAlpha="0.2"
android:strokeLineJoin="round"
android:strokeWidth="2"
android:fillColor="#00000000"
android:strokeColor="#212121"
android:fillAlpha="0.2" />
</group>
<group>
<clip-path android:pathData="M122.5,57.1c-5.8,-7.7 -15.2,-12.4 -24.7,-12.9H94c-8.1,0.5 -15.9,3.9 -21.7,9.6c-4.3,4.3 -7.4,9.7 -8.6,15.7c-1.5,7.5 -0.1,15.4 3.5,22.1c2.3,4.5 5.2,8.6 8.1,12.8c4.9,6.8 9.8,13.7 13.2,21.3c3.1,6.5 4.9,13.6 5.6,20.8c0.4,0.6 1.1,1 1.7,1.5c2.6,-1 2,-4 2.5,-6.2c1.9,-12.8 8.7,-24.2 16.1,-34.6c6.6,-9.2 14.1,-19 14.4,-30.8V74C128.5,67.9 126.3,61.9 122.5,57.1zM106.2,74.3l-12.2,21V79.5h-5.2V60.3h17.5l-7,14H106.2z" />
<path
android:pathData="M106.2,60.3c0,0 -17.5,0 -17.5,0"
android:strokeAlpha="0.2"
android:strokeLineJoin="round"
android:strokeWidth="2"
android:fillColor="#00000000"
android:strokeColor="#212121"
android:fillAlpha="0.2" />
</group>
<group>
<clip-path android:pathData="M122.5,57.1c-5.8,-7.7 -15.2,-12.4 -24.7,-12.9H94c-8.1,0.5 -15.9,3.9 -21.7,9.6c-4.3,4.3 -7.4,9.7 -8.6,15.7c-1.5,7.5 -0.1,15.4 3.5,22.1c2.3,4.5 5.2,8.6 8.1,12.8c4.9,6.8 9.8,13.7 13.2,21.3c3.1,6.5 4.9,13.6 5.6,20.8c0.4,0.6 1.1,1 1.7,1.5c2.6,-1 2,-4 2.5,-6.2c1.9,-12.8 8.7,-24.2 16.1,-34.6c6.6,-9.2 14.1,-19 14.4,-30.8V74C128.5,67.9 126.3,61.9 122.5,57.1zM106.2,74.3l-12.2,21V79.5h-5.2V60.3h17.5l-7,14H106.2z" />
<path
android:pathData="M93.9,79.5L88.7,79.5"
android:strokeAlpha="0.2"
android:strokeLineJoin="round"
android:strokeWidth="2"
android:fillColor="#00000000"
android:strokeColor="#FFFFFF"
android:fillAlpha="0.2" />
</group>
<group>
<clip-path android:pathData="M122.5,57.1c-5.8,-7.7 -15.2,-12.4 -24.7,-12.9H94c-8.1,0.5 -15.9,3.9 -21.7,9.6c-4.3,4.3 -7.4,9.7 -8.6,15.7c-1.5,7.5 -0.1,15.4 3.5,22.1c2.3,4.5 5.2,8.6 8.1,12.8c4.9,6.8 9.8,13.7 13.2,21.3c3.1,6.5 4.9,13.6 5.6,20.8c0.4,0.6 1.1,1 1.7,1.5c2.6,-1 2,-4 2.5,-6.2c1.9,-12.8 8.7,-24.2 16.1,-34.6c6.6,-9.2 14.1,-19 14.4,-30.8V74C128.5,67.9 126.3,61.9 122.5,57.1zM106.2,74.3l-12.2,21V79.5h-5.2V60.3h17.5l-7,14H106.2z" />
<path
android:strokeWidth="1"
android:pathData="M94,79v16.2"
android:strokeAlpha="0.2"
android:strokeLineJoin="round"
android:fillColor="#00000000"
android:strokeColor="#212121"
android:fillAlpha="0.2"
android:strokeLineCap="round" />
</group>
<group>
<clip-path android:pathData="M122.5,57.1c-5.8,-7.7 -15.2,-12.4 -24.7,-12.9H94c-8.1,0.5 -15.9,3.9 -21.7,9.6c-4.3,4.3 -7.4,9.7 -8.6,15.7c-1.5,7.5 -0.1,15.4 3.5,22.1c2.3,4.5 5.2,8.6 8.1,12.8c4.9,6.8 9.8,13.7 13.2,21.3c3.1,6.5 4.9,13.6 5.6,20.8c0.4,0.6 1.1,1 1.7,1.5c2.6,-1 2,-4 2.5,-6.2c1.9,-12.8 8.7,-24.2 16.1,-34.6c6.6,-9.2 14.1,-19 14.4,-30.8V74C128.5,67.9 126.3,61.9 122.5,57.1zM106.2,74.3l-12.2,21V79.5h-5.2V60.3h17.5l-7,14H106.2z" />
<path
android:strokeWidth="1"
android:pathData="M106.2,60.3L99.2,74.3"
android:strokeAlpha="0.2"
android:strokeLineJoin="round"
android:fillColor="#00000000"
android:strokeColor="#FFFFFF"
android:fillAlpha="0.2"
android:strokeLineCap="round" />
</group>
<group>
<clip-path android:pathData="M122.5,57.1c-5.8,-7.7 -15.2,-12.4 -24.7,-12.9H94c-8.1,0.5 -15.9,3.9 -21.7,9.6c-4.3,4.3 -7.4,9.7 -8.6,15.7c-1.5,7.5 -0.1,15.4 3.5,22.1c2.3,4.5 5.2,8.6 8.1,12.8c4.9,6.8 9.8,13.7 13.2,21.3c3.1,6.5 4.9,13.6 5.6,20.8c0.4,0.6 1.1,1 1.7,1.5c2.6,-1 2,-4 2.5,-6.2c1.9,-12.8 8.7,-24.2 16.1,-34.6c6.6,-9.2 14.1,-19 14.4,-30.8V74C128.5,67.9 126.3,61.9 122.5,57.1zM106.2,74.3l-12.2,21V79.5h-5.2V60.3h17.5l-7,14H106.2z" />
<path
android:strokeWidth="1"
android:pathData="M106.2,74.3L93.9,95.2"
android:strokeAlpha="0.2"
android:strokeLineJoin="round"
android:fillColor="#00000000"
android:strokeColor="#FFFFFF"
android:fillAlpha="0.2"
android:strokeLineCap="round" />
</group>
<path
android:pathData="M67.6,120.6L65.7,104l-2.9,0.3l1.9,16.6L67.6,120.6zM77.9,119.4l-1.9,-16.6l-2.9,0.3l1.9,16.6L77.9,119.4z"
android:fillColor="#808080" />
<path
android:pathData="M83.3,142c-0.9,1.1 -1.6,1.8 -1.7,1.9c-2.6,2.1 -4.7,2.7 -6.4,1.9c-3,-1.5 -2.8,-7.1 -2.7,-7.7l2.1,0.1c-0.1,1.6 0.2,5 1.6,5.7c0.8,0.4 2.2,-0.1 4,-1.6l0,0c0,0 5.8,-5.8 4.6,-10.4c-1.4,-5.5 5,-13.4 7.1,-16.1l0.3,-0.3l1.7,1.3l-0.3,0.4c-6.5,8 -7.2,12.1 -6.7,14.2C87.9,135.4 85.2,139.7 83.3,142z"
android:fillColor="#9e9e9e" />
<path
android:pathData="M61.2,120.4l0.8,6.8l6.3,4.2l8.5,-0.9l5.2,-5.5l-0.8,-6.8L61.2,120.4z"
android:fillColor="#9e9e9e" />
<path
android:pathData="M76.7,130.5l-8.5,0.9l1.8,7.5l6.7,-0.8L76.7,130.5L76.7,130.5zM82.8,112.5l0.7,6.2l-24.4,2.8l-0.7,-6.2L82.8,112.5z"
android:fillColor="#666666" />
<path
android:pathData="M101.9,44.1c-17.5,0 -31.7,14.2 -31.7,31.7c0,23.9 26.7,36.4 29.9,70.5c0.1,1 0.9,1.7 1.9,1.7s1.8,-0.7 1.9,-1.7c3.2,-34.1 29.9,-46.6 29.9,-70.5C133.6,58.2 119.4,44.1 101.9,44.1z"
android:fillColor="#737373" />
<path
android:pathData="M101.9,44.8c17.4,0 31.5,14 31.7,31.3c0,-0.1 0,-0.2 0,-0.3c0,-17.5 -14.2,-31.7 -31.7,-31.7S70.2,58.2 70.2,75.8c0,0.1 0,0.2 0,0.3C70.4,58.8 84.5,44.8 101.9,44.8L101.9,44.8z"
android:fillColor="#FFFFFF"
android:fillAlpha="0.2" />
<path
android:pathData="M103.8,145.5c-0.1,1 -0.9,1.7 -1.9,1.7s-1.8,-0.7 -1.9,-1.7c-3.1,-34 -29.6,-46.5 -29.8,-70.1c0,0.2 0,0.3 0,0.5c0,23.9 26.7,36.4 29.9,70.5c0.1,1 0.9,1.7 1.9,1.7s1.8,-0.7 1.9,-1.7c3.2,-34.1 29.9,-46.6 29.9,-70.5c0,-0.2 0,-0.3 0,-0.5C133.4,99 106.9,111.5 103.8,145.5L103.8,145.5z"
android:fillColor="#303030"
android:fillAlpha="0.2" />
<path
android:fillColor="#FF000000"
android:pathData="M94.6,60.3v19.2h5.2v15.7l12.2,-21h-7l7,-14C112.1,60.3 94.6,60.3 94.6,60.3z"
android:strokeAlpha="0.45"
android:fillAlpha="0.45" />
</group>
</vector>

View File

@@ -1,5 +0,0 @@
package net.vonforst.evmap.autocomplete
import android.content.Context
fun getAutocompleteProviders(context: Context) = listOf(MapboxAutocompleteProvider(context))

View File

@@ -27,16 +27,14 @@ class DonateFragment : Fragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
(requireActivity() as AppCompatActivity).setSupportActionBar(binding.toolbar)
val navController = findNavController()
binding.toolbar.setupWithNavController(
navController,
(requireActivity() as MapsActivity).appBarConfiguration
)
binding.btnDonate.setOnClickListener {
(activity as? MapsActivity)?.openUrl(getString(R.string.paypal_link))
}
}
override fun onResume() {
super.onResume()
binding.toolbar.setupWithNavController(
findNavController(),
(requireActivity() as MapsActivity).appBarConfiguration
)
}
}

View File

@@ -1,16 +0,0 @@
package net.vonforst.evmap.fragment
import androidx.fragment.app.Fragment
import androidx.viewpager2.adapter.FragmentStateAdapter
class OnboardingViewPagerAdapter(fragment: Fragment) :
FragmentStateAdapter(fragment) {
override fun getItemCount(): Int = 3
override fun createFragment(position: Int): Fragment = when (position) {
0 -> WelcomeFragment()
1 -> IconsFragment()
2 -> DataSourceSelectFragment()
else -> throw IllegalArgumentException()
}
}

View File

@@ -3,12 +3,6 @@
<string-array name="pref_map_provider_names">
<item>OpenStreetMap (Mapbox)</item>
</string-array>
<string-array name="pref_search_provider_names">
<item>OpenStreetMap (Mapbox)</item>
</string-array>
<string-array name="pref_search_provider_values" tranlatable="false">
<item>mapbox</item>
</string-array>
<string name="donations_info" formatted="false">Findest du EVMap nützlich? Unterstütze die Weiterentwicklung der App mit einer Spende an den Entwickler.\n\nGoogle zieht von der Spende 30% Gebühren ab.</string>
<string name="donate_paypal">Mit PayPal spenden</string>
</resources>

View File

@@ -6,13 +6,6 @@
<string-array name="pref_map_provider_values" tranlatable="false">
<item>mapbox</item>
</string-array>
<string-array name="pref_search_provider_names">
<item>OpenStreetMap (Mapbox)</item>
</string-array>
<string-array name="pref_search_provider_values" tranlatable="false">
<item>mapbox</item>
</string-array>
<string name="pref_search_provider_default" translatable="false">mapbox</string>
<string name="pref_map_provider_default" translatable="false">mapbox</string>
<string name="donations_info" formatted="false">Do you find EVMap useful? Support its development by sending a donation to the developer.</string>
<string name="donate_paypal">Donate with PayPal</string>

View File

@@ -5,14 +5,8 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="androidx.car.app.MAP_TEMPLATES" />
<uses-permission android:name="com.google.android.gms.permission.CAR_FUEL" />
<uses-permission android:name="com.google.android.gms.permission.CAR_SPEED" />
<uses-sdk tools:overrideLibrary="androidx.car.app,androidx.car.app.projected" />
<queries>
<package android:name="com.google.android.projection.gearhead" />
</queries>
<uses-sdk tools:overrideLibrary="androidx.car.app" />
<application>
<meta-data
@@ -27,10 +21,6 @@
android:name="androidx.car.app.theme"
android:resource="@style/CarAppTheme" />
<meta-data
android:name="androidx.car.app.minCarApiLevel"
android:value="1" />
<service
android:name=".auto.CarAppService"
android:label="@string/app_name"
@@ -46,7 +36,8 @@
<service
android:name=".auto.CarLocationService"
android:foregroundServiceType="location"
android:enabled="true"
android:exported="false" />
android:enabled="true" />
<activity android:name=".auto.PermissionActivity" />
</application>
</manifest>

View File

@@ -5,18 +5,10 @@ import android.content.Context
import android.util.Log
import com.google.android.gms.common.ConnectionResult
import com.google.android.gms.common.GoogleApiAvailability
import com.google.android.gms.maps.MapsInitializer
import com.google.android.libraries.places.api.Places
import net.vonforst.evmap.storage.PreferenceDataSource
import net.vonforst.evmap.utils.LocaleContextWrapper
fun init(context: Context) {
Places.initialize(context, context.getString(R.string.google_maps_key))
val localeContext = LocaleContextWrapper.wrap(
context.applicationContext, PreferenceDataSource(context).language
)
MapsInitializer.initialize(localeContext, MapsInitializer.Renderer.LATEST, null)
Places.initialize(context, context.getString(R.string.google_maps_key));
}
fun checkPlayServices(activity: Activity): Boolean {

View File

@@ -1,28 +1,27 @@
package net.vonforst.evmap.auto
import android.Manifest
import android.content.*
import android.content.pm.ApplicationInfo
import android.content.pm.PackageManager
import android.location.Location
import android.os.IBinder
import androidx.car.app.CarContext
import androidx.car.app.Screen
import androidx.car.app.Session
import androidx.car.app.hardware.CarHardwareManager
import androidx.car.app.hardware.info.CarHardwareLocation
import androidx.car.app.hardware.info.CarSensors
import androidx.car.app.model.*
import androidx.car.app.validation.HostValidator
import androidx.core.content.ContextCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleObserver
import androidx.lifecycle.OnLifecycleEvent
import androidx.lifecycle.*
import androidx.localbroadcastmanager.content.LocalBroadcastManager
import net.vonforst.evmap.utils.checkAnyLocationPermission
import kotlinx.coroutines.*
import net.vonforst.evmap.*
interface LocationAwareScreen {
fun updateLocation(location: Location)
}
@androidx.car.app.annotations.ExperimentalCarApi
class CarAppService : androidx.car.app.CarAppService() {
override fun createHostValidator(): HostValidator {
return if (applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE != 0) {
@@ -39,6 +38,7 @@ class CarAppService : androidx.car.app.CarAppService() {
}
}
@androidx.car.app.annotations.ExperimentalCarApi
class EVMapSession(val cas: CarAppService) : Session(), LifecycleObserver {
var mapScreen: LocationAwareScreen? = null
set(value) {
@@ -47,9 +47,6 @@ class EVMapSession(val cas: CarAppService) : Session(), LifecycleObserver {
}
private var location: Location? = null
private var locationService: CarLocationService? = null
private val hardwareMan: CarHardwareManager by lazy {
carContext.getCarService(CarContext.HARDWARE_SERVICE) as CarHardwareManager
}
private val serviceConnection = object : ServiceConnection {
override fun onServiceConnected(name: ComponentName, ibinder: IBinder) {
@@ -62,53 +59,40 @@ class EVMapSession(val cas: CarAppService) : Session(), LifecycleObserver {
locationService = null
}
}
private var serviceBound = false
init {
lifecycle.addObserver(this)
}
override fun onCreateScreen(intent: Intent): Screen {
return WelcomeScreen(carContext, this)
return if (locationPermissionGranted()) {
WelcomeScreen(carContext, this)
} else {
PermissionScreen(carContext, this)
}
}
fun locationPermissionGranted() = carContext.checkAnyLocationPermission()
private fun locationPermissionGranted() =
ContextCompat.checkSelfPermission(
carContext,
Manifest.permission.ACCESS_FINE_LOCATION
) == PackageManager.PERMISSION_GRANTED
private val locationReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val location = intent.getParcelableExtra(CarLocationService.EXTRA_LOCATION) as Location?
updateLocation(location)
val mapScreen = this@EVMapSession.mapScreen
if (location != null && mapScreen != null) {
mapScreen.updateLocation(location)
}
this@EVMapSession.location = location
}
}
private fun updateLocation(location: Location?) {
val mapScreen = mapScreen
if (location != null && mapScreen != null) {
mapScreen.updateLocation(location)
}
this.location = location
}
private fun onCarHardwareLocationReceived(loc: CarHardwareLocation) {
updateLocation(loc.location.value)
// we successfully received a location from the car hardware,
// so we don't need the smartphone location anymore.
unbindLocationService()
}
@OnLifecycleEvent(Lifecycle.Event.ON_START)
fun bindLocationService() {
if (!locationPermissionGranted()) return
if (supportsCarApiLevel3(carContext)) {
val exec = ContextCompat.getMainExecutor(carContext)
hardwareMan.carSensors.addCarHardwareLocationListener(
CarSensors.UPDATE_RATE_NORMAL,
exec,
::onCarHardwareLocationReceived
)
}
serviceBound = cas.bindService(
cas.bindService(
Intent(cas, CarLocationService::class.java),
serviceConnection,
Context.BIND_AUTO_CREATE
@@ -116,18 +100,10 @@ class EVMapSession(val cas: CarAppService) : Session(), LifecycleObserver {
}
@OnLifecycleEvent(Lifecycle.Event.ON_STOP)
private fun onStop() {
if (supportsCarApiLevel3(carContext)) {
hardwareMan.carSensors.removeCarHardwareLocationListener(::onCarHardwareLocationReceived)
}
unbindLocationService()
}
private fun unbindLocationService() {
locationService?.removeLocationUpdates()
if (serviceBound) {
locationService?.let { service ->
service.removeLocationUpdates()
cas.unbindService(serviceConnection)
serviceBound = false
}
}

View File

@@ -1,295 +0,0 @@
package net.vonforst.evmap.auto
import android.content.ActivityNotFoundException
import android.content.Intent
import android.net.Uri
import androidx.browser.customtabs.CustomTabColorSchemeParams
import androidx.browser.customtabs.CustomTabsIntent
import androidx.car.app.CarContext
import androidx.car.app.CarToast
import androidx.car.app.Screen
import androidx.car.app.hardware.CarHardwareManager
import androidx.car.app.hardware.info.Model
import androidx.car.app.model.*
import androidx.core.content.ContextCompat
import androidx.core.graphics.drawable.IconCompat
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import moe.banana.jsonapi2.HasMany
import moe.banana.jsonapi2.HasOne
import moe.banana.jsonapi2.JsonBuffer
import moe.banana.jsonapi2.ResourceIdentifier
import net.vonforst.evmap.*
import net.vonforst.evmap.api.chargeprice.*
import net.vonforst.evmap.model.ChargeLocation
import net.vonforst.evmap.storage.AppDatabase
import net.vonforst.evmap.storage.PreferenceDataSource
import net.vonforst.evmap.ui.currency
import java.io.IOException
class ChargepriceScreen(ctx: CarContext, val charger: ChargeLocation) : Screen(ctx) {
private val prefs = PreferenceDataSource(ctx)
private val db = AppDatabase.getInstance(carContext)
private val api by lazy {
ChargepriceApi.create(carContext.getString(R.string.chargeprice_key))
}
private var prices: List<ChargePrice>? = null
private var meta: ChargepriceChargepointMeta? = null
private val maxRows = 6
private var errorMessage: String? = null
private val batteryRange = listOf(20.0, 80.0)
override fun onGetTemplate(): Template {
if (prices == null) loadData()
return ListTemplate.Builder().apply {
setTitle(
carContext.getString(
R.string.chargeprice_battery_range,
batteryRange[0],
batteryRange[1]
) + " · " + carContext.getString(R.string.powered_by_chargeprice)
)
setHeaderAction(Action.BACK)
if (prices == null && errorMessage == null) {
setLoading(true)
} else {
setSingleList(ItemList.Builder().apply {
setNoItemsMessage(
errorMessage ?: carContext.getString(R.string.chargeprice_no_tariffs_found)
)
prices?.take(maxRows)?.forEach { price ->
addItem(Row.Builder().apply {
setTitle(formatProvider(price))
addText(formatPrice(price))
}.build())
}
}.build())
}
setActionStrip(
ActionStrip.Builder().addAction(
Action.Builder().setIcon(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
R.drawable.ic_chargeprice
)
).build()
).setOnClickListener {
val intent = CustomTabsIntent.Builder()
.setDefaultColorSchemeParams(
CustomTabColorSchemeParams.Builder()
.setToolbarColor(
ContextCompat.getColor(
carContext,
R.color.colorPrimary
)
)
.build()
)
.build().intent
intent.data =
Uri.parse("https://www.chargeprice.app/?poi_id=${charger.id}&poi_source=${getDataAdapter()}")
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
try {
carContext.startActivity(intent)
CarToast.makeText(
carContext,
R.string.opened_on_phone,
CarToast.LENGTH_LONG
).show()
} catch (e: ActivityNotFoundException) {
CarToast.makeText(
carContext,
R.string.no_browser_app_found,
CarToast.LENGTH_LONG
).show()
}
}.build()
).build()
)
}.build()
}
private fun formatProvider(price: ChargePrice): String {
if (!price.tariffName.startsWith(price.provider)) {
return price.provider + " " + price.tariffName
} else {
return price.tariffName
}
}
private fun formatPrice(price: ChargePrice): String {
val totalPrice = carContext.getString(
R.string.charge_price_format,
price.chargepointPrices.first().price,
currency(price.currency)
)
val kwhPrice = if (price.chargepointPrices.first().price > 0f) {
carContext.getString(
if (price.chargepointPrices[0].priceDistribution.isOnlyKwh) {
R.string.charge_price_kwh_format
} else {
R.string.charge_price_average_format
},
price.chargepointPrices.get(0).price / meta!!.energy,
currency(price.currency)
)
} else null
val monthlyFees = if (price.totalMonthlyFee > 0 || price.monthlyMinSales > 0) {
price.formatMonthlyFees(carContext)
} else null
var text = totalPrice
if (kwhPrice != null && monthlyFees != null) {
text += " ($kwhPrice, $monthlyFees)"
} else if (kwhPrice != null) {
text += " ($kwhPrice)"
} else if (monthlyFees != null) {
text += " ($monthlyFees)"
}
return text
}
private fun loadData() {
if (supportsCarApiLevel3(carContext)) {
val exec = ContextCompat.getMainExecutor(carContext)
val hardwareMan =
carContext.getCarService(CarContext.HARDWARE_SERVICE) as CarHardwareManager
hardwareMan.carInfo.fetchModel(exec) { model ->
loadPrices(model)
}
} else {
loadPrices(null)
}
}
private fun loadPrices(model: Model?) {
val dataAdapter = getDataAdapter() ?: return
val manufacturer = model?.manufacturer?.value
val modelName = model?.name?.value
lifecycleScope.launch {
try {
var vehicles = api.getVehicles().filter {
it.id in prefs.chargepriceMyVehicles
}
if (vehicles.isEmpty()) {
errorMessage = carContext.getString(R.string.chargeprice_select_car_first)
invalidate()
return@launch
} else if (vehicles.size > 1) {
if (manufacturer != null && modelName != null) {
vehicles = vehicles.filter {
it.brand == manufacturer && it.name.startsWith(modelName)
}
if (vehicles.isEmpty()) {
errorMessage = carContext.getString(
R.string.auto_chargeprice_vehicle_unknown,
manufacturer,
modelName
)
invalidate()
return@launch
} else if (vehicles.size > 1) {
errorMessage = carContext.getString(
R.string.auto_chargeprice_vehicle_ambiguous,
manufacturer,
modelName
)
invalidate()
return@launch
}
} else {
errorMessage =
carContext.getString(R.string.auto_chargeprice_vehicle_unavailable)
invalidate()
return@launch
}
}
val car = vehicles[0]
val cpStation = ChargepriceStation.fromEvmap(charger, car.compatibleEvmapConnectors)
val result = api.getChargePrices(ChargepriceRequest().apply {
this.dataAdapter = dataAdapter
station = cpStation
vehicle = HasOne(car)
tariffs = if (!prefs.chargepriceMyTariffsAll) {
val myTariffs = prefs.chargepriceMyTariffs ?: emptySet()
HasMany<ChargepriceTariff>(*myTariffs.map {
ResourceIdentifier(
"tariff",
it
)
}.toTypedArray()).apply {
meta = JsonBuffer.create(
ChargepriceApi.moshi.adapter(ChargepriceRequestTariffMeta::class.java),
ChargepriceRequestTariffMeta(ChargepriceInclude.ALWAYS)
)
}
} else null
options = ChargepriceOptions(
batteryRange = batteryRange,
providerCustomerTariffs = prefs.chargepriceShowProviderCustomerTariffs,
maxMonthlyFees = if (prefs.chargepriceNoBaseFee) 0.0 else null,
currency = prefs.chargepriceCurrency
)
}, ChargepriceApi.getChargepriceLanguage())
val myTariffs = prefs.chargepriceMyTariffs
// choose the highest power chargepoint compatible with the car
val chargepoint = cpStation.chargePoints.filterIndexed { i, cp ->
charger.chargepointsMerged[i].type in car.compatibleEvmapConnectors
}.maxByOrNull { it.power }
if (chargepoint == null) {
errorMessage =
carContext.getString(R.string.chargeprice_no_compatible_connectors)
invalidate()
return@launch
}
meta =
(result.meta.get<ChargepriceMeta>(ChargepriceApi.moshi.adapter(ChargepriceMeta::class.java)) as ChargepriceMeta).chargePoints.filterIndexed { i, cp ->
charger.chargepointsMerged[i].type in car.compatibleEvmapConnectors
}.maxByOrNull {
it.power
}
prices = result.map { cp ->
val filteredPrices =
cp.chargepointPrices.filter {
it.plug == chargepoint.plug && it.power == chargepoint.power
}
if (filteredPrices.isEmpty()) {
null
} else {
cp.clone().apply {
chargepointPrices = filteredPrices
}
}
}.filterNotNull()
.sortedBy { it.chargepointPrices.first().price }
.sortedByDescending {
prefs.chargepriceMyTariffsAll ||
myTariffs != null && it.tariff?.get()?.id in myTariffs
}
invalidate()
} catch (e: IOException) {
withContext(Dispatchers.Main) {
CarToast.makeText(
carContext,
R.string.chargeprice_connection_error,
CarToast.LENGTH_LONG
)
.show()
}
}
}
}
private fun getDataAdapter(): String? = when (charger.dataSource) {
"goingelectric" -> ChargepriceApi.DATA_SOURCE_GOINGELECTRIC
"openchargemap" -> ChargepriceApi.DATA_SOURCE_OPENCHARGEMAP
else -> null
}
}

View File

@@ -20,7 +20,6 @@ import kotlinx.coroutines.withContext
import net.vonforst.evmap.*
import net.vonforst.evmap.api.availability.ChargeLocationStatus
import net.vonforst.evmap.api.availability.getAvailability
import net.vonforst.evmap.api.chargeprice.ChargepriceApi
import net.vonforst.evmap.api.createApi
import net.vonforst.evmap.api.nameForPlugType
import net.vonforst.evmap.api.stringProvider
@@ -49,10 +48,7 @@ class ChargerDetailScreen(ctx: CarContext, val chargerSparse: ChargeLocation) :
}
private val referenceData = api.getReferenceData(lifecycleScope, carContext)
private val imageSize = 128 // images should be 128dp according to docs
private val iconGen =
ChargerIconGenerator(carContext, null, oversize = 1.4f, height = imageSize)
private val iconGen = ChargerIconGenerator(carContext, null, oversize = 1.4f, height = 64)
init {
referenceData.observe(this) {
@@ -151,49 +147,29 @@ class ChargerDetailScreen(ctx: CarContext, val chargerSparse: ChargeLocation) :
navigateToCharger(charger)
}
.build())
charger.chargepriceData?.country?.let { country ->
if (ChargepriceApi.isCountrySupported(country, charger.dataSource)) {
addAction(Action.Builder()
.setIcon(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
R.drawable.ic_chargeprice
)
).build()
)
.setTitle(carContext.getString(R.string.auto_prices))
.setOnClickListener {
screenManager.push(ChargepriceScreen(carContext, charger))
}
.build())
}
}
addAction(
Action.Builder()
.setTitle(carContext.getString(R.string.open_in_app))
.setOnClickListener(ParkedOnlyOnClickListener.create {
val intent = Intent(carContext, MapsActivity::class.java)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
.putExtra(EXTRA_CHARGER_ID, charger.id)
.putExtra(EXTRA_LAT, charger.coordinates.lat)
.putExtra(EXTRA_LON, charger.coordinates.lng)
carContext.startActivity(intent)
CarToast.makeText(
carContext,
R.string.opened_on_phone,
CarToast.LENGTH_LONG
).show()
})
.build()
)
} ?: setLoading(true)
}.build()
).apply {
setTitle(chargerSparse.name)
setHeaderAction(Action.BACK)
setActionStrip(
ActionStrip.Builder().addAction(
Action.Builder()
.setTitle(carContext.getString(R.string.open_in_app))
.setOnClickListener(ParkedOnlyOnClickListener.create {
val intent = Intent(carContext, MapsActivity::class.java)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
.putExtra(EXTRA_CHARGER_ID, chargerSparse.id)
.putExtra(EXTRA_LAT, chargerSparse.coordinates.lat)
.putExtra(EXTRA_LON, chargerSparse.coordinates.lng)
carContext.startActivity(intent)
CarToast.makeText(
carContext,
R.string.opened_on_phone,
CarToast.LENGTH_LONG
).show()
})
.build()
).build()
)
}.build()
}

View File

@@ -7,9 +7,7 @@ import androidx.car.app.model.*
import androidx.core.graphics.drawable.IconCompat
import androidx.lifecycle.LiveData
import net.vonforst.evmap.R
import net.vonforst.evmap.model.FILTERS_CUSTOM
import net.vonforst.evmap.model.FILTERS_DISABLED
import net.vonforst.evmap.model.FILTERS_FAVORITES
import net.vonforst.evmap.storage.AppDatabase
import net.vonforst.evmap.storage.FilterProfile
import net.vonforst.evmap.storage.PreferenceDataSource
@@ -28,11 +26,15 @@ class FilterScreen(ctx: CarContext) : Screen(ctx) {
init {
val size = (ctx.resources.displayMetrics.density * 24).roundToInt()
emptyIcon = Bitmap.createBitmap(
size,
size,
Bitmap.Config.ARGB_8888
).asCarIcon()
emptyIcon = CarIcon.Builder(
IconCompat.createWithBitmap(
Bitmap.createBitmap(
size,
size,
Bitmap.Config.ARGB_8888
)
)
).build()
}
init {
@@ -42,12 +44,9 @@ class FilterScreen(ctx: CarContext) : Screen(ctx) {
}
override fun onGetTemplate(): Template {
val filterStatus =
prefs.filterStatus.takeUnless { it == FILTERS_CUSTOM || it == FILTERS_FAVORITES }
?: FILTERS_DISABLED
return ListTemplate.Builder().apply {
filterProfiles.value?.let {
setSingleList(buildFilterProfilesList(it.take(maxRows), filterStatus))
setSingleList(buildFilterProfilesList(it.take(maxRows), prefs.filterStatus))
} ?: setLoading(true)
setTitle(carContext.getString(R.string.menu_filter))
setHeaderAction(Action.BACK)
@@ -73,9 +72,7 @@ class FilterScreen(ctx: CarContext) : Screen(ctx) {
}.build())
profiles.forEach {
addItem(Row.Builder().apply {
val name =
it.name.ifEmpty { carContext.getString(R.string.unnamed_filter_profile) }
setTitle(name)
setTitle(it.name)
if (it.id == filterStatus) {
setImage(checkIcon)
} else {

View File

@@ -1,21 +1,15 @@
package net.vonforst.evmap.auto
import android.content.pm.PackageManager
import android.location.Location
import android.text.SpannableStringBuilder
import android.text.Spanned
import androidx.car.app.CarContext
import androidx.car.app.CarToast
import androidx.car.app.Screen
import androidx.car.app.constraints.ConstraintManager
import androidx.car.app.hardware.CarHardwareManager
import androidx.car.app.hardware.info.EnergyLevel
import androidx.car.app.model.*
import androidx.core.content.ContextCompat
import androidx.core.graphics.drawable.IconCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.OnLifecycleEvent
import androidx.lifecycle.lifecycleScope
import com.car2go.maps.model.LatLng
import kotlinx.coroutines.*
@@ -27,7 +21,6 @@ import net.vonforst.evmap.api.stringProvider
import net.vonforst.evmap.model.ChargeLocation
import net.vonforst.evmap.model.FILTERS_CUSTOM
import net.vonforst.evmap.model.FILTERS_DISABLED
import net.vonforst.evmap.model.FILTERS_FAVORITES
import net.vonforst.evmap.storage.AppDatabase
import net.vonforst.evmap.storage.PreferenceDataSource
import net.vonforst.evmap.ui.availabilityText
@@ -39,7 +32,6 @@ import net.vonforst.evmap.viewmodel.getFilters
import net.vonforst.evmap.viewmodel.getReferenceData
import java.io.IOException
import java.time.Duration
import java.time.Instant
import java.time.ZonedDateTime
import kotlin.collections.set
import kotlin.math.roundToInt
@@ -47,19 +39,15 @@ import kotlin.math.roundToInt
/**
* Main map screen showing either nearby chargers or favorites
*/
@androidx.car.app.annotations.ExperimentalCarApi
class MapScreen(ctx: CarContext, val session: EVMapSession, val favorites: Boolean = false) :
Screen(ctx), LocationAwareScreen {
private var updateCoroutine: Job? = null
private var numUpdates = 0
/* Updating map contents is disabled - if the user uses Chargeprice from the charger
detail screen, this already means 4 steps, after which the app would crash.
follow https://issuetracker.google.com/issues/176694222 for updates how to solve this. */
private val maxNumUpdates = 1
private val maxNumUpdates = 3
private var location: Location? = null
private var lastChargerUpdateLocation: Location? = null
private var lastDistanceUpdateTime: Instant? = null
private var lastUpdateLocation: Location? = null
private var chargers: List<ChargeLocation>? = null
private var prefs = PreferenceDataSource(ctx)
private val db = AppDatabase.getInstance(carContext)
@@ -67,27 +55,20 @@ class MapScreen(ctx: CarContext, val session: EVMapSession, val favorites: Boole
createApi(prefs.dataSource, ctx)
}
private val searchRadius = 5 // kilometers
private val chargerUpdateThreshold = 2000 // meters
private val distanceUpdateThreshold = Duration.ofSeconds(15)
private val updateThreshold = 2000 // meters
private val availabilityUpdateThreshold = Duration.ofMinutes(1)
private var availabilities: MutableMap<Long, Pair<ZonedDateTime, ChargeLocationStatus>> =
HashMap()
private val maxRows = if (ctx.carAppApiLevel >= 2) {
ctx.constraintManager.getContentLimit(ConstraintManager.CONTENT_LIMIT_TYPE_PLACE_LIST)
} else 6
private val maxRows = 6
private val referenceData = api.getReferenceData(lifecycleScope, carContext)
private val filterStatus = MutableLiveData<Long>().apply {
value = prefs.filterStatus.takeUnless { it == FILTERS_CUSTOM || it == FILTERS_FAVORITES }
?: FILTERS_DISABLED
value = prefs.filterStatus.takeUnless { it == FILTERS_CUSTOM } ?: FILTERS_DISABLED
}
private val filterValues = db.filterValueDao().getFilterValues(filterStatus, prefs.dataSource)
private val filters = api.getFilters(referenceData, carContext.stringProvider())
private val filtersWithValue = filtersWithValue(filters, filterValues)
private val hardwareMan = ctx.getCarService(CarContext.HARDWARE_SERVICE) as CarHardwareManager
private var energyLevel: EnergyLevel? = null
init {
filtersWithValue.observe(this) {
loadChargers()
@@ -152,9 +133,7 @@ class MapScreen(ctx: CarContext, val session: EVMapSession, val favorites: Boole
screenManager.pushForResult(FilterScreen(carContext)) {
chargers = null
numUpdates = 0
filterStatus.value =
prefs.filterStatus.takeUnless { it == FILTERS_CUSTOM || it == FILTERS_FAVORITES }
?: FILTERS_DISABLED
filterStatus.value = prefs.filterStatus
}
session.mapScreen = null
}
@@ -166,12 +145,7 @@ class MapScreen(ctx: CarContext, val session: EVMapSession, val favorites: Boole
}
private fun formatCharger(charger: ChargeLocation, showCity: Boolean): Row {
val markerTint = if (charger.maxPower > 100) {
R.color.charger_100kw_dark // slightly darker color for better contrast
} else {
getMarkerTint(charger)
}
val color = ContextCompat.getColor(carContext, markerTint)
val color = ContextCompat.getColor(carContext, getMarkerTint(charger))
val place =
Place.Builder(CarLocation.create(charger.coordinates.lat, charger.coordinates.lng))
.setMarker(
@@ -197,18 +171,13 @@ class MapScreen(ctx: CarContext, val session: EVMapSession, val favorites: Boole
// distance
location?.let {
val distanceMeters = distanceBetween(
val distance = distanceBetween(
it.latitude, it.longitude,
charger.coordinates.lat, charger.coordinates.lng
)
) / 1000
text.append(
"distance",
DistanceSpan.create(
roundValueToDistance(
distanceMeters,
energyLevel?.distanceDisplayUnit?.value
)
),
DistanceSpan.create(Distance.create(distance, Distance.UNIT_KILOMETERS)),
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
)
}
@@ -245,30 +214,18 @@ class MapScreen(ctx: CarContext, val session: EVMapSession, val favorites: Boole
}
override fun updateLocation(location: Location) {
if (location.latitude == this.location?.latitude
&& location.longitude == this.location?.longitude
) {
return
}
this.location = location
if (updateCoroutine != null) {
// don't update while still loading last update
return
}
val now = Instant.now()
if (lastDistanceUpdateTime == null ||
Duration.between(lastDistanceUpdateTime, now) > distanceUpdateThreshold
) {
lastDistanceUpdateTime = now
// update displayed distances
invalidate()
}
invalidate()
if (lastChargerUpdateLocation == null ||
location.distanceTo(lastChargerUpdateLocation) > chargerUpdateThreshold
if (lastUpdateLocation == null ||
location.distanceTo(lastUpdateLocation) > updateThreshold
) {
lastChargerUpdateLocation = location
lastUpdateLocation = location
// update displayed chargers
loadChargers()
}
@@ -282,8 +239,8 @@ class MapScreen(ctx: CarContext, val session: EVMapSession, val favorites: Boole
numUpdates++
println(numUpdates)
if (numUpdates > maxNumUpdates) {
/*CarToast.makeText(carContext, R.string.auto_no_refresh_possible, CarToast.LENGTH_LONG)
.show()*/
CarToast.makeText(carContext, R.string.auto_no_refresh_possible, CarToast.LENGTH_LONG)
.show()
return
}
updateCoroutine = lifecycleScope.launch {
@@ -306,7 +263,7 @@ class MapScreen(ctx: CarContext, val session: EVMapSession, val favorites: Boole
)
chargers = response.data?.filterIsInstance(ChargeLocation::class.java)
chargers?.let {
if (it.size < maxRows) {
if (it.size < 6) {
// try again with larger radius
val response = api.getChargepointsRadius(
referenceData,
@@ -344,7 +301,6 @@ class MapScreen(ctx: CarContext, val session: EVMapSession, val favorites: Boole
}?.awaitAll()
updateCoroutine = null
lastDistanceUpdateTime = Instant.now()
invalidate()
} catch (e: IOException) {
withContext(Dispatchers.Main) {
@@ -354,30 +310,4 @@ class MapScreen(ctx: CarContext, val session: EVMapSession, val favorites: Boole
}
}
}
private fun onEnergyLevelUpdated(energyLevel: EnergyLevel) {
this.energyLevel = energyLevel
invalidate()
}
@OnLifecycleEvent(Lifecycle.Event.ON_RESUME)
private fun setupListeners() {
if (ContextCompat.checkSelfPermission(
carContext,
"com.google.android.gms.permission.CAR_FUEL"
) != PackageManager.PERMISSION_GRANTED
)
return
println("Setting up energy level listener")
val exec = ContextCompat.getMainExecutor(carContext)
hardwareMan.carInfo.addEnergyLevelListener(exec, ::onEnergyLevelUpdated)
}
@OnLifecycleEvent(Lifecycle.Event.ON_PAUSE)
private fun removeListeners() {
println("Removing energy level listener")
hardwareMan.carInfo.removeEnergyLevelListener(::onEnergyLevelUpdated)
}
}

View File

@@ -0,0 +1,72 @@
package net.vonforst.evmap.auto
import android.Manifest
import android.app.Activity
import android.content.pm.PackageManager
import android.os.Bundle
import android.os.ResultReceiver
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
class PermissionActivity : Activity() {
companion object {
const val EXTRA_RESULT_RECEIVER = "result_receiver";
const val RESULT_GRANTED = "granted"
}
private lateinit var resultReceiver: ResultReceiver
private val permissions = arrayOf(Manifest.permission.ACCESS_FINE_LOCATION)
private val requestCode = 1
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (intent != null) {
resultReceiver = intent.getParcelableExtra(EXTRA_RESULT_RECEIVER)!!
if (!hasPermissions(permissions)) {
ActivityCompat.requestPermissions(this, permissions, requestCode)
} else {
onComplete(
requestCode,
permissions,
intArrayOf(PackageManager.PERMISSION_GRANTED)
)
}
} else {
finish()
}
}
private fun onComplete(requestCode: Int, permissions: Array<String>?, grantResults: IntArray) {
val bundle = Bundle()
bundle.putBoolean(
RESULT_GRANTED,
grantResults.all { it == PackageManager.PERMISSION_GRANTED })
resultReceiver.send(requestCode, bundle)
finish()
}
private fun hasPermissions(permissions: Array<String>): Boolean {
var result = true
for (permission in permissions) {
if (ContextCompat.checkSelfPermission(
this,
permission
) != PackageManager.PERMISSION_GRANTED
) {
result = false
break
}
}
return result
}
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<String>,
grantResults: IntArray
) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
onComplete(requestCode, permissions, grantResults)
}
}

View File

@@ -1,21 +1,21 @@
package net.vonforst.evmap.auto
import androidx.annotation.StringRes
import android.content.Intent
import android.os.Bundle
import android.os.ResultReceiver
import androidx.car.app.CarContext
import androidx.car.app.CarToast
import androidx.car.app.Screen
import androidx.car.app.model.*
import net.vonforst.evmap.R
/**
* Screen to grant permission
* Screen to grant location permission
*/
class PermissionScreen(
ctx: CarContext,
@StringRes val message: Int,
val permissions: List<String>
) : Screen(ctx) {
@androidx.car.app.annotations.ExperimentalCarApi
class PermissionScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx) {
override fun onGetTemplate(): Template {
return MessageTemplate.Builder(carContext.getString(message))
return MessageTemplate.Builder(carContext.getString(R.string.auto_location_permission_needed))
.setTitle(carContext.getString(R.string.app_name))
.setHeaderAction(Action.APP_ICON)
.addAction(
@@ -23,7 +23,32 @@ class PermissionScreen(
.setTitle(carContext.getString(R.string.grant_on_phone))
.setBackgroundColor(CarColor.PRIMARY)
.setOnClickListener(ParkedOnlyOnClickListener.create {
requestPermissions()
val intent = Intent(carContext, PermissionActivity::class.java)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
.putExtra(
PermissionActivity.EXTRA_RESULT_RECEIVER,
object : ResultReceiver(null) {
override fun onReceiveResult(
resultCode: Int,
resultData: Bundle?
) {
if (resultData!!.getBoolean(PermissionActivity.RESULT_GRANTED)) {
session.bindLocationService()
screenManager.push(
WelcomeScreen(
carContext,
session
)
)
}
}
})
carContext.startActivity(intent)
CarToast.makeText(
carContext,
R.string.opened_on_phone,
CarToast.LENGTH_LONG
).show()
})
.build()
)
@@ -37,14 +62,4 @@ class PermissionScreen(
)
.build()
}
private fun requestPermissions() {
carContext.requestPermissions(permissions) { granted, rejected ->
if (granted.containsAll(permissions)) {
screenManager.pop()
} else {
requestPermissions()
}
}
}
}

View File

@@ -1,18 +1,7 @@
package net.vonforst.evmap.auto
import android.content.Context
import android.graphics.Bitmap
import androidx.car.app.CarContext
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.Distance
import androidx.car.app.versioning.CarAppApiLevels
import androidx.core.graphics.drawable.IconCompat
import net.vonforst.evmap.api.availability.ChargepointStatus
import java.util.*
import kotlin.math.roundToInt
fun carAvailabilityColor(status: List<ChargepointStatus>): CarColor {
val unknown = status.any { it == ChargepointStatus.UNKNOWN }
@@ -28,122 +17,4 @@ fun carAvailabilityColor(status: List<ChargepointStatus>): CarColor {
} else {
CarColor.BLUE
}
}
val CarContext.constraintManager
get() = getCarService(CarContext.CONSTRAINT_SERVICE) as ConstraintManager
fun Bitmap.asCarIcon(): CarIcon = CarIcon.Builder(IconCompat.createWithBitmap(this)).build()
private const val kmPerMile = 1.609344
private const val ftPerMile = 5280
private const val ydPerMile = 1760
fun getDefaultDistanceUnit(): Int {
return if (usesImperialUnits(Locale.getDefault())) {
CarUnit.MILE
} else {
CarUnit.KILOMETER
}
}
fun usesImperialUnits(locale: Locale): Boolean {
return locale.country in listOf("US", "GB", "MM", "LR")
|| locale.country == "" && locale.language == "en"
}
fun getDefaultSpeedUnit(): Int {
return when (Locale.getDefault().country) {
"US", "GB", "MM", "LR" -> CarUnit.MILES_PER_HOUR
else -> CarUnit.KILOMETERS_PER_HOUR
}
}
fun formatCarUnitDistance(value: Float?, unit: Int?): String {
if (value == null) return ""
return when (unit ?: getDefaultDistanceUnit()) {
// distance units: base unit is meters
CarUnit.METER -> "%.0f m".format(value)
CarUnit.KILOMETER -> "%.1f km".format(value / 1000)
CarUnit.MILLIMETER -> "%.0f mm".format(value * 1000) // whoever uses that...
CarUnit.MILE -> "%.1f mi".format(value / 1000 / kmPerMile)
else -> ""
}
}
fun formatCarUnitSpeed(value: Float?, unit: Int?): String {
if (value == null) return ""
return when (unit ?: getDefaultSpeedUnit()) {
// speed units: base unit is meters per second
CarUnit.METERS_PER_SEC -> "%.0f m/s".format(value)
CarUnit.KILOMETERS_PER_HOUR -> "%.0f km/h".format(value * 3.6)
CarUnit.MILES_PER_HOUR -> "%.0f mph".format(value * 3.6 / kmPerMile)
else -> ""
}
}
fun roundValueToDistance(value: Double, unit: Int? = null): Distance {
// value is in meters
when (unit ?: getDefaultDistanceUnit()) {
CarUnit.MILE -> {
// imperial system
val miles = value / 1000 / kmPerMile
val yards = miles * ydPerMile
val feet = miles * ftPerMile
return when (miles) {
in 0.0..0.1 -> if (Locale.getDefault().country == "UK") {
Distance.create(roundToMultipleOf(yards, 10.0), Distance.UNIT_YARDS)
} else {
Distance.create(roundToMultipleOf(feet, 10.0), Distance.UNIT_FEET)
}
in 0.1..10.0 -> Distance.create(
roundToMultipleOf(miles, 0.1),
Distance.UNIT_MILES_P1
)
else -> Distance.create(roundToMultipleOf(miles, 1.0), Distance.UNIT_MILES)
}
}
else -> {
// metric system
return when (value) {
in 0.0..999.0 -> Distance.create(
roundToMultipleOf(value, 10.0),
Distance.UNIT_METERS
)
in 1000.0..10000.0 -> Distance.create(
roundToMultipleOf(value / 1000, 0.1),
Distance.UNIT_KILOMETERS_P1
)
else -> Distance.create(
roundToMultipleOf(value / 1000, 1.0),
Distance.UNIT_KILOMETERS
)
}
}
}
}
private fun roundToMultipleOf(num: Double, step: Double): Double {
return (num / step).roundToInt() * step
}
fun getAndroidAutoVersion(ctx: Context): List<String> {
val info = ctx.packageManager.getPackageInfo("com.google.android.projection.gearhead", 0)
return info.versionName.split(".")
}
fun supportsCarApiLevel3(ctx: CarContext): Boolean {
if (ctx.carAppApiLevel < CarAppApiLevels.LEVEL_3) return false
ctx.hostInfo?.let { hostInfo ->
if (hostInfo.packageName == "com.google.android.projection.gearhead") {
val version = getAndroidAutoVersion(ctx)
// Android Auto 6.7 is required. 6.6 reports supporting API Level 3,
// but crashes when using it. See: https://issuetracker.google.com/issues/199509584
if (version[0] < "6" || version[0] == "6" && version[1] < "7") {
return false
}
}
}
return true
}

View File

@@ -1,215 +0,0 @@
package net.vonforst.evmap.auto
import android.content.pm.PackageManager
import android.os.Handler
import android.os.Looper
import androidx.car.app.CarContext
import androidx.car.app.Screen
import androidx.car.app.hardware.CarHardwareManager
import androidx.car.app.hardware.info.EnergyLevel
import androidx.car.app.hardware.info.Model
import androidx.car.app.hardware.info.Speed
import androidx.car.app.model.*
import androidx.core.content.ContextCompat
import androidx.core.graphics.drawable.IconCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleObserver
import androidx.lifecycle.OnLifecycleEvent
import net.vonforst.evmap.R
import net.vonforst.evmap.ui.Gauge
import kotlin.math.min
import kotlin.math.roundToInt
class VehicleDataScreen(ctx: CarContext) : Screen(ctx), LifecycleObserver {
private val hardwareMan = ctx.getCarService(CarContext.HARDWARE_SERVICE) as CarHardwareManager
private var model: Model? = null
private var energyLevel: EnergyLevel? = null
private var speed: Speed? = null
private var gauge = Gauge((ctx.resources.displayMetrics.density * 128).roundToInt(), ctx)
private val maxSpeed = 160f / 3.6f // m/s, speed gauge will show max if speed is higher
private val permissions = listOf(
"com.google.android.gms.permission.CAR_FUEL",
"com.google.android.gms.permission.CAR_SPEED"
)
init {
lifecycle.addObserver(this)
}
override fun onGetTemplate(): Template {
if (!permissionsGranted()) {
Handler(Looper.getMainLooper()).post {
screenManager.pushForResult(
PermissionScreen(
carContext,
R.string.auto_vehicle_data_permission_needed,
permissions
)
) {
setupListeners()
}
}
}
val energyLevel = energyLevel
val model = model
val speed = speed
return GridTemplate.Builder().apply {
setTitle(
if (model != null && model.manufacturer.value != null && model.name.value != null) {
"${model.manufacturer.value} ${model.name.value}"
} else {
carContext.getString(R.string.auto_vehicle_data)
}
)
setHeaderAction(Action.BACK)
if (!permissionsGranted()) {
setLoading(true)
} else {
setSingleList(
ItemList.Builder().apply {
addItem(GridItem.Builder().apply {
setTitle(carContext.getString(R.string.auto_charging_level))
if (energyLevel == null) {
setLoading(true)
} else if (energyLevel.batteryPercent.value != null && energyLevel.fuelPercent.value != null) {
// both battery and fuel (Plug-in hybrid)
setText(
"\uD83D\uDD0C %.0f %% ⛽ %.0f %%".format(
energyLevel.batteryPercent.value,
energyLevel.fuelPercent.value
)
)
setImage(
gauge.draw(
energyLevel.batteryPercent.value,
energyLevel.fuelPercent.value
).asCarIcon()
)
} else if (energyLevel.batteryPercent.value != null) {
// BEV
setText("%.0f %%".format(energyLevel.batteryPercent.value))
setImage(gauge.draw(energyLevel.batteryPercent.value).asCarIcon())
} else if (energyLevel.fuelPercent.value != null) {
// ICE
setText("⛽ %.0f %%".format(energyLevel.fuelPercent.value))
setImage(gauge.draw(energyLevel.fuelPercent.value).asCarIcon())
} else {
setText(carContext.getString(R.string.auto_no_data))
setImage(gauge.draw(0f).asCarIcon())
}
}.build())
addItem(GridItem.Builder().apply {
setTitle(carContext.getString(R.string.auto_range))
if (energyLevel == null) {
setLoading(true)
} else if (energyLevel.rangeRemainingMeters.value != null) {
setText(
formatCarUnitDistance(
energyLevel.rangeRemainingMeters.value,
energyLevel.distanceDisplayUnit.value
)
)
setImage(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
R.drawable.ic_car
)
).build()
)
} else {
setText(carContext.getString(R.string.auto_no_data))
setImage(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
R.drawable.ic_car
)
).build()
)
}
}.build())
addItem(GridItem.Builder().apply {
setTitle(carContext.getString(R.string.auto_speed))
if (speed == null) {
setLoading(true)
} else {
val rawSpeed = speed.rawSpeedMetersPerSecond.value
val displaySpeed = speed.displaySpeedMetersPerSecond.value
if (rawSpeed != null) {
setText(
formatCarUnitSpeed(
rawSpeed,
speed.speedDisplayUnit.value
)
)
setImage(
gauge.draw(min(rawSpeed / maxSpeed * 100, 100f)).asCarIcon()
)
} else if (displaySpeed != null) {
setText(
formatCarUnitSpeed(
speed.displaySpeedMetersPerSecond.value,
speed.speedDisplayUnit.value
)
)
setImage(
gauge.draw(min(displaySpeed / maxSpeed * 100, 100f))
.asCarIcon()
)
} else {
setText(carContext.getString(R.string.auto_no_data))
setImage(gauge.draw(0f).asCarIcon())
}
}
}.build())
}.build()
)
}
}.build()
}
private fun onEnergyLevelUpdated(energyLevel: EnergyLevel) {
this.energyLevel = energyLevel
invalidate()
}
private fun onSpeedUpdated(speed: Speed) {
this.speed = speed
invalidate()
}
@OnLifecycleEvent(Lifecycle.Event.ON_RESUME)
private fun setupListeners() {
if (!permissionsGranted()) return
println("Setting up energy level listener")
val exec = ContextCompat.getMainExecutor(carContext)
hardwareMan.carInfo.addEnergyLevelListener(exec, ::onEnergyLevelUpdated)
hardwareMan.carInfo.addSpeedListener(exec, ::onSpeedUpdated)
hardwareMan.carInfo.fetchModel(exec) {
this.model = it
invalidate()
}
}
@OnLifecycleEvent(Lifecycle.Event.ON_PAUSE)
private fun removeListeners() {
println("Removing energy level listener")
hardwareMan.carInfo.removeEnergyLevelListener(::onEnergyLevelUpdated)
hardwareMan.carInfo.removeSpeedListener(::onSpeedUpdated)
}
private fun permissionsGranted(): Boolean =
permissions.all {
ContextCompat.checkSelfPermission(
carContext,
it
) == PackageManager.PERMISSION_GRANTED
}
}

View File

@@ -1,9 +1,6 @@
package net.vonforst.evmap.auto
import android.Manifest
import android.location.Location
import android.os.Handler
import android.os.Looper
import androidx.car.app.CarContext
import androidx.car.app.Screen
import androidx.car.app.model.*
@@ -13,108 +10,60 @@ import net.vonforst.evmap.R
/**
* Welcome screen with selection between favorites and nearby chargers
*/
@androidx.car.app.annotations.ExperimentalCarApi
class WelcomeScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx), LocationAwareScreen {
private var location: Location? = null
override fun onGetTemplate(): Template {
if (!session.locationPermissionGranted()) {
Handler(Looper.getMainLooper()).post {
screenManager.pushForResult(
PermissionScreen(
carContext,
R.string.auto_location_permission_needed,
listOf(
Manifest.permission.ACCESS_FINE_LOCATION,
Manifest.permission.ACCESS_COARSE_LOCATION
)
)
) {
session.bindLocationService()
}
}
}
session.mapScreen = this
return PlaceListMapTemplate.Builder().apply {
setTitle(carContext.getString(R.string.app_name))
if (!session.locationPermissionGranted()) {
setLoading(true)
} else {
location?.let {
setAnchor(Place.Builder(CarLocation.create(it)).build())
}
setItemList(ItemList.Builder().apply {
addItem(
Row.Builder()
.setTitle(carContext.getString(R.string.auto_chargers_closeby))
.setImage(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
R.drawable.ic_address
)
)
.setTint(CarColor.DEFAULT).build()
)
.setBrowsable(true)
.setOnClickListener {
screenManager.push(
MapScreen(
carContext,
session,
favorites = false
)
)
}
.build())
addItem(
Row.Builder()
.setTitle(carContext.getString(R.string.auto_favorites))
.setImage(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
R.drawable.ic_fav
)
)
.setTint(CarColor.DEFAULT).build()
)
.setBrowsable(true)
.setOnClickListener {
screenManager.push(MapScreen(carContext, session, favorites = true))
}
.build())
if (supportsCarApiLevel3(carContext)) {
addItem(
Row.Builder()
.setTitle(carContext.getString(R.string.auto_vehicle_data))
.setImage(
CarIcon.Builder(
IconCompat.createWithResource(carContext, R.drawable.ic_car)
).setTint(CarColor.DEFAULT).build()
)
.setBrowsable(true)
.setOnClickListener {
session.mapScreen = null
screenManager.push(VehicleDataScreen(carContext))
}
.build()
)
}
}.build())
setCurrentLocationEnabled(true)
location?.let {
setAnchor(Place.Builder(CarLocation.create(it)).build())
}
setItemList(ItemList.Builder().apply {
addItem(
Row.Builder()
.setTitle(carContext.getString(R.string.auto_chargers_closeby))
.setImage(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
R.drawable.ic_address
)
)
.setTint(CarColor.DEFAULT).build()
)
.setBrowsable(true)
.setOnClickListener {
screenManager.push(MapScreen(carContext, session, favorites = false))
}
.build())
addItem(
Row.Builder()
.setTitle(carContext.getString(R.string.auto_favorites))
.setImage(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
R.drawable.ic_fav
)
)
.setTint(CarColor.DEFAULT).build()
)
.setBrowsable(true)
.setOnClickListener {
screenManager.push(MapScreen(carContext, session, favorites = true))
}
.build())
}.build())
setCurrentLocationEnabled(true)
setHeaderAction(Action.APP_ICON)
build()
}.build()
}
override fun updateLocation(location: Location) {
if (location.latitude == this.location?.latitude
&& location.longitude == this.location?.longitude
) {
return
}
this.location = location
invalidate()
}

View File

@@ -1,11 +0,0 @@
package net.vonforst.evmap.autocomplete
import android.content.Context
import net.vonforst.evmap.storage.PreferenceDataSource
fun getAutocompleteProviders(context: Context) =
if (PreferenceDataSource(context).searchProvider == "google") {
listOf(GooglePlacesAutocompleteProvider(context), MapboxAutocompleteProvider(context))
} else {
listOf(MapboxAutocompleteProvider(context), GooglePlacesAutocompleteProvider(context))
}

View File

@@ -1,112 +0,0 @@
package net.vonforst.evmap.autocomplete
import android.content.Context
import android.graphics.Typeface
import android.text.style.CharacterStyle
import android.text.style.StyleSpan
import com.car2go.maps.google.adapter.AnyMapAdapter
import com.car2go.maps.util.SphericalUtil
import com.google.android.gms.common.api.ApiException
import com.google.android.gms.maps.model.LatLng
import com.google.android.gms.maps.model.LatLngBounds
import com.google.android.gms.tasks.Tasks.await
import com.google.android.libraries.places.api.Places
import com.google.android.libraries.places.api.model.AutocompleteSessionToken
import com.google.android.libraries.places.api.model.Place
import com.google.android.libraries.places.api.model.RectangularBounds
import com.google.android.libraries.places.api.net.FetchPlaceRequest
import com.google.android.libraries.places.api.net.FindAutocompletePredictionsRequest
import com.google.android.libraries.places.api.net.PlacesStatusCodes
import kotlinx.coroutines.tasks.await
import net.vonforst.evmap.R
import java.util.concurrent.ExecutionException
import kotlin.math.sqrt
class GooglePlacesAutocompleteProvider(val context: Context) : AutocompleteProvider {
private var token = AutocompleteSessionToken.newInstance()
private val client = Places.createClient(context)
private val bold: CharacterStyle = StyleSpan(Typeface.BOLD)
override val id = "google"
override fun autocomplete(
query: String,
location: com.car2go.maps.model.LatLng?
): List<AutocompletePlace> {
val request = FindAutocompletePredictionsRequest.builder().apply {
if (location != null) {
setLocationBias(calcLocationBias(location))
setOrigin(LatLng(location.latitude, location.longitude))
}
setSessionToken(token)
setQuery(query)
}.build()
try {
val result =
await(client.findAutocompletePredictions(request)).autocompletePredictions
return result.map {
AutocompletePlace(
it.getPrimaryText(bold),
it.getSecondaryText(bold),
it.placeId,
it.distanceMeters?.toDouble(),
it.placeTypes.map { AutocompletePlaceType.valueOf(it.name) })
}
} catch (e: ExecutionException) {
val cause = e.cause
if (cause is ApiException) {
if (cause.statusCode == PlacesStatusCodes.OVER_QUERY_LIMIT) {
throw ApiUnavailableException()
}
}
throw e
}
}
override suspend fun getDetails(id: String): PlaceWithBounds {
val request =
FetchPlaceRequest.builder(id, listOf(Place.Field.LAT_LNG, Place.Field.VIEWPORT)).build()
try {
val place = client.fetchPlace(request).await().place
token = AutocompleteSessionToken.newInstance()
return PlaceWithBounds(
AnyMapAdapter.adapt(place.latLng),
AnyMapAdapter.adapt(place.viewport)
)
} catch (e: ApiException) {
if (e.statusCode == PlacesStatusCodes.OVER_QUERY_LIMIT) {
throw ApiUnavailableException()
} else {
throw e
}
}
}
override fun getAttributionString(): Int = R.string.places_powered_by_google
override fun getAttributionImage(dark: Boolean): Int =
if (dark) R.drawable.places_powered_by_google_dark else R.drawable.places_powered_by_google_light
private fun calcLocationBias(location: com.car2go.maps.model.LatLng): RectangularBounds {
val radius = 100e3 // meters
val northEast =
SphericalUtil.computeOffset(
location,
radius * sqrt(2.0),
45.0
)
val southWest =
SphericalUtil.computeOffset(
location,
radius * sqrt(2.0),
225.0
)
return RectangularBounds.newInstance(
LatLngBounds(
AnyMapAdapter.adapt(southWest),
AnyMapAdapter.adapt(northEast)
)
)
}
}

View File

@@ -5,6 +5,7 @@ import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.widget.Toolbar
import androidx.databinding.DataBindingUtil
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
@@ -27,7 +28,7 @@ class DonateFragment : Fragment() {
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
): View? {
binding = DataBindingUtil.inflate(inflater, R.layout.fragment_donate, container, false)
binding.lifecycleOwner = this
binding.vm = vm
@@ -35,7 +36,14 @@ class DonateFragment : Fragment() {
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
(requireActivity() as AppCompatActivity).setSupportActionBar(binding.toolbar)
val toolbar = view.findViewById(R.id.toolbar) as Toolbar
(requireActivity() as AppCompatActivity).setSupportActionBar(toolbar)
val navController = findNavController()
toolbar.setupWithNavController(
navController,
(requireActivity() as MapsActivity).appBarConfiguration
)
binding.productsList.apply {
adapter = DonationAdapter().apply {
@@ -57,12 +65,4 @@ class DonateFragment : Fragment() {
Snackbar.make(view, R.string.donation_failed, Snackbar.LENGTH_LONG).show()
})
}
override fun onResume() {
super.onResume()
binding.toolbar.setupWithNavController(
findNavController(),
(requireActivity() as MapsActivity).appBarConfiguration
)
}
}

View File

@@ -1,69 +0,0 @@
package net.vonforst.evmap.fragment
import android.animation.AnimatorSet
import android.animation.ObjectAnimator
import android.annotation.SuppressLint
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.animation.DecelerateInterpolator
import androidx.fragment.app.Fragment
import androidx.viewpager2.adapter.FragmentStateAdapter
import net.vonforst.evmap.databinding.FragmentOnboardingAndroidAutoBinding
class OnboardingViewPagerAdapter(fragment: Fragment) :
FragmentStateAdapter(fragment) {
override fun getItemCount(): Int = 4
override fun createFragment(position: Int): Fragment = when (position) {
0 -> WelcomeFragment()
1 -> IconsFragment()
2 -> AndroidAutoFragment()
3 -> DataSourceSelectFragment()
else -> throw IllegalArgumentException()
}
}
class AndroidAutoFragment : OnboardingPageFragment() {
private lateinit var binding: FragmentOnboardingAndroidAutoBinding
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
binding = FragmentOnboardingAndroidAutoBinding.inflate(inflater, container, false)
binding.btnGetStarted.setOnClickListener {
parent.goToNext()
}
binding.imgAndroidAuto.alpha = 0f
return binding.root
}
@SuppressLint("Recycle")
override fun onResume() {
super.onResume()
val animators =
listOf(
ObjectAnimator.ofFloat(binding.imgAndroidAuto, "translationY", -20f, 0f).apply {
interpolator = DecelerateInterpolator()
},
ObjectAnimator.ofFloat(binding.imgAndroidAuto, "alpha", 0f, 1f).apply {
interpolator = DecelerateInterpolator()
}
)
AnimatorSet().apply {
playTogether(animators)
start()
}
}
override fun onPause() {
super.onPause()
binding.imgAndroidAuto.alpha = 0f
}
}

View File

@@ -1,74 +0,0 @@
package net.vonforst.evmap.ui
import android.content.Context
import android.graphics.*
import androidx.annotation.ColorInt
import androidx.core.content.ContextCompat
import net.vonforst.evmap.R
import kotlin.math.max
import kotlin.math.min
class Gauge(val size: Int, ctx: Context) {
val arcPaint = Paint().apply {
style = Paint.Style.STROKE
strokeWidth = size * 0.15f
}
val gaugePaint = Paint()
val activeColor = ContextCompat.getColor(ctx, R.color.gauge_active)
val middleColor = ContextCompat.getColor(ctx, R.color.gauge_middle)
val inactiveColor = ContextCompat.getColor(ctx, R.color.gauge_inactive)
fun draw(valuePercent: Float?, secondValuePercent: Float? = null): Bitmap {
val bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bitmap)
canvas.drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR)
val angle = valuePercent?.let { 180f * it / 100 } ?: 0f
val secondAngle = secondValuePercent?.let { 180f * it / 100 }
drawArc(angle, secondAngle, canvas)
if (secondAngle != null) drawGauge(secondAngle, inactiveColor, canvas)
drawGauge(angle, Color.WHITE, canvas)
return bitmap
}
private fun drawGauge(angle: Float, @ColorInt color: Int, canvas: Canvas) {
gaugePaint.color = color
canvas.save()
canvas.rotate(angle - 90, size / 2f, 3 * size / 4f)
canvas.drawCircle(size / 2f, 3 * size / 4f, size * 0.1F, gaugePaint)
canvas.drawRect(size * 0.48f, 3 * size / 4f, size * 0.53f, size * 0.325f, gaugePaint)
canvas.restore()
}
private fun drawArc(angle: Float, secondAngle: Float?, canvas: Canvas) {
val (angle1, angle2) = if (secondAngle != null) {
min(angle, secondAngle) to max(angle, secondAngle)
} else {
angle to null
}
arcPaint.color = activeColor
val arcBounds = RectF(
arcPaint.strokeWidth / 2,
size / 4f + arcPaint.strokeWidth / 2,
size - arcPaint.strokeWidth / 2,
5 * size / 4f - arcPaint.strokeWidth / 2
)
canvas.drawArc(arcBounds, 180f, angle1, false, arcPaint)
if (angle2 != null) {
arcPaint.color = middleColor
canvas.drawArc(arcBounds, 180f + angle1, angle2 - angle1, false, arcPaint)
}
arcPaint.color = inactiveColor
canvas.drawArc(
arcBounds,
180f + (angle2 ?: angle1),
180f - (angle2 ?: angle1),
false,
arcPaint
)
}
}

View File

@@ -1,10 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M18.92,6.01C18.72,5.42 18.16,5 17.5,5h-11c-0.66,0 -1.21,0.42 -1.42,1.01L3,12v8c0,0.55 0.45,1 1,1h1c0.55,0 1,-0.45 1,-1v-1h12v1c0,0.55 0.45,1 1,1h1c0.55,0 1,-0.45 1,-1v-8l-2.08,-5.99zM6.5,16c-0.83,0 -1.5,-0.67 -1.5,-1.5S5.67,13 6.5,13s1.5,0.67 1.5,1.5S7.33,16 6.5,16zM17.5,16c-0.83,0 -1.5,-0.67 -1.5,-1.5s0.67,-1.5 1.5,-1.5 1.5,0.67 1.5,1.5 -0.67,1.5 -1.5,1.5zM5,11l1.5,-4.5h11L19,11L5,11z" />
</vector>

View File

@@ -1,66 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<TextView
android:id="@+id/welcomeTitle"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="32dp"
android:layout_marginEnd="32dp"
android:layout_marginBottom="16dp"
android:gravity="center"
android:text="@string/welcome_android_auto"
android:textAppearance="@style/TextAppearance.MaterialComponents.Headline6"
app:layout_constraintBottom_toTopOf="@+id/welcomeText"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.5"
app:layout_constraintStart_toStartOf="parent" />
<TextView
android:id="@+id/welcomeText"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="32dp"
android:layout_marginEnd="32dp"
android:layout_marginBottom="56dp"
android:breakStrategy="balanced"
android:gravity="center"
android:text="@string/welcome_android_auto_detail"
android:textAppearance="@style/TextAppearance.MaterialComponents.Body2"
android:textColor="?android:textColorSecondary"
app:layout_constraintBottom_toTopOf="@+id/btnGetStarted"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.5"
app:layout_constraintStart_toStartOf="parent" />
<Button
android:id="@+id/btnGetStarted"
style="@style/Widget.MaterialComponents.Button.OutlinedButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="24dp"
android:text="@string/sounds_cool"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" />
<ImageView
android:id="@+id/img_android_auto"
android:layout_width="72dp"
android:layout_height="72dp"
android:layout_marginTop="28dp"
android:layout_marginBottom="28dp"
android:background="@drawable/circle_bg_logo"
android:backgroundTint="@color/android_auto_accent"
android:scaleType="center"
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

@@ -25,9 +25,8 @@
<TextView
android:id="@+id/textView15"
android:layout_width="0dp"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
android:text="@{item.sku.title}"
android:textAppearance="@style/TextAppearance.MaterialComponents.Body1"
app:layout_constraintBottom_toBottomOf="parent"
@@ -35,7 +34,7 @@
app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="Spende (extrem langer Beschreibungstext)" />
tools:text="Spende" />
<TextView
android:id="@+id/textView21"

View File

@@ -4,10 +4,6 @@
<item>Google Maps</item>
<item>OpenStreetMap (Mapbox)</item>
</string-array>
<string-array name="pref_search_provider_names">
<item>Google Maps</item>
<item>OpenStreetMap (Mapbox)</item>
</string-array>
<string name="donations_info" formatted="false">Findest du EVMap nützlich? Unterstütze die Weiterentwicklung der App mit einer Spende an den Entwickler.\n\nGoogle zieht von der Spende 15% Gebühren ab.</string>
<string name="auto_location_service">EVMap läuft unter Android Auto und nutzt dafür deinen Standort.</string>
<string name="auto_no_chargers_found">Keine Ladestationen in der Nähe gefunden</string>
@@ -15,22 +11,9 @@
<string name="open_in_app">In App öffnen</string>
<string name="opened_on_phone">Auf dem Telefon geöffnet</string>
<string name="auto_location_permission_needed">Um EVMap auf Android Auto zu nutzen, braucht die App Zugriff auf deinen Standort.</string>
<string name="auto_vehicle_data_permission_needed">Für diese Funktion benötigt EVMap Zugriff auf Daten deines Fahrzeugs.</string>
<string name="grant_on_phone">Auf Telefon zulassen</string>
<string name="auto_chargers_closeby">In der Nähe</string>
<string name="auto_favorites">Favoriten</string>
<string name="auto_fault_report_date">⚠️ Störungsmeldung (%s)</string>
<string name="auto_no_refresh_possible">Weitere Aktualisierung nicht möglich. Bitte zurück gehen und neu starten.</string>
<string name="auto_prices">Preise</string>
<string name="auto_vehicle_data">Fahrzeugdaten</string>
<string name="auto_charging_level">Ladezustand</string>
<string name="auto_no_data">Nicht verfügbar</string>
<string name="auto_range">Reichweite</string>
<string name="auto_speed">Geschwindigkeit</string>
<string name="welcome_android_auto">Android Auto-Unterstützung</string>
<string name="welcome_android_auto_detail">Auf unterstützen Autos kannst du EVMap auch mit Android Auto nutzen. Öffne dazu einfach die EVMap-App aus dem Menü von Android Auto.</string>
<string name="sounds_cool">klingt cool</string>
<string name="auto_chargeprice_vehicle_unavailable">EVMap konnte das Fahrzeugmodell nicht erkennen.</string>
<string name="auto_chargeprice_vehicle_unknown">Keins der in der App ausgewählten Fahrzeuge passt zu diesem Fahrzeug (%s %s).</string>
<string name="auto_chargeprice_vehicle_ambiguous">Mehrere der in der App ausgewählten Fahrzeuge passen zu diesem Fahrzeug (%s %s).</string>
</resources>

View File

@@ -1,7 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="gauge_active">#00e676</color>
<color name="gauge_middle">#087f23</color>
<color name="gauge_inactive">#9e9e9e</color>
<color name="charger_100kw_dark">#fdd835</color>
</resources>

View File

@@ -8,16 +8,7 @@
<item>google</item>
<item>mapbox</item>
</string-array>
<string-array name="pref_search_provider_names">
<item>Google Maps</item>
<item>OpenStreetMap (Mapbox)</item>
</string-array>
<string-array name="pref_search_provider_values" tranlatable="false">
<item>google</item>
<item>mapbox</item>
</string-array>
<string name="pref_map_provider_default" translatable="false">google</string>
<string name="pref_search_provider_default" translatable="false">mapbox</string>
<string name="donations_info" formatted="false">Do you find EVMap useful? Support its development by sending a donation to the developer.\n\nGoogle takes 15% off every donation.</string>
<string name="auto_location_service">EVMap is running on Android Auto and using your location.</string>
<string name="auto_no_chargers_found">No nearby chargers found</string>
@@ -25,22 +16,9 @@
<string name="open_in_app">Open in app</string>
<string name="opened_on_phone">Opened on phone</string>
<string name="auto_location_permission_needed">To run EVMap on Android Auto, you need to grant access to your location.</string>
<string name="auto_vehicle_data_permission_needed">For this feature, EVMap needs access to your vehicle data.</string>
<string name="grant_on_phone">Grant on phone</string>
<string name="auto_chargers_closeby">Nearby chargers</string>
<string name="auto_favorites">Favorites</string>
<string name="auto_fault_report_date">⚠️ Fault report (%s)</string>
<string name="auto_no_refresh_possible">Further updates not possible. Please go back and restart.</string>
<string name="auto_prices">Pricing</string>
<string name="auto_vehicle_data">Vehicle data</string>
<string name="auto_charging_level">Charging level</string>
<string name="auto_no_data">Unavailable</string>
<string name="auto_range">Range</string>
<string name="auto_speed">Speed</string>
<string name="welcome_android_auto">Android Auto support</string>
<string name="welcome_android_auto_detail">You can also use EVMap from within Android Auto on supported cars. Simply select the EVMap app in the Android Auto menu.</string>
<string name="sounds_cool">sounds cool</string>
<string name="auto_chargeprice_vehicle_unavailable">EVMap could not determine your vehicle model.</string>
<string name="auto_chargeprice_vehicle_unknown">None of the vehicles selected in the app matches this vehicle (%s %s).</string>
<string name="auto_chargeprice_vehicle_ambiguous">Mehrere der in der App ausgewählten Fahrzeuge passen zu diesem Fahrzeug (%s %s).</string>
</resources>

View File

@@ -3,7 +3,6 @@
package="net.vonforst.evmap">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<queries>
@@ -32,9 +31,8 @@
<activity
android:name=".MapsActivity"
android:label="@string/app_name"
android:theme="@style/AppTheme.LaunchScreen"
android:exported="true">
android:label="@string/title_activity_maps"
android:theme="@style/AppTheme.LaunchScreen">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
@@ -258,16 +256,6 @@
</intent-filter>
</activity>
<!-- Override services of the com.mapzen.android.lost library with exported:false
until https://github.com/lostzen/lost/pull/270 is merged -->
<service
android:name="com.mapzen.android.lost.internal.GeofencingIntentService"
android:exported="false">
<intent-filter>
<action android:name="com.mapzen.lost.action.ACTION_GEOFENCING_SERVICE" />
</intent-filter>
</service>
</application>
</manifest>

View File

@@ -1,321 +0,0 @@
/*
* Copyright (C) 2007 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package android.widget;
import android.os.Handler;
import android.os.HandlerThread;
import android.os.Looper;
import android.os.Message;
import android.util.Log;
/**
* Copy of android.widget.Filter, exposing the hidden setDelayer() method.
*
* <p>A filter constrains data with a filtering pattern.</p>
*
* <p>Filters are usually created by {@link android.widget.Filterable}
* classes.</p>
*
* <p>Filtering operations performed by calling {@link #filter(CharSequence)} or
* {@link #filter(CharSequence, android.widget.Filter.FilterListener)} are
* performed asynchronously. When these methods are called, a filtering request
* is posted in a request queue and processed later. Any call to one of these
* methods will cancel any previous non-executed filtering request.</p>
*
* @see android.widget.Filterable
*/
public abstract class Filter {
private static final String LOG_TAG = "Filter";
private static final String THREAD_NAME = "Filter";
private static final int FILTER_TOKEN = 0xD0D0F00D;
private static final int FINISH_TOKEN = 0xDEADBEEF;
private Handler mThreadHandler;
private Handler mResultHandler;
private Delayer mDelayer;
private final Object mLock = new Object();
/**
* <p>Creates a new asynchronous filter.</p>
*/
public Filter() {
mResultHandler = new ResultsHandler();
}
/**
* Provide an interface that decides how long to delay the message for a given query. Useful
* for heuristics such as posting a delay for the delete key to avoid doing any work while the
* user holds down the delete key.
*
* @param delayer The delayer.
* @hide
*/
public void setDelayer(Delayer delayer) {
synchronized (mLock) {
mDelayer = delayer;
}
}
/**
* <p>Starts an asynchronous filtering operation. Calling this method
* cancels all previous non-executed filtering requests and posts a new
* filtering request that will be executed later.</p>
*
* @param constraint the constraint used to filter the data
* @see #filter(CharSequence, android.widget.Filter.FilterListener)
*/
public final void filter(CharSequence constraint) {
filter(constraint, null);
}
/**
* <p>Starts an asynchronous filtering operation. Calling this method
* cancels all previous non-executed filtering requests and posts a new
* filtering request that will be executed later.</p>
*
* <p>Upon completion, the listener is notified.</p>
*
* @param constraint the constraint used to filter the data
* @param listener a listener notified upon completion of the operation
* @see #filter(CharSequence)
* @see #performFiltering(CharSequence)
* @see #publishResults(CharSequence, android.widget.Filter.FilterResults)
*/
public final void filter(CharSequence constraint, FilterListener listener) {
synchronized (mLock) {
if (mThreadHandler == null) {
HandlerThread thread = new HandlerThread(
THREAD_NAME, android.os.Process.THREAD_PRIORITY_BACKGROUND);
thread.start();
mThreadHandler = new RequestHandler(thread.getLooper());
}
final long delay = (mDelayer == null) ? 0 : mDelayer.getPostingDelay(constraint);
Message message = mThreadHandler.obtainMessage(FILTER_TOKEN);
RequestArguments args = new RequestArguments();
// make sure we use an immutable copy of the constraint, so that
// it doesn't change while the filter operation is in progress
args.constraint = constraint != null ? constraint.toString() : null;
args.listener = listener;
message.obj = args;
mThreadHandler.removeMessages(FILTER_TOKEN);
mThreadHandler.removeMessages(FINISH_TOKEN);
mThreadHandler.sendMessageDelayed(message, delay);
}
}
/**
* <p>Invoked in a worker thread to filter the data according to the
* constraint. Subclasses must implement this method to perform the
* filtering operation. Results computed by the filtering operation
* must be returned as a {@link android.widget.Filter.FilterResults} that
* will then be published in the UI thread through
* {@link #publishResults(CharSequence,
* android.widget.Filter.FilterResults)}.</p>
*
* <p><strong>Contract:</strong> When the constraint is null, the original
* data must be restored.</p>
*
* @param constraint the constraint used to filter the data
* @return the results of the filtering operation
* @see #filter(CharSequence, android.widget.Filter.FilterListener)
* @see #publishResults(CharSequence, android.widget.Filter.FilterResults)
* @see android.widget.Filter.FilterResults
*/
protected abstract FilterResults performFiltering(CharSequence constraint);
/**
* <p>Invoked in the UI thread to publish the filtering results in the
* user interface. Subclasses must implement this method to display the
* results computed in {@link #performFiltering}.</p>
*
* @param constraint the constraint used to filter the data
* @param results the results of the filtering operation
* @see #filter(CharSequence, android.widget.Filter.FilterListener)
* @see #performFiltering(CharSequence)
* @see android.widget.Filter.FilterResults
*/
protected abstract void publishResults(CharSequence constraint,
FilterResults results);
/**
* <p>Converts a value from the filtered set into a CharSequence. Subclasses
* should override this method to convert their results. The default
* implementation returns an empty String for null values or the default
* String representation of the value.</p>
*
* @param resultValue the value to convert to a CharSequence
* @return a CharSequence representing the value
*/
public CharSequence convertResultToString(Object resultValue) {
return resultValue == null ? "" : resultValue.toString();
}
/**
* <p>Holds the results of a filtering operation. The results are the values
* computed by the filtering operation and the number of these values.</p>
*/
protected static class FilterResults {
public FilterResults() {
// nothing to see here
}
/**
* <p>Contains all the values computed by the filtering operation.</p>
*/
public Object values;
/**
* <p>Contains the number of values computed by the filtering
* operation.</p>
*/
public int count;
}
/**
* <p>Listener used to receive a notification upon completion of a filtering
* operation.</p>
*/
public static interface FilterListener {
/**
* <p>Notifies the end of a filtering operation.</p>
*
* @param count the number of values computed by the filter
*/
public void onFilterComplete(int count);
}
/**
* <p>Worker thread handler. When a new filtering request is posted from
* {@link android.widget.Filter#filter(CharSequence, android.widget.Filter.FilterListener)},
* it is sent to this handler.</p>
*/
private class RequestHandler extends Handler {
public RequestHandler(Looper looper) {
super(looper);
}
/**
* <p>Handles filtering requests by calling
* {@link Filter#performFiltering} and then sending a message
* with the results to the results handler.</p>
*
* @param msg the filtering request
*/
public void handleMessage(Message msg) {
int what = msg.what;
Message message;
switch (what) {
case FILTER_TOKEN:
RequestArguments args = (RequestArguments) msg.obj;
try {
args.results = performFiltering(args.constraint);
} catch (Exception e) {
args.results = new FilterResults();
Log.w(LOG_TAG, "An exception occured during performFiltering()!", e);
} finally {
message = mResultHandler.obtainMessage(what);
message.obj = args;
message.sendToTarget();
}
synchronized (mLock) {
if (mThreadHandler != null) {
Message finishMessage = mThreadHandler.obtainMessage(FINISH_TOKEN);
mThreadHandler.sendMessageDelayed(finishMessage, 3000);
}
}
break;
case FINISH_TOKEN:
synchronized (mLock) {
if (mThreadHandler != null) {
mThreadHandler.getLooper().quit();
mThreadHandler = null;
}
}
break;
}
}
}
/**
* <p>Handles the results of a filtering operation. The results are
* handled in the UI thread.</p>
*/
private class ResultsHandler extends Handler {
/**
* <p>Messages received from the request handler are processed in the
* UI thread. The processing involves calling
* {@link Filter#publishResults(CharSequence,
* android.widget.Filter.FilterResults)}
* to post the results back in the UI and then notifying the listener,
* if any.</p>
*
* @param msg the filtering results
*/
@Override
public void handleMessage(Message msg) {
RequestArguments args = (RequestArguments) msg.obj;
publishResults(args.constraint, args.results);
if (args.listener != null) {
int count = args.results != null ? args.results.count : -1;
args.listener.onFilterComplete(count);
}
}
}
/**
* <p>Holds the arguments of a filtering request as well as the results
* of the request.</p>
*/
private static class RequestArguments {
/**
* <p>The constraint used to filter the data.</p>
*/
CharSequence constraint;
/**
* <p>The listener to notify upon completion. Can be null.</p>
*/
FilterListener listener;
/**
* <p>The results of the filtering operation.</p>
*/
FilterResults results;
}
/**
* @hide
*/
public interface Delayer {
/**
* @param constraint The constraint passed to {@link Filter#filter(CharSequence)}
* @return The delay that should be used for
* {@link Handler#sendMessageDelayed(android.os.Message, long)}
*/
long getPostingDelay(CharSequence constraint);
}
}

View File

@@ -4,11 +4,6 @@ import android.app.Application
import com.facebook.stetho.Stetho
import net.vonforst.evmap.storage.PreferenceDataSource
import net.vonforst.evmap.ui.updateNightMode
import org.acra.config.dialog
import org.acra.config.limiter
import org.acra.config.mailSender
import org.acra.data.StringFormat
import org.acra.ktx.initAcra
class EvMapApplication : Application() {
override fun onCreate() {
@@ -16,28 +11,5 @@ class EvMapApplication : Application() {
updateNightMode(PreferenceDataSource(this))
Stetho.initializeWithDefaults(this);
init(applicationContext)
if (!BuildConfig.DEBUG) {
initAcra {
buildConfigClass = BuildConfig::class.java
reportFormat = StringFormat.KEY_VALUE_LIST
mailSender {
mailTo = "evmap+crashreport@vonforst.net"
}
dialog {
text = getString(R.string.crash_report_text)
title = getString(R.string.app_name)
commentPrompt = getString(R.string.crash_report_comment_prompt)
resIcon = R.drawable.ic_launcher_foreground
resTheme = R.style.AppTheme
}
limiter {
enabled = true
}
}
}
}
}

View File

@@ -4,30 +4,22 @@ import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.SystemClock
import android.view.View
import androidx.appcompat.app.AppCompatActivity
import androidx.browser.customtabs.CustomTabColorSchemeParams
import androidx.browser.customtabs.CustomTabsIntent
import androidx.core.content.ContextCompat
import androidx.core.splashscreen.SplashScreen
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.drawerlayout.widget.DrawerLayout
import androidx.navigation.NavController
import androidx.navigation.findNavController
import androidx.navigation.fragment.FragmentNavigator
import androidx.navigation.ui.AppBarConfiguration
import androidx.navigation.ui.setupWithNavController
import androidx.preference.Preference
import androidx.preference.PreferenceFragmentCompat
import com.car2go.maps.model.LatLng
import com.google.android.material.navigation.NavigationView
import com.google.android.material.snackbar.Snackbar
import net.vonforst.evmap.fragment.MapFragmentArgs
import net.vonforst.evmap.fragment.MapFragment
import net.vonforst.evmap.model.ChargeLocation
import net.vonforst.evmap.storage.PreferenceDataSource
import net.vonforst.evmap.utils.LocaleContextWrapper
@@ -39,8 +31,7 @@ const val EXTRA_CHARGER_ID = "chargerId"
const val EXTRA_LAT = "lat"
const val EXTRA_LON = "lon"
class MapsActivity : AppCompatActivity(),
PreferenceFragmentCompat.OnPreferenceStartFragmentCallback {
class MapsActivity : AppCompatActivity() {
interface FragmentCallback {
fun getRootView(): View
}
@@ -60,8 +51,9 @@ class MapsActivity : AppCompatActivity(),
}
override fun onCreate(savedInstanceState: Bundle?) {
// set theme to AppTheme to end launch screen
setTheme(R.style.AppTheme)
super.onCreate(savedInstanceState)
val splashScreen = installSplashScreen()
setContentView(R.layout.activity_maps)
@@ -86,32 +78,16 @@ class MapsActivity : AppCompatActivity(),
}
prefs = PreferenceDataSource(this)
prefs.appStartCounter += 1
checkPlayServices(this)
if (!prefs.welcomeDialogShown || !prefs.dataSourceSet) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
// wait for splash screen animation to finish on first start
splashScreen.setKeepVisibleCondition(object : SplashScreen.KeepOnScreenCondition {
var startTime: Long? = null
override fun shouldKeepOnScreen(): Boolean {
val st = startTime
if (st == null) {
startTime = SystemClock.uptimeMillis()
return true
} else {
return (SystemClock.uptimeMillis() - st) < 1000
}
}
})
}
navGraph.setStartDestination(R.id.onboarding)
if (!prefs.welcomeDialogShown || !prefs.dataSourceSet) {
navGraph.startDestination = R.id.onboarding
navController.graph = navGraph
return
} else {
navGraph.setStartDestination(R.id.map)
navGraph.startDestination = R.id.map
navController.graph = navGraph
}
@@ -126,14 +102,14 @@ class MapsActivity : AppCompatActivity(),
val deepLink = navController.createDeepLink()
.setGraph(R.navigation.nav_graph)
.setDestination(R.id.map)
.setArguments(MapFragmentArgs(latLng = LatLng(lat, lon)).toBundle())
.setArguments(MapFragment.showLocation(lat, lon))
.createPendingIntent()
deepLink.send()
} else if (query != null && query.isNotEmpty()) {
val deepLink = navController.createDeepLink()
.setGraph(R.navigation.nav_graph)
.setDestination(R.id.map)
.setArguments(MapFragmentArgs(locationName = query).toBundle())
.setArguments(MapFragment.showLocationByName(query))
.createPendingIntent()
deepLink.send()
}
@@ -143,7 +119,7 @@ class MapsActivity : AppCompatActivity(),
val deepLink = navController.createDeepLink()
.setGraph(R.navigation.nav_graph)
.setDestination(R.id.map)
.setArguments(MapFragmentArgs(chargerId = id).toBundle())
.setArguments(MapFragment.showChargerById(id))
.createPendingIntent()
deepLink.send()
}
@@ -151,13 +127,11 @@ class MapsActivity : AppCompatActivity(),
navController.createDeepLink()
.setDestination(R.id.map)
.setArguments(
MapFragmentArgs(
chargerId = intent.getLongExtra(EXTRA_CHARGER_ID, 0),
latLng = LatLng(
intent.getDoubleExtra(EXTRA_LAT, 0.0),
intent.getDoubleExtra(EXTRA_LON, 0.0)
)
).toBundle()
MapFragment.showCharger(
intent.getLongExtra(EXTRA_CHARGER_ID, 0),
intent.getDoubleExtra(EXTRA_LAT, 0.0),
intent.getDoubleExtra(EXTRA_LON, 0.0)
)
)
.createPendingIntent()
.send()
@@ -220,15 +194,4 @@ class MapsActivity : AppCompatActivity(),
}
startActivity(intent)
}
override fun onPreferenceStartFragment(
caller: PreferenceFragmentCompat,
pref: Preference
): Boolean {
// Identify the Navigation Destination
val navDestination = navController.graph
.find { target -> target is FragmentNavigator.Destination && pref.fragment == target.className }
navDestination?.let { target -> navController.navigate(target.id) }
return true
}
}

View File

@@ -1,7 +1,5 @@
package net.vonforst.evmap
import android.content.Context
import android.content.res.Configuration
import android.graphics.Typeface
import android.os.Bundle
import android.text.*
@@ -11,7 +9,6 @@ import androidx.lifecycle.Observer
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import java.util.*
fun Bundle.optDouble(name: String): Double? {
if (!this.containsKey(name)) return null
@@ -83,8 +80,6 @@ fun max(a: Int?, b: Int?): Int? {
}
}
fun <T> List<T>.containsAny(vararg values: T) = values.any { this.contains(it) }
public suspend fun <T> LiveData<T>.await(): T {
return withContext(Dispatchers.Main.immediate) {
suspendCancellableCoroutine { continuation ->
@@ -102,14 +97,4 @@ public suspend fun <T> LiveData<T>.await(): T {
}
}
}
}
fun Context.isDarkMode() =
(resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES
const val kmPerMile = 1.609344
const val meterPerFt = 0.3048
fun shouldUseImperialUnits(): Boolean {
return Locale.getDefault().country in listOf("US", "GB", "MM", "LR")
}

View File

@@ -84,10 +84,10 @@ fun buildDetails(
loc.openinghours.getStatusText(ctx)
else
loc.openinghours.description ?: "",
if (loc.openinghours.days != null || loc.openinghours.twentyfourSeven) loc.openinghours.description else null,
if (loc.openinghours.days != null) loc.openinghours.description else null,
hoursDays = loc.openinghours.days
) else null,
if (loc.cost != null && !loc.cost.isEmpty) DetailsAdapter.Detail(
if (loc.cost != null) DetailsAdapter.Detail(
R.drawable.ic_cost,
R.string.cost,
loc.cost.getStatusText(ctx),

View File

@@ -1,216 +0,0 @@
package net.vonforst.evmap.adapter
import android.content.Context
import android.os.Handler
import android.os.Looper
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.*
import androidx.databinding.DataBindingUtil
import androidx.lifecycle.LiveData
import com.car2go.maps.model.LatLng
import net.vonforst.evmap.R
import net.vonforst.evmap.autocomplete.*
import net.vonforst.evmap.containsAny
import net.vonforst.evmap.databinding.ItemAutocompleteResultBinding
import net.vonforst.evmap.isDarkMode
import net.vonforst.evmap.storage.AppDatabase
import net.vonforst.evmap.storage.PreferenceDataSource
import net.vonforst.evmap.storage.RecentAutocompletePlace
import java.time.Instant
class PlaceAutocompleteAdapter(val context: Context, val location: LiveData<LatLng>) :
BaseAdapter(), Filterable {
private var resultList: List<AutocompletePlace>? = null
private val providers = getAutocompleteProviders(context)
private val typeItem = 0
private val typeAttribution = 1
private val maxItems = 6
private var currentProvider: AutocompleteProvider? = null
private val recents = AppDatabase.getInstance(context).recentAutocompletePlaceDao()
private var recentResults = mutableListOf<RecentAutocompletePlace>()
data class ViewHolder(val binding: ItemAutocompleteResultBinding)
override fun getCount(): Int {
return resultList?.let { it.size + 1 } ?: 0
}
override fun getItem(position: Int): AutocompletePlace? {
return if (position < resultList!!.size) resultList!![position] else null
}
override fun getItemViewType(position: Int): Int {
return if (position < resultList!!.size) typeItem else typeAttribution
}
override fun getViewTypeCount(): Int = 2
override fun getItemId(position: Int): Long {
return 0
}
override fun getView(position: Int, convertView: View?, parent: ViewGroup?): View {
var view = convertView
if (getItemViewType(position) == typeItem) {
val viewHolder: ViewHolder
if (view == null) {
val binding: ItemAutocompleteResultBinding = DataBindingUtil.inflate(
LayoutInflater.from(context),
R.layout.item_autocomplete_result,
parent,
false
)
view = binding.root
viewHolder = ViewHolder(binding)
view.tag = viewHolder
} else {
viewHolder = view.tag as ViewHolder
}
val place = resultList!![position]
bindView(viewHolder, place)
} else if (getItemViewType(position) == typeAttribution) {
if (view == null) {
view = LayoutInflater.from(context)
.inflate(R.layout.item_autocomplete_attribution, parent, false)
}
(view as ImageView).apply {
setImageResource(currentProvider?.getAttributionImage(context.isDarkMode()) ?: 0)
contentDescription = context.getString(currentProvider?.getAttributionString() ?: 0)
}
}
return view!!
}
private fun bindView(
viewHolder: ViewHolder,
place: AutocompletePlace
) {
viewHolder.binding.item = place
}
override fun getFilter(): Filter {
return object : Filter() {
var delaySet = false
init {
if (PreferenceDataSource(context).searchProvider == "mapbox") {
// set delay to 500 ms to reduce paid Mapbox API requests
this.setDelayer { q -> if (isShortQuery(q)) 0L else 500L }
}
}
override fun publishResults(constraint: CharSequence?, results: FilterResults?) {
resultList = results?.values as? List<AutocompletePlace>?
if (results != null && results.count > 0) {
notifyDataSetChanged()
} else {
notifyDataSetInvalidated()
}
}
override fun performFiltering(constraint: CharSequence?): FilterResults {
val query = constraint.toString()
var resultList: List<AutocompletePlace>? = null
if (constraint != null) {
for (provider in providers) {
try {
recentResults.clear()
currentProvider = provider
// first search in recent places
val recentPlaces = if (query.isEmpty()) {
recents.getAll(provider.id, limit = maxItems)
} else {
recents.search(query, provider.id, limit = maxItems)
}
recentResults.addAll(recentPlaces)
resultList = recentPlaces.map { it.asAutocompletePlace(location.value) }
Handler(Looper.getMainLooper()).post {
// publish intermediate results on main thread
publishResults(constraint, resultList.asFilterResults())
}
// if we already have enough results or the query is short, stop here
if (isShortQuery(query) || recentResults.size >= maxItems) break
// then search online
val recentIds = recentPlaces.map { it.id }
resultList =
(resultList!! + provider.autocomplete(query, location.value)
.filter { !recentIds.contains(it.id) }).take(maxItems)
break
} catch (e: ApiUnavailableException) {
e.printStackTrace()
}
}
}
if (currentProvider is MapboxAutocompleteProvider && !delaySet) {
// set delay to 500 ms to reduce paid Mapbox API requests
this.setDelayer { q -> if (isShortQuery(q)) 0L else 500L }
}
return resultList.asFilterResults()
}
private fun List<AutocompletePlace>?.asFilterResults(): FilterResults {
val result = FilterResults()
if (this != null) {
result.values = this
result.count = this.size
}
return result
}
}
}
private fun isShortQuery(query: CharSequence) = query.length < 3
suspend fun getDetails(id: String): PlaceWithBounds {
val provider = currentProvider!!
val result = resultList!!.find { it.id == id }!!
val recentPlace = recentResults.find { it.id == id }
if (recentPlace != null) return recentPlace.asPlaceWithBounds()
val details = provider.getDetails(id)
recents.insert(RecentAutocompletePlace(result, details, provider.id, Instant.now()))
return details
}
}
fun iconForPlaceType(types: List<AutocompletePlaceType>): Int =
when {
types.contains(
AutocompletePlaceType.RECENT
) -> R.drawable.ic_history
types.containsAny(
AutocompletePlaceType.LIGHT_RAIL_STATION,
AutocompletePlaceType.BUS_STATION,
AutocompletePlaceType.TRAIN_STATION,
AutocompletePlaceType.TRANSIT_STATION
) -> {
R.drawable.ic_place_type_train
}
types.contains(AutocompletePlaceType.AIRPORT) -> {
R.drawable.ic_place_type_airport
}
// TODO: extend this with icons for more place categories
else -> {
R.drawable.ic_place_type_default
}
}
fun isSpecialPlace(types: List<AutocompletePlaceType>): Boolean =
!setOf(
R.drawable.ic_place_type_default,
R.drawable.ic_history
).contains(iconForPlaceType(types))

View File

@@ -65,20 +65,10 @@ abstract class BaseAvailabilityDetector(private val client: OkHttpClient) : Avai
connectors: Map<Long, Pair<Double, String>>,
chargepoints: List<Chargepoint>
): Map<Chargepoint, Set<Long>> {
var chargepoints = chargepoints
// iterate over each connector type
val types = connectors.map { it.value.second }.distinct().toSet()
val equivalentTypes = types.map { equivalentPlugTypes(it).plus(it) }.cartesianProduct()
var geTypes = chargepoints.map { it.type }.distinct().toSet()
if (!equivalentTypes.any { it == geTypes } && geTypes.size > 1 && geTypes.contains(
Chargepoint.SCHUKO
)) {
// If charger has household plugs and other plugs, try removing the household plugs
// (common e.g. in Hamburg -> 2x Type 2 + 2x Schuko, but NM only lists Type 2)
geTypes = geTypes.filter { it != Chargepoint.SCHUKO }.toSet()
chargepoints = chargepoints.filter { it.type != Chargepoint.SCHUKO }
}
val geTypes = chargepoints.map { it.type }.distinct().toSet()
if (!equivalentTypes.any { it == geTypes }) throw AvailabilityDetectorException("chargepoints do not match")
return types.flatMap { type ->
// find connectors of this type
@@ -102,7 +92,7 @@ abstract class BaseAvailabilityDetector(private val client: OkHttpClient) : Avai
chargepoint to ids
}
} else if (powers.size == 1 && gePowers.size == 2
&& chargepoints.sumOf { it.count } == connsOfType.size
&& chargepoints.sumBy { it.count } == connsOfType.size
) {
// special case: dual charger(s) with load balancing
// GoingElectric shows 2 different powers, NewMotion just one
@@ -110,7 +100,7 @@ abstract class BaseAvailabilityDetector(private val client: OkHttpClient) : Avai
var i = 0
gePowers.map { gePower ->
val chargepoint =
chargepoints.find { it.type in equivalentPlugTypes(type) && it.power == gePower }!!
chargepoints.find { it.type == type && it.power == gePower }!!
val ids = allIds.subList(i, i + chargepoint.count).toSet()
i += chargepoint.count
chargepoint to ids

View File

@@ -15,7 +15,6 @@ import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.Header
import retrofit2.http.POST
import java.util.*
interface ChargepriceApi {
@POST("charge_prices")
@@ -34,9 +33,6 @@ interface ChargepriceApi {
private val cacheSize = 1L * 1024 * 1024 // 1MB
val supportedLanguages = setOf("de", "en", "fr", "nl")
val DATA_SOURCE_GOINGELECTRIC = "going_electric"
val DATA_SOURCE_OPENCHARGEMAP = "open_charge_map"
private val jsonApiAdapterFactory = ResourceAdapterFactory.builder()
.add(ChargepriceRequest::class.java)
.add(ChargepriceTariff::class.java)
@@ -78,61 +74,5 @@ interface ChargepriceApi {
.build()
return retrofit.create(ChargepriceApi::class.java)
}
fun getChargepriceLanguage(): String {
val locale = Locale.getDefault().language
return if (supportedLanguages.contains(locale)) {
locale
} else {
"en"
}
}
@JvmStatic
fun isCountrySupported(country: String, dataSource: String): Boolean = when (dataSource) {
// list of countries updated 2021/08/24
"goingelectric" -> country in listOf(
"Deutschland",
"Österreich",
"Schweiz",
"Frankreich",
"Belgien",
"Niederlande",
"Luxemburg",
"Dänemark",
"Norwegen",
"Schweden",
"Slowenien",
"Kroatien",
"Ungarn",
"Tschechien",
"Italien",
"Spanien",
"Großbritannien",
"Irland"
)
"openchargemap" -> country in listOf(
"DE",
"AT",
"CH",
"FR",
"BE",
"NE",
"LU",
"DK",
"NO",
"SE",
"SI",
"HR",
"HU",
"CZ",
"IT",
"ES",
"GB",
"IE"
)
else -> false
}
}
}

View File

@@ -11,7 +11,6 @@ import net.vonforst.evmap.R
import net.vonforst.evmap.adapter.Equatable
import net.vonforst.evmap.api.equivalentPlugTypes
import net.vonforst.evmap.model.ChargeLocation
import net.vonforst.evmap.model.Chargepoint
import net.vonforst.evmap.ui.currency
import kotlin.math.ceil
import kotlin.math.floor
@@ -149,26 +148,6 @@ class ChargepriceCar : Resource(), Equatable {
result = 31 * result + manufacturer.hashCode()
return result
}
private val acConnectors = listOf(
Chargepoint.CEE_BLAU,
Chargepoint.CEE_ROT,
Chargepoint.SCHUKO,
Chargepoint.TYPE_1,
Chargepoint.TYPE_2_UNKNOWN,
Chargepoint.TYPE_2_SOCKET,
Chargepoint.TYPE_2_PLUG
)
private val plugMapping = mapOf(
"ccs" to Chargepoint.CCS_UNKNOWN,
"tesla_suc" to Chargepoint.SUPERCHARGER,
"tesla_ccs" to Chargepoint.CCS_UNKNOWN,
"chademo" to Chargepoint.CHADEMO
)
val compatibleEvmapConnectors: List<String>
get() = dcChargePorts.map {
plugMapping[it]
}.filterNotNull().plus(acConnectors)
}
@JsonApi(type = "brand")
@@ -205,9 +184,6 @@ class ChargePrice : Resource(), Equatable, Cloneable {
@field:Json(name = "charge_point_prices")
lateinit var chargepointPrices: List<ChargepointPrice>
@field:Json(name = "branding")
var branding: ChargepriceBranding? = null
var tariff: HasOne<ChargepriceTariff>? = null
@@ -241,7 +217,6 @@ class ChargePrice : Resource(), Equatable, Cloneable {
if (startTime != other.startTime) return false
if (tags != other.tags) return false
if (chargepointPrices != other.chargepointPrices) return false
if (branding != other.branding) return false
return true
}
@@ -260,7 +235,6 @@ class ChargePrice : Resource(), Equatable, Cloneable {
result = 31 * result + startTime
result = 31 * result + tags.hashCode()
result = 31 * result + chargepointPrices.hashCode()
result = 31 * result + branding.hashCode()
return result
}
@@ -279,7 +253,6 @@ class ChargePrice : Resource(), Equatable, Cloneable {
totalMonthlyFee = this@ChargePrice.totalMonthlyFee
url = this@ChargePrice.url
tariff = this@ChargePrice.tariff
branding = this@ChargePrice.branding
}
}
}
@@ -334,12 +307,6 @@ data class ChargepointPrice(
}
}
data class ChargepriceBranding(
@Json(name = "background_color") val backgroundColor: String,
@Json(name = "text_color") val textColor: String,
@Json(name = "logo_url") val logoUrl: String
)
data class PriceDistribution(val kwh: Double?, val session: Double?, val minute: Double?) {
val isOnlyKwh =
kwh != null && kwh > 0 && (session == null || session == 0.0) && (minute == null || minute == 0.0)
@@ -351,19 +318,6 @@ data class ChargepriceMeta(
@Json(name = "charge_points") val chargePoints: List<ChargepriceChargepointMeta>
)
enum class ChargepriceInclude {
@Json(name = "filter")
FILTER,
@Json(name = "always")
ALWAYS,
@Json(name = "exclusive")
EXCLUSIVE
}
data class ChargepriceRequestTariffMeta(
val include: ChargepriceInclude
)
data class ChargepriceChargepointMeta(
val power: Double,
val plug: String,

View File

@@ -402,7 +402,7 @@ class GoingElectricApiWrapper(
val chargeCards = referenceData.chargecards
val plugMap = plugs.map { plug ->
plug to nameForPlugType(sp, GEChargepoint.convertTypeFromGE(plug))
plug to nameForPlugType(sp, plug)
}.toMap()
val networkMap = networks.map { it to it }.toMap()
val chargecardMap = chargeCards.map { it.id.toString() to it.name }.toMap()
@@ -448,11 +448,11 @@ class GoingElectricApiWrapper(
MultipleChoiceFilter(
sp.getString(R.string.filter_connectors), "connectors",
plugMap,
commonChoices = listOf(
commonChoices = setOf(
Chargepoint.TYPE_2_UNKNOWN,
Chargepoint.CCS_UNKNOWN,
Chargepoint.CHADEMO
).map { GEChargepoint.convertTypeToGE(it)!! }.toSet(),
),
manyChoices = true
),
SliderFilter(

View File

@@ -87,14 +87,7 @@ data class GECost(
@JsonObjectOrFalse @Json(name = "description_short") val descriptionShort: String?,
@JsonObjectOrFalse @Json(name = "description_long") val descriptionLong: String?
) {
fun convert() = Cost(
// In GE, freecharging = false can either mean "paid charging" or "no information
// available", only freecharging = true provides useful information. Therefore convert
// false to null. Same for freeparking.
if (freecharging) freecharging else null,
if (freeparking) freeparking else null,
descriptionShort, descriptionLong
)
fun convert() = Cost(freecharging, freeparking, descriptionShort, descriptionLong)
}
@JsonClass(generateAdapter = true)
@@ -133,7 +126,7 @@ data class GEHours(
val start: LocalTime?,
val end: LocalTime?
) {
fun convert() = if (start != null && end != null) Hours(start, end) else null
fun convert() = Hours(start, end)
}
@JsonClass(generateAdapter = true)

View File

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

View File

@@ -1,189 +0,0 @@
package net.vonforst.evmap.autocomplete
import android.os.Parcelable
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import com.car2go.maps.model.LatLng
import com.car2go.maps.model.LatLngBounds
import kotlinx.parcelize.Parcelize
interface AutocompleteProvider {
val id: String
fun autocomplete(query: String, location: LatLng?): List<AutocompletePlace>
suspend fun getDetails(id: String): PlaceWithBounds
@StringRes
fun getAttributionString(): Int
@DrawableRes
fun getAttributionImage(dark: Boolean): Int
}
data class AutocompletePlace(
val primaryText: CharSequence,
val secondaryText: CharSequence,
val id: String,
val distanceMeters: Double?,
val types: List<AutocompletePlaceType>
)
class ApiUnavailableException : Exception()
enum class AutocompletePlaceType {
// based on Google Places Place.Type enum
OTHER,
ACCOUNTING,
ADMINISTRATIVE_AREA_LEVEL_1,
ADMINISTRATIVE_AREA_LEVEL_2,
ADMINISTRATIVE_AREA_LEVEL_3,
ADMINISTRATIVE_AREA_LEVEL_4,
ADMINISTRATIVE_AREA_LEVEL_5,
AIRPORT,
AMUSEMENT_PARK,
AQUARIUM,
ARCHIPELAGO,
ART_GALLERY,
ATM,
BAKERY,
BANK,
BAR,
BEAUTY_SALON,
BICYCLE_STORE,
BOOK_STORE,
BOWLING_ALLEY,
BUS_STATION,
CAFE,
CAMPGROUND,
CAR_DEALER,
CAR_RENTAL,
CAR_REPAIR,
CAR_WASH,
CASINO,
CEMETERY,
CHURCH,
CITY_HALL,
CLOTHING_STORE,
COLLOQUIAL_AREA,
CONTINENT,
CONVENIENCE_STORE,
COUNTRY,
COURTHOUSE,
DENTIST,
DEPARTMENT_STORE,
DOCTOR,
DRUGSTORE,
ELECTRICIAN,
ELECTRONICS_STORE,
EMBASSY,
ESTABLISHMENT,
FINANCE,
FIRE_STATION,
FLOOR,
FLORIST,
FOOD,
FUNERAL_HOME,
FURNITURE_STORE,
GAS_STATION,
GENERAL_CONTRACTOR,
GEOCODE,
GROCERY_OR_SUPERMARKET,
GYM,
HAIR_CARE,
HARDWARE_STORE,
HEALTH,
HINDU_TEMPLE,
HOME_GOODS_STORE,
HOSPITAL,
INSURANCE_AGENCY,
INTERSECTION,
JEWELRY_STORE,
LAUNDRY,
LAWYER,
LIBRARY,
LIGHT_RAIL_STATION,
LIQUOR_STORE,
LOCAL_GOVERNMENT_OFFICE,
LOCALITY,
LOCKSMITH,
LODGING,
MEAL_DELIVERY,
MEAL_TAKEAWAY,
MOSQUE,
MOVIE_RENTAL,
MOVIE_THEATER,
MOVING_COMPANY,
MUSEUM,
NATURAL_FEATURE,
NEIGHBORHOOD,
NIGHT_CLUB,
PAINTER,
PARK,
PARKING,
PET_STORE,
PHARMACY,
PHYSIOTHERAPIST,
PLACE_OF_WORSHIP,
PLUMBER,
PLUS_CODE,
POINT_OF_INTEREST,
POLICE,
POLITICAL,
POST_BOX,
POST_OFFICE,
POSTAL_CODE_PREFIX,
POSTAL_CODE_SUFFIX,
POSTAL_CODE,
POSTAL_TOWN,
PREMISE,
PRIMARY_SCHOOL,
REAL_ESTATE_AGENCY,
RESTAURANT,
ROOFING_CONTRACTOR,
ROOM,
ROUTE,
RV_PARK,
SCHOOL,
SECONDARY_SCHOOL,
SHOE_STORE,
SHOPPING_MALL,
SPA,
STADIUM,
STORAGE,
STORE,
STREET_ADDRESS,
STREET_NUMBER,
SUBLOCALITY_LEVEL_1,
SUBLOCALITY_LEVEL_2,
SUBLOCALITY_LEVEL_3,
SUBLOCALITY_LEVEL_4,
SUBLOCALITY_LEVEL_5,
SUBLOCALITY,
SUBPREMISE,
SUBWAY_STATION,
SUPERMARKET,
SYNAGOGUE,
TAXI_STAND,
TOURIST_ATTRACTION,
TOWN_SQUARE,
TRAIN_STATION,
TRANSIT_STATION,
TRAVEL_AGENCY,
UNIVERSITY,
VETERINARY_CARE,
ZOO,
RECENT;
companion object {
fun valueOfOrNull(value: String): AutocompletePlaceType? {
try {
return valueOf(value)
} catch (e: IllegalArgumentException) {
return null
}
}
}
}
@Parcelize
data class PlaceWithBounds(val latLng: LatLng, val viewport: LatLngBounds?) : Parcelable

View File

@@ -1,127 +0,0 @@
package net.vonforst.evmap.autocomplete
import android.content.Context
import android.graphics.Typeface
import android.text.Spannable
import android.text.SpannableString
import android.text.style.CharacterStyle
import android.text.style.StyleSpan
import androidx.core.os.ConfigurationCompat
import com.car2go.maps.model.LatLng
import com.car2go.maps.model.LatLngBounds
import com.car2go.maps.util.SphericalUtil
import com.mapbox.api.geocoding.v5.GeocodingCriteria
import com.mapbox.api.geocoding.v5.MapboxGeocoding
import com.mapbox.api.geocoding.v5.models.CarmenFeature
import com.mapbox.geojson.BoundingBox
import com.mapbox.geojson.Point
import net.vonforst.evmap.R
import java.io.IOException
class MapboxAutocompleteProvider(val context: Context) : AutocompleteProvider {
private val bold: CharacterStyle = StyleSpan(Typeface.BOLD)
private val results = HashMap<String, CarmenFeature>()
override val id = "mapbox"
override fun autocomplete(query: String, location: LatLng?): List<AutocompletePlace> {
val result = MapboxGeocoding.builder().apply {
location?.let {
proximity(Point.fromLngLat(location.longitude, location.latitude))
}
languages(ConfigurationCompat.getLocales(context.resources.configuration)[0].language)
accessToken(context.getString(R.string.mapbox_key))
autocomplete(true)
this.query(query)
}.build().executeCall()
if (!result.isSuccessful) {
throw IOException(result.message())
}
return result.body()!!.features().map { feature ->
results[feature.id()!!] = feature
var secondaryText = (feature.matchingPlaceName() ?: feature.placeName())!!
val matchingText = (feature.matchingText() ?: feature.text())!!
val primaryText =
if (feature.address() != null && secondaryText.startsWith(feature.address() + " " + matchingText)) {
// countries where house number comes in front of road ("10 Downing Street")
feature.address() + " " + matchingText
} else {
// countries where house number comes after road ("Willy-Brandt-Str. 1")
matchingText + (feature.address()?.let { " $it" } ?: "")
}
secondaryText = secondaryText.replace("$primaryText, ", "")
AutocompletePlace(
highlightMatch(primaryText, query),
secondaryText,
feature.id()!!,
location?.let { location ->
SphericalUtil.computeDistanceBetween(
feature.center()!!.toLatLng(), location
)
},
getPlaceTypes(feature)
)
}
}
private fun getPlaceTypes(feature: CarmenFeature): List<AutocompletePlaceType> {
val types = feature.placeType()?.mapNotNull {
when (it) {
GeocodingCriteria.TYPE_COUNTRY -> AutocompletePlaceType.COUNTRY
GeocodingCriteria.TYPE_REGION -> AutocompletePlaceType.ADMINISTRATIVE_AREA_LEVEL_1
GeocodingCriteria.TYPE_POSTCODE -> AutocompletePlaceType.POSTAL_CODE
GeocodingCriteria.TYPE_DISTRICT -> AutocompletePlaceType.ADMINISTRATIVE_AREA_LEVEL_2
GeocodingCriteria.TYPE_PLACE -> AutocompletePlaceType.ADMINISTRATIVE_AREA_LEVEL_3
GeocodingCriteria.TYPE_LOCALITY -> AutocompletePlaceType.LOCALITY
GeocodingCriteria.TYPE_NEIGHBORHOOD -> AutocompletePlaceType.NEIGHBORHOOD
GeocodingCriteria.TYPE_ADDRESS -> AutocompletePlaceType.STREET_ADDRESS
GeocodingCriteria.TYPE_POI -> AutocompletePlaceType.POINT_OF_INTEREST
GeocodingCriteria.TYPE_POI_LANDMARK -> AutocompletePlaceType.POINT_OF_INTEREST
else -> null
}
} ?: emptyList()
val categories = feature.properties()?.get("category")?.asString?.split(", ")?.mapNotNull {
// Place categories are defined at https://docs.mapbox.com/api/search/geocoding/#point-of-interest-category-coverage
// We try to find a matching entry in the enum.
// TODO: map categories that are not named the same
AutocompletePlaceType.valueOfOrNull(it.uppercase().replace(" ", "_"))
} ?: emptyList()
return types + categories
}
private fun highlightMatch(text: String, query: String): CharSequence {
val result = SpannableString(text)
val startPos = text.lowercase().indexOf(query.lowercase())
if (startPos > -1) {
val endPos = startPos + query.length
result.setSpan(bold, startPos, endPos, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
}
return result
}
override suspend fun getDetails(id: String): PlaceWithBounds {
val place = results[id]!!
results.clear()
return PlaceWithBounds(
place.center()!!.toLatLng(),
place.geometry()?.bbox()?.toLatLngBounds()
)
}
override fun getAttributionString(): Int = R.string.powered_by_mapbox
override fun getAttributionImage(dark: Boolean): Int =
if (dark) R.drawable.mapbox_logo_icon else R.drawable.mapbox_logo
}
private fun BoundingBox.toLatLngBounds(): LatLngBounds {
return LatLngBounds(
southwest().toLatLng(),
northeast().toLatLng()
)
}
private fun Point.toLatLng(): LatLng = LatLng(this.latitude(), this.longitude())

View File

@@ -1,6 +1,7 @@
package net.vonforst.evmap.fragment.preference
package net.vonforst.evmap.fragment
import android.os.Bundle
import android.view.View
import androidx.appcompat.widget.Toolbar
import androidx.navigation.fragment.findNavController
import androidx.navigation.ui.setupWithNavController
@@ -13,12 +14,13 @@ import net.vonforst.evmap.R
class AboutFragment : PreferenceFragmentCompat() {
override fun onResume() {
super.onResume()
val toolbar = requireView().findViewById(R.id.toolbar) as Toolbar
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val toolbar = view.findViewById(R.id.toolbar) as Toolbar
val navController = findNavController()
toolbar.setupWithNavController(
findNavController(),
navController,
(requireActivity() as MapsActivity).appBarConfiguration
)
}
@@ -57,10 +59,6 @@ class AboutFragment : PreferenceFragmentCompat() {
findNavController().navigate(R.id.action_about_to_donateFragment)
true
}
"github_sponsors" -> {
findNavController().navigate(R.id.action_about_to_github_sponsors)
true
}
"twitter" -> {
(activity as? MapsActivity)?.openUrl(getString(R.string.twitter_url))
true

View File

@@ -6,12 +6,12 @@ import android.view.LayoutInflater
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import androidx.appcompat.widget.Toolbar
import androidx.databinding.DataBindingUtil
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.Observer
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import androidx.navigation.ui.setupWithNavController
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
@@ -21,10 +21,15 @@ import net.vonforst.evmap.R
import net.vonforst.evmap.adapter.ChargepriceAdapter
import net.vonforst.evmap.adapter.CheckableChargepriceCarAdapter
import net.vonforst.evmap.adapter.CheckableConnectorAdapter
import net.vonforst.evmap.api.ChargepointApi
import net.vonforst.evmap.api.chargeprice.ChargepriceCar
import net.vonforst.evmap.api.equivalentPlugTypes
import net.vonforst.evmap.api.goingelectric.GoingElectricApiWrapper
import net.vonforst.evmap.api.openchargemap.OpenChargeMapApiWrapper
import net.vonforst.evmap.databinding.FragmentChargepriceBinding
import net.vonforst.evmap.model.ChargeLocation
import net.vonforst.evmap.model.Chargepoint
import net.vonforst.evmap.model.ReferenceData
import net.vonforst.evmap.viewmodel.ChargepriceViewModel
import net.vonforst.evmap.viewmodel.Status
import net.vonforst.evmap.viewmodel.viewModelFactory
@@ -80,9 +85,16 @@ class ChargepriceFragment : DialogFragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val fragmentArgs: ChargepriceFragmentArgs by navArgs()
val charger = fragmentArgs.charger
val dataSource = fragmentArgs.dataSource
val toolbar = view.findViewById(R.id.toolbar) as Toolbar
val navController = findNavController()
toolbar.setupWithNavController(
navController,
(requireActivity() as MapsActivity).appBarConfiguration
)
val charger = requireArguments().getParcelable<ChargeLocation>(ARG_CHARGER)!!
val dataSource = requireArguments().getString(ARG_DATASOURCE)!!
vm.charger.value = charger
vm.dataSource.value = dataSource
if (vm.chargepoint.value == null) {
@@ -149,11 +161,11 @@ class ChargepriceFragment : DialogFragment() {
}
binding.imgChargepriceLogo.setOnClickListener {
(requireActivity() as MapsActivity).openUrl("https://www.chargeprice.app/?poi_id=${charger.id}&poi_source=${dataSource}")
(requireActivity() as MapsActivity).openUrl("https://www.chargeprice.app/?poi_id=${charger.id}&poi_source=going_electric")
}
binding.btnSettings.setOnClickListener {
findNavController().navigate(R.id.action_chargeprice_to_settingsFragment)
navController.navigate(R.id.action_chargeprice_to_settingsFragment)
}
binding.batteryRange.setLabelFormatter { value: Float ->
@@ -175,10 +187,6 @@ class ChargepriceFragment : DialogFragment() {
dismiss()
true
}
R.id.menu_help -> {
(activity as? MapsActivity)?.openUrl(getString(R.string.chargeprice_faq_link))
true
}
else -> false
}
}
@@ -209,12 +217,28 @@ class ChargepriceFragment : DialogFragment() {
}
}
override fun onResume() {
super.onResume()
binding.toolbar.setupWithNavController(
findNavController(),
(requireActivity() as MapsActivity).appBarConfiguration
)
}
companion object {
const val ARG_CHARGER = "charger"
const val ARG_DATASOURCE = "datasource"
fun showCharger(
charger: ChargeLocation,
dataSource: Class<ChargepointApi<ReferenceData>>
): Bundle {
return Bundle().apply {
putParcelable(
ARG_CHARGER,
charger
)
putString(
ARG_DATASOURCE,
when (dataSource) {
GoingElectricApiWrapper::class.java -> "going_electric"
OpenChargeMapApiWrapper::class.java -> "open_charge_map"
else -> throw IllegalArgumentException("unsupported data source")
}
)
}
}
}
}

View File

@@ -41,10 +41,9 @@ class DataSourceSelectDialog : AppCompatDialogFragment() {
override fun onStart() {
super.onStart()
// dialog with 95% screen height
dialog?.window?.setLayout(
ViewGroup.LayoutParams.MATCH_PARENT,
(resources.displayMetrics.heightPixels * 0.95).toInt()
ViewGroup.LayoutParams.WRAP_CONTENT
)
}

View File

@@ -1,5 +1,7 @@
package net.vonforst.evmap.fragment
import android.Manifest
import android.content.pm.PackageManager
import android.graphics.Canvas
import android.os.Bundle
import android.view.Gravity
@@ -7,6 +9,8 @@ import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
import androidx.appcompat.widget.Toolbar
import androidx.core.content.ContextCompat
import androidx.databinding.DataBindingUtil
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
@@ -27,13 +31,12 @@ import net.vonforst.evmap.adapter.FavoritesAdapter
import net.vonforst.evmap.databinding.FragmentFavoritesBinding
import net.vonforst.evmap.databinding.ItemFavoriteBinding
import net.vonforst.evmap.model.ChargeLocation
import net.vonforst.evmap.utils.checkAnyLocationPermission
import net.vonforst.evmap.viewmodel.FavoritesViewModel
import net.vonforst.evmap.viewmodel.viewModelFactory
class FavoritesFragment : Fragment(), LostApiClient.ConnectionCallbacks {
private lateinit var binding: FragmentFavoritesBinding
private var locationClient: LostApiClient? = null
private lateinit var locationClient: LostApiClient
private var toDelete: ChargeLocation? = null
private var deleteSnackbar: Snackbar? = null
private lateinit var adapter: FavoritesAdapter
@@ -47,17 +50,11 @@ class FavoritesFragment : Fragment(), LostApiClient.ConnectionCallbacks {
}
})
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
locationClient = LostApiClient.Builder(requireContext())
.addConnectionCallbacks(this).build()
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
): View? {
binding = DataBindingUtil.inflate(
inflater,
R.layout.fragment_favorites, container, false
@@ -65,23 +62,27 @@ class FavoritesFragment : Fragment(), LostApiClient.ConnectionCallbacks {
binding.lifecycleOwner = this
binding.vm = vm
locationClient = LostApiClient.Builder(requireContext())
.addConnectionCallbacks(this).build()
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val toolbar = view.findViewById(R.id.toolbar) as Toolbar
val navController = findNavController()
toolbar.setupWithNavController(
navController,
(requireActivity() as MapsActivity).appBarConfiguration
)
adapter = FavoritesAdapter(onDelete = {
delete(it.charger)
}).apply {
onClickListener = {
findNavController().navigate(
R.id.action_favs_to_map,
MapFragmentArgs(
chargerId = it.charger.id,
latLng = LatLng(it.charger.coordinates.lat, it.charger.coordinates.lng)
).toBundle()
)
navController.navigate(R.id.action_favs_to_map, MapFragment.showCharger(it.charger))
}
}
binding.favsList.apply {
@@ -96,13 +97,17 @@ class FavoritesFragment : Fragment(), LostApiClient.ConnectionCallbacks {
}
createTouchHelper().attachToRecyclerView(binding.favsList)
locationClient!!.connect()
locationClient.connect()
}
override fun onConnected() {
val context = this.context ?: return
if (context.checkAnyLocationPermission()) {
val location = LocationServices.FusedLocationApi.getLastLocation(locationClient!!)
if (ContextCompat.checkSelfPermission(
context,
Manifest.permission.ACCESS_FINE_LOCATION
) == PackageManager.PERMISSION_GRANTED
) {
val location = LocationServices.FusedLocationApi.getLastLocation(locationClient)
if (location != null) {
vm.location.value = LatLng(location.latitude, location.longitude)
}
@@ -115,8 +120,8 @@ class FavoritesFragment : Fragment(), LostApiClient.ConnectionCallbacks {
override fun onDestroy() {
super.onDestroy()
locationClient?.let {
if (it.isConnected) it.disconnect()
if (locationClient.isConnected) {
locationClient.disconnect()
}
}
@@ -251,12 +256,4 @@ class FavoritesFragment : Fragment(), LostApiClient.ConnectionCallbacks {
}
})
}
override fun onResume() {
super.onResume()
binding.toolbar.setupWithNavController(
findNavController(),
(requireActivity() as MapsActivity).appBarConfiguration
)
}
}

View File

@@ -3,6 +3,7 @@ package net.vonforst.evmap.fragment
import android.os.Bundle
import android.view.*
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.widget.Toolbar
import androidx.databinding.DataBindingUtil
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
@@ -41,7 +42,20 @@ class FilterFragment : Fragment() {
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
(requireActivity() as AppCompatActivity).setSupportActionBar(binding.toolbar)
val toolbar = view.findViewById(R.id.toolbar) as Toolbar
(requireActivity() as AppCompatActivity).setSupportActionBar(toolbar)
val navController = findNavController()
toolbar.setupWithNavController(
navController,
(requireActivity() as MapsActivity).appBarConfiguration
)
vm.filterProfile.observe(viewLifecycleOwner) {
if (it != null) {
toolbar.title = "${getString(R.string.menu_filter)}: ${it.name}"
}
}
binding.filtersList.apply {
adapter = FiltersAdapter()
@@ -54,7 +68,7 @@ class FilterFragment : Fragment() {
)
}
binding.toolbar.setNavigationOnClickListener {
toolbar.setNavigationOnClickListener {
findNavController().popBackStack()
}
}
@@ -74,52 +88,26 @@ class FilterFragment : Fragment() {
true
}
R.id.menu_save_profile -> {
saveProfile()
showEditTextDialog(requireContext()) { dialog, input ->
vm.filterProfile.value?.let { profile ->
input.setText(profile.name)
}
dialog.setTitle(R.string.save_as_profile)
.setMessage(R.string.save_profile_enter_name)
.setPositiveButton(R.string.ok) { di, button ->
lifecycleScope.launch {
vm.saveAsProfile(input.text.toString())
findNavController().popBackStack()
}
}
.setNegativeButton(R.string.cancel) { di, button ->
}
}
true
}
else -> super.onOptionsItemSelected(item)
}
}
private fun saveProfile(error: Boolean = false) {
showEditTextDialog(requireContext()) { dialog, input ->
vm.filterProfile.value?.let { profile ->
input.setText(profile.name)
}
if (error) {
input.error = getString(R.string.required)
}
dialog.setTitle(R.string.save_as_profile)
.setMessage(R.string.save_profile_enter_name)
.setPositiveButton(R.string.ok) { di, button ->
if (input.text.isBlank()) {
saveProfile(true)
} else {
lifecycleScope.launch {
vm.saveAsProfile(input.text.toString())
findNavController().popBackStack()
}
}
}
.setNegativeButton(R.string.cancel) { di, button ->
}
}
}
override fun onResume() {
super.onResume()
binding.toolbar.setupWithNavController(
findNavController(),
(requireActivity() as MapsActivity).appBarConfiguration
)
vm.filterProfile.observe(viewLifecycleOwner) {
if (it != null) {
binding.toolbar.title = getString(R.string.edit_filter_profile, it.name)
}
}
}
}

View File

@@ -8,6 +8,7 @@ import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.widget.Toolbar
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.lifecycleScope
@@ -56,7 +57,15 @@ class FilterProfilesFragment : Fragment() {
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
(requireActivity() as AppCompatActivity).setSupportActionBar(binding.toolbar)
val toolbar = view.findViewById(R.id.toolbar) as Toolbar
(requireActivity() as AppCompatActivity).setSupportActionBar(toolbar)
val navController = findNavController()
toolbar.setupWithNavController(
navController,
(requireActivity() as MapsActivity).appBarConfiguration
)
touchHelper = ItemTouchHelper(object : ItemTouchHelper.SimpleCallback(
ItemTouchHelper.UP or ItemTouchHelper.DOWN,
@@ -67,8 +76,8 @@ class FilterProfilesFragment : Fragment() {
viewHolder: RecyclerView.ViewHolder,
target: RecyclerView.ViewHolder
): Boolean {
val fromPos = viewHolder.bindingAdapterPosition;
val toPos = target.bindingAdapterPosition;
val fromPos = viewHolder.adapterPosition;
val toPos = target.adapterPosition;
val list = vm.filterProfiles.value?.toMutableList()
if (list != null) {
@@ -198,19 +207,11 @@ class FilterProfilesFragment : Fragment() {
touchHelper.attachToRecyclerView(binding.filterProfilesList)
binding.toolbar.setNavigationOnClickListener {
toolbar.setNavigationOnClickListener {
findNavController().popBackStack()
}
}
override fun onResume() {
super.onResume()
binding.toolbar.setupWithNavController(
findNavController(),
(requireActivity() as MapsActivity).appBarConfiguration
)
}
fun delete(fp: FilterProfile) {
val position = vm.filterProfiles.value?.indexOf(fp) ?: return
// if there is already a profile to delete, delete it now

View File

@@ -1,19 +1,16 @@
package net.vonforst.evmap.fragment
import android.Manifest.permission.ACCESS_COARSE_LOCATION
import android.Manifest.permission.ACCESS_FINE_LOCATION
import android.annotation.SuppressLint
import android.content.Context
import android.app.Activity
import android.content.Intent
import android.content.pm.PackageManager
import android.content.res.Configuration
import android.graphics.Color
import android.location.Geocoder
import android.location.Location
import android.os.Bundle
import android.text.method.KeyListener
import android.view.*
import android.view.inputmethod.InputMethodManager
import android.widget.AdapterView
import android.widget.ImageView
import android.widget.TextView
import android.widget.Toast
@@ -23,7 +20,8 @@ import androidx.appcompat.app.AlertDialog
import androidx.appcompat.widget.PopupMenu
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.core.view.*
import androidx.core.view.MenuCompat
import androidx.core.view.updateLayoutParams
import androidx.databinding.DataBindingUtil
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
@@ -33,7 +31,6 @@ import androidx.lifecycle.Observer
import androidx.lifecycle.lifecycleScope
import androidx.navigation.findNavController
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import androidx.navigation.ui.setupWithNavController
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
@@ -71,11 +68,9 @@ import net.vonforst.evmap.*
import net.vonforst.evmap.adapter.ConnectorAdapter
import net.vonforst.evmap.adapter.DetailsAdapter
import net.vonforst.evmap.adapter.GalleryAdapter
import net.vonforst.evmap.adapter.PlaceAutocompleteAdapter
import net.vonforst.evmap.api.goingelectric.GoingElectricApiWrapper
import net.vonforst.evmap.api.openchargemap.OpenChargeMapApiWrapper
import net.vonforst.evmap.autocomplete.ApiUnavailableException
import net.vonforst.evmap.autocomplete.PlaceWithBounds
import net.vonforst.evmap.autocomplete.handleAutocompleteResult
import net.vonforst.evmap.autocomplete.launchAutocomplete
import net.vonforst.evmap.databinding.FragmentMapBinding
import net.vonforst.evmap.model.*
import net.vonforst.evmap.storage.PreferenceDataSource
@@ -84,13 +79,16 @@ import net.vonforst.evmap.ui.ClusterIconGenerator
import net.vonforst.evmap.ui.MarkerAnimator
import net.vonforst.evmap.ui.getMarkerTint
import net.vonforst.evmap.utils.boundingBox
import net.vonforst.evmap.utils.checkAnyLocationPermission
import net.vonforst.evmap.utils.checkFineLocationPermission
import net.vonforst.evmap.utils.distanceBetween
import net.vonforst.evmap.viewmodel.*
import java.io.IOException
const val REQUEST_AUTOCOMPLETE = 2
const val ARG_CHARGER_ID = "chargerId"
const val ARG_LAT = "lat"
const val ARG_LON = "lon"
const val ARG_LOCATION_NAME = "locationName"
class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallback,
LostApiClient.ConnectionCallbacks, LocationListener {
private lateinit var binding: FragmentMapBinding
@@ -102,7 +100,6 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
private var requestingLocationUpdates = false
private lateinit var bottomSheetBehavior: BottomSheetBehaviorGoogleMapsLike<View>
private lateinit var detailAppBarBehavior: MergedAppBarLayoutBehavior
private lateinit var prefs: PreferenceDataSource
private var markers: MutableBiMap<Marker, ChargeLocation> = HashBiMap()
private var clusterMarkers: List<Marker> = emptyList()
private var searchResultMarker: Marker? = null
@@ -122,10 +119,6 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
return
}
if (binding.search.hasFocus()) {
removeSearchFocus()
}
val state = bottomSheetBehavior.state
if (state != STATE_COLLAPSED && state != STATE_HIDDEN) {
bottomSheetBehavior.state = STATE_COLLAPSED
@@ -140,8 +133,6 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
prefs = PreferenceDataSource(requireContext())
locationClient = LostApiClient.Builder(requireContext())
.addConnectionCallbacks(this)
.build()
@@ -158,7 +149,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
binding.lifecycleOwner = this
binding.vm = vm
val provider = prefs.mapProvider
val provider = PreferenceDataSource(requireContext()).mapProvider
if (mapFragment == null || mapFragment!!.priority[0] != provider) {
mapFragment = MapFragment()
mapFragment!!.priority = arrayOf(
@@ -225,24 +216,11 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
binding.detailAppBar.toolbar.menu.findItem(R.id.menu_edit).title =
getString(R.string.edit_at_datasource, vm.apiName)
binding.detailView.topPart.doOnNextLayout {
bottomSheetBehavior.peekHeight = binding.detailView.topPart.bottom
}
setupObservers()
setupClickListeners()
setupAdapters()
(activity as? MapsActivity)?.setSupportActionBar(binding.toolbar)
if (prefs.appStartCounter > 5 && !prefs.opensourceDonationsDialogShown) {
try {
findNavController().navigate(R.id.action_map_to_opensource_donations)
} catch (ignored: IllegalArgumentException) {
// when there is already another navigation going on
} catch (ignored: IllegalStateException) {
// "no current navigation node"
}
}
/*if (!prefs.update060AndroidAutoDialogShown) {
try {
navController.navigate(R.id.action_map_to_update_060_androidauto)
@@ -264,8 +242,10 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
)
vm.reloadPrefs()
if (requestingLocationUpdates && requireContext().checkAnyLocationPermission()
&& locationClient.isConnected
if (requestingLocationUpdates && ContextCompat.checkSelfPermission(
requireContext(),
ACCESS_FINE_LOCATION
) == PackageManager.PERMISSION_GRANTED && locationClient.isConnected
) {
requestLocationUpdates()
}
@@ -273,14 +253,16 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
private fun setupClickListeners() {
binding.fabLocate.setOnClickListener {
if (!requireContext().checkFineLocationPermission()) {
ActivityCompat.requestPermissions(
requireActivity(),
arrayOf(ACCESS_FINE_LOCATION, ACCESS_COARSE_LOCATION),
if (ContextCompat.checkSelfPermission(
requireContext(),
ACCESS_FINE_LOCATION
) != PackageManager.PERMISSION_GRANTED
) {
requestPermissions(
arrayOf(ACCESS_FINE_LOCATION),
REQUEST_LOCATION_PERMISSION
)
}
if (requireContext().checkAnyLocationPermission()) {
} else {
enableLocation(moveTo = true, animate = true)
}
}
@@ -306,20 +288,17 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
}
binding.detailView.btnChargeprice.setOnClickListener {
val charger = vm.charger.value?.data ?: return@setOnClickListener
val dataSource = when (vm.apiType) {
GoingElectricApiWrapper::class.java -> "going_electric"
OpenChargeMapApiWrapper::class.java -> "open_charge_map"
else -> throw IllegalArgumentException("unsupported data source")
}
findNavController().navigate(
R.id.action_map_to_chargepriceFragment,
ChargepriceFragmentArgs(charger, dataSource).toBundle()
ChargepriceFragment.showCharger(charger, vm.apiType)
)
}
binding.detailView.topPart.setOnClickListener {
bottomSheetBehavior.state = BottomSheetBehaviorGoogleMapsLike.STATE_ANCHOR_POINT
}
setupSearchAutocomplete()
binding.search.setOnClickListener {
launchAutocomplete(this, vm.location.value)
}
binding.detailAppBar.toolbar.setNavigationOnClickListener {
bottomSheetBehavior.state = STATE_COLLAPSED
}
@@ -356,65 +335,6 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
}
}
var searchKeyListener: KeyListener? = null
@SuppressLint("SetTextI18n")
private fun setupSearchAutocomplete() {
binding.search.threshold = 1
searchKeyListener = binding.search.keyListener
binding.search.keyListener = null
val adapter = PlaceAutocompleteAdapter(requireContext(), vm.location)
binding.search.setAdapter(adapter)
binding.search.onItemClickListener =
AdapterView.OnItemClickListener { _, _, position, _ ->
val place = adapter.getItem(position) ?: return@OnItemClickListener
lifecycleScope.launch {
try {
vm.searchResult.value = adapter.getDetails(place.id)
} catch (e: ApiUnavailableException) {
e.printStackTrace()
} catch (e: IOException) {
// TODO: show error
e.printStackTrace()
}
}
removeSearchFocus()
binding.search.setText(
if (place.secondaryText.isNotEmpty()) {
"${place.primaryText}, ${place.secondaryText}"
} else {
place.primaryText.toString()
}
)
}
binding.search.onFocusChangeListener = View.OnFocusChangeListener { v, hasFocus ->
if (hasFocus) {
binding.search.keyListener = searchKeyListener
} else {
binding.search.keyListener = null
}
updateBackPressedCallback()
}
binding.clearSearch.setOnClickListener {
vm.searchResult.value = null
removeSearchFocus()
}
binding.toolbar.doOnLayout {
binding.search.dropDownWidth = binding.toolbar.width
binding.search.dropDownAnchor = R.id.toolbar
}
}
private fun removeSearchFocus() {
// clear focus and hide keyboard
val imm =
requireContext().getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
imm.hideSoftInputFromWindow(binding.search.windowToken, 0)
binding.search.clearFocus()
}
private fun openLayersMenu() {
binding.fabLayers.tag = false
val materialTransform = MaterialContainerTransform().apply {
@@ -444,21 +364,11 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
private fun toggleFavorite() {
val favs = vm.favorites.value ?: return
val charger = vm.chargerSparse.value ?: return
val isFav = favs.find { it.id == charger.id } != null
if (isFav) {
if (favs.find { it.id == charger.id } != null) {
vm.deleteFavorite(charger)
} 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 = !isFav
)
)
}
private fun setupObservers() {
@@ -487,7 +397,6 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
if (vm.bottomSheetState.value != BottomSheetBehaviorGoogleMapsLike.STATE_ANCHOR_POINT) {
bottomSheetBehavior.state = STATE_COLLAPSED
}
removeSearchFocus()
binding.fabDirections.show()
detailAppBarBehavior.setToolbarTitle(it.name)
updateFavoriteToggle()
@@ -550,8 +459,6 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
.icon(searchResultIcon)
.anchor(0.5f, 1f)
)
} else {
binding.search.setText("")
}
updateBackPressedCallback()
@@ -576,7 +483,6 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
vm.bottomSheetState.value != null && vm.bottomSheetState.value != STATE_HIDDEN
|| vm.searchResult.value != null
|| (vm.layersMenuOpen.value ?: false)
|| binding.search.hasFocus()
}
private fun unhighlightAllMarkers() {
@@ -586,8 +492,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
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.id } ?: emptyList()
multi = c.isMulti(vm.filteredConnectors.value)
)
)
}
@@ -601,8 +506,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
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.id } ?: emptyList()
multi = charger.isMulti(vm.filteredConnectors.value)
)
)
animator.animateMarkerBounce(marker)
@@ -615,8 +519,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
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.id } ?: emptyList()
multi = c.isMulti(vm.filteredConnectors.value)
)
)
}
@@ -818,10 +721,10 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
val position = vm.mapPosition.value
val fragmentArgs: MapFragmentArgs by navArgs()
val locationName = fragmentArgs.locationName
val chargerId = fragmentArgs.chargerId
val latLng = fragmentArgs.latLng
val lat = arguments?.optDouble(ARG_LAT)
val lon = arguments?.optDouble(ARG_LON)
val chargerId = arguments?.optLong(ARG_CHARGER_ID)
val locationName = arguments?.getString(ARG_LOCATION_NAME)
var positionSet = false
@@ -830,7 +733,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
map.cameraUpdateFactory.newLatLngZoom(position.bounds.center, position.zoom)
map.moveCamera(cameraUpdate)
positionSet = true
} else if (chargerId != 0L && latLng == null) {
} else if (chargerId != null && (lat == null || lon == null)) {
// show given charger ID
vm.loadChargerById(chargerId)
vm.chargerSparse.observe(
@@ -848,12 +751,13 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
})
positionSet = true
} else if (latLng != null) {
} else if (lat != null && lon != null) {
// show given position
val latLng = LatLng(lat, lon)
val cameraUpdate = map.cameraUpdateFactory.newLatLngZoom(latLng, 16f)
map.moveCamera(cameraUpdate)
if (chargerId != 0L) {
if (chargerId != null) {
// show charger detail after chargers were loaded
vm.chargepoints.observe(
viewLifecycleOwner,
@@ -896,7 +800,11 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
}
}
}
if (context?.checkAnyLocationPermission() ?: false) {
if (ContextCompat.checkSelfPermission(
requireContext(),
ACCESS_FINE_LOCATION
) == PackageManager.PERMISSION_GRANTED
) {
enableLocation(!positionSet, false)
positionSet = true
}
@@ -910,14 +818,9 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
vm.mapPosition.value = MapPosition(
map.projection.visibleRegion.latLngBounds, map.cameraPosition.zoom
)
if (vm.searchResult.value != null) {
// show search result (after configuration change)
vm.searchResult.postValue(vm.searchResult.value)
}
}
@RequiresPermission(anyOf = [ACCESS_FINE_LOCATION, ACCESS_COARSE_LOCATION])
@RequiresPermission(ACCESS_FINE_LOCATION)
private fun enableLocation(moveTo: Boolean, animate: Boolean) {
val map = this.map ?: return
map.setMyLocationEnabled(true)
@@ -931,7 +834,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
}
}
@RequiresPermission(anyOf = [ACCESS_FINE_LOCATION, ACCESS_COARSE_LOCATION])
@RequiresPermission(ACCESS_FINE_LOCATION)
private fun moveToLastLocation(map: AnyMap, animate: Boolean) {
val location = LocationServices.FusedLocationApi.getLastLocation(locationClient)
if (location != null) {
@@ -963,8 +866,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
getMarkerTint(charger, vm.filteredConnectors.value),
highlight = charger == vm.chargerSparse.value,
fault = charger.faultReport != null,
multi = charger.isMulti(vm.filteredConnectors.value),
fav = charger.id in vm.favorites.value?.map { it.id } ?: emptyList()
multi = charger.isMulti(vm.filteredConnectors.value)
)
)
}
@@ -982,8 +884,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
val highlight = charger == vm.chargerSparse.value
val fault = charger.faultReport != null
val multi = charger.isMulti(vm.filteredConnectors.value)
val fav = charger.id in vm.favorites.value?.map { it.id } ?: emptyList()
animator.animateMarkerDisappear(marker, tint, highlight, fault, multi, fav)
animator.animateMarkerDisappear(marker, tint, highlight, fault, multi)
} else {
animator.deleteMarker(marker)
}
@@ -998,7 +899,6 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
val highlight = charger == vm.chargerSparse.value
val fault = charger.faultReport != null
val multi = charger.isMulti(vm.filteredConnectors.value)
val fav = charger.id in vm.favorites.value?.map { it.id } ?: emptyList()
val marker = map.addMarker(
MarkerOptions()
.position(LatLng(charger.coordinates.lat, charger.coordinates.lng))
@@ -1009,13 +909,12 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
255,
highlight,
fault,
multi,
fav
multi
)
)
.anchor(0.5f, 1f)
)
animator.animateMarkerAppear(marker, tint, highlight, fault, multi, fav)
animator.animateMarkerAppear(marker, tint, highlight, fault, multi)
markers[marker] = charger
}
}
@@ -1045,7 +944,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
) {
when (requestCode) {
REQUEST_LOCATION_PERMISSION -> {
if ((grantResults.isNotEmpty() && grantResults.any { it == PackageManager.PERMISSION_GRANTED })) {
if ((grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED)) {
enableLocation(moveTo = true, animate = true)
}
}
@@ -1107,11 +1006,6 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
R.id.menu_group_filter_profiles,
Menu.NONE, Menu.NONE, R.string.no_filters
)
val favoritesItem = popup.menu.add(
R.id.menu_group_filter_profiles,
Menu.NONE,
Menu.NONE, R.string.filter_favorites
)
profiles.forEach { profile ->
val item = popup.menu.add(
R.id.menu_group_filter_profiles,
@@ -1128,12 +1022,11 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
profilesMap[FILTERS_DISABLED] = noFiltersItem
profilesMap[FILTERS_CUSTOM] = customItem
profilesMap[FILTERS_FAVORITES] = favoritesItem
popup.menu.setGroupCheckable(R.id.menu_group_filter_profiles, true, true);
val manageFiltersItem = popup.menu.findItem(R.id.menu_manage_filter_profiles)
manageFiltersItem.isVisible = profiles.isNotEmpty()
manageFiltersItem.isVisible = !profiles.isEmpty()
vm.filterStatus.observe(viewLifecycleOwner, Observer { id ->
when (id) {
@@ -1145,10 +1038,6 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
customItem.isVisible = true
customItem.isChecked = true
}
FILTERS_FAVORITES -> {
customItem.isVisible = false
favoritesItem.isChecked = true
}
else -> {
customItem.isVisible = false
val item = profilesMap[id]
@@ -1183,15 +1072,67 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
}
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
when (requestCode) {
REQUEST_AUTOCOMPLETE -> {
if (resultCode == Activity.RESULT_OK && data != null) {
vm.searchResult.value = handleAutocompleteResult(data)
}
}
else -> super.onActivityResult(requestCode, resultCode, data)
}
}
override fun getRootView(): View {
return binding.root
}
companion object {
fun showCharger(charger: ChargeLocation): Bundle {
return Bundle().apply {
putLong(ARG_CHARGER_ID, charger.id)
putDouble(ARG_LAT, charger.coordinates.lat)
putDouble(ARG_LON, charger.coordinates.lng)
}
}
fun showLocation(lat: Double, lon: Double): Bundle {
return Bundle().apply {
putDouble(ARG_LAT, lat)
putDouble(ARG_LON, lon)
}
}
fun showChargerById(id: Long): Bundle {
return Bundle().apply {
putLong(ARG_CHARGER_ID, id)
}
}
fun showCharger(id: Long, lat: Double, lon: Double): Bundle {
return Bundle().apply {
putLong(ARG_CHARGER_ID, id)
putDouble(ARG_LAT, lat)
putDouble(ARG_LON, lon)
}
}
fun showLocationByName(query: String): Bundle {
return Bundle().apply {
putString(ARG_LOCATION_NAME, query)
}
}
}
override fun onConnected() {
val map = this.map ?: return
val context = this.context ?: return
if (vm.myLocationEnabled.value == true) {
if (context.checkAnyLocationPermission()) {
if (ActivityCompat.checkSelfPermission(
context,
ACCESS_FINE_LOCATION
) == PackageManager.PERMISSION_GRANTED
) {
moveToLastLocation(map, false)
requestLocationUpdates()
}

View File

@@ -79,14 +79,7 @@ class MultiSelectDialog : AppCompatDialogFragment() {
items = data.entries.toList()
.sortedBy { it.value.toLowerCase(Locale.getDefault()) }
.sortedBy {
when {
selected.contains(it.key) && commonChoices?.contains(it.key) == true -> 0
selected.contains(it.key) -> 1
commonChoices?.contains(it.key) == true -> 2
else -> 3
}
}
.sortedByDescending { commonChoices?.contains(it.key) == true }
.map { MultiSelectItem(it.key, it.value, it.key in selected) }
adapter.submitList(items)

View File

@@ -4,15 +4,14 @@ import android.animation.AnimatorSet
import android.animation.ObjectAnimator
import android.annotation.SuppressLint
import android.content.Context
import android.graphics.drawable.AnimatedVectorDrawable
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.animation.DecelerateInterpolator
import android.widget.ImageView
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.*
@@ -21,7 +20,6 @@ import net.vonforst.evmap.storage.PreferenceDataSource
class OnboardingFragment : Fragment() {
private lateinit var binding: FragmentOnboardingBinding
private lateinit var adapter: OnboardingViewPagerAdapter
override fun onCreateView(
inflater: LayoutInflater,
@@ -30,7 +28,7 @@ class OnboardingFragment : Fragment() {
): View {
binding = FragmentOnboardingBinding.inflate(inflater)
adapter = OnboardingViewPagerAdapter(this)
val adapter = OnboardingViewPagerAdapter(this)
binding.viewPager.adapter = adapter
binding.pageIndicatorView.count = adapter.itemCount
binding.viewPager.registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() {
@@ -59,7 +57,7 @@ class OnboardingFragment : Fragment() {
}
fun goToNext() {
if (binding.viewPager.currentItem == adapter.itemCount - 1) {
if (binding.viewPager.currentItem == 2) {
findNavController().navigate(R.id.action_onboarding_to_map)
} else {
binding.viewPager.setCurrentItem(binding.viewPager.currentItem + 1, true)
@@ -67,6 +65,18 @@ class OnboardingFragment : Fragment() {
}
}
class OnboardingViewPagerAdapter(fragment: Fragment) :
FragmentStateAdapter(fragment) {
override fun getItemCount(): Int = 3
override fun createFragment(position: Int): Fragment = when (position) {
0 -> WelcomeFragment()
1 -> IconsFragment()
2 -> DataSourceSelectFragment()
else -> throw IllegalArgumentException()
}
}
abstract class OnboardingPageFragment : Fragment() {
lateinit var parent: OnboardingFragment
@@ -95,18 +105,12 @@ class WelcomeFragment : OnboardingPageFragment() {
override fun onResume() {
super.onResume()
val drawable = (binding.animationView as ImageView).drawable
if (drawable is AnimatedVectorDrawable) {
drawable.start()
}
binding.animationView.playAnimation()
}
override fun onPause() {
super.onPause()
val drawable = (binding.animationView as ImageView).drawable
if (drawable is AnimatedVectorDrawable) {
drawable.stop()
}
binding.animationView.progress = 0f
}
}

View File

@@ -1,15 +1,27 @@
package net.vonforst.evmap.fragment.preference
package net.vonforst.evmap.fragment
import android.content.SharedPreferences
import android.os.Bundle
import android.view.View
import androidx.appcompat.widget.Toolbar
import androidx.fragment.app.viewModels
import androidx.navigation.fragment.findNavController
import androidx.navigation.ui.setupWithNavController
import androidx.preference.MultiSelectListPreference
import androidx.preference.Preference
import androidx.preference.PreferenceFragmentCompat
import net.vonforst.evmap.MapsActivity
import net.vonforst.evmap.R
import net.vonforst.evmap.storage.PreferenceDataSource
import net.vonforst.evmap.ui.updateNightMode
import net.vonforst.evmap.viewmodel.SettingsViewModel
import net.vonforst.evmap.viewmodel.viewModelFactory
class ChargepriceSettingsFragment : BaseSettingsFragment() {
class SettingsFragment : PreferenceFragmentCompat(),
SharedPreferences.OnSharedPreferenceChangeListener {
private lateinit var prefs: PreferenceDataSource
private val vm: SettingsViewModel by viewModels(factoryProducer = {
viewModelFactory {
SettingsViewModel(
@@ -24,6 +36,7 @@ class ChargepriceSettingsFragment : BaseSettingsFragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
prefs = PreferenceDataSource(requireContext())
myVehiclePreference = findPreference("chargeprice_my_vehicle")!!
myVehiclePreference.isEnabled = false
@@ -44,7 +57,7 @@ class ChargepriceSettingsFragment : BaseSettingsFragment() {
res.data?.let { tariffs ->
myTariffsPreference.entryValues = tariffs.map { it.id }.toTypedArray()
myTariffsPreference.entries = tariffs.map {
if (!it.name.lowercase().startsWith(it.provider.lowercase())) {
if (!it.name.startsWith(it.provider)) {
"${it.provider} ${it.name}"
} else {
it.name
@@ -57,18 +70,13 @@ class ChargepriceSettingsFragment : BaseSettingsFragment() {
}
private fun updateMyTariffsSummary() {
myTariffsPreference.summary =
if (prefs.chargepriceMyTariffsAll) {
getString(R.string.chargeprice_all_tariffs_selected)
} else {
val n = prefs.chargepriceMyTariffs?.size ?: 0
requireContext().resources
.getQuantityString(
R.plurals.chargeprice_some_tariffs_selected,
n,
n
) + "\n" + getString(R.string.pref_my_tariffs_summary)
}
myTariffsPreference.summary = if (prefs.chargepriceMyTariffsAll) {
getString(R.string.chargeprice_all_tariffs_selected)
} else {
val n = prefs.chargepriceMyTariffs?.size ?: 0
requireContext().resources
.getQuantityString(R.plurals.chargeprice_some_tariffs_selected, n, n)
}
}
private fun updateMyVehiclesSummary() {
@@ -78,15 +86,31 @@ class ChargepriceSettingsFragment : BaseSettingsFragment() {
"${it.brand} ${it.name}"
}.joinToString(", ")
myVehiclePreference.summary = summary
// TODO: prefs.chargepriceMyVehicleDcChargeports = it.dcChargePorts
}
}
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
setPreferencesFromResource(R.xml.settings_chargeprice, rootKey)
setPreferencesFromResource(R.xml.settings, rootKey)
}
override fun onPreferenceTreeClick(preference: Preference?): Boolean {
return when (preference?.key) {
else -> super.onPreferenceTreeClick(preference)
}
}
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String) {
when (key) {
"language" -> {
activity?.let {
it.finish();
it.startActivity(it.intent);
}
}
"darkmode" -> {
updateNightMode(prefs)
}
"chargeprice_my_vehicle" -> {
updateMyVehiclesSummary()
}
@@ -95,4 +119,23 @@ class ChargepriceSettingsFragment : BaseSettingsFragment() {
}
}
}
override fun onResume() {
super.onResume()
preferenceManager.sharedPreferences.registerOnSharedPreferenceChangeListener(this)
val navController = findNavController()
val toolbar = requireView().findViewById(R.id.toolbar) as Toolbar
toolbar.setupWithNavController(
navController,
(requireActivity() as MapsActivity).appBarConfiguration
)
}
override fun onPause() {
preferenceManager.sharedPreferences
.unregisterOnSharedPreferenceChangeListener(this)
super.onPause()
}
}

View File

@@ -1,40 +0,0 @@
package net.vonforst.evmap.fragment.preference
import android.content.SharedPreferences
import android.os.Bundle
import android.view.View
import androidx.appcompat.widget.Toolbar
import androidx.navigation.fragment.findNavController
import androidx.navigation.ui.setupWithNavController
import androidx.preference.PreferenceFragmentCompat
import net.vonforst.evmap.MapsActivity
import net.vonforst.evmap.R
import net.vonforst.evmap.storage.PreferenceDataSource
abstract class BaseSettingsFragment : PreferenceFragmentCompat(),
SharedPreferences.OnSharedPreferenceChangeListener {
protected lateinit var prefs: PreferenceDataSource
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
prefs = PreferenceDataSource(requireContext())
}
override fun onResume() {
super.onResume()
preferenceManager.sharedPreferences.registerOnSharedPreferenceChangeListener(this)
val navController = findNavController()
val toolbar = requireView().findViewById(R.id.toolbar) as Toolbar
toolbar.setupWithNavController(
navController,
(requireActivity() as MapsActivity).appBarConfiguration
)
}
override fun onPause() {
preferenceManager.sharedPreferences
.unregisterOnSharedPreferenceChangeListener(this)
super.onPause()
}
}

View File

@@ -1,63 +0,0 @@
package net.vonforst.evmap.fragment.preference
import android.content.SharedPreferences
import android.os.Bundle
import android.widget.TextView
import androidx.fragment.app.viewModels
import androidx.preference.Preference
import com.google.android.material.snackbar.Snackbar
import net.vonforst.evmap.R
import net.vonforst.evmap.viewmodel.SettingsViewModel
import net.vonforst.evmap.viewmodel.viewModelFactory
class DataSettingsFragment : BaseSettingsFragment() {
private val vm: SettingsViewModel by viewModels(factoryProducer = {
viewModelFactory {
SettingsViewModel(
requireActivity().application,
getString(R.string.chargeprice_key)
)
}
})
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
setPreferencesFromResource(R.xml.settings_data, rootKey)
}
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String) {
when (key) {
"search_provider" -> {
if (prefs.searchProvider == "google") {
Snackbar.make(
requireView(),
R.string.pref_search_provider_info,
Snackbar.LENGTH_INDEFINITE
).apply {
setAction(R.string.ok) {}
this.view.findViewById<TextView>(com.google.android.material.R.id.snackbar_text)
?.apply {
maxLines = 6
}
}
.show()
}
}
}
}
override fun onPreferenceTreeClick(preference: Preference?): Boolean {
return when (preference?.key) {
"search_delete_recent" -> {
Snackbar.make(
requireView(),
R.string.deleted_recent_search_results,
Snackbar.LENGTH_LONG
)
.show()
vm.deleteRecentSearchResults()
true
}
else -> super.onPreferenceTreeClick(preference)
}
}
}

View File

@@ -1,22 +0,0 @@
package net.vonforst.evmap.fragment.preference
import android.content.SharedPreferences
import android.os.Bundle
import android.view.View
import net.vonforst.evmap.R
class SettingsFragment : BaseSettingsFragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
}
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
setPreferencesFromResource(R.xml.settings, rootKey)
}
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String) {
}
}

View File

@@ -1,26 +0,0 @@
package net.vonforst.evmap.fragment.preference
import android.content.SharedPreferences
import android.os.Bundle
import net.vonforst.evmap.R
import net.vonforst.evmap.ui.updateNightMode
class UiSettingsFragment : BaseSettingsFragment() {
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
setPreferencesFromResource(R.xml.settings_ui, rootKey)
}
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String) {
when (key) {
"language" -> {
activity?.let {
it.finish();
it.startActivity(it.intent);
}
}
"darkmode" -> {
updateNightMode(prefs)
}
}
}
}

View File

@@ -6,39 +6,28 @@ import android.view.View
import android.view.ViewGroup
import android.view.WindowManager
import androidx.appcompat.app.AppCompatDialogFragment
import androidx.navigation.fragment.findNavController
import net.vonforst.evmap.R
import net.vonforst.evmap.databinding.DialogOpensourceDonationsBinding
import net.vonforst.evmap.databinding.DialogUpdate060AndroidautoBinding
import net.vonforst.evmap.storage.PreferenceDataSource
class OpensourceDonationsDialogFramgent : AppCompatDialogFragment() {
private lateinit var binding: DialogOpensourceDonationsBinding
class Update060AndroidAutoDialogFramgent : AppCompatDialogFragment() {
private lateinit var binding: DialogUpdate060AndroidautoBinding
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
binding = DialogOpensourceDonationsBinding.inflate(inflater, container, false)
binding = DialogUpdate060AndroidautoBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val prefs = PreferenceDataSource(requireContext())
binding.btnOk.setOnClickListener {
prefs.opensourceDonationsDialogShown = true
PreferenceDataSource(requireContext()).update060AndroidAutoDialogShown = true
dismiss()
}
binding.btnDonate.setOnClickListener {
prefs.opensourceDonationsDialogShown = true
findNavController().navigate(R.id.action_opensource_donations_to_donate)
}
binding.btnGithubSponsors.setOnClickListener {
prefs.opensourceDonationsDialogShown = true
findNavController().navigate(R.id.action_opensource_donations_to_github_sponsors)
}
}
override fun onStart() {

View File

@@ -126,9 +126,6 @@ data class Cost(
val descriptionShort: String? = null,
val descriptionLong: String? = null
) : Parcelable {
val isEmpty: Boolean
get() = descriptionLong == null && descriptionShort == null && freecharging == null && freeparking == null
fun getStatusText(ctx: Context, emoji: Boolean = false): CharSequence {
if (freecharging != null && freeparking != null) {
val charging =
@@ -140,22 +137,6 @@ data class Cost(
} else {
HtmlCompat.fromHtml(ctx.getString(R.string.cost_detail, charging, parking), 0)
}
} else if (freecharging != null) {
val charging =
if (freecharging) ctx.getString(R.string.free) else ctx.getString(R.string.paid)
return if (emoji) {
"$charging"
} else {
HtmlCompat.fromHtml(ctx.getString(R.string.cost_detail_charging, charging), 0)
}
} else if (freeparking != null) {
val parking =
if (freeparking) ctx.getString(R.string.free) else ctx.getString(R.string.paid)
return if (emoji) {
"$parking"
} else {
HtmlCompat.fromHtml(ctx.getString(R.string.cost_detail_parking, parking), 0)
}
} else if (descriptionShort != null) {
return descriptionShort
} else if (descriptionLong != null) {
@@ -181,7 +162,9 @@ data class OpeningHours(
return HtmlCompat.fromHtml(ctx.getString(R.string.open_247), 0)
} else if (days != null) {
val hours = days.getHoursForDate(LocalDate.now())
?: return HtmlCompat.fromHtml(ctx.getString(R.string.closed), 0)
if (hours.start == null || hours.end == null) {
return HtmlCompat.fromHtml(ctx.getString(R.string.closed), 0)
}
val now = LocalTime.now()
if (hours.start.isBefore(now) && hours.end.isAfter(now)) {
@@ -209,21 +192,21 @@ data class OpeningHours(
@Parcelize
data class OpeningHoursDays(
@Embedded(prefix = "mo") val monday: Hours?,
@Embedded(prefix = "tu") val tuesday: Hours?,
@Embedded(prefix = "we") val wednesday: Hours?,
@Embedded(prefix = "th") val thursday: Hours?,
@Embedded(prefix = "fr") val friday: Hours?,
@Embedded(prefix = "sa") val saturday: Hours?,
@Embedded(prefix = "su") val sunday: Hours?,
@Embedded(prefix = "ho") val holiday: Hours?
@Embedded(prefix = "mo") val monday: Hours,
@Embedded(prefix = "tu") val tuesday: Hours,
@Embedded(prefix = "we") val wednesday: Hours,
@Embedded(prefix = "th") val thursday: Hours,
@Embedded(prefix = "fr") val friday: Hours,
@Embedded(prefix = "sa") val saturday: Hours,
@Embedded(prefix = "su") val sunday: Hours,
@Embedded(prefix = "ho") val holiday: Hours
) : Parcelable {
fun getHoursForDate(date: LocalDate): Hours? {
fun getHoursForDate(date: LocalDate): Hours {
// TODO: check for holidays
return getHoursForDayOfWeek(date.dayOfWeek)
}
fun getHoursForDayOfWeek(dayOfWeek: DayOfWeek?): Hours? {
fun getHoursForDayOfWeek(dayOfWeek: DayOfWeek?): Hours {
@Suppress("WHEN_ENUM_CAN_BE_NULL_IN_JAVA")
return when (dayOfWeek) {
DayOfWeek.MONDAY -> monday
@@ -240,12 +223,16 @@ data class OpeningHoursDays(
@Parcelize
data class Hours(
val start: LocalTime,
val end: LocalTime
val start: LocalTime?,
val end: LocalTime?
) : Parcelable {
override fun toString(): String {
val fmt = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT)
return "${start.format(fmt)} - ${end.format(fmt)}"
if (start != null && end != null) {
val fmt = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT)
return "${start.format(fmt)} - ${end.format(fmt)}"
} else {
return "closed"
}
}
}

View File

@@ -129,5 +129,4 @@ fun FilterValues.getMultipleChoiceValue(key: String) =
this.find { it.value.key == key }?.value as MultipleChoiceFilterValue?
const val FILTERS_DISABLED = -2L
const val FILTERS_CUSTOM = -1L
const val FILTERS_FAVORITES = -3L
const val FILTERS_CUSTOM = -1L

View File

@@ -38,9 +38,6 @@ class CustomNavigator(
}
launchCustomTab(url)
}
if (destination.destination == "github_sponsors") {
launchCustomTab(context.getString(R.string.github_sponsors_link))
}
return null // Do not add to the back stack, managed by Chrome Custom Tabs
}

View File

@@ -23,12 +23,4 @@ interface ChargeLocationsDao {
@Query("SELECT * FROM chargelocation")
fun getAllChargeLocationsBlocking(): List<ChargeLocation>
@Query("SELECT * FROM chargelocation WHERE lat >= :lat1 AND lat <= :lat2 AND lng >= :lng1 AND lng <= :lng2")
suspend fun getChargeLocationsInBoundsAsync(
lat1: Double,
lat2: Double,
lng1: Double,
lng2: Double
): List<ChargeLocation>
}

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