How to buildOctober 11, 202611 min read
Mobile DevelopmentStep 7 of 12

Android with Kotlin and Jetpack Compose: your first screen

In short

A Jetpack Compose screen is a set of Kotlin functions annotated with @Composable that describe the UI for the current state: change a value in mutableStateOf and Compose redraws the affected parts. In this lesson we build a services list screen: a Kotlin model, a card made of Row, Column and Box, selection with remember, a LazyColumn list, Material 3 styling and a ViewModel so the selection survives screen rotation.

TutorialLevel: BeginnerKotlin & AndroidPractice: ~1.5–3 h

After this you will be able to

  • Describe a Kotlin model with a data class, a nullable field and pure functions, and cover it with a local test
  • Build a card from Row, Column, Box and a Modifier chain and show it in a @Preview
  • Keep a selection in remember { mutableStateOf(…) }, hoist the state and render a keyed list with LazyColumn
  • Move the state into a ViewModel with StateFlow so it survives screen rotation

Verified with

Kotlin 2.4.21Android Gradle Plugin 9.4.1Gradle 9.6.0Compose BOM 2026.09.00Compose UI 1.12.1Material 3 1.4.0Lifecycle 2.11.0Android SDK 37JDK 21

Checked against docs: October 11, 2026

Contents
  1. 01What you will build and what you need
  2. 02Kotlin in five minutes: the data model
  3. 03The project in Android Studio and Gradle
  4. 04@Composable, Modifier and previews: the service card
  5. 05State: remember and mutableStateOf
  6. 06The list and Material 3: LazyColumn, Scaffold, theme
  7. 07ViewModel: state that survives rotation
  8. 08Checking, common mistakes and practice

What you will build and what you need

By the end of this lesson you will have a native Android screen that lists a studio's services. Each service is a card with a letter avatar, a title, a description and an optional "Popular" label. Tapping a card selects the service, and the bottom bar counts the selection: "In request: 3 services". The selection survives screen rotation because it lives in a ViewModel.

We build the UI with Jetpack Compose, Android's declarative UI toolkit: instead of XML layouts and manually updating View objects, you write functions that describe how the screen looks for the current data, and Compose redraws whatever changed. Compose works only with Kotlin: in the support table on Android's "Kotlin-first" page, Jetpack Compose is marked "No" for the Java language. That is why the first section is a short Kotlin primer.

IDE

Version in this lesson
Android Studio Rabbit 1 | 2026.2.1
Source
current stable on developer.android.com/studio

Kotlin

Version in this lesson
2.4.21
Source
latest stable release on Maven Central

Android Gradle Plugin

Version in this lesson
9.4.1
Source
Google Maven; AGP 9.4 needs Gradle 9.6.0 and JDK 17+

Compose BOM

Version in this lesson
2026.09.00
Source
Compose UI 1.12.1, Material 3 1.4.0

compileSdk / targetSdk

Version in this lesson
37
Source
Compose 1.12 requires compileSdk 37 and AGP 9

minSdk

Version in this lesson
24
Source
our choice; the Compose docs require at least 21

Kotlin in five minutes: the data model

We start with a file that has no Android code at all — just Kotlin. That way the model and logic can be tested with an ordinary test on your computer, and the screen stays "thin": it only displays data.

app/src/main/java/com/example/services/Services.kt
package com.example.services

data class Service(
    val id: Int,
    val title: String,
    val description: String,
    val badge: String? = null, // not every service has a label, so the type allows null
)

val sampleServices = listOf(
    Service(1, "Company website", "Service pages, request form, SEO basics", badge = "Popular"),
    Service(2, "Mobile app", "Native Android app"),
    Service(3, "CRM system", "Clients, deals and tasks in one place"),
    Service(4, "Integrations", "Connecting the site to 1C, payments and messengers"),
    Service(5, "Website audit", "Speed, SEO and accessibility", badge = "New"),
)

/** Returns a new set: removes the id if it is there, otherwise adds it. */
fun toggle(selectedIds: Set<Int>, id: Int): Set<Int> =
    if (id in selectedIds) selectedIds - id else selectedIds + id

/** "In request: 1 service", "… 3 services". */
fun selectedLabel(count: Int): String {
    if (count == 0) return "Choose services"
    val word = if (count == 1) "service" else "services"
    return "In request: $count $word"
}
The model, sample data and two pure functions. No Android imports — the file compiles and is tested on a plain JVM.

