1
0
Fork 0
mirror of https://github.com/bitwarden/android.git synced 2025-02-22 16:49:13 +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() 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. * 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( override fun modifyStateToShowOnboardingCarousel(
userStateUpdateTrigger: () -> Unit, userStateUpdateTrigger: () -> Unit,
) { ) {

View file

@ -60,7 +60,6 @@ fun <T : Enum<T>> CoachMarkContainer(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
content: @Composable CoachMarkScope<T>.() -> Unit, content: @Composable CoachMarkScope<T>.() -> Unit,
) { ) {
val scope = rememberCoroutineScope()
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@ -101,9 +100,8 @@ fun <T : Enum<T>> CoachMarkContainer(
.pointerInput(Unit) { .pointerInput(Unit) {
detectTapGestures( detectTapGestures(
onTap = { onTap = {
scope.launch { // NO-OP, this consumes any touch events
state.showToolTipForCurrentCoachMark() // 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.ExperimentalMaterial3Api
import androidx.compose.material3.TooltipBox import androidx.compose.material3.TooltipBox
import androidx.compose.material3.TooltipDefaults import androidx.compose.material3.TooltipDefaults
import androidx.compose.material3.TooltipState
import androidx.compose.material3.rememberTooltipState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue 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.coachmark.model.CoachMarkHighlightShape
import com.x8bit.bitwarden.ui.platform.components.model.CardStyle 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.BitwardenToolTip
import com.x8bit.bitwarden.ui.platform.components.tooltip.BitwardenToolTipState
import com.x8bit.bitwarden.ui.platform.components.tooltip.rememberBitwardenToolTipState
import okhttp3.internal.toImmutableList import okhttp3.internal.toImmutableList
import org.jetbrains.annotations.VisibleForTesting import org.jetbrains.annotations.VisibleForTesting
@ -55,7 +55,7 @@ class CoachMarkScopeInstance<T : Enum<T>>(
rightAction: @Composable() (RowScope.() -> Unit)?, rightAction: @Composable() (RowScope.() -> Unit)?,
anchorContent: @Composable () -> Unit, anchorContent: @Composable () -> Unit,
) { ) {
val toolTipState = rememberTooltipState( val toolTipState = rememberBitwardenToolTipState(
initialIsVisible = false, initialIsVisible = false,
isPersistent = true, isPersistent = true,
) )
@ -192,7 +192,7 @@ class CoachMarkScopeInstance<T : Enum<T>>(
leftAction: @Composable() (RowScope.() -> Unit)?, leftAction: @Composable() (RowScope.() -> Unit)?,
rightAction: @Composable() (RowScope.() -> Unit)?, rightAction: @Composable() (RowScope.() -> Unit)?,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
toolTipState: TooltipState = rememberTooltipState( toolTipState: BitwardenToolTipState = rememberBitwardenToolTipState(
initialIsVisible = false, initialIsVisible = false,
isPersistent = true, isPersistent = true,
), ),

View file

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

View file

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

View file

@ -1,8 +1,7 @@
package com.x8bit.bitwarden.ui.platform.components.coachmark.model 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 androidx.compose.ui.geometry.Rect
import com.x8bit.bitwarden.ui.platform.components.tooltip.BitwardenToolTipState
/** /**
* Represents a highlight within a coach mark sequence. * 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 toolTipState The state of the tooltip associated with this highlight.
* @property shape The shape of the highlight (e.g., square, oval). * @property shape The shape of the highlight (e.g., square, oval).
*/ */
@OptIn(ExperimentalMaterial3Api::class)
data class CoachMarkHighlightState<T : Enum<T>>( data class CoachMarkHighlightState<T : Enum<T>>(
val key: T, val key: T,
val highlightBounds: Rect?, val highlightBounds: Rect?,
val toolTipState: TooltipState, val toolTipState: BitwardenToolTipState,
val shape: CoachMarkHighlightShape, 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.height(height = 16.dp))
Spacer(modifier = Modifier.navigationBarsPadding()) Spacer(modifier = Modifier.navigationBarsPadding())
} }

View file

@ -48,9 +48,14 @@ class DebugMenuViewModel @Inject constructor(
DebugMenuAction.ResetFeatureFlagValues -> handleResetFeatureFlagValues() DebugMenuAction.ResetFeatureFlagValues -> handleResetFeatureFlagValues()
DebugMenuAction.RestartOnboarding -> handleResetOnboardingStatus() DebugMenuAction.RestartOnboarding -> handleResetOnboardingStatus()
DebugMenuAction.RestartOnboardingCarousel -> handleResetOnboardingCarousel() DebugMenuAction.RestartOnboardingCarousel -> handleResetOnboardingCarousel()
DebugMenuAction.ResetCoachMarkTourStatuses -> handleResetCoachMarkTourStatuses()
} }
} }
private fun handleResetCoachMarkTourStatuses() {
debugMenuRepository.resetCoachMarkTourStatuses()
}
private fun handleResetOnboardingCarousel() { private fun handleResetOnboardingCarousel() {
debugMenuRepository.modifyStateToShowOnboardingCarousel( debugMenuRepository.modifyStateToShowOnboardingCarousel(
userStateUpdateTrigger = { userStateUpdateTrigger = {
@ -133,6 +138,11 @@ sealed class DebugMenuAction {
*/ */
data object RestartOnboardingCarousel : DebugMenuAction() data object RestartOnboardingCarousel : DebugMenuAction()
/**
* User has clicked to reset coach mark values.
*/
data object ResetCoachMarkTourStatuses : DebugMenuAction()
/** /**
* Internal actions not triggered from the UI. * 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_permanent_dismiss">New device notice permanent dismiss</string>">
<string name="new_device_temporary_dismiss">New device notice temporary 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="ignore_environment_check">Ignore environment check</string>">
<string name="reset_coach_mark_tour_status">Reset all coach mark tours</string>
<!-- /Debug Menu --> <!-- /Debug Menu -->
</resources> </resources>

View file

@ -203,6 +203,23 @@ class DebugMenuRepositoryTest {
} }
assertTrue(lambdaHasBeenCalled) 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" private const val TEST_STRING_VALUE = "test"

View file

@ -89,7 +89,7 @@ class DebugMenuScreenTest : BaseComposeTest() {
.onNodeWithText("Email Verification", ignoreCase = true) .onNodeWithText("Email Verification", ignoreCase = true)
.performClick() .performClick()
verify { verify(exactly = 1) {
viewModel.trySendAction( viewModel.trySendAction(
DebugMenuAction.UpdateFeatureFlag( DebugMenuAction.UpdateFeatureFlag(
FlagKey.EmailVerification, FlagKey.EmailVerification,
@ -106,7 +106,7 @@ class DebugMenuScreenTest : BaseComposeTest() {
.performScrollTo() .performScrollTo()
.performClick() .performClick()
verify { viewModel.trySendAction(DebugMenuAction.ResetFeatureFlagValues) } verify(exactly = 1) { viewModel.trySendAction(DebugMenuAction.ResetFeatureFlagValues) }
} }
@Test @Test
@ -124,7 +124,7 @@ class DebugMenuScreenTest : BaseComposeTest() {
.assertIsEnabled() .assertIsEnabled()
.performClick() .performClick()
verify { viewModel.trySendAction(DebugMenuAction.RestartOnboarding) } verify(exactly = 1) { viewModel.trySendAction(DebugMenuAction.RestartOnboarding) }
} }
@Test @Test
@ -161,7 +161,7 @@ class DebugMenuScreenTest : BaseComposeTest() {
.assertIsEnabled() .assertIsEnabled()
.performClick() .performClick()
verify { viewModel.trySendAction(DebugMenuAction.RestartOnboardingCarousel) } verify(exactly = 1) { viewModel.trySendAction(DebugMenuAction.RestartOnboardingCarousel) }
} }
@Test @Test
@ -182,4 +182,14 @@ class DebugMenuScreenTest : BaseComposeTest() {
verify(exactly = 0) { viewModel.trySendAction(DebugMenuAction.RestartOnboardingCarousel) } 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( viewModel.trySendAction(
DebugMenuAction.UpdateFeatureFlag(FlagKey.EmailVerification, false), DebugMenuAction.UpdateFeatureFlag(FlagKey.EmailVerification, false),
) )
verify { mockDebugMenuRepository.updateFeatureFlag(FlagKey.EmailVerification, false) } verify(exactly = 1) {
mockDebugMenuRepository.updateFeatureFlag(FlagKey.EmailVerification, false)
}
} }
@Test @Test
fun `handleResetOnboardingStatus should reset the onboarding status`() { fun `handleResetOnboardingStatus should reset the onboarding status`() {
val viewModel = createViewModel() val viewModel = createViewModel()
viewModel.trySendAction(DebugMenuAction.RestartOnboarding) viewModel.trySendAction(DebugMenuAction.RestartOnboarding)
verify { mockDebugMenuRepository.resetOnboardingStatusForCurrentUser() } verify(exactly = 1) { mockDebugMenuRepository.resetOnboardingStatusForCurrentUser() }
} }
@Suppress("MaxLineLength") @Suppress("MaxLineLength")
@ -93,12 +95,21 @@ class DebugMenuViewModelTest : BaseViewModelTest() {
fun `handleResetOnboardingCarousel should reset the onboarding carousel and update user state pending account action`() { fun `handleResetOnboardingCarousel should reset the onboarding carousel and update user state pending account action`() {
val viewModel = createViewModel() val viewModel = createViewModel()
viewModel.trySendAction(DebugMenuAction.RestartOnboardingCarousel) viewModel.trySendAction(DebugMenuAction.RestartOnboardingCarousel)
verify { verify(exactly = 1) {
mockDebugMenuRepository.modifyStateToShowOnboardingCarousel(any()) mockDebugMenuRepository.modifyStateToShowOnboardingCarousel(any())
mockAuthRepository.hasPendingAccountAddition = true 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( private fun createViewModel(): DebugMenuViewModel = DebugMenuViewModel(
featureFlagManager = mockFeatureFlagManager, featureFlagManager = mockFeatureFlagManager,
debugMenuRepository = mockDebugMenuRepository, debugMenuRepository = mockDebugMenuRepository,