PM-42849: Feat: Update Debug menu UI (#7338)

This commit is contained in:
David Perez authored and GitHub committed 2026-09-01 16:55:46 +00:00
1 parent 1d80697c2b
commit 8152f5c6f8
8 files changed
+465 -221

No files matched your search

@@ -1,5 +1,6 @@
package com.x8bit.bitwarden.ui.platform.feature.debugmenu
import androidx.compose.animation.AnimatedContent
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
@@ -14,32 +15,39 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitwarden.core.data.manager.model.FlagKey
import com.bitwarden.ui.platform.base.util.EventsEffect
import com.bitwarden.ui.platform.base.util.scrolledContainerBottomDivider
import com.bitwarden.ui.platform.base.util.standardHorizontalMargin
import com.bitwarden.ui.platform.base.util.toListItemCardStyle
import com.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar
import com.bitwarden.ui.platform.components.appbar.NavigationIcon
import com.bitwarden.ui.platform.components.appbar.model.TopAppBarDividerStyle
import com.bitwarden.ui.platform.components.button.BitwardenFilledButton
import com.bitwarden.ui.platform.components.content.BitwardenEmptyContent
import com.bitwarden.ui.platform.components.debug.ListItemContent
import com.bitwarden.ui.platform.components.divider.BitwardenHorizontalDivider
import com.bitwarden.ui.platform.components.header.BitwardenListHeaderText
import com.bitwarden.ui.platform.components.scaffold.BitwardenScaffold
import com.bitwarden.ui.platform.components.segment.BitwardenSegmentedButton
import com.bitwarden.ui.platform.components.segment.SegmentedButtonState
import com.bitwarden.ui.platform.components.segment.transition.segmentedContentTransform
import com.bitwarden.ui.platform.components.util.rememberVectorPainter
import com.bitwarden.ui.platform.resource.BitwardenDrawable
import com.bitwarden.ui.platform.resource.BitwardenString
import com.bitwarden.ui.platform.theme.BitwardenTheme
import com.x8bit.bitwarden.ui.platform.feature.debugmenu.handler.DebugMenuHandler
import com.x8bit.bitwarden.ui.platform.feature.debugmenu.handler.rememberDebugMenuHandler
import kotlinx.collections.immutable.ImmutableMap
import kotlinx.collections.immutable.persistentMapOf
import kotlinx.collections.immutable.toImmutableList
/**
* Top level screen for the debug menu.
@@ -52,7 +60,7 @@ fun DebugMenuScreen(
viewModel: DebugMenuViewModel = hiltViewModel(),
) {
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
val handler = rememberDebugMenuHandler(viewModel = viewModel)
EventsEffect(viewModel = viewModel) { event ->
when (event) {
DebugMenuEvent.NavigateBack -> onNavigateBack()
@@ -62,152 +70,93 @@ fun DebugMenuScreen(
BitwardenScaffold(
modifier = Modifier
.fillMaxSize()
.nestedScroll(scrollBehavior.nestedScrollConnection),
.nestedScroll(connection = scrollBehavior.nestedScrollConnection),
topBar = {
BitwardenTopAppBar(
title = stringResource(BitwardenString.debug_menu),
title = stringResource(id = BitwardenString.debug_menu),
scrollBehavior = scrollBehavior,
dividerStyle = TopAppBarDividerStyle.NONE,
navigationIcon = NavigationIcon(
navigationIcon = rememberVectorPainter(BitwardenDrawable.ic_back),
navigationIcon = rememberVectorPainter(id = BitwardenDrawable.ic_back),
navigationIconContentDescription = stringResource(id = BitwardenString.back),
onNavigationIconClick = {
viewModel.trySendAction(DebugMenuAction.NavigateBack)
},
onNavigationIconClick = handler.onNavigateBack,
),
)
},
) {
Column(
modifier = Modifier.verticalScroll(rememberScrollState()),
) {
Spacer(modifier = Modifier.height(height = 12.dp))
if (state.featureFlags.isNotEmpty()) {
FeatureFlagContent(
featureFlagMap = state.featureFlags,
onValueChange = { key, value ->
viewModel.trySendAction(DebugMenuAction.UpdateFeatureFlag(key, value))
},
onResetValues = {
viewModel.trySendAction(DebugMenuAction.ResetFeatureFlagValues)
},
)
Spacer(Modifier.height(height = 16.dp))
BitwardenHorizontalDivider()
Spacer(Modifier.height(height = 16.dp))
}
OnboardingOverrideContent(
onStartOnboarding = { viewModel.trySendAction(DebugMenuAction.RestartOnboarding) },
onStartOnboardingCarousel = {
viewModel.trySendAction(DebugMenuAction.RestartOnboardingCarousel)
},
utilityBar = {
BitwardenSegmentedButton(
options = DebugMenuState
.MainTypeOption
.entries
.map {
SegmentedButtonState(
text = it.label(),
onClick = { handler.onMainTypeOptionClick(it) },
isChecked = it == state.mainTypeOption,
testTag = it.testTag,
)
}
.toImmutableList(),
modifier = Modifier
.scrolledContainerBottomDivider(topAppBarScrollBehavior = scrollBehavior)
.fillMaxWidth(),
)
},
) {
AnimatedContent(
targetState = state.mainTypeOption,
transitionSpec = { this.segmentedContentTransform() },
label = "DebugContentTransition",
) {
when (it) {
DebugMenuState.MainTypeOption.FLAGS -> {
FeatureFlagContent(
featureFlagMap = state.featureFlags,
handler = handler,
)
}
DebugMenuState.MainTypeOption.OPTIONS -> {
DebugOptionsContent(handler = handler)
}
}
}
}
}
@Composable
private fun FeatureFlagContent(
featureFlagMap: ImmutableMap<FlagKey<Any>, Any>,
handler: DebugMenuHandler,
modifier: Modifier = Modifier,
) {
if (featureFlagMap.isEmpty()) {
BitwardenEmptyContent(
text = stringResource(id = BitwardenString.no_feature_flags),
modifier = modifier,
)
} else {
Column(modifier = modifier.verticalScroll(state = rememberScrollState())) {
Spacer(modifier = Modifier.height(height = 12.dp))
featureFlagMap.forEach { featureFlag ->
featureFlag.key.ListItemContent(
currentValue = featureFlag.value,
onValueChange = handler.onUpdateFeatureFlag,
cardStyle = featureFlagMap.keys.toListItemCardStyle(
index = featureFlagMap.keys.indexOf(element = featureFlag.key),
),
modifier = Modifier
.standardHorizontalMargin()
.fillMaxWidth(),
)
}
Spacer(modifier = Modifier.height(height = 16.dp))
BitwardenFilledButton(
label = stringResource(id = BitwardenString.reset_accessibility_disclaimer),
onClick = { viewModel.trySendAction(DebugMenuAction.ResetAccessibilityDisclaimer) },
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
)
Spacer(modifier = Modifier.height(height = 8.dp))
BitwardenFilledButton(
label = stringResource(BitwardenString.reset_coach_mark_tour_status),
onClick = { viewModel.trySendAction(DebugMenuAction.ResetCoachMarkTourStatuses) },
isEnabled = true,
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
)
Spacer(Modifier.height(height = 16.dp))
BitwardenHorizontalDivider()
Spacer(Modifier.height(height = 16.dp))
BitwardenListHeaderText(
label = stringResource(BitwardenString.cookies),
label = stringResource(id = BitwardenString.reset_values),
onClick = handler.onResetFeatureFlagValues,
modifier = Modifier
.standardHorizontalMargin()
.padding(horizontal = 16.dp),
)
Spacer(Modifier.height(height = 8.dp))
BitwardenFilledButton(
label = stringResource(BitwardenString.trigger_cookie_acquisition),
onClick = { viewModel.trySendAction(DebugMenuAction.TriggerCookieAcquisition) },
isEnabled = true,
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
)
Spacer(Modifier.height(height = 8.dp))
BitwardenFilledButton(
label = stringResource(BitwardenString.clear_sso_cookies),
onClick = {
viewModel.trySendAction(
DebugMenuAction.ClearSsoCookies,
)
},
isEnabled = true,
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
)
Spacer(Modifier.height(height = 16.dp))
BitwardenHorizontalDivider()
Spacer(Modifier.height(height = 16.dp))
BitwardenListHeaderText(
label = stringResource(BitwardenString.premium),
modifier = Modifier
.standardHorizontalMargin()
.padding(horizontal = 16.dp),
)
Spacer(Modifier.height(height = 8.dp))
BitwardenFilledButton(
label = stringResource(BitwardenString.reset_premium_upgrade_banner),
onClick = {
viewModel.trySendAction(
DebugMenuAction.ResetPremiumUpgradeBanner,
)
},
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
)
Spacer(Modifier.height(height = 8.dp))
BitwardenFilledButton(
label = stringResource(BitwardenString.show_upgraded_to_premium_card),
onClick = {
viewModel.trySendAction(
DebugMenuAction.ShowUpgradedToPremiumCard,
)
},
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
)
Spacer(Modifier.height(height = 16.dp))
BitwardenHorizontalDivider()
Spacer(Modifier.height(height = 16.dp))
BitwardenListHeaderText(
label = stringResource(BitwardenString.error_reports),
modifier = Modifier
.standardHorizontalMargin()
.padding(horizontal = 16.dp),
)
Spacer(modifier = Modifier.height(height = 8.dp))
BitwardenFilledButton(
label = stringResource(BitwardenString.generate_error_report),
onClick = { viewModel.trySendAction(DebugMenuAction.GenerateErrorReportClick) },
isEnabled = true,
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
)
Spacer(modifier = Modifier.height(height = 8.dp))
BitwardenFilledButton(
label = stringResource(BitwardenString.generate_crash),
onClick = { viewModel.trySendAction(DebugMenuAction.GenerateCrashClick) },
isEnabled = true,
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(height = 16.dp))
Spacer(modifier = Modifier.navigationBarsPadding())
@@ -216,96 +165,201 @@ fun DebugMenuScreen(
}
@Composable
private fun FeatureFlagContent(
featureFlagMap: ImmutableMap<FlagKey<Any>, Any>,
onValueChange: (key: FlagKey<Any>, value: Any) -> Unit,
onResetValues: () -> Unit,
private fun DebugOptionsContent(
handler: DebugMenuHandler,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier,
) {
Column(modifier = modifier.verticalScroll(state = rememberScrollState())) {
Spacer(modifier = Modifier.height(height = 12.dp))
OnboardingSection(handler = handler)
Spacer(modifier = Modifier.height(height = 16.dp))
CookiesSection(handler = handler)
Spacer(modifier = Modifier.height(height = 16.dp))
PremiumSection(handler = handler)
Spacer(modifier = Modifier.height(height = 16.dp))
ReportsSection(handler = handler)
Spacer(modifier = Modifier.height(height = 16.dp))
Spacer(modifier = Modifier.navigationBarsPadding())
}
}
@Suppress("LongMethod")
@Composable
private fun OnboardingSection(
handler: DebugMenuHandler,
modifier: Modifier = Modifier,
) {
Column(modifier = modifier) {
BitwardenListHeaderText(
label = stringResource(BitwardenString.feature_flags),
label = stringResource(id = BitwardenString.onboarding_override),
modifier = Modifier
.standardHorizontalMargin()
.padding(horizontal = 16.dp),
)
Spacer(modifier = Modifier.height(8.dp))
featureFlagMap.forEach { featureFlag ->
featureFlag.key.ListItemContent(
currentValue = featureFlag.value,
onValueChange = onValueChange,
cardStyle = featureFlagMap.keys.toListItemCardStyle(
index = featureFlagMap.keys.indexOf(element = featureFlag.key),
),
modifier = Modifier
.standardHorizontalMargin()
.fillMaxWidth(),
)
}
Spacer(modifier = Modifier.height(height = 8.dp))
ButtonWithExplanation(
label = stringResource(id = BitwardenString.restart_onboarding_cta),
explanation = stringResource(id = BitwardenString.restart_onboarding_details),
onClick = handler.onRestartOnboarding,
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
)
Spacer(modifier = Modifier.height(height = 16.dp))
ButtonWithExplanation(
label = stringResource(id = BitwardenString.restart_onboarding_carousel),
explanation = stringResource(id = BitwardenString.restart_onboarding_carousel_details),
onClick = handler.onRestartOnboardingCarousel,
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
)
Spacer(modifier = Modifier.height(height = 16.dp))
BitwardenFilledButton(
label = stringResource(BitwardenString.reset_values),
onClick = onResetValues,
label = stringResource(id = BitwardenString.reset_accessibility_disclaimer),
onClick = handler.onResetAccessibilityDisclaimer,
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
)
Spacer(modifier = Modifier.height(height = 12.dp))
BitwardenFilledButton(
label = stringResource(id = BitwardenString.reset_coach_mark_tour_status),
onClick = handler.onResetCoachMarkTourStatuses,
isEnabled = true,
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
)
Spacer(modifier = Modifier.height(height = 16.dp))
BitwardenHorizontalDivider()
}
}
@Composable
private fun CookiesSection(
handler: DebugMenuHandler,
modifier: Modifier = Modifier,
) {
Column(modifier = modifier) {
BitwardenListHeaderText(
label = stringResource(id = BitwardenString.cookies),
modifier = Modifier
.standardHorizontalMargin()
.fillMaxWidth(),
.padding(horizontal = 16.dp),
)
Spacer(modifier = Modifier.height(height = 8.dp))
BitwardenFilledButton(
label = stringResource(id = BitwardenString.trigger_cookie_acquisition),
onClick = handler.onTriggerCookieAcquisition,
isEnabled = true,
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
)
Spacer(modifier = Modifier.height(height = 12.dp))
BitwardenFilledButton(
label = stringResource(id = BitwardenString.clear_sso_cookies),
onClick = handler.onClearSsoCookies,
isEnabled = true,
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
)
Spacer(modifier = Modifier.height(height = 16.dp))
BitwardenHorizontalDivider()
}
}
@Composable
private fun PremiumSection(
handler: DebugMenuHandler,
modifier: Modifier = Modifier,
) {
Column(modifier = modifier) {
BitwardenListHeaderText(
label = stringResource(id = BitwardenString.premium),
modifier = Modifier
.standardHorizontalMargin()
.padding(horizontal = 16.dp),
)
Spacer(modifier = Modifier.height(height = 8.dp))
BitwardenFilledButton(
label = stringResource(id = BitwardenString.reset_premium_upgrade_banner),
onClick = handler.onResetPremiumUpgradeBanner,
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
)
Spacer(modifier = Modifier.height(height = 12.dp))
BitwardenFilledButton(
label = stringResource(id = BitwardenString.show_upgraded_to_premium_card),
onClick = handler.onShowUpgradedToPremiumCard,
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
)
Spacer(modifier = Modifier.height(height = 16.dp))
BitwardenHorizontalDivider()
}
}
@Composable
private fun ReportsSection(
handler: DebugMenuHandler,
modifier: Modifier = Modifier,
) {
Column(modifier = modifier) {
BitwardenListHeaderText(
label = stringResource(id = BitwardenString.error_reports),
modifier = Modifier
.standardHorizontalMargin()
.padding(horizontal = 16.dp),
)
Spacer(modifier = Modifier.height(height = 8.dp))
BitwardenFilledButton(
label = stringResource(id = BitwardenString.generate_error_report),
onClick = handler.onGenerateErrorReportClick,
isEnabled = true,
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
)
Spacer(modifier = Modifier.height(height = 12.dp))
BitwardenFilledButton(
label = stringResource(id = BitwardenString.generate_crash),
onClick = handler.onGenerateCrashClick,
isEnabled = true,
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
)
}
}
/**
* The content for the onboarding override feature flag.
*/
@Composable
private fun OnboardingOverrideContent(
onStartOnboarding: () -> Unit,
onStartOnboardingCarousel: () -> Unit,
private fun ButtonWithExplanation(
label: String,
explanation: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(modifier) {
BitwardenListHeaderText(
label = stringResource(BitwardenString.onboarding_override),
modifier = Modifier
.standardHorizontalMargin()
.padding(horizontal = 16.dp),
)
Spacer(modifier = Modifier.height(8.dp))
Column(modifier = modifier) {
BitwardenFilledButton(
label = stringResource(BitwardenString.restart_onboarding_cta),
onClick = onStartOnboarding,
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
label = label,
onClick = onClick,
modifier = Modifier.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(4.dp))
Spacer(modifier = Modifier.height(height = 4.dp))
Text(
text = stringResource(BitwardenString.restart_onboarding_details),
modifier = Modifier
.align(Alignment.CenterHorizontally)
.standardHorizontalMargin(),
text = explanation,
style = BitwardenTheme.typography.bodySmall,
color = BitwardenTheme.colorScheme.text.primary,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(16.dp))
BitwardenFilledButton(
label = stringResource(BitwardenString.restart_onboarding_carousel),
onClick = onStartOnboardingCarousel,
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = stringResource(BitwardenString.restart_onboarding_carousel_details),
modifier = Modifier
.align(Alignment.CenterHorizontally)
.standardHorizontalMargin(),
style = BitwardenTheme.typography.bodySmall,
color = BitwardenTheme.colorScheme.text.primary,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
)
}
}
@@ -318,19 +372,15 @@ private fun FeatureFlagContent_preview() {
featureFlagMap = persistentMapOf(
FlagKey.DummyBoolean to true,
),
onValueChange = { _, _ -> },
onResetValues = { },
handler = DebugMenuHandler.createEmpty(),
)
}
}
@Preview(showBackground = true)
@Composable
private fun OnboardingOverrideContent_preview() {
private fun DebugOptionsContent_preview() {
BitwardenTheme {
OnboardingOverrideContent(
onStartOnboarding = {},
onStartOnboardingCarousel = {},
)
DebugOptionsContent(handler = DebugMenuHandler.createEmpty())
}
}
@@ -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
@@ -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
@@ -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()
}
@@ -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,
) {
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.
@@ -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].
@@ -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.
*/
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)
}
@@ -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()
@@ -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()
@@ -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()
@@ -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,
),
@@ -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,
),
@@ -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()
@@ -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()
@@ -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()
@@ -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()
@@ -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()
@@ -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()
@@ -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()
@@ -198,4 +206,5 @@ private val DEFAULT_STATE: DebugMenuState = DebugMenuState(
featureFlags = persistentMapOf(
FlagKey.DummyBoolean to true,
),
mainTypeOption = DebugMenuState.MainTypeOption.FLAGS,
)
@@ -65,6 +65,19 @@ 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()
@@ -72,7 +85,10 @@ class DebugMenuViewModelTest : BaseViewModelTest() {
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
@@ -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,
)
@@ -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
@@ -62,9 +64,12 @@ fun BitwardenSegmentedButton(
Int,
Dp,
SegmentedButtonState,
) -> Unit = { _, _, optionState ->
) -> Unit = { _, weightedWidth, optionState ->
this.SegmentedButtonOptionContent(
option = optionState,
modifier = Modifier
.fillMaxHeight()
.width(width = weightedWidth),
)
},
) {
@@ -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))
}
}
@@ -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>