diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/disk/model/FlightRecorderDataSet.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/disk/model/FlightRecorderDataSet.kt index 04e5f39dec..e17e8ad79b 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/disk/model/FlightRecorderDataSet.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/disk/model/FlightRecorderDataSet.kt @@ -43,6 +43,9 @@ data class FlightRecorderDataSet( @SerialName("isActive") val isActive: Boolean, + @SerialName("isBannerDismissed") + val isBannerDismissed: Boolean = false, + @SerialName("expirationTime") val expirationTimeMs: Long? = null, ) diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/flightrecorder/FlightRecorderManager.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/flightrecorder/FlightRecorderManager.kt index 1722221809..ddb3774f59 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/flightrecorder/FlightRecorderManager.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/flightrecorder/FlightRecorderManager.kt @@ -18,6 +18,11 @@ interface FlightRecorderManager { */ val flightRecorderDataFlow: StateFlow + /** + * Dismisses the all flight recorder banners. + */ + fun dismissFlightRecorderBanner() + /** * Starts the flight recorder for the given [duration]. */ diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/flightrecorder/FlightRecorderManagerImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/flightrecorder/FlightRecorderManagerImpl.kt index 182fe3e18d..5e84793138 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/flightrecorder/FlightRecorderManagerImpl.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/flightrecorder/FlightRecorderManagerImpl.kt @@ -71,6 +71,13 @@ internal class FlightRecorderManagerImpl( ) } + override fun dismissFlightRecorderBanner() { + val originalData = flightRecorderData + settingsDiskSource.flightRecorderData = originalData.copy( + data = originalData.data.map { it.copy(isBannerDismissed = true) }.toSet(), + ) + } + override fun startFlightRecorder(duration: FlightRecorderDuration) { val startTime = clock.instant() val originalData = flightRecorderData diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/animation/AnimateNullableContentVisibility.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/animation/AnimateNullableContentVisibility.kt new file mode 100644 index 0000000000..aee57e5c7a --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/animation/AnimateNullableContentVisibility.kt @@ -0,0 +1,50 @@ +package com.x8bit.bitwarden.ui.platform.components.animation + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedContentScope +import androidx.compose.animation.ContentTransform +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.SizeTransform +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier + +/** + * An animation API that tries to work like AnimateVisibility, animating in when the content is + * present and animating out when null. + * + * @param targetState The state the animation should animate towards. + * @param modifier The [Modifier] for the component. + * @param enter The [EnterTransition] defining how the content animates in. + * @param exit The [ExitTransition] defining how the content animates out. + * @param label An optional parameter to differentiate from other animations. + * @param content The lambda that for the UI. + */ +@Composable +fun AnimateNullableContentVisibility( + targetState: T?, + modifier: Modifier = Modifier, + enter: EnterTransition = fadeIn(tween(easing = LinearEasing, durationMillis = 200)), + exit: ExitTransition = fadeOut(tween(durationMillis = 200)), + sizeTransform: SizeTransform? = null, + label: String = "AnimateNullableContent", + content: @Composable AnimatedContentScope.(targetState: T) -> Unit, +) { + AnimatedContent( + content = { state -> state?.let { content(it) } }, + targetState = targetState, + transitionSpec = { + ContentTransform( + targetContentEnter = enter, + initialContentExit = exit, + sizeTransform = sizeTransform, + ) + }, + label = label, + modifier = modifier, + ) +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/fab/BitwardenFloatingActionButton.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/fab/BitwardenFloatingActionButton.kt index d83f319e16..2e354ae22c 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/fab/BitwardenFloatingActionButton.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/fab/BitwardenFloatingActionButton.kt @@ -1,8 +1,10 @@ package com.x8bit.bitwarden.ui.platform.components.fab import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.displayCutout import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.union import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.material3.FloatingActionButton @@ -19,7 +21,8 @@ import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme * @param painter The icon for the button. * @param contentDescription The content description for the button. * @param modifier The [Modifier] to be applied to the button. - * @param windowInsets The insets to be applied to this composable. + * @param windowInsets The insets to be applied to this composable. By default this will account for + * the insets that are on the sides and bottom of the screen (Display Cutout and Navigation bars). */ @Composable fun BitwardenFloatingActionButton( @@ -27,7 +30,9 @@ fun BitwardenFloatingActionButton( painter: Painter, contentDescription: String, modifier: Modifier = Modifier, - windowInsets: WindowInsets = WindowInsets.displayCutout.union(WindowInsets.navigationBars), + windowInsets: WindowInsets = WindowInsets.displayCutout + .only(sides = WindowInsetsSides.Horizontal + WindowInsetsSides.Bottom) + .union(insets = WindowInsets.navigationBars), ) { FloatingActionButton( containerColor = BitwardenTheme.colorScheme.filledButton.background, diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/snackbar/BitwardenSnackbar.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/snackbar/BitwardenSnackbar.kt index 409979a3d7..41326ef363 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/snackbar/BitwardenSnackbar.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/snackbar/BitwardenSnackbar.kt @@ -6,11 +6,18 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.displayCutout import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton +import androidx.compose.foundation.layout.union +import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -18,27 +25,39 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import com.x8bit.bitwarden.R import com.bitwarden.ui.util.asText +import com.x8bit.bitwarden.R import com.x8bit.bitwarden.ui.platform.components.button.BitwardenOutlinedButton +import com.x8bit.bitwarden.ui.platform.components.button.BitwardenStandardIconButton import com.x8bit.bitwarden.ui.platform.components.button.color.bitwardenOutlinedButtonColors -import com.x8bit.bitwarden.ui.platform.components.util.rememberVectorPainter import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme /** * Custom snackbar for Bitwarden. - * Shows a message with an optional actions and title. + * + * @param bitwardenSnackbarData The data required to display the Snackbar. + * @param modifier The [Modifier] to be applied to the button. + * @param windowInsets The insets to be applied to this composable. By default this will account for + * the insets that are on the sides and bottom of the screen (Display Cutout and Navigation bars). + * @param onDismiss The callback invoked when the Snackbar is dismissed. + * @param onActionClick The callback invoked when the Snackbar action occurs. */ @Suppress("LongMethod") @Composable fun BitwardenSnackbar( bitwardenSnackbarData: BitwardenSnackbarData, modifier: Modifier = Modifier, + windowInsets: WindowInsets = WindowInsets.displayCutout + .only(sides = WindowInsetsSides.Horizontal + WindowInsetsSides.Bottom) + .union(insets = WindowInsets.navigationBars), onDismiss: () -> Unit = {}, onActionClick: () -> Unit = {}, ) { Box( - modifier = modifier.padding(12.dp), + modifier = modifier + .windowInsetsPadding(insets = windowInsets) + .consumeWindowInsets(insets = windowInsets) + .padding(12.dp), ) { Row( modifier = Modifier @@ -55,7 +74,7 @@ fun BitwardenSnackbar( ) .padding(16.dp), ) { - Column { + Column(modifier = Modifier.weight(weight = 1f)) { bitwardenSnackbarData.messageHeader?.let { Text( text = it(), @@ -85,16 +104,12 @@ fun BitwardenSnackbar( } } if (bitwardenSnackbarData.withDismissAction) { - Spacer(Modifier.weight(1f)) - IconButton( + BitwardenStandardIconButton( onClick = onDismiss, - content = { - Icon( - rememberVectorPainter(R.drawable.ic_close), - contentDescription = stringResource(R.string.close), - tint = BitwardenTheme.colorScheme.icon.reversed, - ) - }, + vectorIconRes = R.drawable.ic_close, + contentDescription = stringResource(R.string.close), + contentColor = BitwardenTheme.colorScheme.icon.reversed, + modifier = Modifier.offset(x = 12.dp, y = (-12).dp), ) } } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/snackbar/BitwardenSnackbarHost.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/snackbar/BitwardenSnackbarHost.kt index 5b5e5fc751..4267f94320 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/snackbar/BitwardenSnackbarHost.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/snackbar/BitwardenSnackbarHost.kt @@ -1,8 +1,11 @@ package com.x8bit.bitwarden.ui.platform.components.snackbar import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.displayCutout import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.union import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.material3.SnackbarHost @@ -14,17 +17,22 @@ import androidx.compose.ui.Modifier * * @param bitwardenHostState The state of this snackbar. * @param modifier The [Modifier] to be applied to the [SnackbarHost]. - * @param windowInsets The insets to be applied to this composable. + * @param windowInsets The insets to be applied to this composable. By default this will account for + * the insets that are on the sides and bottom of the screen (Display Cutout and Navigation bars). */ @Composable fun BitwardenSnackbarHost( bitwardenHostState: BitwardenSnackbarHostState, modifier: Modifier = Modifier, - windowInsets: WindowInsets = WindowInsets.displayCutout.union(WindowInsets.navigationBars), + windowInsets: WindowInsets = WindowInsets.displayCutout + .only(sides = WindowInsetsSides.Horizontal + WindowInsetsSides.Bottom) + .union(insets = WindowInsets.navigationBars), ) { SnackbarHost( hostState = bitwardenHostState.snackbarHostState, - modifier = modifier.windowInsetsPadding(insets = windowInsets), + modifier = modifier + .windowInsetsPadding(insets = windowInsets) + .consumeWindowInsets(insets = windowInsets), ) { snackbarData -> val message = snackbarData.visuals.message val currentCustomSnackbarData = bitwardenHostState.currentSnackbarData diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/snackbar/BitwardenSnackbarHostState.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/snackbar/BitwardenSnackbarHostState.kt index a4ff077fd3..e931c584b1 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/snackbar/BitwardenSnackbarHostState.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/snackbar/BitwardenSnackbarHostState.kt @@ -1,5 +1,6 @@ package com.x8bit.bitwarden.ui.platform.components.snackbar +import android.os.Parcelable import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarResult @@ -14,6 +15,7 @@ import androidx.compose.runtime.setValue import com.bitwarden.ui.util.Text import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch +import kotlinx.parcelize.Parcelize /** * A custom state holder for [BitwardenSnackbarData] and manging a snackbar host with the @@ -65,13 +67,14 @@ data class BitwardenSnackbarHostState( * @property key The unique key for the [BitwardenSnackbarData]. */ @Immutable +@Parcelize data class BitwardenSnackbarData( val message: Text, val messageHeader: Text? = null, val actionLabel: Text? = null, val withDismissAction: Boolean = false, -) { - val key: String = this.hashCode().toString() +) : Parcelable { + val key: String get() = this.hashCode().toString() } /** diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/SettingsNavigation.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/SettingsNavigation.kt index 7520391583..323b3c5281 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/SettingsNavigation.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/SettingsNavigation.kt @@ -1,8 +1,10 @@ package com.x8bit.bitwarden.ui.platform.feature.settings import androidx.navigation.NavController +import androidx.navigation.NavGraph.Companion.findStartDestination import androidx.navigation.NavGraphBuilder import androidx.navigation.NavOptions +import androidx.navigation.navOptions import androidx.navigation.navigation import com.x8bit.bitwarden.ui.platform.base.util.composableWithRootPushTransitions import com.x8bit.bitwarden.ui.platform.feature.settings.about.aboutDestination @@ -85,8 +87,26 @@ fun NavGraphBuilder.settingsGraph( } /** - * Navigate to the settings screen. + * Navigate to the settings graph. */ fun NavController.navigateToSettingsGraph(navOptions: NavOptions? = null) { navigate(SETTINGS_GRAPH_ROUTE, navOptions) } + +/** + * Navigate to the settings graph root. + */ +fun NavController.navigateToSettingsGraphRoot() { + // Brings up back to the Settings graph + navigateToSettingsGraph( + navOptions = navOptions { + popUpTo(id = graph.findStartDestination().id) { + saveState = true + } + launchSingleTop = true + restoreState = true + }, + ) + // Then ensures that we are at the root + popBackStack(route = SETTINGS_ROUTE, inclusive = false) +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarScreen.kt index a9a0e5cd1c..c95e605f2c 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarScreen.kt @@ -25,7 +25,9 @@ import com.x8bit.bitwarden.ui.platform.components.model.NavigationItem import com.x8bit.bitwarden.ui.platform.components.model.ScaffoldNavigationData import com.x8bit.bitwarden.ui.platform.components.scaffold.BitwardenScaffold import com.x8bit.bitwarden.ui.platform.feature.search.model.SearchType +import com.x8bit.bitwarden.ui.platform.feature.settings.about.navigateToAbout import com.x8bit.bitwarden.ui.platform.feature.settings.navigateToSettingsGraph +import com.x8bit.bitwarden.ui.platform.feature.settings.navigateToSettingsGraphRoot import com.x8bit.bitwarden.ui.platform.feature.settings.settingsGraph import com.x8bit.bitwarden.ui.platform.feature.vaultunlockednavbar.model.VaultUnlockedNavBarTab import com.x8bit.bitwarden.ui.platform.manager.snackbar.SnackbarRelay @@ -227,6 +229,10 @@ private fun VaultUnlockedNavBarScaffold( onDimBottomNavBarRequest = { shouldDim -> shouldDimNavBar = shouldDim }, onNavigateToImportLogins = onNavigateToImportLogins, onNavigateToAddFolderScreen = onNavigateToAddFolderScreen, + onNavigateToAboutScreen = { + navController.navigateToSettingsGraphRoot() + navController.navigateToAbout() + }, ) sendGraph( navController = navController, diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultGraphNavigation.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultGraphNavigation.kt index ac15b69aad..31371996c5 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultGraphNavigation.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultGraphNavigation.kt @@ -28,6 +28,7 @@ fun NavGraphBuilder.vaultGraph( onDimBottomNavBarRequest: (shouldDim: Boolean) -> Unit, onNavigateToImportLogins: (SnackbarRelay) -> Unit, onNavigateToAddFolderScreen: (selectedFolderId: String?) -> Unit, + onNavigateToAboutScreen: () -> Unit, ) { navigation( route = VAULT_GRAPH_ROUTE, @@ -45,6 +46,7 @@ fun NavGraphBuilder.vaultGraph( onDimBottomNavBarRequest = onDimBottomNavBarRequest, onNavigateToImportLogins = onNavigateToImportLogins, onNavigateToAddFolderScreen = onNavigateToAddFolderScreen, + onNavigateToAboutScreen = onNavigateToAboutScreen, ) vaultItemListingDestination( onNavigateBack = { navController.popBackStack() }, diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultNavigation.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultNavigation.kt index a182ce6428..26eca916b1 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultNavigation.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultNavigation.kt @@ -26,6 +26,7 @@ fun NavGraphBuilder.vaultDestination( onDimBottomNavBarRequest: (shouldDim: Boolean) -> Unit, onNavigateToImportLogins: (SnackbarRelay) -> Unit, onNavigateToAddFolderScreen: (selectedFolderId: String?) -> Unit, + onNavigateToAboutScreen: () -> Unit, ) { composableWithRootPushTransitions( route = VAULT_ROUTE, @@ -40,6 +41,7 @@ fun NavGraphBuilder.vaultDestination( onDimBottomNavBarRequest = onDimBottomNavBarRequest, onNavigateToImportLogins = onNavigateToImportLogins, onNavigateToAddFolderScreen = onNavigateToAddFolderScreen, + onNavigateToAboutScreen = onNavigateToAboutScreen, ) } } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreen.kt index a867574971..bdf0c33046 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreen.kt @@ -34,6 +34,7 @@ import com.x8bit.bitwarden.ui.platform.base.util.LivecycleEventEffect import com.x8bit.bitwarden.ui.platform.base.util.standardHorizontalMargin import com.x8bit.bitwarden.ui.platform.components.account.BitwardenAccountActionItem import com.x8bit.bitwarden.ui.platform.components.account.BitwardenAccountSwitcher +import com.x8bit.bitwarden.ui.platform.components.animation.AnimateNullableContentVisibility import com.x8bit.bitwarden.ui.platform.components.appbar.BitwardenMediumTopAppBar import com.x8bit.bitwarden.ui.platform.components.appbar.action.BitwardenOverflowActionItem import com.x8bit.bitwarden.ui.platform.components.appbar.action.BitwardenSearchActionItem @@ -51,6 +52,7 @@ import com.x8bit.bitwarden.ui.platform.components.model.BitwardenPullToRefreshSt import com.x8bit.bitwarden.ui.platform.components.model.TopAppBarDividerStyle import com.x8bit.bitwarden.ui.platform.components.model.rememberBitwardenPullToRefreshState import com.x8bit.bitwarden.ui.platform.components.scaffold.BitwardenScaffold +import com.x8bit.bitwarden.ui.platform.components.snackbar.BitwardenSnackbar import com.x8bit.bitwarden.ui.platform.components.snackbar.BitwardenSnackbarHost import com.x8bit.bitwarden.ui.platform.components.snackbar.BitwardenSnackbarHostState import com.x8bit.bitwarden.ui.platform.components.snackbar.rememberBitwardenSnackbarHostState @@ -94,6 +96,7 @@ fun VaultScreen( onDimBottomNavBarRequest: (shouldDim: Boolean) -> Unit, onNavigateToImportLogins: (SnackbarRelay) -> Unit, onNavigateToAddFolderScreen: (selectedFolderId: String?) -> Unit, + onNavigateToAboutScreen: () -> Unit, exitManager: ExitManager = LocalExitManager.current, intentManager: IntentManager = LocalIntentManager.current, appReviewManager: AppReviewManager = LocalAppReviewManager.current, @@ -181,6 +184,8 @@ fun VaultScreen( VaultEvent.NavigateToAddFolder -> { onNavigateToAddFolderScreen(null) } + + VaultEvent.NavigateToAbout -> onNavigateToAboutScreen() } } val vaultHandlers = remember(viewModel) { VaultHandlers.create(viewModel) } @@ -312,9 +317,23 @@ private fun VaultScreenScaffold( } }, snackbarHost = { - BitwardenSnackbarHost( - bitwardenHostState = snackbarHostState, - ) + AnimateNullableContentVisibility( + targetState = state.flightRecorderSnackBar, + label = "AnimateFlightRecorderSnackbar", + ) { data -> + BitwardenSnackbar( + bitwardenSnackbarData = data, + onDismiss = vaultHandlers.dismissFlightRecorderSnackbar, + onActionClick = vaultHandlers.flightRecorderGoToSettingsClick, + ) + } + if (state.flightRecorderSnackBar == null) { + // We don't want additional animations from the Animated Visibility and we only + // want this displayed if the flight recorder snackbar is not set. + BitwardenSnackbarHost( + bitwardenHostState = snackbarHostState, + ) + } }, floatingActionButton = { AnimatedVisibility( diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModel.kt index 16e38140db..db8048bd93 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModel.kt @@ -15,6 +15,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.LogoutReason import com.x8bit.bitwarden.data.auth.repository.model.SwitchAccountResult import com.x8bit.bitwarden.data.auth.repository.model.UserState import com.x8bit.bitwarden.data.auth.repository.model.ValidatePasswordResult +import com.x8bit.bitwarden.data.platform.datasource.disk.model.FlightRecorderDataSet import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager import com.x8bit.bitwarden.data.platform.manager.FirstTimeActionManager import com.x8bit.bitwarden.data.platform.manager.PolicyManager @@ -47,6 +48,7 @@ import com.x8bit.bitwarden.ui.vault.feature.vault.util.initials import com.x8bit.bitwarden.ui.vault.feature.vault.util.toAccountSummaries import com.x8bit.bitwarden.ui.vault.feature.vault.util.toActiveAccountSummary import com.x8bit.bitwarden.ui.vault.feature.vault.util.toAppBarTitle +import com.x8bit.bitwarden.ui.vault.feature.vault.util.toSnackbarData import com.x8bit.bitwarden.ui.vault.feature.vault.util.toVaultFilterData import com.x8bit.bitwarden.ui.vault.feature.vault.util.toViewState import com.x8bit.bitwarden.ui.vault.feature.vault.util.vaultFilterDataIfRequired @@ -114,6 +116,9 @@ class VaultViewModel @Inject constructor( hasMasterPassword = userState.activeAccount.hasMasterPassword, isRefreshing = false, showImportActionCard = false, + flightRecorderSnackBar = settingsRepository + .flightRecorderData + .toSnackbarData(clock = clock), ) }, ) { @@ -162,9 +167,13 @@ class VaultViewModel @Inject constructor( snackbarRelayManager .getSnackbarDataFlow(SnackbarRelay.MY_VAULT_RELAY) - .map { - VaultAction.Internal.SnackbarDataReceive(it) - } + .map { VaultAction.Internal.SnackbarDataReceive(it) } + .onEach(::sendAction) + .launchIn(viewModelScope) + + settingsRepository + .flightRecorderDataFlow + .map { VaultAction.Internal.FlightRecorderDataReceive(data = it) } .onEach(::sendAction) .launchIn(viewModelScope) } @@ -205,10 +214,21 @@ class VaultViewModel @Inject constructor( VaultAction.ImportActionCardClick -> handleImportActionCardClick() VaultAction.LifecycleResumed -> handleLifecycleResumed() VaultAction.SelectAddItemType -> handleSelectAddItemType() + VaultAction.DismissFlightRecorderSnackbar -> handleDismissFlightRecorderSnackbar() + VaultAction.FlightRecorderGoToSettingsClick -> handleFlightRecorderGoToSettingsClick() } } //region VaultAction Handlers + private fun handleDismissFlightRecorderSnackbar() { + settingsRepository.dismissFlightRecorderBanner() + } + + private fun handleFlightRecorderGoToSettingsClick() { + settingsRepository.dismissFlightRecorderBanner() + sendEvent(VaultEvent.NavigateToAbout) + } + private fun handleSelectAddItemType() { mutableStateFlow.update { it.copy( @@ -607,6 +627,10 @@ class VaultViewModel @Inject constructor( VaultAction.Internal.InternetConnectionErrorReceived -> { handleInternetConnectionErrorReceived() } + + is VaultAction.Internal.FlightRecorderDataReceive -> { + handleFlightRecorderDataReceive(action) + } } } @@ -622,6 +646,14 @@ class VaultViewModel @Inject constructor( } } + private fun handleFlightRecorderDataReceive( + action: VaultAction.Internal.FlightRecorderDataReceive, + ) { + mutableStateFlow.update { + it.copy(flightRecorderSnackBar = action.data.toSnackbarData(clock = clock)) + } + } + private fun handleSnackbarDataReceive(action: VaultAction.Internal.SnackbarDataReceive) { sendEvent(VaultEvent.ShowSnackbar(action.data)) } @@ -840,6 +872,9 @@ data class VaultState( val vaultFilterData: VaultFilterData? = null, val viewState: ViewState, val dialog: DialogState? = null, + val isRefreshing: Boolean, + val showImportActionCard: Boolean, + val flightRecorderSnackBar: BitwardenSnackbarData?, // Internal-use properties val isSwitchingAccounts: Boolean = false, val isPremium: Boolean, @@ -847,8 +882,6 @@ data class VaultState( private val isPullToRefreshSettingEnabled: Boolean, val baseIconUrl: String, val isIconLoadingDisabled: Boolean, - val isRefreshing: Boolean, - val showImportActionCard: Boolean, ) : Parcelable { /** @@ -1256,12 +1289,27 @@ sealed class VaultEvent { * Navigate to the add folder screen */ data object NavigateToAddFolder : VaultEvent() + + /** + * Navigate to settings. + */ + data object NavigateToAbout : VaultEvent() } /** * Models actions for the [VaultScreen]. */ sealed class VaultAction { + /** + * User has clicked the go to settings button. + */ + data object FlightRecorderGoToSettingsClick : VaultAction() + + /** + * User has dismissed the flight recorder. + */ + data object DismissFlightRecorderSnackbar : VaultAction() + /** * User has triggered a pull to refresh. */ @@ -1489,6 +1537,13 @@ sealed class VaultAction { data class SnackbarDataReceive( val data: BitwardenSnackbarData, ) : Internal() + + /** + * Indicates that the flight recorder data was received. + */ + data class FlightRecorderDataReceive( + val data: FlightRecorderDataSet, + ) : Internal() } } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/handlers/VaultHandlers.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/handlers/VaultHandlers.kt index c3fa2e4578..131fe9fb7e 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/handlers/VaultHandlers.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/handlers/VaultHandlers.kt @@ -39,6 +39,8 @@ data class VaultHandlers( val masterPasswordRepromptSubmit: (ListingItemOverflowAction.VaultAction, String) -> Unit, val dismissImportActionCard: () -> Unit, val importActionCardClick: () -> Unit, + val flightRecorderGoToSettingsClick: () -> Unit, + val dismissFlightRecorderSnackbar: () -> Unit, ) { @Suppress("UndocumentedPublicClass") companion object { @@ -46,6 +48,7 @@ data class VaultHandlers( * Creates an instance of [VaultHandlers] by binding actions to the provided * [VaultViewModel]. */ + @Suppress("LongMethod") fun create(viewModel: VaultViewModel): VaultHandlers = VaultHandlers( vaultFilterTypeSelect = { @@ -104,6 +107,12 @@ data class VaultHandlers( importActionCardClick = { viewModel.trySendAction(VaultAction.ImportActionCardClick) }, + flightRecorderGoToSettingsClick = { + viewModel.trySendAction(VaultAction.FlightRecorderGoToSettingsClick) + }, + dismissFlightRecorderSnackbar = { + viewModel.trySendAction(VaultAction.DismissFlightRecorderSnackbar) + }, ) } } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/FlightRecorderDataSetExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/FlightRecorderDataSetExtensions.kt new file mode 100644 index 0000000000..63d5327a27 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/FlightRecorderDataSetExtensions.kt @@ -0,0 +1,31 @@ +package com.x8bit.bitwarden.ui.vault.feature.vault.util + +import com.bitwarden.ui.util.asText +import com.x8bit.bitwarden.R +import com.x8bit.bitwarden.data.platform.datasource.disk.model.FlightRecorderDataSet +import com.x8bit.bitwarden.ui.platform.components.snackbar.BitwardenSnackbarData +import com.x8bit.bitwarden.ui.platform.util.toFormattedPattern +import java.time.Clock +import java.time.Instant + +/** + * Helper function to create a [BitwardenSnackbarData] representing the active flight recorder. + */ +fun FlightRecorderDataSet.toSnackbarData( + clock: Clock, +): BitwardenSnackbarData? { + val expirationTime = this + .data + .find { it.isActive && !it.isBannerDismissed } + ?.let { Instant.ofEpochMilli(it.startTimeMs + it.durationMs) } + ?: return null + return BitwardenSnackbarData( + message = R.string.flight_recorder_banner_message.asText( + expirationTime.toFormattedPattern(pattern = "M/d/yy", clock = clock), + expirationTime.toFormattedPattern(pattern = "h:mm a", clock = clock), + ), + messageHeader = R.string.flight_recorder_banner_title.asText(), + actionLabel = R.string.go_to_settings.asText(), + withDismissAction = true, + ) +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 55454e6e1e..529a151c12 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1253,4 +1253,7 @@ Do you want to switch to this account? Organization: A master password is no longer required for members of the following organization. Please confirm the domain below with your organization administrator. Please confirm the domain below with your organization administrator.\n\nKey Connector domain:\n%1$s + Flight recorder on + Flight recorder will be active until %1$s at %2$s. Return to settings to deactivate now. + Go to settings diff --git a/app/src/test/java/com/x8bit/bitwarden/data/platform/manager/flightrecorder/FlightRecorderManagerTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/platform/manager/flightrecorder/FlightRecorderManagerTest.kt index c131f2cab7..8fd4330832 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/platform/manager/flightrecorder/FlightRecorderManagerTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/platform/manager/flightrecorder/FlightRecorderManagerTest.kt @@ -167,6 +167,67 @@ class FlightRecorderManagerTest { ) } + @Suppress("MaxLineLength") + @Test + fun `dismissFlightRecorderBanner should set the isDismissBanner flag to true and update SettingsDiskSource`() { + val data = FlightRecorderDataSet( + data = setOf( + FlightRecorderDataSet.FlightRecorderData( + id = "40", + fileName = "fileName1", + startTimeMs = FIXED_CLOCK_TIME, + durationMs = 60L, + isActive = true, + expirationTimeMs = null, + isBannerDismissed = false, + ), + FlightRecorderDataSet.FlightRecorderData( + id = "50", + fileName = "fileName2", + startTimeMs = FIXED_CLOCK_TIME, + durationMs = 60L, + isActive = false, + expirationTimeMs = FIXED_CLOCK + .instant() + .plus(30, ChronoUnit.DAYS) + .toEpochMilli(), + isBannerDismissed = false, + ), + ), + ) + fakeSettingsDiskSource.flightRecorderData = data + + flightRecorder.dismissFlightRecorderBanner() + + fakeSettingsDiskSource.assertFlightRecorderData( + expected = FlightRecorderDataSet( + data = setOf( + FlightRecorderDataSet.FlightRecorderData( + id = "40", + fileName = "fileName1", + startTimeMs = FIXED_CLOCK_TIME, + durationMs = 60L, + isActive = true, + expirationTimeMs = null, + isBannerDismissed = true, + ), + FlightRecorderDataSet.FlightRecorderData( + id = "50", + fileName = "fileName2", + startTimeMs = FIXED_CLOCK_TIME, + durationMs = 60L, + isActive = false, + expirationTimeMs = FIXED_CLOCK + .instant() + .plus(30, ChronoUnit.DAYS) + .toEpochMilli(), + isBannerDismissed = true, + ), + ), + ), + ) + } + @Suppress("MaxLineLength") @Test fun `endFlightRecorder should set the active log to inactive and update the SettingsDiskSource`() { diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreenTest.kt index f2d256c54a..b3591739a4 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreenTest.kt @@ -65,6 +65,7 @@ import io.mockk.verify import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update +import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNull @@ -74,6 +75,7 @@ import org.junit.Test @Suppress("LargeClass") class VaultScreenTest : BaseComposeTest() { + private var onNavigateToAboutCalled = false private var onNavigateToImportLoginsCalled = false private var onNavigateToVaultAddItemScreenCalled = false private var onNavigateToVaultItemArgs: VaultItemArgs? = null @@ -120,6 +122,7 @@ class VaultScreenTest : BaseComposeTest() { onNavigateToAddFolderCalled = true onNavigateToAddFolderParentFolderName = folderName }, + onNavigateToAboutScreen = { onNavigateToAboutCalled = true }, ) } } @@ -1250,6 +1253,12 @@ class VaultScreenTest : BaseComposeTest() { assertTrue(onNavigateToImportLoginsCalled) } + @Test + fun `when NavigateToAbout is sent, it should call onNavigateToAbout`() { + mutableEventFlow.tryEmit(VaultEvent.NavigateToAbout) + assertTrue(onNavigateToAboutCalled) + } + @Test fun `when ShowSnackbar is sent snackbar should be displayed`() { val data = BitwardenSnackbarData("message".asText()) @@ -1372,6 +1381,55 @@ class VaultScreenTest : BaseComposeTest() { ) } } + + @Test + fun `on FlightRecorder Snackbar close click sends the DismissFlightRecorderSnackbar`() = + runTest { + mutableStateFlow.update { + it.copy( + flightRecorderSnackBar = BitwardenSnackbarData( + message = R.string.flight_recorder_banner_message.asText( + "4/12/25", + "9:15 AM", + ), + messageHeader = R.string.flight_recorder_banner_title.asText(), + actionLabel = R.string.go_to_settings.asText(), + withDismissAction = true, + ), + ) + } + + composeTestRule.onNodeWithText(text = "Flight recorder on").assertIsDisplayed() + composeTestRule.onNodeWithContentDescription(label = "Close").performClick() + verify(exactly = 1) { + viewModel.trySendAction(VaultAction.DismissFlightRecorderSnackbar) + } + } + + @Suppress("MaxLineLength") + @Test + fun `on FlightRecorder Snackbar go to setting click sends the FlightRecorderGoToSettingsClick`() = + runTest { + mutableStateFlow.update { + it.copy( + flightRecorderSnackBar = BitwardenSnackbarData( + message = R.string.flight_recorder_banner_message.asText( + "4/12/25", + "9:15 AM", + ), + messageHeader = R.string.flight_recorder_banner_title.asText(), + actionLabel = R.string.go_to_settings.asText(), + withDismissAction = true, + ), + ) + } + + composeTestRule.onNodeWithText(text = "Flight recorder on").assertIsDisplayed() + composeTestRule.onNodeWithText(text = "Go to settings").performClick() + verify(exactly = 1) { + viewModel.trySendAction(VaultAction.FlightRecorderGoToSettingsClick) + } + } } private val ACTIVE_ACCOUNT_SUMMARY = AccountSummary( @@ -1426,6 +1484,7 @@ private val DEFAULT_STATE: VaultState = VaultState( hasMasterPassword = true, isRefreshing = false, showImportActionCard = false, + flightRecorderSnackBar = null, ) private val DEFAULT_CONTENT_VIEW_STATE: VaultState.ViewState.Content = VaultState.ViewState.Content( diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModelTest.kt index e0d5e9c08c..3ea781904d 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModelTest.kt @@ -18,6 +18,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.Organization import com.x8bit.bitwarden.data.auth.repository.model.SwitchAccountResult import com.x8bit.bitwarden.data.auth.repository.model.UserState import com.x8bit.bitwarden.data.auth.repository.model.ValidatePasswordResult +import com.x8bit.bitwarden.data.platform.datasource.disk.model.FlightRecorderDataSet import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager import com.x8bit.bitwarden.data.platform.manager.FirstTimeActionManager import com.x8bit.bitwarden.data.platform.manager.PolicyManager @@ -47,6 +48,7 @@ import com.x8bit.bitwarden.ui.vault.components.model.CreateVaultItemType import com.x8bit.bitwarden.ui.vault.feature.itemlisting.model.ListingItemOverflowAction import com.x8bit.bitwarden.ui.vault.feature.vault.model.VaultFilterData import com.x8bit.bitwarden.ui.vault.feature.vault.model.VaultFilterType +import com.x8bit.bitwarden.ui.vault.feature.vault.util.toSnackbarData import com.x8bit.bitwarden.ui.vault.feature.vault.util.toViewState import com.x8bit.bitwarden.ui.vault.model.VaultItemCipherType import com.x8bit.bitwarden.ui.vault.model.VaultItemListingType @@ -54,7 +56,9 @@ import io.mockk.coEvery import io.mockk.every import io.mockk.just import io.mockk.mockk +import io.mockk.mockkStatic import io.mockk.runs +import io.mockk.unmockkStatic import io.mockk.verify import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow @@ -62,6 +66,7 @@ import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.update import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertTrue @@ -120,10 +125,15 @@ class VaultViewModelTest : BaseViewModelTest() { every { switchAccount(any()) } answers { switchAccountResult } } + private var mutableFlightRecorderDataFlow = + MutableStateFlow(FlightRecorderDataSet(data = emptySet())) private val settingsRepository: SettingsRepository = mockk { every { getPullToRefreshEnabledFlow() } returns mutablePullToRefreshEnabledFlow every { isIconLoadingDisabledFlow } returns mutableIsIconLoadingDisabledFlow every { isIconLoadingDisabled } returns false + every { flightRecorderData } returns FlightRecorderDataSet(data = emptySet()) + every { flightRecorderDataFlow } returns mutableFlightRecorderDataFlow + every { dismissFlightRecorderBanner() } just runs } private val vaultRepository: VaultRepository = @@ -157,6 +167,11 @@ class VaultViewModelTest : BaseViewModelTest() { every { isNetworkConnected } returns true } + @AfterEach + fun tearDown() { + unmockkStatic(FlightRecorderDataSet::toSnackbarData) + } + @Test fun `initial state should be correct and should trigger a syncIfNecessary call`() { val viewModel = createViewModel() @@ -374,6 +389,52 @@ class VaultViewModelTest : BaseViewModelTest() { ) } + @Test + fun `Flight Recorder changes should update flightRecorderSnackbar accordingly`() = runTest { + mockkStatic(FlightRecorderDataSet::toSnackbarData) + val viewModel = createViewModel() + + viewModel.stateFlow.test { + assertEquals(DEFAULT_STATE.copy(flightRecorderSnackBar = null), awaitItem()) + + val snackbarData = mockk() + mutableFlightRecorderDataFlow.value = mockk { + every { toSnackbarData(clock = clock) } returns snackbarData + } + assertEquals(DEFAULT_STATE.copy(flightRecorderSnackBar = snackbarData), awaitItem()) + + mutableFlightRecorderDataFlow.value = mockk { + every { toSnackbarData(clock = clock) } returns null + } + assertEquals(DEFAULT_STATE.copy(flightRecorderSnackBar = null), awaitItem()) + } + } + + @Test + fun `on DismissFlightRecorderSnackbar should call dismissFlightRecorderBanner`() { + val viewModel = createViewModel() + + viewModel.trySendAction(VaultAction.DismissFlightRecorderSnackbar) + + verify(exactly = 1) { + settingsRepository.dismissFlightRecorderBanner() + } + } + + @Test + fun `on FlightRecorderGoToSettingsClick should send NavigateToAbout`() = runTest { + val viewModel = createViewModel() + + viewModel.eventFlow.test { + viewModel.trySendAction(VaultAction.FlightRecorderGoToSettingsClick) + assertEquals(VaultEvent.NavigateToAbout, awaitItem()) + } + + verify(exactly = 1) { + settingsRepository.dismissFlightRecorderBanner() + } + } + @Test fun `on LockAccountClick should call lockVault for the given account`() { val accountUserId = "userId" @@ -2119,4 +2180,5 @@ private fun createMockVaultState( hasMasterPassword = true, showImportActionCard = true, isRefreshing = false, + flightRecorderSnackBar = null, ) diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/FlightRecorderDataSetExtensionsTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/FlightRecorderDataSetExtensionsTest.kt new file mode 100644 index 0000000000..c91bc01669 --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/FlightRecorderDataSetExtensionsTest.kt @@ -0,0 +1,94 @@ +package com.x8bit.bitwarden.ui.vault.feature.vault.util + +import com.bitwarden.ui.util.asText +import com.x8bit.bitwarden.R +import com.x8bit.bitwarden.data.platform.datasource.disk.model.FlightRecorderDataSet +import com.x8bit.bitwarden.ui.platform.components.snackbar.BitwardenSnackbarData +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Test +import java.time.Clock +import java.time.Instant +import java.time.ZoneOffset + +class FlightRecorderDataSetExtensionsTest { + @Test + fun `toSnackbarData with empty data should return null`() { + val dataset = FlightRecorderDataSet(data = emptySet()) + + val result = dataset.toSnackbarData(clock = FIXED_CLOCK) + + assertNull(result) + } + + @Test + fun `toSnackbarData with no active logs should return null`() { + val dataset = FlightRecorderDataSet( + data = setOf( + DEFAULT_DATA.copy( + isActive = false, + isBannerDismissed = false, + ), + ), + ) + + val result = dataset.toSnackbarData(clock = FIXED_CLOCK) + + assertNull(result) + } + + @Test + fun `toSnackbarData with active logs but dismissed banner should return null`() { + val dataset = FlightRecorderDataSet( + data = setOf( + DEFAULT_DATA.copy( + isActive = true, + isBannerDismissed = true, + ), + ), + ) + + val result = dataset.toSnackbarData(clock = FIXED_CLOCK) + + assertNull(result) + } + + @Test + fun `toSnackbarData with active logs and un-dismissed banner should return SnackbarData`() { + val dataset = FlightRecorderDataSet( + data = setOf( + DEFAULT_DATA.copy( + isActive = true, + isBannerDismissed = false, + ), + ), + ) + + val result = dataset.toSnackbarData(clock = FIXED_CLOCK) + + assertEquals( + BitwardenSnackbarData( + message = R.string.flight_recorder_banner_message.asText("4/12/25", "9:15 AM"), + messageHeader = R.string.flight_recorder_banner_title.asText(), + actionLabel = R.string.go_to_settings.asText(), + withDismissAction = true, + ), + result, + ) + } +} + +private val DEFAULT_DATA = FlightRecorderDataSet.FlightRecorderData( + id = "50", + fileName = "flight_recorder", + startTimeMs = 1_744_445_752_855L, + durationMs = 3_600_000L, + expirationTimeMs = null, + isActive = false, + isBannerDismissed = false, +) + +private val FIXED_CLOCK: Clock = Clock.fixed( + Instant.parse("2023-10-27T12:00:00Z"), + ZoneOffset.UTC, +)