val / var

What it does
val cannot be reassigned, var can. Default to val
Where in the code
val title: String, val word = …

data class

What it does
a value class: the compiler generates equals, hashCode, toString and copy()
Where in the code
data class Service(…)

String?

What it does
a type that allows null. Without ? assigning null is a compile error
Where in the code
val badge: String? = null

default values and named arguments

What it does
you can omit a parameter or pass it by name
Where in the code
Service(5, …, badge = …)

when

What it does
branching on conditions that returns a value
Where in the code
choosing the word form

expression body

What it does
the function body is a single expression after =
Where in the code
fun toggle(…) = if (…) … else …

Note toggle: it does not modify the set it receives but returns a new one (selectedIds - id or selectedIds + id). A Kotlin Set<Int> is read-only by default. This matters a couple of sections later: Compose notices a state change when a new value is written to the state, not when an old object is quietly modified.

The badge field is declared as String?, so the compiler will not let you use it as a plain string. In the card we use a safe call, service.badge?.let { … }: the block runs only when there is a label. The Kotlin docs say to use the !! operator ("I am sure this is not null") only when you really are sure — otherwise you get a NullPointerException at runtime.

The project in Android Studio and Gradle

  1. New project

    File → New → New Project → the Empty Activity template. The language is Kotlin (Compose offers no other option); the minimum API in this lesson is 24.

  2. Package and name

    Name Services, package com.example.services. For a real app replace com.example with your company's reversed domain — it becomes the applicationId in the store.

  3. Gradle sync

    Studio downloads the dependencies. App code lives in app/src/main/java/…, local tests in app/src/test/….

  4. Versions in the catalog

    The template keeps versions in gradle/libs.versions.toml. Compare them with the table above or bump them to the current ones.

gradle/libs.versions.toml
[versions]
agp = "9.4.1"
kotlin = "2.4.21"
coreKtx = "1.19.1"
activityCompose = "1.13.0"
lifecycle = "2.11.0"
composeBom = "2026.09.00"
junit = "4.13.2"
robolectric = "4.17"

