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
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,
)
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
)
shared/commonMein/auth/domain/models/RefreshRequest.kt
@Serializable
data class RefreshRequest(
@SerialName("refreshToken")
val refreshToken: String? = null
)
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
)
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>
}
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"
)
}
}
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()
}
}
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" }
updating shared module build.gradle.kts
shared/build.gradle.kts
...
kotlin {
sourceSets {
commonMain.dependencies {
...
implementation(libs.androidx.datastore)
implementation(libs.androidx.datastore.preferences)
}
...
}
}
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() },
)
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
},
)
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"
},
)
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>,
)
shared/commonMein/di/data/KoinModules.kt
val sharedModule = module {
single { DataStoreProvider(get()) }
...
}
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()) }
}
and iOS
shared/iosMain/kotlin/di/PlatformModule.ios.kt
actual val platformModule = module {
single<HttpClientEngine> { Darwin.create() }
single<DataStore<Preferences>> { createPreferencesDataStore() }
}
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?
}
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]
}
almost ready, just the di now
shared/commonMain/kotlin/di/KoinModules.kt
val sharedModule = module {
single { DataStoreProvider(get()) }
singleOf(::DataStoreRepositoryImpl).bind<DataStoreRepository>()
...
}
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
}
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,
)
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()
)
}
}
}
}
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",
]
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
}
}
...
}
}
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
}
shared/commonMain/kotlin/auth/ui/profile/ProfileState.kt
data class ProfileState(
val profileResponse: UserModel?,
val isLoading: Boolean = false,
val errorMessage: String? = null,
)
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()
)
}
}
}
}
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)
...
}
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()
}
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")
}
}
}
And for the navigation routes
composeApp/kotlin/navigation/AppRoutes.kt
object AppRoutes {
const val Home = "home"
const val Login = "login"
const val Profile = "profile"
}
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()
}
}
}
}
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
)
}
}
}
}
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 }
}
}
)
}
...
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")
}
}
}
}
and updating the navigation again.
composeApp/kotlin/navigation/AppNavigation.kt
...
composable(AppRoutes.Home) {
HomeScreen(
onProfile = {
navController.navigate(AppRoutes.Profile)
}
)
}
...
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 ?: "...")
}
}
}
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()
}
}
Create one for home, profile, and login. So, the app routes
iosApp/navigation/AppRoutes.swift
enum AppRoutes: Hashable {
case home
case login
case profile
}
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)
}
}
}
}
}
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)
}
}
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)
}
}
}
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()
}
}
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)
}
}
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)
}
}
}
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)