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