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.systemui.brightness.ui.compose
18
19 import android.content.Context
20 import android.view.MotionEvent
21 import androidx.annotation.VisibleForTesting
22 import androidx.compose.animation.animateColorAsState
23 import androidx.compose.animation.core.Animatable
24 import androidx.compose.animation.core.AnimationVector1D
25 import androidx.compose.animation.core.VectorConverter
26 import androidx.compose.animation.core.animateFloatAsState
27 import androidx.compose.animation.core.tween
28 import androidx.compose.foundation.clickable
29 import androidx.compose.foundation.gestures.Orientation
30 import androidx.compose.foundation.interaction.DragInteraction
31 import androidx.compose.foundation.interaction.MutableInteractionSource
32 import androidx.compose.foundation.layout.Box
33 import androidx.compose.foundation.layout.fillMaxWidth
34 import androidx.compose.foundation.layout.height
35 import androidx.compose.foundation.shape.CornerSize
36 import androidx.compose.material3.ExperimentalMaterial3Api
37 import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
38 import androidx.compose.material3.MaterialTheme
39 import androidx.compose.material3.Slider
40 import androidx.compose.material3.SliderColors
41 import androidx.compose.material3.SliderDefaults
42 import androidx.compose.runtime.Composable
43 import androidx.compose.runtime.DisposableEffect
44 import androidx.compose.runtime.LaunchedEffect
45 import androidx.compose.runtime.ReadOnlyComposable
46 import androidx.compose.runtime.derivedStateOf
47 import androidx.compose.runtime.getValue
48 import androidx.compose.runtime.mutableIntStateOf
49 import androidx.compose.runtime.mutableStateOf
50 import androidx.compose.runtime.produceState
51 import androidx.compose.runtime.remember
52 import androidx.compose.runtime.rememberCoroutineScope
53 import androidx.compose.runtime.rememberUpdatedState
54 import androidx.compose.runtime.setValue
55 import androidx.compose.ui.Modifier
56 import androidx.compose.ui.draw.drawWithCache
57 import androidx.compose.ui.draw.drawWithContent
58 import androidx.compose.ui.geometry.CornerRadius
59 import androidx.compose.ui.geometry.Offset
60 import androidx.compose.ui.geometry.Size
61 import androidx.compose.ui.graphics.Color
62 import androidx.compose.ui.graphics.ColorFilter
63 import androidx.compose.ui.graphics.asImageBitmap
64 import androidx.compose.ui.graphics.drawscope.DrawScope
65 import androidx.compose.ui.graphics.drawscope.translate
66 import androidx.compose.ui.graphics.painter.BitmapPainter
67 import androidx.compose.ui.graphics.painter.ColorPainter
68 import androidx.compose.ui.graphics.painter.Painter
69 import androidx.compose.ui.input.pointer.pointerInteropFilter
70 import androidx.compose.ui.platform.LocalContext
71 import androidx.compose.ui.res.colorResource
72 import androidx.compose.ui.unit.DpSize
73 import androidx.compose.ui.unit.dp
74 import androidx.lifecycle.compose.collectAsStateWithLifecycle
75 import com.android.app.tracing.coroutines.launchTraced as launch
76 import com.android.compose.modifiers.padding
77 import com.android.compose.theme.LocalAndroidColorScheme
78 import com.android.compose.ui.graphics.drawInOverlay
79 import com.android.systemui.Flags
80 import com.android.systemui.biometrics.Utils.toBitmap
81 import com.android.systemui.brightness.shared.model.GammaBrightness
82 import com.android.systemui.brightness.ui.compose.AnimationSpecs.IconAppearSpec
83 import com.android.systemui.brightness.ui.compose.AnimationSpecs.IconDisappearSpec
84 import com.android.systemui.brightness.ui.compose.Dimensions.IconPadding
85 import com.android.systemui.brightness.ui.compose.Dimensions.IconSize
86 import com.android.systemui.brightness.ui.compose.Dimensions.SliderBackgroundFrameSize
87 import com.android.systemui.brightness.ui.compose.Dimensions.SliderBackgroundRoundedCorner
88 import com.android.systemui.brightness.ui.compose.Dimensions.SliderTrackRoundedCorner
89 import com.android.systemui.brightness.ui.compose.Dimensions.ThumbTrackGapSize
90 import com.android.systemui.brightness.ui.viewmodel.BrightnessSliderViewModel
91 import com.android.systemui.brightness.ui.viewmodel.Drag
92 import com.android.systemui.common.shared.model.Icon
93 import com.android.systemui.compose.modifiers.sysuiResTag
94 import com.android.systemui.haptics.slider.SeekableSliderTrackerConfig
95 import com.android.systemui.haptics.slider.SliderHapticFeedbackConfig
96 import com.android.systemui.haptics.slider.compose.ui.SliderHapticsViewModel
97 import com.android.systemui.lifecycle.rememberViewModel
98 import com.android.systemui.qs.ui.compose.borderOnFocus
99 import com.android.systemui.res.R
100 import com.android.systemui.utils.PolicyRestriction
101 import platform.test.motion.compose.values.MotionTestValueKey
102 import platform.test.motion.compose.values.motionTestValues
103
104 @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
105 @Composable
106 @VisibleForTesting
107 fun BrightnessSlider(
108 gammaValue: Int,
109 valueRange: IntRange,
110 iconResProvider: (Float) -> Int,
111 imageLoader: suspend (Int, Context) -> Icon.Loaded,
112 restriction: PolicyRestriction,
113 onRestrictedClick: (PolicyRestriction.Restricted) -> Unit,
114 onDrag: (Int) -> Unit,
115 onStop: (Int) -> Unit,
116 overriddenByAppState: Boolean,
117 modifier: Modifier = Modifier,
118 showToast: () -> Unit = {},
119 hapticsViewModelFactory: SliderHapticsViewModel.Factory,
120 ) {
<lambda>null121 var value by remember(gammaValue) { mutableIntStateOf(gammaValue) }
122 val animatedValue by
123 animateFloatAsState(targetValue = value.toFloat(), label = "BrightnessSliderAnimatedValue")
124 val floatValueRange = valueRange.first.toFloat()..valueRange.last.toFloat()
125 val isRestricted = restriction is PolicyRestriction.Restricted
126 val enabled = !isRestricted
<lambda>null127 val interactionSource = remember { MutableInteractionSource() }
128 val hapticsViewModel: SliderHapticsViewModel? =
129 if (Flags.hapticsForComposeSliders()) {
<lambda>null130 rememberViewModel(traceName = "SliderHapticsViewModel") {
131 hapticsViewModelFactory.create(
132 interactionSource,
133 floatValueRange,
134 Orientation.Horizontal,
135 SliderHapticFeedbackConfig(
136 maxVelocityToScale = 1f /* slider progress(from 0 to 1) per sec */
137 ),
138 SeekableSliderTrackerConfig(),
139 )
140 }
141 } else {
142 null
143 }
144 val colors = colors()
145
146 // The value state is recreated every time gammaValue changes, so we recreate this derivedState
147 // We have to use value as that's the value that changes when the user is dragging (gammaValue
148 // is always the starting value: actual (not temporary) brightness).
149 val iconRes by
<lambda>null150 remember(gammaValue, valueRange) {
151 derivedStateOf {
152 val percentage =
153 (value - valueRange.first) * 100f / (valueRange.last - valueRange.first)
154 iconResProvider(percentage)
155 }
156 }
157 val context = LocalContext.current
158 val painter: Painter by
159 produceState<Painter>(
160 initialValue = ColorPainter(Color.Transparent),
161 key1 = iconRes,
162 key2 = context,
<lambda>null163 ) {
164 val icon = imageLoader(iconRes, context)
165 // toBitmap is Drawable?.() -> Bitmap? and handles null internally.
166 val bitmap = icon.drawable.toBitmap()!!.asImageBitmap()
167 this@produceState.value = BitmapPainter(bitmap)
168 }
169
170 val activeIconColor = colors.activeTickColor
171 val inactiveIconColor = colors.inactiveTickColor
<lambda>null172 val trackIcon: DrawScope.(Offset, Color, Float) -> Unit = remember {
173 { offset, color, alpha ->
174 translate(offset.x + IconPadding.toPx(), offset.y) {
175 with(painter) {
176 draw(IconSize.toSize(), colorFilter = ColorFilter.tint(color), alpha = alpha)
177 }
178 }
179 }
180 }
181
182 Slider(
183 value = animatedValue,
184 valueRange = floatValueRange,
185 enabled = enabled,
186 colors = colors,
<lambda>null187 onValueChange = {
188 if (enabled) {
189 if (!overriddenByAppState) {
190 hapticsViewModel?.onValueChange(it)
191 value = it.toInt()
192 onDrag(value)
193 }
194 }
195 },
<lambda>null196 onValueChangeFinished = {
197 if (enabled) {
198 if (!overriddenByAppState) {
199 hapticsViewModel?.onValueChangeEnded()
200 onStop(value)
201 }
202 }
203 },
204 modifier =
<lambda>null205 modifier.sysuiResTag("slider").clickable(enabled = isRestricted) {
206 if (restriction is PolicyRestriction.Restricted) {
207 onRestrictedClick(restriction)
208 }
209 },
210 interactionSource = interactionSource,
<lambda>null211 thumb = {
212 SliderDefaults.Thumb(
213 interactionSource = interactionSource,
214 enabled = enabled,
215 thumbSize = DpSize(4.dp, 52.dp),
216 colors = colors,
217 )
218 },
sliderStatenull219 track = { sliderState ->
220 var showIconActive by remember { mutableStateOf(true) }
221 val iconActiveAlphaAnimatable = remember {
222 Animatable(
223 initialValue = 1f,
224 typeConverter = Float.VectorConverter,
225 label = "iconActiveAlpha",
226 )
227 }
228
229 val iconInactiveAlphaAnimatable = remember {
230 Animatable(
231 initialValue = 0f,
232 typeConverter = Float.VectorConverter,
233 label = "iconInactiveAlpha",
234 )
235 }
236
237 LaunchedEffect(iconActiveAlphaAnimatable, iconInactiveAlphaAnimatable, showIconActive) {
238 if (showIconActive) {
239 launch { iconActiveAlphaAnimatable.appear() }
240 launch { iconInactiveAlphaAnimatable.disappear() }
241 } else {
242 launch { iconActiveAlphaAnimatable.disappear() }
243 launch { iconInactiveAlphaAnimatable.appear() }
244 }
245 }
246
247 SliderDefaults.Track(
248 sliderState = sliderState,
249 modifier =
250 Modifier.motionTestValues {
251 (iconActiveAlphaAnimatable.isRunning ||
252 iconInactiveAlphaAnimatable.isRunning) exportAs
253 BrightnessSliderMotionTestKeys.AnimatingIcon
254
255 iconActiveAlphaAnimatable.value exportAs
256 BrightnessSliderMotionTestKeys.ActiveIconAlpha
257 iconInactiveAlphaAnimatable.value exportAs
258 BrightnessSliderMotionTestKeys.InactiveIconAlpha
259 }
260 .height(40.dp)
261 .drawWithContent {
262 drawContent()
263
264 val yOffset = size.height / 2 - IconSize.toSize().height / 2
265 val activeTrackStart = 0f
266 val activeTrackEnd =
267 size.width * sliderState.coercedValueAsFraction -
268 ThumbTrackGapSize.toPx()
269 val inactiveTrackStart = activeTrackEnd + ThumbTrackGapSize.toPx() * 2
270 val inactiveTrackEnd = size.width
271
272 val activeTrackWidth = activeTrackEnd - activeTrackStart
273 val inactiveTrackWidth = inactiveTrackEnd - inactiveTrackStart
274 if (
275 IconSize.toSize().width < activeTrackWidth - IconPadding.toPx() * 2
276 ) {
277 showIconActive = true
278 trackIcon(
279 Offset(activeTrackStart, yOffset),
280 activeIconColor,
281 iconActiveAlphaAnimatable.value,
282 )
283 } else if (
284 IconSize.toSize().width <
285 inactiveTrackWidth - IconPadding.toPx() * 2
286 ) {
287 showIconActive = false
288 trackIcon(
289 Offset(inactiveTrackStart, yOffset),
290 inactiveIconColor,
291 iconInactiveAlphaAnimatable.value,
292 )
293 }
294 },
295 trackCornerSize = SliderTrackRoundedCorner,
296 trackInsideCornerSize = 2.dp,
297 drawStopIndicator = null,
298 thumbTrackGapSize = ThumbTrackGapSize,
299 colors = colors,
300 )
301 },
302 )
303
304 val currentShowToast by rememberUpdatedState(showToast)
305 // Showing the warning toast if the current running app window has controlled the
306 // brightness value.
307 if (Flags.showToastWhenAppControlBrightness()) {
<lambda>null308 LaunchedEffect(interactionSource) {
309 interactionSource.interactions.collect { interaction ->
310 if (interaction is DragInteraction.Start && overriddenByAppState) {
311 currentShowToast()
312 }
313 }
314 }
315 }
316 }
317
<lambda>null318 private fun Modifier.sliderBackground(color: Color) = drawWithCache {
319 val offsetAround = SliderBackgroundFrameSize.toSize()
320 val newSize = Size(size.width + 2 * offsetAround.width, size.height + 2 * offsetAround.height)
321 val offset = Offset(-offsetAround.width, -offsetAround.height)
322 val cornerRadius = CornerRadius(SliderBackgroundRoundedCorner.toPx())
323 onDrawBehind {
324 drawRoundRect(color = color, topLeft = offset, size = newSize, cornerRadius = cornerRadius)
325 }
326 }
327
328 @Composable
BrightnessSliderContainernull329 fun BrightnessSliderContainer(
330 viewModel: BrightnessSliderViewModel,
331 modifier: Modifier = Modifier,
332 containerColors: ContainerColors,
333 ) {
334 val gamma = viewModel.currentBrightness.value
335 if (gamma == BrightnessSliderViewModel.initialValue.value) { // Ignore initial negative value.
336 return
337 }
338 val context = LocalContext.current
339 val coroutineScope = rememberCoroutineScope()
340 val restriction by
341 viewModel.policyRestriction.collectAsStateWithLifecycle(
342 initialValue = PolicyRestriction.NoRestriction
343 )
344 val overriddenByAppState by
345 if (Flags.showToastWhenAppControlBrightness()) {
346 viewModel.brightnessOverriddenByWindow.collectAsStateWithLifecycle()
347 } else {
348 remember { mutableStateOf(false) }
349 }
350
351 DisposableEffect(Unit) { onDispose { viewModel.setIsDragging(false) } }
352
353 var dragging by remember { mutableStateOf(false) }
354
355 // Use dragging instead of viewModel.showMirror so the color starts changing as soon as the
356 // dragging state changes. If not, we may be waiting for the background to finish fading in
357 // when stopping dragging
358 val containerColor by
359 animateColorAsState(
360 if (dragging) containerColors.mirrorColor else containerColors.idleColor
361 )
362
363 Box(
364 modifier =
365 modifier
366 .padding(vertical = { SliderBackgroundFrameSize.height.roundToPx() })
367 .fillMaxWidth()
368 .sysuiResTag("brightness_slider")
369 ) {
370 BrightnessSlider(
371 gammaValue = gamma,
372 valueRange = viewModel.minBrightness.value..viewModel.maxBrightness.value,
373 iconResProvider = BrightnessSliderViewModel::getIconForPercentage,
374 imageLoader = viewModel::loadImage,
375 restriction = restriction,
376 onRestrictedClick = viewModel::showPolicyRestrictionDialog,
377 onDrag = {
378 viewModel.setIsDragging(true)
379 dragging = true
380 coroutineScope.launch { viewModel.onDrag(Drag.Dragging(GammaBrightness(it))) }
381 },
382 onStop = {
383 viewModel.setIsDragging(false)
384 dragging = false
385 coroutineScope.launch { viewModel.onDrag(Drag.Stopped(GammaBrightness(it))) }
386 },
387 modifier =
388 Modifier.borderOnFocus(
389 color = MaterialTheme.colorScheme.secondary,
390 cornerSize = CornerSize(SliderTrackRoundedCorner),
391 )
392 .then(if (viewModel.showMirror) Modifier.drawInOverlay() else Modifier)
393 .sliderBackground(containerColor)
394 .fillMaxWidth()
395 .pointerInteropFilter {
396 if (
397 it.actionMasked == MotionEvent.ACTION_UP ||
398 it.actionMasked == MotionEvent.ACTION_CANCEL
399 ) {
400 viewModel.emitBrightnessTouchForFalsing()
401 }
402 false
403 },
404 hapticsViewModelFactory = viewModel.hapticsViewModelFactory,
405 overriddenByAppState = overriddenByAppState,
406 showToast = {
407 viewModel.showToast(context, R.string.quick_settings_brightness_unable_adjust_msg)
408 },
409 )
410 }
411 }
412
413 data class ContainerColors(val idleColor: Color, val mirrorColor: Color) {
414 companion object {
singleColornull415 fun singleColor(color: Color) = ContainerColors(color, color)
416
417 val defaultContainerColor: Color
418 @Composable @ReadOnlyComposable get() = colorResource(R.color.shade_panel_fallback)
419 }
420 }
421
422 private object Dimensions {
423 val SliderBackgroundFrameSize = DpSize(10.dp, 6.dp)
424 val SliderBackgroundRoundedCorner = 24.dp
425 val SliderTrackRoundedCorner = 12.dp
426 val IconSize = DpSize(28.dp, 28.dp)
427 val IconPadding = 6.dp
428 val ThumbTrackGapSize = 6.dp
429 }
430
431 private object AnimationSpecs {
432 val IconAppearSpec = tween<Float>(durationMillis = 100, delayMillis = 33)
433 val IconDisappearSpec = tween<Float>(durationMillis = 50)
434 }
435
appearnull436 private suspend fun Animatable<Float, AnimationVector1D>.appear() =
437 animateTo(targetValue = 1f, animationSpec = IconAppearSpec)
438
439 private suspend fun Animatable<Float, AnimationVector1D>.disappear() =
440 animateTo(targetValue = 0f, animationSpec = IconDisappearSpec)
441
442 @VisibleForTesting
443 object BrightnessSliderMotionTestKeys {
444 val AnimatingIcon = MotionTestValueKey<Boolean>("animatingIcon")
445 val ActiveIconAlpha = MotionTestValueKey<Float>("activeIconAlpha")
446 val InactiveIconAlpha = MotionTestValueKey<Float>("inactiveIconAlpha")
447 }
448
449 @Composable
colorsnull450 private fun colors(): SliderColors {
451 return SliderDefaults.colors()
452 .copy(
453 inactiveTrackColor = LocalAndroidColorScheme.current.surfaceEffect2,
454 activeTickColor = MaterialTheme.colorScheme.onPrimary,
455 inactiveTickColor = MaterialTheme.colorScheme.onSurface,
456 )
457 }
458