DEV Community

Cover image for User Authentication Flow with Compose Multiplatform
Gerardo Rodriguez
Gerardo Rodriguez

Posted on • Edited on

User Authentication Flow with Compose Multiplatform

In this guide you'll learn how to make a simple User Authentication Flow using Compose Multiplatform (for an Android mobile app and a Wasm web app)

Before we begin

All of the code is in the support repositories. I'll try to only mention the relevant parts related to the navigation, so you can catch the overall idea. Please review the other classes if you have questions about the dependency injection using koin, the screens and ViewModels

Support repositories

Overview of the project

This is the app we will be doing

Sample app

Navigation graph

Navigation Graph

Navigation flow

Navigation flow

Components

Components

Getting started

The easiest way to start is by creating a new project with the Kotlin Multiplatform Wizard

Show me the code

So let's start by making the navigation graph

NavHost(
    navController = navHostController,
    startDestination = Init,
) {
    composable<Init> {
        // This is required so the ViewModel makes the first request to server
        val initViewModel = koinViewModel<InitViewModel>()
        InitScreen()
    }

    composable<Login> {
        val loginViewModel = koinViewModel<LoginViewModel>()
        LoginScreen(
            onLogin = { username: String, password: String ->
                loginViewModel.login(username, password)
            }
        )
    }

    // Nested navigation graph
    navigation<Home>(startDestination = UserSettings) {
        home()
    }
}
Enter fullscreen mode Exit fullscreen mode

And the nested graph

fun NavGraphBuilder.home() {
    composable<UserSettings> {
        val userSettingsViewModel = koinViewModel<UserSettingsViewModel>()
        UserSettingsScreen(
            onLogout = { userSettingsViewModel.logout() },
        )
    }
}
Enter fullscreen mode Exit fullscreen mode

Using the NavController from the navigation library you can navigate from any composable easily doing something like navHostController.popBackStack() but because the ViewModels are the ones that do the requests to server to determine if the user is logged or not, how do we navigate from inside a ViewModel?

To do this we would need to implement 3 things:

  • NavigationController: This will emit NavigationEvents which are an abstraction on top of the navigation library
  • NavigationControllerObserver: This will listen to the navigation events emitted by the NavigationController and use the NavController to navigate. Basically converting them to the actual navigation
  • RegisterNavigationControllerObserver: Is an extension function that makes the observer, and connects it to the NavController and the NavigationController

NavigationController

/**
 * Proxy to send and receive navigation events from the navController inside ViewModels
 */
interface NavigationController {
    val navigationEvents: SharedFlow<NavigationEvent>

    suspend fun sendNavigationEvent(navigationEvent: NavigationEvent)

    sealed interface NavigationEvent {
        data class Navigate(
            val destinationRoute: Route,
            val launchSingleTop: Boolean = false,
            val restoreState: Boolean = false,
            val popUpTo: PopUpTo? = null,
        ) : NavigationEvent {
            data class PopUpTo(
                val startRoute: Route,
                val isInclusive: Boolean,
                val saveState: Boolean,
            )
        }

        data object PopBackStack : NavigationEvent
    }
}

class DefaultNavigationController : NavigationController {
    private val _navigationEvents = MutableSharedFlow<NavigationEvent>()
    override val navigationEvents: SharedFlow<NavigationEvent> = _navigationEvents

    override suspend fun sendNavigationEvent(navigationEvent: NavigationEvent) {
        _navigationEvents.emit(navigationEvent)
    }
}
Enter fullscreen mode Exit fullscreen mode

NavigationControllerObserver

/**
 * Observer that converts NavigationEvents from the [NavigationController] into actual navigation using the NavHost
 */
interface NavigationControllerObserver {
    val navigationController: NavigationController
    val navHostController: NavHostController

    suspend fun subscribe() {
        coroutineScope {
            launch {
                navigationController.navigationEvents.collect(::onNavigationEvent)
            }
        }
    }

    fun onNavigationEvent(navigationEvent: NavigationEvent)
}

