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

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

Add undo feature to edit mode

This moves the reset button to the bottom of the Edit mode page.
It is replaced with a "Undo" button

Test: manually
Test: InfiniteGRidSnapshotViewModelTest
Test: EditModeTest
Fixes: 402816211
Fixes: 400652597
Flag: com.android.systemui.qs_ui_refactor_compose_fragment

Change-Id: Ic129a2aae0ca0b6066d1939a894d283302575494
parent 13601812
Loading
Loading
Loading
Loading
+154 −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.viewmodel

import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.SmallTest
import com.android.systemui.SysuiTestCase
import com.android.systemui.kosmos.Kosmos
import com.android.systemui.kosmos.runTest
import com.android.systemui.kosmos.useUnconfinedTestDispatcher
import com.android.systemui.qs.panels.domain.interactor.iconTilesInteractor
import com.android.systemui.qs.pipeline.domain.interactor.currentTilesInteractor
import com.android.systemui.qs.pipeline.shared.TileSpec
import com.android.systemui.testKosmos
import com.google.common.truth.Truth.assertThat
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith

@RunWith(AndroidJUnit4::class)
@SmallTest
class InfiniteGridSnapshotViewModelTest : SysuiTestCase() {
    private val kosmos = testKosmos().useUnconfinedTestDispatcher()
    private val Kosmos.underTest by Kosmos.Fixture { infiniteGridSnapshotViewModelFactory.create() }

    @Before
    fun setUp() =
        kosmos.run {
            currentTilesInteractor.setTiles(TestCurrentTiles)
            iconTilesInteractor.setLargeTiles(TestLargeTiles)
        }

    @Test
    fun undo_multipleTimes_restoresState() =
        kosmos.runTest {
            // Take multiple snapshots with changes in between
            for (i in 0..3) {
                underTest.takeSnapshot(
                    currentTilesInteractor.currentTilesSpecs,
                    iconTilesInteractor.largeTilesSpecs.value,
                )
                currentTilesInteractor.setTiles(listOf(TestCurrentTiles[i]))
                iconTilesInteractor.setLargeTiles(setOf(TestCurrentTiles[i]))
            }

            // Assert we can go back the stack of states with undo
            for (i in 3 downTo 0) {
                assertThat(currentTilesInteractor.currentTilesSpecs)
                    .containsExactly(TestCurrentTiles[i])
                assertThat(iconTilesInteractor.largeTilesSpecs.value)
                    .containsExactly(TestCurrentTiles[i])

                // Undo the change
                assertThat(underTest.canUndo).isTrue()
                underTest.undo()
            }

            // Assert that it is no longer possible to undo
            assertThat(underTest.canUndo).isFalse()
            // Assert that the tiles are back to the initial state
            assertThat(currentTilesInteractor.currentTilesSpecs)
                .containsExactlyElementsIn(TestCurrentTiles)
                .inOrder()
            // Assert that the large tiles are back to the initial state
            assertThat(iconTilesInteractor.largeTilesSpecs.value)
                .containsExactlyElementsIn(TestLargeTiles)
        }

    @Test
    fun undo_overMaximum_forgetsOlderSnapshots() =
        kosmos.runTest {
            // Set a simple initial state
            currentTilesInteractor.setTiles(listOf(TestCurrentTiles[0]))

            // Take snapshots more times than the maximum amount, with changes in between
            for (i in 1..<TestCurrentTiles.size) {
                underTest.takeSnapshot(
                    currentTilesInteractor.currentTilesSpecs,
                    iconTilesInteractor.largeTilesSpecs.value,
                )
                currentTilesInteractor.setTiles(listOf(TestCurrentTiles[i]))
            }

            // Undo until we exhausted the snapshots stack
            var undoCount = 0
            while (underTest.canUndo) {
                undoCount++
                underTest.undo()
            }

            // Assert we used undo the same amount of times as the maximum allowed
            assertThat(undoCount).isEqualTo(SNAPSHOTS_MAX_SIZE)
            // Assert that the tiles are NOT back to the initial state
            assertThat(currentTilesInteractor.currentTilesSpecs).doesNotContain(TestCurrentTiles[0])
        }

