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

Commit 8e8ec05a authored by Treehugger Robot's avatar Treehugger Robot Committed by Android (Google) Code Review
Browse files

Merge "[flexiglass] Dual shade education tooltips." into main

parents c98f743c 0c714a66
Loading
Loading
Loading
Loading
+107 −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(ExperimentalMaterial3Api::class)

package com.android.systemui.scene.ui.composable

import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.RichTooltip
import androidx.compose.material3.Text
import androidx.compose.material3.TooltipBox
import androidx.compose.material3.TooltipDefaults
import androidx.compose.material3.rememberTooltipState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import com.android.compose.modifiers.height
import com.android.compose.theme.LocalAndroidColorScheme
import com.android.systemui.lifecycle.rememberViewModel
import com.android.systemui.scene.ui.viewmodel.DualShadeEducationalTooltipsViewModel

@Composable
fun DualShadeEducationalTooltips(viewModelFactory: DualShadeEducationalTooltipsViewModel.Factory) {
    val context = LocalContext.current
    val viewModel =
        rememberViewModel(traceName = "DualShadeEducationalTooltips") {
            viewModelFactory.create(context)
        }

    val visibleTooltip = viewModel.visibleTooltip ?: return

    val anchorBottomY = visibleTooltip.anchorBottomY
    // This Box represents the bounds of the top edge that the user can swipe down on to reveal
    // either of the dual shade overlays. It's used as a convenient way to position the anchor for
    // each of the tooltips that can be shown. As such, this Box is the same size as the status bar.
    Box(
        contentAlignment =
            if (visibleTooltip.isAlignedToStart) {
                Alignment.CenterStart
            } else {
                Alignment.CenterEnd
            },
        modifier = Modifier.fillMaxWidth().height { anchorBottomY }.padding(horizontal = 24.dp),
    ) {
        AnchoredTooltip(
            text = visibleTooltip.text,
            onShown = visibleTooltip.onShown,
            onDismissed = visibleTooltip.onDismissed,
        )
    }
}

@Composable
private fun AnchoredTooltip(
    text: String,
    onShown: () -> Unit,
    onDismissed: () -> Unit,
    modifier: Modifier = Modifier,
) {
    val tooltipState = rememberTooltipState(initialIsVisible = true, isPersistent = true)

    LaunchedEffect(Unit) { onShown() }

    TooltipBox(
        positionProvider = TooltipDefaults.rememberTooltipPositionProvider(),
        tooltip = {
            RichTooltip(
                colors =
                    TooltipDefaults.richTooltipColors(
                        containerColor = LocalAndroidColorScheme.current.tertiaryFixed,
                        contentColor = LocalAndroidColorScheme.current.onTertiaryFixed,
                    ),
                caretSize = TooltipDefaults.caretSize,
                shadowElevation = 2.dp,
            ) {
                Text(text = text, modifier = Modifier.padding(8.dp))
            }
        },
        state = tooltipState,
        onDismissRequest = onDismissed,
        modifier = modifier,
    ) {
        Spacer(modifier = Modifier.width(48.dp).fillMaxHeight())
    }
}
+3 −0
Original line number Diff line number Diff line
@@ -14,6 +14,8 @@
 * limitations under the License.
 */

@file:OptIn(ExperimentalMaterial3Api::class)

package com.android.systemui.scene.ui.composable

import android.os.Build
@@ -22,6 +24,7 @@ import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
+212 −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.ui.viewmodel

