• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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