Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit b269e741 authored by Andreas Miko's avatar Andreas Miko Committed by Android (Google) Code Review
Browse files

Merge changes from topics "bundle-expand", "bundle-icon-border", "bundle-summary" into main

* changes:
  Increase gaps of bundle children to 2dp
  Make the border of PreviewIcons transparent
  Allow two lines for the BundleHeaderGuts summary text
  Introduce new gap height between buckets and fix bundle gaps
  Fix bundle guts after dismissal
  Sync BundleHeader STL state with external expand/collapse
parents 4e2579c7 1958f94b
Loading
Loading
Loading
Loading
+101 −4
Original line number Diff line number Diff line
@@ -18,25 +18,40 @@ package com.android.systemui.notifications.ui.composable.row

import android.content.Context
import android.graphics.drawable.Drawable
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
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.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.CompositingStrategy
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.constrainHeight
import androidx.compose.ui.unit.constrainWidth
import androidx.compose.ui.unit.dp
@@ -51,6 +66,8 @@ 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.animation.scene.rememberMutableSceneTransitionLayoutState
import com.android.compose.animation.scene.transitions
import com.android.compose.theme.PlatformTheme
import com.android.compose.ui.graphics.painter.rememberDrawablePainter
import com.android.systemui.initOnBackPressedDispatcherOwner
@@ -86,16 +103,43 @@ fun createComposeView(viewModel: BundleHeaderViewModel, context: Context): Compo
    }
}

