Merge "Import translations. DO NOT MERGE ANYWHERE" into main
diff --git a/res/layout/bottom_sheet_shortcut.xml b/res/layout/bottom_sheet_shortcut.xml
index a55c2cc..e285604 100644
--- a/res/layout/bottom_sheet_shortcut.xml
+++ b/res/layout/bottom_sheet_shortcut.xml
@@ -14,11 +14,36 @@
~ limitations under the License.
-->
-<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
+<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
- android:layout_height="wrap_content">
- <androidx.recyclerview.widget.RecyclerView
- android:id="@+id/quick_affordance_horizontal_list"
+ android:layout_height="wrap_content"
+ android:paddingHorizontal="16dp"
+ android:paddingBottom="16dp"
+ android:orientation="vertical">
+
+ <FrameLayout
android:layout_width="match_parent"
- android:layout_height="wrap_content" />
-</FrameLayout>
\ No newline at end of file
+ android:layout_height="wrap_content"
+ android:background="@drawable/picker_fragment_background">
+
+ <androidx.recyclerview.widget.RecyclerView
+ android:id="@+id/quick_affordance_horizontal_list"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:clipChildren="false"
+ android:clipToPadding="false"/>
+ </FrameLayout>
+
+ <FrameLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:paddingVertical="8dp">
+
+ <androidx.recyclerview.widget.RecyclerView
+ android:id="@id/slot_tabs"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_gravity="center_horizontal"
+ android:clipToPadding="false" />
+ </FrameLayout>
+</LinearLayout>
\ No newline at end of file
diff --git a/res/layout/quick_affordance_list_item.xml b/res/layout/quick_affordance_list_item.xml
new file mode 100644
index 0000000..c6b3fd4
--- /dev/null
+++ b/res/layout/quick_affordance_list_item.xml
@@ -0,0 +1,63 @@
+<?xml version="1.0" encoding="utf-8"?><!--
+ ~ 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.
+ -->
+
+<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+ android:orientation="vertical"
+ android:layout_width="64dp"
+ android:layout_height="wrap_content"
+ android:divider="@drawable/vertical_divider_8dp"
+ android:clipChildren="false"
+ android:showDividers="middle">
+
+ <FrameLayout
+ android:layout_width="64dp"
+ android:layout_height="64dp"
+ android:background="@drawable/option_item_background"
+ android:clipChildren="false">
+
+ <ImageView
+ 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"
+ android:tint="@color/system_on_surface" />
+ </FrameLayout>
+
+ <TextView
+ android:id="@id/text"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:gravity="center_horizontal"
+ android:textColor="@color/system_on_surface"
+ android:lines="2"
+ android:hyphenationFrequency="normal"
+ android:ellipsize="end" />
+</LinearLayout>
\ No newline at end of file
diff --git a/src/com/android/customization/picker/common/ui/view/KeyguardQuickAffordanceItemSpacing.kt b/src/com/android/customization/picker/common/ui/view/KeyguardQuickAffordanceItemSpacing.kt
new file mode 100644
index 0000000..c056d33
--- /dev/null
+++ b/src/com/android/customization/picker/common/ui/view/KeyguardQuickAffordanceItemSpacing.kt
@@ -0,0 +1,83 @@
+/*
+ * Copyright (C) 2023 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.common.ui.view
+
+import android.graphics.Rect
+import android.view.View
+import androidx.recyclerview.widget.RecyclerView
+
+/** Item spacing used by the RecyclerView. */
+class KeyguardQuickAffordanceItemSpacing() : RecyclerView.ItemDecoration() {
+ override fun getItemOffsets(
+ outRect: Rect,
+ view: View,
+ parent: RecyclerView,
+ state: RecyclerView.State
+ ) {
+ val itemIndex = parent.getChildAdapterPosition(view)
+ val columnIndex = itemIndex / 2
+ val isRtl = parent.layoutManager?.layoutDirection == View.LAYOUT_DIRECTION_RTL
+ val density = parent.context.resources.displayMetrics.density
+
+ val itemCount = parent.adapter?.itemCount ?: 0
+ val columnCount = (itemCount + 1) / 2
+ when {
+ columnCount == 1 -> {
+ outRect.left = EDGE_ITEM_HORIZONTAL_SPACING_DP.toPx(density)
+ outRect.right = EDGE_ITEM_HORIZONTAL_SPACING_DP.toPx(density)
+ }
+ columnIndex > 0 && columnIndex < columnCount - 1 -> {
+ outRect.left = COMMON_HORIZONTAL_SPACING_DP.toPx(density)
+ outRect.right = COMMON_HORIZONTAL_SPACING_DP.toPx(density)
+ }
+ columnIndex == 0 -> {
+ outRect.left =
+ if (!isRtl) EDGE_ITEM_HORIZONTAL_SPACING_DP.toPx(density)
+ else COMMON_HORIZONTAL_SPACING_DP.toPx(density)
+ outRect.right =
+ if (isRtl) EDGE_ITEM_HORIZONTAL_SPACING_DP.toPx(density)
+ else COMMON_HORIZONTAL_SPACING_DP.toPx(density)
+ }
+ columnIndex == columnCount - 1 -> {
+ outRect.right =
+ if (!isRtl) EDGE_ITEM_HORIZONTAL_SPACING_DP.toPx(density)
+ else COMMON_HORIZONTAL_SPACING_DP.toPx(density)
+ outRect.left =
+ if (isRtl) EDGE_ITEM_HORIZONTAL_SPACING_DP.toPx(density)
+ else COMMON_HORIZONTAL_SPACING_DP.toPx(density)
+ }
+ }
+
+ if (itemIndex % 2 == 0) {
+ outRect.top = FIRST_ROW_TOP_SPACING_DP.toPx(density)
+ outRect.bottom = FIRST_ROW_BOTTOM_SPACING_DP.toPx(density)
+ } else {
+ outRect.bottom = SECOND_ROW_BOTTOM_SPACING_DP.toPx(density)
+ }
+ }
+
+ private fun Int.toPx(density: Float): Int {
+ return (this * density).toInt()
+ }
+
+ companion object {
+ const val EDGE_ITEM_HORIZONTAL_SPACING_DP = 20
+ const val COMMON_HORIZONTAL_SPACING_DP = 9
+ const val FIRST_ROW_TOP_SPACING_DP = 20
+ const val FIRST_ROW_BOTTOM_SPACING_DP = 8
+ const val SECOND_ROW_BOTTOM_SPACING_DP = 24
+ }
+}
diff --git a/src/com/android/wallpaper/customization/ui/binder/ShortcutBottomSheetBinder.kt b/src/com/android/wallpaper/customization/ui/binder/ShortcutBottomSheetBinder.kt
new file mode 100644
index 0000000..de95fc3
--- /dev/null
+++ b/src/com/android/wallpaper/customization/ui/binder/ShortcutBottomSheetBinder.kt
@@ -0,0 +1,187 @@
+/*
+ * 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.app.Dialog
+import android.content.Context
+import android.view.View
+import android.view.ViewGroup
+import android.view.accessibility.AccessibilityEvent
+import android.widget.ImageView
+import androidx.core.view.AccessibilityDelegateCompat
+import androidx.core.view.ViewCompat
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleOwner
+import androidx.lifecycle.lifecycleScope
+import androidx.lifecycle.repeatOnLifecycle
+import androidx.recyclerview.widget.GridLayoutManager
+import androidx.recyclerview.widget.LinearLayoutManager
+import androidx.recyclerview.widget.RecyclerView
+import com.android.customization.picker.common.ui.view.ItemSpacing
+import com.android.customization.picker.common.ui.view.KeyguardQuickAffordanceItemSpacing
+import com.android.customization.picker.quickaffordance.ui.adapter.SlotTabAdapter
+import com.android.themepicker.R
+import com.android.wallpaper.customization.ui.viewmodel.KeyguardQuickAffordancePickerViewModel2
+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.common.icon.ui.viewbinder.IconViewBinder
+import com.android.wallpaper.picker.common.icon.ui.viewmodel.Icon
+import com.android.wallpaper.picker.option.ui.adapter.OptionItemAdapter
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.collectIndexed
+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 ShortcutBottomSheetBinder {
+
+ fun bind(
+ view: View,
+ viewModel: KeyguardQuickAffordancePickerViewModel2,
+ lifecycleOwner: LifecycleOwner,
+ ) {
+ val quickAffordanceAdapter =
+ OptionItemAdapter(
+ layoutResourceId = R.layout.quick_affordance_list_item,
+ lifecycleOwner = lifecycleOwner,
+ bindIcon = { foregroundView: View, gridIcon: Icon ->
+ val imageView = foregroundView as? ImageView
+ imageView?.let { IconViewBinder.bind(imageView, gridIcon) }
+ },
+ )
+ val quickAffordanceList =
+ view.requireViewById<RecyclerView>(R.id.quick_affordance_horizontal_list).apply {
+ adapter = quickAffordanceAdapter
+ layoutManager =
+ GridLayoutManager(
+ view.context.applicationContext,
+ 2,
+ GridLayoutManager.HORIZONTAL,
+ false
+ )
+ addItemDecoration(KeyguardQuickAffordanceItemSpacing())
+ }
+ val slotTabAdapter = SlotTabAdapter()
+ val slotTabView: RecyclerView =
+ view.requireViewById<RecyclerView>(R.id.slot_tabs).apply {
+ adapter = slotTabAdapter
+ layoutManager = LinearLayoutManager(view.context, RecyclerView.HORIZONTAL, false)
+ addItemDecoration(ItemSpacing(ItemSpacing.TAB_ITEM_SPACING_DP))
+ }
+ // Setting a custom accessibility delegate so that the default content descriptions
+ // for items in a list aren't announced (for left & right shortcuts). We populate
+ // the content description for these shortcuts later on with the right (expected)
+ // values.
+ val slotTabViewDelegate: AccessibilityDelegateCompat =
+ object : AccessibilityDelegateCompat() {
+ override fun onRequestSendAccessibilityEvent(
+ host: ViewGroup,
+ child: View,
+ event: AccessibilityEvent
+ ): Boolean {
+ if (event.eventType != AccessibilityEvent.TYPE_VIEW_FOCUSED) {
+ child.contentDescription = null
+ }
+ return super.onRequestSendAccessibilityEvent(host, child, event)
+ }
+ }
+ ViewCompat.setAccessibilityDelegate(slotTabView, slotTabViewDelegate)
+
+ var dialog: Dialog? = null
+
+ lifecycleOwner.lifecycleScope.launch {
+ lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
+ launch {
+ viewModel.slots
+ .map { slotById -> slotById.values }
+ .collect { slots -> slotTabAdapter.setItems(slots.toList()) }
+ }
+
+ launch {
+ viewModel.quickAffordances.collect { affordances ->
+ quickAffordanceAdapter.setItems(affordances)
+ }
+ }
+
+ launch {
+ viewModel.quickAffordances
+ .flatMapLatest { affordances ->
+ combine(affordances.map { affordance -> affordance.isSelected }) {
+ selectedFlags ->
+ selectedFlags.indexOfFirst { it }
+ }
+ }
+ .collectIndexed { index, 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.
+ quickAffordanceList.post {
+ if (index == 0) {
+ // don't animate on initial collection
+ quickAffordanceList.scrollToPosition(selectedPosition)
+ } else {
+ quickAffordanceList.smoothScrollToPosition(selectedPosition)
+ }
+ }
+ }
+ }
+ }
+
+ launch {
+ viewModel.dialog.distinctUntilChanged().collect { dialogRequest ->
+ dialog?.dismiss()
+ dialog =
+ if (dialogRequest != null) {
+ showDialog(
+ context = view.context,
+ request = dialogRequest,
+ onDismissed = viewModel::onDialogDismissed
+ )
+ } else {
+ null
+ }
+ }
+ }
+
+ launch {
+ viewModel.activityStartRequests.collect { intent ->
+ if (intent != null) {
+ view.context.startActivity(intent)
+ viewModel.onActivityStarted()
+ }
+ }
+ }
+ }
+ }
+ }
+
+ private fun showDialog(
+ context: Context,
+ request: DialogViewModel,
+ onDismissed: () -> Unit,
+ ): Dialog {
+ return DialogViewBinder.show(
+ context = context,
+ viewModel = request,
+ onDismissed = onDismissed,
+ )
+ }
+}
diff --git a/src/com/android/wallpaper/customization/ui/binder/ThemePickerCustomizationOptionBinder.kt b/src/com/android/wallpaper/customization/ui/binder/ThemePickerCustomizationOptionBinder.kt
index 349c7c5..06374fc 100644
--- a/src/com/android/wallpaper/customization/ui/binder/ThemePickerCustomizationOptionBinder.kt
+++ b/src/com/android/wallpaper/customization/ui/binder/ThemePickerCustomizationOptionBinder.kt
@@ -29,8 +29,10 @@
import com.android.wallpaper.picker.customization.ui.viewmodel.CustomizationOptionsViewModel
import javax.inject.Inject
import javax.inject.Singleton
+import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.launch
+@OptIn(ExperimentalCoroutinesApi::class)
@Singleton
class ThemePickerCustomizationOptionsBinder
@Inject
@@ -77,5 +79,11 @@
}
}
}
+
+ ShortcutBottomSheetBinder.bind(
+ view,
+ viewModel.keyguardQuickAffordancePickerViewModel2,
+ lifecycleOwner,
+ )
}
}
diff --git a/src/com/android/wallpaper/customization/ui/viewmodel/KeyguardQuickAffordancePickerViewModel2.kt b/src/com/android/wallpaper/customization/ui/viewmodel/KeyguardQuickAffordancePickerViewModel2.kt
new file mode 100644
index 0000000..a7fafe5
--- /dev/null
+++ b/src/com/android/wallpaper/customization/ui/viewmodel/KeyguardQuickAffordancePickerViewModel2.kt
@@ -0,0 +1,447 @@
+/*
+ * 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.annotation.SuppressLint
+import android.content.Context
+import android.content.Intent
+import android.graphics.drawable.Drawable
+import androidx.annotation.DrawableRes
+import com.android.customization.module.logging.ThemesUserEventLogger
+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.themepicker.R
+import com.android.wallpaper.picker.common.button.ui.viewmodel.ButtonStyle
+import com.android.wallpaper.picker.common.button.ui.viewmodel.ButtonViewModel
+import com.android.wallpaper.picker.common.dialog.ui.viewmodel.DialogViewModel
+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 dagger.assisted.Assisted
+import dagger.assisted.AssistedFactory
+import dagger.assisted.AssistedInject
+import dagger.hilt.android.qualifiers.ApplicationContext
+import dagger.hilt.android.scopes.ViewModelScoped
+import kotlinx.coroutines.CoroutineScope
+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
+
+class KeyguardQuickAffordancePickerViewModel2
+@AssistedInject
+constructor(
+ @ApplicationContext private val applicationContext: Context,
+ private val quickAffordanceInteractor: KeyguardQuickAffordancePickerInteractor,
+ private val logger: ThemesUserEventLogger,
+ @Assisted private val viewModelScope: CoroutineScope,
+) {
+
+ /** A locally-selected slot, if the user ever switched from the original one. */
+ private val _selectedSlotId = MutableStateFlow<String?>(null)
+ /** 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>> =
+ 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) }
+ },
+ )
+ }
+ }
+
+ /**
+ * 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.selections,
+ selectedSlotId,
+ ) { selections, selectedSlotId ->
+ selections
+ .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<Icon>>> =
+ quickAffordanceInteractor.affordances.map { affordances ->
+ val isNoneSelected = selectedAffordanceIds.map { it.isEmpty() }.stateIn(viewModelScope)
+ listOf(
+ none(
+ slotId = selectedSlotId,
+ isSelected = isNoneSelected,
+ onSelected =
+ combine(
+ isNoneSelected,
+ selectedSlotId,
+ ) { isSelected, selectedSlotId ->
+ if (!isSelected) {
+ {
+ viewModelScope.launch {
+ quickAffordanceInteractor.unselectAllFromSlot(
+ selectedSlotId
+ )
+ logger.logShortcutApplied(
+ shortcut = "none",
+ shortcutSlotId = selectedSlotId,
+ )
+ }
+ }
+ } else {
+ null
+ }
+ }
+ )
+ ) +
+ affordances.map { affordance ->
+ val affordanceIcon = getAffordanceIcon(affordance.iconResourceId)
+ val isSelectedFlow: StateFlow<Boolean> =
+ selectedAffordanceIds
+ .map { it.contains(affordance.id) }
+ .stateIn(viewModelScope)
+ OptionItemViewModel<Icon>(
+ key =
+ selectedSlotId
+ .map { slotId -> "$slotId::${affordance.id}" }
+ .stateIn(viewModelScope),
+ payload = 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,
+ )
+ logger.logShortcutApplied(
+ shortcut = affordance.id,
+ shortcutSlotId = selectedSlotId,
+ )
+ }
+ }
+ } else {
+ null
+ }
+ }
+ } else {
+ flowOf {
+ showEnablementDialog(
+ icon = affordanceIcon,
+ name = affordance.name,
+ explanation = affordance.enablementExplanation,
+ actionText = affordance.enablementActionText,
+ actionIntent = affordance.enablementActionIntent,
+ )
+ }
+ },
+ onLongClicked =
+ if (affordance.configureIntent != null) {
+ { requestActivityStart(affordance.configureIntent) }
+ } else {
+ null
+ },
+ isEnabled = affordance.isEnabled,
+ )
+ }
+ }
+
+ @SuppressLint("UseCompatLoadingForDrawables")
+ val summary: Flow<KeyguardQuickAffordanceSummaryViewModel> =
+ slots.map { slots ->
+ val icon2 =
+ (slots[KeyguardQuickAffordanceSlots.SLOT_ID_BOTTOM_END]
+ ?.selectedQuickAffordances
+ ?.firstOrNull())
+ ?.payload
+ val icon1 =
+ (slots[KeyguardQuickAffordanceSlots.SLOT_ID_BOTTOM_START]
+ ?.selectedQuickAffordances
+ ?.firstOrNull())
+ ?.payload
+
+ KeyguardQuickAffordanceSummaryViewModel(
+ description = toDescriptionText(applicationContext, slots),
+ icon1 =
+ icon1
+ ?: if (icon2 == null) {
+ Icon.Resource(
+ res = R.drawable.link_off,
+ contentDescription = null,
+ )
+ } else {
+ null
+ },
+ icon2 = icon2,
+ )
+ }
+
+ private val _dialog = MutableStateFlow<DialogViewModel?>(null)
+ /**
+ * The current dialog to show. If `null`, no dialog should be shown.
+ *
+ * When the dialog is dismissed, [onDialogDismissed] must be called.
+ */
+ val dialog: Flow<DialogViewModel?> = _dialog.asStateFlow()
+
+ private val _activityStartRequests = MutableStateFlow<Intent?>(null)
+ /**
+ * Requests to start an activity with the given [Intent].
+ *
+ * Important: once the activity is started, the [Intent] should be consumed by calling
+ * [onActivityStarted].
+ */
+ val activityStartRequests: StateFlow<Intent?> = _activityStartRequests.asStateFlow()
+
+ /** Notifies that the dialog has been dismissed in the UI. */
+ fun onDialogDismissed() {
+ _dialog.value = null
+ }
+
+ /**
+ * Notifies that an activity request from [activityStartRequests] has been fulfilled (e.g. the
+ * activity was started and the view-model can forget needing to start this activity).
+ */
+ fun onActivityStarted() {
+ _activityStartRequests.value = null
+ }
+
+ private fun requestActivityStart(
+ intent: Intent,
+ ) {
+ _activityStartRequests.value = intent
+ }
+
+ private fun showEnablementDialog(
+ icon: Drawable,
+ name: String,
+ explanation: String,
+ actionText: String?,
+ actionIntent: Intent?,
+ ) {
+ _dialog.value =
+ DialogViewModel(
+ icon =
+ Icon.Loaded(
+ drawable = icon,
+ contentDescription = null,
+ ),
+ headline = Text.Resource(R.string.keyguard_affordance_enablement_dialog_headline),
+ message = Text.Loaded(explanation),
+ buttons =
+ buildList {
+ add(
+ ButtonViewModel(
+ text =
+ Text.Resource(
+ if (actionText != null) {
+ // This is not the only button on the dialog.
+ R.string.cancel
+ } else {
+ // This is the only button on the dialog.
+ R.string
+ .keyguard_affordance_enablement_dialog_dismiss_button
+ }
+ ),
+ style = ButtonStyle.Secondary,
+ ),
+ )
+
+ if (actionText != null) {
+ add(
+ ButtonViewModel(
+ text = Text.Loaded(actionText),
+ style = ButtonStyle.Primary,
+ onClicked = {
+ actionIntent?.let { intent -> requestActivityStart(intent) }
+ }
+ ),
+ )
+ }
+ },
+ )
+ }
+
+ /** Returns a view-model for the special "None" option. */
+ @SuppressLint("UseCompatLoadingForDrawables")
+ private suspend fun none(
+ slotId: StateFlow<String>,
+ isSelected: StateFlow<Boolean>,
+ onSelected: Flow<(() -> Unit)?>,
+ ): OptionItemViewModel<Icon> {
+ return OptionItemViewModel<Icon>(
+ key = slotId.map { "$it::none" }.stateIn(viewModelScope),
+ payload = Icon.Resource(res = R.drawable.link_off, contentDescription = null),
+ text = Text.Resource(res = R.string.keyguard_affordance_none),
+ isSelected = isSelected,
+ onClicked = onSelected,
+ onLongClicked = null,
+ isEnabled = true,
+ )
+ }
+
+ 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
+ else -> error("No name for slot with ID of \"$slotId\"!")
+ }
+ )
+ }
+
+ 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
+ else -> error("No accessibility label for slot with ID \"$slotId\"!")
+ }
+ )
+ }
+
+ private suspend fun getAffordanceIcon(@DrawableRes iconResourceId: Int): Drawable {
+ return quickAffordanceInteractor.getAffordanceIcon(iconResourceId)
+ }
+
+ private fun toDescriptionText(
+ context: Context,
+ slots: Map<String, KeyguardQuickAffordanceSlotViewModel>,
+ ): Text {
+ val bottomStartAffordanceName =
+ slots[KeyguardQuickAffordanceSlots.SLOT_ID_BOTTOM_START]
+ ?.selectedQuickAffordances
+ ?.firstOrNull()
+ ?.text
+ val bottomEndAffordanceName =
+ slots[KeyguardQuickAffordanceSlots.SLOT_ID_BOTTOM_END]
+ ?.selectedQuickAffordances
+ ?.firstOrNull()
+ ?.text
+
+ return when {
+ bottomStartAffordanceName != null && bottomEndAffordanceName != null -> {
+ Text.Loaded(
+ context.getString(
+ R.string.keyguard_quick_affordance_two_selected_template,
+ bottomStartAffordanceName.asString(context),
+ bottomEndAffordanceName.asString(context),
+ )
+ )
+ }
+ bottomStartAffordanceName != null -> bottomStartAffordanceName
+ bottomEndAffordanceName != null -> bottomEndAffordanceName
+ else -> Text.Resource(R.string.keyguard_quick_affordance_none_selected)
+ }
+ }
+
+ @ViewModelScoped
+ @AssistedFactory
+ interface Factory {
+ fun create(viewModelScope: CoroutineScope): KeyguardQuickAffordancePickerViewModel2
+ }
+}
diff --git a/src/com/android/wallpaper/customization/ui/viewmodel/ThemePickerCustomizationOptionsViewModel.kt b/src/com/android/wallpaper/customization/ui/viewmodel/ThemePickerCustomizationOptionsViewModel.kt
index cc909b5..6628b3d 100644
--- a/src/com/android/wallpaper/customization/ui/viewmodel/ThemePickerCustomizationOptionsViewModel.kt
+++ b/src/com/android/wallpaper/customization/ui/viewmodel/ThemePickerCustomizationOptionsViewModel.kt
@@ -18,19 +18,30 @@
import com.android.wallpaper.customization.ui.util.ThemePickerCustomizationOptionUtil
import com.android.wallpaper.picker.customization.ui.viewmodel.CustomizationOptionsViewModel
+import com.android.wallpaper.picker.customization.ui.viewmodel.CustomizationOptionsViewModelFactory
import com.android.wallpaper.picker.customization.ui.viewmodel.DefaultCustomizationOptionsViewModel
+import dagger.assisted.Assisted
+import dagger.assisted.AssistedFactory
+import dagger.assisted.AssistedInject
import dagger.hilt.android.scopes.ViewModelScoped
-import javax.inject.Inject
+import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
-@ViewModelScoped
class ThemePickerCustomizationOptionsViewModel
-@Inject
+@AssistedInject
constructor(
- private val defaultCustomizationOptionsViewModel: DefaultCustomizationOptionsViewModel
+ defaultCustomizationOptionsViewModelFactory: DefaultCustomizationOptionsViewModel.Factory,
+ keyguardQuickAffordancePickerViewModel2Factory: KeyguardQuickAffordancePickerViewModel2.Factory,
+ @Assisted private val viewModelScope: CoroutineScope,
) : CustomizationOptionsViewModel {
+ private val defaultCustomizationOptionsViewModel =
+ defaultCustomizationOptionsViewModelFactory.create(viewModelScope)
+
+ val keyguardQuickAffordancePickerViewModel2 =
+ keyguardQuickAffordancePickerViewModel2Factory.create(viewModelScope = viewModelScope)
+
override val selectedOption = defaultCustomizationOptionsViewModel.selectedOption
override fun deselectOption(): Boolean = defaultCustomizationOptionsViewModel.deselectOption()
@@ -61,4 +72,12 @@
null
}
}
+
+ @ViewModelScoped
+ @AssistedFactory
+ interface Factory : CustomizationOptionsViewModelFactory {
+ override fun create(
+ viewModelScope: CoroutineScope
+ ): ThemePickerCustomizationOptionsViewModel
+ }
}
diff --git a/src_override/com/android/wallpaper/modules/ThemePickerActivityRetainedModule.kt b/src_override/com/android/wallpaper/modules/ThemePickerActivityRetainedModule.kt
new file mode 100644
index 0000000..9462c6a
--- /dev/null
+++ b/src_override/com/android/wallpaper/modules/ThemePickerActivityRetainedModule.kt
@@ -0,0 +1,36 @@
+/*
+ * 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.modules
+
+import com.android.wallpaper.picker.preview.data.util.DefaultLiveWallpaperDownloader
+import com.android.wallpaper.picker.preview.data.util.LiveWallpaperDownloader
+import dagger.Binds
+import dagger.Module
+import dagger.hilt.InstallIn
+import dagger.hilt.android.components.ActivityRetainedComponent
+import dagger.hilt.android.scopes.ActivityRetainedScoped
+
+@Module
+@InstallIn(ActivityRetainedComponent::class)
+abstract class ThemePickerActivityRetainedModule {
+
+ @Binds
+ @ActivityRetainedScoped
+ abstract fun bindLiveWallpaperDownloader(
+ impl: DefaultLiveWallpaperDownloader
+ ): LiveWallpaperDownloader
+}
diff --git a/src_override/com/android/wallpaper/modules/ThemePickerAppModule.kt b/src_override/com/android/wallpaper/modules/ThemePickerAppModule.kt
index 8f144d8..3f41f0b 100644
--- a/src_override/com/android/wallpaper/modules/ThemePickerAppModule.kt
+++ b/src_override/com/android/wallpaper/modules/ThemePickerAppModule.kt
@@ -32,8 +32,6 @@
import com.android.wallpaper.module.logging.UserEventLogger
import com.android.wallpaper.picker.customization.ui.binder.CustomizationOptionsBinder
import com.android.wallpaper.picker.di.modules.BackgroundDispatcher
-import com.android.wallpaper.picker.preview.data.util.DefaultLiveWallpaperDownloader
-import com.android.wallpaper.picker.preview.data.util.LiveWallpaperDownloader
import com.android.wallpaper.picker.preview.ui.util.DefaultImageEffectDialogUtil
import com.android.wallpaper.picker.preview.ui.util.ImageEffectDialogUtil
import com.android.wallpaper.util.converter.DefaultWallpaperModelFactory
@@ -68,12 +66,6 @@
@Binds
@Singleton
- abstract fun bindLiveWallpaperDownloader(
- impl: DefaultLiveWallpaperDownloader
- ): LiveWallpaperDownloader
-
- @Binds
- @Singleton
abstract fun bindPartnerProvider(impl: DefaultPartnerProvider): PartnerProvider
@Binds
diff --git a/src_override/com/android/wallpaper/modules/ThemePickerViewModelModule.kt b/src_override/com/android/wallpaper/modules/ThemePickerViewModelModule.kt
index 3a80437..3a2da15 100644
--- a/src_override/com/android/wallpaper/modules/ThemePickerViewModelModule.kt
+++ b/src_override/com/android/wallpaper/modules/ThemePickerViewModelModule.kt
@@ -17,7 +17,7 @@
package com.android.wallpaper.modules
import com.android.wallpaper.customization.ui.viewmodel.ThemePickerCustomizationOptionsViewModel
-import com.android.wallpaper.picker.customization.ui.viewmodel.CustomizationOptionsViewModel
+import com.android.wallpaper.picker.customization.ui.viewmodel.CustomizationOptionsViewModelFactory
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
@@ -30,7 +30,7 @@
@Binds
@ViewModelScoped
- abstract fun bindCustomizationOptionsViewModel(
- impl: ThemePickerCustomizationOptionsViewModel
- ): CustomizationOptionsViewModel
+ abstract fun bindCustomizationOptionsViewModelFactory(
+ impl: ThemePickerCustomizationOptionsViewModel.Factory
+ ): CustomizationOptionsViewModelFactory
}
diff --git a/tests/module/src/com/android/wallpaper/ThemePickerTestModule.kt b/tests/module/src/com/android/wallpaper/ThemePickerTestModule.kt
index ca89c01..249f6af 100644
--- a/tests/module/src/com/android/wallpaper/ThemePickerTestModule.kt
+++ b/tests/module/src/com/android/wallpaper/ThemePickerTestModule.kt
@@ -40,11 +40,9 @@
import com.android.wallpaper.picker.customization.ui.binder.DefaultCustomizationOptionsBinder
import com.android.wallpaper.picker.di.modules.BackgroundDispatcher
import com.android.wallpaper.picker.di.modules.EffectsModule
-import com.android.wallpaper.picker.preview.data.util.LiveWallpaperDownloader
import com.android.wallpaper.picker.preview.ui.util.DefaultImageEffectDialogUtil
import com.android.wallpaper.picker.preview.ui.util.ImageEffectDialogUtil
import com.android.wallpaper.testing.FakeDefaultRequester
-import com.android.wallpaper.testing.FakeLiveWallpaperDownloader
import com.android.wallpaper.testing.TestPartnerProvider
import com.android.wallpaper.util.converter.DefaultWallpaperModelFactory
import com.android.wallpaper.util.converter.WallpaperModelFactory
@@ -99,12 +97,6 @@
@Binds
@Singleton
- abstract fun bindLiveWallpaperDownloader(
- impl: FakeLiveWallpaperDownloader
- ): LiveWallpaperDownloader
-
- @Binds
- @Singleton
abstract fun providePartnerProvider(impl: TestPartnerProvider): PartnerProvider
@Binds