Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 5ed9b5d5 authored by Fabian Kozynski's avatar Fabian Kozynski Committed by Android (Google) Code Review
Browse files

Merge "New brightness slider" into main

parents af4ef3cd c5701dfb
Loading
Loading
Loading
Loading
+16 −16
Original line number Diff line number Diff line
@@ -26,10 +26,8 @@ import com.android.systemui.brightness.domain.interactor.screenBrightnessInterac
import com.android.systemui.brightness.shared.model.GammaBrightness
import com.android.systemui.brightness.shared.model.LinearBrightness
import com.android.systemui.classifier.domain.interactor.falsingInteractor
import com.android.systemui.common.shared.model.ContentDescription
import com.android.systemui.common.shared.model.Icon
import com.android.systemui.common.shared.model.Text
import com.android.systemui.coroutines.collectLastValue
import com.android.systemui.graphics.imageLoader
import com.android.systemui.haptics.slider.sliderHapticsViewModelFactory
import com.android.systemui.kosmos.testScope
import com.android.systemui.lifecycle.activateIn
@@ -65,6 +63,7 @@ class BrightnessSliderViewModelTest : SysuiTestCase() {
                falsingInteractor,
                supportsMirroring = true,
                brightnessWarningToast,
                imageLoader,
            )
        }
    }
@@ -161,21 +160,22 @@ class BrightnessSliderViewModelTest : SysuiTestCase() {
            }
        }

    @Test
    fun label() {
        assertThat(underTest.label)
            .isEqualTo(Text.Resource(R.string.quick_settings_brightness_dialog_title))
    }

    @Test
    fun icon() {
        assertThat(underTest.icon)
            .isEqualTo(
                Icon.Resource(
                    R.drawable.ic_brightness_full,
                    ContentDescription.Resource(underTest.label.res),
                )
            )
        assertThat(BrightnessSliderViewModel.getIconForPercentage(0f))
            .isEqualTo(R.drawable.ic_brightness_low)
        assertThat(BrightnessSliderViewModel.getIconForPercentage(20f))
            .isEqualTo(R.drawable.ic_brightness_low)
        assertThat(BrightnessSliderViewModel.getIconForPercentage(20.1f))
            .isEqualTo(R.drawable.ic_brightness_medium)
        assertThat(BrightnessSliderViewModel.getIconForPercentage(50f))
            .isEqualTo(R.drawable.ic_brightness_medium)
        assertThat(BrightnessSliderViewModel.getIconForPercentage(79.9f))
            .isEqualTo(R.drawable.ic_brightness_medium)
        assertThat(BrightnessSliderViewModel.getIconForPercentage(80f))
            .isEqualTo(R.drawable.ic_brightness_full)
        assertThat(BrightnessSliderViewModel.getIconForPercentage(100f))
            .isEqualTo(R.drawable.ic_brightness_full)
    }

    @Test
+226 −49
Original line number Diff line number Diff line
@@ -16,49 +16,75 @@

package com.android.systemui.brightness.ui.compose

import android.content.Context
import android.view.MotionEvent
import androidx.annotation.VisibleForTesting
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.AnimationVector1D
import androidx.compose.animation.core.VectorConverter
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.interaction.DragInteraction
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.shape.CornerSize
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.Slider
import androidx.compose.material3.SliderDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.translate
import androidx.compose.ui.graphics.painter.BitmapPainter
import androidx.compose.ui.graphics.painter.ColorPainter
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.input.pointer.pointerInteropFilter
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.android.app.tracing.coroutines.launchTraced as launch
import com.android.compose.PlatformSlider
import com.android.compose.ui.graphics.drawInOverlay
import com.android.systemui.Flags
import com.android.systemui.biometrics.Utils.toBitmap
import com.android.systemui.brightness.shared.model.GammaBrightness
import com.android.systemui.brightness.ui.compose.AnimationSpecs.IconAppearSpec
import com.android.systemui.brightness.ui.compose.AnimationSpecs.IconDisappearSpec
import com.android.systemui.brightness.ui.compose.Dimensions.IconPadding
import com.android.systemui.brightness.ui.compose.Dimensions.IconSize
import com.android.systemui.brightness.ui.compose.Dimensions.SliderBackgroundFrameSize
import com.android.systemui.brightness.ui.compose.Dimensions.SliderBackgroundRoundedCorner
import com.android.systemui.brightness.ui.compose.Dimensions.SliderTrackRoundedCorner
import com.android.systemui.brightness.ui.compose.Dimensions.ThumbTrackGapSize
import com.android.systemui.brightness.ui.viewmodel.BrightnessSliderViewModel
import com.android.systemui.brightness.ui.viewmodel.Drag
import com.android.systemui.common.shared.model.Icon
import com.android.systemui.common.shared.model.Text
import com.android.systemui.common.ui.compose.Icon
import com.android.systemui.compose.modifiers.sysuiResTag
import com.android.systemui.haptics.slider.SeekableSliderTrackerConfig
import com.android.systemui.haptics.slider.SliderHapticFeedbackConfig
@@ -67,27 +93,32 @@ import com.android.systemui.lifecycle.rememberViewModel
import com.android.systemui.qs.ui.compose.borderOnFocus
import com.android.systemui.res.R
import com.android.systemui.utils.PolicyRestriction
import platform.test.motion.compose.values.MotionTestValueKey
import platform.test.motion.compose.values.motionTestValues