[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" }
androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" }
androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
junit = { group = "junit", name = "junit", version.ref = "junit" }
robolectric = { group = "org.robolectric", name = "robolectric", version.ref = "robolectric" }

[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
The version catalog of the verified project. Robolectric is only needed for UI tests on the JVM — the Studio template does not include it.
app/build.gradle.kts
plugins {
    alias(libs.plugins.android.application)
    alias(libs.plugins.kotlin.compose)
}

android {
    namespace = "com.example.services"
    compileSdk = 37

    defaultConfig {
        applicationId = "com.example.services"
        minSdk = 24
        targetSdk = 37
        versionCode = 1
        versionName = "1.0"
    }

    compileOptions {
        sourceCompatibility = JavaVersion.VERSION_17
        targetCompatibility = JavaVersion.VERSION_17
    }
    buildFeatures {
        compose = true
    }
    testOptions {
        unitTests.isIncludeAndroidResources = true
    }
}

dependencies {
    implementation(libs.androidx.core.ktx)
    implementation(libs.androidx.activity.compose)
    implementation(libs.androidx.lifecycle.viewmodel.compose)
    implementation(libs.androidx.lifecycle.runtime.compose)

    implementation(platform(libs.androidx.compose.bom))
    implementation(libs.androidx.compose.ui)
    implementation(libs.androidx.compose.material3)
    implementation(libs.androidx.compose.ui.tooling.preview)
    debugImplementation(libs.androidx.compose.ui.tooling)

    testImplementation(libs.junit)
    // Compose UI tests on the JVM via Robolectric, only to verify the lesson
    testImplementation(platform(libs.androidx.compose.bom))
    testImplementation(libs.androidx.compose.ui.test.junit4)
    testImplementation(libs.robolectric)
    debugImplementation(libs.androidx.compose.ui.test.manifest)
}
The app module. There is no org.jetbrains.kotlin.android plugin here — on purpose.
  • In AGP 9, Kotlin is built into the Android plugin and enabled by default, so you no longer apply a separate org.jetbrains.kotlin.android plugin. If an older guide includes it, that guide is for AGP 8.
  • The Compose compiler comes from the org.jetbrains.kotlin.plugin.compose plugin; its version matches your Kotlin version (available since Kotlin 2.0).
  • A BOM (Bill of Materials) is a list of compatible versions: you set one compose-bom version and add Compose libraries without version numbers. BOM 2026.09.00 gives Compose UI 1.12.1 and Material 3 1.4.0.
  • buildFeatures { compose = true } turns Compose on for the module. ui-tooling is a debugImplementation dependency, so preview tooling never reaches the release build.

@Composable, Modifier and previews: the service card

A composable function is an ordinary Kotlin function annotated with @Composable. It returns nothing: by calling Text, Row or Card inside, it describes a piece of UI. Composables can only be called from other composables — the root of the tree is set by setContent in an Activity. By convention their names are capitalized nouns: ServiceCard, not showCard.

app/src/main/java/com/example/services/ServiceCard.kt
package com.example.services

import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.selection.toggleable
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Checkbox
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.example.services.ui.theme.ServicesTheme

@Composable
fun ServiceCard(
    service: Service,
    selected: Boolean,
    onToggle: () -> Unit,
    modifier: Modifier = Modifier,
) {
    Card(
        modifier = modifier.fillMaxWidth(),
        colors = CardDefaults.cardColors(
            containerColor = if (selected) {
                MaterialTheme.colorScheme.primaryContainer
            } else {
                MaterialTheme.colorScheme.surfaceContainerHigh
            },
        ),
    ) {
        Row(
            modifier = Modifier
                .toggleable(value = selected, role = Role.Checkbox, onValueChange = { onToggle() })
                .padding(16.dp),
            verticalAlignment = Alignment.CenterVertically,
        ) {
            Box(
                modifier = Modifier
                    .size(40.dp)
                    .clip(CircleShape)
                    .background(MaterialTheme.colorScheme.primary),
                contentAlignment = Alignment.Center,
            ) {
                Text(
                    text = service.title.take(1),
                    color = MaterialTheme.colorScheme.onPrimary,
                    style = MaterialTheme.typography.titleMedium,
                )
            }
            Spacer(Modifier.width(16.dp))
            Column(modifier = Modifier.weight(1f)) {
                Text(service.title, style = MaterialTheme.typography.titleMedium)
                Text(
                    text = service.description,
                    style = MaterialTheme.typography.bodyMedium,
                    color = MaterialTheme.colorScheme.onSurfaceVariant,
                )
                service.badge?.let { badge ->
                    Text(
                        text = badge,
                        style = MaterialTheme.typography.labelMedium,
                        color = MaterialTheme.colorScheme.primary,
                    )
                }
            }
            Checkbox(checked = selected, onCheckedChange = null)
        }
    }
}

@Preview(showBackground = true)
@Composable
fun ServiceCardPreview() {
    ServicesTheme {
        ServiceCard(service = sampleServices[0], selected = true, onToggle = {})
    }
}
The card holds no state: the selected and onToggle parameters tell it what is selected and what to do on tap.

Column

Layout
children top to bottom
In the card
title, description, label

Row

Layout
children left to right
In the card
avatar, text, checkbox

Box

Layout
children stacked on top of each other, aligned with contentAlignment
In the card
a letter centered in a circle

Spacer

Layout
empty space of a given size
In the card
16 dp between avatar and text

Modifier.weight(1f)

Layout
take the remaining space in a Row
In the card
the text shrinks, the checkbox stays on the right

A Modifier is a chain of settings for size, padding, background, shape and behavior. Order matters: modifiers apply one after another. On the avatar, clip(CircleShape) comes before background(…), so the background is drawn inside the circle; swap them and you get a square. On the row, toggleable(…) comes before padding(16.dp), so the whole card is tappable, padding included. By convention a composable takes a modifier: Modifier = Modifier parameter and applies it to its root element, so the caller can set sizing from outside.

A function marked @Preview renders the card right in Android Studio without starting an emulator. A preview is a @Composable without parameters (or with default ones), so ServiceCard needs a separate ServiceCardPreview wrapper with sample data. Previews are rendered by Layoutlib outside a device: no network, no file access, and not every Context API is available.

State: remember and mutableStateOf

For cards to be selectable, the screen needs memory: which services are selected. A plain local variable will not do: a composable function runs again on every update (this is called recomposition), and the variable would be recreated from scratch. You need two things: mutableStateOf creates an observable value — when it changes, Compose redraws the functions that read it — and remember keeps that value across recompositions.

app/src/main/java/com/example/services/ServicesScreen.kt
package com.example.services

import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue

@Composable
fun ServicesScreen() {
    var selectedIds by remember { mutableStateOf(setOf<Int>()) }

    ServicesContent(
        services = sampleServices,
        selectedIds = selectedIds,
        onToggle = { id -> selectedIds = toggle(selectedIds, id) },
    )
}
A screen with state in remember. getValue and setValue are imported explicitly — the by delegate does not work without them.

ServicesScreen owns the state, while ServicesContent (next section) and ServiceCard only receive values and report events through lambdas. This is state hoisting: the state is replaced by a value-plus-callback pair — selected: Boolean and onToggle: () -> Unit. The docs call this unidirectional data flow: state goes down, events go up. Stateless functions like these are easy to reuse, preview and test.

val state = remember { mutableStateOf(x) }

When it fits
you need the state object itself: read state.value

var value by remember { mutableStateOf(x) }

When it fits
the most common form: you use it like an ordinary variable

val (value, setValue) = remember { mutableStateOf(x) }

When it fits
handy when you pass the setter on as a separate function

The limitation of remember: the value lives while the function is in the composition and does not survive a configuration change such as screen rotation, when the Activity is recreated. For that there is rememberSaveable (exercise 2) or a ViewModel (section below).

The list and Material 3: LazyColumn, Scaffold, theme

Five cards would fit in a Column, but a Column composes and lays out every item at once — a performance problem for long lists or lists of unknown length. LazyColumn composes only the visible items. Our verification showed this literally: the UI test could not find the fifth card on Robolectric's small screen until the list was scrolled to it.

app/src/main/java/com/example/services/ServicesContent.kt
package com.example.services

import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.BottomAppBar
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.example.services.ui.theme.ServicesTheme

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ServicesContent(
    services: List<Service>,
    selectedIds: Set<Int>,
    onToggle: (Int) -> Unit,
    modifier: Modifier = Modifier,
) {
    Scaffold(
        modifier = modifier,
        topBar = { TopAppBar(title = { Text("Services") }) },
        bottomBar = {
            BottomAppBar {
                Text(selectedLabel(selectedIds.size), modifier = Modifier.padding(horizontal = 16.dp))
            }
        },
    ) { innerPadding ->
        LazyColumn(
            modifier = Modifier.padding(innerPadding),
            contentPadding = PaddingValues(16.dp),
            verticalArrangement = Arrangement.spacedBy(12.dp),
        ) {
            items(services, key = { it.id }) { service ->
                ServiceCard(
                    service = service,
                    selected = service.id in selectedIds,
                    onToggle = { onToggle(service.id) },
                )
            }
        }
    }
}

@Preview(showBackground = true)
@Composable
fun ServicesContentPreview() {
    ServicesTheme {
        ServicesContent(services = sampleServices, selectedIds = setOf(1, 3), onToggle = {})
    }
}
A stateless screen: the Material 3 scaffold, top and bottom bars, and a lazy list with keys.
  • items(services, key = { it.id }) — inside LazyColumn there is no ordinary composable block but a DSL: you describe the items, and the list decides which to create. The androidx.compose.foundation.lazy.items import is needed for the list overload.
  • key is a stable, unique key. Without it, an item's state is tied to its position and "moves" to a neighbor when items are inserted or sorted. The key type must be storable in a Bundle — Int, String and enums work.
  • contentPadding pads the content (the first and last items do not stick to the edges while scrolling), and Arrangement.spacedBy(12.dp) adds gaps between items.
  • Scaffold places the bars and passes innerPadding — the space the bars take. You must apply it to the content, otherwise the first card slides under the TopAppBar.

MaterialTheme defines colors, typography and shapes. Inside the theme, components read values from MaterialTheme.colorScheme and MaterialTheme.typography — that is why the card has no hardcoded colors and dark theme works without extra effort. The Studio template creates theme files in ui/theme; below is a shortened version with dynamic color.

app/src/main/java/com/example/services/ui/theme/Theme.kt
package com.example.services.ui.theme

import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext

private val LightColors = lightColorScheme(primary = Color(0xFF3949AB))
private val DarkColors = darkColorScheme(primary = Color(0xFF9FA8DA))

@Composable
fun ServicesTheme(
    darkTheme: Boolean = isSystemInDarkTheme(),
    dynamicColor: Boolean = true,
    content: @Composable () -> Unit,
) {
    val colorScheme = when {
        dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
            val context = LocalContext.current
            if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
        }
        darkTheme -> DarkColors
        else -> LightColors
    }
    MaterialTheme(colorScheme = colorScheme, content = content)
}
Dynamic color (a palette taken from the user's wallpaper) is available on Android 12+ (API 31, VERSION_CODES.S); older versions fall back to our own light or dark scheme.

ViewModel: state that survives rotation

A ViewModel is a screen-level state holder: it keeps data and business logic and survives configuration changes. State is exposed through a StateFlow — a stream that always has a current value. The mutable MutableStateFlow stays private; the outside world sees a read-only version.

app/src/main/java/com/example/services/ServicesViewModel.kt
package com.example.services

import androidx.lifecycle.ViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update

data class ServicesUiState(
    val services: List<Service> = sampleServices,
    val selectedIds: Set<Int> = emptySet(),
)

class ServicesViewModel : ViewModel() {
    private val _uiState = MutableStateFlow(ServicesUiState())
    val uiState: StateFlow<ServicesUiState> = _uiState.asStateFlow()

    fun onToggle(id: Int) {
        _uiState.update { state -> state.copy(selectedIds = toggle(state.selectedIds, id)) }
    }
}
update { … } atomically replaces the state with a new copy: the data class copy() changes one field.
app/src/main/java/com/example/services/ServicesRoute.kt
package com.example.services

import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel

@Composable
fun ServicesRoute(viewModel: ServicesViewModel = viewModel()) {
    val uiState by viewModel.uiState.collectAsStateWithLifecycle()

    ServicesContent(
        services = uiState.services,
        selectedIds = uiState.selectedIds,
        onToggle = viewModel::onToggle,
    )
}
Connecting the ViewModel to the screen. viewModel() comes from lifecycle-viewmodel-compose, collectAsStateWithLifecycle() from lifecycle-runtime-compose.
app/src/main/java/com/example/services/MainActivity.kt
package com.example.services

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import com.example.services.ui.theme.ServicesTheme

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContent {
            ServicesTheme {
                ServicesRoute()
            }
        }
    }
}
The entry point: setContent sets the root of the Compose tree. enableEdgeToEdge() draws the app behind the system bars; Scaffold provides the insets for them.
  • ServicesRoute is the only place that knows about the ViewModel. The ViewModel itself is not passed down into cards and lists: the docs recommend keeping it at screen level so lower components do not get more data and logic than they need.
  • A ViewModel must not hold a Context, Activity or Resources: it outlives the screen, and such a reference leaks memory.
  • Previews do not work well with a ViewModel — the preview system cannot construct all its dependencies. That is why we preview ServicesContent, not ServicesRoute: one more reason to keep the screen stateless.
  • collectAsStateWithLifecycle() collects the flow only while the screen is visible and turns it into Compose state.

