diff options
| author | 2024-05-16 17:02:45 +0000 | |
|---|---|---|
| committer | 2024-05-16 17:02:45 +0000 | |
| commit | cd3940849d3da702ba570b86bb50c12879781a26 (patch) | |
| tree | 92036a40e0cc2a54063e4157ade0659ff035c89e | |
| parent | daf6106ec91f90f61f2591cf1ff6e36f8043dd85 (diff) | |
| parent | 34dffcd66ba5b64874d1425662eb71cd1c8a33df (diff) | |
Merge "Implement stretch option for QS tiles" into main
11 files changed, 683 insertions, 422 deletions
diff --git a/packages/SystemUI/src/com/android/systemui/qs/panels/dagger/PanelsModule.kt b/packages/SystemUI/src/com/android/systemui/qs/panels/dagger/PanelsModule.kt index 0696fbe996c0..2cc3985a88ad 100644 --- a/packages/SystemUI/src/com/android/systemui/qs/panels/dagger/PanelsModule.kt +++ b/packages/SystemUI/src/com/android/systemui/qs/panels/dagger/PanelsModule.kt @@ -29,8 +29,10 @@ import com.android.systemui.qs.panels.domain.interactor.NoopGridConsistencyInter import com.android.systemui.qs.panels.shared.model.GridConsistencyLog import com.android.systemui.qs.panels.shared.model.GridLayoutType import com.android.systemui.qs.panels.shared.model.InfiniteGridLayoutType +import com.android.systemui.qs.panels.shared.model.StretchedGridLayoutType import com.android.systemui.qs.panels.ui.compose.GridLayout import com.android.systemui.qs.panels.ui.compose.InfiniteGridLayout +import com.android.systemui.qs.panels.ui.compose.StretchedGridLayout import dagger.Binds import dagger.Module import dagger.Provides @@ -63,6 +65,14 @@ interface PanelsModule { } @Provides + @IntoSet + fun provideStretchedGridLayout( + gridLayout: StretchedGridLayout + ): Pair<GridLayoutType, GridLayout> { + return Pair(StretchedGridLayoutType, gridLayout) + } + + @Provides fun provideGridLayoutMap( entries: Set<@JvmSuppressWildcards Pair<GridLayoutType, GridLayout>> ): Map<GridLayoutType, GridLayout> { @@ -70,6 +80,13 @@ interface PanelsModule { } @Provides + fun provideGridLayoutTypes( + entries: Set<@JvmSuppressWildcards Pair<GridLayoutType, GridLayout>> + ): Set<GridLayoutType> { + return entries.map { it.first }.toSet() + } + + @Provides @IntoSet fun provideGridConsistencyInteractor( consistencyInteractor: InfiniteGridConsistencyInteractor @@ -78,6 +95,14 @@ interface PanelsModule { } @Provides + @IntoSet + fun provideStretchedGridConsistencyInteractor( + consistencyInteractor: NoopGridConsistencyInteractor + ): Pair<GridLayoutType, GridTypeConsistencyInteractor> { + return Pair(StretchedGridLayoutType, consistencyInteractor) + } + + @Provides fun provideGridConsistencyInteractorMap( entries: Set<@JvmSuppressWildcards Pair<GridLayoutType, GridTypeConsistencyInteractor>> ): Map<GridLayoutType, GridTypeConsistencyInteractor> { diff --git a/packages/SystemUI/src/com/android/systemui/qs/panels/data/repository/GridLayoutTypeRepository.kt b/packages/SystemUI/src/com/android/systemui/qs/panels/data/repository/GridLayoutTypeRepository.kt index 542d0cbc425e..31795d59bb2b 100644 --- a/packages/SystemUI/src/com/android/systemui/qs/panels/data/repository/GridLayoutTypeRepository.kt +++ b/packages/SystemUI/src/com/android/systemui/qs/panels/data/repository/GridLayoutTypeRepository.kt @@ -26,10 +26,17 @@ import kotlinx.coroutines.flow.asStateFlow interface GridLayoutTypeRepository { val layout: StateFlow<GridLayoutType> + fun setLayout(type: GridLayoutType) } @SysUISingleton class GridLayoutTypeRepositoryImpl @Inject constructor() : GridLayoutTypeRepository { private val _layout: MutableStateFlow<GridLayoutType> = MutableStateFlow(InfiniteGridLayoutType) override val layout = _layout.asStateFlow() + + override fun setLayout(type: GridLayoutType) { + if (_layout.value != type) { + _layout.value = type + } + } } diff --git a/packages/SystemUI/src/com/android/systemui/qs/panels/domain/interactor/GridLayoutTypeInteractor.kt b/packages/SystemUI/src/com/android/systemui/qs/panels/domain/interactor/GridLayoutTypeInteractor.kt index b6be5780bb60..4af1b2223c4c 100644 --- a/packages/SystemUI/src/com/android/systemui/qs/panels/domain/interactor/GridLayoutTypeInteractor.kt +++ b/packages/SystemUI/src/com/android/systemui/qs/panels/domain/interactor/GridLayoutTypeInteractor.kt @@ -20,9 +20,13 @@ import com.android.systemui.dagger.SysUISingleton import com.android.systemui.qs.panels.data.repository.GridLayoutTypeRepository import com.android.systemui.qs.panels.shared.model.GridLayoutType import javax.inject.Inject -import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow @SysUISingleton -class GridLayoutTypeInteractor @Inject constructor(repo: GridLayoutTypeRepository) { - val layout: Flow<GridLayoutType> = repo.layout +class GridLayoutTypeInteractor @Inject constructor(private val repo: GridLayoutTypeRepository) { + val layout: StateFlow<GridLayoutType> = repo.layout + + fun setLayoutType(type: GridLayoutType) { + repo.setLayout(type) + } } diff --git a/packages/SystemUI/src/com/android/systemui/qs/panels/domain/interactor/InfiniteGridConsistencyInteractor.kt b/packages/SystemUI/src/com/android/systemui/qs/panels/domain/interactor/InfiniteGridConsistencyInteractor.kt index 74e906c621cb..b437f645d4bc 100644 --- a/packages/SystemUI/src/com/android/systemui/qs/panels/domain/interactor/InfiniteGridConsistencyInteractor.kt +++ b/packages/SystemUI/src/com/android/systemui/qs/panels/domain/interactor/InfiniteGridConsistencyInteractor.kt @@ -18,6 +18,8 @@ package com.android.systemui.qs.panels.domain.interactor import android.util.Log import com.android.systemui.dagger.SysUISingleton +import com.android.systemui.qs.panels.shared.model.SizedTile +import com.android.systemui.qs.panels.shared.model.TileRow import com.android.systemui.qs.pipeline.shared.TileSpec import javax.inject.Inject @@ -35,7 +37,7 @@ constructor( */ override fun reconcileTiles(tiles: List<TileSpec>): List<TileSpec> { val newTiles: MutableList<TileSpec> = mutableListOf() - val row = TileRow(columns = gridSizeInteractor.columns.value) + val row = TileRow<TileSpec>(columns = gridSizeInteractor.columns.value) val iconTilesSet = iconTilesInteractor.iconTilesSpecs.value val tilesQueue = ArrayDeque( @@ -54,7 +56,7 @@ constructor( while (tilesQueue.isNotEmpty()) { if (row.isFull()) { - newTiles.addAll(row.tileSpecs()) + newTiles.addAll(row.tiles.map { it.tile }) row.clear() } @@ -66,13 +68,13 @@ constructor( // We'll try to either add an icon tile from the queue to complete the row, or // remove an icon tile from the current row to free up space. - val iconTile: SizedTile? = tilesQueue.firstOrNull { it.width == 1 } + val iconTile: SizedTile<TileSpec>? = tilesQueue.firstOrNull { it.width == 1 } if (iconTile != null) { tilesQueue.remove(iconTile) tilesQueue.addFirst(tile) row.maybeAddTile(iconTile) } else { - val tileToRemove: SizedTile? = row.findLastIconTile() + val tileToRemove: SizedTile<TileSpec>? = row.findLastIconTile() if (tileToRemove != null) { row.removeTile(tileToRemove) row.maybeAddTile(tile) @@ -84,7 +86,7 @@ constructor( // If the row does not have an icon tile, add the incomplete row. // Note: this shouldn't happen because an icon tile is guaranteed to be in a // row that doesn't have enough space for a large tile. - val tileSpecs = row.tileSpecs() + val tileSpecs = row.tiles.map { it.tile } Log.wtf(TAG, "Uneven row does not have an icon tile to remove: $tileSpecs") newTiles.addAll(tileSpecs) row.clear() @@ -95,48 +97,11 @@ constructor( } // Add last row that might be incomplete - newTiles.addAll(row.tileSpecs()) + newTiles.addAll(row.tiles.map { it.tile }) return newTiles.toList() } - /** Tile with a width representing the number of columns it should take. */ - private data class SizedTile(val spec: TileSpec, val width: Int) - - private class TileRow(private val columns: Int) { - private var availableColumns = columns - private val tiles: MutableList<SizedTile> = mutableListOf() - - fun tileSpecs(): List<TileSpec> { - return tiles.map { it.spec } - } - - fun maybeAddTile(tile: SizedTile): Boolean { - if (availableColumns - tile.width >= 0) { - tiles.add(tile) - availableColumns -= tile.width - return true - } - return false - } - - fun findLastIconTile(): SizedTile? { - return tiles.findLast { it.width == 1 } - } - - fun removeTile(tile: SizedTile) { - tiles.remove(tile) - availableColumns += tile.width - } - - fun clear() { - tiles.clear() - availableColumns = columns - } - - fun isFull(): Boolean = availableColumns == 0 - } - private companion object { const val TAG = "InfiniteGridConsistencyInteractor" } diff --git a/packages/SystemUI/src/com/android/systemui/qs/panels/domain/interactor/NoopConsistencyInteractor.kt b/packages/SystemUI/src/com/android/systemui/qs/panels/domain/interactor/NoopConsistencyInteractor.kt new file mode 100644 index 000000000000..97ceacc6926d --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/qs/panels/domain/interactor/NoopConsistencyInteractor.kt @@ -0,0 +1,26 @@ +/* + * Copyright (C) 2024 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.domain.interactor + +import com.android.systemui.dagger.SysUISingleton +import com.android.systemui.qs.pipeline.shared.TileSpec +import javax.inject.Inject + +@SysUISingleton +class NoopConsistencyInteractor @Inject constructor() : GridTypeConsistencyInteractor { + override fun reconcileTiles(tiles: List<TileSpec>): List<TileSpec> = tiles +} diff --git a/packages/SystemUI/src/com/android/systemui/qs/panels/shared/model/GridLayoutType.kt b/packages/SystemUI/src/com/android/systemui/qs/panels/shared/model/GridLayoutType.kt index 23110dcaa560..501730a7c8a3 100644 --- a/packages/SystemUI/src/com/android/systemui/qs/panels/shared/model/GridLayoutType.kt +++ b/packages/SystemUI/src/com/android/systemui/qs/panels/shared/model/GridLayoutType.kt @@ -25,3 +25,9 @@ interface GridLayoutType /** Grid type representing a scrollable vertical grid. */ data object InfiniteGridLayoutType : GridLayoutType + +/** + * Grid type representing a scrollable vertical grid where tiles will stretch to fill in empty + * spaces. + */ +data object StretchedGridLayoutType : GridLayoutType diff --git a/packages/SystemUI/src/com/android/systemui/qs/panels/shared/model/TileRow.kt b/packages/SystemUI/src/com/android/systemui/qs/panels/shared/model/TileRow.kt new file mode 100644 index 000000000000..7e4381bbff03 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/qs/panels/shared/model/TileRow.kt @@ -0,0 +1,53 @@ +/* + * Copyright (C) 2024 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.shared.model + +/** Represents a tile of type [T] associated with a width */ +data class SizedTile<T>(val tile: T, val width: Int) + +/** Represents a row of [SizedTile] with a maximum width of [columns] */ +class TileRow<T>(private val columns: Int) { + private var availableColumns = columns + private val _tiles: MutableList<SizedTile<T>> = mutableListOf() + val tiles: List<SizedTile<T>> + get() = _tiles.toList() + + fun maybeAddTile(tile: SizedTile<T>): Boolean { + if (availableColumns - tile.width >= 0) { + _tiles.add(tile) + availableColumns -= tile.width + return true + } + return false + } + + fun findLastIconTile(): SizedTile<T>? { + return _tiles.findLast { it.width == 1 } + } + + fun removeTile(tile: SizedTile<T>) { + _tiles.remove(tile) + availableColumns += tile.width + } + + fun clear() { + _tiles.clear() + availableColumns = columns + } + + fun isFull(): Boolean = availableColumns == 0 +} diff --git a/packages/SystemUI/src/com/android/systemui/qs/panels/ui/compose/InfiniteGridLayout.kt b/packages/SystemUI/src/com/android/systemui/qs/panels/ui/compose/InfiniteGridLayout.kt index bac0f604e294..f5ee720faff6 100644 --- a/packages/SystemUI/src/com/android/systemui/qs/panels/ui/compose/InfiniteGridLayout.kt +++ b/packages/SystemUI/src/com/android/systemui/qs/panels/ui/compose/InfiniteGridLayout.kt @@ -16,85 +16,23 @@ package com.android.systemui.qs.panels.ui.compose -import android.graphics.drawable.Animatable -import android.text.TextUtils -import androidx.appcompat.content.res.AppCompatResources -import androidx.compose.animation.graphics.ExperimentalAnimationGraphicsApi -import androidx.compose.animation.graphics.res.animatedVectorResource -import androidx.compose.animation.graphics.res.rememberAnimatedVectorPainter -import androidx.compose.animation.graphics.vector.AnimatedImageVector -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.basicMarquee -import androidx.compose.foundation.clickable -import androidx.compose.foundation.combinedClickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Arrangement.spacedBy -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridItemSpan -import androidx.compose.foundation.lazy.grid.LazyGridScope -import androidx.compose.foundation.lazy.grid.LazyVerticalGrid -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.Remove -import androidx.compose.material3.Icon -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberUpdatedState -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.ColorFilter -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.dimensionResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.onClick -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.semantics.stateDescription -import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.android.compose.animation.Expandable -import com.android.compose.theme.colorAttr -import com.android.systemui.common.shared.model.Icon -import com.android.systemui.common.ui.compose.Icon -import com.android.systemui.common.ui.compose.load import com.android.systemui.dagger.SysUISingleton import com.android.systemui.qs.panels.domain.interactor.IconTilesInteractor import com.android.systemui.qs.panels.domain.interactor.InfiniteGridSizeInteractor -import com.android.systemui.qs.panels.ui.viewmodel.ActiveTileColorAttributes -import com.android.systemui.qs.panels.ui.viewmodel.AvailableEditActions import com.android.systemui.qs.panels.ui.viewmodel.EditTileViewModel -import com.android.systemui.qs.panels.ui.viewmodel.TileColorAttributes -import com.android.systemui.qs.panels.ui.viewmodel.TileUiState import com.android.systemui.qs.panels.ui.viewmodel.TileViewModel -import com.android.systemui.qs.panels.ui.viewmodel.toUiState -import com.android.systemui.qs.pipeline.domain.interactor.CurrentTilesInteractor.Companion.POSITION_AT_END import com.android.systemui.qs.pipeline.shared.TileSpec -import com.android.systemui.qs.tileimpl.QSTileImpl import com.android.systemui.res.R import javax.inject.Inject -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.mapLatest @SysUISingleton class InfiniteGridLayout @@ -104,8 +42,6 @@ constructor( private val gridSizeInteractor: InfiniteGridSizeInteractor ) : GridLayout { - private object TileType - @Composable override fun TileGrid( tiles: List<TileViewModel>, @@ -140,55 +76,6 @@ constructor( } } - @OptIn(ExperimentalCoroutinesApi::class, ExperimentalFoundationApi::class) - @Composable - private fun Tile( - tile: TileViewModel, - iconOnly: Boolean, - modifier: Modifier, - ) { - val state: TileUiState by - tile.state - .mapLatest { it.toUiState() } - .collectAsStateWithLifecycle(initialValue = tile.currentState.toUiState()) - val context = LocalContext.current - - Expandable( - color = colorAttr(state.colors.background), - shape = RoundedCornerShape(dimensionResource(R.dimen.qs_corner_radius)), - ) { - Row( - modifier = - modifier - .combinedClickable( - onClick = { tile.onClick(it) }, - onLongClick = { tile.onLongClick(it) } - ) - .tileModifier(state.colors), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = tileHorizontalArrangement(iconOnly), - ) { - val icon = - remember(state.icon) { - state.icon.get().let { - if (it is QSTileImpl.ResourceIcon) { - Icon.Resource(it.resId, null) - } else { - Icon.Loaded(it.getDrawable(context), null) - } - } - } - TileContent( - label = state.label.toString(), - secondaryLabel = state.secondaryLabel?.toString(), - icon = icon, - colors = state.colors, - iconOnly = iconOnly - ) - } - } - } - @Composable override fun EditTileGrid( tiles: List<EditTileViewModel>, @@ -196,262 +83,16 @@ constructor( onAddTile: (TileSpec, Int) -> Unit, onRemoveTile: (TileSpec) -> Unit, ) { - val (currentTiles, otherTiles) = tiles.partition { it.isCurrent } - val (otherTilesStock, otherTilesCustom) = otherTiles.partition { it.appName == null } - val addTileToEnd: (TileSpec) -> Unit by rememberUpdatedState { - onAddTile(it, POSITION_AT_END) - } - val iconOnlySpecs by - iconTilesInteractor.iconTilesSpecs.collectAsStateWithLifecycle( - initialValue = emptySet() - ) - val isIconOnly: (TileSpec) -> Boolean = - remember(iconOnlySpecs) { { tileSpec: TileSpec -> tileSpec in iconOnlySpecs } } + val iconOnlySpecs by iconTilesInteractor.iconTilesSpecs.collectAsStateWithLifecycle() val columns by gridSizeInteractor.columns.collectAsStateWithLifecycle() - TileLazyGrid(modifier = modifier, columns = GridCells.Fixed(columns)) { - // These Text are just placeholders to see the different sections. Not final UI. - item(span = { GridItemSpan(maxLineSpan) }) { - Text("Current tiles", color = Color.White) - } - - editTiles( - currentTiles, - ClickAction.REMOVE, - onRemoveTile, - isIconOnly, - indicatePosition = true, - ) - - item(span = { GridItemSpan(maxLineSpan) }) { Text("Tiles to add", color = Color.White) } - - editTiles( - otherTilesStock, - ClickAction.ADD, - addTileToEnd, - isIconOnly, - ) - - item(span = { GridItemSpan(maxLineSpan) }) { - Text("Custom tiles to add", color = Color.White) - } - - editTiles( - otherTilesCustom, - ClickAction.ADD, - addTileToEnd, - isIconOnly, - ) - } - } - - private fun LazyGridScope.editTiles( - tiles: List<EditTileViewModel>, - clickAction: ClickAction, - onClick: (TileSpec) -> Unit, - isIconOnly: (TileSpec) -> Boolean, - indicatePosition: Boolean = false, - ) { - items( - count = tiles.size, - key = { tiles[it].tileSpec.spec }, - span = { GridItemSpan(if (isIconOnly(tiles[it].tileSpec)) 1 else 2) }, - contentType = { TileType } - ) { - val viewModel = tiles[it] - val canClick = - when (clickAction) { - ClickAction.ADD -> AvailableEditActions.ADD in viewModel.availableEditActions - ClickAction.REMOVE -> - AvailableEditActions.REMOVE in viewModel.availableEditActions - } - val onClickActionName = - when (clickAction) { - ClickAction.ADD -> - stringResource(id = R.string.accessibility_qs_edit_tile_add_action) - ClickAction.REMOVE -> - stringResource(id = R.string.accessibility_qs_edit_remove_tile_action) - } - val stateDescription = - if (indicatePosition) { - stringResource(id = R.string.accessibility_qs_edit_position, it + 1) - } else { - "" - } - - Box( - modifier = - Modifier.clickable(enabled = canClick) { onClick.invoke(viewModel.tileSpec) } - .animateItem() - .semantics { - onClick(onClickActionName) { false } - this.stateDescription = stateDescription - } - ) { - EditTile( - tileViewModel = viewModel, - isIconOnly(viewModel.tileSpec), - modifier = Modifier.height(dimensionResource(id = R.dimen.qs_tile_height)) - ) - if (canClick) { - Badge(clickAction, Modifier.align(Alignment.TopEnd)) - } - } - } - } - - @Composable - private fun Badge(action: ClickAction, modifier: Modifier = Modifier) { - Box(modifier = modifier.size(16.dp).background(Color.Cyan, shape = CircleShape)) { - Icon( - imageVector = - when (action) { - ClickAction.ADD -> Icons.Filled.Add - ClickAction.REMOVE -> Icons.Filled.Remove - }, - "", - tint = Color.Black, - ) - } - } - - @Composable - private fun EditTile( - tileViewModel: EditTileViewModel, - iconOnly: Boolean, - modifier: Modifier = Modifier, - ) { - val label = tileViewModel.label.load() ?: tileViewModel.tileSpec.spec - val colors = ActiveTileColorAttributes - - Row( - modifier = modifier.tileModifier(colors).semantics { this.contentDescription = label }, - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = tileHorizontalArrangement(iconOnly) - ) { - TileContent( - label = label, - secondaryLabel = tileViewModel.appName?.load(), - colors = colors, - icon = tileViewModel.icon, - iconOnly = iconOnly, - animateIconToEnd = true, - ) - } - } - - private enum class ClickAction { - ADD, - REMOVE, - } -} - -@OptIn(ExperimentalAnimationGraphicsApi::class) -@Composable -private fun TileIcon( - icon: Icon, - color: Color, - animateToEnd: Boolean = false, -) { - val modifier = Modifier.size(dimensionResource(id = R.dimen.qs_icon_size)) - val context = LocalContext.current - val loadedDrawable = - remember(icon, context) { - when (icon) { - is Icon.Loaded -> icon.drawable - is Icon.Resource -> AppCompatResources.getDrawable(context, icon.res) - } - } - if (loadedDrawable !is Animatable) { - Icon( - icon = icon, - tint = color, + DefaultEditTileGrid( + tiles = tiles, + iconOnlySpecs = iconOnlySpecs, + columns = GridCells.Fixed(columns), modifier = modifier, + onAddTile = onAddTile, + onRemoveTile = onRemoveTile, ) - } else if (icon is Icon.Resource) { - val image = AnimatedImageVector.animatedVectorResource(id = icon.res) - val painter = - if (animateToEnd) { - rememberAnimatedVectorPainter(animatedImageVector = image, atEnd = true) - } else { - var atEnd by remember(icon.res) { mutableStateOf(false) } - LaunchedEffect(key1 = icon.res) { - delay(350) - atEnd = true - } - rememberAnimatedVectorPainter(animatedImageVector = image, atEnd = atEnd) - } - Image( - painter = painter, - contentDescription = null, - colorFilter = ColorFilter.tint(color = color), - modifier = modifier - ) - } -} - -@Composable -private fun TileLazyGrid( - modifier: Modifier = Modifier, - columns: GridCells, - content: LazyGridScope.() -> Unit, -) { - LazyVerticalGrid( - columns = columns, - verticalArrangement = spacedBy(dimensionResource(R.dimen.qs_tile_margin_vertical)), - horizontalArrangement = spacedBy(dimensionResource(R.dimen.qs_tile_margin_horizontal)), - modifier = modifier, - content = content, - ) -} - -@Composable -private fun Modifier.tileModifier(colors: TileColorAttributes): Modifier { - return fillMaxWidth() - .clip(RoundedCornerShape(dimensionResource(R.dimen.qs_corner_radius))) - .background(colorAttr(colors.background)) - .padding(horizontal = dimensionResource(id = R.dimen.qs_label_container_margin)) -} - -@Composable -private fun tileHorizontalArrangement(iconOnly: Boolean): Arrangement.Horizontal { - val horizontalAlignment = - if (iconOnly) { - Alignment.CenterHorizontally - } else { - Alignment.Start - } - return spacedBy( - space = dimensionResource(id = R.dimen.qs_label_container_margin), - alignment = horizontalAlignment - ) -} - -@Composable -private fun TileContent( - label: String, - secondaryLabel: String?, - icon: Icon, - colors: TileColorAttributes, - iconOnly: Boolean, - animateIconToEnd: Boolean = false, -) { - TileIcon(icon, colorAttr(colors.icon), animateIconToEnd) - - if (!iconOnly) { - Column(verticalArrangement = Arrangement.Center, modifier = Modifier.fillMaxHeight()) { - Text( - label, - color = colorAttr(colors.label), - modifier = Modifier.basicMarquee(), - ) - if (!TextUtils.isEmpty(secondaryLabel)) { - Text( - secondaryLabel ?: "", - color = colorAttr(colors.secondaryLabel), - modifier = Modifier.basicMarquee(), - ) - } - } } } diff --git a/packages/SystemUI/src/com/android/systemui/qs/panels/ui/compose/StretchedGridLayout.kt b/packages/SystemUI/src/com/android/systemui/qs/panels/ui/compose/StretchedGridLayout.kt new file mode 100644 index 000000000000..ddd97c2e8944 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/qs/panels/ui/compose/StretchedGridLayout.kt @@ -0,0 +1,139 @@ +/* + * Copyright (C) 2024 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.foundation.layout.height +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.GridItemSpan +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.dimensionResource +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.android.systemui.dagger.SysUISingleton +import com.android.systemui.qs.panels.domain.interactor.IconTilesInteractor +import com.android.systemui.qs.panels.domain.interactor.InfiniteGridSizeInteractor +import com.android.systemui.qs.panels.shared.model.SizedTile +import com.android.systemui.qs.panels.shared.model.TileRow +import com.android.systemui.qs.panels.ui.viewmodel.EditTileViewModel +import com.android.systemui.qs.panels.ui.viewmodel.TileViewModel +import com.android.systemui.qs.pipeline.shared.TileSpec +import com.android.systemui.res.R +import javax.inject.Inject + +@SysUISingleton +class StretchedGridLayout +@Inject +constructor( + private val iconTilesInteractor: IconTilesInteractor, + private val gridSizeInteractor: InfiniteGridSizeInteractor, +) : GridLayout { + + @Composable + override fun TileGrid( + tiles: List<TileViewModel>, + modifier: Modifier, + ) { + DisposableEffect(tiles) { + val token = Any() + tiles.forEach { it.startListening(token) } + onDispose { tiles.forEach { it.stopListening(token) } } + } + + // Tile widths [normal|stretched] + // Icon [3 | 4] + // Large [6 | 8] + val columns = 12 + val iconTilesSpecs by iconTilesInteractor.iconTilesSpecs.collectAsStateWithLifecycle() + val stretchedTiles = + remember(tiles) { + val sizedTiles = + tiles.map { + SizedTile( + it, + if (iconTilesSpecs.contains(it.spec)) { + 3 + } else { + 6 + } + ) + } + splitInRows(sizedTiles, columns) + } + + TileLazyGrid(columns = GridCells.Fixed(columns), modifier = modifier) { + items(stretchedTiles.size, span = { GridItemSpan(stretchedTiles[it].width) }) { index -> + Tile( + stretchedTiles[index].tile, + iconTilesSpecs.contains(stretchedTiles[index].tile.spec), + Modifier.height(dimensionResource(id = R.dimen.qs_tile_height)) + ) + } + } + } + + @Composable + override fun EditTileGrid( + tiles: List<EditTileViewModel>, + modifier: Modifier, + onAddTile: (TileSpec, Int) -> Unit, + onRemoveTile: (TileSpec) -> Unit + ) { + val iconOnlySpecs by iconTilesInteractor.iconTilesSpecs.collectAsStateWithLifecycle() + val columns by gridSizeInteractor.columns.collectAsStateWithLifecycle() + + DefaultEditTileGrid( + tiles = tiles, + iconOnlySpecs = iconOnlySpecs, + columns = GridCells.Fixed(columns), + modifier = modifier, + onAddTile = onAddTile, + onRemoveTile = onRemoveTile, + ) + } + + private fun splitInRows( + tiles: List<SizedTile<TileViewModel>>, + columns: Int + ): List<SizedTile<TileViewModel>> { + val row = TileRow<TileViewModel>(columns) + + return buildList { + for (tile in tiles) { + if (row.maybeAddTile(tile)) { + if (row.isFull()) { + // Row is full, no need to stretch tiles + addAll(row.tiles) + row.clear() + } + } else { + if (row.isFull()) { + addAll(row.tiles) + } else { + // Stretching tiles when row isn't full + addAll(row.tiles.map { it.copy(width = it.width + (it.width / 3)) }) + } + row.clear() + row.maybeAddTile(tile) + } + } + addAll(row.tiles) + } + } +} diff --git a/packages/SystemUI/src/com/android/systemui/qs/panels/ui/compose/Tile.kt b/packages/SystemUI/src/com/android/systemui/qs/panels/ui/compose/Tile.kt new file mode 100644 index 000000000000..eb45110533a4 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/qs/panels/ui/compose/Tile.kt @@ -0,0 +1,403 @@ +/* + * Copyright (C) 2024 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 android.graphics.drawable.Animatable +import android.text.TextUtils +import androidx.appcompat.content.res.AppCompatResources +import androidx.compose.animation.graphics.ExperimentalAnimationGraphicsApi +import androidx.compose.animation.graphics.res.animatedVectorResource +import androidx.compose.animation.graphics.res.rememberAnimatedVectorPainter +import androidx.compose.animation.graphics.vector.AnimatedImageVector +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.basicMarquee +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Arrangement.spacedBy +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.GridItemSpan +import androidx.compose.foundation.lazy.grid.LazyGridScope +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Remove +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.onClick +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.android.compose.animation.Expandable +import com.android.compose.theme.colorAttr +import com.android.systemui.common.shared.model.Icon +import com.android.systemui.common.ui.compose.Icon +import com.android.systemui.common.ui.compose.load +import com.android.systemui.qs.panels.ui.viewmodel.ActiveTileColorAttributes +import com.android.systemui.qs.panels.ui.viewmodel.AvailableEditActions +import com.android.systemui.qs.panels.ui.viewmodel.EditTileViewModel +import com.android.systemui.qs.panels.ui.viewmodel.TileColorAttributes +import com.android.systemui.qs.panels.ui.viewmodel.TileUiState +import com.android.systemui.qs.panels.ui.viewmodel.TileViewModel +import com.android.systemui.qs.panels.ui.viewmodel.toUiState +import com.android.systemui.qs.pipeline.domain.interactor.CurrentTilesInteractor +import com.android.systemui.qs.pipeline.shared.TileSpec +import com.android.systemui.qs.tileimpl.QSTileImpl +import com.android.systemui.res.R +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.mapLatest + +object TileType + +@OptIn(ExperimentalCoroutinesApi::class, ExperimentalFoundationApi::class) +@Composable +fun Tile( + tile: TileViewModel, + iconOnly: Boolean, + modifier: Modifier, +) { + val state: TileUiState by + tile.state + .mapLatest { it.toUiState() } + .collectAsStateWithLifecycle(tile.currentState.toUiState()) + val context = LocalContext.current + + Expandable( + color = colorAttr(state.colors.background), + shape = RoundedCornerShape(dimensionResource(R.dimen.qs_corner_radius)), + ) { + Row( + modifier = + modifier + .combinedClickable( + onClick = { tile.onClick(it) }, + onLongClick = { tile.onLongClick(it) } + ) + .tileModifier(state.colors), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = tileHorizontalArrangement(iconOnly), + ) { + val icon = + remember(state.icon) { + state.icon.get().let { + if (it is QSTileImpl.ResourceIcon) { + Icon.Resource(it.resId, null) + } else { + Icon.Loaded(it.getDrawable(context), null) + } + } + } + TileContent( + label = state.label.toString(), + secondaryLabel = state.secondaryLabel.toString(), + icon = icon, + colors = state.colors, + iconOnly = iconOnly + ) + } + } +} + +@Composable +fun TileLazyGrid( + modifier: Modifier = Modifier, + columns: GridCells, + content: LazyGridScope.() -> Unit, +) { + LazyVerticalGrid( + columns = columns, + verticalArrangement = spacedBy(dimensionResource(R.dimen.qs_tile_margin_vertical)), + horizontalArrangement = spacedBy(dimensionResource(R.dimen.qs_tile_margin_horizontal)), + modifier = modifier, + content = content, + ) +} + +@Composable +fun DefaultEditTileGrid( + tiles: List<EditTileViewModel>, + iconOnlySpecs: Set<TileSpec>, + columns: GridCells, + modifier: Modifier, + onAddTile: (TileSpec, Int) -> Unit, + onRemoveTile: (TileSpec) -> Unit, +) { + val (currentTiles, otherTiles) = tiles.partition { it.isCurrent } + val (otherTilesStock, otherTilesCustom) = otherTiles.partition { it.appName == null } + val addTileToEnd: (TileSpec) -> Unit by rememberUpdatedState { + onAddTile(it, CurrentTilesInteractor.POSITION_AT_END) + } + val isIconOnly: (TileSpec) -> Boolean = + remember(iconOnlySpecs) { { tileSpec: TileSpec -> tileSpec in iconOnlySpecs } } + + TileLazyGrid(modifier = modifier, columns = columns) { + // These Text are just placeholders to see the different sections. Not final UI. + item(span = { GridItemSpan(maxLineSpan) }) { Text("Current tiles", color = Color.White) } + + editTiles( + currentTiles, + ClickAction.REMOVE, + onRemoveTile, + isIconOnly, + indicatePosition = true, + ) + + item(span = { GridItemSpan(maxLineSpan) }) { Text("Tiles to add", color = Color.White) } + + editTiles( + otherTilesStock, + ClickAction.ADD, + addTileToEnd, + isIconOnly, + ) + + item(span = { GridItemSpan(maxLineSpan) }) { + Text("Custom tiles to add", color = Color.White) + } + + editTiles( + otherTilesCustom, + ClickAction.ADD, + addTileToEnd, + isIconOnly, + ) + } +} + +private fun LazyGridScope.editTiles( + tiles: List<EditTileViewModel>, + clickAction: ClickAction, + onClick: (TileSpec) -> Unit, + isIconOnly: (TileSpec) -> Boolean, + indicatePosition: Boolean = false, +) { + items( + count = tiles.size, + key = { tiles[it].tileSpec.spec }, + span = { GridItemSpan(if (isIconOnly(tiles[it].tileSpec)) 1 else 2) }, + contentType = { TileType } + ) { + val viewModel = tiles[it] + val canClick = + when (clickAction) { + ClickAction.ADD -> AvailableEditActions.ADD in viewModel.availableEditActions + ClickAction.REMOVE -> AvailableEditActions.REMOVE in viewModel.availableEditActions + } + val onClickActionName = + when (clickAction) { + ClickAction.ADD -> + stringResource(id = R.string.accessibility_qs_edit_tile_add_action) + ClickAction.REMOVE -> + stringResource(id = R.string.accessibility_qs_edit_remove_tile_action) + } + val stateDescription = + if (indicatePosition) { + stringResource(id = R.string.accessibility_qs_edit_position, it + 1) + } else { + "" + } + + Box( + modifier = + Modifier.clickable(enabled = canClick) { onClick.invoke(viewModel.tileSpec) } + .animateItem() + .semantics { + onClick(onClickActionName) { false } + this.stateDescription = stateDescription + } + ) { + EditTile( + tileViewModel = viewModel, + isIconOnly(viewModel.tileSpec), + modifier = Modifier.height(dimensionResource(id = R.dimen.qs_tile_height)) + ) + if (canClick) { + Badge(clickAction, Modifier.align(Alignment.TopEnd)) + } + } + } +} + +@Composable +fun Badge(action: ClickAction, modifier: Modifier = Modifier) { + Box(modifier = modifier.size(16.dp).background(Color.Cyan, shape = CircleShape)) { + Icon( + imageVector = + when (action) { + ClickAction.ADD -> Icons.Filled.Add + ClickAction.REMOVE -> Icons.Filled.Remove + }, + "", + tint = Color.Black, + ) + } +} + +@Composable +fun EditTile( + tileViewModel: EditTileViewModel, + iconOnly: Boolean, + modifier: Modifier = Modifier, +) { + val label = tileViewModel.label.load() ?: tileViewModel.tileSpec.spec + val colors = ActiveTileColorAttributes + + Row( + modifier = modifier.tileModifier(colors).semantics { this.contentDescription = label }, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = tileHorizontalArrangement(iconOnly) + ) { + TileContent( + label = label, + secondaryLabel = tileViewModel.appName?.load(), + colors = colors, + icon = tileViewModel.icon, + iconOnly = iconOnly, + animateIconToEnd = true, + ) + } +} + +enum class ClickAction { + ADD, + REMOVE, +} + +@OptIn(ExperimentalAnimationGraphicsApi::class) +@Composable +private fun TileIcon( + icon: Icon, + color: Color, + animateToEnd: Boolean = false, +) { + val modifier = Modifier.size(dimensionResource(id = R.dimen.qs_icon_size)) + val context = LocalContext.current + val loadedDrawable = + remember(icon, context) { + when (icon) { + is Icon.Loaded -> icon.drawable + is Icon.Resource -> AppCompatResources.getDrawable(context, icon.res) + } + } + if (loadedDrawable !is Animatable) { + Icon( + icon = icon, + tint = color, + modifier = modifier, + ) + } else if (icon is Icon.Resource) { + val image = AnimatedImageVector.animatedVectorResource(id = icon.res) + val painter = + if (animateToEnd) { + rememberAnimatedVectorPainter(animatedImageVector = image, atEnd = true) + } else { + var atEnd by remember(icon.res) { mutableStateOf(false) } + LaunchedEffect(key1 = icon.res) { + delay(350) + atEnd = true + } + rememberAnimatedVectorPainter(animatedImageVector = image, atEnd = atEnd) + } + Image( + painter = painter, + contentDescription = null, + colorFilter = ColorFilter.tint(color = color), + modifier = modifier + ) + } +} + +@Composable +private fun Modifier.tileModifier(colors: TileColorAttributes): Modifier { + return fillMaxWidth() + .clip(RoundedCornerShape(dimensionResource(R.dimen.qs_corner_radius))) + .background(colorAttr(colors.background)) + .padding(horizontal = dimensionResource(id = R.dimen.qs_label_container_margin)) +} + +@Composable +private fun tileHorizontalArrangement(iconOnly: Boolean): Arrangement.Horizontal { + val horizontalAlignment = + if (iconOnly) { + Alignment.CenterHorizontally + } else { + Alignment.Start + } + return spacedBy( + space = dimensionResource(id = R.dimen.qs_label_container_margin), + alignment = horizontalAlignment + ) +} + +@Composable +private fun TileContent( + label: String, + secondaryLabel: String?, + icon: Icon, + colors: TileColorAttributes, + iconOnly: Boolean, + animateIconToEnd: Boolean = false, +) { + TileIcon(icon, colorAttr(colors.icon), animateIconToEnd) + + if (!iconOnly) { + Column(verticalArrangement = Arrangement.Center, modifier = Modifier.fillMaxHeight()) { + Text( + label, + color = colorAttr(colors.label), + modifier = Modifier.basicMarquee(), + ) + if (!TextUtils.isEmpty(secondaryLabel)) { + Text( + secondaryLabel ?: "", + color = colorAttr(colors.secondaryLabel), + modifier = Modifier.basicMarquee(), + ) + } + } + } +} diff --git a/packages/SystemUI/tests/src/com/android/systemui/qs/panels/domain/interactor/GridConsistencyInteractorTest.kt b/packages/SystemUI/tests/src/com/android/systemui/qs/panels/domain/interactor/GridConsistencyInteractorTest.kt index db752dd64997..d15cfbf537a2 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/qs/panels/domain/interactor/GridConsistencyInteractorTest.kt +++ b/packages/SystemUI/tests/src/com/android/systemui/qs/panels/domain/interactor/GridConsistencyInteractorTest.kt @@ -20,7 +20,6 @@ import android.testing.AndroidTestingRunner import androidx.test.filters.SmallTest import com.android.systemui.SysuiTestCase import com.android.systemui.kosmos.testScope -import com.android.systemui.qs.panels.data.repository.GridLayoutTypeRepository import com.android.systemui.qs.panels.data.repository.IconTilesRepository import com.android.systemui.qs.panels.data.repository.gridLayoutTypeRepository import com.android.systemui.qs.panels.data.repository.iconTilesRepository @@ -48,9 +47,6 @@ class GridConsistencyInteractorTest : SysuiTestCase() { data object TestGridLayoutType : GridLayoutType - private val gridLayout: MutableStateFlow<GridLayoutType> = - MutableStateFlow(InfiniteGridLayoutType) - private val iconOnlyTiles = MutableStateFlow( setOf( @@ -74,17 +70,13 @@ class GridConsistencyInteractorTest : SysuiTestCase() { Pair(InfiniteGridLayoutType, infiniteGridConsistencyInteractor), Pair(TestGridLayoutType, noopGridConsistencyInteractor) ) - gridLayoutTypeRepository = - object : GridLayoutTypeRepository { - override val layout: StateFlow<GridLayoutType> = gridLayout.asStateFlow() - } } private val underTest = with(kosmos) { gridConsistencyInteractor } @Before fun setUp() { - gridLayout.value = InfiniteGridLayoutType + with(kosmos) { gridLayoutTypeRepository.setLayout(InfiniteGridLayoutType) } underTest.start() } @@ -94,7 +86,7 @@ class GridConsistencyInteractorTest : SysuiTestCase() { with(kosmos) { testScope.runTest { // Using the no-op grid consistency interactor - gridLayout.value = TestGridLayoutType + gridLayoutTypeRepository.setLayout(TestGridLayoutType) // Setting an invalid layout with holes // [ Large A ] [ sa ] |