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