Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ import androidx.lifecycle.viewModelScope
import com.bitwarden.core.data.manager.model.FlagKey
import com.bitwarden.data.repository.util.baseWebVaultUrlOrDefault
import com.bitwarden.ui.platform.base.BaseViewModel
import com.bitwarden.ui.platform.resource.BitwardenString
import com.bitwarden.ui.util.Text
import com.bitwarden.ui.util.asText
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.platform.manager.CookieAcquisitionRequestManager
import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
Expand Down Expand Up @@ -37,7 +40,10 @@ class DebugMenuViewModel @Inject constructor(
private val cookieAcquisitionRequestManager: CookieAcquisitionRequestManager,
private val environmentRepository: EnvironmentRepository,
) : BaseViewModel<DebugMenuState, DebugMenuEvent, DebugMenuAction>(
initialState = DebugMenuState(featureFlags = persistentMapOf()),
initialState = DebugMenuState(
featureFlags = persistentMapOf(),
mainTypeOption = DebugMenuState.MainTypeOption.FLAGS,
),
) {

private var featureFlagResetJob: Job? = null
Expand Down Expand Up @@ -68,9 +74,14 @@ class DebugMenuViewModel @Inject constructor(
DebugMenuAction.ResetPremiumUpgradeBanner -> handleResetPremiumUpgradeBanner()
DebugMenuAction.ShowUpgradedToPremiumCard -> handleShowUpgradedToPremiumCard()
DebugMenuAction.ResetAccessibilityDisclaimer -> handleResetAccessibilityDisclaimer()
is DebugMenuAction.MainTypeOptionClick -> handleMainTypeOptionClick(action)
}
}

private fun handleMainTypeOptionClick(action: DebugMenuAction.MainTypeOptionClick) {
mutableStateFlow.update { it.copy(mainTypeOption = action.option) }
}

private fun handleResetAccessibilityDisclaimer() {
debugMenuRepository.resetAccessibilityDisclaimer()
}
Expand Down Expand Up @@ -148,7 +159,20 @@ class DebugMenuViewModel @Inject constructor(
*/
data class DebugMenuState(
val featureFlags: ImmutableMap<FlagKey<Any>, Any>,
)
val mainTypeOption: MainTypeOption,
) {
/**
* Enum representing the main type options for the debug menu, such as Feature flags and
* options.
*/
enum class MainTypeOption(
val label: Text,
val testTag: String,
Comment thread
david-livefront marked this conversation as resolved.
) {
FLAGS(label = BitwardenString.feature_flags.asText(), testTag = "feature_flags"),
OPTIONS(label = BitwardenString.debug_options.asText(), testTag = "debug_options"),
}
}

