diff --git a/ui/src/main/kotlin/com/bitwarden/ui/platform/components/navigation/model/NavigationItem.kt b/ui/src/main/kotlin/com/bitwarden/ui/platform/components/navigation/model/NavigationItem.kt index 1bca79a4a2..0d622b47d8 100644 --- a/ui/src/main/kotlin/com/bitwarden/ui/platform/components/navigation/model/NavigationItem.kt +++ b/ui/src/main/kotlin/com/bitwarden/ui/platform/components/navigation/model/NavigationItem.kt @@ -1,5 +1,7 @@ package com.bitwarden.ui.platform.components.navigation.model +import androidx.navigation3.runtime.NavKey + /** * Represents a user-interactable item to navigate a user via the bottom app bar or navigation rail. */ @@ -15,14 +17,27 @@ interface NavigationItem { val labelRes: Int /** - * Route of the tab's graph. + * The route of the tab's root destination, which also identifies the tab's back stack. */ - val graphRoute: Any + val rootRoute: NavKey + // TODO: Remove this default once all modules have been migrated to Navigation3 (PM-45372). + get() = error("rootRoute is not supported by legacy Navigation Compose tabs.") /** - * Route of the tab's start destination. + * Legacy route of the tab's Navigation Compose graph. + * + * TODO: Remove once all modules have been migrated to Navigation3 (PM-45372). + */ + val graphRoute: Any + get() = rootRoute + + /** + * Legacy route of the tab's Navigation Compose start destination. + * + * TODO: Remove once all modules have been migrated to Navigation3 (PM-45372). */ val startDestinationRoute: Any + get() = rootRoute /** * The test tag of the tab. diff --git a/ui/src/main/kotlin/com/bitwarden/ui/platform/navigation/BottomNavigationController.kt b/ui/src/main/kotlin/com/bitwarden/ui/platform/navigation/BottomNavigationController.kt new file mode 100644 index 0000000000..8942cc988d --- /dev/null +++ b/ui/src/main/kotlin/com/bitwarden/ui/platform/navigation/BottomNavigationController.kt @@ -0,0 +1,33 @@ +package com.bitwarden.ui.platform.navigation + +import androidx.navigation3.runtime.NavKey + +/** + * Controls navigation within a bottom navigation set of back stacks. + */ +interface BottomNavigationController { + /** + * The controller for the bottom navigation graph. + */ + val root: NavigationController + + /** + * A list of all back stacks in the bottom navigation, including the root bottom nav. + */ + val backStacks: List> + + /** + * Navigates to the selected tab route or pops the backstack up to the root of the already + * selected tab route. + */ + fun navigateToTabOrPopToRoot(route: BottomNavGraph) + + /** + * Navigates back within the back stack of the selected tab. When the selected tab is already + * at its root, the start tab is selected instead, so the user always exits through the start + * tab. + * + * @return `true` if the navigation was handled. + */ + fun navigateBack(): Boolean +} diff --git a/ui/src/main/kotlin/com/bitwarden/ui/platform/navigation/NavigationController.kt b/ui/src/main/kotlin/com/bitwarden/ui/platform/navigation/NavigationController.kt new file mode 100644 index 0000000000..65cbba5086 --- /dev/null +++ b/ui/src/main/kotlin/com/bitwarden/ui/platform/navigation/NavigationController.kt @@ -0,0 +1,94 @@ +package com.bitwarden.ui.platform.navigation + +import androidx.navigation3.runtime.NavKey +import kotlinx.coroutines.flow.Flow + +/** + * Controls navigation within a single backstack of [NavKey] routes. + * + * The [backStack] is observable state that should be displayed by a `NavDisplay`, while all + * changes to it should be made through this controller. + */ +interface NavigationController { + /** + * The current backstack, ordered from the root entry to the top entry. This list is backed by + * snapshot state, so reading it within a composition will observe every change. + */ + val backStack: List + + /** + * Creates an observable snapshot flow of the backstack, indicating any changes that is made to + * it. + */ + val backStackSnapshot: Flow> + + /** + * Navigates to the given [route] by adding it to the top of the backstack. + */ + fun navigate(route: T) + + /** + * Navigates back by removing the top entry from the backstack. + * + * The last remaining entry is never removed, since a backstack being displayed must never be + * empty. + * + * @return `true` if an entry was removed from the backstack. + */ + fun navigateBack(): Boolean + + /** + * Adds the given [route] to the top of the backstack unless an entry of the same type is + * already on top of the backstack. This ensures there is at most one copy of a given + * destination on the top of the backstack. + */ + fun addSingleTop(route: T) + + /** + * Pops entries off of the backstack until the most recent entry matching the [predicate] is + * on top. If [inclusive] is `true`, the matching entry is removed as well (unless it is the + * last remaining entry). + * + * @return `true` if an entry matching the [predicate] was found in the backstack. + */ + fun popUpTo(inclusive: Boolean = false, predicate: (T) -> Boolean): Boolean + + /** + * Pops all entries off the backstack until only the root entry remains. This does nothing if + * only the root entry exists. + */ + fun popToRoot() + + /** + * Atomically pops entries off of the backstack until the most recent entry matching the + * [predicate] is on top (or removed as well, if [inclusive] is `true`) and then adds the given + * [route]. + * + * If there is no entry matching the [predicate] in the backstack, the [route] is simply added. + */ + fun navigatePoppingUpTo(route: T, inclusive: Boolean = false, predicate: (T) -> Boolean) + + /** + * Navigates to the given top-level [route], such as a tab of a navigation bar, where the + * backstack only ever contains top-level routes. + * + * The first route of the backstack (the start route) is always retained beneath the given + * [route], so navigating back from any other top-level route returns to the start route. + */ + fun navigateToTopLevelRoute(route: T) + + /** + * Atomically replaces the entire contents of the backstack with the given [routes]. + */ + fun replaceAll(routes: List) + + /** + * Atomically replaces the entire contents of the backstack with the given [routes]. + */ + fun replaceAll(vararg routes: T) + + /** + * Atomically replaces the top of the backstack with the given [route]. + */ + fun replaceLast(route: T) +} diff --git a/ui/src/main/kotlin/com/bitwarden/ui/platform/navigation/NavigationControllerImpl.kt b/ui/src/main/kotlin/com/bitwarden/ui/platform/navigation/NavigationControllerImpl.kt new file mode 100644 index 0000000000..f02147a7c6 --- /dev/null +++ b/ui/src/main/kotlin/com/bitwarden/ui/platform/navigation/NavigationControllerImpl.kt @@ -0,0 +1,96 @@ +package com.bitwarden.ui.platform.navigation + +import androidx.compose.runtime.snapshotFlow +import androidx.compose.runtime.snapshots.Snapshot +import androidx.navigation3.runtime.NavBackStack +import androidx.navigation3.runtime.NavKey +import kotlinx.coroutines.flow.Flow + +/** + * The default implementation of the [NavigationController], which manages the given [backStack]. + */ +internal class NavigationControllerImpl( + override val backStack: NavBackStack, +) : NavigationController { + override val backStackSnapshot: Flow> get() = snapshotFlow { backStack.toList() } + + override fun navigate(route: T) { + backStack.add(element = route) + } + + override fun navigateBack(): Boolean = if (backStack.size <= 1) { + false + } else { + backStack.removeAt(index = backStack.lastIndex) + true + } + + override fun addSingleTop(route: T) { + if (backStack.lastOrNull()?.let { it::class } == route::class) return + backStack.add(element = route) + } + + override fun popUpTo( + inclusive: Boolean, + predicate: (T) -> Boolean, + ): Boolean { + val index = backStack.indexOfLast(predicate = predicate) + return if (index == -1) { + false + } else { + val newSize = (if (inclusive) index else index + 1).coerceAtLeast(minimumValue = 1) + if (newSize < backStack.size) backStack.subList(newSize, backStack.size).clear() + true + } + } + + override fun popToRoot() { + backStack.subList(fromIndex = 1, toIndex = backStack.size).clear() + } + + override fun navigatePoppingUpTo( + route: T, + inclusive: Boolean, + predicate: (T) -> Boolean, + ) { + val index = backStack.indexOfLast(predicate = predicate) + Snapshot.withMutableSnapshot { + if (index != -1) { + backStack + .subList( + fromIndex = if (inclusive) index else index + 1, + toIndex = backStack.size, + ) + .clear() + } + backStack.add(element = route) + } + } + + override fun navigateToTopLevelRoute(route: T) { + val startRoute = backStack.first() + if (route == startRoute) { + this.replaceAll(routes = listOf(startRoute)) + } else { + this.replaceAll(routes = listOf(startRoute, route)) + } + } + + override fun replaceAll(routes: List) { + require(value = routes.isNotEmpty()) { + "A back stack cannot be replaced with an empty list." + } + Snapshot.withMutableSnapshot { + backStack.clear() + backStack.addAll(elements = routes) + } + } + + override fun replaceAll(vararg routes: T) { + this.replaceAll(routes = routes.toList()) + } + + override fun replaceLast(route: T) { + backStack[backStack.lastIndex] = route + } +} diff --git a/ui/src/main/kotlin/com/bitwarden/ui/platform/navigation/components/BitwardenNavDisplay.kt b/ui/src/main/kotlin/com/bitwarden/ui/platform/navigation/components/BitwardenNavDisplay.kt new file mode 100644 index 0000000000..93fb4ee666 --- /dev/null +++ b/ui/src/main/kotlin/com/bitwarden/ui/platform/navigation/components/BitwardenNavDisplay.kt @@ -0,0 +1,38 @@ +package com.bitwarden.ui.platform.navigation.components + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.navigation3.runtime.NavEntry +import androidx.navigation3.runtime.NavKey +import androidx.navigation3.ui.NavDisplay +import com.bitwarden.ui.platform.navigation.NavigationController +import com.bitwarden.ui.platform.navigation.util.rememberBitwardenNavEntryDecorators +import com.bitwarden.ui.platform.theme.ContentTransformProvider +import com.bitwarden.ui.platform.theme.NavDisplayTransitions +import com.bitwarden.ui.platform.theme.PredictiveContentTransformProvider + +/** + * A [NavDisplay] that displays the back stack of the given [navigationController], which is + * popped whenever the user navigates back. + */ +@Composable +fun BitwardenNavDisplay( + navigationController: NavigationController, + entryProvider: (key: T) -> NavEntry, + modifier: Modifier = Modifier, + transitionSpec: ContentTransformProvider = NavDisplayTransitions.root(), + popTransitionSpec: ContentTransformProvider = transitionSpec, + predictivePopTransitionSpec: PredictiveContentTransformProvider = + NavDisplayTransitions.predictive(provider = popTransitionSpec), +) { + NavDisplay( + backStack = navigationController.backStack, + modifier = modifier, + onBack = navigationController::navigateBack, + entryDecorators = rememberBitwardenNavEntryDecorators(), + transitionSpec = transitionSpec, + popTransitionSpec = popTransitionSpec, + predictivePopTransitionSpec = predictivePopTransitionSpec, + entryProvider = entryProvider, + ) +} diff --git a/ui/src/main/kotlin/com/bitwarden/ui/platform/navigation/components/BitwardenTabNavDisplay.kt b/ui/src/main/kotlin/com/bitwarden/ui/platform/navigation/components/BitwardenTabNavDisplay.kt new file mode 100644 index 0000000000..eb7eb845be --- /dev/null +++ b/ui/src/main/kotlin/com/bitwarden/ui/platform/navigation/components/BitwardenTabNavDisplay.kt @@ -0,0 +1,42 @@ +package com.bitwarden.ui.platform.navigation.components + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.navigation3.runtime.NavEntry +import androidx.navigation3.runtime.NavKey +import androidx.navigation3.ui.NavDisplay +import com.bitwarden.ui.platform.navigation.BottomNavigationController +import com.bitwarden.ui.platform.theme.ContentTransformProvider +import com.bitwarden.ui.platform.theme.NavDisplayTransitions +import com.bitwarden.ui.platform.theme.PredictiveContentTransformProvider + +/** + * A [NavDisplay] that displays the back stacks of the tabs managed by the given + * [bottomNavigationController], following the "multiple back stacks" pattern. + * + * The [tabEntries] of every tab currently in the back stack of the + * [BottomNavigationController.root] are displayed together, so the back stack of the start tab is + * always displayed beneath the back stack of the selected tab. The + * [BottomNavigationController.navigateBack] is invoked whenever the user navigates back. + */ +@Composable +fun BitwardenTabNavDisplay( + bottomNavigationController: BottomNavigationController, + tabEntries: Map>>, + modifier: Modifier = Modifier, + transitionSpec: ContentTransformProvider = NavDisplayTransitions.fade(), + popTransitionSpec: ContentTransformProvider = transitionSpec, + predictivePopTransitionSpec: PredictiveContentTransformProvider = + NavDisplayTransitions.predictive(provider = popTransitionSpec), +) { + NavDisplay( + entries = bottomNavigationController.root.backStack.flatMap { tab -> + tabEntries.getValue(key = tab) + }, + modifier = modifier, + onBack = bottomNavigationController::navigateBack, + transitionSpec = transitionSpec, + popTransitionSpec = popTransitionSpec, + predictivePopTransitionSpec = predictivePopTransitionSpec, + ) +} diff --git a/ui/src/main/kotlin/com/bitwarden/ui/platform/navigation/util/EntryProviderScopeExtensions.kt b/ui/src/main/kotlin/com/bitwarden/ui/platform/navigation/util/EntryProviderScopeExtensions.kt new file mode 100644 index 0000000000..ef9860d392 --- /dev/null +++ b/ui/src/main/kotlin/com/bitwarden/ui/platform/navigation/util/EntryProviderScopeExtensions.kt @@ -0,0 +1,85 @@ +package com.bitwarden.ui.platform.navigation.util + +import androidx.compose.runtime.Composable +import androidx.navigation3.runtime.EntryProviderScope +import androidx.navigation3.runtime.NavKey +import com.bitwarden.ui.platform.theme.EntryTransitions +import com.bitwarden.ui.platform.theme.RootTransition + +/** + * Adds the [Composable] [content] entry to the [EntryProviderScope] using a + * [EntryTransitions.push] transition. + */ +inline fun EntryProviderScope.pushEntry( + noinline clazzContentKey: (key: T) -> Any = { defaultContentKey(key = it) }, + noinline content: @Composable (T) -> Unit, +) { + entry( + clazzContentKey = clazzContentKey, + metadata = EntryTransitions.push, + content = content, + ) +} + +/** + * Adds the [Composable] [content] entry to the [EntryProviderScope] using a + * [EntryTransitions.slideUp] transition. + */ +inline fun EntryProviderScope.slideUpEntry( + noinline clazzContentKey: (key: T) -> Any = { defaultContentKey(key = it) }, + noinline content: @Composable (T) -> Unit, +) { + entry( + clazzContentKey = clazzContentKey, + metadata = EntryTransitions.slideUp, + content = content, + ) +} + +/** + * Adds the [Composable] [content] entry to the [EntryProviderScope] using a + * [EntryTransitions.stay] transition. + */ +inline fun EntryProviderScope.stayEntry( + noinline clazzContentKey: (key: T) -> Any = { defaultContentKey(key = it) }, + noinline content: @Composable (T) -> Unit, +) { + entry( + clazzContentKey = clazzContentKey, + metadata = EntryTransitions.stay, + content = content, + ) +} + +/** + * Adds the [Composable] [content] entry to the [EntryProviderScope] using a + * [EntryTransitions.rootPush] transition. This should be used for the root of a flow. + */ +inline fun EntryProviderScope.rootPushEntry( + noinline clazzContentKey: (key: T) -> Any = { defaultContentKey(key = it) }, + noinline content: @Composable (T) -> Unit, +) { + entry( + clazzContentKey = clazzContentKey, + metadata = EntryTransitions.rootPush, + content = content, + ) +} + +/** + * Adds the [Composable] [content] entry to the [EntryProviderScope] using a + * [EntryTransitions.rootTransition] with the specified [RootTransition] type. + */ +inline fun EntryProviderScope.rootTransitionEntry( + rootTransition: RootTransition, + noinline clazzContentKey: (key: T) -> Any = { defaultContentKey(key = it) }, + noinline content: @Composable (T) -> Unit, +) { + entry( + clazzContentKey = clazzContentKey, + metadata = EntryTransitions.rootTransition(rootTransition = rootTransition), + content = content, + ) +} + +private fun defaultContentKey(key: Any): String = "$key:${key::class}" diff --git a/ui/src/main/kotlin/com/bitwarden/ui/platform/navigation/util/NavEntryUtil.kt b/ui/src/main/kotlin/com/bitwarden/ui/platform/navigation/util/NavEntryUtil.kt new file mode 100644 index 0000000000..98fb943453 --- /dev/null +++ b/ui/src/main/kotlin/com/bitwarden/ui/platform/navigation/util/NavEntryUtil.kt @@ -0,0 +1,24 @@ +package com.bitwarden.ui.platform.navigation.util + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator +import androidx.navigation3.runtime.NavEntry +import androidx.navigation3.runtime.NavEntryDecorator +import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator + +/** + * Remembers the standard [NavEntryDecorator]s used by every navigation display in the app. + * + * These ensure that each [NavEntry] has its own saveable state as well as its own `ViewModelStore`, + * so that ViewModels (and their `SavedStateHandle`s) are scoped to the entry and cleared when the + * entry is popped off of the back stack. + */ +@Composable +fun rememberBitwardenNavEntryDecorators(): List> { + val saveableStateHolderDecorator = rememberSaveableStateHolderNavEntryDecorator() + val viewModelStoreDecorator = rememberViewModelStoreNavEntryDecorator() + return remember(key1 = saveableStateHolderDecorator, key2 = viewModelStoreDecorator) { + listOf(saveableStateHolderDecorator, viewModelStoreDecorator) + } +} diff --git a/ui/src/main/kotlin/com/bitwarden/ui/platform/navigation/util/NavigationControllerUtil.kt b/ui/src/main/kotlin/com/bitwarden/ui/platform/navigation/util/NavigationControllerUtil.kt new file mode 100644 index 0000000000..406aa09f9e --- /dev/null +++ b/ui/src/main/kotlin/com/bitwarden/ui/platform/navigation/util/NavigationControllerUtil.kt @@ -0,0 +1,115 @@ +package com.bitwarden.ui.platform.navigation.util + +import androidx.annotation.VisibleForTesting +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSerializable +import androidx.compose.runtime.snapshotFlow +import androidx.navigation3.runtime.NavBackStack +import androidx.navigation3.runtime.NavEntry +import androidx.navigation3.runtime.NavKey +import androidx.navigation3.runtime.rememberDecoratedNavEntries +import androidx.navigation3.runtime.serialization.NavBackStackSerializer +import androidx.navigation3.runtime.serialization.NavKeySerializer +import com.bitwarden.ui.platform.navigation.NavigationController +import com.bitwarden.ui.platform.navigation.NavigationControllerImpl +import timber.log.Timber + +/** + * Pops entries off of the back stack until the most recent entry of type [Route] is on top. If + * [inclusive] is `true`, the entry of type [Route] is removed as well (unless it is the last + * remaining entry). + */ +inline fun NavigationController.popUpTo( + inclusive: Boolean = false, +): Boolean = this.popUpTo(inclusive = inclusive) { it is Route } + +/** + * Remembers the decorated [NavEntry]s for the back stack of this [NavigationController], which + * can then be displayed by a `NavDisplay`. + * + * The state of each entry (including its saveable state and ViewModels) is owned by the + * composition this is called from, rather than by the `NavDisplay` itself. This allows the state + * to be retained while the `NavDisplay` is not being displayed, such as when it is hosted by an + * unselected tab, and it is cleared once the entry is popped off of the backstack. + */ +@Composable +fun NavigationController.rememberNavEntries( + entryProvider: (key: Route) -> NavEntry, +): List> = rememberDecoratedNavEntries( + backStack = this.backStack, + entryDecorators = rememberBitwardenNavEntryDecorators(), + entryProvider = entryProvider, +) + +/** + * Remembers the decorated [NavEntry]s for the back stack of this [NavigationController] (see + * [rememberNavEntries]) as [NavEntry]s of any [NavKey]. + * + * This allows the entries of multiple back stacks, each belonging to a different navigation graph, + * to be displayed together by a single `NavDisplay`, such as when following the "multiple back + * stacks" pattern for the tabs of a navigation bar. The entries of each back stack are still + * decorated independently, so the state of each back stack is retained while it is not displayed. + */ +@Composable +fun NavigationController.rememberNavKeyEntries( + entryProvider: (key: Route) -> NavEntry, +): List> { + val keys = this.backStack.toList() + val entries = this.rememberNavEntries(entryProvider = entryProvider) + return keys.zip(other = entries) { key, entry -> + NavEntry( + key = key, + contentKey = entry.contentKey, + metadata = entry.metadata, + content = { entry.Content() }, + ) + } +} + +/** + * Creates a [NavigationController] whose backstack initially contains the given [routes]. + * + * The backstack of the returned controller is not saved. Within a composition, use + * [rememberNavigationController] instead. + */ +@VisibleForTesting +fun navigationControllerOf( + vararg routes: T, +): NavigationController = NavigationControllerImpl( + backStack = NavBackStack(elements = routes), +) + +/** + * Remember a [NavigationController] across compositions and process death. + */ +@Composable +inline fun rememberNavigationController( + vararg routes: T, +): NavigationController { + val backStack = rememberSerializable( + serializer = NavBackStackSerializer(elementSerializer = NavKeySerializer()), + ) { + NavBackStack(elements = routes) + } + LaunchedEffect(key1 = backStack) { + snapshotFlow { backStack.toList() }.collect { + val currentStack = it.joinToString(separator = " -> ") { entry -> + entry::class.simpleName ?: "Unknown" + } + Timber.d(message = "${T::class.simpleName}: $currentStack") + } + } + return rememberNavigationController(backStack = backStack) +} + +/** + * Remember a [NavigationController] across compositions and process death. + */ +@Composable +fun rememberNavigationController( + backStack: NavBackStack, +): NavigationController = remember(key1 = backStack) { + NavigationControllerImpl(backStack = backStack) +} diff --git a/ui/src/main/kotlin/com/bitwarden/ui/platform/theme/EntryTransitions.kt b/ui/src/main/kotlin/com/bitwarden/ui/platform/theme/EntryTransitions.kt new file mode 100644 index 0000000000..fe790afd49 --- /dev/null +++ b/ui/src/main/kotlin/com/bitwarden/ui/platform/theme/EntryTransitions.kt @@ -0,0 +1,247 @@ +package com.bitwarden.ui.platform.theme + +import androidx.compose.animation.AnimatedContentTransitionScope +import androidx.compose.animation.ContentTransform +import androidx.compose.animation.togetherWith +import androidx.navigation3.runtime.NavEntry +import androidx.navigation3.runtime.NavMetadataKey +import androidx.navigation3.runtime.contains +import androidx.navigation3.runtime.get +import androidx.navigation3.runtime.metadata +import androidx.navigation3.scene.Scene +import androidx.navigation3.ui.NavDisplay + +typealias ContentTransformProvider = + AnimatedContentTransitionScope>.() -> ContentTransform + +typealias PredictiveContentTransformProvider = + AnimatedContentTransitionScope>.( + swipeEdge: Int, + ) -> ContentTransform + +/** + * Reusable [NavEntry.metadata] that defines how an entry animates as it is pushed onto and popped + * off of a backstack. + */ +object EntryTransitions { + /** + * Slides the entry up from the bottom of the screen when it is pushed and back down when it is + * popped. The entry beneath it stays in place. + */ + val slideUp: Map = entryTransitions( + enter = TransitionProviders.Enter.slideUp, + popExit = TransitionProviders.Exit.slideDown, + ) + + /** + * Keeps the entry in place when it is pushed or popped. + */ + val stay: Map = entryTransitions( + enter = TransitionProviders.Enter.stay, + popExit = TransitionProviders.Exit.stay, + ) + + /** + * Pushes the entry in from the end of the screen when it is pushed and back towards the end + * when it is popped. + * + * This is suitable for screens deeper within a hierarchy that uses push transitions; the root + * screen of such a hierarchy should use [rootPush]. + */ + val push: Map = entryTransitions( + enter = TransitionProviders.Enter.pushToStart, + popExit = TransitionProviders.Exit.pushToEnd, + ) + + /** + * Marks the entry as the root of a flow that uses [push] transitions, such as the root screen + * of a tab. The root is pushed towards the start of the screen when another entry is pushed on + * top of it and is pushed back in from the start when that entry is popped. + * + * Because the entry starts a new flow, navigating to or from the root itself uses the default + * transitions of the hosting [NavDisplay]. + */ + val rootPush: Map = metadata { + put(FlowRootKey, true) + put(CoveredExitTransitionKey, TransitionProviders.Exit.pushToStart) + put(RevealedEnterTransitionKey, TransitionProviders.Enter.pushToEnd) + } + entryTransitions( + enter = TransitionProviders.Enter.stay, + popExit = TransitionProviders.Exit.fadeOut, + ) + + /** + * Specifies the [RootTransition] used when the back stack beginning with this entry replaces + * (or is replaced by) another back stack within a [NavDisplay] using + * [NavDisplayTransitions.root]. + * + * This only has an effect when applied to the first entry of a back stack. + */ + fun rootTransition(rootTransition: RootTransition): Map = metadata { + put(RootTransitionKey, rootTransition) + } +} + +/** + * Describes how a back stack animates when it replaces, or is replaced by, another back stack + * within a [NavDisplay] using [NavDisplayTransitions.root]. + */ +enum class RootTransition { + /** + * Fades the back stack in and out. This is the default behavior. + */ + FADE, + + /** + * The back stack appears without any animation and disappears without any animation. + */ + NONE, + + /** + * Pushes the back stack in from the end of the screen. When replacing another back stack using + * this transition, the replaced back stack stays in place. + */ + PUSH, + + /** + * Slides the back stack up from the bottom of the screen while the replaced back stack stays + * in place, and slides it back down when it is replaced. + */ + SLIDE_UP, +} + +/** + * Reusable default transitions for a [NavDisplay]. These are used whenever the transitioning + * entries do not provide their own [EntryTransitions]. + */ +object NavDisplayTransitions { + /** + * Fades between scenes. + */ + fun fade(): ContentTransformProvider = { + TransitionProviders.Enter.fadeIn(this) togetherWith TransitionProviders.Exit.fadeOut(this) + } + + /** + * Transitions for a "root" [NavDisplay] whose back stack is entirely replaced as the user moves + * between the high level flows of the app. Each back stack declares how it animates using + * [EntryTransitions.rootTransition] on its first entry. + * + * Entries without their own [EntryTransitions] do not animate when navigating within a flow. + */ + fun root(): ContentTransformProvider = { + if (isSameFlowNavigation) { + TransitionProviders.Enter.none(this) togetherWith TransitionProviders.Exit.none(this) + } else { + val initialTransition = initialState.rootTransition + val targetTransition = targetState.rootTransition + val enter = when { + targetTransition == RootTransition.SLIDE_UP -> TransitionProviders.Enter.slideUp + targetTransition == RootTransition.PUSH -> TransitionProviders.Enter.pushToStart + initialTransition == RootTransition.NONE -> TransitionProviders.Enter.none + else -> TransitionProviders.Enter.fadeIn + } + val exit = when { + initialTransition == RootTransition.NONE -> TransitionProviders.Exit.none + initialTransition == RootTransition.SLIDE_UP -> TransitionProviders.Exit.slideDown + targetTransition == RootTransition.SLIDE_UP -> TransitionProviders.Exit.stay + initialTransition == RootTransition.PUSH && + targetTransition == RootTransition.PUSH -> TransitionProviders.Exit.stay + + else -> TransitionProviders.Exit.fadeOut + } + enter(this) togetherWith exit(this) + } + } + + /** + * Converts the given [ContentTransformProvider] into a [PredictiveContentTransformProvider] + * that uses the same [ContentTransform] regardless of the swipe edge. + */ + fun predictive( + provider: ContentTransformProvider, + ): PredictiveContentTransformProvider = { _ -> provider(this) } +} + +/** + * Indicates whether the navigation is occurring within the same flow, which is a push or pop on the + * same back stack beneath the same root entry (see [EntryTransitions.rootPush]). This is `false` + * when the back stack has been replaced or when navigating to or from the root of a flow. + */ +val AnimatedContentTransitionScope>.isSameFlowNavigation: Boolean + get() = initialState.flowRootContentKey == targetState.flowRootContentKey + +/** + * Metadata key indicating that an entry is the root of a flow. + */ +private object FlowRootKey : NavMetadataKey + +/** + * Metadata key for the exit transition used by an entry when another entry is pushed on top of it. + */ +private object CoveredExitTransitionKey : NavMetadataKey + +/** + * Metadata key for the enter transition used by an entry when the entry on top of it is popped. + */ +private object RevealedEnterTransitionKey : NavMetadataKey + +/** + * Metadata key for the [RootTransition] of the back stack beginning with an entry. + */ +private object RootTransitionKey : NavMetadataKey + +/** + * Creates the metadata for the transitions of an entry that is pushed with the given [enter] + * transition and popped with the given [popExit] transition. + */ +private fun entryTransitions( + enter: EnterTransitionProvider, + popExit: ExitTransitionProvider, +): Map { + val popTransitionSpec: AnimatedContentTransitionScope>.() -> ContentTransform? = { + if (isSameFlowNavigation) { + targetState.revealedEnterTransition(this) togetherWith popExit(this) + } else { + null + } + } + return NavDisplay.transitionSpec { + if (isSameFlowNavigation) { + enter(this) togetherWith initialState.coveredExitTransition(this) + } else { + null + } + } + NavDisplay.popTransitionSpec(popTransitionSpec) + + NavDisplay.predictivePopTransitionSpec { _ -> popTransitionSpec(this) } +} + +/** + * The exit transition used by the top entry of this scene when another entry is pushed on top. + */ +private val Scene<*>.coveredExitTransition: ExitTransitionProvider + get() = metadata[CoveredExitTransitionKey] ?: TransitionProviders.Exit.stay + +/** + * The enter transition used by the top entry of this scene when the entry on top is popped. + */ +private val Scene<*>.revealedEnterTransition: EnterTransitionProvider + get() = metadata[RevealedEnterTransitionKey] ?: TransitionProviders.Enter.stay + +/** + * The [RootTransition] declared by the first entry of the back stack represented by this scene. + */ +private val Scene<*>.rootTransition: RootTransition + get() = (previousEntries.firstOrNull() ?: entries.first()) + .metadata[RootTransitionKey] + ?: RootTransition.FADE + +/** + * The content key of the entry that roots the flow currently displayed by this scene. This is the + * last entry marked with [FlowRootKey] or, if there is none, the first entry of the back stack. + */ +private val Scene<*>.flowRootContentKey: Any + get() { + val backStack = previousEntries + entries + return (backStack.lastOrNull { FlowRootKey in it.metadata } ?: backStack.first()).contentKey + } diff --git a/ui/src/main/kotlin/com/bitwarden/ui/platform/theme/Transition.kt b/ui/src/main/kotlin/com/bitwarden/ui/platform/theme/Transition.kt index d74863e124..51913d9dcf 100644 --- a/ui/src/main/kotlin/com/bitwarden/ui/platform/theme/Transition.kt +++ b/ui/src/main/kotlin/com/bitwarden/ui/platform/theme/Transition.kt @@ -7,19 +7,12 @@ import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.navigation.NavBackStackEntry -import androidx.navigation.compose.NavHost typealias EnterTransitionProvider = - (@JvmSuppressWildcards AnimatedContentTransitionScope.() -> EnterTransition?) + @JvmSuppressWildcards AnimatedContentTransitionScope<*>.() -> EnterTransition typealias ExitTransitionProvider = - (@JvmSuppressWildcards AnimatedContentTransitionScope.() -> ExitTransition?) - -typealias NonNullEnterTransitionProvider = - (@JvmSuppressWildcards AnimatedContentTransitionScope.() -> EnterTransition) - -typealias NonNullExitTransitionProvider = - (@JvmSuppressWildcards AnimatedContentTransitionScope.() -> ExitTransition) + @JvmSuppressWildcards AnimatedContentTransitionScope<*>.() -> ExitTransition /** * The default transition time (in milliseconds) for all fade transitions in the @@ -46,25 +39,15 @@ const val DEFAULT_PUSH_TRANSITION_TIME_MS: Int = 350 * This should be at least as large as any other transition that might also be happening during a * navigation. */ -val DEFAULT_STAY_TRANSITION_TIME_MS: Int = - maxOf( - DEFAULT_FADE_TRANSITION_TIME_MS, - DEFAULT_SLIDE_TRANSITION_TIME_MS, - DEFAULT_PUSH_TRANSITION_TIME_MS, - ) +val DEFAULT_STAY_TRANSITION_TIME_MS: Int = maxOf( + DEFAULT_FADE_TRANSITION_TIME_MS, + DEFAULT_SLIDE_TRANSITION_TIME_MS, + DEFAULT_PUSH_TRANSITION_TIME_MS, +) /** - * Checks if the parent of the destination before and after the navigation is the same. This is - * useful to ignore certain enter/exit transitions when navigating between distinct, nested flows. - */ -val AnimatedContentTransitionScope.isSameGraphNavigation: Boolean - get() = initialState.destination.parent == targetState.destination.parent - -/** - * Contains standard "transition providers" that may be used to specify the [EnterTransition] and - * [ExitTransition] used when building a typical composable destination. These may return `null` - * values in order to allow transitions between nested navigation graphs to be specified by - * components higher up in the graph. + * Contains the standard, reusable "transition providers" that are used to build the + * [EntryTransitions] for navigation entries and the default transitions of a navigation display. */ object TransitionProviders { /** @@ -73,160 +56,22 @@ object TransitionProviders { object Enter { /** * Fades the new screen in. - * - * Note that this represents a `null` transition when navigating between different nested - * navigation graphs. */ val fadeIn: EnterTransitionProvider = { - RootTransitionProviders - .Enter - .fadeIn(this) - .takeIf { isSameGraphNavigation } - } - - /** - * Slides the new screen in from the start (left) of the screen towards the end (right). - */ - val pushToEnd: EnterTransitionProvider = { - RootTransitionProviders - .Enter - .pushToEnd(this) - .takeIf { isSameGraphNavigation } - } - - /** - * Slides the new screen in from the end (right) of the screen towards the start (left). - */ - val pushToStart: EnterTransitionProvider = { - RootTransitionProviders - .Enter - .pushToStart(this) - .takeIf { isSameGraphNavigation } - } - - /** - * Slides the new screen in from the bottom of the screen. - * - * Note that this represents a `null` transition when navigating between different nested - * navigation graphs. - */ - val slideUp: EnterTransitionProvider = { - RootTransitionProviders - .Enter - .slideUp(this) - .takeIf { isSameGraphNavigation } - } - - /** - * A "no-op" transition: this changes nothing about the screen but "lasts" as long as - * other standard transitions in order to leave the screen in place such that it does not - * immediately appear while the other screen transitions away. - * - * Note that this represents a `null` transition when navigating between different nested - * navigation graphs. - */ - val stay: EnterTransitionProvider = { - RootTransitionProviders - .Enter - .stay(this) - .takeIf { isSameGraphNavigation } - } - } - - /** - * The standard set of "exit" transition providers. - */ - object Exit { - /** - * Fades the current screen out. - * - * Note that this represents a `null` transition when navigating between different nested - * navigation graphs. - */ - val fadeOut: ExitTransitionProvider = { - RootTransitionProviders - .Exit - .fadeOut(this) - .takeIf { isSameGraphNavigation } - } - - /** - * Slides the current screen out to the start (left) of the screen towards the end (right). - */ - val pushToStart: ExitTransitionProvider = { - RootTransitionProviders - .Exit - .pushToStart(this) - .takeIf { isSameGraphNavigation } - } - - /** - * Slides the current screen out to the end (right) of the screen towards the start (left). - */ - val pushToEnd: ExitTransitionProvider = { - RootTransitionProviders - .Exit - .pushToEnd(this) - .takeIf { isSameGraphNavigation } - } - - /** - * Slides the current screen down to the bottom of the screen. - * - * Note that this represents a `null` transition when navigating between different nested - * navigation graphs. - */ - val slideDown: ExitTransitionProvider = { - RootTransitionProviders - .Exit - .slideDown(this) - .takeIf { isSameGraphNavigation } - } - - /** - * A "no-op" transition: this changes nothing about the screen but "lasts" as long as - * other standard transitions in order to leave the screen in place such that it does not - * immediately disappear while the other screen transitions into place. - * - * Note that this represents a `null` transition when navigating between different nested - * navigation graphs. - */ - val stay: ExitTransitionProvider = { - RootTransitionProviders - .Exit - .stay(this) - .takeIf { isSameGraphNavigation } - } - } -} - -/** - * Contains standard "transition providers" that may be used to specify the [EnterTransition] and - * [ExitTransition] used when building a root [NavHost], which requires a non-null value. - */ -object RootTransitionProviders { - /** - * The standard set of "enter" transition providers. - */ - object Enter { - /** - * Fades the new screen in. - */ - val fadeIn: NonNullEnterTransitionProvider = { - fadeIn(tween(DEFAULT_FADE_TRANSITION_TIME_MS)) + fadeIn(animationSpec = tween(durationMillis = DEFAULT_FADE_TRANSITION_TIME_MS)) } /** * There is no transition for the entering screen. */ - val none: NonNullEnterTransitionProvider = { + val none: EnterTransitionProvider = { EnterTransition.None } /** * Slides the new screen in from the start (left) of the screen towards the end (right). */ - val pushToEnd: NonNullEnterTransitionProvider = { + val pushToEnd: EnterTransitionProvider = { val totalTransitionDurationMs = DEFAULT_PUSH_TRANSITION_TIME_MS slideIntoContainer( towards = AnimatedContentTransitionScope.SlideDirection.End, @@ -243,7 +88,7 @@ object RootTransitionProviders { /** * Slides the new screen in from the end (right) of the screen towards the start (left). */ - val pushToStart: NonNullEnterTransitionProvider = { + val pushToStart: EnterTransitionProvider = { val totalTransitionDurationMs = DEFAULT_PUSH_TRANSITION_TIME_MS slideIntoContainer( towards = AnimatedContentTransitionScope.SlideDirection.Start, @@ -260,10 +105,10 @@ object RootTransitionProviders { /** * Slides the new screen in from the bottom of the screen. */ - val slideUp: NonNullEnterTransitionProvider = { + val slideUp: EnterTransitionProvider = { slideIntoContainer( towards = AnimatedContentTransitionScope.SlideDirection.Up, - animationSpec = tween(DEFAULT_SLIDE_TRANSITION_TIME_MS), + animationSpec = tween(durationMillis = DEFAULT_SLIDE_TRANSITION_TIME_MS), ) } @@ -272,9 +117,9 @@ object RootTransitionProviders { * other standard transitions in order to leave the screen in place such that it does not * immediately appear while the other screen transitions away. */ - val stay: NonNullEnterTransitionProvider = { + val stay: EnterTransitionProvider = { fadeIn( - animationSpec = tween(DEFAULT_STAY_TRANSITION_TIME_MS), + animationSpec = tween(durationMillis = DEFAULT_STAY_TRANSITION_TIME_MS), initialAlpha = 1f, ) } @@ -287,18 +132,18 @@ object RootTransitionProviders { /** * Fades the current screen out. */ - val fadeOut: NonNullExitTransitionProvider = { - fadeOut(tween(DEFAULT_FADE_TRANSITION_TIME_MS)) + val fadeOut: ExitTransitionProvider = { + fadeOut(animationSpec = tween(durationMillis = DEFAULT_FADE_TRANSITION_TIME_MS)) } /** * There is no transition for the exiting screen. * - * Unlike the [RootTransitionProviders.Exit.stay] transition, this will immediately remove + * Unlike the [TransitionProviders.Exit.stay] transition, this will immediately remove * the outgoing screen even if there is an ongoing enter transition happening for the new * screen. */ - val none: NonNullExitTransitionProvider = { + val none: ExitTransitionProvider = { ExitTransition.None } @@ -306,7 +151,7 @@ object RootTransitionProviders { * Slides the current screen out to the start (left) of the screen towards the end (right). */ @Suppress("MagicNumber") - val pushToStart: NonNullExitTransitionProvider = { + val pushToStart: ExitTransitionProvider = { val totalTransitionDurationMs = DEFAULT_PUSH_TRANSITION_TIME_MS val delayMs = totalTransitionDurationMs / 7 val slideWithoutDelayMs = totalTransitionDurationMs - delayMs @@ -329,7 +174,7 @@ object RootTransitionProviders { * Slides the current screen out to the end (right) of the screen towards the start (left). */ @Suppress("MagicNumber") - val pushToEnd: NonNullExitTransitionProvider = { + val pushToEnd: ExitTransitionProvider = { val totalTransitionDurationMs = DEFAULT_PUSH_TRANSITION_TIME_MS val delayMs = totalTransitionDurationMs / 7 val slideWithoutDelayMs = totalTransitionDurationMs - delayMs @@ -351,10 +196,10 @@ object RootTransitionProviders { /** * Slides the current screen down to the bottom of the screen. */ - val slideDown: NonNullExitTransitionProvider = { + val slideDown: ExitTransitionProvider = { slideOutOfContainer( towards = AnimatedContentTransitionScope.SlideDirection.Down, - animationSpec = tween(DEFAULT_SLIDE_TRANSITION_TIME_MS), + animationSpec = tween(durationMillis = DEFAULT_SLIDE_TRANSITION_TIME_MS), ) } @@ -363,11 +208,59 @@ object RootTransitionProviders { * other standard transitions in order to leave the screen in place such that it does not * immediately disappear while the other screen transitions into place. */ - val stay: NonNullExitTransitionProvider = { + val stay: ExitTransitionProvider = { fadeOut( - animationSpec = tween(DEFAULT_STAY_TRANSITION_TIME_MS), + animationSpec = tween(durationMillis = DEFAULT_STAY_TRANSITION_TIME_MS), targetAlpha = 0.99f, ) } } } + +/** + * Legacy non-null "enter" transition provider used by Navigation Compose `NavHost`s. + * + * TODO: Remove once all modules have been migrated to Navigation3 (PM-45372). + */ +typealias NonNullEnterTransitionProvider = + (@JvmSuppressWildcards AnimatedContentTransitionScope.() -> EnterTransition) + +/** + * Legacy non-null "exit" transition provider used by Navigation Compose `NavHost`s. + * + * TODO: Remove once all modules have been migrated to Navigation3 (PM-45372). + */ +typealias NonNullExitTransitionProvider = + (@JvmSuppressWildcards AnimatedContentTransitionScope.() -> ExitTransition) + +/** + * Legacy transition providers used by root Navigation Compose `NavHost`s, which delegate to the + * standard [TransitionProviders]. + * + * TODO: Remove once all modules have been migrated to Navigation3 (PM-45372). + */ +object RootTransitionProviders { + /** + * The standard set of "enter" transition providers. + */ + object Enter { + val fadeIn: EnterTransitionProvider = TransitionProviders.Enter.fadeIn + val none: EnterTransitionProvider = TransitionProviders.Enter.none + val pushToEnd: EnterTransitionProvider = TransitionProviders.Enter.pushToEnd + val pushToStart: EnterTransitionProvider = TransitionProviders.Enter.pushToStart + val slideUp: EnterTransitionProvider = TransitionProviders.Enter.slideUp + val stay: EnterTransitionProvider = TransitionProviders.Enter.stay + } + + /** + * The standard set of "exit" transition providers. + */ + object Exit { + val fadeOut: ExitTransitionProvider = TransitionProviders.Exit.fadeOut + val none: ExitTransitionProvider = TransitionProviders.Exit.none + val pushToEnd: ExitTransitionProvider = TransitionProviders.Exit.pushToEnd + val pushToStart: ExitTransitionProvider = TransitionProviders.Exit.pushToStart + val slideDown: ExitTransitionProvider = TransitionProviders.Exit.slideDown + val stay: ExitTransitionProvider = TransitionProviders.Exit.stay + } +} diff --git a/ui/src/test/kotlin/com/bitwarden/ui/platform/navigation/NavigationControllerTest.kt b/ui/src/test/kotlin/com/bitwarden/ui/platform/navigation/NavigationControllerTest.kt new file mode 100644 index 0000000000..2428d1f72f --- /dev/null +++ b/ui/src/test/kotlin/com/bitwarden/ui/platform/navigation/NavigationControllerTest.kt @@ -0,0 +1,327 @@ +package com.bitwarden.ui.platform.navigation + +import androidx.compose.runtime.snapshots.Snapshot +import androidx.navigation3.runtime.NavKey +import app.cash.turbine.test +import com.bitwarden.ui.platform.navigation.util.navigationControllerOf +import com.bitwarden.ui.platform.navigation.util.popUpTo +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows + +class NavigationControllerTest { + + @Test + fun `navigate should add the route to the top of the back stack`() { + val navigationController = navigationControllerOf(TestGraph.RouteA) + + navigationController.navigate(route = TestGraph.RouteB) + + assertEquals(listOf(TestGraph.RouteA, TestGraph.RouteB), navigationController.backStack) + } + + @Test + fun `navigateBack should remove the top entry when there are multiple entries`() { + val navigationController = navigationControllerOf(TestGraph.RouteA, TestGraph.RouteB) + + val result = navigationController.navigateBack() + + assertTrue(result) + assertEquals(listOf(TestGraph.RouteA), navigationController.backStack) + } + + @Test + fun `navigateBack should not remove the last remaining entry`() { + val navigationController = navigationControllerOf(TestGraph.RouteA) + + val result = navigationController.navigateBack() + + assertFalse(result) + assertEquals(listOf(TestGraph.RouteA), navigationController.backStack) + } + + @Test + fun `addSingleTop should add the route when the top entry is a different type`() { + val navigationController = navigationControllerOf(TestGraph.RouteA) + + navigationController.addSingleTop(route = TestGraph.RouteB) + + assertEquals(listOf(TestGraph.RouteA, TestGraph.RouteB), navigationController.backStack) + } + + @Test + fun `addSingleTop should not add the route when the top entry is the same type`() { + val navigationController = navigationControllerOf( + TestGraph.RouteA, + TestGraph.RouteC(id = "1"), + ) + + navigationController.addSingleTop(TestGraph.RouteC(id = "2")) + + assertEquals( + listOf(TestGraph.RouteA, TestGraph.RouteC(id = "1")), + navigationController.backStack, + ) + } + + @Test + fun `popUpTo should pop every entry above the most recent matching entry`() { + val navigationController = navigationControllerOf( + TestGraph.RouteA, + TestGraph.RouteB, + TestGraph.RouteC(id = "1"), + TestGraph.RouteB, + TestGraph.RouteC(id = "2"), + ) + + val result = navigationController.popUpTo() + + assertTrue(result) + assertEquals( + listOf( + TestGraph.RouteA, + TestGraph.RouteB, + TestGraph.RouteC(id = "1"), + TestGraph.RouteB, + ), + navigationController.backStack, + ) + } + + @Test + fun `popUpTo with inclusive should also pop the matching entry`() { + val navigationController = navigationControllerOf( + TestGraph.RouteA, + TestGraph.RouteB, + TestGraph.RouteC(id = "1"), + ) + + val result = navigationController.popUpTo(inclusive = true) + + assertTrue(result) + assertEquals(listOf(TestGraph.RouteA), navigationController.backStack) + } + + @Test + fun `popUpTo with inclusive should not pop the last remaining entry`() { + val navigationController = navigationControllerOf(TestGraph.RouteA, TestGraph.RouteB) + + val result = navigationController.popUpTo(inclusive = true) + + assertTrue(result) + assertEquals(listOf(TestGraph.RouteA), navigationController.backStack) + } + + @Test + fun `popUpTo should do nothing when there is no matching entry`() { + val navigationController = navigationControllerOf(TestGraph.RouteA, TestGraph.RouteB) + + val result = navigationController.popUpTo() + + assertFalse(result) + assertEquals(listOf(TestGraph.RouteA, TestGraph.RouteB), navigationController.backStack) + } + + @Test + fun `popUpTo with a predicate should pop every entry above the most recent match`() { + val navigationController = navigationControllerOf( + TestGraph.RouteA, + TestGraph.RouteC(id = "1"), + TestGraph.RouteC(id = "2"), + TestGraph.RouteB, + ) + + val result = navigationController.popUpTo { it == TestGraph.RouteC(id = "1") } + + assertTrue(result) + assertEquals( + listOf(TestGraph.RouteA, TestGraph.RouteC(id = "1")), + navigationController.backStack, + ) + } + + @Test + fun `popToRoot should pop every entry except the root entry`() { + val navigationController = navigationControllerOf( + TestGraph.RouteA, + TestGraph.RouteB, + TestGraph.RouteC(id = "1"), + ) + + navigationController.popToRoot() + + assertEquals(listOf(TestGraph.RouteA), navigationController.backStack) + } + + @Test + fun `popToRoot should do nothing when only the root entry exists`() { + val navigationController = navigationControllerOf(TestGraph.RouteA) + + navigationController.popToRoot() + + assertEquals(listOf(TestGraph.RouteA), navigationController.backStack) + } + + @Test + fun `navigatePoppingUpTo should pop above the matching entry before adding the route`() { + val navigationController = navigationControllerOf( + TestGraph.RouteA, + TestGraph.RouteB, + TestGraph.RouteC(id = "1"), + ) + + navigationController.navigatePoppingUpTo(route = TestGraph.RouteC(id = "2")) { + it is TestGraph.RouteB + } + + assertEquals( + listOf(TestGraph.RouteA, TestGraph.RouteB, TestGraph.RouteC(id = "2")), + navigationController.backStack, + ) + } + + @Test + fun `navigatePoppingUpTo with inclusive should pop the matching entry before adding`() { + val navigationController = navigationControllerOf( + TestGraph.RouteA, + TestGraph.RouteB, + TestGraph.RouteC(id = "1"), + ) + + navigationController.navigatePoppingUpTo( + route = TestGraph.RouteC(id = "2"), + inclusive = true, + ) { it is TestGraph.RouteB } + + assertEquals( + listOf(TestGraph.RouteA, TestGraph.RouteC(id = "2")), + navigationController.backStack, + ) + } + + @Test + fun `navigatePoppingUpTo with inclusive should allow replacing the only entry`() { + val navigationController = navigationControllerOf(TestGraph.RouteB) + + navigationController.navigatePoppingUpTo(route = TestGraph.RouteA, inclusive = true) { + it is TestGraph.RouteB + } + + assertEquals(listOf(TestGraph.RouteA), navigationController.backStack) + } + + @Test + fun `navigatePoppingUpTo should add the route when there is no matching entry`() { + val navigationController = navigationControllerOf(TestGraph.RouteA) + + navigationController.navigatePoppingUpTo(route = TestGraph.RouteC(id = "1")) { + it is TestGraph.RouteB + } + + assertEquals( + listOf(TestGraph.RouteA, TestGraph.RouteC(id = "1")), + navigationController.backStack, + ) + } + + @Test + fun `replaceAll should replace the entire back stack`() { + val navigationController = navigationControllerOf(TestGraph.RouteA, TestGraph.RouteB) + + navigationController.replaceAll(TestGraph.RouteC(id = "1"), TestGraph.RouteB) + + assertEquals( + listOf(TestGraph.RouteC(id = "1"), TestGraph.RouteB), + navigationController.backStack, + ) + } + + @Test + fun `replaceAll with a list should replace the entire back stack`() { + val navigationController = navigationControllerOf(TestGraph.RouteA, TestGraph.RouteB) + + navigationController.replaceAll(routes = listOf(TestGraph.RouteC(id = "1"))) + + assertEquals(listOf(TestGraph.RouteC(id = "1")), navigationController.backStack) + } + + @Test + fun `replaceAll should throw when the given routes are empty`() { + val navigationController = navigationControllerOf(TestGraph.RouteA) + + assertThrows { + navigationController.replaceAll(routes = emptyList()) + } + assertEquals(listOf(TestGraph.RouteA), navigationController.backStack) + } + + @Test + fun `navigateToTopLevelRoute should retain the start route beneath the given route`() { + val navigationController = navigationControllerOf(TestGraph.RouteA, TestGraph.RouteB) + + navigationController.navigateToTopLevelRoute(route = TestGraph.RouteC(id = "1")) + + assertEquals( + listOf(TestGraph.RouteA, TestGraph.RouteC(id = "1")), + navigationController.backStack, + ) + } + + @Test + fun `navigateToTopLevelRoute should only contain the start route when navigating to it`() { + val navigationController = navigationControllerOf(TestGraph.RouteA, TestGraph.RouteB) + + navigationController.navigateToTopLevelRoute(route = TestGraph.RouteA) + + assertEquals(listOf(TestGraph.RouteA), navigationController.backStack) + } + + @Test + fun `replaceLast should replace the top entry of the back stack`() { + val navigationController = navigationControllerOf(TestGraph.RouteA, TestGraph.RouteB) + + navigationController.replaceLast(route = TestGraph.RouteC(id = "1")) + + assertEquals( + listOf(TestGraph.RouteA, TestGraph.RouteC(id = "1")), + navigationController.backStack, + ) + } + + @Test + fun `replaceLast should replace the only entry of the back stack`() { + val navigationController = navigationControllerOf(TestGraph.RouteA) + + navigationController.replaceLast(route = TestGraph.RouteB) + + assertEquals(listOf(TestGraph.RouteB), navigationController.backStack) + } + + @Test + fun `backStackSnapshot should emit the current back stack and any changes to it`() = + runTest { + val navigationController = navigationControllerOf(TestGraph.RouteA) + + navigationController.backStackSnapshot.test { + assertEquals(listOf(TestGraph.RouteA), awaitItem()) + + navigationController.navigate(route = TestGraph.RouteB) + Snapshot.sendApplyNotifications() + assertEquals(listOf(TestGraph.RouteA, TestGraph.RouteB), awaitItem()) + + navigationController.replaceAll(TestGraph.RouteC(id = "1")) + assertEquals(listOf(TestGraph.RouteC(id = "1")), awaitItem()) + } + } +} + +private sealed class TestGraph : NavKey { + data object RouteA : TestGraph() + + data object RouteB : TestGraph() + + data class RouteC(val id: String) : TestGraph() +}