1 /*
2  * Copyright (C) 2024 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 @file:OptIn(ExperimentalFoundationApi::class)
18 
19 package com.android.systemui.qs.panels.ui.compose
20 
21 import android.graphics.drawable.Animatable
22 import android.service.quicksettings.Tile.STATE_ACTIVE
23 import android.service.quicksettings.Tile.STATE_INACTIVE
24 import android.text.TextUtils
25 import androidx.appcompat.content.res.AppCompatResources
26 import androidx.compose.animation.graphics.ExperimentalAnimationGraphicsApi
27 import androidx.compose.animation.graphics.res.animatedVectorResource
28 import androidx.compose.animation.graphics.res.rememberAnimatedVectorPainter
29 import androidx.compose.animation.graphics.vector.AnimatedImageVector
30 import androidx.compose.foundation.ExperimentalFoundationApi
31 import androidx.compose.foundation.Image
32 import androidx.compose.foundation.basicMarquee
33 import androidx.compose.foundation.combinedClickable
34 import androidx.compose.foundation.layout.Arrangement
35 import androidx.compose.foundation.layout.Arrangement.spacedBy
36 import androidx.compose.foundation.layout.Box
37 import androidx.compose.foundation.layout.BoxScope
38 import androidx.compose.foundation.layout.Column
39 import androidx.compose.foundation.layout.Row
40 import androidx.compose.foundation.layout.aspectRatio
41 import androidx.compose.foundation.layout.fillMaxHeight
42 import androidx.compose.foundation.layout.fillMaxSize
43 import androidx.compose.foundation.layout.height
44 import androidx.compose.foundation.layout.padding
45 import androidx.compose.foundation.layout.size
46 import androidx.compose.foundation.lazy.grid.GridCells
47 import androidx.compose.foundation.lazy.grid.GridItemSpan
48 import androidx.compose.foundation.lazy.grid.LazyGridScope
49 import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
50 import androidx.compose.foundation.shape.CircleShape
51 import androidx.compose.material3.MaterialTheme
52 import androidx.compose.material3.Text
53 import androidx.compose.runtime.Composable
54 import androidx.compose.runtime.LaunchedEffect
55 import androidx.compose.runtime.getValue
56 import androidx.compose.runtime.mutableStateOf
57 import androidx.compose.runtime.remember
58 import androidx.compose.runtime.rememberUpdatedState
59 import androidx.compose.runtime.setValue
60 import androidx.compose.ui.Alignment
61 import androidx.compose.ui.Modifier
62 import androidx.compose.ui.draw.clip
63 import androidx.compose.ui.graphics.Color
64 import androidx.compose.ui.graphics.ColorFilter
65 import androidx.compose.ui.platform.LocalContext
66 import androidx.compose.ui.res.dimensionResource
67 import androidx.compose.ui.res.stringResource
68 import androidx.compose.ui.semantics.onClick
69 import androidx.compose.ui.semantics.semantics
70 import androidx.compose.ui.semantics.stateDescription
71 import androidx.compose.ui.text.style.TextAlign
72 import androidx.compose.ui.text.style.TextOverflow
73 import androidx.compose.ui.unit.Dp
74 import androidx.compose.ui.unit.dp
75 import androidx.lifecycle.compose.collectAsStateWithLifecycle
76 import com.android.compose.animation.Expandable
77 import com.android.systemui.animation.Expandable
78 import com.android.systemui.common.shared.model.Icon
79 import com.android.systemui.common.ui.compose.Icon
80 import com.android.systemui.common.ui.compose.load
81 import com.android.systemui.plugins.qs.QSTile
82 import com.android.systemui.qs.panels.ui.viewmodel.AvailableEditActions
83 import com.android.systemui.qs.panels.ui.viewmodel.EditTileViewModel
84 import com.android.systemui.qs.panels.ui.viewmodel.TileUiState
85 import com.android.systemui.qs.panels.ui.viewmodel.TileViewModel
86 import com.android.systemui.qs.panels.ui.viewmodel.toUiState
87 import com.android.systemui.qs.pipeline.domain.interactor.CurrentTilesInteractor
88 import com.android.systemui.qs.pipeline.shared.TileSpec
89 import com.android.systemui.qs.tileimpl.QSTileImpl
90 import com.android.systemui.res.R
91 import java.util.function.Supplier
92 import kotlinx.coroutines.ExperimentalCoroutinesApi
93 import kotlinx.coroutines.delay
94 import kotlinx.coroutines.flow.mapLatest
95 
96 object TileType
97 
98 @OptIn(ExperimentalCoroutinesApi::class)
99 @Composable
Tilenull100 fun Tile(
101     tile: TileViewModel,
102     iconOnly: Boolean,
103     showLabels: Boolean = false,
104     modifier: Modifier,
105 ) {
106     val state: TileUiState by
107         tile.state
108             .mapLatest { it.toUiState() }
109             .collectAsStateWithLifecycle(tile.currentState.toUiState())
110     val colors = TileDefaults.getColorForState(state.state)
111 
112     TileContainer(
113         colors = colors,
114         showLabels = showLabels,
115         label = state.label.toString(),
116         iconOnly = iconOnly,
117         onClick = tile::onClick,
118         onLongClick = tile::onLongClick,
119         modifier = modifier,
120     ) {
121         val icon = getTileIcon(icon = state.icon)
122         if (iconOnly) {
123             TileIcon(icon = icon, color = colors.icon, modifier = Modifier.align(Alignment.Center))
124         } else {
125             LargeTileContent(
126                 label = state.label.toString(),
127                 secondaryLabel = state.secondaryLabel.toString(),
128                 icon = icon,
129                 colors = colors,
130                 onClick = tile::onSecondaryClick,
131                 onLongClick = tile::onLongClick,
132             )
133         }
134     }
135 }
136 
137 @Composable
TileContainernull138 private fun TileContainer(
139     colors: TileColors,
140     showLabels: Boolean,
141     label: String,
142     iconOnly: Boolean,
143     clickEnabled: Boolean = true,
144     onClick: (Expandable) -> Unit = {},
<lambda>null145     onLongClick: (Expandable) -> Unit = {},
146     modifier: Modifier = Modifier,
147     content: @Composable BoxScope.() -> Unit,
148 ) {
149     Column(
150         horizontalAlignment = Alignment.CenterHorizontally,
151         verticalArrangement =
152             spacedBy(dimensionResource(id = R.dimen.qs_label_container_margin), Alignment.Top),
153         modifier = modifier,
<lambda>null154     ) {
155         val backgroundColor =
156             if (iconOnly) {
157                 colors.iconBackground
158             } else {
159                 colors.background
160             }
161         Expandable(
162             color = backgroundColor,
163             shape = TileDefaults.TileShape,
164             modifier =
165                 Modifier.height(dimensionResource(id = R.dimen.qs_tile_height))
166                     .clip(TileDefaults.TileShape)
167         ) {
168             Box(
169                 modifier =
170                     Modifier.fillMaxSize()
171                         .combinedClickable(
172                             enabled = clickEnabled,
173                             onClick = { onClick(it) },
174                             onLongClick = { onLongClick(it) }
175                         )
176                         .tilePadding(),
177             ) {
178                 content()
179             }
180         }
181 
182         if (showLabels && iconOnly) {
183             Text(
184                 label,
185                 maxLines = 2,
186                 color = colors.label,
187                 overflow = TextOverflow.Ellipsis,
188                 textAlign = TextAlign.Center,
189             )
190         }
191     }
192 }
193 
194 @Composable
LargeTileContentnull195 private fun LargeTileContent(
196     label: String,
197     secondaryLabel: String?,
198     icon: Icon,
199     colors: TileColors,
200     clickEnabled: Boolean = true,
201     onClick: (Expandable) -> Unit = {},
<lambda>null202     onLongClick: (Expandable) -> Unit = {},
203 ) {
204     Row(
205         verticalAlignment = Alignment.CenterVertically,
206         horizontalArrangement = tileHorizontalArrangement()
<lambda>null207     ) {
208         Expandable(
209             color = colors.iconBackground,
210             shape = TileDefaults.TileShape,
211             modifier = Modifier.fillMaxHeight().aspectRatio(1f)
212         ) {
213             Box(
214                 modifier =
215                     Modifier.fillMaxSize()
216                         .clip(TileDefaults.TileShape)
217                         .combinedClickable(
218                             enabled = clickEnabled,
219                             onClick = { onClick(it) },
220                             onLongClick = { onLongClick(it) }
221                         )
222             ) {
223                 TileIcon(
224                     icon = icon,
225                     color = colors.icon,
226                     modifier = Modifier.align(Alignment.Center)
227                 )
228             }
229         }
230 
231         Column(verticalArrangement = Arrangement.Center, modifier = Modifier.fillMaxHeight()) {
232             Text(
233                 label,
234                 color = colors.label,
235                 modifier = Modifier.basicMarquee(),
236             )
237             if (!TextUtils.isEmpty(secondaryLabel)) {
238                 Text(
239                     secondaryLabel ?: "",
240                     color = colors.secondaryLabel,
241                     modifier = Modifier.basicMarquee(),
242                 )
243             }
244         }
245     }
246 }
247 
248 @Composable
TileLazyGridnull249 fun TileLazyGrid(
250     modifier: Modifier = Modifier,
251     columns: GridCells,
252     content: LazyGridScope.() -> Unit,
253 ) {
254     LazyVerticalGrid(
255         columns = columns,
256         verticalArrangement = spacedBy(dimensionResource(R.dimen.qs_tile_margin_vertical)),
257         horizontalArrangement = spacedBy(dimensionResource(R.dimen.qs_tile_margin_horizontal)),
258         modifier = modifier,
259         content = content,
260     )
261 }
262 
263 @Composable
DefaultEditTileGridnull264 fun DefaultEditTileGrid(
265     tiles: List<EditTileViewModel>,
266     isIconOnly: (TileSpec) -> Boolean,
267     columns: GridCells,
268     modifier: Modifier,
269     onAddTile: (TileSpec, Int) -> Unit,
270     onRemoveTile: (TileSpec) -> Unit,
271 ) {
272     val (currentTiles, otherTiles) = tiles.partition { it.isCurrent }
273     val (otherTilesStock, otherTilesCustom) = otherTiles.partition { it.appName == null }
274     val addTileToEnd: (TileSpec) -> Unit by rememberUpdatedState {
275         onAddTile(it, CurrentTilesInteractor.POSITION_AT_END)
276     }
277 
278     TileLazyGrid(modifier = modifier, columns = columns) {
279         // These Text are just placeholders to see the different sections. Not final UI.
280         item(span = { GridItemSpan(maxLineSpan) }) { Text("Current tiles", color = Color.White) }
281 
282         editTiles(
283             currentTiles,
284             ClickAction.REMOVE,
285             onRemoveTile,
286             isIconOnly,
287             indicatePosition = true,
288         )
289 
290         item(span = { GridItemSpan(maxLineSpan) }) { Text("Tiles to add", color = Color.White) }
291 
292         editTiles(
293             otherTilesStock,
294             ClickAction.ADD,
295             addTileToEnd,
296             isIconOnly,
297         )
298 
299         item(span = { GridItemSpan(maxLineSpan) }) {
300             Text("Custom tiles to add", color = Color.White)
301         }
302 
303         editTiles(
304             otherTilesCustom,
305             ClickAction.ADD,
306             addTileToEnd,
307             isIconOnly,
308         )
309     }
310 }
311 
LazyGridScopenull312 fun LazyGridScope.editTiles(
313     tiles: List<EditTileViewModel>,
314     clickAction: ClickAction,
315     onClick: (TileSpec) -> Unit,
316     isIconOnly: (TileSpec) -> Boolean,
317     showLabels: Boolean = false,
318     indicatePosition: Boolean = false,
319 ) {
320     items(
321         count = tiles.size,
322         key = { tiles[it].tileSpec.spec },
323         span = { GridItemSpan(if (isIconOnly(tiles[it].tileSpec)) 1 else 2) },
324         contentType = { TileType }
325     ) {
326         val viewModel = tiles[it]
327         val canClick =
328             when (clickAction) {
329                 ClickAction.ADD -> AvailableEditActions.ADD in viewModel.availableEditActions
330                 ClickAction.REMOVE -> AvailableEditActions.REMOVE in viewModel.availableEditActions
331             }
332         val onClickActionName =
333             when (clickAction) {
334                 ClickAction.ADD ->
335                     stringResource(id = R.string.accessibility_qs_edit_tile_add_action)
336                 ClickAction.REMOVE ->
337                     stringResource(id = R.string.accessibility_qs_edit_remove_tile_action)
338             }
339         val stateDescription =
340             if (indicatePosition) {
341                 stringResource(id = R.string.accessibility_qs_edit_position, it + 1)
342             } else {
343                 ""
344             }
345 
346         val iconOnly = isIconOnly(viewModel.tileSpec)
347         val tileHeight = tileHeight(iconOnly && showLabels)
348         EditTile(
349             tileViewModel = viewModel,
350             iconOnly = iconOnly,
351             showLabels = showLabels,
352             clickEnabled = canClick,
353             onClick = { onClick.invoke(viewModel.tileSpec) },
354             modifier =
355                 Modifier.height(tileHeight).animateItem().semantics {
356                     onClick(onClickActionName) { false }
357                     this.stateDescription = stateDescription
358                 }
359         )
360     }
361 }
362 
363 @Composable
EditTilenull364 fun EditTile(
365     tileViewModel: EditTileViewModel,
366     iconOnly: Boolean,
367     showLabels: Boolean,
368     clickEnabled: Boolean,
369     onClick: () -> Unit,
370     modifier: Modifier = Modifier,
371 ) {
372     val label = tileViewModel.label.load() ?: tileViewModel.tileSpec.spec
373     val colors = TileDefaults.inactiveTileColors()
374 
375     TileContainer(
376         colors = colors,
377         showLabels = showLabels,
378         label = label,
379         iconOnly = iconOnly,
380         clickEnabled = clickEnabled,
381         onClick = { onClick() },
382         onLongClick = { onClick() },
383         modifier = modifier,
384     ) {
385         if (iconOnly) {
386             TileIcon(
387                 icon = tileViewModel.icon,
388                 color = colors.icon,
389                 modifier = Modifier.align(Alignment.Center)
390             )
391         } else {
392             LargeTileContent(
393                 label = label,
394                 secondaryLabel = tileViewModel.appName?.load(),
395                 icon = tileViewModel.icon,
396                 colors = colors,
397                 clickEnabled = clickEnabled,
398                 onClick = { onClick() },
399                 onLongClick = { onClick() },
400             )
401         }
402     }
403 }
404 
405 enum class ClickAction {
406     ADD,
407     REMOVE,
408 }
409 
410 @Composable
getTileIconnull411 private fun getTileIcon(icon: Supplier<QSTile.Icon>): Icon {
412     val context = LocalContext.current
413     return icon.get().let {
414         if (it is QSTileImpl.ResourceIcon) {
415             Icon.Resource(it.resId, null)
416         } else {
417             Icon.Loaded(it.getDrawable(context), null)
418         }
419     }
420 }
421 
422 @OptIn(ExperimentalAnimationGraphicsApi::class)
423 @Composable
TileIconnull424 private fun TileIcon(
425     icon: Icon,
426     color: Color,
427     animateToEnd: Boolean = false,
428     modifier: Modifier = Modifier,
429 ) {
430     val iconModifier = modifier.size(dimensionResource(id = R.dimen.qs_icon_size))
431     val context = LocalContext.current
432     val loadedDrawable =
433         remember(icon, context) {
434             when (icon) {
435                 is Icon.Loaded -> icon.drawable
436                 is Icon.Resource -> AppCompatResources.getDrawable(context, icon.res)
437             }
438         }
439     if (loadedDrawable !is Animatable) {
440         Icon(
441             icon = icon,
442             tint = color,
443             modifier = iconModifier,
444         )
445     } else if (icon is Icon.Resource) {
446         val image = AnimatedImageVector.animatedVectorResource(id = icon.res)
447         val painter =
448             if (animateToEnd) {
449                 rememberAnimatedVectorPainter(animatedImageVector = image, atEnd = true)
450             } else {
451                 var atEnd by remember(icon.res) { mutableStateOf(false) }
452                 LaunchedEffect(key1 = icon.res) {
453                     delay(350)
454                     atEnd = true
455                 }
456                 rememberAnimatedVectorPainter(animatedImageVector = image, atEnd = atEnd)
457             }
458         Image(
459             painter = painter,
460             contentDescription = null,
461             colorFilter = ColorFilter.tint(color = color),
462             modifier = iconModifier
463         )
464     }
465 }
466 
467 @Composable
Modifiernull468 private fun Modifier.tilePadding(): Modifier {
469     return padding(dimensionResource(id = R.dimen.qs_label_container_margin))
470 }
471 
472 @Composable
tileHorizontalArrangementnull473 private fun tileHorizontalArrangement(): Arrangement.Horizontal {
474     return spacedBy(
475         space = dimensionResource(id = R.dimen.qs_label_container_margin),
476         alignment = Alignment.Start
477     )
478 }
479 
480 @Composable
tileHeightnull481 fun tileHeight(iconWithLabel: Boolean = false): Dp {
482     return if (iconWithLabel) {
483         TileDefaults.IconTileWithLabelHeight
484     } else {
485         dimensionResource(id = R.dimen.qs_tile_height)
486     }
487 }
488 
489 private data class TileColors(
490     val background: Color,
491     val iconBackground: Color,
492     val label: Color,
493     val secondaryLabel: Color,
494     val icon: Color,
495 )
496 
497 private object TileDefaults {
498     val TileShape = CircleShape
499     val IconTileWithLabelHeight = 140.dp
500 
501     @Composable
activeTileColorsnull502     fun activeTileColors(): TileColors =
503         TileColors(
504             background = MaterialTheme.colorScheme.surfaceVariant,
505             iconBackground = MaterialTheme.colorScheme.primary,
506             label = MaterialTheme.colorScheme.onSurfaceVariant,
507             secondaryLabel = MaterialTheme.colorScheme.onSurfaceVariant,
508             icon = MaterialTheme.colorScheme.onPrimary,
509         )
510 
511     @Composable
512     fun inactiveTileColors(): TileColors =
513         TileColors(
514             background = MaterialTheme.colorScheme.surfaceVariant,
515             iconBackground = MaterialTheme.colorScheme.surfaceVariant,
516             label = MaterialTheme.colorScheme.onSurfaceVariant,
517             secondaryLabel = MaterialTheme.colorScheme.onSurfaceVariant,
518             icon = MaterialTheme.colorScheme.onSurfaceVariant,
519         )
520 
521     @Composable
522     fun unavailableTileColors(): TileColors =
523         TileColors(
524             background = MaterialTheme.colorScheme.surface,
525             iconBackground = MaterialTheme.colorScheme.surface,
526             label = MaterialTheme.colorScheme.onSurface,
527             secondaryLabel = MaterialTheme.colorScheme.onSurface,
528             icon = MaterialTheme.colorScheme.onSurface,
529         )
530 
531     @Composable
532     fun getColorForState(state: Int): TileColors {
533         return when (state) {
534             STATE_ACTIVE -> activeTileColors()
535             STATE_INACTIVE -> inactiveTileColors()
536             else -> unavailableTileColors()
537         }
538     }
539 }
540