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