Shortcut preview update (2/3)

Use message handler to send updates to the remote lockscreen preview

Test: Manually tested. See bug.
Bug: 350718583
Flag: com.android.systemui.new_picker_ui
Change-Id: Ib0e309e8cffe8a8f6ae7fe1b46e634a60d5e3dca
diff --git a/src/com/android/wallpaper/customization/ui/binder/ThemePickerCustomizationOptionBinder.kt b/src/com/android/wallpaper/customization/ui/binder/ThemePickerCustomizationOptionBinder.kt
index f20bc2e..ba6fc8b 100644
--- a/src/com/android/wallpaper/customization/ui/binder/ThemePickerCustomizationOptionBinder.kt
+++ b/src/com/android/wallpaper/customization/ui/binder/ThemePickerCustomizationOptionBinder.kt
@@ -50,7 +50,7 @@
         homeScreenCustomizationOptionEntries: List<Pair<CustomizationOption, View>>,
         customizationOptionFloatingSheetViewMap: Map<CustomizationOption, View>?,
         viewModel: CustomizationOptionsViewModel,
-        lifecycleOwner: LifecycleOwner
+        lifecycleOwner: LifecycleOwner,
     ) {
         defaultCustomizationOptionsBinder.bind(
             view,
@@ -104,7 +104,7 @@
                 }
 
                 launch {
-                    viewModel.keyguardQuickAffordanceSummery.collect { summary ->
+                    viewModel.keyguardQuickAffordancePickerViewModel2.summary.collect { summary ->
                         optionShortcutDescription?.let {
                             TextViewBinder.bind(
                                 view = it,
diff --git a/src/com/android/wallpaper/customization/ui/binder/ThemePickerToolbarBinder.kt b/src/com/android/wallpaper/customization/ui/binder/ThemePickerToolbarBinder.kt
new file mode 100644
index 0000000..6bddf4a
--- /dev/null
+++ b/src/com/android/wallpaper/customization/ui/binder/ThemePickerToolbarBinder.kt
@@ -0,0 +1,66 @@
+/*
+ * Copyright (C) 2024 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.wallpaper.customization.ui.binder
+
+import android.widget.Button
+import android.widget.FrameLayout
+import android.widget.Toolbar
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleOwner
+import androidx.lifecycle.lifecycleScope
+import androidx.lifecycle.repeatOnLifecycle
+import com.android.wallpaper.customization.ui.viewmodel.ThemePickerCustomizationOptionsViewModel
+import com.android.wallpaper.picker.customization.ui.binder.DefaultToolbarBinder
+import com.android.wallpaper.picker.customization.ui.binder.ToolbarBinder
+import com.android.wallpaper.picker.customization.ui.viewmodel.CustomizationOptionsViewModel
+import javax.inject.Inject
+import javax.inject.Singleton
+import kotlinx.coroutines.launch
+
+@Singleton
+class ThemePickerToolbarBinder
+@Inject
+constructor(private val defaultToolbarBinder: DefaultToolbarBinder) : ToolbarBinder {
+
+    override fun bind(
+        navButton: FrameLayout,
+        toolbar: Toolbar,
+        applyButton: Button,
+        viewModel: CustomizationOptionsViewModel,
+        lifecycleOwner: LifecycleOwner,
+    ) {
+        defaultToolbarBinder.bind(navButton, toolbar, applyButton, viewModel, lifecycleOwner)
+
+        if (viewModel !is ThemePickerCustomizationOptionsViewModel) {
+            throw IllegalArgumentException(
+                "viewModel $viewModel is not a ThemePickerCustomizationOptionsViewModel."
+            )
+        }
+
+        lifecycleOwner.lifecycleScope.launch {
+            lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
+                launch {
+                    viewModel.keyguardQuickAffordancePickerViewModel2.onApply.collect { onApply ->
+                        applyButton.setOnClickListener {
+                            onApply?.invoke()?.let { viewModel.deselectOption() }
+                        }
+                    }
+                }
+            }
+        }
+    }
+}
diff --git a/src/com/android/wallpaper/customization/ui/viewmodel/KeyguardQuickAffordancePickerViewModel2.kt b/src/com/android/wallpaper/customization/ui/viewmodel/KeyguardQuickAffordancePickerViewModel2.kt
index 67beb25..86cdd8a 100644
--- a/src/com/android/wallpaper/customization/ui/viewmodel/KeyguardQuickAffordancePickerViewModel2.kt
+++ b/src/com/android/wallpaper/customization/ui/viewmodel/KeyguardQuickAffordancePickerViewModel2.kt
@@ -25,7 +25,9 @@
 import com.android.customization.picker.quickaffordance.domain.interactor.KeyguardQuickAffordancePickerInteractor
 import com.android.customization.picker.quickaffordance.ui.viewmodel.KeyguardQuickAffordanceSlotViewModel
 import com.android.customization.picker.quickaffordance.ui.viewmodel.KeyguardQuickAffordanceSummaryViewModel
-import com.android.systemui.shared.keyguard.shared.model.KeyguardQuickAffordanceSlots
+import com.android.systemui.shared.keyguard.shared.model.KeyguardQuickAffordanceSlots.SLOT_ID_BOTTOM_END
+import com.android.systemui.shared.keyguard.shared.model.KeyguardQuickAffordanceSlots.SLOT_ID_BOTTOM_START
+import com.android.systemui.shared.quickaffordance.shared.model.KeyguardPreviewConstants.KEYGUARD_QUICK_AFFORDANCE_ID_NONE
 import com.android.themepicker.R
 import com.android.wallpaper.picker.common.button.ui.viewmodel.ButtonStyle
 import com.android.wallpaper.picker.common.button.ui.viewmodel.ButtonViewModel
@@ -81,60 +83,78 @@
                 started = SharingStarted.WhileSubscribed(),
                 initialValue = "",
             )
+    private val _selectedQuickAffordances = MutableStateFlow<Map<String, String>>(emptyMap())
+    val selectedQuickAffordances: Flow<Map<String, String>> =
+        _selectedQuickAffordances.asStateFlow()
+
+    fun resetPreview() {
+        _selectedQuickAffordances.tryEmit(emptyMap())
+        _selectedSlotId.tryEmit(SLOT_ID_BOTTOM_START)
+    }
 
     /** View-models for each slot, keyed by slot ID. */
-    private val slots: Flow<Map<String, KeyguardQuickAffordanceSlotViewModel>> =
+    private val slots: StateFlow<Map<String, KeyguardQuickAffordanceSlotViewModel>> =
         combine(
-            quickAffordanceInteractor.slots,
-            quickAffordanceInteractor.affordances,
-            quickAffordanceInteractor.selections,
-            selectedSlotId,
-        ) { 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<Icon>(
-                                    key =
-                                        MutableStateFlow("${slot.id}::${affordanceModel.id}")
-                                            as StateFlow<String>,
-                                    payload =
-                                        Icon.Loaded(
-                                            drawable =
-                                                getAffordanceIcon(affordanceModel.iconResourceId),
-                                            contentDescription =
-                                                Text.Loaded(getSlotContentDescription(slot.id)),
-                                        ),
-                                    text = Text.Loaded(affordanceModel.name),
-                                    isSelected = MutableStateFlow(true) as StateFlow<Boolean>,
-                                    onClicked = flowOf(null),
-                                    onLongClicked = null,
-                                    isEnabled = true,
-                                )
-                            },
-                        maxSelectedQuickAffordances = slot.maxSelectedQuickAffordances,
-                        onClicked =
-                            if (isSelected) {
-                                null
-                            } else {
-                                { _selectedSlotId.tryEmit(slot.id) }
-                            },
-                    )
+                quickAffordanceInteractor.slots,
+                quickAffordanceInteractor.affordances,
+                quickAffordanceInteractor.selections,
+                selectedQuickAffordances,
+                selectedSlotId,
+            ) { slots, affordances, selections, selectedQuickAffordances, selectedSlotId ->
+                slots.associate { slot ->
+                    val selectedAffordanceIds =
+                        selectedQuickAffordances[slot.id]?.let { setOf(it) }
+                            ?: 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<Icon>(
+                                        key =
+                                            MutableStateFlow("${slot.id}::${affordanceModel.id}")
+                                                as StateFlow<String>,
+                                        payload =
+                                            Icon.Loaded(
+                                                drawable =
+                                                    getAffordanceIcon(
+                                                        affordanceModel.iconResourceId
+                                                    ),
+                                                contentDescription =
+                                                    Text.Loaded(getSlotContentDescription(slot.id)),
+                                            ),
+                                        text = Text.Loaded(affordanceModel.name),
+                                        isSelected = MutableStateFlow(true) as StateFlow<Boolean>,
+                                        onClicked = flowOf(null),
+                                        onLongClicked = null,
+                                        isEnabled = true,
+                                    )
+                                },
+                            maxSelectedQuickAffordances = slot.maxSelectedQuickAffordances,
+                            onClicked =
+                                if (isSelected) {
+                                    null
+                                } else {
+                                    { _selectedSlotId.tryEmit(slot.id) }
+                                },
+                        )
+                }
             }
-        }
+            .stateIn(
+                scope = viewModelScope,
+                started = SharingStarted.WhileSubscribed(),
+                initialValue = emptyMap(),
+            )
 
     val tabs: Flow<List<FloatingToolbarTabViewModel>> =
         slots.map { slotById ->
@@ -171,7 +191,17 @@
     /** The list of all available quick affordances for the selected slot. */
     val quickAffordances: Flow<List<OptionItemViewModel<Icon>>> =
         quickAffordanceInteractor.affordances.map { affordances ->
-            val isNoneSelected = selectedAffordanceIds.map { it.isEmpty() }.stateIn(viewModelScope)
+            val isNoneSelected =
+                combine(
+                        selectedSlotId,
+                        selectedQuickAffordances,
+                        selectedAffordanceIds,
+                    ) { selectedSlotId, selectedQuickAffordances, selectedAffordanceIds ->
+                        selectedQuickAffordances[selectedSlotId]?.let {
+                            it == KEYGUARD_QUICK_AFFORDANCE_ID_NONE
+                        } ?: selectedAffordanceIds.isEmpty()
+                    }
+                    .stateIn(viewModelScope)
             listOf(
                 none(
                     slotId = selectedSlotId,
@@ -183,15 +213,11 @@
                         ) { isSelected, selectedSlotId ->
                             if (!isSelected) {
                                 {
-                                    viewModelScope.launch {
-                                        quickAffordanceInteractor.unselectAllFromSlot(
-                                            selectedSlotId
-                                        )
-                                        logger.logShortcutApplied(
-                                            shortcut = "none",
-                                            shortcutSlotId = selectedSlotId,
-                                        )
-                                    }
+                                    val newMap =
+                                        _selectedQuickAffordances.value.toMutableMap().apply {
+                                            put(selectedSlotId, KEYGUARD_QUICK_AFFORDANCE_ID_NONE)
+                                        }
+                                    _selectedQuickAffordances.tryEmit(newMap)
                                 }
                             } else {
                                 null
@@ -202,8 +228,15 @@
                 affordances.map { affordance ->
                     val affordanceIcon = getAffordanceIcon(affordance.iconResourceId)
                     val isSelectedFlow: StateFlow<Boolean> =
-                        selectedAffordanceIds
-                            .map { it.contains(affordance.id) }
+                        combine(
+                                selectedSlotId,
+                                selectedQuickAffordances,
+                                selectedAffordanceIds,
+                            ) { selectedSlotId, selectedQuickAffordances, selectedAffordanceIds ->
+                                selectedQuickAffordances[selectedSlotId]?.let {
+                                    it == affordance.id
+                                } ?: selectedAffordanceIds.contains(affordance.id)
+                            }
                             .stateIn(viewModelScope)
                     OptionItemViewModel<Icon>(
                         key =
@@ -221,16 +254,11 @@
                                 ) { isSelected, selectedSlotId ->
                                     if (!isSelected) {
                                         {
-                                            viewModelScope.launch {
-                                                quickAffordanceInteractor.select(
-                                                    slotId = selectedSlotId,
-                                                    affordanceId = affordance.id,
-                                                )
-                                                logger.logShortcutApplied(
-                                                    shortcut = affordance.id,
-                                                    shortcutSlotId = selectedSlotId,
-                                                )
-                                            }
+                                            val newMap =
+                                                _selectedQuickAffordances.value
+                                                    .toMutableMap()
+                                                    .apply { put(selectedSlotId, affordance.id) }
+                                            _selectedQuickAffordances.tryEmit(newMap)
                                         }
                                     } else {
                                         null
@@ -258,6 +286,34 @@
                 }
         }
 
+    val onApply: Flow<(() -> Unit)?> =
+        selectedQuickAffordances.map {
+            if (it.isEmpty()) {
+                null
+            } else {
+                {
+                    it.forEach { entry ->
+                        val slotId = entry.key
+                        val affordanceId = entry.value
+                        viewModelScope.launch {
+                            if (slotId == KEYGUARD_QUICK_AFFORDANCE_ID_NONE) {
+                                quickAffordanceInteractor.unselectAllFromSlot(slotId)
+                            } else {
+                                quickAffordanceInteractor.select(
+                                    slotId = slotId,
+                                    affordanceId = affordanceId
+                                )
+                            }
+                            logger.logShortcutApplied(
+                                shortcut = affordanceId,
+                                shortcutSlotId = slotId,
+                            )
+                        }
+                    }
+                }
+            }
+        }
+
     private val _dialog = MutableStateFlow<DialogViewModel?>(null)
     /**
      * The current dialog to show. If `null`, no dialog should be shown.
@@ -365,10 +421,8 @@
     private fun getSlotName(slotId: String): String {
         return applicationContext.getString(
             when (slotId) {
-                KeyguardQuickAffordanceSlots.SLOT_ID_BOTTOM_START ->
-                    R.string.keyguard_slot_name_bottom_start
-                KeyguardQuickAffordanceSlots.SLOT_ID_BOTTOM_END ->
-                    R.string.keyguard_slot_name_bottom_end
+                SLOT_ID_BOTTOM_START -> R.string.keyguard_slot_name_bottom_start
+                SLOT_ID_BOTTOM_END -> R.string.keyguard_slot_name_bottom_end
                 else -> error("No name for slot with ID of \"$slotId\"!")
             }
         )
@@ -377,10 +431,8 @@
     private fun getSlotContentDescription(slotId: String): String {
         return applicationContext.getString(
             when (slotId) {
-                KeyguardQuickAffordanceSlots.SLOT_ID_BOTTOM_START ->
-                    R.string.keyguard_slot_name_bottom_start
-                KeyguardQuickAffordanceSlots.SLOT_ID_BOTTOM_END ->
-                    R.string.keyguard_slot_name_bottom_end
+                SLOT_ID_BOTTOM_START -> R.string.keyguard_slot_name_bottom_start
+                SLOT_ID_BOTTOM_END -> R.string.keyguard_slot_name_bottom_end
                 else -> error("No accessibility label for slot with ID \"$slotId\"!")
             }
         )
@@ -393,15 +445,9 @@
     val summary: Flow<KeyguardQuickAffordanceSummaryViewModel> =
         slots.map { slots ->
             val icon2 =
-                (slots[KeyguardQuickAffordanceSlots.SLOT_ID_BOTTOM_END]
-                        ?.selectedQuickAffordances
-                        ?.firstOrNull())
-                    ?.payload
+                (slots[SLOT_ID_BOTTOM_END]?.selectedQuickAffordances?.firstOrNull())?.payload
             val icon1 =
-                (slots[KeyguardQuickAffordanceSlots.SLOT_ID_BOTTOM_START]
-                        ?.selectedQuickAffordances
-                        ?.firstOrNull())
-                    ?.payload
+                (slots[SLOT_ID_BOTTOM_START]?.selectedQuickAffordances?.firstOrNull())?.payload
 
             KeyguardQuickAffordanceSummaryViewModel(
                 description = toDescriptionText(applicationContext, slots),
@@ -424,15 +470,9 @@
         slots: Map<String, KeyguardQuickAffordanceSlotViewModel>,
     ): Text {
         val bottomStartAffordanceName =
-            slots[KeyguardQuickAffordanceSlots.SLOT_ID_BOTTOM_START]
-                ?.selectedQuickAffordances
-                ?.firstOrNull()
-                ?.text
+            slots[SLOT_ID_BOTTOM_START]?.selectedQuickAffordances?.firstOrNull()?.text
         val bottomEndAffordanceName =
-            slots[KeyguardQuickAffordanceSlots.SLOT_ID_BOTTOM_END]
-                ?.selectedQuickAffordances
-                ?.firstOrNull()
-                ?.text
+            slots[SLOT_ID_BOTTOM_END]?.selectedQuickAffordances?.firstOrNull()?.text
 
         return when {
             bottomStartAffordanceName != null && bottomEndAffordanceName != null -> {
diff --git a/src/com/android/wallpaper/customization/ui/viewmodel/ThemePickerCustomizationOptionsViewModel.kt b/src/com/android/wallpaper/customization/ui/viewmodel/ThemePickerCustomizationOptionsViewModel.kt
index 79671d6..01c1c80 100644
--- a/src/com/android/wallpaper/customization/ui/viewmodel/ThemePickerCustomizationOptionsViewModel.kt
+++ b/src/com/android/wallpaper/customization/ui/viewmodel/ThemePickerCustomizationOptionsViewModel.kt
@@ -48,7 +48,10 @@
 
     override val selectedOption = defaultCustomizationOptionsViewModel.selectedOption
 
-    override fun deselectOption(): Boolean = defaultCustomizationOptionsViewModel.deselectOption()
+    override fun deselectOption(): Boolean {
+        keyguardQuickAffordancePickerViewModel2.resetPreview()
+        return defaultCustomizationOptionsViewModel.deselectOption()
+    }
 
     val onCustomizeClockClicked: Flow<(() -> Unit)?> =
         selectedOption.map {
@@ -77,8 +80,6 @@
             }
         }
 
-    val keyguardQuickAffordanceSummery = keyguardQuickAffordancePickerViewModel2.summary
-
     val onCustomizeColorsClicked: Flow<(() -> Unit)?> =
         selectedOption.map {
             if (it == null) {
diff --git a/src/com/android/wallpaper/picker/common/preview/ui/binder/ThemePickerWorkspaceCallbackBinder.kt b/src/com/android/wallpaper/picker/common/preview/ui/binder/ThemePickerWorkspaceCallbackBinder.kt
new file mode 100644
index 0000000..a80febd
--- /dev/null
+++ b/src/com/android/wallpaper/picker/common/preview/ui/binder/ThemePickerWorkspaceCallbackBinder.kt
@@ -0,0 +1,124 @@
+/*
+ * Copyright (C) 2024 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.wallpaper.picker.common.preview.ui.binder
+
+import android.os.Bundle
+import android.os.Message
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleOwner
+import androidx.lifecycle.lifecycleScope
+import androidx.lifecycle.repeatOnLifecycle
+import com.android.systemui.shared.keyguard.shared.model.KeyguardQuickAffordanceSlots.SLOT_ID_BOTTOM_END
+import com.android.systemui.shared.keyguard.shared.model.KeyguardQuickAffordanceSlots.SLOT_ID_BOTTOM_START
+import com.android.systemui.shared.quickaffordance.shared.model.KeyguardPreviewConstants.KEY_INITIALLY_SELECTED_SLOT_ID
+import com.android.systemui.shared.quickaffordance.shared.model.KeyguardPreviewConstants.KEY_QUICK_AFFORDANCE_ID
+import com.android.systemui.shared.quickaffordance.shared.model.KeyguardPreviewConstants.KEY_SLOT_ID
+import com.android.systemui.shared.quickaffordance.shared.model.KeyguardPreviewConstants.MESSAGE_ID_DEFAULT_PREVIEW
+import com.android.systemui.shared.quickaffordance.shared.model.KeyguardPreviewConstants.MESSAGE_ID_PREVIEW_QUICK_AFFORDANCE_SELECTED
+import com.android.systemui.shared.quickaffordance.shared.model.KeyguardPreviewConstants.MESSAGE_ID_SLOT_SELECTED
+import com.android.systemui.shared.quickaffordance.shared.model.KeyguardPreviewConstants.MESSAGE_ID_START_CUSTOMIZING_QUICK_AFFORDANCES
+import com.android.wallpaper.customization.ui.util.ThemePickerCustomizationOptionUtil.ThemePickerLockCustomizationOption
+import com.android.wallpaper.customization.ui.viewmodel.ThemePickerCustomizationOptionsViewModel
+import com.android.wallpaper.picker.common.preview.ui.binder.WorkspaceCallbackBinder.Companion.sendMessage
+import com.android.wallpaper.picker.customization.ui.viewmodel.CustomizationOptionsViewModel
+import javax.inject.Inject
+import javax.inject.Singleton
+import kotlinx.coroutines.launch
+
+@Singleton
+class ThemePickerWorkspaceCallbackBinder
+@Inject
+constructor(private val defaultWorkspaceCallbackBinder: DefaultWorkspaceCallbackBinder) :
+    WorkspaceCallbackBinder {
+
+    override fun bind(
+        workspaceCallback: Message,
+        viewModel: CustomizationOptionsViewModel,
+        lifecycleOwner: LifecycleOwner,
+    ) {
+        defaultWorkspaceCallbackBinder.bind(
+            workspaceCallback = workspaceCallback,
+            viewModel = viewModel,
+            lifecycleOwner = lifecycleOwner,
+        )
+
+        if (viewModel !is ThemePickerCustomizationOptionsViewModel) {
+            throw IllegalArgumentException(
+                "viewModel $viewModel is not a ThemePickerCustomizationOptionsViewModel."
+            )
+        }
+
+        lifecycleOwner.lifecycleScope.launch {
+            lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
+                launch {
+                    viewModel.selectedOption.collect {
+                        when (it) {
+                            ThemePickerLockCustomizationOption.SHORTCUTS ->
+                                workspaceCallback.sendMessage(
+                                    MESSAGE_ID_START_CUSTOMIZING_QUICK_AFFORDANCES,
+                                    Bundle().apply {
+                                        putString(
+                                            KEY_INITIALLY_SELECTED_SLOT_ID,
+                                            SLOT_ID_BOTTOM_START,
+                                        )
+                                    }
+                                )
+                            else ->
+                                workspaceCallback.sendMessage(
+                                    MESSAGE_ID_DEFAULT_PREVIEW,
+                                    Bundle.EMPTY,
+                                )
+                        }
+                    }
+                }
+
+                launch {
+                    viewModel.keyguardQuickAffordancePickerViewModel2.selectedSlotId.collect {
+                        workspaceCallback.sendMessage(
+                            MESSAGE_ID_SLOT_SELECTED,
+                            Bundle().apply { putString(KEY_SLOT_ID, it) },
+                        )
+                    }
+                }
+
+                launch {
+                    viewModel.keyguardQuickAffordancePickerViewModel2.selectedQuickAffordances
+                        .collect {
+                            it[SLOT_ID_BOTTOM_START]?.let {
+                                workspaceCallback.sendMessage(
+                                    MESSAGE_ID_PREVIEW_QUICK_AFFORDANCE_SELECTED,
+                                    Bundle().apply {
+                                        putString(KEY_SLOT_ID, SLOT_ID_BOTTOM_START)
+                                        putString(KEY_QUICK_AFFORDANCE_ID, it)
+                                    },
+                                )
+                            }
+                            it[SLOT_ID_BOTTOM_END]?.let {
+                                workspaceCallback.sendMessage(
+                                    MESSAGE_ID_PREVIEW_QUICK_AFFORDANCE_SELECTED,
+                                    Bundle().apply {
+                                        putString(KEY_SLOT_ID, SLOT_ID_BOTTOM_END)
+                                        putString(KEY_QUICK_AFFORDANCE_ID, it)
+                                    },
+                                )
+                            }
+                        }
+                }
+            }
+        }
+    }
+}
diff --git a/src_override/com/android/wallpaper/modules/ThemePickerAppModule.kt b/src_override/com/android/wallpaper/modules/ThemePickerAppModule.kt
index 8cda50e..4426b49 100644
--- a/src_override/com/android/wallpaper/modules/ThemePickerAppModule.kt
+++ b/src_override/com/android/wallpaper/modules/ThemePickerAppModule.kt
@@ -36,6 +36,7 @@
 import com.android.systemui.shared.settings.data.repository.SystemSettingsRepository
 import com.android.systemui.shared.settings.data.repository.SystemSettingsRepositoryImpl
 import com.android.wallpaper.customization.ui.binder.ThemePickerCustomizationOptionsBinder
+import com.android.wallpaper.customization.ui.binder.ThemePickerToolbarBinder
 import com.android.wallpaper.effects.DefaultEffectsController
 import com.android.wallpaper.effects.EffectsController
 import com.android.wallpaper.module.DefaultPartnerProvider
@@ -50,7 +51,10 @@
 import com.android.wallpaper.picker.category.domain.interactor.implementations.DefaultCategoriesLoadingStatusInteractor
 import com.android.wallpaper.picker.category.ui.view.providers.IndividualPickerFactory
 import com.android.wallpaper.picker.category.ui.view.providers.implementation.DefaultIndividualPickerFactory
+import com.android.wallpaper.picker.common.preview.ui.binder.ThemePickerWorkspaceCallbackBinder
+import com.android.wallpaper.picker.common.preview.ui.binder.WorkspaceCallbackBinder
 import com.android.wallpaper.picker.customization.ui.binder.CustomizationOptionsBinder
+import com.android.wallpaper.picker.customization.ui.binder.ToolbarBinder
 import com.android.wallpaper.picker.di.modules.BackgroundDispatcher
 import com.android.wallpaper.picker.di.modules.MainDispatcher
 import com.android.wallpaper.picker.preview.ui.util.DefaultImageEffectDialogUtil
@@ -129,6 +133,8 @@
     @Singleton
     abstract fun bindThemesUserEventLogger(impl: ThemesUserEventLoggerImpl): ThemesUserEventLogger
 
+    @Binds @Singleton abstract fun bindToolbarBinder(impl: ThemePickerToolbarBinder): ToolbarBinder
+
     @Binds
     @Singleton
     abstract fun bindUserEventLogger(impl: ThemesUserEventLoggerImpl): UserEventLogger
@@ -145,6 +151,12 @@
         impl: DefaultCustomizationPreferences
     ): WallpaperPreferences
 
+    @Binds
+    @Singleton
+    abstract fun bindWorkspaceCallbackBinder(
+        impl: ThemePickerWorkspaceCallbackBinder
+    ): WorkspaceCallbackBinder
+
     companion object {
 
         @Provides
diff --git a/tests/common/src/com/android/customization/module/logging/TestThemesUserEventLogger.kt b/tests/common/src/com/android/customization/module/logging/TestThemesUserEventLogger.kt
index 8e9dacd..4651067 100644
--- a/tests/common/src/com/android/customization/module/logging/TestThemesUserEventLogger.kt
+++ b/tests/common/src/com/android/customization/module/logging/TestThemesUserEventLogger.kt
@@ -31,11 +31,15 @@
     @ColorSource
     var themeColorSource: Int = StyleEnums.COLOR_SOURCE_UNSPECIFIED
         private set
+
     var themeColorStyle: Int = -1
         private set
+
     var themeSeedColor: Int = -1
         private set
 
+    var shortcutLogs: List<Pair<String, String>> = emptyList()
+
     override fun logThemeColorApplied(@ColorSource source: Int, style: Int, seedColor: Int) {
         this.themeColorSource = source
         this.themeColorStyle = style
@@ -56,7 +60,9 @@
 
     override fun logLockScreenNotificationApplied(showLockScreenNotifications: Boolean) {}
 
-    override fun logShortcutApplied(shortcut: String, shortcutSlotId: String) {}
+    override fun logShortcutApplied(shortcut: String, shortcutSlotId: String) {
+        shortcutLogs = shortcutLogs.toMutableList().apply { add(shortcut to shortcutSlotId) }
+    }
 
     override fun logDarkThemeApplied(useDarkTheme: Boolean) {}
 
diff --git a/tests/module/src/com/android/wallpaper/ThemePickerTestModule.kt b/tests/module/src/com/android/wallpaper/ThemePickerTestModule.kt
index 3c46cb7..ac49994 100644
--- a/tests/module/src/com/android/wallpaper/ThemePickerTestModule.kt
+++ b/tests/module/src/com/android/wallpaper/ThemePickerTestModule.kt
@@ -35,6 +35,7 @@
 import com.android.systemui.shared.customization.data.content.CustomizationProviderClientImpl
 import com.android.systemui.shared.settings.data.repository.SecureSettingsRepository
 import com.android.systemui.shared.settings.data.repository.SecureSettingsRepositoryImpl
+import com.android.wallpaper.customization.ui.binder.ThemePickerToolbarBinder
 import com.android.wallpaper.effects.EffectsController
 import com.android.wallpaper.effects.FakeEffectsController
 import com.android.wallpaper.module.Injector
@@ -44,8 +45,11 @@
 import com.android.wallpaper.module.logging.UserEventLogger
 import com.android.wallpaper.modules.ThemePickerAppModule
 import com.android.wallpaper.network.Requester
+import com.android.wallpaper.picker.common.preview.ui.binder.ThemePickerWorkspaceCallbackBinder
+import com.android.wallpaper.picker.common.preview.ui.binder.WorkspaceCallbackBinder
 import com.android.wallpaper.picker.customization.ui.binder.CustomizationOptionsBinder
 import com.android.wallpaper.picker.customization.ui.binder.DefaultCustomizationOptionsBinder
+import com.android.wallpaper.picker.customization.ui.binder.ToolbarBinder
 import com.android.wallpaper.picker.di.modules.BackgroundDispatcher
 import com.android.wallpaper.picker.di.modules.MainDispatcher
 import com.android.wallpaper.picker.preview.ui.util.DefaultImageEffectDialogUtil
@@ -114,6 +118,8 @@
     @Singleton
     abstract fun bindThemesUserEventLogger(impl: TestThemesUserEventLogger): ThemesUserEventLogger
 
+    @Binds @Singleton abstract fun bindToolbarBinder(impl: ThemePickerToolbarBinder): ToolbarBinder
+
     @Binds @Singleton abstract fun bindUserEventLogger(impl: TestUserEventLogger): UserEventLogger
 
     @Binds
@@ -128,6 +134,12 @@
         impl: TestDefaultCustomizationPreferences
     ): WallpaperPreferences
 
+    @Binds
+    @Singleton
+    abstract fun bindWorkspaceCallbackBinder(
+        impl: ThemePickerWorkspaceCallbackBinder
+    ): WorkspaceCallbackBinder
+
     companion object {
 
         @Provides
diff --git a/tests/robotests/src/com/android/wallpaper/customization/ui/viewmodel/KeyguardQuickAffordancePickerViewModel2Test.kt b/tests/robotests/src/com/android/wallpaper/customization/ui/viewmodel/KeyguardQuickAffordancePickerViewModel2Test.kt
new file mode 100644
index 0000000..19d74f6
--- /dev/null
+++ b/tests/robotests/src/com/android/wallpaper/customization/ui/viewmodel/KeyguardQuickAffordancePickerViewModel2Test.kt
@@ -0,0 +1,418 @@
+/*
+ * Copyright (C) 2024 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.wallpaper.customization.ui.viewmodel
+
+import android.content.Context
+import androidx.test.core.app.ApplicationProvider
+import androidx.test.filters.SmallTest
+import com.android.customization.module.logging.TestThemesUserEventLogger
+import com.android.customization.picker.quickaffordance.data.repository.KeyguardQuickAffordancePickerRepository
+import com.android.customization.picker.quickaffordance.domain.interactor.KeyguardQuickAffordancePickerInteractor
+import com.android.customization.picker.quickaffordance.domain.interactor.KeyguardQuickAffordanceSnapshotRestorer
+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.themepicker.R
+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.FloatingToolbarTabViewModel
+import com.android.wallpaper.picker.option.ui.viewmodel.OptionItemViewModel
+import com.android.wallpaper.testing.collectLastValue
+import com.google.common.truth.Truth.assertThat
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.emptyFlow
+import kotlinx.coroutines.test.TestDispatcher
+import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
+import kotlinx.coroutines.test.resetMain
+import kotlinx.coroutines.test.runTest
+import kotlinx.coroutines.test.setMain
+import org.junit.After
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+
+@OptIn(ExperimentalCoroutinesApi::class)
+@SmallTest
+@RunWith(RobolectricTestRunner::class)
+class KeyguardQuickAffordancePickerViewModel2Test {
+
+    private val logger = TestThemesUserEventLogger()
+
+    private lateinit var underTest: KeyguardQuickAffordancePickerViewModel2
+
+    private lateinit var context: Context
+    private lateinit var testDispatcher: TestDispatcher
+    private lateinit var testScope: TestScope
+    private lateinit var client: FakeCustomizationProviderClient
+
+    @Before
+    fun setUp() {
+        context = ApplicationProvider.getApplicationContext()
+        testDispatcher = UnconfinedTestDispatcher()
+        Dispatchers.setMain(testDispatcher)
+        testScope = TestScope(testDispatcher)
+        client = FakeCustomizationProviderClient()
+        val quickAffordanceInteractor =
+            KeyguardQuickAffordancePickerInteractor(
+                repository =
+                    KeyguardQuickAffordancePickerRepository(
+                        client = client,
+                        mainScope = testScope.backgroundScope,
+                    ),
+                client = client,
+                snapshotRestorer = KeyguardQuickAffordanceSnapshotRestorer(client),
+            )
+        underTest =
+            KeyguardQuickAffordancePickerViewModel2(
+                applicationContext = context,
+                quickAffordanceInteractor = quickAffordanceInteractor,
+                logger = logger,
+                viewModelScope = testScope.backgroundScope,
+            )
+    }
+
+    @After
+    fun tearDown() {
+        Dispatchers.resetMain()
+    }
+
+    @Test
+    fun selectedSlotIdUpdates_whenClickingOnTabsAndCallingResetPreview() =
+        testScope.runTest {
+            val selectedSlotId = collectLastValue(underTest.selectedSlotId)
+
+            val tabs = collectLastValue(underTest.tabs)
+
+            // Default selected slot ID is bottom_start
+            assertThat(selectedSlotId())
+                .isEqualTo(KeyguardQuickAffordanceSlots.SLOT_ID_BOTTOM_START)
+
+            // Click on tab1
+            val tab1 = tabs()?.get(1) ?: throw NullPointerException("secondTab should not be null.")
+            tab1.onClick?.invoke()
+            assertThat(selectedSlotId()).isEqualTo(KeyguardQuickAffordanceSlots.SLOT_ID_BOTTOM_END)
+
+            underTest.resetPreview()
+            assertThat(selectedSlotId())
+                .isEqualTo(KeyguardQuickAffordanceSlots.SLOT_ID_BOTTOM_START)
+        }
+
+    @Test
+    fun selectedQuickAffordancesMapUpdates_whenClickingOnQuickAffordanceOptionsAndCallingResetPreview() =
+        testScope.runTest {
+            val selectedQuickAffordances = collectLastValue(underTest.selectedQuickAffordances)
+
+            val tabs = collectLastValue(underTest.tabs)
+            val quickAffordances = collectLastValue(underTest.quickAffordances)
+
+            // Default selectedQuickAffordances is an empty map
+            assertThat(selectedQuickAffordances()).isEqualTo(emptyMap<String, String>())
+
+            // Click on quick affordance 1 when selected slot ID is bottom_start
+            val onClickAffordance1 =
+                collectLastValue(quickAffordances()?.get(1)?.onClicked ?: emptyFlow())
+            onClickAffordance1()?.invoke()
+            assertThat(selectedQuickAffordances())
+                .isEqualTo(
+                    mapOf(
+                        KeyguardQuickAffordanceSlots.SLOT_ID_BOTTOM_START to
+                            FakeCustomizationProviderClient.AFFORDANCE_1
+                    )
+                )
+
+            // Click on tab 1 to change the selected slot ID to bottom_end and click on quick
+            // affordance 2
+            tabs()?.get(1)?.onClick?.invoke()
+            val onClickAffordance2 =
+                collectLastValue(quickAffordances()?.get(2)?.onClicked ?: emptyFlow())
+            onClickAffordance2()?.invoke()
+            assertThat(selectedQuickAffordances())
+                .isEqualTo(
+                    mapOf(
+                        KeyguardQuickAffordanceSlots.SLOT_ID_BOTTOM_START to
+                            FakeCustomizationProviderClient.AFFORDANCE_1,
+                        KeyguardQuickAffordanceSlots.SLOT_ID_BOTTOM_END to
+                            FakeCustomizationProviderClient.AFFORDANCE_2
+                    )
+                )
+
+            underTest.resetPreview()
+            assertThat(selectedQuickAffordances()).isEqualTo(emptyMap<String, String>())
+        }
+
+    @Test
+    fun tabsUpdates_whenClickingOnTabsAndQuickAffordanceOptions() =
+        testScope.runTest {
+            val tabs = collectLastValue(underTest.tabs)
+
+            val quickAffordances = collectLastValue(underTest.quickAffordances)
+
+            // Default state of the 2 tabs
+            assertTabUiState(
+                tab = tabs()?.get(0),
+                icon = Icon.Resource(R.drawable.link_off, null),
+                text = "Left shortcut",
+                isSelected = true,
+            )
+            assertTabUiState(
+                tab = tabs()?.get(1),
+                icon = Icon.Resource(R.drawable.link_off, null),
+                text = "Right shortcut",
+                isSelected = false,
+            )
+
+            // Click on tab 1
+            tabs()?.get(1)?.onClick?.invoke()
+            assertTabUiState(
+                tab = tabs()?.get(0),
+                icon = Icon.Resource(R.drawable.link_off, null),
+                text = "Left shortcut",
+                isSelected = false,
+            )
+            val tab1 = tabs()?.get(1)
+            assertTabUiState(
+                tab = tab1,
+                icon = Icon.Resource(R.drawable.link_off, null),
+                text = "Right shortcut",
+                isSelected = true,
+            )
+
+            // Click on quick affordance 1 when tab 1 is selected. Icon should change
+            val clickOnQuickAffordance1 =
+                collectLastValue(quickAffordances()?.get(1)?.onClicked ?: emptyFlow())
+            clickOnQuickAffordance1()?.invoke()
+            assertTabUiState(
+                tab = tabs()?.get(1),
+                icon =
+                    Icon.Loaded(
+                        FakeCustomizationProviderClient.ICON_1,
+                        Text.Loaded("Right shortcut")
+                    ),
+                text = "Right shortcut",
+                isSelected = true,
+            )
+        }
+
+    @Test
+    fun quickAffordancesUpdates_whenClickingOnTabsAndQuickAffordanceOptions() =
+        testScope.runTest {
+            val quickAffordances = collectLastValue(underTest.quickAffordances)
+
+            val tabs = collectLastValue(underTest.tabs)
+
+            // The default quickAffordances snapshot
+            assertThat(quickAffordances()?.size).isEqualTo(4)
+            assertQuickAffordance(
+                testScope = this,
+                quickAffordance = quickAffordances()?.get(0),
+                key = "${KeyguardQuickAffordanceSlots.SLOT_ID_BOTTOM_START}::none",
+                icon = Icon.Resource(R.drawable.link_off, null),
+                text = Text.Resource(R.string.keyguard_affordance_none),
+                isSelected = true,
+            )
+            assertQuickAffordance(
+                testScope = this,
+                quickAffordance = quickAffordances()?.get(1),
+                key =
+                    "${KeyguardQuickAffordanceSlots.SLOT_ID_BOTTOM_START}::${FakeCustomizationProviderClient.AFFORDANCE_1}",
+                icon = Icon.Loaded(FakeCustomizationProviderClient.ICON_1, null),
+                text = Text.Loaded(FakeCustomizationProviderClient.AFFORDANCE_1),
+                isSelected = false,
+            )
+            assertQuickAffordance(
+                testScope = this,
+                quickAffordance = quickAffordances()?.get(2),
+                key =
+                    "${KeyguardQuickAffordanceSlots.SLOT_ID_BOTTOM_START}::${FakeCustomizationProviderClient.AFFORDANCE_2}",
+                icon = Icon.Loaded(FakeCustomizationProviderClient.ICON_2, null),
+                text = Text.Loaded(FakeCustomizationProviderClient.AFFORDANCE_2),
+                isSelected = false,
+            )
+            assertQuickAffordance(
+                testScope = this,
+                quickAffordance = quickAffordances()?.get(3),
+                key =
+                    "${KeyguardQuickAffordanceSlots.SLOT_ID_BOTTOM_START}::${FakeCustomizationProviderClient.AFFORDANCE_3}",
+                icon = Icon.Loaded(FakeCustomizationProviderClient.ICON_3, null),
+                text = Text.Loaded(FakeCustomizationProviderClient.AFFORDANCE_3),
+                isSelected = false,
+            )
+
+            // Click on quick affordance 2. Quick affordance 0 will be unselected and quick
+            // affordance 2 will be selected.
+            val onClickQuickAffordance2 =
+                collectLastValue(quickAffordances()?.get(2)?.onClicked ?: emptyFlow())
+            onClickQuickAffordance2()?.invoke()
+            assertQuickAffordance(
+                testScope = this,
+                quickAffordance = quickAffordances()?.get(0),
+                key = "${KeyguardQuickAffordanceSlots.SLOT_ID_BOTTOM_START}::none",
+                icon = Icon.Resource(R.drawable.link_off, null),
+                text = Text.Resource(R.string.keyguard_affordance_none),
+                isSelected = false,
+            )
+            assertQuickAffordance(
+                testScope = this,
+                quickAffordance = quickAffordances()?.get(2),
+                key =
+                    "${KeyguardQuickAffordanceSlots.SLOT_ID_BOTTOM_START}::${FakeCustomizationProviderClient.AFFORDANCE_2}",
+                icon = Icon.Loaded(FakeCustomizationProviderClient.ICON_2, null),
+                text = Text.Loaded(FakeCustomizationProviderClient.AFFORDANCE_2),
+                isSelected = true,
+            )
+
+            tabs()?.get(1)?.onClick?.invoke()
+            assertQuickAffordance(
+                testScope = this,
+                quickAffordance = quickAffordances()?.get(0),
+                key = "${KeyguardQuickAffordanceSlots.SLOT_ID_BOTTOM_END}::none",
+                icon = Icon.Resource(R.drawable.link_off, null),
+                text = Text.Resource(R.string.keyguard_affordance_none),
+                isSelected = true,
+            )
+            assertQuickAffordance(
+                testScope = this,
+                quickAffordance = quickAffordances()?.get(1),
+                key =
+                    "${KeyguardQuickAffordanceSlots.SLOT_ID_BOTTOM_END}::${FakeCustomizationProviderClient.AFFORDANCE_1}",
+                icon = Icon.Loaded(FakeCustomizationProviderClient.ICON_1, null),
+                text = Text.Loaded(FakeCustomizationProviderClient.AFFORDANCE_1),
+                isSelected = false,
+            )
+            assertQuickAffordance(
+                testScope = this,
+                quickAffordance = quickAffordances()?.get(2),
+                key =
+                    "${KeyguardQuickAffordanceSlots.SLOT_ID_BOTTOM_END}::${FakeCustomizationProviderClient.AFFORDANCE_2}",
+                icon = Icon.Loaded(FakeCustomizationProviderClient.ICON_2, null),
+                text = Text.Loaded(FakeCustomizationProviderClient.AFFORDANCE_2),
+                isSelected = false,
+            )
+            assertQuickAffordance(
+                testScope = this,
+                quickAffordance = quickAffordances()?.get(3),
+                key =
+                    "${KeyguardQuickAffordanceSlots.SLOT_ID_BOTTOM_END}::${FakeCustomizationProviderClient.AFFORDANCE_3}",
+                icon = Icon.Loaded(FakeCustomizationProviderClient.ICON_3, null),
+                text = Text.Loaded(FakeCustomizationProviderClient.AFFORDANCE_3),
+                isSelected = false,
+            )
+
+            // When tab 1 is selected, click on quick affordance 3. Quick affordance 0 will be
+            // unselected and quick affordance 3 will be selected.
+            val onClickQuickAffordance3 =
+                collectLastValue(quickAffordances()?.get(3)?.onClicked ?: emptyFlow())
+            onClickQuickAffordance3()?.invoke()
+            assertQuickAffordance(
+                testScope = this,
+                quickAffordance = quickAffordances()?.get(0),
+                key = "${KeyguardQuickAffordanceSlots.SLOT_ID_BOTTOM_END}::none",
+                icon = Icon.Resource(R.drawable.link_off, null),
+                text = Text.Resource(R.string.keyguard_affordance_none),
+                isSelected = false,
+            )
+            assertQuickAffordance(
+                testScope = this,
+                quickAffordance = quickAffordances()?.get(3),
+                key =
+                    "${KeyguardQuickAffordanceSlots.SLOT_ID_BOTTOM_END}::${FakeCustomizationProviderClient.AFFORDANCE_3}",
+                icon = Icon.Loaded(FakeCustomizationProviderClient.ICON_3, null),
+                text = Text.Loaded(FakeCustomizationProviderClient.AFFORDANCE_3),
+                isSelected = true,
+            )
+        }
+
+    @Test
+    fun loggerShouldLogAndClientShouldUpdate_whenOnApply() =
+        testScope.runTest {
+            val onApply = collectLastValue(underTest.onApply)
+
+            val tabs = collectLastValue(underTest.tabs)
+            val quickAffordances = collectLastValue(underTest.quickAffordances)
+
+            // Select the preview quick affordances
+            val onClickAffordance1 =
+                collectLastValue(quickAffordances()?.get(1)?.onClicked ?: emptyFlow())
+            onClickAffordance1()?.invoke()
+            tabs()?.get(1)?.onClick?.invoke()
+            val onClickAffordance2 =
+                collectLastValue(quickAffordances()?.get(2)?.onClicked ?: emptyFlow())
+            onClickAffordance2()?.invoke()
+
+            onApply()?.invoke()
+            assertThat(client.querySelections())
+                .isEqualTo(
+                    listOf(
+                        CustomizationProviderClient.Selection(
+                            slotId = KeyguardQuickAffordanceSlots.SLOT_ID_BOTTOM_START,
+                            affordanceId = FakeCustomizationProviderClient.AFFORDANCE_1,
+                            affordanceName = FakeCustomizationProviderClient.AFFORDANCE_1,
+                        ),
+                        CustomizationProviderClient.Selection(
+                            slotId = KeyguardQuickAffordanceSlots.SLOT_ID_BOTTOM_END,
+                            affordanceId = FakeCustomizationProviderClient.AFFORDANCE_2,
+                            affordanceName = FakeCustomizationProviderClient.AFFORDANCE_2,
+                        ),
+                    )
+                )
+            assertThat(logger.shortcutLogs)
+                .isEqualTo(
+                    listOf(
+                        FakeCustomizationProviderClient.AFFORDANCE_1 to
+                            KeyguardQuickAffordanceSlots.SLOT_ID_BOTTOM_START,
+                        FakeCustomizationProviderClient.AFFORDANCE_2 to
+                            KeyguardQuickAffordanceSlots.SLOT_ID_BOTTOM_END,
+                    )
+                )
+        }
+
+    private fun assertTabUiState(
+        tab: FloatingToolbarTabViewModel?,
+        icon: Icon?,
+        text: String,
+        isSelected: Boolean,
+    ) {
+        if (tab == null) {
+            throw NullPointerException("tab is null.")
+        }
+        assertThat(tab.icon).isEqualTo(icon)
+        assertThat(tab.text).isEqualTo(text)
+        assertThat(tab.isSelected).isEqualTo(isSelected)
+    }
+
+    private fun assertQuickAffordance(
+        testScope: TestScope,
+        quickAffordance: OptionItemViewModel<Icon>?,
+        key: String,
+        icon: Icon,
+        text: Text,
+        isSelected: Boolean,
+    ) {
+        if (quickAffordance == null) {
+            throw NullPointerException("quickAffordance is null.")
+        }
+        assertThat(testScope.collectLastValue(quickAffordance.key)()).isEqualTo(key)
+        assertThat(quickAffordance.payload).isEqualTo(icon)
+        assertThat(quickAffordance.text).isEqualTo(text)
+        assertThat(quickAffordance.isTextUserVisible).isEqualTo(true)
+        assertThat(testScope.collectLastValue(quickAffordance.isSelected)()).isEqualTo(isSelected)
+        assertThat(quickAffordance.isEnabled).isEqualTo(true)
+    }
+}