Compare commits

...

83 Commits
0.8.0 ... 0.9.1

Author SHA1 Message Date
johan12345
2e4167689d new 0.9.1 release 2021-09-01 19:35:09 +02:00
johan12345
8a2ad55dd6 Mapbox Autocomplete: set language 2021-09-01 19:33:31 +02:00
johan12345
44ce0cfaea Release 0.9.1 2021-09-01 19:23:27 +02:00
johan12345
70f964549e limit autocomplete entries to a single line 2021-09-01 19:14:23 +02:00
johan12345
c045eed41a Mapbox Autocomplete: handle cases where house number comes in front of address 2021-09-01 19:11:12 +02:00
johan12345
3ded108c3c fix exiting app intro after 7eeb10fa 2021-09-01 18:58:38 +02:00
johan12345
b3eb1e31e8 app intro data source selection: add missing fillViewport to ScrollView 2021-09-01 18:52:52 +02:00
johan12345
7eeb10faca app intro: add page about Android Auto support
fixes #114
2021-09-01 18:50:58 +02:00
johan12345
4208e1a4b5 rename some strings 2021-09-01 18:24:54 +02:00
johan12345
54004f14b5 fix missing SharedPreferences.apply() 2021-09-01 18:22:04 +02:00
johan12345
8eabff4888 fix crash on rotate in various fragments
caused by access to appBarConfiguration before it is initialized
fixes #121
2021-09-01 18:20:36 +02:00
johan12345
d5b5337aeb donation screen: limit width of description so that it does not overlap with the price 2021-08-29 16:55:36 +02:00
johan12345
913d8a00cf add dialog with info about open source project and donations
shown after a few launches of the app
#114
2021-08-29 16:52:44 +02:00
Johan von Forstner
fc5003cd31 Merge pull request #120 from johan12345/new-autocomplete-ui
New place search UI supporting both Mapbox and Google Places
2021-08-29 00:08:02 +02:00
johan12345
ff96e49ead use TooltipCompat (fixes crash on Android 7 and earlier) 2021-08-29 00:05:10 +02:00
johan12345
36bd74e091 adjust position of search bar 2021-08-28 23:49:32 +02:00
johan12345
3d0dc16f49 Google Places: fix API quota limit detection 2021-08-28 23:42:11 +02:00
johan12345
41b374350b display distance in autocomplete results 2021-08-28 23:31:00 +02:00
johan12345
fc72044b82 delay Mapbox autocomplete requests 2021-08-28 22:55:57 +02:00
johan12345
e96fcd4a88 make data provider for place search selectable in settings 2021-08-28 22:55:57 +02:00
johan12345
36f34bde1e build new custom autocomplete UI supporting both Google Maps and Mapbox 2021-08-28 22:55:57 +02:00
johan12345
624c5d8f92 Chargeprice: tariff name and provider name comparison should be case-independent 2021-08-26 09:32:32 +02:00
johan12345
7fcb187dda my tariffs preference: add summary 2021-08-26 09:29:13 +02:00
johan12345
7188a2aa64 add ACRA for crash reporting 2021-08-26 09:24:46 +02:00
johan12345
cf6c662832 Disable Chargeprice button for unsupported countries (fixes #117) 2021-08-24 08:59:55 +02:00
johan12345
4ceef7997d "About EVMap" screen: explain what "FAQ" means 2021-08-23 14:51:20 +02:00
johan12345
3f79bdd125 App intro: add info where to find the colors later 2021-08-23 14:48:48 +02:00
Johan von Forstner
fb279f90c5 Add sponsoring info 2021-08-21 16:41:53 +02:00
johan12345
6f35ced260 fix Chargeprice website link for OpenChargeMap 2021-08-15 18:31:44 +02:00
Johan von Forstner
c967bab524 Merge pull request #116 from johan12345/adaptive-layout
Adaptive layout for intro and other things
2021-08-15 12:25:47 +02:00
johan12345
6bf80e2b49 adjust detail view peekHeight for nonstandard font sizes 2021-08-14 17:54:58 +02:00
johan12345
d97cb4b9fb put data source selection into ScrollView 2021-08-14 17:33:34 +02:00
johan12345
17eaeb99da decrease intro animation size if necessary 2021-08-14 16:39:56 +02:00
johan12345
beebbe1c1b avoid animations overlapping text in intro 2021-08-14 16:35:24 +02:00
Johan von Forstner
0a2bbd5fb4 Fix typo in README 2021-08-10 19:42:43 +02:00
johan12345
7f1f4b67a1 Release 0.9.0 2021-08-09 19:07:55 +02:00
Johan von Forstner
d5e29a5112 Android Auto: implement filter profiles
fixes #72
2021-08-09 19:00:20 +02:00
johan12345
77f478c9e0 fix loading donation products 2021-08-09 18:37:18 +02:00
johan12345
1008a2c2cd update Google donation percentage 2021-08-09 18:29:29 +02:00
Johan von Forstner
2219e2fe27 Show current filter profile title in filter edit view 2021-08-08 16:42:35 +02:00
Johan von Forstner
8ce145a9af add dataSource column to ChargeLocation table 2021-08-08 16:24:18 +02:00
Johan von Forstner
b799dae28b add DB migration for GE plug types
fixes problem with loading the availability of favorites
2021-08-08 16:01:05 +02:00
Johan von Forstner
07a482a6b6 fix lint error 2021-08-07 20:53:34 +02:00
Johan von Forstner
4f1253b201 upgrade Travis CI 2021-08-07 20:32:13 +02:00
Johan von Forstner
8bc4a7ae40 Mapbox Autocomplete: use proximity and locale 2021-08-07 20:22:38 +02:00
Johan von Forstner
d686becfe4 update Gradle and Android plugin 2021-08-07 19:54:34 +02:00
Johan von Forstner
a686c51b32 always use Mapbox autocomplete
to avoid Google API costs
fixes #105
2021-08-07 19:47:06 +02:00
Johan von Forstner
382ead9e08 update app description 2021-07-31 20:26:18 +02:00
Johan von Forstner
2da7ea4c05 Release 0.8.4 2021-07-31 20:09:05 +02:00
Johan von Forstner
20c4274c55 Chargeprice: prevent same value for start and end state of charge 2021-07-31 20:05:41 +02:00
Johan von Forstner
748212189f add missing import 2021-07-29 17:47:25 +02:00
Johan von Forstner
d86a49beb7 move Android Auto screen classes to separate files 2021-07-29 17:13:33 +02:00
Johan von Forstner
f8b1a20d1a OpenChargeMap: Comments are optional 2021-07-29 14:31:00 +02:00
Johan von Forstner
14edb6f0cd release 0.8.3 2021-07-27 22:11:52 +02:00
Johan von Forstner
7726088f91 update AnyMaps 2021-07-27 22:09:18 +02:00
Johan von Forstner
cbc7c5a6d8 MapViewModel: cancel loading charger details when another charger is selected 2021-07-25 19:23:20 +02:00
Johan von Forstner
d510d81914 SettingsFragment: move appBarConfiguration to onResume to fix crash when changing dark mode setting 2021-07-25 19:14:23 +02:00
Johan von Forstner
9f5abd6c91 apparently we need @ExperimentalCarApi all classes that create a MapScreen as well 2021-07-22 13:57:31 +02:00
Johan von Forstner
966f62ac3d move @ExperimentalCarApi annotation to the whole MapScreen class 2021-07-22 13:03:40 +02:00
Johan von Forstner
91caf40bdb Android Auto: show city next to charger name
if there is enough room, the name does not already contain the city, and not all chargers on the list are in the same city
fixes #102
2021-07-22 12:41:55 +02:00
Johan von Forstner
72c0293365 update AnyMaps
New version uses Mapbox's legacy Marker API instead of the annotation plugin. This might be a fix for #91
2021-07-22 11:45:18 +02:00
johan12345
ca9dc9629f fix a coroutine crash when no internet available 2021-07-20 20:18:08 +02:00
johan12345
438e529257 fix crash in Android Auto 2021-07-20 19:43:37 +02:00
johan12345
5f69123d89 Release 0.8.2 2021-07-18 20:22:02 +02:00
johan12345
cf421b52a8 catch IOExceptions 2021-07-18 20:16:14 +02:00
johan12345
1b049d35b8 fix IndexOutOfBoundsException 2021-07-18 20:09:34 +02:00
johan12345
f6690a3566 add swipe-to-delete to favorites (fixes #75) 2021-07-18 20:02:05 +02:00
johan12345
cc97020216 adjust "report new charger" button in menu to use OpenChargeMap if chosen 2021-07-18 19:20:33 +02:00
johan12345
0e1e3ba46e use StfalconImageViewer for gallery fullscreen view
fixes #61
2021-07-17 22:22:39 +02:00
Johan von Forstner
657c209827 README.md: fix indentation 2021-07-16 22:56:38 +02:00
johan12345
6ec44bb526 fix filtering of charger status by selected connectors (fixes #100) 2021-07-16 22:40:57 +02:00
johan12345
0943505d90 Chargeprice: show charging duration (fixes #99) 2021-07-16 22:30:02 +02:00
johan12345
f155f7615f Release 0.8.1 2021-07-14 23:25:02 +02:00
johan12345
e8850575f2 avoid another Chargeprice crash 2021-07-14 23:20:29 +02:00
johan12345
d1c4d0a621 fix crash in Chargeprice window for certain chargers 2021-07-14 23:16:40 +02:00
johan12345
ecf27abdc5 remove unnecessary conversion of filter values 2021-07-14 23:05:17 +02:00
johan12345
5f5142baa6 fix bug with connectors filter in GoingElectricApi 2021-07-14 23:00:06 +02:00
johan12345
fa53a9fc5a cleaner implementation of equals check on FilterValues 2021-07-14 23:00:06 +02:00
johan12345
9a0a7b4e5f add SVG source file for Type1 connector 2021-07-14 23:00:06 +02:00
johan12345
1a43703db5 speed up database operations when saving filter values 2021-07-14 23:00:06 +02:00
Johan von Forstner
459589c51f Merge pull request #98 from johan12345/dependabot/bundler/addressable-2.8.0
Bump addressable from 2.7.0 to 2.8.0
2021-07-14 19:12:27 +02:00
dependabot[bot]
9393fe7380 Bump addressable from 2.7.0 to 2.8.0
Bumps [addressable](https://github.com/sporkmonger/addressable) from 2.7.0 to 2.8.0.
- [Release notes](https://github.com/sporkmonger/addressable/releases)
- [Changelog](https://github.com/sporkmonger/addressable/blob/main/CHANGELOG.md)
- [Commits](https://github.com/sporkmonger/addressable/compare/addressable-2.7.0...addressable-2.8.0)

---
updated-dependencies:
- dependency-name: addressable
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2021-07-13 15:00:17 +00:00
Johan von Forstner
f62bd1c3c4 README: Update description with OCM support 2021-07-11 23:39:10 +02:00
115 changed files with 4212 additions and 1691 deletions

2
.github/FUNDING.yml vendored Normal file
View File

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

View File

@@ -1,5 +1,5 @@
language: java
dist: trusty
dist: focal
env:
global:
- secure: KYdFlMarsyXw+OHht1Atp+Kirbw9O09Ck14EjFuKb1eNtknurZ/tGEXuD+8xWh1W8W21kgHEG7s3rzru53t29buz+FW9f+ZmhEWXFP3OydyvXLw4BAVVOjm6xG2uHX/8MOGLJNM7cfaF25EPQ+kznHe84R29KaLH90mNRr2lPa4VnfbcnvDStiVaez/vJ72UoYSP5HICAzoF70yC3ZvvCK1hZv71UIysCbFE2IkxvMhG9OOGebdnRmFssaRCrvfRLjitobcLzkPWzZZIqdjNASf8/iAxX8VgGBYfVj8ID06AfMrtgXNJRCvcD0LICraQ+WPUbikMunRieGO8PNHSB5vKdPoC50aLUa0RoRb4G3QM1pR2A8xAFlIJFX2R7iY+2t24L9hRFqB98+QoQzutfkAI1T0rzem/wtpZpuan+bDawDJEHGCeYbE0aPDAl6lytgrEE9fRgV3c1jJLQzu0xIWG8YLl3iMg0hL+c0wCKXoeqrfCFS6kYmmG7W+rQp4tCZifvRbWfAXwIPQieffKxqdEuUwiUsYxdzCu9v9uU3nflEOLLuRgeMP3gV8mpur9b5GztpkfgfzcAqsF+NiY01kYgGtrgCYlMy0TxASE+UuALrtkQtU01wwhs9RH7Az0Ib3C+MT5DTjxHQCYETIViocmNEG2vfAbgHazCpGAhcY=

View File

@@ -2,7 +2,7 @@ GEM
remote: https://rubygems.org/
specs:
CFPropertyList (3.0.2)
addressable (2.7.0)
addressable (2.8.0)
public_suffix (>= 2.0.2, < 5.0)
atomos (0.1.3)
aws-eventstream (1.1.0)
@@ -125,7 +125,7 @@ GEM
naturally (2.2.0)
os (1.1.1)
plist (3.5.0)
public_suffix (4.0.5)
public_suffix (4.0.6)
rake (13.0.1)
representable (3.0.4)
declarative (< 0.1.0)
@@ -154,6 +154,7 @@ GEM
uber (0.1.0)
unf (0.1.4)
unf_ext
unf_ext (0.0.7.7)
unf_ext (0.0.7.7-x64-mingw32)
unicode-display_width (1.7.0)
word_wrap (1.0.0)
@@ -170,6 +171,7 @@ GEM
PLATFORMS
x64-mingw32
x86_64-linux
DEPENDENCIES
fastlane

View File

@@ -3,7 +3,7 @@ EVMap [![Build Status](https://travis-ci.org/johan12345/EVMap.svg?branch=master)
<img src="https://raw.githubusercontent.com/johan12345/EVMap/master/_img/feature_graphic.svg" width=700 alt="Logo"/>
Android app to access the goingelectric.de electric vehicle charging station directory.
Android app to find electric vehicle charging stations.
<a href="https://play.google.com/store/apps/details?id=net.vonforst.evmap" target="_blank">
<img src="https://play.google.com/intl/en_us/badges/images/generic/en-play-badge.png" alt="Get it on Google Play" height="100"/></a>
@@ -14,11 +14,12 @@ Features
--------
- [Material Design](https://material.io/)
- Shows all charging stations from the community-maintained [GoingElectric.de](https://www.goingelectric.de/stromtankstellen/) directory
- Realtime availability information (beta)
- Search places
- Shows all charging stations from the community-maintained [GoingElectric.de](https://www.goingelectric.de/stromtankstellen/) and [Open Charge Map](https://openchargemap.org) directories
- Realtime availability information (only in Europe)
- Search for places
- Advanced filtering options, including saved filter profiles
- Favorites list, also with availability information
- Charging price comparison, powered by [Chargeprice.app](https://chargeprice.app)
- Integrated price comparison using [Chargeprice.app](https://chargeprice.app) (only in Europe)
- Android Auto integration
- No ads, fully open source
- Compatible with Android 5.0 and above
@@ -59,6 +60,6 @@ following content:
</string>
<string name="openchargemap_key" translatable="false">
insert your OpenChargeMap key here
</string>
</string>
</resources>
```

View File

@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?><!-- Generator: Adobe Illustrator 24.3.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Ebene_5" xmlns="http://www.w3.org/2000/svg" x="0px" y="0px"
viewBox="0 0 24 24" style="enable-background:new 0 0 24 24;" xml:space="preserve">
<style type="text/css">
.st0{fill:none;stroke:#000000;stroke-width:2;stroke-miterlimit:10;}
.st1{fill:none;stroke:#000000;stroke-width:1.7;stroke-miterlimit:10;}
.st2{fill:none;stroke:#000000;stroke-width:0.5;stroke-miterlimit:10;}
</style>
<circle cx="9" cy="18.7" r="1.4" />
<circle cx="15" cy="18.7" r="1.4" />
<path class="st0" d="M8.9,16.1h6.2c1.5,0,2.7,1.2,2.7,2.7l0,0c0,1.5-1.2,2.7-2.7,2.7H8.9c-1.5,0-2.7-1.2-2.7-2.7l0,0
C6.2,17.3,7.4,16.1,8.9,16.1z" />
<g>
<circle cx="14.7" cy="6.4" r="1.3" />
<circle cx="15.3" cy="10.5" r="0.8" />
<circle cx="8.7" cy="10.5" r="0.8" />
<circle cx="9.3" cy="6.4" r="1.3" />
<circle cx="12" cy="13.1" r="1.3" />
<circle class="st1" cx="12" cy="9.1" r="6.3" />
<rect x="11" y="15.4" width="2" height="1.3" />
<line class="st2" x1="10.9" y1="1.3" x2="13.1" y2="1.3" />
<polygon points="13.1,0.9 13.1,2.4 14.5,3.1 13.8,1 " />
<polygon points="10.9,0.9 10.9,2.4 9.5,3.1 10.2,1 " />
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -13,8 +13,8 @@ android {
applicationId "net.vonforst.evmap"
minSdkVersion 21
targetSdkVersion 30
versionCode 48
versionName "0.8.0"
versionCode 57
versionName "0.9.1"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
@@ -112,7 +112,7 @@ dependencies {
implementation 'androidx.cardview:cardview:1.0.0'
implementation 'androidx.preference:preference-ktx:1.1.1'
implementation 'com.google.android.material:material:1.3.0'
implementation 'androidx.constraintlayout:constraintlayout:2.0.4'
implementation 'androidx.constraintlayout:constraintlayout:2.1.0'
implementation 'androidx.recyclerview:recyclerview:1.2.0'
implementation 'androidx.browser:browser:1.3.0'
implementation 'com.github.johan12345:CustomBottomSheetBehavior:f69f532660'
@@ -125,7 +125,7 @@ dependencies {
implementation 'moe.banana:moshi-jsonapi:3.5.0'
implementation 'moe.banana:moshi-jsonapi-retrofit-converter:3.5.0'
implementation 'io.coil-kt:coil:1.1.0'
implementation 'com.github.MikeOrtiz:TouchImageView:3.0.3'
implementation 'com.github.johan12345:StfalconImageViewer:5082ebd392'
implementation "com.mikepenz:aboutlibraries-core:$about_libs_version"
implementation "com.mikepenz:aboutlibraries:$about_libs_version"
implementation 'com.airbnb.android:lottie:3.4.0'
@@ -139,7 +139,7 @@ dependencies {
googleImplementation 'androidx.car.app:app:1.0.0'
// AnyMaps
def anyMapsVersion = '1f050d860f'
def anyMapsVersion = '95ddd6c083'
implementation "com.github.johan12345.AnyMaps:anymaps-base:$anyMapsVersion"
googleImplementation "com.github.johan12345.AnyMaps:anymaps-google:$anyMapsVersion"
implementation "com.github.johan12345.AnyMaps:anymaps-mapbox:$anyMapsVersion"
@@ -157,6 +157,7 @@ dependencies {
googleImplementation 'com.google.code.gson:gson:2.8.6'
googleImplementation 'com.google.android.datatransport:transport-runtime:2.2.5'
googleImplementation 'androidx.lifecycle:lifecycle-extensions:2.2.0'
googleImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.4.1'
// Mapbox places (autocomplete)
// forked this library and included through JitPack to fix https://github.com/mapbox/mapbox-plugins-android/issues/1011
@@ -185,6 +186,12 @@ dependencies {
googleImplementation "com.android.billingclient:billing:$billing_version"
googleImplementation "com.android.billingclient:billing-ktx:$billing_version"
// ACRA (crash reporting)
def acraVersion = "5.8.4"
implementation("ch.acra:acra-mail:$acraVersion")
implementation("ch.acra:acra-dialog:$acraVersion")
implementation("ch.acra:acra-limiter:$acraVersion")
// debug tools
implementation 'com.facebook.stetho:stetho:1.5.1'
implementation 'com.facebook.stetho:stetho-okhttp3:1.5.1'

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,62 +4,24 @@ 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.*
import androidx.localbroadcastmanager.content.LocalBroadcastManager
import coil.imageLoader
import coil.request.ImageRequest
import com.car2go.maps.model.LatLng
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.createApi
import net.vonforst.evmap.api.goingelectric.GoingElectricApiWrapper
import net.vonforst.evmap.api.nameForPlugType
import net.vonforst.evmap.api.openchargemap.OpenChargeMapApiWrapper
import net.vonforst.evmap.api.stringProvider
import net.vonforst.evmap.model.ChargeLocation
import net.vonforst.evmap.model.ReferenceData
import net.vonforst.evmap.storage.AppDatabase
import net.vonforst.evmap.storage.GEReferenceDataRepository
import net.vonforst.evmap.storage.OCMReferenceDataRepository
import net.vonforst.evmap.storage.PreferenceDataSource
import net.vonforst.evmap.ui.ChargerIconGenerator
import net.vonforst.evmap.ui.availabilityText
import net.vonforst.evmap.ui.getMarkerTint
import net.vonforst.evmap.utils.distanceBetween
import java.io.IOException
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)
}
@androidx.car.app.annotations.ExperimentalCarApi
class CarAppService : androidx.car.app.CarAppService() {
override fun createHostValidator(): HostValidator {
return if (applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE != 0) {
@@ -76,6 +38,7 @@ class CarAppService : androidx.car.app.CarAppService() {
}
}
@androidx.car.app.annotations.ExperimentalCarApi
class EVMapSession(val cas: CarAppService) : Session(), LifecycleObserver {
var mapScreen: LocationAwareScreen? = null
set(value) {
@@ -158,566 +121,3 @@ class EVMapSession(val cas: CarAppService) : Session(), LifecycleObserver {
}
}
/**
* 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 var prefs = PreferenceDataSource(ctx)
private val api by lazy {
createApi(prefs.dataSource, 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 {
try {
// 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(
getReferenceData(),
LatLng.fromLocation(location),
searchRadius,
zoom = 16f,
null
)
chargers = response.data?.filterIsInstance(ChargeLocation::class.java)
chargers?.let {
if (it.size < 6) {
// try again with larger radius
val response = api.getChargepointsRadius(
getReferenceData(),
LatLng.fromLocation(location),
searchRadius * 5,
zoom = 16f,
emptyList()
)
chargers =
response.data?.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()
} catch (e: IOException) {
withContext(Dispatchers.Main) {
CarToast.makeText(carContext, R.string.connection_error, CarToast.LENGTH_LONG)
.show()
}
}
}
}
private suspend fun getReferenceData(): ReferenceData {
val api = api
return when (api) {
is GoingElectricApiWrapper -> {
GEReferenceDataRepository(
api,
lifecycleScope,
db.geReferenceDataDao(),
prefs
).getReferenceData().await()
}
is OpenChargeMapApiWrapper -> {
OCMReferenceDataRepository(
api,
lifecycleScope,
db.ocmReferenceDataDao(),
prefs
).getReferenceData().await()
}
else -> {
throw RuntimeException("no reference data implemented")
}
}
}
}
class ChargerDetailScreen(ctx: CarContext, val chargerSparse: ChargeLocation) : Screen(ctx) {
var charger: ChargeLocation? = null
var photo: Bitmap? = null
private var availability: ChargeLocationStatus? = null
val prefs = PreferenceDataSource(ctx)
private val db = AppDatabase.getInstance(carContext)
private val api by lazy {
createApi(prefs.dataSource, 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.stringProvider(),
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)
}
}.ifEmpty {
carContext.getString(R.string.unknown_operator)
}
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 {
try {
val response = api.getChargepointDetail(getReferenceData(), chargerSparse.id)
charger = response.data!!
val photo = charger?.photos?.firstOrNull()
photo?.let {
val size = (carContext.resources.displayMetrics.density * 64).roundToInt()
val url = photo.getUrl(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()
} catch (e: IOException) {
withContext(Dispatchers.Main) {
CarToast.makeText(carContext, R.string.connection_error, CarToast.LENGTH_LONG)
.show()
}
}
}
}
private suspend fun getReferenceData(): ReferenceData {
val api = api
return when (api) {
is GoingElectricApiWrapper -> {
GEReferenceDataRepository(
api,
lifecycleScope,
db.geReferenceDataDao(),
prefs
).getReferenceData().await()
}
is OpenChargeMapApiWrapper -> {
OCMReferenceDataRepository(
api,
lifecycleScope,
db.ocmReferenceDataDao(),
prefs
).getReferenceData().await()
}
else -> {
throw RuntimeException("no reference data implemented")
}
}
}
}
fun carAvailabilityColor(status: List<ChargepointStatus>): CarColor {
val unknown = status.any { it == ChargepointStatus.UNKNOWN }
val available = status.count { it == ChargepointStatus.AVAILABLE }
val allFaulted = status.all { it == ChargepointStatus.FAULTED }
return if (unknown) {
CarColor.DEFAULT
} else if (available > 0) {
CarColor.GREEN
} else if (allFaulted) {
CarColor.RED
} else {
CarColor.BLUE
}
}

View File

@@ -0,0 +1,213 @@
package net.vonforst.evmap.auto
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.drawable.BitmapDrawable
import android.net.Uri
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.model.*
import androidx.core.graphics.drawable.IconCompat
import androidx.lifecycle.lifecycleScope
import coil.imageLoader
import coil.request.ImageRequest
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import net.vonforst.evmap.*
import net.vonforst.evmap.api.availability.ChargeLocationStatus
import net.vonforst.evmap.api.availability.getAvailability
import net.vonforst.evmap.api.createApi
import net.vonforst.evmap.api.nameForPlugType
import net.vonforst.evmap.api.stringProvider
import net.vonforst.evmap.model.ChargeLocation
import net.vonforst.evmap.storage.AppDatabase
import net.vonforst.evmap.storage.PreferenceDataSource
import net.vonforst.evmap.ui.ChargerIconGenerator
import net.vonforst.evmap.ui.availabilityText
import net.vonforst.evmap.ui.getMarkerTint
import net.vonforst.evmap.viewmodel.Status
import net.vonforst.evmap.viewmodel.getReferenceData
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
import kotlin.math.roundToInt
class ChargerDetailScreen(ctx: CarContext, val chargerSparse: ChargeLocation) : Screen(ctx) {
var charger: ChargeLocation? = null
var photo: Bitmap? = null
private var availability: ChargeLocationStatus? = null
val prefs = PreferenceDataSource(ctx)
private val db = AppDatabase.getInstance(carContext)
private val api by lazy {
createApi(prefs.dataSource, ctx)
}
private val referenceData = api.getReferenceData(lifecycleScope, carContext)
private val iconGen = ChargerIconGenerator(carContext, null, oversize = 1.4f, height = 64)
init {
referenceData.observe(this) {
loadCharger()
}
}
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.stringProvider(),
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)
}
}.ifEmpty {
carContext.getString(R.string.unknown_operator)
}
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() {
val referenceData = referenceData.value ?: return
lifecycleScope.launch {
val response = api.getChargepointDetail(referenceData, chargerSparse.id)
if (response.status == Status.SUCCESS) {
charger = response.data!!
val photo = charger?.photos?.firstOrNull()
photo?.let {
val size = (carContext.resources.displayMetrics.density * 64).roundToInt()
val url = photo.getUrl(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()
} else {
withContext(Dispatchers.Main) {
CarToast.makeText(carContext, R.string.connection_error, CarToast.LENGTH_LONG)
.show()
}
}
}
}
}

View File

@@ -0,0 +1,90 @@
package net.vonforst.evmap.auto
import android.graphics.Bitmap
import androidx.car.app.CarContext
import androidx.car.app.Screen
import androidx.car.app.model.*
import androidx.core.graphics.drawable.IconCompat
import androidx.lifecycle.LiveData
import net.vonforst.evmap.R
import net.vonforst.evmap.model.FILTERS_DISABLED
import net.vonforst.evmap.storage.AppDatabase
import net.vonforst.evmap.storage.FilterProfile
import net.vonforst.evmap.storage.PreferenceDataSource
import kotlin.math.roundToInt
class FilterScreen(ctx: CarContext) : Screen(ctx) {
private val prefs = PreferenceDataSource(ctx)
private val db = AppDatabase.getInstance(ctx)
val filterProfiles: LiveData<List<FilterProfile>> by lazy {
db.filterProfileDao().getProfiles(prefs.dataSource)
}
private val maxRows = 6
private val checkIcon =
CarIcon.Builder(IconCompat.createWithResource(carContext, R.drawable.ic_check)).build()
private val emptyIcon: CarIcon
init {
val size = (ctx.resources.displayMetrics.density * 24).roundToInt()
emptyIcon = CarIcon.Builder(
IconCompat.createWithBitmap(
Bitmap.createBitmap(
size,
size,
Bitmap.Config.ARGB_8888
)
)
).build()
}
init {
filterProfiles.observe(this) {
invalidate()
}
}
override fun onGetTemplate(): Template {
return ListTemplate.Builder().apply {
filterProfiles.value?.let {
setSingleList(buildFilterProfilesList(it.take(maxRows), prefs.filterStatus))
} ?: setLoading(true)
setTitle(carContext.getString(R.string.menu_filter))
setHeaderAction(Action.BACK)
}.build()
}
private fun buildFilterProfilesList(
profiles: List<FilterProfile>,
filterStatus: Long
): ItemList {
return ItemList.Builder().apply {
addItem(Row.Builder().apply {
setTitle(carContext.getString(R.string.no_filters))
if (FILTERS_DISABLED == filterStatus) {
setImage(checkIcon)
} else {
setImage(emptyIcon)
}
setOnClickListener {
prefs.filterStatus = FILTERS_DISABLED
screenManager.pop()
}
}.build())
profiles.forEach {
addItem(Row.Builder().apply {
setTitle(it.name)
if (it.id == filterStatus) {
setImage(checkIcon)
} else {
setImage(emptyIcon)
}
setOnClickListener {
prefs.filterStatus = it.id
screenManager.pop()
}
}.build())
}
setNoItemsMessage(carContext.getString(R.string.filterprofiles_empty_state))
}.build()
}
}

View File

@@ -0,0 +1,313 @@
package net.vonforst.evmap.auto
import android.location.Location
import android.text.SpannableStringBuilder
import android.text.Spanned
import androidx.car.app.CarContext
import androidx.car.app.CarToast
import androidx.car.app.Screen
import androidx.car.app.model.*
import androidx.core.content.ContextCompat
import androidx.core.graphics.drawable.IconCompat
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.lifecycleScope
import com.car2go.maps.model.LatLng
import kotlinx.coroutines.*
import net.vonforst.evmap.R
import net.vonforst.evmap.api.availability.ChargeLocationStatus
import net.vonforst.evmap.api.availability.getAvailability
import net.vonforst.evmap.api.createApi
import net.vonforst.evmap.api.stringProvider
import net.vonforst.evmap.model.ChargeLocation
import net.vonforst.evmap.model.FILTERS_CUSTOM
import net.vonforst.evmap.model.FILTERS_DISABLED
import net.vonforst.evmap.storage.AppDatabase
import net.vonforst.evmap.storage.PreferenceDataSource
import net.vonforst.evmap.ui.availabilityText
import net.vonforst.evmap.ui.getMarkerTint
import net.vonforst.evmap.utils.distanceBetween
import net.vonforst.evmap.viewmodel.filtersWithValue
import net.vonforst.evmap.viewmodel.getFilterValues
import net.vonforst.evmap.viewmodel.getFilters
import net.vonforst.evmap.viewmodel.getReferenceData
import java.io.IOException
import java.time.Duration
import java.time.ZonedDateTime
import kotlin.collections.set
import kotlin.math.roundToInt
/**
* Main map screen showing either nearby chargers or favorites
*/
@androidx.car.app.annotations.ExperimentalCarApi
class MapScreen(ctx: CarContext, val session: EVMapSession, val favorites: Boolean = false) :
Screen(ctx), LocationAwareScreen {
private var updateCoroutine: Job? = null
private var numUpdates = 0
private val maxNumUpdates = 3
private var location: Location? = null
private var lastUpdateLocation: Location? = null
private var chargers: List<ChargeLocation>? = null
private var prefs = PreferenceDataSource(ctx)
private val db = AppDatabase.getInstance(carContext)
private val api by lazy {
createApi(prefs.dataSource, 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
private val referenceData = api.getReferenceData(lifecycleScope, carContext)
private val filterStatus = MutableLiveData<Long>().apply {
value = prefs.filterStatus.takeUnless { it == FILTERS_CUSTOM } ?: FILTERS_DISABLED
}
private val filterValues = db.filterValueDao().getFilterValues(filterStatus, prefs.dataSource)
private val filters = api.getFilters(referenceData, carContext.stringProvider())
private val filtersWithValue = filtersWithValue(filters, filterValues)
init {
filtersWithValue.observe(this) {
loadChargers()
}
}
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()
// only show the city if not all chargers are in the same city
val showCity = chargerList.map { it.address.city }.distinct().size > 1
chargerList.forEach { charger ->
builder.addItem(formatCharger(charger, showCity))
}
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)
if (!favorites) {
val filtersCount = filtersWithValue.value?.count {
!it.value.hasSameValueAs(it.filter.defaultValue())
}
setActionStrip(
ActionStrip.Builder()
.addAction(
Action.Builder()
.setIcon(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
R.drawable.ic_filter
)
)
.setTint(if (filtersCount != null && filtersCount > 0) CarColor.SECONDARY else CarColor.DEFAULT)
.build()
)
.setOnClickListener {
screenManager.pushForResult(FilterScreen(carContext)) {
chargers = null
numUpdates = 0
filterStatus.value = prefs.filterStatus
}
session.mapScreen = null
}
.build())
.build())
}
build()
}.build()
}
private fun formatCharger(charger: ChargeLocation, showCity: Boolean): 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 {
// only show the city if not all chargers are in the same city (-> showCity == true)
// and the city is not already contained in the charger name
if (showCity && charger.address.city != null && charger.address.city !in charger.name) {
setTitle(
CarText.Builder("${charger.name} · ${charger.address.city}")
.addVariant(charger.name)
.build())
} else {
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, 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()
}
}
private fun loadChargers() {
val location = location ?: return
val referenceData = referenceData.value ?: return
val filters = filtersWithValue.value ?: return
numUpdates++
println(numUpdates)
if (numUpdates > maxNumUpdates) {
CarToast.makeText(carContext, R.string.auto_no_refresh_possible, CarToast.LENGTH_LONG)
.show()
return
}
updateCoroutine = lifecycleScope.launch {
try {
// 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(
referenceData,
LatLng.fromLocation(location),
searchRadius,
zoom = 16f,
filters
)
chargers = response.data?.filterIsInstance(ChargeLocation::class.java)
chargers?.let {
if (it.size < 6) {
// try again with larger radius
val response = api.getChargepointsRadius(
referenceData,
LatLng.fromLocation(location),
searchRadius * 10,
zoom = 16f,
filters
)
chargers =
response.data?.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()
} catch (e: IOException) {
withContext(Dispatchers.Main) {
CarToast.makeText(carContext, R.string.connection_error, CarToast.LENGTH_LONG)
.show()
}
}
}
}
}

View File

@@ -0,0 +1,65 @@
package net.vonforst.evmap.auto
import android.content.Intent
import android.os.Bundle
import android.os.ResultReceiver
import androidx.car.app.CarContext
import androidx.car.app.CarToast
import androidx.car.app.Screen
import androidx.car.app.model.*
import net.vonforst.evmap.R
/**
* Screen to grant location permission
*/
@androidx.car.app.annotations.ExperimentalCarApi
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()
}
}

View File

@@ -0,0 +1,20 @@
package net.vonforst.evmap.auto
import androidx.car.app.model.CarColor
import net.vonforst.evmap.api.availability.ChargepointStatus
fun carAvailabilityColor(status: List<ChargepointStatus>): CarColor {
val unknown = status.any { it == ChargepointStatus.UNKNOWN }
val available = status.count { it == ChargepointStatus.AVAILABLE }
val allFaulted = status.all { it == ChargepointStatus.FAULTED }
return if (unknown) {
CarColor.DEFAULT
} else if (available > 0) {
CarColor.GREEN
} else if (allFaulted) {
CarColor.RED
} else {
CarColor.BLUE
}
}

View File

@@ -0,0 +1,70 @@
package net.vonforst.evmap.auto
import android.location.Location
import androidx.car.app.CarContext
import androidx.car.app.Screen
import androidx.car.app.model.*
import androidx.core.graphics.drawable.IconCompat
import net.vonforst.evmap.R
/**
* Welcome screen with selection between favorites and nearby chargers
*/
@androidx.car.app.annotations.ExperimentalCarApi
class WelcomeScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx), LocationAwareScreen {
private var location: Location? = null
override fun onGetTemplate(): Template {
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()
}
}

View File

@@ -1,33 +1,11 @@
package net.vonforst.evmap.autocomplete
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.view.inputmethod.InputMethodManager
import androidx.fragment.app.Fragment
import com.car2go.maps.google.adapter.AnyMapAdapter
import com.google.android.libraries.places.api.model.Place
import com.google.android.libraries.places.widget.Autocomplete
import com.google.android.libraries.places.widget.model.AutocompleteActivityMode
import net.vonforst.evmap.fragment.REQUEST_AUTOCOMPLETE
import net.vonforst.evmap.viewmodel.PlaceWithBounds
import net.vonforst.evmap.storage.PreferenceDataSource
fun launchAutocomplete(fragment: Fragment) {
val fields = listOf(Place.Field.LAT_LNG, Place.Field.VIEWPORT)
val intent: Intent = Autocomplete.IntentBuilder(
AutocompleteActivityMode.OVERLAY, fields
)
.build(fragment.requireActivity())
.addFlags(Intent.FLAG_ACTIVITY_NO_ANIMATION)
fragment.startActivityForResult(intent, REQUEST_AUTOCOMPLETE)
// show keyboard
val imm = fragment.requireContext()
.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
imm.toggleSoftInput(0, 0)
}
fun handleAutocompleteResult(intent: Intent): PlaceWithBounds? {
val place = Autocomplete.getPlaceFromIntent(intent)
return PlaceWithBounds(AnyMapAdapter.adapt(place.latLng), AnyMapAdapter.adapt(place.viewport))
}
fun getAutocompleteProviders(context: Context) =
if (PreferenceDataSource(context).searchProvider == "google") {
listOf(GooglePlacesAutocompleteProvider(context), MapboxAutocompleteProvider(context))
} else {
listOf(MapboxAutocompleteProvider(context), GooglePlacesAutocompleteProvider(context))
}

View File

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

View File

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

View File

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

View File

@@ -54,12 +54,12 @@ class DonateViewModel(application: Application) : AndroidViewModel(application),
.build()
billingClient.querySkuDetailsAsync(params) { result, details ->
if (result.responseCode == BillingClient.BillingResponseCode.OK && details != null) {
products.value = Resource.success(details
products.postValue(Resource.success(details
.sortedBy { it.priceAmountMicros }
.map { DonationItem(it) }
)
))
} else {
products.value = Resource.error(result.debugMessage, null)
products.postValue(Resource.error(result.debugMessage, null))
}
}
}

View File

@@ -51,7 +51,7 @@
android:id="@+id/products_list"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_marginTop="8dp"
android:layout_marginTop="16dp"
app:data="@{vm.products.data}"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"

View File

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

View File

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

View File

@@ -4,7 +4,11 @@
<item>Google Maps</item>
<item>OpenStreetMap (Mapbox)</item>
</string-array>
<string name="donations_info" formatted="false">Findest du EVMap nützlich? Unterstütze die Weiterentwicklung der App mit einer Spende an den Entwickler.\n\nGoogle zieht von der Spende 30% Gebühren ab.</string>
<string-array name="pref_search_provider_names">
<item>Google Maps</item>
<item>OpenStreetMap (Mapbox)</item>
</string-array>
<string name="donations_info" formatted="false">Findest du EVMap nützlich? Unterstütze die Weiterentwicklung der App mit einer Spende an den Entwickler.\n\nGoogle zieht von der Spende 15% Gebühren ab.</string>
<string name="auto_location_service">EVMap läuft unter Android Auto und nutzt dafür deinen Standort.</string>
<string name="auto_no_chargers_found">Keine Ladestationen in der Nähe gefunden</string>
<string name="auto_no_favorites_found">Keine Favoriten gefunden</string>
@@ -16,4 +20,7 @@
<string name="auto_favorites">Favoriten</string>
<string name="auto_fault_report_date">⚠️ Störungsmeldung (%s)</string>
<string name="auto_no_refresh_possible">Weitere Aktualisierung nicht möglich. Bitte zurück gehen und neu starten.</string>
<string name="welcome_android_auto">Android Auto-Unterstützung</string>
<string name="welcome_android_auto_detail">Auf unterstützen Autos kannst du EVMap auch mit Android Auto nutzen. Öffne dazu einfach die EVMap-App aus dem Menü von Android Auto.</string>
<string name="sounds_cool">klingt cool</string>
</resources>

View File

@@ -8,8 +8,17 @@
<item>google</item>
<item>mapbox</item>
</string-array>
<string-array name="pref_search_provider_names">
<item>Google Maps</item>
<item>OpenStreetMap (Mapbox)</item>
</string-array>
<string-array name="pref_search_provider_values" tranlatable="false">
<item>google</item>
<item>mapbox</item>
</string-array>
<string name="pref_map_provider_default" translatable="false">google</string>
<string name="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="pref_search_provider_default" translatable="false">mapbox</string>
<string name="donations_info" formatted="false">Do you find EVMap useful? Support its development by sending a donation to the developer.\n\nGoogle takes 15% off every donation.</string>
<string name="auto_location_service">EVMap is running on Android Auto and using your location.</string>
<string name="auto_no_chargers_found">No nearby chargers found</string>
<string name="auto_no_favorites_found">No favorites found</string>
@@ -21,4 +30,7 @@
<string name="auto_favorites">Favorites</string>
<string name="auto_fault_report_date">⚠️ Fault report (%s)</string>
<string name="auto_no_refresh_possible">Further updates not possible. Please go back and restart.</string>
<string name="welcome_android_auto">Android Auto support</string>
<string name="welcome_android_auto_detail">You can also use EVMap from within Android Auto on supported cars. Simply select the EVMap app in the Android Auto menu.</string>
<string name="sounds_cool">sounds cool</string>
</resources>

View File

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

View File

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

View File

@@ -78,6 +78,7 @@ class MapsActivity : AppCompatActivity() {
}
prefs = PreferenceDataSource(this)
prefs.appStartCounter += 1
checkPlayServices(this)

View File

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

View File

@@ -22,7 +22,6 @@ import net.vonforst.evmap.databinding.ItemChargepriceVehicleChipBinding
import net.vonforst.evmap.databinding.ItemConnectorButtonBinding
import net.vonforst.evmap.model.Chargepoint
import net.vonforst.evmap.ui.CheckableConstraintLayout
import net.vonforst.evmap.viewmodel.FavoritesViewModel
interface Equatable {
override fun equals(other: Any?): Boolean
@@ -89,18 +88,6 @@ class ConnectorAdapter : DataBindingAdapter<ConnectorAdapter.ChargepointWithAvai
override fun getItemViewType(position: Int): Int = R.layout.item_connector
}
class FavoritesAdapter(val vm: FavoritesViewModel) :
DataBindingAdapter<FavoritesViewModel.FavoritesListItem>() {
init {
setHasStableIds(true)
}
override fun getItemViewType(position: Int): Int = R.layout.item_favorite
override fun getItemId(position: Int): Long = getItem(position).charger.id
}
class ChargepriceAdapter() :
DataBindingAdapter<ChargePrice>() {
@@ -181,7 +168,7 @@ class CheckableConnectorAdapter : DataBindingAdapter<Chargepoint>() {
}
root.setOnCheckedChangeListener { v: View, checked: Boolean ->
if (checked) {
checkedItem = position
checkedItem = holder.bindingAdapterPosition
root.post {
notifyDataSetChanged()
}

View File

@@ -0,0 +1,39 @@
package net.vonforst.evmap.adapter
import android.annotation.SuppressLint
import android.view.animation.AccelerateInterpolator
import net.vonforst.evmap.R
import net.vonforst.evmap.databinding.ItemFavoriteBinding
import net.vonforst.evmap.viewmodel.FavoritesViewModel
class FavoritesAdapter(val onDelete: (FavoritesViewModel.FavoritesListItem) -> Unit) :
DataBindingAdapter<FavoritesViewModel.FavoritesListItem>() {
init {
setHasStableIds(true)
}
override fun getItemViewType(position: Int): Int = R.layout.item_favorite
override fun getItemId(position: Int): Long = getItem(position).charger.id
@SuppressLint("ClickableViewAccessibility")
override fun bind(
holder: ViewHolder<FavoritesViewModel.FavoritesListItem>,
item: FavoritesViewModel.FavoritesListItem
) {
super.bind(holder, item)
val binding = holder.binding as ItemFavoriteBinding
binding.foreground.translationX = 0f
binding.btnDelete.setOnClickListener {
binding.foreground.animate()
.translationX(binding.foreground.width.toFloat())
.setDuration(250)
.setInterpolator(AccelerateInterpolator())
.withEndAction {
onDelete(item)
}
.start()
}
}
}

View File

@@ -2,7 +2,9 @@ package net.vonforst.evmap.adapter
import android.annotation.SuppressLint
import android.content.Context
import android.view.*
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
@@ -11,19 +13,11 @@ import coil.load
import coil.memory.MemoryCache
import coil.size.OriginalSize
import coil.size.SizeResolver
import com.ortiz.touchview.TouchImageView
import net.vonforst.evmap.R
import net.vonforst.evmap.model.ChargerPhoto
class GalleryAdapter(
context: Context,
val itemClickListener: ItemClickListener? = null,
val detailView: Boolean = false,
val pageToLoad: Int? = null,
val imageCacheKey: MemoryCache.Key? = null,
val loadedListener: (() -> Unit)? = null
) :
class GalleryAdapter(context: Context, val itemClickListener: ItemClickListener? = null) :
ListAdapter<ChargerPhoto, GalleryAdapter.ViewHolder>(ChargerPhotoDiffCallback()) {
class ViewHolder(val view: ImageView) : RecyclerView.ViewHolder(view)
@@ -38,104 +32,34 @@ class GalleryAdapter(
@SuppressLint("ClickableViewAccessibility")
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val inflater = LayoutInflater.from(parent.context)
val view: ImageView
if (detailView) {
view = inflater.inflate(R.layout.gallery_item_fullscreen, parent, false) as ImageView
view.setOnTouchListener { v, event ->
var result = true
//can scroll horizontally checks if there's still a part of the image
//that can be scrolled until you reach the edge
if (event.pointerCount >= 2 || v.canScrollHorizontally(1) && v.canScrollHorizontally(
-1
)
) {
//multi-touch event
result = when (event.action) {
MotionEvent.ACTION_DOWN, MotionEvent.ACTION_MOVE -> {
// Disallow RecyclerView to intercept touch events.
parent.requestDisallowInterceptTouchEvent(true)
// Disable touch on view
false
}
MotionEvent.ACTION_UP -> {
// Allow RecyclerView to intercept touch events.
parent.requestDisallowInterceptTouchEvent(false)
true
}
else -> true
}
}
result
}
} else {
view = inflater.inflate(R.layout.gallery_item, parent, false) as ImageView
}
val view = inflater.inflate(R.layout.gallery_item, parent, false) as ImageView
return ViewHolder(view)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
if (detailView) {
(holder.view as TouchImageView).resetZoom()
}
val id = getItem(position).id
val url = if (detailView) {
getItem(position).getUrl(size = 1000)
} else {
getItem(position).getUrl(height = holder.view.height)
}
val url = getItem(position).getUrl(height = holder.view.height)
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, 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"
class ChargerPhotoDiffCallback : DiffUtil.ItemCallback<ChargerPhoto>() {
override fun areItemsTheSame(oldItem: ChargerPhoto, newItem: ChargerPhoto): Boolean {
return oldItem.id == newItem.id

View File

@@ -0,0 +1,156 @@
package net.vonforst.evmap.adapter
import android.content.Context
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.*
import androidx.databinding.DataBindingUtil
import androidx.lifecycle.LiveData
import com.car2go.maps.model.LatLng
import net.vonforst.evmap.R
import net.vonforst.evmap.autocomplete.*
import net.vonforst.evmap.containsAny
import net.vonforst.evmap.databinding.ItemAutocompleteResultBinding
import net.vonforst.evmap.isDarkMode
import net.vonforst.evmap.storage.PreferenceDataSource
class PlaceAutocompleteAdapter(val context: Context, val location: LiveData<LatLng>) :
BaseAdapter(), Filterable {
private var resultList: List<AutocompletePlace>? = null
private val providers = getAutocompleteProviders(context)
private val typeItem = 0
private val typeAttribution = 1
var currentProvider: AutocompleteProvider? = null
data class ViewHolder(val binding: ItemAutocompleteResultBinding)
override fun getCount(): Int {
return resultList?.let { it.size + 1 } ?: 0
}
override fun getItem(position: Int): AutocompletePlace? {
return if (position < resultList!!.size) resultList!![position] else null
}
override fun getItemViewType(position: Int): Int {
return if (position < resultList!!.size) typeItem else typeAttribution
}
override fun getViewTypeCount(): Int = 2
override fun getItemId(position: Int): Long {
return 0
}
override fun getView(position: Int, convertView: View?, parent: ViewGroup?): View {
var view = convertView
if (getItemViewType(position) == typeItem) {
val viewHolder: ViewHolder
if (view == null) {
val binding: ItemAutocompleteResultBinding = DataBindingUtil.inflate(
LayoutInflater.from(context),
R.layout.item_autocomplete_result,
parent,
false
)
view = binding.root
viewHolder = ViewHolder(binding)
view.tag = viewHolder
} else {
viewHolder = view.tag as ViewHolder
}
val place = resultList!![position]
bindView(viewHolder, place)
} else if (getItemViewType(position) == typeAttribution) {
if (view == null) {
view = LayoutInflater.from(context)
.inflate(R.layout.item_autocomplete_attribution, parent, false)
}
(view as ImageView).apply {
setImageResource(currentProvider?.getAttributionImage(context.isDarkMode()) ?: 0)
contentDescription = context.getString(currentProvider?.getAttributionString() ?: 0)
}
}
return view!!
}
private fun bindView(
viewHolder: ViewHolder,
place: AutocompletePlace
) {
viewHolder.binding.item = place
}
override fun getFilter(): Filter {
return object : Filter() {
var delaySet = false
init {
if (PreferenceDataSource(context).searchProvider == "mapbox") {
// set delay to 500 ms to reduce paid Mapbox API requests
this.setDelayer { 500L }
}
}
override fun publishResults(constraint: CharSequence?, results: FilterResults?) {
if (results != null && results.count > 0) {
notifyDataSetChanged()
} else {
notifyDataSetInvalidated()
}
}
override fun performFiltering(constraint: CharSequence?): FilterResults {
val filterResults = FilterResults()
if (constraint != null) {
for (provider in providers) {
try {
resultList =
provider.autocomplete(constraint.toString(), location.value)
currentProvider = provider
break
} catch (e: ApiUnavailableException) {
e.printStackTrace()
}
}
filterResults.values = resultList
filterResults.count = resultList!!.size
}
if (currentProvider is MapboxAutocompleteProvider && !delaySet) {
// set delay to 500 ms to reduce paid Mapbox API requests
this.setDelayer { 500L }
}
return filterResults
}
}
}
}
fun iconForPlaceType(types: List<AutocompletePlaceType>): Int =
when {
types.containsAny(
AutocompletePlaceType.LIGHT_RAIL_STATION,
AutocompletePlaceType.BUS_STATION,
AutocompletePlaceType.TRAIN_STATION,
AutocompletePlaceType.TRANSIT_STATION
) -> {
R.drawable.ic_place_type_train
}
types.contains(AutocompletePlaceType.AIRPORT) -> {
R.drawable.ic_place_type_airport
}
// TODO: extend this with icons for more place categories
else -> {
R.drawable.ic_place_type_default
}
}
fun isSpecialPlace(types: List<AutocompletePlaceType>): Boolean =
iconForPlaceType(types) != R.drawable.ic_place_type_default

View File

@@ -8,7 +8,8 @@ import net.vonforst.evmap.api.RateLimitInterceptor
import net.vonforst.evmap.api.await
import net.vonforst.evmap.api.equivalentPlugTypes
import net.vonforst.evmap.cartesianProduct
import net.vonforst.evmap.model.*
import net.vonforst.evmap.model.ChargeLocation
import net.vonforst.evmap.model.Chargepoint
import net.vonforst.evmap.viewmodel.Resource
import okhttp3.JavaNetCookieJar
import okhttp3.OkHttpClient
@@ -117,17 +118,10 @@ 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")
fun applyFilters(connectors: Set<String>?, minPower: Int?): ChargeLocationStatus {
val statusFiltered = status.filterKeys {
(connectorsVal == null || connectorsVal.all || connectorsVal.values.map {
equivalentPlugTypes(
it
)
(connectors == null || connectors.map {
equivalentPlugTypes(it)
}.any { equivalent -> it.type in equivalent })
&& (minPower == null || it.power > minPower)
}

View File

@@ -74,5 +74,51 @@ interface ChargepriceApi {
.build()
return retrofit.create(ChargepriceApi::class.java)
}
@JvmStatic
fun isCountrySupported(country: String, dataSource: String): Boolean = when (dataSource) {
// list of countries updated 2021/08/24
"goingelectric" -> country in listOf(
"Deutschland",
"Österreich",
"Schweiz",
"Frankreich",
"Belgien",
"Niederlande",
"Luxemburg",
"Dänemark",
"Norwegen",
"Schweden",
"Slowenien",
"Kroatien",
"Ungarn",
"Tschechien",
"Italien",
"Spanien",
"Großbritannien",
"Irland"
)
"openchargemap" -> country in listOf(
"DE",
"AT",
"CH",
"FR",
"BE",
"NE",
"LU",
"DK",
"NO",
"SE",
"SI",
"HR",
"HU",
"CZ",
"IT",
"ES",
"GB",
"IE"
)
else -> false
}
}
}

View File

@@ -7,6 +7,7 @@ import com.facebook.stetho.okhttp3.StethoInterceptor
import com.squareup.moshi.Moshi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.supervisorScope
import kotlinx.coroutines.withContext
import net.vonforst.evmap.BuildConfig
import net.vonforst.evmap.R
@@ -142,15 +143,10 @@ class GoingElectricApiWrapper(
val minPower = filters?.getSliderValue("min_power")
val minConnectors = filters?.getSliderValue("min_connectors")
val connectorsVal = filters?.getMultipleChoiceValue("connectors")
if (connectorsVal != null) {
if (connectorsVal.values.isEmpty() && !connectorsVal.all) {
// no connectors chosen
return Resource.success(emptyList())
}
connectorsVal.values = connectorsVal.values.mapNotNull {
GEChargepoint.convertTypeToGE(it)
}.toMutableSet()
var connectorsVal = filters?.getMultipleChoiceValue("connectors")
if (connectorsVal != null && connectorsVal.values.isEmpty() && !connectorsVal.all) {
// no connectors chosen
return Resource.success(emptyList())
}
val connectors = formatMultipleChoice(connectorsVal)
@@ -241,15 +237,10 @@ class GoingElectricApiWrapper(
val minPower = filters?.getSliderValue("min_power")
val minConnectors = filters?.getSliderValue("min_connectors")
val connectorsVal = filters?.getMultipleChoiceValue("connectors")
if (connectorsVal != null) {
if (connectorsVal.values.isEmpty() && !connectorsVal.all) {
// no connectors chosen
return Resource.success(emptyList())
}
connectorsVal.values = connectorsVal.values.mapNotNull {
GEChargepoint.convertTypeToGE(it)
}.toMutableSet()
var connectorsVal = filters?.getMultipleChoiceValue("connectors")
if (connectorsVal != null && connectorsVal.values.isEmpty() && !connectorsVal.all) {
// no connectors chosen
return Resource.success(emptyList())
}
val connectors = formatMultipleChoice(connectorsVal)
@@ -354,40 +345,50 @@ class GoingElectricApiWrapper(
referenceData: ReferenceData,
id: Long
): Resource<ChargeLocation> {
val response = api.getChargepointDetail(id)
return if (response.isSuccessful && response.body()!!.status == "ok" && response.body()!!.chargelocations.size == 1) {
Resource.success(
(response.body()!!.chargelocations[0] as GEChargeLocation).convert(
apikey
try {
val response = api.getChargepointDetail(id)
return if (response.isSuccessful && response.body()!!.status == "ok" && response.body()!!.chargelocations.size == 1) {
Resource.success(
(response.body()!!.chargelocations[0] as GEChargeLocation).convert(
apikey
)
)
)
} else {
Resource.error(response.message(), null)
} else {
Resource.error(response.message(), null)
}
} catch (e: IOException) {
return Resource.error(e.message, null)
}
}
override suspend fun getReferenceData(): Resource<GEReferenceData> =
withContext(Dispatchers.IO) {
val plugs = async { api.getPlugs() }
val chargeCards = async { api.getChargeCards() }
val networks = async { api.getNetworks() }
supervisorScope {
try {
val plugs = async { api.getPlugs() }
val chargeCards = async { api.getChargeCards() }
val networks = async { api.getNetworks() }
val plugsResponse = plugs.await()
val chargeCardsResponse = chargeCards.await()
val networksResponse = networks.await()
val plugsResponse = plugs.await()
val chargeCardsResponse = chargeCards.await()
val networksResponse = networks.await()
val responses = listOf(plugsResponse, chargeCardsResponse, networksResponse)
val responses = listOf(plugsResponse, chargeCardsResponse, networksResponse)
if (responses.map { it.isSuccessful }.all { it }) {
Resource.success(
GEReferenceData(
plugsResponse.body()!!.result,
networksResponse.body()!!.result,
chargeCardsResponse.body()!!.result
)
)
} else {
Resource.error(responses.find { !it.isSuccessful }!!.message(), null)
if (responses.map { it.isSuccessful }.all { it }) {
Resource.success(
GEReferenceData(
plugsResponse.body()!!.result,
networksResponse.body()!!.result,
chargeCardsResponse.body()!!.result
)
)
} else {
Resource.error(responses.find { !it.isSuccessful }!!.message(), null)
}
} catch (e: IOException) {
Resource.error(e.message, null)
}
}
}

View File

@@ -56,6 +56,7 @@ data class GEChargeLocation(
) : GEChargepointListItem() {
override fun convert(apikey: String) = ChargeLocation(
id,
"goingelectric",
name,
coordinates.convert(),
address.convert(),

View File

@@ -20,6 +20,7 @@ import retrofit2.Retrofit
import retrofit2.converter.moshi.MoshiConverterFactory
import retrofit2.http.GET
import retrofit2.http.Query
import java.io.IOException
interface OpenChargeMapApi {
@GET("poi/")
@@ -137,29 +138,33 @@ class OpenChargeMapApiWrapper(
}
val operators = formatMultipleChoice(operatorsVal)
val response = api.getChargepoints(
OCMBoundingBox(
bounds.southwest.latitude, bounds.southwest.longitude,
bounds.northeast.latitude, bounds.northeast.longitude
),
minPower = minPower,
plugs = connectors,
operators = operators,
statusType = if (excludeFaults == true) noFaultStatuses.joinToString(",") else null
)
if (!response.isSuccessful) {
return Resource.error(response.message(), null)
}
try {
val response = api.getChargepoints(
OCMBoundingBox(
bounds.southwest.latitude, bounds.southwest.longitude,
bounds.northeast.latitude, bounds.northeast.longitude
),
minPower = minPower,
plugs = connectors,
operators = operators,
statusType = if (excludeFaults == true) noFaultStatuses.joinToString(",") else null
)
if (!response.isSuccessful) {
return Resource.error(response.message(), null)
}
var result = postprocessResult(
response.body()!!,
minPower,
connectorsVal,
minConnectors,
referenceData,
zoom
)
return Resource.success(result)
var result = postprocessResult(
response.body()!!,
minPower,
connectorsVal,
minConnectors,
referenceData,
zoom
)
return Resource.success(result)
} catch (e: IOException) {
return Resource.error(e.message, null)
}
}
override suspend fun getChargepointsRadius(
@@ -189,27 +194,31 @@ class OpenChargeMapApiWrapper(
}
val operators = formatMultipleChoice(operatorsVal)
val response = api.getChargepointsRadius(
location.latitude, location.longitude,
radius.toDouble(),
minPower = minPower,
plugs = connectors,
operators = operators,
statusType = if (excludeFaults == true) noFaultStatuses.joinToString(",") else null
)
if (!response.isSuccessful) {
return Resource.error(response.message(), null)
}
try {
val response = api.getChargepointsRadius(
location.latitude, location.longitude,
radius.toDouble(),
minPower = minPower,
plugs = connectors,
operators = operators,
statusType = if (excludeFaults == true) noFaultStatuses.joinToString(",") else null
)
if (!response.isSuccessful) {
return Resource.error(response.message(), null)
}
val result = postprocessResult(
response.body()!!,
minPower,
connectorsVal,
minConnectors,
referenceData,
zoom
)
return Resource.success(result)
val result = postprocessResult(
response.body()!!,
minPower,
connectorsVal,
minConnectors,
referenceData,
zoom
)
return Resource.success(result)
} catch (e: IOException) {
return Resource.error(e.message, null)
}
}
private fun postprocessResult(
@@ -244,20 +253,28 @@ class OpenChargeMapApiWrapper(
id: Long
): Resource<ChargeLocation> {
val referenceData = referenceData as OCMReferenceData
val response = api.getChargepointDetail(id)
if (response.isSuccessful) {
return Resource.success(response.body()!![0].convert(referenceData))
} else {
return Resource.error(response.message(), null)
try {
val response = api.getChargepointDetail(id)
if (response.isSuccessful && response.body()?.size == 1) {
return Resource.success(response.body()!![0].convert(referenceData))
} else {
return Resource.error(response.message(), null)
}
} catch (e: IOException) {
return Resource.error(e.message, null)
}
}
override suspend fun getReferenceData(): Resource<OCMReferenceData> {
val response = api.getReferenceData()
if (response.isSuccessful) {
return Resource.success(response.body()!!)
} else {
return Resource.error(response.message(), null)
try {
val response = api.getReferenceData()
if (response.isSuccessful) {
return Resource.success(response.body()!!)
} else {
return Resource.error(response.message(), null)
}
} catch (e: IOException) {
return Resource.error(e.message, null)
}
}

View File

@@ -46,6 +46,7 @@ data class OCMChargepoint(
) {
fun convert(refData: OCMReferenceData) = ChargeLocation(
id,
"openchargemap",
addressInfo.title,
Coordinate(addressInfo.latitude, addressInfo.longitude),
addressInfo.toAddress(refData),
@@ -77,7 +78,7 @@ data class OCMChargepoint(
val comment = userComments.filter { it.commentTypeId == faultReportCommentType }
.maxByOrNull { it.dateCreated }
if (comment != null) {
return FaultReport(comment.dateCreated.toInstant(), comment.comment)
return FaultReport(comment.dateCreated.toInstant(), comment.comment ?: "")
}
}
if (statusType != null && statusType.id in faultStatuses) {
@@ -229,7 +230,7 @@ data class OCMMediaItem(
data class OCMUserComment(
@Json(name = "ID") val id: Long,
@Json(name = "CommentTypeID") val commentTypeId: Long,
@Json(name = "Comment") val comment: String,
@Json(name = "Comment") val comment: String?,
@Json(name = "UserName") val userName: String,
@Json(name = "DateCreated") val dateCreated: ZonedDateTime
)
@@ -257,4 +258,4 @@ class OCMChargerPhotoAdapter(
else -> largeUrl
}
}
}
}

View File

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

View File

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

View File

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

View File

@@ -6,7 +6,6 @@ import android.view.LayoutInflater
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import androidx.appcompat.widget.Toolbar
import androidx.databinding.DataBindingUtil
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.viewModels
@@ -85,14 +84,6 @@ class ChargepriceFragment : DialogFragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val toolbar = view.findViewById(R.id.toolbar) as Toolbar
val navController = findNavController()
toolbar.setupWithNavController(
navController,
(requireActivity() as MapsActivity).appBarConfiguration
)
val charger = requireArguments().getParcelable<ChargeLocation>(ARG_CHARGER)!!
val dataSource = requireArguments().getString(ARG_DATASOURCE)!!
vm.charger.value = charger
@@ -161,11 +152,11 @@ class ChargepriceFragment : DialogFragment() {
}
binding.imgChargepriceLogo.setOnClickListener {
(requireActivity() as MapsActivity).openUrl("https://www.chargeprice.app/?poi_id=${charger.id}&poi_source=going_electric")
(requireActivity() as MapsActivity).openUrl("https://www.chargeprice.app/?poi_id=${charger.id}&poi_source=${dataSource}")
}
binding.btnSettings.setOnClickListener {
navController.navigate(R.id.action_chargeprice_to_settingsFragment)
findNavController().navigate(R.id.action_chargeprice_to_settingsFragment)
}
binding.batteryRange.setLabelFormatter { value: Float ->
@@ -217,6 +208,14 @@ class ChargepriceFragment : DialogFragment() {
}
}
override fun onResume() {
super.onResume()
binding.toolbar.setupWithNavController(
findNavController(),
(requireActivity() as MapsActivity).appBarConfiguration
)
}
companion object {
const val ARG_CHARGER = "charger"
const val ARG_DATASOURCE = "datasource"

View File

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

View File

@@ -2,11 +2,13 @@ package net.vonforst.evmap.fragment
import android.Manifest
import android.content.pm.PackageManager
import android.graphics.Canvas
import android.os.Bundle
import android.view.Gravity
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.appcompat.widget.Toolbar
import android.widget.FrameLayout
import androidx.core.content.ContextCompat
import androidx.databinding.DataBindingUtil
import androidx.fragment.app.Fragment
@@ -14,20 +16,29 @@ import androidx.fragment.app.viewModels
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.car2go.maps.model.LatLng
import com.google.android.material.snackbar.Snackbar
import com.mapzen.android.lost.api.LocationServices
import com.mapzen.android.lost.api.LostApiClient
import net.vonforst.evmap.MapsActivity
import net.vonforst.evmap.R
import net.vonforst.evmap.adapter.DataBindingAdapter
import net.vonforst.evmap.adapter.FavoritesAdapter
import net.vonforst.evmap.databinding.FragmentFavoritesBinding
import net.vonforst.evmap.databinding.ItemFavoriteBinding
import net.vonforst.evmap.model.ChargeLocation
import net.vonforst.evmap.viewmodel.FavoritesViewModel
import net.vonforst.evmap.viewmodel.viewModelFactory
class FavoritesFragment : Fragment(), LostApiClient.ConnectionCallbacks {
private lateinit var binding: FragmentFavoritesBinding
private lateinit var locationClient: LostApiClient
private var toDelete: ChargeLocation? = null
private var deleteSnackbar: Snackbar? = null
private lateinit var adapter: FavoritesAdapter
private val vm: FavoritesViewModel by viewModels(factoryProducer = {
viewModelFactory {
@@ -58,21 +69,19 @@ class FavoritesFragment : Fragment(), LostApiClient.ConnectionCallbacks {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val toolbar = view.findViewById(R.id.toolbar) as Toolbar
val navController = findNavController()
toolbar.setupWithNavController(
navController,
(requireActivity() as MapsActivity).appBarConfiguration
)
val favAdapter = FavoritesAdapter(vm).apply {
adapter = FavoritesAdapter(onDelete = {
delete(it.charger)
}).apply {
onClickListener = {
navController.navigate(R.id.action_favs_to_map, MapFragment.showCharger(it.charger))
findNavController().navigate(
R.id.action_favs_to_map,
MapFragment.showCharger(it.charger)
)
}
}
binding.favsList.apply {
adapter = favAdapter
adapter = this@FavoritesFragment.adapter
layoutManager =
LinearLayoutManager(context, LinearLayoutManager.VERTICAL, false)
addItemDecoration(
@@ -81,6 +90,7 @@ class FavoritesFragment : Fragment(), LostApiClient.ConnectionCallbacks {
)
)
}
createTouchHelper().attachToRecyclerView(binding.favsList)
locationClient.connect()
}
@@ -109,4 +119,144 @@ class FavoritesFragment : Fragment(), LostApiClient.ConnectionCallbacks {
locationClient.disconnect()
}
}
fun delete(fav: ChargeLocation) {
val position = vm.listData.value?.indexOfFirst { it.charger == fav } ?: return
// if there is already a profile to delete, delete it now
actuallyDelete()
deleteSnackbar?.dismiss()
toDelete = fav
view?.let {
val snackbar = Snackbar.make(
it,
getString(R.string.deleted_filterprofile, fav.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.deleteFavorite(it) }
toDelete = null
}
private fun createTouchHelper(): ItemTouchHelper {
return ItemTouchHelper(object : ItemTouchHelper.SimpleCallback(
0,
ItemTouchHelper.RIGHT or ItemTouchHelper.LEFT
) {
override fun onMove(
recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder,
target: RecyclerView.ViewHolder
): Boolean {
return false
}
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
val fav = vm.favorites.value?.find { it.id == viewHolder.itemId }
fav?.let { delete(it) }
}
override fun onSelectedChanged(viewHolder: RecyclerView.ViewHolder?, actionState: Int) {
if (viewHolder != null && actionState == ItemTouchHelper.ACTION_STATE_SWIPE) {
val binding =
(viewHolder as DataBindingAdapter.ViewHolder<*>).binding as ItemFavoriteBinding
getDefaultUIUtil().onSelected(binding.foreground)
} else {
super.onSelectedChanged(viewHolder, actionState)
}
}
override fun onChildDrawOver(
c: Canvas, recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder, dX: Float, dY: Float,
actionState: Int, isCurrentlyActive: Boolean
) {
if (actionState == ItemTouchHelper.ACTION_STATE_SWIPE) {
val binding =
(viewHolder as DataBindingAdapter.ViewHolder<*>).binding as ItemFavoriteBinding
getDefaultUIUtil().onDrawOver(
c, recyclerView, binding.foreground, dX, dY,
actionState, isCurrentlyActive
)
val lp = (binding.deleteIcon.layoutParams as FrameLayout.LayoutParams)
lp.gravity = Gravity.CENTER_VERTICAL or if (dX > 0) {
Gravity.START
} else {
Gravity.END
}
binding.deleteIcon.layoutParams = lp
} else {
super.onChildDrawOver(
c,
recyclerView,
viewHolder,
dX,
dY,
actionState,
isCurrentlyActive
)
}
}
override fun clearView(
recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder
) {
val binding =
(viewHolder as DataBindingAdapter.ViewHolder<*>).binding as ItemFavoriteBinding
getDefaultUIUtil().clearView(binding.foreground)
}
override fun onChildDraw(
c: Canvas, recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder, dX: Float, dY: Float,
actionState: Int, isCurrentlyActive: Boolean
) {
if (actionState == ItemTouchHelper.ACTION_STATE_SWIPE) {
val binding =
(viewHolder as DataBindingAdapter.ViewHolder<*>).binding as ItemFavoriteBinding
getDefaultUIUtil().onDraw(
c, recyclerView, binding.foreground, dX, dY,
actionState, isCurrentlyActive
)
} else {
super.onChildDraw(
c,
recyclerView,
viewHolder,
dX,
dY,
actionState,
isCurrentlyActive
)
}
}
})
}
override fun onResume() {
super.onResume()
binding.toolbar.setupWithNavController(
findNavController(),
(requireActivity() as MapsActivity).appBarConfiguration
)
}
}

View File

@@ -3,7 +3,6 @@ package net.vonforst.evmap.fragment
import android.os.Bundle
import android.view.*
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.widget.Toolbar
import androidx.databinding.DataBindingUtil
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
@@ -42,14 +41,13 @@ class FilterFragment : Fragment() {
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val toolbar = view.findViewById(R.id.toolbar) as Toolbar
(requireActivity() as AppCompatActivity).setSupportActionBar(toolbar)
(requireActivity() as AppCompatActivity).setSupportActionBar(binding.toolbar)
val navController = findNavController()
toolbar.setupWithNavController(
navController,
(requireActivity() as MapsActivity).appBarConfiguration
)
vm.filterProfile.observe(viewLifecycleOwner) {
if (it != null) {
binding.toolbar.title = "${getString(R.string.menu_filter)}: ${it.name}"
}
}
binding.filtersList.apply {
adapter = FiltersAdapter()
@@ -62,7 +60,7 @@ class FilterFragment : Fragment() {
)
}
toolbar.setNavigationOnClickListener {
binding.toolbar.setNavigationOnClickListener {
findNavController().popBackStack()
}
}
@@ -104,4 +102,12 @@ class FilterFragment : Fragment() {
else -> super.onOptionsItemSelected(item)
}
}
override fun onResume() {
super.onResume()
binding.toolbar.setupWithNavController(
findNavController(),
(requireActivity() as MapsActivity).appBarConfiguration
)
}
}

View File

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

View File

@@ -1,136 +0,0 @@
package net.vonforst.evmap.fragment
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.activity.OnBackPressedCallback
import androidx.core.app.SharedElementCallback
import androidx.databinding.DataBindingUtil
import androidx.fragment.app.Fragment
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
import net.vonforst.evmap.databinding.FragmentGalleryBinding
import net.vonforst.evmap.model.ChargerPhoto
import net.vonforst.evmap.viewmodel.GalleryViewModel
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,
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 startingPosition: Int = 0
private var currentPosition: Int = 0
private lateinit var galleryAdapter: GalleryAdapter
private var currentPage: TouchImageView? = null
private val galleryVm: GalleryViewModel by activityViewModels()
private val backPressedCallback = object :
OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
val image = currentPage
if (image != null && image.currentZoom !in 0.95f..1.05f) {
image.setZoomAnimated(1f, 0.5f, 0.5f)
} else {
galleryVm.galleryPosition.value = currentPosition
findNavController().popBackStack()
}
}
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
binding = DataBindingUtil.inflate(
inflater,
R.layout.fragment_gallery, container, false
)
binding.lifecycleOwner = this
val args = requireArguments()
startingPosition = args.getInt(EXTRA_POSITION, 0)
currentPosition =
savedInstanceState?.getInt(SAVED_CURRENT_PAGE_POSITION) ?: startingPosition
galleryAdapter =
GalleryAdapter(
requireContext(), detailView = true, pageToLoad = currentPosition,
imageCacheKey = args.getParcelable(EXTRA_IMAGE_CACHE_KEY)
) {
startPostponedEnterTransition()
}
binding.gallery.setPageTransformer { page, _ ->
val v = page as TouchImageView
currentPage = v
}
binding.gallery.adapter = galleryAdapter
binding.photos = args.getParcelableArrayList(EXTRA_PHOTOS)
binding.gallery.post {
binding.gallery.setCurrentItem(currentPosition, false)
binding.gallery.registerOnPageChangeCallback(object :
ViewPager2.OnPageChangeCallback() {
override fun onPageSelected(position: Int) {
currentPosition = position
}
})
}
sharedElementEnterTransition = TransitionInflater.from(context)
.inflateTransition(R.transition.image_shared_element_transition)
sharedElementReturnTransition = TransitionInflater.from(context)
.inflateTransition(R.transition.image_shared_element_transition)
setEnterSharedElementCallback(enterElementCallback)
if (savedInstanceState == null) {
postponeEnterTransition();
}
requireActivity().onBackPressedDispatcher.addCallback(
viewLifecycleOwner,
backPressedCallback
)
return binding.root
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
outState.putInt(SAVED_CURRENT_PAGE_POSITION, currentPosition)
}
private val enterElementCallback: SharedElementCallback = object : SharedElementCallback() {
override fun onMapSharedElements(
names: MutableList<String>,
sharedElements: MutableMap<String, View>
) {
val currentPage = currentPage ?: return
sharedElements[names[0]] = currentPage
}
}
}

View File

@@ -2,16 +2,18 @@ package net.vonforst.evmap.fragment
import android.Manifest.permission.ACCESS_FINE_LOCATION
import android.annotation.SuppressLint
import android.app.Activity
import android.content.Intent
import android.content.Context
import android.content.pm.PackageManager
import android.content.res.Configuration
import android.graphics.Color
import android.location.Geocoder
import android.location.Location
import android.os.Bundle
import android.os.Handler
import android.text.method.KeyListener
import android.view.*
import android.view.inputmethod.InputMethodManager
import android.widget.AdapterView
import android.widget.ImageView
import android.widget.TextView
import android.widget.Toast
import androidx.activity.OnBackPressedCallback
@@ -19,10 +21,8 @@ import androidx.annotation.RequiresPermission
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.widget.PopupMenu
import androidx.core.app.ActivityCompat
import androidx.core.app.SharedElementCallback
import androidx.core.content.ContextCompat
import androidx.core.view.MenuCompat
import androidx.core.view.updateLayoutParams
import androidx.core.view.*
import androidx.databinding.DataBindingUtil
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
@@ -31,14 +31,16 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.Observer
import androidx.lifecycle.lifecycleScope
import androidx.navigation.findNavController
import androidx.navigation.fragment.FragmentNavigatorExtras
import androidx.navigation.fragment.findNavController
import androidx.navigation.ui.setupWithNavController
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.transition.TransitionInflater
import androidx.transition.TransitionManager
import coil.load
import coil.memory.MemoryCache
import coil.size.OriginalSize
import coil.size.SizeResolver
import com.car2go.maps.AnyMap
import com.car2go.maps.MapFragment
import com.car2go.maps.OnMapReadyCallback
@@ -57,6 +59,7 @@ import com.mapzen.android.lost.api.LocationListener
import com.mapzen.android.lost.api.LocationRequest
import com.mapzen.android.lost.api.LocationServices
import com.mapzen.android.lost.api.LostApiClient
import com.stfalcon.imageviewer.StfalconImageViewer
import io.michaelrocks.bimap.HashBiMap
import io.michaelrocks.bimap.MutableBiMap
import kotlinx.coroutines.Dispatchers
@@ -66,9 +69,10 @@ import net.vonforst.evmap.*
import net.vonforst.evmap.adapter.ConnectorAdapter
import net.vonforst.evmap.adapter.DetailsAdapter
import net.vonforst.evmap.adapter.GalleryAdapter
import net.vonforst.evmap.adapter.PlaceAutocompleteAdapter
import net.vonforst.evmap.api.goingelectric.GoingElectricApiWrapper
import net.vonforst.evmap.autocomplete.handleAutocompleteResult
import net.vonforst.evmap.autocomplete.launchAutocomplete
import net.vonforst.evmap.autocomplete.ApiUnavailableException
import net.vonforst.evmap.autocomplete.PlaceWithBounds
import net.vonforst.evmap.databinding.FragmentMapBinding
import net.vonforst.evmap.model.*
import net.vonforst.evmap.storage.PreferenceDataSource
@@ -79,9 +83,9 @@ import net.vonforst.evmap.ui.getMarkerTint
import net.vonforst.evmap.utils.boundingBox
import net.vonforst.evmap.utils.distanceBetween
import net.vonforst.evmap.viewmodel.*
import java.io.IOException
const val REQUEST_AUTOCOMPLETE = 2
const val ARG_CHARGER_ID = "chargerId"
const val ARG_LAT = "lat"
const val ARG_LON = "lon"
@@ -98,6 +102,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
private var requestingLocationUpdates = false
private lateinit var bottomSheetBehavior: BottomSheetBehaviorGoogleMapsLike<View>
private lateinit var detailAppBarBehavior: MergedAppBarLayoutBehavior
private lateinit var prefs: PreferenceDataSource
private var markers: MutableBiMap<Marker, ChargeLocation> = HashBiMap()
private var clusterMarkers: List<Marker> = emptyList()
private var searchResultMarker: Marker? = null
@@ -117,6 +122,10 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
return
}
if (binding.search.hasFocus()) {
removeSearchFocus()
}
val state = bottomSheetBehavior.state
if (state != STATE_COLLAPSED && state != STATE_HIDDEN) {
bottomSheetBehavior.state = STATE_COLLAPSED
@@ -131,6 +140,8 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
prefs = PreferenceDataSource(requireContext())
locationClient = LostApiClient.Builder(requireContext())
.addConnectionCallbacks(this)
.build()
@@ -147,7 +158,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
binding.lifecycleOwner = this
binding.vm = vm
val provider = PreferenceDataSource(requireContext()).mapProvider
val provider = prefs.mapProvider
if (mapFragment == null || mapFragment!!.priority[0] != provider) {
mapFragment = MapFragment()
mapFragment!!.priority = arrayOf(
@@ -173,7 +184,6 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
}
setHasOptionsMenu(true)
postponeEnterTransition()
binding.root.setOnApplyWindowInsetsListener { _, insets ->
binding.detailAppBar.toolbar.updateLayoutParams<ViewGroup.MarginLayoutParams> {
@@ -194,7 +204,6 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
insets
}
setExitSharedElementCallback(reenterSharedElementCallback)
exitTransition = TransitionInflater.from(requireContext())
.inflateTransition(R.transition.map_exit_transition)
@@ -216,11 +225,22 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
binding.detailAppBar.toolbar.menu.findItem(R.id.menu_edit).title =
getString(R.string.edit_at_datasource, vm.apiName)
binding.detailView.topPart.doOnNextLayout {
bottomSheetBehavior.peekHeight = binding.detailView.topPart.bottom
}
setupObservers()
setupClickListeners()
setupAdapters()
(activity as? MapsActivity)?.setSupportActionBar(binding.toolbar)
if (prefs.appStartCounter > 5 && !prefs.opensourceDonationsDialogShown) {
try {
findNavController().navigate(R.id.action_map_to_opensource_donations)
} catch (ignored: IllegalArgumentException) {
// when there is already another navigation going on
}
}
/*if (!prefs.update060AndroidAutoDialogShown) {
try {
navController.navigate(R.id.action_map_to_update_060_androidauto)
@@ -296,9 +316,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
binding.detailView.topPart.setOnClickListener {
bottomSheetBehavior.state = BottomSheetBehaviorGoogleMapsLike.STATE_ANCHOR_POINT
}
binding.search.setOnClickListener {
launchAutocomplete(this)
}
setupSearchAutocomplete()
binding.detailAppBar.toolbar.setNavigationOnClickListener {
bottomSheetBehavior.state = STATE_COLLAPSED
}
@@ -335,6 +353,68 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
}
}
var searchKeyListener: KeyListener? = null
@SuppressLint("SetTextI18n")
private fun setupSearchAutocomplete() {
binding.search.threshold = 1
searchKeyListener = binding.search.keyListener
binding.search.keyListener = null
val adapter = PlaceAutocompleteAdapter(requireContext(), vm.location)
binding.search.setAdapter(adapter)
binding.search.onItemClickListener =
AdapterView.OnItemClickListener { _, _, position, _ ->
val place = adapter.getItem(position) ?: return@OnItemClickListener
lifecycleScope.launch {
try {
vm.searchResult.value = adapter.currentProvider!!.getDetails(place.id)
} catch (e: ApiUnavailableException) {
e.printStackTrace()
} catch (e: IOException) {
// TODO: show error
e.printStackTrace()
}
}
removeSearchFocus()
binding.search.setText(
if (place.secondaryText.isNotEmpty()) {
"${place.primaryText}, ${place.secondaryText}"
} else {
place.primaryText.toString()
}
)
}
binding.search.onFocusChangeListener = View.OnFocusChangeListener { v, hasFocus ->
if (hasFocus) {
binding.search.keyListener = searchKeyListener
if (binding.search.text.isNotEmpty() && isVisible) {
binding.search.showDropDown()
}
} else {
binding.search.keyListener = null
}
updateBackPressedCallback()
}
binding.clearSearch.setOnClickListener {
vm.searchResult.value = null
removeSearchFocus()
}
binding.toolbar.doOnLayout {
binding.search.dropDownWidth = binding.toolbar.width
binding.search.dropDownAnchor = R.id.toolbar
}
}
private fun removeSearchFocus() {
// clear focus and hide keyboard
val imm =
requireContext().getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
imm.hideSoftInputFromWindow(binding.search.windowToken, 0)
binding.search.clearFocus()
}
private fun openLayersMenu() {
binding.fabLayers.tag = false
val materialTransform = MaterialContainerTransform().apply {
@@ -459,6 +539,8 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
.icon(searchResultIcon)
.anchor(0.5f, 1f)
)
} else {
binding.search.setText("")
}
updateBackPressedCallback()
@@ -483,6 +565,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
vm.bottomSheetState.value != null && vm.bottomSheetState.value != STATE_HIDDEN
|| vm.searchResult.value != null
|| (vm.layersMenuOpen.value ?: false)
|| binding.search.hasFocus()
}
private fun unhighlightAllMarkers() {
@@ -537,24 +620,35 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
}
private fun setupAdapters() {
var viewer: StfalconImageViewer<ChargerPhoto>? = null
val galleryClickListener = object : GalleryAdapter.ItemClickListener {
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, imageCacheKey),
null,
extras
)
viewer = StfalconImageViewer.Builder(context, photos) { imageView, photo ->
imageView.load(photo.getUrl(size = 1000)) {
if (photo == photos[position] && imageCacheKey != null) {
placeholderMemoryCacheKey(imageCacheKey)
}
size(SizeResolver(OriginalSize))
allowHardware(false)
}
}
.withTransitionFrom(view as ImageView)
.withImageChangeListener {
binding.gallery.layoutManager!!.scrollToPosition(it)
binding.gallery.layoutManager!!.findViewByPosition(it)?.let {
viewer?.updateTransitionImage(it as ImageView)
}
}
.withStartPosition(position)
.show()
}
}
val galleryPosition = galleryVm.galleryPosition.value
binding.gallery.apply {
adapter = GalleryAdapter(context, galleryClickListener, pageToLoad = galleryPosition) {
startPostponedEnterTransition()
}
adapter = GalleryAdapter(context, galleryClickListener)
itemAnimator = null
layoutManager =
LinearLayoutManager(context, LinearLayoutManager.HORIZONTAL, false)
@@ -565,41 +659,6 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
setDrawable(ContextCompat.getDrawable(context, R.drawable.gallery_divider)!!)
})
}
if (galleryPosition == null) {
startPostponedEnterTransition()
} else {
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()
}, 100)
}
binding.detailView.connectors.apply {
adapter = ConnectorAdapter()
@@ -1096,38 +1155,10 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
}
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
when (requestCode) {
REQUEST_AUTOCOMPLETE -> {
if (resultCode == Activity.RESULT_OK && data != null) {
vm.searchResult.value = handleAutocompleteResult(data)
}
}
else -> super.onActivityResult(requestCode, resultCode, data)
}
}
override fun getRootView(): View {
return binding.root
}
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
// Map the first shared element name to the child ImageView.
sharedElements[names[0]] = vh.itemView
}
}
companion object {
fun showCharger(charger: ChargeLocation): Bundle {
return Bundle().apply {

View File

@@ -11,7 +11,6 @@ import android.view.ViewGroup
import android.view.animation.DecelerateInterpolator
import androidx.fragment.app.Fragment
import androidx.navigation.fragment.findNavController
import androidx.viewpager2.adapter.FragmentStateAdapter
import androidx.viewpager2.widget.ViewPager2
import net.vonforst.evmap.R
import net.vonforst.evmap.databinding.*
@@ -20,6 +19,7 @@ import net.vonforst.evmap.storage.PreferenceDataSource
class OnboardingFragment : Fragment() {
private lateinit var binding: FragmentOnboardingBinding
private lateinit var adapter: OnboardingViewPagerAdapter
override fun onCreateView(
inflater: LayoutInflater,
@@ -28,7 +28,7 @@ class OnboardingFragment : Fragment() {
): View {
binding = FragmentOnboardingBinding.inflate(inflater)
val adapter = OnboardingViewPagerAdapter(this)
adapter = OnboardingViewPagerAdapter(this)
binding.viewPager.adapter = adapter
binding.pageIndicatorView.count = adapter.itemCount
binding.viewPager.registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() {
@@ -57,7 +57,7 @@ class OnboardingFragment : Fragment() {
}
fun goToNext() {
if (binding.viewPager.currentItem == 2) {
if (binding.viewPager.currentItem == adapter.itemCount - 1) {
findNavController().navigate(R.id.action_onboarding_to_map)
} else {
binding.viewPager.setCurrentItem(binding.viewPager.currentItem + 1, true)
@@ -65,18 +65,6 @@ class OnboardingFragment : Fragment() {
}
}
class OnboardingViewPagerAdapter(fragment: Fragment) :
FragmentStateAdapter(fragment) {
override fun getItemCount(): Int = 3
override fun createFragment(position: Int): Fragment = when (position) {
0 -> WelcomeFragment()
1 -> IconsFragment()
2 -> DataSourceSelectFragment()
else -> throw IllegalArgumentException()
}
}
abstract class OnboardingPageFragment : Fragment() {
lateinit var parent: OnboardingFragment

View File

@@ -3,6 +3,7 @@ package net.vonforst.evmap.fragment
import android.content.SharedPreferences
import android.os.Bundle
import android.view.View
import android.widget.Toast
import androidx.appcompat.widget.Toolbar
import androidx.fragment.app.viewModels
import androidx.navigation.fragment.findNavController
@@ -36,15 +37,8 @@ class SettingsFragment : PreferenceFragmentCompat(),
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val toolbar = view.findViewById(R.id.toolbar) as Toolbar
prefs = PreferenceDataSource(requireContext())
val navController = findNavController()
toolbar.setupWithNavController(
navController,
(requireActivity() as MapsActivity).appBarConfiguration
)
myVehiclePreference = findPreference("chargeprice_my_vehicle")!!
myVehiclePreference.isEnabled = false
vm.vehicles.observe(viewLifecycleOwner) { res ->
@@ -64,7 +58,7 @@ class SettingsFragment : PreferenceFragmentCompat(),
res.data?.let { tariffs ->
myTariffsPreference.entryValues = tariffs.map { it.id }.toTypedArray()
myTariffsPreference.entries = tariffs.map {
if (!it.name.startsWith(it.provider)) {
if (!it.name.lowercase().startsWith(it.provider.lowercase())) {
"${it.provider} ${it.name}"
} else {
it.name
@@ -77,13 +71,18 @@ class SettingsFragment : PreferenceFragmentCompat(),
}
private fun updateMyTariffsSummary() {
myTariffsPreference.summary = if (prefs.chargepriceMyTariffsAll) {
getString(R.string.chargeprice_all_tariffs_selected)
} else {
val n = prefs.chargepriceMyTariffs?.size ?: 0
requireContext().resources
.getQuantityString(R.plurals.chargeprice_some_tariffs_selected, n, n)
}
myTariffsPreference.summary =
if (prefs.chargepriceMyTariffsAll) {
getString(R.string.chargeprice_all_tariffs_selected)
} else {
val n = prefs.chargepriceMyTariffs?.size ?: 0
requireContext().resources
.getQuantityString(
R.plurals.chargeprice_some_tariffs_selected,
n,
n
) + "\n" + getString(R.string.pref_my_tariffs_summary)
}
}
private fun updateMyVehiclesSummary() {
@@ -124,12 +123,25 @@ class SettingsFragment : PreferenceFragmentCompat(),
"chargeprice_my_tariffs" -> {
updateMyTariffsSummary()
}
"search_provider" -> {
if (prefs.searchProvider == "google") {
Toast.makeText(context, R.string.pref_search_provider_info, Toast.LENGTH_LONG)
.show()
}
}
}
}
override fun onResume() {
super.onResume()
preferenceManager.sharedPreferences.registerOnSharedPreferenceChangeListener(this)
val navController = findNavController()
val toolbar = requireView().findViewById(R.id.toolbar) as Toolbar
toolbar.setupWithNavController(
navController,
(requireActivity() as MapsActivity).appBarConfiguration
)
}
override fun onPause() {

View File

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

View File

@@ -24,10 +24,11 @@ import kotlin.math.floor
sealed class ChargepointListItem
@Entity
@Entity(primaryKeys = ["id", "dataSource"])
@Parcelize
data class ChargeLocation(
@PrimaryKey val id: Long,
val id: Long,
val dataSource: String,
val name: String,
@Embedded val coordinates: Coordinate,
@Embedded val address: Address,

View File

@@ -48,6 +48,8 @@ sealed class FilterValue : BaseObservable(), Equatable {
abstract val key: String
var dataSource: String = ""
var profile: Long = FILTERS_CUSTOM
abstract fun hasSameValueAs(other: FilterValue): Boolean
}
@Entity(
@@ -62,7 +64,11 @@ sealed class FilterValue : BaseObservable(), Equatable {
data class BooleanFilterValue(
override val key: String,
var value: Boolean
) : FilterValue()
) : FilterValue() {
override fun hasSameValueAs(other: FilterValue): Boolean {
return other is BooleanFilterValue && other.value == this.value
}
}
@Entity(
foreignKeys = [ForeignKey(
@@ -78,23 +84,14 @@ data class MultipleChoiceFilterValue(
var values: MutableSet<String>,
var all: Boolean
) : FilterValue() {
override fun equals(other: Any?): Boolean {
if (other == null || other !is MultipleChoiceFilterValue) return false
if (key != other.key) return false
return if (all) {
other.all
override fun hasSameValueAs(other: FilterValue): Boolean {
return other is MultipleChoiceFilterValue && if (other.all) {
this.all
} else {
!other.all && values == other.values
!this.all && other.values == this.values
}
}
override fun hashCode(): Int {
var result = key.hashCode()
result = 31 * result + all.hashCode()
result = 31 * result + if (all) 0 else values.hashCode()
return result
}
}
@Entity(
@@ -109,7 +106,11 @@ data class MultipleChoiceFilterValue(
data class SliderFilterValue(
override val key: String,
var value: Int
) : FilterValue()
) : FilterValue() {
override fun hasSameValueAs(other: FilterValue): Boolean {
return other is SliderFilterValue && other.value == this.value
}
}
data class FilterWithValue<T : FilterValue>(val filter: Filter<T>, val value: T) : Equatable

View File

@@ -13,11 +13,12 @@ import androidx.navigation.NavDestination
import androidx.navigation.NavOptions
import androidx.navigation.Navigator
import net.vonforst.evmap.R
import net.vonforst.evmap.storage.PreferenceDataSource
@Navigator.Name("chrome")
class ChromeCustomTabsNavigator(
@Navigator.Name("custom")
class CustomNavigator(
private val context: Context
) : Navigator<ChromeCustomTabsNavigator.Destination>() {
) : Navigator<CustomNavigator.Destination>() {
override fun createDestination() =
Destination(this)
@@ -28,6 +29,22 @@ class ChromeCustomTabsNavigator(
navOptions: NavOptions?,
navigatorExtras: Extras?
): NavDestination? {
if (destination.destination == "report_new_charger") {
val prefs = PreferenceDataSource(context)
val url = when (prefs.dataSource) {
"goingelectric" -> "https://www.goingelectric.de/stromtankstellen/new/"
"openchargemap" -> "https://openchargemap.org/site/poi/add"
else -> throw IllegalArgumentException()
}
launchCustomTab(url)
}
if (destination.destination == "github_sponsors") {
launchCustomTab(context.getString(R.string.github_sponsors_link))
}
return null // Do not add to the back stack, managed by Chrome Custom Tabs
}
fun launchCustomTab(url: String) {
val intent = CustomTabsIntent.Builder()
.setDefaultColorSchemeParams(
CustomTabColorSchemeParams.Builder()
@@ -35,20 +52,19 @@ class ChromeCustomTabsNavigator(
.build()
)
.build()
intent.launchUrl(context, destination.url!!)
return null // Do not add to the back stack, managed by Chrome Custom Tabs
intent.launchUrl(context, Uri.parse(url))
}
override fun popBackStack() = true // Managed by Chrome Custom Tabs
@NavDestination.ClassType(Activity::class)
class Destination(navigator: Navigator<out NavDestination>) : NavDestination(navigator) {
var url: Uri? = null
lateinit var destination: String
override fun onInflate(context: Context, attrs: AttributeSet) {
super.onInflate(context, attrs)
context.withStyledAttributes(attrs, R.styleable.ChromeCustomTabsNavigator, 0, 0) {
url = Uri.parse(getString(R.styleable.ChromeCustomTabsNavigator_url))
context.withStyledAttributes(attrs, R.styleable.CustomNavigator, 0, 0) {
destination = getString(R.styleable.CustomNavigator_customDestination)!!
}
}
}

View File

@@ -7,7 +7,7 @@ class NavHostFragment : NavHostFragment() {
override fun onCreateNavController(navController: NavController) {
super.onCreateNavController(navController)
navController.navigatorProvider.addNavigator(
ChromeCustomTabsNavigator(
CustomNavigator(
requireContext()
)
)

View File

@@ -9,6 +9,9 @@ interface ChargeLocationsDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(vararg locations: ChargeLocation)
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insertBlocking(vararg locations: ChargeLocation)
@Delete
suspend fun delete(vararg locations: ChargeLocation)
@@ -17,4 +20,7 @@ interface ChargeLocationsDao {
@Query("SELECT * FROM chargelocation")
suspend fun getAllChargeLocationsAsync(): List<ChargeLocation>
@Query("SELECT * FROM chargelocation")
fun getAllChargeLocationsBlocking(): List<ChargeLocation>
}

View File

@@ -1,6 +1,8 @@
package net.vonforst.evmap.storage
import android.content.ContentValues
import android.content.Context
import android.database.sqlite.SQLiteDatabase
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
@@ -8,6 +10,7 @@ import androidx.room.TypeConverters
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
import net.vonforst.evmap.api.goingelectric.GEChargeCard
import net.vonforst.evmap.api.goingelectric.GEChargepoint
import net.vonforst.evmap.api.openchargemap.OCMConnectionType
import net.vonforst.evmap.api.openchargemap.OCMCountry
import net.vonforst.evmap.api.openchargemap.OCMOperator
@@ -26,7 +29,7 @@ import net.vonforst.evmap.model.*
OCMConnectionType::class,
OCMCountry::class,
OCMOperator::class
], version = 12
], version = 13
)
@TypeConverters(Converters::class)
abstract class AppDatabase : RoomDatabase() {
@@ -47,7 +50,7 @@ abstract class AppDatabase : RoomDatabase() {
.addMigrations(
MIGRATION_2, MIGRATION_3, MIGRATION_4, MIGRATION_5, MIGRATION_6,
MIGRATION_7, MIGRATION_8, MIGRATION_9, MIGRATION_10, MIGRATION_11,
MIGRATION_12
MIGRATION_12, MIGRATION_13
)
.addCallback(object : Callback() {
override fun onCreate(db: SupportSQLiteDatabase) {
@@ -251,5 +254,52 @@ abstract class AppDatabase : RoomDatabase() {
}
}
}
private val MIGRATION_13 = object : Migration(12, 13) {
override fun migrate(db: SupportSQLiteDatabase) {
db.beginTransaction()
try {
// add column dataSource to ChargeLocation table
db.execSQL("ALTER TABLE `ChargeLocation` ADD `dataSource` TEXT NOT NULL DEFAULT 'openchargemap'")
// this should have been included in MIGRATION_12:
// Update GoingElectric format of plug types for favorites to generic EVMap format
val cursor = db.query("SELECT * FROM `ChargeLocation`")
while (cursor.moveToNext()) {
val chargepoints =
Converters().toChargepointList(cursor.getString(cursor.getColumnIndex("chargepoints")))!!
val updated = chargepoints.map {
it.copy(type = GEChargepoint.convertTypeFromGE(it.type))
}
if (updated != chargepoints) {
db.update(
"ChargeLocation",
SQLiteDatabase.CONFLICT_ROLLBACK,
ContentValues().apply {
put("chargepoints", Converters().fromChargepointList(updated))
put("dataSource", "goingelectric")
},
"id = ?",
arrayOf(cursor.getLong(cursor.getColumnIndex("id")))
)
}
}
// update ChargeLocation table to change primary key
db.execSQL(
"CREATE TABLE `ChargeLocationNew` (`id` INTEGER NOT NULL, `dataSource` TEXT NOT NULL, `name` TEXT NOT NULL, `chargepoints` TEXT NOT NULL, `network` TEXT, `url` TEXT NOT NULL, `editUrl` TEXT, `verified` INTEGER NOT NULL, `barrierFree` INTEGER, `operator` TEXT, `generalInformation` TEXT, `amenities` TEXT, `locationDescription` TEXT, `photos` TEXT, `chargecards` TEXT, `license` TEXT, `lat` REAL NOT NULL, `lng` REAL NOT NULL, `city` TEXT, `country` TEXT, `postcode` TEXT, `street` TEXT, `fault_report_created` INTEGER, `fault_report_description` TEXT, `twentyfourSeven` INTEGER, `description` TEXT, `mostart` TEXT, `moend` TEXT, `tustart` TEXT, `tuend` TEXT, `westart` TEXT, `weend` TEXT, `thstart` TEXT, `thend` TEXT, `frstart` TEXT, `frend` TEXT, `sastart` TEXT, `saend` TEXT, `sustart` TEXT, `suend` TEXT, `hostart` TEXT, `hoend` TEXT, `freecharging` INTEGER, `freeparking` INTEGER, `descriptionShort` TEXT, `descriptionLong` TEXT, `chargepricecountry` TEXT, `chargepricenetwork` TEXT, `chargepriceplugTypes` TEXT, PRIMARY KEY(`id`, `dataSource`))"
);
val columnList =
"`id`,`dataSource`,`name`,`chargepoints`,`network`,`url`,`editUrl`,`verified`,`barrierFree`,`operator`,`generalInformation`,`amenities`,`locationDescription`,`photos`,`chargecards`,`license`,`lat`,`lng`,`city`,`country`,`postcode`,`street`,`fault_report_created`,`fault_report_description`,`twentyfourSeven`,`description`,`mostart`,`moend`,`tustart`,`tuend`,`westart`,`weend`,`thstart`,`thend`,`frstart`,`frend`,`sastart`,`saend`,`sustart`,`suend`,`hostart`,`hoend`,`freecharging`,`freeparking`,`descriptionShort`,`descriptionLong`,`chargepricecountry`,`chargepricenetwork`,`chargepriceplugTypes`"
db.execSQL("INSERT INTO `ChargeLocationNew`($columnList) SELECT $columnList FROM `ChargeLocation`")
db.execSQL("DROP TABLE `ChargeLocation`")
db.execSQL("ALTER TABLE `ChargeLocationNew` RENAME TO `ChargeLocation`")
db.setTransactionSuccessful()
} finally {
db.endTransaction()
}
}
}
}
}

View File

@@ -65,7 +65,10 @@ abstract class FilterValueDao {
)
for (source in sources) {
addSource(source) {
value = sources.mapNotNull { it.value }.flatten()
val values = sources.map { it.value }
if (values.all { it != null }) {
value = values.filterNotNull().flatten()
}
}
}
}

View File

@@ -80,6 +80,12 @@ class PreferenceDataSource(val context: Context) {
context.getString(R.string.pref_map_provider_default)
)!!
val searchProvider: String
get() = sp.getString(
"search_provider",
context.getString(R.string.pref_search_provider_default)
)!!
var mapType: AnyMap.Type
get() = AnyMap.Type.valueOf(sp.getString("map_type", null) ?: AnyMap.Type.NORMAL.toString())
set(type) {
@@ -105,7 +111,12 @@ class PreferenceDataSource(val context: Context) {
}
var chargepriceMyVehicles: Set<String>
get() = sp.getStringSet("chargeprice_my_vehicle", emptySet())!!
get() = try {
sp.getStringSet("chargeprice_my_vehicle", emptySet())!!
} catch (e: ClassCastException) {
// backwards compatibility
sp.getString("chargeprice_my_vehicle", null)?.let { setOf(it) } ?: emptySet()
}
set(value) {
sp.edit().putStringSet("chargeprice_my_vehicle", value).apply()
}
@@ -156,4 +167,17 @@ class PreferenceDataSource(val context: Context) {
.putFloat("chargeprice_battery_range_max", value[1])
.apply()
}
/** App start counter, introduced with Version 1.0.0 */
var appStartCounter: Long
get() = sp.getLong("app_start_counter", 0)
set(value) {
sp.edit().putLong("app_start_counter", value).apply()
}
var opensourceDonationsDialogShown: Boolean
get() = sp.getBoolean("opensource_donations_dialog_shown", false)
set(value) {
sp.edit().putBoolean("opensource_donations_dialog_shown", value).apply()
}
}

View File

@@ -8,6 +8,7 @@ import android.view.ViewGroup.MarginLayoutParams
import android.widget.ImageView
import android.widget.TextView
import androidx.annotation.ColorInt
import androidx.appcompat.widget.TooltipCompat
import androidx.core.content.ContextCompat
import androidx.core.content.res.use
import androidx.core.text.HtmlCompat
@@ -22,6 +23,11 @@ import com.google.android.material.slider.RangeSlider
import net.vonforst.evmap.R
import net.vonforst.evmap.api.availability.ChargepointStatus
import net.vonforst.evmap.api.iconForPlugType
import net.vonforst.evmap.kmPerMile
import net.vonforst.evmap.meterPerFt
import net.vonforst.evmap.shouldUseImperialUnits
import kotlin.math.ceil
import kotlin.math.floor
import kotlin.math.roundToInt
@@ -75,16 +81,34 @@ fun invisibleUnlessAnimated(view: View, oldValue: Boolean, newValue: Boolean) {
@BindingAdapter("isFabActive")
fun isFabActive(view: FloatingActionButton, isColored: Boolean) {
val color = view.context.theme.obtainStyledAttributes(
view.imageTintList = activeTint(view.context, isColored)
}
@BindingAdapter("backgroundTintActive")
fun backgroundTintActive(view: View, isColored: Boolean) {
view.backgroundTintList = activeTint(view.context, isColored)
}
@BindingAdapter("imageTintActive")
fun imageTintActive(view: ImageView, isColored: Boolean) {
view.imageTintList = activeTint(view.context, isColored)
}
private fun activeTint(
context: Context,
isColored: Boolean
): ColorStateList {
val color = context.theme.obtainStyledAttributes(
intArrayOf(
if (isColored) {
R.attr.colorAccent
R.attr.colorPrimary
} else {
R.attr.colorControlNormal
}
)
)
view.imageTintList = ColorStateList.valueOf(color.getColor(0, 0))
val valueOf = ColorStateList.valueOf(color.getColor(0, 0))
return valueOf
}
@BindingAdapter("data")
@@ -265,6 +289,33 @@ fun currency(currency: String): String {
}
}
fun time(value: Int): String {
val h = floor(value.toDouble() / 60).toInt();
val min = ceil(value.toDouble() % 60).toInt();
return if (h == 0 && min > 0) "$min min";
else "%d:%02d h".format(h, min);
}
fun distance(meters: Number?): String? {
if (meters == null) return null
if (shouldUseImperialUnits()) {
val ft = meters.toDouble() / meterPerFt
val mi = meters.toDouble() / 1e3 / kmPerMile
return when {
ft < 1000 -> "%.0f ft".format(ft)
mi < 10 -> "%.1f mi".format(mi)
else -> "%.0f mi".format(mi)
}
} else {
val km = meters.toDouble() / 1e3
return when {
km < 1 -> "%.0f m".format(meters.toDouble())
km < 10 -> "%.1f km".format(km)
else -> "%.0f km".format(km)
}
}
}
@InverseBindingAdapter(attribute = "app:values")
fun getRangeSliderValue(slider: RangeSlider) = slider.values
@@ -302,4 +353,9 @@ fun myTariffsBackground(view: View, myTariff: Boolean) {
view.background = it.getDrawable(0)
}
}
}
@BindingAdapter("tooltipTextCompat")
fun setTooltipTextCompat(view: View, text: String) {
TooltipCompat.setTooltipText(view, text)
}

View File

@@ -92,7 +92,14 @@ class ChargepriceViewModel(application: Application, chargepriceApiKey: String)
MutableLiveData<List<Float>>().apply {
value = prefs.chargepriceBatteryRange
observeForever {
prefs.chargepriceBatteryRange = it
if (it[0] == it[1]) {
value = if (it[0] < 1.0) {
listOf(it[0], it[1] + 1)
} else {
listOf(it[0] - 1, it[1])
}
}
prefs.chargepriceBatteryRange = value!!
}
}
}
@@ -165,7 +172,7 @@ class ChargepriceViewModel(application: Application, chargepriceApiKey: String)
}
private fun getChargepricePlugType(chargepoint: Chargepoint): String {
val index = charger.value!!.chargepoints.indexOf(chargepoint)
val index = charger.value!!.chargepointsMerged.indexOf(chargepoint)
val type = charger.value!!.chargepriceData!!.plugTypes?.get(index) ?: chargepoint.type
return type
}
@@ -209,6 +216,7 @@ class ChargepriceViewModel(application: Application, chargepriceApiKey: String)
private var loadPricesJob: Job? = null
fun loadPrices() {
chargePrices.value = Resource.loading(null)
chargePriceMeta.value = Resource.loading(null)
val charger = charger.value
val car = vehicle.value
val compatibleConnectors = vehicleCompatibleConnectors.value

View File

@@ -0,0 +1,81 @@
package net.vonforst.evmap.viewmodel
import android.content.Context
import androidx.lifecycle.LiveData
import androidx.lifecycle.MediatorLiveData
import kotlinx.coroutines.CoroutineScope
import net.vonforst.evmap.api.ChargepointApi
import net.vonforst.evmap.api.StringProvider
import net.vonforst.evmap.api.goingelectric.GoingElectricApiWrapper
import net.vonforst.evmap.api.openchargemap.OpenChargeMapApiWrapper
import net.vonforst.evmap.model.*
import net.vonforst.evmap.storage.*
import kotlin.reflect.full.cast
fun ChargepointApi<ReferenceData>.getReferenceData(
scope: CoroutineScope,
ctx: Context
): LiveData<out ReferenceData> {
val db = AppDatabase.getInstance(ctx)
val prefs = PreferenceDataSource(ctx)
return when (this) {
is GoingElectricApiWrapper -> {
GEReferenceDataRepository(
this,
scope,
db.geReferenceDataDao(),
prefs
).getReferenceData()
}
is OpenChargeMapApiWrapper -> {
OCMReferenceDataRepository(
this,
scope,
db.ocmReferenceDataDao(),
prefs
).getReferenceData()
}
else -> {
throw RuntimeException("no reference data implemented")
}
}
}
fun filtersWithValue(
filters: LiveData<List<Filter<FilterValue>>>,
filterValues: LiveData<List<FilterValue>>
): MediatorLiveData<FilterValues> =
MediatorLiveData<FilterValues>().apply {
listOf(filters, filterValues).forEach {
addSource(it) {
val f = filters.value ?: return@addSource
val values = filterValues.value ?: return@addSource
value = f.map { filter ->
val value =
values.find { it.key == filter.key } ?: filter.defaultValue()
FilterWithValue(filter, filter.valueClass.cast(value))
}
}
}
}
fun ChargepointApi<ReferenceData>.getFilters(
referenceData: LiveData<out ReferenceData>,
stringProvider: StringProvider
) = MediatorLiveData<List<Filter<FilterValue>>>().apply {
addSource(referenceData) { data ->
value = getFilters(data, stringProvider)
}
}
fun FilterValueDao.getFilterValues(filterStatus: LiveData<Long>, dataSource: String) =
MediatorLiveData<List<FilterValue>>().apply {
var source: LiveData<List<FilterValue>>? = null
addSource(filterStatus) { status ->
source?.let { removeSource(it) }
source = getFilterValues(status, dataSource)
addSource(source!!) { result ->
value = result
}
}
}

View File

@@ -66,7 +66,7 @@ class FavoritesViewModel(application: Application, geApiKey: String) :
loc.longitude,
charger.coordinates.lat,
charger.coordinates.lng
) / 1000
)
}
})
}?.sortedBy { it.distance }

View File

@@ -5,60 +5,19 @@ import androidx.lifecycle.*
import kotlinx.coroutines.launch
import net.vonforst.evmap.api.ChargepointApi
import net.vonforst.evmap.api.createApi
import net.vonforst.evmap.api.goingelectric.GoingElectricApiWrapper
import net.vonforst.evmap.api.openchargemap.OpenChargeMapApiWrapper
import net.vonforst.evmap.api.stringProvider
import net.vonforst.evmap.model.*
import net.vonforst.evmap.storage.*
import kotlin.reflect.full.cast
import net.vonforst.evmap.storage.AppDatabase
import net.vonforst.evmap.storage.FilterProfile
import net.vonforst.evmap.storage.PreferenceDataSource
internal fun filtersWithValue(
filters: LiveData<List<Filter<FilterValue>>>,
filterValues: LiveData<List<FilterValue>>
): MediatorLiveData<FilterValues> =
MediatorLiveData<FilterValues>().apply {
listOf(filters, filterValues).forEach {
addSource(it) {
val f = filters.value ?: return@addSource
val values = filterValues.value ?: return@addSource
value = f.map { filter ->
val value =
values.find { it.key == filter.key } ?: filter.defaultValue()
FilterWithValue(filter, filter.valueClass.cast(value))
}
}
}
}
class FilterViewModel(application: Application) : AndroidViewModel(application) {
private var db = AppDatabase.getInstance(application)
private var prefs = PreferenceDataSource(application)
private var api: ChargepointApi<ReferenceData> = createApi(prefs.dataSource, application)
private val referenceData: LiveData<out ReferenceData> by lazy {
val api = api
when (api) {
is GoingElectricApiWrapper -> {
GEReferenceDataRepository(
api,
viewModelScope,
db.geReferenceDataDao(),
prefs
).getReferenceData()
}
is OpenChargeMapApiWrapper -> {
OCMReferenceDataRepository(
api,
viewModelScope,
db.ocmReferenceDataDao(),
prefs
).getReferenceData()
}
else -> {
throw RuntimeException("no reference data implemented")
}
}
}
private val referenceData = api.getReferenceData(viewModelScope, application)
private val filters = MediatorLiveData<List<Filter<FilterValue>>>().apply {
addSource(referenceData) { data ->
value = api.getFilters(data, application.stringProvider())
@@ -93,11 +52,13 @@ class FilterViewModel(application: Application) : AndroidViewModel(application)
}
suspend fun saveFilterValues() {
filtersWithValue.value?.forEach {
filtersWithValue.value?.map {
val value = it.value
value.profile = FILTERS_CUSTOM
value.dataSource = prefs.dataSource
db.filterValueDao().insert(value)
value
}?.let {
db.filterValueDao().insert(*it.toTypedArray())
}
// set selected profile
@@ -113,11 +74,13 @@ class FilterViewModel(application: Application) : AndroidViewModel(application)
}
// save filter values
filtersWithValue.value?.forEach {
filtersWithValue.value?.map {
val value = it.value
value.profile = profileId
value.dataSource = prefs.dataSource
db.filterValueDao().insert(value)
value
}?.let {
db.filterValueDao().insert(*it.toTypedArray())
}
// set selected profile

View File

@@ -5,6 +5,7 @@ import androidx.lifecycle.*
import com.car2go.maps.AnyMap
import com.car2go.maps.model.LatLng
import com.car2go.maps.model.LatLngBounds
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import net.vonforst.evmap.api.ChargepointApi
import net.vonforst.evmap.api.availability.ChargeLocationStatus
@@ -17,15 +18,16 @@ import net.vonforst.evmap.api.openchargemap.OCMConnection
import net.vonforst.evmap.api.openchargemap.OCMReferenceData
import net.vonforst.evmap.api.openchargemap.OpenChargeMapApiWrapper
import net.vonforst.evmap.api.stringProvider
import net.vonforst.evmap.autocomplete.PlaceWithBounds
import net.vonforst.evmap.model.*
import net.vonforst.evmap.storage.*
import net.vonforst.evmap.storage.AppDatabase
import net.vonforst.evmap.storage.FilterProfile
import net.vonforst.evmap.storage.PreferenceDataSource
import net.vonforst.evmap.utils.distanceBetween
import java.io.IOException
data class MapPosition(val bounds: LatLngBounds, val zoom: Float)
data class PlaceWithBounds(val latLng: LatLng, val viewport: LatLngBounds?)
internal fun getClusterDistance(zoom: Float): Int? {
return when (zoom) {
in 0.0..7.0 -> 100
@@ -53,48 +55,19 @@ class MapViewModel(application: Application) : AndroidViewModel(application) {
val mapPosition: MutableLiveData<MapPosition> by lazy {
MutableLiveData<MapPosition>()
}
private val filterValues: LiveData<List<FilterValue>> by lazy {
MediatorLiveData<List<FilterValue>>().apply {
var source: LiveData<List<FilterValue>>? = null
addSource(filterStatus) { status ->
source?.let { removeSource(it) }
source = db.filterValueDao().getFilterValues(status, prefs.dataSource)
addSource(source!!) { result ->
value = result
}
val filterStatus: MutableLiveData<Long> by lazy {
MutableLiveData<Long>().apply {
value = prefs.filterStatus
observeForever {
prefs.filterStatus = it
if (it != FILTERS_DISABLED) prefs.lastFilterProfile = it
}
}
}
private val referenceData: LiveData<out ReferenceData> by lazy {
val api = api
when (api) {
is GoingElectricApiWrapper -> {
GEReferenceDataRepository(
api,
viewModelScope,
db.geReferenceDataDao(),
prefs
).getReferenceData()
}
is OpenChargeMapApiWrapper -> {
OCMReferenceDataRepository(
api,
viewModelScope,
db.ocmReferenceDataDao(),
prefs
).getReferenceData()
}
else -> {
throw RuntimeException("no reference data implemented")
}
}
}
private val filters = MediatorLiveData<List<Filter<FilterValue>>>().apply {
addSource(referenceData) { data ->
val api = api
value = api.getFilters(data, application.stringProvider())
}
}
private val filterValues: LiveData<List<FilterValue>> =
db.filterValueDao().getFilterValues(filterStatus, prefs.dataSource)
private val referenceData = api.getReferenceData(viewModelScope, application)
private val filters = api.getFilters(referenceData, application.stringProvider())
private val filtersWithValue: LiveData<FilterValues> by lazy {
filtersWithValue(filters, filterValues)
@@ -124,7 +97,7 @@ class MapViewModel(application: Application) : AndroidViewModel(application) {
value = 0
addSource(filtersWithValue) { filtersWithValue ->
value = filtersWithValue.count {
it.filter.defaultValue() != it.value
!it.value.hasSameValueAs(it.filter.defaultValue())
}
}
}
@@ -143,6 +116,9 @@ class MapViewModel(application: Application) : AndroidViewModel(application) {
val filteredConnectors: MutableLiveData<Set<String>> by lazy {
MutableLiveData<Set<String>>()
}
val filteredMinPower: MutableLiveData<Int> by lazy {
MutableLiveData<Int>()
}
val filteredChargeCards: MutableLiveData<Set<Long>> by lazy {
MutableLiveData<Set<Long>>()
}
@@ -188,7 +164,7 @@ class MapViewModel(application: Application) : AndroidViewModel(application) {
loc.longitude,
charger.coordinates.lat,
charger.coordinates.lng
) / 1000
)
} else null
}
addSource(chargerSparse, callback)
@@ -217,13 +193,19 @@ class MapViewModel(application: Application) : AndroidViewModel(application) {
val av = availability.value
val filters = filtersWithValue.value
if (av?.status == Status.SUCCESS && filters != null) {
value = Resource.success(av.data!!.applyFilters(filters))
value = Resource.success(
av.data!!.applyFilters(
filteredConnectors.value,
filteredMinPower.value
)
)
} else {
value = av
}
}
addSource(availability, callback)
addSource(filtersWithValue, callback)
addSource(filteredConnectors, callback)
addSource(filteredMinPower, callback)
}
}
val myLocationEnabled: MutableLiveData<Boolean> by lazy {
@@ -261,16 +243,6 @@ class MapViewModel(application: Application) : AndroidViewModel(application) {
}
}
val filterStatus: MutableLiveData<Long> by lazy {
MutableLiveData<Long>().apply {
value = prefs.filterStatus
observeForever {
prefs.filterStatus = it
if (it != FILTERS_DISABLED) prefs.lastFilterProfile = it
}
}
}
fun reloadPrefs() {
filterStatus.value = prefs.filterStatus
}
@@ -287,9 +259,11 @@ class MapViewModel(application: Application) : AndroidViewModel(application) {
if (filterStatus.value == FILTERS_CUSTOM) return
db.filterValueDao().deleteFilterValuesForProfile(FILTERS_CUSTOM, prefs.dataSource)
filterValues.value?.forEach {
filterValues.value?.map {
it.profile = FILTERS_CUSTOM
db.filterValueDao().insert(it)
it
}?.let {
db.filterValueDao().insert(*it.toTypedArray())
}
}
@@ -323,6 +297,7 @@ class MapViewModel(application: Application) : AndroidViewModel(application) {
) { data: Triple<MapPosition, FilterValues, ReferenceData> ->
chargepoints.value = Resource.loading(chargepoints.value?.data)
filteredConnectors.value = null
filteredMinPower.value = null
filteredChargeCards.value = null
val mapPosition = data.first
@@ -347,6 +322,7 @@ class MapViewModel(application: Application) : AndroidViewModel(application) {
if (connectorsVal.all) null else connectorsVal.values.map {
GEChargepoint.convertTypeFromGE(it)
}.toSet()
filteredMinPower.value = filters.getSliderValue("minPower")
} else if (api is OpenChargeMapApiWrapper) {
val connectorsVal = filters.getMultipleChoiceValue("connectors")!!
filteredConnectors.value =
@@ -356,6 +332,7 @@ class MapViewModel(application: Application) : AndroidViewModel(application) {
refData as OCMReferenceData
)
}.toSet()
filteredMinPower.value = filters.getSliderValue("minPower")
}
}
@@ -364,9 +341,12 @@ class MapViewModel(application: Application) : AndroidViewModel(application) {
availability.value = getAvailability(charger)
}
private var chargerLoadingTask: Job? = null
private fun loadChargerDetails(charger: ChargeLocation, referenceData: ReferenceData) {
chargerDetails.value = Resource.loading(null)
viewModelScope.launch {
chargerLoadingTask?.cancel()
chargerLoadingTask = viewModelScope.launch {
try {
chargerDetails.value = api.getChargepointDetail(referenceData, charger.id)
} catch (e: IOException) {

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 158 KiB

View File

@@ -0,0 +1,11 @@
<?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="#33FFFFFF" />
<size
android:height="24dp"
android:width="24dp" />
</shape>
</item>
</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="M22,16v-2l-8.5,-5V3.5C13.5,2.67 12.83,2 12,2s-1.5,0.67 -1.5,1.5V9L2,14v2l8.5,-2.5V19L8,20.5L8,22l4,-1l4,1l0,-1.5L13.5,19v-5.5L22,16z" />
</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="M12,2C8.13,2 5,5.13 5,9c0,5.25 7,13 7,13s7,-7.75 7,-13c0,-3.87 -3.13,-7 -7,-7zM12,11.5c-1.38,0 -2.5,-1.12 -2.5,-2.5s1.12,-2.5 2.5,-2.5 2.5,1.12 2.5,2.5 -1.12,2.5 -2.5,2.5z" />
</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="M12,2c-4,0 -8,0.5 -8,4v9.5C4,17.43 5.57,19 7.5,19L6,20.5v0.5h2.23l2,-2L14,19l2,2h2v-0.5L16.5,19c1.93,0 3.5,-1.57 3.5,-3.5L20,6c0,-3.5 -3.58,-4 -8,-4zM7.5,17c-0.83,0 -1.5,-0.67 -1.5,-1.5S6.67,14 7.5,14s1.5,0.67 1.5,1.5S8.33,17 7.5,17zM11,10L6,10L6,6h5v4zM13,10L13,6h5v4h-5zM16.5,17c-0.83,0 -1.5,-0.67 -1.5,-1.5s0.67,-1.5 1.5,-1.5 1.5,0.67 1.5,1.5 -0.67,1.5 -1.5,1.5z" />
</vector>

View File

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

View File

@@ -25,6 +25,8 @@
<import type="net.vonforst.evmap.api.ChargepointApiKt" />
<import type="net.vonforst.evmap.api.chargeprice.ChargepriceApi" />
<variable
name="charger"
type="Resource&lt;ChargeLocation&gt;" />
@@ -112,9 +114,9 @@
android:gravity="end"
android:maxLines="1"
android:minWidth="50dp"
android:text="@{@string/distance_format(distance)}"
android:text="@{BindingAdaptersKt.distance(distance)}"
android:textAppearance="@style/TextAppearance.MaterialComponents.Caption"
app:layout_constraintBottom_toBottomOf="@+id/topPart"
app:layout_constraintBottom_toBottomOf="@+id/txtConnectors"
app:layout_constraintEnd_toStartOf="@+id/guideline2"
tools:text="10 km" />
@@ -302,6 +304,7 @@
android:layout_width="0dp"
android:layout_height="0dp"
android:text="TextView"
android:layout_marginBottom="-10dp"
app:layout_constraintBottom_toBottomOf="@+id/txtConnectors"
app:layout_constraintEnd_toStartOf="@+id/guideline2"
app:layout_constraintStart_toStartOf="@+id/guideline"
@@ -314,6 +317,7 @@
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/go_to_chargeprice"
app:goneUnless="@{charger.data != null &amp;&amp; ChargepriceApi.isCountrySupported(charger.data.chargepriceData.country, charger.data.dataSource)}"
app:icon="@drawable/ic_chargeprice"
app:layout_constraintEnd_toStartOf="@+id/guideline2"
app:layout_constraintStart_toStartOf="@+id/guideline"
@@ -327,7 +331,7 @@
android:layout_marginTop="2dp"
android:layout_marginBottom="2dp"
android:contentDescription="@string/verified"
android:tooltipText="@{@string/verified_desc(apiName)}"
app:tooltipTextCompat="@{@string/verified_desc(apiName)}"
app:goneUnless="@{ charger.data.verified }"
app:layout_constraintBottom_toBottomOf="@+id/txtName"
app:layout_constraintStart_toEndOf="@+id/imgFaultReport"
@@ -344,7 +348,7 @@
android:layout_marginTop="2dp"
android:layout_marginBottom="2dp"
android:contentDescription="@string/fault_report"
android:tooltipText="@string/fault_report"
app:tooltipTextCompat="@{@string/fault_report}"
app:goneUnless="@{ charger.data.faultReport != null }"
app:layout_constraintBottom_toBottomOf="@+id/txtName"
app:layout_constraintStart_toEndOf="@+id/txtName"

View File

@@ -6,30 +6,57 @@
<TextView
android:id="@+id/dialogTitle"
android:textAppearance="@style/TextAppearance.MaterialComponents.Headline6"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="18dp"
android:layout_marginEnd="16dp"
android:text="@string/pref_data_source"
android:textAppearance="@style/TextAppearance.MaterialComponents.Headline6"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/dataSourceDescription"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
<ScrollView
android:id="@+id/scroll"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_marginTop="8dp"
android:layout_marginEnd="16dp"
android:text="@string/data_sources_description"
android:textAppearance="@style/TextAppearance.MaterialComponents.Body2"
android:layout_marginBottom="8dp"
android:fillViewport="true"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/dialogTitle" />
app:layout_constraintTop_toBottomOf="@+id/dialogTitle"
app:layout_constraintBottom_toTopOf="@id/btnCancel">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<TextView
android:id="@+id/dataSourceDescription"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
android:text="@string/data_sources_description"
android:textAppearance="@style/TextAppearance.MaterialComponents.Body2"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" />
<include
android:id="@+id/rg_data_source"
layout="@layout/data_source_select"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="16dp"
android:layout_marginEnd="16dp" />
</LinearLayout>
</ScrollView>
<Button
android:id="@+id/btnOK"
@@ -40,9 +67,8 @@
android:layout_marginEnd="8dp"
android:layout_marginBottom="8dp"
android:text="@string/ok"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@+id/rg_data_source" />
app:layout_constraintBottom_toBottomOf="parent" />
<Button
android:id="@+id/btnCancel"
@@ -52,20 +78,6 @@
android:layout_marginTop="16dp"
android:layout_marginBottom="8dp"
android:text="@string/cancel"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/btnOK"
app:layout_constraintTop_toBottomOf="@+id/rg_data_source" />
<include
android:id="@+id/rg_data_source"
layout="@layout/data_source_select"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="16dp"
android:layout_marginEnd="16dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/dataSourceDescription" />
app:layout_constraintBottom_toBottomOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -0,0 +1,126 @@
<?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/colorPrimary"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<com.airbnb.lottie.LottieAnimationView
android:id="@+id/animation_view"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:background="@drawable/circle_bg_logo"
app:lottie_autoPlay="true"
app:lottie_rawRes="@raw/heart_anim" />
</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/donation_dialog_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/donation_dialog_detail"
android:textAppearance="@style/TextAppearance.MaterialComponents.Body2"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/welcomeTitle" />
</androidx.constraintlayout.widget.ConstraintLayout>
</ScrollView>
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/linearLayout6"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="end"
android:layout_marginLeft="16dp"
android:layout_marginRight="16dp"
android:layout_marginBottom="8dp">
<Button
android:id="@+id/btnGithubSponsors"
style="@style/Widget.MaterialComponents.Button.TextButton.Dialog"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginLeft="8dp"
android:layout_marginEnd="8dp"
android:layout_marginRight="8dp"
android:text="@string/github_sponsors"
app:layout_constraintBaseline_toBaselineOf="@+id/btnDonate"
app:layout_constraintEnd_toStartOf="@+id/btnDonate"
app:layout_constraintStart_toStartOf="parent" />
<Button
android:id="@+id/btnDonate"
style="@style/Widget.MaterialComponents.Button.TextButton.Dialog"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/donate"
app:layout_constraintBaseline_toBaselineOf="@+id/btnOk"
app:layout_constraintEnd_toStartOf="@+id/btnOk"
app:layout_constraintStart_toEndOf="@+id/btnGithubSponsors" />
<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"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/btnDonate" />
<androidx.constraintlayout.helper.widget.Flow
android:id="@+id/flow"
android:layout_width="0dp"
android:layout_height="wrap_content"
app:constraint_referenced_ids="btnGithubSponsors,btnDonate,btnOk"
app:flow_horizontalBias="1"
app:flow_wrapMode="chain"
app:flow_horizontalStyle="packed"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="1.0"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
</LinearLayout>

View File

@@ -1,93 +0,0 @@
<?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

@@ -7,6 +7,7 @@
<import type="net.vonforst.evmap.viewmodel.ChargepriceViewModel" />
<import type="net.vonforst.evmap.viewmodel.Status" />
<import type="net.vonforst.evmap.ui.BindingAdaptersKt" />
<variable
name="vm"
@@ -99,6 +100,21 @@
app:layout_constraintTop_toBottomOf="@+id/connectors_list"
tools:text="Charge from 20% to 80%" />
<TextView
android:id="@+id/textView4"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="4dp"
android:layout_marginTop="8dp"
android:text="@{@string/chargeprice_duration(BindingAdaptersKt.time((int) Math.round(vm.chargepriceMetaForChargepoint.data.duration)))}"
android:textAppearance="@style/TextAppearance.MaterialComponents.Subtitle2"
android:textColor="?colorPrimary"
app:invisibleUnlessAnimated="@{!vm.batteryRangeSliderDragging &amp;&amp; vm.chargepriceMetaForChargepoint.status == Status.SUCCESS}"
app:layout_constraintStart_toEndOf="@+id/textView2"
app:layout_constraintTop_toBottomOf="@+id/connectors_list"
tools:text="(25 min)" />
<TextView
android:id="@+id/tvVehicleHeader"
android:layout_width="wrap_content"

View File

@@ -1,22 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<data>
<import type="net.vonforst.evmap.model.ChargerPhoto" />
<import type="java.util.List" />
<variable
name="photos"
type="List&lt;ChargerPhoto&gt;" />
</data>
<androidx.viewpager2.widget.ViewPager2
android:id="@+id/gallery"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@android:color/black"
app:data="@{photos}" />
</layout>

View File

@@ -58,16 +58,47 @@
<androidx.appcompat.widget.Toolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="48dp">
android:layout_height="48dp"
app:contentInsetStartWithNavigation="70dp">
<TextView
android:id="@+id/search"
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="horizontal"
android:gravity="center_vertical"
android:text="@string/search"
android:textAppearance="@style/TextAppearance.MaterialComponents.Subtitle1"
android:textColor="?android:textColorSecondary" />
android:focusable="true"
android:focusableInTouchMode="true">
<AutoCompleteTextView
android:id="@+id/search"
android:layout_width="0dp"
android:layout_weight="1"
android:layout_height="match_parent"
android:singleLine="true"
android:scrollHorizontally="true"
android:ellipsize="end"
android:background="@null"
android:gravity="center_vertical"
android:hint="@string/search"
android:textAppearance="@style/TextAppearance.MaterialComponents.Subtitle1"
android:textColor="?android:textColorSecondary"
android:dropDownVerticalOffset="8dp"
android:popupBackground="@drawable/rounded_rect_4dp" />
<ImageButton
android:id="@+id/clearSearch"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:background="?attr/selectableItemBackgroundBorderless"
app:invisibleUnless="@{search.text.length() > 0}"
app:tint="?colorControlNormal"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/handle"
app:layout_constraintTop_toTopOf="parent"
app:srcCompat="@drawable/ic_close"
android:contentDescription="@string/delete" />
</LinearLayout>
</androidx.appcompat.widget.Toolbar>

View File

@@ -4,7 +4,8 @@
android:orientation="vertical"
android:id="@+id/rl_create_account"
android:layout_width="match_parent"
android:layout_height="match_parent">
android:layout_height="match_parent"
android:fitsSystemWindows="true">
<androidx.viewpager2.widget.ViewPager2
android:id="@+id/viewPager"

View File

@@ -1,63 +1,69 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
<ScrollView 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:layout_height="match_parent"
android:fillViewport="true">
<include
android:id="@+id/rg_data_source"
layout="@layout/data_source_select"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="24dp"
android:layout_marginEnd="24dp"
android:layout_marginBottom="56dp"
app:layout_constraintBottom_toTopOf="@+id/btnGetStarted"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.5"
app:layout_constraintStart_toStartOf="parent" />
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<TextView
android:id="@+id/welcomeTitle"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="32dp"
android:layout_marginEnd="32dp"
android:layout_marginBottom="16dp"
android:gravity="center"
android:text="@string/pref_data_source"
android:textAppearance="@style/TextAppearance.MaterialComponents.Headline6"
app:layout_constraintBottom_toTopOf="@+id/welcomeText2"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.5"
app:layout_constraintStart_toStartOf="parent" />
<include
android:id="@+id/rg_data_source"
layout="@layout/data_source_select"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="24dp"
android:layout_marginEnd="24dp"
android:layout_marginBottom="56dp"
app:layout_constraintBottom_toTopOf="@+id/btnGetStarted"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.5"
app:layout_constraintStart_toStartOf="parent" />
<TextView
android:id="@+id/welcomeText2"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="32dp"
android:layout_marginEnd="32dp"
android:layout_marginBottom="32dp"
android:gravity="center"
android:text="@string/data_sources_description"
android:textAppearance="@style/TextAppearance.MaterialComponents.Body2"
android:textColor="?android:textColorSecondary"
android:breakStrategy="balanced"
app:layout_constraintBottom_toTopOf="@+id/rg_data_source"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.5"
app:layout_constraintStart_toStartOf="parent" />
<TextView
android:id="@+id/welcomeTitle"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="32dp"
android:layout_marginEnd="32dp"
android:layout_marginBottom="16dp"
android:gravity="center"
android:text="@string/pref_data_source"
android:textAppearance="@style/TextAppearance.MaterialComponents.Headline6"
app:layout_constraintBottom_toTopOf="@+id/welcomeText2"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.5"
app:layout_constraintStart_toStartOf="parent" />
<Button
android:id="@+id/btnGetStarted"
style="@style/Widget.MaterialComponents.Button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="24dp"
android:text="@string/lets_go"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" />
<TextView
android:id="@+id/welcomeText2"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="32dp"
android:layout_marginEnd="32dp"
android:layout_marginBottom="32dp"
android:gravity="center"
android:text="@string/data_sources_description"
android:textAppearance="@style/TextAppearance.MaterialComponents.Body2"
android:textColor="?android:textColorSecondary"
android:breakStrategy="balanced"
app:layout_constraintBottom_toTopOf="@+id/rg_data_source"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.5"
app:layout_constraintStart_toStartOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
<Button
android:id="@+id/btnGetStarted"
style="@style/Widget.MaterialComponents.Button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="24dp"
android:text="@string/lets_go"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
</ScrollView>

View File

@@ -8,11 +8,13 @@
android:id="@+id/icon1"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="28dp"
app:layout_constraintBottom_toTopOf="@+id/iconLabel1"
app:layout_constraintEnd_toEndOf="@+id/iconLabel1"
app:layout_constraintHorizontal_bias="0.5"
app:layout_constraintStart_toStartOf="@+id/iconLabel1"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.7"
app:layout_constraintVertical_chainStyle="packed"
app:srcCompat="@drawable/ic_map_marker_charging"
app:tint="@color/charger_low"
@@ -26,7 +28,7 @@
android:layout_marginBottom="28dp"
android:text="&lt; 11 kW"
android:textAppearance="@style/TextAppearance.MaterialComponents.Caption"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintBottom_toTopOf="@+id/welcomeTitle"
app:layout_constraintEnd_toStartOf="@+id/iconLabel2"
app:layout_constraintHorizontal_bias="0.5"
app:layout_constraintStart_toStartOf="parent"
@@ -134,7 +136,7 @@
android:gravity="center"
android:text="@string/welcome_2_title"
android:textAppearance="@style/TextAppearance.MaterialComponents.Headline6"
app:layout_constraintBottom_toTopOf="@+id/welcomeText2"
app:layout_constraintBottom_toTopOf="@+id/welcomeText"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.5"
app:layout_constraintStart_toStartOf="parent" />
@@ -145,13 +147,30 @@
android:layout_height="wrap_content"
android:layout_marginStart="32dp"
android:layout_marginEnd="32dp"
android:layout_marginBottom="56dp"
android:layout_marginBottom="8dp"
android:gravity="center"
android:text="@string/welcome_2_detail"
android:textAppearance="@style/TextAppearance.MaterialComponents.Caption"
android:textColor="?android:textColorSecondary"
android:breakStrategy="balanced"
app:layout_constraintBottom_toTopOf="@+id/btnGetStarted"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.5"
app:layout_constraintStart_toStartOf="parent" />
<TextView
android:id="@+id/welcomeText"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="32dp"
android:layout_marginEnd="32dp"
android:layout_marginBottom="8dp"
android:breakStrategy="balanced"
android:gravity="center"
android:text="@string/welcome_2"
android:textAppearance="@style/TextAppearance.MaterialComponents.Body2"
android:textColor="?android:textColorSecondary"
android:breakStrategy="balanced"
app:layout_constraintBottom_toTopOf="@+id/btnGetStarted"
app:layout_constraintBottom_toTopOf="@+id/welcomeText2"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.5"
app:layout_constraintStart_toStartOf="parent" />

View File

@@ -6,14 +6,21 @@
<com.airbnb.lottie.LottieAnimationView
android:id="@+id/animation_view"
android:layout_width="256dp"
android:layout_height="256dp"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_marginStart="32dp"
android:layout_marginTop="28dp"
android:layout_marginEnd="32dp"
android:layout_marginBottom="28dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintBottom_toTopOf="@+id/welcomeTitle"
app:layout_constraintDimensionRatio="1"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHeight_max="256dp"
app:layout_constraintHorizontal_bias="0.5"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.85"
app:layout_constraintWidth_max="256dp"
app:lottie_autoPlay="true"
app:lottie_rawRes="@raw/logo_anim"
app:lottie_speed="0.75" />

View File

@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<com.ortiz.touchview.TouchImageView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:scaleType="fitCenter"
android:fitsSystemWindows="true"
tools:src="@tools:sample/backgrounds/scenic" />

View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<ImageView android:layout_width="match_parent"
android:layout_height="wrap_content"
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:paddingStart="24dp"
android:paddingTop="18dp"
android:paddingEnd="16dp"
android:paddingBottom="14dp"
android:scaleType="fitStart"
tools:src="@drawable/places_powered_by_google_light" />

View File

@@ -0,0 +1,91 @@
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools">
<data>
<import type="android.text.util.Linkify" />
<import type="net.vonforst.evmap.adapter.PlaceAutocompleteAdapterKt" />
<import type="net.vonforst.evmap.ui.BindingAdaptersKt" />
<variable
name="item"
type="net.vonforst.evmap.autocomplete.AutocompletePlace" />
</data>
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?selectableItemBackground">
<TextView
android:id="@+id/title"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="24dp"
android:layout_marginTop="18dp"
android:layout_marginEnd="16dp"
android:layout_marginBottom="14dp"
android:ellipsize="end"
android:singleLine="true"
android:text="@{item.primaryText}"
android:textAppearance="@style/TextAppearance.MaterialComponents.Body2"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/icon"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.0"
tools:text="Lorem ipsum" />
<ImageView
android:id="@+id/icon"
android:layout_width="30dp"
android:layout_height="30dp"
android:layout_marginStart="16dp"
android:layout_marginTop="16dp"
android:contentDescription="@{item.secondaryText}"
android:padding="6dp"
android:background="@drawable/circle_bg_autocomplete"
android:backgroundTintMode="src_in"
app:imageTintActive="@{PlaceAutocompleteAdapterKt.isSpecialPlace(item.types)}"
app:backgroundTintActive="@{PlaceAutocompleteAdapterKt.isSpecialPlace(item.types)}"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:srcCompat="@{PlaceAutocompleteAdapterKt.iconForPlaceType(item.types)}"
tools:srcCompat="@drawable/ic_address"
tools:tint="?colorControlNormal"
tools:backgroundTint="?colorControlNormal" />
<TextView
android:id="@+id/textView16"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="@{BindingAdaptersKt.distance(item.distanceMeters)}"
android:textAppearance="@style/TextAppearance.MaterialComponents.Caption"
app:goneUnless="@{item.distanceMeters != null}"
app:layout_constraintEnd_toEndOf="@+id/icon"
app:layout_constraintStart_toStartOf="@+id/icon"
app:layout_constraintTop_toBottomOf="@+id/icon"
tools:text="9999 km" />
<TextView
android:id="@+id/subtitle"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:layout_marginEnd="16dp"
android:layout_marginBottom="14dp"
android:ellipsize="end"
android:singleLine="true"
android:text="@{item.secondaryText}"
android:textAppearance="@style/TextAppearance.MaterialComponents.Caption"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="@+id/title"
app:layout_constraintTop_toBottomOf="@+id/title"
tools:text="Lorem ipsum" />
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>

View File

@@ -60,7 +60,7 @@
android:layout_marginEnd="4dp"
android:text="@{item.provider}"
android:textAppearance="@style/TextAppearance.MaterialComponents.Caption"
app:goneUnless="@{!item.tariffName.startsWith(item.provider)}"
app:goneUnless="@{!item.tariffName.toLowerCase().startsWith(item.provider.toLowerCase())}"
app:layout_constraintBottom_toTopOf="@+id/rvTags"
app:layout_constraintEnd_toStartOf="@+id/guideline5"
app:layout_constraintStart_toStartOf="@+id/txtTariff"

View File

@@ -6,8 +6,11 @@
<data>
<import type="net.vonforst.evmap.api.UtilsKt" />
<import type="net.vonforst.evmap.viewmodel.Status" />
<import type="net.vonforst.evmap.ui.BindingAdaptersKt" />
<import type="net.vonforst.evmap.api.ChargepointApiKt" />
<variable
@@ -15,21 +18,45 @@
type="net.vonforst.evmap.viewmodel.FavoritesViewModel.FavoritesListItem" />
</data>
<androidx.constraintlayout.widget.ConstraintLayout
<FrameLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="16dp"
android:background="?attr/selectableItemBackground">
android:layout_height="wrap_content">
<TextView
android:id="@+id/textView15"
android:layout_width="wrap_content"
<FrameLayout
android:id="@+id/background"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/delete_red"> <!--Add your background color here-->
<ImageView
android:id="@+id/delete_icon"
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_gravity="center_vertical|end"
android:layout_marginLeft="16dp"
android:layout_marginRight="16dp"
app:tint="@android:color/white"
app:srcCompat="@drawable/ic_delete" />
</FrameLayout>
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/foreground"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@{item.charger.name}"
android:textAppearance="@style/TextAppearance.MaterialComponents.Body1"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="Parkhaus" />
android:padding="16dp"
android:background="@drawable/selectable_opaque_background">
<TextView
android:id="@+id/textView15"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@{item.charger.name}"
android:textAppearance="@style/TextAppearance.MaterialComponents.Body1"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="Parkhaus" />
<TextView
android:id="@+id/textView2"
@@ -59,40 +86,57 @@
app:layout_constraintTop_toBottomOf="@+id/textView2"
tools:text="2x Typ 2 22 kW" />
<TextView
android:id="@+id/textView16"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:goneUnless="@{item.distance != null}"
android:text="@{@string/distance_format(item.distance)}"
android:textAppearance="@style/TextAppearance.MaterialComponents.Caption"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="9999,9 km" />
<TextView
android:id="@+id/textView16"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="16dp"
android:text="@{BindingAdaptersKt.distance(item.distance)}"
android:textAppearance="@style/TextAppearance.MaterialComponents.Caption"
app:goneUnless="@{item.distance != null}"
app:layout_constraintEnd_toStartOf="@id/btnDelete"
app:layout_constraintTop_toTopOf="parent"
tools:text="9999,9 km" />
<TextView
android:id="@+id/textView7"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@drawable/rounded_rect"
android:padding="2dp"
android:text="@{String.format(&quot;%s/%d&quot;, BindingAdaptersKt.availabilityText(item.available.data), item.total)}"
android:textAppearance="@style/TextAppearance.AppCompat.Body1"
android:textColor="@android:color/white"
app:backgroundTintAvailability="@{item.available.data}"
app:goneUnless="@{item.available.status == Status.SUCCESS}"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
tools:backgroundTint="@color/available"
tools:text="80/99" />
<TextView
android:id="@+id/textView7"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="16dp"
android:background="@drawable/rounded_rect"
android:padding="2dp"
android:text="@{String.format(&quot;%s/%d&quot;, BindingAdaptersKt.availabilityText(item.available.data), item.total)}"
android:textAppearance="@style/TextAppearance.AppCompat.Body1"
android:textColor="@android:color/white"
app:backgroundTintAvailability="@{item.available.data}"
app:goneUnless="@{item.available.status == Status.SUCCESS}"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@id/btnDelete"
tools:backgroundTint="@color/available"
tools:text="80/99" />
<ProgressBar
android:id="@+id/progressBar4"
style="?android:attr/progressBarStyle"
android:layout_width="24dp"
android:layout_height="24dp"
app:goneUnless="@{item.available.status == Status.LOADING}"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
<ProgressBar
android:id="@+id/progressBar4"
style="?android:attr/progressBarStyle"
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_marginEnd="16dp"
app:goneUnless="@{item.available.status == Status.LOADING}"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@id/btnDelete" />
<ImageButton
android:id="@+id/btnDelete"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="?attr/selectableItemBackgroundBorderless"
app:tint="?colorControlNormal"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:srcCompat="@drawable/ic_delete"
android:contentDescription="@string/delete" />
</androidx.constraintlayout.widget.ConstraintLayout>
</FrameLayout>
</layout>

View File

@@ -9,13 +9,6 @@
android:name="net.vonforst.evmap.fragment.MapFragment"
android:label="MapFragment"
tools:layout="@layout/fragment_map">
<action
android:id="@+id/action_map_to_galleryFragment"
app:destination="@id/gallery"
app:enterAnim="@animator/nav_default_enter_anim"
app:exitAnim="@animator/nav_default_exit_anim"
app:popEnterAnim="@animator/nav_default_pop_enter_anim"
app:popExitAnim="@animator/nav_default_pop_exit_anim" />
<action
android:id="@+id/action_map_to_filterFragment"
app:destination="@id/filter"
@@ -40,6 +33,9 @@
<action
android:id="@+id/action_map_to_update_060_androidauto"
app:destination="@id/update_060_androidauto" />
<action
android:id="@+id/action_map_to_opensource_donations"
app:destination="@id/opensource_donations" />
</fragment>
<fragment
android:id="@+id/about"
@@ -49,17 +45,15 @@
<action
android:id="@+id/action_about_to_donateFragment"
app:destination="@id/donate" />
<action
android:id="@+id/action_about_to_github_sponsors"
app:destination="@id/github_sponsors" />
</fragment>
<fragment
android:id="@+id/settings"
android:name="net.vonforst.evmap.fragment.SettingsFragment"
android:label="@string/settings"
tools:layout="@layout/fragment_preference" />
<fragment
android:id="@+id/gallery"
android:name="net.vonforst.evmap.fragment.GalleryFragment"
android:label="GalleryFragment"
tools:layout="@layout/fragment_gallery" />
<fragment
android:id="@+id/favs"
android:name="net.vonforst.evmap.fragment.FavoritesFragment"
@@ -102,9 +96,24 @@
android:name="net.vonforst.evmap.fragment.updatedialogs.Update060AndroidAutoDialogFramgent"
android:label="@string/welcome_to_evmap"
tools:layout="@layout/dialog_update_060_androidauto" />
<chrome
<dialog
android:id="@+id/opensource_donations"
android:name="net.vonforst.evmap.fragment.updatedialogs.OpensourceDonationsDialogFramgent"
android:label="@string/donation_dialog_title"
tools:layout="@layout/dialog_opensource_donations">
<action
android:id="@+id/action_opensource_donations_to_donate"
app:destination="@id/donate" />
<action
android:id="@+id/action_opensource_donations_to_github_sponsors"
app:destination="@id/github_sponsors" />
</dialog>
<custom
android:id="@+id/report_new_charger"
app:url="@string/report_new_charger_url" />
app:customDestination="report_new_charger" />
<custom
android:id="@+id/github_sponsors"
app:customDestination="github_sponsors" />
<fragment
android:id="@+id/onboarding"
android:name="net.vonforst.evmap.fragment.OnboardingFragment"

View File

@@ -0,0 +1,593 @@
{
"v": "5.7.11",
"fr": 60,
"ip": 0,
"op": 60,
"w": 56,
"h": 56,
"nm": "favorite_black_24dp",
"ddd": 0,
"assets": [],
"layers": [
{
"ddd": 0,
"ind": 1,
"ty": 4,
"nm": "Formebene 1",
"sr": 1,
"ks": {
"o": {
"a": 1,
"k": [
{
"i": {
"x": [
0.833
],
"y": [
0.833
]
},
"o": {
"x": [
0.167
],
"y": [
0.167
]
},
"t": 13,
"s": [
0
]
},
{
"i": {
"x": [
0.833
],
"y": [
0.833
]
},
"o": {
"x": [
0.167
],
"y": [
0.167
]
},
"t": 15,
"s": [
100
]
},
{
"t": 26,
"s": [
0
]
}
],
"ix": 11
},
"r": {
"a": 0,
"k": 0,
"ix": 10
},
"p": {
"a": 0,
"k": [
28,
26,
0
],
"ix": 2,
"l": 2
},
"a": {
"a": 0,
"k": [
0,
0,
0
],
"ix": 1,
"l": 2
},
"s": {
"a": 1,
"k": [
{
"i": {
"x": [
0.369,
0.369,
0.667
],
"y": [
1.016,
1.016,
1
]
},
"o": {
"x": [
0.417,
0.417,
0.333
],
"y": [
1.004,
1.004,
0
]
},
"t": 13,
"s": [
0,
0,
100
]
},
{
"t": 26,
"s": [
100,
100,
100
]
}
],
"ix": 6,
"l": 2
}
},
"ao": 0,
"shapes": [
{
"ty": "gr",
"it": [
{
"d": 1,
"ty": "el",
"s": {
"a": 0,
"k": [
50,
50
],
"ix": 2
},
"p": {
"a": 0,
"k": [
0,
0
],
"ix": 3
},
"nm": "Elliptischer Pfad 1",
"mn": "ADBE Vector Shape - Ellipse",
"hd": false
},
{
"ty": "st",
"c": {
"a": 0,
"k": [
0.858823529412,
0,
0,
1
],
"ix": 3
},
"o": {
"a": 0,
"k": 100,
"ix": 4
},
"w": {
"a": 0,
"k": 1,
"ix": 5
},
"lc": 1,
"lj": 1,
"ml": 4,
"bm": 0,
"nm": "Kontur 1",
"mn": "ADBE Vector Graphic - Stroke",
"hd": false
},
{
"ty": "tr",
"p": {
"a": 0,
"k": [
0,
0
],
"ix": 2
},
"a": {
"a": 0,
"k": [
0,
0
],
"ix": 1
},
"s": {
"a": 0,
"k": [
100,
100
],
"ix": 3
},
"r": {
"a": 0,
"k": 0,
"ix": 6
},
"o": {
"a": 0,
"k": 100,
"ix": 7
},
"sk": {
"a": 0,
"k": 0,
"ix": 4
},
"sa": {
"a": 0,
"k": 0,
"ix": 5
},
"nm": "Transformieren"
}
],
"nm": "Ellipse 1",
"np": 3,
"cix": 2,
"bm": 0,
"ix": 1,
"mn": "ADBE Vector Group",
"hd": false
}
],
"ip": 0,
"op": 60,
"st": 0,
"bm": 0
},
{
"ddd": 0,
"ind": 2,
"ty": 4,
"nm": "favorite_black_24dp Konturen",
"sr": 1,
"ks": {
"o": {
"a": 1,
"k": [
{
"i": {
"x": [
0.833
],
"y": [
0.833
]
},
"o": {
"x": [
0.167
],
"y": [
0.167
]
},
"t": 9,
"s": [
0
]
},
{
"t": 25,
"s": [
100
]
}
],
"ix": 11
},
"r": {
"a": 0,
"k": 0,
"ix": 10
},
"p": {
"a": 0,
"k": [
28,
29,
0
],
"ix": 2,
"l": 2
},
"a": {
"a": 0,
"k": [
12,
12,
0
],
"ix": 1,
"l": 2
},
"s": {
"a": 1,
"k": [
{
"i": {
"x": [
0.295,
0.295,
0.667
],
"y": [
0.944,
0.944,
1
]
},
"o": {
"x": [
0.351,
0.351,
0.167
],
"y": [
2.108,
2.108,
70.833
]
},
"t": 9,
"s": [
0,
0,
100
]
},
{
"t": 60,
"s": [
177.674,
177.674,
100
]
}
],
"ix": 6,
"l": 2
}
},
"ao": 0,
"shapes": [
{
"ty": "gr",
"it": [
{
"ind": 0,
"ty": "sh",
"ix": 1,
"ks": {
"a": 0,
"k": {
"i": [
[
0,
0
],
[
0,
0
],
[
0,
3.78
],
[
-3.08,
0
],
[
-1.09,
-1.28
],
[
-1.74,
0
],
[
0,
-3.08
],
[
5.15,
-4.68
]
],
"o": [
[
0,
0
],
[
-5.15,
-4.671
],
[
0,
-3.08
],
[
1.74,
0
],
[
1.09,
-1.28
],
[
3.08,
0
],
[
0,
3.78
],
[
0,
0
]
],
"v": [
[
0,
9.175
],
[
-1.45,
7.856
],
[
-10,
-3.675
],
[
-4.5,
-9.175
],
[
0,
-7.085
],
[
4.5,
-9.175
],
[
10,
-3.675
],
[
1.45,
7.865
]
],
"c": true
},
"ix": 2
},
"nm": "Pfad 1",
"mn": "ADBE Vector Shape - Group",
"hd": false
},
{
"ty": "fl",
"c": {
"a": 0,
"k": [
0.858823537827,
0,
0,
1
],
"ix": 4
},
"o": {
"a": 0,
"k": 100,
"ix": 5
},
"r": 1,
"bm": 0,
"nm": "Fläche 1",
"mn": "ADBE Vector Graphic - Fill",
"hd": false
},
{
"ty": "tr",
"p": {
"a": 0,
"k": [
12,
12.175
],
"ix": 2
},
"a": {
"a": 0,
"k": [
0,
0
],
"ix": 1
},
"s": {
"a": 0,
"k": [
100,
100
],
"ix": 3
},
"r": {
"a": 0,
"k": 0,
"ix": 6
},
"o": {
"a": 0,
"k": 100,
"ix": 7
},
"sk": {
"a": 0,
"k": 0,
"ix": 4
},
"sa": {
"a": 0,
"k": 0,
"ix": 5
},
"nm": "Transformieren"
}
],
"nm": "Gruppe 1",
"np": 2,
"cix": 2,
"bm": 0,
"ix": 1,
"mn": "ADBE Vector Group",
"hd": false
}
],
"ip": 0,
"op": 60,
"st": 0,
"bm": 0
}
],
"markers": []
}

View File

@@ -43,7 +43,6 @@
<string name="privacy">Datenschutzerklärung</string>
<string name="fav_add">Zu Favoriten hinzufügen</string>
<string name="fav_remove">Aus Favoriten entfernen</string>
<string name="distance_format">%.1f km</string>
<string name="pref_navigate_use_maps">Navigation sofort starten</string>
<string name="pref_navigate_use_maps_on">Navigationsbutton startet Navigation direkt</string>
<string name="pref_navigate_use_maps_off">Navigationsbutton startet Karten-App mit Position der Ladesäule</string>
@@ -79,6 +78,7 @@
<string name="map_details">Kartendetails</string>
<string name="map_traffic">Verkehr</string>
<string name="faq">FAQ</string>
<string name="faq_desc">Häufig gestellte Fragen</string>
<string name="menu_filters_active">Filter aktiv</string>
<string name="filters_activated">Filter aktiviert</string>
<string name="filters_deactivated">Filter deaktiviert</string>
@@ -152,7 +152,9 @@
<string name="welcome_1">Finde Ladestationen für Elektroautos in deiner Nähe.</string>
<string name="welcome_2_title">Auf die Leistung kommt es an</string>
<string name="welcome_2">Die Farbe einer Ladestatione auf der Karte zeigt dir die maximale Ladeleistung.</string>
<string name="welcome_3">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="welcome_2_detail">Du kannst die Farben im Menü unter Über EVMap → FAQ” erneut ansehen)</string>
<string name="donation_dialog_title">Danke, dass du EVMap nutzt!</string>
<string name="donation_dialog_detail">EVMap ist kostenlos und Open Source, ich entwickle es in meiner Freizeit. Über GitHub kann jeder zur Weiterentwicklung der App beitragen. Durch die steigende Beliebtheit der App müssen allerdings auch laufende Kosten, z.B. für den Zugriff auf die Datenquellen, gedeckt werden. Daher freue ich mich auch über Spenden in der App oder über GitHub Sponsors.</string>
<string name="deleted_filterprofile">„%s” gelöscht</string>
<string name="undo">Rückgängig</string>
<string name="rename">Umbenennen</string>
@@ -164,8 +166,6 @@
<string name="navigate">Navigieren</string>
<string name="verified">Verifiziert</string>
<string name="verified_desc">Verifiziert von der %s 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>
<string name="charge_price_format">%1$.2f %2$s</string>
<string name="charge_price_average_format">⌀ %1$.2f %2$s/kWh</string>
<string name="charge_price_kwh_format">%1$.2f %2$s/kWh</string>
@@ -187,6 +187,7 @@
<string name="pref_chargeprice_show_provider_customer_tariffs_summary">Einige Anbieter bieten für ihre Kunden (z.B. Haushaltsstrom, Gas) günstigere Tarife an</string>
<string name="chargeprice_select_car_first">Bitte wähle zuerst dein Auto in den Einstellungen aus.</string>
<string name="chargeprice_battery_range">Laden von %1$.0f%% bis %2$.0f%%</string>
<string name="chargeprice_duration">(ca. %s)</string>
<string name="chargeprice_vehicle">Fahrzeug</string>
<string name="edit_on_goingelectric_info">Falls hier nur eine leere Seite erscheint, logge dich bitte zuerst bei GoingElectric.de ein.</string>
<string name="close">schließen</string>
@@ -196,6 +197,7 @@
<string name="pref_chargeprice_currency">Währung</string>
<string name="pref_my_tariffs">Meine Tarife</string>
<string name="chargeprice_all_tariffs_selected">alle Tarife ausgewählt</string>
<string name="pref_my_tariffs_summary">(werden im Preisvergleich hervorgehoben)</string>
<string name="license">Lizenz</string>
<string name="settings_charger_data">Ladesäulen</string>
<string name="pref_data_source">Datenquelle</string>
@@ -227,4 +229,12 @@
<string name="get_started">Los geht\'s</string>
<string name="got_it">Alles klar</string>
<string name="lets_go">Und los</string>
<string name="crash_report_text">Sorry, anscheinend ist EVMap abgestürzt. Bitte schicke einen Fehlerbericht an den Entwickler.</string>
<string name="crash_report_comment_prompt">Du kannst unten noch einen Kommentar hinzufügen:</string>
<string name="powered_by_mapbox">powered by Mapbox</string>
<string name="pref_search_provider">Anbieter für Ortssuche</string>
<string name="pref_search_provider_info"><![CDATA[Die Daten für die Ortssuche, vor allem von Google Maps, sind relativ teuer. Wenn du diese Funktion häufig nutzt, würde ich mich über eine Spende unter \"Über EVMap -> Spenden\" sehr freuen.]]></string>
<string name="github_sponsors">GitHub Sponsors</string>
<string name="donate_desc">Unterstütze die Weiterentwicklung von EVMap mit einer einmaligen Spende</string>
<string name="github_sponsors_desc">Unterstütze EVMap über GitHub Sponsors</string>
</resources>

View File

@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<declare-styleable name="ChromeCustomTabsNavigator">
<attr name="url" format="reference" />
<declare-styleable name="CustomNavigator">
<attr name="customDestination" format="string" />
</declare-styleable>
<declare-styleable name="MultiSelectDialogPreference">
<attr name="showAllButton" format="boolean" />

View File

@@ -7,5 +7,5 @@
<string name="twitter_handle">\@ev_map</string>
<string name="twitter_url">https://twitter.com/ev_map</string>
<string name="goingelectric_forum_url"><![CDATA[https://www.goingelectric.de/forum/viewtopic.php?f=5&t=56342]]></string>
<string name="report_new_charger_url">https://www.goingelectric.de/stromtankstellen/new/</string>
<string name="github_sponsors_link">https://github.com/sponsors/johan12345/</string>
</resources>

View File

@@ -42,7 +42,6 @@
<string name="privacy">Privacy Notice</string>
<string name="fav_add">Add to favorites</string>
<string name="fav_remove">Remove from favorites</string>
<string name="distance_format">%.1f km</string>
<string name="pref_navigate_use_maps">Start navigation immediately</string>
<string name="pref_navigate_use_maps_on">Navigation button starts navigation immediately</string>
<string name="pref_navigate_use_maps_off">Navigation button launches maps app with charger location</string>
@@ -78,6 +77,7 @@
<string name="map_details">Map details</string>
<string name="map_traffic">Traffic</string>
<string name="faq">FAQ</string>
<string name="faq_desc">Frequently asked questions</string>
<string name="menu_filters_active">Filters active</string>
<string name="filters_activated">Filters activated</string>
<string name="filters_deactivated">Filters deactivated</string>
@@ -151,7 +151,9 @@
<string name="welcome_1">Find electric vehicle chargers around you.</string>
<string name="welcome_2_title">You\'ve got the power</string>
<string name="welcome_2">The color of a charger on the map shows you its maximum charging power.</string>
<string name="welcome_3">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="welcome_2_detail">(You can check the colors again under “About EVMap → FAQ” in the menu)</string>
<string name="donation_dialog_title">Thank you for using EVMap!</string>
<string name="donation_dialog_detail">EVMap is free and Open Source software that I develop in my spare time. Coding contributions on GitHub are very much appreciated. However, due to increasing popularity of the app, I also need to cover some running costs, e.g. for access to the data sources. Therefore, please consider supporting the app through a donation or via GitHub Sponsors.</string>
<string name="deleted_filterprofile">Deleted “%s”</string>
<string name="undo">Undo</string>
<string name="rename">Rename</string>
@@ -163,8 +165,6 @@
<string name="navigate">Navigate</string>
<string name="verified">verified</string>
<string name="verified_desc">Charger verified by a member at the %s 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>
<string name="charge_price_format">%2$s%1$.2f</string>
<string name="charge_price_average_format">⌀ %2$s%1$.2f/kWh</string>
<string name="charge_price_kwh_format">%2$s%1$.2f/kWh</string>
@@ -186,6 +186,7 @@
<string name="pref_chargeprice_show_provider_customer_tariffs">Show customer-exclusive plans</string>
<string name="chargeprice_select_car_first">Please first select your car model in the settings.</string>
<string name="chargeprice_battery_range">Charge from %1$.0f%% to %2$.0f%%</string>
<string name="chargeprice_duration">(approx. %s)</string>
<string name="chargeprice_vehicle">Vehicle</string>
<string name="pref_chargeprice_show_provider_customer_tariffs_summary">Some providers offer cheaper plans exclusively to their customers (e.g., household electricity, gas)</string>
<string name="close">close</string>
@@ -194,6 +195,7 @@
<string name="chargeprice_no_compatible_connectors">None of the connectors on this charging station is compatible with your vehicle.</string>
<string name="pref_chargeprice_currency">Currency</string>
<string name="pref_my_tariffs">My charging plans</string>
<string name="pref_my_tariffs_summary">(will be highlighted in price comparison)</string>
<string name="chargeprice_all_tariffs_selected">all plans selected</string>
<string name="license">License</string>
<string name="settings_charger_data">Charging stations</string>
@@ -212,4 +214,12 @@
<string name="get_started">Get started</string>
<string name="got_it">Got it</string>
<string name="lets_go">Let\'s go</string>
<string name="crash_report_text">Sorry, it seems that EVMap has crashed. Please send a crash report to the developer.</string>
<string name="crash_report_comment_prompt">You can add a comment below:</string>
<string name="powered_by_mapbox">powered by Mapbox</string>
<string name="pref_search_provider">Place search provider</string>
<string name="pref_search_provider_info"><![CDATA[Data for place search, especially from Google Maps, is relatively expensive. If you use this feature often, please consider making a donation through \"About EVMap -> Donate\".]]></string>
<string name="github_sponsors">GitHub Sponsors</string>
<string name="donate_desc">Support EVMap\'s development with a one-time donation</string>
<string name="github_sponsors_desc">Support EVMap on GitHub Sponsors</string>
</resources>

View File

@@ -16,11 +16,18 @@
<Preference
android:key="faq"
android:title="@string/faq" />
android:title="@string/faq"
android:summary="@string/faq_desc" />
<Preference
android:key="donate"
android:title="@string/donate" />
android:title="@string/donate"
android:summary="@string/donate_desc" />
<Preference
android:key="github_sponsors"
android:title="@string/github_sponsors"
android:summary="@string/github_sponsors_desc" />
</PreferenceCategory>
<PreferenceCategory android:title="@string/contact">

View File

@@ -39,6 +39,14 @@
android:defaultValue="@string/pref_map_provider_default"
android:summary="%s" />
<ListPreference
android:key="search_provider"
android:title="@string/pref_search_provider"
android:entries="@array/pref_search_provider_names"
android:entryValues="@array/pref_search_provider_values"
android:defaultValue="@string/pref_search_provider_default"
android:summary="%s" />
<CheckBoxPreference
android:key="navigate_use_maps"
android:title="@string/pref_navigate_use_maps"
@@ -55,7 +63,8 @@
app:defaultToAll="false" />
<net.vonforst.evmap.ui.MultiSelectDialogPreference
android:key="chargeprice_my_tariffs"
android:title="@string/pref_my_tariffs" />
android:title="@string/pref_my_tariffs"
android:summary="@string/pref_my_tariffs_summary" />
<ListPreference
android:key="chargeprice_currency"
android:title="@string/pref_chargeprice_currency"

View File

@@ -10,7 +10,7 @@ buildscript {
gradlePluginPortal()
}
dependencies {
classpath 'com.android.tools.build:gradle:4.2.1'
classpath 'com.android.tools.build:gradle:7.0.0'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
classpath "com.mikepenz.aboutlibraries.plugin:aboutlibraries-plugin:$about_libs_version"
classpath "androidx.navigation:navigation-safe-args-gradle-plugin:$nav_version"

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