diff --git a/CHANGELOG.md b/CHANGELOG.md index 6fa099e0..8b5f3a7f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## Unreleased +* Updated dependency versions +* Added `instruction` property directly to `NavigationContext`, to provide easy access to the instruction +* Added extensions `getViewModel` and `requireViewModel` to `NavigationContext` to access `ViewModels` directly from a context reference +* Added extensions for `findContext` and `findActiveContext` to `NavigationContext` to allow for finding other NavigationContexts from a context reference +* Updated `NavigationContainer` to add `getChildContext` which allows finding specific Active/ActivePushed/ActivePresented/Specific contexts from a container reference +* Added `instruction` property to `NavigationContext`, and marked `NavigationContext` as `@AdvancedEnroApi` + ## 2.3.0 * Updated NavigationFlow to return from `next` after `onCompleted` is called, rather than continuing to set the backstack from the flow * Updated NavigationContainer to take a `filter` of type NavigationContainerFilter instead of an `accept: (NavigationKey) -> Boolean` lambda. This allows for more advanced filtering of NavigationKeys, and this API will likely be expanded in the future. diff --git a/enro-core/src/main/java/dev/enro/compatability/dev.enro.core.NavigationHandle.kt b/enro-core/src/main/java/dev/enro/compatability/dev.enro.core.NavigationHandle.kt index 376b6275..a13ca92f 100644 --- a/enro-core/src/main/java/dev/enro/compatability/dev.enro.core.NavigationHandle.kt +++ b/enro-core/src/main/java/dev/enro/compatability/dev.enro.core.NavigationHandle.kt @@ -8,7 +8,7 @@ public fun NavigationHandle.forward(key: NavigationKey) { executeInstruction(NavigationInstruction.Forward(key)) } -@Deprecated("You should use a close instruction followed by a push or present") +@Deprecated("You should use a push or present followed by a close instruction") public fun NavigationHandle.replace(key: NavigationKey) { executeInstruction(NavigationInstruction.Replace(key)) } diff --git a/enro-core/src/main/java/dev/enro/core/NavigationContext.findActiveContext.kt b/enro-core/src/main/java/dev/enro/core/NavigationContext.findActiveContext.kt new file mode 100644 index 00000000..9d6c04ee --- /dev/null +++ b/enro-core/src/main/java/dev/enro/core/NavigationContext.findActiveContext.kt @@ -0,0 +1,101 @@ +package dev.enro.core + +import dev.enro.core.container.NavigationContainer +import kotlin.reflect.KClass + + +/** + * Finds a NavigationContext that matches the predicate. This will search the hierarchy of active NavigationContexts starting + * at the context it is invoked on (not the root). Only active child contexts are considered (e.g. if there are + * two containers visible, only the active container will be searched). + * + * If you want to search the entire hierarchy, including the root, you should call this function on the root NavigationContext, + * which can be accessed from any NavigationContext by using the [rootContext] function. + */ +public fun NavigationContext<*>.findActiveContext(predicate: (NavigationContext<*>) -> Boolean): NavigationContext<*>? { + val contexts = mutableListOf(this) + while (contexts.isNotEmpty()) { + val context = contexts.removeAt(0) + if (predicate(context)) { + return context + } + val children = context.containerManager.activeContainer?.let { + setOfNotNull( + it.getChildContext(NavigationContainer.ContextFilter.ActivePushed), + it.getChildContext(NavigationContainer.ContextFilter.ActivePresented), + ) + }.orEmpty() + contexts.addAll(children) + } + return null +} + +/** + * Requires an active NavigationContext that matches the predicate. A wrapper for [findActiveContext] that throws an exception if + * no matching context is found. + * + * @see [findActiveContext] + */ +public fun NavigationContext<*>.requireActiveContext(predicate: (NavigationContext<*>) -> Boolean): NavigationContext<*> { + return requireNotNull(findActiveContext(predicate)) +} + +/** + * Finds an active NavigationContext that has a NavigationKey of type [keyType]. + * + * @see [findActiveContext] + */ +public fun NavigationContext<*>.findActiveContextWithKey(keyType: KClass<*>): NavigationContext<*>? { + return findActiveContext { + val key = it.instruction?.navigationKey ?: return@findActiveContext false + key::class == keyType + } +} + +/** + * Requires an active NavigationContext that has a NavigationKey of type [keyType]. + * + * @see [findActiveContext] + */ +public fun NavigationContext<*>.requireActiveContextWithKey(keyType: KClass<*>): NavigationContext<*> { + return requireContext { + val key = it.instruction?.navigationKey ?: return@requireContext false + key::class == keyType + } +} + +/** + * Finds an active NavigationContext that has a NavigationKey of type [T]. + * + * @see [findActiveContext] + */ +public inline fun NavigationContext<*>.findActiveContextWithKey(): NavigationContext<*>? { + return findActiveContext { it.instruction?.navigationKey is T } +} + +/** + * Requires an active NavigationContext that has a NavigationKey of type [T]. + * + * @see [findActiveContext] + */ +public inline fun NavigationContext<*>.requireActiveContextWithKey(): NavigationContext<*> { + return requireContext { it.instruction?.navigationKey is T } +} + +/** + * Finds an active NavigationContext that has a NavigationKey of matching [predicate]. + * + * @see [findActiveContext] + */ +public inline fun NavigationContext<*>.findActiveContextWithKey(crossinline predicate: (NavigationKey) -> Boolean): NavigationContext<*>? { + return findActiveContext { it.instruction?.navigationKey?.let(predicate) ?: false } +} + +/** + * Requires an active NavigationContext that has a NavigationKey of matching [predicate]. + * + * @see [findActiveContext] + */ +public inline fun NavigationContext<*>.requireActiveContextWithKey(crossinline predicate: (NavigationKey) -> Boolean): NavigationContext<*> { + return requireContext { it.instruction?.navigationKey?.let(predicate) ?: false } +} diff --git a/enro-core/src/main/java/dev/enro/core/NavigationContext.findContext.kt b/enro-core/src/main/java/dev/enro/core/NavigationContext.findContext.kt new file mode 100644 index 00000000..6784ddc5 --- /dev/null +++ b/enro-core/src/main/java/dev/enro/core/NavigationContext.findContext.kt @@ -0,0 +1,100 @@ +package dev.enro.core + +import dev.enro.core.container.NavigationContainer +import kotlin.reflect.KClass + +/** + * Finds a NavigationContext that matches the predicate. This will search the entire hierarchy of NavigationContexts starting + * at the context it is invoked on (not the root). All child contexts are considered, including contexts which are not in the + * active NavigationContainer (e.g. if there are two containers visible, both the active and non-active container will be searched). + * + * If you want to search the entire hierarchy, including the root, you should call this function on the root NavigationContext, + * which can be accessed from any NavigationContext by using the [rootContext] function. + */ +public fun NavigationContext<*>.findContext(predicate: (NavigationContext<*>) -> Boolean): NavigationContext<*>? { + val contexts = mutableListOf(this) + while (contexts.isNotEmpty()) { + val context = contexts.removeAt(0) + if (predicate(context)) { + return context + } + val children = context.containerManager.containers.flatMap { + setOfNotNull( + it.getChildContext(NavigationContainer.ContextFilter.ActivePushed), + it.getChildContext(NavigationContainer.ContextFilter.ActivePresented), + ) + } + contexts.addAll(children) + } + return null +} + +/** + * Requires a NavigationContext that matches the predicate. A wrapper for [findContext] that throws an exception if + * no matching context is found. + * + * @see [findContext] + */ +public fun NavigationContext<*>.requireContext(predicate: (NavigationContext<*>) -> Boolean): NavigationContext<*> { + return requireNotNull(findContext(predicate)) +} + +/** + * Finds a NavigationContext that has a NavigationKey of type [keyType]. + * + * @see [findContext] + */ +public fun NavigationContext<*>.findContextWithKey(keyType: KClass<*>): NavigationContext<*>? { + return findContext { + val key = it.instruction?.navigationKey ?: return@findContext false + key::class == keyType + } +} + +/** + * Requires a NavigationContext that has a NavigationKey of type [keyType]. + * + * @see [findContext] + */ +public fun NavigationContext<*>.requireContextWithKey(keyType: KClass<*>): NavigationContext<*> { + return requireContext { + val key = it.instruction?.navigationKey ?: return@requireContext false + key::class == keyType + } +} + +/** + * Finds a NavigationContext that has a NavigationKey of type [T]. + * + * @see [findContext] + */ +public inline fun NavigationContext<*>.findContextWithKey(): NavigationContext<*>? { + return findContext { it.instruction?.navigationKey is T } +} + +/** + * Requires a NavigationContext that has a NavigationKey of type [T]. + * + * @see [findContext] + */ +public inline fun NavigationContext<*>.requireContextWithKey(): NavigationContext<*> { + return requireContext { it.instruction?.navigationKey is T } +} + +/** + * Finds a NavigationContext that has a NavigationKey of matching [predicate]. + * + * @see [findContext] + */ +public inline fun NavigationContext<*>.findContextWithKey(crossinline predicate: (NavigationKey) -> Boolean): NavigationContext<*>? { + return findContext { it.instruction?.navigationKey?.let(predicate) ?: false } +} + +/** + * Requires a NavigationContext that has a NavigationKey of matching [predicate]. + * + * @see [findContext] + */ +public inline fun NavigationContext<*>.requireContextWithKey(crossinline predicate: (NavigationKey) -> Boolean): NavigationContext<*> { + return requireContext { it.instruction?.navigationKey?.let(predicate) ?: false } +} diff --git a/enro-core/src/main/java/dev/enro/core/NavigationContext.getViewModel.kt b/enro-core/src/main/java/dev/enro/core/NavigationContext.getViewModel.kt new file mode 100644 index 00000000..914d2d28 --- /dev/null +++ b/enro-core/src/main/java/dev/enro/core/NavigationContext.getViewModel.kt @@ -0,0 +1,115 @@ +package dev.enro.core + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewmodel.CreationExtras +import kotlin.reflect.KClass + +/** + * When attempting to find a ViewModel in a NavigationContext, we don't want to create a new ViewModel, rather we want to + * get an existing instance of that ViewModel, if it exists, so this ViewModelProvider.Factory always throws an exception + * if it is ever asked to actually create a ViewModel. + */ +private class NavigationContextViewModelFactory( + private val context: NavigationContext<*>, +) : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + viewModelNotFoundError(context, modelClass) + } + + override fun create(modelClass: Class, extras: CreationExtras): T { + viewModelNotFoundError(context, modelClass) + } +} + +private fun viewModelNotFoundError(context: NavigationContext<*>, modelClass: Class<*>): Nothing { + val key = context.instruction?.navigationKey + error("ViewModel ${modelClass.simpleName} was not found in NavigationContext with navigation key $key") +} + +/** + * Attempt to get a ViewModel of a certain type from a NavigationContext. + * + * @return The ViewModel requested, or null if the ViewModel does not exist in the NavigationContext's ViewModelStore + */ +public fun NavigationContext<*>.getViewModel( + cls: Class, + key: String? = null, +): T? { + val provider = ViewModelProvider( + store = viewModelStoreOwner.viewModelStore, + factory = NavigationContextViewModelFactory(this) + ) + val result = kotlin.runCatching { + when (key) { + null -> provider[cls] + else -> provider[key, cls] + } + } + return result.getOrNull() +} + +/** + * Attempt to get a ViewModel of a certain type from a NavigationContext. + * + * @return The ViewModel requested + * + * @throws IllegalStateException if the ViewModel does not already exist in the NavigationContext + */ +public fun NavigationContext<*>.requireViewModel( + cls: Class, + key: String? = null, +): T { + return getViewModel(cls, key) + ?: viewModelNotFoundError(this, cls) +} + +/** + * Attempt to get a ViewModel of a certain type from a NavigationContext. + * + * @return The ViewModel requested, or null if the ViewModel does not exist in the NavigationContext's ViewModelStore + */ +public fun NavigationContext<*>.getViewModel( + cls: KClass, + key: String? = null, +): T? { + return getViewModel(cls.java, key) +} + +/** + * Attempt to get a ViewModel of a certain type from a NavigationContext. + * + * @return The ViewModel requested + * + * @throws IllegalStateException if the ViewModel does not already exist in the NavigationContext + */ +public fun NavigationContext<*>.requireViewModel( + cls: KClass, + key: String? = null, +): T { + return requireViewModel(cls.java, key) +} + +/** + * Attempt to get a ViewModel of a certain type from a NavigationContext. + * + * @return The ViewModel requested, or null if the ViewModel does not exist in the NavigationContext's ViewModelStore + */ +public inline fun NavigationContext<*>.getViewModel( + key: String? = null, +): T? { + return getViewModel(T::class.java, key) +} + +/** + * Attempt to get a ViewModel of a certain type from a NavigationContext. + * + * @return The ViewModel requested + * + * @throws IllegalStateException if the ViewModel does not already exist in the NavigationContext + */ +public inline fun NavigationContext<*>.requireViewModel( + key: String? = null, +): T { + return requireViewModel(T::class.java, key) +} \ No newline at end of file diff --git a/enro-core/src/main/java/dev/enro/core/NavigationContext.kt b/enro-core/src/main/java/dev/enro/core/NavigationContext.kt index 5c94ef7f..0e33e21e 100644 --- a/enro-core/src/main/java/dev/enro/core/NavigationContext.kt +++ b/enro-core/src/main/java/dev/enro/core/NavigationContext.kt @@ -38,7 +38,18 @@ public class NavigationContext internal constructor( public val controller: NavigationController get() = getController() public val parentContext: NavigationContext<*>? get() = getParentContext() + /** + * The arguments provided to this NavigationContext. It is possible to read the open instruction from these arguments, + * but it may be different than the open instruction attached to the NavigationHandle. If the arguments do not contain + * a NavigationInstruction, a NavigationInstruction is still provided to the NavigationHandle, which will be either a + * default key (if one is provided with the destination) or a "NoNavigationKey" NavigationKey. + * + * Generally it should be preferred to read the instruction property, rather than read the instruction from the arguments. + */ + @AdvancedEnroApi public val arguments: Bundle get() = getArguments() + + public val instruction: NavigationInstruction.Open<*> by lazy { getNavigationHandle().instruction } public val viewModelStoreOwner: ViewModelStoreOwner get() = getViewModelStoreOwner() public val savedStateRegistryOwner: SavedStateRegistryOwner get() = getSavedStateRegistryOwner() public val lifecycleOwner: LifecycleOwner get() = getLifecycleOwner() diff --git a/enro-core/src/main/java/dev/enro/core/container/NavigationContainer.kt b/enro-core/src/main/java/dev/enro/core/container/NavigationContainer.kt index 026e085f..3e4d7e14 100644 --- a/enro-core/src/main/java/dev/enro/core/container/NavigationContainer.kt +++ b/enro-core/src/main/java/dev/enro/core/container/NavigationContainer.kt @@ -29,9 +29,12 @@ import dev.enro.core.controller.get import dev.enro.core.controller.interceptor.builder.NavigationInterceptorBuilder import dev.enro.core.controller.usecase.CanInstructionBeHostedAs import dev.enro.core.controller.usecase.GetNavigationAnimations +import dev.enro.core.findContainer import dev.enro.core.getNavigationHandle +import dev.enro.core.leafContext import dev.enro.core.parentContainer import dev.enro.core.requestClose +import dev.enro.core.rootContext import dev.enro.extensions.getParcelableListCompat import kotlinx.coroutines.Job import kotlinx.coroutines.delay @@ -62,7 +65,7 @@ public abstract class NavigationContainer( .apply(interceptor) .build() - public abstract val childContext: NavigationContext<*>? + public val childContext: NavigationContext<*>? get() = getChildContext(ContextFilter.Active) public abstract val isVisible: Boolean public override val isActive: Boolean @@ -70,6 +73,8 @@ public abstract class NavigationContainer( public override fun setActive() { context.containerManager.setActiveContainer(this) + val parent = parentContainer() ?: return + if (parent != this) parent.setActive() } private val mutableBackstackFlow: MutableStateFlow = @@ -150,6 +155,8 @@ public abstract class NavigationContainer( } } + public abstract fun getChildContext(contextFilter: ContextFilter): NavigationContext<*>? + // Returns true if the backstack was able to be updated successfully protected abstract fun onBackstackUpdated( transition: NavigationBackstackTransition @@ -231,14 +238,17 @@ public abstract class NavigationContainer( val isEmpty = backstackTransition.activeBackstack.isEmpty() if (!isClosing) { - context.containerManager.setActiveContainer(this) + setActive() return } if (backstackTransition.exitingInstruction != null) { - context.containerManager.setActiveContainerByKey( - backstackTransition.exitingInstruction.internal.previouslyActiveContainer - ) + val previouslyActiveContainer = backstackTransition.exitingInstruction.internal.previouslyActiveContainer + if (previouslyActiveContainer != null) { + context.rootContext() + .findContainer(previouslyActiveContainer) + ?.setActive() + } } if (isActive && isEmpty) context.containerManager.setActiveContainer(null) @@ -248,11 +258,18 @@ public abstract class NavigationContainer( return map { if (it.internal.previouslyActiveContainer != null) return@map it it.internal.copy( - previouslyActiveContainer = context.containerManager.activeContainer?.key + previouslyActiveContainer = context.rootContext().leafContext().parentContainer()?.key ) }.toBackstack() } + public sealed class ContextFilter { + public data object Active : ContextFilter() + public data object ActivePresented : ContextFilter() + public data object ActivePushed : ContextFilter() + public data class WithId(val id: String) : ContextFilter() + } + public companion object { private const val BACKSTACK_KEY = "NavigationContainer.BACKSTACK_KEY" internal val initialBackstack = emptyBackstack() diff --git a/enro-core/src/main/java/dev/enro/destination/activity/ActivityNavigationContainer.kt b/enro-core/src/main/java/dev/enro/destination/activity/ActivityNavigationContainer.kt index 11d8f5e0..7186f637 100644 --- a/enro-core/src/main/java/dev/enro/destination/activity/ActivityNavigationContainer.kt +++ b/enro-core/src/main/java/dev/enro/destination/activity/ActivityNavigationContainer.kt @@ -20,7 +20,6 @@ import dev.enro.core.container.backstackOf import dev.enro.core.controller.get import dev.enro.core.controller.usecase.GetNavigationBinding import dev.enro.core.controller.usecase.HostInstructionAs -import dev.enro.core.getNavigationHandle internal class ActivityNavigationContainer internal constructor( activityContext: NavigationContext, @@ -33,21 +32,23 @@ internal class ActivityNavigationContainer internal constructor( animations = { }, instructionFilter = acceptAll(), ) { - override val childContext: NavigationContext<*> - get() = context - override val isVisible: Boolean get() = context.lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED) private val rootInstruction: AnyOpenInstruction - get() = childContext.getNavigationHandle().instruction + get() = getChildContext(ContextFilter.Active).instruction init { setBackstack(backstackOf(rootInstruction)) } + override fun getChildContext(contextFilter: ContextFilter): NavigationContext<*> { + return context + } + override fun onBackstackUpdated(transition: NavigationBackstackTransition): Boolean { if (transition.activeBackstack.singleOrNull()?.instructionId == rootInstruction.instructionId) return true + val childContext = requireNotNull(childContext) setBackstack(backstackOf(rootInstruction)) val activeInstructionIsPresent = transition.activeBackstack.any { it.instructionId == rootInstruction.instructionId } diff --git a/enro-core/src/main/java/dev/enro/destination/compose/container/ComposableNavigationContainer.kt b/enro-core/src/main/java/dev/enro/destination/compose/container/ComposableNavigationContainer.kt index 240661e0..710fb4e3 100644 --- a/enro-core/src/main/java/dev/enro/destination/compose/container/ComposableNavigationContainer.kt +++ b/enro-core/src/main/java/dev/enro/destination/compose/container/ComposableNavigationContainer.kt @@ -63,16 +63,6 @@ public class ComposableNavigationContainer internal constructor( private val restoredDestinationState = mutableMapOf() private var destinationOwners by mutableStateOf>(emptyList()) - private val currentDestination by derivedStateOf { - destinationOwners - .lastOrNull { - it.instruction == backstack.active - } - } - - override val childContext: NavigationContext? by derivedStateOf { - currentDestination?.destination?.context - } override val isVisible: Boolean get() = true @@ -119,7 +109,10 @@ public class ComposableNavigationContainer internal constructor( destinationOwners .filter { it.lifecycle.currentState != Lifecycle.State.DESTROYED } .forEach { destinationOwner -> - savedState.putBundle(DESTINATION_STATE_PREFIX_KEY + destinationOwner.instruction.instructionId, destinationOwner.save()) + savedState.putBundle( + DESTINATION_STATE_PREFIX_KEY + destinationOwner.instruction.instructionId, + destinationOwner.save() + ) } return savedState } @@ -135,6 +128,36 @@ public class ComposableNavigationContainer internal constructor( super.restore(bundle) } + override fun getChildContext(contextFilter: ContextFilter): NavigationContext<*>? { + return when (contextFilter) { + is ContextFilter.Active -> destinationOwners + .lastOrNull { it.lifecycle.currentState.isAtLeast(Lifecycle.State.CREATED) } + ?.destination + ?.context + + is ContextFilter.ActivePushed -> destinationOwners + .lastOrNull { + it.lifecycle.currentState.isAtLeast(Lifecycle.State.CREATED) && + it.instruction.navigationDirection == NavigationDirection.Push + } + ?.destination + ?.context + + is ContextFilter.ActivePresented -> destinationOwners + .takeLastWhile { it.instruction.navigationDirection != NavigationDirection.Push } + .lastOrNull { + it.lifecycle.currentState.isAtLeast(Lifecycle.State.CREATED) && + it.instruction.navigationDirection == NavigationDirection.Push + } + ?.destination + ?.context + + is ContextFilter.WithId -> destinationOwners + .lastOrNull { it.instruction.instructionId == contextFilter.id } + ?.destination + ?.context + } + } @OptIn(ExperimentalMaterialApi::class) override fun onBackstackUpdated( @@ -153,14 +176,14 @@ public class ComposableNavigationContainer internal constructor( transition.removed .mapNotNull { activeDestinations[it]?.destination } .forEach { - when(it) { + when (it) { is DialogDestination -> it.dialogConfiguration.isDismissed.value = true is BottomSheetDestination -> it.bottomSheetConfiguration.isDismissed.value = true } } backstack.forEach { instruction -> - if(activeDestinations[instruction] == null) { + if (activeDestinations[instruction] == null) { activeDestinations[instruction] = createDestinationOwner(instruction) } } @@ -174,7 +197,7 @@ public class ComposableNavigationContainer internal constructor( ?.let { visible.add(it) } destinationOwners.forEach { - if(activeDestinations[it.instruction] == null) { + if (activeDestinations[it.instruction] == null) { it.transitionState.targetState = false } } @@ -219,12 +242,14 @@ public class ComposableNavigationContainer internal constructor( context.contextReference is Fragment && !context.contextReference.isAdded -> true else -> false } - val presented = transition.activeBackstack.takeLastWhile { it.navigationDirection is NavigationDirection.Present }.toSet() + val presented = + transition.activeBackstack.takeLastWhile { it.navigationDirection is NavigationDirection.Present }.toSet() val activePush = transition.activeBackstack.lastOrNull { it.navigationDirection !is NavigationDirection.Present } val activePresented = presented.lastOrNull() destinationOwners.forEach { destinationOwner -> val instruction = destinationOwner.instruction - val isPushedDialogOrBottomSheet = ((destinationOwner.destination is DialogDestination || destinationOwner.destination is BottomSheetDestination) && activePresented != null) + val isPushedDialogOrBottomSheet = + ((destinationOwner.destination is DialogDestination || destinationOwner.destination is BottomSheetDestination) && activePresented != null) destinationOwner.transitionState.targetState = when (instruction) { activePresented -> !isParentBeingRemoved diff --git a/enro-core/src/main/java/dev/enro/destination/fragment/container/FragmentNavigationContainer.kt b/enro-core/src/main/java/dev/enro/destination/fragment/container/FragmentNavigationContainer.kt index 12b5d0d3..0f85ab18 100644 --- a/enro-core/src/main/java/dev/enro/destination/fragment/container/FragmentNavigationContainer.kt +++ b/enro-core/src/main/java/dev/enro/destination/fragment/container/FragmentNavigationContainer.kt @@ -66,18 +66,9 @@ public class FragmentNavigationContainer internal constructor( containerView?.isVisible = value } - override val childContext: NavigationContext? - get() { - val fragment = - backstack.lastOrNull()?.let { fragmentManager.findFragmentByTag(it.instructionId) } - ?: fragmentManager.findFragmentById(containerId) - return fragment?.navigationContext - } - private val ownedFragments = mutableSetOf() private val restoredFragmentStates = mutableMapOf() - init { fragmentManager.registerFragmentLifecycleCallbacks(object : FragmentLifecycleCallbacks() { override fun onFragmentDestroyed(fm: FragmentManager, f: Fragment) { @@ -119,6 +110,29 @@ public class FragmentNavigationContainer internal constructor( super.restore(bundle) } + override fun getChildContext(contextFilter: ContextFilter): NavigationContext<*>? { + val fragment = when(contextFilter) { + is ContextFilter.Active -> { + backstack.lastOrNull()?.let { fragmentManager.findFragmentByTag(it.instructionId) } + ?: fragmentManager.findFragmentById(containerId) + } + is ContextFilter.ActivePushed -> { + backstack + .lastOrNull { it.navigationDirection == NavigationDirection.Push } + ?.let { fragmentManager.findFragmentByTag(it.instructionId) } + } + is ContextFilter.ActivePresented -> { + backstack.takeLastWhile { it.navigationDirection != NavigationDirection.Push } + .lastOrNull { it.navigationDirection == NavigationDirection.Present } + ?.let { fragmentManager.findFragmentByTag(it.instructionId) } + } + is ContextFilter.WithId -> { + fragmentManager.findFragmentByTag(contextFilter.id) + } + } + return fragment?.navigationContext + } + override fun onBackstackUpdated( transition: NavigationBackstackTransition ): Boolean { diff --git a/libs.versions.toml b/libs.versions.toml index 0934ef41..42adb236 100644 --- a/libs.versions.toml +++ b/libs.versions.toml @@ -4,37 +4,37 @@ android-gradle = "com.android.tools.build:gradle:8.1.4" emulator-wtf-gradle = "wtf.emulator:gradle-plugin:0.0.11" -androidx-core = "androidx.core:core-ktx:1.12.0" +androidx-core = "androidx.core:core-ktx:1.13.0" androidx-appcompat = "androidx.appcompat:appcompat:1.6.1" androidx-constraintlayout = "androidx.constraintlayout:constraintlayout:2.1.4" -androidx-fragment = "androidx.fragment:fragment-ktx:1.7.0-alpha09" +androidx-fragment = "androidx.fragment:fragment-ktx:1.7.0-rc02" androidx-recyclerview = "androidx.recyclerview:recyclerview:1.3.2" androidx-splashscreen = "androidx.core:core-splashscreen:1.0.1" # Used for interoperability testing in the main Enro test suite -androidx-navigation-fragment = "androidx.navigation:navigation-fragment-ktx:2.7.6" -androidx-navigation-ui = "androidx.navigation:navigation-ui-ktx:2.7.6" +androidx-navigation-fragment = "androidx.navigation:navigation-fragment-ktx:2.7.7" +androidx-navigation-ui = "androidx.navigation:navigation-ui-ktx:2.7.7" -androidx-activity = "androidx.activity:activity-ktx:1.8.2" -compose-activity = "androidx.activity:activity-compose:1.8.2" +androidx-activity = "androidx.activity:activity-ktx:1.9.0" +compose-activity = "androidx.activity:activity-compose:1.9.0" androidx-lifecycle = "androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0" androidx-lifecycle-process = "androidx.lifecycle:lifecycle-process:2.7.0" compose-viewmodel = "androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0" -compose-compiler = "androidx.compose.compiler:compiler:1.5.8" -compose-foundation = "androidx.compose.foundation:foundation:1.5.4" -compose-foundationLayout = "androidx.compose.foundation:foundation-layout:1.5.4" -compose-ui = "androidx.compose.ui:ui:1.5.4" -compose-uiTooling = "androidx.compose.ui:ui-tooling:1.5.4" -compose-runtime = "androidx.compose.runtime:runtime:1.5.4" -compose-livedata = "androidx.compose.runtime:runtime-livedata:1.5.4" -compose-material = "androidx.compose.material:material:1.5.4" +compose-compiler = "androidx.compose.compiler:compiler:1.5.12" +compose-foundation = "androidx.compose.foundation:foundation:1.6.6" +compose-foundationLayout = "androidx.compose.foundation:foundation-layout:1.6.6" +compose-ui = "androidx.compose.ui:ui:1.6.6" +compose-uiTooling = "androidx.compose.ui:ui-tooling:1.6.6" +compose-runtime = "androidx.compose.runtime:runtime:1.6.6" +compose-livedata = "androidx.compose.runtime:runtime-livedata:1.6.6" +compose-material = "androidx.compose.material:material:1.6.6" compose-accompanist-systemUiController = "com.google.accompanist:accompanist-systemuicontroller:0.27.0" -compose-materialIcons = "androidx.compose.material:material-icons-core:1.5.4" -compose-materialIconsExtended = "androidx.compose.material:material-icons-extended:1.5.4" +compose-materialIcons = "androidx.compose.material:material-icons-core:1.6.6" +compose-materialIconsExtended = "androidx.compose.material:material-icons-extended:1.6.6" kotlin-gradle = "org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.10" kotlin-stdLib = "org.jetbrains.kotlin:kotlin-stdlib:1.9.10" @@ -44,7 +44,7 @@ hilt-gradle = "com.google.dagger:hilt-android-gradle-plugin:2.48.1" hilt-android = "com.google.dagger:hilt-android:2.48.1" hilt-androidCompiler = "com.google.dagger:hilt-android-compiler:2.48.1" hilt-testing = "com.google.dagger:hilt-android-testing:2.48.1" -hilt-compiler = "androidx.hilt:hilt-compiler:1.1.0" +hilt-compiler = "androidx.hilt:hilt-compiler:1.2.0" leakcanary = "com.squareup.leakcanary:leakcanary-android:2.10" @@ -59,7 +59,7 @@ testing-androidx-espresso = "androidx.test.espresso:espresso-core:3.5.1" testing-androidx-espressoRecyclerView = "androidx.test.espresso:espresso-contrib:3.5.1" testing-androidx-espressoIntents = "androidx.test.espresso:espresso-intents:3.5.1" testing-androidx-fragment = "androidx.fragment:fragment-testing:1.6.2" -testing-androidx-compose = "androidx.compose.ui:ui-test-junit4:1.5.4" +testing-androidx-compose = "androidx.compose.ui:ui-test-junit4:1.6.6" testing-leakcanary-instrumentation = "com.squareup.leakcanary:leakcanary-android-instrumentation:2.10" @@ -76,5 +76,5 @@ processing-kotlinPoet-ksp = "com.squareup:kotlinpoet-ksp:1.13.2" material = "com.google.android.material:material:1.11.0" -lint-checks = "com.android.tools.lint:lint-checks:31.2.1" -lint-api = "com.android.tools.lint:lint-api:31.2.1" \ No newline at end of file +lint-checks = "com.android.tools.lint:lint-checks:31.3.2" +lint-api = "com.android.tools.lint:lint-api:31.3.2" \ No newline at end of file diff --git a/tests/application/src/androidTest/java/dev/enro/test/application/SelectDestinationRobot.kt b/tests/application/src/androidTest/java/dev/enro/test/application/SelectDestinationRobot.kt index 3314e3d7..28af398f 100644 --- a/tests/application/src/androidTest/java/dev/enro/test/application/SelectDestinationRobot.kt +++ b/tests/application/src/androidTest/java/dev/enro/test/application/SelectDestinationRobot.kt @@ -10,7 +10,9 @@ import dev.enro.test.application.activity.SimpleActivityRobot import dev.enro.test.application.compose.BottomNavigationRobot import dev.enro.test.application.compose.BottomSheetChangeSizeRobot import dev.enro.test.application.compose.BottomSheetCloseAndPresentRobot +import dev.enro.test.application.compose.FindContextRobot import dev.enro.test.application.compose.LegacyBottomSheetsRobot +import dev.enro.test.application.compose.SyntheticViewModelAccessRobot import dev.enro.test.application.fragment.UnboundBottomSheetRobot import dev.enro.tests.application.SelectDestination @@ -84,4 +86,24 @@ class SelectDestinationRobot( return BottomNavigationRobot(composeRule) } + + fun openSyntheticViewModelAccess() : SyntheticViewModelAccessRobot { + composeRule.onNode(hasText("Synthetic View Model Access")) + .performScrollTo() + .onSiblings() + .filterToOne(hasText("Push")) + .performClick() + + return SyntheticViewModelAccessRobot(composeRule) + } + + fun openFindContext() : FindContextRobot { + composeRule.onNode(hasText("Find Context")) + .performScrollTo() + .onSiblings() + .filterToOne(hasText("Push")) + .performClick() + + return FindContextRobot(composeRule) + } } \ No newline at end of file diff --git a/tests/application/src/androidTest/java/dev/enro/test/application/Utility.kt b/tests/application/src/androidTest/java/dev/enro/test/application/Utility.kt index 4ffb369c..953e0d41 100644 --- a/tests/application/src/androidTest/java/dev/enro/test/application/Utility.kt +++ b/tests/application/src/androidTest/java/dev/enro/test/application/Utility.kt @@ -26,7 +26,8 @@ fun ComposeTestRule.waitForNavigationHandle( var activeContext: NavigationContext<*>? = activity.navigationContext while (activeContext != null) { - navigationHandle = activeContext.getNavigationHandle() + navigationHandle = runCatching { activeContext!!.getNavigationHandle() }.getOrNull() + ?: return@waitUntil false if (block(navigationHandle!!)) { return@waitUntil true } diff --git a/tests/application/src/androidTest/java/dev/enro/test/application/compose/FindContextRobot.kt b/tests/application/src/androidTest/java/dev/enro/test/application/compose/FindContextRobot.kt new file mode 100644 index 00000000..e6dd8040 --- /dev/null +++ b/tests/application/src/androidTest/java/dev/enro/test/application/compose/FindContextRobot.kt @@ -0,0 +1,176 @@ +package dev.enro.test.application.compose + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.junit4.ComposeTestRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextReplacement +import androidx.test.runner.lifecycle.ActivityLifecycleMonitorRegistry +import androidx.test.runner.lifecycle.Stage +import dev.enro.core.NavigationKey +import dev.enro.core.close +import dev.enro.core.findContextWithKey +import dev.enro.core.navigationContext +import dev.enro.test.application.waitForNavigationHandle +import dev.enro.tests.application.compose.FindContext +import kotlin.reflect.KClass + +class FindContextRobot ( + private val composeRule: ComposeTestRule +) { + init { + composeRule.waitForNavigationHandle { + it.key is FindContext + } + val componentActivity = composeRule.runOnUiThread { + ActivityLifecycleMonitorRegistry.getInstance().getActivitiesInStage(Stage.RESUMED) + .single() as ComponentActivity + } + composeRule.waitUntil { + // Need to wait a moment for all of the child contexts to be created in their containers + componentActivity.navigationContext.findContextWithKey() != null + && componentActivity.navigationContext.findContextWithKey() != null + && componentActivity.navigationContext.findContextWithKey() != null + && componentActivity.navigationContext.findContextWithKey() != null + } + } + + fun pushLeftTop(): FindContextRobot { + clickButton("push") + return this + } + + fun pushLeftBottom(): FindContextRobot { + clickButton("push") + return this + } + + fun pushRightTop(): FindContextRobot { + clickButton("push") + return this + } + + fun pushRightBottom(): FindContextRobot { + clickButton("push") + return this + } + + fun setActiveLeftTop(): FindContextRobot { + clickButton("set-active") + return this + } + + fun setActiveLeftBottom(): FindContextRobot { + clickButton("set-active") + return this + } + + fun setActiveRightTop(): FindContextRobot { + clickButton("set-active") + return this + } + + fun setActiveRightBottom(): FindContextRobot { + clickButton("set-active") + return this + } + + private inline fun clickButton(buttonText: String) { + composeRule.onNodeWithTag("$buttonText-${T::class.getLocationName()}", useUnmergedTree = true) + .performClick() + } + + fun find(): FindRobot { + composeRule.onNodeWithText("Find") + .performClick() + return FindRobot() + } + + inner class FindRobot { + val navigation = composeRule.waitForNavigationHandle { it.key is FindContext.Find } + + private fun setId(id: Int?): FindRobot { + composeRule.onNodeWithTag("id-input") + .performTextReplacement( + text = id?.toString() ?: "" + ) + return this + } + + fun setLeftTopTarget(id: Int?): FindRobot { + composeRule.onNodeWithText("Find Left.Top") + .performClick() + return setId(id) + } + + fun setLeftBottomTarget(id: Int?): FindRobot { + composeRule.onNodeWithText("Find Left.Bottom") + .performClick() + return setId(id) + } + + fun setRightTopTarget(id: Int?): FindRobot { + composeRule.onNodeWithText("Find Right.Top") + .performClick() + return setId(id) + } + + fun setRightBottomTarget(id: Int?): FindRobot { + composeRule.onNodeWithText("Find Right.Bottom") + .performClick() + return setId(id) + } + + fun findContext(): FindResultRobot { + composeRule.onNodeWithText("Find Context") + .performClick() + return FindResultRobot() + } + + fun findActiveContext(): FindResultRobot { + composeRule.onNodeWithText("Find Active Context") + .performClick() + return FindResultRobot() + } + + fun close(): FindContextRobot { + navigation.close() + return this@FindContextRobot + } + + inner class FindResultRobot { + private val navigation = composeRule.waitForNavigationHandle { it.key is FindContext.FindResult } + + fun assertContextFound( + expectedKey: NavigationKey, + ): FindRobot { + composeRule + .onNodeWithText("Found context: $expectedKey") + .assertExists() + navigation.close() + return this@FindRobot + } + + fun assertContextNotFound(): FindRobot { + composeRule + .onNodeWithText("No context found") + .assertExists() + navigation.close() + return this@FindRobot + } + } + } + + +} + +private fun KClass<*>.getLocationName(): String { + return when(this) { + FindContext.Left.Top::class -> "Left.Top" + FindContext.Left.Bottom::class -> "Left.Bottom" + FindContext.Right.Top::class -> "Right.Top" + FindContext.Right.Bottom::class -> "Right.Bottom" + else -> error("invalid type") + } +} \ No newline at end of file diff --git a/tests/application/src/androidTest/java/dev/enro/test/application/compose/FindContextTest.kt b/tests/application/src/androidTest/java/dev/enro/test/application/compose/FindContextTest.kt new file mode 100644 index 00000000..2368fa40 --- /dev/null +++ b/tests/application/src/androidTest/java/dev/enro/test/application/compose/FindContextTest.kt @@ -0,0 +1,107 @@ +package dev.enro.test.application.compose + +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import dev.enro.test.application.SelectDestinationRobot +import dev.enro.tests.application.TestActivity +import dev.enro.tests.application.compose.FindContext +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class FindContextTest { + + @get:Rule + val composeRule = createAndroidComposeRule() + + @Test + fun testFindContext() { + SelectDestinationRobot(composeRule) + .openFindContext() + .pushLeftTop() + .pushRightTop() + .pushLeftBottom() + .pushRightBottom() + .find() + + .setLeftTopTarget(1) + .findContext() + .assertContextNotFound() + + .setRightTopTarget(2) + .findContext() + .assertContextFound(FindContext.Right.Top(2)) + + .setRightBottomTarget(null) + .findContext() + .assertContextFound(FindContext.Right.Bottom(2)) + .close() + + .pushLeftBottom() + .find() + .setLeftBottomTarget(3) + .findContext() + .assertContextFound(FindContext.Left.Bottom(3)) + + .setLeftBottomTarget(2) + .findContext() + .assertContextNotFound() + } + + @Test + fun testFindActiveContext() { + SelectDestinationRobot(composeRule) + .openFindContext() + .pushLeftTop() + .pushRightTop() + .pushLeftBottom() + .pushRightBottom() + .find() + + .setRightBottomTarget(1) + .findActiveContext() + .assertContextNotFound() + + .setRightBottomTarget(2) + .findActiveContext() + .assertContextFound(FindContext.Right.Bottom(2)) + + .setRightTopTarget(2) + .findActiveContext() + .assertContextNotFound() + + .setLeftTopTarget(2) + .findActiveContext() + .assertContextNotFound() + + .setLeftBottomTarget(2) + .findActiveContext() + .assertContextNotFound() + + .close() + .setActiveLeftTop() + .find() + + .setLeftTopTarget(2) + .findActiveContext() + .assertContextFound(FindContext.Left.Top(2)) + + .setLeftTopTarget(null) + .findActiveContext() + .assertContextFound(FindContext.Left.Top(2)) + + .setLeftBottomTarget(null) + .findActiveContext() + .assertContextNotFound() + .close() + + .pushLeftBottom() + .find() + + .setLeftBottomTarget(null) + .findActiveContext() + .assertContextFound(FindContext.Left.Bottom(3)) + } + +} \ No newline at end of file diff --git a/tests/application/src/androidTest/java/dev/enro/test/application/compose/SyntheticViewModelAccessRobot.kt b/tests/application/src/androidTest/java/dev/enro/test/application/compose/SyntheticViewModelAccessRobot.kt new file mode 100644 index 00000000..c78f77cd --- /dev/null +++ b/tests/application/src/androidTest/java/dev/enro/test/application/compose/SyntheticViewModelAccessRobot.kt @@ -0,0 +1,35 @@ +package dev.enro.test.application.compose + +import androidx.compose.ui.test.junit4.ComposeTestRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import dev.enro.test.application.waitForNavigationHandle +import dev.enro.tests.application.compose.SyntheticViewModelAccess + +class SyntheticViewModelAccessRobot( + private val composeRule: ComposeTestRule +) { + init { + composeRule.waitForNavigationHandle { + it.key is SyntheticViewModelAccess + } + } + + fun accessValidViewModel(): SyntheticViewModelAccessRobot { + composeRule.onNodeWithText("Access Valid ViewModel") + .performClick() + return this + } + + fun accessInvalidViewModel(): SyntheticViewModelAccessRobot { + composeRule.onNodeWithText("Access Invalid ViewModel (throws)") + .performClick() + return this + } + + fun assertViewModelAccessed(times: Int): SyntheticViewModelAccessRobot { + composeRule.onNodeWithText("ViewModel Accessed $times times") + .assertExists() + return this + } +} diff --git a/tests/application/src/androidTest/java/dev/enro/test/application/compose/SyntheticViewModelAccessTests.kt b/tests/application/src/androidTest/java/dev/enro/test/application/compose/SyntheticViewModelAccessTests.kt new file mode 100644 index 00000000..05c78738 --- /dev/null +++ b/tests/application/src/androidTest/java/dev/enro/test/application/compose/SyntheticViewModelAccessTests.kt @@ -0,0 +1,40 @@ +package dev.enro.test.application.compose + +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import dev.enro.test.application.SelectDestinationRobot +import dev.enro.tests.application.TestActivity +import org.junit.Ignore +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class SyntheticViewModelAccessTests { + @get:Rule + val composeRule = createAndroidComposeRule() + + @Test + fun testValidViewModelAccess() { + SelectDestinationRobot(composeRule) + .openSyntheticViewModelAccess() + .assertViewModelAccessed(0) + .accessValidViewModel() + .assertViewModelAccessed(1) + .accessValidViewModel() + .assertViewModelAccessed(2) + } + + // apparently this test doesn't work because the Compose rule throws an exception on + // the wrong thread, so the expected exception is not caught and the test fails (even though we get the expected result) + @Ignore + @Test(expected = IllegalStateException::class) + fun testInvalidViewModelAccess() { + kotlin.runCatching { + SelectDestinationRobot(composeRule) + .openSyntheticViewModelAccess() + .assertViewModelAccessed(0) + .accessInvalidViewModel() + } + } +} \ No newline at end of file diff --git a/tests/application/src/main/java/dev/enro/tests/application/compose/FindContext.kt b/tests/application/src/main/java/dev/enro/tests/application/compose/FindContext.kt new file mode 100644 index 00000000..5bfbc83c --- /dev/null +++ b/tests/application/src/main/java/dev/enro/tests/application/compose/FindContext.kt @@ -0,0 +1,366 @@ +package dev.enro.tests.application.compose + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +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.padding +import androidx.compose.material.Button +import androidx.compose.material.Checkbox +import androidx.compose.material.FloatingActionButton +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.material.TextField +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import dev.enro.annotations.NavigationDestination +import dev.enro.core.NavigationKey +import dev.enro.core.compose.dialog.DialogDestination +import dev.enro.core.compose.navigationHandle +import dev.enro.core.compose.rememberNavigationContainer +import dev.enro.core.container.EmptyBehavior +import dev.enro.core.container.accept +import dev.enro.core.container.key +import dev.enro.core.findActiveContext +import dev.enro.core.findContext +import dev.enro.core.navigationContext +import dev.enro.core.parentContainer +import dev.enro.core.present +import dev.enro.core.push +import dev.enro.core.requestClose +import dev.enro.core.rootContext +import kotlinx.parcelize.Parcelize +import kotlin.reflect.KClass + +@Parcelize +object FindContext : NavigationKey.SupportsPush { + interface HasId { + val id: Int + } + + @Parcelize + internal object Left : NavigationKey.SupportsPush { + @Parcelize + internal data class Top(override val id: Int) : NavigationKey.SupportsPush, HasId + + @Parcelize + internal data class Bottom(override val id: Int) : NavigationKey.SupportsPush, HasId + } + + @Parcelize + internal object Right : NavigationKey.SupportsPush { + @Parcelize + internal data class Top(override val id: Int) : NavigationKey.SupportsPush, HasId + + @Parcelize + internal data class Bottom(override val id: Int) : NavigationKey.SupportsPush, HasId + } + + @Parcelize + internal object Find : NavigationKey.SupportsPresent + + @Parcelize + internal class FindResult(val found: NavigationKey?) : NavigationKey.SupportsPresent +} + +@NavigationDestination(FindContext::class) +@Composable +fun FindContextDestination() { + val navigation = navigationHandle() + val left = rememberNavigationContainer( + root = FindContext.Left, + emptyBehavior = EmptyBehavior.CloseParent + ) + val right = rememberNavigationContainer( + root = FindContext.Right, + emptyBehavior = EmptyBehavior.CloseParent + ) + Box { + Row { + Box(modifier = Modifier.weight(1f)) { + left.Render() + } + Box(modifier = Modifier.weight(1f)) { + right.Render() + } + } + FloatingActionButton( + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(bottom = 16.dp), + backgroundColor = MaterialTheme.colors.primary, + onClick = { navigation.present(FindContext.Find) } + ) { + Text(text = "Find") + } + } +} + +@NavigationDestination(FindContext.Left::class) +@Composable +fun FindContextLeftDestination() { + val top = rememberNavigationContainer( + root = FindContext.Left.Top(1), + emptyBehavior = EmptyBehavior.CloseParent, + filter = accept { key() }, + ) + val bottom = rememberNavigationContainer( + root = FindContext.Left.Bottom(1), + emptyBehavior = EmptyBehavior.CloseParent, + filter = accept { key() } + ) + Column { + Box(modifier = Modifier.weight(1f)) { + top.Render() + } + Box(modifier = Modifier.weight(1f)) { + bottom.Render() + } + } +} + +@NavigationDestination(FindContext.Right::class) +@Composable +fun FindContextRightDestination() { + val top = rememberNavigationContainer( + root = FindContext.Right.Top(1), + emptyBehavior = EmptyBehavior.CloseParent, + filter = accept { key() } + ) + val bottom = rememberNavigationContainer( + root = FindContext.Right.Bottom(1), + emptyBehavior = EmptyBehavior.CloseParent, + filter = accept { key() } + ) + Column { + Box(modifier = Modifier.weight(1f)) { + top.Render() + } + Box(modifier = Modifier.weight(1f)) { + bottom.Render() + } + } +} + +@NavigationDestination(FindContext.Left.Top::class) +@Composable +fun FindContextLeftTopDestination() { + val navigation = navigationHandle() + LeafDestination( + color = Color.Red, + nextKey = FindContext.Left.Top(navigation.key.id + 1), + ) +} + +@NavigationDestination(FindContext.Left.Bottom::class) +@Composable +fun FindContextLeftBottomDestination() { + val navigation = navigationHandle() + LeafDestination( + color = Color.Blue, + nextKey = FindContext.Left.Bottom(navigation.key.id + 1), + ) +} + +@NavigationDestination(FindContext.Right.Top::class) +@Composable +fun FindContextRightTopDestination() { + val navigation = navigationHandle() + LeafDestination( + color = Color.Green, + nextKey = FindContext.Right.Top(navigation.key.id + 1), + ) +} + +@NavigationDestination(FindContext.Right.Bottom::class) +@Composable +fun FindContextRightBottomDestination() { + val navigation = navigationHandle() + LeafDestination( + color = Color.Yellow, + nextKey = FindContext.Right.Bottom(navigation.key.id + 1), + ) +} + +@Composable +fun LeafDestination( + color: Color, + nextKey: NavigationKey.SupportsPush, +) { + val container = parentContainer + val navigation = navigationHandle() + Column( + modifier = Modifier + .fillMaxSize() + .background(color), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + val title = "${if (navigation.key::class.java.name.contains("Left")) "Left" else "Right"}.${navigation.key}" + val tagName = title.takeWhile { it != '(' } + Text( + text = title, + style = MaterialTheme.typography.caption, + ) + + Button(onClick = { container?.setActive() }) { + Text( + modifier = Modifier.testTag("set-active-$tagName"), + text = "Set Active", + ) + } + + Button(onClick = { + navigation.push(nextKey) + }) { + Text( + modifier = Modifier.testTag("push-$tagName"), + text = "Push", + ) + } + } +} + +@NavigationDestination(FindContext.Find::class) +@Composable +fun FindContextDialog() { + val navigation = navigationHandle() + val context = navigationContext + var selectedType by remember { mutableStateOf>(FindContext.Left.Top::class) } + var selectedId by remember { mutableStateOf("") } + DialogDestination { + Dialog(onDismissRequest = { navigation.requestClose() }) { + Surface( + shape = MaterialTheme.shapes.medium, + ) { + Column( + modifier = Modifier.padding(8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + TextField( + modifier = Modifier.testTag("id-input"), + value = selectedId, + onValueChange = { selectedId = it }, + placeholder = { Text(text = "(optional) id to search for") } + ) + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .clickable { selectedType = FindContext.Left.Top::class } + ) { + Text(text = "Find Left.Top") + Checkbox( + checked = selectedType == FindContext.Left.Top::class, + onCheckedChange = null, + ) + } + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .clickable { selectedType = FindContext.Left.Bottom::class } + ) { + Text(text = "Find Left.Bottom") + Checkbox( + checked = selectedType == FindContext.Left.Bottom::class, + onCheckedChange = null + ) + } + + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .clickable { selectedType = FindContext.Right.Top::class } + ) { + Text(text = "Find Right.Top") + Checkbox( + checked = selectedType == FindContext.Right.Top::class, + onCheckedChange = null + ) + } + + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .clickable { selectedType = FindContext.Right.Bottom::class } + ) { + Text(text = "Find Right.Bottom") + Checkbox( + checked = selectedType == FindContext.Right.Bottom::class, + onCheckedChange = null + ) + } + + Button( + modifier = Modifier.fillMaxWidth(), + onClick = { + val id = selectedId.toIntOrNull() + val foundContext = context.rootContext().findActiveContext { + val key = it.instruction?.navigationKey as? FindContext.HasId + key != null && key::class == selectedType && (id == null || key.id == id) + } + navigation.present(FindContext.FindResult(foundContext?.instruction?.navigationKey)) + } + ) { + Text(text = "Find Active Context") + } + + Button( + modifier = Modifier.fillMaxWidth(), + onClick = { + val id = selectedId.toIntOrNull() + val foundContext = context.rootContext().findContext { + val key = it.instruction?.navigationKey as? FindContext.HasId + key != null && key::class == selectedType && (id == null || key.id == id) + } + navigation.present(FindContext.FindResult(foundContext?.instruction?.navigationKey)) + } + ) { + Text(text = "Find Context") + } + } + } + } + } +} + +@NavigationDestination(FindContext.FindResult::class) +@Composable +fun FindContextResult() = DialogDestination { + val navigation = navigationHandle() + val found = navigation.key.found + Dialog(onDismissRequest = { navigation.requestClose() }) { + Surface( + shape = MaterialTheme.shapes.medium, + ) { + Box(modifier = Modifier.padding(8.dp)) { + if (found == null) { + Text(text = "No context found") + } else { + Text(text = "Found context: $found") + } + } + } + } +} diff --git a/tests/application/src/main/java/dev/enro/tests/application/compose/SyntheticViewModelAccess.kt b/tests/application/src/main/java/dev/enro/tests/application/compose/SyntheticViewModelAccess.kt new file mode 100644 index 00000000..0b69cc17 --- /dev/null +++ b/tests/application/src/main/java/dev/enro/tests/application/compose/SyntheticViewModelAccess.kt @@ -0,0 +1,83 @@ +package dev.enro.tests.application.compose + +import androidx.compose.material.Button +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewmodel.compose.viewModel +import dev.enro.annotations.NavigationDestination +import dev.enro.core.NavigationKey +import dev.enro.core.compose.navigationHandle +import dev.enro.core.present +import dev.enro.core.requireViewModel +import dev.enro.core.synthetic.syntheticDestination +import dev.enro.tests.application.compose.common.TitledColumn +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import kotlinx.parcelize.Parcelize + +@Parcelize +object SyntheticViewModelAccess : NavigationKey.SupportsPush { + @Parcelize + internal object AccessValidViewModel : NavigationKey.SupportsPresent + + @Parcelize + internal object AccessInvalidViewModel : NavigationKey.SupportsPresent +} + +class ViewModelForSyntheticViewModelAccess : ViewModel() { + private val mutableState = MutableStateFlow(0) + val state = mutableState as StateFlow + + fun onViewModelAccessed() { + mutableState.update { it+1 } + } +} + +@NavigationDestination(SyntheticViewModelAccess::class) +@Composable +fun SyntheticViewModelAccessDestination() { + val navigationHandle = navigationHandle() + val viewModel = viewModel() + val state by viewModel.state.collectAsState() + TitledColumn( + "Synthetic ViewModel Access" + ) { + Text(text = "ViewModel Accessed $state times") + + Button(onClick = { + navigationHandle.present(SyntheticViewModelAccess.AccessValidViewModel) + }) { + Text(text = "Access Valid ViewModel") + } + + Button(onClick = { + navigationHandle.present(SyntheticViewModelAccess.AccessInvalidViewModel) + }) { + Text(text = "Access Invalid ViewModel (throws)") + } + } +} + +// This destination should successfully access the ViewModelForSyntheticViewModelAccess from the Destination defined above, +// and trigger a side effect on that ViewModel +@NavigationDestination(SyntheticViewModelAccess.AccessValidViewModel::class) +internal val accessValidViewModel = syntheticDestination { + require(navigationContext.instruction?.navigationKey is SyntheticViewModelAccess) + + navigationContext.requireViewModel() + .onViewModelAccessed() +} + +class InvalidViewModel : ViewModel() + +// This destination should throw an exception for attempting to access an invalid ViewModel +@NavigationDestination(SyntheticViewModelAccess.AccessValidViewModel::class) +internal val accessInvalidViewModel = syntheticDestination { + require(navigationContext.instruction?.navigationKey is SyntheticViewModelAccess) + + navigationContext.requireViewModel() +}