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.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
SecurityButtonnull193 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
ForegroundServicesButtonnull214 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
IconButtonnull236 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
NumberButtonnull257 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
NewChangesDotnull306 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
TextButtonnull317 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