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 package com.android.wallpaper.customization.ui.viewmodel 17 18 import android.content.Context 19 import android.content.res.Resources 20 import android.graphics.drawable.Drawable 21 import androidx.core.graphics.ColorUtils 22 import com.android.customization.model.color.ColorOptionImpl 23 import com.android.customization.module.logging.ThemesUserEventLogger 24 import com.android.customization.picker.clock.domain.interactor.ClockPickerInteractor 25 import com.android.customization.picker.clock.shared.ClockSize 26 import com.android.customization.picker.clock.shared.model.ClockMetadataModel 27 import com.android.customization.picker.clock.ui.viewmodel.ClockColorViewModel 28 import com.android.customization.picker.color.domain.interactor.ColorPickerInteractor 29 import com.android.customization.picker.color.shared.model.ColorOptionModel 30 import com.android.customization.picker.color.shared.model.ColorType 31 import com.android.customization.picker.color.ui.viewmodel.ColorOptionIconViewModel 32 import com.android.systemui.plugins.clocks.ClockFontAxisSetting 33 import com.android.themepicker.R 34 import com.android.wallpaper.picker.common.icon.ui.viewmodel.Icon 35 import com.android.wallpaper.picker.common.text.ui.viewmodel.Text 36 import com.android.wallpaper.picker.customization.ui.viewmodel.FloatingToolbarTabViewModel 37 import com.android.wallpaper.picker.di.modules.BackgroundDispatcher 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.CoroutineDispatcher 46 import kotlinx.coroutines.CoroutineScope 47 import kotlinx.coroutines.ExperimentalCoroutinesApi 48 import kotlinx.coroutines.delay 49 import kotlinx.coroutines.flow.Flow 50 import kotlinx.coroutines.flow.MutableStateFlow 51 import kotlinx.coroutines.flow.SharingStarted 52 import kotlinx.coroutines.flow.StateFlow 53 import kotlinx.coroutines.flow.asStateFlow 54 import kotlinx.coroutines.flow.combine 55 import kotlinx.coroutines.flow.distinctUntilChanged 56 import kotlinx.coroutines.flow.filterNotNull 57 import kotlinx.coroutines.flow.flowOn 58 import kotlinx.coroutines.flow.map 59 import kotlinx.coroutines.flow.mapLatest 60 import kotlinx.coroutines.flow.shareIn 61 import kotlinx.coroutines.flow.stateIn 62 63 /** View model for the clock customization screen. */ 64 class ClockPickerViewModel 65 @AssistedInject 66 constructor( 67 @ApplicationContext context: Context, 68 resources: Resources, 69 private val clockPickerInteractor: ClockPickerInteractor, 70 colorPickerInteractor: ColorPickerInteractor, 71 private val logger: ThemesUserEventLogger, 72 @BackgroundDispatcher private val backgroundDispatcher: CoroutineDispatcher, 73 @Assisted private val viewModelScope: CoroutineScope, 74 ) { 75 76 enum class Tab { 77 STYLE, 78 COLOR, 79 FONT, 80 } 81 82 private val colorMap = ClockColorViewModel.getPresetColorMap(context.resources) 83 84 // Tabs 85 private val _selectedTab = MutableStateFlow(Tab.STYLE) 86 val selectedTab: StateFlow<Tab> = _selectedTab.asStateFlow() 87 val tabs: Flow<List<FloatingToolbarTabViewModel>> = 88 _selectedTab.asStateFlow().map { 89 listOf( 90 FloatingToolbarTabViewModel( 91 Icon.Resource( 92 res = R.drawable.ic_clock_filled_24px, 93 contentDescription = Text.Resource(R.string.clock_style), 94 ), 95 context.getString(R.string.clock_style), 96 it == Tab.STYLE || it == Tab.FONT, 97 ) { 98 _selectedTab.value = Tab.STYLE 99 }, 100 FloatingToolbarTabViewModel( 101 Icon.Resource( 102 res = R.drawable.ic_palette_filled_24px, 103 contentDescription = Text.Resource(R.string.clock_color), 104 ), 105 context.getString(R.string.clock_color), 106 it == Tab.COLOR, 107 ) { 108 _selectedTab.value = Tab.COLOR 109 }, 110 ) 111 } 112 113 // Clock style 114 private val overridingClock = MutableStateFlow<ClockMetadataModel?>(null) 115 private val isClockEdited = 116 combine(overridingClock, clockPickerInteractor.selectedClock) { 117 overridingClock, 118 selectedClock -> 119 overridingClock != null && overridingClock.clockId != selectedClock.clockId 120 } 121 val selectedClock = clockPickerInteractor.selectedClock 122 val previewingClock = 123 combine(overridingClock, selectedClock) { overridingClock, selectedClock -> 124 (overridingClock ?: selectedClock) 125 } 126 .shareIn(viewModelScope, SharingStarted.WhileSubscribed(), 1) 127 128 data class ClockStyleModel(val thumbnail: Drawable, val showEditButton: StateFlow<Boolean>) 129 130 @OptIn(ExperimentalCoroutinesApi::class) 131 val clockStyleOptions: StateFlow<List<OptionItemViewModel2<ClockStyleModel>>> = 132 clockPickerInteractor.allClocks 133 .mapLatest { allClocks -> 134 // Delay to avoid the case that the full list of clocks is not initiated. 135 delay(CLOCKS_EVENT_UPDATE_DELAY_MILLIS) 136 val allClockMap = allClocks.groupBy { it.fontAxes.isNotEmpty() } 137 buildList { 138 allClockMap[true]?.map { add(it.toOption(resources)) } 139 allClockMap[false]?.map { add(it.toOption(resources)) } 140 } 141 } 142 // makes sure that the operations above this statement are executed on I/O dispatcher 143 // while parallelism limits the number of threads this can run on which makes sure that 144 // the flows run sequentially 145 .flowOn(backgroundDispatcher.limitedParallelism(1)) 146 .stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) 147 148 private suspend fun ClockMetadataModel.toOption( 149 resources: Resources 150 ): OptionItemViewModel2<ClockStyleModel> { 151 val isSelectedFlow = previewingClock.map { it.clockId == clockId }.stateIn(viewModelScope) 152 val isEditable = fontAxes.isNotEmpty() 153 val showEditButton = isSelectedFlow.map { it && isEditable }.stateIn(viewModelScope) 154 val contentDescription = 155 resources.getString(R.string.select_clock_action_description, description) 156 return OptionItemViewModel2<ClockStyleModel>( 157 key = MutableStateFlow(clockId) as StateFlow<String>, 158 payload = ClockStyleModel(thumbnail = thumbnail, showEditButton = showEditButton), 159 text = Text.Loaded(contentDescription), 160 isTextUserVisible = false, 161 isSelected = isSelectedFlow, 162 onClicked = 163 isSelectedFlow.map { isSelected -> 164 if (isSelected && isEditable) { 165 fun() { 166 _selectedTab.value = Tab.FONT 167 } 168 } else { 169 fun() { 170 overridingClock.value = this 171 overrideClockFontAxisMap.value = null 172 } 173 } 174 }, 175 ) 176 } 177 178 // Clock Font Axis Editor 179 private val overrideClockFontAxisMap = MutableStateFlow<Map<String, Float>?>(null) 180 private val isFontAxisMapEdited = overrideClockFontAxisMap.map { it != null } 181 val selectedClockFontAxes = 182 previewingClock 183 .map { clock -> clock.fontAxes } 184 .stateIn(viewModelScope, SharingStarted.Eagerly, null) 185 private val selectedClockFontAxisMap = 186 selectedClockFontAxes 187 .filterNotNull() 188 .map { fontAxes -> fontAxes.associate { it.key to it.currentValue } } 189 .stateIn(viewModelScope, SharingStarted.Eagerly, null) 190 val previewingClockFontAxisMap = 191 combine(overrideClockFontAxisMap, selectedClockFontAxisMap.filterNotNull()) { 192 overrideAxisMap, 193 selectedAxisMap -> 194 overrideAxisMap?.let { 195 val mutableMap = selectedAxisMap.toMutableMap() 196 overrideAxisMap.forEach { (key, value) -> mutableMap[key] = value } 197 mutableMap.toMap() 198 } ?: selectedAxisMap 199 } 200 .stateIn(viewModelScope, SharingStarted.Eagerly, emptyMap()) 201 202 fun updatePreviewFontAxis(key: String, value: Float) { 203 val axisMap = (overrideClockFontAxisMap.value?.toMutableMap() ?: mutableMapOf()) 204 axisMap[key] = value 205 overrideClockFontAxisMap.value = axisMap.toMap() 206 } 207 208 fun confirmFontAxes() { 209 _selectedTab.value = Tab.STYLE 210 } 211 212 fun cancelFontAxes() { 213 overrideClockFontAxisMap.value = null 214 _selectedTab.value = Tab.STYLE 215 } 216 217 // Clock size 218 private val overridingClockSize = MutableStateFlow<ClockSize?>(null) 219 private val isClockSizeEdited = 220 combine(overridingClockSize, clockPickerInteractor.selectedClockSize) { 221 overridingClockSize, 222 selectedClockSize -> 223 overridingClockSize != null && overridingClockSize != selectedClockSize 224 } 225 val previewingClockSize = 226 combine(overridingClockSize, clockPickerInteractor.selectedClockSize) { 227 overridingClockSize, 228 selectedClockSize -> 229 overridingClockSize ?: selectedClockSize 230 } 231 val onClockSizeSwitchCheckedChange: Flow<(() -> Unit)> = 232 previewingClockSize.map { 233 { 234 when (it) { 235 ClockSize.DYNAMIC -> overridingClockSize.value = ClockSize.SMALL 236 ClockSize.SMALL -> overridingClockSize.value = ClockSize.DYNAMIC 237 } 238 } 239 } 240 241 // Clock color 242 // 0 - 100 243 private val overridingClockColorId = MutableStateFlow<String?>(null) 244 private val isClockColorIdEdited = 245 combine(overridingClockColorId, clockPickerInteractor.selectedColorId) { 246 overridingClockColorId, 247 selectedColorId -> 248 overridingClockColorId != null && (overridingClockColorId != selectedColorId) 249 } 250 private val previewingClockColorId = 251 combine(overridingClockColorId, clockPickerInteractor.selectedColorId) { 252 overridingClockColorId, 253 selectedColorId -> 254 overridingClockColorId ?: selectedColorId ?: DEFAULT_CLOCK_COLOR_ID 255 } 256 257 private val overridingSliderProgress = MutableStateFlow<Int?>(null) 258 private val isSliderProgressEdited = 259 combine(overridingSliderProgress, clockPickerInteractor.colorToneProgress) { 260 overridingSliderProgress, 261 colorToneProgress -> 262 overridingSliderProgress != null && (overridingSliderProgress != colorToneProgress) 263 } 264 val previewingSliderProgress: Flow<Int> = 265 combine(overridingSliderProgress, clockPickerInteractor.colorToneProgress) { 266 overridingSliderProgress, 267 colorToneProgress -> 268 overridingSliderProgress ?: colorToneProgress 269 } 270 val isSliderEnabled: Flow<Boolean> = 271 combine(previewingClock, previewingClockColorId) { clock, clockColorId -> 272 clock.isReactiveToTone && clockColorId != DEFAULT_CLOCK_COLOR_ID 273 } 274 .distinctUntilChanged() 275 276 fun onSliderProgressChanged(progress: Int) { 277 overridingSliderProgress.value = progress 278 } 279 280 val previewingSeedColor: Flow<Int?> = 281 combine(previewingClockColorId, previewingSliderProgress) { clockColorId, sliderProgress -> 282 val clockColorViewModel = 283 if (clockColorId == DEFAULT_CLOCK_COLOR_ID) null else colorMap[clockColorId] 284 if (clockColorViewModel == null) { 285 null 286 } else { 287 blendColorWithTone( 288 color = clockColorViewModel.color, 289 colorTone = clockColorViewModel.getColorTone(sliderProgress), 290 ) 291 } 292 } 293 294 val clockColorOptions: Flow<List<OptionItemViewModel<ColorOptionIconViewModel>>> = 295 colorPickerInteractor.colorOptions.map { colorOptions -> 296 // Use mapLatest and delay(100) here to prevent too many selectedClockColor update 297 // events from ClockRegistry upstream, caused by sliding the saturation level bar. 298 delay(COLOR_OPTIONS_EVENT_UPDATE_DELAY_MILLIS) 299 buildList { 300 val defaultThemeColorOptionViewModel = 301 (colorOptions[ColorType.WALLPAPER_COLOR]?.find { it.isSelected }) 302 ?.toOptionItemViewModel(context) 303 ?: (colorOptions[ColorType.PRESET_COLOR]?.find { it.isSelected }) 304 ?.toOptionItemViewModel(context) 305 if (defaultThemeColorOptionViewModel != null) { 306 add(defaultThemeColorOptionViewModel) 307 } 308 309 colorMap.values.forEachIndexed { index, colorModel -> 310 val isSelectedFlow = 311 previewingClockColorId 312 .map { colorMap.keys.indexOf(it) == index } 313 .stateIn(viewModelScope) 314 add( 315 OptionItemViewModel<ColorOptionIconViewModel>( 316 key = MutableStateFlow(colorModel.colorId) as StateFlow<String>, 317 payload = 318 ColorOptionIconViewModel( 319 lightThemeColor0 = colorModel.color, 320 lightThemeColor1 = colorModel.color, 321 lightThemeColor2 = colorModel.color, 322 lightThemeColor3 = colorModel.color, 323 darkThemeColor0 = colorModel.color, 324 darkThemeColor1 = colorModel.color, 325 darkThemeColor2 = colorModel.color, 326 darkThemeColor3 = colorModel.color, 327 ), 328 text = 329 Text.Loaded( 330 context.getString( 331 R.string.content_description_color_option, 332 index, 333 ) 334 ), 335 isTextUserVisible = false, 336 isSelected = isSelectedFlow, 337 onClicked = 338 isSelectedFlow.map { isSelected -> 339 if (isSelected) { 340 null 341 } else { 342 { 343 overridingClockColorId.value = colorModel.colorId 344 overridingSliderProgress.value = 345 ClockMetadataModel.DEFAULT_COLOR_TONE_PROGRESS 346 } 347 } 348 }, 349 ) 350 ) 351 } 352 } 353 } 354 355 private suspend fun ColorOptionModel.toOptionItemViewModel( 356 context: Context 357 ): OptionItemViewModel<ColorOptionIconViewModel> { 358 val lightThemeColors = 359 (colorOption as ColorOptionImpl) 360 .previewInfo 361 .resolveColors( 362 /** darkTheme= */ 363 false 364 ) 365 val darkThemeColors = 366 colorOption.previewInfo.resolveColors( 367 /** darkTheme= */ 368 true 369 ) 370 val isSelectedFlow = 371 previewingClockColorId.map { it == DEFAULT_CLOCK_COLOR_ID }.stateIn(viewModelScope) 372 return OptionItemViewModel<ColorOptionIconViewModel>( 373 key = MutableStateFlow(key) as StateFlow<String>, 374 payload = 375 ColorOptionIconViewModel( 376 lightThemeColor0 = lightThemeColors[0], 377 lightThemeColor1 = lightThemeColors[1], 378 lightThemeColor2 = lightThemeColors[2], 379 lightThemeColor3 = lightThemeColors[3], 380 darkThemeColor0 = darkThemeColors[0], 381 darkThemeColor1 = darkThemeColors[1], 382 darkThemeColor2 = darkThemeColors[2], 383 darkThemeColor3 = darkThemeColors[3], 384 ), 385 text = Text.Loaded(context.getString(R.string.default_theme_title)), 386 isTextUserVisible = true, 387 isSelected = isSelectedFlow, 388 onClicked = 389 isSelectedFlow.map { isSelected -> 390 if (isSelected) { 391 null 392 } else { 393 { 394 overridingClockColorId.value = DEFAULT_CLOCK_COLOR_ID 395 overridingSliderProgress.value = 396 ClockMetadataModel.DEFAULT_COLOR_TONE_PROGRESS 397 } 398 } 399 }, 400 ) 401 } 402 403 private val isEdited = 404 combine( 405 isClockEdited, 406 isClockSizeEdited, 407 isClockColorIdEdited, 408 isSliderProgressEdited, 409 isFontAxisMapEdited, 410 ) { 411 isClockEdited, 412 isClockSizeEdited, 413 isClockColorEdited, 414 isSliderProgressEdited, 415 isFontAxisMapEdited -> 416 isClockEdited || 417 isClockSizeEdited || 418 isClockColorEdited || 419 isSliderProgressEdited || 420 isFontAxisMapEdited 421 } 422 423 val onApply: Flow<(suspend () -> Unit)?> = 424 combine( 425 isEdited, 426 previewingClock, 427 previewingClockSize, 428 previewingClockColorId, 429 previewingSliderProgress, 430 previewingClockFontAxisMap, 431 ) { array -> 432 val isEdited = array[0] as Boolean 433 val clock = array[1] as ClockMetadataModel 434 val size = array[2] as ClockSize 435 val previewingColorId = array[3] as String 436 val previewProgress = array[4] as Int 437 val axisMap = array[5] as Map<String, Float> 438 if (isEdited) { 439 { 440 clockPickerInteractor.applyClock( 441 clockId = clock.clockId, 442 size = size, 443 selectedColorId = previewingColorId, 444 colorToneProgress = previewProgress, 445 seedColor = 446 colorMap[previewingColorId]?.let { 447 blendColorWithTone( 448 color = it.color, 449 colorTone = it.getColorTone(previewProgress), 450 ) 451 }, 452 axisSettings = axisMap.map { ClockFontAxisSetting(it.key, it.value) }, 453 ) 454 } 455 } else { 456 null 457 } 458 } 459 460 fun resetPreview() { 461 overridingClock.value = null 462 overridingClockSize.value = null 463 overridingClockColorId.value = null 464 overridingSliderProgress.value = null 465 overrideClockFontAxisMap.value = null 466 _selectedTab.value = Tab.STYLE 467 } 468 469 companion object { 470 private const val DEFAULT_CLOCK_COLOR_ID = "DEFAULT" 471 private val helperColorLab: DoubleArray by lazy { DoubleArray(3) } 472 473 fun blendColorWithTone(color: Int, colorTone: Double): Int { 474 ColorUtils.colorToLAB(color, helperColorLab) 475 return ColorUtils.LABToColor(colorTone, helperColorLab[1], helperColorLab[2]) 476 } 477 478 const val COLOR_OPTIONS_EVENT_UPDATE_DELAY_MILLIS: Long = 100 479 const val CLOCKS_EVENT_UPDATE_DELAY_MILLIS: Long = 100 480 } 481 482 @ViewModelScoped 483 @AssistedFactory 484 interface Factory { 485 fun create(viewModelScope: CoroutineScope): ClockPickerViewModel 486 } 487 } 488