Implement Bundle Header in Compose

First compose version of BundleHeader that will be used inside of a
ComposeView within `NotificationChildrenContainer`.

Test: Manual tests in Gallery app, as well as screenshot test added for
all current variants.
Flag: com.android.systemui.notification_bundle_ui
Bug: b/389839492 b/394476643 b/394476670 b/394478037 b/394478046

Change-Id: Ibaf1ddff3d1224f6d31e9cef663a6fdbcb2b1ce2
diff --git a/packages/SystemUI/compose/features/src/com/android/systemui/notifications/ui/composable/row/BundleHeader.kt b/packages/SystemUI/compose/features/src/com/android/systemui/notifications/ui/composable/row/BundleHeader.kt
new file mode 100644
index 0000000..d7740a4
--- /dev/null
+++ b/packages/SystemUI/compose/features/src/com/android/systemui/notifications/ui/composable/row/BundleHeader.kt
@@ -0,0 +1,193 @@
+/*
+ * Copyright (C) 2025 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.systemui.notifications.ui.composable.row
+
+import android.content.Context
+import android.graphics.drawable.Drawable
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.layout.Layout
+import androidx.compose.ui.platform.ComposeView
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.constrainHeight
+import androidx.compose.ui.unit.constrainWidth
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.util.fastForEach
+import androidx.compose.ui.util.fastMap
+import androidx.compose.ui.util.fastMaxOfOrDefault
+import androidx.compose.ui.util.fastSumBy
+import com.android.compose.animation.scene.ContentScope
+import com.android.compose.animation.scene.ElementKey
+import com.android.compose.animation.scene.SceneKey
+import com.android.compose.animation.scene.SceneTransitionLayout
+import com.android.compose.theme.PlatformTheme
+import com.android.compose.ui.graphics.painter.rememberDrawablePainter
+import com.android.systemui.statusbar.notification.row.ui.viewmodel.BundleHeaderViewModel
+
+object BundleHeader {
+    object Scenes {
+        val Collapsed = SceneKey("Collapsed")
+        val Expanded = SceneKey("Expanded")
+    }
+
+    object Elements {
+        val PreviewIcon1 = ElementKey("PreviewIcon1")
+        val PreviewIcon2 = ElementKey("PreviewIcon2")
+        val PreviewIcon3 = ElementKey("PreviewIcon3")
+        val TitleText = ElementKey("TitleText")
+    }
+}
+
+fun createComposeView(viewModel: BundleHeaderViewModel, context: Context): ComposeView {
+    // TODO(b/399588047): Check if we can init PlatformTheme once instead of once per ComposeView
+    return ComposeView(context).apply { setContent { PlatformTheme { BundleHeader(viewModel) } } }
+}
+
+@Composable
+fun BundleHeader(viewModel: BundleHeaderViewModel, modifier: Modifier = Modifier) {
+    Box(modifier) {
+        Background(background = viewModel.backgroundDrawable, modifier = Modifier.matchParentSize())
+        val scope = rememberCoroutineScope()
+        SceneTransitionLayout(
+            state = viewModel.state,
+            modifier =
+                Modifier.clickable(
+                    onClick = { viewModel.onHeaderClicked(scope) },
+                    interactionSource = null,
+                    indication = null,
+                ),
+        ) {
+            scene(BundleHeader.Scenes.Collapsed) {
+                BundleHeaderContent(viewModel, collapsed = true)
+            }
+            scene(BundleHeader.Scenes.Expanded) {
+                BundleHeaderContent(viewModel, collapsed = false)
+            }
+        }
+    }
+}
+
+@Composable
+private fun Background(background: Drawable?, modifier: Modifier = Modifier) {
+    if (background != null) {
+        val painter = rememberDrawablePainter(drawable = background)
+        Image(
+            painter = painter,
+            contentDescription = null,
+            contentScale = ContentScale.Crop,
+            modifier = modifier,
+        )
+    }
+}
+
+@OptIn(ExperimentalMaterial3ExpressiveApi::class)
+@Composable
+private fun ContentScope.BundleHeaderContent(
+    viewModel: BundleHeaderViewModel,
+    collapsed: Boolean,
+    modifier: Modifier = Modifier,
+) {
+    Row(
+        verticalAlignment = Alignment.CenterVertically,
+        modifier = modifier.padding(vertical = 16.dp),
+    ) {
+        BundleIcon(viewModel.bundleIcon, modifier = Modifier.padding(horizontal = 16.dp))
+        Text(
+            text = viewModel.titleText,
+            style = MaterialTheme.typography.titleMediumEmphasized,
+            color = MaterialTheme.colorScheme.primary,
+            overflow = TextOverflow.Ellipsis,
+            maxLines = 1,
+            modifier = Modifier.element(BundleHeader.Elements.TitleText).weight(1f),
+        )
+
+        if (collapsed && viewModel.previewIcons.isNotEmpty()) {
+            BundlePreviewIcons(
+                previewDrawables = viewModel.previewIcons,
+                modifier = Modifier.padding(start = 8.dp),
+            )
+        }
+
+        ExpansionControl(
+            collapsed = collapsed,
+            hasUnread = viewModel.hasUnreadMessages,
+            numberToShow = viewModel.numberOfChildren,
+            modifier = Modifier.padding(start = 8.dp, end = 16.dp),
+        )
+    }
+}
+
+@Composable
+private fun ContentScope.BundlePreviewIcons(
+    previewDrawables: List<Drawable>,
+    modifier: Modifier = Modifier,
+) {
+    check(previewDrawables.isNotEmpty())
+    val iconSize = 32.dp
+    HalfOverlappingReversedRow(modifier = modifier) {
+        PreviewIcon(
+            drawable = previewDrawables[0],
+            modifier = Modifier.element(BundleHeader.Elements.PreviewIcon1).size(iconSize),
+        )
+        if (previewDrawables.size < 2) return@HalfOverlappingReversedRow
+        PreviewIcon(
+            drawable = previewDrawables[1],
+            modifier = Modifier.element(BundleHeader.Elements.PreviewIcon2).size(iconSize),
+        )
+        if (previewDrawables.size < 3) return@HalfOverlappingReversedRow
+        PreviewIcon(
+            drawable = previewDrawables[2],
+            modifier = Modifier.element(BundleHeader.Elements.PreviewIcon3).size(iconSize),
+        )
+    }
+}
+
+@Composable
+private fun HalfOverlappingReversedRow(
+    modifier: Modifier = Modifier,
+    content: @Composable () -> Unit,
+) {
+    Layout(modifier = modifier, content = content) { measurables, constraints ->
+        val placeables = measurables.fastMap { measurable -> measurable.measure(constraints) }
+
+        if (placeables.isEmpty())
+            return@Layout layout(constraints.minWidth, constraints.minHeight) {}
+        val width = placeables.fastSumBy { it.width / 2 } + placeables.first().width / 2
+        val childHeight = placeables.fastMaxOfOrDefault(0) { it.height }
+
+        layout(constraints.constrainWidth(width), constraints.constrainHeight(childHeight)) {
+            // Start in the middle of the right-most placeable
+            var currentXPosition = placeables.fastSumBy { it.width / 2 }
+            placeables.fastForEach { placeable ->
+                currentXPosition -= placeable.width / 2
+                placeable.placeRelative(x = currentXPosition, y = 0)
+            }
+        }
+    }
+}
diff --git a/packages/SystemUI/compose/features/src/com/android/systemui/notifications/ui/composable/row/NotificationRowPrimitives.kt b/packages/SystemUI/compose/features/src/com/android/systemui/notifications/ui/composable/row/NotificationRowPrimitives.kt
new file mode 100644
index 0000000..c9ffa40
--- /dev/null
+++ b/packages/SystemUI/compose/features/src/com/android/systemui/notifications/ui/composable/row/NotificationRowPrimitives.kt
@@ -0,0 +1,201 @@
+/*
+ * Copyright (C) 2025 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.systemui.notifications.ui.composable.row
+
+import android.graphics.drawable.Drawable
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.ExpandMore
+import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.ReadOnlyComposable
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.draw.drawBehind
+import androidx.compose.ui.geometry.CornerRadius
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.ColorFilter
+import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import com.android.compose.animation.scene.ContentScope
+import com.android.compose.animation.scene.ElementKey
+import com.android.compose.animation.scene.LowestZIndexContentPicker
+import com.android.compose.animation.scene.ValueKey
+import com.android.compose.animation.scene.animateElementColorAsState
+import com.android.compose.animation.scene.animateElementFloatAsState
+import com.android.compose.ui.graphics.painter.rememberDrawablePainter
+
+object NotificationRowPrimitives {
+    object Elements {
+        val PillBackground = ElementKey("PillBackground", contentPicker = LowestZIndexContentPicker)
+        val NotificationIconBackground = ElementKey("NotificationIconBackground")
+        val Chevron = ElementKey("Chevron")
+    }
+
+    object Values {
+        val ChevronRotation = ValueKey("NotificationChevronRotation")
+        val PillBackgroundColor = ValueKey("PillBackgroundColor")
+    }
+}
+
+/** The Icon displayed at the start of any notification row. */
+@Composable
+fun ContentScope.BundleIcon(drawable: Drawable?, modifier: Modifier = Modifier) {
+    val surfaceColor = notificationElementSurfaceColor()
+    Box(
+        modifier =
+            modifier
+                // Has to be a shared element because we may have semi-transparent background color
+                .element(NotificationRowPrimitives.Elements.NotificationIconBackground)
+                .size(40.dp)
+                .background(color = surfaceColor, shape = CircleShape)
+    ) {
+        if (drawable == null) return@Box
+        val painter = rememberDrawablePainter(drawable)
+        Image(
+            painter = painter,
+            contentDescription = null,
+            modifier = Modifier.padding(10.dp).fillMaxSize(),
+            contentScale = ContentScale.Fit,
+            colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.primary),
+        )
+    }
+}
+
+/** The Icon used to display a preview of contained child notifications in a Bundle. */
+@Composable
+fun PreviewIcon(drawable: Drawable, modifier: Modifier = Modifier) {
+    val surfaceColor = notificationElementSurfaceColor()
+    Box(
+        modifier =
+            modifier
+                .background(color = surfaceColor, shape = CircleShape)
+                .border(0.5.dp, surfaceColor, CircleShape)
+    ) {
+        val painter = rememberDrawablePainter(drawable)
+        Image(
+            painter = painter,
+            contentDescription = null,
+            modifier = Modifier.fillMaxSize().clip(CircleShape),
+            contentScale = ContentScale.Fit,
+        )
+    }
+}
+
+/** The ExpansionControl of any expandable notification row, containing a Chevron. */
+@OptIn(ExperimentalMaterial3ExpressiveApi::class)
+@Composable
+fun ContentScope.ExpansionControl(
+    collapsed: Boolean,
+    hasUnread: Boolean,
+    numberToShow: Int?,
+    modifier: Modifier = Modifier,
+) {
+    val textColor =
+        if (hasUnread) MaterialTheme.colorScheme.onTertiary else MaterialTheme.colorScheme.onSurface
+    Box(modifier = modifier) {
+        // The background is a shared Element and therefore can't be the parent of a different
+        // shared Element (the chevron), otherwise the child can't be animated.
+        PillBackground(hasUnread, modifier = Modifier.matchParentSize())
+        Row(
+            verticalAlignment = Alignment.CenterVertically,
+            modifier = Modifier.padding(vertical = 2.dp, horizontal = 6.dp),
+        ) {
+            val iconSizeDp = with(LocalDensity.current) { 16.sp.toDp() }
+
+            if (numberToShow != null) {
+                Text(
+                    text = numberToShow.toString(),
+                    style = MaterialTheme.typography.labelSmallEmphasized,
+                    color = textColor,
+                    modifier = Modifier.padding(end = 2.dp),
+                )
+            }
+            Chevron(collapsed = collapsed, modifier = Modifier.size(iconSizeDp), color = textColor)
+        }
+    }
+}
+
+@Composable
+private fun ContentScope.PillBackground(hasUnread: Boolean, modifier: Modifier = Modifier) {
+    ElementWithValues(NotificationRowPrimitives.Elements.PillBackground, modifier) {
+        val bgColorNoUnread = notificationElementSurfaceColor()
+        val surfaceColor by
+            animateElementColorAsState(
+                if (hasUnread) MaterialTheme.colorScheme.tertiary else bgColorNoUnread,
+                NotificationRowPrimitives.Values.PillBackgroundColor,
+            )
+        content {
+            Box(
+                modifier =
+                    Modifier.drawBehind {
+                        drawRoundRect(
+                            color = surfaceColor,
+                            cornerRadius = CornerRadius(100.dp.toPx(), 100.dp.toPx()),
+                        )
+                    }
+            )
+        }
+    }
+}
+
+@Composable
+@ReadOnlyComposable
+private fun notificationElementSurfaceColor(): Color {
+    return if (isSystemInDarkTheme()) {
+        Color.White.copy(alpha = 0.15f)
+    } else {
+        MaterialTheme.colorScheme.surfaceContainerHighest
+    }
+}
+
+@Composable
+private fun ContentScope.Chevron(collapsed: Boolean, color: Color, modifier: Modifier = Modifier) {
+    val key = NotificationRowPrimitives.Elements.Chevron
+    ElementWithValues(key, modifier) {
+        val rotation by
+            animateElementFloatAsState(
+                if (collapsed) 0f else 180f,
+                NotificationRowPrimitives.Values.ChevronRotation,
+            )
+        content {
+            Icon(
+                imageVector = Icons.Default.ExpandMore,
+                contentDescription = null,
+                modifier = Modifier.graphicsLayer { rotationZ = rotation },
+                tint = color,
+            )
+        }
+    }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/ui/viewmodel/BundleHeaderViewModel.kt b/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/ui/viewmodel/BundleHeaderViewModel.kt
new file mode 100644
index 0000000..d02ae43
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/ui/viewmodel/BundleHeaderViewModel.kt
@@ -0,0 +1,87 @@
+/*
+ * Copyright (C) 2025 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.systemui.statusbar.notification.row.ui.viewmodel
+
+import android.graphics.drawable.Drawable
+import android.view.View
+import androidx.compose.animation.core.tween
+import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
+import androidx.compose.material3.MotionScheme
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.unit.dp
+import com.android.compose.animation.scene.MutableSceneTransitionLayoutState
+import com.android.compose.animation.scene.SceneTransitionLayoutState
+import com.android.compose.animation.scene.transitions
+import com.android.systemui.notifications.ui.composable.row.BundleHeader
+import kotlinx.coroutines.CoroutineScope
+
+interface BundleHeaderViewModel {
+    val titleText: String
+    val numberOfChildren: Int?
+    val bundleIcon: Drawable?
+    val previewIcons: List<Drawable>
+
+    val state: SceneTransitionLayoutState
+
+    val hasUnreadMessages: Boolean
+    val backgroundDrawable: Drawable?
+
+    fun onHeaderClicked(scope: CoroutineScope)
+}
+
+class BundleHeaderViewModelImpl : BundleHeaderViewModel {
+    override var titleText by mutableStateOf("")
+    override var numberOfChildren by mutableStateOf<Int?>(1)
+    override var hasUnreadMessages by mutableStateOf(true)
+    override var bundleIcon by mutableStateOf<Drawable?>(null)
+    override var previewIcons by mutableStateOf(listOf<Drawable>())
+    override var backgroundDrawable by mutableStateOf<Drawable?>(null)
+
+    var onExpandClickListener: View.OnClickListener? = null
+
+    @OptIn(ExperimentalMaterial3ExpressiveApi::class)
+    override var state: MutableSceneTransitionLayoutState =
+        MutableSceneTransitionLayoutState(
+            BundleHeader.Scenes.Collapsed,
+            MotionScheme.standard(),
+            transitions {
+                from(BundleHeader.Scenes.Collapsed, to = BundleHeader.Scenes.Expanded) {
+                    spec = tween(500)
+                    translate(BundleHeader.Elements.PreviewIcon3, x = 32.dp)
+                    translate(BundleHeader.Elements.PreviewIcon2, x = 16.dp)
+                    fade(BundleHeader.Elements.PreviewIcon1)
+                    fade(BundleHeader.Elements.PreviewIcon2)
+                    fade(BundleHeader.Elements.PreviewIcon3)
+                }
+            },
+        )
+
+    override fun onHeaderClicked(scope: CoroutineScope) {
+        val targetScene =
+            when (state.currentScene) {
+                BundleHeader.Scenes.Collapsed -> BundleHeader.Scenes.Expanded
+                BundleHeader.Scenes.Expanded -> BundleHeader.Scenes.Collapsed
+                else -> error("Unknown Scene")
+            }
+        state.setTargetScene(targetScene, scope)
+
+        onExpandClickListener?.onClick(null)
+        hasUnreadMessages = false
+    }
+}