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.
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,Boxand aModifierchain and show it in a@Preview - Keep a selection in
remember { mutableStateOf(…) }, hoist the state and render a keyed list withLazyColumn - Move the state into a
ViewModelwithStateFlowso it survives screen rotation
Know beforehand
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
- What you will build and what you need
- Kotlin in five minutes: the data model
- The project in Android Studio and Gradle
- @Composable, Modifier and previews: the service card
- State: remember and mutableStateOf
- The list and Material 3: LazyColumn, Scaffold, theme
- ViewModel: state that survives rotation
- Checking, 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.
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"
}val / var
- What it does
valcannot be reassigned,varcan. Default toval- Where in the code
val title: String,val word = …
data class
- What it does
- a value class: the compiler generates
equals,hashCode,toStringandcopy() - Where in the code
data class Service(…)
String?
- What it does
- a type that allows
null. Without?assigningnullis 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
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.
Package and name
Name
Services, packagecom.example.services. For a real app replacecom.examplewith your company's reversed domain — it becomes theapplicationIdin the store.Gradle sync
Studio downloads the dependencies. App code lives in
app/src/main/java/…, local tests inapp/src/test/….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.
[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" }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)
}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.androidplugin. If an older guide includes it, that guide is for AGP 8. - The Compose compiler comes from the
org.jetbrains.kotlin.plugin.composeplugin; 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-bomversion 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-toolingis adebugImplementationdependency, 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.
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 = {})
}
}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.
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) },
)
}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.
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 = {})
}
}items(services, key = { it.id })— insideLazyColumnthere is no ordinary composable block but a DSL: you describe the items, and the list decides which to create. Theandroidx.compose.foundation.lazy.itemsimport is needed for the list overload.keyis 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 aBundle—Int,Stringand enums work.contentPaddingpads the content (the first and last items do not stick to the edges while scrolling), andArrangement.spacedBy(12.dp)adds gaps between items.Scaffoldplaces the bars and passesinnerPadding— the space the bars take. You must apply it to the content, otherwise the first card slides under theTopAppBar.
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.
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)
}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.
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.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,
)
}ViewModel to the screen. viewModel() comes from lifecycle-viewmodel-compose, collectAsStateWithLifecycle() from lifecycle-runtime-compose.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()
}
}
}
}setContent sets the root of the Compose tree. enableEdgeToEdge() draws the app behind the system bars; Scaffold provides the insets for them.ServicesRouteis the only place that knows about theViewModel. TheViewModelitself 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
ViewModelmust not hold aContext,ActivityorResources: 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 previewServicesContent, notServicesRoute: 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.
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)
}
}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()
}
}./gradlew assembleDebug testDebugUnitTestBUILD 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
mutableListOfor a variable withoutremember - Fix
remember { mutableStateOf(…) }and a new immutable value
selection resets on rotation
- Cause
rememberdoes not surviveActivityrecreation- Fix
rememberSaveableor aViewModel
the first card is under the top bar
- Cause
innerPaddingfromScaffoldis 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
compileSdkand 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.