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

Commit f57800bc authored by Olivier St-Onge's avatar Olivier St-Onge
Browse files

Add badges to edit tiles

This allows for quick add/remove actions

Test: manually
Flag: com.android.systemui.qs_ui_refactor_compose_fragment
Bug: 379116386
Change-Id: I44bb85a1177491836a46b6b56e31aacb4c7ba72b
parent 6b8460c4
Loading
Loading
Loading
Loading
+102 −23
Original line number Diff line number Diff line
@@ -34,6 +34,7 @@ import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.clipScrollableContainer
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement.spacedBy
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
@@ -56,14 +57,18 @@ import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.systemGestureExclusion
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Clear
import androidx.compose.material.icons.filled.Remove
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.LocalMinimumInteractiveComponentSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
@@ -86,9 +91,12 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.geometry.isSpecified
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.MeasureScope
import androidx.compose.ui.layout.layout
import androidx.compose.ui.layout.onGloballyPositioned
@@ -105,10 +113,13 @@ import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.stateDescription
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.unit.toSize
import androidx.compose.ui.util.fastMap
import androidx.compose.ui.zIndex
import com.android.app.tracing.coroutines.launchTraced as launch
import com.android.compose.animation.bounceable
import com.android.compose.modifiers.height
@@ -131,6 +142,7 @@ import com.android.systemui.qs.panels.ui.compose.infinitegrid.EditModeTileDefaul
import com.android.systemui.qs.panels.ui.compose.infinitegrid.EditModeTileDefaults.AUTO_SCROLL_SPEED
import com.android.systemui.qs.panels.ui.compose.infinitegrid.EditModeTileDefaults.AvailableTilesGridMinHeight
import com.android.systemui.qs.panels.ui.compose.infinitegrid.EditModeTileDefaults.CurrentTilesGridPadding
import com.android.systemui.qs.panels.ui.compose.infinitegrid.EditModeTileDefaults.TileBadgeSize
import com.android.systemui.qs.panels.ui.compose.selection.MutableSelectionState
import com.android.systemui.qs.panels.ui.compose.selection.ResizableTileContainer
import com.android.systemui.qs.panels.ui.compose.selection.ResizingState
@@ -143,6 +155,7 @@ import com.android.systemui.qs.panels.ui.compose.selection.selectableTile
import com.android.systemui.qs.panels.ui.model.GridCell
import com.android.systemui.qs.panels.ui.model.SpacerGridCell
import com.android.systemui.qs.panels.ui.model.TileGridCell
import com.android.systemui.qs.panels.ui.viewmodel.AvailableEditActions
import com.android.systemui.qs.panels.ui.viewmodel.BounceableTileViewModel
import com.android.systemui.qs.panels.ui.viewmodel.EditTileViewModel
import com.android.systemui.qs.pipeline.shared.TileSpec
@@ -152,6 +165,7 @@ import kotlin.math.abs
import kotlin.math.roundToInt
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch

object TileType

@@ -261,6 +275,7 @@ fun DefaultEditTileGrid(
                    columns,
                    largeTilesSpan,
                    onResize,
                    onRemoveTile,
                    onSetTiles,
                )