Checking, common mistakes and practice

We check the logic with local tests: they live in app/src/test, run on your computer's JVM and need no emulator. The pure functions from Services.kt and the ViewModel, which has no Android dependencies, are tested with plain JUnit.

app/src/test/java/com/example/services/ServicesTest.kt
package com.example.services

import org.junit.Assert.assertEquals
import org.junit.Test

class ServicesTest {
    @Test
    fun toggleAddsAndRemoves() {
        val once = toggle(emptySet(), 3)
        assertEquals(setOf(3), once)
        assertEquals(emptySet<Int>(), toggle(once, 3))
    }

    @Test
    fun labelUsesEnglishPlurals() {
        assertEquals("Choose services", selectedLabel(0))
        assertEquals("In request: 1 service", selectedLabel(1))
        assertEquals("In request: 3 services", selectedLabel(3))
        assertEquals("In request: 11 services", selectedLabel(11))
        assertEquals("In request: 21 services", selectedLabel(21))
    }

    @Test
    fun viewModelTogglesSelection() {
        val viewModel = ServicesViewModel()
        viewModel.onToggle(2)
        viewModel.onToggle(5)
        assertEquals(setOf(2, 5), viewModel.uiState.value.selectedIds)
        viewModel.onToggle(2)
        assertEquals(setOf(5), viewModel.uiState.value.selectedIds)
    }
}
app/src/test/java/com/example/services/ServicesScreenTest.kt
package com.example.services

