• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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