Skip to content

Releases: jianastrero/JourneyKMP

v0.1.0

03 Jun 12:35

Choose a tag to compare

JourneyKMP

Maven Central
Kotlin
License
Android
iOS

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 when on 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 stackNavDisplay + rememberSaveable wiring is generated for you and survives configuration changes.
  • Side-effects as data@Piggyback attaches 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"| F

Each 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-annotations is a transitive dependency of journey-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 @Exit is terminal — KSP generates fun 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") }
            }
          }
...
Read more