Compare commits

...

23 Commits

Author SHA1 Message Date
johan12345
2e1e22a44b adjust disk size 2024-05-12 22:29:49 +02:00
johan12345
d638a88f4d update SDK 2024-05-12 18:57:02 +02:00
johan12345
b9c08a8e75 change screenshot size 2024-05-12 15:13:11 +02:00
johan12345
ab2a77d25a remove CleanStatusBar (not needed on ATD) 2024-05-12 14:33:45 +02:00
johan12345
e0ddc9f734 use androidx takeScreenshot 2024-05-10 17:52:28 +02:00
johan12345
168fa244d3 fix name of output file 2024-05-10 00:08:25 +02:00
johan12345
76388ccc6e no emulator metrics 2024-05-09 23:37:16 +02:00
johan12345
76aac7dae4 use ATD system image 2024-05-09 23:35:02 +02:00
johan12345
ee8b586079 implement automatic screenshot generation 2024-05-09 23:17:09 +02:00
Hosted Weblate
360e7767bd Translated using Weblate (Portuguese)
Currently translated at 100.0% (366 of 366 strings)

Co-authored-by: Celso Azevedo <mail@celsoazevedo.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/evmap/android/pt/
Translation: EVMap/Android
2024-05-08 22:27:16 +02:00
Jean-Baptiste
5f0c9fd31d Enable automatic per app language (#340)
* Enable automatic per app language

* fix getAppLocale

---------

Co-authored-by: johan12345 <johan.forstner@gmail.com>
2024-05-08 22:19:09 +02:00
johan12345
536c884f23 fix crash introduced by 00b26d224f 2024-05-03 20:34:09 +02:00
johan12345
7daf5a0adb fix jumping position of slider in ChargepriceFragment
fixes #328
2024-04-28 19:30:57 +02:00
johan12345
862f2b06d8 remove broken resourcePlaceholders plugin, hardcode targetPackage in shortcuts.xml 2024-04-28 19:18:32 +02:00
johan12345
198a9ecc48 Fix snackbar action button color
fixes #337
2024-04-28 19:05:28 +02:00
johan12345
2762a32105 improve look of text input dialog
fixes #336
2024-04-28 18:52:41 +02:00
johan12345
8a83a80e75 Block ability to update filter profile name with nothing
fixes #335
2024-04-28 18:29:10 +02:00
johan12345
75e8569964 dismiss popupMenu when fragment is destroyed
fixes #331
2024-04-27 13:32:17 +02:00
johan12345
00b26d224f fix #329: Layer button reappears after screen rotation 2024-04-27 12:43:34 +02:00
Jean-Baptiste
836f42b299 Fix color of layer button (#334)
* Fix color of layer button

* keep the layers icon gray

---------

Co-authored-by: johan12345 <johan.forstner@gmail.com>
2024-04-27 12:24:19 +02:00
johan12345
3de994f09d update copyright in LICENSE 2024-04-26 22:41:39 +02:00
Jean-Baptiste
d78eda9d97 Update copyright (#333) 2024-04-26 22:40:45 +02:00
johan12345
ed4be05aed fix tests 2024-04-24 21:53:26 +02:00
34 changed files with 584 additions and 114 deletions

115
.github/workflows/screenshots.yml vendored Normal file
View File

@@ -0,0 +1,115 @@
on:
push:
branches:
- '*'
name: Generate Screenshots
jobs:
screenshot:
name: Generate screenshots
runs-on: ubuntu-latest
strategy:
matrix:
device:
- profile: pixel_6
api-level: 34
display_size: 1080x2336 # subtracted the 64px bottom navigation bar
type: phone
- profile: pixel_tablet
api-level: 34
display_size: 2560x1488 # subtracted the 64px navigation bar and 48px status bar
type: tenInch
env:
ANDROID_USER_HOME: /home/runner/.android
steps:
- name: Enable KVM group perms
run: |
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm
- name: Check out code
uses: actions/checkout@v4
- name: Retrieve debug keystore
env:
DEBUG_KEYSTORE_BASE64: ${{ secrets.DEBUG_KEYSTORE_BASE64 }}
run: |
mkdir ~/.config/.android
echo $DEBUG_KEYSTORE_BASE64 | base64 --decode > ~/.config/.android/debug.keystore
- name: Set up Java environment
uses: actions/setup-java@v3
with:
java-version: 17
distribution: 'zulu'
cache: 'gradle'
- name: Setup Android SDK
run: |
$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --install "cmdline-tools;latest"
rm -r $ANDROID_HOME/cmdline-tools/latest
mv $ANDROID_HOME/cmdline-tools/latest-2 $ANDROID_HOME/cmdline-tools/latest
$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --version
- name: AVD cache
uses: actions/cache@v3
id: avd-cache
with:
path: |
~/.android/avd/*
~/.android/adb*
key: ${{ runner.os }}-avd-api${{ matrix.device.api-level }}-${{ matrix.device.profile }}
- name: create AVD and generate snapshot for caching
if: steps.avd-cache.outputs.cache-hit != 'true'
uses: reactivecircus/android-emulator-runner@v2
with:
api-level: ${{ matrix.device.api-level }}
target: google_atd
arch: x86_64
profile: ${{ matrix.device.profile }}
force-avd-creation: false
ram-size: 2048M
disk-size: 4096M
emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none -no-metrics
disable-animations: true
script: echo "Generated AVD snapshot for caching."
- name: Build app
run: ./gradlew assembleGoogleNormalDebug assembleGoogleNormalAndroidTest
env:
GOINGELECTRIC_API_KEY: ${{ secrets.GOINGELECTRIC_API_KEY }}
OPENCHARGEMAP_API_KEY: ${{ secrets.OPENCHARGEMAP_API_KEY }}
MAPBOX_API_KEY: ${{ secrets.MAPBOX_API_KEY }}
GOOGLE_MAPS_API_KEY: ${{ secrets.GOOGLE_MAPS_API_KEY }}
FRONYX_API_KEY: ${{ secrets.FRONYX_API_KEY }}
ACRA_CRASHREPORT_CREDENTIALS: ${{ secrets.ACRA_CRASHREPORT_CREDENTIALS }}
- name: Run emulator and generate screenshots
uses: reactivecircus/android-emulator-runner@v2
with:
api-level: ${{ matrix.device.api-level }}
target: google_atd
arch: x86_64
profile: ${{ matrix.device.profile }}
force-avd-creation: false
ram-size: 2048M
disk-size: 4096M
emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none -no-metrics
disable-animations: true
script: |
adb shell pm clear net.vonforst.evmap.debug || true
adb shell wm size ${{ matrix.device.display_size }}
adb logcat -c
adb logcat *:E &
fastlane screengrab --app_apk_path app/build/outputs/apk/googleNormal/debug/app-google-normal-debug.apk --test_apk_path app/build/outputs/apk/androidTest/googleNormal/debug/app-google-normal-debug-androidTest.apk --tests_package_name=net.vonforst.evmap.debug.test --app_package_name net.vonforst.evmap.debug -p net.vonforst.evmap.screenshot --use_timestamp_suffix false --clear_previous_screenshots true --device_type=${{ matrix.device.type }} -q en-US,de-DE,fr-FR,nb-rNO,nl-NL,pt-PT,ro-RO
- name: Upload screenshots as artifacts
uses: actions/upload-artifact@v3
with:
name: screenshots-${{ matrix.device.profile }}-${{ matrix.device.api-level }}
path: fastlane/metadata/android

View File

@@ -1,6 +1,6 @@
MIT License
Copyright (c) 2020-2023 Johan von Forstner and contributors
Copyright (c) 2020-2024 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

View File

@@ -8,10 +8,8 @@ plugins {
id("kotlin-kapt")
id("androidx.navigation.safeargs.kotlin")
id("com.mikepenz.aboutlibraries.plugin")
id("pt.jcosta.resourceplaceholders")
}
val supportedLocales = "en,de,fr,nb-rNO,nl,pt,ro,cs"
android {
defaultConfig {
@@ -24,8 +22,6 @@ android {
versionName = "1.8.2"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
resourceConfigurations += supportedLocales.split(",")
buildConfigField("String", "supportedLocales", "\"$supportedLocales\"")
}
signingConfigs {
@@ -101,6 +97,9 @@ android {
disable += listOf("NullSafeMutableLiveData")
warning += listOf("MissingTranslation")
}
androidResources {
generateLocaleConfig = true
}
testOptions {
unitTests {
@@ -108,9 +107,6 @@ android {
}
}
resourcePlaceholders {
files("xml/shortcuts.xml")
}
namespace = "net.vonforst.evmap"
// add API keys from environment variable if not set in apikeys.xml
@@ -329,6 +325,7 @@ dependencies {
debugImplementation("com.facebook.flipper:flipper:0.238.0")
debugImplementation("com.facebook.soloader:soloader:0.10.5")
debugImplementation("com.facebook.flipper:flipper-network-plugin:0.238.0")
debugImplementation("androidx.test.espresso:espresso-idling-resource:3.5.1")
// testing
testImplementation("junit:junit:4.13.2")
@@ -339,11 +336,14 @@ dependencies {
testImplementation("androidx.test:core:1.5.0")
testImplementation("androidx.arch.core:core-testing:2.2.0")
testImplementation("androidx.car.app:app-testing:$carAppVersion")
testImplementation("androidx.test:core:1.5.0")
androidTestImplementation("androidx.test.ext:junit:1.1.5")
androidTestImplementation("androidx.test.uiautomator:uiautomator:2.2.0")
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
androidTestImplementation("androidx.test.espresso:espresso-contrib:3.5.1")
androidTestImplementation("androidx.test:rules:1.5.0")
androidTestImplementation("androidx.arch.core:core-testing:2.2.0")
androidTestImplementation("tools.fastlane:screengrab:2.1.1")
kapt("com.squareup.moshi:moshi-kotlin-codegen:1.15.0")

View File

@@ -0,0 +1,112 @@
/*
* Copyright (C) 2019 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package net.vonforst.evmap.screenshot
import android.view.View
import androidx.databinding.DataBindingUtil
import androidx.databinding.ViewDataBinding
import androidx.fragment.app.FragmentActivity
import androidx.test.core.app.ActivityScenario
import androidx.test.espresso.IdlingResource
import androidx.test.ext.junit.rules.ActivityScenarioRule
import java.util.UUID
/**
* An espresso idling resource implementation that reports idle status for all data binding
* layouts. Data Binding uses a mechanism to post messages which Espresso doesn't track yet.
*
* Since this application only uses fragments, the resource only checks the fragments and their
* children instead of the whole view tree.
*
* Tracking bug: https://github.com/android/android-test/issues/317
*/
class DataBindingIdlingResource(
activityScenarioRule: ActivityScenarioRule<out FragmentActivity>
) : IdlingResource {
// list of registered callbacks
private val idlingCallbacks = mutableListOf<IdlingResource.ResourceCallback>()
// give it a unique id to workaround an espresso bug where you cannot register/unregister
// an idling resource w/ the same name.
private val id = UUID.randomUUID().toString()
// holds whether isIdle is called and the result was false. We track this to avoid calling
// onTransitionToIdle callbacks if Espresso never thought we were idle in the first place.
private var wasNotIdle = false
lateinit var activity: FragmentActivity
override fun getName() = "DataBinding $id"
init {
monitorActivity(activityScenarioRule.scenario)
}
override fun isIdleNow(): Boolean {
val idle = !getBindings().any { it.hasPendingBindings() }
@Suppress("LiftReturnOrAssignment")
if (idle) {
if (wasNotIdle) {
// notify observers to avoid espresso race detector
idlingCallbacks.forEach { it.onTransitionToIdle() }
}
wasNotIdle = false
} else {
wasNotIdle = true
// check next frame
activity.findViewById<View>(android.R.id.content).postDelayed({
isIdleNow
}, 16)
}
return idle
}
override fun registerIdleTransitionCallback(callback: IdlingResource.ResourceCallback) {
idlingCallbacks.add(callback)
}
/**
* Sets the activity from an [ActivityScenario] to be used from [DataBindingIdlingResource].
*/
private fun monitorActivity(
activityScenario: ActivityScenario<out FragmentActivity>
) {
activityScenario.onActivity {
this.activity = it
}
}
/**
* Find all binding classes in all currently available fragments.
*/
private fun getBindings(): List<ViewDataBinding> {
val fragments = (activity as? FragmentActivity)
?.supportFragmentManager
?.fragments
val bindings =
fragments?.mapNotNull {
it.view?.getBinding()
} ?: emptyList()
val childrenBindings = fragments?.flatMap { it.childFragmentManager.fragments }
?.mapNotNull { it.view?.getBinding() } ?: emptyList()
return bindings + childrenBindings
}
}
private fun View.getBinding(): ViewDataBinding? = DataBindingUtil.getBinding(this)

View File

@@ -0,0 +1,155 @@
package net.vonforst.evmap.screenshot
import android.Manifest.permission.ACCESS_FINE_LOCATION
import android.content.Intent
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.IdlingRegistry
import androidx.test.espresso.action.ViewActions.click
import androidx.test.espresso.action.ViewActions.pressBack
import androidx.test.espresso.contrib.DrawerActions
import androidx.test.espresso.contrib.NavigationViewActions
import androidx.test.espresso.matcher.ViewMatchers.isRoot
import androidx.test.espresso.matcher.ViewMatchers.withId
import androidx.test.espresso.matcher.ViewMatchers.withText
import androidx.test.ext.junit.rules.ActivityScenarioRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.rule.GrantPermissionRule
import androidx.test.rule.GrantPermissionRule.grant
import kotlinx.coroutines.runBlocking
import net.vonforst.evmap.EXTRA_CHARGER_ID
import net.vonforst.evmap.EXTRA_LAT
import net.vonforst.evmap.EXTRA_LON
import net.vonforst.evmap.EspressoIdlingResource
import net.vonforst.evmap.MapsActivity
import net.vonforst.evmap.R
import net.vonforst.evmap.api.goingelectric.GEReferenceData
import net.vonforst.evmap.api.goingelectric.GoingElectricApiWrapper
import net.vonforst.evmap.model.Favorite
import net.vonforst.evmap.storage.AppDatabase
import net.vonforst.evmap.storage.PreferenceDataSource
import org.junit.Before
import org.junit.BeforeClass
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import tools.fastlane.screengrab.Screengrab
import tools.fastlane.screengrab.locale.LocaleTestRule
import java.time.Instant
@RunWith(AndroidJUnit4::class)
class ScreenshotTest {
companion object {
@JvmStatic
@BeforeClass
fun beforeAll() {
IdlingRegistry.getInstance().register(EspressoIdlingResource.countingIdlingResource)
Screengrab.setDefaultScreenshotStrategy { screenshotName, screenshotCallback ->
screenshotCallback.screenshotCaptured(
screenshotName,
androidx.test.core.app.takeScreenshot()
)
}
val context = InstrumentationRegistry.getInstrumentation().targetContext
val prefs = PreferenceDataSource(context)
prefs.dataSourceSet = true
prefs.welcomeDialogShown = true
prefs.privacyAccepted = true
prefs.opensourceDonationsDialogLastShown = Instant.now()
prefs.chargepriceMyVehicles = setOf("b58bc94d-d929-ad71-d95b-08b877bf76ba")
prefs.appStartCounter = 0
prefs.mapProvider = "google"
// insert favorites
val db = AppDatabase.getInstance(context)
val api = GoingElectricApiWrapper(
context.getString(R.string.goingelectric_key),
context = context
)
val ids = listOf(70774L to true, 40315L to true, 65330L to true, 62489L to false)
runBlocking {
val refData = api.getReferenceData().data as GEReferenceData
ids.forEachIndexed { i, (id, favorite) ->
val detail = api.getChargepointDetail(refData, id).data!!
db.chargeLocationsDao().insert(detail)
if (db.favoritesDao().findFavorite(id, "goingelectric") == null && favorite) {
db.favoritesDao().insert(
Favorite(
chargerId = id,
chargerDataSource = "goingelectric"
)
)
}
}
}
}
}
@get:Rule
val localeTestRule = LocaleTestRule()
@get:Rule
val activityRule: ActivityScenarioRule<MapsActivity> = ActivityScenarioRule(
Intent(
InstrumentationRegistry.getInstrumentation().targetContext,
MapsActivity::class.java
).apply {
putExtra(EXTRA_CHARGER_ID, 62489L)
putExtra(EXTRA_LAT, 53.099512)
putExtra(EXTRA_LON, 9.981884)
})
@get:Rule
val permissionRule: GrantPermissionRule = grant(ACCESS_FINE_LOCATION)
@Before
fun setUp() {
IdlingRegistry.getInstance().register(DataBindingIdlingResource(activityRule))
}
@Test
fun testTakeScreenshot() {
Thread.sleep(15000L)
Screengrab.screenshot("01_map_google")
onView(withId(R.id.topPart)).perform(click())
Thread.sleep(1000L)
Screengrab.screenshot("02_detail")
onView(withId(R.id.btnChargeprice)).perform(click())
Thread.sleep(5000L)
Screengrab.screenshot("03_prices")
onView(isRoot()).perform(pressBack())
Thread.sleep(500L)
onView(isRoot()).perform(pressBack())
Thread.sleep(2000L)
onView(withId(R.id.menu_filter)).perform(click())
Thread.sleep(1000L)
onView(withText(R.string.menu_edit_filters)).perform(click())
Thread.sleep(1000L)
Screengrab.screenshot("05_filters")
onView(isRoot()).perform(pressBack())
onView(withId(R.id.drawer_layout)).perform(DrawerActions.open())
onView(withId(R.id.nav_view)).perform(NavigationViewActions.navigateTo(R.id.favs))
Thread.sleep(10000L)
Screengrab.screenshot("04_favorites")
val context = InstrumentationRegistry.getInstrumentation().targetContext
PreferenceDataSource(context).mapProvider = "mapbox"
onView(isRoot()).perform(pressBack())
Thread.sleep(5000L)
Screengrab.screenshot("01_map_mapbox")
}
}

View File

@@ -1,4 +1,4 @@
package com.johan.evmap.storage
package net.vonforst.evmap.storage
import android.content.Context
import androidx.arch.core.executor.testing.InstantTaskExecutorRule

View File

@@ -1,5 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<manifest xmlns:tools="http://schemas.android.com/tools"
xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Permissions needed for fastlane screengrab -->
<!-- Allows unlocking your device and activating its screen so UI tests can succeed -->
<uses-permission android:name="android.permission.DISABLE_KEYGUARD" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<!-- Allows for storing and retrieving screenshots -->
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<!-- Allows changing locales -->
<uses-permission
android:name="android.permission.CHANGE_CONFIGURATION"
tools:ignore="ProtectedPermissions" />
<!-- Allows changing SystemUI demo mode -->
<uses-permission
android:name="android.permission.DUMP"
tools:ignore="ProtectedPermissions" />
<application>
<activity

View File

@@ -2,6 +2,8 @@ package net.vonforst.evmap
import android.content.Context
import android.os.Build
import androidx.test.espresso.IdlingRegistry
import androidx.test.espresso.idling.CountingIdlingResource
import com.facebook.flipper.android.AndroidFlipperClient
import com.facebook.flipper.plugins.databases.DatabasesFlipperPlugin
import com.facebook.flipper.plugins.inspector.DescriptorMapping
@@ -34,9 +36,29 @@ fun OkHttpClient.Builder.addDebugInterceptors(): OkHttpClient.Builder {
} catch (e: ClassNotFoundException) {
isRunningTest = false
}
if (!isRunningTest) {
this.addNetworkInterceptor(FlipperOkhttpInterceptor(networkFlipperPlugin))
}
return this
}
/**
* Contains a static reference to [IdlingResource], only available in the 'debug' build type.
*/
object EspressoIdlingResource {
private const val RESOURCE = "GLOBAL"
@JvmField
val countingIdlingResource = CountingIdlingResource(RESOURCE)
fun increment() {
countingIdlingResource.increment()
}
fun decrement() {
if (!countingIdlingResource.isIdleNow) {
countingIdlingResource.decrement()
}
}
}

View File

@@ -41,8 +41,7 @@
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/AppTheme"
android:localeConfig="@xml/locales_config">
android:theme="@style/AppTheme">
<meta-data
android:name="com.mapbox.ACCESS_TOKEN"

View File

@@ -1,5 +1,6 @@
package net.vonforst.evmap
import android.app.ActivityOptions
import android.app.PendingIntent
import android.content.ActivityNotFoundException
import android.content.Intent
@@ -206,18 +207,15 @@ class MapsActivity : AppCompatActivity(),
}
}
} else if (intent.hasExtra(EXTRA_CHARGER_ID)) {
deepLink = navController.createDeepLink()
.setDestination(R.id.map)
.setArguments(
MapFragmentArgs(
chargerId = intent.getLongExtra(EXTRA_CHARGER_ID, 0),
latLng = LatLng(
intent.getDoubleExtra(EXTRA_LAT, 0.0),
intent.getDoubleExtra(EXTRA_LON, 0.0)
)
).toBundle()
)
.createPendingIntent()
navController.navigate(
R.id.map, MapFragmentArgs(
chargerId = intent.getLongExtra(EXTRA_CHARGER_ID, 0),
latLng = LatLng(
intent.getDoubleExtra(EXTRA_LAT, 0.0),
intent.getDoubleExtra(EXTRA_LON, 0.0)
)
).toBundle()
)
} else if (intent.hasExtra(EXTRA_FAVORITES)) {
deepLink = navController.createDeepLink()
.setGraph(navGraph)

View File

@@ -1,7 +1,12 @@
package net.vonforst.evmap.fragment
import android.os.Bundle
import android.view.*
import android.view.LayoutInflater
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.MenuProvider
import androidx.databinding.DataBindingUtil
@@ -108,31 +113,19 @@ class FilterFragment : Fragment(), MenuProvider {
}
}
private fun saveProfile(error: Boolean = false) {
showEditTextDialog(requireContext()) { dialog, input ->
private fun saveProfile() {
showEditTextDialog(requireContext(), { dialog, input ->
vm.filterProfile.value?.let { profile ->
input.setText(profile.name)
}
if (error) {
input.error = getString(R.string.required)
}
dialog.setTitle(R.string.save_as_profile)
.setMessage(R.string.save_profile_enter_name)
.setPositiveButton(R.string.ok) { _, _ ->
if (input.text.isBlank()) {
saveProfile(true)
} else {
lifecycleScope.launch {
vm.saveAsProfile(input.text.toString())
findNavController().popBackStack()
}
}
}
.setNegativeButton(R.string.cancel) { _, _ ->
}
}
}, {
lifecycleScope.launch {
vm.saveAsProfile(it)
findNavController().popBackStack()
}
})
}
}