import androidx.compose.ui.test.assertIsOff
import androidx.compose.ui.test.assertIsOn
import androidx.compose.ui.test.hasScrollAction
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.isToggleable
import androidx.compose.ui.test.junit4.v2.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollToNode
import com.example.services.ui.theme.ServicesTheme
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config

// Lesson check on the JVM (Robolectric), not on a device
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [35])
class ServicesScreenTest {
    @get:Rule
    val composeRule = createComposeRule()

    @Test
    fun tapOnCardTogglesSelectionAndLabel() {
        composeRule.setContent { ServicesTheme(dynamicColor = false) { ServicesScreen() } }

        composeRule.onNodeWithText("Choose services").assertExists()
        composeRule.onNodeWithText("Company website").performClick()
        composeRule.onNodeWithText("In request: 1 service").assertExists()

        composeRule.onNodeWithText("Mobile app").performClick()
        composeRule.onNodeWithText("In request: 2 services").assertExists()

        composeRule.onNodeWithText("Company website").performClick()
        composeRule.onNodeWithText("In request: 1 service").assertExists()
    }

    @Test
    fun cardIsOneToggleableNode() {
        composeRule.setContent { ServicesTheme(dynamicColor = false) { ServicesScreen() } }

        val card = composeRule.onNode(hasText("Company website") and isToggleable())
        card.assertIsOff()
        card.performClick()
        card.assertIsOn()
    }

