diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/debugmenu/DebugMenuScreen.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/debugmenu/DebugMenuScreen.kt index ed1b311018a..ad5da3dee5f 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/debugmenu/DebugMenuScreen.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/debugmenu/DebugMenuScreen.kt @@ -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, ), ) }, + 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(), + ) + }, ) { - Column( - modifier = Modifier.verticalScroll(rememberScrollState()), + 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, 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)) - if (state.featureFlags.isNotEmpty()) { - FeatureFlagContent( - featureFlagMap = state.featureFlags, - onValueChange = { key, value -> - viewModel.trySendAction(DebugMenuAction.UpdateFeatureFlag(key, value)) - }, - onResetValues = { - viewModel.trySendAction(DebugMenuAction.ResetFeatureFlagValues) - }, + 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.height(height = 16.dp)) - BitwardenHorizontalDivider() - Spacer(Modifier.height(height = 16.dp)) } - OnboardingOverrideContent( - onStartOnboarding = { viewModel.trySendAction(DebugMenuAction.RestartOnboarding) }, - onStartOnboardingCarousel = { - viewModel.trySendAction(DebugMenuAction.RestartOnboardingCarousel) - }, - ) 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), - 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), + label = stringResource(id = BitwardenString.reset_values), + onClick = handler.onResetFeatureFlagValues, 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, Any>, - onValueChange: (key: FlagKey, 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() } } -/** - * The content for the onboarding override feature flag. - */ @Composable -private fun OnboardingOverrideContent( - onStartOnboarding: () -> Unit, - onStartOnboardingCarousel: () -> Unit, +private fun PremiumSection( + handler: DebugMenuHandler, modifier: Modifier = Modifier, ) { - Column(modifier) { + Column(modifier = modifier) { BitwardenListHeaderText( - label = stringResource(BitwardenString.onboarding_override), + label = stringResource(id = BitwardenString.premium), modifier = Modifier .standardHorizontalMargin() .padding(horizontal = 16.dp), ) - Spacer(modifier = Modifier.height(8.dp)) + Spacer(modifier = Modifier.height(height = 8.dp)) BitwardenFilledButton( - label = stringResource(BitwardenString.restart_onboarding_cta), - onClick = onStartOnboarding, + label = stringResource(id = BitwardenString.reset_premium_upgrade_banner), + onClick = handler.onResetPremiumUpgradeBanner, modifier = Modifier .fillMaxWidth() .standardHorizontalMargin(), ) - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = stringResource(BitwardenString.restart_onboarding_details), + Spacer(modifier = Modifier.height(height = 12.dp)) + BitwardenFilledButton( + label = stringResource(id = BitwardenString.show_upgraded_to_premium_card), + onClick = handler.onShowUpgradedToPremiumCard, modifier = Modifier - .align(Alignment.CenterHorizontally) + .fillMaxWidth() .standardHorizontalMargin(), - style = BitwardenTheme.typography.bodySmall, - color = BitwardenTheme.colorScheme.text.primary, - textAlign = TextAlign.Center, ) - Spacer(Modifier.height(16.dp)) + 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(BitwardenString.restart_onboarding_carousel), - onClick = onStartOnboardingCarousel, + label = stringResource(id = BitwardenString.generate_error_report), + onClick = handler.onGenerateErrorReportClick, + isEnabled = true, modifier = Modifier .fillMaxWidth() .standardHorizontalMargin(), ) - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = stringResource(BitwardenString.restart_onboarding_carousel_details), + Spacer(modifier = Modifier.height(height = 12.dp)) + BitwardenFilledButton( + label = stringResource(id = BitwardenString.generate_crash), + onClick = handler.onGenerateCrashClick, + isEnabled = true, modifier = Modifier - .align(Alignment.CenterHorizontally) + .fillMaxWidth() .standardHorizontalMargin(), + ) + } +} + +@Composable +private fun ButtonWithExplanation( + label: String, + explanation: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier) { + BitwardenFilledButton( + label = label, + onClick = onClick, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(modifier = Modifier.height(height = 4.dp)) + Text( + text = explanation, 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()) } } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/debugmenu/DebugMenuViewModel.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/debugmenu/DebugMenuViewModel.kt index 60c88dea24a..b823cbab5c7 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/debugmenu/DebugMenuViewModel.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/debugmenu/DebugMenuViewModel.kt @@ -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( - 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, 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]. diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/debugmenu/handler/DebugMenuHandler.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/debugmenu/handler/DebugMenuHandler.kt new file mode 100644 index 00000000000..88d833ceb89 --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/debugmenu/handler/DebugMenuHandler.kt @@ -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, 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) + } diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/debugmenu/DebugMenuScreenTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/debugmenu/DebugMenuScreenTest.kt index bd0a714ca9d..1afca9b8171 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/debugmenu/DebugMenuScreenTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/debugmenu/DebugMenuScreenTest.kt @@ -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, ) diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/debugmenu/DebugMenuViewModelTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/debugmenu/DebugMenuViewModelTest.kt index d7a6a9f7a33..b3539e255f3 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/debugmenu/DebugMenuViewModelTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/debugmenu/DebugMenuViewModelTest.kt @@ -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, Any> = FlagKey private val DEFAULT_STATE = DebugMenuState( featureFlags = DEFAULT_MAP_VALUE, + mainTypeOption = DebugMenuState.MainTypeOption.FLAGS, ) diff --git a/ui/src/main/kotlin/com/bitwarden/ui/platform/components/segment/BitwardenSegmentedButton.kt b/ui/src/main/kotlin/com/bitwarden/ui/platform/components/segment/BitwardenSegmentedButton.kt index 8e213eb14ef..5b828e36b44 100644 --- a/ui/src/main/kotlin/com/bitwarden/ui/platform/components/segment/BitwardenSegmentedButton.kt +++ b/ui/src/main/kotlin/com/bitwarden/ui/platform/components/segment/BitwardenSegmentedButton.kt @@ -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), ) }, ) { diff --git a/ui/src/main/kotlin/com/bitwarden/ui/platform/components/segment/transition/TransitionUtil.kt b/ui/src/main/kotlin/com/bitwarden/ui/platform/components/segment/transition/TransitionUtil.kt new file mode 100644 index 00000000000..dc7f79436be --- /dev/null +++ b/ui/src/main/kotlin/com/bitwarden/ui/platform/components/segment/transition/TransitionUtil.kt @@ -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 > AnimatedContentTransitionScope.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)) + } +} diff --git a/ui/src/main/res/values/strings_non_localized.xml b/ui/src/main/res/values/strings_non_localized.xml index 3c46bf31ea8..2a5fb33d570 100644 --- a/ui/src/main/res/values/strings_non_localized.xml +++ b/ui/src/main/res/values/strings_non_localized.xml @@ -18,10 +18,12 @@ %1$s | %2$s (%3$d) - Feature Flags: + Feature Flags + There are currently no feature flags + Debug Options Debug Menu Reset values - Onboarding Status Override + Onboarding Restart Onboarding 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. Show Onboarding Carousel