@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable
private fun BrightnessSlider(
    viewModel: BrightnessSliderViewModel,
@VisibleForTesting
fun BrightnessSlider(
    gammaValue: Int,
    valueRange: IntRange,
    label: Text.Resource,
    icon: Icon,
    iconResProvider: (Float) -> Int,
    imageLoader: suspend (Int, Context) -> Icon.Loaded,
    restriction: PolicyRestriction,
    onRestrictedClick: (PolicyRestriction.Restricted) -> Unit,
    onDrag: (Int) -> Unit,
    onStop: (Int) -> Unit,
    overriddenByAppState: Boolean,
    modifier: Modifier = Modifier,
    formatter: (Int) -> String = { "$it" },
    showToast: () -> Unit = {},
    hapticsViewModelFactory: SliderHapticsViewModel.Factory,
) {
    var value by remember(gammaValue) { mutableIntStateOf(gammaValue) }
    val animatedValue by
        animateFloatAsState(targetValue = value.toFloat(), label = "BrightnessSliderAnimatedValue")
    val floatValueRange = valueRange.first.toFloat()..valueRange.last.toFloat()
    val isRestricted = remember(restriction) { restriction is PolicyRestriction.Restricted }
    val isRestricted = restriction is PolicyRestriction.Restricted
    val enabled = !isRestricted
    val interactionSource = remember { MutableInteractionSource() }
    val hapticsViewModel: SliderHapticsViewModel? =
        if (Flags.hapticsForComposeSliders()) {
@@ -105,20 +136,56 @@ private fun BrightnessSlider(
        } else {
            null
        }
    val colors = SliderDefaults.colors()

    val overriddenByAppState by
        if (Flags.showToastWhenAppControlBrightness()) {
            viewModel.brightnessOverriddenByWindow.collectAsStateWithLifecycle()
        } else {
            remember { mutableStateOf(false) }
    // The value state is recreated every time gammaValue changes, so we recreate this derivedState
    // We have to use value as that's the value that changes when the user is dragging (gammaValue
    // is always the starting value: actual (not temporary) brightness).
    val iconRes by
        remember(gammaValue, valueRange) {
            derivedStateOf {
                val percentage =
                    (value - valueRange.first) * 100f / (valueRange.last - valueRange.first)
                iconResProvider(percentage)
            }
        }
    val context = LocalContext.current
    val painter: Painter by
        produceState<Painter>(
            initialValue = ColorPainter(Color.Transparent),
            key1 = iconRes,
            key2 = context,
        ) {
            val icon = imageLoader(iconRes, context)
            // toBitmap is Drawable?.() -> Bitmap? and handles null internally.
            val bitmap = icon.drawable.toBitmap()!!.asImageBitmap()
            this@produceState.value = BitmapPainter(bitmap)
        }

    val activeIconColor = colors.activeTickColor
    val inactiveIconColor = colors.inactiveTickColor
    val trackIcon: DrawScope.(Offset, Color, Float) -> Unit =
        remember(painter) {
            { offset, color, alpha ->
                translate(offset.x + IconPadding.toPx(), offset.y) {
                    with(painter) {
                        draw(
                            IconSize.toSize(),
                            colorFilter = ColorFilter.tint(color),
                            alpha = alpha,
                        )
                    }
                }
            }
        }

    PlatformSlider(
    Slider(
        value = animatedValue,
        valueRange = floatValueRange,
        enabled = !isRestricted,
        enabled = enabled,
        colors = colors,
        onValueChange = {
            if (!isRestricted) {
            if (enabled) {
                if (!overriddenByAppState) {
                    hapticsViewModel?.onValueChange(it)
                    value = it.toInt()
@@ -127,7 +194,7 @@ private fun BrightnessSlider(
            }
        },
        onValueChangeFinished = {
            if (!isRestricted) {
            if (enabled) {
                if (!overriddenByAppState) {
                    hapticsViewModel?.onValueChangeEnded()
                    onStop(value)
@@ -140,46 +207,117 @@ private fun BrightnessSlider(
                    onRestrictedClick(restriction)
                }
            },
        icon = { isDragging ->
            if (isDragging) {
                Text(text = formatter(value))
        interactionSource = interactionSource,
        thumb = {
            SliderDefaults.Thumb(
                interactionSource = interactionSource,
                enabled = enabled,
                thumbSize = DpSize(4.dp, 52.dp),
            )
        },
        track = { sliderState ->
            var showIconActive by remember { mutableStateOf(true) }
            val iconActiveAlphaAnimatable = remember {
                Animatable(
                    initialValue = 1f,
                    typeConverter = Float.VectorConverter,
                    label = "iconActiveAlpha",
                )
            }

            val iconInactiveAlphaAnimatable = remember {
                Animatable(
                    initialValue = 0f,
                    typeConverter = Float.VectorConverter,
                    label = "iconInactiveAlpha",
                )
            }

            LaunchedEffect(iconActiveAlphaAnimatable, iconInactiveAlphaAnimatable, showIconActive) {
                if (showIconActive) {
                    launch { iconActiveAlphaAnimatable.appear() }
                    launch { iconInactiveAlphaAnimatable.disappear() }
                } else {
                Icon(modifier = Modifier.size(24.dp), icon = icon)
                    launch { iconActiveAlphaAnimatable.disappear() }
                    launch { iconInactiveAlphaAnimatable.appear() }
                }
            }

            SliderDefaults.Track(
                sliderState = sliderState,
                modifier =
                    Modifier.motionTestValues {
                            (iconActiveAlphaAnimatable.isRunning ||
                                iconInactiveAlphaAnimatable.isRunning) exportAs
                                BrightnessSliderMotionTestKeys.AnimatingIcon

                            iconActiveAlphaAnimatable.value exportAs
                                BrightnessSliderMotionTestKeys.ActiveIconAlpha
                            iconInactiveAlphaAnimatable.value exportAs
                                BrightnessSliderMotionTestKeys.InactiveIconAlpha
                        }
                        .height(40.dp)
                        .drawWithContent {
                            drawContent()

                            val yOffset = size.height / 2 - IconSize.toSize().height / 2
                            val activeTrackStart = 0f
                            val activeTrackEnd =
                                size.width * sliderState.coercedValueAsFraction -
                                    ThumbTrackGapSize.toPx()
                            val inactiveTrackStart = activeTrackEnd + ThumbTrackGapSize.toPx() * 2
                            val inactiveTrackEnd = size.width

                            val activeTrackWidth = activeTrackEnd - activeTrackStart
                            val inactiveTrackWidth = inactiveTrackEnd - inactiveTrackStart
                            if (
                                IconSize.toSize().width < activeTrackWidth - IconPadding.toPx() * 2
                            ) {
                                showIconActive = true
                                trackIcon(
                                    Offset(activeTrackStart, yOffset),
                                    activeIconColor,
                                    iconActiveAlphaAnimatable.value,
                                )
                            } else if (
                                IconSize.toSize().width <
                                    inactiveTrackWidth - IconPadding.toPx() * 2
                            ) {
                                showIconActive = false
                                trackIcon(
                                    Offset(inactiveTrackStart, yOffset),
                                    inactiveIconColor,
                                    iconInactiveAlphaAnimatable.value,
                                )
                            }
                        },
        label = {
            Text(
                text = stringResource(id = label.res),
                style = MaterialTheme.typography.titleMedium,
                maxLines = 1,
                trackCornerSize = SliderTrackRoundedCorner,
                trackInsideCornerSize = 2.dp,
                drawStopIndicator = null,
                thumbTrackGapSize = ThumbTrackGapSize,
            )
        },
        interactionSource = interactionSource,
    )

    val currentShowToast by rememberUpdatedState(showToast)
    // Showing the warning toast if the current running app window has controlled the
    // brightness value.
    if (Flags.showToastWhenAppControlBrightness()) {
        val context = LocalContext.current
        LaunchedEffect(interactionSource) {
            interactionSource.interactions.collect { interaction ->
                if (interaction is DragInteraction.Start && overriddenByAppState) {
                    viewModel.showToast(
                        context,
                        R.string.quick_settings_brightness_unable_adjust_msg,
                    )
                    currentShowToast()
                }
            }
        }
    }
}

private val sliderBackgroundFrameSize = 8.dp

private fun Modifier.sliderBackground(color: Color) = drawWithCache {
    val offsetAround = sliderBackgroundFrameSize.toPx()
    val newSize = Size(size.width + 2 * offsetAround, size.height + 2 * offsetAround)
    val offset = Offset(-offsetAround, -offsetAround)
    val cornerRadius = CornerRadius(offsetAround + size.height / 2)
    val offsetAround = SliderBackgroundFrameSize.toSize()
    val newSize = Size(size.width + 2 * offsetAround.width, size.height + 2 * offsetAround.height)
    val offset = Offset(-offsetAround.width, -offsetAround.height)
    val cornerRadius = CornerRadius(SliderBackgroundRoundedCorner.toPx())
    onDrawBehind {
        drawRoundRect(color = color, topLeft = offset, size = newSize, cornerRadius = cornerRadius)
    }
@@ -192,21 +330,30 @@ fun BrightnessSliderContainer(
    containerColor: Color = colorResource(R.color.shade_scrim_background_dark),
) {
    val gamma = viewModel.currentBrightness.value
    if (gamma == BrightnessSliderViewModel.initialValue.value) { // Ignore initial negative value.
        return
    }
    val context = LocalContext.current
    val coroutineScope = rememberCoroutineScope()
    val restriction by
        viewModel.policyRestriction.collectAsStateWithLifecycle(
            initialValue = PolicyRestriction.NoRestriction
        )
    val overriddenByAppState by
        if (Flags.showToastWhenAppControlBrightness()) {
            viewModel.brightnessOverriddenByWindow.collectAsStateWithLifecycle()
        } else {
            remember { mutableStateOf(false) }
        }

    DisposableEffect(Unit) { onDispose { viewModel.setIsDragging(false) } }

    Box(modifier = modifier.fillMaxWidth().sysuiResTag("brightness_slider")) {
        BrightnessSlider(
            viewModel = viewModel,
            gammaValue = gamma,
            valueRange = viewModel.minBrightness.value..viewModel.maxBrightness.value,
            label = viewModel.label,
            icon = viewModel.icon,
            iconResProvider = BrightnessSliderViewModel::getIconForPercentage,
            imageLoader = viewModel::loadImage,
            restriction = restriction,
            onRestrictedClick = viewModel::showPolicyRestrictionDialog,
            onDrag = {
@@ -220,7 +367,7 @@ fun BrightnessSliderContainer(
            modifier =
                Modifier.borderOnFocus(
                        color = MaterialTheme.colorScheme.secondary,
                        cornerSize = CornerSize(32.dp),
                        cornerSize = CornerSize(SliderTrackRoundedCorner),
                    )
                    .then(if (viewModel.showMirror) Modifier.drawInOverlay() else Modifier)
                    .sliderBackground(containerColor)
@@ -234,8 +381,38 @@ fun BrightnessSliderContainer(
                        }
                        false
                    },
            formatter = viewModel::formatValue,
            hapticsViewModelFactory = viewModel.hapticsViewModelFactory,
            overriddenByAppState = overriddenByAppState,
            showToast = {
                viewModel.showToast(context, R.string.quick_settings_brightness_unable_adjust_msg)
            },
        )
    }
}

private object Dimensions {
    val SliderBackgroundFrameSize = DpSize(10.dp, 6.dp)
    val SliderBackgroundRoundedCorner = 24.dp
    val SliderTrackRoundedCorner = 12.dp
    val IconSize = DpSize(28.dp, 28.dp)
    val IconPadding = 6.dp
    val ThumbTrackGapSize = 6.dp
}

private object AnimationSpecs {
    val IconAppearSpec = tween<Float>(durationMillis = 100, delayMillis = 33)
    val IconDisappearSpec = tween<Float>(durationMillis = 50)
}

private suspend fun Animatable<Float, AnimationVector1D>.appear() =
    animateTo(targetValue = 1f, animationSpec = IconAppearSpec)

private suspend fun Animatable<Float, AnimationVector1D>.disappear() =
    animateTo(targetValue = 0f, animationSpec = IconDisappearSpec)

@VisibleForTesting
object BrightnessSliderMotionTestKeys {
    val AnimatingIcon = MotionTestValueKey<Boolean>("animatingIcon")
    val ActiveIconAlpha = MotionTestValueKey<Float>("activeIconAlpha")
    val InactiveIconAlpha = MotionTestValueKey<Float>("inactiveIconAlpha")
}
+42 −19
Original line number Diff line number Diff line
@@ -17,6 +17,8 @@
package com.android.systemui.brightness.ui.viewmodel

import android.content.Context
import androidx.annotation.DrawableRes
import androidx.annotation.FloatRange
import androidx.annotation.StringRes
import androidx.compose.runtime.getValue
import com.android.systemui.brightness.domain.interactor.BrightnessPolicyEnforcementInteractor
@@ -24,9 +26,9 @@ import com.android.systemui.brightness.domain.interactor.ScreenBrightnessInterac
import com.android.systemui.brightness.shared.model.GammaBrightness
import com.android.systemui.classifier.Classifier
import com.android.systemui.classifier.domain.interactor.FalsingInteractor
import com.android.systemui.common.shared.model.ContentDescription
import com.android.systemui.common.shared.model.Icon
import com.android.systemui.common.shared.model.Text
import com.android.systemui.common.shared.model.asIcon
import com.android.systemui.graphics.ImageLoader
import com.android.systemui.haptics.slider.compose.ui.SliderHapticsViewModel
import com.android.systemui.lifecycle.ExclusiveActivatable
import com.android.systemui.lifecycle.Hydrator
@@ -57,6 +59,7 @@ constructor(
    private val falsingInteractor: FalsingInteractor,
    @Assisted private val supportsMirroring: Boolean,
    private val brightnessWarningToast: BrightnessWarningToast,
    private val imageLoader: ImageLoader,
) : ExclusiveActivatable() {

    private val hydrator = Hydrator("BrightnessSliderViewModel.hydrator")
@@ -64,17 +67,13 @@ constructor(
    val currentBrightness by
        hydrator.hydratedStateOf(
            "currentBrightness",
            GammaBrightness(0),
            initialValue,
            screenBrightnessInteractor.gammaBrightness,
        )

    val maxBrightness = screenBrightnessInteractor.maxGammaBrightness
    val minBrightness = screenBrightnessInteractor.minGammaBrightness

    val label = Text.Resource(R.string.quick_settings_brightness_dialog_title)

    val icon = Icon.Resource(R.drawable.ic_brightness_full, ContentDescription.Resource(label.res))

    val policyRestriction = brightnessPolicyEnforcementInteractor.brightnessPolicyRestriction

    fun showPolicyRestrictionDialog(restriction: PolicyRestriction.Restricted) {
@@ -94,6 +93,16 @@ constructor(
        falsingInteractor.isFalseTouch(Classifier.BRIGHTNESS_SLIDER)
    }

    suspend fun loadImage(@DrawableRes resId: Int, context: Context): Icon.Loaded {
        return imageLoader
            .loadDrawable(
                android.graphics.drawable.Icon.createWithResource(context, resId),
                maxHeight = 200,
                maxWidth = 200,
            )!!
            .asIcon(null, resId)
    }

    /**
     * As a brightness slider is dragged, the corresponding events should be sent using this method.
     */
@@ -104,18 +113,6 @@ constructor(
        }
    }

    /**
     * Format the current value of brightness as a percentage between the minimum and maximum gamma.
     */
    fun formatValue(value: Int): String {
        val min = minBrightness.value
        val max = maxBrightness.value
        val coercedValue = value.coerceIn(min, max)
        val percentage = (coercedValue - min) * 100 / (max - min)
        // This is not finalized UI so using fixed string
        return "$percentage%"
    }

    fun setIsDragging(dragging: Boolean) {
        brightnessMirrorShowingInteractor.setMirrorShowing(dragging && supportsMirroring)
    }
@@ -131,6 +128,26 @@ constructor(
    interface Factory {
        fun create(supportsMirroring: Boolean): BrightnessSliderViewModel
    }

    companion object {
        val initialValue = GammaBrightness(-1)

        private val icons =
            BrightnessIcons(
                brightnessLow = R.drawable.ic_brightness_low,
                brightnessMid = R.drawable.ic_brightness_medium,
                brightnessHigh = R.drawable.ic_brightness_full,
            )

        @DrawableRes
        fun getIconForPercentage(@FloatRange(0.0, 100.0) percentage: Float): Int {
            return when {
                percentage <= 20f -> icons.brightnessLow
                percentage >= 80f -> icons.brightnessHigh
                else -> icons.brightnessMid
            }
        }
    }
}

fun BrightnessSliderViewModel.Factory.create() = create(supportsMirroring = true)
@@ -143,3 +160,9 @@ sealed interface Drag {

    @JvmInline value class Stopped(override val brightness: GammaBrightness) : Drag
}

private data class BrightnessIcons(
    @DrawableRes val brightnessLow: Int,
    @DrawableRes val brightnessMid: Int,
    @DrawableRes val brightnessHigh: Int,
)
+168 −0

File added.

Preview size limit exceeded, changes collapsed.

+136 −0

File added.

Preview size limit exceeded, changes collapsed.

Loading