summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
author Olivier St-Onge <ostonge@google.com> 2025-02-03 17:37:55 -0500
committer Olivier St-Onge <ostonge@google.com> 2025-02-11 09:15:02 -0500
commitf57800bcacbc6a6c00b8e47ccdaeb56b7312ada5 (patch)
tree32ee9d53956c8676eac7c5f5e058aaf95d3cd057
parent6b8460c4a6150dd83a484e834069c0db27a0116a (diff)
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
-rw-r--r--packages/SystemUI/src/com/android/systemui/qs/panels/ui/compose/infinitegrid/EditTile.kt125
-rw-r--r--packages/SystemUI/src/com/android/systemui/qs/panels/ui/compose/selection/Selection.kt2
-rw-r--r--packages/SystemUI/tests/src/com/android/systemui/qs/panels/ui/compose/DragAndDropTest.kt42
-rw-r--r--packages/SystemUI/tests/src/com/android/systemui/qs/panels/ui/compose/EditModeTest.kt21
-rw-r--r--packages/SystemUI/tests/src/com/android/systemui/qs/panels/ui/compose/TestMatchers.kt52
5 files changed, 171 insertions, 71 deletions
diff --git a/packages/SystemUI/src/com/android/systemui/qs/panels/ui/compose/infinitegrid/EditTile.kt b/packages/SystemUI/src/com/android/systemui/qs/panels/ui/compose/infinitegrid/EditTile.kt
index 7701b9087e23..ed98d0cfaa26 100644
--- a/packages/SystemUI/src/com/android/systemui/qs/panels/ui/compose/infinitegrid/EditTile.kt
+++ b/packages/SystemUI/src/com/android/systemui/qs/panels/ui/compose/infinitegrid/EditTile.kt
@@ -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,27 +745,35 @@ private fun AvailableTileGridCell(
verticalArrangement = spacedBy(CommonTileDefaults.TilePadding, Alignment.Top),
modifier = modifier,
) {
- Box(
- Modifier.fillMaxWidth()
- .height(TileHeight)
- .clickable(onClick = onClick, onClickLabel = onClickActionName)
- .semantics(mergeDescendants = true) { this.stateDescription = stateDescription }
- .dragAndDropTileSource(
- SizedTileImpl(cell.tile, cell.width),
- dragAndDropState,
- DragType.Add,
- ) {
- selectionState.unSelect()
- }
- .tileBackground(colors.background)
- .tilePadding()
- ) {
- // Icon
- SmallTileContent(
- iconProvider = { cell.tile.icon },
- color = colors.icon,
- animateToEnd = true,
- modifier = Modifier.align(Alignment.Center),
+ Box {
+ Box(
+ Modifier.fillMaxWidth()
+ .height(TileHeight)
+ .clickable(onClick = onClick, onClickLabel = onClickActionName)
+ .semantics(mergeDescendants = true) { this.stateDescription = stateDescription }
+ .dragAndDropTileSource(
+ SizedTileImpl(cell.tile, cell.width),
+ dragAndDropState,
+ DragType.Add,
+ ) {
+ selectionState.unSelect()
+ }
+ .tileBackground(colors.background)
+ .tilePadding()
+ ) {
+ // Icon
+ SmallTileContent(
+ iconProvider = { cell.tile.icon },
+ color = colors.icon,
+ animateToEnd = true,
+ modifier = Modifier.align(Alignment.Center),
+ )
+ }
+
+ TileBadge(
+ icon = Icons.Default.Add,
+ contentDescription = onClickActionName,
+ onClick = onClick,
)
}
Box(Modifier.fillMaxSize()) {
@@ -745,6 +790,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
Box(modifier.height(TileHeight).fillMaxWidth())
@@ -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 =
diff --git a/packages/SystemUI/src/com/android/systemui/qs/panels/ui/compose/selection/Selection.kt b/packages/SystemUI/src/com/android/systemui/qs/panels/ui/compose/selection/Selection.kt
index c1545e1263db..7c472638da63 100644
--- a/packages/SystemUI/src/com/android/systemui/qs/panels/ui/compose/selection/Selection.kt
+++ b/packages/SystemUI/src/com/android/systemui/qs/panels/ui/compose/selection/Selection.kt
@@ -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),
)
}
}
diff --git a/packages/SystemUI/tests/src/com/android/systemui/qs/panels/ui/compose/DragAndDropTest.kt b/packages/SystemUI/tests/src/com/android/systemui/qs/panels/ui/compose/DragAndDropTest.kt
index 26cf4a261289..92b26ea3a8ef 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/qs/panels/ui/compose/DragAndDropTest.kt
+++ b/packages/SystemUI/tests/src/com/android/systemui/qs/panels/ui/compose/DragAndDropTest.kt
@@ -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"
diff --git a/packages/SystemUI/tests/src/com/android/systemui/qs/panels/ui/compose/EditModeTest.kt b/packages/SystemUI/tests/src/com/android/systemui/qs/panels/ui/compose/EditModeTest.kt
index 4e8b0bcd374c..8c09b81744d7 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/qs/panels/ui/compose/EditModeTest.kt
+++ b/packages/SystemUI/tests/src/com/android/systemui/qs/panels/ui/compose/EditModeTest.kt
@@ -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"
diff --git a/packages/SystemUI/tests/src/com/android/systemui/qs/panels/ui/compose/TestMatchers.kt b/packages/SystemUI/tests/src/com/android/systemui/qs/panels/ui/compose/TestMatchers.kt
new file mode 100644
index 000000000000..dbccf864fc26
--- /dev/null
+++ b/packages/SystemUI/tests/src/com/android/systemui/qs/panels/ui/compose/TestMatchers.kt
@@ -0,0 +1,52 @@
+/*
+ * 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
+ }
+}