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