DEV Community

Cover image for KMP Native UI Login and Profile screens, DataStore, navigation, and more
Saad Alkentar
Saad Alkentar

Posted on

KMP Native UI Login and Profile screens, DataStore, navigation, and more

We are all set now. Our KMP Native UI app is fully configured with

  • Koin dependency injection
  • Ktor networking client
  • and structured according to Clean architecture

What next? I believe most of you are set and ready to go.

This tutorial will build upon the previous ones. I'll use native navigation for both Android and iOS to build a login screen, pass data between the screens using a shared ViewModel, try to save the token, and improve the product list a little.
A reminder of two important points from the previous tutorial

What are the app features?

For our simple project, we will add two features.

  • Auth feature: Where we can fill in the login form and navigate to the Home and Profile screens.
  • Products feature: where we can list products and show their details. (already done the list so I'll work on it anymore)

Where to get it from?

I found a website called dummy json that provides both Auth and Products endpoints. We have already used the Products API in a previous tutorial. The Auth will be covered here.

Auth Clean Architecture shared module

Similar to the product's features, the folder structure would look like

-shared
--commonMein

---auth

----data
-----AuthDataSource.kt
-----AuthRepositoryImpl.kt

----domain
-----models
-----AuthRepository.kt

----ui
-----Login
------LoginActions.kt
------LoginState.kt
------LoginViewModel.kt

-----Profile
------ProfileActions.kt
------ProfileState.kt
------ProfileViewModel.kt
Enter fullscreen mode Exit fullscreen mode

We have two screens using the same repository here.

Auth models

We are following the schemas from Auth and using the JSON To Kotlin Class extension. We need the login request, refresh token request, login response, and get auth user response schemas.

shared/commonMein/auth/domain/models/LoginRequest.kt

@Serializable
data class LoginRequest(
    @SerialName("password")
    val password: String? = null,
    @SerialName("username")
    val username: String? = null,
    @SerialName("expiresInMins")
    val expiresInMins: Int? = null,
)
Enter fullscreen mode Exit fullscreen mode

shared/commonMein/auth/domain/models/LoginResponse.kt

@Serializable
data class LoginResponse(
    @SerialName("accessToken")
    val accessToken: String? = null,
    @SerialName("email")
    val email: String? = null,
    @SerialName("firstName")
    val firstName: String? = null,
    @SerialName("gender")
    val gender: String? = null,
    @SerialName("id")
    val id: Int? = null,
    @SerialName("image")
    val image: String? = null,
    @SerialName("lastName")
    val lastName: String? = null,
    @SerialName("refreshToken")
    val refreshToken: String? = null,
    @SerialName("username")
    val username: String? = null
)
Enter fullscreen mode Exit fullscreen mode

shared/commonMein/auth/domain/models/RefreshRequest.kt

@Serializable
data class RefreshRequest(
    @SerialName("refreshToken")
    val refreshToken: String? = null
)
Enter fullscreen mode Exit fullscreen mode

shared/commonMein/auth/domain/models/UserModel.kt

@Serializable
data class UserModel(
    @SerialName("email")
    val email: String? = null,
    @SerialName("firstName")
    val firstName: String? = null,
    @SerialName("gender")
    val gender: String? = null,
    @SerialName("id")
    val id: Int? = null,
    @SerialName("image")
    val image: String? = null,
    @SerialName("lastName")
    val lastName: String? = null,
    @SerialName("username")
    val username: String? = null
)
Enter fullscreen mode Exit fullscreen mode

If there are too many models, I would further separate them into requests, responses, and entities.

Auth repository

We only have three requests

shared/commonMein/auth/domain/AuthRepository.kt

interface AuthRepository {
    suspend fun login(username: String, password: String) : DataState<LoginResponse, DataError.Remote>
    suspend fun refreshToken(refreshToken: String) : DataState<RefreshResponse, DataError.Remote>
    suspend fun getUser() : DataState<UserModel, DataError.Remote>

}
Enter fullscreen mode Exit fullscreen mode

We are done with the domain layer. Let's fill in the data layer

shared/commonMein/auth/data/AuthDataSource.kt

class AuthDataSource(
    private val httpClient: HttpClient
) {

    suspend fun authLogin(
        request: LoginRequest,
    ): HttpResponse {
        return httpClient.post (
            urlString = "$BASE_URL/auth/login"
        ) {
            setBody(request)
        }
    }

    suspend fun authRefresh(
        request: RefreshRequest,
    ): HttpResponse {
        return httpClient.post (
            urlString = "$BASE_URL/auth/refresh"
        ) {
            setBody(request)
        }
    }

    suspend fun authMe(): HttpResponse {
        return httpClient.get(
            urlString = "$BASE_URL/auth/me"
        )
    }
}
Enter fullscreen mode Exit fullscreen mode

shared/commonMein/auth/data/AuthRepositoryImpl.kt

class AuthRepositoryImpl(
    private val dataSource: AuthDataSource
) : AuthRepository {

    override suspend fun login(
        username: String,
        password: String
    ): DataState<LoginResponse, DataError.Remote> =
        safeCall<LoginResponse> {
            dataSource.authLogin(
                request = LoginRequest(
                    username = username,
                    password = password,
                    expiresInMins = 1,
                )
            )

        }.map { it }

    override suspend fun refreshToken(
        refreshToken: String
    ): DataState<RefreshResponse, DataError.Remote> =
        safeCall<RefreshResponse> {
            dataSource.authRefresh(
                request = RefreshRequest(
                    refreshToken = refreshToken,
                )
            )
        }

    override suspend fun getUser(
    ): DataState<UserModel, DataError.Remote> =
        safeCall<UserModel> {
            dataSource.authMe()
        }

}
Enter fullscreen mode Exit fullscreen mode

We have set expiresInMins = 1 to test the refresh token logic.
After building both the domain and data layers.
We need to pass the access token to the getUser request too. We will do that using the ktor auth plugin later on.
It is time for the presentation layer

But before that, how are we to store the tokens?

Datastore implementation

Data store, shared preferences, and user defaults are all the same, and the most common and simple method to store data chunks like tokens. For an actual application, it is better to encrypt the tokens before storage, but this tutorial is complicated enough as is.

Let's start with updating the toml file

gradle/libs.versions.toml

[versions]
datastore = "1.2.1"

[libraries]
androidx-datastore = { group = "androidx.datastore", name = "datastore", version.ref = "datastore" }
androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" }
Enter fullscreen mode Exit fullscreen mode

updating shared module build.gradle.kts

shared/build.gradle.kts

...
kotlin {

    sourceSets {
        commonMain.dependencies {
            ...
            implementation(libs.androidx.datastore)
            implementation(libs.androidx.datastore.preferences)
        }
        ...
    }
}
Enter fullscreen mode Exit fullscreen mode

We will be using the Datastore only in the shared module data layer. For further details, refer to the docs

Usually, the Datastore can be used in all parts of the application, so it makes more sense to add it to the core module

shared/commonMein/core/data/DataStoreFactory.kt

internal const val PREFERENCES_DATASTORE_FILE_NAME = "app_preferences.preferences_pb"

object AuthPreferencesKeys {
    val ACCESS_TOKEN = stringPreferencesKey("access_token")
    val REFRESH_TOKEN = stringPreferencesKey("refresh_token")
}
fun createPreferencesDataStore(
    producePath: () -> String,
): DataStore<Preferences> =
    PreferenceDataStoreFactory.createWithPath(
        produceFile = { producePath().toPath() },
    )

Enter fullscreen mode Exit fullscreen mode

The const can be moved to the config file and the keys to another file. I'm keeping them here to simplify things.

Now, for platform modules, the Android module

shared/androidMain/kotlin/core/data/DataStoreFactory.android.kt

fun createPreferencesDataStore(context: Context): DataStore<Preferences> =
    createPreferencesDataStore(
        producePath = {
            context.filesDir.resolve(PREFERENCES_DATASTORE_FILE_NAME).absolutePath
        },
    )
Enter fullscreen mode Exit fullscreen mode

and the iOS module

shared/iosMain/core/data/DataStoreFactory.ios.kt

@OptIn(ExperimentalForeignApi::class)
fun createPreferencesDataStore(): DataStore<Preferences> =
    createPreferencesDataStore(
        producePath = {
            val documentDirectory: NSURL? = NSFileManager.defaultManager.URLForDirectory(
                directory = NSDocumentDirectory,
                inDomain = NSUserDomainMask,
                appropriateForURL = null,
                create = false,
                error = null,
            )
            requireNotNull(documentDirectory).path + "/$PREFERENCES_DATASTORE_FILE_NAME"
        },
    )
Enter fullscreen mode Exit fullscreen mode

Datastore DI setup

With the platform's code ready, it is time to provide it to the dependency injection

shared/commonMein/di/data/DataStoreProvider.kt

class DataStoreProvider(
    val preferences: DataStore<Preferences>,
)
Enter fullscreen mode Exit fullscreen mode

shared/commonMein/di/data/KoinModules.kt

val sharedModule = module {
    single { DataStoreProvider(get()) }
    ...
}
Enter fullscreen mode Exit fullscreen mode

Now for platform-related di, for Android

shared/androidMain/kotlin/di/PlatformModule.android.kt

actual val platformModule = module {
    single<HttpClientEngine> { OkHttp.create() }
    single<DataStore<Preferences>> { createPreferencesDataStore(androidContext()) }
}
Enter fullscreen mode Exit fullscreen mode

and iOS

shared/iosMain/kotlin/di/PlatformModule.ios.kt

actual val platformModule = module {
    single<HttpClientEngine> { Darwin.create() }
    single<DataStore<Preferences>> { createPreferencesDataStore() }
}
Enter fullscreen mode Exit fullscreen mode

All done, how to use it now? We will create a DataStorage repository

Datastore repository

Similar to api repository, we start by creating the interface schema

shared/commonMain/kotlin/core/domain/DataStoreRepository.kt

interface DataStoreRepository {

    suspend fun saveAccessToken(accessToken: String)
    suspend fun getAccessToken(): String?

    suspend fun saveRefreshToken(refreshToken: String)
    suspend fun getRefreshToken(): String?
}
Enter fullscreen mode Exit fullscreen mode

the data layer implementation

shared/commonMain/kotlin/core/data/DataStoreRepositoryImpl.kt

class DataStoreRepositoryImpl(
    private val dataStore: DataStoreProvider
) : DataStoreRepository {

    override suspend fun saveAccessToken(accessToken: String) {
        dataStore.preferences.edit { prefs ->
            prefs[AuthPreferencesKeys.ACCESS_TOKEN] = accessToken
        }
    }

    override suspend fun getAccessToken(): String? =
        dataStore.preferences.data.first()[AuthPreferencesKeys.ACCESS_TOKEN]

    override suspend fun saveRefreshToken(refreshToken: String) {
        dataStore.preferences.edit { prefs ->
            prefs[AuthPreferencesKeys.REFRESH_TOKEN] = refreshToken
        }
    }

    override suspend fun getRefreshToken(): String? =
        dataStore.preferences.data.first()[AuthPreferencesKeys.REFRESH_TOKEN]

}
Enter fullscreen mode Exit fullscreen mode

almost ready, just the di now

shared/commonMain/kotlin/di/KoinModules.kt

val sharedModule = module {
    single { DataStoreProvider(get()) }
    singleOf(::DataStoreRepositoryImpl).bind<DataStoreRepository>()
    ...
}
Enter fullscreen mode Exit fullscreen mode

Now we can use the DataStore repository where needed

Login shared module setup

I'll try to follow bloc design pattern for the view models
ui triggers actions, bloc receives them, and updates the state accordingly. So we need actions, state, and bloc (ViewModel)

shared/commonMain/kotlin/auth/ui/login/LoginActions.kt

sealed interface LoginActions {
    class Login : LoginActions
    class RefreshToken : LoginActions

    data class OnUsernameUpdate(val username: String) : LoginActions
    data class OnPasswordUpdate(val password: String) : LoginActions
}
Enter fullscreen mode Exit fullscreen mode

Now for the state

shared/commonMain/kotlin/auth/ui/login/LoginState.kt

data class LoginState(
    val loginResponse: LoginResponse?,
    val userModel: UserModel?,
    val refreshResponse: RefreshResponse?,
    val username: String = "",
    val password: String = "",
    val isLoading: Boolean = false,
    val errorMessage: String? = null,
)
Enter fullscreen mode Exit fullscreen mode

Finally, the bloc logic in the ViewModel

shared/commonMain/kotlin/auth/ui/login/LoginViewModel.kt

class LoginViewModel(
    private val repository: AuthRepository,
    private val dataStoreRepository: DataStoreRepository,
) : ViewModel() {

    private var loginJob: Job? = null
    private var refreshJob: Job? = null

    private val _state = MutableStateFlow(LoginState(
        loginResponse = null,
        userModel = null,
        refreshResponse = null,
        isLoading = false,
        errorMessage = null,
        username = "",
        password = ""
    ))

    @NativeCoroutinesState
    val state: StateFlow<LoginState> = _state
        .stateIn(
            viewModelScope,
            SharingStarted.WhileSubscribed(5000L),
            _state.value
        )


    fun onAction(action: LoginActions) {
        when (action) {
            is LoginActions.Login -> {
                loginJob?.cancel()
                loginJob = login(state.value.username, state.value.password)
            }

            is LoginActions.RefreshToken -> {
                refreshJob?.cancel()
                refreshJob = refreshToken()
            }

            is LoginActions.OnUsernameUpdate -> {
                _state.update {
                    it.copy(
                        username = action.username
                    )
                }
            }

            is LoginActions.OnPasswordUpdate -> {
                _state.update {
                    it.copy(
                        password = action.password
                    )
                }
            }

        }
    }

    fun onCancel() = viewModelScope.launch {
        _state.update {
            it.copy(
                errorMessage = "cancel"
            )
        }
    }

    private fun login(username: String, password: String) = viewModelScope.launch {

        _state.update {
            it.copy(
                isLoading = true
            )
        }
        repository
            .login(
                username = username,
                password = password
            ).onSuccess { response ->
                response.accessToken?.let { dataStoreRepository.saveAccessToken(it) }
                response.refreshToken?.let { dataStoreRepository.saveRefreshToken(it) }
                _state.update {
                    it.copy(
                        isLoading = false,
                        errorMessage = null,
                        loginResponse = response
                    )
                }
            }
            .onError { error, message ->
                _state.update {
                    it.copy(
                        isLoading = false,
                        loginResponse = null,
                        errorMessage = message ?: error.toString()
                    )
                }
            }
    }

    private fun refreshToken() = viewModelScope.launch {

        _state.update {
            it.copy(
                isLoading = true
            )
        }
        repository
            .refreshToken(refreshToken = dataStoreRepository.getRefreshToken().orEmpty())
            .onSuccess { response ->
                _state.update {
                    it.copy(
                        isLoading = false,
                        errorMessage = null,
                        refreshResponse = response
                    )
                }
            }
            .onError { error, message ->
                _state.update {
                    it.copy(
                        isLoading = false,
                        refreshResponse = null,
                        errorMessage = message ?: error.toString()
                    )
                }
            }
    }

}
Enter fullscreen mode Exit fullscreen mode

We are using the AuthRepository to trigger the login action. In a success case, we are saving both access and refresh tokens to the DataStore.

But how to use them? How to refresh the token when needed?

Ktor auth plugin setup

It is a wonderful addition to any web client. make sure that we have the auth plugin in our libs.versions.toml

gradle/libs.versions.toml

[libraries]
ktor-client-auth = { module = "io.ktor:ktor-client-auth", version.ref = "ktor" }
...
[bundles]
ktor = [
    "ktor-client-core",
    "ktor-client-content-negotiation",
    "ktor-client-auth",
    "ktor-client-logging",
    "ktor-serialization-kotlinx-json",
]
Enter fullscreen mode Exit fullscreen mode

Now for the actual ktor client config file

shared/commonMain/kotlin/core/data/HttpClientFactory.kt

object HttpClientFactory {

    fun create(engine: HttpClientEngine, dataStoreRepository: DataStoreRepository): HttpClient {
        return HttpClient(engine) {
            ...
            install(Auth) {
                bearer {
                    loadTokens {
                        val access = dataStoreRepository.getAccessToken()?.takeIf { it.isNotBlank() }
                            ?: return@loadTokens null
                        val refresh = dataStoreRepository.getRefreshToken().orEmpty()
                        BearerTokens(access, refresh)
                    }
                    refreshTokens {
                        val refreshToken = dataStoreRepository.getRefreshToken()
                            ?: return@refreshTokens null

                        val response: RefreshResponse = client.post("$BASE_URL/auth/refresh") {
                            markAsRefreshTokenRequest()
                            contentType(ContentType.Application.Json)
                            setBody(RefreshRequest(refreshToken = refreshToken))
                        }.body()

                        val newAccess = response.accessToken ?: return@refreshTokens null
                        val newRefresh = response.refreshToken ?: refreshToken

                        dataStoreRepository.saveAccessToken(newAccess)
                        dataStoreRepository.saveRefreshToken(newRefresh)

                        BearerTokens(newAccess, newRefresh)
                    }
                    sendWithoutRequest { request ->
                        val path = request.url.encodedPath
                        path != "/auth/login" && path != "/auth/refresh"
                    }
                    cacheTokens = false
                }
            }
            ...
    }
}
Enter fullscreen mode Exit fullscreen mode

We are using the dataStoreRepository to get the accessToken and refreshToken. The refreshTokens function will be triggered if we get 401 from the server.
I tried using the AuthRepository for that, but it depends on HttpClientFactory. So I ended up with a cyclic DI.
To avoid this, I wrote a basic function to refresh the token silently in refreshTokens.

This will include the Authorization header in all client requests except those mentioned in sendWithoutRequest

Profile shared module setup

Similar to login, we will create actions, state, and bloc

shared/commonMain/kotlin/auth/ui/profile/ProfileActions.kt

sealed interface ProfileActions {
    class GetProfile : ProfileActions
}
Enter fullscreen mode Exit fullscreen mode

shared/commonMain/kotlin/auth/ui/profile/ProfileState.kt

data class ProfileState(
    val profileResponse: UserModel?,
    val isLoading: Boolean = false,
    val errorMessage: String? = null,
)
Enter fullscreen mode Exit fullscreen mode

shared/commonMain/kotlin/auth/ui/profile/ProfileViewModel.kt

class ProfileViewModel(
    private val repository: AuthRepository,
) : ViewModel() {
    private var profileJob: Job? = null

    private val _state = MutableStateFlow(ProfileState(
        profileResponse = null,
        isLoading = false,
        errorMessage = null,
    ))

    @NativeCoroutinesState
    val state: StateFlow<ProfileState> = _state
        .stateIn(
            viewModelScope,
            SharingStarted.WhileSubscribed(5000L),
            _state.value
        )

    fun onAction(action: ProfileActions) {
        when (action) {

            is ProfileActions.GetProfile -> {
                profileJob?.cancel()
                profileJob = getProfile()
            }

        }
    }

    private fun getProfile() = viewModelScope.launch {

        _state.update {
            it.copy(
                isLoading = true
            )
        }
        repository
            .getUser().onSuccess { response ->
                _state.update {
                    it.copy(
                        isLoading = false,
                        errorMessage = null,
                        profileResponse = response
                    )
                }
            }
            .onError { error, message ->
                _state.update {
                    it.copy(
                        isLoading = false,
                        profileResponse = null,
                        errorMessage = message ?: error.toString()
                    )
                }
            }
    }

}
Enter fullscreen mode Exit fullscreen mode

If the auth plugin worked as expected, those requests will succeed; if not, we will consider another solution, such as injecting the DataStoreRepository into the ProfileViewModel. Let's keep our fingers crossed.

Since our shared logic is done, we can start with the UI design

But we shouldn't forget to provide them in the DI

shared/commonMain/kotlin/di/KoinModules.kt

val sharedModule = module {
    single { DataStoreProvider(get()) }
    singleOf(::DataStoreRepositoryImpl).bind<DataStoreRepository>()
    single { HttpClientFactory.create(get(), get()) }

    singleOf(::AuthDataSource)
    singleOf(::AuthRepositoryImpl).bind<AuthRepository>()

    viewModelOf(::LoginViewModel)
    viewModelOf(::ProfileViewModel)
    ...
}
Enter fullscreen mode Exit fullscreen mode

and don't forget the helpers for iOS

shared/iosMain/kotlin/di/KoinHelper.kt

class KoinHelper : KoinComponent {
    fun loginViewModel(): LoginViewModel = get()
    fun profileViewModel(): ProfileViewModel = get()
}
Enter fullscreen mode Exit fullscreen mode

Android Navigation Foundation

Let's start by creating dummy Login, Home, and Profile screens. You can follow this as a guide for them

composeApp/kotlin/login/LoginScreen.kt

@Composable
fun HomeScreen(
    modifier: Modifier = Modifier,
    onProfile: () -> Unit = {},
) {

    Box(
        modifier = modifier
            .fillMaxSize(),
        contentAlignment = Alignment.Center
        ) {
        Column {
            Text(text = "Login Screen")
        }

    }
}
Enter fullscreen mode Exit fullscreen mode

And for the navigation routes

composeApp/kotlin/navigation/AppRoutes.kt

object AppRoutes {
    const val Home = "home"
    const val Login = "login"
    const val Profile = "profile"
}
Enter fullscreen mode Exit fullscreen mode

Make sure to add all required routes and identified there
Now for AppNavigation

composeApp/kotlin/navigation/AppNavigation.kt

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AppNavigation(modifier: Modifier = Modifier) {

    val navController = rememberNavController()
    val navBackStackEntry by navController.currentBackStackEntryAsState()
    val currentDestination = navBackStackEntry?.destination
    val showBackNavigation = currentDestination?.route in listOf(AppRoutes.Profile)

    Scaffold(
        modifier = modifier,
        topBar = {
            TopAppBar(
                title = { Text("Kotlin Native Demo") },
                navigationIcon = {

                    if (showBackNavigation) {
                        IconButton(onClick = { navController.popBackStack() }) {
                            Icon(
                                imageVector = Icons.Default.ArrowBack,
                                contentDescription = "Back"
                            )
                        }
                    }

                }
            )
        },
        bottomBar = {

        }
    ) { innerPadding ->

        NavHost(
            navController = navController,
            startDestination = AppRoutes.Login,
            modifier = Modifier.padding(innerPadding)
        ) {
            composable(AppRoutes.Home) {
                HomeScreen()
            }
            composable(AppRoutes.Login) {
                LoginScreen()
            }
            composable(AppRoutes.Profile) {
                ProfileScreen()
            }

        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Now, we can pass the navigation controller to the screens to allow them to navigate for themselves, but best practices suggest keeping the navigation out.

Android Login and Profile Screen

Let's be direct

composeApp/kotlin/login/LoginScreen.kt

import org.koin.compose.viewmodel.koinViewModel

@Composable
fun LoginScreen(
    modifier: Modifier = Modifier,
    onLoginSuccess: () -> Unit = {},
    viewModel: LoginViewModel = koinViewModel()
) {

    val state by viewModel.state.collectAsState()
    var validate by remember { mutableStateOf(false) }
    val context = LocalContext.current

    LaunchedEffect(state.errorMessage) {
        state.errorMessage?.let { message ->
            Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
        }
    }

    LaunchedEffect(state.loginResponse?.accessToken) {
        if (state.loginResponse?.accessToken != null) {
            onLoginSuccess()
        }
    }


    Box {
        Column(
            verticalArrangement = Arrangement.Center,
            horizontalAlignment = Alignment.CenterHorizontally,
            modifier = modifier
                .fillMaxSize()
        ) {
            Icon(
                imageVector = Icons.Default.Home,
                contentDescription = "Logo",
            )
            Text(text = "Login")
            Spacer(modifier = Modifier.height(20.dp))
            OutlinedTextField(
                value = state.username,
                onValueChange = {
                    viewModel.onAction(LoginActions.OnUsernameUpdate(it))
                },
                label = { Text("Username", color = Color.Gray) },
                isError = validate && state.username.isBlank(),
                supportingText = {
                    if (state.username.isBlank() && validate) Text("Username is required")
                },
                singleLine = true,
            )
            OutlinedTextField(
                value = state.password,
                onValueChange = {
                    viewModel.onAction(LoginActions.OnPasswordUpdate(it))
                },
                label = { Text("Password", color = Color.Gray) },
                visualTransformation = PasswordVisualTransformation(),
                keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
                isError = validate && state.password.isBlank(),
                supportingText = {
                    if (state.password.isBlank() && validate) Text("Password is required")
                },

                singleLine = true,
            )
            Spacer(modifier = Modifier.height(20.dp))
            Row() {
                Button(onClick = {
                    viewModel.onCancel()
                }) {
                    Text(text = "Cancel")
                }
                Spacer(modifier=Modifier.width(20.dp))
                Button(onClick = {
                    validate = true
                    if (state.username.isBlank()) {
                        Toast.makeText(context, "Username is required", Toast.LENGTH_SHORT).show()
                        return@Button
                    }
                    if (state.password.isBlank()) {
                        Toast.makeText(context, "Password is required", Toast.LENGTH_SHORT).show()
                        return@Button
                    }
                    viewModel.onAction(LoginActions.Login())
                }) {
                    Text(text = "Login")
                }

            }
        }
        if (state.isLoading) {
            Box(
                modifier = Modifier.fillMaxSize()
                    .background(color = Color.Black.copy(alpha = 0.5f)),
                contentAlignment = Alignment.Center
            ) {
                CircularProgressIndicator(
                    modifier = Modifier.size(80.dp),
                    strokeWidth = 8.dp
                )
            }
        }

    }

}
Enter fullscreen mode Exit fullscreen mode

LaunchedEffect is similar to useEffect in React. When the value it listens to changes, it calls the function.
We are using the username and password statuses directly and update them with OnUsernameUpdate and OnPasswordUpdate actions.
The validate boolean is just to show errors after clicking login once.
When accessToken gets a value, we are calling onLoginSuccess. Let's update AppNavigation to handle home navigation after logging in.

composeApp/kotlin/navigation/AppNavigation.kt

...
            composable(AppRoutes.Login) {
                LoginScreen(
                    onLoginSuccess = {
                        navController.navigate(AppRoutes.Home) {
                            popUpTo(AppRoutes.Login) { inclusive = true }
                        }
                    }
                )
            }
...
Enter fullscreen mode Exit fullscreen mode

The inclusive = true is to clear the stack. When clicking back, the application will close instead of going back to the login screen.

From the home screen, let's just navigate to the profile screen

composeApp/kotlin/home/HomeScreen.kt

@Composable
fun HomeScreen(
    modifier: Modifier = Modifier,
    onProfile: () -> Unit = {},
) {

    Box(
        modifier = modifier
            .fillMaxSize(),
        contentAlignment = Alignment.Center
        ) {
        Column {
            Text(text = "Home Screen")
            Button(onClick = {
                onProfile()
            }) {
                Text("Profile")
            }
        }

    }
}
Enter fullscreen mode Exit fullscreen mode

and updating the navigation again.

composeApp/kotlin/navigation/AppNavigation.kt

...
            composable(AppRoutes.Home) {
                HomeScreen(
                    onProfile = {
                        navController.navigate(AppRoutes.Profile)
                    }
                )
            }
...
Enter fullscreen mode Exit fullscreen mode

Finally, the profile screen.

composeApp/kotlin/profile/ProfileScreen.kt

@Composable
fun ProfileScreen(
    modifier: Modifier = Modifier,
    viewModel: ProfileViewModel = koinViewModel()
) {

    val state by viewModel.state.collectAsState()

    LaunchedEffect(Unit) {
        viewModel.onAction(ProfileActions.GetProfile())
    }

    Box(
        modifier = modifier
            .fillMaxSize(),
        contentAlignment = Alignment.Center
    ) {
        Column {
            Text(text = "Profile Screen")
            Text(text = state.profileResponse?.username ?: "...")
            Text(text = state.profileResponse?.id.toString() ?: "...")
            Text(text = state.profileResponse?.email ?: "...")
            Text(text = state.profileResponse?.firstName ?: "...")
            Text(text = state.profileResponse?.lastName ?: "...")
            Text(text = state.profileResponse?.gender ?: "...")
            Text(text = state.profileResponse?.image ?: "...")

        }
    }
}
Enter fullscreen mode Exit fullscreen mode

iOS Navigation Foundation

We will try to build a similar navigation tree in iOS.

A dummy screen would look like

iosApp/home/HomeView.swift

struct HomeView: View {

    var onProfile: () -> Void = {}

    var body: some View {
        VStack {
            Text("Home Screen")
        }
        .frame(
            maxWidth: .infinity, 
            maxHeight: .infinity, 
            alignment: .top,
        )
        .padding()
    }
}
Enter fullscreen mode Exit fullscreen mode

Create one for home, profile, and login. So, the app routes

iosApp/navigation/AppRoutes.swift

enum AppRoutes: Hashable {
    case home
    case login
    case profile
}
Enter fullscreen mode Exit fullscreen mode

and the app navigator

iosApp/navigation/AppNavigation.swift

struct AppNavigation: View {
    @State private var path = NavigationPath()

    var body: some View {
        NavigationStack(path: $path) {
            LoginView(
                onLoginSuccess: {
                    path.append(AppRoutes.home)
                }
            )
            .navigationTitle("Login")
            .navigationBarTitleDisplayMode(.inline)
            .navigationDestination(for: AppRoutes.self) { route in
                switch route {
                case .home:
                    HomeView(
                        onProfile: {
                            path.append(AppRoutes.profile)
                        }
                    )
                    .navigationTitle("Home")
                    .navigationBarTitleDisplayMode(.inline)
                    .navigationBarBackButtonHidden(true)

                case .login:
                    LoginView(
                        onLoginSuccess: {
                            path.append(AppRoutes.home)
                        }
                    )
                    .navigationTitle("Login")
                    .navigationBarTitleDisplayMode(.inline)
                    .navigationBarBackButtonHidden(true)


                case .profile:
                    ProfileScreen()
                        .navigationTitle("Profile")
                        .navigationBarTitleDisplayMode(.inline)

                }

            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

iOS Login and Profile Screens

Great, now for the login view model wrapper

iosApp/login/LoginViewModelWrapper.swift

import Foundation
import Shared
import KMPNativeCoroutinesCombine
import Combine

final class LoginViewModelWrapper: ObservableObject {
    @Published var lastUpdated = Date()

    let viewModel: LoginViewModel = KoinHelper().loginViewModel()
    private var cancellables = Set<AnyCancellable>()

    init() {
        createPublisher(for: viewModel.stateFlow)
            .receive(on: DispatchQueue.main)
            .sink { _ in } receiveValue: { [weak self] _ in
                self?.lastUpdated = Date()
            }
            .store(in: &cancellables)
    }
}
Enter fullscreen mode Exit fullscreen mode

This is a generic view model wrapper that I usually use in iOS

for the login screen

iosApp/login/LoginView.swift

import Shared

struct LoginView: View {
    var onLoginSuccess: () -> Void = {}

    @StateObject private var wrapper = LoginViewModelWrapper()
    @State private var validate = false
    @State private var showErrorAlert = false
    @State private var errorAlertText = ""
    @State private var lastErrorMessage: String?
    @State private var didCompleteLogin = false

    var body: some View {
        let state = wrapper.viewModel.state

        ZStack {
            VStack(spacing: 0) {
                Spacer(minLength: 0)

                Image(systemName: "person.crop.square.fill")
                    .font(.system(size: 72))
                    .foregroundStyle(.blue.gradient)
                    .accessibilityLabel("Logo")

                Text("Login")
                    .font(.title2)
                    .fontWeight(.semibold)
                    .padding(.top, 12)

                Spacer()
                    .frame(height: 20)

                VStack(alignment: .leading, spacing: 8) {
                    TextField("Username", text: Binding(
                        get: { state.username },
                        set: { wrapper.viewModel.onAction(action: LoginActionsOnUsernameUpdate(username: $0)) }
                    ))
                    .textFieldStyle(.roundedBorder)
                    .textInputAutocapitalization(.never)
                    .autocorrectionDisabled()

                    if validate && state.username.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
                        Text("Username is required")
                            .font(.caption)
                            .foregroundStyle(.red)
                    }
                }

                VStack(alignment: .leading, spacing: 8) {
                    SecureField("Password", text: Binding(
                        get: { state.password },
                        set: { wrapper.viewModel.onAction(action: LoginActionsOnPasswordUpdate(password: $0)) }
                    ))
                    .textFieldStyle(.roundedBorder)

                    if validate && state.password.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
                        Text("Password is required")
                            .font(.caption)
                            .foregroundStyle(.red)
                    }
                }
                .padding(.top, 12)

                Spacer()
                    .frame(height: 20)

                HStack(spacing: 20) {
                    Button("Cancel") {
                        wrapper.viewModel.onCancel()
                    }
                    .buttonStyle(.bordered)

                    Button("Login") {
                        validate = true
                        let username = state.username.trimmingCharacters(in: .whitespacesAndNewlines)
                        let password = state.password.trimmingCharacters(in: .whitespacesAndNewlines)
                        if username.isEmpty {
                            errorAlertText = "Username is required"
                            showErrorAlert = true
                            return
                        }
                        if password.isEmpty {
                            errorAlertText = "Password is required"
                            showErrorAlert = true
                            return
                        }
                        lastErrorMessage = nil
                        wrapper.viewModel.onAction(
                            action: LoginActionsLogin()
                        )
                    }
                    .buttonStyle(.borderedProminent)
                }

                Spacer(minLength: 0)
            }
            .frame(maxWidth: .infinity, maxHeight: .infinity)
            .padding(.horizontal, 24)

            if state.isLoading {
                Color.black.opacity(0.5)
                    .ignoresSafeArea()
                ProgressView()
                    .scaleEffect(1.5)
                    .tint(.white)
            }
        }
        .onChange(of: wrapper.lastUpdated) { _, _ in
            let current = wrapper.viewModel.state

            // Success: navigate to home; ViewModel clears errorMessage on success.
            if !didCompleteLogin, current.loginResponse != nil {
                didCompleteLogin = true
                onLoginSuccess()
                return
            }

            // Failure: show server / network message only when there is no success token.
            if current.loginResponse == nil,
               let message = current.errorMessage,
               !message.isEmpty,
               message != lastErrorMessage {
                lastErrorMessage = message
                errorAlertText = message
                showErrorAlert = true
            }
        }
        .alert("Error", isPresented: $showErrorAlert) {
            Button("OK", role: .cancel) {}
        } message: {
            Text(errorAlertText)
        }
    }
}

Enter fullscreen mode Exit fullscreen mode

We are following the same guidelines in Android. Notice that LoginActionsOnUsernameUpdate is the same as LoginActions.OnUsernameUpdate.

for home screen

struct HomeView: View {

    var onProfile: () -> Void = {}
    var onProducts: () -> Void = {}

    var body: some View {
        VStack {

            Text("Home Screen")

            Button("Profile") {
                onProfile()
            }
        }
        .frame(
            maxWidth: .infinity,
            maxHeight: .infinity,
            alignment: .top,
        )
        .padding()
    }
}
Enter fullscreen mode Exit fullscreen mode

And finally, the profile screen view model

iosApp/profile/ProfileViewModelWrapper.swift

import Foundation
import Shared
import KMPNativeCoroutinesCombine
import Combine

final class ProfileViewModelWrapper: ObservableObject {
    @Published var lastUpdated = Date()

    let viewModel: ProfileViewModel = KoinHelper().profileViewModel()
    private var cancellables = Set<AnyCancellable>()

    init() {
        createPublisher(for: viewModel.stateFlow)
        .receive(on: DispatchQueue.main)
        .sink { _ in } receiveValue: { [weak self] _ in
            self?.lastUpdated = Date()
        }
        .store(in: &cancellables)
    }
}
Enter fullscreen mode Exit fullscreen mode

iosApp/profile/ProfileView.swift

struct ProfileScreen: View {

    @StateObject private var wrapper = ProfileViewModelWrapper()
    @State private var showErrorAlert = false
    @State private var errorAlertText = ""
    @State private var lastErrorMessage: String?

    var body: some View {
        let state = wrapper.viewModel.state

        ZStack {
            VStack(alignment: .leading, spacing: 8) {
                Text("Profile Screen")
                Text(state.profileResponse?.username ?? "...")
                Text("\(state.profileResponse?.id ?? 0)")
                Text(state.profileResponse?.email ?? "...")
                Text(state.profileResponse?.firstName ?? "...")
                Text(state.profileResponse?.lastName ?? "...")
                Text(state.profileResponse?.gender ?? "...")
                Text(state.profileResponse?.image ?? "...")
            }

            if state.isLoading {
                Color.black.opacity(0.5)
                    .ignoresSafeArea()
                ProgressView()
                    .scaleEffect(1.4)
                    .tint(.white)
            }
        }
        .frame(maxWidth: .infinity, maxHeight: .infinity)
        .onAppear {
            wrapper.viewModel.onAction(action: ProfileActionsGetProfile())
        }
        .onChange(of: wrapper.lastUpdated) { _, _ in
            let current = wrapper.viewModel.state
            if let message = current.errorMessage,
               !message.isEmpty,
               message != lastErrorMessage {
                lastErrorMessage = message
                errorAlertText = message
                showErrorAlert = true
            }
        }
        .alert("Error", isPresented: $showErrorAlert) {
            Button("OK", role: .cancel) {}
        } message: {
            Text(errorAlertText)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

And we are done here. I hope everything worked as expected. Write a comment if it didn't.
Believe me when I say it is not easy

Top comments (0)