1 /*
<lambda>null2 * Copyright (C) 2023 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
18 package com.android.systemui.shade.ui.composable
19
20 import android.view.ContextThemeWrapper
21 import android.view.ViewGroup
22 import androidx.compose.foundation.background
23 import androidx.compose.foundation.clickable
24 import androidx.compose.foundation.interaction.MutableInteractionSource
25 import androidx.compose.foundation.interaction.collectIsHoveredAsState
26 import androidx.compose.foundation.layout.Arrangement
27 import androidx.compose.foundation.layout.Box
28 import androidx.compose.foundation.layout.Column
29 import androidx.compose.foundation.layout.Row
30 import androidx.compose.foundation.layout.RowScope
31 import androidx.compose.foundation.layout.Spacer
32 import androidx.compose.foundation.layout.defaultMinSize
33 import androidx.compose.foundation.layout.fillMaxWidth
34 import androidx.compose.foundation.layout.height
35 import androidx.compose.foundation.layout.padding
36 import androidx.compose.foundation.layout.width
37 import androidx.compose.foundation.layout.widthIn
38 import androidx.compose.foundation.shape.RoundedCornerShape
39 import androidx.compose.material3.ColorScheme
40 import androidx.compose.material3.MaterialTheme
41 import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass
42 import androidx.compose.runtime.Composable
43 import androidx.compose.runtime.derivedStateOf
44 import androidx.compose.runtime.getValue
45 import androidx.compose.runtime.remember
46 import androidx.compose.ui.Alignment
47 import androidx.compose.ui.Modifier
48 import androidx.compose.ui.draw.clip
49 import androidx.compose.ui.graphics.Color
50 import androidx.compose.ui.graphics.TransformOrigin
51 import androidx.compose.ui.graphics.graphicsLayer
52 import androidx.compose.ui.layout.Layout
53 import androidx.compose.ui.platform.LocalLayoutDirection
54 import androidx.compose.ui.res.stringResource
55 import androidx.compose.ui.unit.Constraints
56 import androidx.compose.ui.unit.LayoutDirection
57 import androidx.compose.ui.unit.dp
58 import androidx.compose.ui.unit.max
59 import androidx.compose.ui.viewinterop.AndroidView
60 import androidx.lifecycle.compose.collectAsStateWithLifecycle
61 import com.android.compose.animation.scene.ElementKey
62 import com.android.compose.animation.scene.LowestZIndexScenePicker
63 import com.android.compose.animation.scene.SceneScope
64 import com.android.compose.animation.scene.TransitionState
65 import com.android.compose.animation.scene.ValueKey
66 import com.android.compose.animation.scene.animateElementFloatAsState
67 import com.android.compose.modifiers.thenIf
68 import com.android.compose.windowsizeclass.LocalWindowSizeClass
69 import com.android.settingslib.Utils
70 import com.android.systemui.battery.BatteryMeterView
71 import com.android.systemui.battery.BatteryMeterViewController
72 import com.android.systemui.common.ui.compose.windowinsets.CutoutLocation
73 import com.android.systemui.common.ui.compose.windowinsets.LocalDisplayCutout
74 import com.android.systemui.common.ui.compose.windowinsets.LocalScreenCornerRadius
75 import com.android.systemui.compose.modifiers.sysuiResTag
76 import com.android.systemui.privacy.OngoingPrivacyChip
77 import com.android.systemui.res.R
78 import com.android.systemui.scene.shared.model.Scenes
79 import com.android.systemui.shade.ui.composable.ShadeHeader.Colors.onScrimDim
80 import com.android.systemui.shade.ui.composable.ShadeHeader.Dimensions.CollapsedHeight
81 import com.android.systemui.shade.ui.composable.ShadeHeader.Values.ClockScale
82 import com.android.systemui.shade.ui.viewmodel.ShadeHeaderViewModel
83 import com.android.systemui.statusbar.phone.StatusBarLocation
84 import com.android.systemui.statusbar.phone.StatusIconContainer
85 import com.android.systemui.statusbar.phone.ui.StatusBarIconController
86 import com.android.systemui.statusbar.phone.ui.TintedIconManager
87 import com.android.systemui.statusbar.pipeline.mobile.ui.view.ModernShadeCarrierGroupMobileView
88 import com.android.systemui.statusbar.pipeline.mobile.ui.viewmodel.ShadeCarrierGroupMobileIconViewModel
89 import com.android.systemui.statusbar.policy.Clock
90
91 object ShadeHeader {
92 object Elements {
93 val ExpandedContent = ElementKey("ShadeHeaderExpandedContent")
94 val CollapsedContentStart = ElementKey("ShadeHeaderCollapsedContentStart")
95 val CollapsedContentEnd = ElementKey("ShadeHeaderCollapsedContentEnd")
96 val PrivacyChip = ElementKey("PrivacyChip", scenePicker = LowestZIndexScenePicker)
97 val Clock = ElementKey("ShadeHeaderClock", scenePicker = LowestZIndexScenePicker)
98 val ShadeCarrierGroup = ElementKey("ShadeCarrierGroup")
99 }
100
101 object Values {
102 val ClockScale = ValueKey("ShadeHeaderClockScale")
103 }
104
105 object Dimensions {
106 val CollapsedHeight = 48.dp
107 val ExpandedHeight = 120.dp
108 }
109
110 object Colors {
111 val ColorScheme.shadeHeaderText: Color
112 get() = Color.White
113 val ColorScheme.onScrimDim: Color
114 get() = Color.DarkGray
115 }
116
117 object TestTags {
118 const val Root = "shade_header_root"
119 }
120 }
121
122 @Composable
CollapsedShadeHeadernull123 fun SceneScope.CollapsedShadeHeader(
124 viewModel: ShadeHeaderViewModel,
125 createTintedIconManager: (ViewGroup, StatusBarLocation) -> TintedIconManager,
126 createBatteryMeterViewController: (ViewGroup, StatusBarLocation) -> BatteryMeterViewController,
127 statusBarIconController: StatusBarIconController,
128 modifier: Modifier = Modifier,
129 ) {
130 val isDisabled by viewModel.isDisabled.collectAsStateWithLifecycle()
131 if (isDisabled) {
132 return
133 }
134
135 val cutoutWidth = LocalDisplayCutout.current.width()
136 val cutoutHeight = LocalDisplayCutout.current.height()
137 val cutoutTop = LocalDisplayCutout.current.top
138 val cutoutLocation = LocalDisplayCutout.current.location
139 val horizontalPadding =
140 max(LocalScreenCornerRadius.current / 2f, Shade.Dimensions.HorizontalPadding)
141
142 val useExpandedTextFormat by
143 remember(cutoutLocation) {
144 derivedStateOf {
145 cutoutLocation != CutoutLocation.CENTER ||
146 shouldUseExpandedFormat(layoutState.transitionState)
147 }
148 }
149
150 val isLargeScreenLayout =
151 LocalWindowSizeClass.current.widthSizeClass == WindowWidthSizeClass.Medium ||
152 LocalWindowSizeClass.current.widthSizeClass == WindowWidthSizeClass.Expanded
153
154 val isPrivacyChipVisible by viewModel.isPrivacyChipVisible.collectAsStateWithLifecycle()
155
156 // This layout assumes it is globally positioned at (0, 0) and is the
157 // same size as the screen.
158 Layout(
159 modifier = modifier.sysuiResTag(ShadeHeader.TestTags.Root),
160 contents =
161 listOf(
162 {
163 Row(modifier = Modifier.padding(horizontal = horizontalPadding)) {
164 Clock(
165 scale = 1f,
166 viewModel = viewModel,
167 modifier = Modifier.align(Alignment.CenterVertically),
168 )
169 Spacer(modifier = Modifier.width(5.dp))
170 VariableDayDate(
171 viewModel = viewModel,
172 modifier =
173 Modifier.element(ShadeHeader.Elements.CollapsedContentStart)
174 .align(Alignment.CenterVertically),
175 )
176 }
177 },
178 {
179 if (isPrivacyChipVisible) {
180 Box(
181 modifier =
182 Modifier.height(CollapsedHeight)
183 .fillMaxWidth()
184 .padding(horizontal = horizontalPadding)
185 ) {
186 PrivacyChip(
187 viewModel = viewModel,
188 modifier = Modifier.align(Alignment.CenterEnd),
189 )
190 }
191 } else {
192 Row(
193 horizontalArrangement = Arrangement.End,
194 modifier =
195 Modifier.element(ShadeHeader.Elements.CollapsedContentEnd)
196 .padding(horizontal = horizontalPadding)
197 ) {
198 if (isLargeScreenLayout) {
199 ShadeCarrierGroup(
200 viewModel = viewModel,
201 modifier = Modifier.align(Alignment.CenterVertically),
202 )
203 }
204 SystemIconContainer(
205 viewModel = viewModel,
206 isClickable = isLargeScreenLayout,
207 modifier = Modifier.align(Alignment.CenterVertically)
208 ) {
209 StatusIcons(
210 viewModel = viewModel,
211 createTintedIconManager = createTintedIconManager,
212 statusBarIconController = statusBarIconController,
213 useExpandedFormat = useExpandedTextFormat,
214 modifier =
215 Modifier.align(Alignment.CenterVertically)
216 .padding(end = 6.dp)
217 .weight(1f, fill = false)
218 )
219 BatteryIcon(
220 createBatteryMeterViewController =
221 createBatteryMeterViewController,
222 useExpandedFormat = useExpandedTextFormat,
223 modifier = Modifier.align(Alignment.CenterVertically),
224 )
225 }
226 }
227 }
228 },
229 ),
230 ) { measurables, constraints ->
231 check(constraints.hasBoundedWidth)
232 check(measurables.size == 2)
233 check(measurables[0].size == 1)
234 check(measurables[1].size == 1)
235
236 val screenWidth = constraints.maxWidth
237 val cutoutWidthPx = cutoutWidth.roundToPx()
238 val height = max(cutoutHeight + (cutoutTop * 2), CollapsedHeight).roundToPx()
239 val childConstraints = Constraints.fixed((screenWidth - cutoutWidthPx) / 2, height)
240
241 val startMeasurable = measurables[0][0]
242 val endMeasurable = measurables[1][0]
243
244 val startPlaceable = startMeasurable.measure(childConstraints)
245 val endPlaceable = endMeasurable.measure(childConstraints)
246
247 layout(screenWidth, height) {
248 when (cutoutLocation) {
249 CutoutLocation.NONE,
250 CutoutLocation.RIGHT -> {
251 startPlaceable.placeRelative(x = 0, y = 0)
252 endPlaceable.placeRelative(
253 x = startPlaceable.width,
254 y = 0,
255 )
256 }
257 CutoutLocation.CENTER -> {
258 startPlaceable.placeRelative(x = 0, y = 0)
259 endPlaceable.placeRelative(
260 x = startPlaceable.width + cutoutWidthPx,
261 y = 0,
262 )
263 }
264 CutoutLocation.LEFT -> {
265 startPlaceable.placeRelative(
266 x = cutoutWidthPx,
267 y = 0,
268 )
269 endPlaceable.placeRelative(
270 x = startPlaceable.width + cutoutWidthPx,
271 y = 0,
272 )
273 }
274 }
275 }
276 }
277 }
278
279 @Composable
ExpandedShadeHeadernull280 fun SceneScope.ExpandedShadeHeader(
281 viewModel: ShadeHeaderViewModel,
282 createTintedIconManager: (ViewGroup, StatusBarLocation) -> TintedIconManager,
283 createBatteryMeterViewController: (ViewGroup, StatusBarLocation) -> BatteryMeterViewController,
284 statusBarIconController: StatusBarIconController,
285 modifier: Modifier = Modifier,
286 ) {
287 val isDisabled by viewModel.isDisabled.collectAsStateWithLifecycle()
288 if (isDisabled) {
289 return
290 }
291
292 val useExpandedFormat by remember {
293 derivedStateOf { shouldUseExpandedFormat(layoutState.transitionState) }
294 }
295
296 val isPrivacyChipVisible by viewModel.isPrivacyChipVisible.collectAsStateWithLifecycle()
297
298 Box(modifier = modifier.sysuiResTag(ShadeHeader.TestTags.Root)) {
299 if (isPrivacyChipVisible) {
300 Box(modifier = Modifier.height(CollapsedHeight).fillMaxWidth()) {
301 PrivacyChip(
302 viewModel = viewModel,
303 modifier = Modifier.align(Alignment.CenterEnd),
304 )
305 }
306 }
307 Column(
308 verticalArrangement = Arrangement.Bottom,
309 modifier =
310 Modifier.fillMaxWidth()
311 .defaultMinSize(minHeight = ShadeHeader.Dimensions.ExpandedHeight)
312 ) {
313 Box(modifier = Modifier.fillMaxWidth()) {
314 Box {
315 Clock(
316 scale = 2.57f,
317 viewModel = viewModel,
318 modifier = Modifier.align(Alignment.CenterStart),
319 )
320 }
321 Box(
322 modifier =
323 Modifier.element(ShadeHeader.Elements.ShadeCarrierGroup).fillMaxWidth()
324 ) {
325 ShadeCarrierGroup(
326 viewModel = viewModel,
327 modifier = Modifier.align(Alignment.CenterEnd),
328 )
329 }
330 }
331 Spacer(modifier = Modifier.width(5.dp))
332 Row(modifier = Modifier.element(ShadeHeader.Elements.ExpandedContent)) {
333 VariableDayDate(
334 viewModel = viewModel,
335 modifier = Modifier.widthIn(max = 90.dp).align(Alignment.CenterVertically),
336 )
337 Spacer(modifier = Modifier.weight(1f))
338 SystemIconContainer(viewModel = viewModel, isClickable = false) {
339 StatusIcons(
340 viewModel = viewModel,
341 createTintedIconManager = createTintedIconManager,
342 statusBarIconController = statusBarIconController,
343 useExpandedFormat = useExpandedFormat,
344 modifier =
345 Modifier.align(Alignment.CenterVertically)
346 .padding(end = 6.dp)
347 .weight(1f, fill = false),
348 )
349 BatteryIcon(
350 useExpandedFormat = useExpandedFormat,
351 createBatteryMeterViewController = createBatteryMeterViewController,
352 modifier = Modifier.align(Alignment.CenterVertically),
353 )
354 }
355 }
356 }
357 }
358 }
359
360 @Composable
SceneScopenull361 private fun SceneScope.Clock(
362 scale: Float,
363 viewModel: ShadeHeaderViewModel,
364 modifier: Modifier,
365 ) {
366 val layoutDirection = LocalLayoutDirection.current
367
368 Element(key = ShadeHeader.Elements.Clock, modifier = modifier) {
369 val animatedScale by animateElementFloatAsState(scale, ClockScale, canOverflow = false)
370 AndroidView(
371 factory = { context ->
372 Clock(
373 ContextThemeWrapper(context, R.style.Theme_SystemUI_QuickSettings_Header),
374 null,
375 )
376 },
377 modifier =
378 modifier
379 // use graphicsLayer instead of Modifier.scale to anchor transform
380 // to the (start, top) corner
381 .graphicsLayer {
382 scaleX = animatedScale
383 scaleY = animatedScale
384 transformOrigin =
385 TransformOrigin(
386 when (layoutDirection) {
387 LayoutDirection.Ltr -> 0f
388 LayoutDirection.Rtl -> 1f
389 },
390 0.5f
391 )
392 }
393 .clickable { viewModel.onClockClicked() }
394 )
395 }
396 }
397
398 @Composable
BatteryIconnull399 private fun BatteryIcon(
400 createBatteryMeterViewController: (ViewGroup, StatusBarLocation) -> BatteryMeterViewController,
401 useExpandedFormat: Boolean,
402 modifier: Modifier = Modifier,
403 ) {
404 AndroidView(
405 factory = { context ->
406 val batteryIcon = BatteryMeterView(context, null)
407 batteryIcon.setPercentShowMode(BatteryMeterView.MODE_ON)
408
409 val themedContext =
410 ContextThemeWrapper(context, R.style.Theme_SystemUI_QuickSettings_Header)
411 val fg = Utils.getColorAttrDefaultColor(themedContext, android.R.attr.textColorPrimary)
412 val bg =
413 Utils.getColorAttrDefaultColor(
414 themedContext,
415 android.R.attr.textColorPrimaryInverse,
416 )
417
418 // [BatteryMeterView.updateColors] is an old method that was built to distinguish
419 // between dual-tone colors and single-tone. The current icon is only single-tone, so
420 // the final [fg] is the only one we actually need
421 batteryIcon.updateColors(fg, bg, fg)
422
423 val batteryMaterViewController =
424 createBatteryMeterViewController(batteryIcon, StatusBarLocation.QS)
425 batteryMaterViewController.init()
426 batteryMaterViewController.ignoreTunerUpdates()
427
428 batteryIcon
429 },
430 update = { batteryIcon ->
431 // TODO(b/298525212): use MODE_ESTIMATE in collapsed view when the screen
432 // has no center cutout. See [QsBatteryModeController.getBatteryMode]
433 batteryIcon.setPercentShowMode(
434 if (useExpandedFormat) {
435 BatteryMeterView.MODE_ESTIMATE
436 } else {
437 BatteryMeterView.MODE_ON
438 }
439 )
440 },
441 modifier = modifier,
442 )
443 }
444
445 @Composable
ShadeCarrierGroupnull446 private fun ShadeCarrierGroup(
447 viewModel: ShadeHeaderViewModel,
448 modifier: Modifier = Modifier,
449 ) {
450 Row(modifier = modifier) {
451 val subIds by viewModel.mobileSubIds.collectAsStateWithLifecycle()
452
453 for (subId in subIds) {
454 Spacer(modifier = Modifier.width(5.dp))
455 AndroidView(
456 factory = { context ->
457 ModernShadeCarrierGroupMobileView.constructAndBind(
458 context = context,
459 logger = viewModel.mobileIconsViewModel.logger,
460 slot = "mobile_carrier_shade_group",
461 viewModel =
462 (viewModel.mobileIconsViewModel.viewModelForSub(
463 subId,
464 StatusBarLocation.SHADE_CARRIER_GROUP
465 ) as ShadeCarrierGroupMobileIconViewModel),
466 )
467 .also { it.setOnClickListener { viewModel.onShadeCarrierGroupClicked() } }
468 },
469 )
470 }
471 }
472 }
473
474 @Composable
StatusIconsnull475 private fun SceneScope.StatusIcons(
476 viewModel: ShadeHeaderViewModel,
477 createTintedIconManager: (ViewGroup, StatusBarLocation) -> TintedIconManager,
478 statusBarIconController: StatusBarIconController,
479 useExpandedFormat: Boolean,
480 modifier: Modifier = Modifier,
481 ) {
482 val carrierIconSlots =
483 listOf(stringResource(id = com.android.internal.R.string.status_bar_mobile))
484 val cameraSlot = stringResource(id = com.android.internal.R.string.status_bar_camera)
485 val micSlot = stringResource(id = com.android.internal.R.string.status_bar_microphone)
486 val locationSlot = stringResource(id = com.android.internal.R.string.status_bar_location)
487
488 val isSingleCarrier by viewModel.isSingleCarrier.collectAsStateWithLifecycle()
489 val isPrivacyChipEnabled by viewModel.isPrivacyChipEnabled.collectAsStateWithLifecycle()
490 val isMicCameraIndicationEnabled by
491 viewModel.isMicCameraIndicationEnabled.collectAsStateWithLifecycle()
492 val isLocationIndicationEnabled by
493 viewModel.isLocationIndicationEnabled.collectAsStateWithLifecycle()
494
495 AndroidView(
496 factory = { context ->
497 val themedContext =
498 ContextThemeWrapper(context, R.style.Theme_SystemUI_QuickSettings_Header)
499 val iconContainer = StatusIconContainer(themedContext, null)
500 val iconManager = createTintedIconManager(iconContainer, StatusBarLocation.QS)
501 iconManager.setTint(
502 Utils.getColorAttrDefaultColor(themedContext, android.R.attr.textColorPrimary),
503 Utils.getColorAttrDefaultColor(
504 themedContext,
505 android.R.attr.textColorPrimaryInverse
506 ),
507 )
508 statusBarIconController.addIconGroup(iconManager)
509
510 iconContainer
511 },
512 update = { iconContainer ->
513 iconContainer.setQsExpansionTransitioning(
514 layoutState.isTransitioningBetween(Scenes.Shade, Scenes.QuickSettings)
515 )
516 if (isSingleCarrier || !useExpandedFormat) {
517 iconContainer.removeIgnoredSlots(carrierIconSlots)
518 } else {
519 iconContainer.addIgnoredSlots(carrierIconSlots)
520 }
521
522 if (isPrivacyChipEnabled) {
523 if (isMicCameraIndicationEnabled) {
524 iconContainer.addIgnoredSlot(cameraSlot)
525 iconContainer.addIgnoredSlot(micSlot)
526 } else {
527 iconContainer.removeIgnoredSlot(cameraSlot)
528 iconContainer.removeIgnoredSlot(micSlot)
529 }
530 if (isLocationIndicationEnabled) {
531 iconContainer.addIgnoredSlot(locationSlot)
532 } else {
533 iconContainer.removeIgnoredSlot(locationSlot)
534 }
535 } else {
536 iconContainer.removeIgnoredSlot(cameraSlot)
537 iconContainer.removeIgnoredSlot(micSlot)
538 iconContainer.removeIgnoredSlot(locationSlot)
539 }
540 },
541 modifier = modifier,
542 )
543 }
544
545 @Composable
SystemIconContainernull546 private fun SystemIconContainer(
547 viewModel: ShadeHeaderViewModel,
548 isClickable: Boolean,
549 modifier: Modifier = Modifier,
550 content: @Composable RowScope.() -> Unit
551 ) {
552 val interactionSource = remember { MutableInteractionSource() }
553 val isHovered by interactionSource.collectIsHoveredAsState()
554
555 val hoverModifier = Modifier
556 .clip(RoundedCornerShape(CollapsedHeight / 4))
557 .background(MaterialTheme.colorScheme.onScrimDim)
558
559 Row(
560 modifier = modifier
561 .height(CollapsedHeight)
562 .padding(vertical = CollapsedHeight / 4)
563 .thenIf(isClickable) {
564 Modifier.clickable(
565 interactionSource = interactionSource,
566 indication = null,
567 onClick = { viewModel.onSystemIconContainerClicked() },
568 )
569 }
570 .thenIf(isHovered) { hoverModifier },
571 content = content,
572 )
573 }
574
575 @Composable
PrivacyChipnull576 private fun SceneScope.PrivacyChip(
577 viewModel: ShadeHeaderViewModel,
578 modifier: Modifier = Modifier,
579 ) {
580 val privacyList by viewModel.privacyItems.collectAsStateWithLifecycle()
581
582 AndroidView(
583 factory = { context ->
584 val view =
585 OngoingPrivacyChip(context, null).also { privacyChip ->
586 privacyChip.privacyList = privacyList
587 privacyChip.setOnClickListener { viewModel.onPrivacyChipClicked(privacyChip) }
588 }
589 view
590 },
591 update = { it.privacyList = privacyList },
592 modifier = modifier.element(ShadeHeader.Elements.PrivacyChip),
593 )
594 }
595
shouldUseExpandedFormatnull596 private fun shouldUseExpandedFormat(state: TransitionState): Boolean {
597 return when (state) {
598 is TransitionState.Idle -> {
599 state.currentScene == Scenes.QuickSettings
600 }
601 is TransitionState.Transition -> {
602 ((state.isTransitioning(Scenes.Shade, Scenes.QuickSettings) ||
603 state.isTransitioning(Scenes.Gone, Scenes.QuickSettings) ||
604 state.isTransitioning(Scenes.Lockscreen, Scenes.QuickSettings)) &&
605 state.progress >= 0.5) ||
606 ((state.isTransitioning(Scenes.QuickSettings, Scenes.Shade) ||
607 state.isTransitioning(Scenes.QuickSettings, Scenes.Gone) ||
608 state.isTransitioning(Scenes.QuickSettings, Scenes.Lockscreen)) &&
609 state.progress <= 0.5)
610 }
611 }
612 }
613