    @Test
    fun lastItemIsReachableByScroll() {
        composeRule.setContent { ServicesTheme(dynamicColor = false) { ServicesScreen() } }

        composeRule.onNode(hasScrollAction()).performScrollToNode(hasText("Website audit"))
        composeRule.onNodeWithText("Website audit").performClick()
        composeRule.onNodeWithText("In request: 1 service").assertExists()
    }
}
A Compose UI test on the JVM via Robolectric: taps, on/off state and scrolling the lazy list. It checks UI logic; it does not replace testing on a device.
bash
./gradlew assembleDebug testDebugUnitTest
Builds the APK and runs all local tests. Verified result: BUILD SUCCESSFUL, 6 of 6 tests, and app/build/outputs/apk/debug/app-debug.apk.

"Property delegate must have a 'getValue(…)' method" on by

Cause
missing delegate imports
Fix
import androidx.compose.runtime.getValue / setValue

tapping changes nothing on screen

Cause
state is a mutableListOf or a variable without remember
Fix
remember { mutableStateOf(…) } and a new immutable value

selection resets on rotation

Cause
remember does not survive Activity recreation
Fix
rememberSaveable or a ViewModel

the first card is under the top bar

Cause
innerPadding from Scaffold is not applied
Fix
Modifier.padding(innerPadding)

"This material API is experimental" error

Cause
an experimental Material 3 component without opt-in
Fix
@OptIn(ExperimentalMaterial3Api::class)

Compose requires compileSdk 37 / AGP 9

Cause
a BOM with Compose 1.12 in an old project
Fix
raise compileSdk and AGP, or use an older BOM

Next, build the same screen for iOS in SwiftUI and compare how state and lists work there. When the app is ready for release, see the guide to publishing on Google Play and the App Store.

How this was verified4 · passed1 · not runchecked 11 October 2026

“Run” means the code was executed in a scratch project or container and the output in the article comes from that run. “Partial” means it was checked against stubs instead of external services. “Not run” means it needs a device, an account or a key; the steps were checked against the official documentation. The verification project files and run logs are kept in the InterLAB archive.

Tell us about your task — we'll review your request and get back to you

Got a project?
We'll scope and estimate it — for free.

Tell us about the product or the problem. After reviewing the task we'll come back with an architecture, a timeline and a price for the first version.

  • Scope and risks estimated before we start
  • NDA — yours or ours
  • Work starts within a week

Please do not send passwords, access keys or source code through this form — we will agree a safe way to share access separately.

By clicking the button you agree to the processing of your personal data.