diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/data/platform/datasource/disk/SettingsDiskSource.kt b/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/data/platform/datasource/disk/SettingsDiskSource.kt index df4625b87f..ac9d776ffc 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/data/platform/datasource/disk/SettingsDiskSource.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/data/platform/datasource/disk/SettingsDiskSource.kt @@ -34,6 +34,16 @@ interface SettingsDiskSource { */ val isIconLoadingDisabledFlow: Flow + /** + * Tracks whether user has seen the Welcome tutorial. + */ + var hasSeenWelcomeTutorial: Boolean + + /** + * Emits update that track [hasSeenWelcomeTutorial] + */ + val hasSeenWelcomeTutorialFlow: Flow + /** * Stores the threshold at which users are alerted that an items validity period is nearing * expiration. diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/data/platform/datasource/disk/SettingsDiskSourceImpl.kt b/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/data/platform/datasource/disk/SettingsDiskSourceImpl.kt index fbbc913ddd..adfbe5a6b1 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/data/platform/datasource/disk/SettingsDiskSourceImpl.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/data/platform/datasource/disk/SettingsDiskSourceImpl.kt @@ -15,6 +15,7 @@ private const val SCREEN_CAPTURE_ALLOW_KEY = "$BASE_KEY:screenCaptureAllowed" private const val ACCOUNT_BIOMETRIC_INTEGRITY_VALID_KEY = "$BASE_KEY:accountBiometricIntegrityValid" private const val ALERT_THRESHOLD_SECONDS_KEY = "$BASE_KEY:alertThresholdSeconds" private const val DISABLE_ICON_LOADING_KEY = "$BASE_KEY:disableFavicon" +private const val FIRST_LAUNCH_KEY = "$BASE_KEY:hasSeenWelcomeTutorial" /** * Primary implementation of [SettingsDiskSource]. @@ -47,6 +48,9 @@ class SettingsDiskSourceImpl( ) } + private val mutableFirstLaunchFlow = + bufferedMutableSharedFlow() + override var appTheme: AppTheme get() = getString(key = APP_THEME_KEY) ?.let { storedValue -> @@ -76,6 +80,16 @@ class SettingsDiskSourceImpl( get() = mutableIsIconLoadingDisabledFlow .onSubscription { emit(getBoolean(DISABLE_ICON_LOADING_KEY)) } + override var hasSeenWelcomeTutorial: Boolean + get() = getBoolean(key = FIRST_LAUNCH_KEY) ?: false + set(value) { + putBoolean(key = FIRST_LAUNCH_KEY, value) + mutableFirstLaunchFlow.tryEmit(hasSeenWelcomeTutorial) + } + + override val hasSeenWelcomeTutorialFlow: Flow + get() = mutableFirstLaunchFlow.onSubscription { emit(hasSeenWelcomeTutorial) } + override fun storeAlertThresholdSeconds(thresholdSeconds: Int) { putInt( ALERT_THRESHOLD_SECONDS_KEY, diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/data/platform/repository/SettingsRepository.kt b/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/data/platform/repository/SettingsRepository.kt index 4be1f57988..320c658acf 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/data/platform/repository/SettingsRepository.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/data/platform/repository/SettingsRepository.kt @@ -44,4 +44,14 @@ interface SettingsRepository { * Emits updates that track the [isIconLoadingDisabled] value. */ val isIconLoadingDisabledFlow: Flow + + /** + * Whether the user has seen the Welcome tutorial. + */ + var hasSeenWelcomeTutorial: Boolean + + /** + * Tracks whether the user has seen the Welcome tutorial. + */ + val hasSeenWelcomeTutorialFlow: StateFlow } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/data/platform/repository/SettingsRepositoryImpl.kt b/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/data/platform/repository/SettingsRepositoryImpl.kt index 00d2a90ebd..1fdee8ee82 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/data/platform/repository/SettingsRepositoryImpl.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/data/platform/repository/SettingsRepositoryImpl.kt @@ -66,4 +66,18 @@ class SettingsRepositoryImpl( .isIconLoadingDisabled ?: false, ) + override var hasSeenWelcomeTutorial: Boolean + get() = settingsDiskSource.hasSeenWelcomeTutorial + set(value) { + settingsDiskSource.hasSeenWelcomeTutorial = value + } + + override val hasSeenWelcomeTutorialFlow: StateFlow + get() = settingsDiskSource + .hasSeenWelcomeTutorialFlow + .stateIn( + scope = unconfinedScope, + started = SharingStarted.Eagerly, + initialValue = hasSeenWelcomeTutorial, + ) } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/authenticator/feature/authenticator/AuthenticatorNavigation.kt b/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/authenticator/feature/authenticator/AuthenticatorNavigation.kt index 1946ce24ee..ecf16ce555 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/authenticator/feature/authenticator/AuthenticatorNavigation.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/authenticator/feature/authenticator/AuthenticatorNavigation.kt @@ -1,22 +1,17 @@ package com.x8bit.bitwarden.authenticator.ui.authenticator.feature.authenticator -import android.widget.Toast import androidx.navigation.NavController import androidx.navigation.NavGraphBuilder import androidx.navigation.NavOptions import androidx.navigation.navigation -import com.x8bit.bitwarden.authenticator.R -import com.x8bit.bitwarden.authenticator.ui.authenticator.feature.edititem.editItemDestination import com.x8bit.bitwarden.authenticator.ui.authenticator.feature.edititem.navigateToEditItem -import com.x8bit.bitwarden.authenticator.ui.authenticator.feature.itemlisting.itemListingDestination import com.x8bit.bitwarden.authenticator.ui.authenticator.feature.itemlisting.itemListingGraph -import com.x8bit.bitwarden.authenticator.ui.authenticator.feature.manualcodeentry.manualCodeEntryDestination import com.x8bit.bitwarden.authenticator.ui.authenticator.feature.manualcodeentry.navigateToManualCodeEntryScreen import com.x8bit.bitwarden.authenticator.ui.authenticator.feature.navbar.AUTHENTICATOR_NAV_BAR_ROUTE import com.x8bit.bitwarden.authenticator.ui.authenticator.feature.navbar.authenticatorNavBarDestination import com.x8bit.bitwarden.authenticator.ui.authenticator.feature.qrcodescan.navigateToQrCodeScanScreen -import com.x8bit.bitwarden.authenticator.ui.authenticator.feature.qrcodescan.qrCodeScanDestination import com.x8bit.bitwarden.authenticator.ui.authenticator.feature.search.navigateToSearch +import com.x8bit.bitwarden.authenticator.ui.platform.feature.tutorial.navigateToTutorial const val AUTHENTICATOR_GRAPH_ROUTE = "authenticator_graph" @@ -42,6 +37,7 @@ fun NavGraphBuilder.authenticatorGraph( onNavigateToQrCodeScanner = { navController.navigateToQrCodeScanScreen() }, onNavigateToManualKeyEntry = { navController.navigateToManualCodeEntryScreen() }, onNavigateToEditItem = { navController.navigateToEditItem(itemId = it) }, + onNavigateToTutorial = { navController.navigateToTutorial() }, ) itemListingGraph( navController = navController, @@ -56,7 +52,8 @@ fun NavGraphBuilder.authenticatorGraph( }, navigateToEditItem = { navController.navigateToEditItem(itemId = it) - } + }, + navigateToTutorial = { navController.navigateToTutorial() }, ) } } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/authenticator/feature/itemlisting/ItemListingGraphNavigation.kt b/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/authenticator/feature/itemlisting/ItemListingGraphNavigation.kt index c994661508..6121d302b2 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/authenticator/feature/itemlisting/ItemListingGraphNavigation.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/authenticator/feature/itemlisting/ItemListingGraphNavigation.kt @@ -10,7 +10,6 @@ import com.x8bit.bitwarden.authenticator.ui.authenticator.feature.manualcodeentr import com.x8bit.bitwarden.authenticator.ui.authenticator.feature.qrcodescan.navigateToQrCodeScanScreen import com.x8bit.bitwarden.authenticator.ui.authenticator.feature.qrcodescan.qrCodeScanDestination import com.x8bit.bitwarden.authenticator.ui.authenticator.feature.search.itemSearchDestination -import com.x8bit.bitwarden.authenticator.ui.authenticator.feature.search.navigateToSearch import com.x8bit.bitwarden.authenticator.ui.platform.feature.settings.settingsGraph const val ITEM_LISTING_GRAPH_ROUTE = "item_listing_graph" @@ -24,6 +23,7 @@ fun NavGraphBuilder.itemListingGraph( navigateToQrCodeScanner: () -> Unit, navigateToManualKeyEntry: () -> Unit, navigateToEditItem: (String) -> Unit, + navigateToTutorial: () -> Unit, ) { navigation( route = ITEM_LISTING_GRAPH_ROUTE, @@ -60,7 +60,10 @@ fun NavGraphBuilder.itemListingGraph( navController.navigateToQrCodeScanScreen() } ) - settingsGraph(navController) + settingsGraph( + navController = navController, + onNavigateToTutorial = navigateToTutorial + ) } } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/authenticator/feature/navbar/AuthenticatorNavBarNavigation.kt b/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/authenticator/feature/navbar/AuthenticatorNavBarNavigation.kt index eddae79923..5b26d2d0d2 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/authenticator/feature/navbar/AuthenticatorNavBarNavigation.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/authenticator/feature/navbar/AuthenticatorNavBarNavigation.kt @@ -13,6 +13,7 @@ fun NavGraphBuilder.authenticatorNavBarDestination( onNavigateToQrCodeScanner: () -> Unit, onNavigateToManualKeyEntry: () -> Unit, onNavigateToEditItem: (itemId: String) -> Unit, + onNavigateToTutorial: () -> Unit, ) { composableWithStayTransitions( route = AUTHENTICATOR_NAV_BAR_ROUTE, @@ -22,6 +23,7 @@ fun NavGraphBuilder.authenticatorNavBarDestination( onNavigateToManualKeyEntry = onNavigateToManualKeyEntry, onNavigateToEditItem = onNavigateToEditItem, onNavigateToSearch = onNavigateToSearch, + onNavigateToTutorial = onNavigateToTutorial, ) } } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/authenticator/feature/navbar/AuthenticatorNavBarScreen.kt b/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/authenticator/feature/navbar/AuthenticatorNavBarScreen.kt index 75e89d6570..0aff021572 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/authenticator/feature/navbar/AuthenticatorNavBarScreen.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/authenticator/feature/navbar/AuthenticatorNavBarScreen.kt @@ -71,6 +71,7 @@ fun AuthenticatorNavBarScreen( onNavigateToQrCodeScanner: () -> Unit, onNavigateToManualKeyEntry: () -> Unit, onNavigateToEditItem: (itemId: String) -> Unit, + onNavigateToTutorial: () -> Unit, ) { EventsEffect(viewModel = viewModel) { event -> navController.apply { @@ -108,6 +109,7 @@ fun AuthenticatorNavBarScreen( navigateToQrCodeScanner = onNavigateToQrCodeScanner, navigateToManualKeyEntry = onNavigateToManualKeyEntry, navigateToEditItem = onNavigateToEditItem, + navigateToTutorial = onNavigateToTutorial, ) } @@ -121,6 +123,7 @@ private fun AuthenticatorNavBarScaffold( navigateToQrCodeScanner: () -> Unit, navigateToManualKeyEntry: () -> Unit, navigateToEditItem: (itemId: String) -> Unit, + navigateToTutorial: () -> Unit, ) { BitwardenScaffold( contentWindowInsets = ScaffoldDefaults.contentWindowInsets.exclude(WindowInsets.statusBars), @@ -166,6 +169,7 @@ private fun AuthenticatorNavBarScaffold( navigateToQrCodeScanner = navigateToQrCodeScanner, navigateToManualKeyEntry = navigateToManualKeyEntry, navigateToEditItem = navigateToEditItem, + navigateToTutorial = navigateToTutorial, ) } } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/platform/feature/rootnav/RootNavScreen.kt b/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/platform/feature/rootnav/RootNavScreen.kt index fc49e743d0..0bb9ddd4d9 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/platform/feature/rootnav/RootNavScreen.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/platform/feature/rootnav/RootNavScreen.kt @@ -19,6 +19,9 @@ import com.x8bit.bitwarden.authenticator.ui.authenticator.feature.authenticator. import com.x8bit.bitwarden.authenticator.ui.platform.feature.splash.SPLASH_ROUTE import com.x8bit.bitwarden.authenticator.ui.platform.feature.splash.navigateToSplash import com.x8bit.bitwarden.authenticator.ui.platform.feature.splash.splashDestination +import com.x8bit.bitwarden.authenticator.ui.platform.feature.tutorial.TUTORIAL_ROUTE +import com.x8bit.bitwarden.authenticator.ui.platform.feature.tutorial.navigateToTutorial +import com.x8bit.bitwarden.authenticator.ui.platform.feature.tutorial.tutorialDestination import com.x8bit.bitwarden.authenticator.ui.platform.theme.NonNullEnterTransitionProvider import com.x8bit.bitwarden.authenticator.ui.platform.theme.NonNullExitTransitionProvider import com.x8bit.bitwarden.authenticator.ui.platform.theme.RootTransitionProviders @@ -62,12 +65,16 @@ fun RootNavScreen( popExitTransition = { toExitTransition()(this) }, ) { splashDestination() + tutorialDestination( + onTutorialFinished = { navController.navigateToAuthenticatorGraph() } + ) authenticatorGraph(navController) } val targetRoute = when (state) { RootNavState.ItemListing -> AUTHENTICATOR_GRAPH_ROUTE RootNavState.Splash -> SPLASH_ROUTE + RootNavState.Tutorial -> TUTORIAL_ROUTE } val currentRoute = navController.currentDestination?.rootLevelRoute() @@ -94,8 +101,9 @@ fun RootNavScreen( LaunchedEffect(state) { when (state) { - RootNavState.ItemListing -> navController.navigateToAuthenticatorGraph(rootNavOptions) RootNavState.Splash -> navController.navigateToSplash(rootNavOptions) + RootNavState.Tutorial -> navController.navigateToTutorial(rootNavOptions) + RootNavState.ItemListing -> navController.navigateToAuthenticatorGraph(rootNavOptions) } } } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/platform/feature/rootnav/RootNavViewModel.kt b/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/platform/feature/rootnav/RootNavViewModel.kt index b83c572025..9b3d7c81a4 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/platform/feature/rootnav/RootNavViewModel.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/platform/feature/rootnav/RootNavViewModel.kt @@ -3,9 +3,12 @@ package com.x8bit.bitwarden.authenticator.ui.platform.feature.rootnav import android.os.Parcelable import androidx.lifecycle.viewModelScope import com.x8bit.bitwarden.authenticator.data.auth.repository.AuthRepository +import com.x8bit.bitwarden.authenticator.data.platform.repository.SettingsRepository import com.x8bit.bitwarden.authenticator.ui.platform.base.BaseViewModel import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.parcelize.Parcelize @@ -14,21 +17,29 @@ import javax.inject.Inject @HiltViewModel class RootNavViewModel @Inject constructor( private val authRepository: AuthRepository, + private val settingsRepository: SettingsRepository, ) : BaseViewModel( initialState = RootNavState.Splash ) { init { viewModelScope.launch { - delay(250) - trySendAction(RootNavAction.Internal.StateUpdate) + settingsRepository.hasSeenWelcomeTutorialFlow + .map { RootNavAction.Internal.HasSeenWelcomeTutorialChange(it) } + .onEach(::sendAction) + .launchIn(viewModelScope) } } override fun handleAction(action: RootNavAction) { when (action) { - RootNavAction.BackStackUpdate -> handleBackStackUpdate() - RootNavAction.Internal.StateUpdate -> handleStateUpdate() + RootNavAction.BackStackUpdate -> { + handleBackStackUpdate() + } + + is RootNavAction.Internal.HasSeenWelcomeTutorialChange -> { + handleHasSeenWelcomeTutorialChange(action.hasSeenWelcomeGuide) + } } } @@ -36,8 +47,12 @@ class RootNavViewModel @Inject constructor( authRepository.updateLastActiveTime() } - private fun handleStateUpdate() { - mutableStateFlow.update { RootNavState.ItemListing } + private fun handleHasSeenWelcomeTutorialChange(hasSeenWelcomeGuide: Boolean) { + if (hasSeenWelcomeGuide) { + mutableStateFlow.update { RootNavState.ItemListing } + } else { + mutableStateFlow.update { RootNavState.Tutorial } + } } } @@ -52,6 +67,12 @@ sealed class RootNavState : Parcelable { @Parcelize data object Splash : RootNavState() + /** + * App should display the Tutorial nav graph. + */ + @Parcelize + data object Tutorial : RootNavState() + /** * App should display the Account List nav graph. */ @@ -68,7 +89,14 @@ sealed class RootNavAction { */ data object BackStackUpdate : RootNavAction() + /** + * Models actions the [RootNavViewModel] itself may send. + */ sealed class Internal : RootNavAction() { - data object StateUpdate : Internal() + + /** + * Indicates an update in the welcome guide being seen has been received. + */ + data class HasSeenWelcomeTutorialChange(val hasSeenWelcomeGuide: Boolean) : Internal() } } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/platform/feature/settings/SettingsNavigation.kt b/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/platform/feature/settings/SettingsNavigation.kt index 989bc8986c..fb746c5fc3 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/platform/feature/settings/SettingsNavigation.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/platform/feature/settings/SettingsNavigation.kt @@ -14,17 +14,20 @@ private const val SETTINGS_ROUTE = "settings" */ fun NavGraphBuilder.settingsGraph( navController: NavController, + onNavigateToTutorial: () -> Unit, ) { - navigation( - startDestination = SETTINGS_ROUTE, - route = SETTINGS_GRAPH_ROUTE - ) { - composableWithRootPushTransitions( - route = SETTINGS_ROUTE - ) { - SettingsScreen() - } - } + navigation( + startDestination = SETTINGS_ROUTE, + route = SETTINGS_GRAPH_ROUTE + ) { + composableWithRootPushTransitions( + route = SETTINGS_ROUTE + ) { + SettingsScreen( + onNavigateToTutorial = onNavigateToTutorial, + ) + } + } } /** diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/platform/feature/settings/SettingsScreen.kt b/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/platform/feature/settings/SettingsScreen.kt index 7178d41e4f..c89b38a68b 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/platform/feature/settings/SettingsScreen.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/platform/feature/settings/SettingsScreen.kt @@ -26,6 +26,7 @@ import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.x8bit.bitwarden.authenticator.R +import com.x8bit.bitwarden.authenticator.ui.platform.base.util.EventsEffect import com.x8bit.bitwarden.authenticator.ui.platform.base.util.asText import com.x8bit.bitwarden.authenticator.ui.platform.components.appbar.BitwardenMediumTopAppBar import com.x8bit.bitwarden.authenticator.ui.platform.components.dialog.BasicDialogState @@ -47,11 +48,18 @@ import com.x8bit.bitwarden.authenticator.ui.platform.util.displayLabel @Composable fun SettingsScreen( viewModel: SettingsViewModel = hiltViewModel(), + onNavigateToTutorial: () -> Unit, ) { val state by viewModel.stateFlow.collectAsStateWithLifecycle() val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState()) + EventsEffect(viewModel = viewModel) { event -> + when (event) { + SettingsEvent.NavigateToTutorial -> onNavigateToTutorial() + } + } + BitwardenScaffold( topBar = { BitwardenMediumTopAppBar( @@ -75,22 +83,32 @@ fun SettingsScreen( } }, onThemeSelection = remember(viewModel) { - { viewModel.trySendAction(SettingsAction.AppearanceChange.ThemeChange(it)) } + { + viewModel.trySendAction(SettingsAction.AppearanceChange.ThemeChange(it)) + } }, onShowWebsiteIconsChange = remember(viewModel) { { viewModel.trySendAction( - SettingsAction.AppearanceChange.ShowWebsiteIconsChange( - it - ) + SettingsAction.AppearanceChange.ShowWebsiteIconsChange(it) ) } }, ) + + HelpSettings( + onTutorialClick = remember(viewModel) { + { + viewModel.trySendAction(SettingsAction.HelpClick.ShowTutorialClick) + } + } + ) } } } +//region Appearance settings + @Composable private fun AppearanceSettings( state: SettingsState, @@ -222,3 +240,25 @@ private fun ThemeSelectionRow( } } } + +//endregion Appearance settings + +//region Help settings + +@Composable +private fun HelpSettings( + modifier: Modifier = Modifier, + onTutorialClick: () -> Unit, +) { + BitwardenListHeaderText( + modifier = Modifier.padding(horizontal = 16.dp), + label = stringResource(id = R.string.help) + ) + BitwardenTextRow( + text = stringResource(id = R.string.tutorial), + onClick = onTutorialClick, + modifier = modifier, + ) +} + +//region Help settings diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/platform/feature/settings/SettingsViewModel.kt b/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/platform/feature/settings/SettingsViewModel.kt index a5e039b424..2a63697a19 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/platform/feature/settings/SettingsViewModel.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/platform/feature/settings/SettingsViewModel.kt @@ -35,6 +35,7 @@ class SettingsViewModel @Inject constructor( override fun handleAction(action: SettingsAction) { when (action) { is SettingsAction.AppearanceChange -> handleAppearanceChange(action) + is SettingsAction.HelpClick -> handleHelpClick(action) } } @@ -85,6 +86,16 @@ class SettingsViewModel @Inject constructor( } settingsRepository.appTheme = theme } + + private fun handleHelpClick(action: SettingsAction.HelpClick) { + when (action) { + SettingsAction.HelpClick.ShowTutorialClick -> handleShowTutorialCLick() + } + } + + private fun handleShowTutorialCLick() { + sendEvent(SettingsEvent.NavigateToTutorial) + } } /** @@ -108,13 +119,19 @@ data class SettingsState( /** * Models events for the settings screen. */ -sealed class SettingsEvent +sealed class SettingsEvent { + data object NavigateToTutorial : SettingsEvent() +} /** * Models actions for the settings screen. */ sealed class SettingsAction { + sealed class HelpClick : SettingsAction() { + data object ShowTutorialClick : HelpClick() + } + sealed class AppearanceChange : SettingsAction() { /** * Indicates the user changed the language. diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/platform/feature/tutorial/TutorialNavigation.kt b/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/platform/feature/tutorial/TutorialNavigation.kt new file mode 100644 index 0000000000..2e977fe8bb --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/platform/feature/tutorial/TutorialNavigation.kt @@ -0,0 +1,20 @@ +package com.x8bit.bitwarden.authenticator.ui.platform.feature.tutorial + +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavOptions +import androidx.navigation.compose.composable + +const val TUTORIAL_ROUTE = "tutorial" + +fun NavGraphBuilder.tutorialDestination(onTutorialFinished: () -> Unit) { + composable(TUTORIAL_ROUTE) { + TutorialScreen( + onTutorialFinished = onTutorialFinished, + ) + } +} + +fun NavController.navigateToTutorial(navOptions: NavOptions? = null) { + navigate(route = TUTORIAL_ROUTE, navOptions = navOptions) +} diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/platform/feature/tutorial/TutorialScreen.kt b/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/platform/feature/tutorial/TutorialScreen.kt new file mode 100644 index 0000000000..f84e57c5fb --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/platform/feature/tutorial/TutorialScreen.kt @@ -0,0 +1,241 @@ +package com.x8bit.bitwarden.authenticator.ui.platform.feature.tutorial + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.res.painterResource +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.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.x8bit.bitwarden.authenticator.R +import com.x8bit.bitwarden.authenticator.ui.platform.base.util.EventsEffect +import com.x8bit.bitwarden.authenticator.ui.platform.components.button.BitwardenFilledTonalButton +import com.x8bit.bitwarden.authenticator.ui.platform.components.button.BitwardenTextButton +import com.x8bit.bitwarden.authenticator.ui.platform.components.scaffold.BitwardenScaffold + +private const val INTRO_PAGE = 0 +private const val QR_SCANNER_PAGE = 1 +private const val UNIQUE_CODES_PAGE = 2 +private const val PAGE_COUNT = 3 + +@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) +@Composable +fun TutorialScreen( + viewModel: TutorialViewModel = hiltViewModel(), + onTutorialFinished: () -> Unit, +) { + val state by viewModel.stateFlow.collectAsStateWithLifecycle() + val pagerState = rememberPagerState(pageCount = { PAGE_COUNT }) + + EventsEffect(viewModel = viewModel) { event -> + when (event) { + TutorialEvent.NavigateToAuthenticator -> { + onTutorialFinished() + } + + TutorialEvent.NavigateToQrScannerSlide -> { + pagerState.animateScrollToPage(page = QR_SCANNER_PAGE) + } + + TutorialEvent.NavigateToUniqueCodesSlide -> { + pagerState.animateScrollToPage(page = UNIQUE_CODES_PAGE) + } + } + } + + BitwardenScaffold( + modifier = Modifier.fillMaxSize(), + ) { innerPadding -> + Column( + modifier = Modifier + .padding(innerPadding), + verticalArrangement = Arrangement.Center + ) { + HorizontalPager( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 16.dp) + .weight(1f), + state = pagerState, + userScrollEnabled = true, + ) { page -> + viewModel.trySendAction( + TutorialAction.TutorialPageChange(pagerState.targetPage) + ) + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + when (page) { + INTRO_PAGE -> VerificationCodesContent() + + QR_SCANNER_PAGE -> TutorialQrScannerScreen() + + UNIQUE_CODES_PAGE -> UniqueCodesContent() + } + } + } + + LazyColumn( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.Bottom, + ) { + item { + Row( + modifier = Modifier + .height(50.dp) + .fillMaxWidth(), + horizontalArrangement = Arrangement.Center + ) { + repeat(PAGE_COUNT) { + val color = if (pagerState.currentPage == it) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.secondary + } + Box( + modifier = Modifier + .padding(8.dp) + .background(color, CircleShape) + .size(10.dp) + ) + } + } + } + + item { + BitwardenFilledTonalButton( + modifier = Modifier.fillMaxWidth(), + label = state.continueButtonText(), + onClick = remember(viewModel) { + { + viewModel.trySendAction( + TutorialAction.ContinueClick + ) + } + }, + ) + } + + item { + val alpha = remember(state) { + if (state.isLastPage) { + 0f + } else { + 1f + } + } + BitwardenTextButton( + modifier = Modifier + .fillMaxWidth() + .alpha(alpha), + isEnabled = !state.isLastPage, + label = stringResource(id = R.string.skip), + onClick = remember(viewModel) { + { + viewModel.trySendAction(TutorialAction.SkipClick) + } + }, + ) + } + } + } + } +} + +@Composable +private fun VerificationCodesContent() { + Image( + painter = painterResource(R.drawable.ic_tutorial_verification_codes), + contentDescription = stringResource( + id = R.string.secure_your_accounts_with_bitwarden_authenticator, + ), + ) + Text( + style = MaterialTheme.typography.headlineSmall, + textAlign = TextAlign.Center, + text = stringResource(R.string.secure_your_accounts_with_bitwarden_authenticator), + ) + Text( + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + text = stringResource(R.string.get_verification_codes_for_all_your_accounts), + ) +} + +@Composable +private fun TutorialQrScannerScreen() { + Image( + painter = painterResource(id = R.drawable.ic_tutorial_qr_scanner), + contentDescription = stringResource(id = R.string.scan_qr_code), + ) + Text( + style = MaterialTheme.typography.headlineSmall, + textAlign = TextAlign.Center, + text = stringResource( + R.string.use_your_device_camera_to_scan_codes + ), + ) + Text( + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + text = stringResource( + R.string.scan_the_qr_code_in_your_2_step_verification_settings_for_any_account + ), + ) +} + +@Composable +private fun UniqueCodesContent() { + Image( + painter = painterResource(id = R.drawable.ic_tutorial_2fa), + contentDescription = stringResource(id = R.string.unique_codes) + ) + Text( + style = MaterialTheme.typography.headlineSmall, + textAlign = TextAlign.Center, + text = stringResource(R.string.sign_in_using_unique_codes), + ) + Text( + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + text = stringResource( + R.string.when_using_2_step_verification_youll_enter_your_username_and_password_and_a_code_generated_in_this_app + ), + ) +} + +@Preview +@Composable +fun TutorialScreenPreview() { + Box { + TutorialScreen( + onTutorialFinished = {} + ) + } +} diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/platform/feature/tutorial/TutorialViewModel.kt b/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/platform/feature/tutorial/TutorialViewModel.kt new file mode 100644 index 0000000000..52161f0baf --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/platform/feature/tutorial/TutorialViewModel.kt @@ -0,0 +1,145 @@ +package com.x8bit.bitwarden.authenticator.ui.platform.feature.tutorial + +import android.os.Parcelable +import com.x8bit.bitwarden.authenticator.R +import com.x8bit.bitwarden.authenticator.data.platform.repository.SettingsRepository +import com.x8bit.bitwarden.authenticator.ui.platform.base.BaseViewModel +import com.x8bit.bitwarden.authenticator.ui.platform.base.util.Text +import com.x8bit.bitwarden.authenticator.ui.platform.base.util.asText +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.update +import kotlinx.parcelize.Parcelize +import javax.inject.Inject + +/** + * View model for the [TutorialScreen]. + */ +@HiltViewModel +class TutorialViewModel @Inject constructor( + private val settingsRepository: SettingsRepository, +) : + BaseViewModel( + initialState = TutorialState.IntroSlide + ) { + + override fun handleAction(action: TutorialAction) { + when (action) { + TutorialAction.ContinueClick -> { + handleContinueClick() + } + + TutorialAction.SkipClick -> { + handleSkipClick() + } + + is TutorialAction.TutorialPageChange -> { + handleTutorialPageChange(action.targetPage) + } + } + } + + private fun handleTutorialPageChange(page: Int) { + when (page) { + 0 -> mutableStateFlow.update { TutorialState.IntroSlide } + 1 -> mutableStateFlow.update { TutorialState.QrScannerSlide } + 2 -> mutableStateFlow.update { TutorialState.UniqueCodesSlide } + } + } + + private fun handleContinueClick() { + val currentPage = mutableStateFlow.value + val event = when (currentPage) { + TutorialState.IntroSlide -> TutorialEvent.NavigateToQrScannerSlide + TutorialState.QrScannerSlide -> TutorialEvent.NavigateToUniqueCodesSlide + TutorialState.UniqueCodesSlide -> { + settingsRepository.hasSeenWelcomeTutorial = true + TutorialEvent.NavigateToAuthenticator + } + } + sendEvent(event) + } + + private fun handleSkipClick() { + settingsRepository.hasSeenWelcomeTutorial = true + sendEvent(TutorialEvent.NavigateToAuthenticator) + } +} + +/** + * Models state for the Tutorial screen. + */ +@Parcelize +sealed class TutorialState( + val continueButtonText: Text, + val isLastPage: Boolean, +) : Parcelable { + + /** + * Tutorial should display the introduction slide. + */ + @Parcelize + data object IntroSlide : TutorialState( + continueButtonText = R.string.continue_button.asText(), + isLastPage = false, + ) + + /** + * Tutorial should display the QR code scanner description slide. + */ + @Parcelize + data object QrScannerSlide : TutorialState( + continueButtonText = R.string.continue_button.asText(), + isLastPage = false + ) + + /** + * Tutorial should display the 2FA code description slide. + */ + @Parcelize + data object UniqueCodesSlide : TutorialState( + continueButtonText = R.string.get_started.asText(), + isLastPage = true + ) +} + +/** + * Represents a set of events related to the tutorial screen. + */ +sealed class TutorialEvent { + /** + * Navigate to the authenticator tutorial slide. + */ + data object NavigateToAuthenticator : TutorialEvent() + + /** + * Navigate to the QR Code scanner tutorial slide. + */ + data object NavigateToQrScannerSlide : TutorialEvent() + + /** + * Navigate to the unique codes tutorial slide. + */ + data object NavigateToUniqueCodesSlide : TutorialEvent() +} + +/** + * Models actions that can be taken on the tutorial screen. + */ +sealed class TutorialAction { + /** + * The user has manually changed the tutorial page by swiping. + */ + data class TutorialPageChange( + val targetPage: Int, + ) : TutorialAction() + + /** + * The user clicked the continue button on the introduction slide. + */ + data object ContinueClick : TutorialAction() + + /** + * The user clicked the skip button on one of the tutorial slides. + */ + data object SkipClick : TutorialAction() +} diff --git a/app/src/main/res/drawable/ic_tutorial_2fa.xml b/app/src/main/res/drawable/ic_tutorial_2fa.xml new file mode 100644 index 0000000000..b09c1c2634 --- /dev/null +++ b/app/src/main/res/drawable/ic_tutorial_2fa.xml @@ -0,0 +1,29 @@ + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_tutorial_qr_scanner.xml b/app/src/main/res/drawable/ic_tutorial_qr_scanner.xml new file mode 100644 index 0000000000..b309a03bbd --- /dev/null +++ b/app/src/main/res/drawable/ic_tutorial_qr_scanner.xml @@ -0,0 +1,93 @@ + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_tutorial_unique_codes.xml b/app/src/main/res/drawable/ic_tutorial_unique_codes.xml new file mode 100644 index 0000000000..073fffbe76 --- /dev/null +++ b/app/src/main/res/drawable/ic_tutorial_unique_codes.xml @@ -0,0 +1,29 @@ + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_tutorial_verification_codes.xml b/app/src/main/res/drawable/ic_tutorial_verification_codes.xml new file mode 100644 index 0000000000..9d0cb712e7 --- /dev/null +++ b/app/src/main/res/drawable/ic_tutorial_verification_codes.xml @@ -0,0 +1,14 @@ + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ad2420f2e8..13125310ad 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -74,4 +74,16 @@ The language has been changed to %1$s. Please restart the app to see the change Show website icons Show a recognizable image next to each login. + Secure your accounts with Bitwarden Authneticator + Get verification codes for all your accounts that support 2-step verification. + Use your device camera to scan codes + Scan the QR code in your 2-step verification settings for any account. + Sign in using unique codes + When using 2-step verification, you’ll enter your username and password and a code generated in this app. + Continue + Skip + Get started + Uniqe codes + Help + Tutorial