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

Commit f471cd03 authored by Behnam Heydarshahi's avatar Behnam Heydarshahi
Browse files

vertical flashlight slider

Flag: com.android.systemui.flashlight_strength
Bug: 440397613
Test: atest VerticalFlashlightSliderMotionTest

Change-Id: If48fe46f2852fa2dc56bed548d0d94525bcdae13
parent 07d74c4c
Loading
Loading
Loading
Loading
+3 −1
Original line number Diff line number Diff line
@@ -37,6 +37,7 @@ import androidx.compose.ui.layout.Layout
import androidx.compose.ui.layout.Placeable
import androidx.compose.ui.layout.layoutId
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import kotlin.math.roundToInt

@@ -56,6 +57,7 @@ fun AlertDialogContent(
    positiveButton: (@Composable () -> Unit)? = null,
    negativeButton: (@Composable () -> Unit)? = null,
    neutralButton: (@Composable () -> Unit)? = null,
    contentBottomPadding: Dp = 32.dp,
) {
    Column(
        modifier.fillMaxWidth().verticalScroll(rememberScrollState()).padding(DialogPaddings),
@@ -97,7 +99,7 @@ fun AlertDialogContent(
                }
            }
        }
        Spacer(Modifier.height(32.dp))
        Spacer(Modifier.height(contentBottomPadding))

        // Buttons.
        if (positiveButton != null || negativeButton != null || neutralButton != null) {
+4 −0
Original line number Diff line number Diff line
@@ -30,6 +30,10 @@ class FlashlightLogger @Inject constructor(@FlashlightLog private val buffer: Lo
        buffer.log(TAG, DEBUG, { str1 = state.toString() }, { "Flashlight state=$str1" })
    }

    fun dialogW(msg: String) {
        buffer.log(TAG, WARNING, { str1 = msg }, { "Flashlight Dialog: $str1" })
    }

    fun d(msg: String) {
        buffer.log(TAG, DEBUG, { str1 = msg }, { "$str1" })
    }
+10 −10
Original line number Diff line number Diff line
@@ -16,15 +16,14 @@

package com.android.systemui.flashlight.ui.composable

import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SliderDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.android.systemui.compose.modifiers.sysuiResTag
import com.android.systemui.flashlight.ui.viewmodel.FlashlightSliderViewModel
import com.android.systemui.util.ui.compose.DualIconSlider
import com.android.systemui.util.ui.compose.defaultColors

@Composable
fun FlashlightSliderContainer(viewModel: FlashlightSliderViewModel, modifier: Modifier = Modifier) {
@@ -36,17 +35,18 @@ fun FlashlightSliderContainer(viewModel: FlashlightSliderViewModel, modifier: Mo
            0 // even if the "level" has been reset to "default" on the backend
        }

    Box(modifier = modifier.fillMaxWidth().sysuiResTag("flashlight_slider")) {
        DualIconSlider(
    Column(modifier = modifier.fillMaxWidth().sysuiResTag("flashlight_slider")) {
        VerticalFlashlightSlider(
            levelValue = levelValue,
            valueRange = 0..currentState.max,
            iconResProvider = FlashlightSliderViewModel::getIconForPercentage,
            imageLoader = viewModel::loadImage,
            onValueChange = viewModel::setFlashlightLevel,
            isEnabled = viewModel.isFlashlightAdjustable,
            hapticsViewModelFactory = viewModel.hapticsViewModelFactory,
            colors =
                defaultColors().copy(inactiveTrackColor = MaterialTheme.colorScheme.surfaceDim),
            onDrag = viewModel::setFlashlightLevel,
            isEnabled = viewModel.isFlashlightAdjustable,
                SliderDefaults.colors(
                    thumbColor = MaterialTheme.colorScheme.primary,
                    activeTrackColor = MaterialTheme.colorScheme.primary,
                ),
        )
    }
}
+344 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2025 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.android.systemui.flashlight.ui.composable

import androidx.annotation.VisibleForTesting
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.graphics.res.animatedVectorResource
import androidx.compose.animation.graphics.res.rememberAnimatedVectorPainter
import androidx.compose.animation.graphics.vector.AnimatedImageVector
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsDraggedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.Slider
import androidx.compose.material3.SliderColors
import androidx.compose.material3.SliderDefaults
import androidx.compose.material3.SliderState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.BlurredEdgeTreatment
import androidx.compose.ui.draw.blur
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Outline
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.hideFromAccessibility
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import com.android.compose.modifiers.height
import com.android.compose.modifiers.sliderPercentage
import com.android.compose.modifiers.width
import com.android.systemui.compose.modifiers.sysuiResTag
import com.android.systemui.flashlight.ui.composable.Specs.BLUR_CONTRACTION
import com.android.systemui.flashlight.ui.composable.Specs.BLUR_X
import com.android.systemui.flashlight.ui.composable.Specs.BLUR_Y
import com.android.systemui.flashlight.ui.composable.Specs.EdgeTreatment
import com.android.systemui.flashlight.ui.composable.Specs.MAX_TRACK_HEIGHT
import com.android.systemui.flashlight.ui.composable.Specs.MIN_TRACK_HEIGHT
import com.android.systemui.flashlight.ui.composable.Specs.THUMB_MAX_HEIGHT
import com.android.systemui.flashlight.ui.composable.Specs.THUMB_MIN_HEIGHT
import com.android.systemui.flashlight.ui.composable.Specs.THUMB_WIDTH
import com.android.systemui.flashlight.ui.composable.Specs.TRACK_LENGTH
import com.android.systemui.flashlight.ui.composable.Specs.TRAPEZOID_BOTTOM_LEFT_HEIGHT_RATIO
import com.android.systemui.flashlight.ui.composable.Specs.TRAPEZOID_TOP_LEFT_HEIGHT_RATIO
import com.android.systemui.flashlight.ui.composable.Specs.WIDTH_CONTRACTION
import com.android.systemui.flashlight.ui.composable.VerticalFlashlightSliderMotionTestKeys.TRACK_TEST_TAG
import com.android.systemui.flashlight.ui.composable.VerticalFlashlightSliderMotionTestKeys.TrackHeight
import com.android.systemui.flashlight.ui.composable.VerticalFlashlightSliderMotionTestKeys.TrackWidth
import com.android.systemui.haptics.slider.SeekableSliderTrackerConfig
import com.android.systemui.haptics.slider.SliderHapticFeedbackConfig
import com.android.systemui.haptics.slider.compose.ui.SliderHapticsViewModel
import com.android.systemui.lifecycle.rememberViewModel
import com.android.systemui.res.R
import platform.test.motion.compose.values.MotionTestValueKey
import platform.test.motion.compose.values.motionTestValues

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun VerticalFlashlightSlider(
    valueRange: IntRange,
    onValueChange: (Int) -> Unit,
    isEnabled: Boolean,
    levelValue: Int,
    hapticsViewModelFactory: SliderHapticsViewModel.Factory,
    colors: SliderColors,
    modifier: Modifier = Modifier,
) {
    // for flashlight icon toggling on and off
    var atEnd by remember { mutableStateOf(levelValue != valueRange.first) }

    val floatValueRange = valueRange.first.toFloat()..valueRange.last.toFloat()

    val interactionSource = remember { MutableInteractionSource() }
    val isDragged = interactionSource.collectIsDraggedAsState()

    var value by remember(levelValue) { mutableIntStateOf(levelValue) }
    val animatedValue by
        animateFloatAsState(targetValue = value.toFloat(), label = "FlashlightSliderAnimatedValue")

    val hapticsViewModel: SliderHapticsViewModel =
        rememberViewModel(traceName = "SliderHapticsViewModel") {
            hapticsViewModelFactory.create(
                interactionSource,
                floatValueRange,
                Orientation.Vertical,
                SliderHapticFeedbackConfig(
                    maxVelocityToScale = 1f /* slider progress(from 0 to 1) per sec */
                ),
                SeekableSliderTrackerConfig(),
            )
        }

    Column(
        modifier = modifier.fillMaxWidth().wrapContentHeight(),
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.spacedBy(4.dp),
    ) {
        Slider(
            modifier =
                Modifier.rotate(270f)
                    // 80 dp is enough for track height, but we need 120 for thumb height.
                    // But since the rotate only rotates the draw layer, we would end up with  a 120
                    // slider length rather than 140. Instead of 120 we go with 140 so that we don't
                    // need extra code to rotate the layout.
                    .size(TRACK_LENGTH)
                    .sliderPercentage { toPercent(animatedValue, valueRange) }
                    .sysuiResTag("slider"),
            enabled = isEnabled,
            value = animatedValue,
            valueRange = floatValueRange,
            onValueChange = {
                if (isEnabled) {
                    hapticsViewModel.onValueChange(it)
                    value = it.toInt()
                    atEnd = value != valueRange.first
                    onValueChange(value)
                }
            },
            onValueChangeFinished = {
                if (isEnabled) {
                    hapticsViewModel.onValueChangeEnded()
                }
            },
            interactionSource = interactionSource,
            colors = colors,
            thumb = {
                Row(verticalAlignment = Alignment.CenterVertically) {
                    Spacer(
                        modifier = Modifier.width(THUMB_WIDTH / 2).fillMaxHeight()
                        // TODO(440617960): this should match dialog bg
                    )
                    SliderDefaults.Thumb(
                        interactionSource = interactionSource,
                        colors = colors,
                        thumbSize =
                            DpSize(THUMB_WIDTH, thumbHeight(value / floatValueRange.endInclusive)),
                    )
                    Spacer(
                        Modifier.width(THUMB_WIDTH / 2).fillMaxHeight()
                        // TODO(440617960): this should match dialog bg
                    )
                }
            },
            track = { sliderState ->
                TrapezoidTrack(
                    modifier =
                        Modifier
                            // TODO(440620729): gradient blur from top to bottom. or no bottom blur.
                            .blur(
                                BLUR_X,
                                // TODO(440620729): start contraction on click down, not on drag.
                                if (isDragged.value) (BLUR_Y * BLUR_CONTRACTION) else BLUR_Y,
                                EdgeTreatment,
                            )
                            .motionTestValues {
                                trackEndAlpha(toPercent(sliderState.value, valueRange)) exportAs
                                    VerticalFlashlightSliderMotionTestKeys.TrackEndAlpha
                            },
                    brush =
                        Brush.horizontalGradient(
                            0f to colors.activeTrackColor,
                            0.5f to colors.activeTrackColor,
                            1.0f to
                                // lower end alpha from 1 to 0.12 as slider progresses.
                                colors.activeTrackColor.copy(
                                    alpha = trackEndAlpha(toPercent(sliderState.value, valueRange))
                                ),
                        ),
                    sliderState = sliderState,
                    maxSliderRange = floatValueRange.endInclusive,
                    widthContraction = if (isDragged.value) WIDTH_CONTRACTION else 1f,
                )
            },
        )
        AnimatedVectorFlashlightDrawable(atEnd, colors.thumbColor, Modifier.size(48.dp))
    }
}

private fun toPercent(value: Float, valueRange: IntRange): Float =
    (value - valueRange.first.toFloat()) / (valueRange.last - valueRange.first)

private fun trackEndAlpha(percentage: Float) = -0.88f * percentage + 1

@ExperimentalMaterial3Api
@Composable
private fun TrapezoidTrack(
    sliderState: SliderState,
    maxSliderRange: Float,
    brush: Brush,
    widthContraction: Float,
    modifier: Modifier = Modifier,
) {

    Row(
        modifier = modifier.size(TRACK_LENGTH, MAX_TRACK_HEIGHT),
        verticalAlignment = Alignment.CenterVertically,
    ) {
        val canvasHeight by animateDpAsState((MAX_TRACK_HEIGHT * widthContraction))
        val path = remember { Path() }

        Canvas(
            modifier =
                modifier
                    .width { (TRACK_LENGTH * (sliderState.value / maxSliderRange)).roundToPx() }
                    .height { canvasHeight.roundToPx() }
                    .testTag(TRACK_TEST_TAG)
                    .motionTestValues {
                        canvasHeight exportAs TrackHeight
                        TRACK_LENGTH * (sliderState.value / maxSliderRange) exportAs TrackWidth
                    }
        ) {
            drawTrapezoidPathAndRewind(path, brush)
        }
    }
}

/** We draw a left to right trapezoid (actual proportion is more horizontally long) */
private fun DrawScope.drawTrapezoidPathAndRewind(path: Path, brush: Brush) {
    path.moveTo(0f, size.height * TRAPEZOID_BOTTOM_LEFT_HEIGHT_RATIO)
    path.lineTo(0f, size.height * TRAPEZOID_TOP_LEFT_HEIGHT_RATIO) // left
    path.lineTo(size.width, 0f) // top
    path.lineTo(size.width, size.height) // right
    path.lineTo(0f, size.height * TRAPEZOID_BOTTOM_LEFT_HEIGHT_RATIO) // bottom
    drawPath(path, brush)

    path.rewind()
}

@Composable
private fun AnimatedVectorFlashlightDrawable(
    atEnd: Boolean,
    color: Color,
    modifier: Modifier = Modifier,
) {
    val image = AnimatedImageVector.animatedVectorResource(R.drawable.qs_flashlight_icon_on)
    Icon(
        modifier = modifier.semantics { hideFromAccessibility() },
        painter = rememberAnimatedVectorPainter(image, atEnd),
        contentDescription = null,
        tint = color,
    )
}

private fun thumbHeight(thumbPosition: Float): Dp {
    return (THUMB_MAX_HEIGHT - THUMB_MIN_HEIGHT) * thumbPosition + THUMB_MIN_HEIGHT
}

private class BeamShape : Shape {

    override fun createOutline(
        size: Size,
        layoutDirection: LayoutDirection,
        density: Density,
    ): Outline {
        val path =
            Path().apply { drawBeamPath(size, density, Offset(size.width / 2, size.height / 2)) }
        return Outline.Generic(path)
    }
}

private fun Path.drawBeamPath(size: Size, density: Density, center: Offset) {
    val leftSideLength = with(density) { MIN_TRACK_HEIGHT.toPx() }
    val topLeftY = center.y - leftSideLength / 2
    val bottomLeftY = center.y + leftSideLength / 2

    moveTo(leftSideLength / 2, topLeftY) // start at top-left
    lineTo(size.width, 0f) // top  (moving right and up)
    lineTo(size.width, size.height) // right (moving down)
    lineTo(leftSideLength / 2, bottomLeftY) // bottom (moving left and up)

    arcTo(
        rect = Rect(left = 0f, top = topLeftY, right = leftSideLength, bottom = bottomLeftY),
        startAngleDegrees = 90f, // point left
        sweepAngleDegrees = 180f, // half the circle
        forceMoveTo = true,
    )
}

@VisibleForTesting
object VerticalFlashlightSliderMotionTestKeys {
    const val TRACK_TEST_TAG = "TrapezoidTrack"
    val TrackEndAlpha = MotionTestValueKey<Float>("trackEndAlpha")
    val TrackHeight = MotionTestValueKey<Dp>("trackHeight")
    val TrackWidth = MotionTestValueKey<Dp>("trackWidth")
}

private object Specs {
    val TRACK_LENGTH = 140.dp
    val MAX_TRACK_HEIGHT = 80.dp
    val MIN_TRACK_HEIGHT = 22.dp
    val THUMB_MIN_HEIGHT = 48.dp
    val THUMB_MAX_HEIGHT = 120.dp
    val THUMB_WIDTH = 4.dp
    val BLUR_X = 20.dp // max 60
    val BLUR_Y = 5.dp // max 30
    val EdgeTreatment = BlurredEdgeTreatment(BeamShape())
    const val BLUR_CONTRACTION = 1f // max 1
    const val WIDTH_CONTRACTION = 0.7f // max 1
    const val TRAPEZOID_TOP_LEFT_HEIGHT_RATIO = 0.3625f
    const val TRAPEZOID_BOTTOM_LEFT_HEIGHT_RATIO = 0.6375f
}
+6 −9
Original line number Diff line number Diff line
@@ -25,6 +25,7 @@ import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTagsAsResourceId
import androidx.compose.ui.unit.dp
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import com.android.compose.PlatformButton
@@ -68,25 +69,21 @@ constructor(

    init {
        if (FlashlightStrength.isUnexpectedlyInLegacyMode()) {
            logger.w("$TAG#init: UnexpectedlyInLegacyMode")
            logger.dialogW("UnexpectedlyInLegacyMode on init")
        }
    }

    override fun createDialog(): SystemUIDialog {
        if (FlashlightStrength.isUnexpectedlyInLegacyMode()) {
            logger.w("$TAG#createDialog: UnexpectedlyInLegacyMode!")
            logger.dialogW("UnexpectedlyInLegacyMode on create dialog")
        }

        Assert.isMainThread()
        if (currentDialog != null) {
            logger.w(
                "$TAG#createDialog: " +
                    "Dialog is already open, dismissing it and creating a new one."
            )
            logger.dialogW("Already open when creating, dismissing it and creating a new one")
            currentDialog?.dismiss()
            return currentDialog!!
        }

        currentDialog =
            sysuiDialogFactory.create(context = shadeDialogContextInteractor.context) {
                FlashlightDialogContent(it)
@@ -144,6 +141,7 @@ constructor(
                        Text(stringResource(R.string.flashlight_dialog_turn_off))
                    }
                },
                contentBottomPadding = 8.dp,
            )
        }
    }
@@ -151,7 +149,7 @@ constructor(
    /** Runs on @Main CoroutineContext */
    suspend fun showDialog(expandable: Expandable? = null): SystemUIDialog? {
        if (FlashlightStrength.isUnexpectedlyInLegacyMode()) {
            logger.w("$TAG#showDialog: UnexpectedlyInLegacyMode!")
            logger.dialogW("UnexpectedlyInLegacyMode on show")
            return null
        }

@@ -173,7 +171,6 @@ constructor(
    }

    companion object {
        private const val TAG = "FlashlightDialogDelegate"
        private const val INTERACTION_JANK_TAG = "flashlight"
        private const val FLASHLIGHT_TITLE_TAG = "flashlight_title"
        private const val FLASHLIGHT_DONE_TAG = "flashlight_done"
Loading