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

Commit 907e84d4 authored by Ale Nijamkin's avatar Ale Nijamkin
Browse files

[flexiglass] Dual shade education: Bounce animation in status bar chips

When user education needs to be shown, before showing the tooltips,
bounces the status bar chip on the side of the overlay that the tooltip
would be educating the user about.

Bug: 406213664
Test: manually verified that the correct chip bounces and stops bouncing
when the tooltip is shown
Flag: com.android.systemui.scene_container

Change-Id: If01c933c1489ce3b9da779bcf1217cd4989f8bd3
parent acc6b4c3
Loading
Loading
Loading
Loading
+65 −0
Original line number Diff line number Diff line
@@ -19,6 +19,9 @@ package com.android.systemui.shade.ui.composable

import android.view.ContextThemeWrapper
import android.view.ViewGroup
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
@@ -33,6 +36,7 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
@@ -40,6 +44,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ColorScheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
@@ -55,6 +60,7 @@ import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.max
@@ -99,6 +105,8 @@ import com.android.systemui.statusbar.pipeline.mobile.ui.viewmodel.ShadeCarrierG
import com.android.systemui.statusbar.pipeline.mobile.ui.viewmodel.composeWrapper
import com.android.systemui.statusbar.policy.Clock
import com.android.systemui.util.composable.kairos.ActivatedKairosSpec
import kotlin.math.roundToInt
import kotlinx.coroutines.delay

