1
0
Fork 0
mirror of https://github.com/bitwarden/android.git synced 2025-01-30 19:53:47 +03:00

PM-17650 Implement custom tool tip state to prevent tool tips from dismissing. ()

This commit is contained in:
Dave Severns 2025-01-28 10:20:15 -05:00 committed by GitHub
parent b2c4fbb593
commit 7a25aafc23
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 224 additions and 25 deletions

View file

@ -38,6 +38,11 @@ interface DebugMenuRepository {
*/
fun resetOnboardingStatusForCurrentUser()
/**
* Resets the value for the show coach mark statuses so their default values will be used.
*/
fun resetCoachMarkTourStatuses()
/**
* Manipulates the state to force showing the onboarding carousel.
*

View file

@ -56,6 +56,11 @@ class DebugMenuRepositoryImpl(
)
}
override fun resetCoachMarkTourStatuses() {
settingsDiskSource.storeShouldShowGeneratorCoachMark(shouldShow = null)
settingsDiskSource.storeShouldShowAddLoginCoachMark(shouldShow = null)
}
override fun modifyStateToShowOnboardingCarousel(
userStateUpdateTrigger: () -> Unit,
) {

View file

@ -60,7 +60,6 @@ fun <T : Enum<T>> CoachMarkContainer(
modifier: Modifier = Modifier,
content: @Composable CoachMarkScope<T>.() -> Unit,
) {
val scope = rememberCoroutineScope()
Box(
modifier = Modifier
.fillMaxSize()
@ -101,9 +100,8 @@ fun <T : Enum<T>> CoachMarkContainer(
.pointerInput(Unit) {
detectTapGestures(
onTap = {
scope.launch {
state.showToolTipForCurrentCoachMark()
}
// NO-OP, this consumes any touch events
// while the scrim is showing.
},
)
}

View file

@ -9,8 +9,6 @@ import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.TooltipBox
import androidx.compose.material3.TooltipDefaults
import androidx.compose.material3.TooltipState
import androidx.compose.material3.rememberTooltipState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@ -32,6 +30,8 @@ import com.x8bit.bitwarden.ui.platform.base.util.toListItemCardStyle
import com.x8bit.bitwarden.ui.platform.components.coachmark.model.CoachMarkHighlightShape
import com.x8bit.bitwarden.ui.platform.components.model.CardStyle
import com.x8bit.bitwarden.ui.platform.components.tooltip.BitwardenToolTip
import com.x8bit.bitwarden.ui.platform.components.tooltip.BitwardenToolTipState
import com.x8bit.bitwarden.ui.platform.components.tooltip.rememberBitwardenToolTipState
import okhttp3.internal.toImmutableList
import org.jetbrains.annotations.VisibleForTesting
@ -55,7 +55,7 @@ class CoachMarkScopeInstance<T : Enum<T>>(
rightAction: @Composable() (RowScope.() -> Unit)?,
anchorContent: @Composable () -> Unit,
) {
val toolTipState = rememberTooltipState(
val toolTipState = rememberBitwardenToolTipState(
initialIsVisible = false,
isPersistent = true,
)
@ -192,7 +192,7 @@ class CoachMarkScopeInstance<T : Enum<T>>(
leftAction: @Composable() (RowScope.() -> Unit)?,
rightAction: @Composable() (RowScope.() -> Unit)?,
modifier: Modifier = Modifier,
toolTipState: TooltipState = rememberTooltipState(
toolTipState: BitwardenToolTipState = rememberBitwardenToolTipState(
initialIsVisible = false,
isPersistent = true,
),

View file

@ -1,8 +1,7 @@
package com.x8bit.bitwarden.ui.platform.components.coachmark
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.TooltipState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.Saver
@ -11,6 +10,7 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.geometry.Rect
import com.x8bit.bitwarden.ui.platform.components.coachmark.model.CoachMarkHighlightShape
import com.x8bit.bitwarden.ui.platform.components.coachmark.model.CoachMarkHighlightState
import com.x8bit.bitwarden.ui.platform.components.tooltip.BitwardenToolTipState
import java.util.concurrent.ConcurrentHashMap
import kotlin.math.max
import kotlin.math.min
@ -27,7 +27,7 @@ import kotlin.math.min
* none should be highlighted at start.
* @param isCoachMarkVisible is any coach mark currently visible.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Stable
open class CoachMarkState<T : Enum<T>>(
val orderedList: List<T>,
initialCoachMarkHighlight: T? = null,
@ -58,7 +58,7 @@ open class CoachMarkState<T : Enum<T>>(
fun updateHighlight(
key: T,
bounds: Rect?,
toolTipState: TooltipState,
toolTipState: BitwardenToolTipState,
shape: CoachMarkHighlightShape = CoachMarkHighlightShape.SQUARE,
) {
highlights[key] = CoachMarkHighlightState(
@ -193,9 +193,9 @@ open class CoachMarkState<T : Enum<T>>(
/**
* Cleans up the tooltip state by dismissing it if visible and calling onDispose.
*/
private fun TooltipState.cleanUp() {
private fun BitwardenToolTipState.cleanUp() {
if (isVisible) {
dismiss()
dismissBitwardenToolTip()
}
onDispose()
}

View file

@ -5,6 +5,7 @@ import androidx.compose.foundation.gestures.scrollBy
import androidx.compose.foundation.lazy.LazyListLayoutInfo
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.listSaver
import androidx.compose.runtime.saveable.rememberSaveable
@ -13,6 +14,7 @@ import androidx.compose.runtime.saveable.rememberSaveable
* A [CoachMarkState] that depends on a [LazyListState] to automatically scroll to the current
* Coach Mark if not on currently on the screen.
*/
@Stable
class LazyListCoachMarkState<T : Enum<T>>(
private val lazyListState: LazyListState,
orderedList: List<T>,

View file

@ -1,8 +1,7 @@
package com.x8bit.bitwarden.ui.platform.components.coachmark.model
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.TooltipState
import androidx.compose.ui.geometry.Rect
import com.x8bit.bitwarden.ui.platform.components.tooltip.BitwardenToolTipState
/**
* Represents a highlight within a coach mark sequence.
@ -13,10 +12,9 @@ import androidx.compose.ui.geometry.Rect
* @property toolTipState The state of the tooltip associated with this highlight.
* @property shape The shape of the highlight (e.g., square, oval).
*/
@OptIn(ExperimentalMaterial3Api::class)
data class CoachMarkHighlightState<T : Enum<T>>(
val key: T,
val highlightBounds: Rect?,
val toolTipState: TooltipState,
val toolTipState: BitwardenToolTipState,
val shape: CoachMarkHighlightShape,
)

View file

@ -0,0 +1,17 @@
package com.x8bit.bitwarden.ui.platform.components.tooltip
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.TooltipState
/**
* A custom [TooltipState] to be used for the tool tips which should not be
* dismissed automatically by clicking outside of the pop-up area.
*/
@OptIn(ExperimentalMaterial3Api::class)
interface BitwardenToolTipState : TooltipState {
/**
* Call to dismiss the tool tip from the screen, should be used in
* place of [TooltipState.dismiss]
*/
fun dismissBitwardenToolTip()
}

View file

@ -0,0 +1,112 @@
package com.x8bit.bitwarden.ui.platform.components.tooltip
import androidx.compose.animation.core.MutableTransitionState
import androidx.compose.foundation.MutatePriority
import androidx.compose.foundation.MutatorMutex
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.TooltipState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import kotlinx.coroutines.CancellableContinuation
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withTimeout
/**
* Default implementation of [BitwardenToolTipState]
*/
class BitwardenToolTipStateImpl(
initialIsVisible: Boolean,
override val isPersistent: Boolean,
private val mutatorMutex: MutatorMutex,
) : BitwardenToolTipState {
override fun dismissBitwardenToolTip() {
transition.targetState = false
}
override val transition: MutableTransitionState<Boolean> =
MutableTransitionState(initialIsVisible)
override val isVisible: Boolean
get() = transition.currentState || transition.targetState
/** continuation used to clean up */
private var job: (CancellableContinuation<Unit>)? = null
/**
* Show the tooltip associated with the current [TooltipState]. When this method is called, all
* of the other tooltips associated with [mutatorMutex] will be dismissed.
*
* @param mutatePriority [MutatePriority] to be used with [mutatorMutex].
*/
override suspend fun show(mutatePriority: MutatePriority) {
val cancellableShow: suspend () -> Unit = {
suspendCancellableCoroutine { continuation ->
transition.targetState = true
job = continuation
}
}
// Show associated tooltip for [TooltipDuration] amount of time
// or until tooltip is explicitly dismissed depending on [isPersistent].
mutatorMutex.mutate(mutatePriority) {
try {
if (isPersistent) {
cancellableShow()
} else {
withTimeout(BITWARDEN_TOOL_TIP_TIMEOUT) { cancellableShow() }
}
} finally {
if (mutatePriority != MutatePriority.PreventUserInput) {
// timeout or cancellation has occurred and we close out the current tooltip.
dismissBitwardenToolTip()
}
}
}
}
/**
* We are overriding this specifically to make it so it is a no-op this prevents the
* tooltip from being dismissed if the user taps anywhere out of it.
*/
override fun dismiss() {
/**No-Op**/
}
/** Cleans up [mutatorMutex] when the tooltip associated with this state leaves Composition. */
override fun onDispose() {
job?.cancel()
}
}
/**
* Provides a [BitwardenToolTipState] in a composable scope remembered across compositions.
*
* @param mutatorMutex if providing your own, ensure that any tool tips that should be
* shown/hidden in the context of the one you are using this state for, make use of the same
* instance of the passed in value.
*/
@Composable
@ExperimentalMaterial3Api
fun rememberBitwardenToolTipState(
initialIsVisible: Boolean = false,
isPersistent: Boolean = false,
mutatorMutex: MutatorMutex = BitwardenToolTipStateDefaults.GlobalMutatorMutex,
): BitwardenToolTipState =
remember(isPersistent, mutatorMutex) {
BitwardenToolTipStateImpl(
initialIsVisible = initialIsVisible,
isPersistent = isPersistent,
mutatorMutex = mutatorMutex,
)
}
/**
* Provides a global [MutatorMutex] as a singleton to be used by default for each
* created [BitwardenToolTipState]
*/
private object BitwardenToolTipStateDefaults {
val GlobalMutatorMutex = MutatorMutex()
}
private const val BITWARDEN_TOOL_TIP_TIMEOUT = 1500L

View file

@ -115,6 +115,19 @@ fun DebugMenuScreen(
}
},
)
Spacer(Modifier.height(16.dp))
BitwardenFilledButton(
label = stringResource(R.string.reset_coach_mark_tour_status),
onClick = remember(viewModel) {
{
viewModel.trySendAction(DebugMenuAction.ResetCoachMarkTourStatuses)
}
},
isEnabled = true,
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
)
Spacer(modifier = Modifier.height(height = 16.dp))
Spacer(modifier = Modifier.navigationBarsPadding())
}

