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