# Complete Guide to Becoming a Fullstack Kotlin Developer - UI with Compose Multiplatform

In previous [posts](https://hashnode.com/post/clymugiht000509jt0onuej9d), I’ve created the repository and presentation layers of the applications. Now, the time has come for the **UI** layer. The interaction points are where the user can finally use the application. The most significant benefit of using **Compose Multiplatform** is that we can use the same UI on all platforms. The once-created UI will be used on Android, iOS, and Web without any problem.

> *The complete project is avaiable on*[*GitHub*](https://github.com/mkonkel/GameShop)

---

# Dependencies

> We should use the latest [Compose Multiplatform](https://github.com/JetBrains/compose-multiplatform/releases) release (at the time of writing this article, the current version was 1.6.0-rc03).

`gradle/libs.versions.toml`

```kotlin
compose-plugin = "1.6.0-rc03" 
... 
jetbrainsCompose = { id = "org.jetbrains.compose", version.ref = "compose-plugin" }
```

# Screen model

One of the first things that need to be implemented is a **model** for our screen. In my approach, this will be a set of essential elements like text, input field, button, image, and more. Each will have its representation in **shared** code and be resolved to the UI control in the **compose** module.

Suppose we are thinking about the **login** screen. In that case, we can imagine that it should have an input for an email and an input for a password (which should be secured), a button enabling us to call the API request, and a button that can change the screen from login to register if we are a new user.

Let’s start with the model and some controls.

`shared/commonMain/features/login/LoginModel.kt`

```kotlin
data class LoginModel( 
    val login: InputText, 
    val pass: InputText, 
    val loginButton: Button, 
    val registerButton: Button, 
)
```

`shared/commonMain/widget/input/InputTetx.kt`

```kotlin
data class InputText( 
    val text: MutableState<String> = mutableStateOf(""), 
    val label: String = "", 
    val maxLines: Int = 1, 
    val type: Type = Type.NORMAL, 
) { 
    enum class Type { 
        NORMAL, 
        SECURE, 
    } 
}
```

Since we are developing a compose multiplatform application, we can easily benefit from the compose `mutableStateOf` – in the case of `InputField`, we can use it to hold the current user’s input and use it later in the business logic

# Control Widgets

With the provided `mutableState`, we can use it as the current value of the text and then update it on every value that has changed. Using the remember lambda will ensure that the produced value remains the same during the layout recomposition. The UI representation of the input model may look like this:

`composeApp/commonMain/ui/widget/Input.kt`

```kotlin
@Composable 
fun InputText.Widget( 
    modifier: Modifier = Modifier, 
) { 
    var currentText by remember { text } 
 
    Column { 
        OutlinedTextField( 
            value = currentText, 
            onValueChange = { 
                currentText = it 
            }, 
            modifier = Modifier.then(modifier), 
            label = { 
                Text(label) 
            }, 
            singleLine = maxLines == 1, 
            maxLines = maxLines, 
            visualTransformation = 
                if (type == InputText.Type.SECURE) { 
                    PasswordVisualTransformation() 
                } else { 
                    VisualTransformation.None 
                }, 
        ) 
    } 
}
```

The button model should be straightforward; we can define an enum class responsible for the styling.

`composeApp/commonMain/ui/widget/Button.kt`

```kotlin
@Composable 
fun AppButton.Widget(modifier: Modifier = Modifier) { 
    when (this.style) { 
        FILLED -> { 
            Button(modifier = modifier, onClick = this@Widget.onClick) { 
                Text(text = this@Widget.text) 
            } 
        } 
        OUTLINED -> { 
            OutlinedButton(modifier = modifier, onClick = this@Widget.onClick) { 
                Text(text = this@Widget.text) 
            } 
        } 
 
        TEXT -> { 
            TextButton(modifier = modifier, onClick = this@Widget.onClick) { 
                Text(text = this@Widget.text) 
            } 
        } 
 
        FLOATING -> 
            ExtendedFloatingActionButton( 
                modifier = modifier, 
                onClick = this@Widget.onClick, 
            ) { 
                Text(text = this@Widget.text) 
            } 
    } 
}
```

# Screen Component

The last thing to do is to provide this mode via the `LoginComponent` and handle it in the `LoginScreen`.

`shared/commonMain/features/login/LoginComponent.kt`

```kotlin
interface LoginComponent { 
    val model: LoginModel 
    fun onLoginClick() 
    fun onRegisterClick() 
}
```

`shared/commonMain/features/login/RealLoginComponent.kt`

```kotlin
internal class RealLoginComponent( 
    componentContext: ComponentContext, 
    coroutineContext: CoroutineContext, 
    private val loginRepository: LoginRepository, 
    private val onLogin: () -> Unit, 
    private val onRegister: () -> Unit, 
) : BaseComponent(componentContext, coroutineContext), LoginComponent { 
    override val model = 
        LoginModel( 
            login = InputText( 
                type = InputText.Type.NORMAL, 
                label = "Login", 
            ), 
            pass = InputText( 
                type = InputText.Type.SECURE, 
                label = "Password", 
            ), 
            loginButton = Button( 
                style = Button.Style.FILLED, 
                text = "Login", 
                onClick = { onLoginClick() }, 
            ), 
            registerButton = Button( 
                style = Button.Style.OUTLINED, 
                text = "Register", 
                onClick = { onRegisterClick() }, 
            ), 
        ) 
 
    override fun onLoginClick() { 
        val login = model.login.text.value 
        val pass = model.pass.text.value 
 
        scope.launch { 
            loginRepository.login(login, pass) 
            onLogin() 
        } 
    } 
 
    override fun onRegisterClick() { 
        onRegister() 
    } 
}
```

Using the previously created `scope` in the `BaseComponent`, we can call the suspend function, which will perform **the login** operation with the **httpClient**. The `onLogin()` lambda will be invoked if the API call finishes successfully. The lambda is handled in the `RootComponent` and will perform the navigation. The `login()` method will call the API and store the received access token in the `TokenRepository`. Every subsequent API call will be made securely with the authorization token.

# Screen View

Let’s add some final touches to the `LoginScreen`, and we will be ready to run it on all three platforms.

`composeApp/commonMain/features/login/LoginScreen.kt`

```kotlin
@Composable 
private fun Content( 
    component: LoginComponent, 
    model: LoginModel, 
    modifier: Modifier, 
) { 
    Column( 
        modifier = modifier.fillMaxSize(), 
        horizontalAlignment = Alignment.CenterHorizontally, 
        verticalArrangement = Arrangement.Center, 
    ) { 
        Card(modifier = Modifier.fillMaxWidth()) { 
            Column(modifier = Modifier.padding(8.dp)) { 
                Text(text = "GAME SHOP!") 
                Spacer(Modifier.height(16.dp)) 
                model.login.Widget(Modifier.fillMaxWidth()) 
                Spacer(Modifier.height(16.dp)) 
                model.pass.Widget(Modifier.fillMaxWidth()) 
                Spacer(Modifier.height(16.dp)) 
                model.loginButton.Widget(Modifier.fillMaxWidth()) 
            } 
        } 
    } 
}
```

The `Content()` function should be displayed on the screen. Elements are ordered in the `Column` and positioned at the center of the screen. The login form is wrapped in the `Card` component and built with the previously created widgets. When we run all our targets, we should see something like:

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1721042162988/34873fc9-50c0-4c91-b06f-19a46157da6f.png align="center")

# Handling UI states

After pressing the login button and successfully making an API call, the app should transition to the games list screen. This process involves fetching all the game data from the backend before displaying the content to the user. To achieve this, we need to introduce a state that holds information about the view itself. This state should be generic and capable of handling any component model. We will use a marker interface for models, a sealed class to represent the current UI state and a component interface that will force such an approach on all components.

`shared/commonMain/features/utils/Model.kt`

```kotlin
interface Model
```

`shared/commonMain/features/utils/ModelState.kt`

```kotlin
sealed class ModelState<T : Model> { 
    class Loading<T : Model> : ModelState<T>() 
    data class Success<T : Model>(val model: T) : ModelState<T>() 
    data class Error<T : Model>(val message: String) : ModelState<T>() 
}
```

`shared/commonMain/features/Component.kt`

```kotlin
interface Component<T : Model> { 
    val modelValue: Value<ModelState<T>> 
}
```

From the **UI** point of view, we need to implement a collector that can handle state changes and display proper views. We can create a generic extension function for the **Component** interface. The function will subscribe to the model changes and change views according to the current state. We can define the default behaviors for loading and error states and pass them as lambda expressions. They can be easily changed if some screens handle them differently.

`composeApp/commonMain/features/game/GamesScreen.kt`

```kotlin
@Composable 
fun <T : Model> Component<T>.observeModel( 
    loading: @Composable () -> Unit = { DefaultProgressIndicator() }, 
    error: @Composable (String) -> Unit = { Text("Error: $it") }, 
    content: @Composable (T) -> Unit, 
) { 
    when (val modelState = this.modelValue.subscribeAsState().value) { 
        is ModelState.Loading -> loading() 
        is ModelState.Success -> content(modelState.model) 
        is ModelState.Error -> error(modelState.message) 
    } 
} 
 
 
@Composable 
private fun DefaultProgressIndicator() { 
    Column( 
        Modifier.fillMaxSize(), 
        verticalArrangement = Arrangement.Center, 
        horizontalAlignment = Alignment.CenterHorizontally, 
    ) { 
        CircularProgressIndicator(modifier = Modifier.size(100.dp)) 
    } 
}
```

# Game list screen

Following the same principles as with the `LoginComponent`, we can now create the `GameListComponent`, which will display all the games fetched from the API, but with newly introduced tools.

`shared/commonMain/features/games/list/GameListModel.kt`

```kotlin
data class GamesListModel( 
    val games: List<Item>, 
) : Model { 
    data class Item( 
        val id: String, 
        val name: String, 
        val description: String, 
        val image: String, 
        val price: String, 
    ) 
}
```

`shared/commonMain/features/games/list/GameListComponent.kt`

```kotlin
interface GamesListComponent : Component<GamesListModel>
```

`shared/commonMain/features/games/list/RealGameListComponent.kt`

```kotlin
internal class RealGamesListComponent( 
    componentContext: ComponentContext, 
    coroutineContext: CoroutineContext, 
    private val gamesRepository: GamesRepository 
) : BaseComponent(componentContext, coroutineContext), GamesListComponent { 
   ... 
}
```

Using `GameListComponent` requires us to implement the `modelValue` field. This field should be visible to the **UI** part of the application, but it should only be modifiable by the component itself. To achieve this, we need to introduce another mutable state. Initially, the state of the UI should be set to **loading**. In the `init` block, we can then use a coroutine scope to fetch the games from the repository. As the games are being fetched, the state will remain **loading**.

Once the games are successfully fetched, we will transition to the **Success** state, updating the `modelValue` with the retrieved data. This means the UI will now display the list of games. However, if the call to fetch games fails for any reason, we should transition to an **Error** state. This state will inform the UI that something went wrong, allowing it to display an appropriate error message to the user.

By managing these states—**loading**, **Success**, and **Error**—we ensure that the UI remains responsive and informative, providing users with a clear understanding of what is happening behind the scenes. This approach also makes our component more robust and easier to maintain, as the state management logic is centralized within the component itself.

`shared/commonMain/features/games/list/RealGameListComponent.kt`

```kotlin
private val modelState: MutableValue<ModelState<GamesListModel>> = 
    MutableValue(ModelState.Loading()) 
 
override val modelValue: Value<ModelState<GamesListModel>> = modelState
```

`shared/commonMain/features/games/list/RealGameListComponent.kt`

```kotlin
init { 
    scope.launch { 
        try { 
            val games = gamesRepository.getGames() 
            val model = GamesModelMapper.mapModel(games) 
            modelState.update { ModelState.Success(model) } 
        } catch (e: Exception) { 
            modelState.update { ModelState.Error("Something went wrong") } 
        } 
    } 
}
```

The model mapper uses a domain `Game` object and transforms it into a UI model so that the application knows how to display it on the screen.

`shared/commonMain/features/games/list/GamesModelMapper.kt`

```kotlin
object GamesModelMapper { 
    fun mapModel(games: List<Game>): GamesListModel { 
        return GamesListModel( 
            games = 
                games.map { game -> 
                    GamesListModel.Item( 
                        id = game.id, 
                        name = game.name, 
                        description = game.description, 
                        image = game.imageUrl, 
                        price = game.price, 
                    ) 
                } 
        ) 
    } 
}
```

Going back to `RealRootComponent`, we need to handle the new configuration, navigate, and create the proper component with the component factory.

`shared/commonMain/features/RealRootComponent.kt`

```kotlin
@Serializable 
data object Games : Config
```

`shared/commonMain/features/RootComponent.kt`

```kotlin
class GamesChild(val component: GamesListComponent) : Child()
```

`shared/commonMain/features/factory/ComponentFactory.kt`

```kotlin
fun createGamesListComponent(componentContext: ComponentContext): GamesListComponent
```

`shared/commonMain/features/factory/RealComponentFactory.kt`

```kotlin
override fun createGamesListComponent(componentContext: ComponentContext): GamesListComponent { 
    return RealGamesListComponent( 
        componentContext, 
        mainContext, 
        remoteRepository.gamesRepository() 
    ) 
}
```

`shared/commonMain/features/RealRootComponent.kt`

```kotlin
Config.Games -> 
    RootComponent.Child.GamesChild( 
        componentFactory.createGamesListComponent(componentContext = componentContext) 
    )
```

`composeApp/commonMain/features/RootScreen.kt`

```kotlin
is RootComponen.Child.GamesChild -> GamesScreen(child.component)
```

`composeApp/commonMain/features/game/GamesScreen.kt`

```kotlin
composeApp/commonMain/features/game/GamesScreen.kt

@Composable 
internal fun GamesScreen(component: GamesListComponent) { 
    component.observeModel { model -> 
            Content(component, model) 
    } 
} 
 
@Composable 
private fun Content( 
    component: GamesListComponent, 
    model: GamesListModel, 
) { 
    Column(modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState())) { 
        model.games.forEach { game -> 
            // TODO: Use game.Widget() here
            Spacer(modifier = Modifier.height(8.dp)) 
        } 
    } 
}
```

## Image processing

With the created component, we must now focus on the UI layer. This involves designing the widget for the game list item. The widget will include standard elements such as Column, Row, Spacer, Card, and Text. Additionally, the API will provide us with the image **URL**, which we need to display as the thumbnail for each game.

To handle image processing in our multiplatform application, we can use [COIL](https://coil-kt.github.io/coil/), an efficient image-loading library. Despite the initial impression that image processing might be complex in a multiplatform environment, COIL makes it straightforward. Since version `3.0.0`, COIL has offered comprehensive support for multiplatform development. It leverages the KTOR engine to fetch images from the Web, ensuring smooth and efficient image loading.

Using COIL, we can easily load and display images in our application. The library provides a simple API to load images from URLs, handle caching, and display them in our UI components. This means we can focus on other aspects of our application without worrying about the intricacies of image loading and processing.

### Dependencies

`gradle/libs.versions.toml`

```kotlin
coil = "3.0.0-SNAPSHOT" 
... 
coil-coil = { group = "io.coil-kt.coil3", name = "coil", version.ref = "coil" } 
coil-compose = { group = "io.coil-kt.coil3", name = "coil-compose", version.ref = "coil" } 
coil-network-ktor = { group = "io.coil-kt.coil3", name = "coil-network-ktor", version.ref = "coil" }
```

`composeApp/build.gradle.kts`

```kotlin
implementation(libs.coil.coil) 
implementation(libs.coil.compose) 
implementation(libs.coil.network.ktor)
```

### Usage

`composeApp/commonMain/ui/widget/game/Game.kt`

```kotlin
@Composable 
fun GamesListModel.Item.Widget(onClick: () -> Unit) { 
    Card( 
        modifier = Modifier.fillMaxWidth(), 
        onClick = onClick, 
    ) { 
        Row( 
            modifier = Modifier.padding(8.dp), 
            verticalAlignment = Alignment.CenterVertically, 
        ) { 
            AsyncImage( 
                modifier = Modifier.height(100.dp).width(80.dp), 
                model = 
                ImageRequest.Builder(LocalPlatformContext.current) 
                    .data(image) 
                    .build(), 
                contentDescription = null, 
                contentScale = ContentScale.Fit 
            ) 
            Spacer(modifier = Modifier.requiredWidth(8.dp)) 
            Column( 
                modifier = Modifier.weight(1f), 
            ) { 
                Text(text = name, fontWeight = FontWeight.Bold) 
                Text( 
                    text = description, 
                    fontWeight = FontWeight.Light, 
                    maxLines = 4, 
                    overflow = TextOverflow.Ellipsis 
                ) 
            } 
            Spacer(modifier = Modifier.requiredWidth(8.dp)) 
            Column(horizontalAlignment = Alignment.CenterHorizontally) { 
                Text(text = "Price", fontWeight = FontWeight.Bold) 
                Text(text = price) 
            } 
        } 
    } 
}
```

# Adjusting Backend

The last thing to do is to populate our backend application with some data. We can insert data from **Wikipedia** into the database at the start of the application.

`server/database/DatabaseInitializer.kt`

```kotlin
internal fun addDefaultGames() { 
    GamesEntity.new { 
        name = "Doom" 
        description = "Doom is a first-person shooter game developed and published by id Software." 
        imageUrl = "https://upload.wikimedia.org/wikipedia/en/5/57/Doom_cover_art.jpg" 
        price = "10.00" 
    } 
 
    GamesEntity.new { 
        name = "Quake" 
        description = "Quake is a first-person shooter game developed by id Software and published by GT Interactive." 
        imageUrl = "https://upload.wikimedia.org/wikipedia/en/4/4c/Quake1cover.jpg" 
        price = "5.00" 
    } 
 
    GamesEntity.new { 
        name = "Duke Nukem 3D" 
        description = "Duke Nukem 3D is a first-person shooter video game developed by 3D Realms." 
        imageUrl = "https://upload.wikimedia.org/wikipedia/en/6/61/Duke_Nukem_3D_Coverart.png" 
        price = "15.00" 
    } 
 
    GamesEntity.new { 
        name = "Wolfenstein 3D" 
        description = "Wolfenstein 3D is a first-person shooter video game developed by id Software and published by Apogee Software and FormGen." 
        imageUrl = "https://upload.wikimedia.org/wikipedia/en/0/05/Wolfenstein-3d.jpg" 
        price = "12.00" 
    } 
}
```

`server/database/RealDatabaseFactory.kt`

```kotlin
private object SchemaDefinition { 
    fun createSchema() { 
        transaction { 
           ... 
           addDefaultGames() 
        } 
    } 
}
```

Now, we can run the server and the applications and check what the game list screen looks like.

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1721043937293/a4f469e7-7a33-46fc-ba70-43df45394d3b.png align="center")

# Summary

That’s all! With these components in place, you can build so many more screens and features for the Game Shop! Imagine the possibilities with Kotlin and Compose’s Multiplatform approach—you can create Android, iOS, and Web applications all from one codebase! And guess what? If you want to support desktops too, just add the Desktop target in **the ComposeApp** module, and it will work almost immediately. How awesome is that?!

I hope these blog posts introduced you to the beautiful world of multiplatform applications and showed that this is easier than it looks at first glance. I strongly encourage you to fetch the repo and play around with it, adding some new features, changing the UI, and handling API errors gently—there are many things to do. If you have any questions, don’t hesitate to ask.!

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1721044362237/d9932e64-345d-43ab-8859-cb443fc5c0fe.gif align="center")

## What's more?

The GitHub repository implements more features if you want to stay and play around or explore the code.

* `HomeComponent` with bottom menu that holds game list and user orders
    
* `GameDetailsComponent`, which will display the extended description of a game
    
* `AddGameComponent`, which allows the addition of new games
    
* Basic **ADMIN** role handling that helps to view a list of users in `UsersListComponent`
    
* Simple input **validation** on `LoginComponent`
    
* Simple animation of custom loading indicator
    
* Paddings modifier for **WEB** wider screens
    

> In this article, I transition to building the UI layer for a cross-platform application using Compose Multiplatform. We leverage a single codebase to create UI components like text fields, buttons, and models that seamlessly work across Android, iOS, and Web. The implementation involves state management, integrating COIL for image loading, and constructing multiple screen components, such as a login screen and a game list screen. The entire project, along with additional features like admin role handling and input validation, is available on GitHub. Readers are encouraged to explore and extend the application's functionality.
