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

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

Implement placement mode for QS edit page

Double tapping a tile will enter placement mode. This mode highlights the selected tile and greys out the rest. Tapping on any tile will move the selection to this position. This enables users to reposition tiles without dragging motion.

Test: manually
Test: EditModeTest
Test: EditTileListStateTest
Test: MutableSelectionStateTest
Fixes: 379116386
Flag: com.android.systemui.qs_ui_refactor_compose_fragment
Change-Id: I287d52e7f264379d547b6a45bed4b7795a8608fc
parent 5a411676
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)
                    }
                }
            }
        }
    }
}
+3 −4
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
@@ -171,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