From abec208768cb2b3690c6fbfef7278302876ffafb Mon Sep 17 00:00:00 2001 From: johan12345 Date: Fri, 24 Jun 2022 19:49:39 +0200 Subject: [PATCH] Android Auto: start implementing creation of filter profiles #172 --- .../net/vonforst/evmap/auto/FilterScreen.kt | 208 ++++++++++++++++-- .../java/net/vonforst/evmap/auto/MapScreen.kt | 11 +- .../vonforst/evmap/auto/TextPromptScreen.kt | 35 +++ .../vonforst/evmap/storage/FilterValueDao.kt | 12 + .../vonforst/evmap/viewmodel/MapViewModel.kt | 10 +- 5 files changed, 250 insertions(+), 26 deletions(-) create mode 100644 app/src/google/java/net/vonforst/evmap/auto/TextPromptScreen.kt diff --git a/app/src/google/java/net/vonforst/evmap/auto/FilterScreen.kt b/app/src/google/java/net/vonforst/evmap/auto/FilterScreen.kt index d3374e49..8afc3c61 100644 --- a/app/src/google/java/net/vonforst/evmap/auto/FilterScreen.kt +++ b/app/src/google/java/net/vonforst/evmap/auto/FilterScreen.kt @@ -1,25 +1,31 @@ package net.vonforst.evmap.auto +import android.app.Application import androidx.car.app.CarContext import androidx.car.app.Screen +import androidx.car.app.constraints.ConstraintManager import androidx.car.app.model.* import androidx.core.graphics.drawable.IconCompat import androidx.lifecycle.LiveData +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.launch import net.vonforst.evmap.R -import net.vonforst.evmap.model.FILTERS_CUSTOM -import net.vonforst.evmap.model.FILTERS_DISABLED -import net.vonforst.evmap.model.FILTERS_FAVORITES +import net.vonforst.evmap.model.* import net.vonforst.evmap.storage.AppDatabase import net.vonforst.evmap.storage.FilterProfile import net.vonforst.evmap.storage.PreferenceDataSource +import net.vonforst.evmap.viewmodel.FilterViewModel +@androidx.car.app.annotations.ExperimentalCarApi class FilterScreen(ctx: CarContext) : Screen(ctx) { private val prefs = PreferenceDataSource(ctx) private val db = AppDatabase.getInstance(ctx) val filterProfiles: LiveData> by lazy { db.filterProfileDao().getProfiles(prefs.dataSource) } - private val maxRows = 6 + private val maxRows = if (ctx.carAppApiLevel >= 2) { + ctx.constraintManager.getContentLimit(ConstraintManager.CONTENT_LIMIT_TYPE_LIST) + } else 6 private val checkedIcon = CarIcon.Builder( IconCompat.createWithResource( @@ -47,11 +53,10 @@ class FilterScreen(ctx: CarContext) : Screen(ctx) { override fun onGetTemplate(): Template { val filterStatus = - prefs.filterStatus.takeUnless { it == FILTERS_CUSTOM || it == FILTERS_FAVORITES } - ?: FILTERS_DISABLED + prefs.filterStatus.takeUnless { it == FILTERS_FAVORITES } ?: FILTERS_DISABLED return ListTemplate.Builder().apply { filterProfiles.value?.let { - setSingleList(buildFilterProfilesList(it.take(maxRows), filterStatus)) + setSingleList(buildFilterProfilesList(it, filterStatus)) } ?: setLoading(true) setTitle(carContext.getString(R.string.menu_filter)) setHeaderAction(Action.BACK) @@ -62,6 +67,8 @@ class FilterScreen(ctx: CarContext) : Screen(ctx) { profiles: List, filterStatus: Long ): ItemList { + val extraRows = if (FILTERS_CUSTOM == filterStatus) 3 else 2 + val profilesToShow = profiles.take(maxRows - extraRows) return ItemList.Builder().apply { addItem(Row.Builder().apply { setTitle(carContext.getString(R.string.no_filters)) @@ -71,11 +78,10 @@ class FilterScreen(ctx: CarContext) : Screen(ctx) { setImage(uncheckedIcon) } setOnClickListener { - prefs.filterStatus = FILTERS_DISABLED - screenManager.pop() + onItemClick(FILTERS_DISABLED) } }.build()) - profiles.forEach { + profilesToShow.forEach { addItem(Row.Builder().apply { val name = it.name.ifEmpty { carContext.getString(R.string.unnamed_filter_profile) } @@ -86,12 +92,188 @@ class FilterScreen(ctx: CarContext) : Screen(ctx) { setImage(uncheckedIcon) } setOnClickListener { - prefs.filterStatus = it.id - screenManager.pop() + onItemClick(it.id) } }.build()) } - setNoItemsMessage(carContext.getString(R.string.filterprofiles_empty_state)) + if (FILTERS_CUSTOM == filterStatus) { + addItem(Row.Builder().apply { + setTitle(carContext.getString(R.string.filter_custom)) + setImage(checkedIcon) + setOnClickListener { + onItemClick(FILTERS_CUSTOM) + } + }.build()) + } + + addItem(Row.Builder().apply { + setTitle(carContext.getString(R.string.menu_edit_filters)) + setOnClickListener(ParkedOnlyOnClickListener.create { + lifecycleScope.launch { + db.filterValueDao().copyFiltersToCustom(filterStatus, prefs.dataSource) + screenManager.push(EditFiltersScreen(carContext)) + } + }) + }.build()) }.build() } + + private fun onItemClick(id: Long) { + prefs.filterStatus = id + screenManager.pop() + } +} + +@androidx.car.app.annotations.ExperimentalCarApi +class EditFiltersScreen(ctx: CarContext) : Screen(ctx) { + private val vm = FilterViewModel(carContext.applicationContext as Application) + + private val maxRows = if (ctx.carAppApiLevel >= 2) { + ctx.constraintManager.getContentLimit(ConstraintManager.CONTENT_LIMIT_TYPE_LIST) + } else 6 + + init { + vm.filtersWithValue.observe(this) { + vm.filterProfile.observe(this) { + invalidate() + } + } + } + + override fun onGetTemplate(): Template { + val currentProfileName = vm.filterProfile.value?.name + + return ListTemplate.Builder().apply { + vm.filtersWithValue.value?.let { filtersWithValue -> + setSingleList(buildFiltersList(filtersWithValue.take(maxRows))) + } ?: setLoading(true) + + setTitle(currentProfileName?.let { + carContext.getString( + R.string.edit_filter_profile, + it + ) + } ?: carContext.getString(R.string.menu_filter)) + + setHeaderAction(Action.BACK) + setActionStrip(ActionStrip.Builder().apply { + addAction(Action.Builder() + .setIcon( + CarIcon.Builder( + IconCompat.createWithResource( + carContext, + R.drawable.ic_check + ) + ).build() + ) + .setOnClickListener { + lifecycleScope.launch { + vm.saveFilterValues() + screenManager.popTo(MapScreen.MARKER) + } + } + .build() + ) + addAction(Action.Builder() + .setIcon( + CarIcon.Builder( + IconCompat.createWithResource( + carContext, + R.drawable.ic_save + ) + ).build() + ) + .setOnClickListener { + val textPromptScreen = TextPromptScreen( + carContext, + R.string.save_as_profile, + R.string.save_profile_enter_name, + currentProfileName + ) + screenManager.pushForResult(textPromptScreen) { name -> + if (name == null) return@pushForResult + lifecycleScope.launch { + vm.saveAsProfile(name as String) + screenManager.popTo(MapScreen.MARKER) + } + } + } + .build() + ) + }.build()) + }.build() + } + + private fun buildFiltersList(filters: List>): ItemList { + return ItemList.Builder().apply { + filters.forEach { + val filter = it.filter + val value = it.value + addItem(Row.Builder().apply { + setTitle(filter.name) + when (filter) { + is BooleanFilter -> { + setToggle(Toggle.Builder { + (value as BooleanFilterValue).value = it + }.setChecked((value as BooleanFilterValue).value).build()) + } + is MultipleChoiceFilter -> { + setOnClickListener { + screenManager.push( + MultipleChoiceFilterScreen( + carContext, + filter, + value as MultipleChoiceFilterValue + ) + ) + } + } + is SliderFilter -> { + // TODO: toggle through possible options on click? + } + } + }.build()) + } + }.build() + } +} + +class MultipleChoiceFilterScreen( + ctx: CarContext, + val filter: MultipleChoiceFilter, + val value: MultipleChoiceFilterValue +) : MultiSelectSearchScreen>(ctx) { + override val isMultiSelect = true + override val shouldShowSelectAll = true + + override fun isSelected(it: Pair): Boolean = + value.all || value.values.contains(it.first) + + override fun toggleSelected(item: Pair) { + if (isSelected(item)) { + val values = if (value.all) filter.choices.keys else value.values + value.values = values.minus(item.first).toMutableSet() + value.all = false + } else { + value.values.add(item.first) + if (value.values == filter.choices.keys) { + value.all = true + } + } + } + + override fun selectAll() { + value.all = true + } + + override fun selectNone() { + value.all = false + value.values = mutableSetOf() + } + + override fun getLabel(it: Pair): String = it.second + + override suspend fun loadData(): List> { + return filter.choices.entries.map { it.toPair() } + } } \ No newline at end of file diff --git a/app/src/google/java/net/vonforst/evmap/auto/MapScreen.kt b/app/src/google/java/net/vonforst/evmap/auto/MapScreen.kt index 8478319e..1b9cf1d4 100644 --- a/app/src/google/java/net/vonforst/evmap/auto/MapScreen.kt +++ b/app/src/google/java/net/vonforst/evmap/auto/MapScreen.kt @@ -23,7 +23,6 @@ import net.vonforst.evmap.api.availability.getAvailability import net.vonforst.evmap.api.createApi import net.vonforst.evmap.api.stringProvider import net.vonforst.evmap.model.ChargeLocation -import net.vonforst.evmap.model.FILTERS_CUSTOM import net.vonforst.evmap.model.FILTERS_DISABLED import net.vonforst.evmap.model.FILTERS_FAVORITES import net.vonforst.evmap.storage.AppDatabase @@ -48,6 +47,10 @@ import kotlin.math.roundToInt class MapScreen(ctx: CarContext, val session: EVMapSession, val favorites: Boolean = false) : Screen(ctx), LocationAwareScreen, OnContentRefreshListener, ItemList.OnItemVisibilityChangedListener { + companion object { + val MARKER = "map" + } + private var updateCoroutine: Job? = null private var availabilityUpdateCoroutine: Job? = null @@ -73,8 +76,7 @@ class MapScreen(ctx: CarContext, val session: EVMapSession, val favorites: Boole private val referenceData = api.getReferenceData(lifecycleScope, carContext) private val filterStatus = MutableLiveData().apply { - value = prefs.filterStatus.takeUnless { it == FILTERS_CUSTOM || it == FILTERS_FAVORITES } - ?: FILTERS_DISABLED + value = prefs.filterStatus.takeUnless { it == FILTERS_FAVORITES } ?: FILTERS_DISABLED } private val filterValues = db.filterValueDao().getFilterValues(filterStatus, prefs.dataSource) private val filters = @@ -99,6 +101,7 @@ class MapScreen(ctx: CarContext, val session: EVMapSession, val favorites: Boole filtersWithValue.observe(this) { loadChargers() } + marker = MARKER } override fun onGetTemplate(): Template { @@ -160,7 +163,7 @@ class MapScreen(ctx: CarContext, val session: EVMapSession, val favorites: Boole screenManager.pushForResult(FilterScreen(carContext)) { chargers = null filterStatus.value = - prefs.filterStatus.takeUnless { it == FILTERS_CUSTOM || it == FILTERS_FAVORITES } + prefs.filterStatus.takeUnless { it == FILTERS_FAVORITES } ?: FILTERS_DISABLED } session.mapScreen = null diff --git a/app/src/google/java/net/vonforst/evmap/auto/TextPromptScreen.kt b/app/src/google/java/net/vonforst/evmap/auto/TextPromptScreen.kt new file mode 100644 index 00000000..b6dd5b35 --- /dev/null +++ b/app/src/google/java/net/vonforst/evmap/auto/TextPromptScreen.kt @@ -0,0 +1,35 @@ +package net.vonforst.evmap.auto + +import androidx.annotation.StringRes +import androidx.car.app.CarContext +import androidx.car.app.Screen +import androidx.car.app.model.Action +import androidx.car.app.model.InputCallback +import androidx.car.app.model.Template +import androidx.car.app.model.signin.InputSignInMethod +import androidx.car.app.model.signin.SignInTemplate + +class TextPromptScreen( + ctx: CarContext, + @StringRes val title: Int, + @StringRes val prompt: Int, + val initialValue: String? = null +) : Screen(ctx), + InputCallback { + override fun onGetTemplate(): Template { + val signInMethod = InputSignInMethod.Builder(this).apply { + initialValue?.let { setDefaultValue(it) } + setShowKeyboardByDefault(true) + }.build() + return SignInTemplate.Builder(signInMethod).apply { + setHeaderAction(Action.BACK) + setInstructions(carContext.getString(prompt)) + setTitle(carContext.getString(title)) + }.build() + } + + override fun onInputSubmitted(text: String) { + setResult(text) + screenManager.pop() + } +} \ No newline at end of file diff --git a/app/src/main/java/net/vonforst/evmap/storage/FilterValueDao.kt b/app/src/main/java/net/vonforst/evmap/storage/FilterValueDao.kt index fd1ebd4f..da71e9ad 100644 --- a/app/src/main/java/net/vonforst/evmap/storage/FilterValueDao.kt +++ b/app/src/main/java/net/vonforst/evmap/storage/FilterValueDao.kt @@ -4,6 +4,7 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.MediatorLiveData import androidx.lifecycle.MutableLiveData import androidx.room.* +import net.vonforst.evmap.await import net.vonforst.evmap.model.* @Dao @@ -92,4 +93,15 @@ abstract class FilterValueDao { deleteSliderFilterValuesForProfile(profile, dataSource) } + @Transaction + open suspend fun copyFiltersToCustom(filterStatus: Long, dataSource: String) { + if (filterStatus == FILTERS_CUSTOM) return + + deleteFilterValuesForProfile(FILTERS_CUSTOM, dataSource) + val values = getFilterValues(filterStatus, dataSource).await().onEach { + it.profile = FILTERS_CUSTOM + } + insert(*values.toTypedArray()) + } + } \ No newline at end of file diff --git a/app/src/main/java/net/vonforst/evmap/viewmodel/MapViewModel.kt b/app/src/main/java/net/vonforst/evmap/viewmodel/MapViewModel.kt index 0eae57ee..bc652bd2 100644 --- a/app/src/main/java/net/vonforst/evmap/viewmodel/MapViewModel.kt +++ b/app/src/main/java/net/vonforst/evmap/viewmodel/MapViewModel.kt @@ -270,15 +270,7 @@ class MapViewModel(application: Application, private val state: SavedStateHandle } suspend fun copyFiltersToCustom() { - if (filterStatus.value == FILTERS_CUSTOM) return - - db.filterValueDao().deleteFilterValuesForProfile(FILTERS_CUSTOM, prefs.dataSource) - filterValues.value?.map { - it.profile = FILTERS_CUSTOM - it - }?.let { - db.filterValueDao().insert(*it.toTypedArray()) - } + filterStatus.value?.let { db.filterValueDao().copyFiltersToCustom(it, prefs.dataSource) } } fun setMapType(type: AnyMap.Type) {