    @Test
    fun undo_onEmptyStack_isIgnored() =
        kosmos.runTest {
            // Apply a change
            currentTilesInteractor.setTiles(listOf(TestCurrentTiles[0]))

            // Attempt to undo without taking a snapshot
            underTest.undo()

            // Assert that nothing changed
            assertThat(currentTilesInteractor.currentTilesSpecs)
                .containsExactly(TestCurrentTiles[0])
        }

    @Test
    fun clearStack_forgetsOlderSnapshots() =
        kosmos.runTest {
            // Apply a change
            currentTilesInteractor.setTiles(listOf(TestCurrentTiles[0]))

            // Take a snapshot
            underTest.takeSnapshot(
                currentTilesInteractor.currentTilesSpecs,
                iconTilesInteractor.largeTilesSpecs.value,
            )

            // Assert that the snapshot is saved
            assertThat(underTest.canUndo).isTrue()

            // Clear the stack and attempt to undo
            underTest.clearStack()
            underTest.undo()

            // Assert that change was not reverted
            assertThat(currentTilesInteractor.currentTilesSpecs)
                .containsExactly(TestCurrentTiles[0])
        }

    private companion object {
        val SNAPSHOTS_MAX_SIZE = 10
        val TestCurrentTiles = buildList { repeat(15) { add(TileSpec.create("$it")) } }
        val TestLargeTiles = setOf(TestCurrentTiles[0], TestCurrentTiles[1])
    }
}
+108 −54
Original line number Diff line number Diff line
@@ -19,6 +19,7 @@
package com.android.systemui.qs.panels.ui.compose.infinitegrid

import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.LinearEasing
@@ -35,6 +36,7 @@ import androidx.compose.foundation.LocalOverscrollFactory
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.background
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
@@ -45,6 +47,7 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
@@ -65,6 +68,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.Undo
import androidx.compose.material.icons.filled.Add
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
@@ -159,6 +163,7 @@ 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.EditTileViewModel
import com.android.systemui.qs.panels.ui.viewmodel.InfiniteGridSnapshotViewModel
import com.android.systemui.qs.pipeline.shared.TileSpec
import com.android.systemui.qs.shared.model.TileCategory
import com.android.systemui.qs.shared.model.groupAndSort
@@ -167,15 +172,21 @@ import kotlin.math.abs
import kotlin.math.roundToInt
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch

object TileType

@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable
private fun EditModeTopBar(onStopEditing: () -> Unit, onReset: (() -> Unit)?) {
private fun EditModeTopBar(
    onStopEditing: () -> Unit,
    actions: @Composable RowScope.() -> Unit = {},
) {
    val surfaceEffect2 = LocalAndroidColorScheme.current.surfaceEffect2
    TopAppBar(
        colors =
@@ -203,62 +214,75 @@ private fun EditModeTopBar(onStopEditing: () -> Unit, onReset: (() -> Unit)?) {
                )
            }
        },
        actions = {
            if (onReset != null) {
                TextButton(
                    onClick = onReset,
                    colors =
                        ButtonDefaults.textButtonColors(
                            containerColor = MaterialTheme.colorScheme.primary,
                            contentColor = MaterialTheme.colorScheme.onPrimary,
                        ),
                ) {
                    Text(
                        text = stringResource(id = com.android.internal.R.string.reset),
                        style = MaterialTheme.typography.labelLarge,
                    )
                }
            }
        },
        actions = actions,
        modifier = Modifier.padding(vertical = 8.dp),
    )
}

sealed interface EditAction {
    data class AddTile(val tileSpec: TileSpec) : EditAction

    data class InsertTile(val tileSpec: TileSpec, val position: Int) : EditAction

    data class RemoveTile(val tileSpec: TileSpec) : EditAction

    data class SetTiles(val tileSpecs: List<TileSpec>) : EditAction

    data class ResizeTile(val tileSpec: TileSpec, val toIcon: Boolean) : EditAction

    data object ResetGrid : EditAction
}