object ShadeHeader {
    object Elements {
@@ -343,6 +351,7 @@ fun ContentScope.OverlayShadeHeaderPartialStateless(
                NotificationsChip(
                    onClick = viewModel::onNotificationIconChipClicked,
                    backgroundColor = chipHighlight.backgroundColor(MaterialTheme.colorScheme),
                    modifier = Modifier.bouncy(isEnabled = viewModel.animateNotificationsChipBounce),
                ) {
                    VariableDayDate(
                        longerDateText = viewModel.longerDateText,
@@ -362,6 +371,7 @@ fun ContentScope.OverlayShadeHeaderPartialStateless(
                SystemIconChip(
                    backgroundColor = chipHighlight.backgroundColor(MaterialTheme.colorScheme),
                    onClick = viewModel::onSystemIconChipClicked,
                    modifier = Modifier.bouncy(isEnabled = viewModel.animateSystemIconChipBounce),
                ) {
                    val paddingEnd =
                        with(LocalDensity.current) {
@@ -791,6 +801,61 @@ private fun ContentScope.PrivacyChip(
    )
}

/** Modifies the given [Modifier] such that it shows a looping vertical bounce animation. */
@Composable
private fun Modifier.bouncy(isEnabled: Boolean): Modifier {
    val density = LocalDensity.current
    val animatable = remember { Animatable(0f) }
    LaunchedEffect(isEnabled) {
        if (isEnabled) {
            while (true) {
                // Lifts the element up to the first peak.
                animatable.animateTo(
                    targetValue = with(density) { -(10.dp).toPx() },
                    animationSpec =
                        tween(
                            durationMillis = 200,
                            easing = CubicBezierEasing(0.15f, 0f, 0.23f, 1f),
                        ),
                )
                // Drops the element back to the ground from the first peak.
                animatable.animateTo(
                    targetValue = 0f,
                    animationSpec =
                        tween(
                            durationMillis = 167,
                            easing = CubicBezierEasing(0.74f, 0f, 0.22f, 1f),
                        ),
                )
                // Lifts the element up again, this time to the second, smaller peak.
                animatable.animateTo(
                    targetValue = with(density) { -(5.dp).toPx() },
                    animationSpec =
                        tween(
                            durationMillis = 150,
                            easing = CubicBezierEasing(0.62f, 0f, 0.35f, 1f),
                        ),
                )
                // Drops the element back to the ground from the second peak.
                animatable.animateTo(
                    targetValue = 0f,
                    animationSpec =
                        tween(
                            durationMillis = 117,
                            easing = CubicBezierEasing(0.67f, 0f, 0.51f, 1f),
                        ),
                )
                // Wait for a moment before repeating it.
                delay(1000)
            }
        } else {
            animatable.animateTo(targetValue = 0f, animationSpec = tween(durationMillis = 500))
        }
    }

    return this.offset { IntOffset(x = 0, y = animatable.value.roundToInt()) }
}

private fun shouldUseExpandedFormat(state: TransitionState): Boolean {
    return when (state) {
        is TransitionState.Idle -> {
+262 −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.scene.domain.interactor

import android.content.pm.UserInfo
import androidx.test.filters.SmallTest
import com.android.compose.animation.scene.OverlayKey
import com.android.systemui.SysuiTestCase
import com.android.systemui.flags.EnableSceneContainer
import com.android.systemui.kosmos.Kosmos
import com.android.systemui.kosmos.advanceTimeBy
import com.android.systemui.kosmos.currentValue
import com.android.systemui.kosmos.runCurrent
import com.android.systemui.kosmos.runTest
import com.android.systemui.scene.domain.model.DualShadeEducationModel
import com.android.systemui.scene.shared.model.Overlays
import com.android.systemui.shade.domain.interactor.disableDualShade
import com.android.systemui.shade.domain.interactor.enableDualShade
import com.android.systemui.testKosmos
import com.android.systemui.user.data.repository.fakeUserRepository
import com.android.systemui.user.domain.interactor.selectedUserInteractor
import com.google.common.truth.Truth.assertThat
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import platform.test.runner.parameterized.ParameterizedAndroidJunit4
import platform.test.runner.parameterized.Parameters

@SmallTest
@RunWith(ParameterizedAndroidJunit4::class)
@EnableSceneContainer
class DualShadeEducationInteractorTest(private val forOverlay: OverlayKey) : SysuiTestCase() {
    private val kosmos = testKosmos()
    private lateinit var underTest: DualShadeEducationInteractor

    @Before
    fun setUp() {
        kosmos.enableDualShade()
        kosmos.fakeUserRepository.setUserInfos(USER_INFOS)
        underTest = kosmos.dualShadeEducationInteractor
        kosmos.runCurrent()
    }

    @Test fun initiallyNull() = kosmos.runTest { assertEducation(DualShadeEducationModel.None) }

    @Test
    fun happyPath() =
        kosmos.runTest {
            val otherOverlay = otherOverlay(forOverlay)

            showOverlay(otherOverlay)
            // No education before the delay.
            assertEducation(DualShadeEducationModel.None)

            // SHOW THE HINT
            advanceTimeBy(DualShadeEducationInteractor.HINT_APPEARANCE_DELAY_MS - 1)
            // No education because didn't wait quite long enough yet.
            assertEducation(DualShadeEducationModel.None)
            advanceTimeBy(1)
            // Expected hint after the full delay.
            assertEducation(expectedHint(forOverlay))

            // SHOW THE TOOLTIP
            advanceTimeBy(DualShadeEducationInteractor.TOOLTIP_APPEARANCE_DELAY_MS - 1)
            // Still showing the hint because didn't wait quite long enough yet.
            assertEducation(expectedHint(forOverlay))
            advanceTimeBy(1)
            // Expected tooltip after the full delay.
            assertEducation(expectedTooltip(forOverlay))

            // UI reports impression and dismissal of the tooltip.
            when (forOverlay) {
                Overlays.NotificationsShade -> {
                    underTest.recordNotificationsShadeTooltipImpression()
                    underTest.dismissNotificationsShadeTooltip()
                }
                Overlays.QuickSettingsShade -> {
                    underTest.recordQuickSettingsShadeTooltipImpression()
                    underTest.dismissQuickSettingsShadeTooltip()
                }
            }
            assertEducation(DualShadeEducationModel.None)

            // Hide and reshow overlay to try and trigger it again it shouldn't show again because
            // it was already shown once.
            hideOverlay(otherOverlay)
            assertEducation(DualShadeEducationModel.None)
            showOverlay(otherOverlay)
            advanceTimeBy(DualShadeEducationInteractor.HINT_APPEARANCE_DELAY_MS)
            // No hint as it was already shown to the user.
            assertEducation(DualShadeEducationModel.None)
            advanceTimeBy(DualShadeEducationInteractor.TOOLTIP_APPEARANCE_DELAY_MS)
            // No hint or tooltip as it was already shown to the user.
            assertEducation(DualShadeEducationModel.None)
        }

    @Test
    fun otherOverlayHiddenBeforeHint_noEducation() =
        kosmos.runTest {
            val otherOverlay = otherOverlay(forOverlay)
            showOverlay(otherOverlay)
            // No tooltip before the delay.
            assertEducation(DualShadeEducationModel.None)

            advanceTimeBy(DualShadeEducationInteractor.HINT_APPEARANCE_DELAY_MS - 1)
            // No hint because didn't wait quite long enough yet.
            assertEducation(DualShadeEducationModel.None)

            // Overlay hidden before the delay elapses.
            hideOverlay(otherOverlay)
            assertEducation(DualShadeEducationModel.None)

            advanceTimeBy(1)
            // Waited the entire delay, but the overlay was already hidden.
            assertEducation(DualShadeEducationModel.None)
            // Even waiting for the tooltip doesn't show anything.
            advanceTimeBy(DualShadeEducationInteractor.TOOLTIP_APPEARANCE_DELAY_MS)
            assertEducation(DualShadeEducationModel.None)
        }

    @Test
    fun otherOverlayHiddenBeforeTooltipDelay_noEducation() =
        kosmos.runTest {
            val otherOverlay = otherOverlay(forOverlay)
            showOverlay(otherOverlay)
            // No tooltip before the delay.
            assertEducation(DualShadeEducationModel.None)

            advanceTimeBy(DualShadeEducationInteractor.HINT_APPEARANCE_DELAY_MS)
            // Showing hint.
            assertEducation(expectedHint(forOverlay))

            advanceTimeBy(DualShadeEducationInteractor.TOOLTIP_APPEARANCE_DELAY_MS - 1)
            // Still showing hint as the tooltip delay isn't over.
            assertEducation(expectedHint(forOverlay))

            // Overlay hidden before the tooltip delay elapses.
            hideOverlay(otherOverlay)
            assertEducation(DualShadeEducationModel.None)

            advanceTimeBy(1)
            // Waited the entire delay, but the overlay was already hidden.
            assertEducation(DualShadeEducationModel.None)
        }

    @Test
    fun notDualShadeMode_noEducation() =
        kosmos.runTest {
            disableDualShade()
            showOverlay(otherOverlay(forOverlay))
            advanceTimeBy(DualShadeEducationInteractor.HINT_APPEARANCE_DELAY_MS)
            assertEducation(DualShadeEducationModel.None)
            advanceTimeBy(DualShadeEducationInteractor.TOOLTIP_APPEARANCE_DELAY_MS)
            assertEducation(DualShadeEducationModel.None)
        }

    @Test
    fun reshowsEducationAndTooltip_afterUserChanged() =
        kosmos.runTest {
            val otherOverlay = otherOverlay(forOverlay)
            showOverlay(otherOverlay)
            advanceTimeBy(DualShadeEducationInteractor.HINT_APPEARANCE_DELAY_MS)
            assertEducation(expectedHint(forOverlay))
            advanceTimeBy(DualShadeEducationInteractor.TOOLTIP_APPEARANCE_DELAY_MS)
            assertEducation(expectedTooltip(forOverlay))
            when (forOverlay) {
                Overlays.NotificationsShade -> {
                    underTest.recordNotificationsShadeTooltipImpression()
                    underTest.dismissNotificationsShadeTooltip()
                }
                Overlays.QuickSettingsShade -> {
                    underTest.recordQuickSettingsShadeTooltipImpression()
                    underTest.dismissQuickSettingsShadeTooltip()
                }
            }
            assertEducation(DualShadeEducationModel.None)
            hideOverlay(otherOverlay)

            selectUser(USER_INFOS[1])

            showOverlay(otherOverlay)
            advanceTimeBy(DualShadeEducationInteractor.HINT_APPEARANCE_DELAY_MS)
            // New user, hint shown again.
            assertEducation(expectedHint(forOverlay))
            advanceTimeBy(DualShadeEducationInteractor.TOOLTIP_APPEARANCE_DELAY_MS)
            // New user, tooltip shown again.
            assertEducation(expectedTooltip(forOverlay))
        }

    /**
     * Returns the complementary overlay for [forOverlay]; the one that, when shown, the tooltip
     * will show for [forOverlay].
     */
    private fun otherOverlay(forOverlay: OverlayKey): OverlayKey {
        return when (forOverlay) {
            Overlays.NotificationsShade -> Overlays.QuickSettingsShade
            Overlays.QuickSettingsShade -> Overlays.NotificationsShade
            else -> error("Test isn't expecting forOverlay of ${forOverlay.debugName}")
        }
    }

    private fun Kosmos.assertEducation(expected: DualShadeEducationModel) {
        runCurrent()
        assertThat(underTest.education).isEqualTo(expected)
    }

    private fun expectedHint(forOverlay: OverlayKey): DualShadeEducationModel {
        return when (forOverlay) {
            Overlays.NotificationsShade -> DualShadeEducationModel.HintForNotificationsShade
            Overlays.QuickSettingsShade -> DualShadeEducationModel.HintForQuickSettingsShade
            else -> DualShadeEducationModel.None
        }
    }

    private fun expectedTooltip(forOverlay: OverlayKey): DualShadeEducationModel {
        return when (forOverlay) {
            Overlays.NotificationsShade -> DualShadeEducationModel.TooltipForNotificationsShade
            Overlays.QuickSettingsShade -> DualShadeEducationModel.TooltipForQuickSettingsShade
            else -> DualShadeEducationModel.None
        }
    }

    private fun Kosmos.showOverlay(overlay: OverlayKey) {
        sceneInteractor.showOverlay(overlay, "")
        assertThat(currentValue(sceneInteractor.currentOverlays)).contains(overlay)
    }

    private fun Kosmos.hideOverlay(overlay: OverlayKey) {
        sceneInteractor.hideOverlay(overlay, "")
        assertThat(currentValue(sceneInteractor.currentOverlays)).doesNotContain(overlay)
    }

    private suspend fun Kosmos.selectUser(userInfo: UserInfo) {
        fakeUserRepository.setSelectedUserInfo(userInfo)
        assertThat(selectedUserInteractor.getSelectedUserId()).isEqualTo(userInfo.id)
    }

    companion object {
        private val USER_INFOS =
            listOf(UserInfo(10, "Initial user", 0), UserInfo(11, "Other user", 0))

        @JvmStatic
        @Parameters(name = "{0}")
        fun testParameters(): List<OverlayKey> {
            return listOf(Overlays.NotificationsShade, Overlays.QuickSettingsShade)
        }
    }
}
+6 −6
Original line number Diff line number Diff line
@@ -82,7 +82,7 @@ class DualShadeEducationalTooltipsViewModelTest(
            showOverlay(otherOverlay)
            // No tooltip before the delay.
            assertNoTooltip()
            advanceTimeBy(DualShadeEducationInteractor.TOOLTIP_APPEARANCE_DELAY_MS - 1)
            advanceTimeBy(DualShadeEducationInteractor.TOOLTIP_APPEARANCE_DELAY_MS * 2 - 1)
            // No tooltip because didn't wait quite long enough yet.
            assertNoTooltip()
            advanceTimeBy(1)
@@ -99,7 +99,7 @@ class DualShadeEducationalTooltipsViewModelTest(
            hideOverlay(otherOverlay)
            assertNoTooltip()
            showOverlay(otherOverlay)
            advanceTimeBy(DualShadeEducationInteractor.TOOLTIP_APPEARANCE_DELAY_MS)
            advanceTimeBy(DualShadeEducationInteractor.TOOLTIP_APPEARANCE_DELAY_MS * 2)
            // The tooltip should still be gone as it was already shown to the user.
            assertNoTooltip()
        }
@@ -112,7 +112,7 @@ class DualShadeEducationalTooltipsViewModelTest(
            // No tooltip before the delay.
            assertNoTooltip()

            advanceTimeBy(DualShadeEducationInteractor.TOOLTIP_APPEARANCE_DELAY_MS - 1)
            advanceTimeBy(DualShadeEducationInteractor.TOOLTIP_APPEARANCE_DELAY_MS * 2 - 1)
            // No tooltip because didn't wait quite long enough yet.
            assertNoTooltip()

@@ -130,7 +130,7 @@ class DualShadeEducationalTooltipsViewModelTest(
        kosmos.runTest {
            disableDualShade()
            showOverlay(otherOverlay(forOverlay))
            advanceTimeBy(DualShadeEducationInteractor.TOOLTIP_APPEARANCE_DELAY_MS)
            advanceTimeBy(DualShadeEducationInteractor.TOOLTIP_APPEARANCE_DELAY_MS * 2)
            assertNoTooltip()
        }

@@ -139,7 +139,7 @@ class DualShadeEducationalTooltipsViewModelTest(
        kosmos.runTest {
            val otherOverlay = otherOverlay(forOverlay)
            showOverlay(otherOverlay)
            advanceTimeBy(DualShadeEducationInteractor.TOOLTIP_APPEARANCE_DELAY_MS)
            advanceTimeBy(DualShadeEducationInteractor.TOOLTIP_APPEARANCE_DELAY_MS * 2)
            val tooltip = assertVisibleTooltip(tooltipText)
            tooltip.onShown()
            tooltip.onDismissed()
@@ -149,7 +149,7 @@ class DualShadeEducationalTooltipsViewModelTest(
            selectUser(USER_INFOS[1])

            showOverlay(otherOverlay)
            advanceTimeBy(DualShadeEducationInteractor.TOOLTIP_APPEARANCE_DELAY_MS)
            advanceTimeBy(DualShadeEducationInteractor.TOOLTIP_APPEARANCE_DELAY_MS * 2)
            // New user, tooltip shown again.
            assertVisibleTooltip(tooltipText)
        }
+1 −1
Original line number Diff line number Diff line
@@ -24,7 +24,7 @@
    Content of user education tooltip shown to teach the user that they can swipe down from the top
    right edge of the display to expand the notification shade panel.
    -->
    <string name="dual_shade_educational_tooltip_notifs">Swipe from the top right to open Notifications</string>
    <string name="dual_shade_educational_tooltip_notifs">Swipe from the top right to open notifications</string>

    <!--
    Content of user education tooltip shown to teach the user that they can swipe down from the top
+1 −1
Original line number Diff line number Diff line
@@ -4244,7 +4244,7 @@
    Content of user education tooltip shown to teach the user that they can swipe down from the top
    left edge of the display to expand the notification shade panel.
    -->
    <string name="dual_shade_educational_tooltip_notifs">Swipe from the top left to open Notifications</string>
    <string name="dual_shade_educational_tooltip_notifs">Swipe from the top left to open notifications</string>

    <!--
    Content of user education tooltip shown to teach the user that they can swipe down from the top
Loading