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