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. (#4637)
This commit is contained in:
parent
b2c4fbb593
commit
7a25aafc23
15 changed files with 224 additions and 25 deletions
app/src
main
java/com/x8bit/bitwarden
data/platform/repository
ui/platform
res/values
test/java/com/x8bit/bitwarden
data/platform/repository
ui/platform/feature/debugmenu
|
@ -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.
|
||||
*
|
||||
|
|
|
@ -56,6 +56,11 @@ class DebugMenuRepositoryImpl(
|
|||
)
|
||||
}
|
||||
|
||||
override fun resetCoachMarkTourStatuses() {
|
||||
settingsDiskSource.storeShouldShowGeneratorCoachMark(shouldShow = null)
|
||||
settingsDiskSource.storeShouldShowAddLoginCoachMark(shouldShow = null)
|
||||
}
|
||||
|
||||
override fun modifyStateToShowOnboardingCarousel(
|
||||
userStateUpdateTrigger: () -> Unit,
|
||||
) {
|
||||
|
|
|
@ -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.
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
),
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -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>,
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
|
|
|
@ -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()
|
||||
}
|
|
@ -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
|
|
@ -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())
|
||||
}
|
||||
|
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
Loading…
Add table
Reference in a new issue