@@ -385,6 +400,7 @@ private fun CurrentTilesGrid(
    columns: Int,
    largeTilesSpan: Int,
    onResize: (TileSpec, toIcon: Boolean) -> Unit,
    onRemoveTile: (TileSpec) -> Unit,
    onSetTiles: (List<TileSpec>) -> Unit,
) {
    val currentListState by rememberUpdatedState(listState)
@@ -424,8 +440,15 @@ private fun CurrentTilesGrid(
                }
                .testTag(CURRENT_TILES_GRID_TEST_TAG),
    ) {
        EditTiles(cells, columns, listState, selectionState, coroutineScope, largeTilesSpan) {
            resizingOperation ->
        EditTiles(
            cells,
            columns,
            listState,
            selectionState,
            coroutineScope,
            largeTilesSpan,
            onRemoveTile,
        ) { resizingOperation ->
            when (resizingOperation) {
                is TemporaryResizeOperation -> {
                    currentListState.resizeTile(resizingOperation.spec, resizingOperation.toIcon)
@@ -530,6 +553,7 @@ fun LazyGridScope.EditTiles(
    selectionState: MutableSelectionState,
    coroutineScope: CoroutineScope,
    largeTilesSpan: Int,
    onRemoveTile: (TileSpec) -> Unit,
    onResize: (operation: ResizeOperation) -> Unit,
) {
    items(
@@ -558,6 +582,7 @@ fun LazyGridScope.EditTiles(
                        dragAndDropState = dragAndDropState,
                        selectionState = selectionState,
                        onResize = onResize,
                        onRemoveTile = onRemoveTile,
                        coroutineScope = coroutineScope,
                        bounceableInfo = cells.bounceableInfo(index, columns),
                        largeTilesSpan = largeTilesSpan,
@@ -576,6 +601,7 @@ private fun TileGridCell(
    dragAndDropState: DragAndDropState,
    selectionState: MutableSelectionState,
    onResize: (operation: ResizeOperation) -> Unit,
    onRemoveTile: (TileSpec) -> Unit,
    coroutineScope: CoroutineScope,
    largeTilesSpan: Int,
    bounceableInfo: BounceableInfo,
@@ -583,6 +609,8 @@ private fun TileGridCell(
) {
    val stateDescription = stringResource(id = R.string.accessibility_qs_edit_position, index + 1)
    var selected by remember { mutableStateOf(false) }
    val showRemovalBadge =
        !selected && cell.tile.availableEditActions.contains(AvailableEditActions.REMOVE)
    val selectionAlpha by
        animateFloatAsState(
            targetValue = if (selected) 1f else 0f,
@@ -682,6 +710,15 @@ private fun TileGridCell(
        ) {
            EditTile(tile = cell.tile, state = state, progress = progress)
        }

        if (showRemovalBadge) {
            TileBadge(
                icon = Icons.Default.Remove,
                contentDescription = stringResource(R.string.qs_customize_remove),
            ) {
                onRemoveTile(cell.tile.tileSpec)
            }
        }
    }
}

@@ -708,6 +745,7 @@ private fun AvailableTileGridCell(
        verticalArrangement = spacedBy(CommonTileDefaults.TilePadding, Alignment.Top),
        modifier = modifier,
    ) {
        Box {
            Box(
                Modifier.fillMaxWidth()
                    .height(TileHeight)
@@ -731,6 +769,13 @@ private fun AvailableTileGridCell(
                    modifier = Modifier.align(Alignment.Center),
                )
            }

            TileBadge(
                icon = Icons.Default.Add,
                contentDescription = onClickActionName,
                onClick = onClick,
            )
        }
        Box(Modifier.fillMaxSize()) {
            Text(
                cell.tile.label.text,
@@ -744,6 +789,39 @@ private fun AvailableTileGridCell(
    }
}

@Composable
private fun TileBadge(icon: ImageVector, contentDescription: String?, onClick: () -> Unit) {
    // Use a higher zIndex than the tile to draw over it, and manually create the touch target as
    // we're drawing over neighbor tiles as well.
    val minTouchTargetSize = LocalMinimumInteractiveComponentSize.current

    Box(
        Modifier.zIndex(2f)
            .layout { measurable, constraints ->
                val size = minTouchTargetSize.roundToPx()
                val placeable = measurable.measure(Constraints(size))
                layout(placeable.width, placeable.height) {
                    val iconRadius = TileBadgeSize.roundToPx() / 2
                    val x = constraints.maxWidth - placeable.width / 2 - iconRadius
                    val y = 0 - placeable.height / 2 + iconRadius
                    placeable.place(x, y)
                }
            }
            .systemGestureExclusion { Rect(Offset.Zero, it.size.toSize()) }
            .pointerInput(Unit) { detectTapGestures { onClick() } }
    ) {
        val secondaryColor = MaterialTheme.colorScheme.secondary
        Icon(
            icon,
            contentDescription = contentDescription,
            modifier =
                Modifier.size(TileBadgeSize).align(Alignment.Center).drawBehind {
                    drawCircle(secondaryColor)
                },
        )
    }
}

@Composable
private fun SpacerGridCell(modifier: Modifier = Modifier) {
    // By default, spacers are invisible and exist purely to catch drag movements
@@ -829,6 +907,7 @@ private object EditModeTileDefaults {
    const val AUTO_SCROLL_SPEED = 2 // 2ms per pixel
    val CurrentTilesGridPadding = 8.dp
    val AvailableTilesGridMinHeight = 200.dp
    val TileBadgeSize = 20.dp

    @Composable
    fun editTileColors(): TileColors =
+1 −1
Original line number Diff line number Diff line
@@ -81,7 +81,7 @@ fun ResizableTileContainer(
            state = state,
            modifier =
                // Higher zIndex to make sure the handle is drawn above the content
                Modifier.zIndex(2f),
                Modifier.zIndex(if (selected) 2f else 1f),
        )
    }
}
+16 −26
Original line number Diff line number Diff line
@@ -22,14 +22,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.SemanticsProperties
import androidx.compose.ui.test.SemanticsMatcher
import androidx.compose.ui.test.assert
import androidx.compose.ui.test.filter
import androidx.compose.ui.test.hasContentDescription
import androidx.compose.ui.test.junit4.ComposeContentTestRule
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onChildren
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
@@ -100,7 +93,10 @@ class DragAndDropTest : SysuiTestCase() {
        composeRule.onNodeWithText("Remove").assertExists()

        // Every other tile should still be in the same order
        composeRule.assertTileGridContainsExactly(listOf("tileB", "tileC", "tileD_large", "tileE"))
        composeRule.assertGridContainsExactly(
            CURRENT_TILES_GRID_TEST_TAG,
            listOf("tileB", "tileC", "tileD_large", "tileE"),
        )
    }

    @Test
@@ -125,8 +121,9 @@ class DragAndDropTest : SysuiTestCase() {
        composeRule.onNodeWithText("Remove").assertDoesNotExist()

        // Tile A and B should swap places
        composeRule.assertTileGridContainsExactly(
            listOf("tileB", "tileA", "tileC", "tileD_large", "tileE")
        composeRule.assertGridContainsExactly(
            CURRENT_TILES_GRID_TEST_TAG,
            listOf("tileB", "tileA", "tileC", "tileD_large", "tileE"),
        )
    }

@@ -152,7 +149,10 @@ class DragAndDropTest : SysuiTestCase() {
        composeRule.onNodeWithText("Remove").assertDoesNotExist()

        // Tile A is gone
        composeRule.assertTileGridContainsExactly(listOf("tileB", "tileC", "tileD_large", "tileE"))
        composeRule.assertGridContainsExactly(
            CURRENT_TILES_GRID_TEST_TAG,
            listOf("tileB", "tileC", "tileD_large", "tileE"),
        )
    }

    @Test
@@ -166,7 +166,7 @@ class DragAndDropTest : SysuiTestCase() {
        }
        composeRule.waitForIdle()

        listState.onStarted(createEditTile("newTile"), DragType.Add)
        listState.onStarted(createEditTile("tile_new"), DragType.Add)
        // Insert after tileD, which is at index 4
        // [ a ] [ b ] [ c ] [ empty ]
        // [ tile d ] [ e ]
@@ -179,23 +179,13 @@ class DragAndDropTest : SysuiTestCase() {
        // Remove drop zone should disappear
        composeRule.onNodeWithText("Remove").assertDoesNotExist()

        // newTile is added after tileD
        composeRule.assertTileGridContainsExactly(
            listOf("tileA", "tileB", "tileC", "tileD_large", "newTile", "tileE")
        // tile_new is added after tileD
        composeRule.assertGridContainsExactly(
            CURRENT_TILES_GRID_TEST_TAG,
            listOf("tileA", "tileB", "tileC", "tileD_large", "tile_new", "tileE"),
        )
    }

    private fun ComposeContentTestRule.assertTileGridContainsExactly(specs: List<String>) {
        onNodeWithTag(CURRENT_TILES_GRID_TEST_TAG)
            .onChildren()
            .filter(SemanticsMatcher.keyIsDefined(SemanticsProperties.ContentDescription))
            .apply {
                fetchSemanticsNodes().forEachIndexed { index, _ ->
                    get(index).assert(hasContentDescription(specs[index]))
                }
            }
    }

    companion object {
        private const val CURRENT_TILES_GRID_TEST_TAG = "CurrentTilesGrid"
        private const val AVAILABLE_TILES_GRID_TEST_TAG = "AvailableTilesGrid"
+0 −21
Original line number Diff line number Diff line
@@ -23,16 +23,9 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.SemanticsProperties
import androidx.compose.ui.test.SemanticsMatcher
import androidx.compose.ui.test.assert
import androidx.compose.ui.test.filter
import androidx.compose.ui.test.hasContentDescription
import androidx.compose.ui.test.junit4.ComposeContentTestRule
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onChildren
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.text.AnnotatedString
@@ -113,20 +106,6 @@ class EditModeTest : SysuiTestCase() {
        specs: List<String>
    ) = assertGridContainsExactly(AVAILABLE_TILES_GRID_TEST_TAG, specs)

    private fun ComposeContentTestRule.assertGridContainsExactly(
        testTag: String,
        specs: List<String>,
    ) {
        onNodeWithTag(testTag)
            .onChildren()
            .filter(SemanticsMatcher.keyIsDefined(SemanticsProperties.ContentDescription))
            .apply {
                fetchSemanticsNodes().forEachIndexed { index, _ ->
                    get(index).assert(hasContentDescription(specs[index]))
                }
            }
    }

    companion object {
        private const val CURRENT_TILES_GRID_TEST_TAG = "CurrentTilesGrid"
        private const val AVAILABLE_TILES_GRID_TEST_TAG = "AvailableTilesGrid"
+52 −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.qs.panels.ui.compose

import androidx.compose.ui.semantics.SemanticsProperties
import androidx.compose.ui.semantics.getOrNull
import androidx.compose.ui.test.SemanticsMatcher
import androidx.compose.ui.test.assert
import androidx.compose.ui.test.filter
import androidx.compose.ui.test.hasContentDescription
import androidx.compose.ui.test.junit4.ComposeContentTestRule
import androidx.compose.ui.test.onChildren
import androidx.compose.ui.test.onNodeWithTag

/** Asserts that the tile grid with [testTag] contains exactly [specs] */
fun ComposeContentTestRule.assertGridContainsExactly(testTag: String, specs: List<String>) {
    onNodeWithTag(testTag)
        .onChildren()
        .filter(SemanticsMatcher.contentDescriptionStartsWith("tile"))
        .apply {
            fetchSemanticsNodes().forEachIndexed { index, _ ->
                get(index).assert(hasContentDescription(specs[index]))
            }
        }
}

/**
 * A [SemanticsMatcher] that matches anything with a content description starting with the given
 * [prefix]
 */
fun SemanticsMatcher.Companion.contentDescriptionStartsWith(prefix: String): SemanticsMatcher {
    return SemanticsMatcher("${SemanticsProperties.ContentDescription.name} starts with $prefix") {
        semanticsNode ->
        semanticsNode.config.getOrNull(SemanticsProperties.ContentDescription)?.any {
            it.startsWith(prefix)
        } ?: false
    }
}