@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun BundleHeader(viewModel: BundleHeaderViewModel, modifier: Modifier = Modifier) {
    val state =
        rememberMutableSceneTransitionLayoutState(
            initialScene = BundleHeader.Scenes.Collapsed,
            transitions =
                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)
                    }
                },
        )

    DisposableEffect(viewModel, state) {
        viewModel.state = state
        onDispose { viewModel.state = null }
    }

    val scope = rememberCoroutineScope()
    DisposableEffect(viewModel, state) {
        viewModel.composeScope = scope
        onDispose { viewModel.composeScope = null }
    }

    Box(modifier) {
        Background(background = viewModel.backgroundDrawable, modifier = Modifier.matchParentSize())
        val scope = rememberCoroutineScope()
        SceneTransitionLayout(
            state = viewModel.state,
            state = state,
            modifier =
                Modifier.clickable(
                    onClick = { viewModel.onHeaderClicked(scope) },
                    onClick = { viewModel.onHeaderClicked() },
                    interactionSource = null,
                    indication = null,
                ),
@@ -176,20 +220,73 @@ private fun ContentScope.BundlePreviewIcons(
) {
    check(previewDrawables.isNotEmpty())
    val iconSize = 32.dp
    HalfOverlappingReversedRow(modifier = modifier) {

    val borderWidth = 2.5.dp
    HalfOverlappingReversedRow(
        modifier =
            modifier.graphicsLayer {
                // This is needed for rendering transparent PreviewIcon border
                compositingStrategy = CompositingStrategy.Offscreen
            }
    ) {
        PreviewIcon(
            drawable = previewDrawables[0],
            modifier = Modifier.element(BundleHeader.Elements.PreviewIcon1).size(iconSize),
            borderWidth = borderWidth,
        )
        if (previewDrawables.size < 2) return@HalfOverlappingReversedRow
        PreviewIcon(
            drawable = previewDrawables[1],
            modifier = Modifier.element(BundleHeader.Elements.PreviewIcon2).size(iconSize),
            borderWidth = borderWidth,
        )
        if (previewDrawables.size < 3) return@HalfOverlappingReversedRow
        PreviewIcon(
            drawable = previewDrawables[2],
            modifier = Modifier.element(BundleHeader.Elements.PreviewIcon3).size(iconSize),
            borderWidth = borderWidth,
        )
    }
}

/** The Icon used to display a preview of contained child notifications in a Bundle. */
@Composable
private fun PreviewIcon(drawable: Drawable, modifier: Modifier = Modifier, borderWidth: Dp) {
    val strokeWidthPx = with(LocalDensity.current) { borderWidth.toPx() }
    val stroke = remember(borderWidth) { Stroke(width = strokeWidthPx) }

    Box(
        modifier =
            modifier.drawWithContent {
                // Draw the original content of the inner Box
                drawContent()

                // Draw a circle with BlendMode.Clear to 'erase' pixels for the stroke.
                // This will punch a hole in *this* icon's local offscreen buffer, allowing the
                // background of the containing Composable (which needs to have a global
                // offscreen layer) to show through.
                drawCircle(
                    color = Color.Black, // Color doesn't matter for BlendMode.Clear
                    // Calculate the radius for the clearing circle.
                    // It should be the full size.minDimension / 2 PLUS half the stroke width.
                    // This pushes the *center* of the stroke outward, so the *inner* edge of the
                    // stroke aligns with the existing content boundary.
                    radius = (size.minDimension / 2f) + (strokeWidthPx / 2f),
                    center = center,
                    style = stroke,
                    blendMode = BlendMode.Clear,
                )
            }
    ) {
        val surfaceColor = notificationElementSurfaceColor()
        Image(
            painter = rememberDrawablePainter(drawable),
            contentDescription = null,
            modifier =
                Modifier.fillMaxSize()
                    .clip(CircleShape)
                    .background(color = surfaceColor, shape = CircleShape),
            contentScale = ContentScale.Fit,
        )
    }
}
+2 −2
Original line number Diff line number Diff line
@@ -131,7 +131,7 @@ private fun ContentRow(viewModel: BundleHeaderGutsViewModel, modifier: Modifier
                style = MaterialTheme.typography.bodyMedium,
                color = MaterialTheme.colorScheme.onSecondaryContainer,
                overflow = TextOverflow.Ellipsis,
                maxLines = 1,
                maxLines = 2,
            )
        }

@@ -164,7 +164,7 @@ private fun BottomRow(viewModel: BundleHeaderGutsViewModel, modifier: Modifier =
                modifier
                    .padding(vertical = 13.dp)
                    .clickable(
                        onClick = viewModel.onDismissClicked,
                        onClick = { viewModel.onDismissClicked() },
                        indication = null,
                        interactionSource = null,
                    ),
+1 −25
Original line number Diff line number Diff line
@@ -16,11 +16,9 @@

package com.android.systemui.notifications.ui.composable.row

import android.graphics.drawable.Drawable
import androidx.annotation.DrawableRes
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
@@ -39,7 +37,6 @@ 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
@@ -55,7 +52,6 @@ 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.animateElementFloatAsState
import com.android.compose.ui.graphics.painter.rememberDrawablePainter

object NotificationRowPrimitives {
    object Elements {
@@ -85,26 +81,6 @@ fun BundleIcon(@DrawableRes drawable: Int?, modifier: Modifier = Modifier) {
    }
}

/** 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
@@ -156,7 +132,7 @@ private fun ContentScope.PillBackground(modifier: Modifier = Modifier) {

@Composable
@ReadOnlyComposable
private fun notificationElementSurfaceColor(): Color {
fun notificationElementSurfaceColor(): Color {
    return if (isSystemInDarkTheme()) {
        Color.White.copy(alpha = 0.15f)
    } else {
+92 −0
Original line number Diff line number Diff line
/*
 * 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.
 */

@file:OptIn(ExperimentalMaterial3ExpressiveApi::class)

package com.android.systemui.statusbar.notification.row.domain.interactor

import android.platform.test.annotations.EnableFlags
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MotionScheme
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.SmallTest
import com.android.compose.animation.scene.MutableSceneTransitionLayoutState
import com.android.systemui.SysuiTestCase
import com.android.systemui.notifications.ui.composable.row.BundleHeader
import com.android.systemui.statusbar.notification.row.domain.bundleInteractor
import com.android.systemui.statusbar.notification.row.icon.appIconProvider
import com.android.systemui.statusbar.notification.row.icon.mockAppIconProvider
import com.android.systemui.statusbar.notification.shared.NotificationBundleUi
import com.android.systemui.testKosmos
import com.google.common.truth.Truth.assertThat
import kotlin.test.Test
import org.junit.Before
import org.junit.Rule
import org.junit.runner.RunWith
import org.mockito.junit.MockitoJUnit
import org.mockito.junit.MockitoRule
import platform.test.motion.compose.runMonotonicClockTest

@SmallTest
@RunWith(AndroidJUnit4::class)
@EnableFlags(NotificationBundleUi.FLAG_NAME)
class BundleInteractorTest : SysuiTestCase() {

    @get:Rule val rule: MockitoRule = MockitoJUnit.rule()

    private val kosmos = testKosmos()

    private lateinit var underTest: BundleInteractor

    @Before
    fun setUp() {
        kosmos.appIconProvider = kosmos.mockAppIconProvider
        underTest = kosmos.bundleInteractor
    }

    @Test
    fun setExpansionState_sets_state_to_expanded() = runMonotonicClockTest {
        // Arrange
        underTest.composeScope = this
        underTest.state =
            MutableSceneTransitionLayoutState(
                BundleHeader.Scenes.Collapsed,
                MotionScheme.standard(),
            )
        assertThat(underTest.state?.currentScene).isEqualTo(BundleHeader.Scenes.Collapsed)

        // Act
        underTest.setExpansionState(true)

        // Assert
        assertThat(underTest.state?.currentScene).isEqualTo(BundleHeader.Scenes.Expanded)
    }

    @Test
    fun setExpansionState_sets_state_to_collapsed() = runMonotonicClockTest {
        // Arrange
        underTest.composeScope = this
        underTest.state =
            MutableSceneTransitionLayoutState(BundleHeader.Scenes.Expanded, MotionScheme.standard())
        assertThat(underTest.state?.currentScene).isEqualTo(BundleHeader.Scenes.Expanded)

        // Act
        underTest.setExpansionState(false)

        // Assert
        assertThat(underTest.state?.currentScene).isEqualTo(BundleHeader.Scenes.Collapsed)
    }
}
+95 −0
Original line number Diff line number Diff line
/*
 * 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.platform.test.annotations.EnableFlags
import android.view.View
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.SmallTest
import com.android.compose.animation.scene.MutableSceneTransitionLayoutState
import com.android.systemui.SysuiTestCase
import com.android.systemui.kosmos.testScope
import com.android.systemui.lifecycle.activateIn
import com.android.systemui.notifications.ui.composable.row.BundleHeader
import com.android.systemui.statusbar.notification.shared.NotificationBundleUi
import com.android.systemui.testKosmos
import kotlinx.coroutines.CoroutineScope
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.Mock
import org.mockito.Mockito.verify
import org.mockito.junit.MockitoJUnit
import org.mockito.junit.MockitoRule
import org.mockito.kotlin.whenever

@SmallTest
@RunWith(AndroidJUnit4::class)
@EnableFlags(NotificationBundleUi.FLAG_NAME)
class BundleHeaderViewModelTest : SysuiTestCase() {

    @get:Rule val rule: MockitoRule = MockitoJUnit.rule()

    private val kosmos = testKosmos()

    @Mock lateinit var mockSceneTransitionLayoutState: MutableSceneTransitionLayoutState
    @Mock lateinit var mockComposeScope: CoroutineScope
    @Mock lateinit var onExpandClickListener: View.OnClickListener

    private lateinit var underTest: BundleHeaderViewModel

    @Before
    fun setup() {
        underTest = kosmos.bundleHeaderViewModelFactory.create()
        underTest.activateIn(kosmos.testScope)

        underTest.state = mockSceneTransitionLayoutState
        underTest.composeScope = mockComposeScope
        underTest.onExpandClickListener = onExpandClickListener
    }

    @Test
    fun onHeaderClicked_toggles_expansion_state_to_expanded() {
        // Arrange
        whenever(mockSceneTransitionLayoutState.currentScene)
            .thenReturn(BundleHeader.Scenes.Collapsed)

        // Act
        underTest.onHeaderClicked()

        // Assert
        verify(mockSceneTransitionLayoutState)
            .setTargetScene(BundleHeader.Scenes.Expanded, mockComposeScope)
        verify(onExpandClickListener).onClick(null)
    }

    @Test
    fun onHeaderClicked_toggles_expansion_state_to_collapsed() {
        // Arrange
        whenever(mockSceneTransitionLayoutState.currentScene)
            .thenReturn(BundleHeader.Scenes.Expanded)

        // Act
        underTest.onHeaderClicked()

        // Assert
        verify(mockSceneTransitionLayoutState)
            .setTargetScene(BundleHeader.Scenes.Collapsed, mockComposeScope)
        verify(onExpandClickListener).onClick(null)
    }
}
Loading