1 /*
<lambda>null2  * Copyright (C) 2024 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package com.android.wallpaper.customization.ui.viewmodel
18 
19 import android.annotation.SuppressLint
20 import android.content.Context
21 import android.content.Intent
22 import android.graphics.drawable.Drawable
23 import androidx.annotation.DrawableRes
24 import com.android.customization.module.logging.ThemesUserEventLogger
25 import com.android.customization.picker.quickaffordance.domain.interactor.KeyguardQuickAffordancePickerInteractor
26 import com.android.customization.picker.quickaffordance.ui.viewmodel.KeyguardQuickAffordanceSlotViewModel
27 import com.android.customization.picker.quickaffordance.ui.viewmodel.KeyguardQuickAffordanceSummaryViewModel
28 import com.android.systemui.shared.keyguard.shared.model.KeyguardQuickAffordanceSlots.SLOT_ID_BOTTOM_END
29 import com.android.systemui.shared.keyguard.shared.model.KeyguardQuickAffordanceSlots.SLOT_ID_BOTTOM_START
30 import com.android.systemui.shared.quickaffordance.shared.model.KeyguardPreviewConstants.KEYGUARD_QUICK_AFFORDANCE_ID_NONE
31 import com.android.themepicker.R
32 import com.android.wallpaper.picker.common.button.ui.viewmodel.ButtonStyle
33 import com.android.wallpaper.picker.common.button.ui.viewmodel.ButtonViewModel
34 import com.android.wallpaper.picker.common.dialog.ui.viewmodel.DialogViewModel
35 import com.android.wallpaper.picker.common.icon.ui.viewmodel.Icon
36 import com.android.wallpaper.picker.common.text.ui.viewmodel.Text
37 import com.android.wallpaper.picker.customization.ui.viewmodel.FloatingToolbarTabViewModel
38 import com.android.wallpaper.picker.option.ui.viewmodel.OptionItemViewModel
39 import com.android.wallpaper.picker.option.ui.viewmodel.OptionItemViewModel2
40 import dagger.assisted.Assisted
41 import dagger.assisted.AssistedFactory
42 import dagger.assisted.AssistedInject
43 import dagger.hilt.android.qualifiers.ApplicationContext
44 import dagger.hilt.android.scopes.ViewModelScoped
45 import kotlinx.coroutines.CoroutineScope
46 import kotlinx.coroutines.flow.Flow
47 import kotlinx.coroutines.flow.MutableStateFlow
48 import kotlinx.coroutines.flow.SharingStarted
49 import kotlinx.coroutines.flow.StateFlow
50 import kotlinx.coroutines.flow.asStateFlow
51 import kotlinx.coroutines.flow.combine
52 import kotlinx.coroutines.flow.flowOf
53 import kotlinx.coroutines.flow.map
54 import kotlinx.coroutines.flow.shareIn
55 import kotlinx.coroutines.flow.stateIn
56 
57 class KeyguardQuickAffordancePickerViewModel2
58 @AssistedInject
59 constructor(
60     @ApplicationContext private val applicationContext: Context,
61     private val quickAffordanceInteractor: KeyguardQuickAffordancePickerInteractor,
62     private val logger: ThemesUserEventLogger,
63     @Assisted private val viewModelScope: CoroutineScope,
64 ) {
65     /** A locally-selected slot, if the user ever switched from the original one. */
66     private val _selectedSlotId = MutableStateFlow<String?>(null)
67     /** The ID of the selected slot. */
68     val selectedSlotId: StateFlow<String> =
69         combine(quickAffordanceInteractor.slots, _selectedSlotId) { slots, selectedSlotIdOrNull ->
70                 if (selectedSlotIdOrNull != null) {
71                     slots.first { slot -> slot.id == selectedSlotIdOrNull }
72                 } else {
73                     // If we haven't yet selected a new slot locally, default to the first slot.
74                     slots[0]
75                 }
76             }
77             .map { selectedSlot -> selectedSlot.id }
78             .stateIn(
79                 scope = viewModelScope,
80                 started = SharingStarted.WhileSubscribed(),
81                 initialValue = "",
82             )
83     private val _previewingQuickAffordances = MutableStateFlow<Map<String, String>>(emptyMap())
84     val previewingQuickAffordances: Flow<Map<String, String>> =
85         _previewingQuickAffordances.asStateFlow()
86 
87     fun resetPreview() {
88         _previewingQuickAffordances.tryEmit(emptyMap())
89         _selectedSlotId.tryEmit(SLOT_ID_BOTTOM_START)
90     }
91 
92     /** View-models for each slot, keyed by slot ID. */
93     private val slots: StateFlow<Map<String, KeyguardQuickAffordanceSlotViewModel>> =
94         combine(
95                 quickAffordanceInteractor.slots,
96                 quickAffordanceInteractor.affordances,
97                 quickAffordanceInteractor.selections,
98                 previewingQuickAffordances,
99                 selectedSlotId,
100             ) { slots, affordances, selections, selectedQuickAffordances, selectedSlotId ->
101                 slots.associate { slot ->
102                     val selectedAffordanceIds =
103                         selectedQuickAffordances[slot.id]?.let { setOf(it) }
104                             ?: selections
105                                 .filter { selection -> selection.slotId == slot.id }
106                                 .map { selection -> selection.affordanceId }
107                                 .toSet()
108                     val selectedAffordances =
109                         affordances.filter { affordance ->
110                             selectedAffordanceIds.contains(affordance.id)
111                         }
112 
113                     val isSelected = selectedSlotId == slot.id
114                     slot.id to
115                         KeyguardQuickAffordanceSlotViewModel(
116                             name = getSlotName(slot.id),
117                             isSelected = isSelected,
118                             selectedQuickAffordances =
119                                 selectedAffordances.map { affordanceModel ->
120                                     OptionItemViewModel<Icon>(
121                                         key =
122                                             MutableStateFlow("${slot.id}::${affordanceModel.id}")
123                                                 as StateFlow<String>,
124                                         payload =
125                                             Icon.Loaded(
126                                                 drawable =
127                                                     getAffordanceIcon(
128                                                         affordanceModel.iconResourceId
129                                                     ),
130                                                 contentDescription =
131                                                     Text.Loaded(getSlotContentDescription(slot.id)),
132                                             ),
133                                         text = Text.Loaded(affordanceModel.name),
134                                         isSelected = MutableStateFlow(true) as StateFlow<Boolean>,
135                                         onClicked = flowOf(null),
136                                         onLongClicked = null,
137                                         isEnabled = true,
138                                     )
139                                 },
140                             maxSelectedQuickAffordances = slot.maxSelectedQuickAffordances,
141                             onClicked =
142                                 if (isSelected) {
143                                     null
144                                 } else {
145                                     { _selectedSlotId.tryEmit(slot.id) }
146                                 },
147                         )
148                 }
149             }
150             .stateIn(
151                 scope = viewModelScope,
152                 started = SharingStarted.WhileSubscribed(),
153                 initialValue = emptyMap(),
154             )
155 
156     val tabs: Flow<List<FloatingToolbarTabViewModel>> =
157         slots.map { slotById ->
158             slotById.values.map {
159                 FloatingToolbarTabViewModel(it.getIcon(), it.name, it.isSelected, it.onClicked)
160             }
161         }
162 
163     /**
164      * The set of IDs of the currently-selected affordances. These change with user selection of new
165      * or different affordances in the currently-selected slot or when slot selection changes.
166      */
167     private val selectedAffordanceIds: Flow<Set<String>> =
168         combine(quickAffordanceInteractor.selections, selectedSlotId) { selections, selectedSlotId
169                 ->
170                 selections
171                     .filter { selection -> selection.slotId == selectedSlotId }
172                     .map { selection -> selection.affordanceId }
173                     .toSet()
174             }
175             .shareIn(scope = viewModelScope, started = SharingStarted.WhileSubscribed(), replay = 1)
176 
177     /** The list of all available quick affordances for the selected slot. */
178     val quickAffordances: Flow<List<OptionItemViewModel2<Icon>>> =
179         quickAffordanceInteractor.affordances.map { affordances ->
180             val isNoneSelected =
181                 combine(selectedSlotId, previewingQuickAffordances, selectedAffordanceIds) {
182                         selectedSlotId,
183                         selectedQuickAffordances,
184                         selectedAffordanceIds ->
185                         selectedQuickAffordances[selectedSlotId]?.let {
186                             it == KEYGUARD_QUICK_AFFORDANCE_ID_NONE
187                         } ?: selectedAffordanceIds.isEmpty()
188                     }
189                     .stateIn(viewModelScope)
190             listOf(
191                 none(
192                     slotId = selectedSlotId,
193                     isSelected = isNoneSelected,
194                     onSelected =
195                         combine(isNoneSelected, selectedSlotId) { isSelected, selectedSlotId ->
196                             if (!isSelected) {
197                                 {
198                                     val newMap =
199                                         _previewingQuickAffordances.value.toMutableMap().apply {
200                                             put(selectedSlotId, KEYGUARD_QUICK_AFFORDANCE_ID_NONE)
201                                         }
202                                     _previewingQuickAffordances.tryEmit(newMap)
203                                 }
204                             } else {
205                                 null
206                             }
207                         },
208                 )
209             ) +
210                 affordances.map { affordance ->
211                     val affordanceIcon = getAffordanceIcon(affordance.iconResourceId)
212                     val isSelectedFlow: StateFlow<Boolean> =
213                         combine(
214                                 selectedSlotId,
215                                 previewingQuickAffordances,
216                                 selectedAffordanceIds,
217                             ) { selectedSlotId, selectedQuickAffordances, selectedAffordanceIds ->
218                                 selectedQuickAffordances[selectedSlotId]?.let {
219                                     it == affordance.id
220                                 } ?: selectedAffordanceIds.contains(affordance.id)
221                             }
222                             .stateIn(viewModelScope)
223                     OptionItemViewModel2<Icon>(
224                         key =
225                             selectedSlotId
226                                 .map { slotId -> "$slotId::${affordance.id}" }
227                                 .stateIn(viewModelScope),
228                         payload = Icon.Loaded(drawable = affordanceIcon, contentDescription = null),
229                         text = Text.Loaded(affordance.name),
230                         isSelected = isSelectedFlow,
231                         onClicked =
232                             if (affordance.isEnabled) {
233                                 combine(isSelectedFlow, selectedSlotId) { isSelected, selectedSlotId
234                                     ->
235                                     if (!isSelected) {
236                                         {
237                                             val newMap =
238                                                 _previewingQuickAffordances.value
239                                                     .toMutableMap()
240                                                     .apply { put(selectedSlotId, affordance.id) }
241                                             _previewingQuickAffordances.tryEmit(newMap)
242                                         }
243                                     } else {
244                                         null
245                                     }
246                                 }
247                             } else {
248                                 flowOf {
249                                     showEnablementDialog(
250                                         icon = affordanceIcon,
251                                         name = affordance.name,
252                                         explanation = affordance.enablementExplanation,
253                                         actionText = affordance.enablementActionText,
254                                         actionIntent = affordance.enablementActionIntent,
255                                     )
256                                 }
257                             },
258                         onLongClicked =
259                             if (affordance.configureIntent != null) {
260                                 { requestActivityStart(affordance.configureIntent) }
261                             } else {
262                                 null
263                             },
264                         isEnabled = affordance.isEnabled,
265                     )
266                 }
267         }
268 
269     val onApply: Flow<(suspend () -> Unit)?> =
270         previewingQuickAffordances.map {
271             if (it.isEmpty()) {
272                 null
273             } else {
274                 {
275                     it.forEach { entry ->
276                         val slotId = entry.key
277                         val affordanceId = entry.value
278                         if (slotId == KEYGUARD_QUICK_AFFORDANCE_ID_NONE) {
279                             quickAffordanceInteractor.unselectAllFromSlot(slotId)
280                         } else {
281                             quickAffordanceInteractor.select(
282                                 slotId = slotId,
283                                 affordanceId = affordanceId,
284                             )
285                         }
286                         logger.logShortcutApplied(shortcut = affordanceId, shortcutSlotId = slotId)
287                     }
288                 }
289             }
290         }
291 
292     private val _dialog = MutableStateFlow<DialogViewModel?>(null)
293     /**
294      * The current dialog to show. If `null`, no dialog should be shown.
295      *
296      * When the dialog is dismissed, [onDialogDismissed] must be called.
297      */
298     val dialog: Flow<DialogViewModel?> = _dialog.asStateFlow()
299 
300     private val _activityStartRequests = MutableStateFlow<Intent?>(null)
301     /**
302      * Requests to start an activity with the given [Intent].
303      *
304      * Important: once the activity is started, the [Intent] should be consumed by calling
305      * [onActivityStarted].
306      */
307     val activityStartRequests: StateFlow<Intent?> = _activityStartRequests.asStateFlow()
308 
309     /** Notifies that the dialog has been dismissed in the UI. */
310     fun onDialogDismissed() {
311         _dialog.value = null
312     }
313 
314     /**
315      * Notifies that an activity request from [activityStartRequests] has been fulfilled (e.g. the
316      * activity was started and the view-model can forget needing to start this activity).
317      */
318     fun onActivityStarted() {
319         _activityStartRequests.value = null
320     }
321 
322     private fun requestActivityStart(intent: Intent) {
323         _activityStartRequests.value = intent
324     }
325 
326     private fun showEnablementDialog(
327         icon: Drawable,
328         name: String,
329         explanation: String,
330         actionText: String?,
331         actionIntent: Intent?,
332     ) {
333         _dialog.value =
334             DialogViewModel(
335                 icon = Icon.Loaded(drawable = icon, contentDescription = null),
336                 headline = Text.Resource(R.string.keyguard_affordance_enablement_dialog_headline),
337                 message = Text.Loaded(explanation),
338                 buttons =
339                     buildList {
340                         add(
341                             ButtonViewModel(
342                                 text =
343                                     Text.Resource(
344                                         if (actionText != null) {
345                                             // This is not the only button on the dialog.
346                                             R.string.cancel
347                                         } else {
348                                             // This is the only button on the dialog.
349                                             R.string
350                                                 .keyguard_affordance_enablement_dialog_dismiss_button
351                                         }
352                                     ),
353                                 style = ButtonStyle.Secondary,
354                             )
355                         )
356 
357                         if (actionText != null) {
358                             add(
359                                 ButtonViewModel(
360                                     text = Text.Loaded(actionText),
361                                     style = ButtonStyle.Primary,
362                                     onClicked = {
363                                         actionIntent?.let { intent -> requestActivityStart(intent) }
364                                     },
365                                 )
366                             )
367                         }
368                     },
369             )
370     }
371 
372     /** Returns a view-model for the special "None" option. */
373     @SuppressLint("UseCompatLoadingForDrawables")
374     private suspend fun none(
375         slotId: StateFlow<String>,
376         isSelected: StateFlow<Boolean>,
377         onSelected: Flow<(() -> Unit)?>,
378     ): OptionItemViewModel2<Icon> {
379         return OptionItemViewModel2<Icon>(
380             key = slotId.map { "$it::none" }.stateIn(viewModelScope),
381             payload = Icon.Resource(res = R.drawable.link_off, contentDescription = null),
382             text = Text.Resource(res = R.string.keyguard_affordance_none),
383             isSelected = isSelected,
384             onClicked = onSelected,
385             onLongClicked = null,
386             isEnabled = true,
387         )
388     }
389 
390     private fun getSlotName(slotId: String): String {
391         return applicationContext.getString(
392             when (slotId) {
393                 SLOT_ID_BOTTOM_START -> R.string.keyguard_slot_name_bottom_start
394                 SLOT_ID_BOTTOM_END -> R.string.keyguard_slot_name_bottom_end
395                 else -> error("No name for slot with ID of \"$slotId\"!")
396             }
397         )
398     }
399 
400     private fun getSlotContentDescription(slotId: String): String {
401         return applicationContext.getString(
402             when (slotId) {
403                 SLOT_ID_BOTTOM_START -> R.string.keyguard_slot_name_bottom_start
404                 SLOT_ID_BOTTOM_END -> R.string.keyguard_slot_name_bottom_end
405                 else -> error("No accessibility label for slot with ID \"$slotId\"!")
406             }
407         )
408     }
409 
410     private suspend fun getAffordanceIcon(@DrawableRes iconResourceId: Int): Drawable {
411         return quickAffordanceInteractor.getAffordanceIcon(iconResourceId)
412     }
413 
414     val summary: Flow<KeyguardQuickAffordanceSummaryViewModel> =
415         slots.map { slots ->
416             val icon2 =
417                 (slots[SLOT_ID_BOTTOM_END]?.selectedQuickAffordances?.firstOrNull())?.payload
418             val icon1 =
419                 (slots[SLOT_ID_BOTTOM_START]?.selectedQuickAffordances?.firstOrNull())?.payload
420 
421             KeyguardQuickAffordanceSummaryViewModel(
422                 description = toDescriptionText(applicationContext, slots),
423                 icon1 =
424                     icon1
425                         ?: if (icon2 == null) {
426                             Icon.Resource(res = R.drawable.link_off, contentDescription = null)
427                         } else {
428                             null
429                         },
430                 icon2 = icon2,
431             )
432         }
433 
434     private fun toDescriptionText(
435         context: Context,
436         slots: Map<String, KeyguardQuickAffordanceSlotViewModel>,
437     ): Text {
438         val bottomStartAffordanceName =
439             slots[SLOT_ID_BOTTOM_START]?.selectedQuickAffordances?.firstOrNull()?.text
440         val bottomEndAffordanceName =
441             slots[SLOT_ID_BOTTOM_END]?.selectedQuickAffordances?.firstOrNull()?.text
442 
443         return when {
444             bottomStartAffordanceName != null && bottomEndAffordanceName != null -> {
445                 Text.Loaded(
446                     context.getString(
447                         R.string.keyguard_quick_affordance_two_selected_template,
448                         bottomStartAffordanceName.asString(context),
449                         bottomEndAffordanceName.asString(context),
450                     )
451                 )
452             }
453             bottomStartAffordanceName != null -> bottomStartAffordanceName
454             bottomEndAffordanceName != null -> bottomEndAffordanceName
455             else -> Text.Resource(R.string.keyguard_quick_affordance_none_selected)
456         }
457     }
458 
459     companion object {
460         private fun KeyguardQuickAffordanceSlotViewModel.getIcon(): Icon =
461             selectedQuickAffordances.firstOrNull()?.payload
462                 ?: Icon.Resource(res = R.drawable.link_off, contentDescription = null)
463     }
464 
465     @ViewModelScoped
466     @AssistedFactory
467     interface Factory {
468         fun create(viewModelScope: CoroutineScope): KeyguardQuickAffordancePickerViewModel2
469     }
470 }
471