1 /*
<lambda>null2 * Copyright (C) 2022 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 package com.android.systemui.qs.footer.ui.compose
18
19 import androidx.compose.animation.AnimatedVisibility
20 import androidx.compose.animation.core.tween
21 import androidx.compose.animation.expandVertically
22 import androidx.compose.animation.fadeIn
23 import androidx.compose.animation.fadeOut
24 import androidx.compose.animation.shrinkVertically
25 import androidx.compose.foundation.BorderStroke
26 import androidx.compose.foundation.Canvas
27 import androidx.compose.foundation.LocalIndication
28 import androidx.compose.foundation.indication
29 import androidx.compose.foundation.interaction.MutableInteractionSource
30 import androidx.compose.foundation.layout.Box
31 import androidx.compose.foundation.layout.Row
32 import androidx.compose.foundation.layout.RowScope
33 import androidx.compose.foundation.layout.Spacer
34 import androidx.compose.foundation.layout.fillMaxSize
35 import androidx.compose.foundation.layout.fillMaxWidth
36 import androidx.compose.foundation.layout.padding
37 import androidx.compose.foundation.layout.size
38 import androidx.compose.foundation.shape.CircleShape
39 import androidx.compose.foundation.shape.CornerSize
40 import androidx.compose.foundation.shape.RoundedCornerShape
41 import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
42 import androidx.compose.material3.Icon
43 import androidx.compose.material3.LocalContentColor
44 import androidx.compose.material3.MaterialTheme
45 import androidx.compose.material3.Text
46 import androidx.compose.runtime.Composable
47 import androidx.compose.runtime.CompositionLocalProvider
48 import androidx.compose.runtime.LaunchedEffect
49 import androidx.compose.runtime.getValue
50 import androidx.compose.runtime.mutableStateOf
51 import androidx.compose.runtime.remember
52 import androidx.compose.runtime.setValue
53 import androidx.compose.ui.Alignment
54 import androidx.compose.ui.Modifier
55 import androidx.compose.ui.draw.clip
56 import androidx.compose.ui.graphics.Color
57 import androidx.compose.ui.graphics.graphicsLayer
58 import androidx.compose.ui.layout.layout
59 import androidx.compose.ui.platform.LocalContext
60 import androidx.compose.ui.res.dimensionResource
61 import androidx.compose.ui.res.painterResource
62 import androidx.compose.ui.res.stringResource
63 import androidx.compose.ui.semantics.contentDescription
64 import androidx.compose.ui.semantics.semantics
65 import androidx.compose.ui.text.style.TextOverflow
66 import androidx.compose.ui.unit.constrainHeight
67 import androidx.compose.ui.unit.constrainWidth
68 import androidx.compose.ui.unit.dp
69 import androidx.compose.ui.unit.em
70 import androidx.compose.ui.unit.sp
71 import androidx.lifecycle.Lifecycle
72 import androidx.lifecycle.LifecycleOwner
73 import androidx.lifecycle.compose.collectAsStateWithLifecycle
74 import androidx.lifecycle.repeatOnLifecycle
75 import com.android.compose.animation.Expandable
76 import com.android.compose.animation.scene.ContentScope
77 import com.android.compose.modifiers.animatedBackground
78 import com.android.compose.theme.colorAttr
79 import com.android.systemui.Flags.notificationShadeBlur
80 import com.android.systemui.animation.Expandable
81 import com.android.systemui.common.shared.model.Icon
82 import com.android.systemui.common.ui.compose.Icon
83 import com.android.systemui.compose.modifiers.sysuiResTag
84 import com.android.systemui.qs.flags.QSComposeFragment
85 import com.android.systemui.qs.flags.QsInCompose
86 import com.android.systemui.qs.footer.ui.viewmodel.FooterActionsButtonViewModel
87 import com.android.systemui.qs.footer.ui.viewmodel.FooterActionsForegroundServicesButtonViewModel
88 import com.android.systemui.qs.footer.ui.viewmodel.FooterActionsSecurityButtonViewModel
89 import com.android.systemui.qs.footer.ui.viewmodel.FooterActionsViewModel
90 import com.android.systemui.qs.ui.composable.QuickSettings
91 import com.android.systemui.qs.ui.composable.QuickSettingsTheme
92 import com.android.systemui.qs.ui.compose.borderOnFocus
93 import com.android.systemui.res.R
94 import kotlinx.coroutines.launch
95
96 @Composable
97 fun ContentScope.FooterActionsWithAnimatedVisibility(
98 viewModel: FooterActionsViewModel,
99 isCustomizing: Boolean,
100 customizingAnimationDuration: Int,
101 lifecycleOwner: LifecycleOwner,
102 modifier: Modifier = Modifier,
103 ) {
104 AnimatedVisibility(
105 visible = !isCustomizing,
106 enter =
107 expandVertically(
108 animationSpec = tween(customizingAnimationDuration),
109 initialHeight = { 0 },
110 ) + fadeIn(tween(customizingAnimationDuration)),
111 exit =
112 shrinkVertically(
113 animationSpec = tween(customizingAnimationDuration),
114 targetHeight = { 0 },
115 ) + fadeOut(tween(customizingAnimationDuration)),
116 modifier = modifier.fillMaxWidth(),
117 ) {
118 QuickSettingsTheme {
119 // This view has its own horizontal padding
120 // TODO(b/321716470) This should use a lifecycle tied to the scene.
121 Element(QuickSettings.Elements.FooterActions, Modifier) {
122 FooterActions(viewModel = viewModel, qsVisibilityLifecycleOwner = lifecycleOwner)
123 }
124 }
125 }
126 }
127
128 /** The Quick Settings footer actions row. */
129 @Composable
FooterActionsnull130 fun FooterActions(
131 viewModel: FooterActionsViewModel,
132 qsVisibilityLifecycleOwner: LifecycleOwner,
133 modifier: Modifier = Modifier,
134 ) {
135 val context = LocalContext.current
136
137 // Collect alphas as soon as we are composed, even when not visible.
138 val alpha by viewModel.alpha.collectAsStateWithLifecycle()
139 val backgroundAlpha = viewModel.backgroundAlpha.collectAsStateWithLifecycle()
140
141 var security by remember { mutableStateOf<FooterActionsSecurityButtonViewModel?>(null) }
142 var foregroundServices by remember {
143 mutableStateOf<FooterActionsForegroundServicesButtonViewModel?>(null)
144 }
145 var userSwitcher by remember { mutableStateOf<FooterActionsButtonViewModel?>(null) }
146 var power by remember { mutableStateOf(viewModel.initialPower()) }
147
148 LaunchedEffect(
149 context,
150 qsVisibilityLifecycleOwner,
151 viewModel,
152 viewModel.security,
153 viewModel.foregroundServices,
154 viewModel.userSwitcher,
155 ) {
156 launch {
157 // Listen for dialog requests as soon as we are composed, even when not visible.
158 viewModel.observeDeviceMonitoringDialogRequests(context)
159 }
160
161 // Listen for model changes only when QS are visible.
162 qsVisibilityLifecycleOwner.repeatOnLifecycle(Lifecycle.State.RESUMED) {
163 launch { viewModel.security.collect { security = it } }
164 launch { viewModel.foregroundServices.collect { foregroundServices = it } }
165 launch { viewModel.userSwitcher.collect { userSwitcher = it } }
166 launch { viewModel.power.collect { power = it } }
167 }
168 }
169
170 val backgroundColor =
171 if (!notificationShadeBlur()) colorAttr(R.attr.underSurface) else Color.Transparent
172 val backgroundAlphaValue = if (!notificationShadeBlur()) backgroundAlpha::value else ({ 0f })
173 val contentColor = MaterialTheme.colorScheme.onSurface
174 val backgroundTopRadius = dimensionResource(R.dimen.qs_corner_radius)
175 val backgroundModifier =
176 remember(backgroundColor, backgroundAlphaValue, backgroundTopRadius) {
177 Modifier.animatedBackground(
178 { backgroundColor },
179 backgroundAlphaValue,
180 RoundedCornerShape(topStart = backgroundTopRadius, topEnd = backgroundTopRadius),
181 )
182 }
183
184 val horizontalPadding = dimensionResource(R.dimen.qs_content_horizontal_padding)
185 Row(
186 modifier
187 .fillMaxWidth()
188 .graphicsLayer { this.alpha = alpha }
189 .then(backgroundModifier)
190 .padding(
191 top = dimensionResource(R.dimen.qs_footer_actions_top_padding),
192 bottom = dimensionResource(R.dimen.qs_footer_actions_bottom_padding),
193 start = horizontalPadding,
194 end = horizontalPadding,
195 )
196 .layout { measurable, constraints ->
197 // All buttons have a 4dp padding to increase their touch size. To be consistent
198 // with the View implementation, we want to left-most and right-most buttons to be
199 // visually aligned with the left and right sides of this row. So we let this
200 // component be 2*4dp wider and then offset it by -4dp to the start.
201 val inset = 4.dp.roundToPx()
202 val additionalWidth = inset * 2
203 val newConstraints =
204 if (constraints.hasBoundedWidth) {
205 constraints.copy(maxWidth = constraints.maxWidth + additionalWidth)
206 } else {
207 constraints
208 }
209 val placeable = measurable.measure(newConstraints)
210
211 val width = constraints.constrainWidth(placeable.width - additionalWidth)
212 val height = constraints.constrainHeight(placeable.height)
213 layout(width, height) { placeable.place(-inset, 0) }
214 },
215 verticalAlignment = Alignment.CenterVertically,
216 ) {
217 CompositionLocalProvider(LocalContentColor provides contentColor) {
218 if (security == null && foregroundServices == null) {
219 Spacer(Modifier.weight(1f))
220 }
221
222 val useModifierBasedExpandable = remember { QSComposeFragment.isEnabled }
223 SecurityButton({ security }, useModifierBasedExpandable, Modifier.weight(1f))
224 ForegroundServicesButton({ foregroundServices }, useModifierBasedExpandable)
225 IconButton(
226 { userSwitcher },
227 useModifierBasedExpandable,
228 Modifier.sysuiResTag("multi_user_switch"),
229 )
230 IconButton(
231 { viewModel.settings },
232 useModifierBasedExpandable,
233 Modifier.sysuiResTag("settings_button_container"),
234 )
235 IconButton({ power }, useModifierBasedExpandable, Modifier.sysuiResTag("pm_lite"))
236 }
237 }
238 }
239
240 /** The security button. */
241 @Composable
SecurityButtonnull242 private fun SecurityButton(
243 model: () -> FooterActionsSecurityButtonViewModel?,
244 useModifierBasedExpandable: Boolean,
245 modifier: Modifier = Modifier,
246 ) {
247 val model = model() ?: return
248 val onClick: ((Expandable) -> Unit)? =
249 model.onClick?.let { onClick ->
250 val context = LocalContext.current
251 { expandable -> onClick(context, expandable) }
252 }
253
254 TextButton(
255 model.icon,
256 model.text,
257 showNewDot = false,
258 onClick = onClick,
259 useModifierBasedExpandable,
260 modifier,
261 )
262 }
263
264 /** The foreground services button. */
265 @Composable
ForegroundServicesButtonnull266 private fun RowScope.ForegroundServicesButton(
267 model: () -> FooterActionsForegroundServicesButtonViewModel?,
268 useModifierBasedExpandable: Boolean,
269 ) {
270 val model = model() ?: return
271 if (model.displayText) {
272 TextButton(
273 Icon.Resource(R.drawable.ic_info_outline, contentDescription = null),
274 model.text,
275 showNewDot = model.hasNewChanges,
276 onClick = model.onClick,
277 useModifierBasedExpandable,
278 Modifier.weight(1f),
279 )
280 } else {
281 NumberButton(
282 model.foregroundServicesCount,
283 contentDescription = model.text,
284 showNewDot = model.hasNewChanges,
285 onClick = model.onClick,
286 useModifierBasedExpandable,
287 )
288 }
289 }
290
291 /** A button with an icon. */
292 @Composable
IconButtonnull293 fun IconButton(
294 model: () -> FooterActionsButtonViewModel?,
295 useModifierBasedExpandable: Boolean,
296 modifier: Modifier = Modifier,
297 ) {
298 val model = model() ?: return
299 IconButton(model, useModifierBasedExpandable, modifier)
300 }
301
302 /** A button with an icon. */
303 @Composable
IconButtonnull304 fun IconButton(
305 model: FooterActionsButtonViewModel,
306 useModifierBasedExpandable: Boolean,
307 modifier: Modifier = Modifier,
308 ) {
309 Expandable(
310 color = colorAttr(model.backgroundColor),
311 shape = CircleShape,
312 onClick = model.onClick,
313 modifier =
314 modifier.borderOnFocus(
315 color = MaterialTheme.colorScheme.secondary,
316 CornerSize(percent = 50),
317 ),
318 useModifierBasedImplementation = useModifierBasedExpandable,
319 ) {
320 val tint = model.iconTint?.let { Color(it) } ?: Color.Unspecified
321 Icon(model.icon, tint = tint, modifier = Modifier.size(20.dp))
322 }
323 }
324
325 /** A button with a number an an optional dot (to indicate new changes). */
326 @Composable
NumberButtonnull327 private fun NumberButton(
328 number: Int,
329 contentDescription: String,
330 showNewDot: Boolean,
331 onClick: (Expandable) -> Unit,
332 useModifierBasedExpandable: Boolean,
333 modifier: Modifier = Modifier,
334 ) {
335 // By default Expandable will show a ripple above its content when clicked, and clip the content
336 // with the shape of the expandable. In this case we also want to show a "new changes dot"
337 // outside of the shape, so we can't clip. To work around that we can pass our own interaction
338 // source and draw the ripple indication ourselves above the text but below the "new changes
339 // dot".
340 val interactionSource = remember { MutableInteractionSource() }
341
342 Expandable(
343 color = colorAttr(R.attr.shadeInactive),
344 shape = CircleShape,
345 onClick = onClick,
346 interactionSource = interactionSource,
347 modifier =
348 modifier.borderOnFocus(
349 color = MaterialTheme.colorScheme.secondary,
350 CornerSize(percent = 50),
351 ),
352 useModifierBasedImplementation = useModifierBasedExpandable,
353 ) {
354 Box(Modifier.size(40.dp)) {
355 Box(
356 Modifier.fillMaxSize()
357 .clip(CircleShape)
358 .indication(interactionSource, LocalIndication.current)
359 ) {
360 Text(
361 number.toString(),
362 modifier =
363 Modifier.align(Alignment.Center).semantics {
364 this.contentDescription = contentDescription
365 },
366 style = MaterialTheme.typography.bodyLarge,
367 color = colorAttr(R.attr.onShadeInactiveVariant),
368 // TODO(b/242040009): This should only use a standard text style instead and
369 // should not override the text size.
370 fontSize = 18.sp,
371 )
372 }
373
374 if (showNewDot) {
375 NewChangesDot(Modifier.align(Alignment.BottomEnd))
376 }
377 }
378 }
379 }
380
381 /** A dot that indicates new changes. */
382 @Composable
NewChangesDotnull383 private fun NewChangesDot(modifier: Modifier = Modifier) {
384 val contentDescription = stringResource(R.string.fgs_dot_content_description)
385 val color = MaterialTheme.colorScheme.tertiary
386
387 Canvas(modifier.size(12.dp).semantics { this.contentDescription = contentDescription }) {
388 drawCircle(color)
389 }
390 }
391
392 /** A larger button with an icon, some text and an optional dot (to indicate new changes). */
393 @OptIn(ExperimentalMaterial3ExpressiveApi::class)
394 @Composable
TextButtonnull395 private fun TextButton(
396 icon: Icon,
397 text: String,
398 showNewDot: Boolean,
399 onClick: ((Expandable) -> Unit)?,
400 useModifierBasedExpandable: Boolean,
401 modifier: Modifier = Modifier,
402 ) {
403 Expandable(
404 shape = CircleShape,
405 color = colorAttr(R.attr.underSurface),
406 contentColor = MaterialTheme.colorScheme.onSurfaceVariant,
407 borderStroke = BorderStroke(1.dp, colorAttr(R.attr.shadeInactive)),
408 modifier =
409 modifier
410 .padding(horizontal = 4.dp)
411 .borderOnFocus(color = MaterialTheme.colorScheme.secondary, CornerSize(50)),
412 onClick = onClick,
413 useModifierBasedImplementation = useModifierBasedExpandable,
414 ) {
415 Row(
416 Modifier.padding(horizontal = dimensionResource(R.dimen.qs_footer_padding)),
417 verticalAlignment = Alignment.CenterVertically,
418 ) {
419 Icon(
420 icon,
421 Modifier.padding(end = 12.dp).size(20.dp),
422 colorAttr(R.attr.onShadeInactiveVariant),
423 )
424
425 Text(
426 text,
427 Modifier.weight(1f),
428 style =
429 if (QsInCompose.isEnabled) {
430 MaterialTheme.typography.labelLarge
431 } else {
432 MaterialTheme.typography.bodyMedium
433 },
434 letterSpacing = if (QsInCompose.isEnabled) 0.em else 0.01.em,
435 color = colorAttr(R.attr.onShadeInactiveVariant),
436 maxLines = 1,
437 overflow = TextOverflow.Ellipsis,
438 )
439
440 if (showNewDot) {
441 NewChangesDot(Modifier.padding(start = 8.dp))
442 }
443
444 if (onClick != null) {
445 Icon(
446 painterResource(com.android.internal.R.drawable.ic_chevron_end),
447 contentDescription = null,
448 Modifier.padding(start = 8.dp).size(20.dp),
449 colorAttr(R.attr.onShadeInactiveVariant),
450 )
451 }
452 }
453 }
454 }
455