import android.content.applicationContext
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.kosmos.testScope
import com.android.systemui.lifecycle.activateIn
import com.android.systemui.res.R
import com.android.systemui.scene.domain.interactor.DualShadeEducationInteractor
import com.android.systemui.scene.domain.interactor.sceneInteractor
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 DualShadeEducationalTooltipsViewModelTest(
    private val forOverlay: OverlayKey,
    private val tooltipText: String,
) : SysuiTestCase() {

    private val kosmos = testKosmos()

    private lateinit var underTest: DualShadeEducationalTooltipsViewModel

    @Before
    fun setUp() {
        kosmos.enableDualShade()
        kosmos.fakeUserRepository.setUserInfos(USER_INFOS)
        overrideResource(R.string.dual_shade_educational_tooltip_notifs, NOTIFS_TOOLTIP)
        overrideResource(R.string.dual_shade_educational_tooltip_qs, QS_TOOLTIP)

        underTest =
            kosmos.dualShadeEducationalTooltipsViewModelFactory.create(kosmos.applicationContext)
        underTest.activateIn(kosmos.testScope)
        kosmos.runCurrent()
    }

    @Test
    fun visibleTooltip_initiallyNull() =
        kosmos.runTest { assertThat(underTest.visibleTooltip).isNull() }

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

            showOverlay(otherOverlay)
            // No tooltip before the delay.
            assertNoTooltip()
            advanceTimeBy(DualShadeEducationInteractor.TOOLTIP_APPEARANCE_DELAY_MS - 1)
            // No tooltip because didn't wait quite long enough yet.
            assertNoTooltip()
            advanceTimeBy(1)
            // Expected tooltip after the full delay.
            val tooltip = assertVisibleTooltip(tooltipText)

            // UI reports impression and dismissal.
            tooltip.onShown()
            tooltip.onDismissed()
            assertNoTooltip()

            // Hide and reshow overlay to try and trigger it again it shouldn't show again because
            // it was already shown once.
            hideOverlay(otherOverlay)
            assertNoTooltip()
            showOverlay(otherOverlay)
            advanceTimeBy(DualShadeEducationInteractor.TOOLTIP_APPEARANCE_DELAY_MS)
            // The tooltip should still be gone as it was already shown to the user.
            assertNoTooltip()
        }

    @Test
    fun visibleTooltip_otherOverlayHiddenBeforeDelay_noTooltip() =
        kosmos.runTest {
            val otherOverlay = otherOverlay(forOverlay)
            showOverlay(otherOverlay)
            // No tooltip before the delay.
            assertNoTooltip()

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

            // Overlay hidden before the delay elapses.
            hideOverlay(otherOverlay)
            assertNoTooltip()

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

    @Test
    fun visibleTooltip_notDualShadeMode_noTooltip() =
        kosmos.runTest {
            disableDualShade()
            showOverlay(otherOverlay(forOverlay))
            advanceTimeBy(DualShadeEducationInteractor.TOOLTIP_APPEARANCE_DELAY_MS)
            assertNoTooltip()
        }

    @Test
    fun visibleTooltip_reshowsTooltip_afterUserChanged() =
        kosmos.runTest {
            val otherOverlay = otherOverlay(forOverlay)
            showOverlay(otherOverlay)
            advanceTimeBy(DualShadeEducationInteractor.TOOLTIP_APPEARANCE_DELAY_MS)
            val tooltip = assertVisibleTooltip(tooltipText)
            tooltip.onShown()
            tooltip.onDismissed()
            assertNoTooltip()
            hideOverlay(otherOverlay)

            selectUser(USER_INFOS[1])

            showOverlay(otherOverlay)
            advanceTimeBy(DualShadeEducationInteractor.TOOLTIP_APPEARANCE_DELAY_MS)
            // New user, tooltip shown again.
            assertVisibleTooltip(tooltipText)
        }

    /**
     * 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.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 fun Kosmos.assertVisibleTooltip(text: String): DualShadeEducationalTooltipViewModel {
        runCurrent()
        assertThat(underTest.visibleTooltip).isNotNull()
        assertThat(underTest.visibleTooltip?.text).isEqualTo(text)
        return underTest.visibleTooltip!!
    }

    private fun Kosmos.assertNoTooltip() {
        runCurrent()
        assertThat(underTest.visibleTooltip).isNull()
    }

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

    companion object {
        private const val NOTIFS_TOOLTIP = "notifs_tooltip"
        private const val QS_TOOLTIP = "qs_tooltip"

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

        @JvmStatic
        @Parameters(name = "{0}, {1}")
        fun testParameters(): List<Array<Any>> {
            return listOf(
                arrayOf(Overlays.NotificationsShade, NOTIFS_TOOLTIP),
                arrayOf(Overlays.QuickSettingsShade, QS_TOOLTIP),
            )
        }
    }
}
+12 −0
Original line number Diff line number Diff line
@@ -19,4 +19,16 @@
<resources>
    <!-- Recents: Text that shows above the navigation bar after launching several apps. [CHAR LIMIT=NONE] -->
    <string name="recents_quick_scrub_onboarding">Drag left to quickly switch apps</string>

    <!--
    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>

    <!--
    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 Quick Settings shade panel.
    -->
    <string name="dual_shade_educational_tooltip_qs">Swipe from the top left to open Quick Settings</string>
</resources>
+12 −0
Original line number Diff line number Diff line
@@ -4233,4 +4233,16 @@

    <!-- Description for the Underlay close button. The Underlay view appears on the bottom of the screen and shows some AI hints. The button will dismiss the underlay view. [CHAR LIMIT=NONE] -->
    <string name="underlay_close_button_content_description">Close</string>

    <!--
    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>

    <!--
    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 Quick Settings shade panel.
    -->
    <string name="dual_shade_educational_tooltip_qs">Swipe from the top right to open Quick Settings</string>
</resources>
Loading