diff --git a/gradle/build-logic/convention/src/main/kotlin/LintConventionPlugin.kt b/gradle/build-logic/convention/src/main/kotlin/LintConventionPlugin.kt index 027004d..59fb6ac 100644 --- a/gradle/build-logic/convention/src/main/kotlin/LintConventionPlugin.kt +++ b/gradle/build-logic/convention/src/main/kotlin/LintConventionPlugin.kt @@ -39,6 +39,7 @@ class LintConventionPlugin : Plugin { "JavaPluginLanguageLevel", "AndroidGradlePluginVersion", "EnsureInitializerMetadata", + "ObsoleteLintCustomCheck", "VectorPath" ) } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5fd98eb..dcbfcaf 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -8,12 +8,13 @@ agp = "8.7.0" androidTools = "31.7.0" kotlin = "2.0.20" kotlinx-coroutines = "1.9.0" +kotlinx-serialization-json = "1.7.1" kotlin-kover = "0.8.3" androidx-compose = "1.7.2" androidx-core = "1.13.1" androidx-lifecycle = "2.8.6" androidx-material = "1.7.2" -androidx-navigation = "2.7.7" +androidx-navigation = "2.8.1" androidx-startup = "1.2.0" androidx-test = "1.6.1" androidx-test-ext = "1.2.1" @@ -23,7 +24,7 @@ androidx-espresso = "3.6.1" androidx-espresso-device = "1.0.1" androidx-espresso-intents = "3.6.1" androidx-uiautomator = "2.3.0" -compose-destinations = "1.10.2" +compose-destinations = "1.11.6" hilt = "2.52" koin = "3.5.6" detekt = "1.23.7" @@ -38,6 +39,7 @@ deps-catalog-updater = "0.8.4" [libraries] kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" } kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" } +kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization-json" } androidx-compose-foundation = { module = "androidx.compose.foundation:foundation", version.ref = "androidx-compose" } androidx-compose-material = { module = "androidx.compose.material:material", version.ref = "androidx-material" } androidx-compose-runtime = { module = "androidx.compose.runtime:runtime", version.ref = "androidx-compose" } @@ -97,6 +99,7 @@ android-library = { id = "com.android.library", version.ref = "agp" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-kapt = { id = "org.jetbrains.kotlin.kapt", version.ref = "kotlin" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } # Plugins defined by this project android-application-convention = { id = "android-application.convention" } diff --git a/instrumented-tests/.gitignore b/instrumented-tests/.gitignore deleted file mode 100644 index 42afabf..0000000 --- a/instrumented-tests/.gitignore +++ /dev/null @@ -1 +0,0 @@ -/build \ No newline at end of file diff --git a/navigation-destinations/.gitignore b/navigation-destinations/.gitignore deleted file mode 100644 index 42afabf..0000000 --- a/navigation-destinations/.gitignore +++ /dev/null @@ -1 +0,0 @@ -/build \ No newline at end of file diff --git a/navigation-destinations/src/main/AndroidManifest.xml b/navigation-destinations/src/main/AndroidManifest.xml deleted file mode 100644 index 8072ee0..0000000 --- a/navigation-destinations/src/main/AndroidManifest.xml +++ /dev/null @@ -1,2 +0,0 @@ - - diff --git a/navigation-destinations/src/main/kotlin/com/miquido/android/navigation/NavActionExt.kt b/navigation-destinations/src/main/kotlin/com/miquido/android/navigation/NavActionExt.kt index 0d06f77..ac2b0b3 100644 --- a/navigation-destinations/src/main/kotlin/com/miquido/android/navigation/NavActionExt.kt +++ b/navigation-destinations/src/main/kotlin/com/miquido/android/navigation/NavActionExt.kt @@ -7,20 +7,22 @@ import com.ramcosta.composedestinations.spec.Direction import com.ramcosta.composedestinations.spec.DirectionDestinationSpec import com.ramcosta.composedestinations.spec.Route -operator fun NavAction.To.Companion.invoke( +fun NavAction.NavigateTo.Companion.direction( direction: Direction, options: NavOptions = EMPTY_OPTIONS -): NavAction.To = NavAction.To(direction.route, options) +): NavAction.NavigateTo = NavAction.NavigateTo(direction.route, options) -operator fun NavAction.Pop.Companion.invoke( +fun NavAction.PopBackTo.Companion.route( route: Route, inclusive: Boolean, saveState: Boolean = false -): NavAction.Pop = NavAction.Pop(route.route, inclusive, saveState) +): NavAction.PopBackTo = NavAction.PopBackTo(route.route, inclusive, saveState) -fun NavAction.To.direction(): Direction = Direction(direction) +fun NavAction.NavigateTo.direction(): Direction = + Direction(requireNotNull(directionString) { "ramcosta.composedestinations.Direction requires a directions defined by string." }) -fun NavAction.Pop.route(): Route = RouteIml(route) +fun NavAction.PopBackTo.route(): Route = + RouteIml(requireNotNull(routeString) { "ramcosta.composedestinations.Route requires route defined by string." }) private data class RouteIml( override val route: String, diff --git a/navigation-destinations/src/main/kotlin/com/miquido/android/navigation/NavigatorExt.kt b/navigation-destinations/src/main/kotlin/com/miquido/android/navigation/NavigatorExt.kt index 36d7024..fd6e1b5 100644 --- a/navigation-destinations/src/main/kotlin/com/miquido/android/navigation/NavigatorExt.kt +++ b/navigation-destinations/src/main/kotlin/com/miquido/android/navigation/NavigatorExt.kt @@ -7,14 +7,14 @@ import kotlinx.coroutines.flow.Flow import kotlin.reflect.KClass /** - * Navigate to a route in the current NavGraph. If an invalid route is given, an IllegalArgumentException will be thrown. + * Navigate to a destination in the current NavGraph. If an invalid route is given, an IllegalArgumentException will be thrown. * * @param direction route for the destination * @param builder DSL for constructing a new [androidx.navigation.NavOptions] * * @see [androidx.navigation.NavController.navigate] */ -suspend fun Navigator.navigate(direction: Direction, builder: NavOptionsBuilder.() -> Unit = {}) = +suspend fun Navigator.navigateToDirection(direction: Direction, builder: NavOptionsBuilder.() -> Unit = {}) = navigate(direction.route, builder) /** @@ -27,7 +27,7 @@ suspend fun Navigator.navigate(direction: Direction, builder: NavOptionsBuilder. * * @see [androidx.navigation.NavController.popBackStack] */ -suspend fun Navigator.popBackStack(route: Route, inclusive: Boolean, saveState: Boolean = false) = +suspend fun Navigator.popBackToRoute(route: Route, inclusive: Boolean, saveState: Boolean = false) = popBackStack(route.route, inclusive, saveState) /** diff --git a/navigation-hilt/.gitignore b/navigation-hilt/.gitignore deleted file mode 100644 index 42afabf..0000000 --- a/navigation-hilt/.gitignore +++ /dev/null @@ -1 +0,0 @@ -/build \ No newline at end of file diff --git a/navigation-koin/.gitignore b/navigation-koin/.gitignore deleted file mode 100644 index 42afabf..0000000 --- a/navigation-koin/.gitignore +++ /dev/null @@ -1 +0,0 @@ -/build \ No newline at end of file diff --git a/navigation-runtime/.gitignore b/navigation-runtime/.gitignore deleted file mode 100644 index 42afabf..0000000 --- a/navigation-runtime/.gitignore +++ /dev/null @@ -1 +0,0 @@ -/build \ No newline at end of file diff --git a/navigation-runtime/build.gradle.kts b/navigation-runtime/build.gradle.kts index c5cac66..2fd6bca 100644 --- a/navigation-runtime/build.gradle.kts +++ b/navigation-runtime/build.gradle.kts @@ -1,6 +1,7 @@ plugins { alias(libs.plugins.android.library.convention) alias(libs.plugins.android.library.compose.convention) + alias(libs.plugins.kotlin.serialization) alias(libs.plugins.publishing) } @@ -13,6 +14,7 @@ dependencies { implementation(libs.androidx.navigation.compose) implementation(libs.kotlinx.coroutines.core) + implementation(libs.kotlinx.serialization.json) testImplementation(libs.bundles.test) } diff --git a/navigation-runtime/src/main/AndroidManifest.xml b/navigation-runtime/src/main/AndroidManifest.xml deleted file mode 100644 index 8072ee0..0000000 --- a/navigation-runtime/src/main/AndroidManifest.xml +++ /dev/null @@ -1,2 +0,0 @@ - - diff --git a/navigation-runtime/src/main/kotlin/com/miquido/android/navigation/NavAction.kt b/navigation-runtime/src/main/kotlin/com/miquido/android/navigation/NavAction.kt index 1390022..c9f0f63 100644 --- a/navigation-runtime/src/main/kotlin/com/miquido/android/navigation/NavAction.kt +++ b/navigation-runtime/src/main/kotlin/com/miquido/android/navigation/NavAction.kt @@ -7,10 +7,23 @@ import androidx.navigation.navOptions val EMPTY_OPTIONS = navOptions {} sealed class NavAction { - data class To( - val direction: String, + @ConsistentCopyVisibility + data class NavigateTo private constructor( + val directionString: String? = null, + val directionAny: Any? = null, val options: NavOptions = EMPTY_OPTIONS ) : NavAction() { + + constructor(direction: String, options: NavOptions = EMPTY_OPTIONS) : this( + directionString = direction, + options = options + ) + + constructor(direction: Any, options: NavOptions = EMPTY_OPTIONS) : this( + directionAny = direction, + options = options + ) + companion object } @@ -19,13 +32,28 @@ sealed class NavAction { val options: NavOptions = EMPTY_OPTIONS ) : NavAction() - data class Pop( - val route: String, - val inclusive: Boolean, + @ConsistentCopyVisibility + data class PopBackTo private constructor( + val routeString: String? = null, + val routeAny: Any? = null, + val inclusive: Boolean = false, val saveState: Boolean = false ) : NavAction() { + + constructor(route: String, inclusive: Boolean, saveState: Boolean = false) : this( + routeString = route, + inclusive = inclusive, + saveState = saveState + ) + + constructor(route: Any, inclusive: Boolean, saveState: Boolean = false) : this( + routeAny = route, + inclusive = inclusive, + saveState = saveState + ) + companion object } - object Up : NavAction() + object NavigateUp : NavAction() } diff --git a/navigation-runtime/src/main/kotlin/com/miquido/android/navigation/Navigator.kt b/navigation-runtime/src/main/kotlin/com/miquido/android/navigation/Navigator.kt index 1c35043..4b78f3b 100644 --- a/navigation-runtime/src/main/kotlin/com/miquido/android/navigation/Navigator.kt +++ b/navigation-runtime/src/main/kotlin/com/miquido/android/navigation/Navigator.kt @@ -22,7 +22,7 @@ interface Navigator { val previousNavEntry: NavEntryInfo? /** - * Navigate to a route in the current NavGraph. + * Navigate to a destination in the current NavGraph. * If an invalid route is given, an IllegalArgumentException will be thrown. * * @param direction route for the destination @@ -32,6 +32,17 @@ interface Navigator { */ suspend fun navigate(direction: String, builder: NavOptionsBuilder.() -> Unit = {}) + /** + * Navigate to a destination in the current NavGraph. + * If an invalid route is given, an IllegalArgumentException will be thrown. + * + * @param direction route for the destination + * @param builder DSL for constructing a new [androidx.navigation.NavOptions] + * + * @see [androidx.navigation.NavController.navigate] + */ + suspend fun navigate(direction: Any, builder: NavOptionsBuilder.() -> Unit = {}) + /** * Navigate to a destination via the given deep link Uri. * If an invalid deep link is given, an IllegalArgumentException will be thrown. @@ -53,6 +64,18 @@ interface Navigator { */ suspend fun popBackStack(route: String, inclusive: Boolean, saveState: Boolean = false) + /** + * Attempts to pop back stack. + * + * @param route the topmost destination to retain + * @param inclusive - whether the given destination should also be popped. + * @param saveState - whether the back stack and the state of all destinations between the current destination + * and the route should be saved for later restoration via [androidx.navigation.NavOptions] + * + * @see [androidx.navigation.NavController.popBackStack] + */ + suspend fun popBackStack(route: Any, inclusive: Boolean, saveState: Boolean = false) + /** * Attempts to navigate up in the navigation hierarchy. * @see [androidx.navigation.NavController.navigateUp] diff --git a/navigation-runtime/src/main/kotlin/com/miquido/android/navigation/NavigatorImpl.kt b/navigation-runtime/src/main/kotlin/com/miquido/android/navigation/NavigatorImpl.kt index 78efee6..8e17094 100644 --- a/navigation-runtime/src/main/kotlin/com/miquido/android/navigation/NavigatorImpl.kt +++ b/navigation-runtime/src/main/kotlin/com/miquido/android/navigation/NavigatorImpl.kt @@ -4,6 +4,9 @@ import android.net.Uri import androidx.activity.result.contract.ActivityResultContract import androidx.navigation.NavOptionsBuilder import androidx.navigation.navOptions +import com.miquido.android.navigation.NavAction.Deeplink +import com.miquido.android.navigation.NavAction.NavigateTo +import com.miquido.android.navigation.NavAction.PopBackTo import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.callbackFlow @@ -18,19 +21,27 @@ internal class NavigatorImpl( get() = navigation.previousNavEntry override suspend fun navigate(direction: String, builder: NavOptionsBuilder.() -> Unit) { - navigation.dispatchAction(navEntryId, NavAction.To(direction, navOptions(builder))) + navigation.dispatchAction(navEntryId, NavigateTo(direction, navOptions(builder))) + } + + override suspend fun navigate(direction: Any, builder: NavOptionsBuilder.() -> Unit) { + navigation.dispatchAction(navEntryId, NavigateTo(direction, navOptions(builder))) } override suspend fun navigate(deeplink: Uri, builder: NavOptionsBuilder.() -> Unit) { - navigation.dispatchAction(navEntryId, NavAction.Deeplink(deeplink, navOptions(builder))) + navigation.dispatchAction(navEntryId, Deeplink(deeplink, navOptions(builder))) } override suspend fun popBackStack(route: String, inclusive: Boolean, saveState: Boolean) { - navigation.dispatchAction(navEntryId, NavAction.Pop(route, inclusive, saveState)) + navigation.dispatchAction(navEntryId, PopBackTo(route, inclusive, saveState)) + } + + override suspend fun popBackStack(route: Any, inclusive: Boolean, saveState: Boolean) { + navigation.dispatchAction(navEntryId, PopBackTo(route, inclusive, saveState)) } override suspend fun navigateUp() { - navigation.dispatchAction(navEntryId, NavAction.Up) + navigation.dispatchAction(navEntryId, NavAction.NavigateUp) } override suspend fun setNavResult(result: R) { diff --git a/navigation-runtime/src/main/kotlin/com/miquido/android/navigation/handler/NavActionsHandler.kt b/navigation-runtime/src/main/kotlin/com/miquido/android/navigation/handler/NavActionsHandler.kt index 381b8d2..88f238b 100644 --- a/navigation-runtime/src/main/kotlin/com/miquido/android/navigation/handler/NavActionsHandler.kt +++ b/navigation-runtime/src/main/kotlin/com/miquido/android/navigation/handler/NavActionsHandler.kt @@ -6,7 +6,10 @@ import androidx.lifecycle.Lifecycle.State.RESUMED import androidx.lifecycle.flowWithLifecycle import androidx.navigation.NavBackStackEntry import androidx.navigation.NavController -import com.miquido.android.navigation.NavAction +import com.miquido.android.navigation.NavAction.Deeplink +import com.miquido.android.navigation.NavAction.NavigateTo +import com.miquido.android.navigation.NavAction.NavigateUp +import com.miquido.android.navigation.NavAction.PopBackTo import com.miquido.android.navigation.viewmodel.AbstractNavigationViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -22,11 +25,23 @@ internal fun NavActionsHandler( .collect { action -> withContext(Dispatchers.Main) { when (action) { - is NavAction.To -> navController.navigate(action.direction, action.options) - is NavAction.Deeplink -> navController.navigate(action.uri, action.options) - is NavAction.Pop -> navController.popBackStack(action.route, action.inclusive, action.saveState) - is NavAction.Up -> navController.navigateUp() + is NavigateTo -> navController.navigateTo(action) + is Deeplink -> navController.navigate(action.uri, action.options) + is PopBackTo -> navController.popBackTo(action) + is NavigateUp -> navController.navigateUp() } } } } + +private fun NavController.navigateTo(direction: NavigateTo) = when { + direction.directionString != null -> navigate(direction.directionString) + direction.directionAny != null -> navigate(direction.directionAny) + else -> throw IllegalArgumentException("Invalid direction provided!") +} + +private fun NavController.popBackTo(direction: PopBackTo) = when { + direction.routeString != null -> popBackStack(direction.routeString, direction.inclusive, direction.saveState) + direction.routeAny != null -> popBackStack(direction.routeAny, direction.inclusive, direction.saveState) + else -> throw IllegalArgumentException("Invalid route provided!") +} diff --git a/navigation-runtime/src/test/kotlin/com/miquido/android/navigation/NavigationTest.kt b/navigation-runtime/src/test/kotlin/com/miquido/android/navigation/NavigationTest.kt index 4567545..d783240 100644 --- a/navigation-runtime/src/test/kotlin/com/miquido/android/navigation/NavigationTest.kt +++ b/navigation-runtime/src/test/kotlin/com/miquido/android/navigation/NavigationTest.kt @@ -7,8 +7,13 @@ import androidx.navigation.navOptions import app.cash.turbine.test import app.cash.turbine.turbineScope import com.google.common.truth.Truth.assertThat +import com.miquido.android.navigation.NavAction.Deeplink +import com.miquido.android.navigation.NavAction.NavigateTo +import com.miquido.android.navigation.NavAction.NavigateUp +import com.miquido.android.navigation.NavAction.PopBackTo import io.mockk.mockk import kotlinx.coroutines.test.runTest +import kotlinx.serialization.Serializable import org.junit.jupiter.api.Test internal class NavigationTest { @@ -27,7 +32,7 @@ internal class NavigationTest { } @Test - fun `navigator navigate with direction is emitted as nav to action`() = runTest { + fun `navigator navigate with direction string is emitted as NavigateTo action`() = runTest { val direction = "dashboard" val options: NavOptionsBuilder.() -> Unit = { popUpTo("authentication") { @@ -38,12 +43,34 @@ internal class NavigationTest { navigator.navigate(direction, options) navigation.navActions(NavEntryId("nav-entry")).test { - assertThat(awaitItem()).isEqualTo(NavAction.To(direction, navOptions(options))) + assertThat(awaitItem()).isEqualTo(NavigateTo(direction, navOptions(options))) } } @Test - fun `navigator navigate with deeplink is emitted as deeplink action`() = runTest { + fun `navigator navigate with direction object is emitted as NavigateTo action`() = runTest { + @Serializable + class Dashboard + + @Serializable + class Authentication + + val direction = Dashboard() + val options: NavOptionsBuilder.() -> Unit = { + popUpTo { + inclusive = true + } + } + + navigator.navigate(direction, options) + + navigation.navActions(NavEntryId("nav-entry")).test { + assertThat(awaitItem()).isEqualTo(NavigateTo(direction, navOptions(options))) + } + } + + @Test + fun `navigator navigate with deeplink is emitted as Deeplink action`() = runTest { val deeplink = mockk() val options: NavOptionsBuilder.() -> Unit = { popUpTo("authentication") { @@ -54,32 +81,46 @@ internal class NavigationTest { navigator.navigate(deeplink, options) navigation.navActions(NavEntryId("nav-entry")).test { - assertThat(awaitItem()).isEqualTo(NavAction.Deeplink(deeplink, navOptions(options))) + assertThat(awaitItem()).isEqualTo(Deeplink(deeplink, navOptions(options))) + } + } + + @Test + fun `navigator pop back stack with route string is emitted as PopBackTo action`() = runTest { + @Serializable + class Authentication + + val route = Authentication() + + navigator.popBackStack(route, inclusive = false) + + navigation.navActions(NavEntryId("nav-entry")).test { + assertThat(awaitItem()).isEqualTo(PopBackTo(route, inclusive = false)) } } @Test - fun `navigator pop back stack is emitted as nav pop action`() = runTest { + fun `navigator pop back stack with route object is emitted as PopBackTo action`() = runTest { val route = "authentication" navigator.popBackStack(route, inclusive = false) navigation.navActions(NavEntryId("nav-entry")).test { - assertThat(awaitItem()).isEqualTo(NavAction.Pop(route, inclusive = false)) + assertThat(awaitItem()).isEqualTo(PopBackTo(route, inclusive = false)) } } @Test - fun `navigator navigate up is emitted as nav up action`() = runTest { + fun `navigator navigate up is emitted as NavigateUp action`() = runTest { navigator.navigateUp() navigation.navActions(NavEntryId("nav-entry")).test { - assertThat(awaitItem()).isEqualTo(NavAction.Up) + assertThat(awaitItem()).isEqualTo(NavigateUp) } } @Test - fun `navigator set result is emitted as nav result`() = runTest { + fun `navigator set result is emitted as NavResult`() = runTest { navigator.setNavResult("result") navigation.navResults(NavEntryId("nav-entry")).test { @@ -88,7 +129,7 @@ internal class NavigationTest { } @Test - fun `navigator launch for result is emitted as nav result launch`() = runTest { + fun `navigator launch for result is emitted as NavResultLaunch`() = runTest { navigator.launchForResult(PickContact::class, null) navigation.resultLaunches(NavEntryId("nav-entry")).test { diff --git a/sample-hilt/.gitignore b/sample-hilt/.gitignore deleted file mode 100644 index 42afabf..0000000 --- a/sample-hilt/.gitignore +++ /dev/null @@ -1 +0,0 @@ -/build \ No newline at end of file diff --git a/sample-koin/.gitignore b/sample-koin/.gitignore deleted file mode 100644 index 42afabf..0000000 --- a/sample-koin/.gitignore +++ /dev/null @@ -1 +0,0 @@ -/build \ No newline at end of file