1 /*
2  * 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 package com.android.systemui.shade.ui.composable
18 
19 import android.view.ViewGroup
20 import androidx.compose.animation.core.animateDpAsState
21 import androidx.compose.animation.core.animateFloatAsState
22 import androidx.compose.animation.core.tween
23 import androidx.compose.foundation.background
24 import androidx.compose.foundation.clickable
25 import androidx.compose.foundation.clipScrollableContainer
26 import androidx.compose.foundation.gestures.Orientation
27 import androidx.compose.foundation.layout.Arrangement
28 import androidx.compose.foundation.layout.Box
29 import androidx.compose.foundation.layout.Column
30 import androidx.compose.foundation.layout.Row
31 import androidx.compose.foundation.layout.WindowInsets
32 import androidx.compose.foundation.layout.asPaddingValues
33 import androidx.compose.foundation.layout.displayCutoutPadding
34 import androidx.compose.foundation.layout.fillMaxHeight
35 import androidx.compose.foundation.layout.fillMaxSize
36 import androidx.compose.foundation.layout.fillMaxWidth
37 import androidx.compose.foundation.layout.navigationBars
38 import androidx.compose.foundation.layout.offset
39 import androidx.compose.foundation.layout.padding
40 import androidx.compose.foundation.rememberScrollState
41 import androidx.compose.foundation.shape.RoundedCornerShape
42 import androidx.compose.foundation.verticalScroll
43 import androidx.compose.material3.windowsizeclass.WindowHeightSizeClass
44 import androidx.compose.runtime.Composable
45 import androidx.compose.runtime.DisposableEffect
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.ui.Alignment
51 import androidx.compose.ui.Modifier
52 import androidx.compose.ui.graphics.CompositingStrategy
53 import androidx.compose.ui.graphics.graphicsLayer
54 import androidx.compose.ui.layout.Layout
55 import androidx.compose.ui.layout.layoutId
56 import androidx.compose.ui.platform.LocalDensity
57 import androidx.compose.ui.platform.LocalLifecycleOwner
58 import androidx.compose.ui.res.colorResource
59 import androidx.compose.ui.unit.dp
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.UserAction
66 import com.android.compose.animation.scene.UserActionResult
67 import com.android.compose.animation.scene.animateSceneDpAsState
68 import com.android.compose.animation.scene.animateSceneFloatAsState
69 import com.android.compose.modifiers.padding
70 import com.android.compose.modifiers.thenIf
71 import com.android.compose.windowsizeclass.LocalWindowSizeClass
72 import com.android.systemui.battery.BatteryMeterViewController
73 import com.android.systemui.common.ui.compose.windowinsets.CutoutLocation
74 import com.android.systemui.common.ui.compose.windowinsets.LocalDisplayCutout
75 import com.android.systemui.common.ui.compose.windowinsets.LocalScreenCornerRadius
76 import com.android.systemui.compose.modifiers.sysuiResTag
77 import com.android.systemui.dagger.SysUISingleton
78 import com.android.systemui.media.controls.ui.composable.MediaCarousel
79 import com.android.systemui.media.controls.ui.controller.MediaCarouselController
80 import com.android.systemui.media.controls.ui.controller.MediaHierarchyManager
81 import com.android.systemui.media.controls.ui.view.MediaHost
82 import com.android.systemui.media.controls.ui.view.MediaHostState
83 import com.android.systemui.media.dagger.MediaModule.QUICK_QS_PANEL
84 import com.android.systemui.notifications.ui.composable.NotificationScrollingStack
85 import com.android.systemui.qs.footer.ui.compose.FooterActionsWithAnimatedVisibility
86 import com.android.systemui.qs.ui.composable.BrightnessMirror
87 import com.android.systemui.qs.ui.composable.QSMediaMeasurePolicy
88 import com.android.systemui.qs.ui.composable.QuickSettings
89 import com.android.systemui.qs.ui.composable.QuickSettings.SharedValues.MediaLandscapeTopOffset
90 import com.android.systemui.qs.ui.composable.QuickSettings.SharedValues.MediaOffset.InQQS
91 import com.android.systemui.res.R
92 import com.android.systemui.scene.session.ui.composable.SaveableSession
93 import com.android.systemui.scene.shared.model.Scenes
94 import com.android.systemui.scene.ui.composable.ComposableScene
95 import com.android.systemui.shade.shared.model.ShadeMode
96 import com.android.systemui.shade.ui.viewmodel.ShadeSceneViewModel
97 import com.android.systemui.statusbar.notification.stack.ui.view.NotificationScrollView
98 import com.android.systemui.statusbar.phone.StatusBarLocation
99 import com.android.systemui.statusbar.phone.ui.StatusBarIconController
100 import com.android.systemui.statusbar.phone.ui.TintedIconManager
101 import dagger.Lazy
102 import javax.inject.Inject
103 import javax.inject.Named
104 import kotlin.math.roundToInt
105 import kotlinx.coroutines.flow.StateFlow
106 
107 object Shade {
108     object Elements {
109         val MediaCarousel = ElementKey("ShadeMediaCarousel")
110         val BackgroundScrim =
111             ElementKey("ShadeBackgroundScrim", scenePicker = LowestZIndexScenePicker)
112         val SplitShadeStartColumn = ElementKey("SplitShadeStartColumn")
113     }
114 
115     object Dimensions {
116         val ScrimCornerSize = 32.dp
117         val HorizontalPadding = 16.dp
118         val ScrimOverscrollLimit = 32.dp
119         const val ScrimVisibilityThreshold = 5f
120     }
121 
122     object Shapes {
123         val Scrim =
124             RoundedCornerShape(
125                 topStart = Dimensions.ScrimCornerSize,
126                 topEnd = Dimensions.ScrimCornerSize,
127             )
128     }
129 }
130 
131 /** The shade scene shows scrolling list of notifications and some of the quick setting tiles. */
132 @SysUISingleton
133 class ShadeScene
134 @Inject
135 constructor(
136     private val shadeSession: SaveableSession,
137     private val notificationStackScrollView: Lazy<NotificationScrollView>,
138     private val viewModel: ShadeSceneViewModel,
139     private val tintedIconManagerFactory: TintedIconManager.Factory,
140     private val batteryMeterViewControllerFactory: BatteryMeterViewController.Factory,
141     private val statusBarIconController: StatusBarIconController,
142     private val mediaCarouselController: MediaCarouselController,
143     @Named(QUICK_QS_PANEL) private val mediaHost: MediaHost,
144 ) : ComposableScene {
145 
146     override val key = Scenes.Shade
147 
148     override val destinationScenes: StateFlow<Map<UserAction, UserActionResult>> =
149         viewModel.destinationScenes
150 
151     @Composable
Contentnull152     override fun SceneScope.Content(
153         modifier: Modifier,
154     ) =
155         ShadeScene(
156             notificationStackScrollView.get(),
157             viewModel = viewModel,
158             createTintedIconManager = tintedIconManagerFactory::create,
159             createBatteryMeterViewController = batteryMeterViewControllerFactory::create,
160             statusBarIconController = statusBarIconController,
161             mediaCarouselController = mediaCarouselController,
162             mediaHost = mediaHost,
163             modifier = modifier,
164             shadeSession = shadeSession,
165         )
166 
167     init {
168         mediaHost.expansion = MediaHostState.EXPANDED
169         mediaHost.showsOnlyActiveMedia = true
170         mediaHost.init(MediaHierarchyManager.LOCATION_QQS)
171     }
172 }
173 
174 @Composable
ShadeScenenull175 private fun SceneScope.ShadeScene(
176     notificationStackScrollView: NotificationScrollView,
177     viewModel: ShadeSceneViewModel,
178     createTintedIconManager: (ViewGroup, StatusBarLocation) -> TintedIconManager,
179     createBatteryMeterViewController: (ViewGroup, StatusBarLocation) -> BatteryMeterViewController,
180     statusBarIconController: StatusBarIconController,
181     mediaCarouselController: MediaCarouselController,
182     mediaHost: MediaHost,
183     modifier: Modifier = Modifier,
184     shadeSession: SaveableSession,
185 ) {
186     val shadeMode by viewModel.shadeMode.collectAsStateWithLifecycle()
187     when (shadeMode) {
188         is ShadeMode.Single ->
189             SingleShade(
190                 notificationStackScrollView = notificationStackScrollView,
191                 viewModel = viewModel,
192                 createTintedIconManager = createTintedIconManager,
193                 createBatteryMeterViewController = createBatteryMeterViewController,
194                 statusBarIconController = statusBarIconController,
195                 mediaCarouselController = mediaCarouselController,
196                 mediaHost = mediaHost,
197                 modifier = modifier,
198                 shadeSession = shadeSession,
199             )
200         is ShadeMode.Split ->
201             SplitShade(
202                 notificationStackScrollView = notificationStackScrollView,
203                 viewModel = viewModel,
204                 createTintedIconManager = createTintedIconManager,
205                 createBatteryMeterViewController = createBatteryMeterViewController,
206                 statusBarIconController = statusBarIconController,
207                 mediaCarouselController = mediaCarouselController,
208                 mediaHost = mediaHost,
209                 modifier = modifier,
210                 shadeSession = shadeSession,
211             )
212         is ShadeMode.Dual -> error("Dual shade is not yet implemented!")
213     }
214 }
215 
216 @Composable
SingleShadenull217 private fun SceneScope.SingleShade(
218     notificationStackScrollView: NotificationScrollView,
219     viewModel: ShadeSceneViewModel,
220     createTintedIconManager: (ViewGroup, StatusBarLocation) -> TintedIconManager,
221     createBatteryMeterViewController: (ViewGroup, StatusBarLocation) -> BatteryMeterViewController,
222     statusBarIconController: StatusBarIconController,
223     mediaCarouselController: MediaCarouselController,
224     mediaHost: MediaHost,
225     modifier: Modifier = Modifier,
226     shadeSession: SaveableSession,
227 ) {
228     val cutoutLocation = LocalDisplayCutout.current.location
229 
230     val maxNotifScrimTop = remember { mutableStateOf(0f) }
231     val tileSquishiness by
232         animateSceneFloatAsState(
233             value = 1f,
234             key = QuickSettings.SharedValues.TilesSquishiness,
235             canOverflow = false
236         )
237     val isClickable by viewModel.isClickable.collectAsStateWithLifecycle()
238     val isMediaVisible by viewModel.isMediaVisible.collectAsStateWithLifecycle()
239 
240     val shouldPunchHoleBehindScrim =
241         layoutState.isTransitioningBetween(Scenes.Gone, Scenes.Shade) ||
242             layoutState.isTransitioningBetween(Scenes.Lockscreen, Scenes.Shade)
243     // Media is visible and we are in landscape on a small height screen
244     val mediaInRow =
245         isMediaVisible &&
246             LocalWindowSizeClass.current.heightSizeClass == WindowHeightSizeClass.Compact
247     val mediaOffset by
248         animateSceneDpAsState(value = InQQS, key = MediaLandscapeTopOffset, canOverflow = false)
249 
250     Box(
251         modifier =
252             modifier.thenIf(shouldPunchHoleBehindScrim) {
253                 // Render the scene to an offscreen buffer so that BlendMode.DstOut only clears this
254                 // scene
255                 // (and not the one under it) during a scene transition.
256                 Modifier.graphicsLayer(compositingStrategy = CompositingStrategy.Offscreen)
257             }
258     ) {
259         Box(
260             modifier =
261                 Modifier.fillMaxSize()
262                     .element(Shade.Elements.BackgroundScrim)
263                     .background(colorResource(R.color.shade_scrim_background_dark)),
264         )
265         Layout(
266             contents =
267                 listOf(
268                     {
269                         Column(
270                             horizontalAlignment = Alignment.CenterHorizontally,
271                             modifier =
272                                 Modifier.fillMaxWidth()
273                                     .thenIf(isClickable) {
274                                         Modifier.clickable(
275                                             onClick = { viewModel.onContentClicked() }
276                                         )
277                                     }
278                                     .thenIf(cutoutLocation != CutoutLocation.CENTER) {
279                                         Modifier.displayCutoutPadding()
280                                     },
281                         ) {
282                             CollapsedShadeHeader(
283                                 viewModel = viewModel.shadeHeaderViewModel,
284                                 createTintedIconManager = createTintedIconManager,
285                                 createBatteryMeterViewController = createBatteryMeterViewController,
286                                 statusBarIconController = statusBarIconController,
287                             )
288 
289                             val content: @Composable () -> Unit = {
290                                 Box(
291                                     Modifier.element(QuickSettings.Elements.QuickQuickSettings)
292                                         .layoutId(QSMediaMeasurePolicy.LayoutId.QS)
293                                 ) {
294                                     QuickSettings(
295                                         viewModel.qsSceneAdapter,
296                                         { viewModel.qsSceneAdapter.qqsHeight },
297                                         isSplitShade = false,
298                                         squishiness = { tileSquishiness },
299                                     )
300                                 }
301 
302                                 MediaCarousel(
303                                     isVisible = isMediaVisible,
304                                     mediaHost = mediaHost,
305                                     modifier =
306                                         Modifier.fillMaxWidth()
307                                             .layoutId(QSMediaMeasurePolicy.LayoutId.Media),
308                                     carouselController = mediaCarouselController,
309                                 )
310                             }
311                             val landscapeQsMediaMeasurePolicy = remember {
312                                 QSMediaMeasurePolicy(
313                                     { viewModel.qsSceneAdapter.qqsHeight },
314                                     { mediaOffset.roundToPx() },
315                                 )
316                             }
317                             if (mediaInRow) {
318                                 Layout(
319                                     content = content,
320                                     measurePolicy = landscapeQsMediaMeasurePolicy,
321                                 )
322                             } else {
323                                 content()
324                             }
325                         }
326                     },
327                     {
328                         NotificationScrollingStack(
329                             shadeSession = shadeSession,
330                             stackScrollView = notificationStackScrollView,
331                             viewModel = viewModel.notifications,
332                             maxScrimTop = { maxNotifScrimTop.value },
333                             shadeMode = ShadeMode.Single,
334                             shouldPunchHoleBehindScrim = shouldPunchHoleBehindScrim,
335                         )
336                     },
337                 )
338         ) { measurables, constraints ->
339             check(measurables.size == 2)
340             check(measurables[0].size == 1)
341             check(measurables[1].size == 1)
342 
343             val quickSettingsPlaceable = measurables[0][0].measure(constraints)
344             val notificationsPlaceable = measurables[1][0].measure(constraints)
345 
346             maxNotifScrimTop.value = quickSettingsPlaceable.height.toFloat()
347 
348             layout(constraints.maxWidth, constraints.maxHeight) {
349                 quickSettingsPlaceable.placeRelative(x = 0, y = 0)
350                 notificationsPlaceable.placeRelative(x = 0, y = maxNotifScrimTop.value.roundToInt())
351             }
352         }
353     }
354 }
355 
356 @Composable
SplitShadenull357 private fun SceneScope.SplitShade(
358     notificationStackScrollView: NotificationScrollView,
359     viewModel: ShadeSceneViewModel,
360     createTintedIconManager: (ViewGroup, StatusBarLocation) -> TintedIconManager,
361     createBatteryMeterViewController: (ViewGroup, StatusBarLocation) -> BatteryMeterViewController,
362     statusBarIconController: StatusBarIconController,
363     mediaCarouselController: MediaCarouselController,
364     mediaHost: MediaHost,
365     modifier: Modifier = Modifier,
366     shadeSession: SaveableSession,
367 ) {
368     val screenCornerRadius = LocalScreenCornerRadius.current
369 
370     val isCustomizing by viewModel.qsSceneAdapter.isCustomizing.collectAsStateWithLifecycle()
371     val isCustomizerShowing by
372         viewModel.qsSceneAdapter.isCustomizerShowing.collectAsStateWithLifecycle()
373     val customizingAnimationDuration by
374         viewModel.qsSceneAdapter.customizerAnimationDuration.collectAsStateWithLifecycle()
375     val lifecycleOwner = LocalLifecycleOwner.current
376     val footerActionsViewModel =
377         remember(lifecycleOwner, viewModel) { viewModel.getFooterActionsViewModel(lifecycleOwner) }
378     val tileSquishiness by
379         animateSceneFloatAsState(
380             value = 1f,
381             key = QuickSettings.SharedValues.TilesSquishiness,
382             canOverflow = false,
383         )
384     val unfoldTranslationXForStartSide by
385         viewModel
386             .unfoldTranslationX(
387                 isOnStartSide = true,
388             )
389             .collectAsStateWithLifecycle(0f)
390     val unfoldTranslationXForEndSide by
391         viewModel
392             .unfoldTranslationX(
393                 isOnStartSide = false,
394             )
395             .collectAsStateWithLifecycle(0f)
396 
397     val navBarBottomHeight = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
398     val bottomPadding by
399         animateDpAsState(
400             targetValue = if (isCustomizing) 0.dp else navBarBottomHeight,
401             animationSpec = tween(customizingAnimationDuration),
402             label = "animateQSSceneBottomPaddingAsState"
403         )
404     val density = LocalDensity.current
405     LaunchedEffect(navBarBottomHeight, density) {
406         with(density) {
407             viewModel.qsSceneAdapter.applyBottomNavBarPadding(navBarBottomHeight.roundToPx())
408         }
409     }
410 
411     val quickSettingsScrollState = rememberScrollState()
412     val isScrollable = layoutState.transitionState is TransitionState.Idle
413     LaunchedEffect(isCustomizing, quickSettingsScrollState) {
414         if (isCustomizing) {
415             quickSettingsScrollState.scrollTo(0)
416         }
417     }
418 
419     val brightnessMirrorShowing by
420         viewModel.brightnessMirrorViewModel.isShowing.collectAsStateWithLifecycle()
421     val contentAlpha by
422         animateFloatAsState(
423             targetValue = if (brightnessMirrorShowing) 0f else 1f,
424             label = "alphaAnimationBrightnessMirrorContentHiding",
425         )
426 
427     viewModel.notifications.setAlphaForBrightnessMirror(contentAlpha)
428     DisposableEffect(Unit) { onDispose { viewModel.notifications.setAlphaForBrightnessMirror(1f) } }
429 
430     val isMediaVisible by viewModel.isMediaVisible.collectAsStateWithLifecycle()
431 
432     val brightnessMirrorShowingModifier = Modifier.graphicsLayer { alpha = contentAlpha }
433 
434     Box(
435         modifier =
436             modifier
437                 .fillMaxSize()
438                 .element(Shade.Elements.BackgroundScrim)
439                 // Cannot set the alpha of the whole element to 0, because the mirror should be
440                 // in the QS column.
441                 .background(
442                     colorResource(R.color.shade_scrim_background_dark).copy(alpha = contentAlpha)
443                 )
444     ) {
445         Column(
446             modifier = Modifier.fillMaxSize(),
447         ) {
448             CollapsedShadeHeader(
449                 viewModel = viewModel.shadeHeaderViewModel,
450                 createTintedIconManager = createTintedIconManager,
451                 createBatteryMeterViewController = createBatteryMeterViewController,
452                 statusBarIconController = statusBarIconController,
453                 modifier =
454                     Modifier.then(brightnessMirrorShowingModifier)
455                         .padding(
456                             horizontal = { unfoldTranslationXForStartSide.roundToInt() },
457                         )
458             )
459 
460             Row(modifier = Modifier.fillMaxWidth().weight(1f)) {
461                 Box(
462                     modifier =
463                         Modifier.element(Shade.Elements.SplitShadeStartColumn)
464                             .weight(1f)
465                             .graphicsLayer { translationX = unfoldTranslationXForStartSide },
466                 ) {
467                     BrightnessMirror(
468                         viewModel = viewModel.brightnessMirrorViewModel,
469                         qsSceneAdapter = viewModel.qsSceneAdapter,
470                         // Need to remove the offset of the header height, as the mirror uses
471                         // the position of the Brightness slider in the window
472                         modifier = Modifier.offset(y = -ShadeHeader.Dimensions.CollapsedHeight)
473                     )
474                     Column(
475                         verticalArrangement = Arrangement.Top,
476                         modifier = Modifier.fillMaxSize().padding(bottom = bottomPadding),
477                     ) {
478                         Column(
479                             modifier =
480                                 Modifier.fillMaxSize()
481                                     .sysuiResTag("expanded_qs_scroll_view")
482                                     .weight(1f)
483                                     .thenIf(!isCustomizerShowing) {
484                                         Modifier.verticalNestedScrollToScene()
485                                             .verticalScroll(
486                                                 quickSettingsScrollState,
487                                                 enabled = isScrollable
488                                             )
489                                             .clipScrollableContainer(Orientation.Horizontal)
490                                     }
491                                     .then(brightnessMirrorShowingModifier)
492                         ) {
493                             Box(
494                                 modifier =
495                                     Modifier.element(QuickSettings.Elements.SplitShadeQuickSettings)
496                             ) {
497                                 QuickSettings(
498                                     qsSceneAdapter = viewModel.qsSceneAdapter,
499                                     heightProvider = { viewModel.qsSceneAdapter.qsHeight },
500                                     isSplitShade = true,
501                                     modifier = Modifier.fillMaxWidth(),
502                                     squishiness = { tileSquishiness },
503                                 )
504                             }
505 
506                             MediaCarousel(
507                                 isVisible = isMediaVisible,
508                                 mediaHost = mediaHost,
509                                 modifier = Modifier.fillMaxWidth(),
510                                 carouselController = mediaCarouselController,
511                             )
512                         }
513                         FooterActionsWithAnimatedVisibility(
514                             viewModel = footerActionsViewModel,
515                             isCustomizing = isCustomizing,
516                             customizingAnimationDuration = customizingAnimationDuration,
517                             lifecycleOwner = lifecycleOwner,
518                             modifier =
519                                 Modifier.align(Alignment.CenterHorizontally)
520                                     .sysuiResTag("qs_footer_actions")
521                                     .then(brightnessMirrorShowingModifier),
522                         )
523                     }
524                 }
525 
526                 NotificationScrollingStack(
527                     shadeSession = shadeSession,
528                     stackScrollView = notificationStackScrollView,
529                     viewModel = viewModel.notifications,
530                     maxScrimTop = { 0f },
531                     shouldPunchHoleBehindScrim = false,
532                     shadeMode = ShadeMode.Split,
533                     modifier =
534                         Modifier.weight(1f)
535                             .fillMaxHeight()
536                             .padding(end = screenCornerRadius / 2f, bottom = navBarBottomHeight)
537                             .then(brightnessMirrorShowingModifier)
538                 )
539             }
540         }
541     }
542 }
543