Compare commits

...

97 Commits
1.3.3 ... 1.3.8

Author SHA1 Message Date
johan12345
a9e9055671 Release 1.3.8 2022-08-21 20:36:39 +02:00
johan12345
53ab8dc4e8 Add singular and plural variants for prefs_my_tariffs_summary 2022-08-21 20:18:21 +02:00
johan12345
c0d7d59817 work around double call of onViewCreated in MaterialDialogFragment 2022-08-21 18:50:23 +02:00
johan12345
42cfdfee1d add permission annotations 2022-08-20 21:54:23 +02:00
johan12345
41cb6cf6b0 Implement our own new fused location provider for Android API < 31
should address #214
regression since b445be99bb
2022-08-20 21:46:28 +02:00
johan12345
64f50cc5e6 Fix typo in fragment class name 2022-08-20 20:03:19 +02:00
johan12345
c24c03bb32 Fix crash in OpensourceDonationsDialogFramgent
caused by 17bd7f024e
2022-08-20 20:02:53 +02:00
johan12345
bec25dd4d2 update CustomBottomSheetBehavior
fixes detailAppBar disappearing after recreation (as mentioned by @PulsarFX in #213)
2022-08-20 19:44:05 +02:00
johan12345
4e4c5a0e9a keep map position after recreation even if there is a current search result
fixes #213
2022-08-20 19:27:50 +02:00
johan12345
17bd7f024e use Material styling for all dialogs 2022-08-20 18:40:48 +02:00
johan12345
fc5e77b01a Rework EditTextDialog
- use MaterialAlertDialog for modern styling
- use proper way to move dialog above keyboard
- fixes #212 (buttons were not clickable)
2022-08-20 17:33:49 +02:00
johan12345
6a114fc2ea Metadata: update title to fit within 30 characters
(as already done in Play Store)
2022-08-19 22:37:29 +02:00
johan12345
6e32c6644c docs/api_keys.md: Update documentation regarding Chargeprice API pricing 2022-08-18 22:32:57 +02:00
johan12345
8fb34ae66f Add information about Weblate translation to README
#208
2022-08-18 22:19:26 +02:00
johan12345
ae3489621e fix untranslated string 2022-08-18 21:56:06 +02:00
johan12345
ff0a110f51 restructure strings for compatibility with Weblate
#208
2022-08-18 21:45:03 +02:00
johan12345
24ef4888a8 restructure strings for compatibility with Weblate
#208
2022-08-18 21:07:09 +02:00
johan12345
13916b0c8d Allow moving map when filter menu is open
workaround using reflection
fixes #155
2022-08-18 20:33:50 +02:00
johan12345
efdd0d6bc5 Don't hide status bar in gallery fullscreen view
easy workaround to fix #193
2022-08-18 19:54:44 +02:00
johan12345
155aca0041 ChargepriceApi: replace moe.banana:moshi-jsonapi with com.markomilos.jsonapi 2022-08-17 17:33:44 +02:00
johan12345
6393eadc81 increase version code
(previous Play Store release was not accepted)
2022-08-16 22:02:24 +02:00
johan12345
4319ece4f3 ChargepriceFragment: avoid reloading prices on orientation change
using SavedStateHandle
2022-08-16 21:20:03 +02:00
johan12345
62f2002e5c Reload Chargeprice data when vehicles have changed
fixes #209
2022-08-16 21:04:44 +02:00
johan12345
4a82250a3d Release 1.3.7 2022-08-15 21:37:43 +02:00
johan12345
a8f23e9fb6 Android Auto/Automotive: Refresh data after relaunching app from background
fixes #207
2022-08-15 21:25:20 +02:00
johan12345
7da64fd566 after first start, remove onboarding from back stack
fixes #202
2022-08-15 20:56:28 +02:00
johan12345
09b5d536cb keep ChargerDetails in saved state
fixes #205
2022-08-15 20:53:22 +02:00
johan12345
5e01200d96 Chargeprice: remove errorneous tint from provider logo in dark mode
fixes #206
2022-08-15 18:54:05 +02:00
johan12345
c8d2e73218 BindingAdapters.colorToTransparent(): fix when values become negative
fixes #206
2022-08-15 18:52:13 +02:00
johan12345
d7b377ea56 disable mini markers completely when filtered by HPCs 2022-08-15 18:38:18 +02:00
johan12345
edd35fba1b suppress lint again 2022-08-13 16:29:18 +02:00
johan12345
1f23080141 suppress lint 2022-08-13 16:17:29 +02:00
johan12345
a3d9ecf49e fix another lint warning 2022-08-13 15:01:39 +02:00
johan12345
6681d3cc17 Android Auto: don't exit app when canceling vehicle data permission 2022-08-13 14:59:21 +02:00
johan12345
a184b817bc Android Auto: fix alignment of +/- buttons for Chargeprice range selection 2022-08-13 14:43:21 +02:00
johan12345
b658e0183c fix lint error 2022-08-13 14:38:11 +02:00
johan12345
6a0234ac2f use proper app icon for persistent Android Auto notification
fixes #201
2022-08-13 14:15:19 +02:00
johan12345
d5ac35100b use new Activity Result API for requesting permissions 2022-08-13 13:08:55 +02:00
johan12345
d3b4cb6a90 update AnyMaps 2022-08-13 13:08:55 +02:00
johan12345
5d70d8c09a replace deprecated override in NavHostFragment 2022-08-13 13:08:55 +02:00
johan12345
9642a58206 implement new MenuProvider API
to avoid deprecated functions
2022-08-13 13:08:55 +02:00
johan12345
0e3280a119 upgrade Kotlin to 1.7.10 2022-08-13 13:08:55 +02:00
johan12345
c60043f925 disable AndroidX Jetifier
which is now not needed anymore
2022-08-13 13:08:55 +02:00
johan12345
b445be99bb remove dependency on unmaintained Lost library
use Android's own Location APIs instead
2022-08-13 13:08:55 +02:00
johan12345
02395dda7f upgrade libraries 2022-08-13 13:08:55 +02:00
johan12345
c33c69db0b update Android Gradle Plugin 2022-08-13 13:08:55 +02:00
Johan von Forstner
77fdfc7ccb Update comparison of product flavors in README 2022-08-10 18:20:45 +02:00
johan12345
bbb5c93132 Release 1.3.6 2022-08-10 17:51:33 +02:00
johan12345
2e8cdb01fd Revert upgrade of Car App library 2022-08-10 17:35:48 +02:00
johan12345
6b6c7da081 Revert "Android Auto: move search button from filter screen back to map"
This reverts commit 9f0c5caf31.
2022-08-10 17:34:42 +02:00
johan12345
720d52285d Android Auto: use locationManager.getBestProvider 2022-08-07 22:07:59 +02:00
johan12345
e7efda2e90 Android Auto: request fine location permission 2022-08-07 21:29:24 +02:00
johan12345
ed80d7b968 Replace mapbox-events-android, removing compile-time GMS dependency
patched version at https://github.com/johan12345/mapbox-events-android
2022-08-07 19:52:45 +02:00
johan12345
8b1b971fad use PASSIVE_PROVIDER to get last known location faster 2022-08-07 13:51:18 +02:00
johan12345
cf20ab8d82 use LocationListenerCompat to fix crash on Android API < 30 2022-08-07 13:31:41 +02:00
johan12345
581d0c07ec increase version code 2022-08-06 16:47:19 +02:00
johan12345
0b17821611 AA: avoid crash when place search cannot load results 2022-08-06 16:46:33 +02:00
johan12345
2493328715 update AnyMaps
fixes crash due to deprecated setRetainInstance(true)
2022-08-06 16:35:54 +02:00
johan12345
f8abeed96c let Android Studio update gradle-wrapper.properties 2022-08-06 15:34:12 +02:00
johan12345
d9ca21c31e update gradle wrapper JAR 2022-08-06 10:17:55 +02:00
johan12345
f6998382b1 Hotfix Release 1.3.5 2022-08-06 09:58:35 +02:00
johan12345
5fc343d973 workaround infinite loop in onApplyWindowInsets when using Mapbox 2022-08-06 09:56:11 +02:00
johan12345
6b0a8bb506 new 1.3.4 release 2022-08-05 22:24:07 +02:00
johan12345
93f379f4e2 fix crash due to view not found 2022-08-05 22:22:28 +02:00
johan12345
00e555594a upgrade libraries 2022-08-05 22:22:05 +02:00
johan12345
4ec5c8fb2e fix highlighting of "my tariffs" in dark mode 2022-08-05 22:05:26 +02:00
johan12345
40b7ad8ef9 Android Auto: fix crash loading availabilities 2022-08-05 22:00:27 +02:00
johan12345
e1fed1ba26 Android Auto: fix reloading availabilities 2022-08-05 21:52:06 +02:00
johan12345
d429ef88b3 Release 1.3.4 2022-08-05 19:05:54 +02:00
johan12345
9f0c5caf31 Android Auto: move search button from filter screen back to map 2022-08-05 18:47:41 +02:00
johan12345
34b51a0742 Android Auto: update image size to follow new docs 2022-08-05 18:34:45 +02:00
johan12345
a533fd315e update libraries 2022-08-05 18:32:46 +02:00
johan12345
d39d51d32c Android Auto: reduce length of slider to avoid cutoff on small screens 2022-08-05 18:12:48 +02:00
johan12345
db11170967 fix rare NPE 2022-07-28 19:53:09 +02:00
johan12345
4135740d07 rework window insets handling
may fix issues with app logo in drawer & compass button on map
2022-07-24 13:34:23 +02:00
johan12345
b67bd12784 increase Gradle heap size 2022-07-24 12:45:55 +02:00
johan12345
b0e000e936 Android Auto: clear availabilities when content refresh is requested 2022-07-23 19:59:27 +02:00
johan12345
1d8a7347c9 TextPromptScreen: add OK and Cancel buttons
fixes #190
2022-07-23 18:24:01 +02:00
johan12345
90f6cb65a8 MapScreen: fix onItemVisibilityChanged if indices are -1 2022-07-23 18:14:37 +02:00
johan12345
5c57a5318b upgrade Android Gradle Plugin 2022-07-23 16:54:30 +02:00
johan12345
9456a6e8ef remove usages of deprecated @OnLifecycleEvent annotation 2022-07-23 16:52:24 +02:00
johan12345
4846699f66 update Google Maps library to 18.1.0 2022-07-23 16:43:34 +02:00
Johan von Forstner
682f05b98b exclude GMS dependency from Mapbox 2022-07-15 12:08:07 +02:00
Johan von Forstner
1f36ef6af8 use Google Places library only in google flavor
#197
2022-07-14 10:49:06 +02:00
Johan von Forstner
032be00bcd add donation hint for users who use Chargeprice data very often 2022-07-13 12:29:12 +02:00
Johan von Forstner
3ac7b4aaee Fix filtering availability by min power
Should be >= instead of >
2022-07-10 21:11:10 +02:00
Johan von Forstner
3386024acb Chargeprice: go directly to chargeprice settings to select vehicle 2022-07-10 20:06:36 +02:00
Johan von Forstner
ad2fb3063c Chargeprice: fix average charge speed
Now calculated as energy / duration

Fixes #171
2022-07-10 20:03:44 +02:00
johan12345
caee3b1d67 update favorite data when opening favorite detail view from list 2022-07-03 00:11:30 +02:00
johan12345
60b151c690 fix markers sometimes not being highlighted even though they should be 2022-07-02 23:55:16 +02:00
johan12345
e8873fa98c fix #177: After opening favorites list using shortcut, going back to map is not possible 2022-07-02 23:50:36 +02:00
johan12345
63740a8fe5 Android Auto/Automotive: Add place search
fixes #186
2022-07-02 16:12:09 +02:00
johan12345
c80452a1fd Android Auto: move delete button to filter profile details 2022-07-02 13:55:49 +02:00
johan12345
7420101153 Android Automotive OS: add driving direction to vehicle data
fixes #188
2022-07-02 13:47:45 +02:00
johan12345
080d3d1080 add simple test for car app 2022-06-29 20:40:17 +02:00
johan12345
d5ea8cfffa increase version code 2022-06-29 20:08:56 +02:00
johan12345
0676dcf31b Android Auto: fix requesting location permissions 2022-06-29 20:03:21 +02:00
99 changed files with 2579 additions and 1156 deletions

View File

@@ -20,7 +20,7 @@ Features
- Advanced filtering options, including saved filter profiles
- Favorites list, also with availability information
- Integrated price comparison using [Chargeprice.app](https://chargeprice.app) (only in Europe)
- Android Auto integration
- Android Auto & Android Automotive OS integration
- No ads, fully open source
- Compatible with Android 5.0 and above
- Can use Google Maps or Mapbox (OpenStreetMap) as map backends - the version available on F-Droid only uses Mapbox.
@@ -41,9 +41,24 @@ EVMap uses and put them into the app in the form of a resource file called `apik
`app/src/main/res/values`. You can find more information on which API keys are necessary for which
features and how they can be obtained in our [documentation page](doc/api_keys.md).
There are two different build flavors, `google` and `foss`, where only the `google` variant uses
Google Maps data and provides the Android Auto integration. The `foss` variant only uses Mapbox data
and should run on devices without Google Play Services.
There are three different build flavors, `googleNormal`, `fossNormal` and `googleAutomotive`.
- The `foss` variant only uses Mapbox data and should run on most Android devices, even without
Google Play Services.
- The `google` variants also include access to Google Maps data.
- `googleNormal` is intended to run on smartphones and tablets, and also includes the Android
Auto app for use on the car display.
- `googleAutomotive` variant is intended to be installed directly on car infotainment systems
using the Google-flavored Android Automotive OS. It does not provide the usual smartphone UI.
We also have a special [documentation page](doc/android_auto.md) on how to test the Android Auto
app.
Translations
------------
You can use our [Weblate page](https://hosted.weblate.org/projects/evmap/) to help translate EVMap
into new languages.
<a href="https://hosted.weblate.org/engage/evmap/">
<img src="https://hosted.weblate.org/widgets/evmap/-/open-graph.png" width="500" alt="Translation status" />
</a>

View File

@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?><!-- Generator: Adobe Illustrator 24.3.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Ebene_1" xmlns="http://www.w3.org/2000/svg" x="0px" y="0px"
viewBox="0 0 120 120" style="enable-background:new 0 0 120 120;" xml:space="preserve">
<style type="text/css">
.st0{fill:#FFFFFF;}
</style>
<g>
<g>
<path class="st0"
d="M27.1,88.3l-2.2-19.2l-3.3,0.3l2.2,19.2L27.1,88.3z M39,86.9l-2.2-19.2l-3.3,0.3l2.2,19.2L39,86.9z" />
<path class="st0" d="M45.2,113c-1,1.3-1.8,2.1-2,2.2c-3,2.4-5.4,3.1-7.4,2.2c-3.5-1.7-3.2-8.2-3.1-8.9l2.4,0.1
c-0.1,1.8,0.2,5.8,1.8,6.6c0.9,0.5,2.5-0.1,4.6-1.8l0,0c0,0,6.7-6.7,5.3-12c-1.6-6.4,5.8-15.5,8.2-18.6l0.3-0.3l2,1.5l-0.3,0.5
c-7.5,9.2-8.3,14-7.7,16.4C50.5,105.4,47.4,110.4,45.2,113z" />
<path class="st0" d="M19.7,88.1l0.9,7.9l7.3,4.9l9.8-1l6-6.4l-0.9-7.9L19.7,88.1z" />
<g>
<path class="st0"
d="M37.6,99.7l-9.8,1l2.1,8.7l7.7-0.9V99.7L37.6,99.7z M44.6,79l0.8,7.2l-28.2,3.2l-0.8-7.2L44.6,79z" />
</g>
</g>
<path class="st0" d="M66.7,0C46.5,0,30.1,16.4,30.1,36.6c0,27.6,30.8,42,34.5,81.4c0.1,1.2,1,2,2.2,2c1.2,0,2.1-0.8,2.2-2
c3.7-39.4,34.5-53.8,34.5-81.4C103.3,16.2,86.9,0,66.7,0z M78.4,34.7L64.3,59V40.8h-6V18.7c0,0,20.2,0,20.1-0.1l-8.1,16.2H78.4z" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -8,9 +8,10 @@ apply plugin: 'kotlin-parcelize'
apply plugin: 'kotlin-kapt'
apply plugin: 'androidx.navigation.safeargs.kotlin'
apply plugin: 'com.mikepenz.aboutlibraries.plugin'
apply plugin: 'de.timfreiheit.resourceplaceholders'
android {
compileSdkVersion 31
compileSdkVersion 32
buildToolsVersion "30.0.3"
defaultConfig {
@@ -18,8 +19,8 @@ android {
minSdkVersion 21
targetSdkVersion 31
// NOTE: always increase versionCode by 2 since automotive flavor uses versionCode + 1
versionCode 82
versionName "1.3.3"
versionCode 102
versionName "1.3.8"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
@@ -94,6 +95,14 @@ android {
disable 'NullSafeMutableLiveData'
}
testOptions {
unitTests.includeAndroidResources true
}
resourcePlaceholders {
files = ['xml/shortcuts.xml']
}
// add API keys from environment variable if not set in apikeys.xml
applicationVariants.all { variant ->
ext.env = System.getenv()
@@ -139,9 +148,9 @@ dependencies {
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
implementation 'androidx.appcompat:appcompat:1.4.2'
implementation 'androidx.core:core-ktx:1.8.0'
implementation 'androidx.core:core-splashscreen:1.0.0-rc01'
implementation "androidx.activity:activity-ktx:1.4.0"
implementation "androidx.fragment:fragment-ktx:1.4.1"
implementation 'androidx.core:core-splashscreen:1.0.0'
implementation "androidx.activity:activity-ktx:1.5.1"
implementation "androidx.fragment:fragment-ktx:1.5.1"
implementation 'androidx.cardview:cardview:1.0.0'
implementation 'androidx.preference:preference-ktx:1.2.0'
implementation 'com.google.android.material:material:1.6.1'
@@ -149,44 +158,48 @@ dependencies {
implementation 'androidx.recyclerview:recyclerview:1.2.1'
implementation 'androidx.browser:browser:1.4.0'
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
implementation 'com.github.johan12345:CustomBottomSheetBehavior:f69f532660'
implementation 'com.github.johan12345:CustomBottomSheetBehavior:dd0167dbff'
implementation 'com.squareup.retrofit2:retrofit:2.9.0'
implementation 'com.squareup.retrofit2:converter-moshi:2.9.0'
implementation 'com.squareup.okhttp3:okhttp:4.9.0'
implementation 'com.squareup.okhttp3:okhttp-urlconnection:4.9.0'
implementation 'com.squareup.moshi:moshi-kotlin:1.13.0'
implementation 'com.squareup.moshi:moshi-adapters:1.13.0'
implementation 'moe.banana:moshi-jsonapi:3.5.0'
implementation 'moe.banana:moshi-jsonapi-retrofit-converter:3.5.0'
implementation 'com.markomilos.jsonapi:jsonapi-adapters:1.0.1'
implementation 'com.markomilos.jsonapi:jsonapi-retrofit:1.0.1'
implementation 'io.coil-kt:coil:1.1.0'
implementation 'com.github.johan12345:StfalconImageViewer:5082ebd392'
implementation "com.mikepenz:aboutlibraries-core:$about_libs_version"
implementation "com.mikepenz:aboutlibraries:$about_libs_version"
implementation 'com.airbnb.android:lottie:4.1.0'
implementation 'io.michaelrocks.bimap:bimap:1.1.0'
implementation 'com.mapzen.android:lost:3.0.2'
implementation 'com.google.guava:guava:29.0-android'
implementation 'com.github.pengrad:mapscaleview:1.6.0'
implementation 'com.github.romandanylyk:PageIndicatorView:b1bad589b5'
// Android Auto
googleImplementation 'androidx.car.app:app:1.2.0-rc01'
googleNormalImplementation 'androidx.car.app:app-projected:1.2.0-rc01'
googleAutomotiveImplementation 'androidx.car.app:app-automotive:1.2.0-rc01'
def carAppVersion = '1.2.0-rc01'
googleImplementation "androidx.car.app:app:$carAppVersion"
googleNormalImplementation "androidx.car.app:app-projected:$carAppVersion"
googleAutomotiveImplementation "androidx.car.app:app-automotive:$carAppVersion"
// AnyMaps
def anyMapsVersion = '3c67d7a1dc'
def anyMapsVersion = 'f36bb3c126'
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.2'
googleImplementation 'com.google.android.gms:play-services-maps:18.1.0'
implementation("com.github.johan12345.AnyMaps:anymaps-mapbox:$anyMapsVersion") {
exclude group: 'com.mapbox.mapboxsdk', module: 'mapbox-android-accounts'
exclude group: 'com.mapbox.mapboxsdk', module: 'mapbox-android-telemetry'
exclude group: 'com.google.android.gms', module: 'play-services-location'
exclude group: 'com.mapbox.mapboxsdk', module: 'mapbox-android-core'
}
// patched version of mapbox-android-core that removes build-time dependency on GMS
implementation 'com.github.johan12345:mapbox-events-android:a21c324501'
// Google Places
implementation 'com.google.android.libraries.places:places:2.6.0'
googleImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.4.1'
googleImplementation 'com.google.android.libraries.places:places:2.6.0'
googleImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.6.1'
// Mapbox Geocoding
implementation 'com.mapbox.mapboxsdk:mapbox-sdk-services:5.5.0'
@@ -196,12 +209,12 @@ dependencies {
implementation "androidx.navigation:navigation-ui-ktx:$nav_version"
// viewmodel library
def lifecycle_version = "2.4.1"
def lifecycle_version = "2.5.1"
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version"
implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycle_version"
// room library
def room_version = "2.4.2"
def room_version = "2.4.3"
implementation "androidx.room:room-runtime:$room_version"
kapt "androidx.room:room-compiler:$room_version"
implementation "androidx.room:room-ktx:$room_version"
@@ -218,12 +231,20 @@ dependencies {
implementation("ch.acra:acra-limiter:$acraVersion")
// debug tools
implementation 'com.facebook.stetho:stetho:1.5.1'
implementation 'com.facebook.stetho:stetho-okhttp3:1.5.1'
implementation 'com.facebook.stetho:stetho:1.6.0'
implementation 'com.facebook.stetho:stetho-okhttp3:1.6.0'
// testing
testImplementation 'junit:junit:4.13.2'
testImplementation "com.squareup.okhttp3:mockwebserver:4.9.0"
//noinspection GradleDependency
testImplementation 'org.json:json:20080701'
// testing for car app
testGoogleImplementation "androidx.car.app:app-testing:$carAppVersion"
testGoogleImplementation 'org.robolectric:robolectric:4.8.1'
testGoogleImplementation 'androidx.test:core:1.4.0'
androidTestImplementation 'androidx.test.ext:junit:1.1.3'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="chargeprice_api_url">https://staging-api.chargeprice.app/v1/</string>
<string name="chargeprice_key">20c0d68918c9dc96c564784b711a6570</string>
</resources>

View File

@@ -1,14 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string-array name="pref_map_provider_names">
<item>OpenStreetMap (Mapbox)</item>
</string-array>
<string-array name="pref_search_provider_names">
<item>OpenStreetMap (Mapbox)</item>
</string-array>
<string-array name="pref_search_provider_values" tranlatable="false">
<item>mapbox</item>
</string-array>
<string name="donations_info" formatted="false">Findest du EVMap nützlich? Unterstütze die Weiterentwicklung der App mit einer Spende an den Entwickler.</string>
<string name="donate_paypal">Mit PayPal spenden</string>
<string name="data_sources_hint">Die Kartendaten für die App stammen von OpenStreetMap (Mapbox).</string>

View File

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string-array name="pref_map_provider_names">
<item>@string/pref_map_provider_osm_mapbox</item>
</string-array>
<string-array name="pref_map_provider_values" translatable="false">
<item>mapbox</item>
</string-array>
<string-array name="pref_search_provider_names">
<item>@string/pref_search_provider_osm_mapbox</item>
</string-array>
<string-array name="pref_search_provider_values" translatable="false">
<item>mapbox</item>
</string-array>
</resources>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="pref_search_provider_default" translatable="false">mapbox</string>
<string name="pref_map_provider_default" translatable="false">mapbox</string>
<string name="paypal_link" translatable="false">https://paypal.me/johan98</string>
</resources>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="donations_info" formatted="false">Do you find EVMap useful? Support its development by sending a donation to the developer.</string>
<string name="donate_paypal">Donate with PayPal</string>
<string name="data_sources_hint">Map data in the app is provided by OpenStreetMap (Mapbox).</string>
</resources>

View File

@@ -1,21 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string-array name="pref_map_provider_names">
<item>OpenStreetMap (Mapbox)</item>
</string-array>
<string-array name="pref_map_provider_values" tranlatable="false">
<item>mapbox</item>
</string-array>
<string-array name="pref_search_provider_names">
<item>OpenStreetMap (Mapbox)</item>
</string-array>
<string-array name="pref_search_provider_values" tranlatable="false">
<item>mapbox</item>
</string-array>
<string name="pref_search_provider_default" translatable="false">mapbox</string>
<string name="pref_map_provider_default" translatable="false">mapbox</string>
<string name="donations_info" formatted="false">Do you find EVMap useful? Support its development by sending a donation to the developer.</string>
<string name="donate_paypal">Donate with PayPal</string>
<string name="paypal_link" translatable="false">https://paypal.me/johan98</string>
<string name="data_sources_hint">Map data in the app is provided by OpenStreetMap (Mapbox).</string>
</resources>

View File

@@ -5,16 +5,15 @@ import android.annotation.SuppressLint
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
import android.content.Intent
import android.content.pm.ApplicationInfo
import android.location.Location
import android.location.LocationManager
import android.os.Build
import android.util.Log
import androidx.annotation.RequiresPermission
import androidx.car.app.CarContext
import androidx.car.app.Screen
import androidx.car.app.ScreenManager
import androidx.car.app.Session
import androidx.car.app.annotations.ExperimentalCarApi
import androidx.car.app.hardware.CarHardwareManager
@@ -25,10 +24,14 @@ import androidx.car.app.validation.HostValidator
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import androidx.core.location.LocationListenerCompat
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import net.vonforst.evmap.R
import net.vonforst.evmap.utils.checkAnyLocationPermission
import net.vonforst.evmap.location.FusionEngine
import net.vonforst.evmap.location.LocationEngine
import net.vonforst.evmap.location.Priority
import net.vonforst.evmap.utils.checkFineLocationPermission
interface LocationAwareScreen {
@@ -68,7 +71,8 @@ class CarAppService : androidx.car.app.CarAppService() {
.setContentTitle(getString(R.string.app_name))
.setOngoing(true)
.setPriority(NotificationManagerCompat.IMPORTANCE_HIGH)
.setSmallIcon(R.mipmap.ic_launcher)
.setSmallIcon(R.drawable.ic_appicon_notification)
.setColor(ContextCompat.getColor(this, R.color.colorPrimary))
.setTicker(getString(R.string.auto_location_service))
.setWhen(System.currentTimeMillis())
@@ -99,8 +103,8 @@ class EVMapSession(val cas: CarAppService) : Session(), DefaultLifecycleObserver
location?.let { value?.updateLocation(it) }
}
private var location: Location? = null
private val locationManager: LocationManager by lazy {
carContext.getSystemService(Context.LOCATION_SERVICE) as LocationManager
private val locationEngine: LocationEngine by lazy {
FusionEngine(carContext)
}
private val hardwareMan: CarHardwareManager by lazy {
@@ -112,10 +116,25 @@ class EVMapSession(val cas: CarAppService) : Session(), DefaultLifecycleObserver
}
override fun onCreateScreen(intent: Intent): Screen {
return MapScreen(carContext, this)
val mapScreen = MapScreen(carContext, this)
if (!locationPermissionGranted()) {
val screenManager = carContext.getCarService(ScreenManager::class.java)
screenManager.push(mapScreen)
return PermissionScreen(
carContext,
R.string.auto_location_permission_needed,
listOf(
Manifest.permission.ACCESS_FINE_LOCATION,
Manifest.permission.ACCESS_COARSE_LOCATION
)
)
}
return mapScreen
}
fun locationPermissionGranted() = carContext.checkAnyLocationPermission()
private fun locationPermissionGranted() = carContext.checkFineLocationPermission()
private fun updateLocation(location: Location?) {
Log.d(TAG, "Received location: $location")
@@ -142,6 +161,7 @@ class EVMapSession(val cas: CarAppService) : Session(), DefaultLifecycleObserver
requestPhoneLocationUpdates()
}
@RequiresPermission(anyOf = [Manifest.permission.ACCESS_FINE_LOCATION])
private fun requestCarHardwareLocationUpdates() {
if (supportsCarApiLevel3(carContext)) {
val exec = ContextCompat.getMainExecutor(carContext)
@@ -153,15 +173,18 @@ class EVMapSession(val cas: CarAppService) : Session(), DefaultLifecycleObserver
}
}
private val phoneLocationListener = LocationListenerCompat {
this.updateLocation(it)
}
@RequiresPermission(anyOf = [Manifest.permission.ACCESS_COARSE_LOCATION, Manifest.permission.ACCESS_FINE_LOCATION])
private fun requestPhoneLocationUpdates() {
val location = locationManager.getLastKnownLocation(LocationManager.GPS_PROVIDER)
val location = locationEngine.getLastKnownLocation()
updateLocation(location)
locationManager.requestLocationUpdates(
LocationManager.GPS_PROVIDER,
locationEngine.requestLocationUpdates(
Priority.HIGH_ACCURACY,
1000,
1f,
this::updateLocation
phoneLocationListener
)
}
@@ -181,7 +204,7 @@ class EVMapSession(val cas: CarAppService) : Session(), DefaultLifecycleObserver
@RequiresPermission(anyOf = [Manifest.permission.ACCESS_COARSE_LOCATION, Manifest.permission.ACCESS_FINE_LOCATION])
private fun removePhoneLocationUpdates() {
locationManager.removeUpdates(this::updateLocation)
locationEngine.removeUpdates(phoneLocationListener)
}
@SuppressLint("MissingPermission")

View File

@@ -0,0 +1,140 @@
package net.vonforst.evmap.auto
import android.content.Context
import android.hardware.Sensor
import android.hardware.SensorEvent
import android.hardware.SensorEventListener
import android.hardware.SensorManager
import androidx.car.app.CarContext
import androidx.car.app.hardware.CarHardwareManager
import androidx.car.app.hardware.common.CarValue
import androidx.car.app.hardware.common.OnCarDataAvailableListener
import androidx.car.app.hardware.info.*
import androidx.car.app.hardware.info.CarSensors.UpdateRate
import net.vonforst.evmap.BuildConfig
import java.util.concurrent.Executor
/**
* CarSensors is not yet implemented for Android Automotive OS
* (see docs at https://developer.android.com/reference/androidx/car/app/hardware/info/CarSensors)
* so we provide our own implementation based on SensorManager APIs.
*/
val CarContext.patchedCarSensors: CarSensors
get() = if (BuildConfig.FLAVOR_automotive != "automotive") {
(this.getCarService(CarContext.HARDWARE_SERVICE) as CarHardwareManager).carSensors
} else {
CarSensorsWrapper(this)
}
class CarSensorsWrapper(carContext: CarContext) :
CarSensors {
private val sensorManager = carContext.getSystemService(Context.SENSOR_SERVICE) as SensorManager
private val compassListeners: MutableMap<OnCarDataAvailableListener<Compass>, SensorEventListener> =
mutableMapOf()
override fun addAccelerometerListener(
rate: Int,
executor: Executor,
listener: OnCarDataAvailableListener<Accelerometer>
) {
TODO("Not yet implemented")
}
override fun removeAccelerometerListener(listener: OnCarDataAvailableListener<Accelerometer>) {
TODO("Not yet implemented")
}
override fun addGyroscopeListener(
rate: Int,
executor: Executor,
listener: OnCarDataAvailableListener<Gyroscope>
) {
TODO("Not yet implemented")
}
override fun removeGyroscopeListener(listener: OnCarDataAvailableListener<Gyroscope>) {
TODO("Not yet implemented")
}
override fun addCompassListener(
rate: Int,
executor: Executor,
listener: OnCarDataAvailableListener<Compass>
) {
val magSensor = sensorManager.getDefaultSensor(Sensor.TYPE_MAGNETIC_FIELD)
val accSensor = sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER)
if (magSensor == null) {
executor.execute {
listener.onCarDataAvailable(Compass(CarValue(null, 0, CarValue.STATUS_UNAVAILABLE)))
}
return
}
val sensorListener = object : SensorEventListener {
var magValues: FloatArray? = null
// AAOS cars may not provide an acceleration sensor, so we assume acceleration based on
// Earth's gravity. May not be correct when driving on other planets.
var accValues = floatArrayOf(0f, 0f, SensorManager.GRAVITY_EARTH)
val rotMatrix = FloatArray(9)
val orientation = FloatArray(3)
override fun onSensorChanged(event: SensorEvent) {
when (event.sensor) {
magSensor -> magValues = event.values
accSensor -> accValues = event.values
}
if (magValues == null) return
SensorManager.getRotationMatrix(rotMatrix, null, accValues, magValues)
SensorManager.getOrientation(rotMatrix, orientation)
val compassDegrees = orientation.map { Math.toDegrees(it.toDouble()).toFloat() }
executor.execute {
listener.onCarDataAvailable(
Compass(
CarValue(
compassDegrees,
event.timestamp,
CarValue.STATUS_SUCCESS
)
)
)
}
}
override fun onAccuracyChanged(sensor: Sensor, accuracy: Int) {
}
}
compassListeners[listener] = sensorListener
sensorManager.registerListener(sensorListener, magSensor, mapRate(rate))
accSensor?.let { sensorManager.registerListener(sensorListener, it, mapRate(rate)) }
}
private fun mapRate(@UpdateRate rate: Int): Int {
return when (rate) {
CarSensors.UPDATE_RATE_NORMAL -> SensorManager.SENSOR_DELAY_NORMAL
CarSensors.UPDATE_RATE_UI -> SensorManager.SENSOR_DELAY_UI
CarSensors.UPDATE_RATE_FASTEST -> SensorManager.SENSOR_DELAY_FASTEST
else -> throw IllegalArgumentException()
}
}
override fun removeCompassListener(listener: OnCarDataAvailableListener<Compass>) {
compassListeners[listener]?.let {
sensorManager.unregisterListener(it)
}
}
override fun addCarHardwareLocationListener(
rate: Int,
executor: Executor,
listener: OnCarDataAvailableListener<CarHardwareLocation>
) {
TODO("Not yet implemented")
}
override fun removeCarHardwareLocationListener(listener: OnCarDataAvailableListener<CarHardwareLocation>) {
TODO("Not yet implemented")
}
}

View File

@@ -15,13 +15,13 @@ import androidx.car.app.model.*
import androidx.core.content.ContextCompat
import androidx.core.graphics.drawable.IconCompat
import androidx.lifecycle.lifecycleScope
import jsonapi.Meta
import jsonapi.Relationship
import jsonapi.Relationships
import jsonapi.ResourceIdentifier
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import moe.banana.jsonapi2.HasMany
import moe.banana.jsonapi2.HasOne
import moe.banana.jsonapi2.JsonBuffer
import moe.banana.jsonapi2.ResourceIdentifier
import net.vonforst.evmap.R
import net.vonforst.evmap.api.chargeprice.*
import net.vonforst.evmap.model.ChargeLocation
@@ -34,7 +34,10 @@ class ChargepriceScreen(ctx: CarContext, val charger: ChargeLocation) : Screen(c
private val prefs = PreferenceDataSource(ctx)
private val db = AppDatabase.getInstance(carContext)
private val api by lazy {
ChargepriceApi.create(carContext.getString(R.string.chargeprice_key))
ChargepriceApi.create(
carContext.getString(R.string.chargeprice_key),
carContext.getString(R.string.chargeprice_api_url)
)
}
private var prices: List<ChargePrice>? = null
private var meta: ChargepriceChargepointMeta? = null
@@ -94,7 +97,7 @@ class ChargepriceScreen(ctx: CarContext, val charger: ChargeLocation) : Screen(c
)
.build().intent
intent.data =
Uri.parse("https://www.chargeprice.app/?poi_id=${charger.id}&poi_source=${getDataAdapter()}")
Uri.parse(ChargepriceApi.getPoiUrl(charger))
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
try {
carContext.startActivity(intent)
@@ -169,39 +172,44 @@ class ChargepriceScreen(ctx: CarContext, val charger: ChargeLocation) : Screen(c
}
private fun loadPrices(model: Model?) {
val dataAdapter = getDataAdapter() ?: return
val dataAdapter = ChargepriceApi.getDataAdapter(charger) ?: return
val manufacturer = model?.manufacturer?.value
val modelName = getVehicleModel(model?.manufacturer?.value, model?.name?.value)
lifecycleScope.launch {
try {
val car = determineVehicle(manufacturer, modelName)
val cpStation = ChargepriceStation.fromEvmap(charger, car.compatibleEvmapConnectors)
val result = api.getChargePrices(ChargepriceRequest().apply {
this.dataAdapter = dataAdapter
station = cpStation
vehicle = HasOne(car)
tariffs = if (!prefs.chargepriceMyTariffsAll) {
val myTariffs = prefs.chargepriceMyTariffs ?: emptySet()
HasMany<ChargepriceTariff>(*myTariffs.map {
ResourceIdentifier(
"tariff",
it
val result = api.getChargePrices(
ChargepriceRequest(
dataAdapter = dataAdapter,
station = cpStation,
vehicle = car,
options = ChargepriceOptions(
batteryRange = batteryRange.map { it.toDouble() },
providerCustomerTariffs = prefs.chargepriceShowProviderCustomerTariffs,
maxMonthlyFees = if (prefs.chargepriceNoBaseFee) 0.0 else null,
currency = prefs.chargepriceCurrency,
allowUnbalancedLoad = prefs.chargepriceAllowUnbalancedLoad
),
relationships = if (!prefs.chargepriceMyTariffsAll) {
val myTariffs = prefs.chargepriceMyTariffs ?: emptySet()
Relationships(
"tariffs" to Relationship.ToMany(
myTariffs.map {
ResourceIdentifier(
"tariff",
id = it
)
},
meta = Meta.from(
ChargepriceRequestTariffMeta(ChargepriceInclude.ALWAYS),
ChargepriceApi.moshi
)
)
)
}.toTypedArray()).apply {
meta = JsonBuffer.create(
ChargepriceApi.moshi.adapter(ChargepriceRequestTariffMeta::class.java),
ChargepriceRequestTariffMeta(ChargepriceInclude.ALWAYS)
)
}
} else null
options = ChargepriceOptions(
batteryRange = batteryRange.map { it.toDouble() },
providerCustomerTariffs = prefs.chargepriceShowProviderCustomerTariffs,
maxMonthlyFees = if (prefs.chargepriceNoBaseFee) 0.0 else null,
currency = prefs.chargepriceCurrency,
allowUnbalancedLoad = prefs.chargepriceAllowUnbalancedLoad
)
}, ChargepriceApi.getChargepriceLanguage())
} else null
), ChargepriceApi.getChargepriceLanguage()
)
val myTariffs = prefs.chargepriceMyTariffs
@@ -215,14 +223,16 @@ class ChargepriceScreen(ctx: CarContext, val charger: ChargeLocation) : Screen(c
invalidate()
return@launch
}
meta =
(result.meta.get<ChargepriceMeta>(ChargepriceApi.moshi.adapter(ChargepriceMeta::class.java)) as ChargepriceMeta).chargePoints.filterIndexed { i, cp ->
charger.chargepointsMerged[i].type in car.compatibleEvmapConnectors
}.maxByOrNull {
it.power
}
prices = result.map { cp ->
val metaMapped =
result.meta!!.map(ChargepriceMeta::class.java, ChargepriceApi.moshi)!!
meta = metaMapped.chargePoints.filterIndexed { i, cp ->
charger.chargepointsMerged[i].type in car.compatibleEvmapConnectors
}.maxByOrNull {
it.power
}
prices = result.data!!.map { cp ->
val filteredPrices =
cp.chargepointPrices.filter {
it.plug == chargepoint.plug && it.power == chargepoint.power
@@ -230,15 +240,15 @@ class ChargepriceScreen(ctx: CarContext, val charger: ChargeLocation) : Screen(c
if (filteredPrices.isEmpty()) {
null
} else {
cp.clone().apply {
cp.copy(
chargepointPrices = filteredPrices
}
)
}
}.filterNotNull()
.sortedBy { it.chargepointPrices.first().price }
.sortedByDescending {
prefs.chargepriceMyTariffsAll ||
myTariffs != null && it.tariff?.get()?.id in myTariffs
myTariffs != null && it.tariff?.id in myTariffs
}
invalidate()
} catch (e: IOException) {
@@ -316,10 +326,4 @@ class ChargepriceScreen(ctx: CarContext, val charger: ChargeLocation) : Screen(c
}
return vehicles[0]
}
private fun getDataAdapter(): String? = when (charger.dataSource) {
"goingelectric" -> ChargepriceApi.DATA_SOURCE_GOINGELECTRIC
"openchargemap" -> ChargepriceApi.DATA_SOURCE_OPENCHARGEMAP
else -> null
}
}

View File

@@ -56,8 +56,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 imageHeightLarge = 480 // images should be 480 x 854 dp according to docs
private val imageWidthLarge = 854
private val imageSizeLarge = 480 // images should be 480 x 480 dp according to docs
private val iconGen =
ChargerIconGenerator(carContext, null, height = imageSize)
@@ -369,10 +368,7 @@ class ChargerDetailScreen(ctx: CarContext, val chargerSparse: ChargeLocation) :
photo?.let {
val density = carContext.resources.displayMetrics.density
val url = if (largeImageSupported) {
photo.getUrl(
width = (imageWidthLarge * density).roundToInt(),
height = (imageHeightLarge * density).roundToInt()
)
photo.getUrl(size = (imageSizeLarge * density).roundToInt())
} else {
photo.getUrl(size = (imageSize * density).roundToInt())
}

View File

@@ -21,7 +21,7 @@ import net.vonforst.evmap.viewmodel.FilterViewModel
import kotlin.math.roundToInt
@androidx.car.app.annotations.ExperimentalCarApi
class FilterScreen(ctx: CarContext) : Screen(ctx) {
class FilterScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx) {
private val prefs = PreferenceDataSource(ctx)
private val db = AppDatabase.getInstance(ctx)
val filterProfiles: LiveData<List<FilterProfile>> by lazy {
@@ -47,6 +47,30 @@ class FilterScreen(ctx: CarContext) : Screen(ctx) {
setHeaderAction(Action.BACK)
setActionStrip(
ActionStrip.Builder().apply {
addAction(Action.Builder().apply {
setIcon(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
if (prefs.placeSearchResultAndroidAuto != null) {
R.drawable.ic_search_off
} else {
R.drawable.ic_search
}
)
).build()
)
setOnClickListener(ParkedOnlyOnClickListener.create {
if (prefs.placeSearchResultAndroidAuto != null) {
prefs.placeSearchResultAndroidAutoName = null
prefs.placeSearchResultAndroidAuto = null
screenManager.pop()
} else {
screenManager.push(PlaceSearchScreen(carContext, session))
}
})
}.build())
addAction(Action.Builder().apply {
setIcon(
CarIcon.Builder(
@@ -65,42 +89,6 @@ class FilterScreen(ctx: CarContext) : Screen(ctx) {
}
})
}.build())
if (filterStatus !in listOf(
FILTERS_CUSTOM,
FILTERS_FAVORITES,
FILTERS_DISABLED
)
) {
addAction(Action.Builder().apply {
setIcon(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
R.drawable.ic_delete
)
).build()
)
setOnClickListener {
val currentProfile =
filterProfiles.value?.find { it.id == filterStatus }
?: return@setOnClickListener
lifecycleScope.launch {
db.filterProfileDao().delete(currentProfile)
prefs.filterStatus = FILTERS_DISABLED
CarToast.makeText(
carContext,
carContext.getString(
R.string.deleted_filterprofile,
currentProfile.name
),
CarToast.LENGTH_SHORT
).show()
invalidate()
}
}
}.build())
}
}.build()
)
}.build()
@@ -190,33 +178,46 @@ class EditFiltersScreen(ctx: CarContext) : Screen(ctx) {
setHeaderAction(Action.BACK)
setActionStrip(ActionStrip.Builder().apply {
addAction(Action.Builder()
.setIcon(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
R.drawable.ic_check
)
).build()
)
.setOnClickListener {
lifecycleScope.launch {
vm.saveFilterValues()
screenManager.popTo(MapScreen.MARKER)
val currentProfile = vm.filterProfile.value
if (currentProfile != null) {
addAction(Action.Builder().apply {
setIcon(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
R.drawable.ic_delete
)
).build()
)
setOnClickListener {
lifecycleScope.launch {
vm.deleteCurrentProfile()
CarToast.makeText(
carContext,
carContext.getString(
R.string.deleted_filterprofile,
currentProfile.name
),
CarToast.LENGTH_SHORT
).show()
invalidate()
screenManager.pop()
}
}
}
.build()
)
addAction(Action.Builder()
.setIcon(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
R.drawable.ic_save
)
).build()
)
.setOnClickListener {
}.build())
}
addAction(
Action.Builder()
.setIcon(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
R.drawable.ic_save
)
).build()
)
.setOnClickListener {
val textPromptScreen = TextPromptScreen(
carContext,
R.string.save_as_profile,
@@ -248,18 +249,21 @@ class EditFiltersScreen(ctx: CarContext) : Screen(ctx) {
is BooleanFilter -> {
setToggle(Toggle.Builder {
(value as BooleanFilterValue).value = it
lifecycleScope.launch { vm.saveFilterValues() }
}.setChecked((value as BooleanFilterValue).value).build())
}
is MultipleChoiceFilter -> {
setBrowsable(true)
setOnClickListener {
screenManager.push(
screenManager.pushForResult(
MultipleChoiceFilterScreen(
carContext,
filter,
value as MultipleChoiceFilterValue
)
)
) {
lifecycleScope.launch { vm.saveFilterValues() }
}
}
addText(
if ((value as MultipleChoiceFilterValue).all) {
@@ -276,13 +280,15 @@ class EditFiltersScreen(ctx: CarContext) : Screen(ctx) {
setBrowsable(true)
addText((value as SliderFilterValue).value.toString() + " " + filter.unit)
setOnClickListener {
screenManager.push(
screenManager.pushForResult(
SliderFilterScreen(
carContext,
filter,
value
)
)
) {
lifecycleScope.launch { vm.saveFilterValues() }
}
}
}
}
@@ -379,7 +385,7 @@ class SliderFilterScreen(
private fun generateSlider(): CharSequence {
val bar = ""
val dot = ""
val length = 35
val length = 30
val position =
((filter.inverseMapping(value.value) - filter.min) / (filter.max - filter.min).toDouble() * length).roundToInt()

View File

@@ -1,10 +1,7 @@
package net.vonforst.evmap.auto
import android.Manifest
import android.content.pm.PackageManager
import android.location.Location
import android.os.Handler
import android.os.Looper
import android.text.SpannableStringBuilder
import android.text.Spanned
import androidx.car.app.CarContext
@@ -40,6 +37,7 @@ import java.time.Duration
import java.time.Instant
import java.time.ZonedDateTime
import kotlin.collections.set
import kotlin.math.min
import kotlin.math.roundToInt
/**
@@ -48,7 +46,7 @@ import kotlin.math.roundToInt
@androidx.car.app.annotations.ExperimentalCarApi
class MapScreen(ctx: CarContext, val session: EVMapSession) :
Screen(ctx), LocationAwareScreen, OnContentRefreshListener,
ItemList.OnItemVisibilityChangedListener {
ItemList.OnItemVisibilityChangedListener, DefaultLifecycleObserver {
companion object {
val MARKER = "map"
}
@@ -85,7 +83,9 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) :
Transformations.map(referenceData) { api.getFilters(it, carContext.stringProvider()) }
private val filtersWithValue = filtersWithValue(filters, filterValues)
private val hardwareMan = ctx.getCarService(CarContext.HARDWARE_SERVICE) as CarHardwareManager
private val hardwareMan: CarHardwareManager by lazy {
ctx.getCarService(CarContext.HARDWARE_SERVICE) as CarHardwareManager
}
private var energyLevel: EnergyLevel? = null
private val permissions = if (BuildConfig.FLAVOR_automotive == "automotive") {
listOf(
@@ -99,20 +99,22 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) :
)
}
private var searchLocation: LatLng? = null
init {
filtersWithValue.observe(this) {
loadChargers()
}
lifecycle.addObserver(this)
marker = MARKER
}
override fun onGetTemplate(): Template {
checkLocationPermission()
session.requestLocationUpdates()
session.mapScreen = this
return PlaceListMapTemplate.Builder().apply {
setTitle(
carContext.getString(
prefs.placeSearchResultAndroidAutoName?.let {
carContext.getString(R.string.auto_chargers_near_location, it)
} ?: carContext.getString(
if (filterStatus.value == FILTERS_FAVORITES) {
R.string.auto_favorites
} else {
@@ -120,8 +122,16 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) :
}
)
)
location?.let {
setAnchor(Place.Builder(CarLocation.create(it)).build())
searchLocation?.let {
setAnchor(Place.Builder(CarLocation.create(it.latitude, it.longitude)).apply {
if (prefs.placeSearchResultAndroidAutoName != null) {
setMarker(
PlaceMarker.Builder()
.setColor(CarColor.PRIMARY)
.build()
)
}
}.build())
} ?: setLoading(true)
chargers?.take(maxRows)?.let { chargerList ->
val builder = ItemList.Builder()
@@ -179,8 +189,7 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) :
.build()
)
.setOnClickListener {
screenManager.pushForResult(FilterScreen(carContext)) {
chargers = null
screenManager.pushForResult(FilterScreen(carContext, session)) {
filterStatus.value = prefs.filterStatus
}
session.mapScreen = null
@@ -191,25 +200,6 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) :
}.build()
}
private fun checkLocationPermission() {
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.requestLocationUpdates()
}
}
}
}
private fun formatCharger(charger: ChargeLocation, showCity: Boolean): Row {
val markerTint = if ((charger.maxPower ?: 0.0) > 100) {
R.color.charger_100kw_dark // slightly darker color for better contrast
@@ -287,13 +277,8 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) :
)
setOnClickListener {
screenManager.pushForResult(ChargerDetailScreen(carContext, charger)) {
if (filterStatus.value == FILTERS_FAVORITES) {
// favorites list may have been updated
chargers = null
loadChargers()
}
}
screenManager.push(ChargerDetailScreen(carContext, charger))
session.mapScreen = null
}
}.build()
}
@@ -325,6 +310,10 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) :
val referenceData = referenceData.value ?: return
val filters = filtersWithValue.value ?: return
val searchLocation =
prefs.placeSearchResultAndroidAuto ?: LatLng.fromLocation(location)
this.searchLocation = searchLocation
updateCoroutine = lifecycleScope.launch {
try {
// load chargers
@@ -339,7 +328,7 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) :
} else {
val response = api.getChargepointsRadius(
referenceData,
LatLng.fromLocation(location),
searchLocation,
searchRadius,
zoom = 16f,
filters
@@ -350,7 +339,7 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) :
// try again with larger radius
val response = api.getChargepointsRadius(
referenceData,
LatLng.fromLocation(location),
searchLocation,
searchRadius * 10,
zoom = 16f,
filters
@@ -374,11 +363,24 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) :
}
private fun onEnergyLevelUpdated(energyLevel: EnergyLevel) {
val isUpdate = this.energyLevel == null
this.energyLevel = energyLevel
invalidate()
if (isUpdate) invalidate()
}
override fun onStart(owner: LifecycleOwner) {
setupListeners()
// Reloading chargers in onStart does not seem to count towards content limit.
// So let's do this so the user gets fresh chargers when re-entering the app.
chargers = null
availabilities.clear()
invalidate()
filtersWithValue.observe(this) {
loadChargers()
}
}
@OnLifecycleEvent(Lifecycle.Event.ON_RESUME)
private fun setupListeners() {
if (!permissions.all {
ContextCompat.checkSelfPermission(
@@ -388,25 +390,39 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) :
})
return
println("Setting up energy level listener")
val exec = ContextCompat.getMainExecutor(carContext)
hardwareMan.carInfo.addEnergyLevelListener(exec, ::onEnergyLevelUpdated)
if (supportsCarApiLevel3(carContext)) {
println("Setting up energy level listener")
val exec = ContextCompat.getMainExecutor(carContext)
hardwareMan.carInfo.addEnergyLevelListener(exec, ::onEnergyLevelUpdated)
}
}
override fun onStop(owner: LifecycleOwner) {
removeListeners()
}
@OnLifecycleEvent(Lifecycle.Event.ON_PAUSE)
private fun removeListeners() {
println("Removing energy level listener")
hardwareMan.carInfo.removeEnergyLevelListener(::onEnergyLevelUpdated)
if (supportsCarApiLevel3(carContext)) {
println("Removing energy level listener")
hardwareMan.carInfo.removeEnergyLevelListener(::onEnergyLevelUpdated)
}
}
override fun onContentRefreshRequested() {
loadChargers()
availabilities.clear()
val start = visibleStart
val end = visibleEnd
if (start != null && end != null) {
onItemVisibilityChanged(start, end)
}
}
override fun onItemVisibilityChanged(startIndex: Int, endIndex: Int) {
// when the list is scrolled, load corresponding availabilities
if (startIndex == visibleStart && endIndex == visibleEnd) return
if (startIndex == visibleStart && endIndex == visibleEnd && !availabilities.isEmpty()) return
if (startIndex == -1 || endIndex == -1) return
if (availabilityUpdateCoroutine != null) return
visibleEnd = endIndex
@@ -423,7 +439,14 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) :
// update availabilities
availabilityUpdateCoroutine = lifecycleScope.launch {
delay(300L)
val tasks = chargers?.subList(startIndex, endIndex)?.mapNotNull {
val chargers = chargers ?: return@launch
if (chargers.isEmpty()) return@launch
val tasks = chargers.subList(
min(startIndex, chargers.size - 1),
min(endIndex, chargers.size - 1)
).mapNotNull {
// update only if not yet stored
if (!availabilities.containsKey(it.id)) {
lifecycleScope.async {
@@ -433,7 +456,7 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) :
}
} else null
}
if (!tasks.isNullOrEmpty()) {
if (tasks.isNotEmpty()) {
tasks.awaitAll()
invalidate()
}

View File

@@ -12,7 +12,8 @@ import net.vonforst.evmap.R
class PermissionScreen(
ctx: CarContext,
@StringRes val message: Int,
val permissions: List<String>
val permissions: List<String>,
val finishApp: Boolean = true
) : Screen(ctx) {
override fun onGetTemplate(): Template {
return MessageTemplate.Builder(carContext.getString(message))
@@ -31,7 +32,13 @@ class PermissionScreen(
Action.Builder()
.setTitle(carContext.getString(R.string.cancel))
.setOnClickListener {
carContext.finishCarApp()
if (finishApp) {
carContext.finishCarApp()
} else {
// pop twice to get away from the screen that requires the permission
screenManager.pop()
screenManager.pop()
}
}
.build(),
)

View File

@@ -0,0 +1,234 @@
package net.vonforst.evmap.auto
import android.content.pm.PackageManager
import android.location.Location
import android.text.SpannableStringBuilder
import android.text.Spanned
import androidx.car.app.CarContext
import androidx.car.app.CarToast
import androidx.car.app.Screen
import androidx.car.app.annotations.ExperimentalCarApi
import androidx.car.app.constraints.ConstraintManager
import androidx.car.app.hardware.CarHardwareManager
import androidx.car.app.hardware.info.EnergyLevel
import androidx.car.app.model.*
import androidx.core.content.ContextCompat
import androidx.core.graphics.drawable.IconCompat
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope
import com.car2go.maps.model.LatLng
import kotlinx.coroutines.*
import net.vonforst.evmap.BuildConfig
import net.vonforst.evmap.R
import net.vonforst.evmap.adapter.iconForPlaceType
import net.vonforst.evmap.adapter.isSpecialPlace
import net.vonforst.evmap.autocomplete.*
import net.vonforst.evmap.storage.AppDatabase
import net.vonforst.evmap.storage.PreferenceDataSource
import net.vonforst.evmap.storage.RecentAutocompletePlace
import java.io.IOException
import java.time.Instant
@ExperimentalCarApi
class PlaceSearchScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx),
SearchTemplate.SearchCallback, LocationAwareScreen,
DefaultLifecycleObserver {
private val hardwareMan: CarHardwareManager by lazy {
ctx.getCarService(CarContext.HARDWARE_SERVICE) as CarHardwareManager
}
private var resultList: List<AutocompletePlace>? = null
private var recentResults = mutableListOf<RecentAutocompletePlace>()
private var currentProvider: AutocompleteProvider? = null
private val providers = getAutocompleteProviders(ctx)
private val recents = AppDatabase.getInstance(ctx).recentAutocompletePlaceDao()
private val maxItems = if (ctx.carAppApiLevel >= 2) {
ctx.constraintManager.getContentLimit(ConstraintManager.CONTENT_LIMIT_TYPE_LIST)
} else 6
private var location: Location? = null
private var energyLevel: EnergyLevel? = null
private var updateJob: Job? = null
private val prefs = PreferenceDataSource(ctx)
private val permissions = if (BuildConfig.FLAVOR_automotive == "automotive") {
listOf(
"android.car.permission.CAR_ENERGY",
"android.car.permission.CAR_ENERGY_PORTS",
"android.car.permission.READ_CAR_DISPLAY_UNITS",
)
} else {
listOf(
"com.google.android.gms.permission.CAR_FUEL"
)
}
init {
lifecycle.addObserver(this)
update("")
}
override fun onGetTemplate(): Template {
return SearchTemplate.Builder(this).apply {
setHeaderAction(Action.BACK)
setSearchHint(carContext.getString(R.string.search))
resultList?.let {
setItemList(buildItemList(it))
} ?: setLoading(true)
}.build()
}
private fun buildItemList(results: List<AutocompletePlace>): ItemList {
return ItemList.Builder().apply {
results.forEach { place ->
addItem(Row.Builder().apply {
setTitle(place.primaryText)
addText(place.secondaryText)
val icon = iconForPlaceType(place.types)
setImage(
CarIcon.Builder(IconCompat.createWithResource(carContext, icon))
.setTint(if (isSpecialPlace(place.types)) CarColor.PRIMARY else CarColor.DEFAULT)
.build()
)
// distance
place.distanceMeters?.let {
val text = SpannableStringBuilder()
text.append(
"distance",
DistanceSpan.create(
roundValueToDistance(
it,
energyLevel?.distanceDisplayUnit?.value
)
),
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
)
addText(text)
setOnClickListener {
lifecycleScope.launch {
val placeDetails = getDetails(place.id)
prefs.placeSearchResultAndroidAuto = placeDetails.latLng
prefs.placeSearchResultAndroidAutoName =
place.primaryText.toString()
screenManager.popTo(MapScreen.MARKER)
}
}
}
}.build())
}
}.build()
}
override fun onSearchTextChanged(searchText: String) {
update(searchText)
}
override fun onSearchSubmitted(searchText: String) {
update(searchText)
}
private fun update(searchText: String) {
updateJob?.cancel()
updateJob = lifecycleScope.launch {
if (prefs.searchProvider == "mapbox" && !isShortQuery(searchText)) {
delay(500L)
}
try {
loadNewList(searchText)
} catch (e: IOException) {
CarToast.makeText(
carContext,
R.string.autocomplete_connection_error,
CarToast.LENGTH_SHORT
).show()
}
}
}
private suspend fun loadNewList(query: String) {
for (provider in providers) {
try {
recentResults.clear()
currentProvider = provider
// first search in recent places
val recentPlaces = if (query.isEmpty()) {
recents.getAllAsync(provider.id, limit = maxItems)
} else {
recents.searchAsync(query, provider.id, limit = maxItems)
}
recentResults.addAll(recentPlaces)
resultList =
recentPlaces.map { it.asAutocompletePlace(LatLng.fromLocation(location)) }
invalidate()
// if we already have enough results or the query is short, stop here
if (isShortQuery(query) || recentResults.size >= maxItems) break
// then search online
val recentIds = recentPlaces.map { it.id }
resultList = withContext(Dispatchers.IO) {
(resultList!! + provider.autocomplete(query, LatLng.fromLocation(location))
.filter { !recentIds.contains(it.id) }).take(maxItems)
}
invalidate()
break
} catch (e: ApiUnavailableException) {
e.printStackTrace()
}
}
}
private fun isShortQuery(query: CharSequence) = query.length < 3
override fun updateLocation(location: Location) {
this.location = location
}
override fun onResume(owner: LifecycleOwner) {
session.requestLocationUpdates()
session.mapScreen = this
if (supportsCarApiLevel3(carContext) && permissions.all {
ContextCompat.checkSelfPermission(
carContext,
it
) == PackageManager.PERMISSION_GRANTED
}) {
println("Setting up energy level listener")
val exec = ContextCompat.getMainExecutor(carContext)
hardwareMan.carInfo.addEnergyLevelListener(exec, ::onEnergyLevelUpdated)
}
}
private fun onEnergyLevelUpdated(energyLevel: EnergyLevel) {
val isUpdate = this.energyLevel == null
this.energyLevel = energyLevel
if (isUpdate) invalidate()
}
override fun onPause(owner: LifecycleOwner) {
session.mapScreen = null
if (supportsCarApiLevel3(carContext)) {
hardwareMan.carInfo.removeEnergyLevelListener(::onEnergyLevelUpdated)
}
}
suspend fun getDetails(id: String): PlaceWithBounds {
val provider = currentProvider!!
val result = resultList!!.find { it.id == id }!!
val recentPlace = recentResults.find { it.id == id }
if (recentPlace != null) return recentPlace.asPlaceWithBounds()
val details = provider.getDetails(id)
recents.insert(RecentAutocompletePlace(result, details, provider.id, Instant.now()))
return details
}
}

View File

@@ -1,33 +1,30 @@
package net.vonforst.evmap.auto
import androidx.car.app.CarContext
import androidx.car.app.CarToast
import androidx.car.app.Screen
import androidx.car.app.constraints.ConstraintManager
import androidx.car.app.model.*
import androidx.core.graphics.drawable.IconCompat
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.launch
import net.vonforst.evmap.R
import net.vonforst.evmap.api.chargeprice.ChargepriceApi
import net.vonforst.evmap.api.chargeprice.ChargepriceCar
import net.vonforst.evmap.api.chargeprice.ChargepriceTariff
import net.vonforst.evmap.storage.AppDatabase
import net.vonforst.evmap.storage.PreferenceDataSource
import kotlin.math.max
import kotlin.math.min
class SettingsScreen(ctx: CarContext) : Screen(ctx) {
val prefs = PreferenceDataSource(carContext)
val dataSourceNames = carContext.resources.getStringArray(R.array.pref_data_source_names)
val dataSourceValues = carContext.resources.getStringArray(R.array.pref_data_source_values)
override fun onGetTemplate(): Template {
return ListTemplate.Builder().apply {
setTitle(carContext.getString(R.string.auto_settings))
setHeaderAction(Action.BACK)
setSingleList(ItemList.Builder().apply {
addItem(Row.Builder().apply {
setTitle(carContext.getString(R.string.pref_data_source))
val dataSourceId = prefs.dataSource
val dataSourceDesc = dataSourceNames[dataSourceValues.indexOf(dataSourceId)]
addText(dataSourceDesc)
setTitle(carContext.getString(R.string.settings_data_sources))
setImage(
CarIcon.Builder(
IconCompat.createWithResource(
@@ -40,7 +37,7 @@ class SettingsScreen(ctx: CarContext) : Screen(ctx) {
)
setBrowsable(true)
setOnClickListener {
screenManager.push(ChooseDataSourceScreen(carContext))
screenManager.push(DataSettingsScreen(carContext))
}
}.build())
addItem(Row.Builder().apply {
@@ -81,31 +78,107 @@ class SettingsScreen(ctx: CarContext) : Screen(ctx) {
}
}
class ChooseDataSourceScreen(ctx: CarContext) : Screen(ctx) {
val prefs = PreferenceDataSource(carContext)
class DataSettingsScreen(ctx: CarContext) : Screen(ctx) {
val prefs = PreferenceDataSource(ctx)
val db = AppDatabase.getInstance(ctx)
val dataSourceNames = carContext.resources.getStringArray(R.array.pref_data_source_names)
val dataSourceValues = carContext.resources.getStringArray(R.array.pref_data_source_values)
val dataSourceDescriptions = listOf(
carContext.getString(R.string.data_source_goingelectric_desc),
carContext.getString(R.string.data_source_openchargemap_desc)
)
val searchProviderNames =
carContext.resources.getStringArray(R.array.pref_search_provider_names)
val searchProviderValues =
carContext.resources.getStringArray(R.array.pref_search_provider_values)
override fun onGetTemplate(): Template {
return ListTemplate.Builder().apply {
setTitle(carContext.getString(R.string.settings_data_sources))
setHeaderAction(Action.BACK)
setSingleList(ItemList.Builder().apply {
addItem(Row.Builder().apply {
setTitle(carContext.getString(R.string.pref_data_source))
setBrowsable(true)
val dataSourceId = prefs.dataSource
val dataSourceDesc = dataSourceNames[dataSourceValues.indexOf(dataSourceId)]
addText(dataSourceDesc)
setOnClickListener {
screenManager.push(
ChooseDataSourceScreen(
carContext,
dataSourceNames,
dataSourceValues,
prefs.dataSource,
dataSourceDescriptions
) {
prefs.dataSource = it
})
}
}.build())
addItem(Row.Builder().apply {
setTitle(carContext.getString(R.string.pref_search_provider))
setBrowsable(true)
val searchProviderId = prefs.searchProvider
val searchProviderDesc =
searchProviderNames[searchProviderValues.indexOf(searchProviderId)]
addText(searchProviderDesc)
setOnClickListener {
screenManager.push(
ChooseDataSourceScreen(
carContext,
searchProviderNames,
searchProviderValues,
prefs.searchProvider
) {
prefs.searchProvider = it
})
}
}.build())
addItem(Row.Builder().apply {
setTitle(carContext.getString(R.string.pref_search_delete_recent))
setOnClickListener {
lifecycleScope.launch {
db.recentAutocompletePlaceDao().deleteAll()
CarToast.makeText(
carContext,
R.string.deleted_recent_search_results,
CarToast.LENGTH_SHORT
).show()
}
}
}.build())
}.build())
}.build()
}
}
class ChooseDataSourceScreen(
ctx: CarContext,
val names: Array<String>,
val values: Array<String>,
val currentValue: String,
val descriptions: List<String>? = null,
val callback: (String) -> Unit
) : Screen(ctx) {
val prefs = PreferenceDataSource(carContext)
override fun onGetTemplate(): Template {
return ListTemplate.Builder().apply {
setTitle(carContext.getString(R.string.pref_data_source))
setHeaderAction(Action.BACK)
setSingleList(ItemList.Builder().apply {
for (i in dataSourceNames.indices) {
for (i in names.indices) {
addItem(Row.Builder().apply {
setTitle(dataSourceNames[i])
addText(dataSourceDescriptions[i])
setTitle(names[i])
descriptions?.let { addText(it[i]) }
}.build())
}
setOnSelectedListener {
prefs.dataSource = dataSourceValues[it]
callback(values[it])
screenManager.pop()
}
setSelectedIndex(dataSourceValues.indexOf(prefs.dataSource))
setSelectedIndex(values.indexOf(currentValue))
}.build())
}.build()
}
@@ -145,7 +218,10 @@ class ChargepriceSettingsScreen(ctx: CarContext) : Screen(ctx) {
R.plurals.chargeprice_some_tariffs_selected,
n,
n
) + "\n" + carContext.getString(R.string.pref_my_tariffs_summary)
) + "\n" + carContext.resources.getQuantityString(
R.plurals.pref_my_tariffs_summary,
n
)
}
)
}.build())
@@ -210,7 +286,10 @@ class ChargepriceSettingsScreen(ctx: CarContext) : Screen(ctx) {
class SelectVehiclesScreen(ctx: CarContext) : MultiSelectSearchScreen<ChargepriceCar>(ctx) {
private val prefs = PreferenceDataSource(carContext)
private var api = ChargepriceApi.create(carContext.getString(R.string.chargeprice_key))
private var api = ChargepriceApi.create(
carContext.getString(R.string.chargeprice_key),
carContext.getString(R.string.chargeprice_api_url)
)
override val isMultiSelect = true
override val shouldShowSelectAll = false
@@ -235,7 +314,10 @@ class SelectVehiclesScreen(ctx: CarContext) : MultiSelectSearchScreen<Chargepric
class SelectTariffsScreen(ctx: CarContext) : MultiSelectSearchScreen<ChargepriceTariff>(ctx) {
private val prefs = PreferenceDataSource(carContext)
private var api = ChargepriceApi.create(carContext.getString(R.string.chargeprice_key))
private var api = ChargepriceApi.create(
carContext.getString(R.string.chargeprice_key),
carContext.getString(R.string.chargeprice_api_url)
)
override val isMultiSelect = true
override val shouldShowSelectAll = true
@@ -369,6 +451,7 @@ class SelectChargingRangeScreen(ctx: CarContext) : Screen(ctx) {
val nSpacers = when {
maxItems % 3 == 0 -> 1
maxItems == 100 -> 0 // AA has increased the limit to 100 and changed the way items are laid out
maxItems % 4 == 0 -> 2
else -> 0
}

View File

@@ -3,31 +3,59 @@ package net.vonforst.evmap.auto
import androidx.annotation.StringRes
import androidx.car.app.CarContext
import androidx.car.app.Screen
import androidx.car.app.model.Action
import androidx.car.app.model.InputCallback
import androidx.car.app.model.Template
import androidx.car.app.model.*
import androidx.car.app.model.signin.InputSignInMethod
import androidx.car.app.model.signin.SignInTemplate
import net.vonforst.evmap.R
class TextPromptScreen(
ctx: CarContext,
@StringRes val title: Int,
@StringRes val prompt: Int,
val initialValue: String? = null
val initialValue: String? = null,
val cancelable: Boolean = true
) : Screen(ctx),
InputCallback {
private var inputText = ""
override fun onGetTemplate(): Template {
val signInMethod = InputSignInMethod.Builder(this).apply {
initialValue?.let { setDefaultValue(it) }
initialValue?.let {
setDefaultValue(it)
inputText = initialValue
}
setShowKeyboardByDefault(true)
}.build()
return SignInTemplate.Builder(signInMethod).apply {
setHeaderAction(Action.BACK)
setInstructions(carContext.getString(prompt))
setTitle(carContext.getString(title))
if (cancelable) {
addAction(
Action.Builder()
.setTitle(carContext.getString(R.string.cancel))
.setOnClickListener(ParkedOnlyOnClickListener.create {
screenManager.pop()
})
.build()
)
}
addAction(
Action.Builder()
.setTitle(carContext.getString(R.string.ok))
.setBackgroundColor(CarColor.PRIMARY)
.setOnClickListener(ParkedOnlyOnClickListener.create {
onInputSubmitted(inputText)
})
.build()
)
}.build()
}
override fun onInputTextChanged(text: String) {
inputText = text
}
override fun onInputSubmitted(text: String) {
setResult(text)
screenManager.pop()

View File

@@ -10,9 +10,8 @@ import androidx.car.app.hardware.info.*
import androidx.car.app.model.*
import androidx.core.content.ContextCompat
import androidx.core.graphics.drawable.IconCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleObserver
import androidx.lifecycle.OnLifecycleEvent
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import net.vonforst.evmap.BuildConfig
import net.vonforst.evmap.R
import net.vonforst.evmap.ui.CompassNeedle
@@ -20,8 +19,10 @@ import net.vonforst.evmap.ui.Gauge
import kotlin.math.min
import kotlin.math.roundToInt
class VehicleDataScreen(ctx: CarContext) : Screen(ctx), LifecycleObserver {
private val hardwareMan = ctx.getCarService(CarContext.HARDWARE_SERVICE) as CarHardwareManager
class VehicleDataScreen(ctx: CarContext) : Screen(ctx), DefaultLifecycleObserver {
private val carInfo =
(ctx.getCarService(CarContext.HARDWARE_SERVICE) as CarHardwareManager).carInfo
private val carSensors = carContext.patchedCarSensors
private var model: Model? = null
private var energyLevel: EnergyLevel? = null
private var speed: Speed? = null
@@ -57,7 +58,8 @@ class VehicleDataScreen(ctx: CarContext) : Screen(ctx), LifecycleObserver {
PermissionScreen(
carContext,
R.string.auto_vehicle_data_permission_needed,
permissions
permissions,
finishApp = false
)
) {
setupListeners()
@@ -225,33 +227,39 @@ class VehicleDataScreen(ctx: CarContext) : Screen(ctx), LifecycleObserver {
invalidate()
}
@OnLifecycleEvent(Lifecycle.Event.ON_RESUME)
override fun onResume(owner: LifecycleOwner) {
setupListeners()
}
private fun setupListeners() {
if (!permissionsGranted()) return
println("Setting up energy level listener")
val exec = ContextCompat.getMainExecutor(carContext)
hardwareMan.carInfo.addEnergyLevelListener(exec, ::onEnergyLevelUpdated)
hardwareMan.carInfo.addSpeedListener(exec, ::onSpeedUpdated)
hardwareMan.carSensors.addCompassListener(
carInfo.addEnergyLevelListener(exec, ::onEnergyLevelUpdated)
carInfo.addSpeedListener(exec, ::onSpeedUpdated)
carSensors.addCompassListener(
CarSensors.UPDATE_RATE_NORMAL,
exec,
::onCompassUpdated
)
hardwareMan.carInfo.fetchModel(exec) {
carInfo.fetchModel(exec) {
this.model = it
invalidate()
}
}
@OnLifecycleEvent(Lifecycle.Event.ON_PAUSE)
override fun onPause(owner: LifecycleOwner) {
removeListeners()
}
private fun removeListeners() {
println("Removing energy level listener")
hardwareMan.carInfo.removeEnergyLevelListener(::onEnergyLevelUpdated)
hardwareMan.carInfo.removeSpeedListener(::onSpeedUpdated)
hardwareMan.carSensors.removeCompassListener(::onCompassUpdated)
carInfo.removeEnergyLevelListener(::onEnergyLevelUpdated)
carInfo.removeSpeedListener(::onSpeedUpdated)
carSensors.removeCompassListener(::onCompassUpdated)
}
private fun permissionsGranted(): Boolean =

View File

@@ -0,0 +1,13 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M15.5,14h-0.79l-0.28,-0.27C15.41,12.59 16,11.11 16,9.5C16,5.91 13.09,3 9.5,3C6.08,3 3.28,5.64 3.03,9h2.02C5.3,6.75 7.18,5 9.5,5C11.99,5 14,7.01 14,9.5S11.99,14 9.5,14c-0.17,0 -0.33,-0.03 -0.5,-0.05v2.02C9.17,15.99 9.33,16 9.5,16c1.61,0 3.09,-0.59 4.23,-1.57L14,14.71v0.79l5,4.99L20.49,19L15.5,14z" />
<path
android:fillColor="@android:color/white"
android:pathData="M6.47,10.82l-2.47,2.47l-2.47,-2.47l-0.71,0.71l2.47,2.47l-2.47,2.47l0.71,0.71l2.47,-2.47l2.47,2.47l0.71,-0.71l-2.47,-2.47l2.47,-2.47z" />
</vector>

View File

@@ -1,13 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string-array name="pref_map_provider_names">
<item>Google Maps</item>
<item>OpenStreetMap (Mapbox)</item>
</string-array>
<string-array name="pref_search_provider_names">
<item>Google Maps</item>
<item>OpenStreetMap (Mapbox)</item>
</string-array>
<string name="donations_info" formatted="false">Findest du EVMap nützlich? Unterstütze die Weiterentwicklung der App mit einer Spende an den Entwickler.\n\nGoogle zieht von der Spende 15% Gebühren ab.</string>
<string name="auto_location_service">EVMap läuft unter Android Auto und nutzt dafür deinen Standort.</string>
<string name="auto_no_chargers_found">Keine Ladestationen in der Nähe gefunden</string>
@@ -19,6 +11,7 @@
<string name="grant_on_phone">Auf Telefon zulassen</string>
<string name="auto_chargers_closeby">In der Nähe</string>
<string name="auto_favorites">Favoriten</string>
<string name="auto_chargers_near_location">Nahe %s</string>
<string name="auto_fault_report_date">⚠️ Störungsmeldung (%s)</string>
<string name="auto_no_refresh_possible">Weitere Aktualisierung nicht möglich. Bitte zurück gehen und neu starten.</string>
<string name="auto_prices">Preise</string>

View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string-array name="pref_map_provider_names">
<item>@string/pref_map_provider_google_maps</item>
<item>@string/pref_map_provider_osm_mapbox</item>
</string-array>
<string-array name="pref_map_provider_values" translatable="false">
<item>google</item>
<item>mapbox</item>
</string-array>
<string-array name="pref_search_provider_names">
<item>@string/pref_search_provider_google_maps</item>
<item>@string/pref_search_provider_osm_mapbox</item>
</string-array>
<string-array name="pref_search_provider_values" translatable="false">
<item>google</item>
<item>mapbox</item>
</string-array>
</resources>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="pref_map_provider_default" translatable="false">google</string>
<string name="pref_search_provider_default" translatable="false">mapbox</string>
</resources>

View File

@@ -1,23 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string-array name="pref_map_provider_names">
<item>Google Maps</item>
<item>OpenStreetMap (Mapbox)</item>
</string-array>
<string-array name="pref_map_provider_values" tranlatable="false">
<item>google</item>
<item>mapbox</item>
</string-array>
<string-array name="pref_search_provider_names">
<item>Google Maps</item>
<item>OpenStreetMap (Mapbox)</item>
</string-array>
<string-array name="pref_search_provider_values" tranlatable="false">
<item>google</item>
<item>mapbox</item>
</string-array>
<string name="pref_map_provider_default" translatable="false">google</string>
<string name="pref_search_provider_default" translatable="false">mapbox</string>
<string name="donations_info" formatted="false">Do you find EVMap useful? Support its development by sending a donation to the developer.\n\nGoogle takes 15% off every donation.</string>
<string name="auto_location_service">EVMap is running on Android Auto and using your location.</string>
<string name="auto_no_chargers_found">No nearby chargers found</string>
@@ -29,6 +11,7 @@
<string name="grant_on_phone">Grant on phone</string>
<string name="auto_chargers_closeby">Nearby chargers</string>
<string name="auto_favorites">Favorites</string>
<string name="auto_chargers_near_location">Near %s</string>
<string name="auto_fault_report_date">⚠️ Fault report (%s)</string>
<string name="auto_no_refresh_possible">Further updates not possible. Please go back and restart.</string>
<string name="auto_prices">Pricing</string>
@@ -44,7 +27,7 @@
<string name="sounds_cool">sounds cool</string>
<string name="auto_chargeprice_vehicle_unavailable">EVMap could not determine your vehicle model.</string>
<string name="auto_chargeprice_vehicle_unknown">None of the vehicles selected in the app matches this vehicle (%1$s %2$s).</string>
<string name="auto_chargeprice_vehicle_ambiguous">Mehrere der in der App ausgewählten Fahrzeuge passen zu diesem Fahrzeug (%1$s %2$s).</string>
<string name="auto_chargeprice_vehicle_ambiguous">Multiple vehicles selected in the app match this vehicle (%1$s %2$s).</string>
<string name="settings_android_auto_chargeprice_range">Charging range for price comparison</string>
<string name="data_sources_hint">In the settings you can also switch between Google Maps and OpenStreetMap (Mapbox) for the map data.</string>
<string name="selecting_all">selected all items</string>

View File

@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="auto_location_permission_needed">To run EVMap on your car, you need to grant access to your location.</string>
<string name="grant_on_phone">Allow</string>
<string name="auto_location_permission_needed">To run EVMap on your car, you need to grant access to your location.</string>
</resources>

View File

@@ -261,17 +261,6 @@
android:name="android.app.shortcuts"
android:resource="@xml/shortcuts" />
</activity>
<!-- Override services of the com.mapzen.android.lost library with exported:false
until https://github.com/lostzen/lost/pull/270 is merged -->
<service
android:name="com.mapzen.android.lost.internal.GeofencingIntentService"
android:exported="false">
<intent-filter>
<action android:name="com.mapzen.lost.action.ACTION_GEOFENCING_SERVICE" />
</intent-filter>
</service>
</application>
</manifest>

View File

@@ -36,7 +36,6 @@ import net.vonforst.evmap.utils.LocaleContextWrapper
import net.vonforst.evmap.utils.getLocationFromIntent
const val REQUEST_LOCATION_PERMISSION = 1
const val EXTRA_CHARGER_ID = "chargerId"
const val EXTRA_LAT = "lat"
const val EXTRA_LON = "lon"
@@ -84,9 +83,9 @@ class MapsActivity : AppCompatActivity(),
val navView = findViewById<NavigationView>(R.id.nav_view)
navView.setupWithNavController(navController)
val header = navView.getHeaderView(0)
ViewCompat.setOnApplyWindowInsetsListener(header) { v, insets ->
v.setPadding(0, insets.getInsets(WindowInsetsCompat.Type.statusBars()).top, 0, 0)
ViewCompat.setOnApplyWindowInsetsListener(navView) { v, insets ->
val header = navView.getHeaderView(0)
header.setPadding(0, insets.getInsets(WindowInsetsCompat.Type.statusBars()).top, 0, 0)
insets
}
@@ -163,6 +162,7 @@ class MapsActivity : AppCompatActivity(),
.createPendingIntent()
} else if (intent.hasExtra(EXTRA_FAVORITES)) {
deepLink = navController.createDeepLink()
.setGraph(navGraph)
.setDestination(R.id.favs)
.createPendingIntent()
}

View File

@@ -171,7 +171,7 @@ class CheckableConnectorAdapter : DataBindingAdapter<Chargepoint>() {
root.post {
notifyDataSetChanged()
}
onCheckedItemChangedListener?.invoke(getCheckedItem()!!)
getCheckedItem()?.let { onCheckedItemChangedListener?.invoke(it) }
}
}
}

View File

@@ -62,6 +62,8 @@ class FiltersAdapter : DataBindingAdapter<FilterWithValue<FilterValue>>() {
)
}
}
is BooleanFilterValue -> {
}
}
}

View File

@@ -139,7 +139,7 @@ data class ChargeLocationStatus(
(connectors == null || connectors.map {
equivalentPlugTypes(it)
}.any { equivalent -> it.type in equivalent })
&& (minPower == null || (it.power != null && it.power > minPower))
&& (minPower == null || (it.power != null && it.power >= minPower))
}
return this.copy(status = statusFiltered)
}
@@ -191,4 +191,4 @@ suspend fun getAvailability(charger: ChargeLocation): Resource<ChargeLocationSta
}
}
return value ?: Resource.error(null, null)
}
}

View File

@@ -3,14 +3,16 @@ package net.vonforst.evmap.api.chargeprice
import android.content.Context
import com.facebook.stetho.okhttp3.StethoInterceptor
import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import moe.banana.jsonapi2.ArrayDocument
import moe.banana.jsonapi2.JsonApiConverterFactory
import moe.banana.jsonapi2.ResourceAdapterFactory
import com.squareup.moshi.adapters.PolymorphicJsonAdapterFactory
import jsonapi.Document
import jsonapi.JsonApiFactory
import jsonapi.retrofit.DocumentConverterFactory
import net.vonforst.evmap.BuildConfig
import net.vonforst.evmap.model.ChargeLocation
import okhttp3.Cache
import okhttp3.OkHttpClient
import retrofit2.Retrofit
import retrofit2.converter.moshi.MoshiConverterFactory
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.Header
@@ -20,34 +22,45 @@ import java.util.*
interface ChargepriceApi {
@POST("charge_prices")
suspend fun getChargePrices(
@Body request: ChargepriceRequest,
@Body @jsonapi.retrofit.Document request: ChargepriceRequest,
@Header("Accept-Language") language: String
): ArrayDocument<ChargePrice>
): Document<List<ChargePrice>>
@GET("vehicles")
suspend fun getVehicles(): ArrayDocument<ChargepriceCar>
@jsonapi.retrofit.Document
suspend fun getVehicles(): List<ChargepriceCar>
@GET("tariffs")
suspend fun getTariffs(): ArrayDocument<ChargepriceTariff>
@jsonapi.retrofit.Document
suspend fun getTariffs(): List<ChargepriceTariff>
@POST("user_feedback")
suspend fun userFeedback(@Body @jsonapi.retrofit.Document feedback: ChargepriceUserFeedback)
companion object {
private val cacheSize = 1L * 1024 * 1024 // 1MB
val supportedLanguages = setOf("de", "en", "fr", "nl")
val DATA_SOURCE_GOINGELECTRIC = "going_electric"
val DATA_SOURCE_OPENCHARGEMAP = "open_charge_map"
private val DATA_SOURCE_GOINGELECTRIC = "going_electric"
private val DATA_SOURCE_OPENCHARGEMAP = "open_charge_map"
private val jsonApiAdapterFactory = ResourceAdapterFactory.builder()
.add(ChargepriceRequest::class.java)
.add(ChargepriceTariff::class.java)
.add(ChargepriceBrand::class.java)
.add(ChargePrice::class.java)
.add(ChargepriceCar::class.java)
private val jsonApiAdapterFactory = JsonApiFactory.Builder()
.addType(ChargepriceRequest::class.java)
.addType(ChargepriceTariff::class.java)
.addType(ChargepriceBrand::class.java)
.addType(ChargePrice::class.java)
.addType(ChargepriceCar::class.java)
.build()
val moshi = Moshi.Builder()
.add(jsonApiAdapterFactory)
.add(KotlinJsonAdapterFactory())
.add(
PolymorphicJsonAdapterFactory.of(ChargepriceUserFeedback::class.java, "type")
.withSubtype(ChargepriceMissingPriceFeedback::class.java, "missing_price")
.withSubtype(ChargepriceWrongPriceFeedback::class.java, "wrong_price")
.withSubtype(ChargepriceMissingVehicleFeedback::class.java, "missing_vehicle")
)
.build()
fun create(
apikey: String,
baseurl: String = "https://api.chargeprice.app/v1/",
@@ -73,7 +86,8 @@ interface ChargepriceApi {
val retrofit = Retrofit.Builder()
.baseUrl(baseurl)
.addConverterFactory(JsonApiConverterFactory.create(moshi))
.addConverterFactory(DocumentConverterFactory.create())
.addConverterFactory(MoshiConverterFactory.create(moshi))
.client(client)
.build()
return retrofit.create(ChargepriceApi::class.java)
@@ -89,6 +103,15 @@ interface ChargepriceApi {
}
}
fun getPoiUrl(charger: ChargeLocation) =
"https://www.chargeprice.app/?poi_id=${charger.id}&poi_source=${getDataAdapter(charger)}"
fun getDataAdapter(charger: ChargeLocation) = when (charger.dataSource) {
"goingelectric" -> DATA_SOURCE_GOINGELECTRIC
"openchargemap" -> DATA_SOURCE_OPENCHARGEMAP
else -> throw IllegalArgumentException()
}
@JvmStatic
fun isCountrySupported(country: String, dataSource: String): Boolean = when (dataSource) {
// list of countries updated 2021/08/24

View File

@@ -1,12 +1,12 @@
package net.vonforst.evmap.api.chargeprice
import android.content.Context
import android.os.Parcelable
import android.util.Patterns
import com.squareup.moshi.Json
import moe.banana.jsonapi2.HasMany
import moe.banana.jsonapi2.HasOne
import moe.banana.jsonapi2.JsonApi
import moe.banana.jsonapi2.Resource
import com.squareup.moshi.JsonClass
import jsonapi.*
import kotlinx.parcelize.Parcelize
import net.vonforst.evmap.R
import net.vonforst.evmap.adapter.Equatable
import net.vonforst.evmap.api.equivalentPlugTypes
@@ -17,16 +17,21 @@ import kotlin.math.ceil
import kotlin.math.floor
@JsonApi(type = "charge_price_request")
class ChargepriceRequest : Resource() {
@field:Json(name = "data_adapter")
lateinit var dataAdapter: String
lateinit var station: ChargepriceStation
lateinit var options: ChargepriceOptions
var tariffs: HasMany<ChargepriceTariff>? = null
var vehicle: HasOne<ChargepriceCar>? = null
}
@Resource("charge_price_request")
@JsonClass(generateAdapter = true)
data class ChargepriceRequest(
@Json(name = "data_adapter")
val dataAdapter: String,
val station: ChargepriceStation,
val options: ChargepriceOptions,
@ToMany("tariffs")
val tariffs: List<ChargepriceTariff>? = null,
@ToOne("vehicle")
val vehicle: ChargepriceCar? = null,
@RelationshipsObject var relationships: Relationships? = null
)
@JsonClass(generateAdapter = true)
data class ChargepriceStation(
val longitude: Double,
val latitude: Double,
@@ -56,11 +61,13 @@ data class ChargepriceStation(
}
}
@JsonClass(generateAdapter = true)
data class ChargepriceChargepoint(
val power: Double,
val plug: String
)
@JsonClass(generateAdapter = true)
data class ChargepriceOptions(
@Json(name = "max_monthly_fees") val maxMonthlyFees: Double? = null,
val energy: Double? = null,
@@ -73,142 +80,109 @@ data class ChargepriceOptions(
@Json(name = "provider_customer_tariffs") val providerCustomerTariffs: Boolean? = null
)
@JsonApi(type = "tariff")
class ChargepriceTariff() : Resource() {
lateinit var provider: String
lateinit var name: String
@field:Json(name = "direct_payment")
var directPayment: Boolean = false
@field:Json(name = "provider_customer_tariff")
var providerCustomerTariff: Boolean = false
@field:Json(name = "supported_cuntries")
lateinit var supportedCountries: Set<String>
@field:Json(name = "charge_card_id")
lateinit var chargeCardId: String // GE charge card ID
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
if (!super.equals(other)) return false
other as ChargepriceTariff
if (provider != other.provider) return false
if (name != other.name) return false
if (directPayment != other.directPayment) return false
if (providerCustomerTariff != other.providerCustomerTariff) return false
if (supportedCountries != other.supportedCountries) return false
if (chargeCardId != other.chargeCardId) return false
return true
}
override fun hashCode(): Int {
var result = super.hashCode()
result = 31 * result + provider.hashCode()
result = 31 * result + name.hashCode()
result = 31 * result + directPayment.hashCode()
result = 31 * result + providerCustomerTariff.hashCode()
result = 31 * result + supportedCountries.hashCode()
result = 31 * result + chargeCardId.hashCode()
return result
}
@Resource("tariff")
@Parcelize
@JsonClass(generateAdapter = true)
data class ChargepriceTariff(
@Id val id_: String?,
val provider: String,
val name: String,
@Json(name = "direct_payment")
val directPayment: Boolean = false,
@Json(name = "provider_customer_tariff")
val providerCustomerTariff: Boolean = false,
@Json(name = "supported_countries")
val supportedCountries: Set<String>,
@Json(name = "charge_card_id")
val chargeCardId: String?, // GE charge card ID
) : Parcelable {
val id: String
get() = id_!!
}
@JsonApi(type = "car")
class ChargepriceCar : Resource(), Equatable {
lateinit var name: String
lateinit var brand: String
@JsonClass(generateAdapter = true)
@Resource("car")
@Parcelize
data class ChargepriceCar(
@Id val id_: String?,
val name: String,
val brand: String,
@field:Json(name = "dc_charge_ports")
lateinit var dcChargePorts: List<String>
lateinit var manufacturer: HasOne<ChargepriceBrand>
@Json(name = "dc_charge_ports")
val dcChargePorts: List<String>,
@ToOne("manufacturer")
val manufacturer: ChargepriceBrand?
) : Equatable, Parcelable {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
if (!super.equals(other)) return false
other as ChargepriceCar
if (name != other.name) return false
if (brand != other.brand) return false
if (dcChargePorts != other.dcChargePorts) return false
if (manufacturer != other.manufacturer) return false
return true
companion object {
private val acConnectors = listOf(
Chargepoint.CEE_BLAU,
Chargepoint.CEE_ROT,
Chargepoint.SCHUKO,
Chargepoint.TYPE_1,
Chargepoint.TYPE_2_UNKNOWN,
Chargepoint.TYPE_2_SOCKET,
Chargepoint.TYPE_2_PLUG
)
private val plugMapping = mapOf(
"ccs" to Chargepoint.CCS_UNKNOWN,
"tesla_suc" to Chargepoint.SUPERCHARGER,
"tesla_ccs" to Chargepoint.CCS_UNKNOWN,
"chademo" to Chargepoint.CHADEMO
)
}
override fun hashCode(): Int {
var result = super.hashCode()
result = 31 * result + name.hashCode()
result = 31 * result + brand.hashCode()
result = 31 * result + dcChargePorts.hashCode()
result = 31 * result + manufacturer.hashCode()
return result
}
val id: String
get() = id_!!
private val acConnectors = listOf(
Chargepoint.CEE_BLAU,
Chargepoint.CEE_ROT,
Chargepoint.SCHUKO,
Chargepoint.TYPE_1,
Chargepoint.TYPE_2_UNKNOWN,
Chargepoint.TYPE_2_SOCKET,
Chargepoint.TYPE_2_PLUG
)
private val plugMapping = mapOf(
"ccs" to Chargepoint.CCS_UNKNOWN,
"tesla_suc" to Chargepoint.SUPERCHARGER,
"tesla_ccs" to Chargepoint.CCS_UNKNOWN,
"chademo" to Chargepoint.CHADEMO
)
val compatibleEvmapConnectors: List<String>
get() = dcChargePorts.map {
plugMapping[it]
}.filterNotNull().plus(acConnectors)
}
@JsonApi(type = "brand")
class ChargepriceBrand : Resource()
@JsonClass(generateAdapter = true)
@Resource("brand")
@Parcelize
data class ChargepriceBrand(
@Id val id: String?
) : Parcelable
@JsonApi(type = "charge_price")
class ChargePrice : Resource(), Equatable, Cloneable {
lateinit var provider: String
@JsonClass(generateAdapter = true)
@Resource("charge_price")
@Parcelize
data class ChargePrice(
val provider: String,
@Json(name = "tariff_name")
val tariffName: String,
val url: String,
@Json(name = "monthly_min_sales")
val monthlyMinSales: Double = 0.0,
@Json(name = "total_monthly_fee")
val totalMonthlyFee: Double = 0.0,
@Json(name = "flat_rate")
val flatRate: Boolean = false,
@field:Json(name = "tariff_name")
lateinit var tariffName: String
lateinit var url: String
@Json(name = "direct_payment")
val directPayment: Boolean = false,
@field:Json(name = "monthly_min_sales")
var monthlyMinSales: Double = 0.0
@Json(name = "provider_customer_tariff")
val providerCustomerTariff: Boolean = false,
val currency: String,
@field:Json(name = "total_monthly_fee")
var totalMonthlyFee: Double = 0.0
@Json(name = "start_time")
val startTime: Int = 0,
val tags: List<ChargepriceTag>,
@field:Json(name = "flat_rate")
var flatRate: Boolean = false
@field:Json(name = "direct_payment")
var directPayment: Boolean = false
@field:Json(name = "provider_customer_tariff")
var providerCustomerTariff: Boolean = false
lateinit var currency: String
@field:Json(name = "start_time")
var startTime: Int = 0
lateinit var tags: List<ChargepriceTag>
@field:Json(name = "charge_point_prices")
lateinit var chargepointPrices: List<ChargepointPrice>
@field:Json(name = "branding")
var branding: ChargepriceBranding? = null
var tariff: HasOne<ChargepriceTariff>? = null
@Json(name = "charge_point_prices")
val chargepointPrices: List<ChargepointPrice>,
@Json(name = "branding")
val branding: ChargepriceBranding? = null,
@ToOne("tariff")
val tariff: ChargepriceTariff?
) : Equatable, Cloneable, Parcelable {
fun formatMonthlyFees(ctx: Context): String {
return listOfNotNull(
if (totalMonthlyFee > 0) {
@@ -219,69 +193,10 @@ class ChargePrice : Resource(), Equatable, Cloneable {
} else null
).joinToString(", ")
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
if (!super.equals(other)) return false
other as ChargePrice
if (provider != other.provider) return false
if (tariffName != other.tariffName) return false
if (url != other.url) return false
if (monthlyMinSales != other.monthlyMinSales) return false
if (totalMonthlyFee != other.totalMonthlyFee) return false
if (flatRate != other.flatRate) return false
if (directPayment != other.directPayment) return false
if (providerCustomerTariff != other.providerCustomerTariff) return false
if (currency != other.currency) return false
if (startTime != other.startTime) return false
if (tags != other.tags) return false
if (chargepointPrices != other.chargepointPrices) return false
if (branding != other.branding) return false
return true
}
override fun hashCode(): Int {
var result = super.hashCode()
result = 31 * result + provider.hashCode()
result = 31 * result + tariffName.hashCode()
result = 31 * result + url.hashCode()
result = 31 * result + monthlyMinSales.hashCode()
result = 31 * result + totalMonthlyFee.hashCode()
result = 31 * result + flatRate.hashCode()
result = 31 * result + directPayment.hashCode()
result = 31 * result + providerCustomerTariff.hashCode()
result = 31 * result + currency.hashCode()
result = 31 * result + startTime
result = 31 * result + tags.hashCode()
result = 31 * result + chargepointPrices.hashCode()
result = 31 * result + branding.hashCode()
return result
}
public override fun clone(): ChargePrice {
return ChargePrice().apply {
chargepointPrices = this@ChargePrice.chargepointPrices
currency = this@ChargePrice.currency
directPayment = this@ChargePrice.directPayment
flatRate = this@ChargePrice.flatRate
monthlyMinSales = this@ChargePrice.monthlyMinSales
provider = this@ChargePrice.provider
providerCustomerTariff = this@ChargePrice.providerCustomerTariff
startTime = this@ChargePrice.startTime
tags = this@ChargePrice.tags
tariffName = this@ChargePrice.tariffName
totalMonthlyFee = this@ChargePrice.totalMonthlyFee
url = this@ChargePrice.url
tariff = this@ChargePrice.tariff
branding = this@ChargePrice.branding
}
}
}
@JsonClass(generateAdapter = true)
@Parcelize
data class ChargepointPrice(
val power: Double,
val plug: String,
@@ -289,7 +204,7 @@ data class ChargepointPrice(
@Json(name = "price_distribution") val priceDistribution: PriceDistribution,
@Json(name = "blocking_fee_start") val blockingFeeStart: Int?,
@Json(name = "no_price_reason") var noPriceReason: String?
) {
) : Parcelable {
fun formatDistribution(ctx: Context): String {
fun percent(value: Double): String {
return ctx.getString(R.string.percent_format, value * 100) + "\u00a0"
@@ -332,19 +247,28 @@ data class ChargepointPrice(
}
}
@JsonClass(generateAdapter = true)
@Parcelize
data class ChargepriceBranding(
@Json(name = "background_color") val backgroundColor: String,
@Json(name = "text_color") val textColor: String,
@Json(name = "logo_url") val logoUrl: String
)
) : Parcelable
data class PriceDistribution(val kwh: Double?, val session: Double?, val minute: Double?) {
val isOnlyKwh =
kwh != null && kwh > 0 && (session == null || session == 0.0) && (minute == null || minute == 0.0)
@JsonClass(generateAdapter = true)
@Parcelize
data class PriceDistribution(val kwh: Double?, val session: Double?, val minute: Double?) :
Parcelable {
val isOnlyKwh
get() = kwh != null && kwh > 0 && (session == null || session == 0.0) && (minute == null || minute == 0.0)
}
data class ChargepriceTag(val kind: String, val text: String, val url: String?) : Equatable
@JsonClass(generateAdapter = true)
@Parcelize
data class ChargepriceTag(val kind: String, val text: String, val url: String?) : Equatable,
Parcelable
@JsonClass(generateAdapter = true)
data class ChargepriceMeta(
@Json(name = "charge_points") val chargePoints: List<ChargepriceChargepointMeta>
)
@@ -358,13 +282,97 @@ enum class ChargepriceInclude {
EXCLUSIVE
}
@JsonClass(generateAdapter = true)
@Parcelize
data class ChargepriceRequestTariffMeta(
val include: ChargepriceInclude
)
) : Parcelable
@JsonClass(generateAdapter = true)
data class ChargepriceChargepointMeta(
val power: Double,
val plug: String,
val energy: Double,
val duration: Double
)
)
@Resource("user_feedback")
sealed class ChargepriceUserFeedback(
val notes: String,
val email: String,
val context: String,
val language: String
) {
init {
if (email.isBlank() || email.length > 100 || !Patterns.EMAIL_ADDRESS.matcher(email)
.matches()
) {
throw IllegalArgumentException("invalid email")
}
if (!ChargepriceApi.supportedLanguages.contains(language)) {
throw IllegalArgumentException("invalid language")
}
if (context.length > 500) throw IllegalArgumentException("invalid context")
if (notes.length > 1000) throw IllegalArgumentException("invalid notes")
}
}
@JsonClass(generateAdapter = true)
@Resource(type = "missing_price")
class ChargepriceMissingPriceFeedback(
val tariff: String,
val cpo: String,
val price: String,
@Json(name = "poi_link") val poiLink: String,
notes: String,
email: String,
context: String,
language: String
) : ChargepriceUserFeedback(notes, email, context, language) {
init {
if (tariff.isBlank() || tariff.length > 100) throw IllegalArgumentException("invalid tariff")
if (cpo.length > 200) throw IllegalArgumentException("invalid cpo")
if (price.isBlank() || price.length > 100) throw IllegalArgumentException("invalid price")
if (poiLink.isBlank() || poiLink.length > 200) throw IllegalArgumentException("invalid poiLink")
}
}
@JsonClass(generateAdapter = true)
@Resource(type = "wrong_price")
class ChargepriceWrongPriceFeedback(
val tariff: String,
val cpo: String,
@Json(name = "displayed_price") val displayedPrice: String,
@Json(name = "actual_price") val actualPrice: String,
@Json(name = "poi_link") val poiLink: String,
notes: String,
email: String,
context: String,
language: String,
) : ChargepriceUserFeedback(notes, email, context, language) {
init {
if (tariff.length > 100) throw IllegalArgumentException("invalid tariff")
if (cpo.length > 200) throw IllegalArgumentException("invalid cpo")
if (displayedPrice.length > 100) throw IllegalArgumentException("invalid displayedPrice")
if (actualPrice.length > 100) throw IllegalArgumentException("invalid actualPrice")
if (poiLink.length > 200) throw IllegalArgumentException("invalid poiLink")
}
}
@JsonClass(generateAdapter = true)
@Resource(type = "missing_vehicle")
class ChargepriceMissingVehicleFeedback(
val brand: String,
val model: String,
notes: String,
email: String,
context: String,
language: String,
) : ChargepriceUserFeedback(notes, email, context, language) {
init {
if (brand.length > 100) throw IllegalArgumentException("invalid brand")
if (model.length > 100) throw IllegalArgumentException("invalid model")
}
}

View File

@@ -2,7 +2,10 @@ package net.vonforst.evmap.fragment
import android.annotation.SuppressLint
import android.os.Bundle
import android.view.*
import android.view.LayoutInflater
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import androidx.databinding.DataBindingUtil
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
@@ -13,6 +16,7 @@ import androidx.navigation.ui.setupWithNavController
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
import com.google.android.material.color.MaterialColors
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar
import com.google.android.material.transition.MaterialContainerTransform
import net.vonforst.evmap.MapsActivity
@@ -20,13 +24,15 @@ import net.vonforst.evmap.R
import net.vonforst.evmap.adapter.ChargepriceAdapter
import net.vonforst.evmap.adapter.CheckableChargepriceCarAdapter
import net.vonforst.evmap.adapter.CheckableConnectorAdapter
import net.vonforst.evmap.api.chargeprice.ChargepriceApi
import net.vonforst.evmap.api.chargeprice.ChargepriceCar
import net.vonforst.evmap.api.equivalentPlugTypes
import net.vonforst.evmap.databinding.FragmentChargepriceBinding
import net.vonforst.evmap.model.Chargepoint
import net.vonforst.evmap.storage.PreferenceDataSource
import net.vonforst.evmap.viewmodel.ChargepriceViewModel
import net.vonforst.evmap.viewmodel.Status
import net.vonforst.evmap.viewmodel.viewModelFactory
import net.vonforst.evmap.viewmodel.savedStateViewModelFactory
import java.text.NumberFormat
class ChargepriceFragment : Fragment() {
@@ -34,10 +40,12 @@ class ChargepriceFragment : Fragment() {
private var connectionErrorSnackbar: Snackbar? = null
private val vm: ChargepriceViewModel by viewModels(factoryProducer = {
viewModelFactory {
savedStateViewModelFactory { state ->
ChargepriceViewModel(
requireActivity().application,
getString(R.string.chargeprice_key)
getString(R.string.chargeprice_key),
getString(R.string.chargeprice_api_url),
state
)
}
})
@@ -45,6 +53,33 @@ class ChargepriceFragment : Fragment() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
sharedElementEnterTransition = MaterialContainerTransform()
if (savedInstanceState == null) {
val prefs = PreferenceDataSource(requireContext())
prefs.chargepriceCounter += 1
if ((prefs.chargepriceCounter - 30).mod(50) == 0) {
showDonationDialog()
}
}
}
override fun onResume() {
super.onResume()
vm.reloadPrefs()
}
private fun showDonationDialog() {
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.chargeprice_donation_dialog_title)
.setMessage(R.string.chargeprice_donation_dialog_detail)
.setNegativeButton(R.string.ok) { di, _ ->
di.cancel()
}
.setPositiveButton(R.string.donate) { di, _ ->
di.dismiss()
findNavController().navigate(R.id.action_chargeprice_to_donateFragment)
}
.show()
}
override fun onCreateView(
@@ -76,9 +111,7 @@ class ChargepriceFragment : Fragment() {
val fragmentArgs: ChargepriceFragmentArgs by navArgs()
val charger = fragmentArgs.charger
val dataSource = fragmentArgs.dataSource
vm.charger.value = charger
vm.dataSource.value = dataSource
if (vm.chargepoint.value == null) {
vm.chargepoint.value = charger.chargepointsMerged.get(0)
}
@@ -143,11 +176,11 @@ class ChargepriceFragment : Fragment() {
}
binding.imgChargepriceLogo.setOnClickListener {
(requireActivity() as MapsActivity).openUrl("https://www.chargeprice.app/?poi_id=${charger.id}&poi_source=${dataSource}")
(requireActivity() as MapsActivity).openUrl(ChargepriceApi.getPoiUrl(charger))
}
binding.btnSettings.setOnClickListener {
findNavController().navigate(R.id.action_chargeprice_to_settingsFragment)
findNavController().navigate(R.id.action_chargeprice_to_chargepriceSettingsFragment)
}
binding.batteryRange.setLabelFormatter { value: Float ->

View File

@@ -4,13 +4,12 @@ import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.appcompat.app.AppCompatDialogFragment
import net.vonforst.evmap.databinding.DialogDataSourceSelectBinding
import net.vonforst.evmap.model.FILTERS_DISABLED
import net.vonforst.evmap.storage.PreferenceDataSource
import java.util.*
import net.vonforst.evmap.ui.MaterialDialogFragment
class DataSourceSelectDialog : AppCompatDialogFragment() {
class DataSourceSelectDialog : MaterialDialogFragment() {
private lateinit var binding: DialogDataSourceSelectBinding
var okListener: ((String) -> Unit)? = null
@@ -41,16 +40,12 @@ class DataSourceSelectDialog : AppCompatDialogFragment() {
override fun onStart() {
super.onStart()
// dialog with 95% screen height
dialog?.window?.setLayout(
ViewGroup.LayoutParams.MATCH_PARENT,
(resources.displayMetrics.heightPixels * 0.95).toInt()
)
setFullSize()
}
private lateinit var prefs: PreferenceDataSource
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
override fun initView(view: View, savedInstanceState: Bundle?) {
val args = requireArguments()
binding.btnCancel.visibility =
if (args.getBoolean("cancel_enabled")) View.VISIBLE else View.GONE

View File

@@ -20,23 +20,23 @@ import com.car2go.maps.model.LatLng
import com.google.android.material.color.MaterialColors
import com.google.android.material.snackbar.Snackbar
import com.google.android.material.transition.MaterialFadeThrough
import com.mapzen.android.lost.api.LocationServices
import com.mapzen.android.lost.api.LostApiClient
import net.vonforst.evmap.MapsActivity
import net.vonforst.evmap.R
import net.vonforst.evmap.adapter.DataBindingAdapter
import net.vonforst.evmap.adapter.FavoritesAdapter
import net.vonforst.evmap.databinding.FragmentFavoritesBinding
import net.vonforst.evmap.databinding.ItemFavoriteBinding
import net.vonforst.evmap.location.FusionEngine
import net.vonforst.evmap.location.LocationEngine
import net.vonforst.evmap.model.Favorite
import net.vonforst.evmap.model.FavoriteWithDetail
import net.vonforst.evmap.utils.checkAnyLocationPermission
import net.vonforst.evmap.viewmodel.FavoritesViewModel
import net.vonforst.evmap.viewmodel.viewModelFactory
class FavoritesFragment : Fragment(), LostApiClient.ConnectionCallbacks {
class FavoritesFragment : Fragment() {
private lateinit var binding: FragmentFavoritesBinding
private var locationClient: LostApiClient? = null
private lateinit var locationEngine: LocationEngine
private var toDelete: Favorite? = null
private var deleteSnackbar: Snackbar? = null
private lateinit var adapter: FavoritesAdapter
@@ -52,8 +52,8 @@ class FavoritesFragment : Fragment(), LostApiClient.ConnectionCallbacks {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
locationClient = LostApiClient.Builder(requireContext())
.addConnectionCallbacks(this).build()
locationEngine = FusionEngine(requireContext())
enterTransition = MaterialFadeThrough()
exitTransition = MaterialFadeThrough()
@@ -109,8 +109,6 @@ class FavoritesFragment : Fragment(), LostApiClient.ConnectionCallbacks {
}
createTouchHelper().attachToRecyclerView(binding.favsList)
locationClient!!.connect()
binding.swipeRefresh.setOnRefreshListener {
vm.reloadAvailability() {
binding.swipeRefresh.isRefreshing = false
@@ -118,27 +116,17 @@ class FavoritesFragment : Fragment(), LostApiClient.ConnectionCallbacks {
}
}
override fun onConnected() {
val context = this.context ?: return
if (context.checkAnyLocationPermission()) {
val location = LocationServices.FusedLocationApi.getLastLocation(locationClient!!)
if (location != null) {
vm.location.value = LatLng(location.latitude, location.longitude)
override fun onStart() {
super.onStart()
if (requireContext().checkAnyLocationPermission()) {
val location = locationEngine.getLastKnownLocation()
location?.let {
vm.location.value = LatLng(it.latitude, it.longitude)
}
}
}
override fun onConnectionSuspended() {
}
override fun onDestroy() {
super.onDestroy()
locationClient?.let {
if (it.isConnected) it.disconnect()
}
}
fun delete(fav: FavoriteWithDetail) {
val position =
vm.listData.value?.indexOfFirst { it.fav.favorite.favoriteId == fav.favorite.favoriteId }

View File

@@ -3,9 +3,11 @@ package net.vonforst.evmap.fragment
import android.os.Bundle
import android.view.*
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.MenuProvider
import androidx.databinding.DataBindingUtil
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.findNavController
import androidx.navigation.ui.setupWithNavController
@@ -22,7 +24,7 @@ import net.vonforst.evmap.ui.showEditTextDialog
import net.vonforst.evmap.viewmodel.FilterViewModel
class FilterFragment : Fragment() {
class FilterFragment : Fragment(), MenuProvider {
private lateinit var binding: FragmentFilterBinding
private val vm: FilterViewModel by viewModels()
@@ -40,9 +42,6 @@ class FilterFragment : Fragment() {
binding = DataBindingUtil.inflate(inflater, R.layout.fragment_filter, container, false)
binding.lifecycleOwner = this
binding.vm = vm
setHasOptionsMenu(true)
vm.filterProfile.observe(viewLifecycleOwner) {}
return binding.root
@@ -50,6 +49,7 @@ class FilterFragment : Fragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
(requireActivity() as AppCompatActivity).setSupportActionBar(binding.toolbar)
requireActivity().addMenuProvider(this, viewLifecycleOwner, Lifecycle.State.RESUMED)
binding.toolbar.setupWithNavController(
findNavController(),
@@ -81,12 +81,11 @@ class FilterFragment : Fragment() {
view.setBackgroundColor(MaterialColors.getColor(view, android.R.attr.windowBackground))
}
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
override fun onCreateMenu(menu: Menu, inflater: MenuInflater) {
inflater.inflate(R.menu.filter, menu)
super.onCreateOptionsMenu(menu, inflater)
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
override fun onMenuItemSelected(item: MenuItem): Boolean {
return when (item.itemId) {
R.id.menu_apply -> {
lifecycleScope.launch {
@@ -99,7 +98,7 @@ class FilterFragment : Fragment() {
saveProfile()
true
}
else -> super.onOptionsItemSelected(item)
else -> false
}
}

View File

@@ -4,11 +4,9 @@ import android.Manifest.permission.ACCESS_COARSE_LOCATION
import android.Manifest.permission.ACCESS_FINE_LOCATION
import android.annotation.SuppressLint
import android.content.Context
import android.content.pm.PackageManager
import android.content.res.Configuration
import android.graphics.Color
import android.location.Geocoder
import android.location.Location
import android.os.Bundle
import android.text.method.KeyListener
import android.view.*
@@ -18,15 +16,12 @@ import android.widget.ImageView
import android.widget.TextView
import android.widget.Toast
import androidx.activity.OnBackPressedCallback
import androidx.activity.result.contract.ActivityResultContracts
import androidx.annotation.RequiresPermission
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.widget.PopupMenu
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.core.view.MenuCompat
import androidx.core.view.doOnLayout
import androidx.core.view.doOnNextLayout
import androidx.core.view.updateLayoutParams
import androidx.core.location.LocationListenerCompat
import androidx.core.view.*
import androidx.databinding.DataBindingUtil
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
@@ -54,6 +49,7 @@ import com.car2go.maps.model.BitmapDescriptor
import com.car2go.maps.model.LatLng
import com.car2go.maps.model.Marker
import com.car2go.maps.model.MarkerOptions
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar
import com.google.android.material.transition.MaterialArcMotion
import com.google.android.material.transition.MaterialContainerTransform
@@ -63,26 +59,27 @@ import com.mahc.custombottomsheetbehavior.BottomSheetBehaviorGoogleMapsLike
import com.mahc.custombottomsheetbehavior.BottomSheetBehaviorGoogleMapsLike.STATE_COLLAPSED
import com.mahc.custombottomsheetbehavior.BottomSheetBehaviorGoogleMapsLike.STATE_HIDDEN
import com.mahc.custombottomsheetbehavior.MergedAppBarLayoutBehavior
import com.mapzen.android.lost.api.LocationListener
import com.mapzen.android.lost.api.LocationRequest
import com.mapzen.android.lost.api.LocationServices
import com.mapzen.android.lost.api.LostApiClient
import com.stfalcon.imageviewer.StfalconImageViewer
import io.michaelrocks.bimap.HashBiMap
import io.michaelrocks.bimap.MutableBiMap
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import net.vonforst.evmap.*
import net.vonforst.evmap.BuildConfig
import net.vonforst.evmap.MapsActivity
import net.vonforst.evmap.R
import net.vonforst.evmap.adapter.ConnectorAdapter
import net.vonforst.evmap.adapter.DetailsAdapter
import net.vonforst.evmap.adapter.GalleryAdapter
import net.vonforst.evmap.adapter.PlaceAutocompleteAdapter
import net.vonforst.evmap.api.goingelectric.GoingElectricApiWrapper
import net.vonforst.evmap.api.openchargemap.OpenChargeMapApiWrapper
import net.vonforst.evmap.autocomplete.ApiUnavailableException
import net.vonforst.evmap.autocomplete.PlaceWithBounds
import net.vonforst.evmap.bold
import net.vonforst.evmap.databinding.FragmentMapBinding
import net.vonforst.evmap.location.FusionEngine
import net.vonforst.evmap.location.LocationEngine
import net.vonforst.evmap.location.Priority
import net.vonforst.evmap.model.*
import net.vonforst.evmap.storage.PreferenceDataSource
import net.vonforst.evmap.ui.*
@@ -98,14 +95,13 @@ import kotlin.collections.contains
import kotlin.collections.set
class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallback,
LostApiClient.ConnectionCallbacks, LocationListener {
class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallback, MenuProvider {
private lateinit var binding: FragmentMapBinding
private val vm: MapViewModel by viewModels()
private val galleryVm: GalleryViewModel by activityViewModels()
private var mapFragment: MapFragment? = null
private var map: AnyMap? = null
private lateinit var locationClient: LostApiClient
private lateinit var locationEngine: LocationEngine
private var requestingLocationUpdates = false
private lateinit var bottomSheetBehavior: BottomSheetBehaviorGoogleMapsLike<View>
private lateinit var detailAppBarBehavior: MergedAppBarLayoutBehavior
@@ -116,6 +112,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
private var searchResultIcon: BitmapDescriptor? = null
private var connectionErrorSnackbar: Snackbar? = null
private var previousChargepointIds: Set<Long>? = null
private var mapTopPadding: Int = 0
private lateinit var clusterIconGenerator: ClusterIconGenerator
private lateinit var chargerIconGenerator: ChargerIconGenerator
@@ -149,10 +146,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
prefs = PreferenceDataSource(requireContext())
locationClient = LostApiClient.Builder(requireContext())
.addConnectionCallbacks(this)
.build()
locationClient.connect()
locationEngine = FusionEngine(requireContext())
clusterIconGenerator = ClusterIconGenerator(requireContext())
enterTransition = MaterialFadeThrough()
@@ -173,7 +167,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
val provider = prefs.mapProvider
if (mapFragment == null) {
mapFragment =
requireActivity().supportFragmentManager.findFragmentByTag(mapFragmentTag) as MapFragment?
childFragmentManager.findFragmentByTag(mapFragmentTag) as MapFragment?
}
if (mapFragment == null || mapFragment!!.priority[0] != provider) {
mapFragment = MapFragment()
@@ -186,7 +180,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
MapFragment.GOOGLE,
MapFragment.MAPBOX
)
requireActivity().supportFragmentManager
childFragmentManager
.beginTransaction()
.replace(R.id.map, mapFragment!!, mapFragmentTag)
.commit()
@@ -199,21 +193,23 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
searchResultIcon = null
}
setHasOptionsMenu(true)
ViewCompat.setOnApplyWindowInsetsListener(
binding.root
) { v, insets ->
ViewCompat.onApplyWindowInsets(binding.root, insets)
binding.root.setOnApplyWindowInsetsListener { _, insets ->
val systemWindowInsetTop = insets.getInsets(WindowInsetsCompat.Type.statusBars()).top
binding.detailAppBar.toolbar.updateLayoutParams<ViewGroup.MarginLayoutParams> {
topMargin = insets.systemWindowInsetTop
topMargin = systemWindowInsetTop
}
// margin of layers button
// margin of layers button: status bar height + toolbar height + margin
val density = resources.displayMetrics.density
// status bar height + toolbar height + margin
val margin =
if (binding.toolbarContainer.layoutParams.width == ViewGroup.LayoutParams.MATCH_PARENT) {
insets.systemWindowInsetTop + (48 * density).toInt() + (28 * density).toInt()
systemWindowInsetTop + (48 * density).toInt() + (28 * density).toInt()
} else {
insets.systemWindowInsetTop + (12 * density).toInt()
systemWindowInsetTop + (12 * density).toInt()
}
binding.fabLayers.updateLayoutParams<ViewGroup.MarginLayoutParams> {
topMargin = margin
@@ -221,6 +217,13 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
binding.layersSheet.updateLayoutParams<ViewGroup.MarginLayoutParams> {
topMargin = margin
}
// set map padding so that compass is not obstructed by toolbar
mapTopPadding = systemWindowInsetTop + (48 * density).toInt() + (16 * density).toInt()
// if we actually use map.setPadding here, Mapbox will re-trigger onApplyWindowInsets
// and cause an infinite loop. So we rely on onMapReady being called later than
// onApplyWindowInsets.
insets
}
@@ -236,6 +239,8 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
requireActivity().addMenuProvider(this, viewLifecycleOwner, Lifecycle.State.RESUMED)
mapFragment!!.getMapAsync(this)
bottomSheetBehavior = BottomSheetBehaviorGoogleMapsLike.from(binding.bottomSheet)
detailAppBarBehavior = MergedAppBarLayoutBehavior.from(binding.detailAppBar)
@@ -310,19 +315,25 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
vm.reloadPrefs()
if (requestingLocationUpdates && requireContext().checkAnyLocationPermission()
&& locationClient.isConnected
) {
requestLocationUpdates()
}
}
@SuppressLint("MissingPermission")
private val requestPermissionLauncher =
registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) {
val context = context ?: return@registerForActivityResult
if (context.checkAnyLocationPermission()) {
enableLocation(moveTo = true, animate = true)
}
}
private fun setupClickListeners() {
binding.fabLocate.setOnClickListener {
if (!requireContext().checkFineLocationPermission()) {
ActivityCompat.requestPermissions(
requireActivity(),
arrayOf(ACCESS_FINE_LOCATION, ACCESS_COARSE_LOCATION),
REQUEST_LOCATION_PERMISSION
requestPermissionLauncher.launch(
arrayOf(ACCESS_FINE_LOCATION, ACCESS_COARSE_LOCATION)
)
}
if (requireContext().checkAnyLocationPermission()) {
@@ -351,16 +362,11 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
}
binding.detailView.btnChargeprice.setOnClickListener {
val charger = vm.charger.value?.data ?: return@setOnClickListener
val dataSource = when (vm.apiType) {
GoingElectricApiWrapper::class.java -> "going_electric"
OpenChargeMapApiWrapper::class.java -> "open_charge_map"
else -> throw IllegalArgumentException("unsupported data source")
}
val extras =
FragmentNavigatorExtras(binding.detailView.btnChargeprice to getString(R.string.shared_element_chargeprice))
findNavController().navigate(
R.id.action_map_to_chargepriceFragment,
ChargepriceFragmentArgs(charger, dataSource).toBundle(),
ChargepriceFragmentArgs(charger).toBundle(),
null, extras
)
}
@@ -593,35 +599,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
updateFavoriteToggle()
})
vm.searchResult.observe(viewLifecycleOwner, Observer { place ->
val map = this.map ?: return@Observer
searchResultMarker?.remove()
searchResultMarker = null
if (place != null) {
// disable location following when search result is shown
vm.myLocationEnabled.value = false
if (place.viewport != null) {
map.animateCamera(map.cameraUpdateFactory.newLatLngBounds(place.viewport, 0))
} else {
map.animateCamera(map.cameraUpdateFactory.newLatLngZoom(place.latLng, 12f))
}
if (searchResultIcon == null) {
searchResultIcon =
map.bitmapDescriptorFactory.fromResource(R.drawable.ic_map_marker)
}
searchResultMarker = map.addMarker(
MarkerOptions()
.z(placeSearchZ)
.position(place.latLng)
.icon(searchResultIcon)
.anchor(0.5f, 1f)
)
} else {
binding.search.setText("")
}
updateBackPressedCallback()
displaySearchResult(place, moveCamera = true)
})
vm.layersMenuOpen.observe(viewLifecycleOwner, Observer { open ->
binding.fabLayers.visibility = if (open) View.INVISIBLE else View.VISIBLE
@@ -638,6 +616,40 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
updateBackPressedCallback()
}
private fun displaySearchResult(place: PlaceWithBounds?, moveCamera: Boolean) {
val map = this.map ?: return
searchResultMarker?.remove()
searchResultMarker = null
if (place != null) {
// disable location following when search result is shown
if (moveCamera) {
vm.myLocationEnabled.value = false
if (place.viewport != null) {
map.animateCamera(map.cameraUpdateFactory.newLatLngBounds(place.viewport, 0))
} else {
map.animateCamera(map.cameraUpdateFactory.newLatLngZoom(place.latLng, 12f))
}
}
if (searchResultIcon == null) {
searchResultIcon =
map.bitmapDescriptorFactory.fromResource(R.drawable.ic_map_marker)
}
searchResultMarker = map.addMarker(
MarkerOptions()
.z(placeSearchZ)
.position(place.latLng)
.icon(searchResultIcon)
.anchor(0.5f, 1f)
)
} else {
binding.search.setText("")
}
updateBackPressedCallback()
}
private fun updateBackPressedCallback() {
backPressedCallback.isEnabled =
vm.bottomSheetState.value != null && vm.bottomSheetState.value != STATE_HIDDEN
@@ -726,6 +738,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
}
}
.withStartPosition(position)
.withHiddenStatusBar(false)
.show()
}
@@ -798,7 +811,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
it.name
}
}
AlertDialog.Builder(activity)
MaterialAlertDialogBuilder(activity)
.setTitle(R.string.charge_cards)
.setItems(names.toTypedArray()) { _, i ->
val card = data[i]
@@ -888,7 +901,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
map.setTrafficEnabled(vm.mapTrafficEnabled.value ?: false)
// set padding so that compass is not obstructed by toolbar
map.setPadding(0, binding.toolbarContainer.height, 0, 0)
map.setPadding(0, mapTopPadding, 0, 0)
val mode = resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK
map.setMapStyle(
@@ -996,7 +1009,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
if (vm.searchResult.value != null) {
// show search result (after configuration change)
vm.searchResult.postValue(vm.searchResult.value)
displaySearchResult(vm.searchResult.value, moveCamera = !positionSet)
}
}
@@ -1007,16 +1020,14 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
map.uiSettings.setMyLocationButtonEnabled(false)
if (moveTo) {
vm.myLocationEnabled.value = true
if (locationClient.isConnected) {
moveToLastLocation(map, animate)
requestLocationUpdates()
}
moveToLastLocation(map, animate)
requestLocationUpdates()
}
}
@RequiresPermission(anyOf = [ACCESS_FINE_LOCATION, ACCESS_COARSE_LOCATION])
private fun moveToLastLocation(map: AnyMap, animate: Boolean) {
val location = LocationServices.FusedLocationApi.getLastLocation(locationClient)
val location = locationEngine.getLastKnownLocation()
if (location != null) {
val latLng = LatLng(location.latitude, location.longitude)
vm.location.value = latLng
@@ -1041,7 +1052,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
// update icons of existing markers (connector filter may have changed)
for ((marker, charger) in markers) {
val highlight = charger == vm.chargerSparse.value
val highlight = charger.id == vm.chargerSparse.value?.id
marker.setIcon(
chargerIconGenerator.getBitmapDescriptor(
getMarkerTint(charger, vm.filteredConnectors.value),
@@ -1065,7 +1076,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
// animate marker if it is visible, otherwise remove immediately
if (bounds.contains(marker.position)) {
val tint = getMarkerTint(charger, vm.filteredConnectors.value)
val highlight = charger == vm.chargerSparse.value
val highlight = charger.id == vm.chargerSparse.value?.id
val fault = charger.faultReport != null
val multi = charger.isMulti(vm.filteredConnectors.value)
val fav =
@@ -1085,7 +1096,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
for (charger in chargers) {
if (!map1.contains(charger.id)) {
val tint = getMarkerTint(charger, vm.filteredConnectors.value)
val highlight = charger == vm.chargerSparse.value
val highlight = charger.id == vm.chargerSparse.value?.id
val fault = charger.faultReport != null
val multi = charger.isMulti(vm.filteredConnectors.value)
val fav = charger.id in vm.favorites.value?.map { it.charger.id } ?: emptyList()
@@ -1133,23 +1144,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
}
}
@SuppressLint("MissingPermission")
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<out String>,
grantResults: IntArray
) {
when (requestCode) {
REQUEST_LOCATION_PERMISSION -> {
if ((grantResults.isNotEmpty() && grantResults.any { it == PackageManager.PERMISSION_GRANTED })) {
enableLocation(moveTo = true, animate = true)
}
}
else -> super.onRequestPermissionsResult(requestCode, permissions, grantResults)
}
}
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
override fun onCreateMenu(menu: Menu, inflater: MenuInflater) {
inflater.inflate(R.menu.map, menu)
val filterItem = menu.findItem(R.id.menu_filter)
@@ -1264,6 +1259,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
}
})
})
popup.setTouchModal(false)
popup.show()
}
@@ -1287,42 +1283,35 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
}
}
override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
return false
}
override fun getRootView(): View {
return binding.root
}
override fun onConnected() {
val map = this.map ?: return
val context = this.context ?: return
if (vm.myLocationEnabled.value == true) {
if (context.checkAnyLocationPermission()) {
moveToLastLocation(map, false)
requestLocationUpdates()
}
}
}
@RequiresPermission(ACCESS_FINE_LOCATION)
@RequiresPermission(anyOf = [ACCESS_FINE_LOCATION, ACCESS_COARSE_LOCATION])
private fun requestLocationUpdates() {
val request: LocationRequest = LocationRequest.create()
.setPriority(LocationRequest.PRIORITY_HIGH_ACCURACY)
.setInterval(5000)
LocationServices.FusedLocationApi.requestLocationUpdates(locationClient, request, this)
locationEngine.requestLocationUpdates(
Priority.HIGH_ACCURACY,
5000,
locationListener
)
requestingLocationUpdates = true
}
@SuppressLint("MissingPermission")
private fun removeLocationUpdates() {
if (locationClient.isConnected) {
LocationServices.FusedLocationApi.removeLocationUpdates(locationClient, this)
if (context?.checkAnyLocationPermission() == true) {
locationEngine.removeUpdates(locationListener)
}
}
override fun onConnectionSuspended() {
}
override fun onLocationChanged(location: Location?) {
val map = this.map ?: return
if (location == null || vm.myLocationEnabled.value == false) return
private val locationListener = LocationListenerCompat { location ->
val map = this.map ?: return@LocationListenerCompat
if (vm.myLocationEnabled.value == false) return@LocationListenerCompat
val latLng = LatLng(location.latitude, location.longitude)
val oldLoc = vm.location.value
@@ -1347,8 +1336,5 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
override fun onDestroy() {
super.onDestroy()
if (locationClient.isConnected) {
locationClient.disconnect()
}
}
}

View File

@@ -4,20 +4,16 @@ import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.WindowManager
import androidx.appcompat.app.AppCompatDialogFragment
import androidx.core.widget.doAfterTextChanged
import androidx.recyclerview.widget.LinearLayoutManager
import net.vonforst.evmap.R
import net.vonforst.evmap.adapter.DataBindingAdapter
import net.vonforst.evmap.adapter.Equatable
import net.vonforst.evmap.databinding.DialogMultiSelectBinding
import net.vonforst.evmap.ui.MaterialDialogFragment
import java.util.*
import kotlin.collections.HashMap
import kotlin.collections.HashSet
import kotlin.math.roundToInt
class MultiSelectDialog : AppCompatDialogFragment() {
class MultiSelectDialog : MaterialDialogFragment() {
companion object {
fun getInstance(
title: String,
@@ -54,19 +50,10 @@ class MultiSelectDialog : AppCompatDialogFragment() {
override fun onStart() {
super.onStart()
val density = resources.displayMetrics.density
val width = resources.displayMetrics.widthPixels
val maxWidth = (500 * density).roundToInt()
// dialog with 95% screen height
dialog?.window?.setLayout(
if (width < maxWidth) WindowManager.LayoutParams.MATCH_PARENT else maxWidth,
(resources.displayMetrics.heightPixels * 0.95).toInt()
)
setFullSize(maxWidthDp = 500)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
override fun initView(view: View, savedInstanceState: Bundle?) {
val args = requireArguments()
val data = args.getSerializable("data") as HashMap<String, String>
val selected = args.getSerializable("selected") as HashSet<String>

View File

@@ -16,7 +16,8 @@ class ChargepriceSettingsFragment : BaseSettingsFragment() {
viewModelFactory {
SettingsViewModel(
requireActivity().application,
getString(R.string.chargeprice_key)
getString(R.string.chargeprice_key),
getString(R.string.chargeprice_api_url)
)
}
})
@@ -69,7 +70,8 @@ class ChargepriceSettingsFragment : BaseSettingsFragment() {
R.plurals.chargeprice_some_tariffs_selected,
n,
n
) + "\n" + getString(R.string.pref_my_tariffs_summary)
) + "\n" + requireContext().resources
.getQuantityString(R.plurals.pref_my_tariffs_summary, n)
}
}

View File

@@ -17,7 +17,8 @@ class DataSettingsFragment : BaseSettingsFragment() {
viewModelFactory {
SettingsViewModel(
requireActivity().application,
getString(R.string.chargeprice_key)
getString(R.string.chargeprice_key),
getString(R.string.chargeprice_api_url)
)
}
})

View File

@@ -4,15 +4,13 @@ import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.WindowManager
import androidx.appcompat.app.AppCompatDialogFragment
import androidx.navigation.fragment.findNavController
import net.vonforst.evmap.R
import net.vonforst.evmap.databinding.DialogOpensourceDonationsBinding
import net.vonforst.evmap.storage.PreferenceDataSource
import kotlin.math.roundToInt
import net.vonforst.evmap.ui.MaterialDialogFragment
class OpensourceDonationsDialogFramgent : AppCompatDialogFragment() {
class OpensourceDonationsDialogFragment : MaterialDialogFragment() {
private lateinit var binding: DialogOpensourceDonationsBinding
override fun onCreateView(
@@ -24,9 +22,7 @@ class OpensourceDonationsDialogFramgent : AppCompatDialogFragment() {
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
override fun initView(view: View, savedInstanceState: Bundle?) {
val prefs = PreferenceDataSource(requireContext())
binding.btnOk.setOnClickListener {
prefs.opensourceDonationsDialogShown = true
@@ -44,14 +40,5 @@ class OpensourceDonationsDialogFramgent : AppCompatDialogFragment() {
override fun onStart() {
super.onStart()
val density = resources.displayMetrics.density
val width = resources.displayMetrics.widthPixels
val maxWidth = (500 * density).roundToInt()
dialog?.window?.setLayout(
if (width < maxWidth) WindowManager.LayoutParams.MATCH_PARENT else maxWidth,
WindowManager.LayoutParams.WRAP_CONTENT
)
}
}

View File

@@ -0,0 +1,271 @@
package net.vonforst.evmap.location
import android.Manifest
import android.content.Context
import android.location.Location
import android.location.LocationManager
import android.os.Build
import android.os.SystemClock
import android.util.Log
import androidx.annotation.RequiresApi
import androidx.annotation.RequiresPermission
import androidx.core.location.LocationListenerCompat
/**
* Location engine that fuses GPS and network locations.
*
* Simplified version of
* https://github.com/lostzen/lost/blob/master/lost/src/main/java/com/mapzen/android/lost/internal/FusionEngine.java
*/
class FusionEngine(context: Context) : LocationEngine(context),
LocationListenerCompat {
/**
* Location updates more than 60 seconds old are considered stale.
*/
private val RECENT_UPDATE_THRESHOLD_IN_MILLIS = (60 * 1000).toLong()
private val RECENT_UPDATE_THRESHOLD_IN_NANOS = RECENT_UPDATE_THRESHOLD_IN_MILLIS * 1000000
private val TAG = FusionEngine::class.java.simpleName
private val locationManager =
context.getSystemService(Context.LOCATION_SERVICE) as LocationManager
private var gpsLocation: Location? = null
private var networkLocation: Location? = null
private val supportsSystemFusedProvider: Boolean
get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && locationManager.allProviders.contains(
LocationManager.FUSED_PROVIDER
)
@RequiresPermission(anyOf = [Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION])
override fun getLastKnownLocation(): Location? {
if (supportsSystemFusedProvider) {
try {
return locationManager.getLastKnownLocation(LocationManager.FUSED_PROVIDER)
} catch (e: SecurityException) {
Log.e(TAG, "Permissions not granted for fused provider", e)
}
}
val minTime = SystemClock.elapsedRealtimeNanos() - RECENT_UPDATE_THRESHOLD_IN_NANOS
var bestLocation: Location? = null
var bestAccuracy = Float.MAX_VALUE
var bestTime = Long.MIN_VALUE
for (provider in locationManager.allProviders) {
try {
val location = locationManager.getLastKnownLocation(provider)
if (location != null) {
val accuracy = location.accuracy
val time = location.elapsedRealtimeNanos
if (time > minTime && accuracy < bestAccuracy) {
bestLocation = location
bestAccuracy = accuracy
bestTime = time
} else if (time < minTime && bestAccuracy == Float.MAX_VALUE && time > bestTime) {
bestLocation = location
bestTime = time
}
}
} catch (e: SecurityException) {
Log.e(TAG, "Permissions not granted for provider: $provider", e)
}
}
return bestLocation
}
@RequiresPermission(anyOf = [Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION])
override fun enable() {
var networkInterval = Long.MAX_VALUE
var gpsInterval = Long.MAX_VALUE
var passiveInterval = Long.MAX_VALUE
for ((priority, interval) in requests) {
when (priority) {
Priority.HIGH_ACCURACY -> {
if (interval < gpsInterval) {
gpsInterval = interval
}
if (interval < networkInterval) {
networkInterval = interval
}
}
Priority.BALANCED_POWER_ACCURACY, Priority.LOW_POWER -> if (interval < networkInterval) {
networkInterval = interval
}
Priority.NO_POWER -> if (interval < passiveInterval) {
passiveInterval = interval
}
}
}
if (supportsSystemFusedProvider && gpsInterval < Long.MAX_VALUE) {
try {
enableFused(gpsInterval)
checkLastKnownFused()
return
} catch (e: SecurityException) {
Log.e(TAG, "Permissions not granted for fused provider", e)
}
}
var checkGps = false
if (gpsInterval < Long.MAX_VALUE) {
enableGps(gpsInterval)
checkGps = true
}
if (networkInterval < Long.MAX_VALUE) {
enableNetwork(networkInterval)
if (checkGps) {
val lastGps = locationManager.getLastKnownLocation(LocationManager.GPS_PROVIDER)
val lastNetwork =
locationManager.getLastKnownLocation(LocationManager.NETWORK_PROVIDER)
if (lastGps != null && lastNetwork != null) {
val useGps = lastGps.isBetterThan(lastNetwork)
if (useGps) {
checkLastKnownGps()
} else {
checkLastKnownNetwork()
}
} else if (lastGps != null) {
checkLastKnownGps()
} else {
checkLastKnownNetwork()
}
} else {
checkLastKnownNetwork()
}
}
if (passiveInterval < Long.MAX_VALUE) {
enablePassive(passiveInterval)
checkLastKnownPassive()
}
}
@RequiresPermission(anyOf = [Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION])
override fun disable() {
locationManager.removeUpdates(this)
}
@RequiresPermission(anyOf = [Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION])
private fun enableGps(interval: Long) {
try {
locationManager.requestLocationUpdates(
LocationManager.GPS_PROVIDER,
interval,
0f,
this,
looper
)
} catch (e: IllegalArgumentException) {
Log.e(TAG, "Unable to register for GPS updates.", e)
}
}
@RequiresPermission(anyOf = [Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION])
private fun enableNetwork(interval: Long) {
try {
locationManager.requestLocationUpdates(
LocationManager.NETWORK_PROVIDER,
interval,
0f,
this,
looper
)
} catch (e: IllegalArgumentException) {
Log.e(TAG, "Unable to register for network updates.", e)
}
}
@RequiresPermission(anyOf = [Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION])
private fun enablePassive(interval: Long) {
try {
locationManager.requestLocationUpdates(
LocationManager.PASSIVE_PROVIDER,
interval,
0f,
this,
looper
)
} catch (e: IllegalArgumentException) {
Log.e(TAG, "Unable to register for passive updates.", e)
}
}
@RequiresApi(Build.VERSION_CODES.S)
@RequiresPermission(anyOf = [Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION])
private fun enableFused(interval: Long) {
try {
locationManager.requestLocationUpdates(
LocationManager.FUSED_PROVIDER,
interval,
0f,
this,
looper
)
} catch (e: IllegalArgumentException) {
Log.e(TAG, "Unable to register for passive updates.", e)
}
}
@RequiresPermission(anyOf = [Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION])
private fun checkLastKnownGps() {
checkLastKnownAndNotify(LocationManager.GPS_PROVIDER)
}
@RequiresPermission(anyOf = [Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION])
private fun checkLastKnownNetwork() {
checkLastKnownAndNotify(LocationManager.NETWORK_PROVIDER)
}
@RequiresPermission(anyOf = [Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION])
private fun checkLastKnownPassive() {
checkLastKnownAndNotify(LocationManager.PASSIVE_PROVIDER)
}
@RequiresApi(Build.VERSION_CODES.S)
@RequiresPermission(anyOf = [Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION])
private fun checkLastKnownFused() {
checkLastKnownAndNotify(LocationManager.FUSED_PROVIDER)
}
@RequiresPermission(anyOf = [Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION])
private fun checkLastKnownAndNotify(provider: String) {
val location = locationManager.getLastKnownLocation(provider)
location?.let { onLocationChanged(it) }
}
override fun onLocationChanged(location: Location) {
if (LocationManager.FUSED_PROVIDER == location.provider) {
requests.forEach { it.listener.onLocationChanged(location) }
} else if (LocationManager.GPS_PROVIDER == location.provider) {
gpsLocation = location
if (gpsLocation.isBetterThan(networkLocation)) {
requests.forEach { it.listener.onLocationChanged(location) }
}
} else if (LocationManager.NETWORK_PROVIDER == location.provider) {
networkLocation = location
if (networkLocation.isBetterThan(gpsLocation)) {
requests.forEach { it.listener.onLocationChanged(location) }
}
}
}
private fun Location?.isBetterThan(other: Location?): Boolean {
if (this == null) {
return false
}
if (other == null) {
return true
}
if (this.elapsedRealtimeNanos
> other.elapsedRealtimeNanos + RECENT_UPDATE_THRESHOLD_IN_NANOS
) {
return true
}
if (!this.hasAccuracy()) {
return false
}
return if (!other.hasAccuracy()) {
true
} else this.accuracy < other.accuracy
}
}

View File

@@ -0,0 +1,83 @@
package net.vonforst.evmap.location
import android.Manifest
import android.content.Context
import android.location.Location
import android.location.LocationListener
import android.os.Looper
import androidx.annotation.RequiresPermission
/**
* Base class for [com.mapzen.android.lost.internal.FusionEngine].
*/
abstract class LocationEngine(protected val context: Context) {
protected val requests: MutableList<LocationRequest> = mutableListOf()
/**
* Return most best recent location available.
*/
@RequiresPermission(anyOf = [Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION])
abstract fun getLastKnownLocation(): Location?
/**
* Enables the engine on receiving a valid location request.
*
* @param request Valid location request to enable.
*/
@RequiresPermission(anyOf = [Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION])
fun requestLocationUpdates(priority: Priority, intervalMs: Long, listener: LocationListener) {
requests.add(LocationRequest(priority, intervalMs, listener))
enable()
}
/**
* Disables the engine when no requests remain, otherwise updates the engine's configuration.
*
* @param requests Valid location request to enable.
*/
@RequiresPermission(anyOf = [Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION])
fun removeUpdates(listener: LocationListener) {
this.requests.removeIf { it.listener == listener }
disable()
if (this.requests.isNotEmpty()) enable()
}
@RequiresPermission(anyOf = [Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION])
fun removeAllRequests() {
requests.clear()
disable()
}
/**
* Subclass should perform all operations required to enable the engine. (ex. Register for
* location updates.)
*/
@RequiresPermission(anyOf = [Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION])
protected abstract fun enable()
/**
* Subclass should perform all operations required to disable the engine. (ex. Remove location
* updates.)
*/
@RequiresPermission(anyOf = [Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION])
protected abstract fun disable()
protected val looper: Looper
get() = context.mainLooper
interface Callback {
fun reportLocation(location: Location)
}
}
data class LocationRequest(
val priority: Priority,
val intervalMs: Long,
val listener: LocationListener
)
enum class Priority {
HIGH_ACCURACY,
BALANCED_POWER_ACCURACY,
LOW_POWER,
NO_POWER
}

View File

@@ -1,12 +1,12 @@
package net.vonforst.evmap.navigation
import androidx.navigation.NavController
import androidx.navigation.NavHostController
import androidx.navigation.fragment.NavHostFragment
class NavHostFragment : NavHostFragment() {
override fun onCreateNavController(navController: NavController) {
super.onCreateNavController(navController)
navController.navigatorProvider.addNavigator(
override fun onCreateNavHostController(navHostController: NavHostController) {
super.onCreateNavHostController(navHostController)
navHostController.navigatorProvider.addNavigator(
CustomNavigator(
requireContext()
)

View File

@@ -3,6 +3,7 @@ package net.vonforst.evmap.storage
import android.content.Context
import androidx.preference.PreferenceManager
import com.car2go.maps.AnyMap
import com.car2go.maps.model.LatLng
import net.vonforst.evmap.R
import net.vonforst.evmap.model.FILTERS_CUSTOM
import net.vonforst.evmap.model.FILTERS_DISABLED
@@ -86,11 +87,14 @@ class PreferenceDataSource(val context: Context) {
context.getString(R.string.pref_map_provider_default)
)!!
val searchProvider: String
var searchProvider: String
get() = sp.getString(
"search_provider",
context.getString(R.string.pref_search_provider_default)
)!!
set(value) {
sp.edit().putString("search_provider", value).apply()
}
var mapType: AnyMap.Type
get() = AnyMap.Type.valueOf(sp.getString("map_type", null) ?: AnyMap.Type.NORMAL.toString())
@@ -198,9 +202,41 @@ class PreferenceDataSource(val context: Context) {
sp.edit().putLong("app_start_counter", value).apply()
}
/** Counter for how many times the price comparison page was opened,
* introduced with Version 1.3.4 **/
var chargepriceCounter: Long
get() = sp.getLong("chargeprice_counter", 0)
set(value) {
sp.edit().putLong("chargeprice_counter", value).apply()
}
var opensourceDonationsDialogShown: Boolean
get() = sp.getBoolean("opensource_donations_dialog_shown", false)
set(value) {
sp.edit().putBoolean("opensource_donations_dialog_shown", value).apply()
}
var placeSearchResultAndroidAuto: LatLng?
get() = if (sp.contains("place_search_result_android_auto_lat")) {
LatLng(
Double.fromBits(sp.getLong("place_search_result_android_auto_lat", 0L)),
Double.fromBits(sp.getLong("place_search_result_android_auto_lng", 0L))
)
} else null
set(value) {
if (value == null) {
sp.edit().remove("place_search_result_android_auto_lat")
.remove("place_search_result_android_auto_lng").apply()
} else {
sp.edit().putLong("place_search_result_android_auto_lat", value.latitude.toBits())
.putLong("place_search_result_android_auto_lng", value.longitude.toBits())
.apply()
}
}
var placeSearchResultAndroidAutoName: String?
get() = sp.getString("place_search_result_android_auto_name", null)
set(value) {
sp.edit().putString("place_search_result_android_auto_name", value).apply()
}
}

View File

@@ -65,6 +65,19 @@ abstract class RecentAutocompletePlaceDao {
limit: Int? = null
): List<RecentAutocompletePlace>
@Query("SELECT * FROM recentautocompleteplace WHERE dataSource = :dataSource AND primaryText LIKE '%' || :query || '%' ORDER BY timestamp DESC LIMIT :limit")
abstract suspend fun searchAsync(
query: String,
dataSource: String,
limit: Int? = null
): List<RecentAutocompletePlace>
@Query("SELECT * FROM recentautocompleteplace WHERE dataSource = :dataSource ORDER BY timestamp DESC LIMIT :limit")
abstract fun getAll(dataSource: String, limit: Int? = null): List<RecentAutocompletePlace>
@Query("SELECT * FROM recentautocompleteplace WHERE dataSource = :dataSource ORDER BY timestamp DESC LIMIT :limit")
abstract suspend fun getAllAsync(
dataSource: String,
limit: Int? = null
): List<RecentAutocompletePlace>
}

View File

@@ -389,9 +389,10 @@ private fun colorToTransparent(color: Int, targetAlpha: Float = 31f / 255): Int
val green = Color.green(color)
val blue = Color.blue(color)
val newRed = ((red - (1 - targetAlpha) * 255) / targetAlpha).roundToInt()
val newGreen = ((green - (1 - targetAlpha) * 255) / targetAlpha).roundToInt()
val newBlue = ((blue - (1 - targetAlpha) * 255) / targetAlpha).roundToInt()
val newRed = kotlin.math.max(((red - (1 - targetAlpha) * 255) / targetAlpha).roundToInt(), 0)
val newGreen =
kotlin.math.max(((green - (1 - targetAlpha) * 255) / targetAlpha).roundToInt(), 0)
val newBlue = kotlin.math.max(((blue - (1 - targetAlpha) * 255) / targetAlpha).roundToInt(), 0)
return Color.argb((targetAlpha * 255).roundToInt(), newRed, newGreen, newBlue)
}

View File

@@ -1,14 +1,20 @@
package net.vonforst.evmap.ui
import android.app.Dialog
import android.content.Context
import android.content.DialogInterface
import android.view.Gravity
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.WindowManager
import android.view.inputmethod.EditorInfo
import android.view.inputmethod.InputMethodManager
import android.widget.EditText
import android.widget.FrameLayout
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatDialogFragment
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import kotlin.math.roundToInt
private fun dialogEditText(ctx: Context): Pair<View, EditText> {
val container = FrameLayout(ctx)
@@ -24,30 +30,19 @@ private fun dialogEditText(ctx: Context): Pair<View, EditText> {
fun showEditTextDialog(
ctx: Context,
customize: (AlertDialog.Builder, EditText) -> Unit
customize: (MaterialAlertDialogBuilder, EditText) -> Unit
): AlertDialog {
val (container, input) = dialogEditText(ctx)
val dialogBuilder = AlertDialog.Builder(ctx)
val dialogBuilder = MaterialAlertDialogBuilder(ctx)
.setView(container)
customize(dialogBuilder, input)
val dialog = dialogBuilder.show()
// move dialog to top
val attrs = dialog.window?.attributes?.apply {
gravity = Gravity.TOP
}
dialog.window?.attributes = attrs
dialog.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE)
// focus and show keyboard
input.requestFocus()
input.postDelayed({
val imm =
ctx.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
imm.showSoftInput(input, InputMethodManager.SHOW_IMPLICIT)
}, 100)
input.setOnEditorActionListener { _, actionId, _ ->
if (actionId == EditorInfo.IME_ACTION_DONE) {
val text = input.text
@@ -60,4 +55,57 @@ fun showEditTextDialog(
false
}
return dialog
}
/**
* DialogFragment that uses Material styling.
* This needs a bit of a workaround, see also
* https://github.com/material-components/material-components-android/issues/540 and
* https://dev.to/bhullnatik/how-to-use-material-dialogs-with-dialogfragment-28i1
*/
abstract class MaterialDialogFragment : AppCompatDialogFragment() {
private lateinit var dialogView: View
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val dialog = MaterialAlertDialogBuilder(requireContext(), theme).apply {
dialogView =
onCreateView(LayoutInflater.from(requireContext()), null, savedInstanceState)!!
setView(dialogView)
}.create()
initView(dialogView, savedInstanceState)
return dialog
}
abstract fun initView(view: View, savedInstanceState: Bundle?)
override fun getView(): View {
return dialogView
}
override fun onStart() {
super.onStart()
// make sure that custom view fills whole dialog height
(view.parent as View).layoutParams.height = ViewGroup.LayoutParams.MATCH_PARENT
(view.parent.parent as View).layoutParams.height = ViewGroup.LayoutParams.MATCH_PARENT
(view.parent.parent.parent as View).layoutParams.height =
ViewGroup.LayoutParams.MATCH_PARENT
}
/**
* Makes the dialog fill the whole width & height of the screen, with an optional maximum
* width in dp. Call this during onStart.
*/
fun setFullSize(maxWidthDp: Int? = null) {
val width = resources.displayMetrics.widthPixels
val maxWidth = if (maxWidthDp != null) {
val density = resources.displayMetrics.density
(maxWidthDp * density).roundToInt()
} else null
dialog?.window?.setLayout(
if (maxWidth == null || width < maxWidth) WindowManager.LayoutParams.MATCH_PARENT else maxWidth,
WindowManager.LayoutParams.MATCH_PARENT
)
}
}

View File

@@ -0,0 +1,32 @@
package net.vonforst.evmap.ui
import android.annotation.SuppressLint
import androidx.appcompat.view.menu.MenuPopupHelper
import androidx.appcompat.widget.MenuPopupWindow
import androidx.appcompat.widget.PopupMenu
/**
* Reflection workaround to make setTouchModal accessible for
*/
@SuppressLint("RestrictedApi")
fun PopupMenu.setTouchModal(modal: Boolean) {
try {
val mPopup = javaClass.getDeclaredField("mPopup").let { field ->
field.isAccessible = true
field.get(this)
} as MenuPopupHelper
val mPopup2 = mPopup.javaClass.getDeclaredMethod("getPopup").let { method ->
method.isAccessible = true
method.invoke(mPopup)
}
val mPopup3 = mPopup2.javaClass.getDeclaredField("mPopup").let { field ->
field.isAccessible = true
field.get(mPopup2)
} as MenuPopupWindow
mPopup3.setTouchModal(modal)
} catch (e: NoSuchFieldException) {
e.printStackTrace()
} catch (e: NoSuchMethodException) {
e.printStackTrace()
}
}

View File

@@ -2,12 +2,12 @@ package net.vonforst.evmap.viewmodel
import android.app.Application
import androidx.lifecycle.*
import jsonapi.Meta
import jsonapi.Relationship
import jsonapi.Relationships
import jsonapi.ResourceIdentifier
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import moe.banana.jsonapi2.HasMany
import moe.banana.jsonapi2.HasOne
import moe.banana.jsonapi2.JsonBuffer
import moe.banana.jsonapi2.ResourceIdentifier
import net.vonforst.evmap.api.chargeprice.*
import net.vonforst.evmap.api.equivalentPlugTypes
import net.vonforst.evmap.model.ChargeLocation
@@ -16,30 +16,48 @@ import net.vonforst.evmap.storage.PreferenceDataSource
import retrofit2.HttpException
import java.io.IOException
class ChargepriceViewModel(application: Application, chargepriceApiKey: String) :
class ChargepriceViewModel(
application: Application,
chargepriceApiKey: String,
chargepriceApiUrl: String,
private val state: SavedStateHandle
) :
AndroidViewModel(application) {
private var api = ChargepriceApi.create(chargepriceApiKey)
private var api = ChargepriceApi.create(chargepriceApiKey, chargepriceApiUrl)
private var prefs = PreferenceDataSource(application)
val charger: MutableLiveData<ChargeLocation> by lazy {
MutableLiveData<ChargeLocation>()
}
val dataSource: MutableLiveData<String> by lazy {
MutableLiveData<String>()
state.getLiveData("charger")
}
val chargepoint: MutableLiveData<Chargepoint> by lazy {
MutableLiveData<Chargepoint>()
state.getLiveData("chargepoint")
}
val vehicles: MutableLiveData<Resource<List<ChargepriceCar>>> by lazy {
MutableLiveData<Resource<List<ChargepriceCar>>>().apply {
if (prefs.chargepriceMyVehicles.isEmpty()) {
value = Resource.success(emptyList())
} else {
value = Resource.loading(null)
loadVehicles()
private val vehicleIds: MutableLiveData<Set<String>> by lazy {
MutableLiveData<Set<String>>().apply {
value = prefs.chargepriceMyVehicles
}
}
val vehicles: LiveData<Resource<List<ChargepriceCar>>> by lazy {
MediatorLiveData<Resource<List<ChargepriceCar>>>().apply {
addSource(vehicleIds.distinctUntilChanged()) { vehicleIds ->
if (vehicleIds.isEmpty()) {
value = Resource.success(emptyList())
} else {
value = Resource.loading(null)
viewModelScope.launch {
value = try {
val result = api.getVehicles()
Resource.success(result.filter {
it.id in vehicleIds
})
} catch (e: IOException) {
Resource.error(e.message, null)
}
}
}
}
observeForever {
vehicle.value = it.data?.firstOrNull()
@@ -48,7 +66,7 @@ class ChargepriceViewModel(application: Application, chargepriceApiKey: String)
}
val vehicle: MutableLiveData<ChargepriceCar> by lazy {
MutableLiveData<ChargepriceCar>()
state.getLiveData("vehicle")
}
val vehicleCompatibleConnectors: LiveData<List<String>> by lazy {
@@ -94,21 +112,24 @@ class ChargepriceViewModel(application: Application, chargepriceApiKey: String)
}
}
val chargePrices: MediatorLiveData<Resource<List<ChargePrice>>> by lazy {
val chargePrices: MutableLiveData<Resource<List<ChargePrice>>> by lazy {
MediatorLiveData<Resource<List<ChargePrice>>>().apply {
value = Resource.loading(null)
value = state["chargePrices"] ?: Resource.loading(null)
listOf(
charger,
dataSource,
batteryRange,
batteryRangeSliderDragging,
vehicleCompatibleConnectors,
myTariffs, myTariffsAll
).forEach {
addSource(it) {
addSource(it.distinctUntilChanged()) {
if (!batteryRangeSliderDragging.value!!) loadPrices()
}
}
observeForever {
// persist data in case fragment gets recreated
state["chargePrices"] = it
}
}
}
@@ -140,15 +161,15 @@ class ChargepriceViewModel(application: Application, chargepriceApiKey: String)
if (filteredPrices.isEmpty()) {
null
} else {
cp.clone().apply {
cp.copy(
chargepointPrices = filteredPrices
}
)
}
}.filterNotNull()
.sortedBy { it.chargepointPrices.first().price }
.sortedByDescending {
prefs.chargepriceMyTariffsAll ||
myTariffs != null && it.tariff?.get()?.id in myTariffs
myTariffs != null && it.tariff?.id in myTariffs
}
)
}
@@ -157,6 +178,10 @@ class ChargepriceViewModel(application: Application, chargepriceApiKey: String)
}
}
fun reloadPrefs() {
vehicleIds.value = prefs.chargepriceMyVehicles
}
private fun getChargepricePlugType(chargepoint: Chargepoint): String {
val index = charger.value!!.chargepointsMerged.indexOf(chargepoint)
val type = charger.value!!.chargepriceData!!.plugTypes?.get(index) ?: chargepoint.type
@@ -210,10 +235,9 @@ class ChargepriceViewModel(application: Application, chargepriceApiKey: String)
val charger = charger.value
val car = vehicle.value
val compatibleConnectors = vehicleCompatibleConnectors.value
val dataSource = dataSource.value
val myTariffs = myTariffs.value
val myTariffsAll = myTariffsAll.value
if (charger == null || car == null || compatibleConnectors == null || dataSource == null || myTariffsAll == null || myTariffsAll == false && myTariffs == null) {
if (charger == null || car == null || compatibleConnectors == null || myTariffsAll == null || myTariffsAll == false && myTariffs == null) {
chargePrices.value = Resource.error(null, null)
return
}
@@ -223,34 +247,39 @@ class ChargepriceViewModel(application: Application, chargepriceApiKey: String)
loadPricesJob?.cancel()
loadPricesJob = viewModelScope.launch {
try {
val result = api.getChargePrices(ChargepriceRequest().apply {
dataAdapter = dataSource
station = cpStation
vehicle = HasOne(car)
tariffs = if (!myTariffsAll) {
HasMany<ChargepriceTariff>(*myTariffs!!.map {
ResourceIdentifier(
"tariff",
it
val result = api.getChargePrices(
ChargepriceRequest(
dataAdapter = ChargepriceApi.getDataAdapter(charger),
station = cpStation,
vehicle = car,
options = ChargepriceOptions(
batteryRange = batteryRange.value!!.map { it.toDouble() },
providerCustomerTariffs = prefs.chargepriceShowProviderCustomerTariffs,
maxMonthlyFees = if (prefs.chargepriceNoBaseFee) 0.0 else null,
currency = prefs.chargepriceCurrency,
allowUnbalancedLoad = prefs.chargepriceAllowUnbalancedLoad
),
relationships = if (!myTariffsAll) {
Relationships(
"tariffs" to Relationship.ToMany(
(myTariffs ?: emptySet()).map {
ResourceIdentifier(
"tariff",
id = it
)
},
meta = Meta.from(
ChargepriceRequestTariffMeta(ChargepriceInclude.ALWAYS),
ChargepriceApi.moshi
)
)
)
}.toTypedArray()).apply {
meta = JsonBuffer.create(
ChargepriceApi.moshi.adapter(ChargepriceRequestTariffMeta::class.java),
ChargepriceRequestTariffMeta(ChargepriceInclude.ALWAYS)
)
}
} else null
options = ChargepriceOptions(
batteryRange = batteryRange.value!!.map { it.toDouble() },
providerCustomerTariffs = prefs.chargepriceShowProviderCustomerTariffs,
maxMonthlyFees = if (prefs.chargepriceNoBaseFee) 0.0 else null,
currency = prefs.chargepriceCurrency,
allowUnbalancedLoad = prefs.chargepriceAllowUnbalancedLoad
)
}, ChargepriceApi.getChargepriceLanguage())
val meta =
result.meta.get<ChargepriceMeta>(ChargepriceApi.moshi.adapter(ChargepriceMeta::class.java)) as ChargepriceMeta
chargePrices.value = Resource.success(result)
} else null
), ChargepriceApi.getChargepriceLanguage()
)
val meta = result.meta!!.map(ChargepriceMeta::class.java, ChargepriceApi.moshi)!!
chargePrices.value = Resource.success(result.data)
chargePriceMeta.value = Resource.success(meta)
} catch (e: IOException) {
chargePrices.value = Resource.error(e.message, null)
@@ -261,17 +290,4 @@ class ChargepriceViewModel(application: Application, chargepriceApiKey: String)
}
}
}
private fun loadVehicles() {
viewModelScope.launch {
try {
val result = api.getVehicles()
vehicles.value = Resource.success(result.filter {
it.id in prefs.chargepriceMyVehicles
})
} catch (e: IOException) {
vehicles.value = Resource.error(e.message, null)
}
}
}
}

View File

@@ -86,4 +86,11 @@ class FilterViewModel(application: Application) : AndroidViewModel(application)
// set selected profile
prefs.filterStatus = profileId
}
suspend fun deleteCurrentProfile() {
filterProfile.value?.let {
db.filterProfileDao().delete(it)
prefs.filterStatus = FILTERS_DISABLED
}
}
}

View File

@@ -6,8 +6,10 @@ import androidx.lifecycle.*
import com.car2go.maps.AnyMap
import com.car2go.maps.model.LatLng
import com.car2go.maps.model.LatLngBounds
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.parcelize.Parcelize
import net.vonforst.evmap.api.ChargepointApi
import net.vonforst.evmap.api.availability.ChargeLocationStatus
@@ -141,17 +143,24 @@ class MapViewModel(application: Application, private val state: SavedStateHandle
}
val chargerDetails: MediatorLiveData<Resource<ChargeLocation>> by lazy {
MediatorLiveData<Resource<ChargeLocation>>().apply {
value = state["chargerDetails"]
listOf(chargerSparse, referenceData).forEach {
addSource(it) { _ ->
val charger = chargerSparse.value
val refData = referenceData.value
if (charger != null && refData != null) {
loadChargerDetails(charger, refData)
if (charger.id != value?.data?.id) {
loadChargerDetails(charger, refData)
}
} else {
value = null
}
}
}
observeForever {
// persist data in case fragment gets recreated
state["chargerDetails"] = it
}
}
}
val charger: MediatorLiveData<Resource<ChargeLocation>> by lazy {
@@ -270,7 +279,11 @@ class MapViewModel(application: Application, private val state: SavedStateHandle
}
suspend fun copyFiltersToCustom() {
filterStatus.value?.let { db.filterValueDao().copyFiltersToCustom(it, prefs.dataSource) }
filterStatus.value?.let {
withContext(Dispatchers.IO) {
db.filterValueDao().copyFiltersToCustom(it, prefs.dataSource)
}
}
}
fun setMapType(type: AnyMap.Type) {
@@ -311,7 +324,8 @@ class MapViewModel(application: Application, private val state: SavedStateHandle
}
minPower >= 100 -> {
// when only showing high-power chargers we can use large markers
zoom < clusterThreshold
// because the density is much lower
false
}
else -> {
zoom < miniMarkerThreshold
@@ -434,6 +448,11 @@ class MapViewModel(application: Application, private val state: SavedStateHandle
chargerDetails.value = response
if (response.status == Status.SUCCESS) {
chargerSparse.value = response.data
if (response.data != null && favorites.value?.any { it.charger.id == response.data.id } == true) {
// update data of stored favorite
db.chargeLocationsDao().insert(response.data)
}
} else {
chargerSparse.value = null
}

View File

@@ -11,9 +11,13 @@ import net.vonforst.evmap.api.chargeprice.ChargepriceTariff
import net.vonforst.evmap.storage.AppDatabase
import java.io.IOException
class SettingsViewModel(application: Application, chargepriceApiKey: String) :
class SettingsViewModel(
application: Application,
chargepriceApiKey: String,
chargepriceApiUrl: String
) :
AndroidViewModel(application) {
private var api = ChargepriceApi.create(chargepriceApiKey)
private var api = ChargepriceApi.create(chargepriceApiKey, chargepriceApiUrl)
private var db = AppDatabase.getInstance(application)
val vehicles: MutableLiveData<Resource<List<ChargepriceCar>>> by lazy {

View File

@@ -1,5 +1,6 @@
package net.vonforst.evmap.viewmodel
import android.os.Parcelable
import androidx.annotation.MainThread
import androidx.annotation.Nullable
import androidx.lifecycle.*
@@ -7,6 +8,8 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize
import kotlinx.parcelize.RawValue
import java.util.concurrent.atomic.AtomicBoolean
@@ -16,6 +19,16 @@ inline fun <VM : ViewModel> viewModelFactory(crossinline f: () -> VM) =
override fun <T : ViewModel> create(aClass: Class<T>): T = f() as T
}
@Suppress("UNCHECKED_CAST")
inline fun <VM : ViewModel> savedStateViewModelFactory(crossinline f: (SavedStateHandle) -> VM) =
object : AbstractSavedStateViewModelFactory() {
override fun <T : ViewModel> create(
key: String,
modelClass: Class<T>,
handle: SavedStateHandle
) = f(handle) as T
}
enum class Status {
SUCCESS,
ERROR,
@@ -24,9 +37,13 @@ enum class Status {
/**
* A generic class that holds a value with its loading status.
* @param <T>
</T> */
data class Resource<out T>(val status: Status, val data: T?, val message: String?) {
*
* Note that this class implements Parcelable for convenience, but will give a runtime error when
* trying to write it to a Parcel if the type parameter does not implement Parcelable.
*/
@Parcelize
data class Resource<out T>(val status: Status, val data: @RawValue T?, val message: String?) :
Parcelable {
companion object {
fun <T> success(data: T?): Resource<T> {
return Resource(Status.SUCCESS, data, null)

View File

@@ -0,0 +1,28 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#FFFFFF">
<group
android:scaleX="0.184"
android:scaleY="0.184"
android:translateX="0.96"
android:translateY="0.96">
<path
android:pathData="M27.1,88.3l-2.2,-19.2l-3.3,0.3l2.2,19.2L27.1,88.3zM39,86.9l-2.2,-19.2l-3.3,0.3l2.2,19.2L39,86.9z"
android:fillColor="#FFFFFF" />
<path
android:pathData="M45.2,113c-1,1.3 -1.8,2.1 -2,2.2c-3,2.4 -5.4,3.1 -7.4,2.2c-3.5,-1.7 -3.2,-8.2 -3.1,-8.9l2.4,0.1c-0.1,1.8 0.2,5.8 1.8,6.6c0.9,0.5 2.5,-0.1 4.6,-1.8l0,0c0,0 6.7,-6.7 5.3,-12c-1.6,-6.4 5.8,-15.5 8.2,-18.6l0.3,-0.3l2,1.5l-0.3,0.5c-7.5,9.2 -8.3,14 -7.7,16.4C50.5,105.4 47.4,110.4 45.2,113z"
android:fillColor="#FFFFFF" />
<path
android:pathData="M19.7,88.1l0.9,7.9l7.3,4.9l9.8,-1l6,-6.4l-0.9,-7.9L19.7,88.1z"
android:fillColor="#FFFFFF" />
<path
android:pathData="M37.6,99.7l-9.8,1l2.1,8.7l7.7,-0.9V99.7L37.6,99.7zM44.6,79l0.8,7.2l-28.2,3.2l-0.8,-7.2L44.6,79z"
android:fillColor="#FFFFFF" />
<path
android:pathData="M66.7,0C46.5,0 30.1,16.4 30.1,36.6c0,27.6 30.8,42 34.5,81.4c0.1,1.2 1,2 2.2,2c1.2,0 2.1,-0.8 2.2,-2c3.7,-39.4 34.5,-53.8 34.5,-81.4C103.3,16.2 86.9,0 66.7,0zM78.4,34.7L64.3,59V40.8h-6V18.7c0,0 20.2,0 20.1,-0.1l-8.1,16.2H78.4z"
android:fillColor="#FFFFFF" />
</group>
</vector>

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 583 B

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 393 B

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 778 B

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@color/chip_background" />
<item android:drawable="@color/my_tariff_background" />
<item android:drawable="?selectableItemBackground" />
</layer-list>

View File

@@ -3,7 +3,8 @@
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/drawer_layout"
android:layout_width="match_parent"
android:layout_height="match_parent">
android:layout_height="match_parent"
android:fitsSystemWindows="true">
<androidx.fragment.app.FragmentContainerView
android:id="@+id/nav_host_fragment"
@@ -18,7 +19,6 @@
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:layout_gravity="start"
android:fitsSystemWindows="true"
app:headerLayout="@layout/nav_header"
app:menu="@menu/drawer" />
</androidx.drawerlayout.widget.DrawerLayout>

View File

@@ -17,7 +17,7 @@
android:orientation="vertical">
<FrameLayout
android:id="@+id/topPanel"
android:id="@+id/topPane"
android:layout_width="0dp"
android:layout_height="88dp"
android:background="@color/colorPrimary"
@@ -46,7 +46,7 @@
android:textAppearance="@style/TextAppearance.Material3.HeadlineSmall"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/topPanel" />
app:layout_constraintTop_toBottomOf="@+id/topPane" />
<TextView
android:id="@+id/welcomeText1"

View File

@@ -107,7 +107,7 @@
android:id="@+id/textView4"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@{@string/chargeprice_stats(vm.chargepriceMetaForChargepoint.data.energy, BindingAdaptersKt.time((int) Math.round(vm.chargepriceMetaForChargepoint.data.duration)), vm.chargepriceMetaForChargepoint.data.power)}"
android:text="@{@string/chargeprice_stats(vm.chargepriceMetaForChargepoint.data.energy, BindingAdaptersKt.time((int) Math.round(vm.chargepriceMetaForChargepoint.data.duration)), vm.chargepriceMetaForChargepoint.data.energy / vm.chargepriceMetaForChargepoint.data.duration * 60)}"
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
app:invisibleUnlessAnimated="@{!vm.batteryRangeSliderDragging &amp;&amp; vm.chargepriceMetaForChargepoint.status == Status.SUCCESS}"
app:layout_constraintStart_toStartOf="@+id/textView2"
@@ -242,4 +242,4 @@
</androidx.core.widget.NestedScrollView>
</LinearLayout>
</layout>
</layout>

View File

@@ -37,7 +37,7 @@
android:paddingTop="8dp"
android:paddingRight="16dp"
android:paddingBottom="8dp"
android:background="@{BindingAdaptersKt.tariffBackground(context,!myTariffsAll &amp;&amp; myTariffs.contains(item.tariff.get().id), item.branding.backgroundColor)}">
android:background="@{BindingAdaptersKt.tariffBackground(context,!myTariffsAll &amp;&amp; myTariffs.contains(item.tariff.getId()), item.branding.backgroundColor)}">
<TextView
android:id="@+id/txtTariff"
@@ -167,7 +167,6 @@
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/guideline5"
app:layout_constraintTop_toTopOf="parent"
app:tintNullable="@{BindingAdaptersKt.isDarkMode(context) ? @android:color/white : null}"
tools:srcCompat="@tools:sample/avatars" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -3,7 +3,6 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingTop="24dp"
android:fitsSystemWindows="true"
android:id="@+id/nav_header">
<include layout="@layout/app_logo" />

View File

@@ -4,50 +4,54 @@
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/nav_graph">
<fragment
<navigation
android:id="@+id/map"
android:name="net.vonforst.evmap.fragment.MapFragment"
android:label=""
tools:layout="@layout/fragment_map">
<action
android:id="@+id/action_map_to_filterFragment"
app:destination="@id/filter"
app:exitAnim="@animator/nav_default_exit_anim"
app:enterAnim="@animator/nav_default_enter_anim"
app:popEnterAnim="@animator/nav_default_pop_enter_anim"
app:popExitAnim="@animator/nav_default_pop_exit_anim" />
<action
android:id="@+id/action_map_to_filterProfilesFragment"
app:destination="@id/filter_profiles"
app:exitAnim="@animator/nav_default_exit_anim"
app:enterAnim="@animator/nav_default_enter_anim"
app:popEnterAnim="@animator/nav_default_pop_enter_anim"
app:popExitAnim="@animator/nav_default_pop_exit_anim" />
<action
android:id="@+id/action_map_to_chargepriceFragment"
app:destination="@id/chargeprice" />
<action
android:id="@+id/action_map_to_opensource_donations"
app:destination="@id/opensource_donations" />
<argument
android:name="locationName"
android:defaultValue="@null"
app:argType="string"
app:nullable="true" />
<argument
android:name="chargerId"
android:defaultValue="0L"
app:argType="long" />
<argument
android:name="latLng"
android:defaultValue="@null"
app:argType="com.car2go.maps.model.LatLng"
app:nullable="true" />
<argument
android:name="appStart"
android:defaultValue="false"
app:argType="boolean" />
</fragment>
app:startDestination="@id/map_frag">
<fragment
android:id="@+id/map_frag"
android:name="net.vonforst.evmap.fragment.MapFragment"
android:label=""
tools:layout="@layout/fragment_map">
<action
android:id="@+id/action_map_to_filterFragment"
app:destination="@id/filter"
app:exitAnim="@animator/nav_default_exit_anim"
app:enterAnim="@animator/nav_default_enter_anim"
app:popEnterAnim="@animator/nav_default_pop_enter_anim"
app:popExitAnim="@animator/nav_default_pop_exit_anim" />
<action
android:id="@+id/action_map_to_filterProfilesFragment"
app:destination="@id/filter_profiles"
app:exitAnim="@animator/nav_default_exit_anim"
app:enterAnim="@animator/nav_default_enter_anim"
app:popEnterAnim="@animator/nav_default_pop_enter_anim"
app:popExitAnim="@animator/nav_default_pop_exit_anim" />
<action
android:id="@+id/action_map_to_chargepriceFragment"
app:destination="@id/chargeprice" />
<action
android:id="@+id/action_map_to_opensource_donations"
app:destination="@id/opensource_donations" />
<argument
android:name="locationName"
android:defaultValue="@null"
app:argType="string"
app:nullable="true" />
<argument
android:name="chargerId"
android:defaultValue="0L"
app:argType="long" />
<argument
android:name="latLng"
android:defaultValue="@null"
app:argType="com.car2go.maps.model.LatLng"
app:nullable="true" />
<argument
android:name="appStart"
android:defaultValue="false"
app:argType="boolean" />
</fragment>
</navigation>
<fragment
android:id="@+id/about"
android:name="net.vonforst.evmap.fragment.preference.AboutFragment"
@@ -85,15 +89,19 @@
android:name="net.vonforst.evmap.fragment.preference.AndroidAutoSettingsFragment"
android:label="@string/settings_android_auto"
tools:layout="@layout/fragment_preference" />
<fragment
<navigation
android:id="@+id/favs"
android:name="net.vonforst.evmap.fragment.FavoritesFragment"
android:label="@string/menu_favs"
tools:layout="@layout/fragment_favorites">
<action
android:id="@+id/action_favs_to_map"
app:destination="@id/map" />
</fragment>
app:startDestination="@id/favs_frag">
<fragment
android:id="@+id/favs_frag"
android:name="net.vonforst.evmap.fragment.FavoritesFragment"
android:label="@string/menu_favs"
tools:layout="@layout/fragment_favorites">
<action
android:id="@+id/action_favs_to_map"
app:destination="@id/map" />
</fragment>
</navigation>
<fragment
android:id="@+id/filter"
android:name="net.vonforst.evmap.fragment.FilterFragment"
@@ -110,18 +118,18 @@
android:label="@string/chargeprice_title"
tools:layout="@layout/fragment_chargeprice">
<action
android:id="@+id/action_chargeprice_to_settingsFragment"
app:destination="@id/settings"
android:id="@+id/action_chargeprice_to_chargepriceSettingsFragment"
app:destination="@id/settings_chargeprice"
app:exitAnim="@animator/nav_default_exit_anim"
app:enterAnim="@animator/nav_default_enter_anim"
app:popEnterAnim="@animator/nav_default_enter_anim"
app:popExitAnim="@animator/nav_default_exit_anim" />
<action
android:id="@+id/action_chargeprice_to_donateFragment"
app:destination="@id/donate" />
<argument
android:name="charger"
app:argType="net.vonforst.evmap.model.ChargeLocation" />
<argument
android:name="dataSource"
app:argType="string" />
</fragment>
<fragment
android:id="@+id/donate"
@@ -130,7 +138,7 @@
tools:layout="@layout/fragment_donate" />
<dialog
android:id="@+id/opensource_donations"
android:name="net.vonforst.evmap.fragment.updatedialogs.OpensourceDonationsDialogFramgent"
android:name="net.vonforst.evmap.fragment.updatedialogs.OpensourceDonationsDialogFragment"
android:label="@string/donation_dialog_title"
tools:layout="@layout/dialog_opensource_donations">
<action
@@ -152,6 +160,8 @@
android:label="OnboardingFragment">
<action
android:id="@+id/action_onboarding_to_map"
app:destination="@id/map" />
app:destination="@id/map"
app:popUpTo="@id/onboarding"
app:popUpToInclusive="true" />
</fragment>
</navigation>

View File

@@ -1,17 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string-array name="pref_language_names">
<item>Gerätesprache verwenden</item>
<item>Englisch</item>
<item>Deutsch</item>
</string-array>
<string-array name="pref_darkmode_names">
<item>Geräteeinstellung verwenden</item>
<item>immer an</item>
<item>immer aus</item>
</string-array>
<string-array name="pref_data_source_names">
<item>GoingElectric.de</item>
<item>Open Charge Map</item>
</string-array>
</resources>

View File

@@ -158,6 +158,8 @@
<string name="welcome_2_detail">Du kannst die Farben im Menü unter “Über EVMap → FAQ” erneut ansehen)</string>
<string name="donation_dialog_title">Danke, dass du EVMap nutzt!</string>
<string name="donation_dialog_detail">EVMap ist kostenlos und Open Source, ich entwickle es in meiner Freizeit. Über GitHub kann jeder zur Weiterentwicklung der App beitragen. Durch die steigende Beliebtheit der App müssen allerdings auch laufende Kosten, z.B. für den Zugriff auf die Datenquellen, gedeckt werden. Daher freue ich mich auch über Spenden in der App oder über GitHub Sponsors.</string>
<string name="chargeprice_donation_dialog_title">Du bist ein richtiger Sparfuchs!</string>
<string name="chargeprice_donation_dialog_detail">Es sieht so aus, als wenn du den Preisvergleich sehr gern nutzt. Für den Zugang zu den Preisinformationen muss der Entwickler von EVMap eine monatliche Gebühr an die Datenquelle Chargeprice.app zahlen. Um diesen Dienst weiter anbieten zu können, würde ich mich sehr über Spenden freuen.</string>
<string name="deleted_filterprofile">„%s” gelöscht</string>
<string name="undo">Rückgängig</string>
<string name="rename">Umbenennen</string>
@@ -202,24 +204,13 @@
<string name="pref_chargeprice_currency">Währung</string>
<string name="pref_my_tariffs">Meine Tarife</string>
<string name="chargeprice_all_tariffs_selected">alle Tarife ausgewählt</string>
<string name="pref_my_tariffs_summary">(werden im Preisvergleich hervorgehoben)</string>
<plurals name="pref_my_tariffs_summary">
<item quantity="one">(wird im Preisvergleich hervorgehoben)</item>
<item quantity="other">(werden im Preisvergleich hervorgehoben)</item>
</plurals>
<string name="license">Lizenz</string>
<string name="settings_charger_data">Ladesäulen</string>
<string name="pref_data_source">Datenquelle</string>
<string-array name="pref_chargeprice_currency_names">
<item>Schweizer Franken (CHF)</item>
<item>Tschechische Krone (CZK)</item>
<item>Dänische Krone (DKK)</item>
<item>Euro (EUR)</item>
<item>Britisches Pfund (GBP)</item>
<item>Kroatische Kuna (HRK)</item>
<item>Ungarischer Forint (HUF)</item>
<item>Isländische Krone (ISK)</item>
<item>Norwegische Krone (NOK)</item>
<item>Polnischer Złoty (PLN)</item>
<item>Schwedische Krone (SEK)</item>
<item>US-Dollar (USD)</item>
</string-array>
<plurals name="chargeprice_some_tariffs_selected">
<item quantity="one">%d Tarif ausgewählt</item>
<item quantity="other">%d Tarife ausgewählt</item>
@@ -259,4 +250,27 @@
<string name="pref_map_rotate_gestures_on">Karte kann mit Zweifingergeste rotiert werden</string>
<string name="pref_map_rotate_gestures_off">Karte bleibt fest nach Norden ausgerichtet</string>
<string name="refresh_live_data">Echtzeitstatus aktualisieren</string>
<string name="autocomplete_connection_error">Vorschläge konnten nicht geladen werden</string>
<string name="pref_language_device_default">Gerätesprache verwenden</string>
<string name="pref_language_en">Englisch</string>
<string name="pref_language_de">Deutsch</string>
<string name="pref_darkmode_device_default">Geräteeinstellung verwenden</string>
<string name="pref_darkmode_always_on">immer an</string>
<string name="pref_darkmode_always_off">immer aus</string>
<string name="pref_chargeprice_currency_chf">Schweizer Franken (CHF)</string>
<string name="pref_chargeprice_currency_czk">Tschechische Krone (CZK)</string>
<string name="pref_chargeprice_currency_dkk">Dänische Krone (DKK)</string>
<string name="pref_chargeprice_currency_eur">Euro (EUR)</string>
<string name="pref_chargeprice_currency_gbp">Britisches Pfund (GBP)</string>
<string name="pref_chargeprice_currency_hrk">Kroatische Kuna (HRK)</string>
<string name="pref_chargeprice_currency_huf">Ungarischer Forint (HUF)</string>
<string name="pref_chargeprice_currency_isk">Isländische Krone (ISK)</string>
<string name="pref_chargeprice_currency_nok">Norwegische Krone (NOK)</string>
<string name="pref_chargeprice_currency_pln">Polnischer Złoty (PLN)</string>
<string name="pref_chargeprice_currency_sek">Schwedische Krone (SEK)</string>
<string name="pref_chargeprice_currency_usd">US-Dollar (USD)</string>
<string name="pref_map_provider_google_maps">Google Maps</string>
<string name="pref_map_provider_osm_mapbox">OpenStreetMap (Mapbox)</string>
<string name="pref_search_provider_google_maps">Google Maps</string>
<string name="pref_search_provider_osm_mapbox">OpenStreetMap (Mapbox)</string>
</resources>

View File

@@ -1,4 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="background">#121212</color>
<color name="my_tariff_background">#1FFFFFFF</color>
</resources>

View File

@@ -1,40 +1,40 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string-array name="pref_language_names">
<item>Device default</item>
<item>English</item>
<item>German</item>
<item>@string/pref_language_device_default</item>
<item>@string/pref_language_en</item>
<item>@string/pref_language_de</item>
</string-array>
<string-array name="pref_language_values" tranlatable="false">
<string-array name="pref_language_values" translatable="false">
<item>default</item>
<item>en</item>
<item>de</item>
</string-array>
<string-array name="pref_darkmode_names">
<item>Device default</item>
<item>always on</item>
<item>always off</item>
<item>@string/pref_darkmode_device_default</item>
<item>@string/pref_darkmode_always_on</item>
<item>@string/pref_darkmode_always_off</item>
</string-array>
<string-array name="pref_darkmode_values" tranlatable="false">
<string-array name="pref_darkmode_values" translatable="false">
<item>default</item>
<item>on</item>
<item>off</item>
</string-array>
<string-array name="pref_chargeprice_currency_names">
<item>Swiss franc (CHF)</item>
<item>Czech koruna (CZK)</item>
<item>Danish krone (DKK)</item>
<item>Euro (EUR)</item>
<item>Pound sterling (GBP)</item>
<item>Croatian kuna (HRK)</item>
<item>Hungarian forint (HUF)</item>
<item>Icelandic króna (ISK)</item>
<item>Norwegian krone (NOK)</item>
<item>Polish złoty (PLN)</item>
<item>Swedish krona (SEK)</item>
<item>US dollar (USD)</item>
<item>@string/pref_chargeprice_currency_chf</item>
<item>@string/pref_chargeprice_currency_czk</item>
<item>@string/pref_chargeprice_currency_dkk</item>
<item>@string/pref_chargeprice_currency_eur</item>
<item>@string/pref_chargeprice_currency_gbp</item>
<item>@string/pref_chargeprice_currency_hrk</item>
<item>@string/pref_chargeprice_currency_huf</item>
<item>@string/pref_chargeprice_currency_isk</item>
<item>@string/pref_chargeprice_currency_nok</item>
<item>@string/pref_chargeprice_currency_pln</item>
<item>@string/pref_chargeprice_currency_sek</item>
<item>@string/pref_chargeprice_currency_usd</item>
</string-array>
<string-array name="pref_chargeprice_currency_values" donottranslate="true">
<string-array name="pref_chargeprice_currency_values" translatable="false">
<item>CHF</item>
<item>CZK</item>
<item>DKK</item>
@@ -49,8 +49,8 @@
<item>USD</item>
</string-array>
<string-array name="pref_data_source_names">
<item>GoingElectric.de</item>
<item>Open Charge Map</item>
<item>@string/data_source_goingelectric</item>
<item>@string/data_source_openchargemap</item>
</string-array>
<string-array name="pref_data_source_values" tranlatable="false">
<item>goingelectric</item>

View File

@@ -25,6 +25,7 @@
<color name="chargeprice_lock">#546E7A</color>
<color name="chargeprice_star">#00C853</color>
<color name="chip_background">#1F000000</color>
<color name="my_tariff_background">#1F000000</color>
<color name="background">#FFFFFF</color>
<color name="pager_unselected">#1F000000</color>
</resources>

View File

@@ -7,4 +7,5 @@
<string name="twitter_url">https://twitter.com/ev_map</string>
<string name="goingelectric_forum_url"><![CDATA[https://www.goingelectric.de/forum/viewtopic.php?f=5&t=56342]]></string>
<string name="github_sponsors_link">https://github.com/sponsors/johan12345/</string>
<string name="chargeprice_api_url">https://api.chargeprice.app/v1/</string>
</resources>

View File

@@ -157,6 +157,8 @@
<string name="welcome_2_detail">(You can check the colors again under “About EVMap → FAQ” in the menu)</string>
<string name="donation_dialog_title">Thank you for using EVMap!</string>
<string name="donation_dialog_detail">EVMap is free and Open Source software that I develop in my spare time. Coding contributions on GitHub are very much appreciated. However, due to increasing popularity of the app, I also need to cover some running costs, e.g. for access to the data sources. Therefore, please consider supporting the app through a donation or via GitHub Sponsors.</string>
<string name="chargeprice_donation_dialog_title">You\'re a real bargain hunter!</string>
<string name="chargeprice_donation_dialog_detail">It seems like you like the price comparison feature a lot. To access the pricing data, the developer of EVMap needs to pay a monthly fee to the data provider Chargeprice.app. Therefore, please consider supporting EVMap through a donation.</string>
<string name="deleted_filterprofile">Deleted “%s”</string>
<string name="undo">Undo</string>
<string name="rename">Rename</string>
@@ -200,7 +202,10 @@
<string name="chargeprice_no_compatible_connectors">None of the connectors on this charging station is compatible with your vehicle.</string>
<string name="pref_chargeprice_currency">Currency</string>
<string name="pref_my_tariffs">My charging plans</string>
<string name="pref_my_tariffs_summary">(will be highlighted in price comparison)</string>
<plurals name="pref_my_tariffs_summary">
<item quantity="one">(will be highlighted in price comparison)</item>
<item quantity="other">(will be highlighted in price comparison)</item>
</plurals>
<string name="chargeprice_all_tariffs_selected">all plans selected</string>
<string name="license">License</string>
<string name="settings_charger_data">Charging stations</string>
@@ -244,4 +249,27 @@
<string name="pref_map_rotate_gestures_on">Map can be rotated with two-finger gesture</string>
<string name="pref_map_rotate_gestures_off">Map will be fixed to north-up</string>
<string name="refresh_live_data">refresh real-time status</string>
<string name="autocomplete_connection_error">Suggestions could not be loaded</string>
<string name="pref_language_device_default">Device default</string>
<string name="pref_language_en">English</string>
<string name="pref_language_de">German</string>
<string name="pref_darkmode_device_default">Device default</string>
<string name="pref_darkmode_always_on">always on</string>
<string name="pref_darkmode_always_off">always off</string>
<string name="pref_chargeprice_currency_chf">Swiss franc (CHF)</string>
<string name="pref_chargeprice_currency_czk">Czech koruna (CZK)</string>
<string name="pref_chargeprice_currency_dkk">Danish krone (DKK)</string>
<string name="pref_chargeprice_currency_eur">Euro (EUR)</string>
<string name="pref_chargeprice_currency_gbp">Pound sterling (GBP)</string>
<string name="pref_chargeprice_currency_hrk">Croatian kuna (HRK)</string>
<string name="pref_chargeprice_currency_huf">Hungarian forint (HUF)</string>
<string name="pref_chargeprice_currency_isk">Icelandic króna (ISK)</string>
<string name="pref_chargeprice_currency_nok">Norwegian krone (NOK)</string>
<string name="pref_chargeprice_currency_pln">Polish złoty (PLN)</string>
<string name="pref_chargeprice_currency_sek">Swedish krona (SEK)</string>
<string name="pref_chargeprice_currency_usd">US dollar (USD)</string>
<string name="pref_map_provider_google_maps">Google Maps</string>
<string name="pref_map_provider_osm_mapbox">OpenStreetMap (Mapbox)</string>
<string name="pref_search_provider_google_maps">Google Maps</string>
<string name="pref_search_provider_osm_mapbox">OpenStreetMap (Mapbox)</string>
</resources>

View File

@@ -13,6 +13,8 @@
<item name="colorOnSecondaryContainer">@color/colorSecondaryDark</item>
<item name="android:windowTranslucentStatus">true</item>
<item name="preferenceTheme">@style/AppTheme.Preference</item>
<item name="alertDialogTheme">@style/AppTheme.AlertDialog</item>
<item name="materialAlertDialogTheme">@style/AppTheme.AlertDialog</item>
</style>
<style name="AppTheme.Preference" parent="@style/PreferenceThemeOverlay">
@@ -67,4 +69,10 @@
<item name="iconTint">?android:textColorSecondary</item>
</style>
<style name="AppTheme.AlertDialog" parent="ThemeOverlay.Material3.MaterialAlertDialog">
<!-- this is necessary to make sure the dialog gets "pushed up" when the keyboard appears -->
<item name="android:windowTranslucentStatus">false</item>
<item name="dialogCornerRadius">28dp</item>
</style>
</resources>

View File

@@ -8,8 +8,7 @@
app:defaultToAll="false" />
<net.vonforst.evmap.ui.MultiSelectDialogPreference
android:key="chargeprice_my_tariffs"
android:title="@string/pref_my_tariffs"
android:summary="@string/pref_my_tariffs_summary" />
android:title="@string/pref_my_tariffs" />
<ListPreference
android:key="chargeprice_currency"
android:title="@string/pref_chargeprice_currency"

View File

@@ -9,7 +9,7 @@
(e.g. in the debug version). -->
<intent
android:action="android.intent.action.VIEW"
android:targetPackage="net.vonforst.evmap"
android:targetPackage="${applicationId}"
android:targetClass="net.vonforst.evmap.MapsActivity">
<extra
android:name="favorites"

View File

@@ -63,14 +63,14 @@ class ChargepriceApiTest {
runBlocking {
val result = chargeprice.getChargePrices(
ChargepriceRequest().apply {
dataAdapter = "going_electric"
ChargepriceRequest(
dataAdapter = "going_electric",
station =
ChargepriceStation.fromEvmap(charger, listOf("Typ2", "Schuko"))
ChargepriceStation.fromEvmap(charger, listOf("Typ2", "Schuko")),
options = ChargepriceOptions(energy = 22.0, duration = 60)
}, "en"
), "en"
)
assertEquals(25, result.size)
assertEquals(25, result.data!!.size)
}
}
}

View File

@@ -0,0 +1,42 @@
package net.vonforst.evmap.auto
import android.content.ComponentName
import android.content.Intent
import androidx.car.app.HandshakeInfo
import androidx.car.app.testing.SessionController
import androidx.car.app.testing.TestCarContext
import androidx.car.app.testing.TestScreenManager
import androidx.lifecycle.Lifecycle
import androidx.test.core.app.ApplicationProvider
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.Robolectric
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.internal.DoNotInstrument
@RunWith(RobolectricTestRunner::class)
@DoNotInstrument
class CarAppTest {
private val testCarContext =
TestCarContext.createCarContext(ApplicationProvider.getApplicationContext()).apply {
updateHandshakeInfo(HandshakeInfo("auto.testing", 1))
}
@Test
fun onCreateScreen_returnsExpectedScreen() {
val service = Robolectric.setupService(CarAppService::class.java)
val session = service.onCreateSession()
val controller = SessionController(
session, testCarContext,
Intent().setComponent(
ComponentName(testCarContext, CarAppService::class.java)
)
)
controller.moveToState(Lifecycle.State.CREATED)
val screenCreated =
testCarContext.getCarService(TestScreenManager::class.java).screensPushed.last()
// location permission required
assert(screenCreated is PermissionScreen)
}
}

View File

@@ -1,19 +1,20 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript {
ext.kotlin_version = '1.6.21'
ext.kotlin_version = '1.7.10'
ext.about_libs_version = '8.9.4'
ext.nav_version = '2.4.2'
ext.nav_version = '2.5.1'
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
dependencies {
classpath 'com.android.tools.build:gradle:7.1.3'
classpath 'com.android.tools.build:gradle:7.2.2'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
classpath "com.mikepenz.aboutlibraries.plugin:aboutlibraries-plugin:$about_libs_version"
classpath "androidx.navigation:navigation-safe-args-gradle-plugin:$nav_version"
classpath "de.timfreiheit.resourceplaceholders:placeholders:0.4"
// NOTE: Do not place your application dependencies here; they belong
// in the individual module build.gradle files
@@ -25,7 +26,6 @@ allprojects {
google()
mavenCentral()
//noinspection JcenterRepositoryObsolete
jcenter() // still required for https://github.com/kamikat/moshi-jsonapi
maven { url 'https://jitpack.io' }
}
}

View File

@@ -35,8 +35,9 @@ Not all API keys are strictly required if you only want to work on certain parts
example, you can choose only one of the map providers and one of the charging station databases. The
Chargeprice API key is also only required if you want to test the price comparison feature.
All API keys are available for free. Some APIs require payment above a certain limit, but the free
tier should be plenty for local testing and development.
All APIs can be used for free, at least for testing. Some APIs require payment above a certain usage
limit or to get access to the full dataset, but the free tiers should be plenty for local testing
and development.
Below you find a list of all the services and how to obtain the API keys.
@@ -152,14 +153,19 @@ Pricing providers
<details>
<summary>How to obtain an API key</summary>
1. Check the
[Pricing page](https://github.com/chargeprice/chargeprice-api-docs/blob/master/plans.md)
for information on the current plans at Chargeprice. There should be a free tier up to a certain
limit of API calls per month.
2. Contact [contact@chargeprice.net](mailto:contact@chargeprice.net), stating that you would like to
contribute to the development the open source EVMap app and therefore need access to the
Chargeprice API for testing.
3. When your access to the API is approved, you will receive an API key via email.
Since February 2022, the Chargeprice API is no longer available for free to new customers. However,
you can use their
[staging API](https://github.com/chargeprice/chargeprice-api-docs/blob/master/test_the_api.md)
for free to test the Chargeprice features. This is already
[configured](https://github.com/johan12345/EVMap/blob/master/app/src/debug/res/values/donottranslate.xml)
by default for the debug version of the app, so you can leave the `chargeprice_key` field in your
new `app/src/main/res/values/apikeys.xml` file blank. Note that the staging API contains only a
limited dataset, so it only outputs prices for certain charge point operators and payment plans (see
[here](https://docs.google.com/document/d/14zlFr5IEhhR3uGXO5QePKjNUQANVwA-Ba-cZbOCiOBk/edit) for
details).
In case you want to pay for access to the full Chargeprice API, check out their
[API docs](https://github.com/chargeprice/chargeprice-api-docs) on GitHub and contact them at
[sales@chargeprice.net](mailto:sales@chargeprice.net).
</details>

View File

@@ -0,0 +1,12 @@
Unter https://hosted.weblate.org/engage/evmap/ können Sie jetzt dazu beitragen,
EVMap in andere Sprachen zu übersetzen!
Verbesserungen:
- Neues Design für Dialoge
- Karte kann bei geöffnetem Filtermenü verschoben werden
- Vorbereitungen für Übersetzung der App in andere Sprachen
- Android 11 und niedriger: Ortung verbessert, wenn GPS aktiviert aber nicht verfügbar (z.B. in Gebäuden)
Fehler behoben:
- Absturz im Preisvergleich behoben
- Verbesserungen für weitere kleine Darstellungsfehler

View File

@@ -0,0 +1,12 @@
Verbesserungen:
- Android Auto: Ortssuche hinzugefügt
Fehler behoben:
- Shortcut Favoriten: kein Wechsel zur Karte möglich
- Favoritendaten wurden nicht bei Öffnen aktualisiert
- Preisvergleich: Durchschnittsladeleistung falsch
- Verfügbarkeit in kompakter Ansicht falsch (mit Filter nach Leistung)
- Kompass hinter Pinhole
- Abstürze behoben
- Android Automotive: Verfügbarkeitsdaten wurden bei Refresh nicht neu geladen
- Android Automotive: Fahrtrichtung wurde bei Fahrzeugdaten nicht angezeigt

View File

@@ -0,0 +1,2 @@
Fehler behoben:
- OpenStreetMap/Mapbox: Karte ließ sich nicht mehr verschieben

View File

@@ -0,0 +1,3 @@
Fehler behoben:
- Mögliche Behebung von Abstürzen unter Android Auto
- Filterbutton war unter Android Automotive verschwunden

View File

@@ -0,0 +1,6 @@
Fehler behoben:
- Darstellungsprobleme mit einigen hervorgehobenen Tarifen im Preisvergleich behoben
- Verbesserungen für weitere kleine Darstellungsfehler
- Android Auto: Richtiges Icon für dauerhafte Benachrichtigung zum Standortzugriff verwenden
- Android Auto: Ausrichtung von +/- Buttons korrigiert
- Android Auto: Liste der Ladestationen nach Neustart der App aktualisieren

View File

@@ -1 +1 @@
EVMap - Elektroauto-Ladestationen
EVMap - Elektroauto laden

View File

@@ -0,0 +1,11 @@
At https://hosted.weblate.org/engage/evmap/ you can now help translating EVMap into other languages!
Improvements:
- New design for dialogs
- Map can be moved when filter menu is open
- Preparations for translating the app into other languages
- Android 11 and lower: Improved localization if GPS is enabled but not available (e.g. in buildings)
Bugfixes:
- Fixed crash in price comparison
- Improvements for some more minor visual issues

View File

@@ -0,0 +1,12 @@
Improvements:
- Android Auto: Added place search
Bugfixes:
- Shortcut to favorites: switching to map not possible
- Favorite data were not updated when viewing details
- Price comparison: incorrect average power
- Wrong availability data in compact view (with minimum power filter)
- Compass hidden behind pinhole
- Crashes fixed
- Android Automotive: Availability data not updated on refresh
- Android Automotive: Driving direction was not shown in vehicle data

View File

@@ -0,0 +1,2 @@
Bugfixes:
- OpenStreetMap/Mapbox: Map was not movable

View File

@@ -0,0 +1,3 @@
Bugfixes:
- Possible fix of crashes under Android Auto
- Filter button disappeared under Android Automotive

View File

@@ -0,0 +1,6 @@
Bugfixes:
- Fixed visual problems with some highlighted providers in price comparison
- Improvements for some other minor visual issues
- Android Auto: Use proper icon for persistent notification about location access
- Android Auto: Fix alignment of +/- buttons
- Android Auto: Refresh chargers after going back to app

View File

@@ -1 +1 @@
EVMap - Electric vehicle chargers
EVMap - EV chargers

View File

@@ -1,21 +1,16 @@
# Project-wide Gradle settings.
# IDE (e.g. Android Studio) users:
# Gradle settings configured through the IDE *will override*
# any settings specified in this file.
# For more details on how to configure your build environment visit
## For more details on how to configure your build environment visit
# http://www.gradle.org/docs/current/userguide/build_environment.html
#
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
org.gradle.jvmargs=-Xmx1536m
# Default value: -Xmx1024m -XX:MaxPermSize=256m
# org.gradle.jvmargs=-Xmx2048m -XX:MaxPermSize=512m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8
#
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. More details, visit
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
# org.gradle.parallel=true
# AndroidX package structure to make it clearer which packages are bundled with the
# Android operating system, and which are packaged with your app's APK
# https://developer.android.com/topic/libraries/support-library/androidx-rn
#Sun Jul 24 11:49:27 CEST 2022
kotlin.code.style=official
org.gradle.jvmargs=-Xmx2048M -Dkotlin.daemon.jvm.options\="-Xmx2048M"
android.useAndroidX=true
# Automatically convert third-party libraries to use AndroidX
android.enableJetifier=true
# Kotlin code style for this project: "official" or "obsolete":
kotlin.code.style=official

View File

Binary file not shown.

View File

@@ -1,6 +1,6 @@
#Wed Dec 23 14:54:49 CET 2020
#Sat Aug 06 15:33:46 CEST 2022
distributionBase=GRADLE_USER_HOME
distributionUrl=https\://services.gradle.org/distributions/gradle-7.3.3-bin.zip
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-7.2-bin.zip
zipStoreBase=GRADLE_USER_HOME

286
gradlew vendored
View File

@@ -1,78 +1,129 @@
#!/usr/bin/env sh
#!/bin/sh
#
# Copyright <20> 2015-2021 the original authors.
#
# 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
#
# https://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.
#
##############################################################################
##
## Gradle start up script for UN*X
##
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions <20>$var<61>, <20>${var}<7D>, <20>${var:-default}<7D>, <20>${var+SET}<7D>,
# <20>${var#prefix}<7D>, <20>${var%suffix}<7D>, and <20>$( cmd )<29>;
# * compound commands having a testable exit status, especially <20>case<73>;
# * various built-in commands including <20>command<6E>, <20>set<65>, and <20>ulimit<69>.
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
PRG="$0"
# Need this for relative symlinks.
while [ -h "$PRG" ] ; do
ls=`ls -ld "$PRG"`
link=`expr "$ls" : '.*-> \(.*\)$'`
if expr "$link" : '/.*' > /dev/null; then
PRG="$link"
else
PRG=`dirname "$PRG"`"/$link"
fi
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
SAVED="`pwd`"
cd "`dirname \"$PRG\"`/" >/dev/null
APP_HOME="`pwd -P`"
cd "$SAVED" >/dev/null
APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit
APP_NAME="Gradle"
APP_BASE_NAME=`basename "$0"`
APP_BASE_NAME=${0##*/}
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS=""
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD="maximum"
MAX_FD=maximum
warn () {
echo "$*"
}
} >&2
die () {
echo
echo "$*"
echo
exit 1
}
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "`uname`" in
CYGWIN* )
cygwin=true
;;
Darwin* )
darwin=true
;;
MINGW* )
msys=true
;;
NONSTOP* )
nonstop=true
;;
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD="$JAVA_HOME/jre/sh/java"
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD="$JAVA_HOME/bin/java"
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
@@ -81,7 +132,7 @@ Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD="java"
JAVACMD=java
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
@@ -89,84 +140,95 @@ location of your Java installation."
fi
# Increase the maximum file descriptors if we can.
if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
MAX_FD_LIMIT=`ulimit -H -n`
if [ $? -eq 0 ] ; then
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
MAX_FD="$MAX_FD_LIMIT"
fi
ulimit -n $MAX_FD
if [ $? -ne 0 ] ; then
warn "Could not set maximum file descriptor limit: $MAX_FD"
fi
else
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
fi
fi
# For Darwin, add options to specify how the application appears in the dock
if $darwin; then
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
fi
# For Cygwin, switch paths to Windows format before running java
if $cygwin ; then
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
JAVACMD=`cygpath --unix "$JAVACMD"`
# We build the pattern for arguments to be converted via cygpath
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
SEP=""
for dir in $ROOTDIRSRAW ; do
ROOTDIRS="$ROOTDIRS$SEP$dir"
SEP="|"
done
OURCYGPATTERN="(^($ROOTDIRS))"
# Add a user-defined pattern to the cygpath arguments
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
fi
# Now convert the arguments - kludge to limit ourselves to /bin/sh
i=0
for arg in "$@" ; do
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
else
eval `echo args$i`="\"$arg\""
fi
i=$((i+1))
done
case $i in
(0) set -- ;;
(1) set -- "$args0" ;;
(2) set -- "$args0" "$args1" ;;
(3) set -- "$args0" "$args1" "$args2" ;;
(4) set -- "$args0" "$args1" "$args2" "$args3" ;;
(5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
(6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
(7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
(8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
(9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Escape application args
save () {
for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
echo " "
}
APP_ARGS=$(save "$@")
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# Collect all arguments for the java command, following the shell quoting and substitution rules
eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong
if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then
cd "$(dirname "$0")"
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Collect all arguments for the java command;
# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of
# shell script including quotes and variable substitutions, so put them in
# double quotes to make sure that they get re-expanded; and
# * put everything else in single quotes, so that it's not re-expanded.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
"$@"
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"

43
gradlew.bat vendored
View File

@@ -1,3 +1,19 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@if "%DEBUG%" == "" @echo off
@rem ##########################################################################
@rem
@@ -13,15 +29,18 @@ if "%DIRNAME%" == "" set DIRNAME=.
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS=
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if "%ERRORLEVEL%" == "0" goto init
if "%ERRORLEVEL%" == "0" goto execute
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
@@ -35,7 +54,7 @@ goto fail
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto init
if exist "%JAVA_EXE%" goto execute
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
@@ -45,28 +64,14 @@ echo location of your Java installation.
goto fail
:init
@rem Get command-line arguments, handling Windows variants
if not "%OS%" == "Windows_NT" goto win9xME_args
:win9xME_args
@rem Slurp the command line arguments.
set CMD_LINE_ARGS=
set _SKIP=2
:win9xME_args_slurp
if "x%~1" == "x" goto execute
set CMD_LINE_ARGS=%*
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell