Compare commits

...

66 Commits
0.4.0 ... 0.6.0

Author SHA1 Message Date
johan12345
6aa8a3d7a2 Release 0.6.0 2021-04-05 22:52:34 +02:00
johan12345
887702b729 Add Android Auto information dialog 2021-04-05 22:42:26 +02:00
johan12345
0417c4f1ae Show GoingElectric verified state 2021-04-05 22:01:52 +02:00
johan12345
0b95785f49 add Android Auto screenshots 2021-04-05 21:22:44 +02:00
Johan von Forstner
2772e9ad4d Merge pull request #63 from johan12345/android-auto
Android Auto support
2021-04-05 21:14:16 +02:00
johan12345
8a16fa3a5c Android Auto: update car app library 2021-04-05 21:13:53 +02:00
johan12345
84d3127675 Android Auto: migrate to new version of car app library 2021-04-05 21:13:53 +02:00
Johan von Forstner
e684fbc0dc Android Auto: avoid crash after maximum number of updates is reached 2021-04-05 21:13:53 +02:00
Johan von Forstner
bb92d26be9 Android Auto: display fault reports 2021-04-05 21:13:53 +02:00
Johan von Forstner
f74bb8e4a5 Android Auto: fix typo for cost string 2021-04-05 21:13:53 +02:00
Johan von Forstner
5d72be8e87 Android Auto: Add charger icon 2021-04-05 21:13:53 +02:00
Johan von Forstner
04e6f63cd7 Android Auto: Add permission screen, add selection between nearby and favorites 2021-04-05 21:13:53 +02:00
Johan von Forstner
ffb0b77f37 Android Auto: implement detail view to app link 2021-04-05 21:13:53 +02:00
Johan von Forstner
9d621c3149 Android Auto: add more information in detail view 2021-04-05 21:13:52 +02:00
Johan von Forstner
7126c3c67c Android Auto: add detail view with button to navigate 2021-04-05 21:13:52 +02:00
Johan von Forstner
62197f99cb GoingElectricApi: use coroutines for loading charger details 2021-04-05 21:13:51 +02:00
johan12345
db68452f55 Android Auto: initial implementation 2021-04-05 21:13:51 +02:00
johan12345
9ec5010495 NewMotionAvailabilityDetector: fail silently for unknown connector types 2021-04-05 21:03:56 +02:00
johan12345
5978b90da2 fix crash if charger ID was not found 2021-04-05 21:00:25 +02:00
johan12345
223d9d394f fix crash if location client is not connected 2021-04-05 20:58:31 +02:00
johan12345
38b82abc48 Preserve map traffic enabled state across app restarts
like map type, which was implemented in 6cb682f0
2021-04-05 20:56:03 +02:00
johan12345
aade4ec488 increase touch target size for search bar 2021-04-05 20:51:33 +02:00
johan12345
38a02f8304 use more restrictive pattern for intent-filter
For example edit button (url ending with /edit/) would try to open in EVMap
2021-04-05 20:47:50 +02:00
johan12345
8f7e1c5629 disable location following when search result is shown 2021-04-05 19:11:09 +02:00
johan12345
0be90d8801 Release 0.5.0 2021-03-28 23:12:37 +02:00
johan12345
4ca9cc68cb Handle intents to https://www.goingelectric.de/stromtankstellen website 2021-03-28 23:02:24 +02:00
johan12345
62e9acf9be throttle repetitive loading of chargepoints to 500 ms 2021-03-28 22:43:08 +02:00
johan12345
6cb682f065 Preserve selected map type across app restarts 2021-03-28 21:46:59 +02:00
johan12345
4cfd5c8ef2 follow current location in map view (fixes #56) 2021-03-28 21:42:26 +02:00
johan12345
24bf66ddbe fix calculation of total chargers from filtered availability introduced in a0b0339c8b 2021-03-28 18:42:07 +02:00
johan12345
a0b0339c8b Handle geo intents to open map (fixes #69) 2021-03-27 21:35:42 +01:00
johan12345
2c9081b313 filter availability displayed in sparse view by selected connectors 2021-03-27 20:58:38 +01:00
johan12345
bd245801b0 refactoring of FilterValues using typealias and extension function 2021-03-27 20:48:15 +01:00
johan12345
11dac62b94 update copyright year 2021-03-24 08:43:25 +01:00
Johan von Forstner
a8bac7875a README.md: document Mapbox API key 2021-02-08 22:17:51 +01:00
johan12345
dbba00b51b Rework filter profile delete undo functionality (similar bug to #70) 2021-01-28 22:45:05 +01:00
johan12345
90cddce54c fix #70: Renaming filter profile resets settings 2021-01-28 21:47:47 +01:00
Johan von Forstner
f0f6c08610 Release 0.4.3 2021-01-17 14:15:46 +01:00
Johan von Forstner
a2fe9a06c5 fix another IllegalStateException 2021-01-17 14:09:37 +01:00
Johan von Forstner
cb79f17c23 catch IllegalArgumentException 2021-01-17 14:08:28 +01:00
Johan von Forstner
0009895537 fix IllegalStateException 2021-01-17 14:07:20 +01:00
Johan von Forstner
df705670b1 fix ClassCastException 2021-01-17 14:00:35 +01:00
Johan von Forstner
c616e9fdbd README.md: Describe map backends
see also #36
2021-01-06 19:30:45 +01:00
Johan von Forstner
c70a092d99 Release 0.4.2 2021-01-03 16:47:15 +01:00
Johan von Forstner
34fee47c08 Fix incorrect linking of text (fixes #29) 2021-01-03 16:23:07 +01:00
Johan von Forstner
bf97a14fe3 add station availability in map screen (fixes #52) 2021-01-03 15:28:58 +01:00
Johan von Forstner
60d4d56f80 Fix links to Google Maps
(maps app was not found due to https://developer.android.com/training/basics/intents/package-visibility)
2021-01-03 11:00:22 +01:00
Johan von Forstner
8bf33c7384 FilterProfilesFragment: Add rename and delete buttons + undo function 2021-01-03 10:45:56 +01:00
Johan von Forstner
595e6e9a8f Welcome dialog: replace > with ≥ 2021-01-03 09:52:07 +01:00
Johan von Forstner
9efbdfc046 Fix typo in welcome page 2021-01-02 22:38:54 +01:00
Johan von Forstner
e1d4b6bcc5 welcome dialog: fix height on small screens 2021-01-02 20:09:12 +01:00
Johan von Forstner
a6db74488e release 0.4.1 2020-12-31 20:29:48 +01:00
Johan von Forstner
821f5d61b5 add welcome dialog on first start (fixes #66) 2020-12-31 19:18:58 +01:00
Johan von Forstner
f83ac17c83 don't generate icons in background for Mapbox 2020-12-30 20:10:52 +01:00
Johan von Forstner
3519c7f699 decrease memory usage of charger icons
by allowing "fault" and "highlight" only with scale == 1f
refs #59
2020-12-30 20:01:47 +01:00
Licaon_Kter
78d9706cb7 Remove suffix for fdroid (#67)
...as you'd know it's not-google 👍 
Also, breaks AutoUpdate
2020-12-30 19:01:03 +01:00
Johan von Forstner
a593a8054b fix some gallery glitches (#61) 2020-12-30 18:58:55 +01:00
Johan von Forstner
9556be6b85 Gallery fixes 2020-12-29 20:24:22 +01:00
Johan von Forstner
e8669f8a3d Gallery: replace Picasso with Coil 2020-12-29 18:09:29 +01:00
Johan von Forstner
6a887ee1e4 NewMotionAvailabilityDetector: add some more plug types
(rarely occurring)
2020-12-29 18:08:37 +01:00
Johan von Forstner
6dbaaa3099 travis CI: use latest android SDK commandline tools 2020-12-28 11:14:24 +01:00
Johan von Forstner
7f9242da1e fix license file 2020-12-28 10:58:07 +01:00
Johan von Forstner
2c3151089f CI: accept android licenses 2020-12-28 10:53:42 +01:00
Johan von Forstner
1ee388126f travis: build-tools;30.0.3 2020-12-28 10:46:18 +01:00
Johan von Forstner
964cecdf66 travis CI: use Android 30 sdk 2020-12-28 10:38:15 +01:00
Johan von Forstner
7141eb5013 update to Android 11 SDK 2020-12-27 17:15:18 +01:00
81 changed files with 2708 additions and 467 deletions

View File

@@ -1,16 +1,23 @@
language: android
language: java
dist: trusty
android:
components:
- build-tools-29.0.3
- android-29
env:
global:
- secure: KYdFlMarsyXw+OHht1Atp+Kirbw9O09Ck14EjFuKb1eNtknurZ/tGEXuD+8xWh1W8W21kgHEG7s3rzru53t29buz+FW9f+ZmhEWXFP3OydyvXLw4BAVVOjm6xG2uHX/8MOGLJNM7cfaF25EPQ+kznHe84R29KaLH90mNRr2lPa4VnfbcnvDStiVaez/vJ72UoYSP5HICAzoF70yC3ZvvCK1hZv71UIysCbFE2IkxvMhG9OOGebdnRmFssaRCrvfRLjitobcLzkPWzZZIqdjNASf8/iAxX8VgGBYfVj8ID06AfMrtgXNJRCvcD0LICraQ+WPUbikMunRieGO8PNHSB5vKdPoC50aLUa0RoRb4G3QM1pR2A8xAFlIJFX2R7iY+2t24L9hRFqB98+QoQzutfkAI1T0rzem/wtpZpuan+bDawDJEHGCeYbE0aPDAl6lytgrEE9fRgV3c1jJLQzu0xIWG8YLl3iMg0hL+c0wCKXoeqrfCFS6kYmmG7W+rQp4tCZifvRbWfAXwIPQieffKxqdEuUwiUsYxdzCu9v9uU3nflEOLLuRgeMP3gV8mpur9b5GztpkfgfzcAqsF+NiY01kYgGtrgCYlMy0TxASE+UuALrtkQtU01wwhs9RH7Az0Ib3C+MT5DTjxHQCYETIViocmNEG2vfAbgHazCpGAhcY=
- secure: Hoko50vP+Mwm/O4CWvPvjMxd1gGhi+Bultjyy1WpludSmZFCfKz45Mj1EqzeYk6MeMLvGOEkSLB5wjdXdgJ9j5gEOF5K34k4vESJA7+DqDO3I7Xw9cnWgOXdFqB0qGHar0TVP3Dfg7ZcRgtmeX6t2uoELFLiS9GvTnbZXk4PCUybd979Xi8XHjEQV7+3EZbSjtsI4GFeIK1rrjd0I+UM88zYrWnz1KhdCjWvQb4iZjo+ib6NmGGEMqR7jJPRZz3KB01Y+n5h21qq9/Nv31zQIqt2B5nRxy3vBvvqKapgprIk+hVOpNnBU8w89uWUU6tYUeFk0t7z1TYWjgaBrMmGCM+aKkQ2q5F/ygNzDwB+KkpJx709Yhf0ZX8g2yvkdz+Ok7moYuvmrOPOf4E/U3BlfZxbGtRD2bGYbDgHLFYlTn6v5J2kJHDJAz31yvF5jvJejDaPp2IBVfoMRy7ZJFmGUNHGd9Se6bRwxS++AoobP5WDrBiUXNe2KKDMs3e7vbaO+hLbZ9XHpjeWIJGUfvtTee8EHZqF/8A3ju53V4/R0ehlOv2UZbpYNqcmwrsy9/R4pgMfDkG3Q054LmYrxD8DIC9b8excVMwWRP5aQ1TqZnxO2B1/vJU87RcnGl3jekeHTdHXbRq9BMV4dAdPfB9X3nGIi2GgV0iTBk+24xccOc0=
- secure: RsN/2nBVv0byMzwchuAhDti1AWKECg3Uzqk6Ahgaqg02zx8GHj60j0qNax7KuMUg70X3G4b3m8ZzndAU0wcJ98UyIku7ofUYgXHm0XYKTJwiyyhrKJ8pZ4qoeCoRkitIGIitlb84fSufVamcoLyLNbLUsO5OzL+Uscyhq/BwAhyOhj5FB9DM+GE9ntanQ4m3k4guMyMctR9CGS6Tk4LKJ1WCm0AnjTPalc+we+7yORY2J/9d6VHLKfaFXbpuWmrdnFfDZqxqcUODsxPVE0LuUtXyKQDTjjfQc8106Z/z19AJS+oLm/2UND84PD3MqsjX6EX0/9k3fY0OsoCuQAivDRmtevQ0bDQrTAyeBLcfnPCw/MYiJWyBcaYBAYK42EAfsFTDBRIduFB/Dpvg+8GuLZSdm4xVYpTlQ2pMtlGNWIGIRQsjk9LZ8swq8QBMiiF/bpMGKdfmnyQj8jkEOCWaAzkR9O+4E4Y7PuBENef9XuV0hLMryCrML2YXigxAKkEUOPhfnG+AbEY+g2kAMp+2EtXaG3tsGxoMhEYA+uRd3o2cacQMMwRpnePH5DYg6F05mrvdHPpt+P9UR1iHaHmjBPAYeksmrdP8bS088zcnePgiL6+6N0m3+l8Krihmxg5RyWjnH18IwX4RO+xg4x3cW+zaScCDbbotDMEYtChF+Hs=
- ANDROID_HOME=$HOME/android-sdk
before_install:
- openssl aes-256-cbc -K $encrypted_53968681344a_key -iv $encrypted_53968681344a_iv -in _ci/keystore.jks.enc -out _ci/keystore.jks -d
install:
# Download and unzip the Android command line tools (if not already there thanks to the cache mechanism)
# Latest version of this file available here: https://developer.android.com/studio/#command-tools
- if test ! -e $HOME/android-cmdline-tools/cmdline-tools.zip ; then curl https://dl.google.com/android/repository/commandlinetools-linux-6609375_latest.zip > $HOME/android-cmdline-tools/cmdline-tools.zip ; fi
- unzip -qq -n $HOME/android-cmdline-tools/cmdline-tools.zip -d $HOME/android-cmdline-tools
# Install or update Android SDK components (will not do anything if already up to date thanks to the cache mechanism)
- echo y | $HOME/android-cmdline-tools/tools/bin/sdkmanager --sdk_root=$HOME/android-sdk 'platform-tools' > /dev/null
# Latest version of build-tools available here: https://developer.android.com/studio/releases/build-tools.html
- echo y | $HOME/android-cmdline-tools/tools/bin/sdkmanager --sdk_root=$HOME/android-sdk 'build-tools;29.0.3' > /dev/null
- echo y | $HOME/android-cmdline-tools/tools/bin/sdkmanager --sdk_root=$HOME/android-sdk 'platforms;android-29' > /dev/null
script:
- "./gradlew lintFossDebug testFossDebugUnitTest lintGoogleDebug testGoogleDebugUnitTest"
- "./gradlew assembleRelease"
@@ -22,6 +29,8 @@ cache:
- "$HOME/.gradle/caches/"
- "$HOME/.gradle/wrapper/"
- "$HOME/.android/build-cache"
- "$HOME/android-cmdline-tools"
- "$HOME/android-sdk"
deploy:
provider: releases
api_key:

View File

@@ -1,6 +1,6 @@
MIT License
Copyright (c) 2020 Johan von Forstner
Copyright (c) 2020-2021 Johan von Forstner
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal

View File

@@ -20,6 +20,7 @@ Features
- Favorites list, also with availability information
- 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.
Screenshots
-----------
@@ -31,10 +32,10 @@ Development setup
The App is developed using Android Studio.
For testing the app, you need to obtain API Keys for the
For testing the app, you need to obtain free API Keys for the
[GoingElectric API](https://www.goingelectric.de/stromtankstellen/api/)
as well as for [Google APIs](https://console.developers.google.com/)
("Maps SDK for Android" and "Places API" need to be activated). These APIs need to be put into the
("Maps SDK for Android" and "Places API" need to be activated) and/or [Mapbox](https://www.mapbox.com/). These APIs need to be put into the
app in the form of a resource file called `apikeys.xml` under `app/src/main/res/values`, with the
following content:
@@ -43,6 +44,9 @@ following content:
<string name="google_maps_key" templateMergeStrategy="preserve" translatable="false">
insert your Google Maps key here
</string>
<string name="mapbox_key" translatable="false">
insert your Mapbox key here
</string>
<string name="goingelectric_key" translatable="false">
insert your GoingElectric key here
</string>

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

View File

@@ -6,15 +6,15 @@ apply plugin: 'androidx.navigation.safeargs.kotlin'
apply plugin: 'com.mikepenz.aboutlibraries.plugin'
android {
compileSdkVersion 29
buildToolsVersion "29.0.3"
compileSdkVersion 30
buildToolsVersion "30.0.3"
defaultConfig {
applicationId "net.vonforst.evmap"
minSdkVersion 21
targetSdkVersion 29
versionCode 27
versionName "0.4.0"
targetSdkVersion 30
versionCode 42
versionName "0.6.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
@@ -48,7 +48,6 @@ android {
productFlavors {
foss {
dimension "dependencies"
versionNameSuffix "-foss"
}
google {
dimension "dependencies"
@@ -110,8 +109,8 @@ dependencies {
implementation 'com.squareup.okhttp3:okhttp:4.9.0'
implementation 'com.squareup.okhttp3:okhttp-urlconnection:4.9.0'
implementation 'com.squareup.moshi:moshi-kotlin:1.9.2'
implementation 'com.squareup.picasso:picasso:2.71828'
implementation 'com.github.MikeOrtiz:TouchImageView:2.3.3'
implementation 'io.coil-kt:coil:1.1.0'
implementation 'com.github.MikeOrtiz:TouchImageView:3.0.3'
implementation "com.mikepenz:aboutlibraries-core:$about_libs_version"
implementation "com.mikepenz:aboutlibraries:$about_libs_version"
implementation 'com.airbnb.android:lottie:3.4.0'
@@ -120,8 +119,11 @@ dependencies {
implementation 'com.google.guava:guava:29.0-android'
implementation 'com.github.pengrad:mapscaleview:1.6.0'
// Android Auto
googleImplementation 'androidx.car.app:app:1.0.0-rc01'
// AnyMaps
def anyMapsVersion = '631708a156'
def anyMapsVersion = '1f050d860f'
implementation "com.github.johan12345.AnyMaps:anymaps-base:$anyMapsVersion"
googleImplementation "com.github.johan12345.AnyMaps:anymaps-google:$anyMapsVersion"
implementation "com.github.johan12345.AnyMaps:anymaps-mapbox:$anyMapsVersion"
@@ -180,4 +182,4 @@ dependencies {
kapt "com.squareup.moshi:moshi-kotlin-codegen:1.9.2"
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.1.1'
}
}

View File

@@ -1,10 +1,43 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
package="net.vonforst.evmap">
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="androidx.car.app.MAP_TEMPLATES" />
<uses-sdk tools:overrideLibrary="androidx.car.app" />
<application>
<meta-data
android:name="com.google.android.geo.API_KEY"
android:value="@string/google_maps_key" />
<meta-data
android:name="com.google.android.geo.API_KEY"
android:value="@string/google_maps_key" />
<meta-data
android:name="com.google.android.gms.car.application"
android:resource="@xml/automotive_app_desc" />
<meta-data
android:name="androidx.car.app.theme"
android:resource="@style/CarAppTheme" />
<service
android:name=".auto.CarAppService"
android:label="@string/app_name"
android:icon="@mipmap/ic_launcher"
android:exported="true">
<intent-filter>
<action
android:name="androidx.car.app.CarAppService"
android:category="androidx.car.app.category.CHARGING" />
</intent-filter>
</service>
<service
android:name=".auto.CarLocationService"
android:foregroundServiceType="location"
android:enabled="true" />
<activity android:name=".auto.PermissionActivity" />
</application>
</manifest>

View File

@@ -0,0 +1,632 @@
package net.vonforst.evmap.auto
import android.Manifest
import android.content.*
import android.content.pm.ApplicationInfo
import android.content.pm.PackageManager
import android.graphics.Bitmap
import android.graphics.drawable.BitmapDrawable
import android.location.Location
import android.net.Uri
import android.os.Bundle
import android.os.IBinder
import android.os.ResultReceiver
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.Session
import androidx.car.app.model.*
import androidx.car.app.model.Distance.UNIT_KILOMETERS
import androidx.car.app.validation.HostValidator
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.lifecycleScope
import androidx.localbroadcastmanager.content.LocalBroadcastManager
import coil.imageLoader
import coil.request.ImageRequest
import kotlinx.coroutines.*
import net.vonforst.evmap.*
import net.vonforst.evmap.api.availability.ChargeLocationStatus
import net.vonforst.evmap.api.availability.ChargepointStatus
import net.vonforst.evmap.api.availability.getAvailability
import net.vonforst.evmap.api.goingelectric.ChargeLocation
import net.vonforst.evmap.api.goingelectric.GoingElectricApi
import net.vonforst.evmap.api.nameForPlugType
import net.vonforst.evmap.storage.AppDatabase
import net.vonforst.evmap.ui.ChargerIconGenerator
import net.vonforst.evmap.ui.availabilityText
import net.vonforst.evmap.ui.getMarkerTint
import net.vonforst.evmap.utils.distanceBetween
import java.time.Duration
import java.time.ZoneId
import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
import kotlin.math.roundToInt
interface LocationAwareScreen {
fun updateLocation(location: Location)
}
class CarAppService : androidx.car.app.CarAppService() {
override fun createHostValidator(): HostValidator {
return if (applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE != 0) {
HostValidator.ALLOW_ALL_HOSTS_VALIDATOR
} else {
HostValidator.Builder(applicationContext)
.addAllowedHosts(androidx.car.app.R.array.hosts_allowlist_sample)
.build()
}
}
override fun onCreateSession(): Session {
return EVMapSession(this)
}
}
class EVMapSession(val cas: CarAppService) : Session(), LifecycleObserver {
var mapScreen: LocationAwareScreen? = null
set(value) {
field = value
location?.let { value?.updateLocation(it) }
}
private var location: Location? = null
private var locationService: CarLocationService? = null
private val serviceConnection = object : ServiceConnection {
override fun onServiceConnected(name: ComponentName, ibinder: IBinder) {
val binder: CarLocationService.LocalBinder = ibinder as CarLocationService.LocalBinder
locationService = binder.service
// TODO: check for location permission
locationService?.requestLocationUpdates()
}
override fun onServiceDisconnected(name: ComponentName?) {
locationService = null
}
}
init {
lifecycle.addObserver(this)
}
override fun onCreateScreen(intent: Intent): Screen {
return if (locationPermissionGranted()) {
WelcomeScreen(carContext, this)
} else {
PermissionScreen(carContext, this)
}
}
private fun locationPermissionGranted() =
ContextCompat.checkSelfPermission(
carContext,
Manifest.permission.ACCESS_FINE_LOCATION
) == PackageManager.PERMISSION_GRANTED
private val locationReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val location = intent.getParcelableExtra(CarLocationService.EXTRA_LOCATION) as Location?
val mapScreen = this@EVMapSession.mapScreen
if (location != null && mapScreen != null) {
mapScreen.updateLocation(location)
}
this@EVMapSession.location = location
}
}
@OnLifecycleEvent(Lifecycle.Event.ON_START)
fun bindLocationService() {
if (!locationPermissionGranted()) return
cas.bindService(
Intent(cas, CarLocationService::class.java),
serviceConnection,
Context.BIND_AUTO_CREATE
)
}
@OnLifecycleEvent(Lifecycle.Event.ON_STOP)
private fun unbindLocationService() {
locationService?.removeLocationUpdates()
cas.unbindService(serviceConnection)
}
@OnLifecycleEvent(Lifecycle.Event.ON_RESUME)
private fun registerBroadcastReceiver() {
LocalBroadcastManager.getInstance(cas).registerReceiver(
locationReceiver,
IntentFilter(CarLocationService.ACTION_BROADCAST)
);
}
@OnLifecycleEvent(Lifecycle.Event.ON_PAUSE)
private fun unregisterBroadcastReceiver() {
LocalBroadcastManager.getInstance(cas).unregisterReceiver(locationReceiver)
}
}
/**
* Welcome screen with selection between favorites and nearby chargers
*/
class WelcomeScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx), LocationAwareScreen {
private var location: Location? = null
override fun onGetTemplate(): Template {
session.mapScreen = this
return PlaceListMapTemplate.Builder().apply {
setTitle(carContext.getString(R.string.app_name))
location?.let {
setAnchor(Place.Builder(CarLocation.create(it)).build())
}
setItemList(ItemList.Builder().apply {
addItem(Row.Builder()
.setTitle(carContext.getString(R.string.auto_chargers_closeby))
.setImage(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
R.drawable.ic_address
)
)
.setTint(CarColor.DEFAULT).build()
)
.setBrowsable(true)
.setOnClickListener {
screenManager.push(MapScreen(carContext, session, favorites = false))
}
.build())
addItem(
Row.Builder()
.setTitle(carContext.getString(R.string.auto_favorites))
.setImage(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
R.drawable.ic_fav
)
)
.setTint(CarColor.DEFAULT).build()
)
.setBrowsable(true)
.setOnClickListener {
screenManager.push(MapScreen(carContext, session, favorites = true))
}
.build())
}.build())
setCurrentLocationEnabled(true)
setHeaderAction(Action.APP_ICON)
build()
}.build()
}
override fun updateLocation(location: Location) {
this.location = location
invalidate()
}
}
/**
* Screen to grant location permission
*/
class PermissionScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx) {
override fun onGetTemplate(): Template {
return MessageTemplate.Builder(carContext.getString(R.string.auto_location_permission_needed))
.setTitle(carContext.getString(R.string.app_name))
.setHeaderAction(Action.APP_ICON)
.addAction(
Action.Builder()
.setTitle(carContext.getString(R.string.grant_on_phone))
.setBackgroundColor(CarColor.PRIMARY)
.setOnClickListener(ParkedOnlyOnClickListener.create {
val intent = Intent(carContext, PermissionActivity::class.java)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
.putExtra(
PermissionActivity.EXTRA_RESULT_RECEIVER,
object : ResultReceiver(null) {
override fun onReceiveResult(
resultCode: Int,
resultData: Bundle?
) {
if (resultData!!.getBoolean(PermissionActivity.RESULT_GRANTED)) {
session.bindLocationService()
screenManager.push(
WelcomeScreen(
carContext,
session
)
)
}
}
})
carContext.startActivity(intent)
CarToast.makeText(
carContext,
R.string.opened_on_phone,
CarToast.LENGTH_LONG
).show()
})
.build()
)
.addAction(
Action.Builder()
.setTitle(carContext.getString(R.string.cancel))
.setOnClickListener {
carContext.finishCarApp()
}
.build(),
)
.build()
}
}
/**
* Main map screen showing either nearby chargers or favorites
*/
class MapScreen(ctx: CarContext, val session: EVMapSession, val favorites: Boolean = false) :
Screen(ctx), LocationAwareScreen {
private var updateCoroutine: Job? = null
private var numUpdates = 0
private val maxNumUpdates = 3
private var location: Location? = null
private var lastUpdateLocation: Location? = null
private var chargers: List<ChargeLocation>? = null
private val api by lazy {
GoingElectricApi.create(ctx.getString(R.string.goingelectric_key), context = ctx)
}
private val searchRadius = 5 // kilometers
private val updateThreshold = 2000 // meters
private val availabilityUpdateThreshold = Duration.ofMinutes(1)
private var availabilities: MutableMap<Long, Pair<ZonedDateTime, ChargeLocationStatus>> =
HashMap()
private val maxRows = 6
override fun onGetTemplate(): Template {
session.mapScreen = this
return PlaceListMapTemplate.Builder().apply {
setTitle(
carContext.getString(
if (favorites) {
R.string.auto_favorites
} else {
R.string.auto_chargers_closeby
}
)
)
location?.let {
setAnchor(Place.Builder(CarLocation.create(it)).build())
} ?: setLoading(true)
chargers?.take(maxRows)?.let { chargerList ->
val builder = ItemList.Builder()
chargerList.forEach { charger ->
builder.addItem(formatCharger(charger))
}
builder.setNoItemsMessage(
carContext.getString(
if (favorites) {
R.string.auto_no_favorites_found
} else {
R.string.auto_no_chargers_found
}
)
)
setItemList(builder.build())
} ?: setLoading(true)
setCurrentLocationEnabled(true)
setHeaderAction(Action.BACK)
build()
}.build()
}
private fun formatCharger(charger: ChargeLocation): Row {
val color = ContextCompat.getColor(carContext, getMarkerTint(charger))
val place =
Place.Builder(CarLocation.create(charger.coordinates.lat, charger.coordinates.lng))
.setMarker(
PlaceMarker.Builder()
.setColor(CarColor.createCustom(color, color))
.build()
)
.build()
return Row.Builder().apply {
setTitle(charger.name)
val text = SpannableStringBuilder()
// distance
location?.let {
val distance = distanceBetween(
it.latitude, it.longitude,
charger.coordinates.lat, charger.coordinates.lng
) / 1000
text.append(
"distance",
DistanceSpan.create(Distance.create(distance, UNIT_KILOMETERS)),
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
)
}
// power
if (text.isNotEmpty()) text.append(" · ")
text.append("${charger.maxPower.roundToInt()} kW")
// availability
availabilities[charger.id]?.second?.let { av ->
val status = av.status.values.flatten()
val available = availabilityText(status)
val total = charger.chargepoints.sumBy { it.count }
if (text.isNotEmpty()) text.append(" · ")
text.append(
"$available/$total",
ForegroundCarColorSpan.create(carAvailabilityColor(status)),
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
)
}
addText(text)
setMetadata(
Metadata.Builder()
.setPlace(place)
.build()
)
setOnClickListener {
screenManager.push(ChargerDetailScreen(carContext, charger))
}
}.build()
}
override fun updateLocation(location: Location) {
this.location = location
if (updateCoroutine != null) {
// don't update while still loading last update
return
}
invalidate()
if (lastUpdateLocation == null ||
location.distanceTo(lastUpdateLocation) > updateThreshold
) {
lastUpdateLocation = location
// update displayed chargers
loadChargers(location)
}
}
private val db = AppDatabase.getInstance(carContext)
private fun loadChargers(location: Location) {
numUpdates++
println(numUpdates)
if (numUpdates > maxNumUpdates) {
CarToast.makeText(carContext, R.string.auto_no_refresh_possible, CarToast.LENGTH_LONG)
.show()
return
}
updateCoroutine = lifecycleScope.launch {
// load chargers
if (favorites) {
chargers = db.chargeLocationsDao().getAllChargeLocationsAsync().sortedBy {
distanceBetween(
location.latitude, location.longitude,
it.coordinates.lat, it.coordinates.lng
)
}
} else {
val response = api.getChargepointsRadius(
location.latitude,
location.longitude,
searchRadius,
zoom = 16f
)
chargers =
response.body()?.chargelocations?.filterIsInstance(ChargeLocation::class.java)
}
// remove outdated availabilities
availabilities = availabilities.filter {
Duration.between(
it.value.first,
ZonedDateTime.now()
) > availabilityUpdateThreshold
}.toMutableMap()
// update availabilities
chargers?.take(maxRows)?.map {
lifecycleScope.async {
// update only if not yet stored
if (!availabilities.containsKey(it.id)) {
val date = ZonedDateTime.now()
val availability = getAvailability(it).data
if (availability != null) {
availabilities[it.id] = date to availability
}
}
}
}?.awaitAll()
updateCoroutine = null
invalidate()
}
}
}
class ChargerDetailScreen(ctx: CarContext, val chargerSparse: ChargeLocation) : Screen(ctx) {
var charger: ChargeLocation? = null
var photo: Bitmap? = null
private var availability: ChargeLocationStatus? = null
val apikey = ctx.getString(R.string.goingelectric_key)
private val api by lazy {
GoingElectricApi.create(apikey, context = ctx)
}
private val iconGen = ChargerIconGenerator(carContext, null, oversize = 1.4f, height = 64)
override fun onGetTemplate(): Template {
if (charger == null) loadCharger()
return PaneTemplate.Builder(
Pane.Builder().apply {
charger?.let { charger ->
addRow(Row.Builder().apply {
setTitle(charger.address.toString())
val icon = iconGen.getBitmap(
tint = getMarkerTint(charger),
fault = charger.faultReport != null,
multi = charger.isMulti()
)
setImage(
CarIcon.Builder(IconCompat.createWithBitmap(icon)).build(),
Row.IMAGE_TYPE_LARGE
)
val chargepointsText = SpannableStringBuilder()
charger.chargepointsMerged.forEachIndexed { i, cp ->
if (i > 0) chargepointsText.append(" · ")
chargepointsText.append(
"${cp.count}× ${
nameForPlugType(
carContext,
cp.type
)
} ${cp.formatPower()}"
)
availability?.status?.get(cp)?.let { status ->
chargepointsText.append(
" (${availabilityText(status)}/${cp.count})",
ForegroundCarColorSpan.create(carAvailabilityColor(status)),
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
)
}
}
addText(chargepointsText)
}.build())
addRow(Row.Builder().apply {
photo?.let {
setImage(
CarIcon.Builder(IconCompat.createWithBitmap(photo)).build(),
Row.IMAGE_TYPE_LARGE
)
}
val operatorText = StringBuilder().apply {
charger.operator?.let { append(it) }
charger.network?.let {
if (isNotEmpty()) append(" · ")
append(it)
}
}
setTitle(operatorText)
charger.cost?.let { addText(it.getStatusText(carContext, emoji = true)) }
charger.faultReport?.created?.let {
addText(
carContext.getString(
R.string.auto_fault_report_date,
it.atZone(ZoneId.systemDefault())
.format(DateTimeFormatter.ofLocalizedDate(FormatStyle.SHORT))
)
)
}
/*val types = charger.chargepoints.map { it.type }.distinct()
if (types.size == 1) {
setImage(
CarIcon.of(IconCompat.createWithResource(carContext, iconForPlugType(types[0]))),
Row.IMAGE_TYPE_ICON)
}*/
}.build())
addAction(Action.Builder()
.setIcon(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
R.drawable.ic_navigation
)
).build()
)
.setTitle(carContext.getString(R.string.navigate))
.setBackgroundColor(CarColor.PRIMARY)
.setOnClickListener {
navigateToCharger(charger)
}
.build())
addAction(
Action.Builder()
.setTitle(carContext.getString(R.string.open_in_app))
.setOnClickListener(ParkedOnlyOnClickListener.create {
val intent = Intent(carContext, MapsActivity::class.java)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
.putExtra(EXTRA_CHARGER_ID, charger.id)
.putExtra(EXTRA_LAT, charger.coordinates.lat)
.putExtra(EXTRA_LON, charger.coordinates.lng)
carContext.startActivity(intent)
CarToast.makeText(
carContext,
R.string.opened_on_phone,
CarToast.LENGTH_LONG
).show()
})
.build()
)
} ?: setLoading(true)
}.build()
).apply {
setTitle(chargerSparse.name)
setHeaderAction(Action.BACK)
}.build()
}
private fun navigateToCharger(charger: ChargeLocation) {
val coord = charger.coordinates
val intent =
Intent(
CarContext.ACTION_NAVIGATE,
Uri.parse("geo:0,0?q=${coord.lat},${coord.lng}(${charger.name})")
)
carContext.startCarApp(intent)
}
private fun loadCharger() {
lifecycleScope.launch {
val response = api.getChargepointDetail(chargerSparse.id)
charger = response.body()?.chargelocations?.get(0) as ChargeLocation
val photo = charger?.photos?.get(0)
photo?.let {
val size = (carContext.resources.displayMetrics.density * 64).roundToInt()
val url = "https://api.goingelectric.de/chargepoints/photo/?key=${apikey}" +
"&id=${photo.id}&size=${size}"
val request = ImageRequest.Builder(carContext).data(url).build()
this@ChargerDetailScreen.photo =
(carContext.imageLoader.execute(request).drawable as BitmapDrawable).bitmap
}
availability = charger?.let { getAvailability(it).data }
invalidate()
}
}
}
fun carAvailabilityColor(status: List<ChargepointStatus>): CarColor {
val unknown = status.any { it == ChargepointStatus.UNKNOWN }
val available = status.count { it == ChargepointStatus.AVAILABLE }
return if (unknown) {
CarColor.DEFAULT
} else if (available > 0) {
CarColor.GREEN
} else {
CarColor.RED
}
}

View File

@@ -0,0 +1,163 @@
package net.vonforst.evmap.auto
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.Service
import android.content.Intent
import android.location.Location
import android.os.*
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.localbroadcastmanager.content.LocalBroadcastManager
import com.google.android.gms.location.*
import net.vonforst.evmap.BuildConfig
import net.vonforst.evmap.R
class CarLocationService : Service() {
private lateinit var serviceHandler: Handler
private lateinit var locationRequest: LocationRequest
private lateinit var notificationManager: NotificationManager
private lateinit var locationCallback: LocationCallback
private lateinit var fusedLocationClient: FusedLocationProviderClient
private val binder: IBinder = LocalBinder(this)
private var location: Location? = null
private val UPDATE_INTERVAL_IN_MILLISECONDS = 10000L
private val FASTEST_UPDATE_INTERVAL_IN_MILLISECONDS = UPDATE_INTERVAL_IN_MILLISECONDS / 2
private val CHANNEL_ID = "car_location"
private val NOTIFICATION_ID = 1000
private val TAG = "CarLocationService"
companion object {
const val ACTION_BROADCAST: String = BuildConfig.APPLICATION_ID + ".car_location_broadcast"
const val EXTRA_LOCATION: String = BuildConfig.APPLICATION_ID + ".location"
}
override fun onCreate() {
fusedLocationClient = LocationServices.getFusedLocationProviderClient(this)
locationCallback = object : LocationCallback() {
override fun onLocationResult(locationResult: LocationResult) {
super.onLocationResult(locationResult)
onNewLocation(locationResult.lastLocation)
}
}
createLocationRequest()
getLastLocation()
val handlerThread = HandlerThread(TAG)
handlerThread.start()
serviceHandler = Handler(handlerThread.looper)
notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
// Android O requires a Notification Channel.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val name: CharSequence = getString(R.string.app_name)
// Create the channel for the notification
val mChannel =
NotificationChannel(CHANNEL_ID, name, NotificationManager.IMPORTANCE_DEFAULT)
// Set the Notification Channel for the Notification Manager.
notificationManager.createNotificationChannel(mChannel)
}
startForeground(NOTIFICATION_ID, getNotification())
}
/**
* Returns the [NotificationCompat] used as part of the foreground service.
*/
private fun getNotification(): Notification {
val builder: NotificationCompat.Builder = NotificationCompat.Builder(this, CHANNEL_ID)
.setContentText(getString(R.string.auto_location_service))
.setContentTitle(getString(R.string.app_name))
.setOngoing(true)
.setPriority(NotificationManagerCompat.IMPORTANCE_HIGH)
.setSmallIcon(R.mipmap.ic_launcher)
.setTicker(getString(R.string.auto_location_service))
.setWhen(System.currentTimeMillis())
return builder.build()
}
override fun onBind(intent: Intent?): IBinder {
return binder
}
private fun createLocationRequest() {
locationRequest = LocationRequest()
locationRequest.interval = UPDATE_INTERVAL_IN_MILLISECONDS
locationRequest.fastestInterval = FASTEST_UPDATE_INTERVAL_IN_MILLISECONDS
locationRequest.priority = LocationRequest.PRIORITY_HIGH_ACCURACY
}
private fun onNewLocation(location: Location) {
Log.i(TAG, "New location: $location")
this.location = location
// Notify anyone listening for broadcasts about the new location.
val intent = Intent(ACTION_BROADCAST)
intent.putExtra(EXTRA_LOCATION, location)
LocalBroadcastManager.getInstance(applicationContext).sendBroadcast(intent)
}
private fun getLastLocation() {
try {
fusedLocationClient.lastLocation
.addOnCompleteListener { task ->
if (task.isSuccessful && task.result != null) {
location = task.result
} else {
Log.w(TAG, "Failed to get location.")
}
}
} catch (unlikely: SecurityException) {
Log.e(TAG, "Lost location permission.$unlikely")
}
}
/**
* Makes a request for location updates. Note that in this sample we merely log the
* [SecurityException].
*/
fun requestLocationUpdates() {
Log.i(TAG, "Requesting location updates")
startService(Intent(applicationContext, CarLocationService::class.java))
try {
fusedLocationClient.requestLocationUpdates(
locationRequest,
locationCallback, Looper.myLooper()
)
} catch (unlikely: SecurityException) {
Log.e(TAG, "Lost location permission. Could not request updates. $unlikely")
}
}
/**
* Removes location updates. Note that in this sample we merely log the
* [SecurityException].
*/
fun removeLocationUpdates() {
Log.i(TAG, "Removing location updates")
try {
fusedLocationClient.removeLocationUpdates(locationCallback)
stopSelf()
} catch (unlikely: SecurityException) {
Log.e(TAG, "Lost location permission. Could not remove updates. $unlikely")
}
}
override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int {
Log.i(TAG, "Service started")
// Tells the system to not try to recreate the service after it has been killed.
return START_NOT_STICKY
}
override fun onDestroy() {
serviceHandler.removeCallbacksAndMessages(null)
}
class LocalBinder(val service: CarLocationService) : Binder()
}

View File

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

View File

@@ -5,4 +5,15 @@
<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 30% 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>
<string name="auto_no_favorites_found">Keine Favoriten gefunden</string>
<string name="open_in_app">In App öffnen</string>
<string name="opened_on_phone">Auf dem Telefon geöffnet</string>
<string name="auto_location_permission_needed">Um EVMap auf Android Auto zu nutzen, braucht die App Zugriff auf deinen Standort.</string>
<string name="grant_on_phone">Auf Telefon zulassen</string>
<string name="auto_chargers_closeby">In der Nähe</string>
<string name="auto_favorites">Favoriten</string>
<string name="auto_fault_report_date">⚠️ Störungsmeldung (%s)</string>
<string name="auto_no_refresh_possible">Weitere Aktualisierung nicht möglich. Bitte zurück gehen und neu starten.</string>
</resources>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="CarAppTheme">
<item name="carColorPrimary">@color/colorPrimary</item>
<item name="carColorPrimaryDark">@color/colorPrimaryVariant</item>
<item name="carColorSecondary">@color/colorSecondary</item>
<item name="carColorSecondaryDark">@color/colorSecondaryVariant</item>
</style>
</resources>

View File

@@ -10,4 +10,15 @@
</string-array>
<string name="pref_map_provider_default" translatable="false">google</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 30% 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>
<string name="auto_no_favorites_found">No favorites found</string>
<string name="open_in_app">Open in app</string>
<string name="opened_on_phone">Opened on phone</string>
<string name="auto_location_permission_needed">To run EVMap on Android Auto, you need to grant access to your location.</string>
<string name="grant_on_phone">Grant on phone</string>
<string name="auto_chargers_closeby">Nearby chargers</string>
<string name="auto_favorites">Favorites</string>
<string name="auto_fault_report_date">⚠️ Fault report (%s)</string>
<string name="auto_no_refresh_possible">Further updates not possible. Please go back and restart.</string>
</resources>

View File

@@ -0,0 +1,5 @@
<automotiveApp xmlns:tools="http://schemas.android.com/tools">
<uses
name="template"
tools:ignore="InvalidUsesTagAttribute" />
</automotiveApp>

View File

@@ -5,6 +5,17 @@
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<queries>
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="geo" />
</intent>
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="google.navigation" />
</intent>
</queries>
<application
android:name=".EvMapApplication"
android:allowBackup="true"
@@ -27,7 +38,23 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="geo" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<data
android:scheme="https"
android:host="www.goingelectric.de"
android:pathPattern="/stromtankstellen/..*/..*/..*/..*/" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
</intent-filter>
</activity>
</application>
</manifest>

View File

@@ -17,11 +17,15 @@ import androidx.navigation.ui.setupWithNavController
import com.google.android.material.navigation.NavigationView
import com.google.android.material.snackbar.Snackbar
import net.vonforst.evmap.api.goingelectric.ChargeLocation
import net.vonforst.evmap.fragment.MapFragment
import net.vonforst.evmap.storage.PreferenceDataSource
import net.vonforst.evmap.utils.LocaleContextWrapper
const val REQUEST_LOCATION_PERMISSION = 1
const val EXTRA_CHARGER_ID = "chargerId"
const val EXTRA_LAT = "lat"
const val EXTRA_LON = "lon"
class MapsActivity : AppCompatActivity() {
interface FragmentCallback {
@@ -64,6 +68,46 @@ class MapsActivity : AppCompatActivity() {
prefs = PreferenceDataSource(this)
checkPlayServices(this)
if (intent?.scheme == "geo") {
val pos = intent.data?.schemeSpecificPart?.split("?")?.get(0)
val coords = pos?.split(",")?.map { it.toDoubleOrNull() }
if (coords != null && coords.size == 2) {
val lat = coords[0]
val lon = coords[1]
if (lat != null && lon != null) {
val deepLink = navController.createDeepLink()
.setGraph(R.navigation.nav_graph)
.setDestination(R.id.map)
.setArguments(MapFragment.showLocation(lat, lon))
.createPendingIntent()
deepLink.send()
}
}
} else if (intent?.scheme == "https" && intent?.data?.host == "www.goingelectric.de") {
val id = intent.data?.pathSegments?.last()?.toLongOrNull()
if (id != null) {
val deepLink = navController.createDeepLink()
.setGraph(R.navigation.nav_graph)
.setDestination(R.id.map)
.setArguments(MapFragment.showChargerById(id))
.createPendingIntent()
deepLink.send()
}
} else if (intent.hasExtra(EXTRA_CHARGER_ID)) {
navController.createDeepLink()
.setDestination(R.id.map)
.setArguments(
MapFragment.showCharger(
intent.getLongExtra(EXTRA_CHARGER_ID, 0),
intent.getDoubleExtra(EXTRA_LAT, 0.0),
intent.getDoubleExtra(EXTRA_LON, 0.0)
)
)
.createPendingIntent()
.send()
}
}
fun navigateTo(charger: ChargeLocation) {
@@ -91,7 +135,7 @@ class MapsActivity : AppCompatActivity() {
cb.getRootView(),
R.string.no_maps_app_found,
Snackbar.LENGTH_SHORT
)
).show()
}
}

View File

@@ -48,7 +48,8 @@ fun buildDetails(
R.drawable.ic_address,
R.string.address,
loc.address.toString(),
loc.locationDescription
loc.locationDescription,
clickable = true
),
if (loc.operator != null) DetailsAdapter.Detail(
R.drawable.ic_operator,

View File

@@ -1,16 +1,23 @@
package net.vonforst.evmap.adapter
import android.annotation.SuppressLint
import android.view.MotionEvent
import android.view.animation.AccelerateInterpolator
import androidx.recyclerview.widget.ItemTouchHelper
import net.vonforst.evmap.R
import net.vonforst.evmap.databinding.ItemFilterProfileBinding
import net.vonforst.evmap.storage.FilterProfile
class FilterProfilesAdapter(val dragHelper: ItemTouchHelper) : DataBindingAdapter<FilterProfile>() {
class FilterProfilesAdapter(
val dragHelper: ItemTouchHelper,
val onDelete: (FilterProfile) -> Unit,
val onRename: (FilterProfile) -> Unit
) : DataBindingAdapter<FilterProfile>() {
init {
setHasStableIds(true)
}
@SuppressLint("ClickableViewAccessibility")
override fun bind(
holder: ViewHolder<FilterProfile>,
item: FilterProfile
@@ -24,6 +31,20 @@ class FilterProfilesAdapter(val dragHelper: ItemTouchHelper) : DataBindingAdapte
}
false
}
binding.foreground.translationX = 0f
binding.btnDelete.setOnClickListener {
binding.foreground.animate()
.translationX(binding.foreground.width.toFloat())
.setDuration(250)
.setInterpolator(AccelerateInterpolator())
.withEndAction {
onDelete(item)
}
.start()
}
binding.btnRename.setOnClickListener {
onRename(item)
}
}
override fun getItemId(position: Int): Long {

View File

@@ -7,9 +7,11 @@ import android.widget.ImageView
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import coil.load
import coil.memory.MemoryCache
import coil.size.OriginalSize
import coil.size.SizeResolver
import com.ortiz.touchview.TouchImageView
import com.squareup.picasso.Callback
import com.squareup.picasso.Picasso
import net.vonforst.evmap.R
import net.vonforst.evmap.api.goingelectric.ChargerPhoto
@@ -19,17 +21,19 @@ class GalleryAdapter(
val itemClickListener: ItemClickListener? = null,
val detailView: Boolean = false,
val pageToLoad: Int? = null,
val imageCacheKey: MemoryCache.Key? = null,
val loadedListener: (() -> Unit)? = null
) :
ListAdapter<ChargerPhoto, GalleryAdapter.ViewHolder>(ChargerPhotoDiffCallback()) {
class ViewHolder(val view: ImageView) : RecyclerView.ViewHolder(view)
interface ItemClickListener {
fun onItemClick(view: View, position: Int)
fun onItemClick(view: View, position: Int, imageCacheKey: MemoryCache.Key?)
}
val apikey = context.getString(R.string.goingelectric_key)
var loaded = false
val memoryKeys = HashMap<String, MemoryCache.Key?>()
@SuppressLint("ClickableViewAccessibility")
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
@@ -73,46 +77,63 @@ class GalleryAdapter(
if (detailView) {
(holder.view as TouchImageView).resetZoom()
}
Picasso.get()
.load(
"https://api.goingelectric.de/chargepoints/photo/?key=${apikey}" +
"&id=${getItem(position).id}" +
if (detailView) {
"&size=1000"
} else {
"&height=${holder.view.height}"
}
)
.into(holder.view, object : Callback {
override fun onSuccess() {
if (!loaded && loadedListener != null && pageToLoad == position) {
holder.view.viewTreeObserver.addOnPreDrawListener(object :
ViewTreeObserver.OnPreDrawListener {
override fun onPreDraw(): Boolean {
holder.view.viewTreeObserver.removeOnPreDrawListener(this)
loadedListener.invoke()
return true
}
})
loaded = true
}
val id = getItem(position).id
val url = "https://api.goingelectric.de/chargepoints/photo/?key=${apikey}" +
"&id=$id" +
if (detailView) {
"&size=1000"
} else {
"&height=${holder.view.height}"
}
override fun onError(e: Exception?) {
holder.view.load(
url
) {
if (pageToLoad == position && imageCacheKey != null) {
placeholderMemoryCacheKey(imageCacheKey)
}
size(SizeResolver(OriginalSize))
allowHardware(false)
listener(
onSuccess = { _, metadata ->
memoryKeys[id] = metadata.memoryCacheKey
if (pageToLoad == position) invokeLoadedListener(holder.view)
},
onError = { _, _ ->
if (!loaded && loadedListener != null && pageToLoad == position) {
loadedListener.invoke()
loaded = true
}
}
})
)
}
if (pageToLoad == position && imageCacheKey != null) {
// start transition immediately
if (pageToLoad == position) invokeLoadedListener(holder.view)
}
holder.view.transitionName = galleryTransitionName(position)
if (itemClickListener != null) {
holder.view.setOnClickListener {
itemClickListener.onItemClick(holder.view, position)
itemClickListener.onItemClick(holder.view, position, memoryKeys[id])
}
}
}
private fun invokeLoadedListener(
view: ImageView
) {
if (!loaded && loadedListener != null) {
view.viewTreeObserver.addOnPreDrawListener(object :
ViewTreeObserver.OnPreDrawListener {
override fun onPreDraw(): Boolean {
view.viewTreeObserver.removeOnPreDrawListener(this)
loadedListener.invoke()
return true
}
})
loaded = true
}
}
}
fun galleryTransitionName(position: Int) = "gallery_$position"

View File

@@ -1,7 +1,11 @@
package net.vonforst.evmap.api
import android.content.Context
import androidx.annotation.DrawableRes
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.suspendCancellableCoroutine
import net.vonforst.evmap.R
import net.vonforst.evmap.api.goingelectric.Chargepoint
import okhttp3.Call
import okhttp3.Callback
import okhttp3.Response
@@ -39,26 +43,35 @@ suspend fun Call.await(): Response {
}
}
const val earthRadiusKm: Double = 6372.8
private val plugNames = mapOf(
Chargepoint.TYPE_1 to R.string.plug_type_1,
Chargepoint.TYPE_2 to R.string.plug_type_2,
Chargepoint.TYPE_3 to R.string.plug_type_3,
Chargepoint.CCS to R.string.plug_ccs,
Chargepoint.SCHUKO to R.string.plug_schuko,
Chargepoint.CHADEMO to R.string.plug_chademo,
Chargepoint.SUPERCHARGER to R.string.plug_supercharger,
Chargepoint.CEE_BLAU to R.string.plug_cee_blau,
Chargepoint.CEE_ROT to R.string.plug_cee_rot,
Chargepoint.TESLA_ROADSTER_HPC to R.string.plug_roadster_hpc
)
/**
* Calculates the distance between two points on Earth in meters.
* Latitude and longitude should be given in degrees.
*/
fun distanceBetween(
startLatitude: Double, startLongitude: Double,
endLatitude: Double, endLongitude: Double
): Double {
// see https://rosettacode.org/wiki/Haversine_formula#Java
val dLat = Math.toRadians(endLatitude - startLatitude);
val dLon = Math.toRadians(endLongitude - startLongitude);
val originLat = Math.toRadians(startLatitude);
val destinationLat = Math.toRadians(endLatitude);
fun nameForPlugType(ctx: Context, type: String): String =
plugNames[type]?.let {
ctx.getString(it)
} ?: type
val a = Math.pow(Math.sin(dLat / 2), 2.toDouble()) + Math.pow(
Math.sin(dLon / 2),
2.toDouble()
) * Math.cos(originLat) * Math.cos(destinationLat);
val c = 2 * Math.asin(Math.sqrt(a));
return earthRadiusKm * c * 1000;
}
@DrawableRes
fun iconForPlugType(type: String): Int =
when (type) {
Chargepoint.CCS -> R.drawable.ic_connector_ccs
Chargepoint.CHADEMO -> R.drawable.ic_connector_chademo
Chargepoint.SCHUKO -> R.drawable.ic_connector_schuko
Chargepoint.SUPERCHARGER -> R.drawable.ic_connector_supercharger
Chargepoint.TYPE_2 -> R.drawable.ic_connector_typ2
Chargepoint.CEE_BLAU -> R.drawable.ic_connector_cee_blau
Chargepoint.CEE_ROT -> R.drawable.ic_connector_cee_rot
Chargepoint.TYPE_1 -> R.drawable.ic_connector_typ1
// TODO: add other connectors
else -> 0
}

View File

@@ -8,7 +8,10 @@ import net.vonforst.evmap.api.RateLimitInterceptor
import net.vonforst.evmap.api.await
import net.vonforst.evmap.api.goingelectric.ChargeLocation
import net.vonforst.evmap.api.goingelectric.Chargepoint
import net.vonforst.evmap.viewmodel.FilterValues
import net.vonforst.evmap.viewmodel.Resource
import net.vonforst.evmap.viewmodel.getMultipleChoiceValue
import net.vonforst.evmap.viewmodel.getSliderValue
import okhttp3.JavaNetCookieJar
import okhttp3.OkHttpClient
import okhttp3.Request
@@ -113,7 +116,21 @@ abstract class BaseAvailabilityDetector(private val client: OkHttpClient) : Avai
data class ChargeLocationStatus(
val status: Map<Chargepoint, List<ChargepointStatus>>,
val source: String
)
) {
fun applyFilters(filters: FilterValues?): ChargeLocationStatus {
if (filters == null) return this
val connectorsVal = filters.getMultipleChoiceValue("connectors")
val minPower = filters.getSliderValue("min_power")
val statusFiltered = status.filterKeys {
(connectorsVal.all || it.type in connectorsVal.values) && it.power > minPower
}
return this.copy(status = statusFiltered)
}
val totalChargepoints = status.map { it.key.count }.sum()
}
enum class ChargepointStatus {
AVAILABLE, UNKNOWN, CHARGING, OCCUPIED, FAULTED

View File

@@ -1,14 +1,15 @@
package net.vonforst.evmap.api.availability
import com.squareup.moshi.JsonClass
import net.vonforst.evmap.api.distanceBetween
import net.vonforst.evmap.api.goingelectric.ChargeLocation
import net.vonforst.evmap.api.goingelectric.Chargepoint
import net.vonforst.evmap.utils.distanceBetween
import okhttp3.OkHttpClient
import retrofit2.Retrofit
import retrofit2.converter.moshi.MoshiConverterFactory
import retrofit2.http.GET
import retrofit2.http.Path
import java.util.*
private const val coordRange = 0.1 // range of latitude and longitude for loading the map
private const val maxDistance = 15 // max distance between reported positions in meters
@@ -138,15 +139,18 @@ class NewMotionAvailabilityDetector(client: OkHttpClient, baseUrl: String? = nul
connectorStatus.forEach { (connector, statusStr) ->
val id = connector.uid
val power = connector.electricalProperties.getPower()
val type = when (connector.connectorType) {
"Type3" -> Chargepoint.TYPE_3
"Type2" -> Chargepoint.TYPE_2
"Type1" -> Chargepoint.TYPE_1
"Domestic" -> Chargepoint.SCHUKO
"Type2Combo" -> Chargepoint.CCS
"TepcoCHAdeMO" -> Chargepoint.CHADEMO
"Unspecified" -> "unspecified"
else -> throw IllegalArgumentException("unrecognized type ${connector.connectorType}")
val type = when (connector.connectorType.toLowerCase(Locale.ROOT)) {
"type3" -> Chargepoint.TYPE_3
"type2" -> Chargepoint.TYPE_2
"type1" -> Chargepoint.TYPE_1
"domestic" -> Chargepoint.SCHUKO
"type1combo" -> Chargepoint.CCS // US CCS, aka type1_combo
"type2combo" -> Chargepoint.CCS // EU CCS, aka type2_combo
"tepcochademo" -> Chargepoint.CHADEMO
"unspecified" -> "unknown"
"unknown" -> "unknown"
"saej1772" -> "unknown"
else -> "unknown"
}
val status = when (statusStr) {
"Unavailable" -> ChargepointStatus.FAULTED

View File

@@ -6,7 +6,6 @@ import com.squareup.moshi.Moshi
import net.vonforst.evmap.BuildConfig
import okhttp3.Cache
import okhttp3.OkHttpClient
import retrofit2.Call
import retrofit2.Response
import retrofit2.Retrofit
import retrofit2.converter.moshi.MoshiConverterFactory
@@ -16,7 +15,7 @@ import retrofit2.http.Query
interface GoingElectricApi {
@GET("chargepoints/")
suspend fun getChargepoints(
@Query("sw_lat") swlat: Double, @Query("sw_lng") sw_lng: Double,
@Query("sw_lat") sw_lat: Double, @Query("sw_lng") sw_lng: Double,
@Query("ne_lat") ne_lat: Double, @Query("ne_lng") ne_lng: Double,
@Query("zoom") zoom: Float,
@Query("clustering") clustering: Boolean = false,
@@ -35,7 +34,28 @@ interface GoingElectricApi {
): Response<ChargepointList>
@GET("chargepoints/")
fun getChargepointDetail(@Query("ge_id") id: Long): Call<ChargepointList>
suspend fun getChargepointsRadius(
@Query("lat") lat: Double, @Query("lng") lng: Double,
@Query("radius") radius: Int,
@Query("zoom") zoom: Float,
@Query("orderby") orderby: String = "distance",
@Query("clustering") clustering: Boolean = false,
@Query("cluster_distance") clusterDistance: Int? = null,
@Query("freecharging") freecharging: Boolean = false,
@Query("freeparking") freeparking: Boolean = false,
@Query("min_power") minPower: Int = 0,
@Query("plugs") plugs: String? = null,
@Query("chargecards") chargecards: String? = null,
@Query("networks") networks: String? = null,
@Query("categories") categories: String? = null,
@Query("startkey") startkey: Int? = null,
@Query("open_twentyfourseven") open247: Boolean = false,
@Query("barrierfree") barrierfree: Boolean = false,
@Query("exclude_faults") excludeFaults: Boolean = false
): Response<ChargepointList>
@GET("chargepoints/")
suspend fun getChargepointDetail(@Query("ge_id") id: Long): Response<ChargepointList>
@GET("chargepoints/pluglist/")
suspend fun getPlugs(): Response<StringList>

View File

@@ -80,6 +80,21 @@ data class ChargeLocation(
.map { it.power }.maxOrNull() ?: 0.0
}
fun isMulti(filteredConnectors: Set<String>? = null): Boolean {
var chargepoints = chargepointsMerged
.filter { filteredConnectors?.contains(it.type) ?: true }
if (maxPower(filteredConnectors) >= 43) {
// fast charger -> only count fast chargers
chargepoints = chargepoints.filter { it.power >= 43 }
}
val connectors = chargepoints.map { it.type }.distinct().toSet()
// check if there is more than one plug for any connector type
val chargepointsPerConnector =
connectors.map { conn -> chargepoints.filter { it.type == conn }.sumBy { it.count } }
return chargepointsPerConnector.any { it > 1 }
}
/**
* Merges chargepoints if they have the same plug and power
*
@@ -97,6 +112,9 @@ data class ChargeLocation(
}
}
val totalChargepoints: Int
get() = chargepoints.sumBy { it.count }
fun formatChargepoints(): String {
return chargepointsMerged.map {
"${it.count} × ${it.type} ${it.formatPower()}"
@@ -111,14 +129,16 @@ data class Cost(
@JsonObjectOrFalse @Json(name = "description_short") val descriptionShort: String?,
@JsonObjectOrFalse @Json(name = "description_long") val descriptionLong: String?
) {
fun getStatusText(ctx: Context): CharSequence {
return HtmlCompat.fromHtml(
ctx.getString(
R.string.cost_detail,
if (freecharging) ctx.getString(R.string.free) else ctx.getString(R.string.paid),
if (freeparking) ctx.getString(R.string.free) else ctx.getString(R.string.paid)
), 0
)
fun getStatusText(ctx: Context, emoji: Boolean = false): CharSequence {
val charging =
if (freecharging) ctx.getString(R.string.free) else ctx.getString(R.string.paid)
val parking =
if (freeparking) ctx.getString(R.string.free) else ctx.getString(R.string.paid)
return if (emoji) {
"$charging · \uD83C\uDD7F $parking"
} else {
HtmlCompat.fromHtml(ctx.getString(R.string.cost_detail, charging, parking), 0)
}
}
}

View File

@@ -86,8 +86,9 @@ class FavoritesFragment : Fragment(), LostApiClient.ConnectionCallbacks {
}
override fun onConnected() {
val context = this.context ?: return
if (ContextCompat.checkSelfPermission(
requireContext(),
context,
Manifest.permission.ACCESS_FINE_LOCATION
) == PackageManager.PERMISSION_GRANTED
) {

View File

@@ -1,14 +1,7 @@
package net.vonforst.evmap.fragment
import android.content.Context
import android.content.DialogInterface
import android.os.Bundle
import android.view.*
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.AppCompatActivity
import androidx.appcompat.widget.Toolbar
import androidx.databinding.DataBindingUtil
@@ -24,6 +17,7 @@ import net.vonforst.evmap.MapsActivity
import net.vonforst.evmap.R
import net.vonforst.evmap.adapter.FiltersAdapter
import net.vonforst.evmap.databinding.FragmentFilterBinding
import net.vonforst.evmap.ui.showEditTextDialog
import net.vonforst.evmap.viewmodel.FilterViewModel
import net.vonforst.evmap.viewmodel.viewModelFactory
@@ -96,54 +90,22 @@ class FilterFragment : Fragment() {
true
}
R.id.menu_save_profile -> {
val container = FrameLayout(requireContext())
container.setPadding(
(16 * resources.displayMetrics.density).toInt(), 0,
(16 * resources.displayMetrics.density).toInt(), 0
)
val input = EditText(requireContext())
input.isSingleLine = true
vm.filterProfile.value?.let { profile ->
input.setText(profile.name)
}
container.addView(input)
val dialog = AlertDialog.Builder(requireContext())
.setTitle(R.string.save_as_profile)
.setMessage(R.string.save_profile_enter_name)
.setView(container)
.setPositiveButton(R.string.ok) { di, button ->
lifecycleScope.launch {
vm.saveAsProfile(input.text.toString())
findNavController().popBackStack()
}
showEditTextDialog(requireContext()) { dialog, input ->
vm.filterProfile.value?.let { profile ->
input.setText(profile.name)
}
.setNegativeButton(R.string.cancel) { di, button ->
}.show()
// move dialog to top
val attrs = dialog.window?.attributes?.apply {
gravity = Gravity.TOP
}
dialog.window?.attributes = attrs
// focus and show keyboard
input.requestFocus()
input.postDelayed({
val imm =
requireContext().getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
imm.showSoftInput(input, InputMethodManager.SHOW_IMPLICIT)
}, 100)
input.setOnEditorActionListener { _, actionId, event ->
if (actionId == EditorInfo.IME_ACTION_DONE) {
val text = input.text
if (text != null) {
dialog.getButton(DialogInterface.BUTTON_POSITIVE).performClick()
return@setOnEditorActionListener true
dialog.setTitle(R.string.save_as_profile)
.setMessage(R.string.save_profile_enter_name)
.setPositiveButton(R.string.ok) { di, button ->
lifecycleScope.launch {
vm.saveAsProfile(input.text.toString())
findNavController().popBackStack()
}
}
.setNegativeButton(R.string.cancel) { di, button ->
}
}
false
}
true
}

View File

@@ -11,29 +11,38 @@ import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.widget.Toolbar
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.findNavController
import androidx.navigation.ui.setupWithNavController
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.snackbar.Snackbar
import kotlinx.coroutines.launch
import net.vonforst.evmap.MapsActivity
import net.vonforst.evmap.R
import net.vonforst.evmap.adapter.DataBindingAdapter
import net.vonforst.evmap.adapter.FilterProfilesAdapter
import net.vonforst.evmap.databinding.FragmentFilterProfilesBinding
import net.vonforst.evmap.databinding.ItemFilterProfileBinding
import net.vonforst.evmap.storage.FilterProfile
import net.vonforst.evmap.ui.showEditTextDialog
import net.vonforst.evmap.viewmodel.FilterProfilesViewModel
import net.vonforst.evmap.viewmodel.viewModelFactory
class FilterProfilesFragment : Fragment() {
private lateinit var touchHelper: ItemTouchHelper
private lateinit var adapter: FilterProfilesAdapter
private lateinit var binding: FragmentFilterProfilesBinding
private val vm: FilterProfilesViewModel by viewModels(factoryProducer = {
viewModelFactory {
FilterProfilesViewModel(requireActivity().application)
}
})
private var deleteSnackbar: Snackbar? = null
private var toDelete: FilterProfile? = null
override fun onCreateView(
inflater: LayoutInflater,
@@ -58,7 +67,7 @@ class FilterProfilesFragment : Fragment() {
)
val touchHelper = ItemTouchHelper(object : ItemTouchHelper.SimpleCallback(
touchHelper = ItemTouchHelper(object : ItemTouchHelper.SimpleCallback(
ItemTouchHelper.UP or ItemTouchHelper.DOWN,
ItemTouchHelper.RIGHT or ItemTouchHelper.LEFT
) {
@@ -86,7 +95,8 @@ class FilterProfilesFragment : Fragment() {
}
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
vm.delete(viewHolder.itemId)
val fp = vm.filterProfiles.value?.find { it.id == viewHolder.itemId }
fp?.let { delete(it) }
}
override fun onSelectedChanged(viewHolder: RecyclerView.ViewHolder?, actionState: Int) {
@@ -166,9 +176,26 @@ class FilterProfilesFragment : Fragment() {
}
})
val adapter = FilterProfilesAdapter(touchHelper)
adapter = FilterProfilesAdapter(touchHelper, onDelete = { fp ->
delete(fp)
}, onRename = { fp ->
showEditTextDialog(requireContext()) { dialog, input ->
input.setText(fp.name)
dialog.setTitle(R.string.rename)
.setMessage(R.string.save_profile_enter_name)
.setPositiveButton(R.string.ok) { di, button ->
lifecycleScope.launch {
vm.update(fp.copy(name = input.text.toString()))
}
}
.setNegativeButton(R.string.cancel) { di, button ->
}
}
})
binding.filterProfilesList.apply {
this.adapter = adapter
this.adapter = this@FilterProfilesFragment.adapter
layoutManager =
LinearLayoutManager(context, LinearLayoutManager.VERTICAL, false)
addItemDecoration(
@@ -184,4 +211,45 @@ class FilterProfilesFragment : Fragment() {
findNavController().popBackStack()
}
}
fun delete(fp: FilterProfile) {
val position = vm.filterProfiles.value?.indexOf(fp) ?: return
// if there is already a profile to delete, delete it now
actuallyDelete()
deleteSnackbar?.dismiss()
toDelete = fp
view?.let {
val snackbar = Snackbar.make(
it,
getString(R.string.deleted_filterprofile, fp.name),
Snackbar.LENGTH_LONG
).setAction(R.string.undo) {
toDelete = null
adapter.notifyItemChanged(position)
}.addCallback(object : Snackbar.Callback() {
override fun onDismissed(transientBottomBar: Snackbar?, event: Int) {
// if undo was not clicked, actually delete
if (event == DISMISS_EVENT_TIMEOUT || event == DISMISS_EVENT_SWIPE) {
actuallyDelete()
}
}
})
deleteSnackbar = snackbar
snackbar.show()
} ?: run {
actuallyDelete()
}
}
private fun actuallyDelete() {
toDelete?.let { vm.delete(it.id) }
toDelete = null
}
override fun onStop() {
super.onStop()
actuallyDelete()
}
}

View File

@@ -12,6 +12,7 @@ import androidx.fragment.app.activityViewModels
import androidx.navigation.fragment.findNavController
import androidx.transition.TransitionInflater
import androidx.viewpager2.widget.ViewPager2
import coil.memory.MemoryCache
import com.ortiz.touchview.TouchImageView
import net.vonforst.evmap.R
import net.vonforst.evmap.adapter.GalleryAdapter
@@ -24,18 +25,23 @@ class GalleryFragment : Fragment() {
companion object {
private const val EXTRA_POSITION = "position"
private const val EXTRA_PHOTOS = "photos"
private const val EXTRA_IMAGE_CACHE_KEY = "image_cache_key"
private const val SAVED_CURRENT_PAGE_POSITION = "current_page_position"
fun buildArgs(photos: List<ChargerPhoto>, position: Int): Bundle {
fun buildArgs(
photos: List<ChargerPhoto>,
position: Int,
imageCacheKey: MemoryCache.Key?
): Bundle {
return Bundle().apply {
putParcelableArrayList(EXTRA_PHOTOS, ArrayList(photos))
putInt(EXTRA_POSITION, position)
putParcelable(EXTRA_IMAGE_CACHE_KEY, imageCacheKey)
}
}
}
private lateinit var binding: FragmentGalleryBinding
private var isReturning: Boolean = false
private var startingPosition: Int = 0
private var currentPosition: Int = 0
private lateinit var galleryAdapter: GalleryAdapter
@@ -49,7 +55,6 @@ class GalleryFragment : Fragment() {
if (image != null && image.currentZoom !in 0.95f..1.05f) {
image.setZoomAnimated(1f, 0.5f, 0.5f)
} else {
isReturning = true
galleryVm.galleryPosition.value = currentPosition
findNavController().popBackStack()
}
@@ -73,7 +78,10 @@ class GalleryFragment : Fragment() {
savedInstanceState?.getInt(SAVED_CURRENT_PAGE_POSITION) ?: startingPosition
galleryAdapter =
GalleryAdapter(requireContext(), detailView = true, pageToLoad = currentPosition) {
GalleryAdapter(
requireContext(), detailView = true, pageToLoad = currentPosition,
imageCacheKey = args.getParcelable(EXTRA_IMAGE_CACHE_KEY)
) {
startPostponedEnterTransition()
}
binding.gallery.setPageTransformer { page, _ ->
@@ -120,10 +128,8 @@ class GalleryFragment : Fragment() {
names: MutableList<String>,
sharedElements: MutableMap<String, View>
) {
if (isReturning) {
val currentPage = currentPage ?: return
sharedElements[names[0]] = currentPage
}
val currentPage = currentPage ?: return
sharedElements[names[0]] = currentPage
}
}

View File

@@ -7,6 +7,7 @@ import android.content.Intent
import android.content.pm.PackageManager
import android.content.res.Configuration
import android.graphics.Color
import android.location.Location
import android.os.Bundle
import android.os.Handler
import android.view.*
@@ -35,6 +36,7 @@ import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.transition.TransitionInflater
import androidx.transition.TransitionManager
import coil.memory.MemoryCache
import com.car2go.maps.AnyMap
import com.car2go.maps.MapFragment
import com.car2go.maps.OnMapReadyCallback
@@ -49,11 +51,15 @@ 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 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.adapter.ConnectorAdapter
import net.vonforst.evmap.adapter.DetailsAdapter
@@ -71,13 +77,14 @@ import net.vonforst.evmap.ui.MarkerAnimator
import net.vonforst.evmap.ui.getMarkerTint
import net.vonforst.evmap.viewmodel.*
const val REQUEST_AUTOCOMPLETE = 2
const val ARG_CHARGER_ID = "chargerId"
const val ARG_LAT = "lat"
const val ARG_LON = "lon"
class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallback,
LostApiClient.ConnectionCallbacks {
LostApiClient.ConnectionCallbacks, LocationListener {
private lateinit var binding: FragmentMapBinding
private val vm: MapViewModel by viewModels(factoryProducer = {
viewModelFactory {
@@ -88,9 +95,10 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
}
})
private val galleryVm: GalleryViewModel by activityViewModels()
private lateinit var mapFragment: MapFragment
private var mapFragment: MapFragment? = null
private var map: AnyMap? = null
private lateinit var locationClient: LostApiClient
private var requestingLocationUpdates = false
private lateinit var bottomSheetBehavior: BottomSheetBehaviorGoogleMapsLike<View>
private lateinit var detailAppBarBehavior: MergedAppBarLayoutBehavior
private var markers: MutableBiMap<Marker, ChargeLocation> = HashBiMap()
@@ -123,19 +131,29 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
locationClient = LostApiClient.Builder(requireContext())
.addConnectionCallbacks(this)
.build()
locationClient.connect()
clusterIconGenerator = ClusterIconGenerator(requireContext())
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
): View {
binding = DataBindingUtil.inflate(inflater, R.layout.fragment_map, container, false)
binding.lifecycleOwner = this
binding.vm = vm
mapFragment = MapFragment()
val provider = PreferenceDataSource(requireContext()).mapProvider
mapFragment.setPriority(
arrayOf(
if (mapFragment == null || mapFragment!!.priority[0] != provider) {
mapFragment = MapFragment()
mapFragment!!.priority = arrayOf(
when (provider) {
"mapbox" -> MapFragment.MAPBOX
"google" -> MapFragment.GOOGLE
@@ -144,25 +162,18 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
MapFragment.GOOGLE,
MapFragment.MAPBOX
)
)
requireActivity().supportFragmentManager
.beginTransaction()
.replace(R.id.map, mapFragment)
.commit()
requireActivity().supportFragmentManager
.beginTransaction()
.replace(R.id.map, mapFragment!!)
.commit()
// reset map-related stuff (map provider may have changed)
map = null
markers.clear()
clusterMarkers = emptyList()
searchResultMarker = null
searchResultIcon = null
locationClient = LostApiClient.Builder(requireContext())
.addConnectionCallbacks(this)
.build()
locationClient.connect()
clusterIconGenerator = ClusterIconGenerator(requireContext())
// reset map-related stuff (map provider may have changed)
map = null
markers.clear()
clusterMarkers = emptyList()
searchResultMarker = null
searchResultIcon = null
}
setHasOptionsMenu(true)
postponeEnterTransition()
@@ -186,7 +197,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
insets
}
setExitSharedElementCallback(exitElementCallback)
setExitSharedElementCallback(reenterSharedElementCallback)
exitTransition = TransitionInflater.from(requireContext())
.inflateTransition(R.transition.map_exit_transition)
@@ -199,7 +210,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
mapFragment.getMapAsync(this)
mapFragment!!.getMapAsync(this)
bottomSheetBehavior = BottomSheetBehaviorGoogleMapsLike.from(binding.bottomSheet)
detailAppBarBehavior = MergedAppBarLayoutBehavior.from(binding.detailAppBar)
@@ -216,6 +227,21 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
navController,
(requireActivity() as MapsActivity).appBarConfiguration
)
val prefs = PreferenceDataSource(requireContext())
if (!prefs.welcomeDialogShown) {
try {
navController.navigate(R.id.action_map_to_welcome)
} catch (ignored: IllegalArgumentException) {
// when there is already another navigation going on
}
} else if (!prefs.update060AndroidAutoDialogShown) {
try {
navController.navigate(R.id.action_map_to_update_060_androidauto)
} catch (ignored: IllegalArgumentException) {
// when there is already another navigation going on
}
}
}
override fun onResume() {
@@ -223,6 +249,13 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
val hostActivity = activity as? MapsActivity ?: return
hostActivity.fragmentCallback = this
vm.reloadPrefs()
if (requestingLocationUpdates && ContextCompat.checkSelfPermission(
requireContext(),
ACCESS_FINE_LOCATION
) == PackageManager.PERMISSION_GRANTED
) {
requestLocationUpdates()
}
}
private fun setupClickListeners() {
@@ -260,7 +293,8 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
binding.detailView.btnChargeprice.setOnClickListener {
val charger = vm.charger.value?.data ?: return@setOnClickListener
(activity as? MapsActivity)?.openUrl(
"https://www.chargeprice.app/?poi_id=${charger.id}&poi_source=going_electric")
"https://www.chargeprice.app/?poi_id=${charger.id}&poi_source=going_electric"
)
}
binding.detailView.topPart.setOnClickListener {
bottomSheetBehavior.state = BottomSheetBehaviorGoogleMapsLike.STATE_ANCHOR_POINT
@@ -343,7 +377,12 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
vm.bottomSheetState.value = newState
updateBackPressedCallback()
if (vm.layersMenuOpen.value!! && newState !in listOf(BottomSheetBehaviorGoogleMapsLike.STATE_SETTLING, BottomSheetBehaviorGoogleMapsLike.STATE_HIDDEN, BottomSheetBehaviorGoogleMapsLike.STATE_COLLAPSED)) {
if (vm.layersMenuOpen.value!! && newState !in listOf(
BottomSheetBehaviorGoogleMapsLike.STATE_SETTLING,
BottomSheetBehaviorGoogleMapsLike.STATE_HIDDEN,
BottomSheetBehaviorGoogleMapsLike.STATE_COLLAPSED
)
) {
closeLayersMenu()
}
}
@@ -397,6 +436,8 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
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 {
@@ -446,7 +487,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
getMarkerTint(c, vm.filteredConnectors.value),
highlight = false,
fault = c.faultReport != null,
multi = getMarkerMulti(c, vm.filteredConnectors.value)
multi = c.isMulti(vm.filteredConnectors.value)
)
)
}
@@ -460,7 +501,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
getMarkerTint(charger, vm.filteredConnectors.value),
highlight = true,
fault = charger.faultReport != null,
multi = getMarkerMulti(charger, vm.filteredConnectors.value)
multi = charger.isMulti(vm.filteredConnectors.value)
)
)
animator.animateMarkerBounce(marker)
@@ -473,28 +514,13 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
getMarkerTint(c, vm.filteredConnectors.value),
highlight = false,
fault = c.faultReport != null,
multi = getMarkerMulti(c, vm.filteredConnectors.value)
multi = c.isMulti(vm.filteredConnectors.value)
)
)
}
}
}
private fun getMarkerMulti(charger: ChargeLocation, filteredConnectors: Set<String>?): Boolean {
var chargepoints = charger.chargepointsMerged
.filter { filteredConnectors?.contains(it.type) ?: true }
if (charger.maxPower(filteredConnectors) >= 43) {
// fast charger -> only count fast chargers
chargepoints = chargepoints.filter { it.power >= 43 }
}
val connectors = chargepoints.map { it.type }.distinct().toSet()
// check if there is more than one plug for any connector type
val chargepointsPerConnector =
connectors.map { conn -> chargepoints.filter { it.type == conn }.sumBy { it.count } }
return chargepointsPerConnector.any { it > 1 }
}
private fun updateFavoriteToggle() {
val favs = vm.favorites.value ?: return
val charger = vm.chargerSparse.value ?: return
@@ -507,12 +533,12 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
private fun setupAdapters() {
val galleryClickListener = object : GalleryAdapter.ItemClickListener {
override fun onItemClick(view: View, position: Int) {
override fun onItemClick(view: View, position: Int, imageCacheKey: MemoryCache.Key?) {
val photos = vm.charger.value?.data?.photos ?: return
val extras = FragmentNavigatorExtras(view to view.transitionName)
view.findNavController().navigate(
R.id.action_map_to_galleryFragment,
GalleryFragment.buildArgs(photos, position),
GalleryFragment.buildArgs(photos, position, imageCacheKey),
null,
extras
)
@@ -531,17 +557,43 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
DividerItemDecoration(
context, LinearLayoutManager.HORIZONTAL
).apply {
setDrawable(context.getDrawable(R.drawable.gallery_divider)!!)
setDrawable(ContextCompat.getDrawable(context, R.drawable.gallery_divider)!!)
})
}
if (galleryPosition == null) {
startPostponedEnterTransition()
} else {
binding.gallery.scrollToPosition(galleryPosition)
binding.gallery.addOnLayoutChangeListener(object : View.OnLayoutChangeListener {
override fun onLayoutChange(
v: View,
left: Int,
top: Int,
right: Int,
bottom: Int,
oldLeft: Int,
oldTop: Int,
oldRight: Int,
oldBottom: Int
) {
v.removeOnLayoutChangeListener(this)
val layoutManager = binding.gallery.layoutManager!!
val viewAtPosition = layoutManager.findViewByPosition(galleryPosition)
if (viewAtPosition == null || layoutManager.isViewPartiallyVisible(
viewAtPosition,
false,
true
)
) {
binding.gallery.post {
layoutManager.scrollToPosition(galleryPosition)
}
}
}
})
// make sure that the app does not freeze waiting for a picture to load
Handler().postDelayed({
startPostponedEnterTransition()
}, 500)
}, 100)
}
binding.detailView.connectors.apply {
@@ -557,7 +609,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
val charger = vm.chargerDetails.value?.data
if (charger != null) {
when (it.icon) {
R.drawable.ic_location -> {
R.drawable.ic_location, R.drawable.ic_address -> {
(activity as? MapsActivity)?.showLocation(charger)
}
R.drawable.ic_fault_report -> {
@@ -609,6 +661,21 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
override fun onMapReady(map: AnyMap) {
this.map = map
chargerIconGenerator = ChargerIconGenerator(requireContext(), map.bitmapDescriptorFactory)
if (BuildConfig.FLAVOR == "google" && mapFragment!!.priority[0] == MapFragment.GOOGLE) {
// Google Maps: icons can be generated in background thread
lifecycleScope.launch {
withContext(Dispatchers.IO) {
chargerIconGenerator.preloadCache()
}
}
} else {
// Mapbox: needs to be run on main thread
chargerIconGenerator.preloadCache()
}
animator = MarkerAnimator(chargerIconGenerator)
map.uiSettings.setTiltGesturesEnabled(false)
map.setIndoorEnabled(false)
@@ -622,6 +689,15 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
map.setOnCameraMoveListener {
binding.scaleView.update(map.cameraPosition.zoom, map.cameraPosition.target.latitude)
}
map.setOnCameraMoveStartedListener { reason ->
if (reason == AnyMap.OnCameraMoveStartedListener.REASON_GESTURE
&& vm.myLocationEnabled.value == true
) {
// disable location following when manually scrolling the map
vm.myLocationEnabled.value = false
removeLocationUpdates()
}
}
map.setOnMarkerClickListener { marker ->
when (marker) {
in markers -> {
@@ -647,6 +723,8 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
backPressedCallback.handleOnBackPressed()
}
}
map.setMapType(vm.mapType.value)
map.setTrafficEnabled(vm.mapTrafficEnabled.value ?: false)
// set padding so that compass is not obstructed by toolbar
map.setPadding(0, binding.toolbarContainer.height, 0, 0)
@@ -660,33 +738,57 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
val position = vm.mapPosition.value
val lat = arguments?.optDouble(ARG_LAT)
val lon = arguments?.optDouble(ARG_LON)
val chargerId = arguments?.optLong(ARG_CHARGER_ID)
var positionSet = false
if (position != null) {
val cameraUpdate =
map.cameraUpdateFactory.newLatLngZoom(position.bounds.center, position.zoom)
map.moveCamera(cameraUpdate)
positionSet = true
} else if (chargerId != null && (lat == null || lon == null)) {
// show given charger ID
vm.loadChargerById(chargerId)
vm.chargerSparse.observe(
viewLifecycleOwner,
object : Observer<ChargeLocation> {
override fun onChanged(item: ChargeLocation?) {
if (item?.id == chargerId) {
val cameraUpdate = map.cameraUpdateFactory.newLatLngZoom(
LatLng(item.coordinates.lat, item.coordinates.lng), 16f
)
map.moveCamera(cameraUpdate)
vm.chargerSparse.removeObserver(this)
}
}
})
positionSet = true
} else if (lat != null && lon != null) {
// show given position
val cameraUpdate = map.cameraUpdateFactory.newLatLngZoom(LatLng(lat, lon), 16f)
map.moveCamera(cameraUpdate)
// show charger detail after chargers were loaded
val chargerId = arguments?.optLong(ARG_CHARGER_ID)
vm.chargepoints.observe(
viewLifecycleOwner,
object : Observer<Resource<List<ChargepointListItem>>> {
override fun onChanged(res: Resource<List<ChargepointListItem>>) {
if (res.data == null) return
for (item in res.data) {
if (item is ChargeLocation && item.id == chargerId) {
vm.chargerSparse.value = item
vm.chargepoints.removeObserver(this)
if (chargerId != null) {
// show charger detail after chargers were loaded
vm.chargepoints.observe(
viewLifecycleOwner,
object : Observer<Resource<List<ChargepointListItem>>> {
override fun onChanged(res: Resource<List<ChargepointListItem>>) {
if (res.data == null) return
for (item in res.data) {
if (item is ChargeLocation && item.id == chargerId) {
vm.chargerSparse.value = item
vm.chargepoints.removeObserver(this)
}
}
}
}
})
})
} else {
// mark location as search result
vm.searchResult.value = PlaceWithBounds(LatLng(lat, lon), null)
}
positionSet = true
}
@@ -714,15 +816,18 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
private fun enableLocation(moveTo: Boolean, animate: Boolean) {
val map = this.map ?: return
map.setMyLocationEnabled(true)
vm.myLocationEnabled.value = true
map.uiSettings.setMyLocationButtonEnabled(false)
if (moveTo && locationClient.isConnected) {
moveToCurrentLocation(map, animate)
if (moveTo) {
vm.myLocationEnabled.value = true
if (locationClient.isConnected) {
moveToLastLocation(map, animate)
requestLocationUpdates()
}
}
}
@RequiresPermission(ACCESS_FINE_LOCATION)
private fun moveToCurrentLocation(map: AnyMap, animate: Boolean) {
private fun moveToLastLocation(map: AnyMap, animate: Boolean) {
val location = LocationServices.FusedLocationApi.getLastLocation(locationClient)
if (location != null) {
val latLng = LatLng(location.latitude, location.longitude)
@@ -753,7 +858,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
getMarkerTint(charger, vm.filteredConnectors.value),
highlight = charger == vm.chargerSparse.value,
fault = charger.faultReport != null,
multi = getMarkerMulti(charger, vm.filteredConnectors.value)
multi = charger.isMulti(vm.filteredConnectors.value)
)
)
}
@@ -770,7 +875,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
val tint = getMarkerTint(charger, vm.filteredConnectors.value)
val highlight = charger == vm.chargerSparse.value
val fault = charger.faultReport != null
val multi = getMarkerMulti(charger, vm.filteredConnectors.value)
val multi = charger.isMulti(vm.filteredConnectors.value)
animator.animateMarkerDisappear(marker, tint, highlight, fault, multi)
} else {
animator.deleteMarker(marker)
@@ -785,14 +890,14 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
val tint = getMarkerTint(charger, vm.filteredConnectors.value)
val highlight = charger == vm.chargerSparse.value
val fault = charger.faultReport != null
val multi = getMarkerMulti(charger, vm.filteredConnectors.value)
val multi = charger.isMulti(vm.filteredConnectors.value)
val marker = map.addMarker(
MarkerOptions()
.position(LatLng(charger.coordinates.lat, charger.coordinates.lng))
.icon(
chargerIconGenerator.getBitmapDescriptor(
tint,
0,
0f,
255,
highlight,
fault,
@@ -974,16 +1079,17 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
return binding.root
}
private val exitElementCallback: SharedElementCallback = object : SharedElementCallback() {
override fun onMapSharedElements(
names: MutableList<String>,
sharedElements: MutableMap<String, View>
) {
// Locate the ViewHolder for the clicked position.
val position = galleryVm.galleryPosition.value ?: return
private val reenterSharedElementCallback: SharedElementCallback =
object : SharedElementCallback() {
override fun onMapSharedElements(
names: MutableList<String>,
sharedElements: MutableMap<String, View>
) {
// Locate the ViewHolder for the clicked position.
val position = galleryVm.galleryPosition.value ?: return
val vh = binding.gallery.findViewHolderForAdapterPosition(position)
if (vh?.itemView == null) return
val vh = binding.gallery.findViewHolderForAdapterPosition(position)
if (vh?.itemView == null) return
// Map the first shared element name to the child ImageView.
sharedElements[names[0]] = vh.itemView
@@ -998,21 +1104,74 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
putDouble(ARG_LON, charger.coordinates.lng)
}
}
fun showLocation(lat: Double, lon: Double): Bundle {
return Bundle().apply {
putDouble(ARG_LAT, lat)
putDouble(ARG_LON, lon)
}
}
fun showChargerById(id: Long): Bundle? {
return Bundle().apply {
putLong(ARG_CHARGER_ID, id)
}
}
fun showCharger(id: Long, lat: Double, lon: Double): Bundle {
return Bundle().apply {
putLong(ARG_CHARGER_ID, id)
putDouble(ARG_LAT, lat)
putDouble(ARG_LON, lon)
}
}
}
override fun onConnected() {
val map = this.map ?: return
val context = this.context ?: return
if (vm.myLocationEnabled.value == true) {
if (ActivityCompat.checkSelfPermission(
requireContext(),
context,
ACCESS_FINE_LOCATION
) == PackageManager.PERMISSION_GRANTED
) {
moveToCurrentLocation(map, false)
moveToLastLocation(map, false)
requestLocationUpdates()
}
}
}
@RequiresPermission(ACCESS_FINE_LOCATION)
private fun requestLocationUpdates() {
val request: LocationRequest = LocationRequest.create()
.setPriority(LocationRequest.PRIORITY_HIGH_ACCURACY)
.setInterval(5000)
LocationServices.FusedLocationApi.requestLocationUpdates(locationClient, request, this)
requestingLocationUpdates = true
}
private fun removeLocationUpdates() {
if (locationClient.isConnected) {
LocationServices.FusedLocationApi.removeLocationUpdates(locationClient, this)
}
}
override fun onConnectionSuspended() {
}
override fun onLocationChanged(location: Location?) {
val map = this.map ?: return
if (location == null || vm.myLocationEnabled.value == false) return
val latLng = LatLng(location.latitude, location.longitude)
vm.location.value = latLng
val camUpdate = map.cameraUpdateFactory.newLatLng(latLng)
map.animateCamera(camUpdate)
}
override fun onPause() {
super.onPause()
removeLocationUpdates()
}
}

View File

@@ -0,0 +1,42 @@
package net.vonforst.evmap.fragment
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 net.vonforst.evmap.databinding.DialogWelcomeBinding
import net.vonforst.evmap.storage.PreferenceDataSource
class WelcomeDialogFragment : AppCompatDialogFragment() {
private lateinit var binding: DialogWelcomeBinding
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
binding = DialogWelcomeBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.btnOk.setOnClickListener {
val prefs = PreferenceDataSource(requireContext())
prefs.welcomeDialogShown = true
prefs.update060AndroidAutoDialogShown = true
dismiss()
}
}
override fun onStart() {
super.onStart()
dialog?.window?.setLayout(
WindowManager.LayoutParams.MATCH_PARENT,
WindowManager.LayoutParams.WRAP_CONTENT
)
}
}

View File

@@ -0,0 +1,40 @@
package net.vonforst.evmap.fragment.updatedialogs
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 net.vonforst.evmap.databinding.DialogUpdate060AndroidautoBinding
import net.vonforst.evmap.storage.PreferenceDataSource
class Update060AndroidAutoDialogFramgent : AppCompatDialogFragment() {
private lateinit var binding: DialogUpdate060AndroidautoBinding
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
binding = DialogUpdate060AndroidautoBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.btnOk.setOnClickListener {
PreferenceDataSource(requireContext()).update060AndroidAutoDialogShown = true
dismiss()
}
}
override fun onStart() {
super.onStart()
dialog?.window?.setLayout(
WindowManager.LayoutParams.MATCH_PARENT,
WindowManager.LayoutParams.WRAP_CONTENT
)
}
}

View File

@@ -14,4 +14,7 @@ interface ChargeLocationsDao {
@Query("SELECT * FROM chargelocation")
fun getAllChargeLocations(): LiveData<List<ChargeLocation>>
@Query("SELECT * FROM chargelocation")
suspend fun getAllChargeLocationsAsync(): List<ChargeLocation>
}

View File

@@ -2,6 +2,7 @@ package net.vonforst.evmap.storage
import android.content.Context
import androidx.preference.PreferenceManager
import com.car2go.maps.AnyMap
import net.vonforst.evmap.R
import net.vonforst.evmap.viewmodel.FILTERS_CUSTOM
import net.vonforst.evmap.viewmodel.FILTERS_DISABLED
@@ -72,4 +73,28 @@ class PreferenceDataSource(val context: Context) {
"map_provider",
context.getString(R.string.pref_map_provider_default)
)!!
var mapType: AnyMap.Type
get() = AnyMap.Type.valueOf(sp.getString("map_type", null) ?: AnyMap.Type.NORMAL.toString())
set(type) {
sp.edit().putString("map_type", type.toString()).apply()
}
var mapTrafficEnabled: Boolean
get() = sp.getBoolean("map_traffic_enabled", false)
set(value) {
sp.edit().putBoolean("map_traffic_enabled", value).apply()
}
var welcomeDialogShown: Boolean
get() = sp.getBoolean("welcome_dialog_shown", false)
set(value) {
sp.edit().putBoolean("welcome_dialog_shown", value).apply()
}
var update060AndroidAutoDialogShown: Boolean
get() = sp.getBoolean("update_0.6.0_androidauto_dialog_shown", false)
set(value) {
sp.edit().putBoolean("update_0.6.0_androidauto_dialog_shown", value).apply()
}
}

View File

@@ -2,6 +2,7 @@ package net.vonforst.evmap.ui
import android.content.Context
import android.content.res.ColorStateList
import android.text.SpannableString
import android.view.View
import android.view.ViewGroup.MarginLayoutParams
import android.widget.ImageView
@@ -16,7 +17,7 @@ import androidx.viewpager2.widget.ViewPager2
import com.google.android.material.floatingactionbutton.FloatingActionButton
import net.vonforst.evmap.R
import net.vonforst.evmap.api.availability.ChargepointStatus
import net.vonforst.evmap.api.goingelectric.Chargepoint
import net.vonforst.evmap.api.iconForPlugType
import kotlin.math.roundToInt
@@ -25,6 +26,25 @@ fun goneUnless(view: View, visible: Boolean) {
view.visibility = if (visible) View.VISIBLE else View.GONE
}
@BindingAdapter("goneUnlessAnimated")
fun goneUnlessAnimated(view: View, oldValue: Boolean, newValue: Boolean) {
if (oldValue == newValue) return
view.animate().cancel()
if (newValue) {
view.visibility = View.VISIBLE
view.alpha = 0f
view.animate().alpha(1f).withEndAction {
view.alpha = 1f
}
} else {
view.animate().alpha(0f).withEndAction {
view.alpha = 1f
view.visibility = View.GONE
}
}
}
@BindingAdapter("invisibleUnless")
fun invisibleUnless(view: View, visible: Boolean) {
view.visibility = if (visible) View.VISIBLE else View.INVISIBLE
@@ -60,20 +80,7 @@ fun <T> setRecyclerViewData(recyclerView: ViewPager2, items: List<T>?) {
@BindingAdapter("connectorIcon")
fun getConnectorItem(view: ImageView, type: String) {
view.setImageResource(
when (type) {
Chargepoint.CCS -> R.drawable.ic_connector_ccs
Chargepoint.CHADEMO -> R.drawable.ic_connector_chademo
Chargepoint.SCHUKO -> R.drawable.ic_connector_schuko
Chargepoint.SUPERCHARGER -> R.drawable.ic_connector_supercharger
Chargepoint.TYPE_2 -> R.drawable.ic_connector_typ2
Chargepoint.CEE_BLAU -> R.drawable.ic_connector_cee_blau
Chargepoint.CEE_ROT -> R.drawable.ic_connector_cee_rot
Chargepoint.TYPE_1 -> R.drawable.ic_connector_typ1
// TODO: add other connectors
else -> 0
}
)
view.setImageResource(iconForPlugType(type))
}
@BindingAdapter("srcCompat")
@@ -131,6 +138,26 @@ fun setTopMargin(view: View, topMargin: Float) {
view.layoutParams = layoutParams
}
/**
* Linkify is already possible using the autoLink and linksClickable attributes, but this does not
* remove spans correctly. So we implement a new version that manually removes the spans.
*/
@BindingAdapter("linkify")
fun setLinkify(textView: TextView, oldValue: Int, newValue: Int) {
if (oldValue == newValue) return
textView.autoLinkMask = newValue
textView.linksClickable = newValue != 0
// remove spans
val text = textView.text
if (newValue == 0 && text != null && text is SpannableString) {
text.getSpans(0, text.length, Any::class.java).forEach {
text.removeSpan(it)
}
}
}
private fun availabilityColor(
status: List<ChargepointStatus>?,
context: Context
@@ -160,4 +187,8 @@ fun availabilityText(status: List<ChargepointStatus>?): String? {
return if (unknown > 0) {
if (unknown == total) "?" else "$available?"
} else available.toString()
}
fun flatten(it: Iterable<Iterable<ChargepointStatus>>?): List<ChargepointStatus>? {
return it?.flatten()
}

View File

@@ -0,0 +1,63 @@
package net.vonforst.evmap.ui
import android.content.Context
import android.content.DialogInterface
import android.view.Gravity
import android.view.View
import android.view.inputmethod.EditorInfo
import android.view.inputmethod.InputMethodManager
import android.widget.EditText
import android.widget.FrameLayout
import androidx.appcompat.app.AlertDialog
private fun dialogEditText(ctx: Context): Pair<View, EditText> {
val container = FrameLayout(ctx)
container.setPadding(
(16 * ctx.resources.displayMetrics.density).toInt(), 0,
(16 * ctx.resources.displayMetrics.density).toInt(), 0
)
val input = EditText(ctx)
input.isSingleLine = true
container.addView(input)
return container to input
}
fun showEditTextDialog(
ctx: Context,
customize: (AlertDialog.Builder, EditText) -> Unit
): AlertDialog {
val (container, input) = dialogEditText(ctx)
val dialogBuilder = AlertDialog.Builder(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
// 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
val button = dialog.getButton(DialogInterface.BUTTON_POSITIVE)
if (text != null && button != null) {
button.performClick()
return@setOnEditorActionListener true
}
}
false
}
return dialog
}

View File

@@ -16,6 +16,7 @@ import com.car2go.maps.model.BitmapDescriptor
import com.google.maps.android.ui.IconGenerator
import com.google.maps.android.ui.SquareTextView
import net.vonforst.evmap.R
import kotlin.math.roundToInt
class ClusterIconGenerator(context: Context) : IconGenerator(context) {
init {
@@ -41,8 +42,14 @@ class ClusterIconGenerator(context: Context) : IconGenerator(context) {
}
class ChargerIconGenerator(val context: Context, val factory: BitmapDescriptorFactory) {
data class BitmapData(
class ChargerIconGenerator(
val context: Context,
val factory: BitmapDescriptorFactory?,
val scaleResolution: Int = 20,
val oversize: Float = 1.4f, // increase to add padding for fault icon or scale > 1
val height: Int = 44
) {
private data class BitmapData(
val tint: Int,
val scale: Int,
val alpha: Int,
@@ -51,20 +58,16 @@ class ChargerIconGenerator(val context: Context, val factory: BitmapDescriptorFa
val multi: Boolean
)
val cacheSize = 840; // 840 items: 21 sizes, 5 colors, highlight, fault, multi on/off
val cache = LruCache<BitmapData, BitmapDescriptor>(cacheSize)
val oversize = 1.4f // increase to add padding for fault icon or scale > 1
val icon = R.drawable.ic_map_marker_charging
val multiIcon = R.drawable.ic_map_marker_charging_multiple
val highlightIcon = R.drawable.ic_map_marker_highlight
val highlightIconMulti = R.drawable.ic_map_marker_charging_highlight_multiple
val faultIcon = R.drawable.ic_map_marker_fault
// 230 items: (21 sizes, 5 colors, multi on/off) + highlight + fault (only with scale = 1)
private val cacheSize = (scaleResolution + 3) * 5 * 2;
private val cache = LruCache<BitmapData, BitmapDescriptor>(cacheSize)
private val icon = R.drawable.ic_map_marker_charging
private val multiIcon = R.drawable.ic_map_marker_charging_multiple
private val highlightIcon = R.drawable.ic_map_marker_highlight
private val highlightIconMulti = R.drawable.ic_map_marker_charging_highlight_multiple
private val faultIcon = R.drawable.ic_map_marker_fault
init {
preloadCache()
}
private fun preloadCache() {
fun preloadCache() {
// pre-generates images for scale from 0 to 255 for all possible tint colors
val tints = listOf(
R.color.charger_100kw,
@@ -77,8 +80,11 @@ class ChargerIconGenerator(val context: Context, val factory: BitmapDescriptorFa
for (highlight in listOf(false, true)) {
for (multi in listOf(false, true)) {
for (tint in tints) {
for (scale in 0..20) {
getBitmapDescriptor(tint, scale, 255, highlight, fault, multi)
for (scale in 0..scaleResolution) {
getBitmapDescriptor(
tint, scale.toFloat() / scaleResolution,
255, highlight, fault, multi
)
}
}
}
@@ -88,24 +94,48 @@ class ChargerIconGenerator(val context: Context, val factory: BitmapDescriptorFa
fun getBitmapDescriptor(
@ColorRes tint: Int,
scale: Int = 20,
scale: Float = 1f,
alpha: Int = 255,
highlight: Boolean = false,
fault: Boolean = false,
multi: Boolean = false
): BitmapDescriptor? {
val data = BitmapData(tint, scale, alpha, highlight, fault, multi)
val data = BitmapData(
tint, (scale * scaleResolution).roundToInt(),
alpha,
if (scale == 1f) highlight else false,
if (scale == 1f) fault else false,
multi
)
val cachedImg = cache[data]
return if (cachedImg != null) {
cachedImg
} else {
val bitmap = generateBitmap(data)
val bmd = factory.fromBitmap(bitmap)
val bmd = factory!!.fromBitmap(bitmap)
cache.put(data, bmd)
bmd
}
}
fun getBitmap(
@ColorRes tint: Int,
scale: Float = 1f,
alpha: Int = 255,
highlight: Boolean = false,
fault: Boolean = false,
multi: Boolean = false
): Bitmap {
val data = BitmapData(
tint, (scale * scaleResolution).roundToInt(),
alpha,
if (scale == 1f) highlight else false,
if (scale == 1f) fault else false,
multi
)
return generateBitmap(data)
}
private fun generateBitmap(data: BitmapData): Bitmap {
val icon = if (data.multi) multiIcon else icon
val vd: Drawable = ContextCompat.getDrawable(context, icon)!!
@@ -113,27 +143,32 @@ class ChargerIconGenerator(val context: Context, val factory: BitmapDescriptorFa
DrawableCompat.setTint(vd, ContextCompat.getColor(context, data.tint));
DrawableCompat.setTintMode(vd, PorterDuff.Mode.MULTIPLY);
val leftPadding = vd.intrinsicWidth * (oversize - 1) / 2
val topPadding = vd.intrinsicHeight * (oversize - 1)
val density = context.resources.displayMetrics.density
val width =
(height.toFloat() * density / vd.intrinsicHeight * vd.intrinsicWidth).roundToInt()
val height = (height * density).roundToInt()
val leftPadding = width * (oversize - 1) / 2
val topPadding = height * (oversize - 1)
vd.setBounds(
leftPadding.toInt(), topPadding.toInt(),
leftPadding.toInt() + vd.intrinsicWidth,
topPadding.toInt() + vd.intrinsicHeight
leftPadding.toInt() + width,
topPadding.toInt() + height
)
vd.alpha = data.alpha
val bm = Bitmap.createBitmap(
(vd.intrinsicWidth * oversize).toInt(), (vd.intrinsicHeight * oversize).toInt(),
(width * oversize).toInt(), (height * oversize).toInt(),
Bitmap.Config.ARGB_8888
)
val canvas = Canvas(bm)
val scale = data.scale / 20f
val scale = data.scale.toFloat() / scaleResolution
canvas.scale(
scale,
scale,
leftPadding + vd.intrinsicWidth / 2f,
topPadding + vd.intrinsicHeight.toFloat()
leftPadding + width / 2f,
topPadding + height.toFloat()
)
vd.draw(canvas)
@@ -143,8 +178,8 @@ class ChargerIconGenerator(val context: Context, val factory: BitmapDescriptorFa
val highlightDrawable = ContextCompat.getDrawable(context, hIcon)!!
highlightDrawable.setBounds(
leftPadding.toInt(), topPadding.toInt(),
leftPadding.toInt() + vd.intrinsicWidth,
topPadding.toInt() + vd.intrinsicHeight
leftPadding.toInt() + width,
topPadding.toInt() + height
)
highlightDrawable.alpha = data.alpha
highlightDrawable.draw(canvas)
@@ -154,7 +189,7 @@ class ChargerIconGenerator(val context: Context, val factory: BitmapDescriptorFa
val faultDrawable = ContextCompat.getDrawable(context, faultIcon)!!
val faultSize = 0.75
val faultShift = 0.25
val base = vd.intrinsicWidth
val base = width
faultDrawable.setBounds(
(leftPadding.toInt() + base * (1 - faultSize + faultShift)).toInt(),
(topPadding.toInt() - base * faultShift).toInt(),

View File

@@ -12,7 +12,7 @@ import kotlin.math.max
fun getMarkerTint(
charger: ChargeLocation,
connectors: Set<String>?
connectors: Set<String>? = null
): Int = when {
charger.maxPower(connectors) >= 100 -> R.color.charger_100kw
charger.maxPower(connectors) >= 43 -> R.color.charger_43kw
@@ -36,11 +36,11 @@ class MarkerAnimator(val gen: ChargerIconGenerator) {
animatingMarkers.remove(marker)
}
val anim = ValueAnimator.ofInt(0, 20).apply {
val anim = ValueAnimator.ofFloat(0f, 1f).apply {
duration = 250
interpolator = LinearOutSlowInInterpolator()
addUpdateListener { animationState ->
val scale = animationState.animatedValue as Int
val scale = animationState.animatedValue as Float
marker.setIcon(
gen.getBitmapDescriptor(
tint,
@@ -73,11 +73,11 @@ class MarkerAnimator(val gen: ChargerIconGenerator) {
animatingMarkers.remove(marker)
}
val anim = ValueAnimator.ofInt(20, 0).apply {
val anim = ValueAnimator.ofFloat(1f, 0f).apply {
duration = 200
interpolator = FastOutLinearInInterpolator()
addUpdateListener { animationState ->
val scale = animationState.animatedValue as Int
val scale = animationState.animatedValue as Float
marker.setIcon(
gen.getBitmapDescriptor(
tint,

View File

@@ -0,0 +1,34 @@
package net.vonforst.evmap.utils
import android.location.Location
import kotlin.math.*
/**
* Adds a certain distance in meters to a location. Approximate calculation.
*/
fun Location.plusMeters(dx: Double, dy: Double): Pair<Double, Double> {
val lat = this.latitude + (180 / Math.PI) * (dx / 6378137.0)
val lon = this.longitude + (180 / Math.PI) * (dy / 6378137.0) / cos(Math.toRadians(lat))
return Pair(lat, lon)
}
const val earthRadiusM = 6378137.0
/**
* Calculates the distance between two points on Earth in meters.
* Latitude and longitude should be given in degrees.
*/
fun distanceBetween(
startLatitude: Double, startLongitude: Double,
endLatitude: Double, endLongitude: Double
): Double {
// see https://rosettacode.org/wiki/Haversine_formula#Java
val dLat = Math.toRadians(endLatitude - startLatitude)
val dLon = Math.toRadians(endLongitude - startLongitude)
val originLat = Math.toRadians(startLatitude)
val destinationLat = Math.toRadians(endLatitude)
val a = sin(dLat / 2).pow(2.0) + sin(dLon / 2).pow(2.0) * cos(originLat) * cos(destinationLat)
val c = 2 * asin(sqrt(a))
return earthRadiusM * c
}

View File

@@ -10,10 +10,10 @@ import net.vonforst.evmap.adapter.Equatable
import net.vonforst.evmap.api.availability.ChargeLocationStatus
import net.vonforst.evmap.api.availability.ChargepointStatus
import net.vonforst.evmap.api.availability.getAvailability
import net.vonforst.evmap.api.distanceBetween
import net.vonforst.evmap.api.goingelectric.ChargeLocation
import net.vonforst.evmap.api.goingelectric.GoingElectricApi
import net.vonforst.evmap.storage.AppDatabase
import net.vonforst.evmap.utils.distanceBetween
class FavoritesViewModel(application: Application, geApiKey: String) :
AndroidViewModel(application) {

View File

@@ -27,6 +27,18 @@ class FilterProfilesViewModel(application: Application) : AndroidViewModel(appli
}
}
fun insert(item: FilterProfile) {
viewModelScope.launch {
db.filterProfileDao().insert(item)
}
}
fun update(item: FilterProfile) {
viewModelScope.launch {
db.filterProfileDao().update(item)
}
}
fun reorderProfiles(list: List<FilterProfile>) {
viewModelScope.launch {
db.filterProfileDao().update(*list.toTypedArray())

View File

@@ -12,6 +12,7 @@ import net.vonforst.evmap.adapter.Equatable
import net.vonforst.evmap.api.goingelectric.ChargeCard
import net.vonforst.evmap.api.goingelectric.Chargepoint
import net.vonforst.evmap.api.goingelectric.GoingElectricApi
import net.vonforst.evmap.api.nameForPlugType
import net.vonforst.evmap.storage.*
import kotlin.math.abs
import kotlin.reflect.KClass
@@ -30,21 +31,9 @@ internal fun getFilters(
chargeCards: LiveData<List<ChargeCard>>
): LiveData<List<Filter<FilterValue>>> {
return MediatorLiveData<List<Filter<FilterValue>>>().apply {
val plugNames = mapOf(
Chargepoint.TYPE_1 to application.getString(R.string.plug_type_1),
Chargepoint.TYPE_2 to application.getString(R.string.plug_type_2),
Chargepoint.TYPE_3 to application.getString(R.string.plug_type_3),
Chargepoint.CCS to application.getString(R.string.plug_ccs),
Chargepoint.SCHUKO to application.getString(R.string.plug_schuko),
Chargepoint.CHADEMO to application.getString(R.string.plug_chademo),
Chargepoint.SUPERCHARGER to application.getString(R.string.plug_supercharger),
Chargepoint.CEE_BLAU to application.getString(R.string.plug_cee_blau),
Chargepoint.CEE_ROT to application.getString(R.string.plug_cee_rot),
Chargepoint.TESLA_ROADSTER_HPC to application.getString(R.string.plug_roadster_hpc)
)
listOf(plugs, networks, chargeCards).forEach { source ->
addSource(source) { _ ->
buildFilters(plugs, plugNames, networks, chargeCards, application)
buildFilters(plugs, networks, chargeCards, application)
}
}
}
@@ -52,13 +41,12 @@ internal fun getFilters(
private fun MediatorLiveData<List<Filter<FilterValue>>>.buildFilters(
plugs: LiveData<List<Plug>>,
plugNames: Map<String, String>,
networks: LiveData<List<Network>>,
chargeCards: LiveData<List<ChargeCard>>,
application: Application
) {
val plugMap = plugs.value?.map { plug ->
plug.name to (plugNames[plug.name] ?: plug.name)
plug.name to nameForPlugType(application, plug.name)
}?.toMap() ?: return
val networkMap = networks.value?.map { it.name to it.name }?.toMap() ?: return
val chargecardMap = chargeCards.value?.map { it.id.toString() to it.name }?.toMap() ?: return
@@ -135,8 +123,8 @@ private fun MediatorLiveData<List<Filter<FilterValue>>>.buildFilters(
internal fun filtersWithValue(
filters: LiveData<List<Filter<FilterValue>>>,
filterValues: LiveData<List<FilterValue>>
): MediatorLiveData<List<FilterWithValue<out FilterValue>>> =
MediatorLiveData<List<FilterWithValue<out FilterValue>>>().apply {
): MediatorLiveData<FilterValues> =
MediatorLiveData<FilterValues>().apply {
listOf(filters, filterValues).forEach {
addSource(it) {
val f = filters.value ?: return@addSource
@@ -173,7 +161,7 @@ class FilterViewModel(application: Application, geApiKey: String) :
db.filterValueDao().getFilterValues(FILTERS_CUSTOM)
}
val filtersWithValue: LiveData<List<FilterWithValue<out FilterValue>>> by lazy {
val filtersWithValue: LiveData<FilterValues> by lazy {
filtersWithValue(filters, filterValues)
}
@@ -331,5 +319,19 @@ data class SliderFilterValue(
data class FilterWithValue<T : FilterValue>(val filter: Filter<T>, val value: T) : Equatable
typealias FilterValues = List<FilterWithValue<out FilterValue>>
fun FilterValues.getBooleanValue(key: String) =
(this.find { it.value.key == key }!!.value as BooleanFilterValue).value
fun FilterValues.getSliderValue(key: String) =
(this.find { it.value.key == key }!!.value as SliderFilterValue).value
fun FilterValues.getMultipleChoiceFilter(key: String) =
this.find { it.value.key == key }!!.filter as MultipleChoiceFilter
fun FilterValues.getMultipleChoiceValue(key: String) =
this.find { it.value.key == key }!!.value as MultipleChoiceFilterValue
const val FILTERS_DISABLED = -2L
const val FILTERS_CUSTOM = -1L

View File

@@ -6,17 +6,16 @@ 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 net.vonforst.evmap.api.availability.ChargeLocationStatus
import net.vonforst.evmap.api.availability.getAvailability
import net.vonforst.evmap.api.distanceBetween
import net.vonforst.evmap.api.goingelectric.*
import net.vonforst.evmap.api.goingelectric.ChargeCard
import net.vonforst.evmap.api.goingelectric.ChargeLocation
import net.vonforst.evmap.api.goingelectric.ChargepointListItem
import net.vonforst.evmap.api.goingelectric.GoingElectricApi
import net.vonforst.evmap.storage.*
import net.vonforst.evmap.ui.cluster
import retrofit2.Call
import retrofit2.Callback
import retrofit2.Response
import net.vonforst.evmap.utils.distanceBetween
import java.io.IOException
data class MapPosition(val bounds: LatLngBounds, val zoom: Float)
@@ -37,7 +36,6 @@ class MapViewModel(application: Application, geApiKey: String) : AndroidViewMode
private var api = GoingElectricApi.create(geApiKey, context = application)
private var db = AppDatabase.getInstance(application)
private var prefs = PreferenceDataSource(application)
private var chargepointLoader: Job? = null
val bottomSheetState: MutableLiveData<Int> by lazy {
MutableLiveData<Int>()
@@ -69,7 +67,7 @@ class MapViewModel(application: Application, geApiKey: String) : AndroidViewMode
}
private val filters = getFilters(application, plugs, networks, chargeCards)
private val filtersWithValue: LiveData<List<FilterWithValue<out FilterValue>>> by lazy {
private val filtersWithValue: LiveData<FilterValues> by lazy {
filtersWithValue(filters, filterValues)
}
@@ -147,7 +145,7 @@ class MapViewModel(application: Application, geApiKey: String) : AndroidViewMode
val callback = { _: Any? ->
val loc = location.value
val charger = chargerSparse.value
value = if (loc != null && charger != null && myLocationEnabled.value == true) {
value = if (loc != null && charger != null) {
distanceBetween(
loc.latitude,
loc.longitude,
@@ -158,7 +156,6 @@ class MapViewModel(application: Application, geApiKey: String) : AndroidViewMode
}
addSource(chargerSparse, callback)
addSource(location, callback)
addSource(myLocationEnabled, callback)
}
}
val location: MutableLiveData<LatLng> by lazy {
@@ -177,6 +174,21 @@ class MapViewModel(application: Application, geApiKey: String) : AndroidViewMode
}
}
}
val filteredAvailability: MediatorLiveData<Resource<ChargeLocationStatus>> by lazy {
MediatorLiveData<Resource<ChargeLocationStatus>>().apply {
val callback = { _: Any? ->
val av = availability.value
val filters = filtersWithValue.value
if (av?.status == Status.SUCCESS && filters != null) {
value = Resource.success(av.data!!.applyFilters(filters))
} else {
value = av
}
}
addSource(availability, callback)
addSource(filtersWithValue, callback)
}
}
val myLocationEnabled: MutableLiveData<Boolean> by lazy {
MutableLiveData<Boolean>()
}
@@ -196,13 +208,19 @@ class MapViewModel(application: Application, geApiKey: String) : AndroidViewMode
val mapType: MutableLiveData<AnyMap.Type> by lazy {
MutableLiveData<AnyMap.Type>().apply {
value = AnyMap.Type.NORMAL
value = prefs.mapType
observeForever {
prefs.mapType = it
}
}
}
val mapTrafficEnabled: MutableLiveData<Boolean> by lazy {
MutableLiveData<Boolean>().apply {
value = false
value = prefs.mapTrafficEnabled
observeForever {
prefs.mapTrafficEnabled = it
}
}
}
@@ -260,39 +278,41 @@ class MapViewModel(application: Application, geApiKey: String) : AndroidViewMode
loadChargepoints(pos, filters)
}
private fun loadChargepoints(
mapPosition: MapPosition,
filters: List<FilterWithValue<out FilterValue>>
) {
chargepointLoader?.cancel()
private var chargepointLoader =
throttleLatest(500L, viewModelScope) { data: Pair<MapPosition, FilterValues> ->
chargepoints.value = Resource.loading(chargepoints.value?.data)
filteredConnectors.value = null
filteredChargeCards.value = null
chargepoints.value = Resource.loading(chargepoints.value?.data)
filteredConnectors.value = null
filteredChargeCards.value = null
val bounds = mapPosition.bounds
val zoom = mapPosition.zoom
chargepointLoader = viewModelScope.launch {
val result = getChargepointsWithFilters(bounds, zoom, filters)
val mapPosition = data.first
val filters = data.second
val result = getChargepointsWithFilters(mapPosition.bounds, mapPosition.zoom, filters)
filteredConnectors.value = result.second
filteredChargeCards.value = result.third
chargepoints.value = result.first
}
private fun loadChargepoints(
mapPosition: MapPosition,
filters: FilterValues
) {
chargepointLoader(Pair(mapPosition, filters))
}
private suspend fun getChargepointsWithFilters(
bounds: LatLngBounds,
zoom: Float,
filters: List<FilterWithValue<out FilterValue>>
filters: FilterValues
): Triple<Resource<List<ChargepointListItem>>, Set<String>?, Set<Long>?> {
val freecharging = getBooleanValue(filters, "freecharging")
val freeparking = getBooleanValue(filters, "freeparking")
val open247 = getBooleanValue(filters, "open_247")
val barrierfree = getBooleanValue(filters, "barrierfree")
val excludeFaults = getBooleanValue(filters, "exclude_faults")
val minPower = getSliderValue(filters, "min_power")
val minConnectors = getSliderValue(filters, "min_connectors")
val freecharging = filters.getBooleanValue("freecharging")
val freeparking = filters.getBooleanValue("freeparking")
val open247 = filters.getBooleanValue("open_247")
val barrierfree = filters.getBooleanValue("barrierfree")
val excludeFaults = filters.getBooleanValue("exclude_faults")
val minPower = filters.getSliderValue("min_power")
val minConnectors = filters.getSliderValue("min_connectors")
val connectorsVal = getMultipleChoiceValue(filters, "connectors")
val connectorsVal = filters.getMultipleChoiceValue("connectors")
if (connectorsVal.values.isEmpty() && !connectorsVal.all) {
// no connectors chosen
return Triple(Resource.success(emptyList()), null, null)
@@ -300,7 +320,7 @@ class MapViewModel(application: Application, geApiKey: String) : AndroidViewMode
val connectors = formatMultipleChoice(connectorsVal)
val filteredConnectors = if (connectorsVal.all) null else connectorsVal.values
val chargeCardsVal = getMultipleChoiceValue(filters, "chargecards")
val chargeCardsVal = filters.getMultipleChoiceValue("chargecards")
if (chargeCardsVal.values.isEmpty() && !chargeCardsVal.all) {
// no chargeCards chosen
return Triple(Resource.success(emptyList()), filteredConnectors, null)
@@ -309,14 +329,14 @@ class MapViewModel(application: Application, geApiKey: String) : AndroidViewMode
val filteredChargeCards =
if (chargeCardsVal.all) null else chargeCardsVal.values.map { it.toLong() }.toSet()
val networksVal = getMultipleChoiceValue(filters, "networks")
val networksVal = filters.getMultipleChoiceValue("networks")
if (networksVal.values.isEmpty() && !networksVal.all) {
// no networks chosen
return Triple(Resource.success(emptyList()), filteredConnectors, filteredChargeCards)
}
val networks = formatMultipleChoice(networksVal)
val categoriesVal = getMultipleChoiceValue(filters, "categories")
val categoriesVal = filters.getMultipleChoiceValue("categories")
if (categoriesVal.values.isEmpty() && !categoriesVal.all) {
// no categories chosen
return Triple(Resource.success(emptyList()), filteredConnectors, filteredChargeCards)
@@ -398,26 +418,6 @@ class MapViewModel(application: Application, geApiKey: String) : AndroidViewMode
private fun formatMultipleChoice(connectorsVal: MultipleChoiceFilterValue) =
if (connectorsVal.all) null else connectorsVal.values.joinToString(",")
private fun getBooleanValue(
filters: List<FilterWithValue<out FilterValue>>,
key: String
) = (filters.find { it.value.key == key }!!.value as BooleanFilterValue).value
private fun getSliderValue(
filters: List<FilterWithValue<out FilterValue>>,
key: String
) = (filters.find { it.value.key == key }!!.value as SliderFilterValue).value
private fun getMultipleChoiceFilter(
filters: List<FilterWithValue<out FilterValue>>,
key: String
) = filters.find { it.value.key == key }!!.filter as MultipleChoiceFilter
private fun getMultipleChoiceValue(
filters: List<FilterWithValue<out FilterValue>>,
key: String
) = filters.find { it.value.key == key }!!.value as MultipleChoiceFilterValue
private suspend fun loadAvailability(charger: ChargeLocation) {
availability.value = Resource.loading(null)
availability.value = getAvailability(charger)
@@ -425,24 +425,41 @@ class MapViewModel(application: Application, geApiKey: String) : AndroidViewMode
private fun loadChargerDetails(charger: ChargeLocation) {
chargerDetails.value = Resource.loading(null)
api.getChargepointDetail(charger.id).enqueue(object :
Callback<ChargepointList> {
override fun onFailure(call: Call<ChargepointList>, t: Throwable) {
chargerDetails.value = Resource.error(t.message, null)
t.printStackTrace()
}
override fun onResponse(
call: Call<ChargepointList>,
response: Response<ChargepointList>
) {
viewModelScope.launch {
try {
val response = api.getChargepointDetail(charger.id)
if (!response.isSuccessful || response.body()!!.status != "ok") {
chargerDetails.value = Resource.error(response.message(), null)
} else {
chargerDetails.value =
Resource.success(response.body()!!.chargelocations[0] as ChargeLocation)
}
} catch (e: IOException) {
chargerDetails.value = Resource.error(e.message, null)
e.printStackTrace()
}
})
}
}
fun loadChargerById(chargerId: Long) {
chargerDetails.value = Resource.loading(null)
chargerSparse.value = null
viewModelScope.launch {
val response = api.getChargepointDetail(chargerId)
if (!response.isSuccessful || response.body()!!.status != "ok") {
chargerSparse.value = null
chargerDetails.value = Resource.error(response.message(), null)
} else {
val chargers = response.body()!!.chargelocations
if (chargers.isNotEmpty()) {
val charger = chargers[0] as ChargeLocation
chargerDetails.value =
Resource.success(charger)
chargerSparse.value = charger} else {
chargerDetails.value = Resource.error("not found", null)
chargerSparse.value = null
}
}
}
}
}

View File

@@ -3,6 +3,10 @@ package net.vonforst.evmap.viewmodel
import androidx.annotation.MainThread
import androidx.annotation.Nullable
import androidx.lifecycle.*
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import java.util.concurrent.atomic.AtomicBoolean
@@ -63,4 +67,27 @@ class SingleLiveEvent<T> : MutableLiveData<T>() {
fun call() {
value = null
}
}
fun <T> throttleLatest(
skipMs: Long = 300L,
coroutineScope: CoroutineScope,
destinationFunction: suspend (T) -> Unit
): (T) -> Unit {
var throttleJob: Job? = null
var waitingParam: T? = null
return { param: T ->
if (throttleJob?.isCompleted != false) {
throttleJob = coroutineScope.launch {
destinationFunction(param)
delay(skipMs)
waitingParam?.let { wParam ->
waitingParam = null
destinationFunction(wParam)
}
}
} else {
waitingParam = param
}
}
}

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

View File

@@ -0,0 +1,42 @@
<vector android:height="45.9264dp"
android:viewportHeight="480"
android:viewportWidth="501.334"
android:width="48dp"
xmlns:aapt="http://schemas.android.com/aapt"
xmlns:android="http://schemas.android.com/apk/res/android">
<path
android:fillColor="#03a9f4"
android:fillType="evenOdd"
android:pathData="m32,416c-17.68,0 -32,-14.32 -32,-32 0,-5.814 1.547,-11.28 4.267,-15.974l202.667,-352c5.52,-9.573 15.866,-16.026 27.733,-16.026s22.213,6.453 27.733,16.026l202.667,352c2.72,4.694 4.267,10.16 4.267,15.974 0,17.68 -14.32,32 -32,32z" />
<path
android:fillType="evenOdd"
android:pathData="m234.667,149.333v266.667h266.667z">
<aapt:attr name="android:fillColor">
<gradient
android:endX="459.19528"
android:endY="410.17865"
android:startX="177.41093"
android:startY="250.14912"
android:type="linear">
<item
android:color="#331A237E"
android:offset="0" />
<item
android:color="#051A237E"
android:offset="1" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#039be5"
android:fillType="evenOdd"
android:pathData="m206.934,16.026 l-202.667,352c-2.72,4.694 -4.267,10.16 -4.267,15.974 0,17.68 14.32,32 32,32h202.667v-416c-11.867,0 -22.213,6.453 -27.733,16.026z" />
<path
android:fillColor="#f1f1f1"
android:fillType="evenOdd"
android:pathData="m234.667,149.333 l181.333,320 -10.666,10.667 -170.667,-64 -170.667,64 -10.666,-10.667z" />
<path
android:fillColor="#e1e1e1"
android:fillType="evenOdd"
android:pathData="m234.667,149.333 l181.333,320 -10.666,10.667 -170.667,-64" />
</vector>

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<shape android:shape="oval">
<solid android:color="#66FFFFFF" />
<size
android:height="24dp"
android:width="24dp" />
</shape>
</item>
<item android:drawable="?attr/controlBackground" />
</layer-list>

View File

@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M12,2L4.5,20.29l0.71,0.71L12,18l6.79,3 0.71,-0.71z" />
</vector>

View File

@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M23,12l-2.44,-2.79l0.34,-3.69l-3.61,-0.82L15.4,1.5L12,2.96L8.6,1.5L6.71,4.69L3.1,5.5L3.44,9.2L1,12l2.44,2.79l-0.34,3.7l3.61,0.82L8.6,22.5l3.4,-1.47l3.4,1.46l1.89,-3.19l3.61,-0.82l-0.34,-3.69L23,12zM10.09,16.72l-3.8,-3.81l1.48,-1.48l2.32,2.33l5.85,-5.87l1.48,1.48L10.09,16.72z" />
</vector>

View File

@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<ImageView
android:id="@+id/imageView2"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:srcCompat="@drawable/ic_launcher_foreground" />
<TextView
android:id="@+id/textView14"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="@string/app_name"
android:textAppearance="@style/TextAppearance.MaterialComponents.Headline4"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.5"
app:layout_constraintStart_toEndOf="@+id/imageView2"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -21,6 +21,8 @@
<import type="net.vonforst.evmap.viewmodel.Status" />
<import type="net.vonforst.evmap.ui.BindingAdaptersKt" />
<variable
name="charger"
type="Resource&lt;ChargeLocation&gt;" />
@@ -33,6 +35,10 @@
name="availability"
type="Resource&lt;ChargeLocationStatus&gt;" />
<variable
name="filteredAvailability"
type="Resource&lt;ChargeLocationStatus&gt;" />
<variable
name="chargeCards"
type="java.util.Map&lt;Long, ChargeCard&gt;" />
@@ -41,6 +47,10 @@
name="filteredChargeCards"
type="java.util.Set&lt;Long&gt;" />
<variable
name="expanded"
type="Boolean" />
</data>
<androidx.cardview.widget.CardView
@@ -59,14 +69,17 @@
android:paddingBottom="8dp">
<TextView
android:id="@+id/textView"
android:layout_width="0dp"
android:id="@+id/txtName"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="30dp"
android:ellipsize="end"
android:maxLines="1"
android:text="@{charger.data.name}"
android:textAppearance="@style/TextAppearance.MaterialComponents.Body1"
app:layout_constraintEnd_toStartOf="@+id/guideline2"
app:layout_constrainedWidth="true"
app:layout_constraintEnd_toStartOf="@+id/txtAvailability"
app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintStart_toStartOf="@+id/guideline"
app:layout_constraintTop_toTopOf="parent"
tools:text="Parkhaus" />
@@ -81,11 +94,11 @@
android:textAppearance="@style/TextAppearance.MaterialComponents.Caption"
app:layout_constraintEnd_toStartOf="@+id/guideline2"
app:layout_constraintStart_toStartOf="@+id/guideline"
app:layout_constraintTop_toBottomOf="@+id/textView"
app:layout_constraintTop_toBottomOf="@+id/txtName"
tools:text="Beispielstraße 10, 12345 Berlin" />
<TextView
android:id="@+id/textView27"
android:id="@+id/txtDistance"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
@@ -95,12 +108,33 @@
android:minWidth="50dp"
android:text="@{@string/distance_format(distance)}"
android:textAppearance="@style/TextAppearance.MaterialComponents.Caption"
app:layout_constraintBottom_toBottomOf="@+id/topPart"
app:layout_constraintEnd_toStartOf="@+id/guideline2"
app:layout_constraintTop_toTopOf="@+id/textView3"
tools:text="10 km" />
<TextView
android:id="@+id/textView3"
android:id="@+id/txtAvailability"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="72dp"
android:background="@drawable/rounded_rect"
android:ellipsize="end"
android:gravity="end"
android:maxLines="1"
android:padding="2dp"
android:text="@{String.format(&quot;%s/%d&quot;, BindingAdaptersKt.availabilityText(BindingAdaptersKt.flatten(filteredAvailability.data.status.values())), filteredAvailability.data.totalChargepoints)}"
android:textAppearance="@style/TextAppearance.MaterialComponents.Caption"
android:textColor="@android:color/white"
app:backgroundTintAvailability="@{BindingAdaptersKt.flatten(availability.data.status.values())}"
app:goneUnlessAnimated="@{availability.data != null &amp;&amp; !expanded}"
app:goneUnless="@{availability.data != null}"
app:layout_constraintEnd_toStartOf="@+id/guideline2"
app:layout_constraintTop_toTopOf="@+id/txtName"
tools:backgroundTint="@color/available"
tools:text="2/2" />
<TextView
android:id="@+id/txtConnectors"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
@@ -108,7 +142,7 @@
android:maxLines="1"
android:text="@{charger.data.formatChargepoints()}"
android:textAppearance="@style/TextAppearance.MaterialComponents.Caption"
app:layout_constraintEnd_toStartOf="@+id/textView27"
app:layout_constraintEnd_toStartOf="@+id/txtDistance"
app:layout_constraintStart_toStartOf="@+id/guideline"
app:layout_constraintTop_toBottomOf="@+id/textView2"
tools:text="2x Typ 2 22 kW" />
@@ -136,7 +170,7 @@
android:textColor="?colorPrimary"
app:layout_constraintEnd_toStartOf="@+id/guideline2"
app:layout_constraintStart_toStartOf="@+id/guideline"
app:layout_constraintTop_toBottomOf="@+id/textView3" />
app:layout_constraintTop_toBottomOf="@+id/txtConnectors" />
<TextView
android:id="@+id/textView12"
@@ -263,10 +297,10 @@
android:layout_width="0dp"
android:layout_height="0dp"
android:text="TextView"
app:layout_constraintBottom_toBottomOf="@+id/textView3"
app:layout_constraintBottom_toBottomOf="@+id/txtConnectors"
app:layout_constraintEnd_toStartOf="@+id/guideline2"
app:layout_constraintStart_toStartOf="@+id/guideline"
app:layout_constraintTop_toTopOf="@+id/textView" />
app:layout_constraintTop_toTopOf="@+id/txtName" />
<Button
android:id="@+id/btnChargeprice"
@@ -280,6 +314,23 @@
app:layout_constraintStart_toStartOf="@+id/guideline"
app:layout_constraintTop_toBottomOf="@+id/textView13" />
<ImageView
android:id="@+id/imgVerified"
android:layout_width="18dp"
android:layout_height="18dp"
android:layout_marginStart="4dp"
android:layout_marginTop="2dp"
android:layout_marginBottom="2dp"
android:contentDescription="@string/verified"
android:tooltipText="@string/verified_desc"
app:goneUnless="@{ charger.data.verified &amp;&amp; charger.data.faultReport == null }"
app:layout_constraintBottom_toBottomOf="@+id/txtName"
app:layout_constraintStart_toEndOf="@+id/txtName"
app:layout_constraintTop_toTopOf="@+id/txtName"
app:srcCompat="@drawable/ic_verified"
app:tint="@color/available"
tools:targetApi="o" />
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.cardview.widget.CardView>

View File

@@ -0,0 +1,93 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:maxWidth="200dp"
android:orientation="vertical">
<ScrollView
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1">
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/linearLayout4"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<FrameLayout
android:id="@+id/topPanel"
android:layout_width="0dp"
android:layout_height="88dp"
android:background="@color/android_auto_accent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<ImageView
android:id="@+id/imageView4"
android:layout_width="72dp"
android:layout_height="72dp"
android:layout_gravity="center"
android:background="@drawable/circle_bg_logo"
android:scaleType="center"
app:srcCompat="@drawable/android_auto" />
</FrameLayout>
<TextView
android:id="@+id/welcomeTitle"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="16dp"
android:layout_marginEnd="16dp"
android:text="@string/update_060_androidauto_title"
android:textAppearance="@style/TextAppearance.MaterialComponents.Headline6"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/topPanel" />
<TextView
android:id="@+id/welcomeText1"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="8dp"
android:layout_marginEnd="16dp"
android:text="@string/update_060_androidauto_text"
android:textAppearance="@style/TextAppearance.MaterialComponents.Body2"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/welcomeTitle" />
<ImageView
android:id="@+id/icon1"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="32dp"
android:layout_marginTop="16dp"
android:layout_marginEnd="32dp"
android:adjustViewBounds="true"
android:scaleType="fitCenter"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/welcomeText1"
app:srcCompat="@drawable/android_auto_screenshot" />
</androidx.constraintlayout.widget.ConstraintLayout>
</ScrollView>
<Button
android:id="@+id/btnOk"
style="@style/Widget.MaterialComponents.Button.TextButton.Dialog"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="end"
android:layout_marginEnd="16dp"
android:layout_marginBottom="8dp"
android:text="@string/ok" />
</LinearLayout>

View File

@@ -0,0 +1,199 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="match_parent">
<ScrollView
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1">
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/linearLayout4"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingLeft="16dp"
android:paddingTop="16dp"
android:paddingRight="16dp"
android:paddingBottom="16dp">
<include
android:id="@+id/include"
layout="@layout/app_logo"
android:layout_width="0dp"
android:layout_height="wrap_content"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/welcomeTitle"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="@string/welcome_to_evmap"
android:textAppearance="@style/TextAppearance.MaterialComponents.Headline6"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/include" />
<TextView
android:id="@+id/welcomeText1"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/welcome_1"
android:textAppearance="@style/TextAppearance.MaterialComponents.Body2"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/welcomeTitle" />
<ImageView
android:id="@+id/icon1"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
app:layout_constraintEnd_toEndOf="@+id/iconLabel1"
app:layout_constraintStart_toStartOf="@+id/iconLabel1"
app:layout_constraintTop_toBottomOf="@+id/welcomeText1"
app:srcCompat="@drawable/ic_map_marker_charging"
app:tint="@color/charger_low"
app:tintMode="multiply" />
<TextView
android:id="@+id/iconLabel1"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="&lt;11 kW"
android:textAppearance="@style/TextAppearance.MaterialComponents.Caption"
app:layout_constraintEnd_toStartOf="@+id/iconLabel2"
app:layout_constraintHorizontal_bias="0.5"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/icon1" />
<ImageView
android:id="@+id/icon2"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
app:layout_constraintEnd_toEndOf="@+id/iconLabel2"
app:layout_constraintStart_toStartOf="@+id/iconLabel2"
app:layout_constraintTop_toBottomOf="@+id/welcomeText1"
app:srcCompat="@drawable/ic_map_marker_charging"
app:tint="@color/charger_11kw"
app:tintMode="multiply" />
<TextView
android:id="@+id/iconLabel2"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="≥11 kW"
android:textAppearance="@style/TextAppearance.MaterialComponents.Caption"
app:layout_constraintEnd_toStartOf="@+id/iconLabel3"
app:layout_constraintHorizontal_bias="0.5"
app:layout_constraintStart_toEndOf="@+id/iconLabel1"
app:layout_constraintTop_toBottomOf="@+id/icon2" />
<ImageView
android:id="@+id/icon3"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
app:layout_constraintEnd_toEndOf="@+id/iconLabel3"
app:layout_constraintStart_toStartOf="@+id/iconLabel3"
app:layout_constraintTop_toBottomOf="@+id/welcomeText1"
app:srcCompat="@drawable/ic_map_marker_charging"
app:tint="@color/charger_20kw"
app:tintMode="multiply" />
<TextView
android:id="@+id/iconLabel3"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="≥20 kW"
android:textAppearance="@style/TextAppearance.MaterialComponents.Caption"
app:layout_constraintEnd_toStartOf="@+id/iconLabel4"
app:layout_constraintHorizontal_bias="0.5"
app:layout_constraintStart_toEndOf="@+id/iconLabel2"
app:layout_constraintTop_toBottomOf="@+id/icon3" />
<ImageView
android:id="@+id/icon4"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
app:layout_constraintEnd_toEndOf="@+id/iconLabel4"
app:layout_constraintStart_toStartOf="@+id/iconLabel4"
app:layout_constraintTop_toBottomOf="@+id/welcomeText1"
app:srcCompat="@drawable/ic_map_marker_charging"
app:tint="@color/charger_43kw"
app:tintMode="multiply" />
<TextView
android:id="@+id/iconLabel4"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="≥43 kW"
android:textAppearance="@style/TextAppearance.MaterialComponents.Caption"
app:layout_constraintEnd_toStartOf="@+id/iconLabel5"
app:layout_constraintHorizontal_bias="0.5"
app:layout_constraintStart_toEndOf="@+id/iconLabel3"
app:layout_constraintTop_toBottomOf="@+id/icon4" />
<ImageView
android:id="@+id/icon5"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
app:layout_constraintEnd_toEndOf="@+id/iconLabel5"
app:layout_constraintStart_toStartOf="@+id/iconLabel5"
app:layout_constraintTop_toBottomOf="@+id/welcomeText1"
app:srcCompat="@drawable/ic_map_marker_charging"
app:tint="@color/charger_100kw"
app:tintMode="multiply" />
<TextView
android:id="@+id/iconLabel5"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="≥100 kW"
android:textAppearance="@style/TextAppearance.MaterialComponents.Caption"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.5"
app:layout_constraintStart_toEndOf="@+id/iconLabel4"
app:layout_constraintTop_toBottomOf="@+id/icon5" />
<TextView
android:id="@+id/welcomeText2"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="@string/welcome_2"
android:textAppearance="@style/TextAppearance.MaterialComponents.Body2"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/iconLabel1" />
</androidx.constraintlayout.widget.ConstraintLayout>
</ScrollView>
<Button
android:id="@+id/btnOk"
style="@style/Widget.MaterialComponents.Button.TextButton.Dialog"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/ok"
android:layout_marginEnd="16dp"
android:layout_marginBottom="8dp"
android:layout_gravity="end" />
</LinearLayout>

View File

@@ -9,6 +9,8 @@
<import type="net.vonforst.evmap.viewmodel.Status" />
<import type="com.mahc.custombottomsheetbehavior.BottomSheetBehaviorGoogleMapsLike" />
<variable
name="vm"
type="net.vonforst.evmap.viewmodel.MapViewModel" />
@@ -61,7 +63,8 @@
<TextView
android:id="@+id/search"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_height="match_parent"
android:gravity="center_vertical"
android:text="@string/search"
android:textAppearance="@style/TextAppearance.MaterialComponents.Subtitle1"
android:textColor="?android:textColorSecondary" />
@@ -88,7 +91,6 @@
android:layout_width="match_parent"
android:layout_height="@dimen/gallery_height_with_margin"
android:background="?android:colorBackground"
android:fitsSystemWindows="true"
app:layout_behavior="@string/BackDropBottomSheetBehavior">
<androidx.recyclerview.widget.RecyclerView
@@ -124,7 +126,6 @@
android:id="@+id/bottom_sheet"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fitsSystemWindows="true"
android:fillViewport="true"
android:orientation="vertical"
app:bottomsheetbehavior_anchorPoint="@dimen/gallery_height"
@@ -139,9 +140,11 @@
layout="@layout/detail_view"
app:charger="@{vm.charger}"
app:availability="@{vm.availability}"
app:filteredAvailability="@{vm.filteredAvailability}"
app:chargeCards="@{vm.chargeCardMap}"
app:filteredChargeCards="@{vm.filteredChargeCards}"
app:distance="@{vm.chargerDistance}" />
app:distance="@{vm.chargerDistance}"
app:expanded="@{vm.bottomSheetState != BottomSheetBehaviorGoogleMapsLike.STATE_COLLAPSED &amp;&amp; vm.bottomSheetState != BottomSheetBehaviorGoogleMapsLike.STATE_HIDDEN}" />
</androidx.core.widget.NestedScrollView>

View File

@@ -18,7 +18,7 @@
app:selectableItemBackground="@{item.clickable}">
<TextView
android:id="@+id/textView9"
android:id="@+id/txtTitle"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="24dp"
@@ -48,7 +48,7 @@
tools:srcCompat="@drawable/ic_address" />
<TextView
android:id="@+id/textView8"
android:id="@+id/txtContent"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
@@ -56,13 +56,12 @@
android:layout_marginBottom="14dp"
android:text="@{item.detailText}"
android:textAppearance="@style/TextAppearance.MaterialComponents.Caption"
android:autoLink="@{item.links ? Linkify.WEB_URLS | Linkify.PHONE_NUMBERS : 0}"
android:linksClickable="@{item.links}"
app:linkify="@{item.links ? Linkify.WEB_URLS | Linkify.PHONE_NUMBERS : 0}"
app:goneUnless="@{item.detailText != null}"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="@+id/textView9"
app:layout_constraintTop_toBottomOf="@+id/textView9"
app:layout_constraintStart_toStartOf="@+id/txtTitle"
app:layout_constraintTop_toBottomOf="@+id/txtTitle"
tools:text="Lorem ipsum" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -24,7 +24,7 @@
app:selectableItemBackground="@{item.clickable}">
<TextView
android:id="@+id/textView9"
android:id="@+id/txtTitle"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="24dp"
@@ -54,21 +54,20 @@
tools:srcCompat="@drawable/ic_address" />
<TextView
android:id="@+id/textView8"
android:id="@+id/txtContent"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:layout_marginEnd="8dp"
android:layout_marginBottom="16dp"
android:autoLink="@{item.links ? Linkify.WEB_URLS | Linkify.PHONE_NUMBERS : 0}"
android:linksClickable="@{item.links}"
app:linkify="@{item.links ? Linkify.WEB_URLS | Linkify.PHONE_NUMBERS : 0}"
android:text="@{item.detailText}"
android:textAppearance="@style/TextAppearance.MaterialComponents.Caption"
app:goneUnless="@{item.detailText != null}"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/expandToggle"
app:layout_constraintStart_toStartOf="@+id/textView9"
app:layout_constraintTop_toBottomOf="@+id/textView9"
app:layout_constraintStart_toStartOf="@+id/txtTitle"
app:layout_constraintTop_toBottomOf="@+id/txtTitle"
app:layout_constraintVertical_bias="0.0"
tools:text="Lorem ipsum" />
@@ -83,8 +82,8 @@
app:goneUnless="@{expandToggle.checked}"
app:hours="@{item.hoursDays}"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="@+id/textView9"
app:layout_constraintTop_toBottomOf="@+id/textView8" />
app:layout_constraintStart_toStartOf="@+id/txtTitle"
app:layout_constraintTop_toBottomOf="@+id/txtContent" />
<include
android:id="@+id/hours_tue"
@@ -97,7 +96,7 @@
app:dayOfWeek="@{DayOfWeek.TUESDAY}"
app:hours="@{item.hoursDays}"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="@+id/textView9"
app:layout_constraintStart_toStartOf="@+id/txtTitle"
app:layout_constraintTop_toBottomOf="@id/hours_mon" />
<include
@@ -111,7 +110,7 @@
app:dayOfWeek="@{DayOfWeek.WEDNESDAY}"
app:hours="@{item.hoursDays}"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="@+id/textView9"
app:layout_constraintStart_toStartOf="@+id/txtTitle"
app:layout_constraintTop_toBottomOf="@id/hours_tue" />
<include
@@ -125,7 +124,7 @@
app:dayOfWeek="@{DayOfWeek.THURSDAY}"
app:hours="@{item.hoursDays}"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="@+id/textView9"
app:layout_constraintStart_toStartOf="@+id/txtTitle"
app:layout_constraintTop_toBottomOf="@id/hours_wed" />
<include
@@ -139,7 +138,7 @@
app:dayOfWeek="@{DayOfWeek.FRIDAY}"
app:hours="@{item.hoursDays}"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="@+id/textView9"
app:layout_constraintStart_toStartOf="@+id/txtTitle"
app:layout_constraintTop_toBottomOf="@id/hours_thu" />
<include
@@ -153,7 +152,7 @@
app:dayOfWeek="@{DayOfWeek.SATURDAY}"
app:hours="@{item.hoursDays}"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="@+id/textView9"
app:layout_constraintStart_toStartOf="@+id/txtTitle"
app:layout_constraintTop_toBottomOf="@id/hours_fri" />
<include
@@ -167,7 +166,7 @@
app:dayOfWeek="@{DayOfWeek.SUNDAY}"
app:hours="@{item.hoursDays}"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="@+id/textView9"
app:layout_constraintStart_toStartOf="@+id/txtTitle"
app:layout_constraintTop_toBottomOf="@id/hours_sat" />
<include
@@ -183,7 +182,7 @@
app:hours="@{item.hoursDays}"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="@+id/textView9"
app:layout_constraintStart_toStartOf="@+id/txtTitle"
app:layout_constraintTop_toBottomOf="@id/hours_sun" />
<ToggleButton

View File

@@ -45,7 +45,7 @@
tools:text="Beispielstraße 10, 12345 Berlin" />
<TextView
android:id="@+id/textView3"
android:id="@+id/txtConnectors"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginEnd="8dp"

View File

@@ -29,6 +29,7 @@
android:layout_marginRight="16dp"
app:tint="@android:color/white"
app:srcCompat="@drawable/ic_delete" />
</FrameLayout>
@@ -43,17 +44,42 @@
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
android:layout_marginEnd="8dp"
android:text="@{item.name}"
android:textAppearance="@style/TextAppearance.MaterialComponents.Body2"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/handle"
app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintEnd_toStartOf="@+id/btnRename"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.511"
tools:text="Lorem ipsum" />
<ImageButton
android:id="@+id/btnDelete"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="16dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:tint="?colorControlNormal"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/handle"
app:layout_constraintTop_toTopOf="parent"
app:srcCompat="@drawable/ic_delete"
android:contentDescription="@string/delete" />
<ImageButton
android:id="@+id/btnRename"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:tint="?colorControlNormal"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/btnDelete"
app:layout_constraintTop_toTopOf="parent"
app:srcCompat="@drawable/ic_edit"
android:contentDescription="@string/rename" />
<ImageView
android:id="@+id/handle"
android:layout_width="wrap_content"

View File

@@ -1,28 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingTop="24dp">
<ImageView
android:id="@+id/imageView2"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:srcCompat="@drawable/ic_launcher_foreground" />
<TextView
android:id="@+id/textView14"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="@string/app_name"
android:textAppearance="@style/TextAppearance.MaterialComponents.Headline4"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.5"
app:layout_constraintStart_toEndOf="@+id/imageView2"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
<include layout="@layout/app_logo" />
</FrameLayout>

View File

@@ -31,6 +31,12 @@
app:enterAnim="@anim/fragment_fade_enter"
app:popEnterAnim="@anim/fragment_fade_enter"
app:popExitAnim="@anim/fragment_fade_exit" />
<action
android:id="@+id/action_map_to_welcome"
app:destination="@id/welcome" />
<action
android:id="@+id/action_map_to_update_060_androidauto"
app:destination="@id/update_060_androidauto" />
</fragment>
<fragment
android:id="@+id/about"
@@ -75,6 +81,16 @@
android:name="net.vonforst.evmap.fragment.DonateFragment"
android:label="@string/donate"
tools:layout="@layout/fragment_donate" />
<dialog
android:id="@+id/welcome"
android:name="net.vonforst.evmap.fragment.WelcomeDialogFragment"
android:label="@string/welcome_to_evmap"
tools:layout="@layout/dialog_welcome" />
<dialog
android:id="@+id/update_060_androidauto"
android:name="net.vonforst.evmap.fragment.updatedialogs.Update060AndroidAutoDialogFramgent"
android:label="@string/welcome_to_evmap"
tools:layout="@layout/dialog_update_060_androidauto" />
<chrome
android:id="@+id/report_new_charger"
app:url="@string/report_new_charger_url" />

View File

@@ -3,7 +3,6 @@
android:duration="375"
android:interpolator="@android:interpolator/fast_out_slow_in"
android:transitionOrdering="together">
<changeClipBounds />
<changeTransform />
<changeImageTransform />
<changeBounds />
</transitionSet>

View File

@@ -37,7 +37,7 @@
<string name="settings_ui">Oberfläche</string>
<string name="settings_map">Karte</string>
<string name="copyright">Copyright</string>
<string name="copyright_summary">©2020 Johan von Forstner</string>
<string name="copyright_summary">©20202021 Johan von Forstner</string>
<string name="other">Sonstiges</string>
<string name="privacy">Datenschutzerklärung</string>
<string name="fav_add">Zu Favoriten hinzufügen</string>
@@ -146,8 +146,19 @@
<string name="save_as_profile">Als Profil speichern</string>
<string name="save_profile_enter_name">Geben Sie den Namen des Filterprofils ein:</string>
<string name="filterprofiles_empty_state">Du hast noch keine Filterprofile gespeichert.</string>
<string name="welcome_to_evmap">Willkommen bei EVMap!</string>
<string name="welcome_1">Mit EVMap kannst du Ladestationen für Elektroautos in deiner Nähe finden. EVMap nutzt dafür die Community-gepflegte Datenbank von GoingElectric.de, die sich vor allem auf Europa und den deutschsprachigen Raum konzentriert. Über die Website GoingElectric.de kannst du selbst zum Verzeichnis beitragen.\n\nDie Ladestationen werden auf der Karte mit verschiedenen Farben angezeigt, die die maximale Ladeleistung angeben:</string>
<string name="welcome_2">EVMap ist kostenlos und Open Source. Du kannst bei GitHub zur Weiterentwicklung beitragen oder die Entwicklung mit Spenden unterstützen. Die entsprechenden Links findest du unter „Über EVMap” im Menü.</string>
<string name="deleted_filterprofile">„%s” gelöscht</string>
<string name="undo">Rückgängig</string>
<string name="rename">Umbenennen</string>
<plurals name="charge_cards_compatible_num">
<item quantity="one">%d kompatibler Ladetarif</item>
<item quantity="other">%d kompatible Ladetarife</item>
</plurals>
</resources>
<string name="navigate">Navigieren</string>
<string name="verified">Verifiziert</string>
<string name="verified_desc">Verifiziert von der GoingElectric.de Community nicht zwangsläufig auch aktuell verfügbar.</string>
<string name="update_060_androidauto_title">Neues Update: Android Auto</string>
<string name="update_060_androidauto_text">Mit diesem neuen Update kannst du EVMap nutzen, um Ladestationen in der Nähe auf unterstützen Autos direkt aus Android Auto zu finden. Öffne einfach die EVMap-App aus dem Menü von Android Auto.</string>
</resources>

View File

@@ -14,4 +14,5 @@
<color name="unknown">#9e9e9e</color>
<color name="status_bar_scrim">#C3000000</color>
<color name="delete_red">#f44336</color>
<color name="android_auto_accent">#039be5</color>
</resources>

View File

@@ -36,7 +36,7 @@
<string name="settings_ui">User Interface</string>
<string name="settings_map">Map</string>
<string name="copyright">Copyright</string>
<string name="copyright_summary">©2020 Johan von Forstner</string>
<string name="copyright_summary">©20202021 Johan von Forstner</string>
<string name="other">Other</string>
<string name="privacy">Privacy Notice</string>
<string name="fav_add">Add to favorites</string>
@@ -145,8 +145,19 @@
<string name="save_as_profile">Save as profile</string>
<string name="save_profile_enter_name">Enter the name of the filter profile:</string>
<string name="filterprofiles_empty_state">You have not yet saved any filter profiles.</string>
<string name="welcome_to_evmap">Welcome to EVMap!</string>
<string name="welcome_1">Using EVMap, you can find electric vehicle chargers around you. EVMap uses the community-maintained database from GoingElectric.de, which focuses on chargers in Europe and the German-speaking countries. You can contribute to this database on the GoingElectric.de website.\n\nChargers are shown on the map in different colors, which correspond to their maximum charging power:</string>
<string name="welcome_2">EVMap is free and Open Source software. You can contribute to the development on GitHub or support me through donations. The corresponding links can be found under “About EVMap” in the menu.</string>
<string name="deleted_filterprofile">Deleted “%s”</string>
<string name="undo">Undo</string>
<string name="rename">Rename</string>
<plurals name="charge_cards_compatible_num">
<item quantity="one">%d compatible payment method</item>
<item quantity="other">%d compatible payment methods</item>
</plurals>
<string name="navigate">Navigate</string>
<string name="verified">verified</string>
<string name="verified_desc">Charger verified by a member at the GoingElectric.de community — not necessarily working right now.</string>
<string name="update_060_androidauto_title">New update: Android Auto</string>
<string name="update_060_androidauto_text">With this new update, you can also use EVMap to find nearby chargers from within Android Auto on supported cars. Simply select the EVMap app in the Android Auto menu.</string>
</resources>

View File

@@ -1,12 +0,0 @@
package net.vonforst.evmap.api
import org.junit.Assert.assertEquals
import org.junit.Test
class UtilsTest {
@Test
fun testDistanceBetween() {
assertEquals(129412.71, distanceBetween(54.0, 9.0, 53.0, 8.0), 0.01)
}
}

View File

@@ -66,8 +66,7 @@ class NewMotionAvailabilityDetectorTest {
@Test
fun apiTest() {
for (chargepoint in listOf(2105L, 18284L)) {
val charger = api.getChargepointDetail(chargepoint)
.execute().body()!!
val charger = runBlocking { api.getChargepointDetail(chargepoint).body()!! }
.chargelocations[0] as ChargeLocation
println(charger)

View File

@@ -53,7 +53,7 @@ class GoingElectricApiTest {
@Test
fun testLoadChargepointDetail() {
val response = api.getChargepointDetail(2105).execute()
val response = runBlocking { api.getChargepointDetail(2105) }
assertTrue(response.isSuccessful)
val body = response.body()!!
assertEquals("ok", body.status)

View File

@@ -0,0 +1,12 @@
package net.vonforst.evmap.utils
import org.junit.Assert.assertEquals
import org.junit.Test
class LocationUtilsTest {
@Test
fun testDistanceBetween() {
assertEquals(129521.08, distanceBetween(54.0, 9.0, 53.0, 8.0), 0.01)
}
}

View File

@@ -0,0 +1,14 @@
Frohes neues Jahr! 🎆
Neue Features:
- Sammlung von Filtern als Filterprofil speichern
- Filter nach Kategorien (Restaurant, Hotel, Parkhaus etc.)
- Maßstab wird auf der Karte angezeigt
- Knopf zum Bearbeiten eines Ladepunkts bei GoingElectric
- Willkommensdialog beim ersten Start
Korrekturen:
- Favoriten werden nach Abstand zur aktuellen Position sortiert
- Stabilitätsverbesserung beim Laden der Verfügbarkeit (v.a. in der Favoritenliste)
- App-Name "EV Map" -> "EVMap"
- Abstürze behoben

View File

@@ -0,0 +1,9 @@
Neue Funktionen:
- Filterprofile verwalten: Möglichkeit zum Löschen und Umbennenen der angelegten Filterprofile
- Verfügbarkeit der Ladestationen wird auch direkt in der Schnellansicht angezeigt
Verbesserungen:
- Fehlender OK-Button beim Willkommensdialog auf kleinen Bildschirmen behoben
- Nicht funktionierender Navigationsbutton auf Android 11 behoben
- Tippfehler behoben
- Fehlerhafte Links bei den Koordinaten behoben

View File

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

View File

@@ -0,0 +1,10 @@
Neue Features:
- Kartenausschnitt folgt der aktuellen Position
- Verfügbarkeitsanzeige in der Kartenansicht beinhaltet nur die per Filter gewählten Anschlüsse (z.B. nur CCS)
- Links zu https://www.goingelectric.de/stromtankstellen können in EVMap geöffnet werden
- Geteilte Standorte (z.B. aus Messenger-Apps) können in EVMap geöffnet werden
Fehlerkorrekturen:
- Filtereinstellungen wurden bei Umbenennen eines Filterprofils fälschlicherweise gelöscht
- Ausgewählter Kartentyp (Satellit, Gelände, Standard) bleibt beim App-Neustart erhalten
- Copyright-Jahr aktualisiert

View File

@@ -0,0 +1,7 @@
Neue Features:
- Unterstützung für Android Auto
- Von der GoingElectric.de-Community verifizierte Ladestationen werden markiert
Verbesserungen:
- Aktivierte Verkehrsdaten auf der Karte werden nach App-Neustart beibehalten
- Abstürze behoben

View File

@@ -10,6 +10,7 @@ Funktionen:
- Suche nach Orten
- Erweiterte Filterfunktionen
- Favoritenliste, auch mit Anzeige der Verfügbarkeit
- Unterstützung für Android Auto
- Keine nervige Werbung
EVMap ist ein Open-Source-Projekt und unter https://github.com/johan12345/EVMap zu finden.

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

View File

@@ -0,0 +1,14 @@
Happy new year! 🎆
New features:
- save collections of filter settings as a filter profile
- filter by categories (restaurant, hotel, car park etc.)
- show scale on map
- button to edit charger information on GoingElectric.de (only works when logged in)
- welcome dialog on first start
Fixes:
- favorites list sorted by distance
- improved stability of real-time availability info loading
- app name "EV Map" -> "EVMap"
- crashes fixed

View File

@@ -0,0 +1,9 @@
New features:
- Manage filter profiles: Buttons to delete and rename filter profiles
- Real-time availability also displayed in quick view
Improvements:
- Fixed missing OK button of welcome dialog on small screens
- Fixed not working navigation button on Android 11
- Fixed typos
- Removed incorrect links on charger coordinates

View File

@@ -0,0 +1,2 @@
Improvements:
- Fixed various crashes

View File

@@ -0,0 +1,10 @@
New Features:
- Map follows current location
- Availability indicator in map view only shows currently filtered connectors (e.g. only CCS)
- Links to https://www.goingelectric.de/stromtankstellen can be opened in EVMap
- Shared locations (e.g. from messenger apps) can be opened in EVMap
Bugfixes:
- Filter settings would be deleted when renaming a saved filter profile
- Selected map type (Default, Satellite, Terrain) will be kept across app restarts
- Updated copyright year

View File

@@ -0,0 +1,7 @@
New Features:
- Android Auto support
- Chargers verified by the GoingElectric.de community are marked
Improvements:
- Enabled traffic data on map is preserved after app restart
- Fixed crashes

View File

@@ -9,6 +9,7 @@ Features:
- Search places
- Favorites list, also with availability information
- Advanced filtering options
- Android Auto support
- No ads, fully open source
EVMap is an open source project and can be found at https://github.com/johan12345/EVMap.