View file

@ -48,9 +48,14 @@ class DebugMenuViewModel @Inject constructor(
DebugMenuAction.ResetFeatureFlagValues -> handleResetFeatureFlagValues()
DebugMenuAction.RestartOnboarding -> handleResetOnboardingStatus()
DebugMenuAction.RestartOnboardingCarousel -> handleResetOnboardingCarousel()
DebugMenuAction.ResetCoachMarkTourStatuses -> handleResetCoachMarkTourStatuses()
}
}
private fun handleResetCoachMarkTourStatuses() {
debugMenuRepository.resetCoachMarkTourStatuses()
}
private fun handleResetOnboardingCarousel() {
debugMenuRepository.modifyStateToShowOnboardingCarousel(
userStateUpdateTrigger = {
@ -133,6 +138,11 @@ sealed class DebugMenuAction {
*/
data object RestartOnboardingCarousel : DebugMenuAction()
/**
* User has clicked to reset coach mark values.
*/
data object ResetCoachMarkTourStatuses : DebugMenuAction()
/**
* Internal actions not triggered from the UI.
*/

View file

@ -25,5 +25,6 @@
<string name="new_device_permanent_dismiss">New device notice permanent dismiss</string>">
<string name="new_device_temporary_dismiss">New device notice temporary dismiss</string>">
<string name="ignore_environment_check">Ignore environment check</string>">
<string name="reset_coach_mark_tour_status">Reset all coach mark tours</string>
<!-- /Debug Menu -->
</resources>

View file

@ -203,6 +203,23 @@ class DebugMenuRepositoryTest {
}
assertTrue(lambdaHasBeenCalled)
}
@Test
fun `resetCoachMarkTourStatuses calls settings disk source setting values back to null`() {
every {
mockSettingsDiskSource.storeShouldShowGeneratorCoachMark(shouldShow = any())
} just runs
every {
mockSettingsDiskSource.storeShouldShowAddLoginCoachMark(shouldShow = any())
} just runs
debugMenuRepository.resetCoachMarkTourStatuses()
verify(exactly = 1) {
mockSettingsDiskSource.storeShouldShowGeneratorCoachMark(shouldShow = null)
mockSettingsDiskSource.storeShouldShowAddLoginCoachMark(shouldShow = null)
}
}
}
private const val TEST_STRING_VALUE = "test"

View file

@ -89,7 +89,7 @@ class DebugMenuScreenTest : BaseComposeTest() {
.onNodeWithText("Email Verification", ignoreCase = true)
.performClick()
verify {
verify(exactly = 1) {
viewModel.trySendAction(
DebugMenuAction.UpdateFeatureFlag(
FlagKey.EmailVerification,
@ -106,7 +106,7 @@ class DebugMenuScreenTest : BaseComposeTest() {
.performScrollTo()
.performClick()
verify { viewModel.trySendAction(DebugMenuAction.ResetFeatureFlagValues) }
verify(exactly = 1) { viewModel.trySendAction(DebugMenuAction.ResetFeatureFlagValues) }
}
@Test
@ -124,7 +124,7 @@ class DebugMenuScreenTest : BaseComposeTest() {
.assertIsEnabled()
.performClick()
verify { viewModel.trySendAction(DebugMenuAction.RestartOnboarding) }
verify(exactly = 1) { viewModel.trySendAction(DebugMenuAction.RestartOnboarding) }
}
@Test
@ -161,7 +161,7 @@ class DebugMenuScreenTest : BaseComposeTest() {
.assertIsEnabled()
.performClick()
verify { viewModel.trySendAction(DebugMenuAction.RestartOnboardingCarousel) }
verify(exactly = 1) { viewModel.trySendAction(DebugMenuAction.RestartOnboardingCarousel) }
}
@Test
@ -182,4 +182,14 @@ class DebugMenuScreenTest : BaseComposeTest() {
verify(exactly = 0) { viewModel.trySendAction(DebugMenuAction.RestartOnboardingCarousel) }
}
@Test
fun `reset all coach mark tours should send ResetCoachMarkTourStatuses action`() {
composeTestRule
.onNodeWithText("Reset all coach mark tours")
.performScrollTo()
.performClick()
verify(exactly = 1) { viewModel.trySendAction(DebugMenuAction.ResetCoachMarkTourStatuses) }
}
}

View file

@ -78,14 +78,16 @@ class DebugMenuViewModelTest : BaseViewModelTest() {
viewModel.trySendAction(
DebugMenuAction.UpdateFeatureFlag(FlagKey.EmailVerification, false),
)
verify { mockDebugMenuRepository.updateFeatureFlag(FlagKey.EmailVerification, false) }
verify(exactly = 1) {
mockDebugMenuRepository.updateFeatureFlag(FlagKey.EmailVerification, false)
}
}
@Test
fun `handleResetOnboardingStatus should reset the onboarding status`() {
val viewModel = createViewModel()
viewModel.trySendAction(DebugMenuAction.RestartOnboarding)
verify { mockDebugMenuRepository.resetOnboardingStatusForCurrentUser() }
verify(exactly = 1) { mockDebugMenuRepository.resetOnboardingStatusForCurrentUser() }
}
@Suppress("MaxLineLength")
@ -93,12 +95,21 @@ class DebugMenuViewModelTest : BaseViewModelTest() {
fun `handleResetOnboardingCarousel should reset the onboarding carousel and update user state pending account action`() {
val viewModel = createViewModel()
viewModel.trySendAction(DebugMenuAction.RestartOnboardingCarousel)
verify {
verify(exactly = 1) {
mockDebugMenuRepository.modifyStateToShowOnboardingCarousel(any())
mockAuthRepository.hasPendingAccountAddition = true
}
}
@Test
fun `handleResetCoachMarkTourStatuses should call repository to reset values`() {
val viewModel = createViewModel()
viewModel.trySendAction(DebugMenuAction.ResetCoachMarkTourStatuses)
verify(exactly = 1) {
mockDebugMenuRepository.resetCoachMarkTourStatuses()
}
}
private fun createViewModel(): DebugMenuViewModel = DebugMenuViewModel(
featureFlagManager = mockFeatureFlagManager,
debugMenuRepository = mockDebugMenuRepository,