@Composable
fun DefaultEditTileGrid(
    listState: EditTileListState,
    allTiles: List<EditTileViewModel>,
    modifier: Modifier,
    onAddTile: (TileSpec, Int) -> Unit,
    onRemoveTile: (TileSpec) -> Unit,
    onSetTiles: (List<TileSpec>) -> Unit,
    onResize: (TileSpec, toIcon: Boolean) -> Unit,
    snapshotViewModel: InfiniteGridSnapshotViewModel,
    onStopEditing: () -> Unit,
    onReset: (() -> Unit)?,
    onEditAction: (EditAction) -> Unit,
) {
    val selectionState = rememberSelectionState()
    val reset: (() -> Unit)? =
        if (onReset != null) {
            {
                selectionState.unSelect()
                onReset()
            }
        } else {
            null
        }

    LaunchedEffect(selectionState.placementEvent) {
        selectionState.placementEvent?.let { event ->
            listState
                .targetIndexForPlacement(event)
                .takeIf { it != INVALID_INDEX }
                ?.let { onAddTile(event.movingSpec, it) }
                ?.let { onEditAction(EditAction.InsertTile(event.movingSpec, it)) }
        }
    }

    Scaffold(
        containerColor = Color.Transparent,
        topBar = { EditModeTopBar(onStopEditing = onStopEditing, onReset = reset) },
        topBar = {
            EditModeTopBar(onStopEditing = onStopEditing) {
                AnimatedVisibility(snapshotViewModel.canUndo, enter = fadeIn(), exit = fadeOut()) {
                    TextButton(
                        enabled = snapshotViewModel.canUndo,
                        onClick = {
                            selectionState.unSelect()
                            snapshotViewModel.undo()
                        },
                        colors =
                            ButtonDefaults.textButtonColors(
                                containerColor = MaterialTheme.colorScheme.primary,
                                contentColor = MaterialTheme.colorScheme.onPrimary,
                            ),
                    ) {
                        Icon(
                            Icons.AutoMirrored.Default.Undo,
                            contentDescription = null,
                            modifier = Modifier.padding(end = 8.dp),
                        )
                        Text(
                            text = stringResource(id = com.android.internal.R.string.undo),
                            style = MaterialTheme.typography.labelLarge,
                        )
                    }
                }
            }
        },
    ) { innerPadding ->
        CompositionLocalProvider(
            LocalOverscrollFactory provides rememberOffsetOverscrollEffectFactory()
@@ -288,10 +312,10 @@ fun DefaultEditTileGrid(
                        .dragAndDropRemoveZone(listState) { spec, removalEnabled ->
                            if (removalEnabled) {
                                // If removal is enabled, remove the tile
                                onRemoveTile(spec)
                                onEditAction(EditAction.RemoveTile(spec))
                            } else {
                                // Otherwise submit the new tile ordering
                                onSetTiles(listState.tileSpecs())
                                onEditAction(EditAction.SetTiles(listState.tileSpecs()))
                                selectionState.select(spec)
                            }
                        },
@@ -302,7 +326,7 @@ fun DefaultEditTileGrid(
                    modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp),
                )

                CurrentTilesGrid(listState, selectionState, onResize, onRemoveTile, onSetTiles)
                CurrentTilesGrid(listState, selectionState, onEditAction)

                // Sets a minimum height to be used when available tiles are hidden
                Box(
@@ -325,6 +349,7 @@ fun DefaultEditTileGrid(
                    ) {
                        // Hide available tiles when dragging
                        Column(
                            horizontalAlignment = Alignment.CenterHorizontally,
                            verticalArrangement =
                                spacedBy(dimensionResource(id = R.dimen.qs_label_container_margin)),
                            modifier = modifier.fillMaxSize(),
@@ -333,9 +358,27 @@ fun DefaultEditTileGrid(
                                allTiles,
                                selectionState,
                                listState.columns,
                                { onAddTile(it, listState.tileSpecs().size) }, // Add to the end
                                { onEditAction(EditAction.AddTile(it)) }, // Add to the end
                                listState,
                            )

                            TextButton(
                                onClick = {
                                    selectionState.unSelect()
                                    onEditAction(EditAction.ResetGrid)
                                },
                                colors =
                                    ButtonDefaults.textButtonColors(
                                        containerColor = MaterialTheme.colorScheme.primary,
                                        contentColor = MaterialTheme.colorScheme.onPrimary,
                                    ),
                                modifier = Modifier.padding(vertical = 16.dp),
                            ) {
                                Text(
                                    text = stringResource(id = com.android.internal.R.string.reset),
                                    style = MaterialTheme.typography.labelLarge,
                                )
                            }
                        }
                    }
                }
@@ -465,9 +508,7 @@ private fun EditGridCenteredText(text: String, modifier: Modifier = Modifier) {
private fun CurrentTilesGrid(
    listState: EditTileListState,
    selectionState: MutableSelectionState,
    onResize: (TileSpec, toIcon: Boolean) -> Unit,
    onRemoveTile: (TileSpec) -> Unit,
    onSetTiles: (List<TileSpec>) -> Unit,
    onEditAction: (EditAction) -> Unit,
) {
    val currentListState by rememberUpdatedState(listState)
    val totalRows = listState.tiles.lastOrNull()?.row ?: 0
@@ -494,7 +535,7 @@ private fun CurrentTilesGrid(
                    shape = RoundedCornerShape(GridBackgroundCornerRadius),
                )
                .dragAndDropTileList(gridState, { gridContentOffset }, listState) { spec ->
                    onSetTiles(currentListState.tileSpecs())
                    onEditAction(EditAction.SetTiles(currentListState.tileSpecs()))
                    selectionState.select(spec)
                }
                .onGloballyPositioned { coordinates ->
@@ -514,7 +555,7 @@ private fun CurrentTilesGrid(
            selectionState = selectionState,
            gridState = gridState,
            coroutineScope = coroutineScope,
            onRemoveTile = onRemoveTile,
            onRemoveTile = { onEditAction(EditAction.RemoveTile(it)) },
        ) { resizingOperation ->
            when (resizingOperation) {
                is TemporaryResizeOperation -> {
@@ -522,7 +563,9 @@ private fun CurrentTilesGrid(
                }
                is FinalResizeOperation -> {
                    // Commit the new size of the tile
                    onResize(resizingOperation.spec, resizingOperation.toIcon)
                    onEditAction(
                        EditAction.ResizeTile(resizingOperation.spec, resizingOperation.toIcon)
                    )
                }
            }
        }
@@ -722,11 +765,15 @@ private fun TileGridCell(
    } else {
        // If the tile is selected, listen to new target values from the draggable anchor to toggle
        // the tile's size
        LaunchedEffect(resizingState.temporaryResizeOperation) {
            onResize(resizingState.temporaryResizeOperation)
        }
        LaunchedEffect(resizingState.finalResizeOperation) {
            onResize(resizingState.finalResizeOperation)
        LaunchedEffect(resizingState) {
            snapshotFlow { resizingState.temporaryResizeOperation }
                .drop(1) // Drop the initial state
                .onEach { onResize(it) }
                .launchIn(this)
            snapshotFlow { resizingState.finalResizeOperation }
                .drop(1) // Drop the initial state
                .onEach { onResize(it) }
                .launchIn(this)
        }
    }

@@ -882,7 +929,16 @@ private fun AvailableTileGridCell(
                        selectionState.unSelect()
                    }
                }
            Box(draggableModifier.fillMaxSize().tileBackground { colors.background }) {
            val onClick: () -> Unit = {
                onAddTile(cell.tileSpec)
                selectionState.select(cell.tileSpec)
            }
            Box(
                draggableModifier
                    .fillMaxSize()
                    .clickable(enabled = !cell.isCurrent, onClick = onClick)
                    .tileBackground { colors.background }
            ) {
                // Icon
                SmallTileContent(
                    iconProvider = { cell.icon },
@@ -897,10 +953,8 @@ private fun AvailableTileGridCell(
                contentDescription =
                    stringResource(id = R.string.accessibility_qs_edit_tile_add_action),
                enabled = !cell.isCurrent,
            ) {
                onAddTile(cell.tileSpec)
                selectionState.select(cell.tileSpec)
            }
                onClick = onClick,
            )
        }
        Box(Modifier.fillMaxSize()) {
            Text(
+39 −6
Original line number Diff line number Diff line
@@ -158,6 +158,17 @@ constructor(
            rememberViewModel(traceName = "InfiniteGridLayout.EditTileGrid") {
                viewModel.columnsWithMediaViewModelFactory.createWithoutMediaTracking()
            }
        val snapshotViewModel =
            rememberViewModel("InfiniteGridLayout.EditTileGrid") {
                viewModel.snapshotViewModelFactory.create()
            }
        val dialogDelegate =
            rememberViewModel("InfiniteGridLayout.EditTileGrid") {
                viewModel.resetDialogDelegateFactory.create {
                    // Clear the stack of snapshots on reset
                    snapshotViewModel.clearStack()
                }
            }
        val columns = columnsViewModel.columns
        val largeTilesSpan by iconTilesViewModel.largeTilesSpanState
        val largeTiles by iconTilesViewModel.largeTiles.collectAsStateWithLifecycle()
@@ -178,13 +189,35 @@ constructor(
            listState = listState,
            allTiles = tiles,
            modifier = modifier,
            onAddTile = onAddTile,
            onRemoveTile = onRemoveTile,
            onSetTiles = onSetTiles,
            onResize = iconTilesViewModel::resize,
            snapshotViewModel = snapshotViewModel,
            onStopEditing = onStopEditing,
            onReset = viewModel::showResetDialog,
        )
        ) { action ->
            // Opening the dialog doesn't require a snapshot
            if (action != EditAction.ResetGrid) {
                snapshotViewModel.takeSnapshot(currentTiles.map { it.tileSpec }, largeTiles)
            }

            when (action) {
                is EditAction.AddTile -> {
                    onAddTile(action.tileSpec, listState.tileSpecs().size)
                }
                is EditAction.InsertTile -> {
                    onAddTile(action.tileSpec, action.position)
                }
                is EditAction.RemoveTile -> {
                    onRemoveTile(action.tileSpec)
                }
                EditAction.ResetGrid -> {
                    dialogDelegate.showDialog()
                }
                is EditAction.ResizeTile -> {
                    iconTilesViewModel.resize(action.tileSpec, action.toIcon)
                }
                is EditAction.SetTiles -> {
                    onSetTiles(action.tileSpecs)
                }
            }
        }
    }

    override fun splitIntoPages(
+11 −4
Original line number Diff line number Diff line
@@ -24,7 +24,6 @@ import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import com.android.compose.PlatformButton
import com.android.compose.PlatformTextButton
import com.android.systemui.dagger.SysUISingleton
import com.android.systemui.dialog.ui.composable.AlertDialogContent
import com.android.systemui.qs.panels.domain.interactor.EditTilesResetInteractor
import com.android.systemui.res.R
@@ -34,15 +33,17 @@ import com.android.systemui.statusbar.phone.SystemUIDialog
import com.android.systemui.statusbar.phone.SystemUIDialogFactory
import com.android.systemui.statusbar.phone.create
import com.android.systemui.util.Assert
import javax.inject.Inject
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject

@SysUISingleton
class QSResetDialogDelegate
@Inject
@AssistedInject
constructor(
    private val sysuiDialogFactory: SystemUIDialogFactory,
    private val shadeDialogContextInteractor: ShadeDialogContextInteractor,
    private val resetInteractor: EditTilesResetInteractor,
    @Assisted private val onReset: () -> Unit,
) : SystemUIDialog.Delegate {
    private var currentDialog: ComponentSystemUIDialog? = null

@@ -82,6 +83,7 @@ constructor(
                PlatformButton(
                    onClick = {
                        dialog.dismiss()
                        onReset()
                        resetInteractor.reset()
                    }
                ) {
@@ -103,6 +105,11 @@ constructor(
        currentDialog?.show()
    }

    @AssistedFactory
    interface Factory {
        fun create(onReset: () -> Unit): QSResetDialogDelegate
    }

    companion object {
        private const val TAG = "ResetDialogDelegate"
    }
+80 −0

File added.

Preview size limit exceeded, changes collapsed.

Loading