View File

@@ -183,20 +183,16 @@ class FilterProfilesFragment : Fragment() {
adapter = FilterProfilesAdapter(touchHelper, onDelete = { fp ->
delete(fp)
}, onRename = { fp ->
showEditTextDialog(requireContext()) { dialog, input ->
showEditTextDialog(requireContext(), { dialog, input ->
input.setText(fp.name)
dialog.setTitle(R.string.rename)
.setMessage(R.string.save_profile_enter_name)
.setPositiveButton(R.string.ok) { _, _ ->
lifecycleScope.launch {
vm.update(fp.copy(name = input.text.toString()))
}
}
.setNegativeButton(R.string.cancel) { _, _ ->
}
}
}, {
lifecycleScope.launch {
vm.update(fp.copy(name = it))
}
})
})
binding.filterProfilesList.apply {
this.adapter = this@FilterProfilesFragment.adapter

View File

@@ -44,7 +44,6 @@ import androidx.fragment.app.viewModels
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.Observer
import androidx.lifecycle.lifecycleScope
import androidx.navigation.findNavController
import androidx.navigation.fragment.FragmentNavigatorExtras
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
@@ -110,6 +109,7 @@ import net.vonforst.evmap.shouldUseImperialUnits
import net.vonforst.evmap.storage.PreferenceDataSource
import net.vonforst.evmap.ui.ChargerIconGenerator
import net.vonforst.evmap.ui.ClusterIconGenerator
import net.vonforst.evmap.ui.HideOnScrollFabBehavior
import net.vonforst.evmap.ui.MarkerAnimator
import net.vonforst.evmap.ui.chargerZ
import net.vonforst.evmap.ui.clusterZ
@@ -155,6 +155,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
private var connectionErrorSnackbar: Snackbar? = null
private var previousChargepointIds: Set<Long>? = null
private var mapTopPadding: Int = 0
private var popupMenu: PopupMenu? = null
private lateinit var clusterIconGenerator: ClusterIconGenerator
private lateinit var chargerIconGenerator: ChargerIconGenerator
@@ -729,6 +730,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
displaySearchResult(place, moveCamera = true)
}
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
updateBackPressedCallback()
@@ -1399,14 +1401,13 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
MenuCompat.setGroupDividerEnabled(popup.menu, true)
popup.setForceShowIcon(true)
popup.setOnMenuItemClickListener {
val navController = requireView().findNavController()
when (it.itemId) {
R.id.menu_edit_filters -> {
exitTransition = MaterialSharedAxis(MaterialSharedAxis.Z, true)
reenterTransition = MaterialSharedAxis(MaterialSharedAxis.Z, false)
lifecycleScope.launch {
vm.copyFiltersToCustom()
navController.safeNavigate(
findNavController().safeNavigate(
MapFragmentDirections.actionMapToFilterFragment()
)
}
@@ -1416,7 +1417,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
R.id.menu_manage_filter_profiles -> {
exitTransition = MaterialSharedAxis(MaterialSharedAxis.Z, true)
reenterTransition = MaterialSharedAxis(MaterialSharedAxis.Z, false)
navController.safeNavigate(
findNavController().safeNavigate(
MapFragmentDirections.actionMapToFilterProfilesFragment()
)
true
@@ -1496,6 +1497,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
}
}
popup.setTouchModal(false)
popupMenu = popup
popup.show()
}
@@ -1579,5 +1581,8 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
override fun onDestroy() {
super.onDestroy()
/* if we don't dismiss the popup menu, it will be recreated in some cases
(split-screen mode) and then have references to a destroyed fragment. */
popupMenu?.dismiss()
}
}

View File

@@ -41,7 +41,7 @@ class UiSettingsFragment : BaseSettingsFragment() {
override fun onResume() {
super.onResume()
langPref.value = getAppLocale()
langPref.value = getAppLocale(requireContext())
immediateNavPref.isVisible = isGoogleMapsInstalled()
}

View File

@@ -108,11 +108,14 @@ class PreferenceDataSource(val context: Context) {
val darkmode: String
get() = sp.getString("darkmode", "default")!!
val mapProvider: String
var mapProvider: String
get() = sp.getString(
"map_provider",
context.getString(R.string.pref_map_provider_default)
)!!
set(value) {
sp.edit().putString("map_provider", value).apply()
}
var searchProvider: String
get() = sp.getString(

View File

@@ -26,9 +26,13 @@ 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.*
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
@@ -69,7 +73,7 @@ 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.GONE
view.visibility = View.INVISIBLE
} else {
return
}

View File

@@ -1,10 +1,12 @@
package net.vonforst.evmap.ui
import android.content.Context
import androidx.appcompat.app.AppCompatDelegate
import androidx.core.os.LocaleListCompat
import net.vonforst.evmap.BuildConfig
import net.vonforst.evmap.R
import net.vonforst.evmap.storage.PreferenceDataSource
fun updateNightMode(prefs: PreferenceDataSource) {
AppCompatDelegate.setDefaultNightMode(
when (prefs.darkmode) {
@@ -25,13 +27,14 @@ fun updateAppLocale(language: String) {
)
}
fun getAppLocale(): String? {
fun getAppLocale(context: Context): String? {
val locales = AppCompatDelegate.getApplicationLocales()
return if (locales.isEmpty) {
"default"
} else {
val arr = Array(locales.size()) { locales.get(it)!!.toLanguageTag() }
LocaleListCompat.forLanguageTags(BuildConfig.supportedLocales).getFirstMatch(arr)
?.toLanguageTag()
val choices =
context.resources.getStringArray(R.array.pref_language_values).joinToString(",")
LocaleListCompat.forLanguageTags(choices).getFirstMatch(arr)?.toLanguageTag()
}
}
}

View File

@@ -10,50 +10,60 @@ import android.view.ViewGroup
import android.view.WindowManager
import android.view.inputmethod.EditorInfo
import android.widget.EditText
import android.widget.FrameLayout
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatDialogFragment
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.textfield.TextInputLayout
import net.vonforst.evmap.R
import kotlin.math.roundToInt
private fun dialogEditText(ctx: Context): Pair<View, EditText> {
val container = FrameLayout(ctx)
container.setPadding(
(16 * ctx.resources.displayMetrics.density).toInt(), 0,
(16 * ctx.resources.displayMetrics.density).toInt(), 0
)
val input = EditText(ctx)
input.isSingleLine = true
container.addView(input)
return container to input
private fun dialogEditText(ctx: Context): Pair<TextInputLayout, EditText> {
val view = LayoutInflater.from(ctx).inflate(R.layout.dialog_textinput, null)
return view as TextInputLayout to view.findViewById(R.id.input)
}
fun showEditTextDialog(
ctx: Context,
customize: (MaterialAlertDialogBuilder, EditText) -> Unit
customize: (MaterialAlertDialogBuilder, EditText) -> Unit,
okAction: (String) -> Unit
): AlertDialog {
val (container, input) = dialogEditText(ctx)
val dialogBuilder = MaterialAlertDialogBuilder(ctx)
.setView(container)
.setPositiveButton(R.string.ok) { _, _ -> }
.setNegativeButton(R.string.cancel) { _, _ -> }
customize(dialogBuilder, input)
val dialog = dialogBuilder.show()
dialog.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE)
val okButton = dialog.getButton(DialogInterface.BUTTON_POSITIVE)
// focus and show keyboard
input.requestFocus()
input.setOnEditorActionListener { _, actionId, _ ->
if (actionId == EditorInfo.IME_ACTION_DONE) {
val text = input.text
val button = dialog.getButton(DialogInterface.BUTTON_POSITIVE)
if (text != null && button != null) {
button.performClick()
if (text != null && okButton != null) {
okButton.performClick()
return@setOnEditorActionListener true
}
}
false
}
okButton?.setOnClickListener {
if (input.text.isBlank()) {
container.isErrorEnabled = true
container.error = ctx.getString(R.string.required)
} else {
container.isErrorEnabled = false
okAction(input.text.toString())
dialog.dismiss()
}
}
return dialog
}

View File

@@ -12,6 +12,13 @@ import com.mahc.custombottomsheetbehavior.BottomSheetBehaviorGoogleMapsLike
class HideOnScrollFabBehavior(context: Context, attrs: AttributeSet) :
FloatingActionButton.Behavior(context, attrs) {
var hidden: Boolean = false
companion object {
fun from(view: View): HideOnScrollFabBehavior? {
return ((view.layoutParams as? CoordinatorLayout.LayoutParams)?.behavior as? HideOnScrollFabBehavior)
}
}
override fun onStartNestedScroll(
coordinatorLayout: CoordinatorLayout,
@@ -61,13 +68,13 @@ class HideOnScrollFabBehavior(context: Context, attrs: AttributeSet) :
child: FloatingActionButton,
dependency: View
): Boolean {
val behavior = BottomSheetBehaviorGoogleMapsLike.from<View>(dependency)
val behavior = BottomSheetBehaviorGoogleMapsLike.from(dependency)
when (behavior.state) {
BottomSheetBehaviorGoogleMapsLike.STATE_SETTLING -> {
}
BottomSheetBehaviorGoogleMapsLike.STATE_HIDDEN -> {
child.show()
if (!hidden) child.show()
}
else -> {
child.hide()
@@ -103,7 +110,7 @@ class HideOnScrollFabBehavior(context: Context, attrs: AttributeSet) :
child.hide()
} else if (dyConsumed < 0 && child.visibility != View.VISIBLE) {
// User scrolled up and the FAB is currently not visible -> show the FAB
child.show()
if (!hidden) child.show()
}
}
}

View File

@@ -8,6 +8,7 @@ import jsonapi.Relationships
import jsonapi.ResourceIdentifier
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import net.vonforst.evmap.EspressoIdlingResource
import net.vonforst.evmap.api.chargeprice.*
import net.vonforst.evmap.api.equivalentPlugTypes
import net.vonforst.evmap.model.ChargeLocation
@@ -48,6 +49,7 @@ class ChargepriceViewModel(
} else {
value = Resource.loading(null)
viewModelScope.launch {
EspressoIdlingResource.increment()
value = try {
val result = api.getVehicles()
Resource.success(result.filter {
@@ -57,6 +59,8 @@ class ChargepriceViewModel(
Resource.error(e.message, null)
} catch (e: HttpException) {
Resource.error(e.message, null)
} finally {
EspressoIdlingResource.decrement()
}
}
}
@@ -253,6 +257,7 @@ class ChargepriceViewModel(
loadPricesJob?.cancel()
loadPricesJob = viewModelScope.launch {
EspressoIdlingResource.increment()
try {
val result = api.getChargePrices(
ChargepriceRequest(
@@ -295,6 +300,8 @@ class ChargepriceViewModel(
} catch (e: HttpException) {
chargePrices.value = Resource.error(e.message, null)
chargePriceMeta.value = Resource.error(e.message, null)
} finally {
EspressoIdlingResource.decrement()
}
}
}

View File

@@ -158,7 +158,6 @@
android:textColor="@android:color/white"
app:backgroundTintAvailability="@{BindingAdaptersKt.flatten(filteredAvailability.data.status.values())}"
app:invisibleUnless="@{filteredAvailability.data != null &amp;&amp; !expanded}"
app:invisibleUnlessAnimated="@{filteredAvailability.data != null &amp;&amp; !expanded}"
app:layout_constraintEnd_toStartOf="@+id/guideline2"
app:layout_constraintTop_toTopOf="@+id/txtName"
tools:backgroundTint="@color/available"

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<com.google.android.material.textfield.TextInputLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingLeft="16dp"
android:paddingRight="16dp">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/input"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:maxLines="1" />
</com.google.android.material.textfield.TextInputLayout>

View File

@@ -238,7 +238,8 @@
android:layout_gravity="top|end"
android:layout_marginEnd="20dp"
android:layout_marginTop="@dimen/layers_fab_top_padding"
app:tint="?android:colorControlNormal"
app:tint="?colorControlNormal"
app:backgroundTint="?android:colorBackground"
app:borderWidth="0dp"
app:srcCompat="@drawable/ic_layers"
app:layout_behavior="@string/hide_on_scroll_fab_behavior"
@@ -261,4 +262,4 @@
app:vm="@{vm}" />
</androidx.cardview.widget.CardView>
</androidx.coordinatorlayout.widget.CoordinatorLayout>
</layout>
</layout>

View File

@@ -0,0 +1 @@
unqualifiedResLocale=en-US

View File

@@ -380,4 +380,7 @@
<string name="status_faulted">Fora de serviço</string>
<string name="status_since">%1$s desde %2$s</string>
<string name="status_unknown">Estado Desconhecido</string>
<string name="pref_chargeprice_native_integration">Comparação de preços no EVMap</string>
<string name="pref_chargeprice_native_integration_on">Os preços serão exibidos diretamente no EVMap</string>
<string name="pref_chargeprice_native_integration_off">O botão de comparação de preços abrirá a app ou site do Chargeprice</string>
</resources>

View File

@@ -46,6 +46,6 @@
<string name="referral_maingau" translatable="false">Maingau</string>
<string name="referral_ewieeinfach" translatable="false">E wie einfach</string>
<string name="referral_eprimo" translatable="false">eprimo</string>
<string name="copyright_summary">©20202023 Johan von Forstner and contributors</string>
<string name="copyright_summary">©20202024 Johan von Forstner and contributors</string>
<string name="acra_backend_url" translatable="false">https://acra.muc.vonforst.net/report</string>
</resources>
</resources>

View File

@@ -15,6 +15,7 @@
<item name="preferenceTheme">@style/AppTheme.Preference</item>
<item name="alertDialogTheme">@style/AppTheme.AlertDialog</item>
<item name="materialAlertDialogTheme">@style/AppTheme.AlertDialog</item>
<item name="snackbarButtonStyle">@style/Button.TextButton.Snackbar.App</item>
</style>
<style name="AppTheme.Preference" parent="@style/PreferenceThemeOverlay">
@@ -82,6 +83,10 @@
<item name="backgroundInsetBottom">24dp</item>
</style>
<style name="Button.TextButton.Snackbar.App" parent="Widget.Material3.Button.TextButton.Snackbar">
<item name="android:textColor">@color/colorPrimary</item>
</style>
<style name="CarAppTheme">
<item name="carColorPrimary">@color/colorPrimary</item>
<item name="carColorPrimaryDark">@color/colorPrimaryDark</item>

View File

@@ -1,7 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<locale-config xmlns:android="http://schemas.android.com/apk/res/android">
<locale android:name="en" />
<locale android:name="de" />
<locale android:name="fr" />
<locale android:name="nb-NO" />
</locale-config>

View File

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

View File

@@ -7,4 +7,10 @@ fun addDebugInterceptors(context: Context) {
}
fun OkHttpClient.Builder.addDebugInterceptors(): OkHttpClient.Builder = this
fun OkHttpClient.Builder.addDebugInterceptors(): OkHttpClient.Builder = this
object EspressoIdlingResource {
fun increment() {}
fun decrement() {}
}

View File

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

View File

@@ -60,7 +60,7 @@ class ChargepriceApiTest {
fun apiTest() {
for (chargepoint in listOf(2105L, 18284L)) {
val charger = runBlocking { ge.getChargepointDetail(chargepoint).body()!! }
.chargelocations[0].convert("", true) as ChargeLocation
.chargelocations!![0].convert("", true) as ChargeLocation
println(charger)
runBlocking {

View File

@@ -63,8 +63,8 @@ class GoingElectricApiTest {
val body = response.body()!!
assertEquals("ok", body.status)
assertEquals(null, body.startkey)
assertEquals(1, body.chargelocations.size)
val charger = body.chargelocations[0] as GEChargeLocation
assertEquals(1, body.chargelocations!!.size)
val charger = body.chargelocations!![0] as GEChargeLocation
assertEquals(2105, charger.id)
}
@@ -75,8 +75,8 @@ class GoingElectricApiTest {
val body = response.body()!!
assertEquals("ok", body.status)
assertEquals(null, body.startkey)
assertEquals(1, body.chargelocations.size)
val charger = body.chargelocations[0] as GEChargeLocation
assertEquals(1, body.chargelocations!!.size)
val charger = body.chargelocations!![0] as GEChargeLocation
assertEquals(34210, charger.id)
assertEquals(LocalTime.MIN, charger.openinghours!!.days!!.monday.start)
assertEquals(LocalTime.MAX, charger.openinghours!!.days!!.monday.end)
@@ -92,8 +92,8 @@ class GoingElectricApiTest {
val body = response.body()!!
assertEquals("ok", body.status)
assertEquals(null, body.startkey)
assertEquals(2, body.chargelocations.size)
val charger = body.chargelocations[0] as GEChargeLocation
assertEquals(2, body.chargelocations!!.size)
val charger = body.chargelocations!![0] as GEChargeLocation
assertEquals(41161, charger.id)
}
@@ -106,7 +106,7 @@ class GoingElectricApiTest {
val body = response.body()!!
assertEquals("ok", body.status)
assertEquals(null, body.startkey)
assertEquals(0, body.chargelocations.size)
assertEquals(0, body.chargelocations!!.size)
}
@Test
@@ -118,8 +118,8 @@ class GoingElectricApiTest {
val body = response.body()!!
assertEquals("ok", body.status)
assertEquals(2, body.startkey)
assertEquals(2, body.chargelocations.size)
val charger = body.chargelocations[0] as GEChargeLocation
assertEquals(2, body.chargelocations!!.size)
val charger = body.chargelocations!![0] as GEChargeLocation
assertEquals(41161, charger.id)
}
}

View File

@@ -14,7 +14,6 @@ buildscript {
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion")
classpath("com.mikepenz.aboutlibraries.plugin:aboutlibraries-plugin:$aboutLibsVersion")
classpath("androidx.navigation:navigation-safe-args-gradle-plugin:$navVersion")
classpath("pt.jcosta.resourceplaceholders:plugin:0.7")
// NOTE: Do not place your application dependencies here; they belong
// in the individual module build.gradle files