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

Commit 3332e6cd authored by Lucas Dupin's avatar Lucas Dupin
Browse files

Short Pill UI for 3-Button Nav and Taskbar

Added short pill composable and connected repository to interactor to
decide when to show it

Flag: com.android.systemui.enable_underlay
Test: atest NavBarPillScreenshotTest
Test: atest AmbientCueInteractorTest
Test: atest ShortPillScreenshotTest
Bug: 415914274
Bug: 415914083
Change-Id: Ic076d0efc7b262bf979b0daa290e0a83cf860d82
parent c9e9b20a
Loading
Loading
Loading
Loading
+8 −4
Original line number Diff line number Diff line
@@ -31,18 +31,22 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastForEachIndexed
import com.android.systemui.ambientcue.ui.viewmodel.ActionViewModel

@Composable
fun ActionList(actions: List<ActionViewModel>, visible: Boolean, modifier: Modifier = Modifier) {
fun ActionList(
    actions: List<ActionViewModel>,
    visible: Boolean,
    modifier: Modifier = Modifier,
    horizontalAlignment: Alignment.Horizontal = Alignment.CenterHorizontally,
) {
    val density = LocalDensity.current
    Column(
        modifier = modifier,
        verticalArrangement = Arrangement.spacedBy(8.dp),
        horizontalAlignment = Alignment.CenterHorizontally,
        horizontalAlignment = horizontalAlignment,
    ) {
        actions.fastForEachIndexed { index, action ->
        actions.forEachIndexed { index, action ->
            AnimatedVisibility(
                visible = visible,
                enter =
+118 −22
Original line number Diff line number Diff line
@@ -16,17 +16,34 @@

package com.android.systemui.ambientcue.ui.compose

import android.content.res.Configuration
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
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.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.boundsInParent
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import com.android.systemui.ambientcue.ui.viewmodel.ActionViewModel
import com.android.systemui.ambientcue.ui.viewmodel.AmbientCueViewModel
import com.android.systemui.ambientcue.ui.viewmodel.PillStyleViewModel
import com.android.systemui.lifecycle.rememberViewModel

// TODO: b/414507396 - Replace with the height of the navbar
private val chipsBottomPadding = 46.dp

@Composable
fun AmbientCueContainer(
    modifier: Modifier = Modifier,
@@ -38,9 +55,7 @@ fun AmbientCueContainer(
    val visible = viewModel.isVisible
    val expanded = viewModel.isExpanded
    val actions = viewModel.actions

    // TODO: b/414507396 - Replace with the height of the navbar
    val chipsBottomPadding = 46.dp
    val pillStyle = viewModel.pillStyle

    LaunchedEffect(expanded) {
        onShouldInterceptTouches(expanded)
@@ -58,24 +73,105 @@ fun AmbientCueContainer(
            viewModel.collapse()
        }
    ) {
        BackgroundGlow(
        when (pillStyle) {
            is PillStyleViewModel.NavBarPillStyle -> {
                NavBarAmbientCue(
                    viewModel = viewModel,
                    actions = actions,
                    visible = visible,
                    expanded = expanded,
                    modifier = Modifier.align(Alignment.BottomCenter),
                )
            }
            is PillStyleViewModel.ShortPillStyle -> {
                val pillCenterInWindow = pillStyle.position
                TaskBarAnd3ButtonAmbientCue(
                    viewModel = viewModel,
                    actions = actions,
                    visible = visible,
                    expanded = expanded,
                    pillCenterInWindow = pillCenterInWindow,
                    modifier =
                        if (pillCenterInWindow == null) {
                            Modifier.align(Alignment.BottomCenter)
                        } else {
                            Modifier
                        },
                )
            }
            is PillStyleViewModel.Uninitialized -> {}
        }
    }
}

@Composable
private fun TaskBarAnd3ButtonAmbientCue(
    viewModel: AmbientCueViewModel,
    actions: List<ActionViewModel>,
    visible: Boolean,
    expanded: Boolean,
    pillCenterInWindow: Rect?,
    modifier: Modifier = Modifier,
) {
    val configuration = LocalConfiguration.current
    val portrait = configuration.orientation == Configuration.ORIENTATION_PORTRAIT
    var pillCenter by remember { mutableStateOf(Offset.Zero) }
    BackgroundGlow(
        visible = visible,
        expanded = expanded,
        collapsedOffset = IntOffset(0, 110),
        modifier = modifier.graphicsLayer { translationX = -size.width / 2 + pillCenter.x },
    )
    ShortPill(
        actions = actions,
        visible = visible,
        horizontal = portrait,
        expanded = expanded,
        modifier =
            if (pillCenterInWindow == null) {
                modifier.padding(bottom = 12.dp, end = 24.dp).onGloballyPositioned {
                    pillCenter = it.boundsInParent().center
                }
            } else {
                Modifier.graphicsLayer {
                    val center = pillCenterInWindow.center
                    translationX = center.x - size.width / 2
                    translationY = center.y - size.height / 2
                    pillCenter = center
                }
            },
        onClick = { viewModel.expand() },
        onCloseClick = { viewModel.hide() },
    )
    ActionList(
        actions = actions,
        visible = visible && expanded,
        horizontalAlignment = Alignment.End,
        modifier = modifier.padding(bottom = chipsBottomPadding, end = 24.dp),
    )
}

@Composable
private fun NavBarAmbientCue(
    viewModel: AmbientCueViewModel,
    actions: List<ActionViewModel>,
    visible: Boolean,
    expanded: Boolean,
    modifier: Modifier = Modifier,
) {
    BackgroundGlow(visible = visible, expanded = expanded, modifier = modifier)
    NavBarPill(
        actions = actions,
        navBarWidth = 110.dp, // TODO: b/414507396 - Replace with the width of the navbar
        visible = visible,
        expanded = expanded,
            modifier = Modifier.align(Alignment.BottomCenter),
        modifier = modifier,
        onClick = { viewModel.expand() },
        onCloseClick = { viewModel.hide() },
    )
    ActionList(
        actions = actions,
        visible = visible && expanded,
            modifier = Modifier.align(Alignment.BottomCenter).padding(bottom = chipsBottomPadding),
        modifier = modifier.padding(bottom = chipsBottomPadding),
    )
}
}
+12 −6
Original line number Diff line number Diff line
@@ -19,9 +19,9 @@ package com.android.systemui.ambientcue.ui.compose
import android.graphics.RuntimeShader
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.animateIntOffsetAsState
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
@@ -37,20 +37,25 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ShaderBrush
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import com.android.systemui.ambientcue.ui.shader.BackgroundGlowShader
import com.android.systemui.ambientcue.ui.utils.AiColorUtils.boostChroma

@Composable
fun BackgroundGlow(visible: Boolean, expanded: Boolean, modifier: Modifier) {
fun BackgroundGlow(
    visible: Boolean,
    expanded: Boolean,
    collapsedOffset: IntOffset = IntOffset(0, 110),
    modifier: Modifier,
) {
    val density = LocalDensity.current
    val turbulenceDisplacementPx = with(density) { Defaults.TURBULENCE_DISPLACEMENT_DP.dp.toPx() }
    val gradientRadiusPx = with(density) { Defaults.GRADIENT_RADIUS.dp.toPx() }

    val alpha by animateFloatAsState(if (visible) 1f else 0f, animationSpec = tween(750))
    val verticalOffset by
        animateDpAsState(if (expanded) 0.dp else Defaults.COLLAPSED_TRANSLATION_DP.dp, tween(350))
    val verticalOffsetPx = with(density) { verticalOffset.toPx() }
        animateIntOffsetAsState(if (expanded) IntOffset.Zero else collapsedOffset, tween(350))

    // Infinite animation responsible for the "vapor" effect distorting the radial gradient
    val infiniteTransition = rememberInfiniteTransition(label = "backgroundGlow")
@@ -76,12 +81,14 @@ fun BackgroundGlow(visible: Boolean, expanded: Boolean, modifier: Modifier) {
    Box(
        modifier.size(400.dp, 200.dp).alpha(alpha).drawWithCache {
            onDrawWithContent {
                val offsetX = with(density) { verticalOffset.x.dp.toPx() }
                val offsetY = with(density) { verticalOffset.y.dp.toPx() }
                shader.setFloatUniform("alpha", alpha)
                shader.setFloatUniform("resolution", size.width, size.height)
                shader.setColorUniform("color1", color1.toArgb())
                shader.setColorUniform("color2", color2.toArgb())
                shader.setColorUniform("color3", color3.toArgb())
                shader.setFloatUniform("origin", size.width / 2, size.height + verticalOffsetPx)
                shader.setFloatUniform("origin", size.width / 2 + offsetX, size.height + offsetY)
                shader.setFloatUniform("radius", gradientRadiusPx)
                shader.setFloatUniform("turbulenceAmount", turbulenceDisplacementPx)
                shader.setFloatUniform("turbulencePhase", turbulencePhase)
@@ -93,7 +100,6 @@ fun BackgroundGlow(visible: Boolean, expanded: Boolean, modifier: Modifier) {
}

private object Defaults {
    const val COLLAPSED_TRANSLATION_DP = 110
    const val TURBULENCE_SIZE = 4.7f
    const val TURBULENCE_DISPLACEMENT_DP = 30
    const val GRADIENT_RADIUS = 200
+218 −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.ambientcue.ui.compose

import androidx.compose.animation.core.AnimationSpec
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.IconButtonColors
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastForEach
import com.android.compose.PlatformIconButton
import com.android.compose.ui.graphics.painter.rememberDrawablePainter
import com.android.systemui.ambientcue.ui.compose.modifier.animatedActionBorder
import com.android.systemui.ambientcue.ui.viewmodel.ActionViewModel
import com.android.systemui.res.R

@Composable
fun ShortPill(
    actions: List<ActionViewModel>,
    modifier: Modifier = Modifier,
    horizontal: Boolean = true,
    visible: Boolean = true,
    expanded: Boolean = false,
    onClick: () -> Unit = {},
    onCloseClick: () -> Unit = {},
) {
    val outlineColor = MaterialTheme.colorScheme.onBackground
    val backgroundColor = MaterialTheme.colorScheme.background
    val minSize = 48.dp
    val closeButtonSize = 28.dp
    val transitionTween: AnimationSpec<Float> = tween(250, delayMillis = 200)

    val enterProgress by
        animateFloatAsState(
            if (visible) 1f else 0f,
            animationSpec = transitionTween,
            label = "enter",
        )
    val expansionAlpha by
        animateFloatAsState(
            if (expanded) 0f else 1f,
            animationSpec = transitionTween,
            label = "expansion",
        )

    Box(
        modifier =
            modifier.graphicsLayer {
                scaleY = enterProgress
                scaleX = enterProgress
            }
    ) {
        val pillModifier =
            Modifier.graphicsLayer { alpha = enterProgress * expansionAlpha }
                .clip(RoundedCornerShape(16.dp))
                .background(backgroundColor)
                .animatedActionBorder(
                    strokeWidth = 2.dp,
                    strokeColor = outlineColor,
                    cornerRadius = 16.dp,
                    visible = visible,
                )
                .widthIn(0.dp, minSize * 2)
                .clickable { onClick() }
                .padding(4.dp)

        if (horizontal) {
            Row(
                horizontalArrangement = Arrangement.spacedBy(8.dp),
                verticalAlignment = Alignment.CenterVertically,
            ) {
                Spacer(modifier = Modifier.size(closeButtonSize))

                Row(
                    horizontalArrangement =
                        Arrangement.spacedBy(-4.dp, Alignment.CenterHorizontally),
                    verticalAlignment = Alignment.CenterVertically,
                    modifier = pillModifier.defaultMinSize(minWidth = minSize),
                ) {
                    actions.take(3).fastForEach { action ->
                        Icon(action, backgroundColor, outlineColor)
                        if (actions.size == 1) {
                            Text(
                                text = action.label,
                                color = outlineColor,
                                style = MaterialTheme.typography.labelMedium,
                                overflow = TextOverflow.Ellipsis,
                                maxLines = 1,
                                modifier = Modifier.padding(horizontal = 8.dp),
                            )
                        }
                    }
                }

                CloseButton(
                    backgroundColor,
                    outlineColor,
                    onCloseClick,
                    Modifier.size(closeButtonSize),
                )
            }
        } else {
            Column(
                verticalArrangement = Arrangement.spacedBy(8.dp),
                horizontalAlignment = Alignment.CenterHorizontally,
            ) {
                Spacer(modifier = Modifier.size(closeButtonSize))

                Column(
                    verticalArrangement = Arrangement.spacedBy(-4.dp, Alignment.CenterVertically),
                    modifier = pillModifier.defaultMinSize(minHeight = minSize),
                ) {
                    actions.take(3).fastForEach { action ->
                        Icon(action, backgroundColor, outlineColor)
                    }
                }

                CloseButton(
                    backgroundColor,
                    outlineColor,
                    onCloseClick,
                    Modifier.size(closeButtonSize),
                )
            }
        }
    }
}

@Composable
private fun CloseButton(
    backgroundColor: Color,
    outlineColor: Color,
    onCloseClick: () -> Unit,
    modifier: Modifier = Modifier,
) {
    PlatformIconButton(
        modifier = modifier.clip(CircleShape).background(backgroundColor).padding(8.dp),
        iconResource = R.drawable.ic_close_white_rounded,
        colors =
            IconButtonColors(
                containerColor = backgroundColor,
                contentColor = outlineColor,
                disabledContainerColor = backgroundColor,
                disabledContentColor = outlineColor,
            ),
        contentDescription =
            stringResource(id = R.string.underlay_close_button_content_description),
        onClick = onCloseClick,
    )
}

@Composable
private fun Icon(
    action: ActionViewModel,
    backgroundColor: Color,
    outlineColor: Color,
    modifier: Modifier = Modifier,
) {
    val hasAttribution = action.attribution != null
    Image(
        painter = rememberDrawablePainter(action.icon),
        colorFilter =
            if (hasAttribution) {
                ColorFilter.tint(outlineColor)
            } else {
                null
            },
        contentDescription = action.label,
        modifier =
            modifier
                .size(18.dp)
                .border(1.dp, backgroundColor, CircleShape)
                .padding(1.dp)
                .clip(CircleShape)
                .background(backgroundColor),
    )
}
+32 −0
Original line number Diff line number Diff line
@@ -88,4 +88,36 @@ class AmbientCueInteractorTest : SysuiTestCase() {
            ambientCueInteractor.setImeVisible(false)
            assertThat(isImeVisible).isFalse()
        }

    @Test
    fun isGestureNav_setTrue_true() =
        kosmos.runTest {
            val isGestureNav by collectLastValue(ambientCueInteractor.isGestureNav)
            ambientCueRepository.fake.setIsGestureNav(true)
            assertThat(isGestureNav).isTrue()
        }

    @Test
    fun isGestureNav_setFalse_false() =
        kosmos.runTest {
            val isGestureNav by collectLastValue(ambientCueInteractor.isGestureNav)
            ambientCueRepository.fake.setIsGestureNav(false)
            assertThat(isGestureNav).isFalse()
        }

    @Test
    fun isTaskBarVisible_setTrue_true() =
        kosmos.runTest {
            val isTaskBarVisible by collectLastValue(ambientCueInteractor.isTaskBarVisible)
            ambientCueRepository.fake.setTaskBarVisible(true)
            assertThat(isTaskBarVisible).isTrue()
        }

    @Test
    fun isTaskBarVisible_setFalse_false() =
        kosmos.runTest {
            val isTaskBarVisible by collectLastValue(ambientCueInteractor.isTaskBarVisible)
            ambientCueRepository.fake.setTaskBarVisible(false)
            assertThat(isTaskBarVisible).isFalse()
        }
}
Loading