Compare commits

..
166 Commits
Author SHA1 Message Date
johan12345 1f6a515480 Release 2.1.4 2026-08-21 13:31:30 +02:00
johan12345 34329e1ecc update MapLibre with Vulkan+OpenGL ES 3.0 support 2026-08-21 13:16:55 +02:00
johan12345 1ce1fd9256 EnBW: move to new host 2026-08-21 12:58:04 +02:00
Hosted Weblateandmcliquid 4495c4fb92 Translated using Weblate (German)
Currently translated at 100.0% (381 of 381 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: mcliquid <info@mcliquid.de>
Translate-URL: https://hosted.weblate.org/projects/evmap/android/de/
Translation: EVMap/Android
2026-08-20 22:59:44 +02:00
johan12345 37ede73295 update Mastodon instance
fixes #444
2026-08-20 21:49:57 +02:00
johan12345 6d351d4fa9 update Google Maps & Places 2026-08-03 22:29:42 +02:00
johan12345 e342f3e424 update AnyMaps & MapLibre
fixes #443
2026-08-03 22:12:54 +02:00
johan12345 305eb5fe65 update Google Play Billing library 2026-08-03 21:51:09 +02:00
johan12345 10053bcc00 update Maplibre 2026-07-11 13:29:53 +02:00
Hosted WeblateandRobert Högberg 87cef478a0 Translated using Weblate (Swedish)
Currently translated at 100.0% (381 of 381 strings)

Translated using Weblate (Swedish)

Currently translated at 100.0% (380 of 380 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Robert Högberg <robert.hogberg@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/evmap/android/sv/
Translation: EVMap/Android
2026-07-11 13:29:08 +02:00
Hosted WeblateandThomas Di Cristofaro c8907c8b1c Translated using Weblate (Italian)
Currently translated at 100.0% (379 of 379 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Thomas Di Cristofaro <hostedweblate.8347@tdc.akamail.it>
Translate-URL: https://hosted.weblate.org/projects/evmap/android/it/
Translation: EVMap/Android
2026-07-11 13:29:08 +02:00
Hosted WeblateandFjuro ab5125ede9 Translated using Weblate (Czech)
Currently translated at 100.0% (381 of 381 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (380 of 380 strings)

Co-authored-by: Fjuro <fjuro@users.noreply.hosted.weblate.org>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/evmap/android/cs/
Translation: EVMap/Android
2026-07-11 13:29:07 +02:00
Hosted WeblateandPriit Jõerüüt cb3433828c Translated using Weblate (Estonian)
Currently translated at 100.0% (381 of 381 strings)

Translated using Weblate (Estonian)

Currently translated at 100.0% (380 of 380 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Priit Jõerüüt <jrthwlate@users.noreply.hosted.weblate.org>
Translate-URL: https://hosted.weblate.org/projects/evmap/android/et/
Translation: EVMap/Android
2026-07-11 13:29:07 +02:00
dependabot[bot] 3ae8f8ddf0 Bump faraday from 1.10.5 to 1.10.6
Bumps [faraday](https://github.com/lostisland/faraday) from 1.10.5 to 1.10.6.
- [Release notes](https://github.com/lostisland/faraday/releases)
- [Changelog](https://github.com/lostisland/faraday/blob/main/CHANGELOG.md)
- [Commits](https://github.com/lostisland/faraday/compare/v1.10.5...v1.10.6)

---
updated-dependencies:
- dependency-name: faraday
  dependency-version: 1.10.6
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-11 13:26:39 +02:00
dependabot[bot] b9760dafd1 Bump excon from 0.76.0 to 1.5.0
Bumps [excon](https://github.com/excon/excon) from 0.76.0 to 1.5.0.
- [Changelog](https://github.com/excon/excon/blob/master/changelog.txt)
- [Commits](https://github.com/excon/excon/compare/v0.76.0...v1.5.0)

---
updated-dependencies:
- dependency-name: excon
  dependency-version: 1.5.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-11 13:24:07 +02:00
Robert Högberg 0043f57e2e Add support for Megawatt Charging System (MCS) connector
So far only used by the Nobil data source
2026-06-18 19:10:28 +02:00
johan12345 7488dbf100 upgrade MapLibre 2026-06-04 17:53:04 +02:00
johan12345 89231c99f6 Improve DataSourceSelectFragment UI
Tablet layout: scroll down automatically to reveal "Let's go" button
2026-06-01 19:17:14 +02:00
johan12345 6aeede295e UiSettingsFragment: avoid crash when APP_OPEN_BY_DEFAULT_SETTINGS is not supported 2026-06-01 18:55:56 +02:00
johan12345 9125ff133b Merge branch 'master' of github.com:johan12345/EVMap 2026-06-01 18:52:17 +02:00
Robert Högberg 0cbb2d82c0 Limit download progress percentage to range 0-100
For nobil the percentage reaches 101. I haven't figured out why nobil's data
is out of sync.

When querying nobil for the total number of chargers we get:
Norway: 4957
Sweden: 9108
------------
       14065

.. but the data dump we download has:
Norway: 5168
Sweden: 9059
------------
       14227
2026-06-01 18:44:48 +02:00
Robert Högberg abb00f144b Fix app crash when downloading full charger list from data source
R.string.downloading_chargers_percent expects a Float argument, but an Int
was supplied.
2026-05-31 23:13:37 +02:00
johan12345 e31a83377f upgrade Material Components 2026-05-30 17:30:41 +02:00
johan12345 a0e645886e Tesla: catch and clear invalid refresh tokens 2026-05-30 17:25:31 +02:00
johan12345 b57bb5d884 catch non-IOExceptions in Retrofit interceptors 2026-05-30 17:07:45 +02:00
johan12345 b7571acc9e upgrade ACRA 2026-05-30 14:28:02 +02:00
johan12345 757c8589fb remove old logging call 2026-05-30 14:19:40 +02:00
johan12345 bbebc9fc20 fix typo in README 2026-05-26 00:10:12 +02:00
johan12345 40d894a270 GitHub Actions update 2026-05-26 00:02:53 +02:00
johan12345 88ab03e681 aboutLibraries update 2026-05-25 23:49:25 +02:00
johan12345 6b751a7371 use Gradle configuration cache 2026-05-25 23:39:52 +02:00
johan12345 edd451d61e fix missing view id 2026-05-25 23:34:28 +02:00
johan12345 15e0e6d6ae upgrade some dependencies 2026-05-25 23:33:44 +02:00
johan12345 2e9b7c941b viewbinding migration cleanup 2026-05-25 23:23:04 +02:00
johan12345 dda491c34f migrate from databinding to viewbinding
fixes #144
2026-05-25 17:23:10 +02:00
johan12345 f6c39e1bdc update GitHub Action versions 2026-05-25 13:07:27 +02:00
johan12345 f03dc0ebdb update AGP to 9.2.1 2026-05-25 13:05:06 +02:00
johan12345 640057e0ef remove unused jsonapi-retrofit library
was part of Chargeprice integration
2026-05-25 13:00:16 +02:00
johan12345 4fa4831fcb migrate to libs.versions.toml version catalog 2026-05-25 12:59:31 +02:00
johan12345 bb50f0bcc4 upgrade AGP to 9.0.1 2026-05-25 12:48:28 +02:00
johan12345 637f8dbfad fix insets handling on Android <=10
fixes #423
2026-05-23 01:01:48 +02:00
johan12345 fa21ffd7ff upgrade AnyMaps, MapLibre uses OpenGL ES 3.0 now
#345
2026-05-23 00:45:44 +02:00
johan12345 53809b55fb upgrade Kotlin and KSP, switch to Moshi-IR 2026-05-23 00:32:07 +02:00
johan12345 63a10d7f68 Release 2.1.3 2026-05-10 13:30:24 +02:00
johan12345 e117c844ed Merge branch 'car-app-library-1.8' 2026-05-10 13:25:22 +02:00
johan12345 36174297e5 fix Tesla login
redirect URI changed
fixes #422
2026-05-06 20:35:26 +02:00
johan12345 ac37cdde32 new map screen: keep scroll position on refresh 2026-04-24 22:12:40 +02:00
johan12345 4345ee6550 Upgrade Car App Library to 1.8.0-beta01 2026-04-24 22:12:40 +02:00
johan12345 562f2f2192 Release 2.1.2 2026-04-24 22:02:55 +02:00
johan12345 80b4ab3011 Remove NewMotionAvailabilityDetectorTest 2026-04-20 23:52:05 +02:00
johan12345 178fe804dc Remove NewMotionAvailabilityDetector
old Shell Recharge/NewMotion map doesn't exist anymore, the new one is limited to Shell's own chargers
2026-04-20 23:45:50 +02:00
johan12345 4a7c3f0b8f EnBwAvailabilityDetector: try ungrouping markers multiple times 2026-04-20 23:36:58 +02:00
johan12345 3b76e8fdfd update copyright year
fixes #421
2026-04-16 23:55:34 +02:00
johan12345 084f090d53 Release 2.1.1 2026-04-15 23:02:40 +02:00
johan12345 707b83d830 Revert "upgrade AGP"
This reverts commit 045fcc137d.
2026-04-15 22:58:35 +02:00
johan12345 e881d18f1d upgrade StfalconImageViewer 2026-04-15 22:52:00 +02:00
johan12345 045fcc137d upgrade AGP 2026-04-15 22:51:07 +02:00
johan12345 463116e87e upgrade android-spatialite 2026-04-15 21:44:24 +02:00
dependabot[bot] 0dcd06539f Bump addressable from 2.8.0 to 2.9.0
Bumps [addressable](https://github.com/sporkmonger/addressable) from 2.8.0 to 2.9.0.
- [Changelog](https://github.com/sporkmonger/addressable/blob/main/CHANGELOG.md)
- [Commits](https://github.com/sporkmonger/addressable/compare/addressable-2.8.0...addressable-2.9.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-14 21:29:44 +02:00
johan12345 ff651a37cf log when app to handle intent is not found 2026-03-17 20:36:01 +01:00
Robert Högberg c414e4bcc2 Nobil: Support new charger speeds 2026-03-07 17:10:45 +01:00
dependabot[bot] aebcdaa06c Bump faraday from 1.0.1 to 1.10.5
Bumps [faraday](https://github.com/lostisland/faraday) from 1.0.1 to 1.10.5.
- [Release notes](https://github.com/lostisland/faraday/releases)
- [Changelog](https://github.com/lostisland/faraday/blob/main/CHANGELOG.md)
- [Commits](https://github.com/lostisland/faraday/compare/v1.0.1...v1.10.5)

---
updated-dependencies:
- dependency-name: faraday
  dependency-version: 1.10.5
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-13 17:21:36 +01:00
Hosted WeblateandRobert Högberg 4ae6a74d96 Translated using Weblate (Swedish)
Currently translated at 100.0% (379 of 379 strings)

Translated using Weblate (Swedish)

Currently translated at 100.0% (94 of 94 strings)

Translated using Weblate (Swedish)

Currently translated at 100.0% (378 of 378 strings)

Translated using Weblate (Swedish)

Currently translated at 100.0% (378 of 378 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Robert Högberg <robert.hogberg@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/evmap/android/sv/
Translate-URL: https://hosted.weblate.org/projects/evmap/app-store-metadata/sv/
Translation: EVMap/Android
Translation: EVMap/App Store metadata
2026-01-25 17:39:29 +01:00
Hosted WeblateandFjuro 6868206a71 Translated using Weblate (Czech)
Currently translated at 100.0% (379 of 379 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (378 of 378 strings)

Co-authored-by: Fjuro <fjuro@alius.cz>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/evmap/android/cs/
Translation: EVMap/Android
2026-01-25 17:39:29 +01:00
Hosted WeblateandPriit Jõerüüt 174a0c81d3 Translated using Weblate (Estonian)
Currently translated at 100.0% (379 of 379 strings)

Translated using Weblate (Estonian)

Currently translated at 100.0% (378 of 378 strings)

Translated using Weblate (Estonian)

Currently translated at 100.0% (378 of 378 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Priit Jõerüüt <jrthwlate@users.noreply.hosted.weblate.org>
Translate-URL: https://hosted.weblate.org/projects/evmap/android/et/
Translation: EVMap/Android
2026-01-25 17:39:28 +01:00
johan12345 68948948c2 add another fallback for maps + browser intents
possible fix for #398, #414
2026-01-25 17:38:17 +01:00
johan12345 ce4f7418ea don't crash when no sharing app is found 2026-01-21 21:24:40 +01:00
Johan von Forstner 4507912f97 Fix #410: confirm before deleting profile on Automotive 2026-01-02 18:58:59 +01:00
Robert Högberg fa0826ca24 Auto: Sort nearby locations by distance in meters
The return value of Distance() differs depending on which version of that's
used:

Distance( geom1 Geometry , geom2 Geometry ) : Double precision
  return the distance between geom1 and geom2 (always measured in CRS units).

Distance( geom1 Geometry , geom2 Geometry ,
          use_ellipsoid Boolean ) : Double precision
  return the distance between geom1 and geom2 (measured in meters).

Ordering by CRS units causes unexpected item order.

Fixes #411
2026-01-02 18:53:55 +01:00
Johan von Forstner e69b3229d6 remove JetBrains from sponsors 2025-12-29 09:53:17 +01:00
Johan von Forstner 0d466e2f16 CI builds - add EVMap API key 2025-12-25 17:00:54 +01:00
Johan von Forstner cf8c7f6024 fix README typo 2025-12-25 16:49:57 +01:00
Johan von Forstner bc9292ac26 Release 2.1.0 2025-12-24 16:48:13 +01:00
Johan von Forstner 897b439cca fix crash when favorite exists but charger has been deleted from cache 2025-12-24 16:40:16 +01:00
Robert Högberg 141b2c76b1 Nobil: Add real-time availability support 2025-12-24 16:40:16 +01:00
Johan von Forstner fc22b16111 NewMotionAvailabilityDetector: get correct charger power if available 2025-12-24 12:08:43 +01:00
Hosted WeblateandPixelcode f41ea230de Translated using Weblate (German)
Currently translated at 100.0% (377 of 377 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Pixelcode <pixelcode@dismail.de>
Translate-URL: https://hosted.weblate.org/projects/evmap/android/de/
Translation: EVMap/Android
2025-12-24 11:46:56 +01:00
dependabot[bot] ceb5081757 Bump aws-sdk-s3 from 1.78.0 to 1.208.0
Bumps [aws-sdk-s3](https://github.com/aws/aws-sdk-ruby) from 1.78.0 to 1.208.0.
- [Release notes](https://github.com/aws/aws-sdk-ruby/releases)
- [Changelog](https://github.com/aws/aws-sdk-ruby/blob/version-3/gems/aws-sdk-s3/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-ruby/commits)

---
updated-dependencies:
- dependency-name: aws-sdk-s3
  dependency-version: 1.208.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-20 06:11:21 -05:00
Johan von Forstner 28bb8cef5f Update androidx.core.splashscreen 2025-12-10 17:42:39 +01:00
Robert Högberg ba17cb989a Nobil: Fix data expiration 2025-12-03 22:18:21 +01:00
johan12345 d08aaa3325 Auto: make AboutScreen only accessible when parked 2025-12-02 17:46:20 +01:00
Johan von Forstner 0f24608d2a Remove references to Chargeprice from README.md 2025-11-30 15:09:19 +01:00
Hosted WeblateandRobert Högberg 92e9539286 Translated using Weblate (Norwegian Bokmål)
Currently translated at 74.0% (279 of 377 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Robert Högberg <robert.hogberg@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/evmap/android/nb_NO/
Translation: EVMap/Android
2025-11-22 12:37:02 +01:00
b373f49180 Translated using Weblate (Swedish)
Currently translated at 100.0% (377 of 377 strings)

Translated using Weblate (Swedish)

Currently translated at 100.0% (377 of 377 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Robert Högberg <robert.hogberg@gmail.com>
Co-authored-by: bittin1ddc447d824349b2 <bittin@reimu.nl>
Translate-URL: https://hosted.weblate.org/projects/evmap/android/sv/
Translation: EVMap/Android
2025-11-22 12:37:02 +01:00
Hosted WeblateandRobert Högberg ec8728a253 Translated using Weblate (French)
Currently translated at 91.2% (344 of 377 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Robert Högberg <robert.hogberg@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/evmap/android/fr/
Translation: EVMap/Android
2025-11-22 12:37:02 +01:00
Hosted WeblateandRobert Högberg 9ca470cd46 Translated using Weblate (Dutch)
Currently translated at 80.9% (305 of 377 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Robert Högberg <robert.hogberg@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/evmap/android/nl/
Translation: EVMap/Android
2025-11-22 12:37:02 +01:00
johan12345 38a1bf2da5 fix #403: add edit URL for OSM 2025-11-22 12:34:56 +01:00
johan12345 5c1dad82b1 add x64 versions of libc++_shared.so to fortify exceptions 2025-11-07 20:22:58 +01:00
Hosted WeblateandRobert Högberg 5647820f3e Translated using Weblate (Swedish)
Currently translated at 99.7% (376 of 377 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Robert Högberg <robert.hogberg@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/evmap/android/sv/
Translation: EVMap/Android
2025-11-07 20:10:27 +01:00
Hosted WeblateandFjuro 092a3e50bc Translated using Weblate (Czech)
Currently translated at 100.0% (377 of 377 strings)

Co-authored-by: Fjuro <fjuro@alius.cz>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/evmap/android/cs/
Translation: EVMap/Android
2025-11-07 20:10:27 +01:00
Hosted WeblateandPriit Jõerüüt 7b27fe2cac Translated using Weblate (Estonian)
Currently translated at 100.0% (377 of 377 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Priit Jõerüüt <jrthwlate@users.noreply.hosted.weblate.org>
Translate-URL: https://hosted.weblate.org/projects/evmap/android/et/
Translation: EVMap/Android
2025-11-07 20:10:27 +01:00
johan12345 8991cb4e4a increase minSdk to 23 2025-11-07 20:04:33 +01:00
johan12345 66d6aee97e upgrade spatia-room 2025-11-07 19:57:11 +01:00
Johan von Forstner 3a5646a3ac Release 2.0.2 2025-10-30 16:32:08 +01:00
Johan von Forstner 14eadef10d AAOS new map screen: increase default zoom level 2025-10-30 16:23:12 +01:00
Johan von Forstner cea0878267 OnboardingFragment: use CustomTabs for privacy policy 2025-10-30 16:02:02 +01:00
Johan von Forstner 2b4c0829a8 remove unused Chargeprice icons 2025-10-29 12:21:41 +01:00
Johan von Forstner 8e9d9d15c4 Auto: make new map screen optional for now
mainly due to bug https://issuetracker.google.com/issues/389974133
2025-10-29 11:58:02 +01:00
Hosted WeblateandRobert Högberg ca9a7df8b0 Translated using Weblate (Swedish)
Currently translated at 99.7% (375 of 376 strings)

Translated using Weblate (Swedish)

Currently translated at 100.0% (92 of 92 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Robert Högberg <robert.hogberg@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/evmap/android/sv/
Translate-URL: https://hosted.weblate.org/projects/evmap/app-store-metadata/sv/
Translation: EVMap/Android
Translation: EVMap/App Store metadata
2025-10-29 11:00:29 +01:00
Hosted WeblateandFjuro 51aecd179c Translated using Weblate (Czech)
Currently translated at 100.0% (376 of 376 strings)

Co-authored-by: Fjuro <fjuro@alius.cz>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/evmap/android/cs/
Translation: EVMap/Android
2025-10-29 11:00:28 +01:00
Hosted WeblateandPriit Jõerüüt 6781989266 Translated using Weblate (Estonian)
Currently translated at 100.0% (376 of 376 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Priit Jõerüüt <jrthwlate@users.noreply.hosted.weblate.org>
Translate-URL: https://hosted.weblate.org/projects/evmap/android/et/
Translation: EVMap/Android
2025-10-29 11:00:27 +01:00
johan12345 872d3c5143 Automotive: update "my location" button icon more quickly 2025-10-26 23:40:22 +01:00
johan12345 69622c6816 update AnyMaps 2025-10-26 23:30:17 +01:00
johan12345 15fdac6348 Automotive: implement map rotation by compass 2025-10-26 23:09:23 +01:00
johan12345 6c206c7a25 revert work-runtime-ktx version 2025-10-26 22:24:12 +01:00
johan12345 8f49b1f238 update dependencies 2025-10-26 22:18:00 +01:00
Hosted WeblateandRobert Högberg 31bd2b7dd4 Translated using Weblate (Swedish)
Currently translated at 99.7% (373 of 374 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Robert Högberg <robert.hogberg@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/evmap/android/sv/
Translation: EVMap/Android
2025-10-26 21:32:10 +01:00
Hosted WeblateandThomas Di Cristofaro 5524d14562 Translated using Weblate (Italian)
Currently translated at 100.0% (374 of 374 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Thomas Di Cristofaro <hostedweblate.8347@tdc.akamail.it>
Translate-URL: https://hosted.weblate.org/projects/evmap/android/it/
Translation: EVMap/Android
2025-10-26 21:32:10 +01:00
johan12345 5a360a7ee0 remove Chargeprice API key 2025-10-26 21:30:20 +01:00
johan12345 98d3c91686 remove Chargeprice tests 2025-10-26 21:30:20 +01:00
johan12345 12c1c6a5ec add dialog to explain removal of Chargeprice data 2025-10-26 21:30:20 +01:00
johan12345 21e23efb50 remove native Chargeprice integration
fixes #320
2025-10-26 21:30:20 +01:00
johan12345 f6f2b15f41 OSM: parse output power without units
assume kW if the number is <1000, W otherwise
#393
2025-10-07 21:05:32 +02:00
Hosted WeblateandRobert Högberg c3776758b3 Translated using Weblate (Swedish)
Currently translated at 99.7% (373 of 374 strings)

Translated using Weblate (Swedish)

Currently translated at 100.0% (2 of 2 strings)

Translated using Weblate (Swedish)

Currently translated at 100.0% (3 of 3 strings)

Translated using Weblate (Swedish)

Currently translated at 99.7% (373 of 374 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Robert Högberg <robert.hogberg@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/evmap/android-fdroid/sv/
Translate-URL: https://hosted.weblate.org/projects/evmap/android-google/sv/
Translate-URL: https://hosted.weblate.org/projects/evmap/android/sv/
Translation: EVMap/Android
Translation: EVMap/Android (strings specific to F-Droid variant)
Translation: EVMap/Android (strings specific to Google Play variant)
2025-10-07 20:48:03 +02:00
Hosted WeblateandFjuro 6d9e34667c Translated using Weblate (Czech)
Currently translated at 100.0% (374 of 374 strings)

Co-authored-by: Fjuro <fjuro@alius.cz>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/evmap/android/cs/
Translation: EVMap/Android
2025-10-07 20:48:02 +02:00
Hosted WeblateandPriit Jõerüüt 24b94a055e Translated using Weblate (Estonian)
Currently translated at 100.0% (374 of 374 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Priit Jõerüüt <jrthwlate@users.noreply.hosted.weblate.org>
Translate-URL: https://hosted.weblate.org/projects/evmap/android/et/
Translation: EVMap/Android
2025-10-07 20:48:02 +02:00
Robert Högberg 1d2a7e4af9 Nobil: Adapt to dropped support for Denmark, Finland and Iceland 2025-10-07 20:40:18 +02:00
johan12345 fa86c7c15a fix typo 2025-09-26 18:22:27 +02:00
Robert Högberg 4cd9872d0f Fix connector name for Type 1 connectors in EnBW availability data 2025-09-26 18:19:08 +02:00
johan12345 1e78ffce7e Update Nobil description: Only Sweden and Norway supported
https://info.nobil.no/nyheter/264-important-update-on-nobil-data-nobil-will-no-longer-support-data-from-denmark-finland-and-iceland
2025-09-26 18:16:40 +02:00
johan12345 3eaa97ea4f MapSurfaceCallback: workaround for coordinnate offset on Renault 5 2025-09-24 21:31:29 +02:00
Hosted WeblateandFjuro adaf2f0c87 Translated using Weblate (Czech)
Currently translated at 100.0% (374 of 374 strings)

Co-authored-by: Fjuro <fjuro@alius.cz>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/evmap/android/cs/
Translation: EVMap/Android
2025-09-24 21:08:59 +02:00
Hosted WeblateandPriit Jõerüüt 5802526d14 Translated using Weblate (Estonian)
Currently translated at 100.0% (374 of 374 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Priit Jõerüüt <jrthwlate@users.noreply.hosted.weblate.org>
Translate-URL: https://hosted.weblate.org/projects/evmap/android/et/
Translation: EVMap/Android
2025-09-24 21:08:56 +02:00
johan12345 fe731f71e8 update LocaleConfigX 2025-09-24 21:08:37 +02:00
johan12345 c22173b79e TeslaGuestApi: nullability fix 2025-09-24 21:08:37 +02:00
Robert Högberg 82a5730aed Enable NewMotion availability checks for Nobil 2025-09-23 21:51:02 +02:00
johan12345 3386092bf8 Revert "update AGP"
This reverts commit abf9165602.
2025-09-21 22:45:00 +02:00
johan12345 1318126780 Release 2.0.1 2025-09-21 22:35:04 +02:00
johan12345 abf9165602 update AGP 2025-09-21 22:35:04 +02:00
johan12345 2c35df6360 fix #390 2025-09-21 22:23:21 +02:00
johan12345 4ed046df7a trigger website update after release 2025-09-21 17:34:56 +02:00
johan12345 a20f25af17 add Nobil API key on CI 2025-09-21 17:10:26 +02:00
johan12345 b2a2114c88 Release 2.0.0 (first beta) 2025-09-21 16:52:30 +02:00
johan12345 c2896ade45 export licenses for Appning on CI 2025-09-21 16:52:30 +02:00
johan12345 45983bce7f add changelogs from 1.9.x branch 2025-09-21 16:36:00 +02:00
Hosted WeblateandRobert Högberg d0fffb1a97 Translated using Weblate (Swedish)
Currently translated at 99.7% (364 of 365 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Robert Högberg <robert.hogberg@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/evmap/android/sv/
Translation: EVMap/Android
2025-09-21 16:28:35 +02:00
johan12345 4819a10d03 add OSM taginfo.json
#97
2025-09-21 16:27:09 +02:00
johan12345 8a0f7e79f0 add .kotlin to .gitignore 2025-09-21 16:27:09 +02:00
johan12345 c727d9f1b8 delete removed chargers from DB during fullDownload
addresses #364 for data sources that use fullDownload (OSM, Nobil)
2025-09-21 15:52:13 +02:00
johan12345 e5d0ebbbb5 add German translations for Nobil strings 2025-09-21 15:52:13 +02:00
johan12345 ee4b5e7319 when using spatial index, explicitly specify the column 2025-09-21 15:52:13 +02:00
johan12345 fecde441f1 fix migration 26 2025-09-21 15:52:13 +02:00
Robert Högberg cb1543cb4a Adapt ChargeLocationsDaoTest to nobil changes 2025-09-21 15:52:13 +02:00
johan12345 276daac607 fix CI build 2025-09-21 15:52:13 +02:00
johan12345 f7d39a1ba5 fix DB migrations 2025-09-21 15:52:13 +02:00
Robert Högberg fa09b9188e Add support for full nobil data download
This improves the speed of the nobil implementation since all data
is cached on the device and it also reduces the load on the nobil server(s)
since fewer nobil requests are needed.
2025-09-21 15:52:13 +02:00
Robert Högberg b31e55f130 Add Tesla realtime for nobil
Copy-n-paste implementation that needs to be cleaned up.
2025-09-21 15:52:13 +02:00
Robert Högberg c494b0d5e2 Use EnBw realtime data for nobil 2025-09-21 15:52:13 +02:00
Robert Högberg 272b86ff88 Parse payment methods into Cost object description 2025-09-21 15:52:13 +02:00
Robert Högberg 32de28bc1c Add connector type filter 2025-09-21 15:52:13 +02:00
Robert Högberg 4cd6c44ba1 Add charge location accessibility to ChargeLocation and as filter 2025-09-21 15:52:13 +02:00
Robert Högberg 3265694c51 Add nobil api key handling in build.gradle.kts 2025-09-21 15:52:13 +02:00
Robert Högberg 529be2cc34 Hide share-charge-location-button if there's no URL for the location 2025-09-21 15:52:13 +02:00
Robert Högberg 00862b66a1 Add Chargelocation.dataSourceUrl and make ChargeLocation.url optional
Nobil has no suitable sites to individual charging stations so url needs
to be optional and then we use dataSourceUrl instead in "data source button".
2025-09-21 15:52:13 +02:00
Robert Högberg cabaa42772 Add evseId to class Chargepoint
.. and populate it in nobil data source
2025-09-21 15:52:13 +02:00
Robert Högberg 1663607171 Add URLs to edit nobil chargers
There's a web page for Swedish chargers, but we need to send email
for the other countries.
2025-09-21 15:52:13 +02:00
Robert Högberg 126c47bbc1 Add basic filters 2025-09-21 15:52:13 +02:00
Robert Högberg b93d01f96d Basic NOBIL implementation 2025-09-21 15:52:13 +02:00
7fb5df29e4 Translated using Weblate (German)
Currently translated at 100.0% (365 of 365 strings)

Translated using Weblate (German)

Currently translated at 100.0% (365 of 365 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Johan von Forstner <johan.forstner@gmail.com>
Co-authored-by: mcliquid <info@mcliquid.de>
Translate-URL: https://hosted.weblate.org/projects/evmap/android/de/
Translation: EVMap/Android
2025-09-20 17:08:58 +02:00
Hosted WeblateandFjuro b878d37982 Translated using Weblate (Czech)
Currently translated at 100.0% (365 of 365 strings)

Co-authored-by: Fjuro <fjuro@alius.cz>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/evmap/android/cs/
Translation: EVMap/Android
2025-09-14 19:25:14 +02:00
Hosted WeblateandPriit Jõerüüt 0f7aa44d8e Translated using Weblate (Estonian)
Currently translated at 100.0% (365 of 365 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Priit Jõerüüt <jrthwlate@users.noreply.hosted.weblate.org>
Translate-URL: https://hosted.weblate.org/projects/evmap/android/et/
Translation: EVMap/Android
2025-09-14 19:25:13 +02:00
johan12345 d8e1c36993 MapSurfaceCallback: Implement double-tap zoom 2025-09-09 23:47:04 +02:00
johan12345 03f613fa4b MapSurfaceCallback: Apply status bar offset on all AAOS systems 2025-09-09 23:47:04 +02:00
Robert Högberg aba533e553 Add Swedish translation 2025-09-09 22:20:45 +02:00
Robert Högberg 307af88f01 Separate connectors "type 2 socket" and "type 2 plug"
This avoids duplicate "Type 2" entries when using filters to select
connectors and when showing charger details.
2025-09-09 20:39:56 +02:00
johan12345 8478948d5f upgrade LocaleConfigX
possible fix for #386
2025-09-08 23:21:21 +02:00
200 changed files with 8386 additions and 6530 deletions

No files matched your search

+40 -5
View File
@@ -11,21 +11,24 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out code
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Set up Java environment
uses: actions/setup-java@v4
uses: actions/setup-java@v5
with:
java-version: 21
distribution: 'zulu'
cache: 'gradle'
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v6
- name: Decrypt keystore
run: openssl aes-256-cbc -K ${{ secrets.encrypted_53968681344a_key }} -iv ${{ secrets.encrypted_53968681344a_iv }} -in _ci/keystore.jks.enc -out _ci/keystore.jks -d
- name: Extract version code
run: echo "VERSION_CODE=$(grep -o "^\s*versionCode\s*=\s*[0-9]\+" app/build.gradle.kts | awk '{ print $3 }' | tr -d \''"\\')" >> $GITHUB_ENV
- name: Build app release & export libraries
- name: Build app release & export licenses
env:
EVMAP_API_KEY: ${{ secrets.EVMAP_API_KEY }}
GOINGELECTRIC_API_KEY: ${{ secrets.GOINGELECTRIC_API_KEY }}
OPENCHARGEMAP_API_KEY: ${{ secrets.OPENCHARGEMAP_API_KEY }}
CHARGEPRICE_API_KEY: ${{ secrets.CHARGEPRICE_API_KEY }}
@@ -35,10 +38,14 @@ jobs:
GOOGLE_MAPS_API_KEY: ${{ secrets.GOOGLE_MAPS_API_KEY }}
FRONYX_API_KEY: ${{ secrets.FRONYX_API_KEY }}
ACRA_CRASHREPORT_CREDENTIALS: ${{ secrets.ACRA_CRASHREPORT_CREDENTIALS }}
NOBIL_API_KEY: ${{ secrets.NOBIL_API_KEY }}
KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
KEYSTORE_ALIAS: ${{ secrets.KEYSTORE_ALIAS }}
KEYSTORE_ALIAS_PASSWORD: ${{ secrets.KEYSTORE_ALIAS_PASSWORD }}
run: ./gradlew exportLibraryDefinitions assembleRelease --no-daemon
run: ./gradlew exportLibraryDefinitions assembleRelease
- name: Export licenses in Appning format
run: python3 _ci/export_licenses_appning.py
- name: release
uses: actions/create-release@v1
@@ -97,3 +104,31 @@ jobs:
asset_path: app/build/generated/aboutLibraries/aboutlibraries.json
asset_name: aboutlibraries.json
asset_content_type: application/json
- name: upload Licenses Appning
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ github.token }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: licenses_fossAutomotiveRelease_appning.csv
asset_name: licenses_fossAutomotiveRelease_appning.csv
asset_content_type: text/csv
- name: upload Licenses Appning
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ github.token }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: licenses_fossNormalRelease_appning.csv
asset_name: licenses_fossNormalRelease_appning.csv
asset_content_type: text/csv
- name: Trigger Website update
run: |
curl -L \
-X POST \
-H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer ${{ github.token }}" \
-H "X-GitHub-Api-Version: 2022-11-28" \
https://api.github.com/repos/ev-map/ev-map.github.io/dispatches \
-d "{\"event_type\": \"trigger-workflow\"}"
+15 -11
View File
@@ -16,26 +16,28 @@ jobs:
buildvariant: [ FossNormal, FossAutomotive, GoogleNormal, GoogleAutomotive ]
steps:
- name: Check out code
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Set up Java environment
uses: actions/setup-java@v4
uses: actions/setup-java@v5
with:
java-version: 21
distribution: 'zulu'
cache: 'gradle'
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v6
- name: Copy apikeys.xml
run: cp _ci/apikeys-ci.xml app/src/main/res/values/apikeys.xml
- name: Build app
run: ./gradlew assemble${{ matrix.buildvariant }}Debug --no-daemon
run: ./gradlew assemble${{ matrix.buildvariant }}Debug
- name: Run unit tests
run: ./gradlew test${{ matrix.buildvariant }}DebugUnitTest --no-daemon
run: ./gradlew test${{ matrix.buildvariant }}DebugUnitTest
- name: Run Android Lint
run: ./gradlew lint${{ matrix.buildvariant }}Debug --no-daemon
run: ./gradlew lint${{ matrix.buildvariant }}Debug
- name: Check licenses
run: ./gradlew exportLibraryDefinitions --no-daemon
run: ./gradlew exportLibraryDefinitions
apk_check:
name: Release APK checks (${{ matrix.buildvariant }})
@@ -49,10 +51,10 @@ jobs:
run: sudo apt install -y checksec
- name: Check out code
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Set up Java environment
uses: actions/setup-java@v4
uses: actions/setup-java@v5
with:
java-version: 17
distribution: 'zulu'
@@ -62,7 +64,7 @@ jobs:
run: cp _ci/apikeys-ci.xml app/src/main/res/values/apikeys.xml
- name: Build app
run: ./gradlew assemble${{ matrix.buildvariant }}Release --no-daemon
run: ./gradlew assemble${{ matrix.buildvariant }}Release
- name: Unpack native libraries from APK
run: |
@@ -75,8 +77,10 @@ jobs:
run: |
checksec --output=json --dir=lib > checksec_output.json
jq --argjson exceptions '[
"lib/arm64-v8a/libc++_shared.so",
"lib/armeabi-v7a/libc++_shared.so",
"lib/x86/libc++_shared.so"
"lib/x86/libc++_shared.so",
"lib/x86_64/libc++_shared.so"
]' '
to_entries
| map(select(.value.fortify_source == "no" and (.key as $lib | $exceptions | index($lib) | not)))
+3 -1
View File
@@ -1,5 +1,6 @@
*.iml
.gradle
.kotlin
/local.properties
/.idea/*
.DS_Store
@@ -13,4 +14,5 @@ apikeys.xml
/_img/connectors/*.ai
api-7125266970515251116-798419-8e2dda660c80.json
output-metadata.json
licenses_*.csv
licenses_*.csv
gradle/gradle-daemon-jvm.properties
+144 -80
View File
@@ -2,31 +2,40 @@ GEM
remote: https://rubygems.org/
specs:
CFPropertyList (3.0.2)
addressable (2.8.0)
public_suffix (>= 2.0.2, < 5.0)
abbrev (0.1.2)
addressable (2.9.0)
public_suffix (>= 2.0.2, < 8.0)
artifactory (3.0.17)
atomos (0.1.3)
aws-eventstream (1.1.0)
aws-partitions (1.354.0)
aws-sdk-core (3.104.3)
aws-eventstream (~> 1, >= 1.0.2)
aws-partitions (~> 1, >= 1.239.0)
aws-sigv4 (~> 1.1)
jmespath (~> 1.0)
aws-sdk-kms (1.36.0)
aws-sdk-core (~> 3, >= 3.99.0)
aws-sigv4 (~> 1.1)
aws-sdk-s3 (1.78.0)
aws-sdk-core (~> 3, >= 3.104.3)
aws-eventstream (1.4.0)
aws-partitions (1.1196.0)
aws-sdk-core (3.240.0)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0)
aws-sigv4 (~> 1.9)
base64
bigdecimal
jmespath (~> 1, >= 1.6.1)
logger
aws-sdk-kms (1.118.0)
aws-sdk-core (~> 3, >= 3.239.1)
aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.208.0)
aws-sdk-core (~> 3, >= 3.234.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.1)
aws-sigv4 (1.2.1)
aws-sigv4 (~> 1.5)
aws-sigv4 (1.12.1)
aws-eventstream (~> 1, >= 1.0.2)
babosa (1.0.3)
base64 (0.3.0)
benchmark (0.5.0)
bigdecimal (4.0.1)
claide (1.0.3)
colored (1.2)
colored2 (3.1.2)
commander-fastlane (4.4.6)
highline (~> 1.7.2)
commander (4.6.0)
highline (~> 2.0.0)
csv (3.3.5)
declarative (0.0.20)
declarative-option (0.1.0)
digest-crc (0.6.1)
@@ -35,118 +44,174 @@ GEM
unf (>= 0.0.5, < 1.0.0)
dotenv (2.7.6)
emoji_regex (3.0.0)
excon (0.76.0)
faraday (1.0.1)
multipart-post (>= 1.2, < 3)
excon (1.5.0)
logger
faraday (1.10.6)
faraday-em_http (~> 1.0)
faraday-em_synchrony (~> 1.0)
faraday-excon (~> 1.1)
faraday-httpclient (~> 1.0)
faraday-multipart (~> 1.0)
faraday-net_http (~> 1.0)
faraday-net_http_persistent (~> 1.0)
faraday-patron (~> 1.0)
faraday-rack (~> 1.0)
faraday-retry (~> 1.0)
ruby2_keywords (>= 0.0.4)
faraday-cookie_jar (0.0.6)
faraday (>= 0.7.4)
http-cookie (~> 1.0.0)
faraday-em_http (1.0.0)
faraday-em_synchrony (1.0.1)
faraday-excon (1.1.0)
faraday-httpclient (1.0.1)
faraday-multipart (1.2.0)
multipart-post (~> 2.0)
faraday-net_http (1.0.2)
faraday-net_http_persistent (1.2.0)
faraday-patron (1.0.0)
faraday-rack (1.0.0)
faraday-retry (1.0.4)
faraday_middleware (1.0.0)
faraday (~> 1.0)
fastimage (2.2.0)
fastlane (2.156.1)
CFPropertyList (>= 2.3, < 4.0.0)
addressable (>= 2.3, < 3.0.0)
aws-sdk-s3 (~> 1.0)
fastlane (2.237.0)
CFPropertyList (>= 2.3, < 5.0.0)
abbrev (~> 0.1)
addressable (>= 2.9.0, < 3.0.0)
artifactory (~> 3.0)
aws-sdk-s3 (~> 1.197)
babosa (>= 1.0.3, < 2.0.0)
bundler (>= 1.12.0, < 3.0.0)
colored
commander-fastlane (>= 4.4.6, < 5.0.0)
base64 (~> 0.2)
benchmark (>= 0.1.0)
bundler (>= 2.4.0, < 5.0.0)
colored (~> 1.2)
commander (~> 4.6)
csv (~> 3.3)
dotenv (>= 2.1.1, < 3.0.0)
emoji_regex (>= 0.1, < 4.0)
excon (>= 0.71.0, < 1.0.0)
excon (>= 0.71.0, < 2.0.0)
faraday (~> 1.0)
faraday-cookie_jar (~> 0.0.6)
faraday_middleware (~> 1.0)
fastimage (>= 2.1.0, < 3.0.0)
fastlane-sirp (>= 1.1.0)
gh_inspector (>= 1.1.2, < 2.0.0)
google-api-client (>= 0.37.0, < 0.39.0)
google-cloud-storage (>= 1.15.0, < 2.0.0)
highline (>= 1.7.2, < 2.0.0)
google-apis-androidpublisher_v3 (~> 0.3)
google-apis-playcustomapp_v1 (~> 0.1)
google-cloud-env (>= 1.6.0, < 2.3.0)
google-cloud-storage (~> 1.31)
highline (~> 2.0)
http-cookie (~> 1.0.5)
json (< 3.0.0)
jwt (>= 2.1.0, < 3)
jwt (>= 2.10.3, < 4)
logger (>= 1.6, < 2.0)
mini_magick (>= 4.9.4, < 5.0.0)
multipart-post (~> 2.0.0)
multi_json (~> 1.12)
multipart-post (>= 2.0.0, < 3.0.0)
mutex_m (~> 0.3)
naturally (~> 2.2)
nkf (~> 0.2)
optparse (>= 0.1.1, < 1.0.0)
ostruct (>= 0.1.0)
plist (>= 3.1.0, < 4.0.0)
rubyzip (>= 2.0.0, < 3.0.0)
security (= 0.1.3)
security (= 0.1.5)
simctl (~> 1.6.3)
slack-notifier (>= 2.0.0, < 3.0.0)
terminal-notifier (>= 2.0.0, < 3.0.0)
terminal-table (>= 1.4.5, < 2.0.0)
terminal-table (~> 3)
tty-screen (>= 0.6.3, < 1.0.0)
tty-spinner (>= 0.8.0, < 1.0.0)
word_wrap (~> 1.0.0)
xcodeproj (>= 1.13.0, < 2.0.0)
xcpretty (~> 0.3.0)
xcpretty-travis-formatter (>= 0.0.3)
xcpretty (~> 0.4.1)
xcpretty-travis-formatter (>= 0.0.3, < 2.0.0)
fastlane-sirp (1.1.0)
gh_inspector (1.1.3)
google-api-client (0.38.0)
google-apis-androidpublisher_v3 (0.104.0)
google-apis-core (>= 0.15.0, < 2.a)
google-apis-core (0.18.0)
addressable (~> 2.5, >= 2.5.1)
googleauth (~> 0.9)
httpclient (>= 2.8.1, < 3.0)
googleauth (~> 1.9)
httpclient (>= 2.8.3, < 3.a)
mini_mime (~> 1.0)
mutex_m
representable (~> 3.0)
retriable (>= 2.0, < 4.0)
signet (~> 0.12)
google-cloud-core (1.5.0)
google-cloud-env (~> 1.0)
retriable (>= 2.0, < 4.a)
google-apis-iamcredentials_v1 (0.28.0)
google-apis-core (>= 0.15.0, < 2.a)
google-apis-playcustomapp_v1 (0.18.0)
google-apis-core (>= 0.15.0, < 2.a)
google-apis-storage_v1 (0.64.0)
google-apis-core (>= 0.15.0, < 2.a)
google-cloud-core (1.9.0)
google-cloud-env (>= 1.0, < 3.a)
google-cloud-errors (~> 1.0)
google-cloud-env (1.3.3)
faraday (>= 0.17.3, < 2.0)
google-cloud-env (2.2.2)
base64 (~> 0.2)
faraday (>= 1.0, < 3.a)
google-cloud-errors (1.0.1)
google-cloud-storage (1.27.0)
addressable (~> 2.5)
google-cloud-storage (1.62.0)
addressable (~> 2.8)
digest-crc (~> 0.4)
google-api-client (~> 0.33)
google-cloud-core (~> 1.2)
googleauth (~> 0.9)
google-apis-core (>= 0.18, < 2)
google-apis-iamcredentials_v1 (~> 0.18)
google-apis-storage_v1 (>= 0.42)
google-cloud-core (~> 1.6)
googleauth (~> 1.9)
mini_mime (~> 1.0)
googleauth (0.13.1)
faraday (>= 0.17.3, < 2.0)
jwt (>= 1.4, < 3.0)
memoist (~> 0.16)
multi_json (~> 1.11)
google-logging-utils (0.2.0)
googleauth (1.17.1)
faraday (>= 1.0, < 3.a)
google-cloud-env (~> 2.2)
google-logging-utils (~> 0.1)
jwt (>= 1.4, < 4.0)
os (>= 0.9, < 2.0)
signet (~> 0.14)
highline (1.7.10)
http-cookie (1.0.3)
pstore (~> 0.1)
signet (>= 0.16, < 2.a)
highline (2.0.3)
http-cookie (1.0.8)
domain_name (~> 0.5)
httpclient (2.8.3)
jmespath (1.6.1)
jmespath (1.6.2)
json (2.3.1)
jwt (2.2.1)
memoist (0.16.2)
jwt (3.2.0)
base64
logger (1.7.0)
mini_magick (4.10.1)
mini_mime (1.0.2)
multi_json (1.15.0)
multipart-post (2.0.0)
multipart-post (2.4.1)
mutex_m (0.3.0)
nanaimo (0.3.0)
naturally (2.2.0)
nkf (0.3.0)
optparse (0.8.1)
os (1.1.1)
ostruct (0.6.3)
plist (3.5.0)
public_suffix (4.0.6)
pstore (0.2.1)
public_suffix (7.0.5)
rake (13.0.1)
representable (3.0.4)
declarative (< 0.1.0)
declarative-option (< 0.2.0)
uber (< 0.2.0)
retriable (3.1.2)
rouge (2.0.7)
rouge (3.28.0)
ruby2_keywords (0.0.5)
rubyzip (2.3.0)
security (0.1.3)
signet (0.14.0)
addressable (~> 2.3)
faraday (>= 0.17.3, < 2.0)
jwt (>= 1.5, < 3.0)
multi_json (~> 1.10)
security (0.1.5)
signet (0.22.0)
addressable (~> 2.8)
faraday (>= 0.17.5, < 3.a)
jwt (>= 1.5, < 4.0)
simctl (1.6.8)
CFPropertyList
naturally
slack-notifier (2.3.2)
terminal-notifier (2.0.0)
terminal-table (1.8.0)
unicode-display_width (~> 1.1, >= 1.1.1)
terminal-table (3.0.2)
unicode-display_width (>= 1.1.1, < 3)
tty-cursor (0.7.1)
tty-screen (0.8.1)
tty-spinner (0.9.3)
@@ -155,7 +220,6 @@ GEM
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)
xcodeproj (1.18.0)
@@ -164,13 +228,13 @@ GEM
claide (>= 1.0.2, < 2.0)
colored2 (~> 3.1)
nanaimo (~> 0.3.0)
xcpretty (0.3.0)
rouge (~> 2.0.7)
xcpretty (0.4.1)
rouge (~> 3.28.0)
xcpretty-travis-formatter (1.0.0)
xcpretty (~> 0.2, >= 0.0.7)
PLATFORMS
x64-mingw32
x64-mingw-ucrt
x86_64-linux
DEPENDENCIES
+1 -1
View File
@@ -1,6 +1,6 @@
MIT License
Copyright (c) 2020-2024 Johan von Forstner and contributors
Copyright (c) 2020-2026 Johan von Forstner and contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
+2 -10
View File
@@ -20,7 +20,6 @@ Features
- Search for places
- Advanced filtering options, including saved filter profiles
- Favorites list, also with availability information
- Integrated price comparison using [Chargeprice.app](https://chargeprice.app) (only in Europe)
- Android Auto & Android Automotive OS integration
- No ads, fully open source
- Compatible with Android 5.0 and above
@@ -83,16 +82,9 @@ into new languages.
Sponsors
--------
Many users currently support the development EVMap with their donations. You can find more
Many users currently support the development of EVMap with their donations. You can find more
information on the [Donate page](https://ev-map.app/donate/) on the EVMap website.
<a href="https://www.jawg.io"><img src="https://www.jawg.io/static/Blue@10x-9cdc4596e4e59acbd9ead55e9c28613e.png" alt="JawgMaps" height="38"/></a><br>
Since May 2024, **JawgMaps** provides their OpenStreetMap vector map tiles service to EVMap for
Since May 2024, **JawgMaps** provide their OpenStreetMap vector map tiles service to EVMap for
free, i.e. the background map displayed in the app if OpenStreetMap is selected as the data source.
<a href="https://chargeprice.app"><img src="https://raw.githubusercontent.com/ev-map/EVMap/master/_img/powered_by_chargeprice.svg" alt="Powered by Chargeprice" height="38"/></a><br>
Since April 2021, **Chargeprice.app** provide their price comparison API at a greatly reduced
price for EVMap. This data is used in EVMap's price comparison feature.
<a href="https://www.jetbrains.com/community/opensource/"><img src="https://resources.jetbrains.com/storage/products/company/brand/logos/jetbrains.svg" alt="JetBrains logo" height="38"/></a><br>
As part of its support program for Open-source projects, **JetBrains** supports the development of EVMap since December 2023 with a license of their software suite.
+2 -1
View File
@@ -4,8 +4,9 @@
<string name="jawg_key" translatable="false">ci</string>
<string name="arcgis_key" translatable="false">ci</string>
<string name="goingelectric_key" translatable="false">ci</string>
<string name="chargeprice_key" translatable="false">ci</string>
<string name="openchargemap_key" translatable="false">ci</string>
<string name="nobil_key" translatable="false">ci</string>
<string name="fronyx_key" translatable="false">ci</string>
<string name="acra_credentials" translatable="false">ci:ci</string>
<string name="evmap_key" translatable="false">ci</string>
</resources>
@@ -4,13 +4,10 @@ import json
build_types = ["fossNormalRelease", "fossAutomotiveRelease"]
for build_type in build_types:
result = subprocess.run(["gradlew.bat", f"generateLibraryDefinitions{build_type.capitalize()}"],
capture_output=True)
data = json.load(
open(f"app/build/generated/aboutLibraries/{build_type}/res/raw/aboutlibraries.json"))
with open(f"licenses_{build_type}.csv", "w") as f:
with open(f"licenses_{build_type}_appning.csv", "w") as f:
f.write("component_name;license_title;license_url;public_repository;copyrights\n")
for lib in data["libraries"]:
license = data["licenses"][lib["licenses"][0]] if len(lib["licenses"]) > 0 else None
+14
View File
@@ -0,0 +1,14 @@
<svg id="vector" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<title>connector_mcs</title>
<path fill="#000000" d="M7.57,2.904A5.168,5.168 0,0 0,2.4 8.074a5.168,5.168 0,0 0,1.097 3.181l5.656,7.24A3.616,3.616 0,0 0,12 19.885a3.616,3.616 0,0 0,2.843 -1.389l5.656,-7.24 A5.168,5.168 0,0 0,21.6 8.074a5.168,5.168 0,0 0,-5.17 -5.17z" fill-opacity="0" stroke="#000" stroke-width="2" id="path_0"/>
<path d="M11.5 10.159a1.0 1.0 0 0 0 -1.0 1.0 1.0 1.0 0 0 0 1.0 1.0h1.0a1.0 1.0 0 0 0 1.0 -1.0 1.0 1.0 0 0 0 -1.0 -1.0z"/>
<circle fill="none" stroke="black" stroke-width="1px" cx="7.5" cy="8.074" r="2.9" />
<circle fill="none" stroke="black" stroke-width="1px" cx="16.5" cy="8.074" r="2.9" />
<circle cx="9" cy="13" r="1" />
<circle cx="15" cy="13" r="1" />
<circle fill="none" stroke="black" stroke-width="1px" cx="12" cy="15.5" r="1.8" />
</svg>

After

Width:  |  Height:  |  Size: 886 B

+231
View File
@@ -0,0 +1,231 @@
{
"data_format": 1,
"data_url": "https://raw.githubusercontent.com/ev-map/evmap/master/_misc/taginfo.json",
"data_updated": "20250921T140000Z",
"project": {
"name": "EVMap",
"description": "Find electric vehicle chargers comfortably using your Android phone.",
"project_url": "https://ev-map.app/",
"doc_url": "https://github.com/ev-map/evmap-osm",
"icon_url": "https://avatars.githubusercontent.com/u/115927597?s=32",
"contact_name": "Johan von Forstner",
"contact_email": "evmap@vonforst.net"
},
"tags": [
{
"key": "amenity",
"value": "charging_station",
"description": "Used to display charging stations."
},
{
"key": "name"
},
{
"key": "network"
},
{
"key": "authentication:none",
"value": "yes"
},
{
"key": "operator"
},
{
"key": "description"
},
{
"key": "website"
},
{
"key": "addr:city"
},
{
"key": "addr:country"
},
{
"key": "addr:postcode"
},
{
"key": "addr:street"
},
{
"key": "addr:housenumber"
},
{
"key": "addr:housename"
},
{
"key": "socket:type1"
},
{
"key": "socket:type1:output"
},
{
"key": "socket:type1_combo"
},
{
"key": "socket:type1_combo:output"
},
{
"key": "socket:type2"
},
{
"key": "socket:type2:output"
},
{
"key": "socket:type2_cable"
},
{
"key": "socket:type2_cable:output"
},
{
"key": "socket:type2_combo"
},
{
"key": "socket:type2_combo:output"
},
{
"key": "socket:chademo"
},
{
"key": "socket:chademo:output"
},
{
"key": "socket:tesla_standard"
},
{
"key": "socket:tesla_standard:output"
},
{
"key": "socket:tesla_supercharger"
},
{
"key": "socket:tesla_supercharger:output"
},
{
"key": "socket:tesla_supercharger_ccs"
},
{
"key": "socket:tesla_supercharger_ccs:output"
},
{
"key": "socket:cee_blue"
},
{
"key": "socket:cee_blue:output"
},
{
"key": "socket:cee_red_16a"
},
{
"key": "socket:cee_red_16a:output"
},
{
"key": "socket:cee_red_32a"
},
{
"key": "socket:cee_red_32a:output"
},
{
"key": "socket:cee_red_63a"
},
{
"key": "socket:cee_red_63a:output"
},
{
"key": "socket:cee_red_125a"
},
{
"key": "socket:cee_red_125a:output"
},
{
"key": "socket:schuko"
},
{
"key": "socket:schuko:output"
},
{
"key": "socket:sev1011_t13"
},
{
"key": "socket:sev1011_t13:output"
},
{
"key": "socket:sev1011_t15"
},
{
"key": "socket:sev1011_t15:output"
},
{
"key": "socket:sev1011_t23"
},
{
"key": "socket:sev1011_t23:output"
},
{
"key": "socket:sev1011_t25"
},
{
"key": "socket:sev1011_t25:output"
},
{
"key": "opening_hours",
"value": "24/7"
},
{
"key": "fee",
"value": "yes"
},
{
"key": "fee",
"value": "no"
},
{
"key": "parking:fee",
"value": "yes"
},
{
"key": "parking:fee",
"value": "no"
},
{
"key": "charge"
},
{
"key": "charge:conditional"
},
{
"key": "image"
},
{
"key": "image:0"
},
{
"key": "image:1"
},
{
"key": "image:2"
},
{
"key": "image:3"
},
{
"key": "image:4"
},
{
"key": "image:5"
},
{
"key": "image:6"
},
{
"key": "image:7"
},
{
"key": "image:8"
},
{
"key": "image:9"
}
]
}
+207 -204
View File
@@ -1,14 +1,15 @@
import com.android.build.api.dsl.ApplicationBaseFlavor
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import java.util.Base64
plugins {
id("com.adarshr.test-logger") version "4.0.0"
id("com.android.application")
id("kotlin-android")
id("kotlin-parcelize")
id("kotlin-kapt")
id("com.google.devtools.ksp").version("2.0.21-1.0.28")
id("androidx.navigation.safeargs.kotlin")
id("com.mikepenz.aboutlibraries.plugin")
alias(libs.plugins.test.logger)
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.parcelize)
alias(libs.plugins.ksp)
alias(libs.plugins.moshix)
alias(libs.plugins.navigation.safeargs)
alias(libs.plugins.aboutlibraries)
}
@@ -18,17 +19,15 @@ android {
defaultConfig {
applicationId = "net.vonforst.evmap"
compileSdk = 36
minSdk = 21
minSdk = 23
targetSdk = 36
// NOTE: always increase versionCode by 2 since automotive flavor uses versionCode + 1
versionCode = 230
versionName = "1.9.6"
versionCode = 280
versionName = "2.1.4"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
ksp {
arg("room.schemaLocation", "$projectDir/schemas")
addApiKeys()
}
val isRunningOnCI = System.getenv("CI") == "true"
@@ -83,6 +82,7 @@ android {
create("google") {
dimension = "dependencies"
versionNameSuffix = "-google"
addGoogleApiKey()
}
create("normal") {
dimension = "automotive"
@@ -102,14 +102,10 @@ android {
sourceCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_17.toString()
}
buildFeatures {
dataBinding = true
viewBinding = true
buildConfig = true
resValues = true
}
lint {
disable += listOf("NullSafeMutableLiveData")
@@ -127,101 +123,6 @@ android {
namespace = "net.vonforst.evmap"
// add API keys from environment variable if not set in apikeys.xml
applicationVariants.all {
val goingelectricKey =
System.getenv("GOINGELECTRIC_API_KEY") ?: project.findProperty("GOINGELECTRIC_API_KEY")
?.toString()
if (goingelectricKey != null) {
resValue("string", "goingelectric_key", goingelectricKey)
}
var openchargemapKey =
System.getenv("OPENCHARGEMAP_API_KEY") ?: project.findProperty("OPENCHARGEMAP_API_KEY")
?.toString()
if (openchargemapKey == null && project.hasProperty("OPENCHARGEMAP_API_KEY_ENCRYPTED")) {
openchargemapKey = decode(
project.findProperty("OPENCHARGEMAP_API_KEY_ENCRYPTED").toString(),
"FmK.d,-f*p+rD+WK!eds"
)
}
if (openchargemapKey != null) {
resValue("string", "openchargemap_key", openchargemapKey)
}
val googleMapsKey =
System.getenv("GOOGLE_MAPS_API_KEY") ?: project.findProperty("GOOGLE_MAPS_API_KEY")
?.toString()
if (googleMapsKey != null && flavorName.startsWith("google")) {
resValue("string", "google_maps_key", googleMapsKey)
}
var mapboxKey =
System.getenv("MAPBOX_API_KEY") ?: project.findProperty("MAPBOX_API_KEY")?.toString()
if (mapboxKey == null && project.hasProperty("MAPBOX_API_KEY_ENCRYPTED")) {
mapboxKey = decode(
project.findProperty("MAPBOX_API_KEY_ENCRYPTED").toString(),
"FmK.d,-f*p+rD+WK!eds"
)
}
if (mapboxKey != null) {
resValue("string", "mapbox_key", mapboxKey)
}
var jawgKey =
System.getenv("JAWG_API_KEY") ?: project.findProperty("JAWG_API_KEY")?.toString()
if (jawgKey == null && project.hasProperty("JAWG_API_KEY_ENCRYPTED")) {
jawgKey = decode(
project.findProperty("JAWG_API_KEY_ENCRYPTED").toString(),
"FmK.d,-f*p+rD+WK!eds"
)
}
if (jawgKey != null) {
resValue("string", "jawg_key", jawgKey)
}
var arcgisKey =
System.getenv("ARCGIS_API_KEY") ?: project.findProperty("ARCGIS_API_KEY")?.toString()
if (arcgisKey == null && project.hasProperty("ARCGIS_API_KEY_ENCRYPTED")) {
arcgisKey = decode(
project.findProperty("ARCGIS_API_KEY_ENCRYPTED").toString(),
"FmK.d,-f*p+rD+WK!eds"
)
}
if (arcgisKey != null) {
resValue("string", "arcgis_key", jawgKey)
}
var chargepriceKey =
System.getenv("CHARGEPRICE_API_KEY") ?: project.findProperty("CHARGEPRICE_API_KEY")
?.toString()
if (chargepriceKey == null && project.hasProperty("CHARGEPRICE_API_KEY_ENCRYPTED")) {
chargepriceKey = decode(
project.findProperty("CHARGEPRICE_API_KEY_ENCRYPTED").toString(),
"FmK.d,-f*p+rD+WK!eds"
)
}
if (chargepriceKey != null) {
resValue("string", "chargeprice_key", chargepriceKey)
}
var fronyxKey =
System.getenv("FRONYX_API_KEY") ?: project.findProperty("FRONYX_API_KEY")?.toString()
if (fronyxKey == null && project.hasProperty("FRONYX_API_KEY_ENCRYPTED")) {
fronyxKey = decode(
project.findProperty("FRONYX_API_KEY_ENCRYPTED").toString(),
"FmK.d,-f*p+rD+WK!eds"
)
}
if (fronyxKey != null) {
resValue("string", "fronyx_key", fronyxKey)
}
var acraKey = System.getenv("ACRA_CRASHREPORT_CREDENTIALS")
?: project.findProperty("ACRA_CRASHREPORT_CREDENTIALS")?.toString()
if (acraKey == null && project.hasProperty("ACRA_CRASHREPORT_CREDENTIALS_ENCRYPTED")) {
acraKey = decode(
project.findProperty("ACRA_CRASHREPORT_CREDENTIALS_ENCRYPTED").toString(),
"FmK.d,-f*p+rD+WK!eds"
)
}
if (acraKey != null) {
resValue("string", "acra_credentials", acraKey)
}
}
packaging {
jniLibs {
pickFirsts.addAll(
@@ -236,6 +137,126 @@ android {
}
}
/**
* add API keys from environment variable if not set in apikeys.xml
*/
fun ApplicationBaseFlavor.addApiKeys() {
var evmapKey =
System.getenv("EVMAP_API_KEY") ?: project.findProperty("EVMAP_API_KEY")?.toString()
if (evmapKey == null && project.hasProperty("EVMAP_API_KEY_ENCRYPTED")) {
evmapKey = decode(
project.findProperty("EVMAP_API_KEY_ENCRYPTED").toString(),
"FmK.d,-f*p+rD+WK!eds"
)
}
if (evmapKey != null) {
resValue("string", "evmap_key", evmapKey)
}
val goingelectricKey =
System.getenv("GOINGELECTRIC_API_KEY") ?: project.findProperty("GOINGELECTRIC_API_KEY")
?.toString()
if (goingelectricKey != null) {
resValue("string", "goingelectric_key", goingelectricKey)
}
var nobilKey =
System.getenv("NOBIL_API_KEY") ?: project.findProperty("NOBIL_API_KEY")?.toString()
if (nobilKey == null && project.hasProperty("NOBIL_API_KEY_ENCRYPTED")) {
nobilKey = decode(
project.findProperty("NOBIL_API_KEY_ENCRYPTED").toString(),
"FmK.d,-f*p+rD+WK!eds"
)
}
if (nobilKey != null) {
resValue("string", "nobil_key", nobilKey)
}
var openchargemapKey =
System.getenv("OPENCHARGEMAP_API_KEY") ?: project.findProperty("OPENCHARGEMAP_API_KEY")
?.toString()
if (openchargemapKey == null && project.hasProperty("OPENCHARGEMAP_API_KEY_ENCRYPTED")) {
openchargemapKey = decode(
project.findProperty("OPENCHARGEMAP_API_KEY_ENCRYPTED").toString(),
"FmK.d,-f*p+rD+WK!eds"
)
}
if (openchargemapKey != null) {
resValue("string", "openchargemap_key", openchargemapKey)
}
var mapboxKey =
System.getenv("MAPBOX_API_KEY") ?: project.findProperty("MAPBOX_API_KEY")?.toString()
if (mapboxKey == null && project.hasProperty("MAPBOX_API_KEY_ENCRYPTED")) {
mapboxKey = decode(
project.findProperty("MAPBOX_API_KEY_ENCRYPTED").toString(),
"FmK.d,-f*p+rD+WK!eds"
)
}
if (mapboxKey != null) {
resValue("string", "mapbox_key", mapboxKey)
}
var jawgKey =
System.getenv("JAWG_API_KEY") ?: project.findProperty("JAWG_API_KEY")?.toString()
if (jawgKey == null && project.hasProperty("JAWG_API_KEY_ENCRYPTED")) {
jawgKey = decode(
project.findProperty("JAWG_API_KEY_ENCRYPTED").toString(),
"FmK.d,-f*p+rD+WK!eds"
)
}
if (jawgKey != null) {
resValue("string", "jawg_key", jawgKey)
}
var arcgisKey =
System.getenv("ARCGIS_API_KEY") ?: project.findProperty("ARCGIS_API_KEY")?.toString()
if (arcgisKey == null && project.hasProperty("ARCGIS_API_KEY_ENCRYPTED")) {
arcgisKey = decode(
project.findProperty("ARCGIS_API_KEY_ENCRYPTED").toString(),
"FmK.d,-f*p+rD+WK!eds"
)
}
if (arcgisKey != null) {
resValue("string", "arcgis_key", arcgisKey)
}
var fronyxKey =
System.getenv("FRONYX_API_KEY") ?: project.findProperty("FRONYX_API_KEY")?.toString()
if (fronyxKey == null && project.hasProperty("FRONYX_API_KEY_ENCRYPTED")) {
fronyxKey = decode(
project.findProperty("FRONYX_API_KEY_ENCRYPTED").toString(),
"FmK.d,-f*p+rD+WK!eds"
)
}
if (fronyxKey != null) {
resValue("string", "fronyx_key", fronyxKey)
}
var acraKey = System.getenv("ACRA_CRASHREPORT_CREDENTIALS")
?: project.findProperty("ACRA_CRASHREPORT_CREDENTIALS")?.toString()
if (acraKey == null && project.hasProperty("ACRA_CRASHREPORT_CREDENTIALS_ENCRYPTED")) {
acraKey = decode(
project.findProperty("ACRA_CRASHREPORT_CREDENTIALS_ENCRYPTED").toString(),
"FmK.d,-f*p+rD+WK!eds"
)
}
if (acraKey != null) {
resValue("string", "acra_credentials", acraKey)
}
}
fun ApplicationBaseFlavor.addGoogleApiKey() {
val googleMapsKey =
System.getenv("GOOGLE_MAPS_API_KEY") ?: project.findProperty("GOOGLE_MAPS_API_KEY")
?.toString()
if (googleMapsKey != null) {
resValue("string", "google_maps_key", googleMapsKey)
}
}
kotlin {
compilerOptions {
jvmTarget = JvmTarget.fromTarget("17")
}
}
ksp {
arg("room.schemaLocation", "$projectDir/schemas")
}
androidComponents {
beforeVariants { variantBuilder ->
if (variantBuilder.buildType == "releaseAutomotivePackageName"
@@ -276,128 +297,110 @@ aboutLibraries {
}
dependencies {
val kotlinVersion: String by rootProject.extra
val aboutLibsVersion: String by rootProject.extra
val navVersion: String by rootProject.extra
val normalImplementation by configurations
val googleImplementation by configurations
val automotiveImplementation by configurations
val fossImplementation by configurations
val testGoogleImplementation by configurations
implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlinVersion")
implementation("androidx.appcompat:appcompat:1.7.1")
implementation("androidx.core:core-ktx:1.17.0")
implementation("androidx.core:core-splashscreen:1.0.1")
implementation("androidx.activity:activity-ktx:1.10.1")
implementation("androidx.fragment:fragment-ktx:1.8.9")
implementation("androidx.cardview:cardview:1.0.0")
implementation("androidx.preference:preference-ktx:1.2.1")
implementation("com.google.android.material:material:1.13.0-rc01")
implementation("androidx.constraintlayout:constraintlayout:2.2.1")
implementation("androidx.recyclerview:recyclerview:1.4.0")
implementation("androidx.browser:browser:1.9.0")
implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0")
implementation("androidx.viewpager2:viewpager2:1.1.0")
implementation("androidx.security:security-crypto:1.1.0")
implementation("androidx.work:work-runtime-ktx:2.10.3")
implementation("com.github.ev-map:CustomBottomSheetBehavior:e48f73ea7b")
implementation("com.squareup.retrofit2:retrofit:3.0.0")
implementation("com.squareup.retrofit2:converter-moshi:3.0.0")
implementation("com.squareup.okhttp3:okhttp:4.12.0")
implementation("com.squareup.okhttp3:okhttp-urlconnection:4.12.0")
implementation("com.squareup.moshi:moshi-kotlin:1.15.2")
implementation("com.squareup.moshi:moshi-adapters:1.15.2")
implementation("com.markomilos.jsonapi:jsonapi-retrofit:1.1.0")
implementation("io.coil-kt:coil:2.7.0")
implementation("com.github.ev-map:StfalconImageViewer:5082ebd392")
implementation("com.mikepenz:aboutlibraries-core:$aboutLibsVersion")
implementation("com.mikepenz:aboutlibraries:$aboutLibsVersion")
implementation("com.airbnb.android:lottie:6.6.7")
implementation("io.michaelrocks.bimap:bimap:1.1.0")
implementation("com.github.pengrad:mapscaleview:1.6.0")
implementation("com.github.romandanylyk:PageIndicatorView:b1bad589b5")
implementation("com.github.erfansn:locale-config-x:1.0.1")
implementation(libs.kotlin.stdlib.jdk7)
implementation(libs.androidx.appcompat)
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.core.splashscreen)
implementation(libs.androidx.activity.ktx)
implementation(libs.androidx.fragment.ktx)
implementation(libs.androidx.cardview)
implementation(libs.androidx.preference.ktx)
implementation(libs.material)
implementation(libs.androidx.constraintlayout)
implementation(libs.androidx.recyclerview)
implementation(libs.androidx.browser)
implementation(libs.androidx.swiperefreshlayout)
implementation(libs.androidx.viewpager2)
implementation(libs.androidx.security.crypto)
implementation(libs.androidx.work.runtime.ktx)
implementation(libs.custom.bottom.sheet.behavior)
implementation(libs.retrofit)
implementation(libs.retrofit.converter.moshi)
implementation(libs.okhttp)
implementation(libs.okhttp.urlconnection)
implementation(libs.moshi.kotlin)
implementation(libs.moshi.adapters)
implementation(libs.coil)
implementation(libs.stfalcon.image.viewer)
implementation(libs.aboutlibraries.core)
implementation(libs.aboutlibraries)
implementation(libs.lottie)
implementation(libs.bimap)
implementation(libs.mapscaleview)
implementation(libs.page.indicator.view)
implementation(libs.locale.config.x)
// Android Auto
val carAppVersion = "1.7.0"
implementation("androidx.car.app:app:$carAppVersion")
normalImplementation("androidx.car.app:app-projected:$carAppVersion")
automotiveImplementation("androidx.car.app:app-automotive:$carAppVersion")
implementation(libs.androidx.car.app)
normalImplementation(libs.androidx.car.app.projected)
automotiveImplementation(libs.androidx.car.app.automotive)
// AnyMaps
val anyMapsVersion = "1174ef9375"
implementation("com.github.ev-map.AnyMaps:anymaps-base:$anyMapsVersion")
googleImplementation("com.github.ev-map.AnyMaps:anymaps-google:$anyMapsVersion")
googleImplementation("com.google.android.gms:play-services-maps:19.2.0")
implementation("com.github.ev-map.AnyMaps:anymaps-maplibre:$anyMapsVersion") {
// duplicates classes from mapbox-sdk-services
exclude("org.maplibre.gl", "android-sdk-geojson")
}
implementation("org.maplibre.gl:android-sdk:10.3.5") {
exclude("org.maplibre.gl", "android-sdk-geojson")
implementation(libs.anymaps.base)
googleImplementation(libs.anymaps.google)
googleImplementation(libs.play.services.maps)
implementation(libs.anymaps.maplibre) {
// exclude default (Vulkan) version and use Vulkan + OpenGL ES 3.0 version for better compatibility
exclude("org.maplibre.gl", "android-sdk")
}
implementation(libs.maplibre.android.sdk.vulkan.opengl)
// Google Places
googleImplementation("com.google.android.libraries.places:places:3.5.0")
googleImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.10.2")
googleImplementation(libs.places)
googleImplementation(libs.kotlinx.coroutines.play.services)
// Mapbox Geocoding
implementation("com.mapbox.mapboxsdk:mapbox-sdk-services:5.8.0")
implementation(libs.mapbox.sdk.services)
// navigation library
implementation("androidx.navigation:navigation-fragment-ktx:$navVersion")
implementation("androidx.navigation:navigation-ui-ktx:$navVersion")
implementation(libs.androidx.navigation.fragment.ktx)
implementation(libs.androidx.navigation.ui.ktx)
// viewmodel library
val lifecycleVersion = "2.9.2"
implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycleVersion")
implementation("androidx.lifecycle:lifecycle-livedata-ktx:$lifecycleVersion")
implementation(libs.androidx.lifecycle.viewmodel.ktx)
implementation(libs.androidx.lifecycle.livedata.ktx)
// room library
val roomVersion = "2.7.2"
implementation("androidx.room:room-runtime:$roomVersion")
ksp("androidx.room:room-compiler:$roomVersion")
implementation("androidx.room:room-ktx:$roomVersion")
implementation("com.github.anboralabs:spatia-room:0.3.0") {
implementation(libs.androidx.room.runtime)
ksp(libs.androidx.room.compiler)
implementation(libs.androidx.room.ktx)
implementation(libs.spatia.room) {
exclude("com.github.dalgarins", "android-spatialite")
}
// forked version with upgraded sqlite & libxml & 16 KB page size support
// https://github.com/dalgarins/android-spatialite/pull/11
// https://github.com/dalgarins/android-spatialite/pull/12
implementation("io.github.ev-map:android-spatialite:2.2.1-alpha")
// forked version with upgraded libxml 2.15.2 & APP_STL := c++_static
implementation(libs.android.spatialite)
// billing library
val billingVersion = "7.0.0"
googleImplementation("com.android.billingclient:billing:$billingVersion")
googleImplementation("com.android.billingclient:billing-ktx:$billingVersion")
googleImplementation(libs.billing)
googleImplementation(libs.billing.ktx)
// ACRA (crash reporting)
val acraVersion = "5.12.0"
implementation("ch.acra:acra-http:$acraVersion")
implementation("ch.acra:acra-dialog:$acraVersion")
implementation("ch.acra:acra-limiter:$acraVersion")
implementation(libs.acra.http)
implementation(libs.acra.dialog)
implementation(libs.acra.limiter)
// debug tools
debugImplementation("com.jakewharton.timber:timber:5.0.1")
debugImplementation("com.squareup.leakcanary:leakcanary-android:2.14")
debugImplementation(libs.timber)
debugImplementation(libs.leakcanary.android)
// testing
testImplementation("junit:junit:4.13.2")
testImplementation("com.squareup.okhttp3:mockwebserver:4.12.0")
testImplementation(libs.junit4)
testImplementation(libs.okhttp.mockwebserver)
//noinspection GradleDependency
testImplementation("org.robolectric:robolectric:4.16-beta-1")
testImplementation("androidx.test:core:1.7.0")
testImplementation("androidx.arch.core:core-testing:2.2.0")
testImplementation("androidx.car.app:app-testing:$carAppVersion")
testImplementation(libs.robolectric)
testImplementation(libs.androidx.test.core)
testImplementation(libs.androidx.arch.core.testing)
testImplementation(libs.androidx.car.app.testing)
androidTestImplementation("androidx.test.ext:junit:1.3.0")
androidTestImplementation("androidx.test.espresso:espresso-core:3.7.0")
androidTestImplementation("androidx.arch.core:core-testing:2.2.0")
androidTestImplementation(libs.androidx.test.ext.junit)
androidTestImplementation(libs.androidx.test.espresso.core)
androidTestImplementation(libs.androidx.arch.core.testing)
ksp("com.squareup.moshi:moshi-kotlin-codegen:1.15.2")
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.5")
coreLibraryDesugaring(libs.desugar.jdk.libs)
}
fun decode(s: String, key: String): String {
@@ -41,8 +41,7 @@
{
"fieldPath": "network",
"columnName": "network",
"affinity": "TEXT",
"notNull": false
"affinity": "TEXT"
},
{
"fieldPath": "url",
@@ -53,8 +52,7 @@
{
"fieldPath": "editUrl",
"columnName": "editUrl",
"affinity": "TEXT",
"notNull": false
"affinity": "TEXT"
},
{
"fieldPath": "verified",
@@ -65,62 +63,52 @@
{
"fieldPath": "barrierFree",
"columnName": "barrierFree",
"affinity": "INTEGER",
"notNull": false
"affinity": "INTEGER"
},
{
"fieldPath": "operator",
"columnName": "operator",
"affinity": "TEXT",
"notNull": false
"affinity": "TEXT"
},
{
"fieldPath": "generalInformation",
"columnName": "generalInformation",
"affinity": "TEXT",
"notNull": false
"affinity": "TEXT"
},
{
"fieldPath": "amenities",
"columnName": "amenities",
"affinity": "TEXT",
"notNull": false
"affinity": "TEXT"
},
{
"fieldPath": "locationDescription",
"columnName": "locationDescription",
"affinity": "TEXT",
"notNull": false
"affinity": "TEXT"
},
{
"fieldPath": "photos",
"columnName": "photos",
"affinity": "TEXT",
"notNull": false
"affinity": "TEXT"
},
{
"fieldPath": "chargecards",
"columnName": "chargecards",
"affinity": "TEXT",
"notNull": false
"affinity": "TEXT"
},
{
"fieldPath": "license",
"columnName": "license",
"affinity": "TEXT",
"notNull": false
"affinity": "TEXT"
},
{
"fieldPath": "networkUrl",
"columnName": "networkUrl",
"affinity": "TEXT",
"notNull": false
"affinity": "TEXT"
},
{
"fieldPath": "chargerUrl",
"columnName": "chargerUrl",
"affinity": "TEXT",
"notNull": false
"affinity": "TEXT"
},
{
"fieldPath": "timeRetrieved",
@@ -143,188 +131,157 @@
{
"fieldPath": "address.city",
"columnName": "city",
"affinity": "TEXT",
"notNull": false
"affinity": "TEXT"
},
{
"fieldPath": "address.country",
"columnName": "country",
"affinity": "TEXT",
"notNull": false
"affinity": "TEXT"
},
{
"fieldPath": "address.postcode",
"columnName": "postcode",
"affinity": "TEXT",
"notNull": false
"affinity": "TEXT"
},
{
"fieldPath": "address.street",
"columnName": "street",
"affinity": "TEXT",
"notNull": false
"affinity": "TEXT"
},
{
"fieldPath": "faultReport.created",
"columnName": "fault_report_created",
"affinity": "INTEGER",
"notNull": false
"affinity": "INTEGER"
},
{
"fieldPath": "faultReport.description",
"columnName": "fault_report_description",
"affinity": "TEXT",
"notNull": false
"affinity": "TEXT"
},
{
"fieldPath": "openinghours.twentyfourSeven",
"columnName": "twentyfourSeven",
"affinity": "INTEGER",
"notNull": false
"affinity": "INTEGER"
},
{
"fieldPath": "openinghours.description",
"columnName": "description",
"affinity": "TEXT",
"notNull": false
"affinity": "TEXT"
},
{
"fieldPath": "openinghours.days.monday.start",
"columnName": "mostart",
"affinity": "TEXT",
"notNull": false
"affinity": "TEXT"
},
{
"fieldPath": "openinghours.days.monday.end",
"columnName": "moend",
"affinity": "TEXT",
"notNull": false
"affinity": "TEXT"
},
{
"fieldPath": "openinghours.days.tuesday.start",
"columnName": "tustart",
"affinity": "TEXT",
"notNull": false
"affinity": "TEXT"
},
{
"fieldPath": "openinghours.days.tuesday.end",
"columnName": "tuend",
"affinity": "TEXT",
"notNull": false
"affinity": "TEXT"
},
{
"fieldPath": "openinghours.days.wednesday.start",
"columnName": "westart",
"affinity": "TEXT",
"notNull": false
"affinity": "TEXT"
},
{
"fieldPath": "openinghours.days.wednesday.end",
"columnName": "weend",
"affinity": "TEXT",
"notNull": false
"affinity": "TEXT"
},
{
"fieldPath": "openinghours.days.thursday.start",
"columnName": "thstart",
"affinity": "TEXT",
"notNull": false
"affinity": "TEXT"
},
{
"fieldPath": "openinghours.days.thursday.end",
"columnName": "thend",
"affinity": "TEXT",
"notNull": false
"affinity": "TEXT"
},
{
"fieldPath": "openinghours.days.friday.start",
"columnName": "frstart",
"affinity": "TEXT",
"notNull": false
"affinity": "TEXT"
},
{
"fieldPath": "openinghours.days.friday.end",
"columnName": "frend",
"affinity": "TEXT",
"notNull": false
"affinity": "TEXT"
},
{
"fieldPath": "openinghours.days.saturday.start",
"columnName": "sastart",
"affinity": "TEXT",
"notNull": false
"affinity": "TEXT"
},
{
"fieldPath": "openinghours.days.saturday.end",
"columnName": "saend",
"affinity": "TEXT",
"notNull": false
"affinity": "TEXT"
},
{
"fieldPath": "openinghours.days.sunday.start",
"columnName": "sustart",
"affinity": "TEXT",
"notNull": false
"affinity": "TEXT"
},
{
"fieldPath": "openinghours.days.sunday.end",
"columnName": "suend",
"affinity": "TEXT",
"notNull": false
"affinity": "TEXT"
},
{
"fieldPath": "openinghours.days.holiday.start",
"columnName": "hostart",
"affinity": "TEXT",
"notNull": false
"affinity": "TEXT"
},
{
"fieldPath": "openinghours.days.holiday.end",
"columnName": "hoend",
"affinity": "TEXT",
"notNull": false
"affinity": "TEXT"
},
{
"fieldPath": "cost.freecharging",
"columnName": "freecharging",
"affinity": "INTEGER",
"notNull": false
"affinity": "INTEGER"
},
{
"fieldPath": "cost.freeparking",
"columnName": "freeparking",
"affinity": "INTEGER",
"notNull": false
"affinity": "INTEGER"
},
{
"fieldPath": "cost.descriptionShort",
"columnName": "descriptionShort",
"affinity": "TEXT",
"notNull": false
"affinity": "TEXT"
},
{
"fieldPath": "cost.descriptionLong",
"columnName": "descriptionLong",
"affinity": "TEXT",
"notNull": false
"affinity": "TEXT"
},
{
"fieldPath": "chargepriceData.country",
"columnName": "chargepricecountry",
"affinity": "TEXT",
"notNull": false
"affinity": "TEXT"
},
{
"fieldPath": "chargepriceData.network",
"columnName": "chargepricenetwork",
"affinity": "TEXT",
"notNull": false
"affinity": "TEXT"
},
{
"fieldPath": "chargepriceData.plugTypes",
"columnName": "chargepriceplugTypes",
"affinity": "TEXT",
"notNull": false
"affinity": "TEXT"
}
],
"primaryKey": {
@@ -333,9 +290,7 @@
"id",
"dataSource"
]
},
"indices": [],
"foreignKeys": []
}
},
{
"tableName": "Favorite",
@@ -642,8 +597,7 @@
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_FilterProfile_dataSource_name` ON `${TABLE_NAME}` (`dataSource`, `name`)"
}
],
"foreignKeys": []
]
},
{
"tableName": "RecentAutocompletePlace",
@@ -688,8 +642,7 @@
{
"fieldPath": "viewport",
"columnName": "viewport",
"affinity": "TEXT",
"notNull": false
"affinity": "TEXT"
},
{
"fieldPath": "types",
@@ -704,9 +657,7 @@
"id",
"dataSource"
]
},
"indices": [],
"foreignKeys": []
}
},
{
"tableName": "GEPlug",
@@ -724,9 +675,7 @@
"columnNames": [
"name"
]
},
"indices": [],
"foreignKeys": []
}
},
{
"tableName": "GENetwork",
@@ -744,9 +693,7 @@
"columnNames": [
"name"
]
},
"indices": [],
"foreignKeys": []
}
},
{
"tableName": "GEChargeCard",
@@ -776,9 +723,7 @@
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
}
},
{
"tableName": "OCMConnectionType",
@@ -799,20 +744,17 @@
{
"fieldPath": "formalName",
"columnName": "formalName",
"affinity": "TEXT",
"notNull": false
"affinity": "TEXT"
},
{
"fieldPath": "discontinued",
"columnName": "discontinued",
"affinity": "INTEGER",
"notNull": false
"affinity": "INTEGER"
},
{
"fieldPath": "obsolete",
"columnName": "obsolete",
"affinity": "INTEGER",
"notNull": false
"affinity": "INTEGER"
}
],
"primaryKey": {
@@ -820,9 +762,7 @@
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
}
},
{
"tableName": "OCMCountry",
@@ -843,8 +783,7 @@
{
"fieldPath": "continentCode",
"columnName": "continentCode",
"affinity": "TEXT",
"notNull": false
"affinity": "TEXT"
},
{
"fieldPath": "title",
@@ -858,9 +797,7 @@
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
}
},
{
"tableName": "OCMOperator",
@@ -875,8 +812,7 @@
{
"fieldPath": "websiteUrl",
"columnName": "websiteUrl",
"affinity": "TEXT",
"notNull": false
"affinity": "TEXT"
},
{
"fieldPath": "title",
@@ -887,20 +823,17 @@
{
"fieldPath": "contactEmail",
"columnName": "contactEmail",
"affinity": "TEXT",
"notNull": false
"affinity": "TEXT"
},
{
"fieldPath": "contactTelephone1",
"columnName": "contactTelephone1",
"affinity": "TEXT",
"notNull": false
"affinity": "TEXT"
},
{
"fieldPath": "contactTelephone2",
"columnName": "contactTelephone2",
"affinity": "TEXT",
"notNull": false
"affinity": "TEXT"
}
],
"primaryKey": {
@@ -908,9 +841,7 @@
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
}
},
{
"tableName": "OSMNetwork",
@@ -928,9 +859,7 @@
"columnNames": [
"name"
]
},
"indices": [],
"foreignKeys": []
}
},
{
"tableName": "SavedRegion",
@@ -957,8 +886,7 @@
{
"fieldPath": "filters",
"columnName": "filters",
"affinity": "TEXT",
"notNull": false
"affinity": "TEXT"
},
{
"fieldPath": "isDetailed",
@@ -969,8 +897,7 @@
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": false
"affinity": "INTEGER"
}
],
"primaryKey": {
@@ -990,11 +917,9 @@
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_SavedRegion_filters_dataSource` ON `${TABLE_NAME}` (`filters`, `dataSource`)"
}
],
"foreignKeys": []
]
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'b2b3f39d450f4f7c8280ca850161bbb3')"
@@ -0,0 +1,938 @@
{
"formatVersion": 1,
"database": {
"version": 27,
"identityHash": "84f71cce385c444726ba336834ddf6b4",
"entities": [
{
"tableName": "ChargeLocation",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `dataSource` TEXT NOT NULL, `name` TEXT NOT NULL, `coordinates` BLOB NOT NULL, `chargepoints` TEXT NOT NULL, `network` TEXT, `dataSourceUrl` TEXT NOT NULL, `url` TEXT, `editUrl` TEXT, `verified` INTEGER NOT NULL, `barrierFree` INTEGER, `operator` TEXT, `generalInformation` TEXT, `amenities` TEXT, `locationDescription` TEXT, `photos` TEXT, `chargecards` TEXT, `accessibility` TEXT, `license` TEXT, `networkUrl` TEXT, `chargerUrl` TEXT, `timeRetrieved` INTEGER NOT NULL, `isDetailed` INTEGER NOT NULL, `coordinatesProjected` BLOB NOT NULL, `city` TEXT, `country` TEXT, `postcode` TEXT, `street` TEXT, `fault_report_created` INTEGER, `fault_report_description` TEXT, `twentyfourSeven` INTEGER, `description` TEXT, `mostart` TEXT, `moend` TEXT, `tustart` TEXT, `tuend` TEXT, `westart` TEXT, `weend` TEXT, `thstart` TEXT, `thend` TEXT, `frstart` TEXT, `frend` TEXT, `sastart` TEXT, `saend` TEXT, `sustart` TEXT, `suend` TEXT, `hostart` TEXT, `hoend` TEXT, `freecharging` INTEGER, `freeparking` INTEGER, `descriptionShort` TEXT, `descriptionLong` TEXT, `chargepricecountry` TEXT, `chargepricenetwork` TEXT, `chargepriceplugTypes` TEXT, PRIMARY KEY(`id`, `dataSource`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "dataSource",
"columnName": "dataSource",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "coordinates",
"columnName": "coordinates",
"affinity": "BLOB",
"notNull": true
},
{
"fieldPath": "chargepoints",
"columnName": "chargepoints",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "network",
"columnName": "network",
"affinity": "TEXT"
},
{
"fieldPath": "dataSourceUrl",
"columnName": "dataSourceUrl",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT"
},
{
"fieldPath": "editUrl",
"columnName": "editUrl",
"affinity": "TEXT"
},
{
"fieldPath": "verified",
"columnName": "verified",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "barrierFree",
"columnName": "barrierFree",
"affinity": "INTEGER"
},
{
"fieldPath": "operator",
"columnName": "operator",
"affinity": "TEXT"
},
{
"fieldPath": "generalInformation",
"columnName": "generalInformation",
"affinity": "TEXT"
},
{
"fieldPath": "amenities",
"columnName": "amenities",
"affinity": "TEXT"
},
{
"fieldPath": "locationDescription",
"columnName": "locationDescription",
"affinity": "TEXT"
},
{
"fieldPath": "photos",
"columnName": "photos",
"affinity": "TEXT"
},
{
"fieldPath": "chargecards",
"columnName": "chargecards",
"affinity": "TEXT"
},
{
"fieldPath": "accessibility",
"columnName": "accessibility",
"affinity": "TEXT"
},
{
"fieldPath": "license",
"columnName": "license",
"affinity": "TEXT"
},
{
"fieldPath": "networkUrl",
"columnName": "networkUrl",
"affinity": "TEXT"
},
{
"fieldPath": "chargerUrl",
"columnName": "chargerUrl",
"affinity": "TEXT"
},
{
"fieldPath": "timeRetrieved",
"columnName": "timeRetrieved",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isDetailed",
"columnName": "isDetailed",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "coordinatesProjected",
"columnName": "coordinatesProjected",
"affinity": "BLOB",
"notNull": true
},
{
"fieldPath": "address.city",
"columnName": "city",
"affinity": "TEXT"
},
{
"fieldPath": "address.country",
"columnName": "country",
"affinity": "TEXT"
},
{
"fieldPath": "address.postcode",
"columnName": "postcode",
"affinity": "TEXT"
},
{
"fieldPath": "address.street",
"columnName": "street",
"affinity": "TEXT"
},
{
"fieldPath": "faultReport.created",
"columnName": "fault_report_created",
"affinity": "INTEGER"
},
{
"fieldPath": "faultReport.description",
"columnName": "fault_report_description",
"affinity": "TEXT"
},
{
"fieldPath": "openinghours.twentyfourSeven",
"columnName": "twentyfourSeven",
"affinity": "INTEGER"
},
{
"fieldPath": "openinghours.description",
"columnName": "description",
"affinity": "TEXT"
},
{
"fieldPath": "openinghours.days.monday.start",
"columnName": "mostart",
"affinity": "TEXT"
},
{
"fieldPath": "openinghours.days.monday.end",
"columnName": "moend",
"affinity": "TEXT"
},
{
"fieldPath": "openinghours.days.tuesday.start",
"columnName": "tustart",
"affinity": "TEXT"
},
{
"fieldPath": "openinghours.days.tuesday.end",
"columnName": "tuend",
"affinity": "TEXT"
},
{
"fieldPath": "openinghours.days.wednesday.start",
"columnName": "westart",
"affinity": "TEXT"
},
{
"fieldPath": "openinghours.days.wednesday.end",
"columnName": "weend",
"affinity": "TEXT"
},
{
"fieldPath": "openinghours.days.thursday.start",
"columnName": "thstart",
"affinity": "TEXT"
},
{
"fieldPath": "openinghours.days.thursday.end",
"columnName": "thend",
"affinity": "TEXT"
},
{
"fieldPath": "openinghours.days.friday.start",
"columnName": "frstart",
"affinity": "TEXT"
},
{
"fieldPath": "openinghours.days.friday.end",
"columnName": "frend",
"affinity": "TEXT"
},
{
"fieldPath": "openinghours.days.saturday.start",
"columnName": "sastart",
"affinity": "TEXT"
},
{
"fieldPath": "openinghours.days.saturday.end",
"columnName": "saend",
"affinity": "TEXT"
},
{
"fieldPath": "openinghours.days.sunday.start",
"columnName": "sustart",
"affinity": "TEXT"
},
{
"fieldPath": "openinghours.days.sunday.end",
"columnName": "suend",
"affinity": "TEXT"
},
{
"fieldPath": "openinghours.days.holiday.start",
"columnName": "hostart",
"affinity": "TEXT"
},
{
"fieldPath": "openinghours.days.holiday.end",
"columnName": "hoend",
"affinity": "TEXT"
},
{
"fieldPath": "cost.freecharging",
"columnName": "freecharging",
"affinity": "INTEGER"
},
{
"fieldPath": "cost.freeparking",
"columnName": "freeparking",
"affinity": "INTEGER"
},
{
"fieldPath": "cost.descriptionShort",
"columnName": "descriptionShort",
"affinity": "TEXT"
},
{
"fieldPath": "cost.descriptionLong",
"columnName": "descriptionLong",
"affinity": "TEXT"
},
{
"fieldPath": "chargepriceData.country",
"columnName": "chargepricecountry",
"affinity": "TEXT"
},
{
"fieldPath": "chargepriceData.network",
"columnName": "chargepricenetwork",
"affinity": "TEXT"
},
{
"fieldPath": "chargepriceData.plugTypes",
"columnName": "chargepriceplugTypes",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id",
"dataSource"
]
}
},
{
"tableName": "Favorite",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`favoriteId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `chargerId` INTEGER NOT NULL, `chargerDataSource` TEXT NOT NULL, FOREIGN KEY(`chargerId`, `chargerDataSource`) REFERENCES `ChargeLocation`(`id`, `dataSource`) ON UPDATE NO ACTION ON DELETE NO ACTION )",
"fields": [
{
"fieldPath": "favoriteId",
"columnName": "favoriteId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "chargerId",
"columnName": "chargerId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "chargerDataSource",
"columnName": "chargerDataSource",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"favoriteId"
]
},
"indices": [
{
"name": "index_Favorite_chargerId_chargerDataSource",
"unique": false,
"columnNames": [
"chargerId",
"chargerDataSource"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_Favorite_chargerId_chargerDataSource` ON `${TABLE_NAME}` (`chargerId`, `chargerDataSource`)"
}
],
"foreignKeys": [
{
"table": "ChargeLocation",
"onDelete": "NO ACTION",
"onUpdate": "NO ACTION",
"columns": [
"chargerId",
"chargerDataSource"
],
"referencedColumns": [
"id",
"dataSource"
]
}
]
},
{
"tableName": "BooleanFilterValue",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` INTEGER NOT NULL, `dataSource` TEXT NOT NULL, `profile` INTEGER NOT NULL, PRIMARY KEY(`key`, `profile`, `dataSource`), FOREIGN KEY(`profile`, `dataSource`) REFERENCES `FilterProfile`(`id`, `dataSource`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "key",
"columnName": "key",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "value",
"columnName": "value",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "dataSource",
"columnName": "dataSource",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "profile",
"columnName": "profile",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"key",
"profile",
"dataSource"
]
},
"indices": [
{
"name": "index_BooleanFilterValue_profile_dataSource",
"unique": false,
"columnNames": [
"profile",
"dataSource"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_BooleanFilterValue_profile_dataSource` ON `${TABLE_NAME}` (`profile`, `dataSource`)"
}
],
"foreignKeys": [
{
"table": "FilterProfile",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"profile",
"dataSource"
],
"referencedColumns": [
"id",
"dataSource"
]
}
]
},
{
"tableName": "MultipleChoiceFilterValue",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `values` TEXT NOT NULL, `all` INTEGER NOT NULL, `dataSource` TEXT NOT NULL, `profile` INTEGER NOT NULL, PRIMARY KEY(`key`, `profile`, `dataSource`), FOREIGN KEY(`profile`, `dataSource`) REFERENCES `FilterProfile`(`id`, `dataSource`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "key",
"columnName": "key",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "values",
"columnName": "values",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "all",
"columnName": "all",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "dataSource",
"columnName": "dataSource",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "profile",
"columnName": "profile",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"key",
"profile",
"dataSource"
]
},
"indices": [
{
"name": "index_MultipleChoiceFilterValue_profile_dataSource",
"unique": false,
"columnNames": [
"profile",
"dataSource"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_MultipleChoiceFilterValue_profile_dataSource` ON `${TABLE_NAME}` (`profile`, `dataSource`)"
}
],
"foreignKeys": [
{
"table": "FilterProfile",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"profile",
"dataSource"
],
"referencedColumns": [
"id",
"dataSource"
]
}
]
},
{
"tableName": "SliderFilterValue",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` INTEGER NOT NULL, `dataSource` TEXT NOT NULL, `profile` INTEGER NOT NULL, PRIMARY KEY(`key`, `profile`, `dataSource`), FOREIGN KEY(`profile`, `dataSource`) REFERENCES `FilterProfile`(`id`, `dataSource`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "key",
"columnName": "key",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "value",
"columnName": "value",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "dataSource",
"columnName": "dataSource",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "profile",
"columnName": "profile",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"key",
"profile",
"dataSource"
]
},
"indices": [
{
"name": "index_SliderFilterValue_profile_dataSource",
"unique": false,
"columnNames": [
"profile",
"dataSource"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_SliderFilterValue_profile_dataSource` ON `${TABLE_NAME}` (`profile`, `dataSource`)"
}
],
"foreignKeys": [
{
"table": "FilterProfile",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"profile",
"dataSource"
],
"referencedColumns": [
"id",
"dataSource"
]
}
]
},
{
"tableName": "FilterProfile",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `dataSource` TEXT NOT NULL, `id` INTEGER NOT NULL, `order` INTEGER NOT NULL, PRIMARY KEY(`dataSource`, `id`))",
"fields": [
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "dataSource",
"columnName": "dataSource",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "order",
"columnName": "order",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"dataSource",
"id"
]
},
"indices": [
{
"name": "index_FilterProfile_dataSource_name",
"unique": true,
"columnNames": [
"dataSource",
"name"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_FilterProfile_dataSource_name` ON `${TABLE_NAME}` (`dataSource`, `name`)"
}
]
},
{
"tableName": "RecentAutocompletePlace",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `dataSource` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `primaryText` TEXT NOT NULL, `secondaryText` TEXT NOT NULL, `latLng` TEXT NOT NULL, `viewport` TEXT, `types` TEXT NOT NULL, PRIMARY KEY(`id`, `dataSource`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "dataSource",
"columnName": "dataSource",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "timestamp",
"columnName": "timestamp",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "primaryText",
"columnName": "primaryText",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "secondaryText",
"columnName": "secondaryText",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "latLng",
"columnName": "latLng",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "viewport",
"columnName": "viewport",
"affinity": "TEXT"
},
{
"fieldPath": "types",
"columnName": "types",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id",
"dataSource"
]
}
},
{
"tableName": "GEPlug",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, PRIMARY KEY(`name`))",
"fields": [
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"name"
]
}
},
{
"tableName": "GENetwork",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, PRIMARY KEY(`name`))",
"fields": [
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"name"
]
}
},
{
"tableName": "GEChargeCard",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `url` TEXT NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "OCMConnectionType",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `title` TEXT NOT NULL, `formalName` TEXT, `discontinued` INTEGER, `obsolete` INTEGER, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "formalName",
"columnName": "formalName",
"affinity": "TEXT"
},
{
"fieldPath": "discontinued",
"columnName": "discontinued",
"affinity": "INTEGER"
},
{
"fieldPath": "obsolete",
"columnName": "obsolete",
"affinity": "INTEGER"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "OCMCountry",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `isoCode` TEXT NOT NULL, `continentCode` TEXT, `title` TEXT NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isoCode",
"columnName": "isoCode",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "continentCode",
"columnName": "continentCode",
"affinity": "TEXT"
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "OCMOperator",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `websiteUrl` TEXT, `title` TEXT NOT NULL, `contactEmail` TEXT, `contactTelephone1` TEXT, `contactTelephone2` TEXT, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "websiteUrl",
"columnName": "websiteUrl",
"affinity": "TEXT"
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "contactEmail",
"columnName": "contactEmail",
"affinity": "TEXT"
},
{
"fieldPath": "contactTelephone1",
"columnName": "contactTelephone1",
"affinity": "TEXT"
},
{
"fieldPath": "contactTelephone2",
"columnName": "contactTelephone2",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "OSMNetwork",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, PRIMARY KEY(`name`))",
"fields": [
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"name"
]
}
},
{
"tableName": "SavedRegion",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`region` BLOB NOT NULL, `dataSource` TEXT NOT NULL, `timeRetrieved` INTEGER NOT NULL, `filters` TEXT, `isDetailed` INTEGER NOT NULL, `id` INTEGER PRIMARY KEY AUTOINCREMENT)",
"fields": [
{
"fieldPath": "region",
"columnName": "region",
"affinity": "BLOB",
"notNull": true
},
{
"fieldPath": "dataSource",
"columnName": "dataSource",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "timeRetrieved",
"columnName": "timeRetrieved",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "filters",
"columnName": "filters",
"affinity": "TEXT"
},
{
"fieldPath": "isDetailed",
"columnName": "isDetailed",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER"
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_SavedRegion_filters_dataSource",
"unique": false,
"columnNames": [
"filters",
"dataSource"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_SavedRegion_filters_dataSource` ON `${TABLE_NAME}` (`filters`, `dataSource`)"
}
]
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '84f71cce385c444726ba336834ddf6b4')"
]
}
}
@@ -0,0 +1,938 @@
{
"formatVersion": 1,
"database": {
"version": 28,
"identityHash": "84f71cce385c444726ba336834ddf6b4",
"entities": [
{
"tableName": "ChargeLocation",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `dataSource` TEXT NOT NULL, `name` TEXT NOT NULL, `coordinates` BLOB NOT NULL, `chargepoints` TEXT NOT NULL, `network` TEXT, `dataSourceUrl` TEXT NOT NULL, `url` TEXT, `editUrl` TEXT, `verified` INTEGER NOT NULL, `barrierFree` INTEGER, `operator` TEXT, `generalInformation` TEXT, `amenities` TEXT, `locationDescription` TEXT, `photos` TEXT, `chargecards` TEXT, `accessibility` TEXT, `license` TEXT, `networkUrl` TEXT, `chargerUrl` TEXT, `timeRetrieved` INTEGER NOT NULL, `isDetailed` INTEGER NOT NULL, `coordinatesProjected` BLOB NOT NULL, `city` TEXT, `country` TEXT, `postcode` TEXT, `street` TEXT, `fault_report_created` INTEGER, `fault_report_description` TEXT, `twentyfourSeven` INTEGER, `description` TEXT, `mostart` TEXT, `moend` TEXT, `tustart` TEXT, `tuend` TEXT, `westart` TEXT, `weend` TEXT, `thstart` TEXT, `thend` TEXT, `frstart` TEXT, `frend` TEXT, `sastart` TEXT, `saend` TEXT, `sustart` TEXT, `suend` TEXT, `hostart` TEXT, `hoend` TEXT, `freecharging` INTEGER, `freeparking` INTEGER, `descriptionShort` TEXT, `descriptionLong` TEXT, `chargepricecountry` TEXT, `chargepricenetwork` TEXT, `chargepriceplugTypes` TEXT, PRIMARY KEY(`id`, `dataSource`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "dataSource",
"columnName": "dataSource",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "coordinates",
"columnName": "coordinates",
"affinity": "BLOB",
"notNull": true
},
{
"fieldPath": "chargepoints",
"columnName": "chargepoints",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "network",
"columnName": "network",
"affinity": "TEXT"
},
{
"fieldPath": "dataSourceUrl",
"columnName": "dataSourceUrl",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT"
},
{
"fieldPath": "editUrl",
"columnName": "editUrl",
"affinity": "TEXT"
},
{
"fieldPath": "verified",
"columnName": "verified",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "barrierFree",
"columnName": "barrierFree",
"affinity": "INTEGER"
},
{
"fieldPath": "operator",
"columnName": "operator",
"affinity": "TEXT"
},
{
"fieldPath": "generalInformation",
"columnName": "generalInformation",
"affinity": "TEXT"
},
{
"fieldPath": "amenities",
"columnName": "amenities",
"affinity": "TEXT"
},
{
"fieldPath": "locationDescription",
"columnName": "locationDescription",
"affinity": "TEXT"
},
{
"fieldPath": "photos",
"columnName": "photos",
"affinity": "TEXT"
},
{
"fieldPath": "chargecards",
"columnName": "chargecards",
"affinity": "TEXT"
},
{
"fieldPath": "accessibility",
"columnName": "accessibility",
"affinity": "TEXT"
},
{
"fieldPath": "license",
"columnName": "license",
"affinity": "TEXT"
},
{
"fieldPath": "networkUrl",
"columnName": "networkUrl",
"affinity": "TEXT"
},
{
"fieldPath": "chargerUrl",
"columnName": "chargerUrl",
"affinity": "TEXT"
},
{
"fieldPath": "timeRetrieved",
"columnName": "timeRetrieved",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isDetailed",
"columnName": "isDetailed",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "coordinatesProjected",
"columnName": "coordinatesProjected",
"affinity": "BLOB",
"notNull": true
},
{
"fieldPath": "address.city",
"columnName": "city",
"affinity": "TEXT"
},
{
"fieldPath": "address.country",
"columnName": "country",
"affinity": "TEXT"
},
{
"fieldPath": "address.postcode",
"columnName": "postcode",
"affinity": "TEXT"
},
{
"fieldPath": "address.street",
"columnName": "street",
"affinity": "TEXT"
},
{
"fieldPath": "faultReport.created",
"columnName": "fault_report_created",
"affinity": "INTEGER"
},
{
"fieldPath": "faultReport.description",
"columnName": "fault_report_description",
"affinity": "TEXT"
},
{
"fieldPath": "openinghours.twentyfourSeven",
"columnName": "twentyfourSeven",
"affinity": "INTEGER"
},
{
"fieldPath": "openinghours.description",
"columnName": "description",
"affinity": "TEXT"
},
{
"fieldPath": "openinghours.days.monday.start",
"columnName": "mostart",
"affinity": "TEXT"
},
{
"fieldPath": "openinghours.days.monday.end",
"columnName": "moend",
"affinity": "TEXT"
},
{
"fieldPath": "openinghours.days.tuesday.start",
"columnName": "tustart",
"affinity": "TEXT"
},
{
"fieldPath": "openinghours.days.tuesday.end",
"columnName": "tuend",
"affinity": "TEXT"
},
{
"fieldPath": "openinghours.days.wednesday.start",
"columnName": "westart",
"affinity": "TEXT"
},
{
"fieldPath": "openinghours.days.wednesday.end",
"columnName": "weend",
"affinity": "TEXT"
},
{
"fieldPath": "openinghours.days.thursday.start",
"columnName": "thstart",
"affinity": "TEXT"
},
{
"fieldPath": "openinghours.days.thursday.end",
"columnName": "thend",
"affinity": "TEXT"
},
{
"fieldPath": "openinghours.days.friday.start",
"columnName": "frstart",
"affinity": "TEXT"
},
{
"fieldPath": "openinghours.days.friday.end",
"columnName": "frend",
"affinity": "TEXT"
},
{
"fieldPath": "openinghours.days.saturday.start",
"columnName": "sastart",
"affinity": "TEXT"
},
{
"fieldPath": "openinghours.days.saturday.end",
"columnName": "saend",
"affinity": "TEXT"
},
{
"fieldPath": "openinghours.days.sunday.start",
"columnName": "sustart",
"affinity": "TEXT"
},
{
"fieldPath": "openinghours.days.sunday.end",
"columnName": "suend",
"affinity": "TEXT"
},
{
"fieldPath": "openinghours.days.holiday.start",
"columnName": "hostart",
"affinity": "TEXT"
},
{
"fieldPath": "openinghours.days.holiday.end",
"columnName": "hoend",
"affinity": "TEXT"
},
{
"fieldPath": "cost.freecharging",
"columnName": "freecharging",
"affinity": "INTEGER"
},
{
"fieldPath": "cost.freeparking",
"columnName": "freeparking",
"affinity": "INTEGER"
},
{
"fieldPath": "cost.descriptionShort",
"columnName": "descriptionShort",
"affinity": "TEXT"
},
{
"fieldPath": "cost.descriptionLong",
"columnName": "descriptionLong",
"affinity": "TEXT"
},
{
"fieldPath": "chargepriceData.country",
"columnName": "chargepricecountry",
"affinity": "TEXT"
},
{
"fieldPath": "chargepriceData.network",
"columnName": "chargepricenetwork",
"affinity": "TEXT"
},
{
"fieldPath": "chargepriceData.plugTypes",
"columnName": "chargepriceplugTypes",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id",
"dataSource"
]
}
},
{
"tableName": "Favorite",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`favoriteId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `chargerId` INTEGER NOT NULL, `chargerDataSource` TEXT NOT NULL, FOREIGN KEY(`chargerId`, `chargerDataSource`) REFERENCES `ChargeLocation`(`id`, `dataSource`) ON UPDATE NO ACTION ON DELETE NO ACTION )",
"fields": [
{
"fieldPath": "favoriteId",
"columnName": "favoriteId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "chargerId",
"columnName": "chargerId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "chargerDataSource",
"columnName": "chargerDataSource",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"favoriteId"
]
},
"indices": [
{
"name": "index_Favorite_chargerId_chargerDataSource",
"unique": false,
"columnNames": [
"chargerId",
"chargerDataSource"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_Favorite_chargerId_chargerDataSource` ON `${TABLE_NAME}` (`chargerId`, `chargerDataSource`)"
}
],
"foreignKeys": [
{
"table": "ChargeLocation",
"onDelete": "NO ACTION",
"onUpdate": "NO ACTION",
"columns": [
"chargerId",
"chargerDataSource"
],
"referencedColumns": [
"id",
"dataSource"
]
}
]
},
{
"tableName": "BooleanFilterValue",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` INTEGER NOT NULL, `dataSource` TEXT NOT NULL, `profile` INTEGER NOT NULL, PRIMARY KEY(`key`, `profile`, `dataSource`), FOREIGN KEY(`profile`, `dataSource`) REFERENCES `FilterProfile`(`id`, `dataSource`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "key",
"columnName": "key",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "value",
"columnName": "value",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "dataSource",
"columnName": "dataSource",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "profile",
"columnName": "profile",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"key",
"profile",
"dataSource"
]
},
"indices": [
{
"name": "index_BooleanFilterValue_profile_dataSource",
"unique": false,
"columnNames": [
"profile",
"dataSource"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_BooleanFilterValue_profile_dataSource` ON `${TABLE_NAME}` (`profile`, `dataSource`)"
}
],
"foreignKeys": [
{
"table": "FilterProfile",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"profile",
"dataSource"
],
"referencedColumns": [
"id",
"dataSource"
]
}
]
},
{
"tableName": "MultipleChoiceFilterValue",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `values` TEXT NOT NULL, `all` INTEGER NOT NULL, `dataSource` TEXT NOT NULL, `profile` INTEGER NOT NULL, PRIMARY KEY(`key`, `profile`, `dataSource`), FOREIGN KEY(`profile`, `dataSource`) REFERENCES `FilterProfile`(`id`, `dataSource`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "key",
"columnName": "key",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "values",
"columnName": "values",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "all",
"columnName": "all",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "dataSource",
"columnName": "dataSource",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "profile",
"columnName": "profile",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"key",
"profile",
"dataSource"
]
},
"indices": [
{
"name": "index_MultipleChoiceFilterValue_profile_dataSource",
"unique": false,
"columnNames": [
"profile",
"dataSource"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_MultipleChoiceFilterValue_profile_dataSource` ON `${TABLE_NAME}` (`profile`, `dataSource`)"
}
],
"foreignKeys": [
{
"table": "FilterProfile",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"profile",
"dataSource"
],
"referencedColumns": [
"id",
"dataSource"
]
}
]
},
{
"tableName": "SliderFilterValue",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` INTEGER NOT NULL, `dataSource` TEXT NOT NULL, `profile` INTEGER NOT NULL, PRIMARY KEY(`key`, `profile`, `dataSource`), FOREIGN KEY(`profile`, `dataSource`) REFERENCES `FilterProfile`(`id`, `dataSource`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "key",
"columnName": "key",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "value",
"columnName": "value",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "dataSource",
"columnName": "dataSource",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "profile",
"columnName": "profile",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"key",
"profile",
"dataSource"
]
},
"indices": [
{
"name": "index_SliderFilterValue_profile_dataSource",
"unique": false,
"columnNames": [
"profile",
"dataSource"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_SliderFilterValue_profile_dataSource` ON `${TABLE_NAME}` (`profile`, `dataSource`)"
}
],
"foreignKeys": [
{
"table": "FilterProfile",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"profile",
"dataSource"
],
"referencedColumns": [
"id",
"dataSource"
]
}
]
},
{
"tableName": "FilterProfile",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `dataSource` TEXT NOT NULL, `id` INTEGER NOT NULL, `order` INTEGER NOT NULL, PRIMARY KEY(`dataSource`, `id`))",
"fields": [
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "dataSource",
"columnName": "dataSource",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "order",
"columnName": "order",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"dataSource",
"id"
]
},
"indices": [
{
"name": "index_FilterProfile_dataSource_name",
"unique": true,
"columnNames": [
"dataSource",
"name"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_FilterProfile_dataSource_name` ON `${TABLE_NAME}` (`dataSource`, `name`)"
}
]
},
{
"tableName": "RecentAutocompletePlace",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `dataSource` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `primaryText` TEXT NOT NULL, `secondaryText` TEXT NOT NULL, `latLng` TEXT NOT NULL, `viewport` TEXT, `types` TEXT NOT NULL, PRIMARY KEY(`id`, `dataSource`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "dataSource",
"columnName": "dataSource",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "timestamp",
"columnName": "timestamp",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "primaryText",
"columnName": "primaryText",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "secondaryText",
"columnName": "secondaryText",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "latLng",
"columnName": "latLng",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "viewport",
"columnName": "viewport",
"affinity": "TEXT"
},
{
"fieldPath": "types",
"columnName": "types",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id",
"dataSource"
]
}
},
{
"tableName": "GEPlug",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, PRIMARY KEY(`name`))",
"fields": [
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"name"
]
}
},
{
"tableName": "GENetwork",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, PRIMARY KEY(`name`))",
"fields": [
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"name"
]
}
},
{
"tableName": "GEChargeCard",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `url` TEXT NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "OCMConnectionType",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `title` TEXT NOT NULL, `formalName` TEXT, `discontinued` INTEGER, `obsolete` INTEGER, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "formalName",
"columnName": "formalName",
"affinity": "TEXT"
},
{
"fieldPath": "discontinued",
"columnName": "discontinued",
"affinity": "INTEGER"
},
{
"fieldPath": "obsolete",
"columnName": "obsolete",
"affinity": "INTEGER"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "OCMCountry",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `isoCode` TEXT NOT NULL, `continentCode` TEXT, `title` TEXT NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isoCode",
"columnName": "isoCode",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "continentCode",
"columnName": "continentCode",
"affinity": "TEXT"
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "OCMOperator",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `websiteUrl` TEXT, `title` TEXT NOT NULL, `contactEmail` TEXT, `contactTelephone1` TEXT, `contactTelephone2` TEXT, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "websiteUrl",
"columnName": "websiteUrl",
"affinity": "TEXT"
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "contactEmail",
"columnName": "contactEmail",
"affinity": "TEXT"
},
{
"fieldPath": "contactTelephone1",
"columnName": "contactTelephone1",
"affinity": "TEXT"
},
{
"fieldPath": "contactTelephone2",
"columnName": "contactTelephone2",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "OSMNetwork",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, PRIMARY KEY(`name`))",
"fields": [
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"name"
]
}
},
{
"tableName": "SavedRegion",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`region` BLOB NOT NULL, `dataSource` TEXT NOT NULL, `timeRetrieved` INTEGER NOT NULL, `filters` TEXT, `isDetailed` INTEGER NOT NULL, `id` INTEGER PRIMARY KEY AUTOINCREMENT)",
"fields": [
{
"fieldPath": "region",
"columnName": "region",
"affinity": "BLOB",
"notNull": true
},
{
"fieldPath": "dataSource",
"columnName": "dataSource",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "timeRetrieved",
"columnName": "timeRetrieved",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "filters",
"columnName": "filters",
"affinity": "TEXT"
},
{
"fieldPath": "isDetailed",
"columnName": "isDetailed",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER"
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_SavedRegion_filters_dataSource",
"unique": false,
"columnNames": [
"filters",
"dataSource"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_SavedRegion_filters_dataSource` ON `${TABLE_NAME}` (`filters`, `dataSource`)"
}
]
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '84f71cce385c444726ba336834ddf6b4')"
]
}
}
@@ -0,0 +1,938 @@
{
"formatVersion": 1,
"database": {
"version": 29,
"identityHash": "84f71cce385c444726ba336834ddf6b4",
"entities": [
{
"tableName": "ChargeLocation",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `dataSource` TEXT NOT NULL, `name` TEXT NOT NULL, `coordinates` BLOB NOT NULL, `chargepoints` TEXT NOT NULL, `network` TEXT, `dataSourceUrl` TEXT NOT NULL, `url` TEXT, `editUrl` TEXT, `verified` INTEGER NOT NULL, `barrierFree` INTEGER, `operator` TEXT, `generalInformation` TEXT, `amenities` TEXT, `locationDescription` TEXT, `photos` TEXT, `chargecards` TEXT, `accessibility` TEXT, `license` TEXT, `networkUrl` TEXT, `chargerUrl` TEXT, `timeRetrieved` INTEGER NOT NULL, `isDetailed` INTEGER NOT NULL, `coordinatesProjected` BLOB NOT NULL, `city` TEXT, `country` TEXT, `postcode` TEXT, `street` TEXT, `fault_report_created` INTEGER, `fault_report_description` TEXT, `twentyfourSeven` INTEGER, `description` TEXT, `mostart` TEXT, `moend` TEXT, `tustart` TEXT, `tuend` TEXT, `westart` TEXT, `weend` TEXT, `thstart` TEXT, `thend` TEXT, `frstart` TEXT, `frend` TEXT, `sastart` TEXT, `saend` TEXT, `sustart` TEXT, `suend` TEXT, `hostart` TEXT, `hoend` TEXT, `freecharging` INTEGER, `freeparking` INTEGER, `descriptionShort` TEXT, `descriptionLong` TEXT, `chargepricecountry` TEXT, `chargepricenetwork` TEXT, `chargepriceplugTypes` TEXT, PRIMARY KEY(`id`, `dataSource`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "dataSource",
"columnName": "dataSource",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "coordinates",
"columnName": "coordinates",
"affinity": "BLOB",
"notNull": true
},
{
"fieldPath": "chargepoints",
"columnName": "chargepoints",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "network",
"columnName": "network",
"affinity": "TEXT"
},
{
"fieldPath": "dataSourceUrl",
"columnName": "dataSourceUrl",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT"
},
{
"fieldPath": "editUrl",
"columnName": "editUrl",
"affinity": "TEXT"
},
{
"fieldPath": "verified",
"columnName": "verified",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "barrierFree",
"columnName": "barrierFree",
"affinity": "INTEGER"
},
{
"fieldPath": "operator",
"columnName": "operator",
"affinity": "TEXT"
},
{
"fieldPath": "generalInformation",
"columnName": "generalInformation",
"affinity": "TEXT"
},
{
"fieldPath": "amenities",
"columnName": "amenities",
"affinity": "TEXT"
},
{
"fieldPath": "locationDescription",
"columnName": "locationDescription",
"affinity": "TEXT"
},
{
"fieldPath": "photos",
"columnName": "photos",
"affinity": "TEXT"
},
{
"fieldPath": "chargecards",
"columnName": "chargecards",
"affinity": "TEXT"
},
{
"fieldPath": "accessibility",
"columnName": "accessibility",
"affinity": "TEXT"
},
{
"fieldPath": "license",
"columnName": "license",
"affinity": "TEXT"
},
{
"fieldPath": "networkUrl",
"columnName": "networkUrl",
"affinity": "TEXT"
},
{
"fieldPath": "chargerUrl",
"columnName": "chargerUrl",
"affinity": "TEXT"
},
{
"fieldPath": "timeRetrieved",
"columnName": "timeRetrieved",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isDetailed",
"columnName": "isDetailed",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "coordinatesProjected",
"columnName": "coordinatesProjected",
"affinity": "BLOB",
"notNull": true
},
{
"fieldPath": "address.city",
"columnName": "city",
"affinity": "TEXT"
},
{
"fieldPath": "address.country",
"columnName": "country",
"affinity": "TEXT"
},
{
"fieldPath": "address.postcode",
"columnName": "postcode",
"affinity": "TEXT"
},
{
"fieldPath": "address.street",
"columnName": "street",
"affinity": "TEXT"
},
{
"fieldPath": "faultReport.created",
"columnName": "fault_report_created",
"affinity": "INTEGER"
},
{
"fieldPath": "faultReport.description",
"columnName": "fault_report_description",
"affinity": "TEXT"
},
{
"fieldPath": "openinghours.twentyfourSeven",
"columnName": "twentyfourSeven",
"affinity": "INTEGER"
},
{
"fieldPath": "openinghours.description",
"columnName": "description",
"affinity": "TEXT"
},
{
"fieldPath": "openinghours.days.monday.start",
"columnName": "mostart",
"affinity": "TEXT"
},
{
"fieldPath": "openinghours.days.monday.end",
"columnName": "moend",
"affinity": "TEXT"
},
{
"fieldPath": "openinghours.days.tuesday.start",
"columnName": "tustart",
"affinity": "TEXT"
},
{
"fieldPath": "openinghours.days.tuesday.end",
"columnName": "tuend",
"affinity": "TEXT"
},
{
"fieldPath": "openinghours.days.wednesday.start",
"columnName": "westart",
"affinity": "TEXT"
},
{
"fieldPath": "openinghours.days.wednesday.end",
"columnName": "weend",
"affinity": "TEXT"
},
{
"fieldPath": "openinghours.days.thursday.start",
"columnName": "thstart",
"affinity": "TEXT"
},
{
"fieldPath": "openinghours.days.thursday.end",
"columnName": "thend",
"affinity": "TEXT"
},
{
"fieldPath": "openinghours.days.friday.start",
"columnName": "frstart",
"affinity": "TEXT"
},
{
"fieldPath": "openinghours.days.friday.end",
"columnName": "frend",
"affinity": "TEXT"
},
{
"fieldPath": "openinghours.days.saturday.start",
"columnName": "sastart",
"affinity": "TEXT"
},
{
"fieldPath": "openinghours.days.saturday.end",
"columnName": "saend",
"affinity": "TEXT"
},
{
"fieldPath": "openinghours.days.sunday.start",
"columnName": "sustart",
"affinity": "TEXT"
},
{
"fieldPath": "openinghours.days.sunday.end",
"columnName": "suend",
"affinity": "TEXT"
},
{
"fieldPath": "openinghours.days.holiday.start",
"columnName": "hostart",
"affinity": "TEXT"
},
{
"fieldPath": "openinghours.days.holiday.end",
"columnName": "hoend",
"affinity": "TEXT"
},
{
"fieldPath": "cost.freecharging",
"columnName": "freecharging",
"affinity": "INTEGER"
},
{
"fieldPath": "cost.freeparking",
"columnName": "freeparking",
"affinity": "INTEGER"
},
{
"fieldPath": "cost.descriptionShort",
"columnName": "descriptionShort",
"affinity": "TEXT"
},
{
"fieldPath": "cost.descriptionLong",
"columnName": "descriptionLong",
"affinity": "TEXT"
},
{
"fieldPath": "chargepriceData.country",
"columnName": "chargepricecountry",
"affinity": "TEXT"
},
{
"fieldPath": "chargepriceData.network",
"columnName": "chargepricenetwork",
"affinity": "TEXT"
},
{
"fieldPath": "chargepriceData.plugTypes",
"columnName": "chargepriceplugTypes",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id",
"dataSource"
]
}
},
{
"tableName": "Favorite",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`favoriteId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `chargerId` INTEGER NOT NULL, `chargerDataSource` TEXT NOT NULL, FOREIGN KEY(`chargerId`, `chargerDataSource`) REFERENCES `ChargeLocation`(`id`, `dataSource`) ON UPDATE NO ACTION ON DELETE NO ACTION )",
"fields": [
{
"fieldPath": "favoriteId",
"columnName": "favoriteId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "chargerId",
"columnName": "chargerId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "chargerDataSource",
"columnName": "chargerDataSource",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"favoriteId"
]
},
"indices": [
{
"name": "index_Favorite_chargerId_chargerDataSource",
"unique": false,
"columnNames": [
"chargerId",
"chargerDataSource"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_Favorite_chargerId_chargerDataSource` ON `${TABLE_NAME}` (`chargerId`, `chargerDataSource`)"
}
],
"foreignKeys": [
{
"table": "ChargeLocation",
"onDelete": "NO ACTION",
"onUpdate": "NO ACTION",
"columns": [
"chargerId",
"chargerDataSource"
],
"referencedColumns": [
"id",
"dataSource"
]
}
]
},
{
"tableName": "BooleanFilterValue",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` INTEGER NOT NULL, `dataSource` TEXT NOT NULL, `profile` INTEGER NOT NULL, PRIMARY KEY(`key`, `profile`, `dataSource`), FOREIGN KEY(`profile`, `dataSource`) REFERENCES `FilterProfile`(`id`, `dataSource`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "key",
"columnName": "key",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "value",
"columnName": "value",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "dataSource",
"columnName": "dataSource",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "profile",
"columnName": "profile",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"key",
"profile",
"dataSource"
]
},
"indices": [
{
"name": "index_BooleanFilterValue_profile_dataSource",
"unique": false,
"columnNames": [
"profile",
"dataSource"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_BooleanFilterValue_profile_dataSource` ON `${TABLE_NAME}` (`profile`, `dataSource`)"
}
],
"foreignKeys": [
{
"table": "FilterProfile",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"profile",
"dataSource"
],
"referencedColumns": [
"id",
"dataSource"
]
}
]
},
{
"tableName": "MultipleChoiceFilterValue",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `values` TEXT NOT NULL, `all` INTEGER NOT NULL, `dataSource` TEXT NOT NULL, `profile` INTEGER NOT NULL, PRIMARY KEY(`key`, `profile`, `dataSource`), FOREIGN KEY(`profile`, `dataSource`) REFERENCES `FilterProfile`(`id`, `dataSource`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "key",
"columnName": "key",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "values",
"columnName": "values",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "all",
"columnName": "all",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "dataSource",
"columnName": "dataSource",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "profile",
"columnName": "profile",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"key",
"profile",
"dataSource"
]
},
"indices": [
{
"name": "index_MultipleChoiceFilterValue_profile_dataSource",
"unique": false,
"columnNames": [
"profile",
"dataSource"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_MultipleChoiceFilterValue_profile_dataSource` ON `${TABLE_NAME}` (`profile`, `dataSource`)"
}
],
"foreignKeys": [
{
"table": "FilterProfile",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"profile",
"dataSource"
],
"referencedColumns": [
"id",
"dataSource"
]
}
]
},
{
"tableName": "SliderFilterValue",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` INTEGER NOT NULL, `dataSource` TEXT NOT NULL, `profile` INTEGER NOT NULL, PRIMARY KEY(`key`, `profile`, `dataSource`), FOREIGN KEY(`profile`, `dataSource`) REFERENCES `FilterProfile`(`id`, `dataSource`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "key",
"columnName": "key",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "value",
"columnName": "value",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "dataSource",
"columnName": "dataSource",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "profile",
"columnName": "profile",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"key",
"profile",
"dataSource"
]
},
"indices": [
{
"name": "index_SliderFilterValue_profile_dataSource",
"unique": false,
"columnNames": [
"profile",
"dataSource"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_SliderFilterValue_profile_dataSource` ON `${TABLE_NAME}` (`profile`, `dataSource`)"
}
],
"foreignKeys": [
{
"table": "FilterProfile",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"profile",
"dataSource"
],
"referencedColumns": [
"id",
"dataSource"
]
}
]
},
{
"tableName": "FilterProfile",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `dataSource` TEXT NOT NULL, `id` INTEGER NOT NULL, `order` INTEGER NOT NULL, PRIMARY KEY(`dataSource`, `id`))",
"fields": [
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "dataSource",
"columnName": "dataSource",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "order",
"columnName": "order",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"dataSource",
"id"
]
},
"indices": [
{
"name": "index_FilterProfile_dataSource_name",
"unique": true,
"columnNames": [
"dataSource",
"name"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_FilterProfile_dataSource_name` ON `${TABLE_NAME}` (`dataSource`, `name`)"
}
]
},
{
"tableName": "RecentAutocompletePlace",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `dataSource` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `primaryText` TEXT NOT NULL, `secondaryText` TEXT NOT NULL, `latLng` TEXT NOT NULL, `viewport` TEXT, `types` TEXT NOT NULL, PRIMARY KEY(`id`, `dataSource`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "dataSource",
"columnName": "dataSource",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "timestamp",
"columnName": "timestamp",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "primaryText",
"columnName": "primaryText",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "secondaryText",
"columnName": "secondaryText",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "latLng",
"columnName": "latLng",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "viewport",
"columnName": "viewport",
"affinity": "TEXT"
},
{
"fieldPath": "types",
"columnName": "types",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id",
"dataSource"
]
}
},
{
"tableName": "GEPlug",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, PRIMARY KEY(`name`))",
"fields": [
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"name"
]
}
},
{
"tableName": "GENetwork",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, PRIMARY KEY(`name`))",
"fields": [
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"name"
]
}
},
{
"tableName": "GEChargeCard",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `url` TEXT NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "OCMConnectionType",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `title` TEXT NOT NULL, `formalName` TEXT, `discontinued` INTEGER, `obsolete` INTEGER, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "formalName",
"columnName": "formalName",
"affinity": "TEXT"
},
{
"fieldPath": "discontinued",
"columnName": "discontinued",
"affinity": "INTEGER"
},
{
"fieldPath": "obsolete",
"columnName": "obsolete",
"affinity": "INTEGER"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "OCMCountry",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `isoCode` TEXT NOT NULL, `continentCode` TEXT, `title` TEXT NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isoCode",
"columnName": "isoCode",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "continentCode",
"columnName": "continentCode",
"affinity": "TEXT"
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "OCMOperator",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `websiteUrl` TEXT, `title` TEXT NOT NULL, `contactEmail` TEXT, `contactTelephone1` TEXT, `contactTelephone2` TEXT, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "websiteUrl",
"columnName": "websiteUrl",
"affinity": "TEXT"
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "contactEmail",
"columnName": "contactEmail",
"affinity": "TEXT"
},
{
"fieldPath": "contactTelephone1",
"columnName": "contactTelephone1",
"affinity": "TEXT"
},
{
"fieldPath": "contactTelephone2",
"columnName": "contactTelephone2",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "OSMNetwork",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, PRIMARY KEY(`name`))",
"fields": [
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"name"
]
}
},
{
"tableName": "SavedRegion",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`region` BLOB NOT NULL, `dataSource` TEXT NOT NULL, `timeRetrieved` INTEGER NOT NULL, `filters` TEXT, `isDetailed` INTEGER NOT NULL, `id` INTEGER PRIMARY KEY AUTOINCREMENT)",
"fields": [
{
"fieldPath": "region",
"columnName": "region",
"affinity": "BLOB",
"notNull": true
},
{
"fieldPath": "dataSource",
"columnName": "dataSource",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "timeRetrieved",
"columnName": "timeRetrieved",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "filters",
"columnName": "filters",
"affinity": "TEXT"
},
{
"fieldPath": "isDetailed",
"columnName": "isDetailed",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER"
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_SavedRegion_filters_dataSource",
"unique": false,
"columnNames": [
"filters",
"dataSource"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_SavedRegion_filters_dataSource` ON `${TABLE_NAME}` (`filters`, `dataSource`)"
}
]
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '84f71cce385c444726ba336834ddf6b4')"
]
}
}
@@ -61,6 +61,7 @@ class ChargeLocationsDaoTest {
"https://google.com",
null,
null,
null,
false,
null,
null,
@@ -68,7 +69,7 @@ class ChargeLocationsDaoTest {
null,
null,
null,
null, null, null, null, null, null, null, Instant.now(), true
null, null, null, null, null, null, null, null, Instant.now(), true
)
}
runBlocking {
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="grant_on_phone">Tillåt</string>
<string name="auto_location_permission_needed">Du måste tillåta platsåtkomst för att använda EVMap i din bil.</string>
</resources>
@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="chargeprice_api_url">https://staging-api.chargeprice.app/v1/</string>
<string name="chargeprice_key">20c0d68918c9dc96c564784b711a6570</string>
</resources>
+1 -1
View File
@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">EVMap</string>
<string name="app_name">EVMap (debug)</string>
</resources>
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="donations_info" formatted="false">Har du nytta av EVMap? Stöd utvecklingen genom att skicka en donation till utvecklaren.</string>
<string name="donate_paypal">Donera med PayPal</string>
<string name="data_sources_hint">Kartdata i appen tillhandahålls av OpenStreetMap.</string>
</resources>
@@ -1,8 +1,25 @@
package net.vonforst.evmap.adapter
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.viewbinding.ViewBinding
import net.vonforst.evmap.R
import net.vonforst.evmap.databinding.ItemDonationBinding
import net.vonforst.evmap.viewmodel.DonationItem
class DonationAdapter() : DataBindingAdapter<DonationItem>() {
class DonationAdapter : DataBindingAdapter<DonationItem>() {
override fun getItemViewType(position: Int): Int = R.layout.item_donation
override fun createBinding(
inflater: LayoutInflater,
parent: ViewGroup,
viewType: Int
): ViewBinding = ItemDonationBinding.inflate(inflater, parent, false)
override fun bind(holder: ViewHolder, item: DonationItem) {
super.bind(holder, item)
val binding = holder.binding as ItemDonationBinding
binding.txtName.text = item.product.title
binding.txtPrice.text = item.product.oneTimePurchaseOfferDetails?.formattedPrice
}
}
@@ -19,7 +19,6 @@ 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.io.IOException
import java.util.concurrent.ExecutionException
import kotlin.math.sqrt
@@ -53,7 +52,7 @@ class GooglePlacesAutocompleteProvider(val context: Context) : AutocompleteProvi
it.getSecondaryText(bold),
it.placeId,
it.distanceMeters?.toDouble(),
it.placeTypes.map { AutocompletePlaceType.valueOf(it.name) })
it.types.mapNotNull { AutocompletePlaceType.valueOfOrNull(it.uppercase()) })
}
} catch (e: ExecutionException) {
val cause = e.cause
@@ -75,12 +74,13 @@ class GooglePlacesAutocompleteProvider(val context: Context) : AutocompleteProvi
override suspend fun getDetails(id: String): PlaceWithBounds {
val request =
FetchPlaceRequest.builder(id, listOf(Place.Field.LAT_LNG, Place.Field.VIEWPORT)).build()
FetchPlaceRequest.builder(id, listOf(Place.Field.LOCATION, Place.Field.VIEWPORT))
.build()
try {
val place = client.fetchPlace(request).await().place
token = AutocompleteSessionToken.newInstance()
return PlaceWithBounds(
AnyMapAdapter.adapt(place.latLng),
AnyMapAdapter.adapt(place.location),
AnyMapAdapter.adapt(place.viewport)
)
} catch (e: ApiException) {
@@ -5,7 +5,6 @@ import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.appcompat.app.AppCompatActivity
import androidx.databinding.DataBindingUtil
import androidx.fragment.app.viewModels
import androidx.navigation.fragment.findNavController
import androidx.navigation.ui.setupWithNavController
@@ -21,7 +20,9 @@ import net.vonforst.evmap.adapter.SingleViewAdapter
import net.vonforst.evmap.databinding.FragmentDonateBinding
import net.vonforst.evmap.databinding.FragmentDonateHeaderBinding
import net.vonforst.evmap.databinding.FragmentDonateReferralBinding
import net.vonforst.evmap.ui.goneUnless
import net.vonforst.evmap.viewmodel.DonateViewModel
import net.vonforst.evmap.viewmodel.Status
class DonateFragment : DonateFragmentBase() {
private lateinit var binding: FragmentDonateBinding
@@ -40,9 +41,7 @@ class DonateFragment : DonateFragmentBase() {
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
binding = DataBindingUtil.inflate(inflater, R.layout.fragment_donate, container, false)
binding.lifecycleOwner = this
binding.vm = vm
binding = FragmentDonateBinding.inflate(inflater, container, false)
header = FragmentDonateHeaderBinding.inflate(inflater, container, false)
referrals = FragmentDonateReferralBinding.inflate(inflater, container, false)
@@ -75,6 +74,7 @@ class DonateFragment : DonateFragmentBase() {
vm.products.observe(viewLifecycleOwner) {
donationAdapter.submitList(it.data)
binding.progressBar.visibility = goneUnless(it.status == Status.LOADING)
}
vm.purchaseSuccessful.observe(viewLifecycleOwner) {
@@ -4,8 +4,18 @@ import android.app.Activity
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.MutableLiveData
import com.android.billingclient.api.*
import com.android.billingclient.api.BillingClient
import com.android.billingclient.api.BillingClient.ProductType
import com.android.billingclient.api.BillingClientStateListener
import com.android.billingclient.api.BillingFlowParams
import com.android.billingclient.api.BillingResult
import com.android.billingclient.api.ConsumeParams
import com.android.billingclient.api.PendingPurchasesParams
import com.android.billingclient.api.ProductDetails
import com.android.billingclient.api.Purchase
import com.android.billingclient.api.PurchasesUpdatedListener
import com.android.billingclient.api.QueryProductDetailsParams
import com.android.billingclient.api.QueryPurchasesParams
import net.vonforst.evmap.BuildConfig
import net.vonforst.evmap.adapter.Equatable
@@ -13,7 +23,7 @@ class DonateViewModel(application: Application) : AndroidViewModel(application),
PurchasesUpdatedListener {
private var billingClient = BillingClient.newBuilder(application)
.setListener(this)
.enablePendingPurchases()
.enablePendingPurchases(PendingPurchasesParams.newBuilder().enableOneTimeProducts().build())
.build()
val products: MutableLiveData<Resource<List<DonationItem>>> by lazy {
@@ -67,6 +77,7 @@ class DonateViewModel(application: Application) : AndroidViewModel(application),
billingClient.queryProductDetailsAsync(params) { result, details ->
if (result.responseCode == BillingClient.BillingResponseCode.OK) {
products.postValue(Resource.success(details
.productDetailsList
.sortedBy { it.oneTimePurchaseOfferDetails!!.priceAmountMicros }
.map { DonationItem(it) }
))
+15 -30
View File
@@ -1,23 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
<androidx.constraintlayout.widget.ConstraintLayout 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="net.vonforst.evmap.viewmodel.DonateViewModel" />
<import type="net.vonforst.evmap.viewmodel.Status" />
<variable
name="vm"
type="DonateViewModel" />
</data>
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/linearLayout2"
android:layout_width="match_parent"
android:layout_height="match_parent">
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/linearLayout2"
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.google.android.material.appbar.AppBarLayout
android:id="@+id/toolbar_container"
@@ -46,15 +33,13 @@
tools:itemCount="1"
tools:listitem="@layout/fragment_donate_preview" />
<ProgressBar
android:id="@+id/progressBar3"
style="?android:attr/progressBarStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="@+id/products_list"
app:goneUnless="@{vm.products.status == Status.LOADING}" />
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>
<ProgressBar
android:id="@+id/progressBar"
style="?android:attr/progressBarStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="@+id/products_list" />
</androidx.constraintlayout.widget.ConstraintLayout>
+11 -25
View File
@@ -1,21 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
<androidx.cardview.widget.CardView 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>
<variable
name="item"
type="net.vonforst.evmap.viewmodel.DonationItem" />
</data>
<androidx.cardview.widget.CardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginLeft="16dp"
android:layout_marginRight="16dp"
android:layout_marginBottom="16dp">
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginLeft="16dp"
android:layout_marginRight="16dp"
android:layout_marginBottom="16dp">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
@@ -24,32 +15,27 @@
android:padding="16dp">
<TextView
android:id="@+id/textView15"
android:id="@+id/txtName"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
android:text="@{item.product.title}"
android:textAppearance="@style/TextAppearance.Material3.BodyLarge"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/textView21"
app:layout_constraintEnd_toStartOf="@+id/txtPrice"
app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="Spende (extrem langer Beschreibungstext)" />
<TextView
android:id="@+id/textView21"
android:id="@+id/txtPrice"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@{item.product.oneTimePurchaseOfferDetails.formattedPrice}"
android:textAppearance="@style/TextAppearance.Material3.BodyLarge"
android:textColor="?android:textColorSecondary"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="1,00 €" />
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.cardview.widget.CardView>
</layout>
</androidx.cardview.widget.CardView>
+5
View File
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="donations_info" formatted="false">Har du nytta av EVMap? Stöd utvecklingen genom att skicka en donation till utvecklaren.\n\nGoogle tar 15% av alla donationer.</string>
<string name="data_sources_hint">I inställningarna kan du välja mellan Google Maps och OpenStreetMap som kartleverantör.</string>
</resources>
@@ -9,12 +9,14 @@ import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.SystemClock
import android.util.Log
import android.view.View
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import androidx.browser.customtabs.CustomTabColorSchemeParams
import androidx.browser.customtabs.CustomTabsIntent
import androidx.core.content.ContextCompat
import androidx.core.net.toUri
import androidx.core.splashscreen.SplashScreen
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.core.view.ViewCompat
@@ -254,11 +256,9 @@ class MapsActivity : AppCompatActivity(),
fun showLocation(charger: ChargeLocation, rootView: View) {
val coord = charger.coordinates
val intent = Intent(Intent.ACTION_VIEW)
intent.data = Uri.parse(
"geo:${coord.lat},${coord.lng}?q=${coord.lat},${coord.lng}(${
Uri.encode(charger.name)
})"
)
intent.data = "geo:${coord.lat},${coord.lng}?q=${coord.lat},${coord.lng}(${
Uri.encode(charger.name)
})".toUri()
val resolveInfo =
packageManager.resolveActivity(intent, PackageManager.MATCH_DEFAULT_ONLY)
@@ -277,11 +277,19 @@ class MapsActivity : AppCompatActivity(),
try {
startActivity(intent)
} catch (e: ActivityNotFoundException) {
Snackbar.make(
rootView,
R.string.no_maps_app_found,
Snackbar.LENGTH_SHORT
).show()
try {
// if the intent is not working with an explicit package for some reason,
// as a last resort, try again without package
intent.setPackage(null)
startActivity(intent)
} catch (e: ActivityNotFoundException) {
Snackbar.make(
rootView,
R.string.no_maps_app_found,
Snackbar.LENGTH_SHORT
).show()
Log.e("MapsActivity", "no navigation app found")
}
}
}
@@ -294,12 +302,12 @@ class MapsActivity : AppCompatActivity(),
)
.build()
val uri = Uri.parse(url)
val uri = url.toUri()
val viewIntent = Intent(Intent.ACTION_VIEW, uri)
if (preferBrowser) {
// EVMap may be set as default app for this link, but we want to open it in a browser
// try to find default web browser
val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse("http://"))
val browserIntent = Intent(Intent.ACTION_VIEW, "http://".toUri())
val resolveInfo =
packageManager.resolveActivity(browserIntent, PackageManager.MATCH_DEFAULT_ONLY)
val pkg = resolveInfo?.activityInfo?.packageName.takeIf { it != "android" }
@@ -334,20 +342,37 @@ class MapsActivity : AppCompatActivity(),
try {
intent.launchUrl(this, uri)
} catch (e: ActivityNotFoundException) {
Snackbar.make(
rootView,
R.string.no_browser_app_found,
Snackbar.LENGTH_SHORT
).show()
try {
// if the intent is not working with an explicit package for some reason,
// as a last resort, try again without package
intent.intent.setPackage(null)
intent.launchUrl(this, uri)
} catch (e: ActivityNotFoundException) {
Snackbar.make(
rootView,
R.string.no_browser_app_found,
Snackbar.LENGTH_SHORT
).show()
Log.e("MapsActivity", "no web browser found")
}
}
}
fun shareUrl(url: String) {
fun shareUrl(url: String, rootView: View) {
val intent = Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(Intent.EXTRA_TEXT, url)
}
startActivity(intent)
try {
startActivity(intent)
} catch (e: ActivityNotFoundException) {
Snackbar.make(
rootView,
R.string.no_share_app_found,
Snackbar.LENGTH_SHORT
).show()
Log.e("MapsActivity", "no sharing app found")
}
}
override fun onPreferenceStartFragment(
@@ -1,73 +1,73 @@
package net.vonforst.evmap.adapter
import android.content.res.ColorStateList
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.databinding.DataBindingUtil
import androidx.databinding.ViewDataBinding
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.chip.Chip
import net.vonforst.evmap.BR
import androidx.viewbinding.ViewBinding
import net.vonforst.evmap.R
import net.vonforst.evmap.api.availability.ChargepointStatus
import net.vonforst.evmap.api.chargeprice.ChargePrice
import net.vonforst.evmap.api.chargeprice.ChargepriceCar
import net.vonforst.evmap.api.chargeprice.ChargepriceChargepointMeta
import net.vonforst.evmap.api.chargeprice.ChargepriceTag
import net.vonforst.evmap.databinding.ItemChargepriceBinding
import net.vonforst.evmap.databinding.ItemChargepriceVehicleChipBinding
import net.vonforst.evmap.databinding.ItemConnectorButtonBinding
import net.vonforst.evmap.api.iconForPlugType
import net.vonforst.evmap.databinding.DialogConnectorDetailsItemBinding
import net.vonforst.evmap.databinding.ItemConnectorBinding
import net.vonforst.evmap.model.Chargepoint
import net.vonforst.evmap.ui.CheckableConstraintLayout
import net.vonforst.evmap.ui.availabilityColor
import net.vonforst.evmap.ui.availabilityText
import net.vonforst.evmap.ui.goneUnless
import java.time.Instant
import java.util.Locale
interface Equatable {
override fun equals(other: Any?): Boolean
}
abstract class DataBindingAdapter<T : Equatable>(getKey: ((T) -> Any)? = null) :
ListAdapter<T, DataBindingAdapter.ViewHolder<T>>(DiffCallback(getKey)) {
ListAdapter<T, DataBindingAdapter.ViewHolder>(DiffCallback(getKey)) {
var onClickListener: ((T) -> Unit)? = null
var onLongClickListener: ((T) -> Boolean)? = null
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder<T> {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val layoutInflater = LayoutInflater.from(parent.context)
val binding =
DataBindingUtil.inflate<ViewDataBinding>(layoutInflater, viewType, parent, false)
return ViewHolder(binding)
return ViewHolder(createBinding(layoutInflater, parent, viewType))
}
override fun onBindViewHolder(holder: ViewHolder<T>, position: Int) =
override fun onBindViewHolder(holder: ViewHolder, position: Int) =
bind(holder, getItem(position))
class ViewHolder<T>(val binding: ViewDataBinding) :
RecyclerView.ViewHolder(binding.root) {
}
protected abstract fun createBinding(
inflater: LayoutInflater,
parent: ViewGroup,
viewType: Int
): ViewBinding
open fun bind(holder: ViewHolder<T>, item: T) {
holder.binding.setVariable(BR.item, item)
holder.binding.executePendingBindings()
class ViewHolder(val binding: ViewBinding) : RecyclerView.ViewHolder(binding.root)
open fun bind(holder: ViewHolder, item: T) {
if (onClickListener != null) {
holder.binding.root.setOnClickListener {
val listener = onClickListener ?: return@setOnClickListener
listener(item)
}
} else {
holder.binding.root.setOnClickListener(null)
}
if (onLongClickListener != null) {
holder.binding.root.setOnLongClickListener {
val listener = onLongClickListener ?: return@setOnLongClickListener false
return@setOnLongClickListener listener(item)
}
} else {
holder.binding.root.setOnLongClickListener(null)
}
}
class DiffCallback<T : Equatable>(val getKey: ((T) -> Any)?) : DiffUtil.ItemCallback<T>() {
class DiffCallback<T : Equatable>(private val getKey: ((T) -> Any)?) :
DiffUtil.ItemCallback<T>() {
override fun areItemsTheSame(oldItem: T, newItem: T): Boolean = if (getKey != null) {
(getKey)(oldItem) == (getKey)(newItem)
getKey(oldItem) == getKey(newItem)
} else {
oldItem === newItem
}
@@ -90,10 +90,46 @@ class ConnectorAdapter : DataBindingAdapter<ConnectorAdapter.ChargepointWithAvai
data class ChargepointWithAvailability(
val chargepoint: Chargepoint,
val status: List<ChargepointStatus>?
) :
Equatable
) : Equatable
override fun getItemViewType(position: Int): Int = R.layout.item_connector
override fun createBinding(
inflater: LayoutInflater,
parent: ViewGroup,
viewType: Int
): ViewBinding = ItemConnectorBinding.inflate(inflater, parent, false)
override fun bind(holder: ViewHolder, item: ChargepointWithAvailability) {
super.bind(holder, item)
val binding = holder.binding as ItemConnectorBinding
val context = binding.root.context
val locale = Locale.getDefault()
binding.imageView.setImageResource(iconForPlugType(item.chargepoint.type))
binding.imageView.contentDescription = item.chargepoint.type
binding.imageView.imageTintList =
ColorStateList.valueOf(availabilityColor(item.status, context))
binding.txtNumber.text = String.format(locale, "x %d", item.chargepoint.count)
binding.txtNumber.visibility = goneUnless(item.status == null)
if (item.status != null) {
binding.txtStatus.text = String.format(
locale,
"%s/%d",
availabilityText(item.status),
item.chargepoint.count
)
binding.txtStatus.backgroundTintList =
ColorStateList.valueOf(availabilityColor(item.status, context))
}
binding.txtStatus.visibility = goneUnless(item.status != null)
binding.txtPower.text = item.chargepoint.formatPower(locale)
binding.txtPower.visibility = goneUnless(item.chargepoint.hasKnownPower())
binding.txtPower.setTextColor(availabilityColor(item.status, context))
}
}
class ConnectorDetailsAdapter : DataBindingAdapter<ConnectorDetailsAdapter.ConnectorDetails>() {
@@ -102,145 +138,32 @@ class ConnectorDetailsAdapter : DataBindingAdapter<ConnectorDetailsAdapter.Conne
val evseId: String?,
val label: String?,
val lastChange: Instant?
) :
Equatable
) : Equatable
override fun getItemViewType(position: Int): Int = R.layout.dialog_connector_details_item
}
class ChargepriceAdapter :
DataBindingAdapter<ChargePrice>() {
override fun createBinding(
inflater: LayoutInflater,
parent: ViewGroup,
viewType: Int
): ViewBinding = DialogConnectorDetailsItemBinding.inflate(inflater, parent, false)
val viewPool = RecyclerView.RecycledViewPool()
var meta: ChargepriceChargepointMeta? = null
set(value) {
field = value
notifyDataSetChanged()
}
var myTariffs: Set<String>? = null
set(value) {
field = value
notifyDataSetChanged()
}
var myTariffsAll: Boolean? = null
set(value) {
field = value
notifyDataSetChanged()
}
override fun getItemViewType(position: Int): Int = R.layout.item_chargeprice
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder<ChargePrice> {
val holder = super.onCreateViewHolder(parent, viewType)
val binding = holder.binding as ItemChargepriceBinding
binding.rvTags.apply {
adapter = ChargepriceTagsAdapter()
layoutManager =
LinearLayoutManager(context, LinearLayoutManager.VERTICAL, false).apply {
recycleChildrenOnDetach = true
}
itemAnimator = null
setRecycledViewPool(viewPool)
}
return holder
}
override fun bind(holder: ViewHolder<ChargePrice>, item: ChargePrice) {
override fun bind(holder: ViewHolder, item: ConnectorDetails) {
super.bind(holder, item)
(holder.binding as ItemChargepriceBinding).apply {
this.meta = this@ChargepriceAdapter.meta
this.myTariffs = this@ChargepriceAdapter.myTariffs
this.myTariffsAll = this@ChargepriceAdapter.myTariffsAll
val binding = holder.binding as DialogConnectorDetailsItemBinding
val context = binding.root.context
binding.imageView.imageTintList =
ColorStateList.valueOf(availabilityColor(item.status, context))
binding.imageView.contentDescription = availabilityText(item.status, null, context)
binding.txtEvseid.text = when {
item.label != null && item.evseId != null -> "${item.label} · ${item.evseId}"
item.label != null -> item.label
else -> item.evseId ?: ""
}
binding.txtStatus.text = availabilityText(item.status, item.lastChange, context)
binding.txtStatus.visibility = goneUnless(item.status != null)
}
}
class CheckableConnectorAdapter : DataBindingAdapter<Chargepoint>() {
private var checkedItem: Int? = 0
var enabledConnectors: List<String>? = null
get() = field
set(value) {
field = value
checkedItem?.let {
if (value != null && getItem(it).type !in value) {
checkedItem = currentList.indexOfFirst {
it.type in value
}.takeIf { it != -1 }
onCheckedItemChangedListener?.invoke(getCheckedItem())
}
}
notifyDataSetChanged()
}
override fun getItemViewType(position: Int): Int = R.layout.item_connector_button
override fun onBindViewHolder(holder: ViewHolder<Chargepoint>, position: Int) {
val item = getItem(position)
super.bind(holder, item)
val binding = holder.binding as ItemConnectorButtonBinding
binding.enabled = enabledConnectors?.let { item.type in it } ?: true
val root = binding.root as CheckableConstraintLayout
root.setOnCheckedChangeListener { _, _ -> }
root.isChecked = checkedItem == position
root.setOnClickListener {
root.isChecked = true
}
root.setOnCheckedChangeListener { _, checked: Boolean ->
if (checked) {
checkedItem = holder.bindingAdapterPosition.takeIf { it != -1 }
root.post {
notifyDataSetChanged()
}
getCheckedItem()?.let { onCheckedItemChangedListener?.invoke(it) }
}
}
}
fun getCheckedItem(): Chargepoint? = checkedItem?.let { getItem(it) }
fun setCheckedItem(item: Chargepoint?) {
checkedItem = item?.let { currentList.indexOf(item) }.takeIf { it != -1 }
}
var onCheckedItemChangedListener: ((Chargepoint?) -> Unit)? = null
}
class ChargepriceTagsAdapter() :
DataBindingAdapter<ChargepriceTag>() {
override fun getItemViewType(position: Int): Int = R.layout.item_chargeprice_tag
}
class CheckableChargepriceCarAdapter : DataBindingAdapter<ChargepriceCar>() {
private var checkedItem: ChargepriceCar? = null
override fun getItemViewType(position: Int): Int = R.layout.item_chargeprice_vehicle_chip
override fun onBindViewHolder(holder: ViewHolder<ChargepriceCar>, position: Int) {
val item = getItem(position)
super.bind(holder, item)
val binding = holder.binding as ItemChargepriceVehicleChipBinding
val root = binding.root as Chip
root.isChecked = checkedItem == item
root.setOnClickListener {
root.isChecked = true
}
root.setOnCheckedChangeListener { _, checked: Boolean ->
if (checked && item != checkedItem) {
checkedItem = item
root.post {
notifyDataSetChanged()
onCheckedItemChangedListener?.invoke(getCheckedItem()!!)
}
}
}
}
fun getCheckedItem(): ChargepriceCar? = checkedItem
fun setCheckedItem(item: ChargepriceCar?) {
checkedItem = item
}
var onCheckedItemChangedListener: ((ChargepriceCar?) -> Unit)? = null
}
@@ -4,25 +4,36 @@ import android.content.Context
import android.graphics.Typeface
import android.text.Spannable
import android.text.style.StyleSpan
import android.text.util.Linkify
import android.transition.TransitionManager
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.core.text.HtmlCompat
import androidx.core.text.buildSpannedString
import androidx.viewbinding.ViewBinding
import net.vonforst.evmap.R
import net.vonforst.evmap.api.availability.tesla.Pricing
import net.vonforst.evmap.api.availability.tesla.Rates
import net.vonforst.evmap.bold
import net.vonforst.evmap.databinding.ItemDetailBinding
import net.vonforst.evmap.databinding.ItemDetailOpeninghoursBinding
import net.vonforst.evmap.joinToSpannedString
import net.vonforst.evmap.model.ChargeCard
import net.vonforst.evmap.model.ChargeCardId
import net.vonforst.evmap.model.ChargeLocation
import net.vonforst.evmap.model.Coordinate
import net.vonforst.evmap.model.OpeningHoursDays
import net.vonforst.evmap.plus
import net.vonforst.evmap.ui.applySelectableItemBackground
import net.vonforst.evmap.ui.currency
import net.vonforst.evmap.ui.goneUnless
import net.vonforst.evmap.ui.setLinkify
import net.vonforst.evmap.utils.formatDMS
import net.vonforst.evmap.utils.formatDecimal
import java.time.DayOfWeek
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
import java.time.format.TextStyle
import java.util.Locale
class DetailsAdapter : DataBindingAdapter<DetailsAdapter.Detail>() {
data class Detail(
@@ -37,12 +48,132 @@ class DetailsAdapter : DataBindingAdapter<DetailsAdapter.Detail>() {
override fun getItemViewType(position: Int): Int {
val item = getItem(position)
if (item.hoursDays != null) {
return R.layout.item_detail_openinghours
return if (item.hoursDays != null) {
R.layout.item_detail_openinghours
} else {
return R.layout.item_detail
R.layout.item_detail
}
}
override fun createBinding(
inflater: LayoutInflater,
parent: ViewGroup,
viewType: Int
): ViewBinding = when (viewType) {
R.layout.item_detail_openinghours -> ItemDetailOpeninghoursBinding.inflate(
inflater,
parent,
false
)
else -> ItemDetailBinding.inflate(inflater, parent, false)
}
override fun bind(holder: ViewHolder, item: Detail) {
super.bind(holder, item)
if (holder.binding is ItemDetailBinding) {
bindNormal(holder.binding, item)
} else {
bindOpeningHours(holder.binding as ItemDetailOpeninghoursBinding, item)
}
}
private fun bindNormal(binding: ItemDetailBinding, item: Detail) {
binding.root.isClickable = item.clickable
applySelectableItemBackground(binding.root, item.clickable)
binding.imgIcon.setImageResource(item.icon)
binding.imgIcon.contentDescription =
binding.root.context.getString(item.contentDescription)
binding.txtTariff.text = item.text
binding.txtProvider.text = item.detailText
binding.txtProvider.visibility = goneUnless(item.detailText != null)
setLinkify(
binding.txtProvider,
if (item.links) Linkify.WEB_URLS or Linkify.PHONE_NUMBERS else 0,
item.detailText
)
}
private fun bindOpeningHours(binding: ItemDetailOpeninghoursBinding, item: Detail) {
binding.root.isClickable = item.clickable
applySelectableItemBackground(binding.root, item.clickable)
binding.imageView3.setImageResource(item.icon)
binding.imageView3.contentDescription =
binding.root.context.getString(item.contentDescription)
binding.txtTariff.text = item.text
binding.txtProvider.text = item.detailText
binding.txtProvider.visibility = goneUnless(item.detailText != null)
setLinkify(
binding.txtProvider,
if (item.links) Linkify.WEB_URLS or Linkify.PHONE_NUMBERS else 0,
item.detailText
)
val toggleMarginTop = binding.root.resources.getDimensionPixelSize(
if (item.detailText != null) {
R.dimen.expand_toggle_padding_large
} else {
R.dimen.expand_toggle_padding_small
}
)
val toggleLayoutParams = binding.expandToggle.layoutParams
if (toggleLayoutParams is ViewGroup.MarginLayoutParams) {
toggleLayoutParams.topMargin = toggleMarginTop
binding.expandToggle.layoutParams = toggleLayoutParams
}
val days = item.hoursDays
val expanded = days != null
binding.expandToggle.visibility =
if (expanded) android.view.View.VISIBLE else android.view.View.GONE
if (!expanded) return
bindDay(binding.hoursMon, DayOfWeek.MONDAY, days)
bindDay(binding.hoursTue, DayOfWeek.TUESDAY, days)
bindDay(binding.hoursWed, DayOfWeek.WEDNESDAY, days)
bindDay(binding.hoursThu, DayOfWeek.THURSDAY, days)
bindDay(binding.hoursFri, DayOfWeek.FRIDAY, days)
bindDay(binding.hoursSat, DayOfWeek.SATURDAY, days)
bindDay(binding.hoursSun, DayOfWeek.SUNDAY, days)
bindDay(binding.hoursHoliday, null, days)
val toggleListener = android.view.View.OnClickListener {
TransitionManager.beginDelayedTransition(binding.container)
val show = binding.expandToggle.isChecked
val visibility = if (show) android.view.View.VISIBLE else android.view.View.GONE
listOf(
binding.hoursMon.root,
binding.hoursTue.root,
binding.hoursWed.root,
binding.hoursThu.root,
binding.hoursFri.root,
binding.hoursSat.root,
binding.hoursSun.root,
binding.hoursHoliday.root
).forEach { it.visibility = visibility }
}
binding.expandToggle.setOnClickListener(toggleListener)
binding.expandToggle.isChecked = false
toggleListener.onClick(binding.expandToggle)
}
private fun bindDay(
include: net.vonforst.evmap.databinding.ItemDetailOpeninghoursItemBinding,
dayOfWeek: DayOfWeek?,
hours: OpeningHoursDays
) {
include.txtDay.text = if (dayOfWeek != null) {
dayOfWeek.getDisplayName(TextStyle.FULL, Locale.getDefault())
} else {
include.root.context.getString(R.string.holiday)
}
include.txtHours.text = hours.getHoursForDayOfWeek(dayOfWeek)?.toString()
?: include.root.context.getString(R.string.closed_unfmt)
}
}
fun buildDetails(
@@ -278,7 +409,9 @@ fun formatChargeCards(
.mapNotNull {
val name = chargecardData[it.id]?.name ?: return@mapNotNull null
if (filteredChargeCards?.contains(it.id) == true) {
name.bold()
buildSpannedString {
append(name, StyleSpan(Typeface.BOLD), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
}
} else {
name
}
@@ -288,4 +421,4 @@ fun formatChargeCards(
}
return result
}
}
@@ -1,10 +1,22 @@
package net.vonforst.evmap.adapter
import android.annotation.SuppressLint
import android.content.res.ColorStateList
import android.view.LayoutInflater
import android.view.ViewGroup
import android.view.animation.AccelerateInterpolator
import androidx.viewbinding.ViewBinding
import net.vonforst.evmap.R
import net.vonforst.evmap.api.stringProvider
import net.vonforst.evmap.databinding.ItemFavoriteBinding
import net.vonforst.evmap.ui.availabilityColor
import net.vonforst.evmap.ui.availabilityText
import net.vonforst.evmap.ui.distance
import net.vonforst.evmap.ui.goneUnless
import net.vonforst.evmap.ui.invisibleUnless
import net.vonforst.evmap.viewmodel.FavoritesViewModel
import net.vonforst.evmap.viewmodel.Status
import java.util.Locale
class FavoritesAdapter(val onDelete: (FavoritesViewModel.FavoritesListItem) -> Unit) :
DataBindingAdapter<FavoritesViewModel.FavoritesListItem>() {
@@ -14,16 +26,49 @@ class FavoritesAdapter(val onDelete: (FavoritesViewModel.FavoritesListItem) -> U
override fun getItemViewType(position: Int): Int = R.layout.item_favorite
override fun createBinding(
inflater: LayoutInflater,
parent: ViewGroup,
viewType: Int
): ViewBinding = ItemFavoriteBinding.inflate(inflater, parent, false)
override fun getItemId(position: Int): Long = getItem(position).fav.favorite.favoriteId
@SuppressLint("ClickableViewAccessibility")
override fun bind(
holder: ViewHolder<FavoritesViewModel.FavoritesListItem>,
holder: ViewHolder,
item: FavoritesViewModel.FavoritesListItem
) {
super.bind(holder, item)
val binding = holder.binding as ItemFavoriteBinding
val context = binding.root.context
val locale = Locale.getDefault()
binding.txtName.text = item.charger.name
binding.txtAddress.text = item.charger.address?.toString()
binding.txtAddress.visibility = invisibleUnless(item.charger.address != null)
binding.txtConnectors.text =
item.charger.formatChargepoints(context.stringProvider(), locale)
binding.txtDistance.text = distance(item.distance, context)
binding.txtDistance.visibility = goneUnless(item.distance != null)
val availableData = item.available.data
if (availableData != null) {
binding.txtStatus.text = String.format(
locale,
"%s/%d",
availabilityText(availableData),
item.total
)
binding.txtStatus.backgroundTintList =
ColorStateList.valueOf(availabilityColor(availableData, context))
}
binding.txtStatus.visibility = invisibleUnless(item.available.status == Status.SUCCESS)
binding.progressBar.visibility = goneUnless(item.available.status == Status.LOADING)
binding.foreground.translationX = 0f
binding.btnDelete.setOnClickListener {
binding.foreground.animate()
@@ -1,9 +1,12 @@
package net.vonforst.evmap.adapter
import android.annotation.SuppressLint
import android.view.LayoutInflater
import android.view.MotionEvent
import android.view.ViewGroup
import android.view.animation.AccelerateInterpolator
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.viewbinding.ViewBinding
import net.vonforst.evmap.R
import net.vonforst.evmap.databinding.ItemFilterProfileBinding
import net.vonforst.evmap.storage.FilterProfile
@@ -17,14 +20,21 @@ class FilterProfilesAdapter(
setHasStableIds(true)
}
override fun createBinding(
inflater: LayoutInflater,
parent: ViewGroup,
viewType: Int
): ViewBinding = ItemFilterProfileBinding.inflate(inflater, parent, false)
@SuppressLint("ClickableViewAccessibility")
override fun bind(
holder: ViewHolder<FilterProfile>,
holder: ViewHolder,
item: FilterProfile
) {
super.bind(holder, item)
val binding = holder.binding as ItemFilterProfileBinding
binding.txtName.text = item.name
binding.handle.setOnTouchListener { _, event ->
if (event?.action == MotionEvent.ACTION_DOWN) {
dragHelper.startDrag(holder)
@@ -2,17 +2,27 @@ package net.vonforst.evmap.adapter
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.CompoundButton
import android.widget.SeekBar
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.children
import androidx.databinding.Observable
import androidx.viewbinding.ViewBinding
import com.google.android.material.chip.Chip
import net.vonforst.evmap.BR
import net.vonforst.evmap.R
import net.vonforst.evmap.databinding.ItemFilterBooleanBinding
import net.vonforst.evmap.databinding.ItemFilterMultipleChoiceBinding
import net.vonforst.evmap.databinding.ItemFilterMultipleChoiceLargeBinding
import net.vonforst.evmap.databinding.ItemFilterSliderBinding
import net.vonforst.evmap.fragment.MultiSelectDialog
import net.vonforst.evmap.model.*
import net.vonforst.evmap.model.BooleanFilter
import net.vonforst.evmap.model.BooleanFilterValue
import net.vonforst.evmap.model.FilterValue
import net.vonforst.evmap.model.FilterWithValue
import net.vonforst.evmap.model.MultipleChoiceFilter
import net.vonforst.evmap.model.MultipleChoiceFilterValue
import net.vonforst.evmap.model.SliderFilter
import net.vonforst.evmap.model.SliderFilterValue
import kotlin.math.max
class FiltersAdapter : DataBindingAdapter<FilterWithValue<FilterValue>>() {
@@ -36,8 +46,30 @@ class FiltersAdapter : DataBindingAdapter<FilterWithValue<FilterValue>>() {
is SliderFilter -> R.layout.item_filter_slider
}
override fun createBinding(
inflater: LayoutInflater,
parent: ViewGroup,
viewType: Int
): ViewBinding = when (viewType) {
R.layout.item_filter_boolean -> ItemFilterBooleanBinding.inflate(inflater, parent, false)
R.layout.item_filter_multiple_choice -> ItemFilterMultipleChoiceBinding.inflate(
inflater,
parent,
false
)
R.layout.item_filter_multiple_choice_large -> ItemFilterMultipleChoiceLargeBinding.inflate(
inflater,
parent,
false
)
R.layout.item_filter_slider -> ItemFilterSliderBinding.inflate(inflater, parent, false)
else -> error("Unknown viewType: $viewType")
}
override fun bind(
holder: ViewHolder<FilterWithValue<FilterValue>>,
holder: ViewHolder,
item: FilterWithValue<FilterValue>
) {
super.bind(holder, item)
@@ -45,7 +77,8 @@ class FiltersAdapter : DataBindingAdapter<FilterWithValue<FilterValue>>() {
is SliderFilterValue -> {
setupSlider(
holder.binding as ItemFilterSliderBinding,
item.filter as SliderFilter, item.value
item.filter as SliderFilter,
item.value
)
}
is MultipleChoiceFilterValue -> {
@@ -53,54 +86,75 @@ class FiltersAdapter : DataBindingAdapter<FilterWithValue<FilterValue>>() {
if (filter.manyChoices) {
setupMultipleChoiceMany(
holder.binding as ItemFilterMultipleChoiceLargeBinding,
filter, item.value
filter,
item.value
)
} else {
setupMultipleChoice(
holder.binding as ItemFilterMultipleChoiceBinding,
filter, item.value
filter,
item.value
)
}
}
is BooleanFilterValue -> {
setupBoolean(holder.binding as ItemFilterBooleanBinding, item)
}
}
}
private fun setupBoolean(
binding: ItemFilterBooleanBinding,
item: FilterWithValue<FilterValue>
) {
val filter = item.filter as BooleanFilter
val value = item.value as BooleanFilterValue
binding.txtName.text = filter.name
binding.toggle.setOnCheckedChangeListener(null)
binding.toggle.isChecked = value.value
binding.toggle.setOnCheckedChangeListener { _: CompoundButton, isChecked: Boolean ->
value.value = isChecked
}
}
private fun setupMultipleChoice(
binding: ItemFilterMultipleChoiceBinding,
filter: MultipleChoiceFilter,
value: MultipleChoiceFilterValue
) {
// TODO: this implementation seems to be buggy
val inflater = LayoutInflater.from(binding.root.context)
binding.txtName.text = filter.name
value.values.toList().forEach {
// delete values that cannot be selected anymore
if (it !in filter.choices.keys) value.values.remove(it)
}
var showingAll = false
fun updateButtons() {
value.all = value.values == filter.choices.keys
binding.btnAll.isEnabled = !value.all
binding.btnNone.isEnabled = value.values.isNotEmpty()
binding.chipMore.text = binding.root.context.getString(
if (showingAll) R.string.show_less else R.string.show_more
)
}
val chips = mutableMapOf<String, Chip>()
// reuse existing chips in layout
val reuseChips = binding.chipGroup.children.filter {
it.id != R.id.chipMore
}.toMutableList()
binding.chipGroup.children.forEach {
binding.chipGroup.children.toList().forEach {
if (it.id != R.id.chipMore) binding.chipGroup.removeView(it)
}
filter.choices.entries.sortedByDescending {
it.key in value.values
}.sortedByDescending {
if (filter.commonChoices != null) it.key in filter.commonChoices else false
}.forEach { choice ->
var reused = false
val chip = if (reuseChips.size > 0) {
val chip = if (reuseChips.isNotEmpty()) {
reused = true
reuseChips.removeAt(0) as Chip
} else {
@@ -111,6 +165,7 @@ class FiltersAdapter : DataBindingAdapter<FilterWithValue<FilterValue>>() {
) as Chip
}
chip.text = choice.value
chip.setOnCheckedChangeListener(null)
chip.isChecked = choice.key in value.values || value.all
if (value.all && choice.key !in value.values) value.values.add(choice.key)
@@ -124,7 +179,7 @@ class FiltersAdapter : DataBindingAdapter<FilterWithValue<FilterValue>>() {
}
if (filter.commonChoices != null && choice.key !in filter.commonChoices
&& !(chip.isChecked && !value.all) && !binding.showingAll
&& !(chip.isChecked && !value.all) && !showingAll
) {
chip.visibility = View.GONE
} else {
@@ -134,7 +189,7 @@ class FiltersAdapter : DataBindingAdapter<FilterWithValue<FilterValue>>() {
if (!reused) binding.chipGroup.addView(chip, binding.chipGroup.childCount - 1)
chips[choice.key] = chip
}
// delete surplus reusable chips
reuseChips.forEach {
binding.chipGroup.removeView(it)
}
@@ -152,16 +207,17 @@ class FiltersAdapter : DataBindingAdapter<FilterWithValue<FilterValue>>() {
updateButtons()
}
binding.chipMore.setOnClickListener {
binding.showingAll = !binding.showingAll
showingAll = !showingAll
chips.forEach { (key, chip) ->
if (filter.commonChoices != null && key !in filter.commonChoices
&& !(chip.isChecked && !value.all) && !binding.showingAll
&& !(chip.isChecked && !value.all) && !showingAll
) {
chip.visibility = View.GONE
} else {
chip.visibility = View.VISIBLE
}
}
updateButtons()
}
updateButtons()
}
@@ -173,21 +229,36 @@ class FiltersAdapter : DataBindingAdapter<FilterWithValue<FilterValue>>() {
) {
if (value.all) {
value.values = filter.choices.keys.toMutableSet()
binding.notifyPropertyChanged(BR.item)
}
binding.txtName.text = filter.name
binding.txtDetail.text = binding.root.context.getString(
if (value.all) {
R.string.all_selected
} else {
R.string.number_selected
},
value.values.size
)
binding.btnEdit.setOnClickListener {
val dialog =
MultiSelectDialog.getInstance(
filter.name,
filter.choices,
value.values,
commonChoices = filter.commonChoices
)
val dialog = MultiSelectDialog.getInstance(
filter.name,
filter.choices,
value.values,
commonChoices = filter.commonChoices
)
dialog.okListener = { selected ->
value.values = selected.toMutableSet()
value.all = value.values == filter.choices.keys
binding.item = binding.item
binding.txtDetail.text = binding.root.context.getString(
if (value.all) {
R.string.all_selected
} else {
R.string.number_selected
},
value.values.size
)
}
dialog.show((binding.root.context as AppCompatActivity).supportFragmentManager, null)
}
@@ -198,24 +269,37 @@ class FiltersAdapter : DataBindingAdapter<FilterWithValue<FilterValue>>() {
filter: SliderFilter,
value: SliderFilterValue
) {
binding.progress =
max(filter.inverseMapping(value.value) - filter.min, 0)
binding.mappedValue = filter.mapping(binding.progress + filter.min)
binding.txtName.text = filter.name
val progress = max(filter.inverseMapping(value.value) - filter.min, 0)
binding.seekBar.max = filter.max - filter.min
binding.seekBar.setOnSeekBarChangeListener(null)
binding.seekBar.progress = progress
updateSliderValueText(binding, filter, progress)
binding.addOnPropertyChangedCallback(object :
Observable.OnPropertyChangedCallback() {
override fun onPropertyChanged(sender: Observable?, propertyId: Int) {
when (propertyId) {
BR.progress -> {
val mapped = filter.mapping(binding.progress + filter.min)
value.value = mapped
binding.mappedValue = mapped
}
}
binding.seekBar.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) {
value.value = filter.mapping(progress + filter.min)
updateSliderValueText(binding, filter, progress)
}
override fun onStartTrackingTouch(seekBar: SeekBar?) = Unit
override fun onStopTrackingTouch(seekBar: SeekBar?) = Unit
})
}
private fun updateSliderValueText(
binding: ItemFilterSliderBinding,
filter: SliderFilter,
progress: Int
) {
val mappedValue = filter.mapping(progress + filter.min)
binding.txtValue.text = if (filter.unit.isNullOrBlank()) {
mappedValue.toString()
} else {
"$mappedValue ${filter.unit}"
}
}
override fun getItemId(position: Int): Long {
val key = getItem(position).filter.key
var value = itemids[key]
@@ -1,23 +1,34 @@
package net.vonforst.evmap.adapter
import android.content.Context
import android.content.res.ColorStateList
import android.os.Handler
import android.os.Looper
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.*
import androidx.databinding.DataBindingUtil
import android.widget.BaseAdapter
import android.widget.Filter
import android.widget.Filterable
import android.widget.ImageView
import androidx.lifecycle.LiveData
import com.car2go.maps.model.LatLng
import net.vonforst.evmap.R
import net.vonforst.evmap.autocomplete.*
import net.vonforst.evmap.autocomplete.ApiUnavailableException
import net.vonforst.evmap.autocomplete.AutocompletePlace
import net.vonforst.evmap.autocomplete.AutocompletePlaceType
import net.vonforst.evmap.autocomplete.AutocompleteProvider
import net.vonforst.evmap.autocomplete.MapboxAutocompleteProvider
import net.vonforst.evmap.autocomplete.PlaceWithBounds
import net.vonforst.evmap.autocomplete.getAutocompleteProviders
import net.vonforst.evmap.containsAny
import net.vonforst.evmap.databinding.ItemAutocompleteResultBinding
import net.vonforst.evmap.isDarkMode
import net.vonforst.evmap.storage.AppDatabase
import net.vonforst.evmap.storage.PreferenceDataSource
import net.vonforst.evmap.storage.RecentAutocompletePlace
import net.vonforst.evmap.ui.distance
import net.vonforst.evmap.ui.goneUnless
import java.time.Instant
class PlaceAutocompleteAdapter(val context: Context, val location: LiveData<LatLng>) :
@@ -56,9 +67,8 @@ class PlaceAutocompleteAdapter(val context: Context, val location: LiveData<LatL
if (getItemViewType(position) == typeItem) {
val viewHolder: ViewHolder
if (view == null) {
val binding: ItemAutocompleteResultBinding = DataBindingUtil.inflate(
val binding = ItemAutocompleteResultBinding.inflate(
LayoutInflater.from(context),
R.layout.item_autocomplete_result,
parent,
false
)
@@ -88,7 +98,27 @@ class PlaceAutocompleteAdapter(val context: Context, val location: LiveData<LatL
viewHolder: ViewHolder,
place: AutocompletePlace
) {
viewHolder.binding.item = place
val binding = viewHolder.binding
val isSpecial = isSpecialPlace(place.types)
binding.title.text = place.primaryText
binding.subtitle.text = place.secondaryText
binding.icon.contentDescription = place.secondaryText
binding.icon.setImageResource(iconForPlaceType(place.types))
val tintColor = if (isSpecial) {
androidx.appcompat.R.attr.colorPrimary
} else {
androidx.appcompat.R.attr.colorControlNormal
}
val ta = binding.root.context.theme.obtainStyledAttributes(intArrayOf(tintColor))
val tint = ta.getColor(0, 0)
ta.recycle()
binding.icon.imageTintList = ColorStateList.valueOf(tint)
binding.icon.backgroundTintList = ColorStateList.valueOf(tint)
binding.textView16.text = distance(place.distanceMeters, binding.root.context)
binding.textView16.visibility = goneUnless(place.distanceMeters != null)
}
override fun getFilter(): Filter {
@@ -5,6 +5,7 @@ import com.car2go.maps.model.LatLng
import com.car2go.maps.model.LatLngBounds
import net.vonforst.evmap.R
import net.vonforst.evmap.api.goingelectric.GoingElectricApiWrapper
import net.vonforst.evmap.api.nobil.NobilApiWrapper
import net.vonforst.evmap.api.openchargemap.OpenChargeMapApiWrapper
import net.vonforst.evmap.api.openstreetmap.OpenStreetMapApiWrapper
import net.vonforst.evmap.model.*
@@ -94,6 +95,13 @@ fun Context.stringProvider() = object : StringProvider {
fun createApi(type: String, ctx: Context): ChargepointApi<ReferenceData> {
return when (type) {
"nobil" -> {
NobilApiWrapper(
ctx.getString(
R.string.nobil_key
)
)
}
"openchargemap" -> {
OpenChargeMapApiWrapper(
ctx.getString(
@@ -2,10 +2,27 @@ package net.vonforst.evmap.api
import okhttp3.Interceptor
import okhttp3.Response
import okio.IOException
import retrofit2.HttpException
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
import kotlin.time.TimeSource
fun Interceptor.Chain.proceedSafely(request: okhttp3.Request): Response {
return try {
proceed(request)
} catch (e: Throwable) {
if (e is IOException) {
throw e
} else if (e is HttpException) {
// wrap HttpExceptions as IOExceptions, so that they are properly handled by Retrofit's error handling
throw IOException("${e.response()}", e)
} else {
// wrap other exceptions as IOExceptions, so that they are properly handled by Retrofit's error handling
throw IOException(e)
}
}
}
class RateLimitInterceptor : Interceptor {
private val rateLimiter = SimpleRateLimiter(3.0)
@@ -16,7 +33,7 @@ class RateLimitInterceptor : Interceptor {
// limit requests sent to NewMotion to 3 per second
rateLimiter.acquire()
var response: Response = chain.proceed(request)
var response: Response = chain.proceedSafely(request)
// 403 is how the NewMotion API indicates a rate limit error
if (!response.isSuccessful && response.code == 403) {
response.close()
@@ -25,11 +42,11 @@ class RateLimitInterceptor : Interceptor {
Thread.sleep(1000)
} catch (e: InterruptedException) {
}
response = chain.proceed(request)
response = chain.proceedSafely(request)
}
return response
} else {
return chain.proceed(request)
return chain.proceedSafely(request)
}
}
}
@@ -8,7 +8,7 @@ import kotlin.math.abs
private val plugNames = mapOf(
Chargepoint.TYPE_1 to R.string.plug_type_1,
Chargepoint.TYPE_2_UNKNOWN to R.string.plug_type_2,
Chargepoint.TYPE_2_PLUG to R.string.plug_type_2,
Chargepoint.TYPE_2_PLUG to R.string.plug_type_2_tethered,
Chargepoint.TYPE_2_SOCKET to R.string.plug_type_2,
Chargepoint.TYPE_3A to R.string.plug_type_3a,
Chargepoint.TYPE_3C to R.string.plug_type_3c,
@@ -20,7 +20,8 @@ private val plugNames = mapOf(
Chargepoint.SUPERCHARGER to R.string.plug_supercharger,
Chargepoint.CEE_BLAU to R.string.plug_cee_blau,
Chargepoint.CEE_ROT to R.string.plug_cee_rot,
Chargepoint.TESLA_ROADSTER_HPC to R.string.plug_roadster_hpc
Chargepoint.TESLA_ROADSTER_HPC to R.string.plug_roadster_hpc,
Chargepoint.MCS to R.string.plug_mcs
)
fun nameForPlugType(ctx: StringProvider, type: String): String =
@@ -63,6 +64,7 @@ fun iconForPlugType(type: String): Int =
Chargepoint.CEE_BLAU -> R.drawable.ic_connector_cee_blau
Chargepoint.CEE_ROT -> R.drawable.ic_connector_cee_rot
Chargepoint.TYPE_1 -> R.drawable.ic_connector_typ1
Chargepoint.MCS -> R.drawable.ic_connector_mcs
// TODO: add other connectors
else -> R.drawable.ic_connector_unknown
}
@@ -175,8 +175,8 @@ class AvailabilityRepository(context: Context) {
RheinenergieAvailabilityDetector(okhttp),
teslaOwnerAvailabilityDetector,
TeslaGuestAvailabilityDetector(okhttp),
EnBwAvailabilityDetector(okhttp),
NewMotionAvailabilityDetector(okhttp)
NobilAvailabilityDetector(okhttp, context),
EnBwAvailabilityDetector(okhttp)
)
suspend fun getAvailability(charger: ChargeLocation): Resource<ChargeLocationStatus> {
@@ -4,6 +4,7 @@ import com.squareup.moshi.FromJson
import com.squareup.moshi.JsonClass
import com.squareup.moshi.Moshi
import com.squareup.moshi.ToJson
import net.vonforst.evmap.api.proceedSafely
import net.vonforst.evmap.model.ChargeLocation
import net.vonforst.evmap.model.Chargepoint
import net.vonforst.evmap.utils.distanceBetween
@@ -86,15 +87,15 @@ interface EnBwApi {
.addInterceptor { chain ->
// add API key to every request
val request = chain.request().newBuilder()
.header("Ocp-Apim-Subscription-Key", "d4954e8b2e444fc89a89a463788c0a72")
.header("Ocp-Apim-Subscription-Key", "90a67b9900364009b588e100e4b1cc64")
.header("Origin", "https://www.enbw.com")
.header("Referer", "https://www.enbw.com/")
.header("Accept", "application/json")
.build()
chain.proceed(request)
chain.proceedSafely(request)
}.build()
val retrofit = Retrofit.Builder()
.baseUrl(baseUrl ?: "https://enbw-emp.azure-api.net/emobility-public-api/api/v1/")
.baseUrl(baseUrl ?: "https://api.emp.emob-enbw.com/emobility-public-api/api/v1/")
.addConverterFactory(
MoshiConverterFactory.create(
Moshi.Builder().add(InstantAdapter()).build()
@@ -136,17 +137,8 @@ class EnBwAvailabilityDetector(client: OkHttpClient, baseUrl: String? = null) :
var markers =
api.getMarkers(lng - coordRange, lng + coordRange, lat - coordRange, lat + coordRange)
markers = markers.flatMap {
if (it.grouped) {
api.getMarkers(
it.viewPort.lowerLeftLon,
it.viewPort.upperRightLon,
it.viewPort.lowerLeftLat,
it.viewPort.upperRightLat
)
} else {
listOf(it)
}
repeat(3) {
markers = ungroupMarkers(markers)
}
if (markers.any { it.grouped }) throw AvailabilityDetectorException("markers still grouped")
@@ -203,7 +195,7 @@ class EnBwAvailabilityDetector(client: OkHttpClient, baseUrl: String? = null) :
"Typ 3A" -> Chargepoint.TYPE_3A
"Typ 3C \"Scame\"" -> Chargepoint.TYPE_3C
"Typ 2" -> Chargepoint.TYPE_2_UNKNOWN
"Typ 1" -> Chargepoint.TYPE_1
"Typ 1 Steckdose" -> Chargepoint.TYPE_1
"Steckdose(D)" -> Chargepoint.SCHUKO
"CCS (Typ 1)" -> Chargepoint.CCS_TYPE_1 // US CCS, aka type1_combo
"CCS (Typ 2)" -> Chargepoint.CCS_TYPE_2 // EU CCS, aka type2_combo
@@ -241,6 +233,20 @@ class EnBwAvailabilityDetector(client: OkHttpClient, baseUrl: String? = null) :
)
}
private suspend fun ungroupMarkers(markers: List<EnBwApi.EnBwLocation>): List<EnBwApi.EnBwLocation> =
markers.flatMap {
if (it.grouped) {
api.getMarkers(
it.viewPort.lowerLeftLon,
it.viewPort.upperRightLon,
it.viewPort.lowerLeftLat,
it.viewPort.upperRightLat
)
} else {
listOf(it)
}
}
override fun isChargerSupported(charger: ChargeLocation): Boolean {
val country = charger.chargepriceData?.country ?: charger.address?.country
@@ -266,6 +272,7 @@ class EnBwAvailabilityDetector(client: OkHttpClient, baseUrl: String? = null) :
"Spanien",
"Tschechien"
) && charger.network != "Tesla Supercharger"
"nobil" -> charger.network != "Tesla"
"openchargemap" -> country in listOf(
"DE",
"AT",
@@ -1,229 +0,0 @@
package net.vonforst.evmap.api.availability
import com.squareup.moshi.FromJson
import com.squareup.moshi.JsonClass
import com.squareup.moshi.Moshi
import com.squareup.moshi.ToJson
import net.vonforst.evmap.model.ChargeLocation
import net.vonforst.evmap.model.Chargepoint
import net.vonforst.evmap.utils.distanceBetween
import okhttp3.OkHttpClient
import retrofit2.Retrofit
import retrofit2.converter.moshi.MoshiConverterFactory
import retrofit2.http.GET
import retrofit2.http.Path
import java.time.ZonedDateTime
import java.util.Locale
private const val coordRange = 0.005 // range of latitude and longitude for loading the map
private const val maxDistance = 60 // max distance between reported positions in meters
interface NewMotionApi {
@GET("markers/{lngMin}/{lngMax}/{latMin}/{latMax}/{zoom}")
suspend fun getMarkers(
@Path("lngMin") lngMin: Double,
@Path("lngMax") lngMax: Double,
@Path("latMin") latMin: Double,
@Path("latMax") latMax: Double,
@Path("zoom") zoom: Int = 22
): List<NMMarker>
@GET("locations/{id}")
suspend fun getLocation(@Path("id") id: Long): NMLocation
@JsonClass(generateAdapter = true)
data class NMMarker(val coordinates: NMCoordinates, val locationUid: Long, val evseCount: Int)
@JsonClass(generateAdapter = true)
data class NMCoordinates(val latitude: Double, val longitude: Double)
@JsonClass(generateAdapter = true)
data class NMLocation(
val uid: Long,
val coordinates: NMCoordinates,
val operatorName: String,
val evses: List<NMEvse>
)
@JsonClass(generateAdapter = true)
data class NMEvse(
val evseId: String?,
val status: String,
val connectors: List<NMConnector>,
val updated: ZonedDateTime?
)
@JsonClass(generateAdapter = true)
data class NMConnector(
val uid: Long,
val connectorType: String,
val electricalProperties: NMElectricalProperties
)
@JsonClass(generateAdapter = true)
data class NMElectricalProperties(val powerType: String, val voltage: Int, val amperage: Int) {
fun getPower(): Double {
val phases = when (powerType) {
"AC1Phase" -> 1
"AC3Phase" -> 3
else -> 1
}
val volt = when (voltage) {
277 -> 230
else -> voltage
}
val power = volt * amperage * phases
return when (power) {
3680 -> 3.7
11040 -> 11.0
22080 -> 22.0
43470 -> 43.0
else -> power / 1000.0
}
}
}
companion object {
fun create(client: OkHttpClient, baseUrl: String? = null): NewMotionApi {
val retrofit = Retrofit.Builder()
.baseUrl(baseUrl ?: "https://ui-map.shellrecharge.com/api/map/v2/")
.addConverterFactory(
MoshiConverterFactory.create(
Moshi.Builder().add(ZonedDateTimeAdapter()).build()
)
)
.client(client)
.build()
return retrofit.create(NewMotionApi::class.java)
}
}
}
internal class ZonedDateTimeAdapter {
@FromJson
fun fromJson(value: String): ZonedDateTime? = ZonedDateTime.parse(value)
@ToJson
fun toJson(value: ZonedDateTime): String = value.toString()
}
data class NmStatus(
val conn: NewMotionApi.NMConnector,
val status: String,
val evseId: String?,
val updated: ZonedDateTime?
)
class NewMotionAvailabilityDetector(client: OkHttpClient, baseUrl: String? = null) :
BaseAvailabilityDetector(client) {
val api = NewMotionApi.create(client, baseUrl)
override suspend fun getAvailability(location: ChargeLocation): ChargeLocationStatus {
val lat = location.coordinates.lat
val lng = location.coordinates.lng
// find nearest station to this position
var markers =
api.getMarkers(lng - coordRange, lng + coordRange, lat - coordRange, lat + coordRange)
val nearest = markers.minByOrNull { marker ->
distanceBetween(marker.coordinates.latitude, marker.coordinates.longitude, lat, lng)
} ?: throw AvailabilityDetectorException("no candidates found.")
if (distanceBetween(
nearest.coordinates.latitude,
nearest.coordinates.longitude,
lat,
lng
) > radius
) {
throw AvailabilityDetectorException("no candidates found")
}
markers = if (nearest.evseCount < location.totalChargepoints) {
// combine related stations
markers.filter { marker ->
distanceBetween(
marker.coordinates.latitude,
marker.coordinates.longitude,
nearest.coordinates.latitude,
nearest.coordinates.longitude
) < maxDistance
}
} else {
listOf(nearest)
}
// load details
var details = markers.map {
api.getLocation(it.locationUid)
}
// only include stations from same operator
details = details.filter {
it.operatorName == details[0].operatorName
}
val connectorStatus = details.flatMap { it.evses }.flatMap { evse ->
evse.connectors.map { connector ->
NmStatus(connector, evse.status, evse.evseId, evse.updated)
}
}
val nmConnectors = mutableMapOf<Long, Pair<Double, String>>()
val nmStatus = mutableMapOf<Long, ChargepointStatus>()
val nmEvseId = mutableMapOf<Long, String>()
val nmUpdated = mutableMapOf<Long, ZonedDateTime>()
connectorStatus.forEach { (connector, statusStr, evseId, updated) ->
val id = connector.uid
val power = connector.electricalProperties.getPower()
val type = when (connector.connectorType.lowercase(Locale.ROOT)) {
"type3" -> Chargepoint.TYPE_3C
"type2" -> Chargepoint.TYPE_2_UNKNOWN
"type1" -> Chargepoint.TYPE_1
"domestic" -> Chargepoint.SCHUKO
"type1combo" -> Chargepoint.CCS_TYPE_1 // US CCS, aka type1_combo
"type2combo" -> Chargepoint.CCS_TYPE_2 // EU CCS, aka type2_combo
"tepcochademo" -> Chargepoint.CHADEMO
"unspecified" -> "unknown"
"unknown" -> "unknown"
"saej1772" -> "unknown"
else -> "unknown"
}
val status = when (statusStr) {
"Unavailable" -> ChargepointStatus.FAULTED
"Available" -> ChargepointStatus.AVAILABLE
"Occupied" -> ChargepointStatus.CHARGING
"Unspecified" -> ChargepointStatus.UNKNOWN
else -> ChargepointStatus.UNKNOWN
}
nmConnectors.put(id, power to type)
nmStatus.put(id, status)
evseId?.let { nmEvseId[id] = it }
updated?.let { nmUpdated[id] = it }
}
val match = matchChargepoints(nmConnectors, location.chargepointsMerged)
val chargepointStatus = match.mapValues { entry ->
entry.value.map { nmStatus[it]!! }
}
val evseIds = if (nmEvseId.size == nmStatus.size) match.mapValues { entry ->
entry.value.map { nmEvseId[it]!! }
} else null
val updated = match.mapValues { entry -> entry.value.map { nmUpdated[it]?.toInstant() } }
return ChargeLocationStatus(
chargepointStatus,
"NewMotion",
evseIds,
lastChange = updated
)
}
override fun isChargerSupported(charger: ChargeLocation): Boolean {
// NewMotion is our fallback
return when (charger.dataSource) {
"goingelectric" -> charger.network != "Tesla Supercharger"
"openchargemap" -> charger.chargepriceData?.network !in listOf("23", "3534")
"openstreetmap" -> charger.operator !in listOf("Tesla, Inc.", "Tesla")
else -> false
}
}
}
@@ -0,0 +1,109 @@
package net.vonforst.evmap.api.availability
import android.content.Context
import com.squareup.moshi.FromJson
import com.squareup.moshi.JsonClass
import com.squareup.moshi.Moshi
import com.squareup.moshi.ToJson
import net.vonforst.evmap.R
import net.vonforst.evmap.model.ChargeLocation
import okhttp3.OkHttpClient
import retrofit2.Retrofit
import retrofit2.converter.moshi.MoshiConverterFactory
import retrofit2.http.GET
import retrofit2.http.Header
import retrofit2.http.Path
import java.time.Instant
internal class InstantStringAdapter {
@FromJson
fun fromJson(value: String?): Instant? = value?.let {
Instant.parse(value)
}
@ToJson
fun toJson(value: Instant?): String? = value?.toString()
}
interface NobilRealtimeApi {
@GET("{nobilId}")
suspend fun getAvailability(
@Path("nobilId") nobilId: String,
@Header("X-Api-Key") apiKey: String
): List<NobilChargepointState>
companion object {
fun create(client: OkHttpClient): NobilRealtimeApi {
val retrofit = Retrofit.Builder()
.baseUrl("https://api.ev-map.app/nobil/api/realtime/")
.addConverterFactory(
MoshiConverterFactory.create(
Moshi.Builder().add(InstantStringAdapter()).build()
)
)
.client(client)
.build()
return retrofit.create(NobilRealtimeApi::class.java)
}
}
}
@JsonClass(generateAdapter = true)
data class NobilChargepointState(
val evseUid: String,
val status: String,
val timestamp: Instant
)
class NobilAvailabilityDetector(client: OkHttpClient, context: Context) :
BaseAvailabilityDetector(client) {
val api = NobilRealtimeApi.create(client)
val apiKey = context.getString(R.string.evmap_key)
override suspend fun getAvailability(location: ChargeLocation): ChargeLocationStatus {
val nobilId = when (location.address?.country) {
"Norway" -> "NOR"
"Sweden" -> "SWE"
else -> throw AvailabilityDetectorException("nobil: unsupported country")
} + "_%05d".format(location.id)
val availability = api.getAvailability(nobilId, apiKey)
if (availability.isEmpty()) {
throw AvailabilityDetectorException("nobil: no real-time data available")
}
return ChargeLocationStatus(
location.chargepointsMerged.associateWith { cp ->
cp.evseUIds!!.map { evseUId ->
when (availability.find { it.evseUid == evseUId }?.status) {
"AVAILABLE" -> ChargepointStatus.AVAILABLE
"BLOCKED" -> ChargepointStatus.OCCUPIED
"CHARGING" -> ChargepointStatus.CHARGING
"INOPERATIVE" -> ChargepointStatus.FAULTED
"OUTOFORDER" -> ChargepointStatus.FAULTED
"PLANNED" -> ChargepointStatus.FAULTED
"REMOVED" -> ChargepointStatus.FAULTED
"RESERVED" -> ChargepointStatus.OCCUPIED
"UNKNOWN" -> ChargepointStatus.UNKNOWN
else -> ChargepointStatus.UNKNOWN
}
}
},
"Nobil",
location.chargepointsMerged.associateWith { cp ->
if (cp.evseIds != null) cp.evseIds.map { it ?: "??" } else listOf()
},
lastChange = location.chargepointsMerged.associateWith { cp ->
cp.evseUIds!!.map { evseUId ->
availability.find { it.evseUid == evseUId }?.timestamp
}
}
)
}
override fun isChargerSupported(charger: ChargeLocation): Boolean {
return when (charger.dataSource) {
"nobil" -> charger.chargepoints.any { it.evseUIds?.isNotEmpty() == true }
else -> false
}
}
}
@@ -86,6 +86,40 @@ class TeslaGuestAvailabilityDetector(
}
val details = detailsA.await()
if (location.dataSource == "nobil") {
// TODO: Lots of copy & paste here. The main difference for nobil data
// is that V2 chargers don't have duplicated connectors.
var detailsSorted = details.chargerList
.sortedBy { c -> c.labelLetter }
.sortedBy { c -> c.labelNumber }
if (detailsSorted.size != location.chargepoints.size) {
// TODO: Tesla data could also be missing for connectors
throw AvailabilityDetectorException("charger has unknown connectors")
}
val detailsMap =
mutableMapOf<Chargepoint, List<TeslaChargingGuestGraphQlApi.ChargerDetail>>()
var i = 0
for (connector in location.chargepointsMerged) {
detailsMap[connector] =
detailsSorted.subList(i, i + connector.count)
i += connector.count
}
val statusMap = detailsMap.mapValues { it.value.map { it.availability.toStatus() } }
val labelsMap = detailsMap.mapValues { it.value.map { it.label } }
val pricing = details.pricing?.copy(memberRates = guestPricing.await()?.userRates)
return ChargeLocationStatus(
statusMap,
"Tesla",
labels = labelsMap,
extraData = pricing
)
}
val scV2Connectors = location.chargepoints.filter { it.type == Chargepoint.SUPERCHARGER }
val scV2CCSConnectors = location.chargepoints.filter {
it.type in listOf(
@@ -166,6 +200,7 @@ class TeslaGuestAvailabilityDetector(
override fun isChargerSupported(charger: ChargeLocation): Boolean {
return when (charger.dataSource) {
"goingelectric" -> charger.network == "Tesla Supercharger"
"nobil" -> charger.network == "Tesla"
"openchargemap" -> charger.chargepriceData?.network in listOf("23", "3534")
"openstreetmap" -> charger.operator in listOf("Tesla, Inc.", "Tesla")
else -> false
@@ -7,6 +7,7 @@ import net.vonforst.evmap.api.availability.tesla.asTeslaCoord
import net.vonforst.evmap.model.ChargeLocation
import net.vonforst.evmap.model.Chargepoint
import okhttp3.OkHttpClient
import retrofit2.HttpException
import java.time.Instant
import java.util.Collections
@@ -67,7 +68,54 @@ class TeslaOwnerAvailabilityDetector(
)
).data.charging.site ?: throw AvailabilityDetectorException("no candidates found.")
if (location.dataSource == "nobil") {
// TODO: Lots of copy & paste here. The main difference for nobil data
// is that V2 chargers don't have duplicated connectors.s
val chargerDetails = details.siteDynamic.chargerDetails
val chargers = details.siteStatic.chargers.associateBy { it.id }
var detailsSorted = chargerDetails
.sortedBy { c -> c.charger.labelLetter ?: chargers[c.charger.id]?.labelLetter }
.sortedBy { c -> c.charger.labelNumber ?: chargers[c.charger.id]?.labelNumber }
if (detailsSorted.size != location.chargepoints.size) {
// TODO: Code below suggests tesla data could also be missing for
// connectors
throw AvailabilityDetectorException("Tesla API chargepoints do not match data source")
}
val congestionHistogram = details.congestionPriceHistogram?.let { cph ->
val indexOfMidnight = cph.dataAttributes.indexOfFirst { it.label == "12AM" }
indexOfMidnight.takeIf { it >= 0 }?.let { index ->
val data = cph.data.toMutableList()
Collections.rotate(data, -index)
data
}
}
val detailsMap =
emptyMap<Chargepoint, List<TeslaChargingOwnershipGraphQlApi.ChargerDetail>>().toMutableMap()
var i = 0
for (connector in location.chargepointsMerged) {
detailsMap[connector] =
detailsSorted.subList(i, i + connector.count)
i += connector.count
}
val statusMap = detailsMap.mapValues { it.value.map { it.availability.toStatus() } }
val labelsMap = detailsMap.mapValues {
it.value.map {
it.charger.label?.value ?: chargers[it.charger.id]?.label?.value
}
}
return ChargeLocationStatus(
statusMap,
"Tesla",
labels = labelsMap,
congestionHistogram = congestionHistogram,
extraData = details.pricing
)
}
val scV2Connectors = location.chargepoints.filter { it.type == Chargepoint.SUPERCHARGER }
val scV2CCSConnectors = location.chargepoints.filter {
it.type in listOf(
@@ -165,6 +213,7 @@ class TeslaOwnerAvailabilityDetector(
override fun isChargerSupported(charger: ChargeLocation): Boolean {
return when (charger.dataSource) {
"goingelectric" -> charger.network == "Tesla Supercharger"
"nobil" -> charger.network == "Tesla"
"openchargemap" -> charger.chargepriceData?.network in listOf("23", "3534")
"openstreetmap" -> charger.operator in listOf("Tesla, Inc.", "Tesla")
else -> false
@@ -181,12 +230,21 @@ class TeslaOwnerAvailabilityDetector(
?: run {
val refreshToken = tokenStore.teslaRefreshToken
?: throw NotSignedInException()
val response =
val response = try {
authApi.getToken(
TeslaAuthenticationApi.RefreshTokenRequest(
refreshToken
)
)
} catch (e: HttpException) {
if (e.code() == 401) {
// refresh token is invalid, clear it
tokenStore.teslaRefreshToken = null
throw NotSignedInException()
} else {
throw e
}
}
tokenStore.teslaAccessToken = response.accessToken
tokenStore.teslaAccessTokenExpiry = now + response.expiresIn
response.accessToken
@@ -3,6 +3,7 @@ package net.vonforst.evmap.api.availability.tesla
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import com.squareup.moshi.Moshi
import net.vonforst.evmap.api.proceedSafely
import okhttp3.CacheControl
import okhttp3.OkHttpClient
import retrofit2.Retrofit
@@ -48,7 +49,7 @@ interface TeslaCuaApi {
val request = chain.request().newBuilder()
.cacheControl(CacheControl.Builder().maxStale(24, TimeUnit.HOURS).build())
.build()
chain.proceed(request)
chain.proceedSafely(request)
}.build()
val retrofit = Retrofit.Builder()
.baseUrl(baseUrl ?: "https://www.tesla.com/cua-api/")
@@ -115,7 +116,7 @@ interface TeslaChargingGuestGraphQlApi {
val activeOutages: List<Outage>?,
val chargerList: List<ChargerDetail>,
val trtId: Long,
val maxPowerKw: Int,
val maxPowerKw: Int?,
val name: String,
val pricing: Pricing?,
val publicStallCount: Int
@@ -7,6 +7,7 @@ import com.squareup.moshi.JsonClass
import com.squareup.moshi.Moshi
import com.squareup.moshi.adapters.PolymorphicJsonAdapterFactory
import kotlinx.coroutines.runBlocking
import net.vonforst.evmap.api.proceedSafely
import okhttp3.OkHttpClient
import retrofit2.Retrofit
import retrofit2.converter.moshi.MoshiConverterFactory
@@ -25,7 +26,7 @@ interface TeslaAuthenticationApi {
class AuthCodeRequest(
val code: String,
@Json(name = "code_verifier") val codeVerifier: String,
@Json(name = "redirect_uri") val redirectUri: String = "https://auth.tesla.com/void/callback",
@Json(name = "redirect_uri") val redirectUri: String = "tesla://auth/callback",
scope: String = "openid email offline_access",
@Json(name = "client_id") clientId: String = "ownerapi"
) : OAuth2Request(scope, clientId)
@@ -98,13 +99,13 @@ interface TeslaAuthenticationApi {
.appendQueryParameter("client_id", "ownerapi")
.appendQueryParameter("code_challenge", codeChallenge)
.appendQueryParameter("code_challenge_method", "S256")
.appendQueryParameter("redirect_uri", "https://auth.tesla.com/void/callback")
.appendQueryParameter("redirect_uri", "tesla://auth/callback")
.appendQueryParameter("response_type", "code")
.appendQueryParameter("scope", "openid email offline_access phone")
.appendQueryParameter("is_in_app", "true")
.appendQueryParameter("state", "123").build()
val resultUrlPrefix = "https://auth.tesla.com/void/callback"
val resultUrlPrefix = "tesla://auth/callback"
}
}
@@ -135,7 +136,7 @@ interface TeslaOwnerApi {
.header("x-tesla-user-agent", "TeslaApp/4.44.5-3304/3a5d531cc3/android/27")
.header("Accept", "*/*")
.build()
chain.proceed(request)
chain.proceedSafely(request)
}.build()
val retrofit = Retrofit.Builder()
.baseUrl(baseUrl ?: "https://owner-api.teslamotors.com")
@@ -376,7 +377,7 @@ interface TeslaChargingOwnershipGraphQlApi {
.header("x-tesla-user-agent", "TeslaApp/4.44.5-3304/3a5d531cc3/android/27")
.header("Accept", "*/*")
.build()
chain.proceed(request)
chain.proceedSafely(request)
}.build()
val retrofit = Retrofit.Builder()
.baseUrl(baseUrl ?: "https://akamai-apigateway-charging-ownership.tesla.com")
@@ -1,99 +1,16 @@
package net.vonforst.evmap.api.chargeprice
import android.content.Context
import com.squareup.moshi.Moshi
import com.squareup.moshi.adapters.PolymorphicJsonAdapterFactory
import jsonapi.Document
import jsonapi.JsonApiFactory
import jsonapi.retrofit.DocumentConverterFactory
import net.vonforst.evmap.BuildConfig
import net.vonforst.evmap.addDebugInterceptors
import net.vonforst.evmap.model.ChargeLocation
import okhttp3.Cache
import okhttp3.OkHttpClient
import retrofit2.Retrofit
import retrofit2.converter.moshi.MoshiConverterFactory
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.Header
import retrofit2.http.POST
import java.util.*
import java.util.Locale
interface ChargepriceApi {
@POST("charge_prices")
suspend fun getChargePrices(
@Body @jsonapi.retrofit.Document request: ChargepriceRequest,
@Header("Accept-Language") language: String
): Document<List<ChargePrice>>
@GET("vehicles")
@jsonapi.retrofit.Document
suspend fun getVehicles(): List<ChargepriceCar>
@GET("tariffs")
@jsonapi.retrofit.Document
suspend fun getTariffs(): List<ChargepriceTariff>
@POST("user_feedback")
suspend fun userFeedback(@Body @jsonapi.retrofit.Document feedback: ChargepriceUserFeedback)
companion object {
private val cacheSize = 1L * 1024 * 1024 // 1MB
val supportedLanguages = setOf("de", "en", "fr", "nl")
private val DATA_SOURCE_GOINGELECTRIC = "going_electric"
private val DATA_SOURCE_OPENCHARGEMAP = "open_charge_map"
private val jsonApiAdapterFactory = JsonApiFactory.Builder()
.addType(ChargepriceRequest::class.java)
.addType(ChargepriceTariff::class.java)
.addType(ChargepriceBrand::class.java)
.addType(ChargePrice::class.java)
.addType(ChargepriceCar::class.java)
.build()
val moshi = Moshi.Builder()
.add(jsonApiAdapterFactory)
.add(
PolymorphicJsonAdapterFactory.of(ChargepriceUserFeedback::class.java, "type")
.withSubtype(ChargepriceMissingPriceFeedback::class.java, "missing_price")
.withSubtype(ChargepriceWrongPriceFeedback::class.java, "wrong_price")
.withSubtype(ChargepriceMissingVehicleFeedback::class.java, "missing_vehicle")
)
.build()
fun create(
apikey: String,
baseurl: String = "https://api.chargeprice.app/v1/",
context: Context? = null
): ChargepriceApi {
val client = OkHttpClient.Builder().apply {
addInterceptor { chain ->
// add API key to every request
val original = chain.request()
val new = original.newBuilder()
.header("API-Key", apikey)
.header("Content-Type", "application/json")
.build()
chain.proceed(new)
}
if (BuildConfig.DEBUG) {
addDebugInterceptors()
}
if (context != null) {
cache(Cache(context.cacheDir, cacheSize))
}
}.build()
val retrofit = Retrofit.Builder()
.baseUrl(baseurl)
.addConverterFactory(DocumentConverterFactory.create())
.addConverterFactory(MoshiConverterFactory.create(moshi))
.client(client)
.build()
return retrofit.create(ChargepriceApi::class.java)
}
fun getChargepriceLanguage(): String {
val locale = Locale.getDefault().language
return if (supportedLanguages.contains(locale)) {
@@ -1,466 +0,0 @@
package net.vonforst.evmap.api.chargeprice
import android.content.Context
import android.os.Parcel
import android.os.Parcelable
import android.util.Patterns
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import jsonapi.*
import kotlinx.parcelize.Parceler
import kotlinx.parcelize.Parcelize
import kotlinx.parcelize.WriteWith
import net.vonforst.evmap.R
import net.vonforst.evmap.adapter.Equatable
import net.vonforst.evmap.api.equivalentPlugTypes
import net.vonforst.evmap.model.ChargeLocation
import net.vonforst.evmap.model.Chargepoint
import net.vonforst.evmap.ui.currency
import kotlin.math.ceil
import kotlin.math.floor
@Resource("charge_price_request")
@JsonClass(generateAdapter = true)
data class ChargepriceRequest(
@Json(name = "data_adapter")
val dataAdapter: String,
val station: ChargepriceStation,
val options: ChargepriceOptions,
@ToMany("tariffs")
val tariffs: List<ChargepriceTariff>? = null,
@ToOne("vehicle")
val vehicle: ChargepriceCar? = null,
@RelationshipsObject var relationships: Relationships? = null
)
@JsonClass(generateAdapter = true)
data class ChargepriceStation(
val longitude: Double,
val latitude: Double,
val country: String?,
val network: String?,
@Json(name = "charge_points") val chargePoints: List<ChargepriceChargepoint>
) {
companion object {
fun fromEvmap(
charger: ChargeLocation,
compatibleConnectors: List<String>,
): ChargepriceStation {
if (charger.chargepriceData == null) throw IllegalArgumentException()
val plugTypes =
charger.chargepriceData.plugTypes ?: charger.chargepoints.map { it.type }
return ChargepriceStation(
charger.coordinates.lng,
charger.coordinates.lat,
charger.chargepriceData.country,
charger.chargepriceData.network,
charger.chargepoints.zip(plugTypes)
.filter { equivalentPlugTypes(it.first.type).any { it in compatibleConnectors } }
.map { ChargepriceChargepoint(it.first.power ?: 0.0, it.second) }
)
}
}
}
@JsonClass(generateAdapter = true)
data class ChargepriceChargepoint(
val power: Double,
val plug: String
)
@JsonClass(generateAdapter = true)
data class ChargepriceOptions(
@Json(name = "max_monthly_fees") val maxMonthlyFees: Double? = null,
val energy: Double? = null,
val duration: Int? = null,
@Json(name = "battery_range") val batteryRange: List<Double>? = null,
@Json(name = "car_ac_phases") val carAcPhases: Int? = null,
val currency: String? = null,
@Json(name = "start_time") val startTime: Int? = null,
@Json(name = "allow_unbalanced_load") val allowUnbalancedLoad: Boolean? = null,
@Json(name = "provider_customer_tariffs") val providerCustomerTariffs: Boolean? = null,
@Json(name = "show_price_unavailable") val showPriceUnavailable: Boolean? = null,
@Json(name = "show_all_brand_restricted_tariffs") val showAllBrandRestrictedTariffs: Boolean? = null
)
@Resource("tariff")
@Parcelize
@JsonClass(generateAdapter = true)
data class ChargepriceTariff(
@Id val id_: String?,
val provider: String,
val name: String,
@Json(name = "direct_payment")
val directPayment: Boolean = false,
@Json(name = "provider_customer_tariff")
val providerCustomerTariff: Boolean = false,
@Json(name = "supported_countries")
val supportedCountries: Set<String>,
@Json(name = "charge_card_id")
val chargeCardId: String?, // GE charge card ID
) : Parcelable {
val id: String
get() = id_!!
}
@JsonClass(generateAdapter = true)
@Resource("car")
@Parcelize
data class ChargepriceCar(
@Id val id_: String?,
val name: String,
val brand: String,
@Json(name = "dc_charge_ports")
val dcChargePorts: List<String>,
@Json(name = "usable_battery_size")
val usableBatterySize: Float,
@Json(name = "ac_max_power")
val acMaxPower: Float,
@Json(name = "dc_max_power")
val dcMaxPower: Float?
) : Equatable, Parcelable {
fun formatSpecs(): String = buildString {
append("%.0f kWh".format(usableBatterySize))
append(" | ")
append("AC %.0f kW".format(acMaxPower))
dcMaxPower?.let {
append(" | ")
append("DC %.0f kW".format(it))
}
}
companion object {
private val acConnectors = listOf(
Chargepoint.CEE_BLAU,
Chargepoint.CEE_ROT,
Chargepoint.SCHUKO,
Chargepoint.TYPE_1,
Chargepoint.TYPE_2_UNKNOWN,
Chargepoint.TYPE_2_SOCKET,
Chargepoint.TYPE_2_PLUG
)
private val plugMapping = mapOf(
"ccs" to Chargepoint.CCS_UNKNOWN,
"tesla_suc" to Chargepoint.SUPERCHARGER,
"tesla_ccs" to Chargepoint.CCS_UNKNOWN,
"chademo" to Chargepoint.CHADEMO
)
}
val id: String
get() = id_!!
val compatibleEvmapConnectors: List<String>
get() = dcChargePorts.mapNotNull {
plugMapping[it]
}.plus(acConnectors)
}
@JsonClass(generateAdapter = true)
@Resource("brand")
@Parcelize
data class ChargepriceBrand(
@Id val id: String?
) : Parcelable
@JsonClass(generateAdapter = true)
@Resource("charge_price")
@Parcelize
data class ChargePrice(
val provider: String,
@Json(name = "tariff_name")
val tariffName: String,
val url: String,
@Json(name = "monthly_min_sales")
val monthlyMinSales: Double = 0.0,
@Json(name = "total_monthly_fee")
val totalMonthlyFee: Double = 0.0,
@Json(name = "flat_rate")
val flatRate: Boolean = false,
@Json(name = "direct_payment")
val directPayment: Boolean = false,
@Json(name = "provider_customer_tariff")
val providerCustomerTariff: Boolean = false,
val currency: String,
@Json(name = "start_time")
val startTime: Int = 0,
val tags: List<ChargepriceTag>,
@Json(name = "charge_point_prices")
val chargepointPrices: List<ChargepointPrice>,
@Json(name = "branding")
val branding: ChargepriceBranding? = null,
@RelationshipsObject
val relationships: @WriteWith<RelationshipsParceler>() Relationships? = null,
) : Equatable, Cloneable, Parcelable {
val tariffId: String?
get() = (relationships?.get("tariff") as? Relationship.ToOne)?.data?.id
fun formatMonthlyFees(ctx: Context): String {
return listOfNotNull(
if (totalMonthlyFee > 0) {
ctx.getString(R.string.chargeprice_base_fee, totalMonthlyFee, currency(currency))
} else null,
if (monthlyMinSales > 0) {
ctx.getString(R.string.chargeprice_min_spend, monthlyMinSales, currency(currency))
} else null
).joinToString(", ")
}
}
/**
* Parceler implementation for the Relationships object.
* Note that this ignores certain fields that we don't need (links, meta, etc.)
*/
internal object RelationshipsParceler : Parceler<Relationships?> {
override fun create(parcel: Parcel): Relationships? {
if (parcel.readInt() == 0) return null
val nMembers = parcel.readInt()
val members = (0 until nMembers).associate { _ ->
val key = parcel.readString()!!
val value = if (parcel.readInt() == 0) {
val type = parcel.readString()
val id = parcel.readString()
val ri = if (type != null && id != null) {
ResourceIdentifier(type, id)
} else null
Relationship.ToOne(ri)
} else {
val size = parcel.readInt()
val ris = (0 until size).map { _ ->
val type = parcel.readString()!!
val id = parcel.readString()!!
ResourceIdentifier(type, id)
}
Relationship.ToMany(ris)
}
key to value
}
return Relationships(members)
}
override fun Relationships?.write(parcel: Parcel, flags: Int) {
if (this == null) {
parcel.writeInt(0)
return
} else {
parcel.writeInt(1)
}
parcel.writeInt(members.size)
for (member in this.members) {
parcel.writeString(member.key)
when (val value = member.value) {
is Relationship.ToOne -> {
parcel.writeInt(0)
parcel.writeString(value.data?.type)
parcel.writeString(value.data?.id)
}
is Relationship.ToMany -> {
parcel.writeInt(1)
parcel.writeInt(value.data.size)
for (ri in value.data) {
parcel.writeString(ri.type)
parcel.writeString(ri.id)
}
}
}
}
}
}
@JsonClass(generateAdapter = true)
@Parcelize
data class ChargepointPrice(
val power: Double,
val plug: String,
val price: Double?,
@Json(name = "price_distribution") val priceDistribution: PriceDistribution,
@Json(name = "blocking_fee_start") val blockingFeeStart: Int?,
@Json(name = "no_price_reason") var noPriceReason: String?
) : Parcelable {
fun formatDistribution(ctx: Context): String {
fun percent(value: Double): String {
return ctx.getString(R.string.percent_format, value * 100) + "\u00a0"
}
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";
// be slightly sloppy (3:01 is shown as 3h) to save space
else if (h > 0 && (min == 0 || min == 1)) "${h}h";
else "%d:%02dh".format(h, min)
}
// based on https://github.com/chargeprice/chargeprice-client/blob/d420bb2f216d9ad91a210a36dd0859a368a8229a/src/views/priceList.js
with(priceDistribution) {
return listOfNotNull(
if (session != null && session > 0.0) {
(if (session < 1) percent(session) else "") + ctx.getString(R.string.chargeprice_session_fee)
} else null,
if (kwh != null && kwh > 0.0 && !isOnlyKwh) {
(if (kwh < 1) percent(kwh) else "") + ctx.getString(R.string.chargeprice_per_kwh)
} else null,
if (minute != null && minute > 0.0) {
(if (minute < 1) percent(minute) else "") + ctx.getString(R.string.chargeprice_per_minute) +
if (blockingFeeStart != null) {
" (${
ctx.getString(
R.string.chargeprice_blocking_fee,
time(blockingFeeStart)
)
})"
} else ""
} else null,
if ((minute == null || minute == 0.0) && blockingFeeStart != null) {
ctx.getString(R.string.chargeprice_blocking_fee, time(blockingFeeStart))
} else null
).joinToString(" +\u00a0")
}
}
}
@JsonClass(generateAdapter = true)
@Parcelize
data class ChargepriceBranding(
@Json(name = "background_color") val backgroundColor: String,
@Json(name = "text_color") val textColor: String,
@Json(name = "logo_url") val logoUrl: String
) : Parcelable
@JsonClass(generateAdapter = true)
@Parcelize
data class PriceDistribution(val kwh: Double?, val session: Double?, val minute: Double?) :
Parcelable {
val isOnlyKwh
get() = kwh != null && kwh > 0 && (session == null || session == 0.0) && (minute == null || minute == 0.0)
}
@JsonClass(generateAdapter = true)
@Parcelize
data class ChargepriceTag(val kind: String, val text: String, val url: String?) : Equatable,
Parcelable
@JsonClass(generateAdapter = true)
data class ChargepriceMeta(
@Json(name = "charge_points") val chargePoints: List<ChargepriceChargepointMeta>
)
enum class ChargepriceInclude {
@Json(name = "filter")
FILTER,
@Json(name = "always")
ALWAYS,
@Json(name = "exclusive")
EXCLUSIVE
}
@JsonClass(generateAdapter = true)
@Parcelize
data class ChargepriceRequestTariffMeta(
val include: ChargepriceInclude
) : Parcelable
@JsonClass(generateAdapter = true)
data class ChargepriceChargepointMeta(
val power: Double,
val plug: String,
val energy: Double,
val duration: Double
)
@Resource("user_feedback")
sealed class ChargepriceUserFeedback(
val notes: String,
val email: String,
val context: String,
val language: String
) {
init {
if (email.isBlank() || email.length > 100 || !Patterns.EMAIL_ADDRESS.matcher(email)
.matches()
) {
throw IllegalArgumentException("invalid email")
}
if (!ChargepriceApi.supportedLanguages.contains(language)) {
throw IllegalArgumentException("invalid language")
}
if (context.length > 500) throw IllegalArgumentException("invalid context")
if (notes.length > 1000) throw IllegalArgumentException("invalid notes")
}
}
@JsonClass(generateAdapter = true)
@Resource(type = "missing_price")
class ChargepriceMissingPriceFeedback(
val tariff: String,
val cpo: String,
val price: String,
@Json(name = "poi_link") val poiLink: String,
notes: String,
email: String,
context: String,
language: String
) : ChargepriceUserFeedback(notes, email, context, language) {
init {
if (tariff.isBlank() || tariff.length > 100) throw IllegalArgumentException("invalid tariff")
if (cpo.length > 200) throw IllegalArgumentException("invalid cpo")
if (price.isBlank() || price.length > 100) throw IllegalArgumentException("invalid price")
if (poiLink.isBlank() || poiLink.length > 200) throw IllegalArgumentException("invalid poiLink")
}
}
@JsonClass(generateAdapter = true)
@Resource(type = "wrong_price")
class ChargepriceWrongPriceFeedback(
val tariff: String,
val cpo: String,
@Json(name = "displayed_price") val displayedPrice: String,
@Json(name = "actual_price") val actualPrice: String,
@Json(name = "poi_link") val poiLink: String,
notes: String,
email: String,
context: String,
language: String,
) : ChargepriceUserFeedback(notes, email, context, language) {
init {
if (tariff.length > 100) throw IllegalArgumentException("invalid tariff")
if (cpo.length > 200) throw IllegalArgumentException("invalid cpo")
if (displayedPrice.length > 100) throw IllegalArgumentException("invalid displayedPrice")
if (actualPrice.length > 100) throw IllegalArgumentException("invalid actualPrice")
if (poiLink.length > 200) throw IllegalArgumentException("invalid poiLink")
}
}
@JsonClass(generateAdapter = true)
@Resource(type = "missing_vehicle")
class ChargepriceMissingVehicleFeedback(
val brand: String,
val model: String,
notes: String,
email: String,
context: String,
language: String,
) : ChargepriceUserFeedback(notes, email, context, language) {
init {
if (brand.length > 100) throw IllegalArgumentException("invalid brand")
if (model.length > 100) throw IllegalArgumentException("invalid model")
}
}
@@ -4,6 +4,7 @@ import android.content.Context
import com.squareup.moshi.Moshi
import net.vonforst.evmap.BuildConfig
import net.vonforst.evmap.addDebugInterceptors
import net.vonforst.evmap.api.proceedSafely
import net.vonforst.evmap.model.ChargeLocation
import net.vonforst.evmap.model.Chargepoint
import okhttp3.Cache
@@ -46,7 +47,7 @@ private interface FronyxApiRetrofit {
.header("X-API-Token", apikey)
.header("Content-Type", "application/json")
.build()
chain.proceed(new)
chain.proceedSafely(new)
}
if (BuildConfig.DEBUG) {
addDebugInterceptors()
@@ -1,7 +1,14 @@
package net.vonforst.evmap.api.goingelectric
import android.util.Log
import com.squareup.moshi.*
import com.squareup.moshi.FromJson
import com.squareup.moshi.JsonAdapter
import com.squareup.moshi.JsonQualifier
import com.squareup.moshi.JsonReader
import com.squareup.moshi.JsonWriter
import com.squareup.moshi.Moshi
import com.squareup.moshi.ToJson
import com.squareup.moshi.Types
import java.lang.reflect.Type
import java.time.Instant
import java.time.LocalTime
@@ -67,58 +74,95 @@ internal class ChargepointListItemJsonAdapter(val moshi: Moshi) :
}
}
internal class JsonObjectOrFalseAdapter<T> private constructor(
private val objectDelegate: JsonAdapter<T>,
private val clazz: Class<*>
) : JsonAdapter<T>() {
class Factory : JsonAdapter.Factory {
override fun create(
type: Type,
annotations: Set<Annotation>,
moshi: Moshi
): JsonAdapter<Any>? {
val clazz = Types.getRawType(type)
return when (hasJsonObjectOrFalseAnnotation(
annotations
)) {
false -> null
true -> JsonObjectOrFalseAdapter(
moshi.adapter(type), clazz
)
}
}
}
@Suppress("UNCHECKED_CAST")
override fun fromJson(reader: JsonReader): T? = when (reader.peek()) {
class StringOrFalseAdapter {
@FromJson
@JsonObjectOrFalse
fun fromJson(reader: JsonReader): String? = when (reader.peek()) {
JsonReader.Token.BOOLEAN -> when (reader.nextBoolean()) {
false -> null // Response was false
else -> {
if (this.clazz == GEFaultReport::class.java) {
GEFaultReport(null, "") as T
} else {
throw IllegalStateException("Non-false boolean for @JsonObjectOrFalse field")
}
}
false -> null
true -> throw IllegalArgumentException("found true")
}
JsonReader.Token.BEGIN_OBJECT -> objectDelegate.fromJson(reader)
JsonReader.Token.BEGIN_ARRAY -> objectDelegate.fromJson(reader)
JsonReader.Token.STRING -> objectDelegate.fromJson(reader)
JsonReader.Token.NUMBER -> objectDelegate.fromJson(reader)
else ->
throw IllegalStateException("Non-object-non-boolean value for @JsonObjectOrFalse field")
JsonReader.Token.STRING -> reader.nextString()
else -> throw IllegalArgumentException("illegal value found")
}
override fun toJson(writer: JsonWriter, value: T?) = objectDelegate.toJson(writer, value)
@ToJson
fun toJson(writer: JsonWriter, @JsonObjectOrFalse value: String?) {
writer.value(value)
}
}
private fun hasJsonObjectOrFalseAnnotation(annotations: Set<Annotation>?) =
annotations?.firstOrNull { it.annotationClass == JsonObjectOrFalse::class } != null
class IntOrFalseAdapter {
@FromJson
@JsonObjectOrFalse
fun fromJson(reader: JsonReader): Int? = when (reader.peek()) {
JsonReader.Token.BOOLEAN -> when (reader.nextBoolean()) {
false -> null
true -> throw IllegalArgumentException("found true")
}
JsonReader.Token.NUMBER -> reader.nextInt()
else -> throw IllegalArgumentException("illegal value found")
}
@ToJson
fun toJson(writer: JsonWriter, @JsonObjectOrFalse value: Int?) {
writer.value(value)
}
}
class GEFaultReportOrFalseAdapter {
@FromJson
@JsonObjectOrFalse
fun fromJson(reader: JsonReader, delegate: JsonAdapter<GEFaultReport>): GEFaultReport? =
when (reader.peek()) {
JsonReader.Token.BOOLEAN -> when (reader.nextBoolean()) {
false -> null
true -> GEFaultReport(null, "")
}
else -> delegate.fromJson(reader)
}
@ToJson
fun toJson(
writer: JsonWriter,
@JsonObjectOrFalse value: GEFaultReport?,
delegate: JsonAdapter<GEFaultReport>
) {
delegate.toJson(writer, value)
}
}
class ChargeCardListOrFalseAdapter {
@FromJson
@JsonObjectOrFalse
fun fromJson(
reader: JsonReader,
delegate: JsonAdapter<List<GEChargeCardId>>
): List<GEChargeCardId>? = when (reader.peek()) {
JsonReader.Token.BOOLEAN -> when (reader.nextBoolean()) {
false -> null
true -> throw IllegalArgumentException("found true")
}
else -> delegate.fromJson(reader)
}
@ToJson
fun toJson(
writer: JsonWriter,
@JsonObjectOrFalse value: List<GEChargeCardId>?,
delegate: JsonAdapter<List<GEChargeCardId>>
) {
delegate.toJson(writer, value)
}
}
@JsonQualifier
@Retention(AnnotationRetention.RUNTIME)
@Target(AnnotationTarget.FIELD)
annotation class JsonObjectOrFalse {
}
@@ -21,6 +21,7 @@ import net.vonforst.evmap.api.mapPower
import net.vonforst.evmap.api.mapPowerInverse
import net.vonforst.evmap.api.nameForPlugType
import net.vonforst.evmap.api.powerSteps
import net.vonforst.evmap.api.proceedSafely
import net.vonforst.evmap.model.BooleanFilter
import net.vonforst.evmap.model.ChargeLocation
import net.vonforst.evmap.model.Chargepoint
@@ -112,7 +113,10 @@ interface GoingElectricApi {
private val moshi = Moshi.Builder()
.add(ChargepointListItemJsonAdapterFactory())
.add(JsonObjectOrFalseAdapter.Factory())
.add(StringOrFalseAdapter())
.add(IntOrFalseAdapter())
.add(GEFaultReportOrFalseAdapter())
.add(ChargeCardListOrFalseAdapter())
.add(HoursAdapter())
.add(InstantAdapter())
.build()
@@ -128,7 +132,7 @@ interface GoingElectricApi {
var original = chain.request()
val url = original.url.newBuilder().addQueryParameter("key", apikey).build()
original = original.newBuilder().url(url).build()
chain.proceed(original)
chain.proceedSafely(original)
}
if (BuildConfig.DEBUG) {
addDebugInterceptors()
@@ -77,6 +77,7 @@ data class GEChargeLocation(
address.convert(),
chargepoints.map { it.convert() },
network,
"https://www.goingelectric.de/",
"https:${url}",
"https:${url}edit/",
faultReport?.convert(),
@@ -88,6 +89,7 @@ data class GEChargeLocation(
locationDescription,
photos?.map { it.convert(apikey) },
chargecards?.map { it.convert() },
null,
openinghours?.convert(),
cost?.convert(),
null,
@@ -0,0 +1,128 @@
package net.vonforst.evmap.api.nobil
import com.squareup.moshi.FromJson
import com.squareup.moshi.JsonDataException
import com.squareup.moshi.JsonReader
import com.squareup.moshi.Moshi
import com.squareup.moshi.ToJson
import com.squareup.moshi.rawType
import net.vonforst.evmap.model.Coordinate
import okhttp3.ResponseBody
import retrofit2.Converter
import retrofit2.Retrofit
import java.lang.reflect.Type
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
internal class CoordinateAdapter {
@FromJson
fun fromJson(position: String): Coordinate {
val pattern = """\((\d+(\.\d+)?), *(-?\d+(\.\d+)?)\)"""
val match = Regex(pattern).matchEntire(position)
?: throw JsonDataException("Unexpected coordinate format: '$position'")
val latitude : String = match.groups[1]?.value ?: "0.0"
val longitude : String = match.groups[3]?.value ?: "0.0"
return Coordinate(latitude.toDouble(), longitude.toDouble())
}
@ToJson
fun toJson(value: Coordinate): String = "(" + value.lat + ", " + value.lng + ")"
}
internal class LocalDateTimeAdapter {
@FromJson
fun fromJson(value: String?): LocalDateTime? = value?.let {
LocalDateTime.parse(value, DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))
}
@ToJson
fun toJson(value: LocalDateTime?): String? = value?.toString()
}
internal class NobilConverterFactory(val moshi: Moshi) : Converter.Factory() {
override fun responseBodyConverter(
type: Type,
annotations: Array<out Annotation>,
retrofit: Retrofit
): Converter<ResponseBody, *>? {
val stringAdapter = moshi.adapter(String::class.java)
if (type.rawType == NobilNumChargepointsResponseData::class.java) {
// {"Provider":"NOBIL.no",
// "Rights":"Creative Commons Attribution 4.0 International License",
// "apiver":"3",
// "chargerstations": [{"count":8748}]
// }
return Converter<ResponseBody, NobilNumChargepointsResponseData> { body ->
val reader = JsonReader.of(body.source())
reader.beginObject()
var error: String? = null
var provider: String? = null
var rights: String? = null
var apiver: String? = null
var count: Int? = null
while (reader.hasNext()) {
when (reader.nextName()) {
"error" -> error = stringAdapter.fromJson(reader)!!
"Provider" -> provider = stringAdapter.fromJson(reader)!!
"Rights" -> rights = stringAdapter.fromJson(reader)!!
"apiver" -> apiver = stringAdapter.fromJson(reader)!!
"chargerstations" -> {
reader.beginArray()
val intAdapter = moshi.adapter(Int::class.java)
reader.beginObject()
while (reader.hasNext()) {
when (reader.nextName()) {
"count" -> count = intAdapter.fromJson(reader)!!
}
}
reader.endObject()
reader.endArray()
reader.close()
break
}
}
}
NobilNumChargepointsResponseData(error, provider, rights, apiver, count)
}
}
if (type.rawType == NobilDynamicResponseData::class.java) {
val nobilChargerStationAdapter = moshi.adapter(NobilChargerStation::class.java)
return Converter<ResponseBody, NobilDynamicResponseData> { body ->
val reader = JsonReader.of(body.source())
reader.beginObject()
var error: String? = null
var provider: String? = null
var rights: String? = null
var apiver: String? = null
var doc: Sequence<NobilChargerStation>? = null
while (reader.hasNext()) {
when (reader.nextName()) {
"error" -> error = stringAdapter.fromJson(reader)!!
"Provider" -> provider = stringAdapter.fromJson(reader)!!
"Rights" -> rights = stringAdapter.fromJson(reader)!!
"apiver" -> apiver = stringAdapter.fromJson(reader)!!
"chargerstations" -> {
doc = sequence {
reader.beginArray()
while (reader.hasNext()) {
yield(nobilChargerStationAdapter.fromJson(reader)!!)
}
reader.endArray()
reader.close()
}
break
}
}
}
NobilDynamicResponseData(error, provider, rights, apiver, doc)
}
}
return null
}
}
@@ -0,0 +1,355 @@
package net.vonforst.evmap.api.nobil
import android.content.Context
import android.database.DatabaseUtils
import com.car2go.maps.model.LatLng
import com.car2go.maps.model.LatLngBounds
import com.squareup.moshi.JsonDataException
import com.squareup.moshi.Moshi
import net.vonforst.evmap.BuildConfig
import net.vonforst.evmap.R
import net.vonforst.evmap.addDebugInterceptors
import net.vonforst.evmap.api.ChargepointApi
import net.vonforst.evmap.api.ChargepointList
import net.vonforst.evmap.api.FiltersSQLQuery
import net.vonforst.evmap.api.FullDownloadResult
import net.vonforst.evmap.api.StringProvider
import net.vonforst.evmap.api.mapPower
import net.vonforst.evmap.api.mapPowerInverse
import net.vonforst.evmap.api.nameForPlugType
import net.vonforst.evmap.api.powerSteps
import net.vonforst.evmap.model.BooleanFilter
import net.vonforst.evmap.model.ChargeLocation
import net.vonforst.evmap.model.Chargepoint
import net.vonforst.evmap.model.ChargepointListItem
import net.vonforst.evmap.model.Filter
import net.vonforst.evmap.model.FilterValue
import net.vonforst.evmap.model.FilterValues
import net.vonforst.evmap.model.MultipleChoiceFilter
import net.vonforst.evmap.model.ReferenceData
import net.vonforst.evmap.model.SliderFilter
import net.vonforst.evmap.model.getBooleanValue
import net.vonforst.evmap.model.getMultipleChoiceValue
import net.vonforst.evmap.model.getSliderValue
import net.vonforst.evmap.viewmodel.Resource
import okhttp3.Cache
import okhttp3.OkHttpClient
import retrofit2.HttpException
import retrofit2.Response
import retrofit2.Retrofit
import retrofit2.converter.moshi.MoshiConverterFactory
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.POST
import retrofit2.http.Query
import java.io.IOException
import java.time.Duration
private const val maxResults = 2000
interface NobilApi {
@GET("datadump.php")
suspend fun getAllChargingStations(
@Query("apikey") apikey: String,
@Query("format") dataFormat: String = "json"
): Response<NobilDynamicResponseData>
@POST("search.php")
suspend fun getNumChargepoints(
@Body request: NobilNumChargepointsRequest
): Response<NobilNumChargepointsResponseData>
@POST("search.php")
suspend fun getChargepoints(
@Body request: NobilRectangleSearchRequest
): Response<NobilResponseData>
@POST("search.php")
suspend fun getChargepointsRadius(
@Body request: NobilRadiusSearchRequest
): Response<NobilResponseData>
@POST("search.php")
suspend fun getChargepointDetail(
@Body request: NobilDetailSearchRequest
): Response<NobilResponseData>
companion object {
private val cacheSize = 10L * 1024 * 1024 // 10MB
private val moshi = Moshi.Builder()
.add(LocalDateTimeAdapter())
.add(CoordinateAdapter())
.build()
fun create(
baseurl: String,
context: Context?
): NobilApi {
val client = OkHttpClient.Builder().apply {
if (BuildConfig.DEBUG) {
addDebugInterceptors()
}
if (context != null) {
cache(Cache(context.cacheDir, cacheSize))
}
}.build()
val retrofit = Retrofit.Builder()
.baseUrl(baseurl)
.addConverterFactory(NobilConverterFactory(moshi))
.addConverterFactory(MoshiConverterFactory.create(moshi))
.client(client)
.build()
return retrofit.create(NobilApi::class.java)
}
}
}
class NobilApiWrapper(
val apikey: String,
baseurl: String = "https://nobil.no/api/server/",
context: Context? = null
) : ChargepointApi<NobilReferenceData> {
override val name = "Nobil"
override val id = "nobil"
override val supportsOnlineQueries = false // Online queries are supported, but can't be used together with full downloads
override val supportsFullDownload = true
override val cacheLimit = Duration.ofDays(300L)
val api = NobilApi.create(baseurl, context)
override suspend fun fullDownload(): FullDownloadResult<NobilReferenceData> {
var numTotalChargepoints = 0
arrayOf("NOR", "SWE").forEach { countryCode ->
val request = NobilNumChargepointsRequest(apikey, countryCode)
val response = api.getNumChargepoints(request)
if (!response.isSuccessful) {
throw IOException(response.message())
}
val numChargepoints = response.body()!!.count
?: throw JsonDataException("Failed to get chargepoint count for '$countryCode'")
numTotalChargepoints += numChargepoints
}
val response = api.getAllChargingStations(apikey)
if (!response.isSuccessful) {
throw IOException(response.message())
} else {
val data = response.body()!!
return NobilFullDownloadResult(data, numTotalChargepoints)
}
}
override suspend fun getChargepoints(
referenceData: ReferenceData,
bounds: LatLngBounds,
zoom: Float,
useClustering: Boolean,
filters: FilterValues?,
): Resource<ChargepointList> {
try {
val northeast = "(" + bounds.northeast.latitude + ", " + bounds.northeast.longitude + ")"
val southwest = "(" + bounds.southwest.latitude + ", " + bounds.southwest.longitude + ")"
val request = NobilRectangleSearchRequest(apikey, northeast, southwest, maxResults)
val response = api.getChargepoints(request)
if (!response.isSuccessful) {
return Resource.error(response.message(), null)
}
val data = response.body()!!
if (data.chargerStations == null) {
return Resource.success(ChargepointList.empty())
}
val result = postprocessResult(
data,
filters
)
return Resource.success(ChargepointList(result, data.chargerStations.size < maxResults))
} catch (e: IOException) {
return Resource.error(e.message, null)
} catch (e: HttpException) {
return Resource.error(e.message, null)
}
}
override suspend fun getChargepointsRadius(
referenceData: ReferenceData,
location: LatLng,
radius: Int,
zoom: Float,
useClustering: Boolean,
filters: FilterValues?
): Resource<ChargepointList> {
try {
val request = NobilRadiusSearchRequest(apikey, location.latitude, location.longitude, radius * 1000.0, maxResults)
val response = api.getChargepointsRadius(request)
if (!response.isSuccessful) {
return Resource.error(response.message(), null)
}
val data = response.body()!!
if (data.chargerStations == null) {
return Resource.error(response.message(), null)
}
val result = postprocessResult(
data,
filters
)
return Resource.success(ChargepointList(result, data.chargerStations.size < maxResults))
} catch (e: IOException) {
return Resource.error(e.message, null)
} catch (e: HttpException) {
return Resource.error(e.message, null)
}
}
private fun postprocessResult(
data: NobilResponseData,
filters: FilterValues?
): List<ChargepointListItem> {
if (data.rights == null ) throw JsonDataException("Rights field is missing in received data")
return data.chargerStations!!.mapNotNull { it.convert(data.rights, filters) }.distinct()
}
override suspend fun getChargepointDetail(
referenceData: ReferenceData,
id: Long
): Resource<ChargeLocation> {
// TODO: Nobil ids are "SWE_1234", not Long
return Resource.error("getChargepointDetail is not implemented", null)
}
override suspend fun getReferenceData(): Resource<NobilReferenceData> {
return Resource.success(NobilReferenceData(0))
}
override fun getFilters(
referenceData: ReferenceData,
sp: StringProvider
): List<Filter<FilterValue>> {
val connectors = listOf(
Chargepoint.TYPE_1,
Chargepoint.TYPE_2_SOCKET,
Chargepoint.TYPE_2_PLUG,
Chargepoint.CCS_UNKNOWN,
Chargepoint.CHADEMO,
Chargepoint.SUPERCHARGER,
Chargepoint.MCS
)
val connectorsMap = connectors.associateWith { connector ->
nameForPlugType(sp, connector)
}
val accessibilityMap = mapOf(
"Public" to sp.getString(R.string.accessibility_public),
"Visitors" to sp.getString(R.string.accessibility_visitors),
"Employees" to sp.getString(R.string.accessibility_employees),
"By appointment" to sp.getString(R.string.accessibility_by_appointment),
"Residents" to sp.getString(R.string.accessibility_residents)
)
return listOf(
BooleanFilter(sp.getString(R.string.filter_free_parking), "freeparking"),
BooleanFilter(sp.getString(R.string.filter_open_247), "open_247"),
SliderFilter(
sp.getString(R.string.filter_min_power), "min_power",
powerSteps.size - 1,
mapping = ::mapPower,
inverseMapping = ::mapPowerInverse,
unit = "kW"
),
MultipleChoiceFilter(
sp.getString(R.string.filter_connectors), "connectors",
connectorsMap, manyChoices = true
),
SliderFilter(
sp.getString(R.string.filter_min_connectors),
"min_connectors",
10,
min = 1
),
MultipleChoiceFilter(
sp.getString(R.string.filter_accessibility), "accessibilities",
accessibilityMap, manyChoices = true
)
)
}
override fun convertFiltersToSQL(
filters: FilterValues,
referenceData: ReferenceData
): FiltersSQLQuery {
if (filters.isEmpty()) return FiltersSQLQuery("",
requiresChargepointQuery = false,
requiresChargeCardQuery = false
)
var requiresChargepointQuery = false
val result = StringBuilder()
if (filters.getBooleanValue("freeparking") == true) {
result.append(" AND freeparking IS 1")
}
if (filters.getBooleanValue("open_247") == true) {
result.append(" AND twentyfourSeven IS 1")
}
val minPower = filters.getSliderValue("min_power")
if (minPower != null && minPower > 0) {
result.append(" AND json_extract(cp.value, '$.power') >= $minPower")
requiresChargepointQuery = true
}
val connectors = filters.getMultipleChoiceValue("connectors")
if (connectors != null && !connectors.all) {
val connectorsList = connectors.values.joinToString(",") {
DatabaseUtils.sqlEscapeString(it)
}
result.append(" AND json_extract(cp.value, '$.type') IN (${connectorsList})")
requiresChargepointQuery = true
}
val minConnectors = filters.getSliderValue("min_connectors")
if (minConnectors != null && minConnectors > 1) {
result.append(" GROUP BY ChargeLocation.id HAVING SUM(json_extract(cp.value, '$.count')) >= $minConnectors")
requiresChargepointQuery = true
}
val accessibilities = filters.getMultipleChoiceValue("accessibilities")
if (accessibilities != null && !accessibilities.all) {
val accessibilitiesList = accessibilities.values.joinToString(",") {
DatabaseUtils.sqlEscapeString(it)
}
result.append(" AND accessibility IN (${accessibilitiesList})")
}
return FiltersSQLQuery(result.toString(), requiresChargepointQuery, false)
}
override fun filteringInSQLRequiresDetails(filters: FilterValues): Boolean {
return false
}
}
class NobilFullDownloadResult(private val data: NobilDynamicResponseData,
private val numTotalChargepoints: Int) : FullDownloadResult<NobilReferenceData> {
private var downloadProgress = 0f
private var refData: NobilReferenceData? = null
override val chargers: Sequence<ChargeLocation>
get() {
if (data.rights == null) throw JsonDataException("Rights field is missing in received data")
return sequence {
data.chargerStations?.forEachIndexed { i, it ->
downloadProgress = i.toFloat() / numTotalChargepoints
val charger = it.convert(data.rights, null)
charger?.let { yield(charger) }
}
refData = NobilReferenceData(0)
}
}
override val progress: Float
get() = downloadProgress
override val referenceData: NobilReferenceData
get() = refData ?: throw UnsupportedOperationException("referenceData is only available once download is complete")
}
@@ -0,0 +1,354 @@
package net.vonforst.evmap.api.nobil
import android.net.Uri
import androidx.core.text.HtmlCompat
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import kotlinx.parcelize.Parcelize
import net.vonforst.evmap.max
import net.vonforst.evmap.model.Address
import net.vonforst.evmap.model.ChargeLocation
import net.vonforst.evmap.model.Chargepoint
import net.vonforst.evmap.model.ChargerPhoto
import net.vonforst.evmap.model.Coordinate
import net.vonforst.evmap.model.Cost
import net.vonforst.evmap.model.FilterValues
import net.vonforst.evmap.model.OpeningHours
import net.vonforst.evmap.model.ReferenceData
import net.vonforst.evmap.model.getBooleanValue
import net.vonforst.evmap.model.getMultipleChoiceValue
import net.vonforst.evmap.model.getSliderValue
import java.time.Instant
import java.time.LocalDateTime
data class NobilReferenceData(
val dummy: Int
) : ReferenceData()
@JsonClass(generateAdapter = true)
data class NobilNumChargepointsRequest(
val apikey: String,
val countrycode: String,
val action: String = "search",
val type: String = "stats_GetSumChargerstations",
val format: String = "json",
val apiversion: String = "3"
)
@JsonClass(generateAdapter = true)
data class NobilRectangleSearchRequest(
val apikey: String,
val northeast: String,
val southwest: String,
val limit: Int,
val action: String = "search",
val type: String = "rectangle",
val format: String = "json",
val apiversion: String = "3",
// val existingids: String
)
@JsonClass(generateAdapter = true)
data class NobilRadiusSearchRequest(
val apikey: String,
val lat: Double,
val long: Double,
val distance: Double, // meters
val limit: Int,
val action: String = "search",
val type: String = "near",
val format: String = "json",
val apiversion: String = "3",
// val existingids: String,
)
@JsonClass(generateAdapter = true)
data class NobilDetailSearchRequest(
val apikey: String,
val id: String,
val action: String = "search",
val type: String = "id",
val format: String = "json",
val apiversion: String = "3",
)
@JsonClass(generateAdapter = true)
data class NobilResponseData(
@Json(name = "error") val error: String?,
@Json(name = "Provider") val provider: String?,
@Json(name = "Rights") val rights: String?,
@Json(name = "apiver") val apiver: String?,
@Json(name = "chargerstations") val chargerStations: List<NobilChargerStation>?
)
data class NobilNumChargepointsResponseData(
val error: String?,
val provider: String?,
val rights: String?,
val apiver: String?,
val count: Int?
)
data class NobilDynamicResponseData(
val error: String?,
val provider: String?,
val rights: String?,
val apiver: String?,
val chargerStations: Sequence<NobilChargerStation>?
)
@JsonClass(generateAdapter = true)
data class NobilChargerStation(
@Json(name = "csmd") val chargerStationData: NobilChargerStationData,
@Json(name = "attr") val chargerStationAttributes: NobilChargerStationAttributes
) {
fun convert(dataLicense: String,
filters: FilterValues?) : ChargeLocation? {
val chargepoints = chargerStationAttributes.conn
.mapNotNull { createChargepointFromNobilConnection(it.value) }
if (chargepoints.isEmpty()) return null
val minPower = filters?.getSliderValue("min_power")
val connectors = filters?.getMultipleChoiceValue("connectors")
val minConnectors = filters?.getSliderValue("min_connectors")
if (chargepoints
.filter { it.power != null && it.power >= (minPower ?: 0) }
.filter { if (connectors != null && !connectors.all) it.type in connectors.values else true }
.size < (minConnectors ?: 0)) return null
val chargeLocation = ChargeLocation(
chargerStationData.id,
"nobil",
HtmlCompat.fromHtml(chargerStationData.name, HtmlCompat.FROM_HTML_MODE_COMPACT)
.toString(),
chargerStationData.position,
Address(
chargerStationData.city,
when (chargerStationData.landCode) {
"NOR" -> "Norway"
"SWE" -> "Sweden"
else -> ""
},
chargerStationData.zipCode,
listOfNotNull(
chargerStationData.street,
chargerStationData.houseNumber
).joinToString(" ")
),
chargepoints,
if (chargerStationData.operator != null) HtmlCompat.fromHtml(
chargerStationData.operator,
HtmlCompat.FROM_HTML_MODE_COMPACT
).toString() else null,
"https://nobil.no/",
null,
when (chargerStationData.landCode) {
"SWE" -> "https://www.energimyndigheten.se/klimat/transporter/laddinfrastruktur/registrera-din-laddstation/elbilsagare/"
else -> "mailto:post@nobil.no?subject=" + Uri.encode("Regarding charging station " + chargerStationData.internationalId)
},
null,
chargerStationData.ocpiId != null ||
chargerStationData.updated.isAfter(LocalDateTime.now().minusMonths(6)),
null,
if (chargerStationData.ownedBy != null) HtmlCompat.fromHtml(
chargerStationData.ownedBy,
HtmlCompat.FROM_HTML_MODE_COMPACT
).toString() else null,
if (chargerStationData.userComment != null) HtmlCompat.fromHtml(
chargerStationData.userComment,
HtmlCompat.FROM_HTML_MODE_COMPACT
).toString() else null,
null,
if (chargerStationData.description != null) HtmlCompat.fromHtml(
chargerStationData.description,
HtmlCompat.FROM_HTML_MODE_COMPACT
).toString() else null,
if (Regex("""\d+\.\w+""").matchEntire(chargerStationData.image) != null) listOf(
NobilChargerPhotoAdapter(chargerStationData.image)
) else null,
null,
// 2: Availability
chargerStationAttributes.st["2"]?.attrTrans,
// 24: Open 24h
if (chargerStationAttributes.st["24"]?.attrTrans == "Yes") OpeningHours(
twentyfourSeven = true,
null,
null
) else null,
Cost(
// 7: Parking fee
freeparking = when (chargerStationAttributes.st["7"]?.attrTrans) {
"Yes" -> false
"No" -> true
else -> null
},
descriptionLong = chargerStationAttributes.conn.mapNotNull {
// 19: Payment method
when (it.value["19"]?.attrValId) {
"1" -> listOf("Mobile phone") // TODO: Translate
"2" -> listOf("Bank card")
"10" -> listOf("Other")
"20" -> listOf("Mobile phone", "Charging card")
"21" -> listOf("Bank card", "Charging card")
"25" -> listOf("Bank card", "Charging card", "Mobile phone")
else -> null
}
}.flatten().sorted().toSet().ifEmpty { null }
?.joinToString(prefix = "Accepted payment methods: ")
),
dataLicense,
null,
null,
null,
Instant.now(),
true
)
val accessibilities = filters?.getMultipleChoiceValue("accessibilities")
if (accessibilities != null && !accessibilities.all) {
if (!accessibilities.values.contains(chargeLocation.accessibility)) return null
}
val freeparking = filters?.getBooleanValue("freeparking")
if (freeparking == true && chargeLocation.cost?.freeparking != true) return null
val open247 = filters?.getBooleanValue("open_247")
if (open247 == true && chargeLocation.openinghours?.twentyfourSeven != true) return null
return chargeLocation
}
companion object {
fun createChargepointFromNobilConnection(attribs: Map<String, NobilChargerStationGenericAttribute>): Chargepoint? {
// https://nobil.no/admin/attributes.php
val isFixedCable = attribs["25"]?.attrTrans == "Yes"
val connectionType = when (attribs["4"]?.attrValId) {
"0" -> "" // Unspecified
"30" -> Chargepoint.CHADEMO // CHAdeMO
"31" -> Chargepoint.TYPE_1 // Type 1
"32" -> if (isFixedCable) Chargepoint.TYPE_2_PLUG else Chargepoint.TYPE_2_SOCKET // Type 2
"39" -> Chargepoint.CCS_UNKNOWN // CCS/Combo
"40" -> Chargepoint.SUPERCHARGER // Tesla Connector Model
"70" -> return null // Hydrogen
"82" -> return null // Biogas
"87" -> Chargepoint.MCS
// These are deprecated and not used
"50" -> "" // Type 2 + Schuko
"60" -> "" // Type1/Type2
else -> ""
}
val connectionPower = when (attribs["5"]?.attrValId) {
"7" -> 3.6 // 3,6 kW - 230V 1-phase max 16A
"8" -> 7.4 // 7,4 kW - 230V 1-phase max 32A
"10" -> 11.0 // 11 kW - 400V 3-phase max 16A
"11" -> 22.0 // 22 kW - 400V 3-phase max 32A
"12" -> 43.0 // 43 kW - 400V 3-phase max 63A
"13" -> 50.0 // 50 kW - 500VDC max 100A
"16" -> 11.0 // 230V 3-phase max 16A'
"17" -> 22.0 // 230V 3-phase max 32A
"18" -> 43.0 // 230V 3-phase max 63A
"19" -> 20.0 // 20 kW - 500VDC max 50A
"22" -> 135.0 // 135 kW - 480VDC max 270A
"23" -> 100.0 // 100 kW - 500VDC max 200A
"24" -> 150.0 // 150 kW DC
"25" -> 350.0 // 350 kW DC
"26" -> null // 350 bar
"27" -> null // 700 bar
"29" -> 75.0 // 75 kW DC
"30" -> 225.0 // 225 kW DC
"31" -> 250.0 // 250 kW DC
"32" -> 200.0 // 200 kW DC
"33" -> 300.0 // 300 kW DC
"34" -> null // CBG
"35" -> null // LBG
"36" -> 400.0 // 400 kW DC
"37" -> 30.0 // 30 kW DC
"38" -> 62.5 // 62,5 kW DC
"39" -> 500.0 // 500 kW DC
"41" -> 175.0 // 175 kW DC
"42" -> 180.0 // 180 kW DC
"43" -> 600.0 // 600 kW DC
"44" -> 700.0 // 700 kW DC
"45" -> 800.0 // 800 kW DC
"46" -> 320.0
"47" -> 900.0
"48" -> 1000.0
"49" -> 1100.0
"50" -> 1200.0
"51" -> 1300.0
"52" -> 1400.0
"53" -> 1500.0
else -> null
}
val connectionVoltage = if (attribs["12"]?.attrVal is String) attribs["12"]?.attrVal.toString().toDoubleOrNull() else null
val connectionCurrent = if (attribs["31"]?.attrVal is String) attribs["31"]?.attrVal.toString().toDoubleOrNull() else null
val evseUId = if (attribs["27"]?.attrVal is String) listOf(attribs["27"]?.attrVal.toString()) else null
val evseId = if (attribs["28"]?.attrVal is String) listOf(attribs["28"]?.attrVal.toString()) else null
return Chargepoint(connectionType, connectionPower, 1, connectionCurrent, connectionVoltage, evseId, evseUId)
}
}
}
@JsonClass(generateAdapter = true)
data class NobilChargerStationData(
@Json(name = "id") val id: Long,
@Json(name = "name") val name: String,
@Json(name = "ocpidb_mapping_stasjon_id") val ocpiId: String?,
@Json(name = "Street") val street: String?,
@Json(name = "House_number") val houseNumber: String,
@Json(name = "Zipcode") val zipCode: String?,
@Json(name = "City") val city: String?,
@Json(name = "Municipality_ID") val municipalityId: String,
@Json(name = "Municipality") val municipality: String,
@Json(name = "County_ID") val countyId: String,
@Json(name = "County") val county: String,
@Json(name = "Description_of_location") val description: String?,
@Json(name = "Owned_by") val ownedBy: String?,
@Json(name = "Operator") val operator: String?,
@Json(name = "Number_charging_points") val numChargePoints: Int,
@Json(name = "Position") val position: Coordinate,
@Json(name = "Image") val image: String,
@Json(name = "Available_charging_points") val availableChargePoints: Int,
@Json(name = "User_comment") val userComment: String?,
@Json(name = "Contact_info") val contactInfo: String?,
@Json(name = "Created") val created: LocalDateTime,
@Json(name = "Updated") val updated: LocalDateTime,
@Json(name = "Station_status") val stationStatus: Int,
@Json(name = "Land_code") val landCode: String,
@Json(name = "International_id") val internationalId: String
)
@JsonClass(generateAdapter = true)
data class NobilChargerStationAttributes(
@Json(name = "st") val st: Map<String, NobilChargerStationGenericAttribute>,
@Json(name = "conn") val conn: Map<String, Map<String, NobilChargerStationGenericAttribute>>
)
@JsonClass(generateAdapter = true)
data class NobilChargerStationGenericAttribute(
@Json(name = "attrtypeid") val attrTypeId: String,
@Json(name = "attrname") val attrName: String,
@Json(name = "attrvalid") val attrValId: String,
@Json(name = "trans") val attrTrans: String,
@Json(name = "attrval") val attrVal: Any
)
@Parcelize
@JsonClass(generateAdapter = true)
class NobilChargerPhotoAdapter(override val id: String) :
ChargerPhoto(id) {
override fun getUrl(height: Int?, width: Int?, size: Int?, allowOriginal: Boolean): String {
val maxSize = size ?: max(height, width)
return "https://www.nobil.no/img/ladestasjonbilder/" +
when (maxSize) {
in 0..50 -> "tn_$id"
else -> id
}
}
}
@@ -16,6 +16,7 @@ import net.vonforst.evmap.api.StringProvider
import net.vonforst.evmap.api.mapPower
import net.vonforst.evmap.api.mapPowerInverse
import net.vonforst.evmap.api.powerSteps
import net.vonforst.evmap.api.proceedSafely
import net.vonforst.evmap.model.BooleanFilter
import net.vonforst.evmap.model.ChargeLocation
import net.vonforst.evmap.model.ChargepointListItem
@@ -101,7 +102,7 @@ interface OpenChargeMapApi {
val new = original.newBuilder()
.header("X-API-Key", apikey)
.build()
chain.proceed(new)
chain.proceedSafely(new)
}
if (BuildConfig.DEBUG) {
addDebugInterceptors()
@@ -64,6 +64,7 @@ data class OCMChargepoint(
addressInfo.toAddress(refData),
connections.map { it.convert(refData) },
operatorInfo?.title ?: refData.operators.find { it.id == operatorId }?.title,
"https://openchargemap.org/",
"https://map.openchargemap.io/?id=$id",
"https://map.openchargemap.io/?id=$id",
convertFaultReport(),
@@ -76,6 +77,7 @@ data class OCMChargepoint(
mediaItems?.mapNotNull { it.convert() },
null,
null,
null,
cost?.takeIf { it.isNotBlank() }.let { Cost(descriptionShort = it) },
dataProvider?.let { "© ${it.title}" + if (it.license != null) ". ${it.license}" else "" },
ChargepriceData(
@@ -3,7 +3,13 @@ package net.vonforst.evmap.api.openstreetmap
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import kotlinx.parcelize.Parcelize
import net.vonforst.evmap.model.*
import net.vonforst.evmap.model.Address
import net.vonforst.evmap.model.ChargeLocation
import net.vonforst.evmap.model.Chargepoint
import net.vonforst.evmap.model.ChargerPhoto
import net.vonforst.evmap.model.Coordinate
import net.vonforst.evmap.model.Cost
import net.vonforst.evmap.model.OpeningHours
import okhttp3.internal.immutableListOf
import java.time.Instant
import java.time.ZonedDateTime
@@ -98,6 +104,7 @@ data class OSMChargingStation(
getAddress(),
getChargepoints(),
tags["network"],
"https://www.openstreetmap.org/",
"https://www.openstreetmap.org/node/$id",
"https://www.openstreetmap.org/edit?node=$id",
null,
@@ -109,6 +116,7 @@ data class OSMChargingStation(
null,
getPhotos(),
null,
null,
getOpeningHours(),
getCost(),
"© OpenStreetMap contributors",
@@ -237,10 +245,24 @@ data class OSMChargingStation(
if (rawOutput == null) {
return null
}
val pattern = Regex("([0-9.,]+)\\s*(kW|kVA)", setOf(RegexOption.IGNORE_CASE))
val matchResult = pattern.matchEntire(rawOutput) ?: return null
val numberString = matchResult.groupValues[1].replace(',', '.')
return numberString.toDoubleOrNull()
val kwPattern = Regex("([0-9.,]+)\\s*(kW|kVA)", setOf(RegexOption.IGNORE_CASE))
kwPattern.matchEntire(rawOutput)?.let { matchResult ->
val numberString = matchResult.groupValues[1].replace(',', '.')
return numberString.toDoubleOrNull()
}
val numberPattern = Regex("([0-9.,]+)")
numberPattern.matchEntire(rawOutput)?.let { matchResult ->
// just a number is mapped without unit
val numberString = matchResult.groupValues[1].replace(',', '.')
val number = numberString.toDoubleOrNull()
return number?.let {
// assume kW if the number is < 1000, otherwise assume W and convert to kW
if (number < 1000) number else number / 1000
}
}
return null
}
}
}
@@ -109,6 +109,7 @@ class CarAppService : androidx.car.app.CarAppService() {
@ExperimentalCarApi
class EVMapSession(val cas: CarAppService) : Session(), DefaultLifecycleObserver {
private val TAG = "EVMapSession"
lateinit var intent: Intent
var mapScreen: LocationAwareScreen? = null
set(value) {
field = value
@@ -132,7 +133,8 @@ class EVMapSession(val cas: CarAppService) : Session(), DefaultLifecycleObserver
}
override fun onCreateScreen(intent: Intent): Screen {
val mapScreen = if (supportsNewMapScreen(carContext)) {
this.intent = intent
val mapScreen = if (supportsNewMapScreen(carContext) && prefs.androidAutoNewMapScreenEnabled) {
MapScreen(carContext, this)
} else {
LegacyMapScreen(carContext, this)
@@ -1,402 +0,0 @@
package net.vonforst.evmap.auto
import androidx.car.app.CarContext
import androidx.car.app.CarToast
import androidx.car.app.Screen
import androidx.car.app.annotations.ExperimentalCarApi
import androidx.car.app.constraints.ConstraintManager
import androidx.car.app.hardware.CarHardwareManager
import androidx.car.app.hardware.info.Model
import androidx.car.app.model.Action
import androidx.car.app.model.ActionStrip
import androidx.car.app.model.CarIcon
import androidx.car.app.model.ItemList
import androidx.car.app.model.ListTemplate
import androidx.car.app.model.Row
import androidx.car.app.model.SectionedItemList
import androidx.car.app.model.Template
import androidx.core.content.ContextCompat
import androidx.core.graphics.drawable.IconCompat
import androidx.lifecycle.lifecycleScope
import com.github.erfansn.localeconfigx.currentOrDefaultLocale
import jsonapi.Meta
import jsonapi.Relationship
import jsonapi.Relationships
import jsonapi.ResourceIdentifier
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import net.vonforst.evmap.R
import net.vonforst.evmap.api.chargeprice.ChargePrice
import net.vonforst.evmap.api.chargeprice.ChargepriceApi
import net.vonforst.evmap.api.chargeprice.ChargepriceCar
import net.vonforst.evmap.api.chargeprice.ChargepriceChargepointMeta
import net.vonforst.evmap.api.chargeprice.ChargepriceInclude
import net.vonforst.evmap.api.chargeprice.ChargepriceMeta
import net.vonforst.evmap.api.chargeprice.ChargepriceOptions
import net.vonforst.evmap.api.chargeprice.ChargepriceRequest
import net.vonforst.evmap.api.chargeprice.ChargepriceRequestTariffMeta
import net.vonforst.evmap.api.chargeprice.ChargepriceStation
import net.vonforst.evmap.api.equivalentPlugTypes
import net.vonforst.evmap.api.nameForPlugType
import net.vonforst.evmap.api.stringProvider
import net.vonforst.evmap.model.ChargeLocation
import net.vonforst.evmap.model.Chargepoint
import net.vonforst.evmap.storage.AppDatabase
import net.vonforst.evmap.storage.PreferenceDataSource
import net.vonforst.evmap.ui.currency
import net.vonforst.evmap.ui.time
import retrofit2.HttpException
import java.io.IOException
import kotlin.math.roundToInt
@ExperimentalCarApi
class ChargepriceScreen(ctx: CarContext, val session: EVMapSession, val charger: ChargeLocation) :
Screen(ctx) {
private val prefs = PreferenceDataSource(ctx)
private val db = AppDatabase.getInstance(carContext)
private val api by lazy {
ChargepriceApi.create(
carContext.getString(R.string.chargeprice_key),
carContext.getString(R.string.chargeprice_api_url)
)
}
private var prices: List<ChargePrice>? = null
private var meta: ChargepriceChargepointMeta? = null
private var chargepoint: Chargepoint? = null
private val maxRows = ctx.getContentLimit(ConstraintManager.CONTENT_LIMIT_TYPE_LIST)
private var errorMessage: String? = null
private val batteryRange = prefs.chargepriceBatteryRangeAndroidAuto
override fun onGetTemplate(): Template {
if (prices == null) loadData()
return ListTemplate.Builder().apply {
setTitle(
carContext.getString(
R.string.chargeprice_battery_range,
batteryRange[0],
batteryRange[1]
) + " · " + carContext.getString(R.string.powered_by_chargeprice)
)
setHeaderAction(Action.BACK)
if (prices == null && errorMessage == null) {
setLoading(true)
} else {
val header = meta?.let { meta ->
chargepoint?.let { chargepoint ->
"${
nameForPlugType(
carContext.stringProvider(),
chargepoint.type
)
} ${chargepoint.formatPower(carContext.currentOrDefaultLocale)} ${
carContext.getString(
R.string.chargeprice_stats,
meta.energy,
time(meta.duration.roundToInt()),
meta.energy / meta.duration * 60
)
}"
}
}
val myTariffs = prefs.chargepriceMyTariffs
val myTariffsAll = prefs.chargepriceMyTariffsAll
val prices = prices?.take(maxRows)
if (prices != null && prices.isNotEmpty() && !myTariffsAll && myTariffs != null) {
val (myPrices, otherPrices) = prices.partition { price -> price.tariffId in myTariffs }
val myPricesList = buildPricesList(myPrices)
val otherPricesList = buildPricesList(otherPrices)
if (myPricesList.items.isNotEmpty() && otherPricesList.items.isNotEmpty()) {
addSectionedList(
SectionedItemList.create(
myPricesList,
(header?.let { it + "\n" } ?: "") +
carContext.getString(R.string.chargeprice_header_my_tariffs)
)
)
addSectionedList(
SectionedItemList.create(
otherPricesList,
carContext.getString(R.string.chargeprice_header_other_tariffs)
)
)
} else {
val list =
if (myPricesList.items.isNotEmpty()) myPricesList else otherPricesList
if (header != null) {
addSectionedList(SectionedItemList.create(list, header))
} else {
setSingleList(list)
}
}
} else {
val list = buildPricesList(prices)
if (header != null && list.items.isNotEmpty()) {
addSectionedList(SectionedItemList.create(list, header))
} else {
setSingleList(list)
}
}
}
setActionStrip(
ActionStrip.Builder().addAction(
Action.Builder().setIcon(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
R.drawable.ic_chargeprice
)
).build()
).setOnClickListener {
openUrl(carContext, session.cas, ChargepriceApi.getPoiUrl(charger))
}.build()
).build()
)
}.build()
}
private fun buildPricesList(prices: List<ChargePrice>?): ItemList {
return ItemList.Builder().apply {
setNoItemsMessage(
errorMessage
?: carContext.getString(R.string.chargeprice_no_tariffs_found)
)
prices?.forEach { price ->
addItem(Row.Builder().apply {
setTitle(formatProvider(price))
addText(formatPrice(price))
}.build())
}
}.build()
}
private fun formatProvider(price: ChargePrice): String {
if (!price.tariffName.startsWith(price.provider)) {
return price.provider + " " + price.tariffName
} else {
return price.tariffName
}
}
private fun formatPrice(price: ChargePrice): String {
val amount = price.chargepointPrices.first().price
?: return "${carContext.getString(R.string.chargeprice_price_not_available)} (${price.chargepointPrices.first().noPriceReason})"
val totalPrice = carContext.getString(
R.string.charge_price_format,
amount,
currency(price.currency)
)
val kwhPrice = if (amount > 0f) {
carContext.getString(
if (price.chargepointPrices[0].priceDistribution.isOnlyKwh) {
R.string.charge_price_kwh_format
} else {
R.string.charge_price_average_format
},
amount / meta!!.energy,
currency(price.currency)
)
} else null
val monthlyFees = if (price.totalMonthlyFee > 0 || price.monthlyMinSales > 0) {
price.formatMonthlyFees(carContext)
} else null
var text = totalPrice
if (kwhPrice != null && monthlyFees != null) {
text += " ($kwhPrice, $monthlyFees)"
} else if (kwhPrice != null) {
text += " ($kwhPrice)"
} else if (monthlyFees != null) {
text += " ($monthlyFees)"
}
return text
}
private fun loadData() {
if (supportsCarApiLevel3(carContext)) {
val exec = ContextCompat.getMainExecutor(carContext)
val hardwareMan =
carContext.getCarService(CarContext.HARDWARE_SERVICE) as CarHardwareManager
hardwareMan.carInfo.fetchModel(exec) { model ->
loadPrices(model)
}
} else {
loadPrices(null)
}
}
private fun loadPrices(model: Model?) {
val dataAdapter = ChargepriceApi.getDataAdapter(charger)
val manufacturer = getVehicleBrand(model?.manufacturer?.value)
val modelName = getVehicleModel(model?.manufacturer?.value, model?.name?.value)
lifecycleScope.launch {
try {
val car = determineVehicle(manufacturer, modelName)
val cpStation = ChargepriceStation.fromEvmap(charger, car.compatibleEvmapConnectors)
if (cpStation.chargePoints.isEmpty()) {
errorMessage =
carContext.getString(R.string.chargeprice_no_compatible_connectors)
invalidate()
return@launch
}
val result = api.getChargePrices(
ChargepriceRequest(
dataAdapter = dataAdapter,
station = cpStation,
vehicle = car,
options = ChargepriceOptions(
batteryRange = batteryRange.map { it.toDouble() },
providerCustomerTariffs = prefs.chargepriceShowProviderCustomerTariffs,
maxMonthlyFees = if (prefs.chargepriceNoBaseFee) 0.0 else null,
currency = prefs.chargepriceCurrency,
allowUnbalancedLoad = prefs.chargepriceAllowUnbalancedLoad,
showPriceUnavailable = true
),
relationships = if (!prefs.chargepriceMyTariffsAll) {
val myTariffs = prefs.chargepriceMyTariffs ?: emptySet()
Relationships(
"tariffs" to Relationship.ToMany(
myTariffs.map {
ResourceIdentifier(
"tariff",
id = it
)
},
meta = Meta.from(
ChargepriceRequestTariffMeta(ChargepriceInclude.ALWAYS),
ChargepriceApi.moshi
)
)
)
} else null
), ChargepriceApi.getChargepriceLanguage()
)
val myTariffs = prefs.chargepriceMyTariffs
// choose the highest power chargepoint
// (we have already filtered so that only compatible ones are included)
val chargepoint = cpStation.chargePoints.maxByOrNull { it.power }
val index = cpStation.chargePoints.indexOf(chargepoint)
this@ChargepriceScreen.chargepoint =
charger.chargepoints.filter { equivalentPlugTypes(it.type).any { it in car.compatibleEvmapConnectors } }[index]
if (chargepoint == null) {
errorMessage =
carContext.getString(R.string.chargeprice_no_compatible_connectors)
invalidate()
return@launch
}
val metaMapped =
result.meta!!.map(ChargepriceMeta::class.java, ChargepriceApi.moshi)!!
meta = metaMapped.chargePoints.maxByOrNull { it.power }
prices = result.data!!.mapNotNull { cp ->
val filteredPrices =
cp.chargepointPrices.filter {
it.plug == chargepoint.plug && it.power == chargepoint.power
}
if (filteredPrices.isEmpty()) {
null
} else {
cp.copy(
chargepointPrices = filteredPrices
)
}
}
.sortedBy { it.chargepointPrices.first().price ?: Double.MAX_VALUE }
.sortedByDescending {
prefs.chargepriceMyTariffsAll ||
myTariffs != null && it.tariffId in myTariffs
}
invalidate()
} catch (e: IOException) {
withContext(Dispatchers.Main) {
CarToast.makeText(
carContext,
R.string.chargeprice_connection_error,
CarToast.LENGTH_LONG
)
.show()
}
} catch (e: HttpException) {
withContext(Dispatchers.Main) {
CarToast.makeText(
carContext,
R.string.chargeprice_connection_error,
CarToast.LENGTH_LONG
)
.show()
}
} catch (e: NoVehicleSelectedException) {
errorMessage = carContext.getString(R.string.chargeprice_select_car_first)
invalidate()
} catch (e: VehicleUnknownException) {
errorMessage = carContext.getString(
R.string.auto_chargeprice_vehicle_unknown,
manufacturer,
modelName
)
invalidate()
} catch (e: VehicleAmbiguousException) {
errorMessage = carContext.getString(
R.string.auto_chargeprice_vehicle_ambiguous,
manufacturer,
modelName
)
invalidate()
} catch (e: VehicleUnavailableException) {
errorMessage =
carContext.getString(R.string.auto_chargeprice_vehicle_unavailable)
invalidate()
}
}
}
private class NoVehicleSelectedException : Exception()
private class VehicleUnknownException : Exception()
private class VehicleAmbiguousException : Exception()
private class VehicleUnavailableException : Exception()
private suspend fun determineVehicle(
manufacturer: String?,
modelName: String?
): ChargepriceCar {
var vehicles = api.getVehicles().filter {
it.id in prefs.chargepriceMyVehicles
}
if (vehicles.isEmpty()) {
throw NoVehicleSelectedException()
} else if (vehicles.size > 1) {
if (manufacturer != null) {
vehicles = vehicles.filter {
it.brand.lowercase() == getVehicleBrand(manufacturer)?.lowercase()
}
if (vehicles.isEmpty()) {
throw VehicleUnknownException()
} else if (vehicles.size > 1) {
if (modelName != null) {
vehicles = vehicles.filter {
it.name.lowercase().startsWith(modelName.lowercase())
}
if (vehicles.isEmpty()) {
throw VehicleUnknownException()
} else if (vehicles.size > 1) {
throw VehicleAmbiguousException()
}
} else {
throw VehicleAmbiguousException()
}
}
} else {
throw VehicleUnavailableException()
}
}
return vehicles[0]
}
}
@@ -32,7 +32,6 @@ import androidx.core.text.HtmlCompat
import androidx.lifecycle.lifecycleScope
import coil.imageLoader
import coil.request.ImageRequest
import com.github.erfansn.localeconfigx.currentOrDefaultLocale
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
@@ -142,32 +141,26 @@ class ChargerDetailScreen(
if (ChargepriceApi.isChargerSupported(charger)) {
addAction(
Action.Builder()
.setIcon(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
R.drawable.ic_chargeprice
)
).build()
)
.setTitle(carContext.getString(R.string.auto_prices))
.setOnClickListener {
if (prefs.chargepriceNativeIntegration) {
if (!prefs.chargepriceRemoval2025DialogShown) {
screenManager.push(
ChargepriceScreen(
TextDialogScreen(
carContext,
session,
charger
R.string.chargeprice_removal_2025_dialog_title,
R.string.chargeprice_removal_2025_dialog_detail
)
)
} else {
val intent = Intent(
Intent.ACTION_VIEW,
Uri.parse(ChargepriceApi.getPoiUrl(charger))
)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
session.cas.startActivity(intent)
prefs.chargepriceRemoval2025DialogShown = true
return@setOnClickListener
}
val intent = Intent(
Intent.ACTION_VIEW,
Uri.parse(ChargepriceApi.getPoiUrl(charger))
)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
session.cas.startActivity(intent)
}
.build())
}
@@ -21,6 +21,7 @@ import androidx.car.app.model.Pane
import androidx.car.app.model.Place
import androidx.car.app.model.PlaceMarker
import androidx.car.app.model.Row
import androidx.car.app.model.RowSection
import androidx.core.content.ContextCompat
import androidx.core.graphics.drawable.IconCompat
import net.vonforst.evmap.R
@@ -54,7 +55,7 @@ class ChargerListFormatter(
private val iconGen = ChargerIconGenerator(carContext, null, height = 96)
var favorites: Set<Long> = emptySet()
fun buildChargerList(
fun buildChargerListLegacy(
chargers: List<ChargeLocation>?,
availabilities: Map<Long, Pair<ZonedDateTime, ChargeLocationStatus?>>
): ItemList? {
@@ -103,6 +104,55 @@ class ChargerListFormatter(
}
}
fun buildChargerList(
chargers: List<ChargeLocation>?,
availabilities: Map<Long, Pair<ZonedDateTime, ChargeLocationStatus?>>
): RowSection? {
return if (chargers != null) {
val chargerList = chargers.take(screen.maxRows)
val builder = RowSection.Builder()
// only show the city if not all chargers are in the same city
val showCity = chargerList.map { it.address?.city }.distinct().size > 1
chargerList.forEach { charger ->
builder.addItem(
formatCharger(
charger,
availabilities,
showCity,
charger.id in favorites
)
)
}
builder.setNoItemsMessage(
carContext.getString(
if (screen.filterStatus == FILTERS_FAVORITES) {
R.string.auto_no_favorites_found
} else {
R.string.auto_no_chargers_found
}
)
)
builder.setOnItemVisibilityChangedListener(screen)
builder.build()
} else {
if (screen.loadingError) {
val builder = RowSection.Builder()
builder.setNoItemsMessage(
carContext.getString(R.string.connection_error)
)
builder.build()
} else if (screen.locationError) {
val builder = RowSection.Builder()
builder.setNoItemsMessage(
carContext.getString(R.string.location_error)
)
builder.build()
} else {
null
}
}
}
private fun formatCharger(
charger: ChargeLocation,
availabilities: Map<Long, Pair<ZonedDateTime, ChargeLocationStatus?>>,
@@ -15,8 +15,10 @@ import androidx.car.app.model.CarColor
import androidx.car.app.model.CarIcon
import androidx.car.app.model.CarText
import androidx.car.app.model.ForegroundCarColorSpan
import androidx.car.app.model.Header
import androidx.car.app.model.ItemList
import androidx.car.app.model.ListTemplate
import androidx.car.app.model.MessageTemplate
import androidx.car.app.model.Pane
import androidx.car.app.model.PaneTemplate
import androidx.car.app.model.ParkedOnlyOnClickListener
@@ -241,20 +243,23 @@ class FilterScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx) {
)
setOnClickListener {
lifecycleScope.launch {
db.filterProfileDao().delete(it)
if (prefs.filterStatus == it.id) {
prefs.filterStatus = FILTERS_DISABLED
val confirmScreen = ConfirmDeleteScreen(carContext, it) {
lifecycleScope.launch {
db.filterProfileDao().delete(it)
if (prefs.filterStatus == it.id) {
prefs.filterStatus = FILTERS_DISABLED
}
CarToast.makeText(
carContext,
carContext.getString(
R.string.deleted_item,
it.name
),
CarToast.LENGTH_SHORT
).show()
}
CarToast.makeText(
carContext,
carContext.getString(
R.string.deleted_item,
it.name
),
CarToast.LENGTH_SHORT
).show()
}
screenManager.push(confirmScreen)
}
}.build())
}
@@ -299,6 +304,31 @@ class FilterScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx) {
}
}
class ConfirmDeleteScreen(ctx: CarContext, val profile: FilterProfile, val deleteListener: () -> Unit): Screen(ctx) {
override fun onGetTemplate(): Template {
val message = carContext.getString(R.string.auto_confirm_delete_profile, profile.name)
return MessageTemplate.Builder(message)
.setHeader(Header.Builder()
.setTitle(carContext.getString(R.string.delete))
.setStartHeaderAction(Action.BACK)
.build())
.addAction(Action.Builder()
.setTitle(carContext.getString(R.string.delete))
.setBackgroundColor(CarColor.PRIMARY)
.setFlags(Action.FLAG_PRIMARY)
.setOnClickListener {
deleteListener()
screenManager.pop()
}
.build())
.addAction(Action.Builder()
.setTitle(carContext.getString(R.string.cancel))
.setOnClickListener { screenManager.pop() }
.build())
.build()
}
}
@androidx.car.app.annotations.ExperimentalCarApi
class EditFiltersScreen(ctx: CarContext) : Screen(ctx) {
private val vm = FilterViewModel(carContext.applicationContext as Application)
@@ -360,18 +390,21 @@ class EditFiltersScreen(ctx: CarContext) : Screen(ctx) {
)
setOnClickListener {
lifecycleScope.launch {
vm.deleteCurrentProfile()
CarToast.makeText(
carContext,
carContext.getString(
R.string.deleted_item,
currentProfile.name
),
CarToast.LENGTH_SHORT
).show()
screenManager.pop()
val confirmScreen = ConfirmDeleteScreen(carContext, currentProfile) {
lifecycleScope.launch {
vm.deleteCurrentProfile()
CarToast.makeText(
carContext,
carContext.getString(
R.string.deleted_item,
currentProfile.name
),
CarToast.LENGTH_SHORT
).show()
screenManager.pop()
}
}
screenManager.push(confirmScreen)
}
}.build())
}
@@ -58,7 +58,6 @@ import java.io.IOException
import java.time.Duration
import java.time.Instant
import java.time.ZonedDateTime
import kotlin.collections.set
import kotlin.math.abs
import kotlin.math.min
@@ -164,7 +163,7 @@ class LegacyMapScreen(ctx: CarContext, val session: EVMapSession) :
setAnchor(Place.Builder(CarLocation.create(it.latitude, it.longitude)).build())
}
}
formatter.buildChargerList(chargers, availabilities)?.let {
formatter.buildChargerListLegacy(chargers, availabilities)?.let {
setItemList(it)
} ?: setLoading(true)
setCurrentLocationEnabled(true)
@@ -21,9 +21,9 @@ import androidx.car.app.model.ActionStrip
import androidx.car.app.model.CarColor
import androidx.car.app.model.CarIcon
import androidx.car.app.model.Header
import androidx.car.app.model.ListTemplate
import androidx.car.app.model.MessageTemplate
import androidx.car.app.model.PaneTemplate
import androidx.car.app.model.SectionedItemTemplate
import androidx.car.app.model.Template
import androidx.car.app.navigation.model.MapController
import androidx.car.app.navigation.model.MapWithContentTemplate
@@ -59,7 +59,6 @@ import net.vonforst.evmap.storage.ChargeLocationsRepository
import net.vonforst.evmap.storage.PreferenceDataSource
import net.vonforst.evmap.ui.MarkerManager
import net.vonforst.evmap.utils.distanceBetween
import net.vonforst.evmap.utils.headingDiff
import net.vonforst.evmap.viewmodel.Resource
import net.vonforst.evmap.viewmodel.Status
import net.vonforst.evmap.viewmodel.await
@@ -70,12 +69,12 @@ import java.io.IOException
import java.time.Duration
import java.time.Instant
import java.time.ZonedDateTime
import kotlin.collections.set
import kotlin.math.min
import kotlin.math.roundToInt
import kotlin.time.DurationUnit
import kotlin.time.TimeSource
private const val DEFAULT_ZOOM_MYLOCATION = 14f
/**
* Main map screen showing either nearby chargers or favorites.
*
@@ -146,6 +145,7 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) :
private var map: AnyMap? = null
private var markerManager: MarkerManager? = null
private var myLocationEnabled = false
private var compassEnabled = false
private var myLocationNeedsUpdate = false
private val formatter = ChargerListFormatter(ctx, this, session.cas)
@@ -213,7 +213,7 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) :
}.build())
}.build()
} else {
ListTemplate.Builder().apply {
SectionedItemTemplate.Builder().apply {
setHeader(Header.Builder().apply {
setTitle(title)
setStartHeaderAction(Action.APP_ICON)
@@ -223,8 +223,10 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) :
chargers?.filterIsInstance<ChargeLocation>(),
availabilities
)?.let {
setSingleList(it)
addSection(it)
} ?: setLoading(true)
setScrollStatePersistenceStrategy(SectionedItemTemplate.SCROLL_STATE_PRESERVE_INDEX)
}.build()
}
return MapWithContentTemplate.Builder().apply {
@@ -241,11 +243,17 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) :
.addAction(Action.PAN)
.addAction(
Action.Builder().setIcon(
CarIcon.Builder(IconCompat.createWithResource(carContext, R.drawable.ic_location))
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
if (compassEnabled) R.drawable.ic_compass else R.drawable.ic_location
)
)
.setTint(if (myLocationEnabled) CarColor.SECONDARY else CarColor.DEFAULT)
.build()
).setOnClickListener {
enableLocation(true)
enableLocation(true, myLocationEnabled && !compassEnabled)
invalidate()
}.build()
)
.addAction(
@@ -385,8 +393,15 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) :
val map = map ?: return
if (myLocationEnabled) {
val bearing = if (compassEnabled) getBearing(location) else 0f
if (oldLoc == null) {
mapSurfaceCallback.animateCamera(map.cameraUpdateFactory.newLatLngZoom(latLng, 13f))
mapSurfaceCallback.animateCamera(
map.cameraUpdateFactory.newLatLngZoomBearing(
latLng,
DEFAULT_ZOOM_MYLOCATION,
bearing
)
)
} else if (latLng != oldLoc && distanceBetween(
latLng.latitude,
latLng.longitude,
@@ -395,7 +410,11 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) :
) > 1
) {
// only update map if location changed by more than 1 meter
val camUpdate = map.cameraUpdateFactory.newLatLng(latLng)
val camUpdate = map.cameraUpdateFactory.newLatLngZoomBearing(
latLng,
map.cameraPosition.zoom,
bearing
)
mapSurfaceCallback.animateCamera(camUpdate)
}
}
@@ -545,6 +564,7 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) :
availabilities.clear()
location = null
myLocationEnabled = false
compassEnabled = false
removeListeners()
}
@@ -556,6 +576,7 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) :
prefs.currentMapZoom = it.cameraPosition.zoom
}
prefs.currentMapMyLocationEnabled = myLocationEnabled
prefs.androidAutoCompassEnabled = compassEnabled
}
private fun removeListeners() {
@@ -625,9 +646,10 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) :
onClusterClick = {
val newZoom = map.cameraPosition.zoom + 2
mapSurfaceCallback.animateCamera(
map.cameraUpdateFactory.newLatLngZoom(
map.cameraUpdateFactory.newLatLngZoomBearing(
LatLng(it.coordinates.lat, it.coordinates.lng),
newZoom
newZoom,
if (compassEnabled) location?.let { getBearing(it) } ?: 0f else 0f
)
)
}
@@ -657,6 +679,7 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) :
prefs.placeSearchResultAndroidAuto?.let { place ->
// move to the location of the search result
myLocationEnabled = false
compassEnabled = false
markerManager?.searchResult = place
if (place.viewport != null) {
map.moveCamera(map.cameraUpdateFactory.newLatLngBounds(place.viewport, 0))
@@ -664,7 +687,7 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) :
map.moveCamera(map.cameraUpdateFactory.newLatLngZoom(place.latLng, 12f))
}
} ?: if (prefs.currentMapMyLocationEnabled) {
enableLocation(false)
enableLocation(false, prefs.androidAutoCompassEnabled)
} else {
// use position saved in preferences, fall back to default (Europe)
val cameraUpdate =
@@ -692,14 +715,16 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) :
loadChargers()
}
private fun enableLocation(animated: Boolean) {
private fun enableLocation(animated: Boolean, withCompass: Boolean) {
myLocationEnabled = true
compassEnabled = withCompass
myLocationNeedsUpdate = true
if (location != null) {
location?.let { location ->
val map = map ?: return
val update = map.cameraUpdateFactory.newLatLngZoom(
val update = map.cameraUpdateFactory.newLatLngZoomBearing(
LatLng.fromLocation(location),
13f
DEFAULT_ZOOM_MYLOCATION,
if (withCompass) getBearing(location) else 0f
)
if (animated) {
mapSurfaceCallback.animateCamera(update)
@@ -708,4 +733,7 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) :
}
}
}
private fun getBearing(location: Location): Float =
heading?.orientations?.value?.get(0) ?: location.bearing
}
@@ -3,6 +3,7 @@ package net.vonforst.evmap.auto
import android.animation.ValueAnimator
import android.app.Presentation
import android.content.Context
import android.graphics.Point
import android.graphics.Rect
import android.hardware.display.DisplayManager
import android.hardware.display.VirtualDisplay
@@ -39,10 +40,6 @@ class MapSurfaceCallback(val ctx: CarContext, val lifecycleScope: LifecycleCorou
SurfaceCallback, OnMapReadyCallback {
private val VIRTUAL_DISPLAY_NAME = "evmap_map"
private val VELOCITY_THRESHOLD_IGNORE_FLING = 1000
private val STATUSBAR_OFFSET_SYSTEMS = listOf(
"VolvoCars/ihu_emulator_volvo_car/ihu_emulator:11",
"Google/sdk_gcar_x86_64/generic_64bitonly_x86_64:11"
)
private val prefs = PreferenceDataSource(ctx)
@@ -173,14 +170,23 @@ class MapSurfaceCallback(val ctx: CarContext, val lifecycleScope: LifecycleCorou
override fun onScale(focusX: Float, focusY: Float, scaleFactor: Float) {
flingAnimator?.cancel()
val map = map ?: return
if (scaleFactor == 2f) return
val offsetX = (focusX - mapView.width / 2) * (scaleFactor - 1f)
val offsetY = (offsetY(focusY) - mapView.height / 2) * (scaleFactor - 1f)
val (x, y) = offsetScreen(focusX, focusY)
val offsetX = (x - mapView.width / 2) * (scaleFactor - 1f)
val offsetY = (y - mapView.height / 2) * (scaleFactor - 1f)
Log.i("MapSurfaceCallback", "focus: $focusX, $focusY, scaleFactor: $scaleFactor")
map.moveCamera(map.cameraUpdateFactory.zoomBy(scaleFactor - 1))
map.moveCamera(map.cameraUpdateFactory.scrollBy(offsetX, offsetY))
if (scaleFactor == 2f) {
map.animateCamera(
map.cameraUpdateFactory.zoomBy(
scaleFactor - 1,
Point(focusX.roundToInt(), focusY.roundToInt())
)
)
} else {
map.moveCamera(map.cameraUpdateFactory.zoomBy(scaleFactor - 1))
map.moveCamera(map.cameraUpdateFactory.scrollBy(offsetX, offsetY))
}
dispatchCameraMoveStarted()
}
@@ -218,13 +224,13 @@ class MapSurfaceCallback(val ctx: CarContext, val lifecycleScope: LifecycleCorou
flingAnimator?.cancel()
val downTime: Long = SystemClock.uptimeMillis()
val eventTime: Long = downTime + 100
val yOffset = offsetY(y)
val (xOffset, yOffset) = offsetScreen(x, y)
val downEvent = MotionEvent.obtain(
downTime,
downTime,
MotionEvent.ACTION_DOWN,
x,
xOffset,
yOffset,
0
)
@@ -234,7 +240,7 @@ class MapSurfaceCallback(val ctx: CarContext, val lifecycleScope: LifecycleCorou
downTime,
eventTime,
MotionEvent.ACTION_UP,
x,
xOffset,
yOffset,
0
)
@@ -242,14 +248,24 @@ class MapSurfaceCallback(val ctx: CarContext, val lifecycleScope: LifecycleCorou
upEvent.recycle()
}
private fun offsetY(y: Float): Float {
if (!STATUSBAR_OFFSET_SYSTEMS.any { Build.FINGERPRINT.startsWith(it) }) return y
private fun offsetScreen(x: Float, y: Float): Pair<Float, Float> {
if (BuildConfig.FLAVOR_automotive != "automotive") {
return x to y
}
// In some emulators, touch locations are offset by the status bar height
// On AAOS, touch locations don't seem to take into account system bar insets
// related: https://issuetracker.google.com/issues/256905247
val resId = ctx.resources.getIdentifier("status_bar_height", "dimen", "android")
val offset = resId.takeIf { it > 0 }?.let { ctx.resources.getDimensionPixelSize(it) } ?: 0
return y + offset
val yOffset = resId.takeIf { it > 0 }?.let { ctx.resources.getDimensionPixelSize(it) } ?: 0
val xOffset = if (Build.MODEL == "AIVI2 R FULL DOM" && width > height) {
// Renault 5 left system bar
120
} else {
0
}
return x + xOffset to y + yOffset
}
private fun createMap(ctx: Context): MapContainerView {
@@ -1,9 +1,7 @@
package net.vonforst.evmap.auto
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.PackageManager.NameNotFoundException
import android.hardware.Sensor
import android.hardware.SensorManager
@@ -17,8 +15,6 @@ import androidx.car.app.constraints.ConstraintManager
import androidx.car.app.model.Action
import androidx.car.app.model.CarColor
import androidx.car.app.model.CarIcon
import androidx.car.app.model.GridItem
import androidx.car.app.model.GridTemplate
import androidx.car.app.model.ItemList
import androidx.car.app.model.ListTemplate
import androidx.car.app.model.MessageTemplate
@@ -27,15 +23,12 @@ import androidx.car.app.model.Row
import androidx.car.app.model.SectionedItemList
import androidx.car.app.model.Template
import androidx.car.app.model.Toggle
import androidx.core.content.IntentCompat
import androidx.core.graphics.drawable.IconCompat
import androidx.core.net.toUri
import androidx.core.text.HtmlCompat
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.single
import kotlinx.coroutines.launch
import net.vonforst.evmap.BuildConfig
import net.vonforst.evmap.EXTRA_DONATE
@@ -44,11 +37,6 @@ import net.vonforst.evmap.R
import net.vonforst.evmap.addDebugInterceptors
import net.vonforst.evmap.api.availability.tesla.TeslaAuthenticationApi
import net.vonforst.evmap.api.availability.tesla.TeslaOwnerApi
import net.vonforst.evmap.api.chargeprice.ChargepriceApi
import net.vonforst.evmap.api.chargeprice.ChargepriceCar
import net.vonforst.evmap.api.chargeprice.ChargepriceTariff
import net.vonforst.evmap.currencyDisplayName
import net.vonforst.evmap.fragment.oauth.OAuthLoginFragment
import net.vonforst.evmap.fragment.oauth.OAuthLoginFragmentArgs
import net.vonforst.evmap.getPackageInfoCompat
import net.vonforst.evmap.storage.AppDatabase
@@ -57,12 +45,15 @@ import net.vonforst.evmap.storage.PreferenceDataSource
import okhttp3.OkHttpClient
import java.io.IOException
import java.time.Instant
import kotlin.math.max
import kotlin.math.min
@ExperimentalCarApi
class SettingsScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx) {
class SettingsScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx), DefaultLifecycleObserver {
val prefs = PreferenceDataSource(ctx)
val newMapScreenEnabledPrevious = prefs.androidAutoNewMapScreenEnabled
init {
lifecycle.addObserver(this)
}
override fun onGetTemplate(): Template {
return ListTemplate.Builder().apply {
@@ -86,23 +77,6 @@ class SettingsScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx) {
screenManager.push(DataSettingsScreen(carContext, session))
}
}.build())
addItem(Row.Builder().apply {
setTitle(carContext.getString(R.string.settings_chargeprice))
setImage(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
R.drawable.ic_chargeprice
)
).setTint(
CarColor.DEFAULT
).build()
)
setBrowsable(true)
setOnClickListener {
screenManager.push(ChargepriceSettingsScreen(carContext))
}
}.build())
if (supportsCarApiLevel3(carContext)) {
addItem(
Row.Builder()
@@ -118,7 +92,26 @@ class SettingsScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx) {
}
.build()
)
if (carContext.carAppApiLevel < 7 || !carContext.isAppDrivenRefreshSupported) {
if (supportsNewMapScreen(carContext)) {
addItem(
Row.Builder()
.setTitle(carContext.getString(R.string.auto_use_new_map_screen))
.setToggle(Toggle.Builder {
prefs.androidAutoNewMapScreenEnabled = it
invalidate()
}.setChecked(prefs.androidAutoNewMapScreenEnabled).build())
.setImage(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
R.drawable.ic_developer
)
).setTint(CarColor.DEFAULT).build()
)
.build()
)
}
if (!supportsNewMapScreen(carContext) || !prefs.androidAutoNewMapScreenEnabled) {
// this option is only supported in LegacyMapScreen
addItem(
Row.Builder()
@@ -150,14 +143,23 @@ class SettingsScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx) {
).setTint(CarColor.DEFAULT).build()
)
.setBrowsable(true)
.setOnClickListener {
.setOnClickListener(ParkedOnlyOnClickListener.create {
screenManager.push(AboutScreen(carContext, session))
}
})
.build()
)
}.build())
}.build()
}
override fun onStop(owner: LifecycleOwner) {
if (newMapScreenEnabledPrevious != prefs.androidAutoNewMapScreenEnabled) {
val newMapScreen = session.onCreateScreen(session.intent)
val oldMapScreen = screenManager.screenStack.last()
screenManager.push(newMapScreen)
screenManager.remove(oldMapScreen)
}
}
}
@ExperimentalCarApi
@@ -448,6 +450,7 @@ class ChooseDataSourceScreen(
val descriptions = when (type) {
Type.CHARGER_DATA_SOURCE -> listOf(
carContext.getString(R.string.data_source_goingelectric_desc),
carContext.getString(R.string.data_source_nobil_desc),
carContext.getString(R.string.data_source_openchargemap_desc),
carContext.getString(R.string.data_source_openstreetmap_desc)
)
@@ -506,341 +509,6 @@ class ChooseDataSourceScreen(
}
}
class ChargepriceSettingsScreen(ctx: CarContext) : Screen(ctx) {
val prefs = PreferenceDataSource(carContext)
private val maxRows = ctx.getContentLimit(ConstraintManager.CONTENT_LIMIT_TYPE_LIST)
override fun onGetTemplate(): Template {
return ListTemplate.Builder().apply {
setTitle(carContext.getString(R.string.settings_chargeprice))
setHeaderAction(Action.BACK)
setSingleList(ItemList.Builder().apply {
addItem(Row.Builder().apply {
setTitle(carContext.getString(R.string.pref_chargeprice_native_integration))
addText(carContext.getString(if (prefs.chargepriceNativeIntegration) R.string.pref_chargeprice_native_integration_on else R.string.pref_chargeprice_native_integration_off))
setToggle(Toggle.Builder {
prefs.chargepriceNativeIntegration = it
invalidate()
}.setChecked(prefs.chargepriceNativeIntegration).build())
}.build())
addItem(Row.Builder().apply {
setTitle(carContext.getString(R.string.pref_my_vehicle))
setBrowsable(true)
setOnClickListener {
screenManager.push(SelectVehiclesScreen(carContext))
}
setEnabled(prefs.chargepriceNativeIntegration)
}.build())
addItem(Row.Builder().apply {
setTitle(carContext.getString(R.string.pref_my_tariffs))
setBrowsable(true)
setOnClickListener {
screenManager.push(SelectTariffsScreen(carContext))
}
addText(
if (prefs.chargepriceMyTariffsAll) {
carContext.getString(R.string.chargeprice_all_tariffs_selected)
} else {
val n = prefs.chargepriceMyTariffs?.size ?: 0
carContext.resources
.getQuantityString(
R.plurals.chargeprice_some_tariffs_selected,
n,
n
) + "\n" + carContext.resources.getQuantityString(
R.plurals.pref_my_tariffs_summary,
n
)
}
)
setEnabled(prefs.chargepriceNativeIntegration)
}.build())
addItem(Row.Builder().apply {
setTitle(carContext.getString(R.string.settings_android_auto_chargeprice_range))
setBrowsable(true)
val range = prefs.chargepriceBatteryRangeAndroidAuto
addText(
carContext.getString(
R.string.chargeprice_battery_range,
range[0],
range[1]
)
)
setOnClickListener {
screenManager.push(SelectChargingRangeScreen(carContext))
}
setEnabled(prefs.chargepriceNativeIntegration)
}.build())
addItem(Row.Builder().apply {
setTitle(carContext.getString(R.string.pref_chargeprice_currency))
val values =
carContext.resources.getStringArray(R.array.pref_chargeprice_currencies)
val names = values.map(::currencyDisplayName)
val index = values.indexOf(prefs.chargepriceCurrency)
addText(if (index >= 0) names[index] else "")
setBrowsable(true)
setOnClickListener {
screenManager.push(SelectCurrencyScreen(carContext))
}
setEnabled(prefs.chargepriceNativeIntegration)
}.build())
addItem(Row.Builder().apply {
setTitle(carContext.getString(R.string.pref_chargeprice_no_base_fee))
setToggle(Toggle.Builder {
prefs.chargepriceNoBaseFee = it
}.setChecked(prefs.chargepriceNoBaseFee).build())
setEnabled(prefs.chargepriceNativeIntegration)
}.build())
if (maxRows > 6) {
addItem(Row.Builder().apply {
setTitle(carContext.getString(R.string.pref_chargeprice_show_provider_customer_tariffs))
addText(carContext.getString(R.string.pref_chargeprice_show_provider_customer_tariffs_summary))
setToggle(Toggle.Builder {
prefs.chargepriceShowProviderCustomerTariffs = it
}.setChecked(prefs.chargepriceShowProviderCustomerTariffs).build())
setEnabled(prefs.chargepriceNativeIntegration)
}.build())
addItem(Row.Builder().apply {
setTitle(carContext.getString(R.string.pref_chargeprice_allow_unbalanced_load))
addText(carContext.getString(R.string.pref_chargeprice_allow_unbalanced_load_summary))
setToggle(Toggle.Builder {
prefs.chargepriceAllowUnbalancedLoad = it
}.setChecked(prefs.chargepriceAllowUnbalancedLoad).build())
setEnabled(prefs.chargepriceNativeIntegration)
}.build())
}
}.build())
}.build()
}
}
class SelectVehiclesScreen(ctx: CarContext) : MultiSelectSearchScreen<ChargepriceCar>(ctx) {
private val prefs = PreferenceDataSource(carContext)
private var api = ChargepriceApi.create(
carContext.getString(R.string.chargeprice_key),
carContext.getString(R.string.chargeprice_api_url)
)
override val isMultiSelect = true
override val shouldShowSelectAll = false
override fun isSelected(it: ChargepriceCar): Boolean {
return prefs.chargepriceMyVehicles.contains(it.id)
}
override fun toggleSelected(item: ChargepriceCar) {
if (isSelected(item)) {
prefs.chargepriceMyVehicles = prefs.chargepriceMyVehicles.minus(item.id)
} else {
prefs.chargepriceMyVehicles = prefs.chargepriceMyVehicles.plus(item.id)
}
}
override fun getLabel(it: ChargepriceCar) = "${it.brand} ${it.name}"
override fun getDetails(it: ChargepriceCar) = it.formatSpecs()
override suspend fun loadData(): List<ChargepriceCar> {
return api.getVehicles()
}
}
class SelectTariffsScreen(ctx: CarContext) : MultiSelectSearchScreen<ChargepriceTariff>(ctx) {
private val prefs = PreferenceDataSource(carContext)
private var api = ChargepriceApi.create(
carContext.getString(R.string.chargeprice_key),
carContext.getString(R.string.chargeprice_api_url)
)
override val isMultiSelect = true
override val shouldShowSelectAll = true
override fun isSelected(it: ChargepriceTariff): Boolean {
return prefs.chargepriceMyTariffsAll or (prefs.chargepriceMyTariffs?.contains(it.id)
?: false)
}
override fun toggleSelected(item: ChargepriceTariff) {
val tariffs = prefs.chargepriceMyTariffs ?: if (prefs.chargepriceMyTariffsAll) {
fullList!!.map { it.id }.toSet()
} else {
emptySet()
}
if (isSelected(item)) {
prefs.chargepriceMyTariffs = tariffs.minus(item.id)
prefs.chargepriceMyTariffsAll = false
} else {
prefs.chargepriceMyTariffs = tariffs.plus(item.id)
if (prefs.chargepriceMyTariffs == fullList!!.map { it.id }.toSet()) {
prefs.chargepriceMyTariffsAll = true
}
}
}
override fun selectAll() {
prefs.chargepriceMyTariffsAll = true
super.selectAll()
}
override fun selectNone() {
prefs.chargepriceMyTariffsAll = false
prefs.chargepriceMyTariffs = emptySet()
super.selectNone()
}
override fun getLabel(it: ChargepriceTariff): String {
return if (!it.name.lowercase().startsWith(it.provider.lowercase())) {
"${it.provider} ${it.name}"
} else {
it.name
}
}
override suspend fun loadData(): List<ChargepriceTariff> {
return api.getTariffs()
}
}
class SelectCurrencyScreen(ctx: CarContext) : MultiSelectSearchScreen<Pair<String, String>>(ctx) {
private val prefs = PreferenceDataSource(carContext)
override val isMultiSelect = false
override val shouldShowSelectAll = false
override fun isSelected(it: Pair<String, String>): Boolean =
prefs.chargepriceCurrency == it.second
override fun toggleSelected(item: Pair<String, String>) {
prefs.chargepriceCurrency = item.second
}
override fun getLabel(it: Pair<String, String>): String = it.first
override suspend fun loadData(): List<Pair<String, String>> {
val values = carContext.resources.getStringArray(R.array.pref_chargeprice_currencies)
val names = values.map(::currencyDisplayName)
return names.zip(values)
}
}
class SelectChargingRangeScreen(ctx: CarContext) : Screen(ctx) {
val prefs = PreferenceDataSource(carContext)
private val maxItems = if (ctx.carAppApiLevel >= 2) {
ctx.constraintManager.getContentLimit(ConstraintManager.CONTENT_LIMIT_TYPE_GRID)
} else 6
override fun onGetTemplate(): Template {
return GridTemplate.Builder().apply {
setTitle(carContext.getString(R.string.settings_android_auto_chargeprice_range))
setHeaderAction(Action.BACK)
setSingleList(
ItemList.Builder().apply {
addItem(GridItem.Builder().apply {
setTitle(carContext.getString(R.string.chargeprice_battery_range_from))
setText(
carContext.getString(
R.string.percent_format,
prefs.chargepriceBatteryRangeAndroidAuto[0]
)
)
setImage(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
R.drawable.ic_add
)
).build()
)
setOnClickListener {
prefs.chargepriceBatteryRangeAndroidAuto =
prefs.chargepriceBatteryRangeAndroidAuto.toMutableList().apply {
this[0] = min(this[1] - 5, this[0] + 5)
}
invalidate()
}
}.build())
addItem(GridItem.Builder().apply {
setTitle(carContext.getString(R.string.chargeprice_battery_range_to))
setText(
carContext.getString(
R.string.percent_format,
prefs.chargepriceBatteryRangeAndroidAuto[1]
)
)
setImage(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
R.drawable.ic_add
)
).build()
)
setOnClickListener {
prefs.chargepriceBatteryRangeAndroidAuto =
prefs.chargepriceBatteryRangeAndroidAuto.toMutableList().apply {
this[1] = min(100f, this[1] + 5)
}
invalidate()
}
}.build())
val nSpacers = when {
maxItems % 3 == 0 -> 1
maxItems == 100 -> 0 // AA has increased the limit to 100 and changed the way items are laid out
maxItems % 4 == 0 -> 2
else -> 0
}
for (i in 0..nSpacers) {
addItem(GridItem.Builder().apply {
setTitle(" ")
setImage(emptyCarIcon)
}.build())
}
addItem(GridItem.Builder().apply {
setTitle(" ")
setImage(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
R.drawable.ic_remove
)
).build()
)
setOnClickListener {
prefs.chargepriceBatteryRangeAndroidAuto =
prefs.chargepriceBatteryRangeAndroidAuto.toMutableList().apply {
this[0] = max(0f, this[0] - 5)
}
invalidate()
}
}.build())
addItem(GridItem.Builder().apply {
setTitle(" ")
setImage(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
R.drawable.ic_remove
)
).build()
)
setOnClickListener {
prefs.chargepriceBatteryRangeAndroidAuto =
prefs.chargepriceBatteryRangeAndroidAuto.toMutableList().apply {
this[1] = max(this[0] + 5, this[1] - 5)
}
invalidate()
}
}.build())
}.build()
)
}.build()
}
}
@ExperimentalCarApi
class AboutScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx) {
val prefs = PreferenceDataSource(ctx)
@@ -0,0 +1,21 @@
package net.vonforst.evmap.auto
import androidx.annotation.StringRes
import androidx.car.app.CarContext
import androidx.car.app.Screen
import androidx.car.app.model.Action
import androidx.car.app.model.LongMessageTemplate
import androidx.car.app.model.Template
class TextDialogScreen(
ctx: CarContext,
@StringRes val title: Int,
@StringRes val message: Int
) : Screen(ctx) {
override fun onGetTemplate(): Template {
return LongMessageTemplate.Builder(carContext.getString(message)).apply {
setTitle(carContext.getString(title))
setHeaderAction(Action.BACK)
}.build()
}
}
@@ -1,275 +0,0 @@
package net.vonforst.evmap.fragment
import android.annotation.SuppressLint
import android.os.Bundle
import android.view.LayoutInflater
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.updatePadding
import androidx.databinding.DataBindingUtil
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.Observer
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import androidx.navigation.ui.setupWithNavController
import androidx.recyclerview.widget.ConcatAdapter
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
import com.google.android.material.color.MaterialColors
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar
import com.google.android.material.transition.MaterialContainerTransform
import net.vonforst.evmap.MapsActivity
import net.vonforst.evmap.R
import net.vonforst.evmap.adapter.ChargepriceAdapter
import net.vonforst.evmap.adapter.CheckableChargepriceCarAdapter
import net.vonforst.evmap.adapter.CheckableConnectorAdapter
import net.vonforst.evmap.adapter.SingleViewAdapter
import net.vonforst.evmap.api.chargeprice.ChargepriceApi
import net.vonforst.evmap.api.chargeprice.ChargepriceCar
import net.vonforst.evmap.api.equivalentPlugTypes
import net.vonforst.evmap.databinding.FragmentChargepriceBinding
import net.vonforst.evmap.databinding.FragmentChargepriceHeaderBinding
import net.vonforst.evmap.model.Chargepoint
import net.vonforst.evmap.navigation.safeNavigate
import net.vonforst.evmap.storage.PreferenceDataSource
import net.vonforst.evmap.viewmodel.ChargepriceViewModel
import net.vonforst.evmap.viewmodel.Status
import net.vonforst.evmap.viewmodel.savedStateViewModelFactory
import java.text.NumberFormat
class ChargepriceFragment : Fragment() {
private lateinit var binding: FragmentChargepriceBinding
private lateinit var headerBinding: FragmentChargepriceHeaderBinding
private var connectionErrorSnackbar: Snackbar? = null
private val vm: ChargepriceViewModel by viewModels(factoryProducer = {
savedStateViewModelFactory { state ->
ChargepriceViewModel(
requireActivity().application,
getString(R.string.chargeprice_key),
getString(R.string.chargeprice_api_url),
state
)
}
})
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
sharedElementEnterTransition = MaterialContainerTransform()
if (savedInstanceState == null) {
val prefs = PreferenceDataSource(requireContext())
prefs.chargepriceCounter += 1
if ((prefs.chargepriceCounter).mod(30) == 0) {
showDonationDialog()
}
}
}
override fun onResume() {
super.onResume()
vm.reloadPrefs()
}
private fun showDonationDialog() {
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.chargeprice_donation_dialog_title)
.setMessage(R.string.chargeprice_donation_dialog_detail)
.setNegativeButton(R.string.ok) { di, _ ->
di.cancel()
}
.setPositiveButton(R.string.donate) { di, _ ->
di.dismiss()
findNavController().safeNavigate(ChargepriceFragmentDirections.actionChargepriceToDonateFragment())
}
.show()
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
binding = DataBindingUtil.inflate(
inflater,
R.layout.fragment_chargeprice, container, false
)
headerBinding = DataBindingUtil.inflate(
inflater,
R.layout.fragment_chargeprice_header, container, false
)
binding.lifecycleOwner = viewLifecycleOwner
binding.vm = vm
headerBinding.lifecycleOwner = viewLifecycleOwner
headerBinding.vm = vm
binding.toolbar.inflateMenu(R.menu.chargeprice)
binding.toolbar.setTitle(R.string.chargeprice_title)
ViewCompat.setOnApplyWindowInsetsListener(binding.chargePricesList) { v, insets ->
v.updatePadding(bottom = insets.getInsets(WindowInsetsCompat.Type.systemBars()).bottom)
WindowInsetsCompat.CONSUMED
}
return binding.root
}
@SuppressLint("ClickableViewAccessibility")
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.toolbar.setupWithNavController(
findNavController(),
(requireActivity() as MapsActivity).appBarConfiguration
)
val fragmentArgs: ChargepriceFragmentArgs by navArgs()
val charger = fragmentArgs.charger
vm.charger.value = charger
if (vm.chargepoint.value == null) {
vm.chargepoint.value = charger.chargepointsMerged[0]
}
val vehicleAdapter = CheckableChargepriceCarAdapter()
headerBinding.vehicleSelection.adapter = vehicleAdapter
val vehicleObserver: Observer<ChargepriceCar?> = Observer {
vehicleAdapter.setCheckedItem(it)
}
vm.vehicle.observe(viewLifecycleOwner, vehicleObserver)
vehicleAdapter.onCheckedItemChangedListener = {
vm.vehicle.removeObserver(vehicleObserver)
vm.vehicle.value = it
vm.vehicle.observe(viewLifecycleOwner, vehicleObserver)
}
val chargepriceAdapter = ChargepriceAdapter().apply {
onClickListener = {
(requireActivity() as MapsActivity).openUrl(it.url, binding.root)
}
}
val joinedAdapter = ConcatAdapter(
SingleViewAdapter(headerBinding.root),
chargepriceAdapter
)
binding.chargePricesList.apply {
adapter = joinedAdapter
layoutManager =
LinearLayoutManager(context, LinearLayoutManager.VERTICAL, false)
addItemDecoration(
DividerItemDecoration(
context, LinearLayoutManager.VERTICAL
)
)
}
vm.chargepriceMetaForChargepoint.observe(viewLifecycleOwner) {
chargepriceAdapter.meta = it?.data
}
vm.myTariffs.observe(viewLifecycleOwner) {
chargepriceAdapter.myTariffs = it
}
vm.myTariffsAll.observe(viewLifecycleOwner) {
chargepriceAdapter.myTariffsAll = it
}
vm.chargePricesForChargepoint.observe(viewLifecycleOwner) {
chargepriceAdapter.submitList(it?.data ?: emptyList())
}
val connectorsAdapter = CheckableConnectorAdapter()
val observer: Observer<Chargepoint?> = Observer {
connectorsAdapter.setCheckedItem(it)
}
vm.chargepoint.observe(viewLifecycleOwner, observer)
connectorsAdapter.onCheckedItemChangedListener = {
vm.chargepoint.removeObserver(observer)
vm.chargepoint.value = it
vm.chargepoint.observe(viewLifecycleOwner, observer)
}
vm.vehicleCompatibleConnectors.observe(viewLifecycleOwner) { plugs ->
connectorsAdapter.enabledConnectors =
plugs?.flatMap { plug -> equivalentPlugTypes(plug) }
}
headerBinding.connectorsList.apply {
adapter = connectorsAdapter
layoutManager = LinearLayoutManager(context, LinearLayoutManager.HORIZONTAL, false)
}
binding.imgChargepriceLogo.setOnClickListener {
(requireActivity() as MapsActivity).openUrl(
ChargepriceApi.getPoiUrl(charger),
binding.root
)
}
binding.btnSettings.setOnClickListener {
findNavController().safeNavigate(ChargepriceFragmentDirections.actionChargepriceToChargepriceSettingsFragment())
}
headerBinding.batteryRange.setLabelFormatter { value: Float ->
val fmt = NumberFormat.getNumberInstance()
fmt.maximumFractionDigits = 0
fmt.format(value.toDouble()) + "%"
}
headerBinding.batteryRange.setOnTouchListener { _: View, motionEvent: MotionEvent ->
when (motionEvent.actionMasked) {
MotionEvent.ACTION_DOWN -> vm.batteryRangeSliderDragging.value = true
MotionEvent.ACTION_UP -> vm.batteryRangeSliderDragging.value = false
}
false
}
headerBinding.tvChargeFromTo.setOnClickListener {
it.postDelayed({
vm.resetBatteryRangeToDefault()
}, 250)
}
binding.toolbar.setOnMenuItemClickListener {
when (it.itemId) {
R.id.menu_help -> {
(activity as? MapsActivity)?.openUrl(
getString(R.string.chargeprice_faq_link),
binding.root
)
true
}
else -> false
}
}
vm.chargePricesForChargepoint.observe(viewLifecycleOwner) { res ->
when (res?.status) {
Status.ERROR -> {
if (vm.vehicle.value == null) return@observe
connectionErrorSnackbar?.dismiss()
connectionErrorSnackbar = Snackbar
.make(
view,
R.string.chargeprice_connection_error,
Snackbar.LENGTH_INDEFINITE
)
.setAction(R.string.retry) {
connectionErrorSnackbar?.dismiss()
vm.loadPrices()
}
connectionErrorSnackbar!!.show()
}
Status.SUCCESS, null -> {
connectionErrorSnackbar?.dismiss()
}
Status.LOADING -> {
}
}
}
// Workaround for AndroidX bug: https://github.com/material-components/material-components-android/issues/1984
view.setBackgroundColor(MaterialColors.getColor(view, android.R.attr.windowBackground))
}
}
@@ -1,19 +1,23 @@
package net.vonforst.evmap.fragment
import android.content.Context
import android.content.res.ColorStateList
import android.view.LayoutInflater
import android.view.View
import androidx.databinding.DataBindingUtil
import androidx.recyclerview.widget.ConcatAdapter
import androidx.recyclerview.widget.LinearLayoutManager
import net.vonforst.evmap.R
import net.vonforst.evmap.adapter.ConnectorAdapter
import net.vonforst.evmap.adapter.ConnectorDetailsAdapter
import net.vonforst.evmap.adapter.SingleViewAdapter
import net.vonforst.evmap.api.availability.ChargeLocationStatus
import net.vonforst.evmap.api.iconForPlugType
import net.vonforst.evmap.api.nameForPlugType
import net.vonforst.evmap.api.stringProvider
import net.vonforst.evmap.databinding.DialogConnectorDetailsBinding
import net.vonforst.evmap.databinding.DialogConnectorDetailsHeaderBinding
import net.vonforst.evmap.model.Chargepoint
import net.vonforst.evmap.ui.availabilityColor
import net.vonforst.evmap.ui.availabilityText
import net.vonforst.evmap.ui.goneUnless
import java.util.Locale
class ConnectorDetailsDialog(
binding: DialogConnectorDetailsBinding,
@@ -30,9 +34,10 @@ class ConnectorDetailsDialog(
layoutManager =
LinearLayoutManager(context, LinearLayoutManager.VERTICAL, false)
}
headerBinding_ = DataBindingUtil.inflate(
headerBinding_ = DialogConnectorDetailsHeaderBinding.inflate(
LayoutInflater.from(context),
R.layout.dialog_connector_details_header, binding.list, false
binding.list,
false
)
binding.list.adapter = ConcatAdapter(
SingleViewAdapter(headerBinding.root),
@@ -57,8 +62,41 @@ class ConnectorDetailsDialog(
} else emptyList()
detailsAdapter.submitList(items)
headerBinding.divider.visibility = if (items.isEmpty()) View.GONE else View.VISIBLE
headerBinding.item = ConnectorAdapter.ChargepointWithAvailability(cp, cpStatus)
headerBinding.divider.visibility = goneUnless(items.isNotEmpty())
val context = headerBinding.root.context
val locale = Locale.getDefault()
val provider = context.stringProvider()
headerBinding.imageView.setImageResource(iconForPlugType(cp.type))
headerBinding.imageView.contentDescription = cp.type
headerBinding.imageView.imageTintList =
ColorStateList.valueOf(availabilityColor(cpStatus, context))
headerBinding.txtNumber.text = String.format(locale, "x %d", cp.count)
headerBinding.txtNumber.visibility = goneUnless(cpStatus == null)
if (cpStatus != null) {
headerBinding.txtStatus.text = String.format(
locale,
"%s/%d",
availabilityText(cpStatus),
cp.count
)
headerBinding.txtStatus.backgroundTintList =
ColorStateList.valueOf(availabilityColor(cpStatus, context))
}
headerBinding.txtStatus.visibility = goneUnless(cpStatus != null)
val name = nameForPlugType(provider, cp.type)
headerBinding.txtTitle.text = if (cp.hasKnownPower()) {
"$name - ${cp.formatPower(locale)}"
} else {
name
}
headerBinding.txtDetail.text = cp.formatVoltageAndCurrent()
headerBinding.txtDetail.visibility = goneUnless(cp.hasKnownVoltageAndCurrent())
}
fun onDestroy() {
@@ -53,6 +53,7 @@ class DataSourceSelectDialog : MaterialDialogFragment() {
if (prefs.dataSourceSet) {
when (prefs.dataSource) {
"goingelectric" -> binding.rgDataSource.rbGoingElectric.isChecked = true
"nobil" -> binding.rgDataSource.rbNobil.isChecked = true
"openchargemap" -> binding.rgDataSource.rbOpenChargeMap.isChecked = true
"openstreetmap" -> binding.rgDataSource.rbOpenStreetMap.isChecked = true
}
@@ -64,6 +65,8 @@ class DataSourceSelectDialog : MaterialDialogFragment() {
binding.btnOK.setOnClickListener {
val result = if (binding.rgDataSource.rbGoingElectric.isChecked) {
"goingelectric"
} else if (binding.rgDataSource.rbNobil.isChecked) {
"nobil"
} else if (binding.rgDataSource.rbOpenChargeMap.isChecked) {
"openchargemap"
} else if (binding.rgDataSource.rbOpenStreetMap.isChecked) {
@@ -10,7 +10,6 @@ import android.widget.FrameLayout
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.updatePadding
import androidx.databinding.DataBindingUtil
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.navigation.fragment.findNavController
@@ -32,6 +31,7 @@ import net.vonforst.evmap.databinding.ItemFavoriteBinding
import net.vonforst.evmap.location.FusionEngine
import net.vonforst.evmap.location.LocationEngine
import net.vonforst.evmap.model.FavoriteWithDetail
import net.vonforst.evmap.ui.goneUnless
import net.vonforst.evmap.utils.checkAnyLocationPermission
import net.vonforst.evmap.viewmodel.FavoritesViewModel
import net.vonforst.evmap.viewmodel.viewModelFactory
@@ -64,12 +64,7 @@ class FavoritesFragment : Fragment() {
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
binding = DataBindingUtil.inflate(
inflater,
R.layout.fragment_favorites, container, false
)
binding.lifecycleOwner = viewLifecycleOwner
binding.vm = vm
binding = FragmentFavoritesBinding.inflate(inflater, container, false)
ViewCompat.setOnApplyWindowInsetsListener(
binding.favsList
@@ -114,6 +109,13 @@ class FavoritesFragment : Fragment() {
)
)
}
vm.listData.observe(viewLifecycleOwner) { items ->
adapter.submitList(items)
val isEmpty = items.isNullOrEmpty()
binding.animationView.visibility = goneUnless(isEmpty)
binding.txtEmptyState.visibility = goneUnless(isEmpty)
}
createTouchHelper().attachToRecyclerView(binding.favsList)
binding.swipeRefresh.setOnRefreshListener {
@@ -185,7 +187,7 @@ class FavoritesFragment : Fragment() {
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
(viewHolder as DataBindingAdapter.ViewHolder).binding as ItemFavoriteBinding
getDefaultUIUtil().onSelected(binding.foreground)
} else {
super.onSelectedChanged(viewHolder, actionState)
@@ -199,7 +201,7 @@ class FavoritesFragment : Fragment() {
) {
if (actionState == ItemTouchHelper.ACTION_STATE_SWIPE) {
val binding =
(viewHolder as DataBindingAdapter.ViewHolder<*>).binding as ItemFavoriteBinding
(viewHolder as DataBindingAdapter.ViewHolder).binding as ItemFavoriteBinding
getDefaultUIUtil().onDrawOver(
c, recyclerView, binding.foreground, dX, dY,
actionState, isCurrentlyActive
@@ -229,7 +231,7 @@ class FavoritesFragment : Fragment() {
viewHolder: RecyclerView.ViewHolder
) {
val binding =
(viewHolder as DataBindingAdapter.ViewHolder<*>).binding as ItemFavoriteBinding
(viewHolder as DataBindingAdapter.ViewHolder).binding as ItemFavoriteBinding
getDefaultUIUtil().clearView(binding.foreground)
}
@@ -240,7 +242,7 @@ class FavoritesFragment : Fragment() {
) {
if (actionState == ItemTouchHelper.ACTION_STATE_SWIPE) {
val binding =
(viewHolder as DataBindingAdapter.ViewHolder<*>).binding as ItemFavoriteBinding
(viewHolder as DataBindingAdapter.ViewHolder).binding as ItemFavoriteBinding
getDefaultUIUtil().onDraw(
c, recyclerView, binding.foreground, dX, dY,
actionState, isCurrentlyActive
@@ -12,7 +12,6 @@ import androidx.core.view.MenuProvider
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.updatePadding
import androidx.databinding.DataBindingUtil
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.Lifecycle
@@ -34,6 +33,7 @@ import net.vonforst.evmap.viewmodel.FilterViewModel
class FilterFragment : Fragment(), MenuProvider {
private lateinit var binding: FragmentFilterBinding
private lateinit var filtersAdapter: FiltersAdapter
private val vm: FilterViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
@@ -47,10 +47,7 @@ class FilterFragment : Fragment(), MenuProvider {
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
binding = DataBindingUtil.inflate(inflater, R.layout.fragment_filter, container, false)
binding.lifecycleOwner = viewLifecycleOwner
binding.vm = vm
vm.filterProfile.observe(viewLifecycleOwner) {}
binding = FragmentFilterBinding.inflate(inflater, container, false)
ViewCompat.setOnApplyWindowInsetsListener(
binding.filtersList
@@ -77,8 +74,9 @@ class FilterFragment : Fragment(), MenuProvider {
}
}
filtersAdapter = FiltersAdapter()
binding.filtersList.apply {
adapter = FiltersAdapter()
adapter = filtersAdapter
layoutManager =
LinearLayoutManager(context, LinearLayoutManager.VERTICAL, false)
addItemDecoration(
@@ -88,6 +86,13 @@ class FilterFragment : Fragment(), MenuProvider {
)
}
vm.filtersWithValue.observe(viewLifecycleOwner) {
@Suppress("UNCHECKED_CAST")
val items =
it as? List<net.vonforst.evmap.model.FilterWithValue<net.vonforst.evmap.model.FilterValue>>
filtersAdapter.submitList(items)
}
binding.toolbar.setNavigationOnClickListener {
findNavController().popBackStack()
}
@@ -31,6 +31,7 @@ import net.vonforst.evmap.adapter.FilterProfilesAdapter
import net.vonforst.evmap.databinding.FragmentFilterProfilesBinding
import net.vonforst.evmap.databinding.ItemFilterProfileBinding
import net.vonforst.evmap.storage.FilterProfile
import net.vonforst.evmap.ui.goneUnless
import net.vonforst.evmap.ui.showEditTextDialog
import net.vonforst.evmap.viewmodel.FilterProfilesViewModel
import net.vonforst.evmap.viewmodel.viewModelFactory
@@ -60,8 +61,6 @@ class FilterProfilesFragment : Fragment() {
savedInstanceState: Bundle?
): View {
binding = FragmentFilterProfilesBinding.inflate(inflater, container, false)
binding.lifecycleOwner = viewLifecycleOwner
binding.vm = vm
ViewCompat.setOnApplyWindowInsetsListener(
binding.filterProfilesList
@@ -116,7 +115,7 @@ class FilterProfilesFragment : Fragment() {
override fun onSelectedChanged(viewHolder: RecyclerView.ViewHolder?, actionState: Int) {
if (viewHolder != null && actionState == ItemTouchHelper.ACTION_STATE_SWIPE) {
val binding =
(viewHolder as DataBindingAdapter.ViewHolder<*>).binding as ItemFilterProfileBinding
(viewHolder as DataBindingAdapter.ViewHolder).binding as ItemFilterProfileBinding
getDefaultUIUtil().onSelected(binding.foreground)
} else {
super.onSelectedChanged(viewHolder, actionState)
@@ -130,7 +129,7 @@ class FilterProfilesFragment : Fragment() {
) {
if (actionState == ItemTouchHelper.ACTION_STATE_SWIPE) {
val binding =
(viewHolder as DataBindingAdapter.ViewHolder<*>).binding as ItemFilterProfileBinding
(viewHolder as DataBindingAdapter.ViewHolder).binding as ItemFilterProfileBinding
getDefaultUIUtil().onDrawOver(
c, recyclerView, binding.foreground, dX, dY,
actionState, isCurrentlyActive
@@ -160,7 +159,7 @@ class FilterProfilesFragment : Fragment() {
viewHolder: RecyclerView.ViewHolder
) {
val binding =
(viewHolder as DataBindingAdapter.ViewHolder<*>).binding as ItemFilterProfileBinding
(viewHolder as DataBindingAdapter.ViewHolder).binding as ItemFilterProfileBinding
getDefaultUIUtil().clearView(binding.foreground)
}
@@ -171,7 +170,7 @@ class FilterProfilesFragment : Fragment() {
) {
if (actionState == ItemTouchHelper.ACTION_STATE_SWIPE) {
val binding =
(viewHolder as DataBindingAdapter.ViewHolder<*>).binding as ItemFilterProfileBinding
(viewHolder as DataBindingAdapter.ViewHolder).binding as ItemFilterProfileBinding
getDefaultUIUtil().onDraw(
c, recyclerView, binding.foreground, dX, dY,
actionState, isCurrentlyActive
@@ -223,6 +222,11 @@ class FilterProfilesFragment : Fragment() {
)
}
vm.filterProfiles.observe(viewLifecycleOwner) { profiles ->
adapter.submitList(profiles)
binding.txtEmptyState.visibility = goneUnless(profiles.isNullOrEmpty())
}
touchHelper.attachToRecyclerView(binding.filterProfilesList)
binding.toolbar.setNavigationOnClickListener {
@@ -3,13 +3,18 @@ package net.vonforst.evmap.fragment
import android.Manifest.permission.ACCESS_COARSE_LOCATION
import android.Manifest.permission.ACCESS_FINE_LOCATION
import android.annotation.SuppressLint
import android.content.ActivityNotFoundException
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.Intent
import android.content.res.ColorStateList
import android.content.res.Configuration
import android.graphics.Color
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.text.format.DateUtils
import android.text.method.KeyListener
import android.view.ContextThemeWrapper
import android.view.Gravity
@@ -28,23 +33,23 @@ import androidx.activity.OnBackPressedCallback
import androidx.activity.result.contract.ActivityResultContracts
import androidx.annotation.RequiresPermission
import androidx.appcompat.widget.PopupMenu
import androidx.appcompat.widget.TooltipCompat
import androidx.core.content.ContextCompat
import androidx.core.location.LocationListenerCompat
import androidx.core.view.MenuCompat
import androidx.core.view.MenuProvider
import androidx.core.view.ViewCompat
import androidx.core.view.ViewGroupCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.doOnLayout
import androidx.core.view.doOnNextLayout
import androidx.core.view.updateLayoutParams
import androidx.databinding.DataBindingUtil
import androidx.core.widget.doAfterTextChanged
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.fragment.app.viewModels
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.Observer
import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.FragmentNavigatorExtras
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import androidx.navigation.ui.setupWithNavController
@@ -84,7 +89,10 @@ 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.adapter.buildDetails
import net.vonforst.evmap.adapter.chargepointWithAvailability
import net.vonforst.evmap.api.chargeprice.ChargepriceApi
import net.vonforst.evmap.api.stringProvider
import net.vonforst.evmap.autocomplete.ApiUnavailableException
import net.vonforst.evmap.autocomplete.PlaceWithBounds
import net.vonforst.evmap.bold
@@ -103,13 +111,20 @@ import net.vonforst.evmap.shouldUseImperialUnits
import net.vonforst.evmap.storage.PreferenceDataSource
import net.vonforst.evmap.ui.HideOnScrollFabBehavior
import net.vonforst.evmap.ui.MarkerManager
import net.vonforst.evmap.ui.availabilityColor
import net.vonforst.evmap.ui.availabilityText
import net.vonforst.evmap.ui.distance
import net.vonforst.evmap.ui.flatten
import net.vonforst.evmap.ui.goneUnless
import net.vonforst.evmap.ui.invisibleUnless
import net.vonforst.evmap.ui.isFabActive
import net.vonforst.evmap.ui.setTouchModal
import net.vonforst.evmap.utils.boundingBox
import net.vonforst.evmap.utils.checkAnyLocationPermission
import net.vonforst.evmap.utils.checkFineLocationPermission
import net.vonforst.evmap.utils.distanceBetween
import net.vonforst.evmap.utils.formatDMS
import net.vonforst.evmap.utils.formatDecimal
import net.vonforst.evmap.viewmodel.GalleryViewModel
import net.vonforst.evmap.viewmodel.MapPosition
import net.vonforst.evmap.viewmodel.MapViewModel
import net.vonforst.evmap.viewmodel.Resource
@@ -117,6 +132,7 @@ import net.vonforst.evmap.viewmodel.Status
import java.io.IOException
import java.time.Duration
import java.time.Instant
import java.util.Locale
import kotlin.math.min
@@ -124,7 +140,6 @@ class MapFragment : Fragment(), OnMapReadyCallback, MenuProvider {
private var _binding: FragmentMapBinding? = null
private val binding get() = _binding!!
private val vm: MapViewModel by viewModels()
private val galleryVm: GalleryViewModel by activityViewModels()
private var mapFragment: MapFragment? = null
private var map: AnyMap? = null
private var markerManager: MarkerManager? = null
@@ -189,10 +204,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MenuProvider {
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
_binding = DataBindingUtil.inflate(inflater, R.layout.fragment_map, container, false)
println(binding.detailView.sourceButton)
binding.lifecycleOwner = viewLifecycleOwner
binding.vm = vm
_binding = FragmentMapBinding.inflate(inflater, container, false)
val provider = prefs.mapProvider
if (mapFragment == null) {
@@ -219,6 +231,9 @@ class MapFragment : Fragment(), OnMapReadyCallback, MenuProvider {
com.google.android.material.R.style.Theme_Material3_DayNight
val density = resources.displayMetrics.density
ViewGroupCompat.installCompatInsetsDispatch(binding.root)
ViewCompat.setOnApplyWindowInsetsListener(binding.detailAppBar.toolbar) { v, insets ->
val systemWindowInsetTop = insets.getInsets(WindowInsetsCompat.Type.systemBars()).top
v.updateLayoutParams<ViewGroup.MarginLayoutParams> {
@@ -319,6 +334,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MenuProvider {
vm.apiName.observe(viewLifecycleOwner) {
binding.detailAppBar.toolbar.menu.findItem(R.id.menu_edit).title =
getString(R.string.edit_at_datasource, it)
renderDetailView()
}
binding.detailView.topPart.doOnNextLayout {
@@ -331,6 +347,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MenuProvider {
setupObservers()
setupClickListeners()
setupAdapters()
renderDetailView()
(activity as? MapsActivity)?.setSupportActionBar(binding.toolbar)
binding.toolbar.setupWithNavController(
@@ -434,30 +451,49 @@ class MapFragment : Fragment(), OnMapReadyCallback, MenuProvider {
binding.fabLayers.setOnClickListener {
openLayersMenu()
}
binding.layers.radioGroup.setOnCheckedChangeListener { group, i ->
when (i) {
R.id.rbStandard -> vm.setMapType(AnyMap.Type.NORMAL)
R.id.rbSatellite -> vm.setMapType(AnyMap.Type.HYBRID)
R.id.rbTerrain -> vm.setMapType(AnyMap.Type.TERRAIN)
}
}
binding.layers.cbTraffic.setOnCheckedChangeListener { _, isChecked ->
vm.mapTrafficEnabled.value = isChecked
}
binding.layers.btnClose.setOnClickListener {
closeLayersMenu()
}
binding.detailView.sourceButton.setOnClickListener {
val charger = vm.charger.value?.data
if (charger != null) {
(activity as? MapsActivity)?.openUrl(charger.url, binding.root, true)
(activity as? MapsActivity)?.openUrl(charger.url ?: charger.dataSourceUrl, binding.root, true)
}
}
binding.detailView.btnChargeprice.setOnClickListener {
val charger = vm.charger.value?.data ?: return@setOnClickListener
if (prefs.chargepriceNativeIntegration) {
val extras =
FragmentNavigatorExtras(binding.detailView.btnChargeprice to getString(R.string.shared_element_chargeprice))
findNavController().safeNavigate(
MapFragmentDirections.actionMapToChargepriceFragment(charger),
extras
)
} else {
(activity as? MapsActivity)?.openUrl(
ChargepriceApi.getPoiUrl(charger),
binding.root
)
if (prefs.chargepriceCounter > 0 && !prefs.chargepriceRemoval2025DialogShown) {
// user has been using the native Chargeprice integration before
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.chargeprice_removal_2025_dialog_title)
.setMessage(R.string.chargeprice_removal_2025_dialog_detail)
.setPositiveButton(R.string.ok) { di, _ ->
di.cancel()
prefs.chargepriceRemoval2025DialogShown = true
(activity as? MapsActivity)?.openUrl(
ChargepriceApi.getPoiUrl(charger),
binding.root
)
}
.show()
return@setOnClickListener
}
(activity as? MapsActivity)?.openUrl(
ChargepriceApi.getPoiUrl(charger),
binding.root
)
}
binding.detailView.btnChargerWebsite.setOnClickListener {
val charger = vm.charger.value?.data ?: return@setOnClickListener
@@ -501,15 +537,31 @@ class MapFragment : Fragment(), OnMapReadyCallback, MenuProvider {
}
R.id.menu_share -> {
val charger = vm.charger.value?.data
if (charger != null) {
(activity as? MapsActivity)?.shareUrl(charger.url)
if (charger != null && charger.url != null) {
(activity as? MapsActivity)?.shareUrl(charger.url, binding.root)
}
true
}
R.id.menu_edit -> {
val charger = vm.charger.value?.data
if (charger?.editUrl != null) {
(activity as? MapsActivity)?.openUrl(charger.editUrl, binding.root, true)
val uri = Uri.parse(charger.editUrl)
if (uri.getScheme() == "mailto") {
val intent = Intent(Intent.ACTION_SENDTO, uri)
try {
startActivity(intent)
} catch (e: ActivityNotFoundException) {
Toast.makeText(
requireContext(),
R.string.no_email_app_found,
Toast.LENGTH_LONG
).show()
}
}
else {
(activity as? MapsActivity)?.openUrl(charger.editUrl, binding.root, true)
}
if (vm.apiId.value == "goingelectric") {
// instructions specific to GoingElectric
Toast.makeText(
@@ -540,6 +592,9 @@ class MapFragment : Fragment(), OnMapReadyCallback, MenuProvider {
@SuppressLint("SetTextI18n")
private fun setupSearchAutocomplete() {
binding.search.threshold = 1
binding.search.doAfterTextChanged {
updateClearSearchVisibility()
}
searchKeyListener = binding.search.keyListener
binding.search.keyListener = null
@@ -567,6 +622,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MenuProvider {
place.primaryText.toString()
}
)
updateClearSearchVisibility()
}
binding.search.onFocusChangeListener = View.OnFocusChangeListener { _, hasFocus ->
if (hasFocus) {
@@ -580,11 +636,186 @@ class MapFragment : Fragment(), OnMapReadyCallback, MenuProvider {
binding.clearSearch.setOnClickListener {
vm.searchResult.value = null
removeSearchFocus()
updateClearSearchVisibility()
}
binding.toolbar.doOnLayout {
binding.search.dropDownWidth = binding.toolbar.width
binding.search.dropDownAnchor = R.id.toolbar
}
updateClearSearchVisibility()
}
private fun updateClearSearchVisibility() {
binding.clearSearch.visibility = invisibleUnless(!binding.search.text.isNullOrEmpty())
}
private fun updateChargepointsUi(res: Resource<List<ChargepointListItem>>) {
binding.progressBar2.visibility = goneUnless(res.status == Status.LOADING)
binding.progressBar2.isIndeterminate = res.progress == null
binding.progressBar2.progress = ((res.progress ?: 0f) * 100f).toInt().coerceIn(0, 100)
binding.search.hint = if (res.progress != null) {
getString(R.string.downloading_chargers_percent, (res.progress * 100f).coerceIn(0f, 100f))
} else {
getString(R.string.search)
}
}
private fun updateGallery(photos: List<ChargerPhoto>?) {
val galleryAdapter = binding.gallery.adapter as? GalleryAdapter ?: return
galleryAdapter.submitList(photos)
binding.galleryPlaceholder.visibility = invisibleUnless(photos.isNullOrEmpty())
}
private fun renderDetailView() {
if (_binding == null) return
val context = requireContext()
val charger = vm.charger.value?.data
val availability = vm.availability.value
val filteredAvailability = vm.filteredAvailability.value
val predictionData = vm.predictionData.value
val chargeCards = vm.chargeCardMap.value
val filteredChargeCards = vm.filteredChargeCards.value
val distanceMeters = vm.chargerDistance.value
val expanded = vm.bottomSheetExpanded.value == true
val teslaPricing = vm.teslaPricing.value
val apiName = vm.apiName.value.orEmpty()
binding.detailView.apply {
txtName.text = charger?.name
txtName.maxLines = if (expanded) 3 else 1
txtAddress.text = charger?.address?.toString() ?: charger?.coordinates?.formatDMS()
txtAddress.visibility = invisibleUnless(charger != null)
txtDistance.text = distance(distanceMeters, context)
txtConnectors.text =
charger?.formatChargepoints(context.stringProvider(), Locale.getDefault())
val hasConnectors = charger?.chargepointsMerged?.isNotEmpty() == true
connectors.visibility = goneUnless(hasConnectors)
txtConnectorsHeading.visibility = goneUnless(hasConnectors)
txtRealtimeDesc.visibility = goneUnless(hasConnectors)
btnRefreshLiveData.visibility = goneUnless(hasConnectors)
btnRefreshLiveData.isEnabled = availability?.status != Status.LOADING
txtRealtimeDesc.text = when {
availability?.status == Status.SUCCESS -> getString(
R.string.realtime_data_source,
availability.data?.source.orEmpty()
)
availability?.status == Status.LOADING -> getString(R.string.realtime_data_loading)
availability?.message == "not signed in" -> getString(R.string.realtime_data_login_needed)
else -> getString(R.string.realtime_data_unavailable)
}
btnLogin.visibility = goneUnless(
hasConnectors && availability?.status == Status.ERROR && availability.message == "not signed in"
)
val flattenedAvailability = flatten(filteredAvailability?.data?.status?.values)
if (flattenedAvailability != null && !expanded) {
txtAvailability.visibility = View.VISIBLE
txtAvailability.text = String.format(
Locale.getDefault(),
"%s/%d",
availabilityText(flattenedAvailability),
filteredAvailability?.data?.totalChargepoints ?: 0
)
txtAvailability.backgroundTintList = ColorStateList.valueOf(
availabilityColor(flattenedAvailability, context)
)
} else {
txtAvailability.visibility = View.INVISIBLE
}
(connectors.adapter as? ConnectorAdapter)?.submitList(
chargepointWithAvailability(
charger?.chargepointsMerged,
availability?.data?.status
)
)
(details.adapter as? DetailsAdapter)?.submitList(
buildDetails(charger, chargeCards, filteredChargeCards, teslaPricing, context)
)
val amenities = charger?.amenities
val hasAmenities = !amenities.isNullOrBlank()
txtAmenitiesHeading.visibility = goneUnless(hasAmenities)
txtAmenities.visibility = goneUnless(hasAmenities)
txtAmenities.text = amenities
val generalInformation = charger?.generalInformation
val hasGeneralInformation = !generalInformation.isNullOrBlank()
txtGeneralInformationHeading.visibility = goneUnless(hasGeneralInformation)
txtGeneralInformation.visibility = goneUnless(hasGeneralInformation)
txtGeneralInformation.text = generalInformation
sourceButton.text = getString(R.string.source, apiName)
val hasChargeprice = charger?.let { ChargepriceApi.isChargerSupported(it) } == true
val hasChargerWebsite = charger?.chargerUrl != null
val hasExternalLinks = charger != null && (hasChargeprice || hasChargerWebsite)
buttonsScroller.visibility = goneUnless(hasExternalLinks)
divider3.visibility = goneUnless(hasExternalLinks)
btnChargeprice.visibility = goneUnless(hasChargeprice)
btnChargerWebsite.visibility = goneUnless(hasChargerWebsite)
val hasPredictionGraph = predictionData?.predictionGraph != null
val isPredictionPercentage = predictionData?.isPercentage == true
val predictionDescription = predictionData?.description
val showPredictionInfo = hasPredictionGraph && !isPredictionPercentage
txtPredictionHeading.visibility = goneUnless(hasPredictionGraph)
txtPredictionHeading.text = if (isPredictionPercentage) {
getString(R.string.average_utilization)
} else {
getString(R.string.utilization_prediction)
}
prediction.visibility = goneUnless(hasPredictionGraph)
prediction.data = predictionData?.predictionGraph
prediction.maxValue = predictionData?.maxValue
prediction.isPercentage = isPredictionPercentage
txtPredictionDescription.text = predictionDescription
txtPredictionDescription.visibility =
goneUnless(showPredictionInfo && !predictionDescription.isNullOrBlank())
btnPredictionHelp.visibility = goneUnless(showPredictionInfo)
imgPredictionSource.visibility = goneUnless(showPredictionInfo)
divider1.visibility = goneUnless(hasPredictionGraph)
imgVerified.visibility = goneUnless(charger?.verified == true)
TooltipCompat.setTooltipText(imgVerified, getString(R.string.verified_desc, apiName))
imgFaultReport.visibility = goneUnless(charger?.faultReport != null)
TooltipCompat.setTooltipText(imgFaultReport, getString(R.string.fault_report))
val timeRetrieved = charger?.timeRetrieved
val showTimeRetrieved = timeRetrieved != null &&
Duration.between(timeRetrieved, Instant.now()) > Duration.ofHours(1)
txtTimeRetrieved.visibility = goneUnless(showTimeRetrieved)
val shownTimeRetrieved = if (showTimeRetrieved) timeRetrieved else null
if (shownTimeRetrieved != null) {
txtTimeRetrieved.text = getString(
R.string.data_retrieved_at,
DateUtils.getRelativeTimeSpanString(
shownTimeRetrieved.toEpochMilli(),
Instant.now().toEpochMilli(),
0
)
)
} else {
txtTimeRetrieved.text = null
}
val license = charger?.license
txtLicense.visibility = goneUnless(!license.isNullOrBlank())
txtLicense.text = license
}
}
private fun removeSearchFocus() {
@@ -657,7 +888,6 @@ class MapFragment : Fragment(), OnMapReadyCallback, MenuProvider {
mapBottomPadding + min(bottomSheetBehavior.peekHeight, height)
)
}
println(slideOffset)
if (bottomSheetBehavior.state != STATE_HIDDEN) {
binding.navBarScrim.visibility = View.VISIBLE
binding.navBarScrim.translationY =
@@ -698,6 +928,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MenuProvider {
removeSearchFocus()
binding.fabDirections.show()
detailAppBarBehavior.setToolbarTitle(it.name)
updateShareItemVisibility()
updateFavoriteToggle()
markerManager?.highlighedCharger = it
markerManager?.animateBounce(it)
@@ -707,6 +938,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MenuProvider {
}
}
vm.chargepoints.observe(viewLifecycleOwner, Observer { res ->
updateChargepointsUi(res)
val chargepoints = res.data
if (chargepoints != null) {
markerManager?.chargepoints = chargepoints
@@ -744,21 +976,72 @@ class MapFragment : Fragment(), OnMapReadyCallback, MenuProvider {
vm.searchResult.observe(viewLifecycleOwner) { place ->
displaySearchResult(place, moveCamera = true)
}
vm.charger.observe(viewLifecycleOwner) {
updateGallery(it?.data?.photos)
renderDetailView()
}
vm.availability.observe(viewLifecycleOwner) {
renderDetailView()
}
vm.filteredAvailability.observe(viewLifecycleOwner) {
renderDetailView()
}
vm.predictionData.observe(viewLifecycleOwner) {
renderDetailView()
}
vm.chargeCardMap.observe(viewLifecycleOwner) {
renderDetailView()
}
vm.filteredChargeCards.observe(viewLifecycleOwner) {
renderDetailView()
}
vm.chargerDistance.observe(viewLifecycleOwner) {
renderDetailView()
}
vm.bottomSheetExpanded.observe(viewLifecycleOwner) {
renderDetailView()
}
vm.teslaPricing.observe(viewLifecycleOwner) {
renderDetailView()
}
vm.myLocationEnabled.observe(viewLifecycleOwner) {
isFabActive(binding.fabLocate, it == true)
}
vm.bottomSheetState.observe(viewLifecycleOwner) {
binding.navBarScrim.visibility = invisibleUnless(it == STATE_COLLAPSED)
}
vm.layersMenuOpen.observe(viewLifecycleOwner) { open ->
HideOnScrollFabBehavior.from(binding.fabLayers)?.hidden = open
binding.fabLayers.visibility = if (open) View.INVISIBLE else View.VISIBLE
binding.layersSheet.visibility = if (open) View.VISIBLE else View.INVISIBLE
binding.fabLayers.visibility = invisibleUnless(!open)
binding.layersSheet.visibility = invisibleUnless(open)
updateBackPressedCallback()
}
vm.mapType.observe(viewLifecycleOwner) {
map?.setMapType(it)
when (it) {
AnyMap.Type.NORMAL -> binding.layers.rbStandard.isChecked = true
AnyMap.Type.HYBRID -> binding.layers.rbSatellite.isChecked = true
AnyMap.Type.TERRAIN -> binding.layers.rbTerrain.isChecked = true
else -> {
binding.layers.rbStandard.isChecked = false
binding.layers.rbSatellite.isChecked = false
binding.layers.rbTerrain.isChecked = false
}
}
}
vm.mapTrafficEnabled.observe(viewLifecycleOwner) {
map?.setTrafficEnabled(it)
if (binding.layers.cbTraffic.isChecked != it) {
binding.layers.cbTraffic.isChecked = it
}
}
vm.mapTrafficSupported.observe(viewLifecycleOwner) { supported ->
val visibility = goneUnless(supported)
binding.layers.textView23.visibility = visibility
binding.layers.cbTraffic.visibility = visibility
}
vm.selectedChargepoint.observe(viewLifecycleOwner) {
binding.detailView.connectorDetailsCard.visibility =
if (it != null) View.VISIBLE else View.INVISIBLE
binding.detailView.connectorDetailsCard.visibility = invisibleUnless(it != null)
if (it != null) {
detailsDialog.setData(it, vm.availability.value?.data)
}
@@ -808,6 +1091,12 @@ class MapFragment : Fragment(), OnMapReadyCallback, MenuProvider {
}
}
private fun updateShareItemVisibility() {
val charger = vm.chargerSparse.value ?: return
val shareItem = binding.detailAppBar.toolbar.menu.findItem(R.id.menu_share)
shareItem.isVisible = charger.url != null
}
private fun setupAdapters() {
var viewer: StfalconImageViewer<ChargerPhoto>? = null
val galleryClickListener = object : GalleryAdapter.ItemClickListener {
@@ -871,11 +1160,13 @@ class MapFragment : Fragment(), OnMapReadyCallback, MenuProvider {
(activity as? MapsActivity)?.showLocation(charger, binding.root)
}
R.drawable.ic_fault_report -> {
(activity as? MapsActivity)?.openUrl(
charger.url,
binding.root,
true
)
if (charger.url != null) {
(activity as? MapsActivity)?.openUrl(
charger.url,
binding.root,
true
)
}
}
R.drawable.ic_payment -> {
@@ -1231,7 +1522,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MenuProvider {
if (filterBadge != null) {
// set up badge showing number of active filters
vm.filtersCount.observe(viewLifecycleOwner) {
filterBadge.visibility = if (it > 0) View.VISIBLE else View.GONE
filterBadge.visibility = goneUnless(it > 0)
filterBadge.text = it.toString()
}
}
@@ -1433,4 +1724,4 @@ class MapFragment : Fragment(), OnMapReadyCallback, MenuProvider {
(split-screen mode) and then have references to a destroyed fragment. */
popupMenu?.dismiss()
}
}
}
@@ -4,14 +4,20 @@ import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.CheckBox
import android.widget.CompoundButton
import androidx.core.widget.doAfterTextChanged
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.viewbinding.ViewBinding
import net.vonforst.evmap.R
import net.vonforst.evmap.adapter.DataBindingAdapter
import net.vonforst.evmap.adapter.Equatable
import net.vonforst.evmap.databinding.DialogMultiSelectBinding
import net.vonforst.evmap.databinding.DialogMultiSelectItemBinding
import net.vonforst.evmap.ui.MaterialDialogFragment
import java.util.*
import java.util.HashMap
import java.util.HashSet
import java.util.Locale
class MultiSelectDialog : MaterialDialogFragment() {
companion object {
@@ -121,8 +127,26 @@ private fun search(
}
}
class Adapter() : DataBindingAdapter<MultiSelectItem>({ it.key }) {
class Adapter : DataBindingAdapter<MultiSelectItem>({ it.key }) {
override fun getItemViewType(position: Int) = R.layout.dialog_multi_select_item
override fun createBinding(
inflater: LayoutInflater,
parent: ViewGroup,
viewType: Int
): ViewBinding = DialogMultiSelectItemBinding.inflate(inflater, parent, false)
override fun bind(holder: ViewHolder, item: MultiSelectItem) {
super.bind(holder, item)
val binding = holder.binding as DialogMultiSelectItemBinding
val checkBox = binding.root.findViewById<CheckBox>(android.R.id.text1)
checkBox.text = item.name
checkBox.setOnCheckedChangeListener(null)
checkBox.isChecked = item.selected
checkBox.setOnCheckedChangeListener { _: CompoundButton, isChecked: Boolean ->
item.selected = isChecked
}
}
}
data class MultiSelectItem(val key: String, val name: CharSequence, var selected: Boolean) :
@@ -11,6 +11,7 @@ import android.view.View
import android.view.ViewGroup
import android.view.animation.DecelerateInterpolator
import android.widget.ImageView
import android.widget.ScrollView
import androidx.core.content.ContextCompat
import androidx.core.text.HtmlCompat
import androidx.core.text.method.LinkMovementMethodCompat
@@ -27,6 +28,7 @@ import net.vonforst.evmap.databinding.FragmentOnboardingWelcomeBinding
import net.vonforst.evmap.model.FILTERS_DISABLED
import net.vonforst.evmap.navigation.safeNavigate
import net.vonforst.evmap.storage.PreferenceDataSource
import net.vonforst.evmap.ui.replaceUrlSpansWithCustom
import net.vonforst.evmap.waitForLayout
class OnboardingFragment : Fragment() {
@@ -220,6 +222,8 @@ class DataSourceSelectFragment : OnboardingPageFragment() {
binding.rgDataSource.textView28,
binding.rgDataSource.rbOpenStreetMap,
binding.rgDataSource.textView29,
binding.rgDataSource.rbNobil,
binding.rgDataSource.textView30,
binding.dataSourceHint,
binding.cbAcceptPrivacy
)
@@ -235,19 +239,21 @@ class DataSourceSelectFragment : OnboardingPageFragment() {
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
binding.cbAcceptPrivacy.text =
val text =
HtmlCompat.fromHtml(
getString(
R.string.accept_privacy,
getString(R.string.privacy_link)
), HtmlCompat.FROM_HTML_MODE_LEGACY
)
).replaceUrlSpansWithCustom()
binding.cbAcceptPrivacy.text = text
binding.cbAcceptPrivacy.linksClickable = true
binding.cbAcceptPrivacy.movementMethod = LinkMovementMethodCompat.getInstance()
binding.btnGetStarted.visibility = View.INVISIBLE
for (rb in listOf(
binding.rgDataSource.rbGoingElectric,
binding.rgDataSource.rbNobil,
binding.rgDataSource.rbOpenChargeMap,
binding.rgDataSource.rbOpenStreetMap
)) {
@@ -257,12 +263,16 @@ class DataSourceSelectFragment : OnboardingPageFragment() {
ObjectAnimator.ofFloat(binding.btnGetStarted, "alpha", 0f, 1f).apply {
interpolator = DecelerateInterpolator()
}.start()
(binding.root as? ScrollView)?.let {
it.smoothScrollBy(0, it.height)
}
}
}
}
if (prefs.dataSourceSet) {
when (prefs.dataSource) {
"goingelectric" -> binding.rgDataSource.rbGoingElectric.isChecked = true
"nobil" -> binding.rgDataSource.rbNobil.isChecked = true
"openchargemap" -> binding.rgDataSource.rbOpenChargeMap.isChecked = true
"openstreetmap" -> binding.rgDataSource.rbOpenStreetMap.isChecked = true
}
@@ -281,6 +291,8 @@ class DataSourceSelectFragment : OnboardingPageFragment() {
val result = if (binding.rgDataSource.rbGoingElectric.isChecked) {
"goingelectric"
} else if (binding.rgDataSource.rbNobil.isChecked) {
"nobil"
} else if (binding.rgDataSource.rbOpenChargeMap.isChecked) {
"openchargemap"
} else if (binding.rgDataSource.rbOpenStreetMap.isChecked) {
@@ -1,43 +0,0 @@
package net.vonforst.evmap.fragment.preference
import android.content.SharedPreferences
import android.os.Bundle
import android.view.View
import net.vonforst.evmap.R
import net.vonforst.evmap.ui.RangeSliderPreference
import java.text.NumberFormat
class AndroidAutoSettingsFragment : BaseSettingsFragment() {
override val isTopLevel = false
private lateinit var rangePreference: RangeSliderPreference
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
rangePreference = findPreference("chargeprice_battery_range_android_auto")!!
rangePreference.labelFormatter = { value: Float ->
val fmt = NumberFormat.getNumberInstance()
fmt.maximumFractionDigits = 0
fmt.format(value.toDouble()) + "%"
}
updateRangePreferenceSummary()
}
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
setPreferencesFromResource(R.xml.settings_android_auto, rootKey)
}
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String?) {
when (key) {
"chargeprice_battery_range_android_auto_min", "chargeprice_battery_range_android_auto_max" -> {
updateRangePreferenceSummary()
}
}
}
private fun updateRangePreferenceSummary() {
val range = prefs.chargepriceBatteryRangeAndroidAuto
rangePreference.summary = getString(R.string.chargeprice_battery_range, range[0], range[1])
}
}
@@ -1,147 +0,0 @@
package net.vonforst.evmap.fragment.preference
import android.content.SharedPreferences
import android.os.Bundle
import android.text.SpannableStringBuilder
import android.text.Spanned
import android.text.style.RelativeSizeSpan
import android.view.View
import androidx.fragment.app.viewModels
import androidx.preference.CheckBoxPreference
import androidx.preference.ListPreference
import net.vonforst.evmap.R
import net.vonforst.evmap.currencyDisplayName
import net.vonforst.evmap.ui.MultiSelectDialogPreference
import net.vonforst.evmap.viewmodel.SettingsViewModel
import net.vonforst.evmap.viewmodel.viewModelFactory
class ChargepriceSettingsFragment : BaseSettingsFragment() {
override val isTopLevel = false
private val vm: SettingsViewModel by viewModels(factoryProducer = {
viewModelFactory {
SettingsViewModel(
requireActivity().application,
getString(R.string.chargeprice_key),
getString(R.string.chargeprice_api_url)
)
}
})
private lateinit var myVehiclePreference: MultiSelectDialogPreference
private lateinit var myTariffsPreference: MultiSelectDialogPreference
private lateinit var nativeIntegrationPreference: CheckBoxPreference
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
nativeIntegrationPreference = findPreference("chargeprice_native_integration")!!
myVehiclePreference = findPreference("chargeprice_my_vehicle")!!
myVehiclePreference.isEnabled = false
vm.vehicles.observe(viewLifecycleOwner) { res ->
res.data?.let { cars ->
val sortedCars = cars.sortedBy { it.brand }
myVehiclePreference.entryValues = sortedCars.map { it.id }.toTypedArray()
myVehiclePreference.entries = sortedCars.map {
SpannableStringBuilder().apply {
appendLine("${it.brand} ${it.name}")
append(
it.formatSpecs(),
RelativeSizeSpan(0.86f),
Spanned.SPAN_INCLUSIVE_EXCLUSIVE
)
}
}.toTypedArray()
myVehiclePreference.isEnabled = nativeIntegrationPreference.isChecked
updateMyVehiclesSummary()
}
}
myTariffsPreference = findPreference("chargeprice_my_tariffs")!!
myTariffsPreference.isEnabled = false
vm.tariffs.observe(viewLifecycleOwner) { res ->
res.data?.let { tariffs ->
myTariffsPreference.entryValues = tariffs.map { it.id }.toTypedArray()
myTariffsPreference.entries = tariffs.map {
if (!it.name.lowercase().startsWith(it.provider.lowercase())) {
"${it.provider} ${it.name}"
} else {
it.name
}
}.toTypedArray()
myTariffsPreference.isEnabled = nativeIntegrationPreference.isChecked
updateMyTariffsSummary()
}
}
updateNativeIntegrationState()
val currencyPreference = findPreference<ListPreference>("chargeprice_currency")!!
currencyPreference.entries = currencyPreference.entryValues.map {
currencyDisplayName(it.toString()).replaceFirstChar { it.uppercase() }
}.toTypedArray()
}
private fun updateNativeIntegrationState() {
for (i in 0 until preferenceScreen.preferenceCount) {
val pref = preferenceScreen.getPreference(i)
if (pref == nativeIntegrationPreference) {
continue
} else if (pref == myTariffsPreference) {
pref.isEnabled =
nativeIntegrationPreference.isChecked && vm.tariffs.value?.data != null
} else if (pref == myVehiclePreference) {
pref.isEnabled =
nativeIntegrationPreference.isChecked && vm.tariffs.value?.data != null
} else {
pref.isEnabled = nativeIntegrationPreference.isChecked
}
}
}
private fun updateMyTariffsSummary() {
myTariffsPreference.summary =
if (prefs.chargepriceMyTariffsAll) {
getString(R.string.chargeprice_all_tariffs_selected)
} else {
val n = prefs.chargepriceMyTariffs?.size ?: 0
requireContext().resources
.getQuantityString(
R.plurals.chargeprice_some_tariffs_selected,
n,
n
) + "\n" + requireContext().resources
.getQuantityString(R.plurals.pref_my_tariffs_summary, n)
}
}
private fun updateMyVehiclesSummary() {
vm.vehicles.value?.data?.let { cars ->
val vehicles = cars.filter { it.id in prefs.chargepriceMyVehicles }
val summary = vehicles.joinToString(", ") {
"${it.brand} ${it.name}"
}
myVehiclePreference.summary = summary
}
}
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
setPreferencesFromResource(R.xml.settings_chargeprice, rootKey)
}
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String?) {
when (key) {
"chargeprice_my_vehicle" -> {
updateMyVehiclesSummary()
}
"chargeprice_my_tariffs" -> {
updateMyTariffsSummary()
}
"chargeprice_native_integration" -> {
updateNativeIntegrationState()
}
}
}
}
@@ -1,10 +1,10 @@
package net.vonforst.evmap.fragment.preference
import android.content.SharedPreferences
import android.net.Uri
import android.os.Bundle
import android.view.View
import android.widget.TextView
import androidx.core.net.toUri
import androidx.fragment.app.setFragmentResultListener
import androidx.fragment.app.viewModels
import androidx.lifecycle.lifecycleScope
@@ -24,7 +24,6 @@ import net.vonforst.evmap.viewmodel.viewModelFactory
import okhttp3.OkHttpClient
import okio.IOException
import java.time.Instant
import androidx.core.net.toUri
class DataSettingsFragment : BaseSettingsFragment() {
override val isTopLevel = false
@@ -33,8 +32,6 @@ class DataSettingsFragment : BaseSettingsFragment() {
viewModelFactory {
SettingsViewModel(
requireActivity().application,
getString(R.string.chargeprice_key),
getString(R.string.chargeprice_api_url)
)
}
})
@@ -1,15 +1,18 @@
package net.vonforst.evmap.fragment.preference
import android.content.ActivityNotFoundException
import android.content.Intent
import android.content.SharedPreferences
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.provider.Settings
import android.util.Log
import androidx.core.net.toUri
import androidx.preference.CheckBoxPreference
import androidx.preference.ListPreference
import androidx.preference.Preference
import com.github.erfansn.localeconfigx.configuredLocales
import com.google.android.material.snackbar.Snackbar
import net.vonforst.evmap.R
import net.vonforst.evmap.isAppInstalled
import net.vonforst.evmap.ui.getAppLocale
@@ -83,9 +86,23 @@ class UiSettingsFragment : BaseSettingsFragment() {
val context = context ?: return false
val intent = Intent(
Settings.ACTION_APP_OPEN_BY_DEFAULT_SETTINGS,
Uri.parse("package:${context.packageName}")
"package:${context.packageName}".toUri()
)
context.startActivity(intent)
try {
context.startActivity(intent)
} catch (_: ActivityNotFoundException) {
view?.let { v ->
Snackbar.make(
v,
R.string.system_setting_not_supported,
Snackbar.LENGTH_SHORT
).show()
}
Log.e(
"UiSettingsFragment",
"Settings.ACTION_APP_OPEN_BY_DEFAULT_SETTINGS activity not found"
)
}
}
return true
}
@@ -37,6 +37,7 @@ sealed class ChargepointListItem
* @param address The charge location address
* @param chargepoints List of chargepoints at this location
* @param network The charging network (Mobility Service Provider, MSP)
* @param dataSourceUrl A link to the data source website
* @param url A link to this charging site
* @param editUrl A link to a website where this charging site can be edited
* @param faultReport Set this if the charging site is reported to be out of service
@@ -49,6 +50,7 @@ sealed class ChargepointListItem
* @param locationDescription Directions on how to find the charger (e.g. "In the parking garage on level 5")
* @param photos List of photos of this charging site
* @param chargecards List of charge cards accepted here
* @param accessibility Specifies who may use this charge location
* @param openinghours List of times when this charging site can be accessed / used
* @param cost The cost for charging and/or parking
* @param license How the data about this chargepoint is licensed
@@ -67,7 +69,8 @@ data class ChargeLocation(
@Embedded val address: Address?,
val chargepoints: List<Chargepoint>,
val network: String?,
val url: String, // URL of this charger at the data source
val dataSourceUrl: String, // URL to the data source
val url: String?, // URL of this charger at the data source
val editUrl: String?, // URL to edit this charger at the data source
@Embedded(prefix = "fault_report_") val faultReport: FaultReport?,
val verified: Boolean,
@@ -79,6 +82,7 @@ data class ChargeLocation(
val locationDescription: String?,
val photos: List<ChargerPhoto>?,
val chargecards: List<ChargeCardId>?,
val accessibility: String?,
@Embedded val openinghours: OpeningHours?,
@Embedded val cost: Cost?,
val license: String?,
@@ -135,9 +139,13 @@ data class ChargeLocation(
val filtered = chargepoints
.filter { it.type == variant.type && it.power == variant.power }
val count = filtered.sumOf { it.count }
val mergedEvseIds = filtered.map { if (it.evseIds == null) List(it.count) {null} else it.evseIds }.flatten()
val mergedEvseUIds = filtered.map { if (it.evseUIds == null) List(it.count) {null} else it.evseUIds }.flatten()
Chargepoint(variant.type, variant.power, count,
filtered.map { it.current }.distinct().singleOrNull(),
filtered.map { it.voltage }.distinct().singleOrNull()
filtered.map { it.voltage }.distinct().singleOrNull(),
if (mergedEvseIds.all { it == null }) null else mergedEvseIds,
if (mergedEvseUIds.all { it == null }) null else mergedEvseUIds
)
}
}
@@ -417,7 +425,11 @@ data class Chargepoint(
// Max voltage in V (or null if unknown).
// note that for DC chargers: current * voltage may be larger than power
// (each of the three can be separately limited)
val voltage: Double? = null
val voltage: Double? = null,
// Electric Vehicle Supply Equipment Ids for this Chargepoint's plugs/sockets
val evseIds: List<String?>? = null,
// Electric Vehicle Supply Equipment Unique Ids for this Chargepoint's plugs/sockets
val evseUIds: List<String?>? = null
) : Equatable, Parcelable {
fun hasKnownPower(): Boolean = power != null
fun hasKnownVoltageAndCurrent(): Boolean = voltage != null && current != null
@@ -458,6 +470,7 @@ data class Chargepoint(
const val CEE_BLAU = "CEE Blau"
const val CEE_ROT = "CEE Rot"
const val TESLA_ROADSTER_HPC = "Tesla HPC"
const val MCS = "Megawatt Charging System"
}
}
@@ -25,4 +25,4 @@ data class Favorite(
data class FavoriteWithDetail(
@Embedded val favorite: Favorite,
@Embedded val charger: ChargeLocation
)
)
@@ -1,6 +1,5 @@
package net.vonforst.evmap.model
import androidx.databinding.BaseObservable
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
@@ -46,7 +45,7 @@ data class SliderFilter(
override fun defaultValue() = SliderFilterValue(key, min)
}
sealed class FilterValue : BaseObservable(), Equatable {
sealed class FilterValue : Equatable {
abstract val key: String
var dataSource: String = ""
var profile: Long = FILTERS_CUSTOM
@@ -35,7 +35,9 @@ class CustomNavigator(
val prefs = PreferenceDataSource(context)
val url = when (prefs.dataSource) {
"goingelectric" -> "https://www.goingelectric.de/stromtankstellen/new/"
"nobil" -> "http://nobil.no/api/chargerregistration/chargerregistration.php?action=register"
"openchargemap" -> "https://openchargemap.org/site/poi/add"
"openstreetmap" -> "https://www.openstreetmap.org/edit"
else -> throw IllegalArgumentException()
}
launchCustomTab(url)
@@ -3,11 +3,6 @@ package net.vonforst.evmap.storage
import android.util.Log
import androidx.lifecycle.LiveData
import androidx.lifecycle.MediatorLiveData
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map
import net.vonforst.evmap.model.ChargeLocation
import net.vonforst.evmap.viewmodel.Resource
import net.vonforst.evmap.viewmodel.Status
@@ -146,44 +141,4 @@ class PreferCacheLiveData(
}
}
}
}
/**
* Flow-based implementation that allows loading data both from a cache and an API.
*
* It first tries loading from cache, and if the result is newer than `cacheSoftLimit` it does not
* reload from the API.
*/
fun preferCacheFlow(
cache: Flow<ChargeLocation?>,
api: Flow<Resource<ChargeLocation>>,
cacheSoftLimit: Duration
): Flow<Resource<ChargeLocation>> = flow {
emit(Resource.loading(null)) // initial state
val cacheRes = cache.firstOrNull() // read cache once
if (cacheRes != null) {
if (cacheRes.isDetailed && cacheRes.timeRetrieved > Instant.now() - cacheSoftLimit) {
emit(Resource.success(cacheRes))
return@flow
} else {
emit(Resource.loading(cacheRes))
emitAll(api.map { apiRes ->
when (apiRes.status) {
Status.SUCCESS -> apiRes
Status.ERROR -> Resource.error(apiRes.message, cacheRes)
Status.LOADING -> Resource.loading(cacheRes)
}
})
}
} else {
// No cache → straight to API
emitAll(api.map { apiRes ->
when (apiRes.status) {
Status.SUCCESS -> apiRes
Status.ERROR -> Resource.error(apiRes.message, null)
Status.LOADING -> Resource.loading(null)
}
})
}
}
@@ -14,14 +14,7 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.shareIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import net.vonforst.evmap.api.ChargepointApi
@@ -30,6 +23,7 @@ import net.vonforst.evmap.api.FiltersSQLQuery
import net.vonforst.evmap.api.StringProvider
import net.vonforst.evmap.api.goingelectric.GEReferenceData
import net.vonforst.evmap.api.goingelectric.GoingElectricApiWrapper
import net.vonforst.evmap.api.nobil.NobilApiWrapper
import net.vonforst.evmap.api.openchargemap.OpenChargeMapApiWrapper
import net.vonforst.evmap.api.openstreetmap.OSMReferenceData
import net.vonforst.evmap.api.openstreetmap.OpenStreetMapApiWrapper
@@ -41,9 +35,9 @@ import net.vonforst.evmap.utils.splitAtAntimeridian
import net.vonforst.evmap.viewmodel.Resource
import net.vonforst.evmap.viewmodel.Status
import net.vonforst.evmap.viewmodel.await
import net.vonforst.evmap.viewmodel.singleSwitchMap
import java.time.Duration
import java.time.Instant
import kotlin.time.TimeSource
const val CLUSTER_MAX_ZOOM_LEVEL = 11f
@@ -75,6 +69,12 @@ abstract class ChargeLocationsDao {
@Query("DELETE FROM chargelocation WHERE NOT EXISTS (SELECT 1 FROM favorite WHERE favorite.chargerId = chargelocation.id)")
abstract suspend fun deleteAllIfNotFavorite()
@Query("SELECT id FROM chargelocation WHERE dataSource == :dataSource")
abstract suspend fun getAllIds(dataSource: String): List<Long>
@Query("DELETE FROM chargelocation WHERE dataSource == :dataSource AND id IN (:chargerIds)")
abstract suspend fun deleteById(dataSource: String, chargerIds: List<Long>)
@Query("SELECT * FROM chargelocation WHERE dataSource == :dataSource AND id == :id AND isDetailed == 1 AND timeRetrieved > :after")
abstract suspend fun getChargeLocationById(
id: Long,
@@ -90,7 +90,7 @@ abstract class ChargeLocationsDao {
): List<ChargeLocation>
@SkipQueryVerification
@Query("SELECT * FROM chargelocation WHERE dataSource == :dataSource AND timeRetrieved > :after AND ROWID IN (SELECT ROWID FROM SpatialIndex WHERE f_table_name = 'ChargeLocation' AND search_frame = BuildMbr(:lng1, :lat1, :lng2, :lat2))")
@Query("SELECT * FROM chargelocation WHERE dataSource == :dataSource AND timeRetrieved > :after AND ROWID IN (SELECT ROWID FROM SpatialIndex WHERE f_table_name = 'ChargeLocation' AND f_geometry_column = 'coordinates' AND search_frame = BuildMbr(:lng1, :lat1, :lng2, :lat2))")
abstract suspend fun getChargeLocationsInBounds(
lat1: Double,
lat2: Double,
@@ -101,7 +101,7 @@ abstract class ChargeLocationsDao {
): List<ChargeLocation>
@SkipQueryVerification
@Query("SELECT * FROM chargelocation WHERE dataSource == :dataSource AND PtDistWithin(coordinates, MakePoint(:lng, :lat, 4326), :radius) AND timeRetrieved > :after AND ROWID IN (SELECT ROWID FROM SpatialIndex WHERE f_table_name = 'ChargeLocation' AND search_frame = BuildCircleMbr(:lng, :lat, :radius)) ORDER BY Distance(coordinates, MakePoint(:lng, :lat, 4326))")
@Query("SELECT * FROM chargelocation WHERE dataSource == :dataSource AND PtDistWithin(coordinates, MakePoint(:lng, :lat, 4326), :radius) AND timeRetrieved > :after AND ROWID IN (SELECT ROWID FROM SpatialIndex WHERE f_table_name = 'ChargeLocation' AND f_geometry_column = 'coordinates' AND search_frame = BuildCircleMbr(:lng, :lat, :radius)) ORDER BY Distance(coordinates, MakePoint(:lng, :lat, 4326), 0)")
abstract suspend fun getChargeLocationsRadius(
lat: Double,
lng: Double,
@@ -177,9 +177,10 @@ private const val TAG = "ChargeLocationsDao"
* and clustering functionality.
*/
class ChargeLocationsRepository(
private val api: ChargepointApi<ReferenceData>, private val scope: CoroutineScope,
api: ChargepointApi<ReferenceData>, private val scope: CoroutineScope,
private val db: AppDatabase, private val prefs: PreferenceDataSource
) {
val api = MutableLiveData<ChargepointApi<ReferenceData>>().apply { value = api }
// if zoom level is below this value, server-side clustering will be used (if the API provides it)
private val serverSideClusteringThreshold = 9f
@@ -188,33 +189,39 @@ class ChargeLocationsRepository(
// if cached data is available and more recent than this duration, API will not be queried
private val cacheSoftLimit = Duration.ofDays(1)
val referenceData = when (api) {
is GoingElectricApiWrapper -> {
GEReferenceDataRepository(
api,
scope,
db.geReferenceDataDao(),
prefs
).getReferenceData()
}
val referenceData = this.api.switchMap { api ->
when (api) {
is GoingElectricApiWrapper -> {
GEReferenceDataRepository(
api,
scope,
db.geReferenceDataDao(),
prefs
).getReferenceData()
}
is OpenChargeMapApiWrapper -> {
OCMReferenceDataRepository(
api,
scope,
db.ocmReferenceDataDao(),
prefs
).getReferenceData()
}
is NobilApiWrapper -> {
NobilReferenceDataRepository(scope, prefs).getReferenceData()
}
is OpenStreetMapApiWrapper -> {
OSMReferenceDataRepository(db.osmReferenceDataDao()).getReferenceData()
}
is OpenChargeMapApiWrapper -> {
OCMReferenceDataRepository(
api,
scope,
db.ocmReferenceDataDao(),
prefs
).getReferenceData()
}
else -> {
throw RuntimeException("no reference data implemented")
is OpenStreetMapApiWrapper -> {
OSMReferenceDataRepository(db.osmReferenceDataDao()).getReferenceData()
}
else -> {
throw RuntimeException("no reference data implemented")
}
}
}.shareIn(scope, SharingStarted.Lazily, 1)
}
private val chargeLocationsDao = db.chargeLocationsDao()
private val savedRegionDao = db.savedRegionDao()
@@ -225,36 +232,41 @@ class ChargeLocationsRepository(
bounds: LatLngBounds,
zoom: Float,
filters: FilterValues?,
overrideCache: Boolean = false,
): Flow<List<ChargepointListItem>> {
overrideCache: Boolean = false
): LiveData<Resource<List<ChargepointListItem>>> {
if (bounds.crossesAntimeridian()) {
val (a, b) = bounds.splitAtAntimeridian()
val flowA = getChargepoints(a, zoom, filters, overrideCache)
val flowB = getChargepoints(b, zoom, filters, overrideCache)
return flowA.combine(flowB) { a, b -> a + b }
val liveDataA = getChargepoints(a, zoom, filters, overrideCache)
val liveDataB = getChargepoints(b, zoom, filters, overrideCache)
return combineLiveData(liveDataA, liveDataB)
}
val dbResult = flow {
val t1 = TimeSource.Monotonic.markNow()
val result = if (filters.isNullOrEmpty()) {
chargeLocationsDao.getChargeLocationsClustered(
bounds.southwest.latitude,
bounds.northeast.latitude,
bounds.southwest.longitude,
bounds.northeast.longitude,
api.id,
cacheLimitDate(api),
zoom
val api = api.value!!
val t1 = System.currentTimeMillis()
val dbResult = if (filters.isNullOrEmpty()) {
liveData {
emit(
Resource.success(
chargeLocationsDao.getChargeLocationsClustered(
bounds.southwest.latitude,
bounds.northeast.latitude,
bounds.southwest.longitude,
bounds.northeast.longitude,
api.id,
cacheLimitDate(api),
zoom
)
)
)
} else {
queryWithFiltersClustered(api, filters, bounds, zoom)
}
val t2 = TimeSource.Monotonic.markNow()
} else {
queryWithFiltersClustered(api, filters, bounds, zoom)
}.map {
val t2 = System.currentTimeMillis()
Log.d(TAG, "DB loading time: ${t2 - t1}")
Log.d(TAG, "number of chargers: ${result.size}")
emit(result)
Log.d(TAG, "number of chargers: ${it.data?.size}")
it
}
val filtersSerialized =
filters?.filter { it.value != it.filter.defaultValue() }?.takeIf { it.isNotEmpty() }
?.serialize()
@@ -271,8 +283,8 @@ class ChargeLocationsRepository(
)
val useClustering = shouldUseServerSideClustering(zoom)
if (api.supportsOnlineQueries) {
val apiResult = flow {
val refData = referenceData.first()
val apiResult = liveData {
val refData = referenceData.await()
val time = Instant.now()
val result = api.getChargepoints(refData, bounds, zoom, useClustering, filters)
emit(applyLocalClustering(result, zoom))
@@ -327,11 +339,39 @@ class ChargeLocationsRepository(
}
}
private fun combineLiveData(
liveDataA: LiveData<Resource<List<ChargepointListItem>>>,
liveDataB: LiveData<Resource<List<ChargepointListItem>>>
) = MediatorLiveData<Resource<List<ChargepointListItem>>>().apply {
listOf(liveDataA, liveDataB).forEach {
addSource(it) {
val valA = liveDataA.value
val valB = liveDataB.value
val combinedList = if (valA?.data != null && valB?.data != null) {
valA.data + valB.data
} else if (valA?.data != null) {
valA.data
} else if (valB?.data != null) {
valB.data
} else null
if (valA?.status == Status.SUCCESS && valB?.status == Status.SUCCESS) {
Resource.success(combinedList)
} else if (valA?.status == Status.ERROR || valB?.status == Status.ERROR) {
Resource.error(valA?.message ?: valB?.message, combinedList)
} else {
Resource.loading(combinedList)
}
}
}
}
fun getChargepointsRadius(
location: LatLng,
radius: Int,
filters: FilterValues?
): LiveData<Resource<List<ChargeLocation>>> {
val api = api.value!!
val radiusMeters = radius.toDouble() * 1000
val dbResult = if (filters.isNullOrEmpty()) {
liveData {
@@ -365,7 +405,7 @@ class ChargeLocationsRepository(
)
if (api.supportsOnlineQueries) {
val apiResult = liveData {
val refData = referenceData.first()
val refData = referenceData.await()
val time = Instant.now()
val result =
api.getChargepointsRadius(
@@ -426,7 +466,7 @@ class ChargeLocationsRepository(
}
}
private suspend fun applyLocalClustering(
private fun applyLocalClustering(
result: Resource<ChargepointList>,
zoom: Float
): Resource<List<ChargepointListItem>> {
@@ -443,7 +483,7 @@ class ChargeLocationsRepository(
return Resource(result.status, clustered, result.message)
}
private suspend fun applyLocalClustering(
private fun applyLocalClustering(
chargers: List<ChargeLocation>,
zoom: Float
): List<ChargepointListItem> {
@@ -453,7 +493,7 @@ class ChargeLocationsRepository(
val useClustering = chargers.size > 500 || zoom <= CLUSTER_MAX_ZOOM_LEVEL
val chargersClustered = if (useClustering) {
withContext(Dispatchers.Default) {
Dispatchers.Default.run {
cluster(chargers, zoom)
}
} else chargers
@@ -463,8 +503,9 @@ class ChargeLocationsRepository(
fun getChargepointDetail(
id: Long,
overrideCache: Boolean = false
): Flow<Resource<ChargeLocation>> {
val dbResult = flow {
): LiveData<Resource<ChargeLocation>> {
val api = api.value!!
val dbResult = liveData {
emit(
chargeLocationsDao.getChargeLocationById(
id,
@@ -474,8 +515,9 @@ class ChargeLocationsRepository(
)
}
if (api.supportsOnlineQueries) {
val apiResult = flow {
val refData = referenceData.first()
val apiResult = liveData {
emit(Resource.loading(null))
val refData = referenceData.await()
val result = api.getChargepointDetail(refData, id)
emit(result)
if (result.status == Status.SUCCESS) {
@@ -485,15 +527,22 @@ class ChargeLocationsRepository(
return if (overrideCache) {
apiResult
} else {
preferCacheFlow(dbResult, apiResult, cacheSoftLimit)
PreferCacheLiveData(dbResult, apiResult, cacheSoftLimit)
}
} else {
return dbResult.map { Resource.success(it) }
}
}
fun getFilters(sp: StringProvider) = referenceData.map {
api.getFilters(it, sp)
fun getFilters(sp: StringProvider) = MediatorLiveData<List<Filter<FilterValue>>>().apply {
addSource(referenceData) { refData: ReferenceData? ->
refData?.let { value = api.value!!.getFilters(refData, sp) }
}
}
suspend fun getFiltersAsync(sp: StringProvider): List<Filter<FilterValue>> {
val refData = referenceData.await()
return api.value!!.getFilters(refData, sp)
}
val chargeCardMap by lazy {
@@ -508,86 +557,112 @@ class ChargeLocationsRepository(
}
}
private suspend fun queryWithFilters(
private fun queryWithFilters(
api: ChargepointApi<ReferenceData>,
filters: FilterValues,
bounds: LatLngBounds
): List<ChargeLocation> {
): LiveData<Resource<List<ChargeLocation>>> {
return queryWithFilters(api, filters, boundsSpatialIndexQuery(bounds))
}
private suspend fun queryWithFiltersClustered(
private fun queryWithFiltersClustered(
api: ChargepointApi<ReferenceData>,
filters: FilterValues,
bounds: LatLngBounds,
zoom: Float
): List<ChargepointListItem> {
): LiveData<Resource<List<ChargepointListItem>>> {
return queryWithFiltersClustered(api, filters, boundsSpatialIndexQuery(bounds), zoom)
}
private suspend fun queryWithFilters(
private fun queryWithFilters(
api: ChargepointApi<ReferenceData>,
filters: FilterValues,
location: LatLng,
radius: Double
): List<ChargeLocation> {
): LiveData<Resource<List<ChargeLocation>>> {
val region =
radiusSpatialIndexQuery(location, radius)
val order =
"ORDER BY Distance(coordinates, MakePoint(${location.longitude}, ${location.latitude}, 4326))"
"ORDER BY Distance(coordinates, MakePoint(${location.longitude}, ${location.latitude}, 4326), 0)"
return queryWithFilters(api, filters, region, order)
}
private fun boundsSpatialIndexQuery(bounds: LatLngBounds) =
"ChargeLocation.ROWID IN (SELECT ROWID FROM SpatialIndex WHERE f_table_name = 'ChargeLocation' AND search_frame = BuildMbr(${bounds.southwest.longitude}, ${bounds.southwest.latitude}, ${bounds.northeast.longitude}, ${bounds.northeast.latitude}))"
"ChargeLocation.ROWID IN (SELECT ROWID FROM SpatialIndex WHERE f_table_name = 'ChargeLocation' AND f_geometry_column = 'coordinates' AND search_frame = BuildMbr(${bounds.southwest.longitude}, ${bounds.southwest.latitude}, ${bounds.northeast.longitude}, ${bounds.northeast.latitude}))"
private fun radiusSpatialIndexQuery(location: LatLng, radius: Double) =
"PtDistWithin(coordinates, MakePoint(${location.longitude}, ${location.latitude}, 4326), ${radius}) AND ChargeLocation.ROWID IN (SELECT ROWID FROM SpatialIndex WHERE f_table_name = 'ChargeLocation' AND search_frame = BuildCircleMbr(${location.longitude}, ${location.latitude}, $radius))"
"PtDistWithin(coordinates, MakePoint(${location.longitude}, ${location.latitude}, 4326), ${radius}) AND ChargeLocation.ROWID IN (SELECT ROWID FROM SpatialIndex WHERE f_table_name = 'ChargeLocation' AND f_geometry_column = 'coordinates' AND search_frame = BuildCircleMbr(${location.longitude}, ${location.latitude}, $radius))"
private suspend fun queryWithFilters(
private fun queryWithFilters(
api: ChargepointApi<ReferenceData>,
filters: FilterValues,
regionSql: String,
orderSql: String? = null
): List<ChargeLocation> {
val query = api.convertFiltersToSQL(filters, referenceData.first())
val after = cacheLimitDate(api)
val sql = buildFilteredQuery(query, regionSql, after, orderSql)
): LiveData<Resource<List<ChargeLocation>>> = referenceData.singleSwitchMap { refData ->
try {
val query = api.convertFiltersToSQL(filters, refData)
val after = cacheLimitDate(api)
val sql = buildFilteredQuery(query, regionSql, after, orderSql)
return chargeLocationsDao.getChargeLocationsCustom(
SimpleSQLiteQuery(
sql,
null
)
)
liveData {
emit(
Resource.success(
chargeLocationsDao.getChargeLocationsCustom(
SimpleSQLiteQuery(
sql,
null
)
)
)
)
}
} catch (e: NotImplementedError) {
MutableLiveData() // in this case we cannot get a DB result
}
}
private suspend fun queryWithFiltersClustered(
private fun queryWithFiltersClustered(
api: ChargepointApi<ReferenceData>,
filters: FilterValues,
regionSql: String,
zoom: Float,
orderSql: String? = null
): List<ChargepointListItem> = if (zoom > CLUSTER_MAX_ZOOM_LEVEL) {
queryWithFilters(api, filters, regionSql, orderSql).map { it }
} else {
val query = api.convertFiltersToSQL(filters, referenceData.first())
val after = cacheLimitDate(api)
val clusterPrecision = getClusterPrecision(zoom)
val sql = buildFilteredQuery(query, regionSql, after, orderSql, clusterPrecision)
): LiveData<Resource<List<ChargepointListItem>>> = referenceData.singleSwitchMap { refData ->
try {
if (zoom > CLUSTER_MAX_ZOOM_LEVEL) {
queryWithFilters(api, filters, regionSql, orderSql).map { it }
} else {
val query = api.convertFiltersToSQL(filters, refData)
val after = cacheLimitDate(api)
val clusterPrecision = getClusterPrecision(zoom)
val sql = buildFilteredQuery(query, regionSql, after, orderSql, clusterPrecision)
val clusters = chargeLocationsDao.getChargeLocationClustersCustom(
SimpleSQLiteQuery(
sql,
null
)
)
val singleChargers =
chargeLocationsDao.getChargeLocationsById(clusters.filter { it.clusterCount == 1 }
.map { it.ids }
.flatten(), prefs.dataSource, after)
clusters.filter { it.clusterCount > 1 }
.map { it.convert() } + singleChargers
liveData {
val clusters = chargeLocationsDao.getChargeLocationClustersCustom(
SimpleSQLiteQuery(
sql,
null
)
)
val singleChargers =
chargeLocationsDao.getChargeLocationsById(clusters.filter { it.clusterCount == 1 }
.map { it.ids }
.flatten(), prefs.dataSource, after)
emit(
Resource.success(
clusters.filter { it.clusterCount > 1 }
.map { it.convert() } + singleChargers
))
}
}
} catch (e: NotImplementedError) {
MutableLiveData(
Resource.error(
e.message,
null
)
) // in this case we cannot get a DB result
}
}
private fun buildFilteredQuery(
@@ -627,12 +702,14 @@ class ChargeLocationsRepository(
}.toString()
private suspend fun fullDownload() {
val api = api.value!!
if (!api.supportsFullDownload) return
val time = Instant.now()
val result = api.fullDownload()
try {
var insertJob: Job? = null
val idsToDelete = chargeLocationsDao.getAllIds(api.id).toMutableSet()
result.chargers.chunked(1024).forEach {
insertJob?.join()
insertJob = withContext(Dispatchers.IO) {
@@ -640,8 +717,12 @@ class ChargeLocationsRepository(
chargeLocationsDao.insert(*it.toTypedArray())
}
}
idsToDelete.removeAll(it.map { it.id })
fullDownloadProgress.value = result.progress
}
// delete chargers that have been removed
chargeLocationsDao.deleteById(api.id, idsToDelete.toList())
val region = Mbr(
-180.0,
-90.0,
@@ -15,7 +15,7 @@ class CleanupCacheWorker(appContext: Context, workerParams: WorkerParameters) :
val savedRegionDao = db.savedRegionDao()
val now = Instant.now()
val dataSources = listOf("openchargemap", "openstreetmap", "goingelectric")
val dataSources = listOf("openchargemap", "openstreetmap", "goingelectric", "nobil")
for (dataSource in dataSources) {
val api = createApi(dataSource, applicationContext)
val limit = now.minus(api.cacheLimit).toEpochMilli()
@@ -40,7 +40,7 @@ import net.vonforst.evmap.model.SliderFilterValue
OCMOperator::class,
OSMNetwork::class,
SavedRegion::class
], version = 24
], version = 29
)
@TypeConverters(Converters::class, GeometryConverters::class)
abstract class AppDatabase : RoomDatabase() {
@@ -84,12 +84,14 @@ abstract class AppDatabase : RoomDatabase() {
MIGRATION_7, MIGRATION_8, MIGRATION_9, MIGRATION_10, MIGRATION_11,
MIGRATION_12, MIGRATION_13, MIGRATION_14, MIGRATION_15, MIGRATION_16,
MIGRATION_17, MIGRATION_18, MIGRATION_19, MIGRATION_20, MIGRATION_21,
MIGRATION_22, MIGRATION_23, MIGRATION_24
MIGRATION_22, MIGRATION_23, MIGRATION_24, MIGRATION_25, MIGRATION_26,
MIGRATION_27, MIGRATION_28, MIGRATION_29
)
.addCallback(object : Callback() {
override fun onCreate(db: SupportSQLiteDatabase) {
// create default filter profile for each data source
db.execSQL("INSERT INTO `FilterProfile` (`dataSource`, `name`, `id`, `order`) VALUES ('goingelectric', 'FILTERS_CUSTOM', $FILTERS_CUSTOM, 0)")
db.execSQL("INSERT INTO `FilterProfile` (`dataSource`, `name`, `id`, `order`) VALUES ('nobil', 'FILTERS_CUSTOM', $FILTERS_CUSTOM, 0)")
db.execSQL("INSERT INTO `FilterProfile` (`dataSource`, `name`, `id`, `order`) VALUES ('openchargemap', 'FILTERS_CUSTOM', $FILTERS_CUSTOM, 0)")
db.execSQL("INSERT INTO `FilterProfile` (`dataSource`, `name`, `id`, `order`) VALUES ('openstreetmap', 'FILTERS_CUSTOM', $FILTERS_CUSTOM, 0)")
// initialize spatialite columns
@@ -501,6 +503,66 @@ abstract class AppDatabase : RoomDatabase() {
}
}
}
private val MIGRATION_25 = object : Migration(24, 25) {
override fun migrate(db: SupportSQLiteDatabase) {
// API nobil added
db.execSQL("INSERT INTO `FilterProfile` (`dataSource`, `name`, `id`, `order`) VALUES ('nobil', 'FILTERS_CUSTOM', $FILTERS_CUSTOM, 0)")
}
}
private val MIGRATION_26 = object : Migration(25, 26) {
override fun migrate(db: SupportSQLiteDatabase) {
// adding dataSourceUrl and making url optional
try {
db.beginTransaction()
db.execSQL(
"CREATE TABLE `ChargeLocationNew` (`id` INTEGER NOT NULL, `dataSource` TEXT NOT NULL, `name` TEXT NOT NULL, `coordinates` BLOB NOT NULL, `coordinatesProjected` BLOB NOT NULL, `chargepoints` TEXT NOT NULL, `network` TEXT, `dataSourceUrl` TEXT NOT NULL, `url` TEXT, `editUrl` TEXT, `verified` INTEGER NOT NULL, `barrierFree` INTEGER, `operator` TEXT, `generalInformation` TEXT, `amenities` TEXT, `locationDescription` TEXT, `photos` TEXT, `chargecards` TEXT, `license` TEXT, `timeRetrieved` INTEGER NOT NULL, `isDetailed` INTEGER NOT NULL, `city` TEXT, `country` TEXT, `postcode` TEXT, `street` TEXT, `fault_report_created` INTEGER, `fault_report_description` TEXT, `twentyfourSeven` INTEGER, `description` TEXT, `mostart` TEXT, `moend` TEXT, `tustart` TEXT, `tuend` TEXT, `westart` TEXT, `weend` TEXT, `thstart` TEXT, `thend` TEXT, `frstart` TEXT, `frend` TEXT, `sastart` TEXT, `saend` TEXT, `sustart` TEXT, `suend` TEXT, `hostart` TEXT, `hoend` TEXT, `freecharging` INTEGER, `freeparking` INTEGER, `descriptionShort` TEXT, `descriptionLong` TEXT, `chargepricecountry` TEXT, `chargepricenetwork` TEXT, `chargepriceplugTypes` TEXT, `networkUrl` TEXT, `chargerUrl` TEXT, PRIMARY KEY(`id`, `dataSource`))"
)
db.execSQL("INSERT INTO `ChargeLocationNew` SELECT `id`, `dataSource`, `name`, `coordinates`, `coordinatesProjected`, `chargepoints`, `network`, '', `url`, `editUrl`, `verified`, `barrierFree`, `operator`, `generalInformation`, `amenities`, `locationDescription`, `photos`, `chargecards`, `license`, `timeRetrieved`, `isDetailed`, `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`, `networkUrl`, `chargerUrl` FROM `ChargeLocation`")
db.execSQL("UPDATE ChargeLocationNew SET `dataSourceUrl` = 'https://www.goingelectric.de/' WHERE `dataSource` = 'goingelectric'")
db.execSQL("UPDATE ChargeLocationNew SET `dataSourceUrl` = 'https://openchargemap.org/' WHERE `dataSource` = 'openchargemap'")
db.execSQL("UPDATE ChargeLocationNew SET `dataSourceUrl` = 'https://www.openstreetmap.org/' WHERE `dataSource` = 'openstreetmap'")
db.query("SELECT DropGeoTable('ChargeLocation', FALSE)").moveToNext()
db.execSQL("ALTER TABLE `ChargeLocationNew` RENAME TO `ChargeLocation`")
db.query("SELECT RecoverGeometryColumn('ChargeLocation', 'coordinates', 4326, 'POINT', 'XY');")
.moveToNext()
db.query("SELECT CreateSpatialIndex('ChargeLocation', 'coordinates');")
.moveToNext()
db.query("SELECT RecoverGeometryColumn('ChargeLocation', 'coordinatesProjected', 3857, 'POINT', 'XY');")
.moveToNext()
db.query("SELECT CreateSpatialIndex('ChargeLocation', 'coordinatesProjected');")
.moveToNext()
db.setTransactionSuccessful()
} finally {
db.endTransaction()
}
}
}
private val MIGRATION_27 = object : Migration(26, 27) {
override fun migrate(db: SupportSQLiteDatabase) {
// adding accessibility to ChargeLocation
db.execSQL("ALTER TABLE `ChargeLocation` ADD `accessibility` TEXT")
}
}
private val MIGRATION_28 = object : Migration(27, 28) {
override fun migrate(db: SupportSQLiteDatabase) {
// Force nobil data refresh to fetch EVSE UId attributes needed for real-time data
db.execSQL("DELETE FROM SavedRegion WHERE `dataSource` = 'nobil'")
db.execSQL("DELETE FROM ChargeLocation WHERE `dataSource` = 'nobil'")
}
}
private val MIGRATION_29 = object : Migration(28, 29) {
override fun migrate(db: SupportSQLiteDatabase) {
// Force nobil data refresh to update MCS connectors
db.execSQL("DELETE FROM SavedRegion WHERE `dataSource` = 'nobil'")
db.execSQL("DELETE FROM ChargeLocation WHERE `dataSource` = 'nobil'")
}
}
}
/**
@@ -13,10 +13,10 @@ interface FavoritesDao {
@Delete
suspend fun delete(vararg favorites: Favorite)
@Query("SELECT * FROM favorite LEFT JOIN chargelocation ON favorite.chargerDataSource = chargelocation.dataSource AND favorite.chargerId = chargelocation.id")
@Query("SELECT * FROM favorite LEFT JOIN chargelocation ON favorite.chargerDataSource = chargelocation.dataSource AND favorite.chargerId = chargelocation.id WHERE chargelocation.id is not NULL")
fun getAllFavorites(): LiveData<List<FavoriteWithDetail>>
@Query("SELECT * FROM favorite LEFT JOIN chargelocation ON favorite.chargerDataSource = chargelocation.dataSource AND favorite.chargerId = chargelocation.id")
@Query("SELECT * FROM favorite LEFT JOIN chargelocation ON favorite.chargerDataSource = chargelocation.dataSource AND favorite.chargerId = chargelocation.id WHERE chargelocation.id is not NULL")
suspend fun getAllFavoritesAsync(): List<FavoriteWithDetail>
@SkipQueryVerification
@@ -1,15 +1,9 @@
package net.vonforst.evmap.storage
import androidx.room.Dao
import androidx.room.Entity
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.PrimaryKey
import androidx.room.Query
import androidx.room.Transaction
import androidx.lifecycle.LiveData
import androidx.lifecycle.MediatorLiveData
import androidx.room.*
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch
import net.vonforst.evmap.api.goingelectric.GEChargeCard
import net.vonforst.evmap.api.goingelectric.GEReferenceData
@@ -42,7 +36,7 @@ abstract class GEReferenceDataDao {
}
@Query("SELECT * FROM genetwork")
abstract fun getAllNetworks(): Flow<List<GENetwork>>
abstract fun getAllNetworks(): LiveData<List<GENetwork>>
// PLUGS
@Insert(onConflict = OnConflictStrategy.REPLACE)
@@ -60,7 +54,7 @@ abstract class GEReferenceDataDao {
}
@Query("SELECT * FROM geplug")
abstract fun getAllPlugs(): Flow<List<GEPlug>>
abstract fun getAllPlugs(): LiveData<List<GEPlug>>
// CHARGE CARDS
@Insert(onConflict = OnConflictStrategy.REPLACE)
@@ -78,21 +72,31 @@ abstract class GEReferenceDataDao {
}
@Query("SELECT * FROM gechargecard")
abstract fun getAllChargeCards(): Flow<List<GEChargeCard>>
abstract fun getAllChargeCards(): LiveData<List<GEChargeCard>>
}
class GEReferenceDataRepository(
private val api: GoingElectricApiWrapper, private val scope: CoroutineScope,
private val dao: GEReferenceDataDao, private val prefs: PreferenceDataSource
) {
fun getReferenceData(): Flow<GEReferenceData> {
fun getReferenceData(): LiveData<GEReferenceData> {
scope.launch {
updateData()
}
val plugs = dao.getAllPlugs()
val networks = dao.getAllNetworks()
val chargeCards = dao.getAllChargeCards()
return combine(plugs, networks, chargeCards) { p, n, c -> GEReferenceData(p.map { it.name }, n.map { it.name }, c) }
return MediatorLiveData<GEReferenceData>().apply {
value = null
listOf(chargeCards, networks, plugs).map { source ->
addSource(source) { _ ->
val p = plugs.value ?: return@addSource
val n = networks.value ?: return@addSource
val cc = chargeCards.value ?: return@addSource
value = GEReferenceData(p.map { it.name }, n.map { it.name }, cc)
}
}
}
}
private suspend fun updateData() {
@@ -0,0 +1,26 @@
package net.vonforst.evmap.storage
import androidx.lifecycle.LiveData
import androidx.lifecycle.MediatorLiveData
import androidx.room.*
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import net.vonforst.evmap.api.nobil.*
import net.vonforst.evmap.viewmodel.Status
import java.time.Duration
import java.time.Instant
@Dao
abstract class NobilReferenceDataDao {
}
class NobilReferenceDataRepository(
private val scope: CoroutineScope,
private val prefs: PreferenceDataSource
) {
fun getReferenceData(): LiveData<NobilReferenceData> {
return MediatorLiveData<NobilReferenceData>().apply {
value = NobilReferenceData(0)
}
}
}
@@ -1,19 +1,11 @@
package net.vonforst.evmap.storage
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Transaction
import androidx.lifecycle.LiveData
import androidx.lifecycle.MediatorLiveData
import androidx.room.*
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch
import net.vonforst.evmap.api.openchargemap.OCMConnectionType
import net.vonforst.evmap.api.openchargemap.OCMCountry
import net.vonforst.evmap.api.openchargemap.OCMOperator
import net.vonforst.evmap.api.openchargemap.OCMReferenceData
import net.vonforst.evmap.api.openchargemap.OpenChargeMapApiWrapper
import net.vonforst.evmap.api.openchargemap.*
import net.vonforst.evmap.viewmodel.Status
import java.time.Duration
import java.time.Instant
@@ -36,7 +28,7 @@ abstract class OCMReferenceDataDao {
}
@Query("SELECT * FROM ocmconnectiontype")
abstract fun getAllConnectionTypes(): Flow<List<OCMConnectionType>>
abstract fun getAllConnectionTypes(): LiveData<List<OCMConnectionType>>
// COUNTRIES
@Insert(onConflict = OnConflictStrategy.REPLACE)
@@ -54,7 +46,7 @@ abstract class OCMReferenceDataDao {
}
@Query("SELECT * FROM ocmcountry")
abstract fun getAllCountries(): Flow<List<OCMCountry>>
abstract fun getAllCountries(): LiveData<List<OCMCountry>>
// OPERATORS
@Insert(onConflict = OnConflictStrategy.REPLACE)
@@ -72,21 +64,32 @@ abstract class OCMReferenceDataDao {
}
@Query("SELECT * FROM ocmoperator")
abstract fun getAllOperators(): Flow<List<OCMOperator>>
abstract fun getAllOperators(): LiveData<List<OCMOperator>>
}
class OCMReferenceDataRepository(
private val api: OpenChargeMapApiWrapper, private val scope: CoroutineScope,
private val dao: OCMReferenceDataDao, private val prefs: PreferenceDataSource
) {
fun getReferenceData(): Flow<OCMReferenceData> {
fun getReferenceData(): LiveData<OCMReferenceData> {
scope.launch {
updateData()
}
val connectionTypes = dao.getAllConnectionTypes()
val countries = dao.getAllCountries()
val operators = dao.getAllOperators()
return combine(connectionTypes, countries, operators) { ct, c, o -> OCMReferenceData(ct, c, o) }
return MediatorLiveData<OCMReferenceData>().apply {
value = null
listOf(countries, connectionTypes, operators).map { source ->
addSource(source) { _ ->
val ct = connectionTypes.value
val c = countries.value
val o = operators.value
if (ct.isNullOrEmpty() || c.isNullOrEmpty() || o.isNullOrEmpty()) return@addSource
value = OCMReferenceData(ct, c, o)
}
}
}
}
private suspend fun updateData() {
@@ -4,8 +4,6 @@ import androidx.lifecycle.LiveData
import androidx.lifecycle.MediatorLiveData
import androidx.room.*
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import net.vonforst.evmap.api.openchargemap.*
import net.vonforst.evmap.api.openstreetmap.OSMReferenceData
@@ -34,13 +32,19 @@ abstract class OSMReferenceDataDao {
}
@Query("SELECT * FROM osmnetwork")
abstract fun getAllNetworks(): Flow<List<OSMNetwork>>
abstract fun getAllNetworks(): LiveData<List<OSMNetwork>>
}
class OSMReferenceDataRepository(private val dao: OSMReferenceDataDao) {
fun getReferenceData(): Flow<OSMReferenceData> {
fun getReferenceData(): LiveData<OSMReferenceData> {
val networks = dao.getAllNetworks()
return networks.map { OSMReferenceData(it.map { it.name }) }
return MediatorLiveData<OSMReferenceData>().apply {
value = null
addSource(networks) { _ ->
val n = networks.value ?: return@addSource
value = OSMReferenceData(n.map { it.name })
}
}
}
suspend fun updateReferenceData(refData: OSMReferenceData) {
@@ -12,6 +12,7 @@ import net.vonforst.evmap.autocomplete.PlaceWithBounds
import net.vonforst.evmap.model.FILTERS_CUSTOM
import net.vonforst.evmap.model.FILTERS_DISABLED
import java.time.Instant
import androidx.core.content.edit
class PreferenceDataSource(val context: Context) {
val sp = PreferenceManager.getDefaultSharedPreferences(context)
@@ -152,87 +153,6 @@ class PreferenceDataSource(val context: Context) {
sp.edit().putBoolean("update_0.6.0_androidauto_dialog_shown", value).apply()
}
var chargepriceNativeIntegration: Boolean
get() = sp.getBoolean("chargeprice_native_integration", true)
set(value) {
sp.edit().putBoolean("chargeprice_native_integration", value).apply()
}
var chargepriceMyVehicles: Set<String>
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()
}
var chargepriceLastSelectedVehicle: String?
get() = sp.getString("chargeprice_last_vehicle", null)
set(value) {
sp.edit().putString("chargeprice_last_vehicle", value).apply()
}
var chargepriceMyTariffs: Set<String>?
get() = sp.getStringSet("chargeprice_my_tariffs", null)
set(value) {
sp.edit().putStringSet("chargeprice_my_tariffs", value).apply()
}
var chargepriceMyTariffsAll: Boolean
get() = sp.getBoolean("chargeprice_my_tariffs_all", true)
set(value) {
sp.edit().putBoolean("chargeprice_my_tariffs_all", value).apply()
}
var chargepriceNoBaseFee: Boolean
get() = sp.getBoolean("chargeprice_no_base_fee", false)
set(value) {
sp.edit().putBoolean("chargeprice_no_base_fee", value).apply()
}
var chargepriceShowProviderCustomerTariffs: Boolean
get() = sp.getBoolean("chargeprice_show_provider_customer_tariffs", false)
set(value) {
sp.edit().putBoolean("chargeprice_show_provider_customer_tariffs", value).apply()
}
var chargepriceAllowUnbalancedLoad: Boolean
get() = sp.getBoolean("chargeprice_allow_unbalanced_load", false)
set(value) {
sp.edit().putBoolean("chargeprice_allow_unbalanced_load", value).apply()
}
var chargepriceCurrency: String
get() = sp.getString("chargeprice_currency", null) ?: "EUR"
set(value) {
sp.edit().putString("chargeprice_currency", value).apply()
}
var chargepriceBatteryRange: List<Float>
get() = listOf(
sp.getFloat("chargeprice_battery_range_min", 20f),
sp.getFloat("chargeprice_battery_range_max", 80f),
)
set(value) {
sp.edit().putFloat("chargeprice_battery_range_min", value[0])
.putFloat("chargeprice_battery_range_max", value[1])
.apply()
}
var chargepriceBatteryRangeAndroidAuto: List<Float>
get() = listOf(
sp.getFloat("chargeprice_battery_range_android_auto_min", 20f),
sp.getFloat("chargeprice_battery_range_android_auto_max", 80f),
)
set(value) {
sp.edit().putFloat("chargeprice_battery_range_android_auto_min", value[0])
.putFloat("chargeprice_battery_range_android_auto_max", value[1])
.apply()
}
/** App start counter, introduced with Version 1.0.0 */
var appStartCounter: Long
get() = sp.getLong("app_start_counter", 0)
@@ -248,6 +168,12 @@ class PreferenceDataSource(val context: Context) {
sp.edit().putLong("chargeprice_counter", value).apply()
}
var chargepriceRemoval2025DialogShown: Boolean
get() = sp.getBoolean("chargeprice_removal_2025_dialog_shown", false)
set(value) {
sp.edit().putBoolean("chargeprice_removal_2025_dialog_shown", value).apply()
}
var opensourceDonationsDialogLastShown: Instant
get() = Instant.ofEpochMilli(sp.getLong("opensource_donations_dialog_last_shown", 0L))
set(value) {
@@ -323,6 +249,18 @@ class PreferenceDataSource(val context: Context) {
set(value) {
sp.edit().putBoolean("privacy_accepted", value).apply()
}
var androidAutoCompassEnabled: Boolean
get() = sp.getBoolean("android_auto_compass_enabled", false)
set(value) {
sp.edit().putBoolean("android_auto_compass_enabled", value).apply()
}
var androidAutoNewMapScreenEnabled: Boolean
get() = sp.getBoolean("android_auto_new_map_screen_enabled", false)
set(value) {
sp.edit { putBoolean("android_auto_new_map_screen_enabled", value) }
}
}
fun SharedPreferences.getLatLng(key: String): LatLng? =
@@ -1,12 +1,8 @@
package net.vonforst.evmap.storage
import androidx.room.Dao
import androidx.room.Entity
import androidx.room.Index
import androidx.room.Insert
import androidx.room.PrimaryKey
import androidx.room.Query
import androidx.room.SkipQueryVerification
import androidx.lifecycle.LiveData
import androidx.lifecycle.map
import androidx.room.*
import co.anbora.labs.spatia.geometry.Geometry
import co.anbora.labs.spatia.geometry.LineString
import co.anbora.labs.spatia.geometry.Polygon
@@ -39,31 +35,31 @@ abstract class SavedRegionDao {
@SkipQueryVerification
@Query("SELECT Covers(GUnion(region), BuildMbr(:lng1, :lat1, :lng2, :lat2, 4326)) FROM savedregion WHERE dataSource == :dataSource AND timeRetrieved > :after AND Intersects(region, BuildMbr(:lng1, :lat1, :lng2, :lat2, 4326)) AND (filters == :filters OR filters IS NULL) AND (isDetailed OR NOT :isDetailed)")
protected abstract suspend fun savedRegionCoversInt(
protected abstract fun savedRegionCoversInt(
lat1: Double,
lat2: Double,
lng1: Double,
lng2: Double,
dataSource: String, after: Long, filters: String? = null, isDetailed: Boolean = false
): Int
): LiveData<Int>
@SkipQueryVerification
@Query("SELECT Covers(GUnion(region), MakeEllipse(:lng, :lat, :radiusLng, :radiusLat, 4326)) FROM savedregion WHERE dataSource == :dataSource AND timeRetrieved > :after AND Intersects(region, MakeEllipse(:lng, :lat, :radiusLng, :radiusLat, 4326)) AND (filters == :filters OR filters IS NULL) AND (isDetailed OR NOT :isDetailed)")
protected abstract suspend fun savedRegionCoversRadiusInt(
protected abstract fun savedRegionCoversRadiusInt(
lat: Double,
lng: Double,
radiusLat: Double,
radiusLng: Double,
dataSource: String, after: Long, filters: String? = null, isDetailed: Boolean = false
): Int
): LiveData<Int>
suspend fun savedRegionCovers(
fun savedRegionCovers(
lat1: Double,
lat2: Double,
lng1: Double,
lng2: Double,
dataSource: String, after: Long, filters: String? = null, isDetailed: Boolean = false
): Boolean {
): LiveData<Boolean> {
return savedRegionCoversInt(
lat1,
lat2,
@@ -73,15 +69,15 @@ abstract class SavedRegionDao {
after,
filters,
isDetailed
) == 1
).map { it == 1 }
}
suspend fun savedRegionCoversRadius(
fun savedRegionCoversRadius(
lat: Double,
lng: Double,
radius: Double,
dataSource: String, after: Long, filters: String? = null, isDetailed: Boolean = false
): Boolean {
): LiveData<Boolean> {
val (radiusLat, radiusLng) = circleAsEllipse(lat, lng, radius)
return savedRegionCoversRadiusInt(
lat,
@@ -92,7 +88,7 @@ abstract class SavedRegionDao {
after,
filters,
isDetailed
) == 1
).map { it == 1 }
}
@Insert
@@ -8,6 +8,7 @@ import com.squareup.moshi.Moshi
import com.squareup.moshi.Types
import com.squareup.moshi.adapters.PolymorphicJsonAdapterFactory
import net.vonforst.evmap.api.goingelectric.GEChargerPhotoAdapter
import net.vonforst.evmap.api.nobil.NobilChargerPhotoAdapter
import net.vonforst.evmap.api.openchargemap.OCMChargerPhotoAdapter
import net.vonforst.evmap.api.openstreetmap.ImgurChargerPhoto
import net.vonforst.evmap.autocomplete.AutocompletePlaceType
@@ -23,6 +24,7 @@ class Converters {
.add(
PolymorphicJsonAdapterFactory.of(ChargerPhoto::class.java, "type")
.withSubtype(GEChargerPhotoAdapter::class.java, "goingelectric")
.withSubtype(NobilChargerPhotoAdapter::class.java, "nobil")
.withSubtype(OCMChargerPhotoAdapter::class.java, "openchargemap")
.withSubtype(ImgurChargerPhoto::class.java, "imgur")
.withDefaultValue(null)
@@ -23,6 +23,7 @@ class UpdateFullDownloadWorker(appContext: Context, workerParams: WorkerParamete
var insertJob: Job? = null
val result = api.fullDownload()
val idsToDelete = chargeLocations.getAllIds(api.id).toMutableSet()
result.chargers.chunked(1024).forEach {
insertJob?.join()
insertJob = withContext(Dispatchers.IO) {
@@ -30,8 +31,12 @@ class UpdateFullDownloadWorker(appContext: Context, workerParams: WorkerParamete
chargeLocations.insert(*it.toTypedArray())
}
}
idsToDelete.removeAll(it.map { it.id })
}
// delete chargers that have been removed
chargeLocations.deleteById(api.id, idsToDelete.toList())
when (api) {
is OpenStreetMapApiWrapper -> {
val refData = result.referenceData
@@ -40,7 +45,6 @@ class UpdateFullDownloadWorker(appContext: Context, workerParams: WorkerParamete
}
}
// TODO: remove deleted chargers
return Result.success()
}
}
@@ -2,188 +2,40 @@ package net.vonforst.evmap.ui
import android.content.Context
import android.content.res.ColorStateList
import android.graphics.Color
import android.graphics.drawable.ColorDrawable
import android.graphics.drawable.Drawable
import android.graphics.drawable.LayerDrawable
import android.text.format.DateUtils
import android.text.method.LinkMovementMethod
import android.text.util.Linkify
import android.view.View
import android.view.ViewGroup.MarginLayoutParams
import android.widget.ImageView
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
import androidx.databinding.BindingAdapter
import androidx.databinding.InverseBindingAdapter
import androidx.databinding.InverseBindingListener
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import androidx.viewpager2.widget.ViewPager2
import coil.load
import com.google.android.material.floatingactionbutton.FloatingActionButton
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.isDarkMode
import net.vonforst.evmap.kmPerMile
import net.vonforst.evmap.meterPerFt
import net.vonforst.evmap.shouldUseImperialUnits
import java.time.Instant
import kotlin.math.ceil
import kotlin.math.floor
import kotlin.math.roundToInt
fun goneUnless(visible: Boolean): Int = if (visible) View.VISIBLE else View.GONE
@BindingAdapter("goneUnless")
fun goneUnless(view: View, visible: Boolean) {
view.visibility = if (visible) View.VISIBLE else View.GONE
}
fun invisibleUnless(visible: Boolean): Int = if (visible) View.VISIBLE else View.INVISIBLE
@BindingAdapter("goneUnlessAnimated")
fun goneUnlessAnimated(view: View, oldValue: Boolean, newValue: Boolean) {
if (oldValue == newValue) return
view.animate().cancel()
if (newValue) {
view.visibility = View.VISIBLE
view.alpha = 0f
view.animate().alpha(1f).withEndAction {
view.alpha = 1f
}
} else {
view.animate().alpha(0f).withEndAction {
view.alpha = 1f
view.visibility = View.GONE
}
}
}
@BindingAdapter("invisibleUnless")
fun invisibleUnless(view: View, visible: Boolean) {
view.visibility = if (visible) View.VISIBLE else View.INVISIBLE
}
@BindingAdapter("invisibleUnlessAnimated")
fun invisibleUnlessAnimated(view: View, oldValue: Boolean, newValue: Boolean) {
if (oldValue == newValue) {
if (!newValue && view.visibility == View.VISIBLE && view.alpha == 1f) {
// view is initially invisible
view.visibility = View.INVISIBLE
} else {
return
}
}
view.animate().cancel()
if (newValue) {
view.visibility = View.VISIBLE
view.alpha = 0f
view.animate().alpha(1f).withEndAction {
view.alpha = 1f
}
} else {
view.animate().alpha(0f).withEndAction {
view.alpha = 1f
view.visibility = View.INVISIBLE
}
}
}
@BindingAdapter("isFabActive")
fun isFabActive(view: FloatingActionButton, isColored: Boolean) {
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) {
androidx.appcompat.R.attr.colorPrimary
} else {
androidx.appcompat.R.attr.colorControlNormal
}
)
)
val valueOf = ColorStateList.valueOf(color.getColor(0, 0))
return valueOf
}
@BindingAdapter("data")
@Suppress("UNCHECKED_CAST")
fun <T> setRecyclerViewData(recyclerView: RecyclerView, items: List<T>?) {
if (recyclerView.adapter is ListAdapter<*, *>) {
(recyclerView.adapter as ListAdapter<T, *>).submitList(items)
private fun activeTint(context: Context, isColored: Boolean): ColorStateList {
val colorAttr = if (isColored) {
androidx.appcompat.R.attr.colorPrimary
} else {
androidx.appcompat.R.attr.colorControlNormal
}
return context.obtainStyledAttributes(intArrayOf(colorAttr)).use {
ColorStateList.valueOf(it.getColor(0, 0))
}
}
@BindingAdapter("data")
@Suppress("UNCHECKED_CAST")
fun <T> setRecyclerViewData(recyclerView: ViewPager2, items: List<T>?) {
if (recyclerView.adapter is ListAdapter<*, *>) {
(recyclerView.adapter as ListAdapter<T, *>).submitList(items)
}
}
@BindingAdapter("connectorIcon")
fun getConnectorItem(view: ImageView, type: String?) {
view.setImageResource(type?.let { iconForPlugType(it) } ?: 0)
}
@BindingAdapter("srcCompat")
fun setImageResource(imageView: ImageView, resource: Int) {
imageView.setImageResource(resource)
}
@BindingAdapter("android:contentDescription")
fun setContentDescriptionResource(imageView: ImageView, resource: Int) {
imageView.contentDescription = imageView.context.getString(resource)
}
@BindingAdapter("tintAvailability")
fun setImageTintAvailability(view: ImageView, available: List<ChargepointStatus>?) {
view.imageTintList = ColorStateList.valueOf(availabilityColor(available, view.context))
}
@BindingAdapter("tintAvailability")
fun setImageTintAvailability(view: ImageView, available: ChargepointStatus?) {
view.imageTintList = ColorStateList.valueOf(availabilityColor(available, view.context))
}
@BindingAdapter("textColorAvailability")
fun setTextColorAvailability(view: TextView, available: List<ChargepointStatus>?) {
view.setTextColor(availabilityColor(available, view.context))
}
@BindingAdapter("textColorAvailability")
fun setTextColorAvailability(view: TextView, available: ChargepointStatus?) {
view.setTextColor(availabilityColor(available, view.context))
}
@BindingAdapter("backgroundTintAvailability")
fun setBackgroundTintAvailability(view: View, available: List<ChargepointStatus>?) {
view.backgroundTintList = ColorStateList.valueOf(availabilityColor(available, view.context))
}
@BindingAdapter("selectableItemBackground")
fun applySelectableItemBackground(view: View, apply: Boolean) {
if (apply) {
view.context.obtainStyledAttributes(intArrayOf(androidx.appcompat.R.attr.selectableItemBackground))
@@ -195,105 +47,36 @@ fun applySelectableItemBackground(view: View, apply: Boolean) {
}
}
@BindingAdapter("htmlText")
fun setHtmlTextValue(textView: TextView, htmlText: String?) {
if (htmlText == null) {
textView.text = null
} else {
textView.text = HtmlCompat.fromHtml(htmlText, HtmlCompat.FROM_HTML_MODE_LEGACY)
}
}
@BindingAdapter("android:layout_marginTop")
fun setTopMargin(view: View, topMargin: Float) {
val layoutParams = view.layoutParams as MarginLayoutParams
layoutParams.setMargins(
layoutParams.leftMargin, topMargin.roundToInt(),
layoutParams.rightMargin, layoutParams.bottomMargin
)
view.layoutParams = layoutParams
}
/**
* Linkify is already possible using the autoLink and linksClickable attributes, but this does not
* remove spans correctly after autoLink is set to false.
* So we implement a new version that manually uses Linkify to create links if necessary.
*/
@BindingAdapter(value = ["linkify", "android:text"])
fun setLinkify(
textView: TextView,
oldLinkify: Int,
oldText: CharSequence?,
newLinkify: Int,
newText: CharSequence?
) {
if (oldLinkify == newLinkify && oldText == newText) return
textView.text = newText
if (newLinkify != 0) {
Linkify.addLinks(textView, newLinkify)
fun setLinkify(textView: TextView, linkifyMask: Int, text: CharSequence?) {
textView.text = text
if (linkifyMask != 0) {
Linkify.addLinks(textView, linkifyMask)
textView.movementMethod = LinkMovementMethod.getInstance()
} else {
textView.movementMethod = null
}
}
@BindingAdapter("chargepriceTagColor")
fun setChargepriceTagColor(view: TextView, kind: String) {
view.backgroundTintList = ColorStateList.valueOf(
ContextCompat.getColor(
view.context,
when (kind) {
"star" -> R.color.chargeprice_star
"alert" -> R.color.chargeprice_alert
"info" -> R.color.chargeprice_info
"lock" -> R.color.chargeprice_lock
else -> R.color.chip_background
}
)
)
}
@BindingAdapter("chargepriceTagIcon")
fun setChargepriceTagIcon(view: TextView, kind: String) {
view.setCompoundDrawablesRelativeWithIntrinsicBounds(
when (kind) {
"star" -> R.drawable.ic_chargeprice_star
"alert" -> R.drawable.ic_chargeprice_alert
"info" -> R.drawable.ic_chargeprice_info
"lock" -> R.drawable.ic_chargeprice_lock
else -> 0
}, 0, 0, 0
)
}
private fun availabilityColor(
status: List<ChargepointStatus>?,
context: Context
): Int = if (status != null) {
fun availabilityColor(status: List<ChargepointStatus>?, context: Context): Int =
if (status != null) {
val unknown = status.any { it == ChargepointStatus.UNKNOWN }
val available = status.count { it == ChargepointStatus.AVAILABLE }
val allFaulted = status.all { it == ChargepointStatus.FAULTED }
if (unknown) {
ContextCompat.getColor(context, R.color.unknown)
} else if (available > 0) {
ContextCompat.getColor(context, R.color.available)
} else if (allFaulted) {
ContextCompat.getColor(context, R.color.unavailable)
} else {
ContextCompat.getColor(context, R.color.charging)
when {
unknown -> ContextCompat.getColor(context, R.color.unknown)
available > 0 -> ContextCompat.getColor(context, R.color.available)
allFaulted -> ContextCompat.getColor(context, R.color.unavailable)
else -> ContextCompat.getColor(context, R.color.charging)
}
} else {
val ta =
context.theme.obtainStyledAttributes(intArrayOf(androidx.appcompat.R.attr.colorControlNormal))
ta.getColor(0, 0)
context.obtainStyledAttributes(intArrayOf(androidx.appcompat.R.attr.colorControlNormal))
.use {
it.getColor(0, 0)
}
}
private fun availabilityColor(
status: ChargepointStatus?,
context: Context
): Int = when (status) {
fun availabilityColor(status: ChargepointStatus?, context: Context): Int = when (status) {
ChargepointStatus.UNKNOWN -> ContextCompat.getColor(context, R.color.unknown)
ChargepointStatus.AVAILABLE -> ContextCompat.getColor(context, R.color.available)
ChargepointStatus.FAULTED -> ContextCompat.getColor(context, R.color.unavailable)
@@ -302,28 +85,26 @@ private fun availabilityColor(
R.color.charging
)
null -> {
val ta =
context.theme.obtainStyledAttributes(intArrayOf(androidx.appcompat.R.attr.colorControlNormal))
ta.getColor(0, 0)
null -> context.obtainStyledAttributes(intArrayOf(androidx.appcompat.R.attr.colorControlNormal))
.use {
it.getColor(0, 0)
}
}
fun availabilityText(status: List<ChargepointStatus>?): String? {
if (status == null) return null
val total = status.size
val unknown = status.count { it == ChargepointStatus.UNKNOWN }
val available = status.count { it == ChargepointStatus.AVAILABLE }
return if (unknown > 0) {
if (unknown == total) "?" else "$available?"
} else available.toString()
} else {
available.toString()
}
}
fun availabilityText(status: ChargepointStatus?, lastChange: Instant?, context: Context): String? {
if (status == null) return null
val statusText = when (status) {
ChargepointStatus.UNKNOWN -> context.getString(R.string.status_unknown)
ChargepointStatus.AVAILABLE -> context.getString(R.string.status_available)
@@ -331,60 +112,49 @@ fun availabilityText(status: ChargepointStatus?, lastChange: Instant?, context:
ChargepointStatus.OCCUPIED -> context.getString(R.string.status_occupied)
ChargepointStatus.FAULTED -> context.getString(R.string.status_faulted)
}
return if (lastChange != null) {
val relativeTime = DateUtils.getRelativeTimeSpanString(
lastChange.toEpochMilli(),
Instant.now().toEpochMilli(),
0
).toString()
return context.getString(R.string.status_since, statusText, relativeTime)
context.getString(R.string.status_since, statusText, relativeTime)
} else {
statusText
}
}
fun flatten(it: Iterable<Iterable<ChargepointStatus>>?): List<ChargepointStatus>? {
return it?.flatten()
fun flatten(values: Iterable<Iterable<ChargepointStatus>>?): List<ChargepointStatus>? {
return values?.flatten()
}
fun currency(currency: String): String {
// shorthands for currencies
return when (currency) {
"EUR" -> ""
"USD" -> "$"
"DKK", "SEK", "NOK" -> "kr."
"PLN" -> ""
"CHF" -> "Fr. "
"CZK" -> ""
"GBP" -> "£"
"HRK" -> "kn"
"HUF" -> "Ft"
"ISK" -> "kr"
else -> currency
}
}
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 currency(currency: String): String = when (currency) {
"EUR" -> ""
"USD" -> "$"
"DKK", "SEK", "NOK" -> "kr."
"PLN" -> ""
"CHF" -> "Fr. "
"CZK" -> ""
"GBP" -> "£"
"HRK" -> "kn"
"HUF" -> "Ft"
"ISK" -> "kr"
else -> currency
}
fun distance(meters: Number?, ctx: Context): String? {
if (meters == null) return null
if (shouldUseImperialUnits(ctx)) {
return if (shouldUseImperialUnits(ctx)) {
val ft = meters.toDouble() / meterPerFt
val mi = meters.toDouble() / 1e3 / kmPerMile
return when {
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 {
when {
km < 1 -> "%.0f m".format(meters.toDouble())
km < 10 -> "%.1f km".format(km)
else -> "%.0f km".format(km)
@@ -392,103 +162,3 @@ fun distance(meters: Number?, ctx: Context): String? {
}
}
@InverseBindingAdapter(attribute = "values")
fun getRangeSliderValue(slider: RangeSlider) = slider.values
@BindingAdapter("valuesAttrChanged")
fun setRangeSliderListeners(slider: RangeSlider, attrChange: InverseBindingListener) {
slider.addOnChangeListener { _, _, _ ->
attrChange.onChange()
}
}
@ColorInt
fun colorEnabled(ctx: Context, enabled: Boolean): Int {
val attr = if (enabled) {
android.R.attr.textColorSecondary
} else {
android.R.attr.textColorHint
}
val typedValue = ctx.obtainStyledAttributes(intArrayOf(attr))
val color = typedValue.getColor(0, 0)
typedValue.recycle()
return color
}
@BindingAdapter("tint")
fun setImageTintList(view: ImageView, @ColorInt color: Int) {
view.imageTintList = ColorStateList.valueOf(color)
}
fun tariffBackground(context: Context, myTariff: Boolean, brandingColor: String?): Drawable? {
when {
myTariff -> {
return ContextCompat.getDrawable(context, R.drawable.my_tariff_background)
}
brandingColor != null -> {
val drawable = ContextCompat.getDrawable(context, R.drawable.branded_tariff_background)
val color = colorToTransparent(Color.parseColor(brandingColor))
(drawable as LayerDrawable).setDrawableByLayerId(
R.id.background, ColorDrawable(
color
)
)
return drawable
}
else -> {
context.obtainStyledAttributes(intArrayOf(androidx.appcompat.R.attr.selectableItemBackground))
.use {
return it.getDrawable(0)
}
}
}
}
fun isDarkMode(context: Context) = context.isDarkMode()
/**
* Converts an opaque color to a transparent color, assuming it was on a white background
* with a certain opacity targetAlpha.
*/
private fun colorToTransparent(color: Int, targetAlpha: Float = 31f / 255): Int {
if (Color.alpha(color) != 255) return color
val red = Color.red(color)
val green = Color.green(color)
val blue = Color.blue(color)
val newRed = kotlin.math.max(((red - (1 - targetAlpha) * 255) / targetAlpha).roundToInt(), 0)
val newGreen =
kotlin.math.max(((green - (1 - targetAlpha) * 255) / targetAlpha).roundToInt(), 0)
val newBlue = kotlin.math.max(((blue - (1 - targetAlpha) * 255) / targetAlpha).roundToInt(), 0)
return Color.argb((targetAlpha * 255).roundToInt(), newRed, newGreen, newBlue)
}
@BindingAdapter("imageUrl")
fun loadImage(view: ImageView, url: String?) {
if (url != null) {
view.load(url)
} else {
view.setImageDrawable(null)
}
}
@BindingAdapter("tooltipTextCompat")
fun setTooltipTextCompat(view: View, text: String) {
TooltipCompat.setTooltipText(view, text)
}
@BindingAdapter("tintNullable")
fun setImageTint(view: ImageView, @ColorInt tint: Int?) {
if (tint != null) {
view.imageTintList = ColorStateList.valueOf(tint)
} else {
view.imageTintList = null
}
}
@BindingAdapter("isPercentage")
fun setIsPercentage(view: BarGraphView, value: Boolean) {
view.isPercentage = value
}
@@ -0,0 +1,27 @@
package net.vonforst.evmap.ui
import android.text.SpannableStringBuilder
import android.text.Spanned
import android.text.style.URLSpan
import android.view.View
import androidx.core.text.getSpans
import net.vonforst.evmap.MapsActivity
class CustomUrlSpan(url: String): URLSpan(url) {
override fun onClick(widget: View) {
(widget.context as? MapsActivity)?.let {
it.openUrl(url, widget.rootView)
} ?: {
super.onClick(widget)
}
}
}
fun Spanned.replaceUrlSpansWithCustom(): Spanned {
val builder = SpannableStringBuilder(this)
builder.getSpans<URLSpan>().forEach {
builder.setSpan(CustomUrlSpan(it.url), builder.getSpanStart(it), builder.getSpanEnd(it), builder.getSpanFlags(it))
builder.removeSpan(it)
}
return builder
}
@@ -1,320 +0,0 @@
package net.vonforst.evmap.viewmodel
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.LiveData
import androidx.lifecycle.MediatorLiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.distinctUntilChanged
import androidx.lifecycle.viewModelScope
import jsonapi.Meta
import jsonapi.Relationship
import jsonapi.Relationships
import jsonapi.ResourceIdentifier
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import net.vonforst.evmap.api.chargeprice.ChargePrice
import net.vonforst.evmap.api.chargeprice.ChargepriceApi
import net.vonforst.evmap.api.chargeprice.ChargepriceCar
import net.vonforst.evmap.api.chargeprice.ChargepriceChargepointMeta
import net.vonforst.evmap.api.chargeprice.ChargepriceInclude
import net.vonforst.evmap.api.chargeprice.ChargepriceMeta
import net.vonforst.evmap.api.chargeprice.ChargepriceOptions
import net.vonforst.evmap.api.chargeprice.ChargepriceRequest
import net.vonforst.evmap.api.chargeprice.ChargepriceRequestTariffMeta
import net.vonforst.evmap.api.chargeprice.ChargepriceStation
import net.vonforst.evmap.api.equivalentPlugTypes
import net.vonforst.evmap.model.ChargeLocation
import net.vonforst.evmap.model.Chargepoint
import net.vonforst.evmap.storage.PreferenceDataSource
import retrofit2.HttpException
import java.io.IOException
class ChargepriceViewModel(
application: Application,
chargepriceApiKey: String,
chargepriceApiUrl: String,
private val state: SavedStateHandle
) :
AndroidViewModel(application) {
private var api = ChargepriceApi.create(chargepriceApiKey, chargepriceApiUrl)
private var prefs = PreferenceDataSource(application)
val charger: MutableLiveData<ChargeLocation> by lazy {
state.getLiveData("charger")
}
val chargepoint: MutableLiveData<Chargepoint?> by lazy {
state.getLiveData("chargepoint")
}
private val vehicleIds: MutableLiveData<Set<String>> by lazy {
MutableLiveData<Set<String>>().apply {
value = prefs.chargepriceMyVehicles
}
}
val vehicles: LiveData<Resource<List<ChargepriceCar>>> by lazy {
MediatorLiveData<Resource<List<ChargepriceCar>>>().apply {
addSource(vehicleIds.distinctUntilChanged()) { vehicleIds ->
if (vehicleIds.isEmpty()) {
value = Resource.success(emptyList())
} else {
value = Resource.loading(null)
viewModelScope.launch {
value = try {
val result = api.getVehicles()
Resource.success(result.filter {
it.id in vehicleIds
})
} catch (e: IOException) {
Resource.error(e.message, null)
} catch (e: HttpException) {
Resource.error(e.message, null)
}
}
}
}
observeForever {
vehicle.value = it.data?.firstOrNull()
}
}
}
val vehicle: MutableLiveData<ChargepriceCar> by lazy {
state.getLiveData("vehicle")
}
val vehicleCompatibleConnectors: LiveData<List<String>> by lazy {
MediatorLiveData<List<String>>().apply {
addSource(vehicle) {
value = it?.compatibleEvmapConnectors
}
}
}
val noCompatibleConnectors: LiveData<Boolean> by lazy {
MediatorLiveData<Boolean>().apply {
value = false
listOf(charger, vehicleCompatibleConnectors).forEach {
addSource(it) {
val charger = charger.value ?: return@addSource
val connectors = vehicleCompatibleConnectors.value ?: return@addSource
value = !charger.chargepoints.flatMap { equivalentPlugTypes(it.type) }
.any { it in connectors }
}
}
}
}
val batteryRange: MutableLiveData<List<Float>> by lazy {
MutableLiveData<List<Float>>().apply {
value = prefs.chargepriceBatteryRange
observeForever {
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!!
}
}
}
val batteryRangeSliderDragging: MutableLiveData<Boolean> by lazy {
MutableLiveData<Boolean>().apply {
value = false
}
}
val chargePrices: MutableLiveData<Resource<List<ChargePrice>>> by lazy {
MediatorLiveData<Resource<List<ChargePrice>>>().apply {
value = state["chargePrices"] ?: Resource.loading(null)
listOf(
vehicle,
batteryRange,
batteryRangeSliderDragging,
vehicleCompatibleConnectors,
myTariffs, myTariffsAll, charger
).forEach {
addSource(it.distinctUntilChanged()) {
if (!batteryRangeSliderDragging.value!!) {
loadPrices()
state["chargePrices"] = this.value
}
}
}
}
}
val chargePriceMeta: MutableLiveData<Resource<ChargepriceMeta>> by lazy {
MutableLiveData<Resource<ChargepriceMeta>>().apply {
value = Resource.loading(null)
}
}
val chargePricesForChargepoint: MediatorLiveData<Resource<List<ChargePrice>>> by lazy {
MediatorLiveData<Resource<List<ChargePrice>>>().apply {
listOf(chargePrices, chargepoint).forEach {
addSource(it) {
val cps = chargePrices.value
val chargepoint = chargepoint.value
if (cps == null || chargepoint == null) {
value = null
} else if (cps.status == Status.ERROR) {
value = Resource.error(cps.message, null)
} else if (cps.status == Status.LOADING) {
value = Resource.loading(null)
} else {
val myTariffs = prefs.chargepriceMyTariffs
value = Resource.success(cps.data!!.mapNotNull { cp ->
val filteredPrices =
cp.chargepointPrices.filter {
it.plug == getChargepricePlugType(chargepoint) && it.power == chargepoint.power
}
if (filteredPrices.isEmpty()) {
null
} else {
cp.copy(
chargepointPrices = filteredPrices
)
}
}
.sortedBy { it.chargepointPrices.first().price ?: Double.MAX_VALUE }
.sortedByDescending {
prefs.chargepriceMyTariffsAll ||
myTariffs != null && it.tariffId in myTariffs
}
)
}
}
}
}
}
fun reloadPrefs() {
vehicleIds.value = prefs.chargepriceMyVehicles
}
private fun getChargepricePlugType(chargepoint: Chargepoint): String {
val index = charger.value!!.chargepointsMerged.indexOf(chargepoint)
val type = charger.value!!.chargepriceData!!.plugTypes?.get(index) ?: chargepoint.type
return type
}
val myTariffs: LiveData<Set<String>> by lazy {
MutableLiveData<Set<String>>().apply {
value = prefs.chargepriceMyTariffs
}
}
val myTariffsAll: LiveData<Boolean> by lazy {
MutableLiveData<Boolean>().apply {
value = prefs.chargepriceMyTariffsAll
}
}
val chargepriceMetaForChargepoint: MediatorLiveData<Resource<ChargepriceChargepointMeta>> by lazy {
MediatorLiveData<Resource<ChargepriceChargepointMeta>>().apply {
listOf(chargePriceMeta, chargepoint).forEach {
addSource(it) {
val cpMeta = chargePriceMeta.value
val chargepoint = chargepoint.value
if (cpMeta == null || chargepoint == null) {
value = null
} else if (cpMeta.status == Status.ERROR) {
value = Resource.error(cpMeta.message, null)
} else if (cpMeta.status == Status.LOADING) {
value = Resource.loading(null)
} else {
val result = cpMeta.data!!.chargePoints.filter {
it.plug == getChargepricePlugType(
chargepoint
) && it.power == chargepoint.power
}.elementAtOrNull(0)
value = if (result != null) {
Resource.success(result)
} else {
Resource.error("matching chargepoint not found", null)
}
}
}
}
}
}
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
val myTariffs = myTariffs.value
val myTariffsAll = myTariffsAll.value
if (charger == null || car == null || compatibleConnectors == null || myTariffsAll == null || myTariffsAll == false && myTariffs == null) {
chargePrices.value = Resource.error(null, null)
return
}
val cpStation = ChargepriceStation.fromEvmap(charger, compatibleConnectors)
if (cpStation.chargePoints.isEmpty()) {
// no compatible connectors
chargePrices.value = Resource.success(emptyList())
chargePriceMeta.value = Resource.success(ChargepriceMeta(emptyList()))
return
}
loadPricesJob?.cancel()
loadPricesJob = viewModelScope.launch {
try {
val result = api.getChargePrices(
ChargepriceRequest(
dataAdapter = ChargepriceApi.getDataAdapter(charger),
station = cpStation,
vehicle = car,
options = ChargepriceOptions(
batteryRange = batteryRange.value!!.map { it.toDouble() },
providerCustomerTariffs = prefs.chargepriceShowProviderCustomerTariffs,
maxMonthlyFees = if (prefs.chargepriceNoBaseFee) 0.0 else null,
currency = prefs.chargepriceCurrency,
allowUnbalancedLoad = prefs.chargepriceAllowUnbalancedLoad,
showPriceUnavailable = true
),
relationships = if (!myTariffsAll) {
Relationships(
"tariffs" to Relationship.ToMany(
(myTariffs ?: emptySet()).map {
ResourceIdentifier(
"tariff",
id = it
)
},
meta = Meta.from(
ChargepriceRequestTariffMeta(ChargepriceInclude.ALWAYS),
ChargepriceApi.moshi
)
)
)
} else null
), ChargepriceApi.getChargepriceLanguage()
)
val meta = result.meta!!.map(ChargepriceMeta::class.java, ChargepriceApi.moshi)!!
chargePrices.value = Resource.success(result.data)
chargePriceMeta.value = Resource.success(meta)
} catch (e: IOException) {
chargePrices.value = Resource.error(e.message, null)
chargePriceMeta.value = Resource.error(e.message, null)
} catch (e: HttpException) {
chargePrices.value = Resource.error(e.message, null)
chargePriceMeta.value = Resource.error(e.message, null)
}
}
}
fun resetBatteryRangeToDefault() {
batteryRange.value = prefs.chargepriceBatteryRangeAndroidAuto
}
}
Loaded 100 of 200 files, more files were not shown because too many files have changed in this diff. Show more