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

Commit c0e04bd2 authored by Olivier St-Onge's avatar Olivier St-Onge Committed by Android (Google) Code Review
Browse files

Merge changes I287d52e7,I81173c94 into main

* changes:
  Implement placement mode for QS edit page
  Block tile removal when the minimum amount of tiles is reached
parents c22787a8 07c78844
Loading
Loading
Loading
Loading
+71 −0
Original line number Diff line number Diff line
@@ -23,6 +23,7 @@ import com.android.systemui.SysuiTestCase
import com.android.systemui.common.shared.model.Icon
import com.android.systemui.qs.panels.shared.model.SizedTile
import com.android.systemui.qs.panels.shared.model.SizedTileImpl
import com.android.systemui.qs.panels.ui.compose.selection.PlacementEvent
import com.android.systemui.qs.panels.ui.model.GridCell
import com.android.systemui.qs.panels.ui.model.TileGridCell
import com.android.systemui.qs.panels.ui.viewmodel.EditTileViewModel
@@ -108,6 +109,76 @@ class EditTileListStateTest : SysuiTestCase() {
        assertThat(underTest.tiles.toStrings()).doesNotContain(TestEditTiles[0].tile.tileSpec.spec)
    }

    @Test
    fun targetIndexForPlacementToTileSpec_returnsCorrectIndex() {
        val placementEvent =
            PlacementEvent.PlaceToTileSpec(
                movingSpec = TestEditTiles[0].tile.tileSpec,
                targetSpec = TestEditTiles[3].tile.tileSpec,
            )
        val index = underTest.targetIndexForPlacement(placementEvent)

        assertThat(index).isEqualTo(3)
    }

    @Test
    fun targetIndexForPlacementToIndex_indexOutOfBounds_returnsCorrectIndex() {
        val placementEventTooLow =
            PlacementEvent.PlaceToIndex(
                movingSpec = TestEditTiles[0].tile.tileSpec,
                targetIndex = -1,
            )
        val index1 = underTest.targetIndexForPlacement(placementEventTooLow)

        assertThat(index1).isEqualTo(0)

        val placementEventTooHigh =
            PlacementEvent.PlaceToIndex(
                movingSpec = TestEditTiles[0].tile.tileSpec,
                targetIndex = 10,
            )
        val index2 = underTest.targetIndexForPlacement(placementEventTooHigh)
        assertThat(index2).isEqualTo(TestEditTiles.size)
    }

    @Test
    fun targetIndexForPlacementToIndex_movingBack_returnsCorrectIndex() {
        /**
         * With the grid: [ a ] [ b ] [ c ] [ Large D ] [ e ] [ f ]
         *
         * Moving 'e' to the spacer at index 3 will result in the tilespec order: a, b, c, e, d, f
         *
         * 'e' is now at index 3
         */
        val placementEvent =
            PlacementEvent.PlaceToIndex(
                movingSpec = TestEditTiles[4].tile.tileSpec,
                targetIndex = 3,
            )
        val index = underTest.targetIndexForPlacement(placementEvent)

        assertThat(index).isEqualTo(3)
    }

    @Test
    fun targetIndexForPlacementToIndex_movingForward_returnsCorrectIndex() {
        /**
         * With the grid: [ a ] [ b ] [ c ] [ Large D ] [ e ] [ f ]
         *
         * Moving '1' to the spacer at index 3 will result in the tilespec order: b, c, a, d, e, f
         *
         * 'a' is now at index 2
         */
        val placementEvent =
            PlacementEvent.PlaceToIndex(
                movingSpec = TestEditTiles[0].tile.tileSpec,
                targetIndex = 3,
            )
        val index = underTest.targetIndexForPlacement(placementEvent)

        assertThat(index).isEqualTo(2)
    }

    private fun List<GridCell>.toStrings(): List<String> {
        return map {
            if (it is TileGridCell) {
+103 −0
Original line number Diff line number Diff line
@@ -19,8 +19,14 @@ package com.android.systemui.qs.panels.ui.compose.selection
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.SmallTest
import com.android.systemui.SysuiTestCase
import com.android.systemui.qs.panels.ui.compose.selection.TileState.GreyedOut
import com.android.systemui.qs.panels.ui.compose.selection.TileState.None
import com.android.systemui.qs.panels.ui.compose.selection.TileState.Placeable
import com.android.systemui.qs.panels.ui.compose.selection.TileState.Removable
import com.android.systemui.qs.panels.ui.compose.selection.TileState.Selected
import com.android.systemui.qs.pipeline.shared.TileSpec
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.junit.runner.RunWith

@@ -45,7 +51,104 @@ class MutableSelectionStateTest : SysuiTestCase() {
        assertThat(underTest.selection).isEqualTo(newSpec)
    }

    @Test
    fun placementModeEnabled_tapOnIndex_sendsCorrectPlacementEvent() {
        // Tap while in placement mode
        underTest.enterPlacementMode(TEST_SPEC)
        underTest.onTap(2)

        assertThat(underTest.placementEnabled).isFalse()
        val event = underTest.placementEvent as PlacementEvent.PlaceToIndex
        assertThat(event.movingSpec).isEqualTo(TEST_SPEC)
        assertThat(event.targetIndex).isEqualTo(2)
    }

    @Test
    fun placementModeDisabled_tapOnIndex_doesNotSendPlacementEvent() {
        // Tap while placement mode is disabled
        underTest.onTap(2)

        assertThat(underTest.placementEnabled).isFalse()
        assertThat(underTest.placementEvent).isNull()
    }

    @Test
    fun placementModeEnabled_tapOnSelection_exitPlacementMode() {
        // Tap while in placement mode
        underTest.enterPlacementMode(TEST_SPEC)
        underTest.onTap(TEST_SPEC)

        assertThat(underTest.placementEnabled).isFalse()
        assertThat(underTest.placementEvent).isNull()
    }

    @Test
    fun placementModeEnabled_tapOnTileSpec_sendsCorrectPlacementEvent() {
        // Tap while in placement mode
        underTest.enterPlacementMode(TEST_SPEC)
        underTest.onTap(TEST_SPEC_2)

        assertThat(underTest.placementEnabled).isFalse()
        val event = underTest.placementEvent as PlacementEvent.PlaceToTileSpec
        assertThat(event.movingSpec).isEqualTo(TEST_SPEC)
        assertThat(event.targetSpec).isEqualTo(TEST_SPEC_2)
    }

    @Test
    fun placementModeDisabled_tapOnSelection_unselect() {
        // Select the tile and tap on it
        underTest.select(TEST_SPEC)
        underTest.onTap(TEST_SPEC)

        assertThat(underTest.placementEnabled).isFalse()
        assertThat(underTest.selected).isFalse()
    }

    @Test
    fun placementModeDisabled_tapOnTile_selects() {
        // Select a tile but tap a second one
        underTest.select(TEST_SPEC)
        underTest.onTap(TEST_SPEC_2)

        assertThat(underTest.placementEnabled).isFalse()
        assertThat(underTest.selection).isEqualTo(TEST_SPEC_2)
    }

    @Test
    fun tileStateFor_selectedTile_returnsSingleSelection() = runTest {
        underTest.select(TEST_SPEC)

        assertThat(underTest.tileStateFor(TEST_SPEC, None, canShowRemovalBadge = true))
            .isEqualTo(Selected)
        assertThat(underTest.tileStateFor(TEST_SPEC_2, None, canShowRemovalBadge = true))
            .isEqualTo(Removable)
        assertThat(underTest.tileStateFor(TEST_SPEC_3, None, canShowRemovalBadge = true))
            .isEqualTo(Removable)
    }

    @Test
    fun tileStateFor_placementMode_returnsSinglePlaceable() = runTest {
        underTest.enterPlacementMode(TEST_SPEC)

        assertThat(underTest.tileStateFor(TEST_SPEC, None, canShowRemovalBadge = true))
            .isEqualTo(Placeable)
        assertThat(underTest.tileStateFor(TEST_SPEC_2, None, canShowRemovalBadge = true))
            .isEqualTo(GreyedOut)
        assertThat(underTest.tileStateFor(TEST_SPEC_3, None, canShowRemovalBadge = true))
            .isEqualTo(GreyedOut)
    }

    @Test
    fun tileStateFor_nonRemovableTile_returnsNoneState() = runTest {
        assertThat(underTest.tileStateFor(TEST_SPEC, None, canShowRemovalBadge = true))
            .isEqualTo(Removable)
        assertThat(underTest.tileStateFor(TEST_SPEC_2, None, canShowRemovalBadge = false))
            .isEqualTo(None)
    }

    companion object {
        private val TEST_SPEC = TileSpec.create("testSpec")
        private val TEST_SPEC_2 = TileSpec.create("testSpec2")
        private val TEST_SPEC_3 = TileSpec.create("testSpec3")
    }
}
+9 −0
Original line number Diff line number Diff line
@@ -2551,6 +2551,9 @@
    <!-- Label for header of customize QS [CHAR LIMIT=60] -->
    <string name="drag_to_rearrange_tiles">Hold and drag to rearrange tiles</string>

    <!-- Label for placing tiles in edit mode for QS [CHAR LIMIT=60] -->
    <string name="tap_to_position_tile">Tap to position tile</string>

    <!-- Label for area where tiles can be dragged in to [CHAR LIMIT=60] -->
    <string name="drag_to_remove_tiles">Drag here to remove</string>

@@ -2592,6 +2595,12 @@
    <!-- Accessibility description of action to remove QS tile on click. It will read as "Double-tap to remove tile" in screen readers [CHAR LIMIT=NONE] -->
    <string name="accessibility_qs_edit_remove_tile_action">remove tile</string>

    <!-- Accessibility description of action to select the QS tile to place on click. It will read as "Double-tap to toggle placement mode" in screen readers [CHAR LIMIT=NONE] -->
    <string name="accessibility_qs_edit_toggle_placement_mode">toggle placement mode</string>

    <!-- Accessibility description of action to toggle the QS tile selection. It will read as "Double-tap to toggle selection" in screen readers [CHAR LIMIT=NONE] -->
    <string name="accessibility_qs_edit_toggle_selection">toggle selection</string>

    <!-- Accessibility action of action to add QS tile to end. It will read as "Double-tap to add tile to the last position" in screen readers [CHAR LIMIT=NONE] -->
    <string name="accessibility_qs_edit_tile_add_action">add tile to the last position</string>

+91 −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.common.ui.compose.gestures

import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.waitForUpOrCancellation
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.pointer.PointerInputChange
import androidx.compose.ui.input.pointer.PointerInputScope
import kotlinx.coroutines.coroutineScope

/**
 * Detects taps and double taps without waiting for the double tap minimum delay in between
 *
 * Using [detectTapGestures] with both a single tap and a double tap defined will send only one of
 * these event per user interaction. This variant will send the single tap at all times, with the
 * optional double tap if the user pressed a second time in a short period of time.
 *
 * Warning: Use this only if you know that reporting a single tap followed by a double tap won't be
 * a problem in your use case.
 *
 * @param doubleTapEnabled whether this should listen for double tap events. This value is captured
 *   at the first down movement.
 * @param onDoubleTap the double tap callback
 * @param onTap the single tap callback
 */
suspend fun PointerInputScope.detectEagerTapGestures(
    doubleTapEnabled: () -> Boolean,
    onDoubleTap: (Offset) -> Unit,
    onTap: () -> Unit,
) = coroutineScope {
    awaitEachGesture {
        val down = awaitFirstDown()
        down.consume()

        // Capture whether double tap is enabled on first down as this state can change following
        // the first tap
        val isDoubleTapEnabled = doubleTapEnabled()

        // wait for first tap up or long press
        val upOrCancel = waitForUpOrCancellation()

        if (upOrCancel != null) {
            // tap was successful.
            upOrCancel.consume()
            onTap.invoke()

            if (isDoubleTapEnabled) {
                // check for second tap
                val secondDown =
                    withTimeoutOrNull(viewConfiguration.doubleTapTimeoutMillis) {
                        val minUptime =
                            upOrCancel.uptimeMillis + viewConfiguration.doubleTapMinTimeMillis
                        var change: PointerInputChange
                        // The second tap doesn't count if it happens before DoubleTapMinTime of the
                        // first tap
                        do {
                            change = awaitFirstDown()
                        } while (change.uptimeMillis < minUptime)
                        change
                    }

                if (secondDown != null) {
                    // Second tap down detected

                    // Might have a long second press as the second tap
                    val secondUp = waitForUpOrCancellation()
                    if (secondUp != null) {
                        secondUp.consume()
                        onDoubleTap(secondUp.position)
                    }
                }
            }
        }
    }
}
+8 −6
Original line number Diff line number Diff line
@@ -35,7 +35,6 @@ import androidx.compose.ui.draganddrop.mimeTypes
import androidx.compose.ui.draganddrop.toAndroidDragEvent
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.unit.IntRect
import androidx.compose.ui.unit.center
import androidx.compose.ui.unit.toRect
import com.android.systemui.qs.panels.shared.model.SizedTile
import com.android.systemui.qs.panels.ui.viewmodel.EditTileViewModel
@@ -44,6 +43,7 @@ import com.android.systemui.qs.pipeline.shared.TileSpec
/** Holds the [TileSpec] of the tile being moved and receives drag and drop events. */
interface DragAndDropState {
    val draggedCell: SizedTile<EditTileViewModel>?
    val isDraggedCellRemovable: Boolean
    val draggedPosition: Offset
    val dragInProgress: Boolean
    val dragType: DragType?
@@ -76,7 +76,7 @@ enum class DragType {
@Composable
fun Modifier.dragAndDropRemoveZone(
    dragAndDropState: DragAndDropState,
    onDrop: (TileSpec) -> Unit,
    onDrop: (TileSpec, removalEnabled: Boolean) -> Unit,
): Modifier {
    val target =
        remember(dragAndDropState) {
@@ -87,13 +87,15 @@ fun Modifier.dragAndDropRemoveZone(

                override fun onDrop(event: DragAndDropEvent): Boolean {
                    return dragAndDropState.draggedCell?.let {
                        onDrop(it.tile.tileSpec)
                        onDrop(it.tile.tileSpec, dragAndDropState.isDraggedCellRemovable)
                        dragAndDropState.onDrop()
                        true
                    } ?: false
                }

                override fun onEntered(event: DragAndDropEvent) {
                    if (!dragAndDropState.isDraggedCellRemovable) return

                    dragAndDropState.movedOutOfBounds()
                }
            }
@@ -168,10 +170,10 @@ private fun DragAndDropEvent.toOffset(): Offset {
}

private fun insertAfter(item: LazyGridItemInfo, offset: Offset): Boolean {
    // We want to insert the tile after the target if we're aiming at the right side of a large tile
    // We want to insert the tile after the target if we're aiming at the end of a large tile
    // TODO(ostonge): Verify this behavior in RTL
    val itemCenter = item.offset + item.size.center
    return item.span != 1 && offset.x > itemCenter.x
    val itemCenter = item.offset.x + item.size.width * .75
    return item.span != 1 && offset.x > itemCenter
}

@OptIn(ExperimentalFoundationApi::class)
Loading