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