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