1 /* <lambda>null2 * Copyright (C) 2023 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.customization.picker.clock.ui.viewmodel 17 18 import android.content.Context 19 import androidx.core.graphics.ColorUtils 20 import androidx.lifecycle.ViewModel 21 import androidx.lifecycle.ViewModelProvider 22 import androidx.lifecycle.viewModelScope 23 import com.android.customization.model.color.ColorOptionImpl 24 import com.android.customization.module.logging.ThemesUserEventLogger 25 import com.android.customization.module.logging.ThemesUserEventLogger.Companion.NULL_SEED_COLOR 26 import com.android.customization.picker.clock.domain.interactor.ClockPickerInteractor 27 import com.android.customization.picker.clock.shared.ClockSize 28 import com.android.customization.picker.clock.shared.model.ClockMetadataModel 29 import com.android.customization.picker.clock.shared.toClockSizeForLogging 30 import com.android.customization.picker.color.domain.interactor.ColorPickerInteractor 31 import com.android.customization.picker.color.shared.model.ColorOptionModel 32 import com.android.customization.picker.color.shared.model.ColorType 33 import com.android.customization.picker.color.ui.viewmodel.ColorOptionIconViewModel 34 import com.android.themepicker.R 35 import com.android.wallpaper.picker.common.text.ui.viewmodel.Text 36 import com.android.wallpaper.picker.option.ui.viewmodel.OptionItemViewModel 37 import kotlinx.coroutines.ExperimentalCoroutinesApi 38 import kotlinx.coroutines.delay 39 import kotlinx.coroutines.flow.Flow 40 import kotlinx.coroutines.flow.MutableStateFlow 41 import kotlinx.coroutines.flow.SharingStarted 42 import kotlinx.coroutines.flow.StateFlow 43 import kotlinx.coroutines.flow.asStateFlow 44 import kotlinx.coroutines.flow.combine 45 import kotlinx.coroutines.flow.distinctUntilChanged 46 import kotlinx.coroutines.flow.flatMapLatest 47 import kotlinx.coroutines.flow.map 48 import kotlinx.coroutines.flow.merge 49 import kotlinx.coroutines.flow.stateIn 50 import kotlinx.coroutines.launch 51 52 /** View model for the clock settings screen. */ 53 class ClockSettingsViewModel 54 private constructor( 55 context: Context, 56 private val clockPickerInteractor: ClockPickerInteractor, 57 private val colorPickerInteractor: ColorPickerInteractor, 58 private val getIsReactiveToTone: (clockId: String?) -> Boolean, 59 private val logger: ThemesUserEventLogger, 60 ) : ViewModel() { 61 62 enum class Tab { 63 COLOR, 64 SIZE, 65 } 66 67 private val colorMap = ClockColorViewModel.getPresetColorMap(context.resources) 68 69 val selectedClockId: StateFlow<String?> = 70 clockPickerInteractor.selectedClockId 71 .distinctUntilChanged() 72 .stateIn(viewModelScope, SharingStarted.Eagerly, null) 73 74 private val selectedColorId: StateFlow<String?> = 75 clockPickerInteractor.selectedColorId.stateIn(viewModelScope, SharingStarted.Eagerly, null) 76 77 private val sliderColorToneProgress = 78 MutableStateFlow(ClockMetadataModel.DEFAULT_COLOR_TONE_PROGRESS) 79 val isSliderEnabled: Flow<Boolean> = 80 combine(selectedClockId, clockPickerInteractor.selectedColorId) { clockId, colorId -> 81 if (colorId == null) { 82 false 83 } else { 84 getIsReactiveToTone(clockId) 85 } 86 } 87 .distinctUntilChanged() 88 val sliderProgress: Flow<Int> = 89 merge(clockPickerInteractor.colorToneProgress, sliderColorToneProgress) 90 91 private val _seedColor: MutableStateFlow<Int?> = MutableStateFlow(null) 92 val seedColor: Flow<Int?> = merge(clockPickerInteractor.seedColor, _seedColor) 93 94 /** 95 * The slider color tone updates are quick. Do not set color tone and the blended color to the 96 * settings until [onSliderProgressStop] is called. Update to a locally cached temporary 97 * [sliderColorToneProgress] and [_seedColor] instead. 98 */ 99 fun onSliderProgressChanged(progress: Int) { 100 sliderColorToneProgress.value = progress 101 val selectedColorId = selectedColorId.value ?: return 102 val clockColorViewModel = colorMap[selectedColorId] ?: return 103 _seedColor.value = 104 blendColorWithTone( 105 color = clockColorViewModel.color, 106 colorTone = clockColorViewModel.getColorTone(progress), 107 ) 108 } 109 110 suspend fun onSliderProgressStop(progress: Int) { 111 val selectedColorId = selectedColorId.value ?: return 112 val clockColorViewModel = colorMap[selectedColorId] ?: return 113 val seedColor = 114 blendColorWithTone( 115 color = clockColorViewModel.color, 116 colorTone = clockColorViewModel.getColorTone(progress), 117 ) 118 clockPickerInteractor.setClockColor( 119 selectedColorId = selectedColorId, 120 colorToneProgress = progress, 121 seedColor = seedColor, 122 ) 123 logger.logClockColorApplied(seedColor) 124 } 125 126 @OptIn(ExperimentalCoroutinesApi::class) 127 val colorOptions: Flow<List<OptionItemViewModel<ColorOptionIconViewModel>>> = 128 colorPickerInteractor.colorOptions.map { colorOptions -> 129 // Use mapLatest and delay(100) here to prevent too many selectedClockColor update 130 // events from ClockRegistry upstream, caused by sliding the saturation level bar. 131 delay(COLOR_OPTIONS_EVENT_UPDATE_DELAY_MILLIS) 132 buildList { 133 val defaultThemeColorOptionViewModel = 134 (colorOptions[ColorType.WALLPAPER_COLOR]?.find { it.isSelected }) 135 ?.toOptionItemViewModel(context) 136 ?: (colorOptions[ColorType.PRESET_COLOR]?.find { it.isSelected }) 137 ?.toOptionItemViewModel(context) 138 if (defaultThemeColorOptionViewModel != null) { 139 add(defaultThemeColorOptionViewModel) 140 } 141 142 colorMap.values.forEachIndexed { index, colorModel -> 143 val isSelectedFlow = 144 selectedColorId 145 .map { colorMap.keys.indexOf(it) == index } 146 .stateIn(viewModelScope) 147 val colorToneProgress = ClockMetadataModel.DEFAULT_COLOR_TONE_PROGRESS 148 add( 149 OptionItemViewModel<ColorOptionIconViewModel>( 150 key = MutableStateFlow(colorModel.colorId) as StateFlow<String>, 151 payload = 152 ColorOptionIconViewModel( 153 lightThemeColor0 = colorModel.color, 154 lightThemeColor1 = colorModel.color, 155 lightThemeColor2 = colorModel.color, 156 lightThemeColor3 = colorModel.color, 157 darkThemeColor0 = colorModel.color, 158 darkThemeColor1 = colorModel.color, 159 darkThemeColor2 = colorModel.color, 160 darkThemeColor3 = colorModel.color, 161 ), 162 text = 163 Text.Loaded( 164 context.getString( 165 R.string.content_description_color_option, 166 index, 167 ) 168 ), 169 isTextUserVisible = false, 170 isSelected = isSelectedFlow, 171 onClicked = 172 isSelectedFlow.map { isSelected -> 173 if (isSelected) { 174 null 175 } else { 176 { 177 viewModelScope.launch { 178 val seedColor = 179 blendColorWithTone( 180 color = colorModel.color, 181 colorTone = 182 colorModel.getColorTone( 183 colorToneProgress, 184 ), 185 ) 186 clockPickerInteractor.setClockColor( 187 selectedColorId = colorModel.colorId, 188 colorToneProgress = colorToneProgress, 189 seedColor = seedColor, 190 ) 191 logger.logClockColorApplied(seedColor) 192 } 193 } 194 } 195 }, 196 ) 197 ) 198 } 199 } 200 } 201 202 @OptIn(ExperimentalCoroutinesApi::class) 203 val selectedColorOptionPosition: Flow<Int> = 204 colorOptions.flatMapLatest { colorOptions -> 205 combine(colorOptions.map { colorOption -> colorOption.isSelected }) { selectedFlags -> 206 selectedFlags.indexOfFirst { it } 207 } 208 } 209 210 private suspend fun ColorOptionModel.toOptionItemViewModel( 211 context: Context 212 ): OptionItemViewModel<ColorOptionIconViewModel> { 213 val lightThemeColors = 214 (colorOption as ColorOptionImpl) 215 .previewInfo 216 .resolveColors( 217 /** darkTheme= */ 218 false 219 ) 220 val darkThemeColors = 221 colorOption.previewInfo.resolveColors( 222 /** darkTheme= */ 223 true 224 ) 225 val isSelectedFlow = selectedColorId.map { it == null }.stateIn(viewModelScope) 226 return OptionItemViewModel<ColorOptionIconViewModel>( 227 key = MutableStateFlow(key) as StateFlow<String>, 228 payload = 229 ColorOptionIconViewModel( 230 lightThemeColor0 = lightThemeColors[0], 231 lightThemeColor1 = lightThemeColors[1], 232 lightThemeColor2 = lightThemeColors[2], 233 lightThemeColor3 = lightThemeColors[3], 234 darkThemeColor0 = darkThemeColors[0], 235 darkThemeColor1 = darkThemeColors[1], 236 darkThemeColor2 = darkThemeColors[2], 237 darkThemeColor3 = darkThemeColors[3], 238 ), 239 text = Text.Loaded(context.getString(R.string.default_theme_title)), 240 isTextUserVisible = true, 241 isSelected = isSelectedFlow, 242 onClicked = 243 isSelectedFlow.map { isSelected -> 244 if (isSelected) { 245 null 246 } else { 247 { 248 viewModelScope.launch { 249 clockPickerInteractor.setClockColor( 250 selectedColorId = null, 251 colorToneProgress = 252 ClockMetadataModel.DEFAULT_COLOR_TONE_PROGRESS, 253 seedColor = null, 254 ) 255 logger.logClockColorApplied(NULL_SEED_COLOR) 256 } 257 } 258 } 259 }, 260 ) 261 } 262 263 val selectedClockSize: Flow<ClockSize> = clockPickerInteractor.selectedClockSize 264 265 fun setClockSize(size: ClockSize) { 266 viewModelScope.launch { 267 clockPickerInteractor.setClockSize(size) 268 logger.logClockSizeApplied(size.toClockSizeForLogging()) 269 } 270 } 271 272 private val _selectedTabPosition = MutableStateFlow(Tab.COLOR) 273 val selectedTab: StateFlow<Tab> = _selectedTabPosition.asStateFlow() 274 val tabs: Flow<List<ClockSettingsTabViewModel>> = 275 selectedTab.map { 276 listOf( 277 ClockSettingsTabViewModel( 278 name = context.resources.getString(R.string.clock_color), 279 isSelected = it == Tab.COLOR, 280 onClicked = 281 if (it == Tab.COLOR) { 282 null 283 } else { 284 { _selectedTabPosition.tryEmit(Tab.COLOR) } 285 } 286 ), 287 ClockSettingsTabViewModel( 288 name = context.resources.getString(R.string.clock_size), 289 isSelected = it == Tab.SIZE, 290 onClicked = 291 if (it == Tab.SIZE) { 292 null 293 } else { 294 { _selectedTabPosition.tryEmit(Tab.SIZE) } 295 } 296 ), 297 ) 298 } 299 300 companion object { 301 private val helperColorLab: DoubleArray by lazy { DoubleArray(3) } 302 303 fun blendColorWithTone(color: Int, colorTone: Double): Int { 304 ColorUtils.colorToLAB(color, helperColorLab) 305 return ColorUtils.LABToColor( 306 colorTone, 307 helperColorLab[1], 308 helperColorLab[2], 309 ) 310 } 311 312 const val COLOR_OPTIONS_EVENT_UPDATE_DELAY_MILLIS: Long = 100 313 } 314 315 class Factory( 316 private val context: Context, 317 private val clockPickerInteractor: ClockPickerInteractor, 318 private val colorPickerInteractor: ColorPickerInteractor, 319 private val logger: ThemesUserEventLogger, 320 private val getIsReactiveToTone: (clockId: String?) -> Boolean, 321 ) : ViewModelProvider.Factory { 322 override fun <T : ViewModel> create(modelClass: Class<T>): T { 323 @Suppress("UNCHECKED_CAST") 324 return ClockSettingsViewModel( 325 context = context, 326 clockPickerInteractor = clockPickerInteractor, 327 colorPickerInteractor = colorPickerInteractor, 328 logger = logger, 329 getIsReactiveToTone = getIsReactiveToTone, 330 ) 331 as T 332 } 333 } 334 } 335