Fancy animation for quick affordance option picker
Building on the companion WallpaperPicker2, this CL achieves a refactor
that allows the UI to actually animate user selection of quick
affordance items.
Part of this CL is a refactor that extracts out an "option item
framework" out into the WallpaperPicker2 code repository (please see the
other CL for thate).
Another part of this CL is a heavy refactor of
KeyguardQuickAffordancePickerViewModel which is the main view-model for
the "shortcuts" full-screen experience. Namely, we had to change it to
emit a stable list of option items (one for each lock screen shortcut)
where the _contents_ of it can change (namely, isSelected, onSelected
have both become flows). This required some careful restructuring of the
logic that populate the quickAffordances flow in that class.
Fix: 266116562
Test: existing view-model integration test updated to match new field
types, still passes without logical changes to the test
Test: manually verified the correctness of the UI in Walpaper & style >
Shortcuts
Test: manually verified animations: they do not happen when switching
tabs, they do not happen on initial load. They do happen when I select a
new affordance (both on the newly-selected one and the
previously-selected one).
Change-Id: I985afdfbbc72d0b98859df3378f2c90d2397e7d4
diff --git a/res/drawable/keyguard_quick_affordance_icon_container_background.xml b/res/drawable/keyguard_quick_affordance_icon_container_background.xml
deleted file mode 100644
index 8bd8af4..0000000
--- a/res/drawable/keyguard_quick_affordance_icon_container_background.xml
+++ /dev/null
@@ -1,20 +0,0 @@
-<!--
- Copyright (C) 2021 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.
--->
-<shape xmlns:android="http://schemas.android.com/apk/res/android"
- android:shape="rectangle">
- <corners android:radius="20dp" />
- <solid android:color="@color/color_surface_variant" />
-</shape>
diff --git a/res/drawable/keyguard_quick_affordance_icon_container_background_selected.xml b/res/drawable/keyguard_quick_affordance_icon_container_background_selected.xml
deleted file mode 100644
index 6b1ab28..0000000
--- a/res/drawable/keyguard_quick_affordance_icon_container_background_selected.xml
+++ /dev/null
@@ -1,48 +0,0 @@
-<!--
- Copyright (C) 2021 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.
--->
-<layer-list
- xmlns:android="http://schemas.android.com/apk/res/android"
- >
-
- <item
- android:left="6dp"
- android:top="6dp"
- android:right="6dp"
- android:bottom="6dp"
- >
- <shape
- android:layout_width="wrap_content"
- android:shape="rectangle">
-
- <solid android:color="@color/color_surface_variant" />
-
- <corners android:radius="14dp" />
-
- </shape>
- </item>
-
- <item>
- <shape android:shape="rectangle" >
-
- <stroke
- android:width="2dp"
- android:color="@color/text_color_primary" />
-
- <corners android:radius="20dp" />
-
- </shape>
- </item>
-</layer-list>
diff --git a/res/layout/fragment_lock_screen_quick_affordances.xml b/res/layout/fragment_lock_screen_quick_affordances.xml
index b167b5f..aba9a0c 100644
--- a/res/layout/fragment_lock_screen_quick_affordances.xml
+++ b/res/layout/fragment_lock_screen_quick_affordances.xml
@@ -18,7 +18,8 @@
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
- android:orientation="vertical">
+ android:orientation="vertical"
+ android:clipChildren="false">
<FrameLayout
android:id="@+id/section_header_container"
@@ -53,7 +54,8 @@
android:layout_marginBottom="28dp"
android:background="@drawable/picker_fragment_background"
android:paddingTop="22dp"
- android:paddingBottom="62dp">
+ android:paddingBottom="62dp"
+ android:clipChildren="false">
<FrameLayout
android:layout_width="match_parent"
@@ -91,14 +93,16 @@
<FrameLayout
android:layout_width="match_parent"
- android:layout_height="wrap_content">
+ android:layout_height="wrap_content"
+ android:clipChildren="false">
<androidx.recyclerview.widget.RecyclerView
android:id="@id/affordances"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:clipToPadding="false"
- android:paddingHorizontal="16dp" />
+ android:paddingHorizontal="16dp"
+ android:clipChildren="false" />
<!--
This is just an invisible placeholder put in place so that the parent keeps its height
diff --git a/res/layout/keyguard_quick_affordance.xml b/res/layout/keyguard_quick_affordance.xml
index b3b6893..03b4e14 100644
--- a/res/layout/keyguard_quick_affordance.xml
+++ b/res/layout/keyguard_quick_affordance.xml
@@ -22,15 +22,31 @@
android:layout_width="@dimen/keyguard_quick_affordance_picker_item_width"
android:layout_height="wrap_content"
android:orientation="vertical"
- android:gravity="center_horizontal">
+ android:gravity="center_horizontal"
+ android:clipChildren="false">
<FrameLayout
- android:id="@+id/icon_container"
android:layout_width="@dimen/keyguard_quick_affordance_icon_container_size"
- android:layout_height="@dimen/keyguard_quick_affordance_icon_container_size" >
+ android:layout_height="@dimen/keyguard_quick_affordance_icon_container_size"
+ android:clipChildren="false">
<ImageView
- android:id="@+id/icon"
+ android:id="@id/selection_border"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:background="@drawable/option_item_border"
+ android:alpha="0"
+ android:importantForAccessibility="no" />
+
+ <ImageView
+ android:id="@id/background"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:background="@drawable/option_item_background"
+ android:importantForAccessibility="no" />
+
+ <ImageView
+ android:id="@id/foreground"
android:layout_width="@dimen/keyguard_quick_affordance_icon_size"
android:layout_height="@dimen/keyguard_quick_affordance_icon_size"
android:layout_gravity="center"
@@ -43,7 +59,7 @@
android:layout_height="8dp" />
<TextView
- android:id="@+id/name"
+ android:id="@id/text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/text_color_primary"
@@ -52,4 +68,4 @@
android:text="Placeholder for stable size calculation, please do not remove."
tools:ignore="HardcodedText" />
-</LinearLayout>
\ No newline at end of file
+</LinearLayout>
diff --git a/src/com/android/customization/picker/quickaffordance/domain/interactor/KeyguardQuickAffordancePickerInteractor.kt b/src/com/android/customization/picker/quickaffordance/domain/interactor/KeyguardQuickAffordancePickerInteractor.kt
index fbe303b..f154de6 100644
--- a/src/com/android/customization/picker/quickaffordance/domain/interactor/KeyguardQuickAffordancePickerInteractor.kt
+++ b/src/com/android/customization/picker/quickaffordance/domain/interactor/KeyguardQuickAffordancePickerInteractor.kt
@@ -63,16 +63,6 @@
snapshotRestorer.get().storeSnapshot()
}
- /** Unselects an affordance with the given ID from the slot with the given ID. */
- suspend fun unselect(slotId: String, affordanceId: String) {
- client.deleteSelection(
- slotId = slotId,
- affordanceId = affordanceId,
- )
-
- snapshotRestorer.get().storeSnapshot()
- }
-
/** Unselects all affordances from the slot with the given ID. */
suspend fun unselectAll(slotId: String) {
client.deleteAllSelections(
diff --git a/src/com/android/customization/picker/quickaffordance/ui/adapter/AffordancesAdapter.kt b/src/com/android/customization/picker/quickaffordance/ui/adapter/AffordancesAdapter.kt
deleted file mode 100644
index e11643a..0000000
--- a/src/com/android/customization/picker/quickaffordance/ui/adapter/AffordancesAdapter.kt
+++ /dev/null
@@ -1,104 +0,0 @@
-/*
- * Copyright (C) 2022 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 com.android.customization.picker.quickaffordance.ui.adapter
-
-import android.view.LayoutInflater
-import android.view.View
-import android.view.ViewGroup
-import android.widget.ImageView
-import android.widget.TextView
-import androidx.recyclerview.widget.RecyclerView
-import com.android.customization.picker.quickaffordance.ui.viewmodel.KeyguardQuickAffordanceViewModel
-import com.android.wallpaper.R
-
-/** Adapts between lock screen quick affordance items and views. */
-class AffordancesAdapter : RecyclerView.Adapter<AffordancesAdapter.ViewHolder>() {
-
- private val items = mutableListOf<KeyguardQuickAffordanceViewModel>()
-
- fun setItems(items: List<KeyguardQuickAffordanceViewModel>) {
- this.items.clear()
- this.items.addAll(items)
- notifyDataSetChanged()
- }
-
- class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
- val iconContainerView: View = itemView.requireViewById(R.id.icon_container)
- val iconView: ImageView = itemView.requireViewById(R.id.icon)
- val nameView: TextView = itemView.requireViewById(R.id.name)
- }
-
- override fun getItemCount(): Int {
- return items.size
- }
-
- override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
- return ViewHolder(
- LayoutInflater.from(parent.context)
- .inflate(
- R.layout.keyguard_quick_affordance,
- parent,
- false,
- )
- )
- }
-
- override fun onBindViewHolder(holder: ViewHolder, position: Int) {
- val item = items[position]
- holder.itemView.alpha =
- if (item.isEnabled) {
- ALPHA_ENABLED
- } else {
- ALPHA_DISABLED
- }
-
- holder.itemView.setOnClickListener(
- if (item.onClicked != null) {
- View.OnClickListener { item.onClicked.invoke() }
- } else {
- null
- }
- )
- holder.itemView.onLongClickListener =
- if (item.onLongClicked != null) {
- View.OnLongClickListener {
- item.onLongClicked.invoke()
- true
- }
- } else {
- null
- }
- holder.iconContainerView.setBackgroundResource(
- if (item.isSelected) {
- R.drawable.keyguard_quick_affordance_icon_container_background_selected
- } else {
- R.drawable.keyguard_quick_affordance_icon_container_background
- }
- )
- holder.iconView.isSelected = item.isSelected
- holder.nameView.isSelected = item.isSelected
- holder.iconView.setImageDrawable(item.icon)
- holder.nameView.text = item.contentDescription
- holder.nameView.isSelected = item.isSelected
- }
-
- companion object {
- private const val ALPHA_ENABLED = 1f
- private const val ALPHA_DISABLED = 0.3f
- }
-}
diff --git a/src/com/android/customization/picker/quickaffordance/ui/binder/KeyguardQuickAffordancePickerBinder.kt b/src/com/android/customization/picker/quickaffordance/ui/binder/KeyguardQuickAffordancePickerBinder.kt
index efa090b..f03fda3 100644
--- a/src/com/android/customization/picker/quickaffordance/ui/binder/KeyguardQuickAffordancePickerBinder.kt
+++ b/src/com/android/customization/picker/quickaffordance/ui/binder/KeyguardQuickAffordancePickerBinder.kt
@@ -27,16 +27,20 @@
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.android.customization.picker.common.ui.view.ItemSpacing
-import com.android.customization.picker.quickaffordance.ui.adapter.AffordancesAdapter
import com.android.customization.picker.quickaffordance.ui.adapter.SlotTabAdapter
import com.android.customization.picker.quickaffordance.ui.viewmodel.KeyguardQuickAffordancePickerViewModel
import com.android.wallpaper.R
import com.android.wallpaper.picker.common.dialog.ui.viewbinder.DialogViewBinder
import com.android.wallpaper.picker.common.dialog.ui.viewmodel.DialogViewModel
+import com.android.wallpaper.picker.option.ui.adapter.OptionItemAdapter
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
+@OptIn(ExperimentalCoroutinesApi::class)
object KeyguardQuickAffordancePickerBinder {
/** Binds view with view-model for a lock screen quick affordance picker experience. */
@@ -54,7 +58,11 @@
slotTabView.layoutManager =
LinearLayoutManager(view.context, RecyclerView.HORIZONTAL, false)
slotTabView.addItemDecoration(ItemSpacing(ItemSpacing.TAB_ITEM_SPACING_DP))
- val affordancesAdapter = AffordancesAdapter()
+ val affordancesAdapter =
+ OptionItemAdapter(
+ layoutResourceId = R.layout.keyguard_quick_affordance,
+ lifecycleOwner = lifecycleOwner,
+ )
affordancesView.adapter = affordancesAdapter
affordancesView.layoutManager =
LinearLayoutManager(view.context, RecyclerView.HORIZONTAL, false)
@@ -73,17 +81,27 @@
launch {
viewModel.quickAffordances.collect { affordances ->
affordancesAdapter.setItems(affordances)
+ }
+ }
- // Scroll the view to show the first selected affordance.
- val selectedPosition = affordances.indexOfFirst { it.isSelected }
- if (selectedPosition != -1) {
- // We use "post" because we need to give the adapter item a pass to
- // update the view.
- affordancesView.post {
- affordancesView.smoothScrollToPosition(selectedPosition)
+ launch {
+ viewModel.quickAffordances
+ .flatMapLatest { affordances ->
+ combine(affordances.map { affordance -> affordance.isSelected }) {
+ selectedFlags ->
+ selectedFlags.indexOfFirst { it }
}
}
- }
+ .collect { selectedPosition ->
+ // Scroll the view to show the first selected affordance.
+ if (selectedPosition != -1) {
+ // We use "post" because we need to give the adapter item a pass to
+ // update the view.
+ affordancesView.post {
+ affordancesView.smoothScrollToPosition(selectedPosition)
+ }
+ }
+ }
}
launch {
diff --git a/src/com/android/customization/picker/quickaffordance/ui/binder/KeyguardQuickAffordanceSectionViewBinder.kt b/src/com/android/customization/picker/quickaffordance/ui/binder/KeyguardQuickAffordanceSectionViewBinder.kt
index c8880b9..28ad51a 100644
--- a/src/com/android/customization/picker/quickaffordance/ui/binder/KeyguardQuickAffordanceSectionViewBinder.kt
+++ b/src/com/android/customization/picker/quickaffordance/ui/binder/KeyguardQuickAffordanceSectionViewBinder.kt
@@ -27,6 +27,8 @@
import androidx.lifecycle.lifecycleScope
import com.android.customization.picker.quickaffordance.ui.viewmodel.KeyguardQuickAffordancePickerViewModel
import com.android.wallpaper.R
+import com.android.wallpaper.picker.common.icon.ui.viewbinder.IconViewBinder
+import com.android.wallpaper.picker.common.text.ui.viewbinder.TextViewBinder
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
@@ -48,12 +50,25 @@
viewModel.summary
.flowWithLifecycle(lifecycleOwner.lifecycle, Lifecycle.State.RESUMED)
.collectLatest { summary ->
- descriptionView.text = summary.description
+ TextViewBinder.bind(
+ view = descriptionView,
+ viewModel = summary.description,
+ )
- icon1.setImageDrawable(summary.icon1)
+ if (summary.icon1 != null) {
+ IconViewBinder.bind(
+ view = icon1,
+ viewModel = summary.icon1,
+ )
+ }
icon1.isVisible = summary.icon1 != null
- icon2.setImageDrawable(summary.icon2)
+ if (summary.icon2 != null) {
+ IconViewBinder.bind(
+ view = icon2,
+ viewModel = summary.icon2,
+ )
+ }
icon2.isVisible = summary.icon2 != null
}
}
diff --git a/src/com/android/customization/picker/quickaffordance/ui/viewmodel/KeyguardQuickAffordancePickerViewModel.kt b/src/com/android/customization/picker/quickaffordance/ui/viewmodel/KeyguardQuickAffordancePickerViewModel.kt
index 39feec6..d88edfa 100644
--- a/src/com/android/customization/picker/quickaffordance/ui/viewmodel/KeyguardQuickAffordancePickerViewModel.kt
+++ b/src/com/android/customization/picker/quickaffordance/ui/viewmodel/KeyguardQuickAffordancePickerViewModel.kt
@@ -38,14 +38,19 @@
import com.android.wallpaper.picker.common.icon.ui.viewmodel.Icon
import com.android.wallpaper.picker.common.text.ui.viewmodel.Text
import com.android.wallpaper.picker.customization.ui.viewmodel.ScreenPreviewViewModel
+import com.android.wallpaper.picker.option.ui.viewmodel.OptionItemViewModel
import com.android.wallpaper.util.PreviewUtils
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.shareIn
+import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
@@ -95,8 +100,27 @@
},
)
+ /** A locally-selected slot, if the user ever switched from the original one. */
private val _selectedSlotId = MutableStateFlow<String?>(null)
- val selectedSlotId: StateFlow<String?> = _selectedSlotId.asStateFlow()
+ /** The ID of the selected slot. */
+ val selectedSlotId: StateFlow<String> =
+ combine(
+ quickAffordanceInteractor.slots,
+ _selectedSlotId,
+ ) { slots, selectedSlotIdOrNull ->
+ if (selectedSlotIdOrNull != null) {
+ slots.first { slot -> slot.id == selectedSlotIdOrNull }
+ } else {
+ // If we haven't yet selected a new slot locally, default to the first slot.
+ slots[0]
+ }
+ }
+ .map { selectedSlot -> selectedSlot.id }
+ .stateIn(
+ scope = viewModelScope,
+ started = SharingStarted.WhileSubscribed(),
+ initialValue = "",
+ )
/** View-models for each slot, keyed by slot ID. */
val slots: Flow<Map<String, KeyguardQuickAffordanceSlotViewModel>> =
@@ -105,95 +129,125 @@
quickAffordanceInteractor.affordances,
quickAffordanceInteractor.selections,
selectedSlotId,
- ) { slots, affordances, selections, selectedSlotIdOrNull ->
- slots
- .mapIndexed { index, slot ->
- val selectedAffordanceIds =
- selections
- .filter { selection -> selection.slotId == slot.id }
- .map { selection -> selection.affordanceId }
- .toSet()
- val selectedAffordances =
- affordances.filter { affordance ->
- selectedAffordanceIds.contains(affordance.id)
- }
- val isSelected =
- (selectedSlotIdOrNull == null && index == 0) ||
- selectedSlotIdOrNull == slot.id
- slot.id to
- KeyguardQuickAffordanceSlotViewModel(
- name = getSlotName(slot.id),
- isSelected = isSelected,
- selectedQuickAffordances =
- selectedAffordances.map { affordanceModel ->
- KeyguardQuickAffordanceViewModel(
- icon = getAffordanceIcon(affordanceModel.iconResourceId),
- contentDescription = affordanceModel.name,
- isSelected = true,
- onClicked = null,
- onLongClicked = null,
- isEnabled = affordanceModel.isEnabled,
- )
- },
- maxSelectedQuickAffordances = slot.maxSelectedQuickAffordances,
- onClicked =
- if (isSelected) {
- null
- } else {
- { _selectedSlotId.tryEmit(slot.id) }
- },
- )
- }
- .toMap()
+ ) { slots, affordances, selections, selectedSlotId ->
+ slots.associate { slot ->
+ val selectedAffordanceIds =
+ selections
+ .filter { selection -> selection.slotId == slot.id }
+ .map { selection -> selection.affordanceId }
+ .toSet()
+ val selectedAffordances =
+ affordances.filter { affordance ->
+ selectedAffordanceIds.contains(affordance.id)
+ }
+ val isSelected = selectedSlotId == slot.id
+ slot.id to
+ KeyguardQuickAffordanceSlotViewModel(
+ name = getSlotName(slot.id),
+ isSelected = isSelected,
+ selectedQuickAffordances =
+ selectedAffordances.map { affordanceModel ->
+ OptionItemViewModel(
+ key = flowOf("${slot.id}::${affordanceModel.id}"),
+ icon =
+ Icon.Loaded(
+ drawable =
+ getAffordanceIcon(affordanceModel.iconResourceId),
+ contentDescription = null,
+ ),
+ text = Text.Loaded(affordanceModel.name),
+ isSelected = flowOf(true),
+ onClicked = flowOf(null),
+ onLongClicked = null,
+ isEnabled = true,
+ )
+ },
+ maxSelectedQuickAffordances = slot.maxSelectedQuickAffordances,
+ onClicked =
+ if (isSelected) {
+ null
+ } else {
+ { _selectedSlotId.tryEmit(slot.id) }
+ },
+ )
+ }
}
- /** The list of all available quick affordances for the selected slot. */
- val quickAffordances: Flow<List<KeyguardQuickAffordanceViewModel>> =
+ /**
+ * The set of IDs of the currently-selected affordances. These change with user selection of new
+ * or different affordances in the currently-selected slot or when slot selection changes.
+ */
+ private val selectedAffordanceIds: Flow<Set<String>> =
combine(
- quickAffordanceInteractor.slots,
- quickAffordanceInteractor.affordances,
- quickAffordanceInteractor.selections,
- selectedSlotId,
- ) { slots, affordances, selections, selectedSlotIdOrNull ->
- val selectedSlot =
- selectedSlotIdOrNull?.let { slots.find { slot -> slot.id == it } } ?: slots.first()
- val selectedAffordanceIds =
+ quickAffordanceInteractor.selections,
+ selectedSlotId,
+ ) { selections, selectedSlotId ->
selections
- .filter { selection -> selection.slotId == selectedSlot.id }
+ .filter { selection -> selection.slotId == selectedSlotId }
.map { selection -> selection.affordanceId }
.toSet()
+ }
+ .shareIn(
+ scope = viewModelScope,
+ started = SharingStarted.WhileSubscribed(),
+ replay = 1,
+ )
+
+ /** The list of all available quick affordances for the selected slot. */
+ val quickAffordances: Flow<List<OptionItemViewModel>> =
+ quickAffordanceInteractor.affordances.map { affordances ->
+ val isNoneSelected = selectedAffordanceIds.map { it.isEmpty() }
listOf(
none(
- slotId = selectedSlot.id,
- isSelected = selectedAffordanceIds.isEmpty(),
- )
- ) +
- affordances.map { affordance ->
- val isSelected = selectedAffordanceIds.contains(affordance.id)
- val affordanceIcon = getAffordanceIcon(affordance.iconResourceId)
- KeyguardQuickAffordanceViewModel(
- icon = affordanceIcon,
- contentDescription = affordance.name,
- isSelected = isSelected,
- onClicked =
- if (affordance.isEnabled) {
+ slotId = selectedSlotId,
+ isSelected = isNoneSelected,
+ onSelected =
+ combine(
+ isNoneSelected,
+ selectedSlotId,
+ ) { isSelected, selectedSlotId ->
+ if (!isSelected) {
{
viewModelScope.launch {
- if (isSelected) {
- quickAffordanceInteractor.unselect(
- slotId = selectedSlot.id,
- affordanceId = affordance.id,
- )
- } else {
- quickAffordanceInteractor.select(
- slotId = selectedSlot.id,
- affordanceId = affordance.id,
- )
- }
+ quickAffordanceInteractor.unselectAll(selectedSlotId)
}
}
} else {
- {
+ null
+ }
+ }
+ )
+ ) +
+ affordances.map { affordance ->
+ val affordanceIcon = getAffordanceIcon(affordance.iconResourceId)
+ val isSelectedFlow: Flow<Boolean> =
+ selectedAffordanceIds.map { it.contains(affordance.id) }
+ OptionItemViewModel(
+ key = selectedSlotId.map { slotId -> "$slotId::${affordance.id}" },
+ icon = Icon.Loaded(drawable = affordanceIcon, contentDescription = null),
+ text = Text.Loaded(affordance.name),
+ isSelected = isSelectedFlow,
+ onClicked =
+ if (affordance.isEnabled) {
+ combine(
+ isSelectedFlow,
+ selectedSlotId,
+ ) { isSelected, selectedSlotId ->
+ if (!isSelected) {
+ {
+ viewModelScope.launch {
+ quickAffordanceInteractor.select(
+ slotId = selectedSlotId,
+ affordanceId = affordance.id,
+ )
+ }
+ }
+ } else {
+ null
+ }
+ }
+ } else {
+ flowOf {
showEnablementDialog(
icon = affordanceIcon,
name = affordance.name,
@@ -233,7 +287,10 @@
description = toDescriptionText(context, slots),
icon1 = icon1
?: if (icon2 == null) {
- context.getDrawable(R.drawable.link_off)
+ Icon.Resource(
+ res = R.drawable.link_off,
+ contentDescription = null,
+ )
} else {
null
},
@@ -300,17 +357,21 @@
)
}
+ /** Returns a view-model for the special "None" option. */
@SuppressLint("UseCompatLoadingForDrawables")
private fun none(
- slotId: String,
- isSelected: Boolean,
- ): KeyguardQuickAffordanceViewModel {
- return KeyguardQuickAffordanceViewModel.none(
- context = applicationContext,
+ slotId: Flow<String>,
+ isSelected: Flow<Boolean>,
+ onSelected: Flow<(() -> Unit)?>,
+ ): OptionItemViewModel {
+ return OptionItemViewModel(
+ key = slotId.map { "$it::none" },
+ icon = Icon.Resource(res = R.drawable.link_off, contentDescription = null),
+ text = Text.Resource(res = R.string.keyguard_affordance_none),
isSelected = isSelected,
- onSelected = {
- viewModelScope.launch { quickAffordanceInteractor.unselectAll(slotId) }
- },
+ onClicked = onSelected,
+ onLongClicked = null,
+ isEnabled = true,
)
}
@@ -356,30 +417,31 @@
private fun toDescriptionText(
context: Context,
slots: Map<String, KeyguardQuickAffordanceSlotViewModel>,
- ): String {
+ ): Text {
val bottomStartAffordanceName =
slots[KeyguardQuickAffordanceSlots.SLOT_ID_BOTTOM_START]
?.selectedQuickAffordances
?.firstOrNull()
- ?.contentDescription
+ ?.text
val bottomEndAffordanceName =
slots[KeyguardQuickAffordanceSlots.SLOT_ID_BOTTOM_END]
?.selectedQuickAffordances
?.firstOrNull()
- ?.contentDescription
+ ?.text
return when {
- !bottomStartAffordanceName.isNullOrEmpty() &&
- !bottomEndAffordanceName.isNullOrEmpty() -> {
- context.getString(
- R.string.keyguard_quick_affordance_two_selected_template,
- bottomStartAffordanceName,
- bottomEndAffordanceName,
+ bottomStartAffordanceName != null && bottomEndAffordanceName != null -> {
+ Text.Loaded(
+ context.getString(
+ R.string.keyguard_quick_affordance_two_selected_template,
+ bottomStartAffordanceName.asString(context),
+ bottomEndAffordanceName.asString(context),
+ )
)
}
- !bottomStartAffordanceName.isNullOrEmpty() -> bottomStartAffordanceName
- !bottomEndAffordanceName.isNullOrEmpty() -> bottomEndAffordanceName
- else -> context.getString(R.string.keyguard_quick_affordance_none_selected)
+ bottomStartAffordanceName != null -> bottomStartAffordanceName
+ bottomEndAffordanceName != null -> bottomEndAffordanceName
+ else -> Text.Resource(R.string.keyguard_quick_affordance_none_selected)
}
}
diff --git a/src/com/android/customization/picker/quickaffordance/ui/viewmodel/KeyguardQuickAffordanceSlotViewModel.kt b/src/com/android/customization/picker/quickaffordance/ui/viewmodel/KeyguardQuickAffordanceSlotViewModel.kt
index bb9b29b..6d8195a 100644
--- a/src/com/android/customization/picker/quickaffordance/ui/viewmodel/KeyguardQuickAffordanceSlotViewModel.kt
+++ b/src/com/android/customization/picker/quickaffordance/ui/viewmodel/KeyguardQuickAffordanceSlotViewModel.kt
@@ -17,6 +17,8 @@
package com.android.customization.picker.quickaffordance.ui.viewmodel
+import com.android.wallpaper.picker.option.ui.viewmodel.OptionItemViewModel
+
/** Models UI state for a single lock screen quick affordance slot in a picker experience. */
data class KeyguardQuickAffordanceSlotViewModel(
/** User-visible name for the slot. */
@@ -30,7 +32,7 @@
*
* Useful for preview.
*/
- val selectedQuickAffordances: List<KeyguardQuickAffordanceViewModel>,
+ val selectedQuickAffordances: List<OptionItemViewModel>,
/**
* The maximum number of quick affordances that can be selected for this slot.
diff --git a/src/com/android/customization/picker/quickaffordance/ui/viewmodel/KeyguardQuickAffordanceSummaryViewModel.kt b/src/com/android/customization/picker/quickaffordance/ui/viewmodel/KeyguardQuickAffordanceSummaryViewModel.kt
index d5fc79b..ee89d3e 100644
--- a/src/com/android/customization/picker/quickaffordance/ui/viewmodel/KeyguardQuickAffordanceSummaryViewModel.kt
+++ b/src/com/android/customization/picker/quickaffordance/ui/viewmodel/KeyguardQuickAffordanceSummaryViewModel.kt
@@ -17,10 +17,11 @@
package com.android.customization.picker.quickaffordance.ui.viewmodel
-import android.graphics.drawable.Drawable
+import com.android.wallpaper.picker.common.icon.ui.viewmodel.Icon
+import com.android.wallpaper.picker.common.text.ui.viewmodel.Text
data class KeyguardQuickAffordanceSummaryViewModel(
- val description: String,
- val icon1: Drawable?,
- val icon2: Drawable?,
+ val description: Text,
+ val icon1: Icon?,
+ val icon2: Icon?,
)
diff --git a/src/com/android/customization/picker/quickaffordance/ui/viewmodel/KeyguardQuickAffordanceViewModel.kt b/src/com/android/customization/picker/quickaffordance/ui/viewmodel/KeyguardQuickAffordanceViewModel.kt
deleted file mode 100644
index 8f6d327..0000000
--- a/src/com/android/customization/picker/quickaffordance/ui/viewmodel/KeyguardQuickAffordanceViewModel.kt
+++ /dev/null
@@ -1,67 +0,0 @@
-/*
- * Copyright (C) 2022 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 com.android.customization.picker.quickaffordance.ui.viewmodel
-
-import android.annotation.SuppressLint
-import android.content.Context
-import android.graphics.drawable.Drawable
-import com.android.wallpaper.R
-
-/** Models UI state for a single lock screen quick affordance in a picker experience. */
-data class KeyguardQuickAffordanceViewModel(
- /** An icon for the quick affordance. */
- val icon: Drawable,
-
- /** A content description for the icon. */
- val contentDescription: String,
-
- /** Whether this quick affordance is selected in its slot. */
- val isSelected: Boolean,
-
- /** Whether this quick affordance is enabled. */
- val isEnabled: Boolean,
-
- /** Notifies that the quick affordance has been clicked by the user. */
- val onClicked: (() -> Unit)?,
-
- /** Notifies that the quick affordance has been long-clicked by the user. */
- val onLongClicked: (() -> Unit)?,
-) {
- companion object {
- @SuppressLint("UseCompatLoadingForDrawables")
- fun none(
- context: Context,
- isSelected: Boolean,
- onSelected: () -> Unit,
- ): KeyguardQuickAffordanceViewModel {
- return KeyguardQuickAffordanceViewModel(
- icon = checkNotNull(context.getDrawable(R.drawable.link_off)),
- contentDescription = context.getString(R.string.keyguard_affordance_none),
- isSelected = isSelected,
- onClicked =
- if (isSelected) {
- null
- } else {
- onSelected
- },
- onLongClicked = null,
- isEnabled = true,
- )
- }
- }
-}
diff --git a/tests/src/com/android/customization/model/picker/quickaffordance/domain/interactor/KeyguardQuickAffordancePickerInteractorTest.kt b/tests/src/com/android/customization/model/picker/quickaffordance/domain/interactor/KeyguardQuickAffordancePickerInteractorTest.kt
index 4879fb0..fea94dc 100644
--- a/tests/src/com/android/customization/model/picker/quickaffordance/domain/interactor/KeyguardQuickAffordancePickerInteractorTest.kt
+++ b/tests/src/com/android/customization/model/picker/quickaffordance/domain/interactor/KeyguardQuickAffordancePickerInteractorTest.kt
@@ -115,23 +115,6 @@
}
@Test
- fun unselect() =
- testScope.runTest {
- val selections = collectLastValue(underTest.selections)
- underTest.select(
- slotId = KeyguardQuickAffordanceSlots.SLOT_ID_BOTTOM_START,
- affordanceId = FakeCustomizationProviderClient.AFFORDANCE_1,
- )
-
- underTest.unselect(
- slotId = KeyguardQuickAffordanceSlots.SLOT_ID_BOTTOM_START,
- affordanceId = FakeCustomizationProviderClient.AFFORDANCE_1,
- )
-
- assertThat(selections()).isEmpty()
- }
-
- @Test
fun unselectAll() =
testScope.runTest {
client.setSlotCapacity(KeyguardQuickAffordanceSlots.SLOT_ID_BOTTOM_END, 3)
diff --git a/tests/src/com/android/customization/model/picker/quickaffordance/ui/viewmodel/KeyguardQuickAffordancePickerViewModelTest.kt b/tests/src/com/android/customization/model/picker/quickaffordance/ui/viewmodel/KeyguardQuickAffordancePickerViewModelTest.kt
index fd03285..6044724 100644
--- a/tests/src/com/android/customization/model/picker/quickaffordance/ui/viewmodel/KeyguardQuickAffordancePickerViewModelTest.kt
+++ b/tests/src/com/android/customization/model/picker/quickaffordance/ui/viewmodel/KeyguardQuickAffordancePickerViewModelTest.kt
@@ -27,13 +27,14 @@
import com.android.customization.picker.quickaffordance.ui.viewmodel.KeyguardQuickAffordancePickerViewModel
import com.android.customization.picker.quickaffordance.ui.viewmodel.KeyguardQuickAffordanceSlotViewModel
import com.android.customization.picker.quickaffordance.ui.viewmodel.KeyguardQuickAffordanceSummaryViewModel
-import com.android.customization.picker.quickaffordance.ui.viewmodel.KeyguardQuickAffordanceViewModel
import com.android.systemui.shared.customization.data.content.CustomizationProviderClient
import com.android.systemui.shared.customization.data.content.FakeCustomizationProviderClient
import com.android.systemui.shared.keyguard.shared.model.KeyguardQuickAffordanceSlots
+import com.android.wallpaper.R
import com.android.wallpaper.module.InjectorProvider
import com.android.wallpaper.picker.common.icon.ui.viewmodel.Icon
import com.android.wallpaper.picker.common.text.ui.viewmodel.Text
+import com.android.wallpaper.picker.option.ui.viewmodel.OptionItemViewModel
import com.android.wallpaper.testing.FakeSnapshotStore
import com.android.wallpaper.testing.TestCurrentWallpaperInfoFactory
import com.android.wallpaper.testing.TestInjector
@@ -131,7 +132,7 @@
)
// Select "affordance 1" for the first slot.
- quickAffordances()?.get(1)?.onClicked?.invoke()
+ selectAffordance(quickAffordances, 1)
assertPickerUiState(
slots = slots(),
affordances = quickAffordances(),
@@ -152,7 +153,7 @@
// First, switch to the second slot:
slots()?.get(KeyguardQuickAffordanceSlots.SLOT_ID_BOTTOM_END)?.onClicked?.invoke()
// Second, select the "affordance 3" affordance:
- quickAffordances()?.get(3)?.onClicked?.invoke()
+ selectAffordance(quickAffordances, 3)
assertPickerUiState(
slots = slots(),
affordances = quickAffordances(),
@@ -171,7 +172,7 @@
)
// Select a different affordance for the second slot.
- quickAffordances()?.get(2)?.onClicked?.invoke()
+ selectAffordance(quickAffordances, 2)
assertPickerUiState(
slots = slots(),
affordances = quickAffordances(),
@@ -197,17 +198,17 @@
val quickAffordances = collectLastValue(underTest.quickAffordances)
// Select "affordance 1" for the first slot.
- quickAffordances()?.get(1)?.onClicked?.invoke()
+ selectAffordance(quickAffordances, 1)
// Select an affordance for the second slot.
// First, switch to the second slot:
slots()?.get(KeyguardQuickAffordanceSlots.SLOT_ID_BOTTOM_END)?.onClicked?.invoke()
// Second, select the "affordance 3" affordance:
- quickAffordances()?.get(3)?.onClicked?.invoke()
+ selectAffordance(quickAffordances, 3)
// Switch back to the first slot:
slots()?.get(KeyguardQuickAffordanceSlots.SLOT_ID_BOTTOM_START)?.onClicked?.invoke()
// Select the "none" affordance, which is always in position 0:
- quickAffordances()?.get(0)?.onClicked?.invoke()
+ selectAffordance(quickAffordances, 0)
assertPickerUiState(
slots = slots(),
@@ -253,7 +254,7 @@
)
// Lets try to select that disabled affordance:
- quickAffordances()?.get(affordanceIndex + 1)?.onClicked?.invoke()
+ selectAffordance(quickAffordances, affordanceIndex + 1)
// We expect there to be a dialog that should be shown:
assertThat(dialog()?.icon)
@@ -304,21 +305,23 @@
val summary = collectLastValue(underTest.summary)
// Select "affordance 1" for the first slot.
- quickAffordances()?.get(1)?.onClicked?.invoke()
+ selectAffordance(quickAffordances, 1)
// Select an affordance for the second slot.
// First, switch to the second slot:
slots()?.get(KeyguardQuickAffordanceSlots.SLOT_ID_BOTTOM_END)?.onClicked?.invoke()
// Second, select the "affordance 3" affordance:
- quickAffordances()?.get(3)?.onClicked?.invoke()
+ selectAffordance(quickAffordances, 3)
assertThat(summary())
.isEqualTo(
KeyguardQuickAffordanceSummaryViewModel(
description =
- "${FakeCustomizationProviderClient.AFFORDANCE_1}," +
- " ${FakeCustomizationProviderClient.AFFORDANCE_3}",
- icon1 = FakeCustomizationProviderClient.ICON_1,
- icon2 = FakeCustomizationProviderClient.ICON_3,
+ Text.Loaded(
+ "${FakeCustomizationProviderClient.AFFORDANCE_1}," +
+ " ${FakeCustomizationProviderClient.AFFORDANCE_3}"
+ ),
+ icon1 = Icon.Loaded(FakeCustomizationProviderClient.ICON_1, null),
+ icon2 = Icon.Loaded(FakeCustomizationProviderClient.ICON_3, null),
)
)
}
@@ -331,13 +334,13 @@
val summary = collectLastValue(underTest.summary)
// Select "affordance 1" for the first slot.
- quickAffordances()?.get(1)?.onClicked?.invoke()
+ selectAffordance(quickAffordances, 1)
assertThat(summary())
.isEqualTo(
KeyguardQuickAffordanceSummaryViewModel(
- description = FakeCustomizationProviderClient.AFFORDANCE_1,
- icon1 = FakeCustomizationProviderClient.ICON_1,
+ description = Text.Loaded(FakeCustomizationProviderClient.AFFORDANCE_1),
+ icon1 = Icon.Loaded(FakeCustomizationProviderClient.ICON_1, null),
icon2 = null,
)
)
@@ -354,14 +357,14 @@
// First, switch to the second slot:
slots()?.get(KeyguardQuickAffordanceSlots.SLOT_ID_BOTTOM_END)?.onClicked?.invoke()
// Second, select the "affordance 3" affordance:
- quickAffordances()?.get(3)?.onClicked?.invoke()
+ selectAffordance(quickAffordances, 3)
assertThat(summary())
.isEqualTo(
KeyguardQuickAffordanceSummaryViewModel(
- description = FakeCustomizationProviderClient.AFFORDANCE_3,
+ description = Text.Loaded(FakeCustomizationProviderClient.AFFORDANCE_3),
icon1 = null,
- icon2 = FakeCustomizationProviderClient.ICON_3,
+ icon2 = Icon.Loaded(FakeCustomizationProviderClient.ICON_3, null),
)
)
}
@@ -369,15 +372,28 @@
@Test
fun `summary - no affordances selected`() =
testScope.runTest {
- val slots = collectLastValue(underTest.slots)
- val quickAffordances = collectLastValue(underTest.quickAffordances)
val summary = collectLastValue(underTest.summary)
- assertThat(summary()?.description).isEqualTo("None")
+ assertThat(summary()?.description)
+ .isEqualTo(Text.Resource(R.string.keyguard_quick_affordance_none_selected))
assertThat(summary()?.icon1).isNotNull()
assertThat(summary()?.icon2).isNull()
}
+ /** Simulates a user selecting the affordance at the given index, if that is clickable. */
+ private fun TestScope.selectAffordance(
+ affordances: () -> List<OptionItemViewModel>?,
+ index: Int,
+ ) {
+ val onClickedFlow = affordances()?.get(index)?.onClicked
+ val onClickedLastValueOrNull: (() -> (() -> Unit)?)? =
+ onClickedFlow?.let { collectLastValue(it) }
+ onClickedLastValueOrNull?.let { onClickedLastValue ->
+ val onClickedOrNull: (() -> Unit)? = onClickedLastValue()
+ onClickedOrNull?.let { onClicked -> onClicked() }
+ }
+ }
+
/**
* Asserts the entire picker UI state is what is expected. This includes the slot tabs and the
* affordance list.
@@ -387,9 +403,9 @@
* @param selectedSlotText The text of the slot that's expected to be selected
* @param selectedAffordanceText The text of the affordance that's expected to be selected
*/
- private fun assertPickerUiState(
+ private fun TestScope.assertPickerUiState(
slots: Map<String, KeyguardQuickAffordanceSlotViewModel>?,
- affordances: List<KeyguardQuickAffordanceViewModel>?,
+ affordances: List<OptionItemViewModel>?,
selectedSlotText: String,
selectedAffordanceText: String,
) {
@@ -407,12 +423,18 @@
var foundSelectedAffordance = false
assertThat(affordances).isNotNull()
affordances?.forEach { affordance ->
- val nameMatchesSelectedName = affordance.contentDescription == selectedAffordanceText
- assertWithMessage(
- "Expected affordance with name \"${affordance.contentDescription}\" to have" +
- " isSelected=$nameMatchesSelectedName but it was ${affordance.isSelected}"
+ val nameMatchesSelectedName =
+ Text.evaluationEquals(
+ context,
+ affordance.text,
+ Text.Loaded(selectedAffordanceText),
)
- .that(affordance.isSelected)
+ val isSelected: Boolean? = collectLastValue(affordance.isSelected).invoke()
+ assertWithMessage(
+ "Expected affordance with name \"${affordance.text}\" to have" +
+ " isSelected=$nameMatchesSelectedName but it was $isSelected"
+ )
+ .that(isSelected)
.isEqualTo(nameMatchesSelectedName)
foundSelectedAffordance = foundSelectedAffordance || nameMatchesSelectedName
}
@@ -449,13 +471,12 @@
assertThat(slots).isNotNull()
slots?.forEach { (slotId, slotViewModel) ->
val expectedAffordanceName = expectedAffordanceNameBySlotId[slotId]
- val actualAffordanceName =
- slotViewModel.selectedQuickAffordances.firstOrNull()?.contentDescription
+ val actualAffordanceName = slotViewModel.selectedQuickAffordances.firstOrNull()?.text
assertWithMessage(
"At slotId=\"$slotId\", expected affordance=\"$expectedAffordanceName\" but" +
- " was \"$actualAffordanceName\"!"
+ " was \"${actualAffordanceName?.asString(context)}\"!"
)
- .that(actualAffordanceName)
+ .that(actualAffordanceName?.asString(context))
.isEqualTo(expectedAffordanceName)
}
}