class DefaultNavigationControllerObserver(
    override val navigationController: NavigationController,
    override val navHostController: NavHostController,
) : NavigationControllerObserver {

    override fun onNavigationEvent(navigationEvent: NavigationEvent) {
        when (navigationEvent) {
            is Navigate -> {
                navHostController.navigate(route = navigationEvent.destinationRoute) {
                    navigationEvent.popUpTo?.let { popUpTo ->
                        popUpTo(popUpTo.startRoute) {
                            inclusive = popUpTo.isInclusive
                            saveState = popUpTo.saveState
                        }
                    }
                }
            }

            is PopBackStack -> navHostController.popBackStack()
        }
    }
}

@Composable
fun rememberNavigationObserver(
    navigationController: NavigationController,
    navHostController: NavHostController,
): NavigationControllerObserver =
    remember {
        DefaultNavigationControllerObserver(navigationController, navHostController)
}
Enter fullscreen mode Exit fullscreen mode

Navigation extensions

fun popUpToInclusive(
    startRoute: Route,
    saveState: Boolean = false,
): NavigationEvent.Navigate.PopUpTo =
    NavigationEvent.Navigate.PopUpTo(
        startRoute = startRoute,
        isInclusive = true,
        saveState = saveState,
    )

/**
 * Convenience composable that connects the NavHost and the [NavigationController] by a [NavigationControllerObserver]
 */
@Composable
fun RegisterNavigationControllerObserver(
    navigationController: NavigationController,
    navHostController: NavHostController
) {
    val navigationControllerObserver: NavigationControllerObserver =
        rememberNavigationObserver(navigationController, navHostController)

    LaunchedEffect(navHostController, navigationController, navigationControllerObserver) {
        navigationControllerObserver.subscribe()
    }
}
Enter fullscreen mode Exit fullscreen mode

Let's see how this works with the LoginViewModel

class LoginViewModel(
    // The data source to send requests to server
    private val authDataSource: AuthDataSource,
    // Some utility class to get the io and ui dispatchers
    dispatchersProvider: DispatchersProvider, 
    // The NavigationController we created to navigate
    private val navigationController: NavigationController,
) : ViewModel() {
    // Not related to the navigation, but needed for the request
    private var loginJob: Job? = null
    private val ioScope = CoroutineScope(dispatchersProvider.io())

    fun login(username: String, password: String) {
        loginJob = ioScope.launch {
            // We do the request to the server
            val loginResult = authDataSource.login(username = username, password = password)
            if (loginResult) {
                // If the request to login was successful we navigate to Home
                navigationController.login()
            } else {
                // For simplicity, we are not notifying the user if there was an error with the login
            }
        }
    }

    // Convenience extension to navigate to Home
    private suspend fun NavigationController.login() {
        sendNavigationEvent(
            NavigationEvent.Navigate(
                destinationRoute = Home,
                launchSingleTop = true,
                popUpTo = popUpToInclusive(startRoute = Login),
            )
        )
    }

    // More code...
}
Enter fullscreen mode Exit fullscreen mode

This is how you navigate from inside a ViewModel. Remember to call the RegisterNavigationControllerObserver extension function that glues everything, just over your NavHost like this

RegisterNavigationControllerObserver(
    navigationController = koinInject(),
    navHostController = navHostController
)

NavHost(...)
Enter fullscreen mode Exit fullscreen mode

Advantages of this approach

I've found this approach to be useful for the following reasons:

  • You have a single global entry-point which is the Init destination. Here you always check if the user is logged or not, then remove the destination, so when the user tries to go back, it exits the app
  • You can very easily add and remove screens by using nested navigation graphs
  • You have a unified component (the NavigationController) to do navigation from your non-compose code which is very handy and can even be mocked for unit tests
  • All the code is shared between Android and Wasm targets so the navigation is actually multiplatform but if you wanted you could adjust the NavigationController depending on the platform you are on
  • You can add events (like analytics or logs) on each navigation event if you wanted

Things that can be improved

To make this into a proper production-ready solution, we would need to add a few improvements to the authentication flow:

  • A dependencies scope that only exists if the user is logged, so we don't leak logged user data to a non-logged user
  • Automatic navigation to Login destination if the user is logged out for any reason
  • Persistent session handling, so exiting the app doesn't logs out the user
  • BottomNavigation or NavigationRail when the user is logged in and depending of the screen size of the device
  • Token encryption for platforms that handle token based authentication (like mobile)
  • Real data source to make actual requests to server

Top comments (0)