/**
* Models event for the [DebugMenuViewModel] to send to the UI.
Expand All @@ -164,6 +188,12 @@ sealed class DebugMenuEvent {
* Models action for the [DebugMenuViewModel] to handle.
*/
sealed class DebugMenuAction {
/**
* Indicates that the main option type has been changed by the user.
*/
data class MainTypeOptionClick(
val option: DebugMenuState.MainTypeOption,
) : DebugMenuAction()

/**
* Updates a feature flag for the given [FlagKey] to the given [newValue].
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
package com.x8bit.bitwarden.ui.platform.feature.debugmenu.handler

import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import com.bitwarden.core.data.manager.model.FlagKey
import com.x8bit.bitwarden.ui.platform.feature.debugmenu.DebugMenuAction
import com.x8bit.bitwarden.ui.platform.feature.debugmenu.DebugMenuState
import com.x8bit.bitwarden.ui.platform.feature.debugmenu.DebugMenuViewModel

/**
* Handler for the debug menu screen lambda invocations.
*/
@Suppress("LongParameterList")
class DebugMenuHandler(
val onNavigateBack: () -> Unit,
val onMainTypeOptionClick: (option: DebugMenuState.MainTypeOption) -> Unit,
val onUpdateFeatureFlag: (flagKey: FlagKey<Any>, newValue: Any) -> Unit,
val onResetFeatureFlagValues: () -> Unit,
val onRestartOnboarding: () -> Unit,
val onRestartOnboardingCarousel: () -> Unit,
val onResetAccessibilityDisclaimer: () -> Unit,
val onResetCoachMarkTourStatuses: () -> Unit,
val onTriggerCookieAcquisition: () -> Unit,
val onClearSsoCookies: () -> Unit,
val onResetPremiumUpgradeBanner: () -> Unit,
val onShowUpgradedToPremiumCard: () -> Unit,
val onGenerateErrorReportClick: () -> Unit,
val onGenerateCrashClick: () -> Unit,
) {
@Suppress("UndocumentedPublicClass")
companion object {
/**
* Create [DebugMenuHandler] with the given [viewModel] to send actions to.
*/
Comment thread
david-livefront marked this conversation as resolved.
fun create(viewModel: DebugMenuViewModel): DebugMenuHandler = DebugMenuHandler(
onNavigateBack = { viewModel.trySendAction(DebugMenuAction.NavigateBack) },
onMainTypeOptionClick = {
viewModel.trySendAction(DebugMenuAction.MainTypeOptionClick(it))
},
onUpdateFeatureFlag = { key, value ->
viewModel.trySendAction(DebugMenuAction.UpdateFeatureFlag(key, value))
},
onResetFeatureFlagValues = {
viewModel.trySendAction(DebugMenuAction.ResetFeatureFlagValues)
},
onRestartOnboarding = { viewModel.trySendAction(DebugMenuAction.RestartOnboarding) },
onRestartOnboardingCarousel = {
viewModel.trySendAction(DebugMenuAction.RestartOnboardingCarousel)
},
onResetAccessibilityDisclaimer = {
viewModel.trySendAction(DebugMenuAction.ResetAccessibilityDisclaimer)
},
onResetCoachMarkTourStatuses = {
viewModel.trySendAction(DebugMenuAction.ResetCoachMarkTourStatuses)
},
onTriggerCookieAcquisition = {
viewModel.trySendAction(DebugMenuAction.TriggerCookieAcquisition)
},
onClearSsoCookies = { viewModel.trySendAction(DebugMenuAction.ClearSsoCookies) },
onResetPremiumUpgradeBanner = {
viewModel.trySendAction(DebugMenuAction.ResetPremiumUpgradeBanner)
},
onShowUpgradedToPremiumCard = {
viewModel.trySendAction(DebugMenuAction.ShowUpgradedToPremiumCard)
},
onGenerateErrorReportClick = {
viewModel.trySendAction(DebugMenuAction.GenerateErrorReportClick)
},
onGenerateCrashClick = { viewModel.trySendAction(DebugMenuAction.GenerateCrashClick) },
)

/**
* Create [DebugMenuHandler] with all empty callbacks. This should only be used for
* previews.
*/
fun createEmpty(): DebugMenuHandler = DebugMenuHandler(
onNavigateBack = { },
onMainTypeOptionClick = { },
onUpdateFeatureFlag = { _, _ -> },
onResetFeatureFlagValues = { },
onRestartOnboarding = { },
onRestartOnboardingCarousel = { },
onResetAccessibilityDisclaimer = { },
onResetCoachMarkTourStatuses = { },
onTriggerCookieAcquisition = { },
onClearSsoCookies = { },
onResetPremiumUpgradeBanner = { },
onShowUpgradedToPremiumCard = { },
onGenerateErrorReportClick = { },
onGenerateCrashClick = { },
)
}
}

/**
* Remember [DebugMenuHandler] with the given [viewModel] within a [Composable] scope.
*/
@Composable
fun rememberDebugMenuHandler(
viewModel: DebugMenuViewModel,
): DebugMenuHandler =
remember(viewModel) {
DebugMenuHandler.create(viewModel = viewModel)
}
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ class DebugMenuScreenTest : BitwardenComposeTest() {

@Test
fun `on generate crash click should send GenerateCrashClick action`() {
mutableStateFlow.update { it.copy(mainTypeOption = DebugMenuState.MainTypeOption.OPTIONS) }
composeTestRule
.onNodeWithText(text = "Generate crash")
.performScrollTo()
Expand All @@ -64,6 +65,7 @@ class DebugMenuScreenTest : BitwardenComposeTest() {

@Test
fun `on generate error report click should send GenerateErrorReportClick action`() {
mutableStateFlow.update { it.copy(mainTypeOption = DebugMenuState.MainTypeOption.OPTIONS) }
composeTestRule
.onNodeWithText(text = "Generate error report")
.performScrollTo()
Expand All @@ -74,7 +76,7 @@ class DebugMenuScreenTest : BitwardenComposeTest() {

@Test
fun `feature flag content should not display if the state is empty`() {
mutableStateFlow.update { DebugMenuState(featureFlags = persistentMapOf()) }
mutableStateFlow.update { it.copy(featureFlags = persistentMapOf()) }
composeTestRule
.onNodeWithText(text = "dummy-boolean")
.assertDoesNotExist()
Expand All @@ -83,7 +85,7 @@ class DebugMenuScreenTest : BitwardenComposeTest() {
@Test
fun `feature flag content should display if the state is not empty`() {
mutableStateFlow.update {
DebugMenuState(
it.copy(
featureFlags = persistentMapOf(
FlagKey.DummyBoolean to true,
),
Expand All @@ -97,7 +99,7 @@ class DebugMenuScreenTest : BitwardenComposeTest() {
@Test
fun `boolean feature flag content should send action when clicked`() {
mutableStateFlow.update {
DebugMenuState(
it.copy(
featureFlags = persistentMapOf(
FlagKey.DummyBoolean to true,
),
Expand All @@ -120,7 +122,7 @@ class DebugMenuScreenTest : BitwardenComposeTest() {
@Test
fun `reset feature flag values should send action when clicked`() {
composeTestRule
.onNodeWithText("Reset Values", ignoreCase = true)
.onNodeWithText("Reset values")
.performScrollTo()
.performClick()

Expand All @@ -129,6 +131,7 @@ class DebugMenuScreenTest : BitwardenComposeTest() {

@Test
fun `restart onboarding should send action when clicked`() {
mutableStateFlow.update { it.copy(mainTypeOption = DebugMenuState.MainTypeOption.OPTIONS) }
composeTestRule
.onNodeWithText("Restart Onboarding", ignoreCase = true)
.performScrollTo()
Expand All @@ -140,8 +143,9 @@ class DebugMenuScreenTest : BitwardenComposeTest() {

@Test
fun `Show onboarding carousel should send action when enabled and clicked`() {
mutableStateFlow.update { it.copy(mainTypeOption = DebugMenuState.MainTypeOption.OPTIONS) }
composeTestRule
.onNodeWithText("Show Onboarding Carousel", ignoreCase = true)
.onNodeWithText("Show Onboarding Carousel")
.performScrollTo()
.assertIsEnabled()
.performClick()
Expand All @@ -151,6 +155,7 @@ class DebugMenuScreenTest : BitwardenComposeTest() {

@Test
fun `clear SSO cookies should send ClearSsoCookies action`() {
mutableStateFlow.update { it.copy(mainTypeOption = DebugMenuState.MainTypeOption.OPTIONS) }
composeTestRule
.onNodeWithText("Clear SSO cookies")
.performScrollTo()
Expand All @@ -161,6 +166,7 @@ class DebugMenuScreenTest : BitwardenComposeTest() {

@Test
fun `reset Premium upgrade banner should send ResetPremiumUpgradeBanner action`() {
mutableStateFlow.update { it.copy(mainTypeOption = DebugMenuState.MainTypeOption.OPTIONS) }
composeTestRule
.onNodeWithText("Reset Premium upgrade banner")
.performScrollTo()
Expand All @@ -173,6 +179,7 @@ class DebugMenuScreenTest : BitwardenComposeTest() {

@Test
fun `reset accessibility disclaimer should send ResetAccessibilityDisclaimer action`() {
mutableStateFlow.update { it.copy(mainTypeOption = DebugMenuState.MainTypeOption.OPTIONS) }
composeTestRule
.onNodeWithText("Reset accessibility disclaimer")
.performScrollTo()
Expand All @@ -185,6 +192,7 @@ class DebugMenuScreenTest : BitwardenComposeTest() {

@Test
fun `reset all coach mark tours should send ResetCoachMarkTourStatuses action`() {
mutableStateFlow.update { it.copy(mainTypeOption = DebugMenuState.MainTypeOption.OPTIONS) }
composeTestRule
.onNodeWithText("Reset all coach mark tours")
.performScrollTo()
Expand All @@ -198,4 +206,5 @@ private val DEFAULT_STATE: DebugMenuState = DebugMenuState(
featureFlags = persistentMapOf(
FlagKey.DummyBoolean to true,
),
mainTypeOption = DebugMenuState.MainTypeOption.FLAGS,
)
Original file line number Diff line number Diff line change
Expand Up @@ -65,14 +65,30 @@ class DebugMenuViewModelTest : BaseViewModelTest() {
assertEquals(viewModel.stateFlow.value, DEFAULT_STATE)
}

@Test
fun `handleMainTypeOptionClick should update the main option state`() {
val viewModel = createViewModel()
assertEquals(viewModel.stateFlow.value, DEFAULT_STATE)
viewModel.trySendAction(
DebugMenuAction.MainTypeOptionClick(DebugMenuState.MainTypeOption.OPTIONS),
)
assertEquals(
viewModel.stateFlow.value,
DEFAULT_STATE.copy(mainTypeOption = DebugMenuState.MainTypeOption.OPTIONS),
)
}

@Test
fun `handleUpdateFeatureFlag should update the feature flag`() {
val viewModel = createViewModel()
assertEquals(viewModel.stateFlow.value, DEFAULT_STATE)
viewModel.trySendAction(
DebugMenuAction.Internal.UpdateFeatureFlagMap(UPDATED_MAP_VALUE),
)
assertEquals(viewModel.stateFlow.value, DebugMenuState(UPDATED_MAP_VALUE))
assertEquals(
viewModel.stateFlow.value,
DEFAULT_STATE.copy(featureFlags = UPDATED_MAP_VALUE),
)
}

@Test
Expand Down Expand Up @@ -218,4 +234,5 @@ private val UPDATED_MAP_VALUE: ImmutableMap<FlagKey<Any>, Any> = FlagKey

private val DEFAULT_STATE = DebugMenuState(
featureFlags = DEFAULT_MAP_VALUE,
mainTypeOption = DebugMenuState.MainTypeOption.FLAGS,
)
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,14 @@ import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.displayCutout
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.union
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
Expand Down Expand Up @@ -62,9 +64,12 @@ fun BitwardenSegmentedButton(
Int,
Dp,
SegmentedButtonState,
) -> Unit = { _, _, optionState ->
) -> Unit = { _, weightedWidth, optionState ->
this.SegmentedButtonOptionContent(
option = optionState,
modifier = Modifier
.fillMaxHeight()
.width(width = weightedWidth),
)
},
) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package com.bitwarden.ui.platform.components.segment.transition

import androidx.compose.animation.AnimatedContentTransitionScope
import androidx.compose.animation.ContentTransform
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.animation.togetherWith

private const val TWEEN_DURATION_MS: Int = 300

/**
* A standard [ContentTransform] to be used when animating to different content states of a
* segmented control indicated by the [Enum].
*/
fun <T : Enum<T>> AnimatedContentTransitionScope<T>.segmentedContentTransform(): ContentTransform {
// Slide in from right if moving forward, from left if moving backward
return if (targetState.ordinal > initialState.ordinal) {
(slideInHorizontally { width -> width } + fadeIn(tween(TWEEN_DURATION_MS))) togetherWith
slideOutHorizontally { width -> -width } + fadeOut(tween(TWEEN_DURATION_MS))
} else {
(slideInHorizontally { width -> -width } + fadeIn(tween(TWEEN_DURATION_MS))) togetherWith
slideOutHorizontally { width -> width } + fadeOut(tween(TWEEN_DURATION_MS))
}
}
6 changes: 4 additions & 2 deletions ui/src/main/res/values/strings_non_localized.xml
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,12 @@
<string name="shared_accounts_header" translatable="false">%1$s | %2$s (%3$d)</string>

<!-- region Debug Menu -->
<string name="feature_flags">Feature Flags:</string>
<string name="feature_flags">Feature Flags</string>
<string name="no_feature_flags">There are currently no feature flags</string>
<string name="debug_options">Debug Options</string>
<string name="debug_menu">Debug Menu</string>
<string name="reset_values">Reset values</string>
<string name="onboarding_override">Onboarding Status Override</string>
<string name="onboarding_override">Onboarding</string>
<string name="restart_onboarding_cta">Restart Onboarding</string>
<string name="restart_onboarding_details">This will reset the onboarding status for the current user, if available. After clicking the button you will immediately be redirected to the onboarding flow. Onboarding flag must be enabled.</string>
<string name="restart_onboarding_carousel">Show Onboarding Carousel</string>
Expand Down
Loading