Releases: jianastrero/JourneyKMP
v0.1.0
JourneyKMP
A type-safe, annotation-driven navigation library for Kotlin Multiplatform (Android & iOS), built on top of Navigation3.
You define a journey as a sealed interface. KSP reads it at build time and generates a typed controller per step, a sealed view class, and a ready-to-use Compose host. Screens can only navigate to the exits you declared — nothing more.
- Compile-time safety — exhaustive
whenon the view type means the compiler flags any unhandled step. - Typed controllers — each screen only sees the exits it declared; no accidental cross-step navigation.
- Zero boilerplate back stack —
NavDisplay+rememberSaveablewiring is generated for you and survives configuration changes. - Side-effects as data —
@Piggybackattaches analytics, logging, or cache calls to a step without touching screen code.
Supported Platforms
| Platform | Supported |
|---|---|
| Android | ✅ |
| iOS (arm64, simulator arm64) | ✅ |
| Desktop (JVM) | ❌ |
| Wasm / JS | ❌ |
How it works
flowchart TD
A["@Journey sealed interface\n(your code)"]
A -->|"KSP — build time"| B["XxxControllers.kt\nOne typed interface per step"]
A -->|"KSP — build time"| C["XxxView.kt\nSealed class — step data + controller"]
A -->|"KSP — build time"| D["XxxJourneyHost.kt\n@Composable + rememberSaveable NavDisplay"]
D -->|"content lambda"| E["when(view) { ... }"]
C -->|"sealed XxxView"| E
E --> F["Your screen composables"]
B -->|"typed controller"| FEach box you write is one sealed interface. Everything else — the controllers, the view type, the host composable, the back stack — is generated at build time and is never something you maintain.
Installation
Add the KSP plugin and JourneyKMP to your multiplatform module's build.gradle.kts:
plugins {
id("com.google.devtools.ksp") version "2.3.9"
}
kotlin {
sourceSets {
commonMain.dependencies {
implementation("dev.jianastrero:journey-kmp:0.1.0")
}
}
}
dependencies {
// Run the KSP processor against the common source set
add("kspCommonMainMetadata", "dev.jianastrero:journey-kmp-ksp:0.1.0")
}Note:
journey-kmp-annotationsis a transitive dependency ofjourney-kmp— no need to add it separately.
Usage
1. Define a journey
Create a sealed interface in commonMain, extend JourneyStep, and annotate it with @Journey. Each subclass is a step; @Exit declares where that step can navigate.
The example below is taken from the Bazaar sample app's sign-in flow. It shows two exits from a single step (password and SSO), data class steps that carry state forward through the journey, and piggybacks that fire analytics and session events automatically:
@Journey
sealed interface SignIn : JourneyStep {
// ON_ENTER: fires immediately — registers a screen view even if the user bounces.
// ON_EXIT: fires on departure regardless of direction — measures the credentials
// screen bounce rate from a single event stream.
@Step
@Piggyback("analytics:screen_view", on = ON_ENTER)
@Piggyback("analytics:credentials_screen_exit", on = ON_EXIT)
@Exit("toPassword", EnterPassword::class)
@Exit("toSSO", SSOLoading::class)
data object EnterCredentials : SignIn
// ON_EXIT for both: auth method and audit log are confirmed when the user *leaves*,
// whether they succeed or press back.
@Step
@Piggyback("analytics:auth_method_password", on = ON_EXIT)
@Piggyback("log:password_auth_attempt", on = ON_EXIT)
@Exit("toDone", Done::class)
data class EnterPassword(val username: String) : SignIn
// SSO provider is known on arrival — ON_ENTER is the right trigger here.
@Step
@Piggyback("analytics:auth_method_sso", on = ON_ENTER)
@Exit("toDone", Done::class)
data class SSOLoading(val provider: String) : SignIn
// Terminal step — KSP generates finish() on SignInDoneController.
// session:start fires on arrival; cache prefetch fires on exit (just before navigating away).
@Step
@Piggyback("analytics:signin_completed", on = ON_ENTER)
@Piggyback("session:start", on = ON_ENTER)
@Piggyback("cache:prefetch_user_data", on = ON_EXIT)
data object Done : SignIn
}Annotation reference
| Annotation | Purpose |
|---|---|
@Journey |
Marks the sealed interface; triggers KSP code generation |
@Step |
Marks a sealed subclass as a navigation step |
@Exit("name", To::class) |
Generates fun name(...) on the controller; parameters come from To's primary constructor |
@Piggyback("id", on = ...) |
Fires a named side-effect on ON_ENTER (default) or ON_EXIT |
Rules enforced at build time
- The interface must extend
JourneyStep - The first subclass must be a
data object(it is the initial step and requires no constructor arguments) - A step with no
@Exitis terminal — KSP generatesfun finish()on its controller
2. What gets generated
For the SignIn journey above, KSP produces three files in the same package:
| Generated file | Contents |
|---|---|
SignInControllers.kt |
One interface per step — SignInEnterCredentialsController, SignInEnterPasswordController, etc. Each exposes only the exits declared for that step plus back() from StepController. |
SignInView.kt |
sealed class SignInView with one inner class per step. Data class steps (e.g. EnterPassword) include a step property carrying the accumulated data; all steps include their controller. |
SignInJourneyHost.kt |
@Composable fun SignInJourneyHost(onFinish, content) wired to Nav3's NavDisplay with a rememberSaveable back stack. |
Generated: SignInJourneyHost.kt
package dev.jianastrero.journey.example.journeys
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.listSaver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.navigation3.runtime.NavEntry
import androidx.navigation3.ui.NavDisplay
import dev.jianastrero.journey.JourneyStep
import dev.jianastrero.journey.LocalPiggybackRegistry
import dev.jianastrero.journey.navigateTo
import kotlin.String
import kotlin.Unit
import kotlinx.coroutines.launch
@Composable
public fun SignInJourneyHost(onFinish: () -> Unit = {}, content: @Composable (SignInView) -> Unit) {
val backStack = rememberSaveable(saver = listSaver(
save = { list -> list.map { step ->
when (step) {
is SignIn.EnterCredentials -> "EnterCredentials"
is SignIn.EnterPassword -> "EnterPassword|${step.username}"
is SignIn.SSOLoading -> "SSOLoading|${step.provider}"
is SignIn.Done -> "Done"
else -> ""
}
}},
restore = { saved ->
mutableStateListOf(*saved.mapNotNull { encoded ->
val parts = encoded.split("|")
when (parts.getOrNull(0)) {
"EnterCredentials" -> SignIn.EnterCredentials
"EnterPassword" -> SignIn.EnterPassword(parts[1])
"SSOLoading" -> SignIn.SSOLoading(parts[1])
"Done" -> SignIn.Done
else -> null
}
}.toTypedArray())
}
)) { mutableStateListOf<JourneyStep>(SignIn.EnterCredentials) }
val piggybackRegistry = LocalPiggybackRegistry.current
val scope = rememberCoroutineScope()
NavDisplay(backStack = backStack, onBack = { backStack.removeLastOrNull() }) { step ->
when (step) {
is SignIn.EnterCredentials -> {
NavEntry(step) {
LaunchedEffect(step) {
piggybackRegistry?.fire("analytics:screen_view", stepId = "EnterCredentials", journeyId = "SignIn")
}
DisposableEffect(step) {
onDispose {
scope.launch { piggybackRegistry?.fire("analytics:credentials_screen_exit", stepId = "EnterCredentials", journeyId = "SignIn") }
}
}
val controller = remember(backStack) {
object : SignInEnterCredentialsController {
override fun toPassword(username: String) {
navigateTo(backStack, SignIn.EnterPassword(username))
}
override fun toSSO(provider: String) {
navigateTo(backStack, SignIn.SSOLoading(provider))
}
override fun back() {
backStack.removeLastOrNull()
}
}
}
content(SignInView.EnterCredentials(controller))
}
}
is SignIn.EnterPassword -> {
NavEntry(step) {
DisposableEffect(step) {
onDispose {
scope.launch { piggybackRegistry?.fire("analytics:auth_method_password", stepId = "EnterPassword", journeyId = "SignIn") }
scope.launch { piggybackRegistry?.fire("log:password_auth_attempt", stepId = "EnterPassword", journeyId = "SignIn") }
}
}
...