Mobile Design Rounds
Build guide Android25 min read

Build it: a notifications screen in Jetpack Compose

Last reviewed . Drafted with AI assistance. Checked against official documentation where noted (how this is checked). Part of: Design a notifications screen in Jetpack Compose · Feeds and media · The five-step method.

This is the implementation material behind the notifications screen interview case. It covers:

  • the JSON and the wire types, with a fallback for a row type the app does not know
  • the app model, the clock and the ticker
  • the repository, the ViewModel and the screen
  • the countdown card, with the tick read in two small places
  • strings and plurals, and the tests

None of it fits in a 45 minute round. I write only the model, the clock and the card if the interviewer asks.

How this code was checked. I compiled it and ran 27 tests on the desktop JVM with Compose 1.7.3 and Kotlin 2.0.21. Three Android pieces were stand-ins there: the image composable (a box that counts how often it runs), string resources, and PullToRefreshBox. The strings section and the dependency list below are not compiled. I did not run any of it on a phone. The desktop build uses Compose Multiplatform 1.7.3, which is based on Jetpack Compose 1.7. Check your own versions before you copy anything.

Imports are left out. The package is app in the listings. Left out on purpose: dependency injection, navigation, theme, the HTTP client behind NotificationsApi, and the image library setup.

45-minute plan

MinutesStepWhat I have drawn by the endSkip if late
0 to 5Scope and constraintsThe seen/ask table, defaultsFewer questions
5 to 13Data and contractsThe sealed model, the callsThe call table
13 to 21Client structureScreen, ViewModel, repository, clock; the LazyColumnOne line per box
21 to 38Make the hard part workThe clock, the ticker, where the tick is read, Accept and DeclineThe progress bar
38 to 45Break it, ship it, roll it backTop failures, tests, the flagTwo failures and the flag

Dependencies

// Kotlin 2.0.20 or newer, so strong skipping is on by default.
dependencies {
    implementation(platform("androidx.compose:compose-bom:<current>"))
    implementation("androidx.compose.material3:material3")   // PullToRefreshBox: the 1.3 line or newer, check your version
    implementation("androidx.lifecycle:lifecycle-runtime-compose:<current>")   // collectAsStateWithLifecycle
    implementation("androidx.lifecycle:lifecycle-viewmodel-compose:<current>")
    implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:<current>")
    implementation("io.coil-kt.coil3:coil-compose:<current>")                  // or the image library you already use

    testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:<current>")
    androidTestImplementation("androidx.compose.ui:ui-test-junit4")
}

1. Wire format

One list, in display order, with the server time on the response:

{
  "serverTimeMs": 1792492800000,
  "items": [
    { "type": "photo_activity", "id": "pa_881", "createdAtMs": 1792492080000,
      "action": "added", "photoCount": 5, "albumId": "al_7", "albumTitle": "Lisbon",
      "actorCount": 3,
      "actors": [ { "id": "u1", "name": "Ana", "avatarUrl": "https://..." }, { "id": "u2", "name": "Ben" } ] },
    { "type": "album_offer", "id": "offer_12", "createdAtMs": 1792400000000,
      "title": "Print your Lisbon trip", "body": "A hardcover album, 24 pages",
      "imageUrl": "https://...", "ctaLabel": "Order album", "expiresAtMs": 1792669385000 },
    { "type": "friend_request", "id": "fr_40", "createdAtMs": 1792300000000,
      "from": { "id": "u9", "name": "Sam" }, "status": "pending" }
  ]
}

The DTOs, the unknown-type fallback and the mapping to the app model. A newer server can send a type this version does not know. polymorphicDefaultDeserializer turns it into UnknownDto, and the mapper drops it. ignoreUnknownKeys covers new fields on known types.

// ---- Wire format -------------------------------------------------------------------------

@Serializable
data class FeedDto(val serverTimeMs: Long, val items: List<ItemDto>)

@Serializable
sealed interface ItemDto {
    val id: String
    val createdAtMs: Long
}

@Serializable
data class ActorDto(val id: String, val name: String, val avatarUrl: String? = null)

@Serializable @SerialName("photo_activity")
data class PhotoActivityDto(
    override val id: String,
    override val createdAtMs: Long,
    val action: String,
    val actors: List<ActorDto>,
    val actorCount: Int,
    val photoCount: Int,
    val albumId: String,
    val albumTitle: String,
    val text: String? = null,
) : ItemDto

@Serializable @SerialName("album_offer")
data class AlbumOfferDto(
    override val id: String,
    override val createdAtMs: Long,
    val title: String,
    val body: String,
    val imageUrl: String,
    val ctaLabel: String,
    val expiresAtMs: Long,
) : ItemDto

@Serializable @SerialName("friend_request")
data class FriendRequestDto(
    override val id: String,
    override val createdAtMs: Long,
    val from: ActorDto,
    val status: String,
) : ItemDto

/** A type this app version does not know. It must not crash the screen. */
@Serializable
data class UnknownDto(override val id: String = "", override val createdAtMs: Long = 0) : ItemDto

val NotificationsJson = Json {
    ignoreUnknownKeys = true
    serializersModule = SerializersModule {
        polymorphicDefaultDeserializer(ItemDto::class) { UnknownDto.serializer() }
    }
}

fun ItemDto.toDomain(): NotificationItem? = when (this) {
    is PhotoActivityDto -> PhotoActivity(
        id = id, createdAtMs = createdAtMs,
        action = when (action) { "added" -> PhotoAction.Added; "edited" -> PhotoAction.Edited; else -> PhotoAction.Unknown },
        actors = actors.map { Actor(it.id, it.name, it.avatarUrl) },
        actorCount = maxOf(actorCount, actors.size),
        photoCount = photoCount, albumId = albumId, albumTitle = albumTitle, fallbackText = text,
    )
    is AlbumOfferDto -> AlbumOffer(id, createdAtMs, title, body, imageUrl, ctaLabel, expiresAtMs)
    is FriendRequestDto -> FriendRequest(
        id = id, createdAtMs = createdAtMs,
        from = Actor(from.id, from.name, from.avatarUrl),
        status = when (status) { "accepted" -> RequestStatus.Accepted; "declined" -> RequestStatus.Declined; else -> RequestStatus.Pending },
    )
    is UnknownDto -> null // skip what we cannot draw
}

2. REST

CallRequestSuccessErrors the app handles
GET /v1/notifications?limit=30none200 with the JSON aboveNetwork or 5xx: keep the old list, show a message. First load: error state with Try again
POST /v1/friend-requests/{id}/acceptnone200 with the new status5xx: roll back. Timeout or 409: unknown, refresh. 404, 410: remove the row
POST /v1/friend-requests/{id}/declinenone200 with the new statusThe same
401 on any callThe HTTP layer refreshes the token and retries once. Accept and Decline are state changes, so a repeat is safe

If the server cannot give serverTimeMs in the body, the Date response header is the same idea with one-second precision. The app model below treats both the same way: it calls sync once per response.

3. App model

Models are @Immutable so that the Compose compiler uses equals, even though they contain a List. inFlight is local only. It is not in the DTOs.

@Immutable
data class Actor(val id: String, val name: String, val avatarUrl: String?)

@Immutable
sealed interface NotificationItem {
    /** Stable and unique across all types, for example "pa_881", "offer_12", "fr_40". */
    val id: String
    /** Epoch millis in server time. Never a string like "12 minutes ago". */
    val createdAtMs: Long
}

enum class PhotoAction { Added, Edited, Unknown }

@Immutable
data class PhotoActivity(
    override val id: String,
    override val createdAtMs: Long,
    val action: PhotoAction,
    val actors: List<Actor>,   // the first few, most recent first, enough for the avatars
    val actorCount: Int,       // everybody involved; can be larger than actors.size
    val photoCount: Int,
    val albumId: String,
    val albumTitle: String,
    val fallbackText: String?, // server text, used only when action is Unknown
) : NotificationItem

@Immutable
data class AlbumOffer(
    override val id: String,
    override val createdAtMs: Long,
    val title: String,
    val body: String,
    val imageUrl: String,
    val ctaLabel: String,
    val expiresAtMs: Long,     // the deadline. "Expired" is computed from it, never stored.
) : NotificationItem

enum class RequestStatus { Pending, Accepted, Declined }
enum class RequestAction { Accept, Decline }

@Immutable
data class FriendRequest(
    override val id: String,
    override val createdAtMs: Long,
    val from: Actor,
    val status: RequestStatus,        // what the server says
    val inFlight: RequestAction? = null, // what the user just did and the server has not confirmed
) : NotificationItem

4. The clock and the ticker

AnchoredServerClock does not follow the wall clock. countdownTicks wakes when the shown second changes, and each value is a clock reading. minuteTicks is the slow one for the "ago" labels. In the app, create the clock with AnchoredServerClock({ SystemClock.elapsedRealtime() }) and share one instance.

interface ServerClock {
    /** Best estimate of the server's time in epoch millis. */
    fun nowMs(): Long
}

/**
 * Server time = the server time of the last response + the time that has passed since,
 * measured with a clock that the user cannot change.
 */
class AnchoredServerClock(
    private val elapsedRealtimeMs: () -> Long,  // { SystemClock.elapsedRealtime() }
    private val wallClockMs: () -> Long = { System.currentTimeMillis() },
) : ServerClock {

    private class Anchor(val serverMs: Long, val elapsedMs: Long)

    @Volatile private var anchor: Anchor? = null

    /**
     * Call on a fresh response that carries the server time, not on a cached one.
     * The server stamped the time before the response travelled, so this estimate runs behind
     * by about one network trip. A careful version adds round trip / 2. Each call can also move the
     * display by the jitter between two responses.
     */
    fun sync(serverNowMs: Long) {
        anchor = Anchor(serverNowMs, elapsedRealtimeMs())
    }

    override fun nowMs(): Long {
        val a = anchor ?: return wallClockMs() // nothing synced yet: the best we have
        return a.serverMs + (elapsedRealtimeMs() - a.elapsedMs)
    }
}

/**
 * Emits the server time each time the second shown for [deadlineMs] changes,
 * and once more when the deadline has passed. Then it completes.
 */
fun ServerClock.countdownTicks(deadlineMs: Long): Flow<Long> = flow {
    while (true) {
        val now = nowMs()
        emit(now)
        val remaining = deadlineMs - now
        if (remaining <= 0) return@flow
        // delay() only says "wake me about then". The value always comes from the clock.
        val toNextSecond = remaining % 1_000L
        delay(if (toNextSecond == 0L) 1_000L else toNextSecond)
    }
}

/** Emits the server time at the start of every minute, forever. For "12 minutes ago". */
fun ServerClock.minuteTicks(): Flow<Long> = flow {
    while (true) {
        val now = nowMs()
        emit(now)
        delay(60_000L - Math.floorMod(now, 60_000L))
    }
}

The pure functions for display, with no Android code, so they test easily. The countdown rounds up, and an age is never negative.

/** The time left, split for display. Pure, so it is easy to test. */
data class Remaining(val days: Long, val hours: Long, val minutes: Long, val seconds: Long) {
    val isOver: Boolean get() = days == 0L && hours == 0L && minutes == 0L && seconds == 0L
}

/** Rounds up: 0.4 s left still shows "1 second", so the display hits zero when the offer ends. */
fun remainingParts(deadlineMs: Long, nowMs: Long): Remaining {
    val left = (deadlineMs - nowMs).coerceAtLeast(0L)
    val totalSeconds = (left + 999L) / 1_000L
    return Remaining(
        days = totalSeconds / 86_400L,
        hours = totalSeconds % 86_400L / 3_600L,
        minutes = totalSeconds % 3_600L / 60L,
        seconds = totalSeconds % 60L,
    )
}

sealed interface Age {
    data object JustNow : Age
    data class Minutes(val n: Int) : Age
    data class Hours(val n: Int) : Age
    data class Days(val n: Int) : Age
}

/** A device clock that is ahead of the server must not print "in 2 hours". */
fun ageOf(createdAtMs: Long, nowMs: Long): Age {
    val minutes = ((nowMs - createdAtMs).coerceAtLeast(0L) / 60_000L).toInt()
    return when {
        minutes < 1 -> Age.JustNow
        minutes < 60 -> Age.Minutes(minutes)
        minutes < 24 * 60 -> Age.Hours(minutes / 60)
        else -> Age.Days(minutes / (24 * 60))
    }
}

5. The repository

  • refresh and respond take turns on one Mutex, so a slow refresh cannot bring back an old "Pending" after an Accept.
  • Only one refresh runs at a time. A second call returns at once.
  • respond runs in the app scope. Leaving the screen does not cancel a tap that was already sent.
  • Every catch (e: Exception) is preceded by a CancellationException rethrow. Without it, cancelling a coroutine would look like a network error.
  • The refresh also runs in the app scope, so a cleared ViewModel cannot leave refreshing = true for the next screen.
  • 404 and 410 remove the row. A timeout (IOException), a 409 or a status the app does not know is Unknown: the ViewModel refreshes and shows what the server holds. A 5xx is Failed: roll back.
  • Two rows with one id would crash LazyColumn, so the mapper keeps the first. One bad row inside a known type still fails the whole refresh, and the old list stays. Decoding each row on its own is the fix if that matters.
  • Accepted keeps the row with the new status. Declined removes it.
sealed interface FeedState {
    data object Loading : FeedState
    data object Failed : FeedState // nothing to show
    data class Loaded(
        val items: List<NotificationItem>,
        val refreshing: Boolean = false,
    ) : FeedState
}

sealed interface RespondResult {
    data class Done(val status: RequestStatus) : RespondResult
    data object Gone : RespondResult          // withdrawn, or the account is gone
    data object Failed : RespondResult        // the server said no (5xx, 4xx): nothing changed, the user can retry
    data object Unknown : RespondResult       // timeout or conflict: it may have worked. Refresh to find out.
}

interface NotificationsRepository {
    val feed: Flow<FeedState>
    /** Returns false when the refresh failed. The old items stay. */
    suspend fun refresh(): Boolean
    suspend fun respond(requestId: String, action: RequestAction): RespondResult
}
class HttpError(val code: Int) : Exception("HTTP $code")

interface NotificationsApi {
    suspend fun notifications(limit: Int): FeedDto
    /** Returns the new status. Throws HttpError for 4xx and 5xx. */
    suspend fun respond(requestId: String, action: RequestAction): String
}

// ---- Repository --------------------------------------------------------------------------

class DefaultNotificationsRepository(
    private val api: NotificationsApi,
    private val clock: AnchoredServerClock,
    private val appScope: CoroutineScope, // lives as long as the process, not the screen
) : NotificationsRepository {

    private val state = MutableStateFlow<FeedState>(FeedState.Loading)
    override val feed: StateFlow<FeedState> = state

    private val turn = Mutex()                       // refresh and respond take turns
    private val refreshing = AtomicBoolean(false)    // at most one refresh at a time

    // The repository outlives the screen, so the refresh must too. If it ran in the caller's
    // scope, leaving the screen would cancel it and leave `refreshing = true` for the next screen.
    override suspend fun refresh(): Boolean = appScope.async { load() }.await()

    private suspend fun load(): Boolean {
        if (!refreshing.compareAndSet(false, true)) return true // one is already running
        try {
            // Before the lock, so the spinner shows even while a tap is being sent.
            state.update { if (it is FeedState.Loaded) it.copy(refreshing = true) else FeedState.Loading }
            return turn.withLock {
                try {
                    val dto = api.notifications(limit = 30)
                    clock.sync(dto.serverTimeMs)
                    // Two rows with one id would crash LazyColumn ("key already used").
                    state.value = FeedState.Loaded(dto.items.distinctBy { it.id }.mapNotNull { it.toDomain() })
                    true
                } catch (e: CancellationException) {
                    throw e
                } catch (e: Exception) {
                    state.update { if (it is FeedState.Loaded) it.copy(refreshing = false) else FeedState.Failed }
                    false
                }
            }
        } finally {
            refreshing.set(false)
        }
    }

    override suspend fun respond(requestId: String, action: RequestAction): RespondResult =
        // Not in the caller's scope: leaving the screen must not cancel a tap that was already sent.
        appScope.async { turn.withLock { send(requestId, action) } }.await()

    private suspend fun send(requestId: String, action: RequestAction): RespondResult =
        try {
            val status = when (api.respond(requestId, action)) {
                "accepted" -> RequestStatus.Accepted
                "declined" -> RequestStatus.Declined
                else -> return RespondResult.Unknown // a status we do not know: ask the server again
            }
            apply { items ->
                if (status == RequestStatus.Declined) items.filterNot { it.id == requestId }
                else items.map { if (it.id == requestId && it is FriendRequest) it.copy(status = status) else it }
            }
            RespondResult.Done(status)
        } catch (e: CancellationException) {
            throw e
        } catch (e: HttpError) {
            when (e.code) {
                404, 410 -> { apply { items -> items.filterNot { it.id == requestId } }; RespondResult.Gone }
                409 -> RespondResult.Unknown // someone, maybe this device, answered already: refresh decides
                else -> RespondResult.Failed
            }
        } catch (e: IOException) {
            RespondResult.Unknown // a timeout can hide a success: the server may have saved it
        } catch (e: Exception) {
            RespondResult.Failed
        }

    private fun apply(change: (List<NotificationItem>) -> List<NotificationItem>) =
        state.update { if (it is FeedState.Loaded) it.copy(items = change(it.items)) else it }
}

6. The ViewModel

  • inFlight is a map from request ID to action. It is the only place that knows "the user did this, the server has not answered".
  • A second tap for the same ID is ignored. The check and the insert happen in one update, so two taps in one frame cannot both pass.
  • A declined row is hidden at once. An accepted row shows the result at once. Both come back if the call fails.
  • The message is state, not an event. The screen shows it once and calls onMessageShown.
  • WhileSubscribed(5_000) keeps the state for five seconds, so a rotation does not reload.
sealed interface NotificationsUiState {
    data object Loading : NotificationsUiState
    data object Failed : NotificationsUiState
    data class Content(
        val items: List<NotificationItem>,
        val refreshing: Boolean,
        val message: UserMessage?,
    ) : NotificationsUiState
}

enum class UserMessage { RespondFailed, RespondUnknown, RequestGone, RefreshFailed }

class NotificationsViewModel(
    private val repository: NotificationsRepository,
) : ViewModel() {

    // What the user did and the server has not confirmed yet. Lives only in memory.
    private val inFlight = MutableStateFlow<Map<String, RequestAction>>(emptyMap())
    private val message = MutableStateFlow<UserMessage?>(null)

    val uiState: StateFlow<NotificationsUiState> =
        combine(repository.feed, inFlight, message) { feed, pending, msg ->
            when (feed) {
                FeedState.Loading -> NotificationsUiState.Loading
                FeedState.Failed -> NotificationsUiState.Failed
                is FeedState.Loaded -> NotificationsUiState.Content(
                    items = feed.items
                        .filterNot { it is FriendRequest && pending[it.id] == RequestAction.Decline } // hide at once
                        .map { item ->
                            if (item is FriendRequest) item.copy(inFlight = pending[item.id]) else item
                        },
                    refreshing = feed.refreshing,
                    message = msg,
                )
            }
        }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), NotificationsUiState.Loading)

    // The first load. A repeat while one runs is ignored by the repository.
    init { onRefresh() }

    fun onRefresh() {
        viewModelScope.launch {
            if (!repository.refresh() && uiState.value is NotificationsUiState.Content) {
                message.value = UserMessage.RefreshFailed
            }
        }
    }

    fun onRespond(requestId: String, action: RequestAction) {
        // The buttons disappear on the next frame, but two taps can arrive before it.
        // getAndUpdate returns the old map, so a retried update cannot leave a stale flag behind.
        val before = inFlight.getAndUpdate { if (requestId in it) it else it + (requestId to action) }
        if (requestId in before) return
        viewModelScope.launch {
            val result = repository.respond(requestId, action)
            // The repository has already applied the new status, so there is no flicker.
            inFlight.update { it - requestId }
            when (result) {
                is RespondResult.Done -> Unit
                RespondResult.Gone -> message.value = UserMessage.RequestGone
                RespondResult.Failed -> message.value = UserMessage.RespondFailed
                RespondResult.Unknown -> {
                    message.value = UserMessage.RespondUnknown
                    repository.refresh() // the server knows what happened
                }
            }
        }
    }

    fun onMessageShown() {
        message.value = null
    }
}

7. The screen and the list

The route reads the ViewModel. The screen takes state and callbacks, so a preview or a test can call it with fake data. The clock is passed down, and the "ago" clock is made once for the list and passed as a State.

@Composable
fun NotificationsRoute(
    viewModel: NotificationsViewModel,
    clock: ServerClock,
    onOfferClick: (offerId: String) -> Unit,
    onAlbumClick: (albumId: String) -> Unit,
) {
    val state by viewModel.uiState.collectAsStateWithLifecycle()
    NotificationsScreen(
        state = state,
        clock = clock,
        onRefresh = viewModel::onRefresh,
        onRespond = viewModel::onRespond,
        onOfferClick = onOfferClick,
        onAlbumClick = onAlbumClick,
        onMessageShown = viewModel::onMessageShown,
    )
}

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun NotificationsScreen(
    state: NotificationsUiState,
    clock: ServerClock,
    onRefresh: () -> Unit,
    onRespond: (requestId: String, action: RequestAction) -> Unit,
    onOfferClick: (offerId: String) -> Unit,
    onAlbumClick: (albumId: String) -> Unit,
    onMessageShown: () -> Unit,
) {
    val snackbarHost = remember { SnackbarHostState() }
    val content = state as? NotificationsUiState.Content
    val message = content?.message
    if (message != null) {
        val text = messageText(message)
        LaunchedEffect(message) {
            try {
                snackbarHost.showSnackbar(text)
            } finally {
                onMessageShown() // also when the user leaves while it shows
            }
        }
    }

    Scaffold(snackbarHost = { SnackbarHost(snackbarHost) }) { padding ->
        Box(Modifier.padding(padding).fillMaxSize()) {
            when (state) {
                NotificationsUiState.Loading -> LoadingPlaceholder()
                NotificationsUiState.Failed -> ErrorState(onRetry = onRefresh)
                is NotificationsUiState.Content -> PullToRefreshBox(
                    isRefreshing = state.refreshing,
                    onRefresh = onRefresh,
                ) {
                    NotificationsList(state.items, clock, onRespond, onOfferClick, onAlbumClick)
                }
            }
        }
    }
}

@Composable
private fun NotificationsList(
    items: List<NotificationItem>,
    clock: ServerClock,
    onRespond: (String, RequestAction) -> Unit,
    onOfferClick: (String) -> Unit,
    onAlbumClick: (String) -> Unit,
) {
    val listState = rememberLazyListState()
    // One slow clock for all "12 minutes ago" labels. We pass the State, not its value.
    val minuteNow = rememberMinuteNow(clock)

    LazyColumn(
        state = listState,
        modifier = Modifier.fillMaxSize().testTag("notifications"),
        contentPadding = PaddingValues(16.dp),
        verticalArrangement = Arrangement.spacedBy(12.dp),
    ) {
        items(
            items = items,
            key = { it.id },
            contentType = { it::class },
        ) { item ->
            when (item) {
                is PhotoActivity -> PhotoActivityRow(
                    item = item,
                    now = minuteNow,
                    onClick = { onAlbumClick(item.albumId) },
                    modifier = Modifier.animateItem(),
                )
                is AlbumOffer -> OfferCard(
                    offer = item,
                    clock = clock,
                    onOrderClick = { onOfferClick(item.id) },
                    modifier = Modifier.animateItem(),
                )
                is FriendRequest -> FriendRequestRow(
                    item = item,
                    now = minuteNow,
                    onAccept = { onRespond(item.id, RequestAction.Accept) },
                    onDecline = { onRespond(item.id, RequestAction.Decline) },
                    modifier = Modifier.animateItem(),
                )
            }
        }
    }
}

@Composable
private fun rememberMinuteNow(clock: ServerClock): State<Long> {
    val flow = remember(clock) { clock.minuteTicks() }
    return flow.collectAsStateWithLifecycle(initialValue = remember(clock) { clock.nowMs() })
}

8. The photo activity and friend request rows

  • Arrangement.spacedBy(-overlap) makes the avatars overlap. The docs say a negative space does that. zIndex puts the first avatar on top.
  • Each avatar has a ring in the surface colour, so the overlap reads clearly.
  • Avatars have contentDescription = null, because the text names the people.
  • AgeText is its own function. It is the only place in a row that reads the minute clock.
// ---- Photo activity ------------------------------------------------------------------------

@Composable
fun PhotoActivityRow(
    item: PhotoActivity,
    now: State<Long>,
    onClick: () -> Unit,
    modifier: Modifier = Modifier,
) {
    Card(onClick = onClick, modifier = modifier.fillMaxWidth()) {
        Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
            AvatarStack(item.actors, item.actorCount)
            Spacer(Modifier.width(12.dp))
            Column(Modifier.weight(1f)) {
                Text(activityText(item), style = MaterialTheme.typography.bodyMedium)
                AgeText(item.createdAtMs, now)
            }
        }
    }
}

@Composable
fun AvatarStack(
    actors: List<Actor>,
    totalCount: Int,
    modifier: Modifier = Modifier,
    size: Dp = 40.dp,
    overlap: Dp = 14.dp,
) {
    val shown = actors.take(3)
    val extra = totalCount - shown.size
    Row(modifier, horizontalArrangement = Arrangement.spacedBy(-overlap)) {
        shown.forEachIndexed { index, actor ->
            Avatar(actor, size, Modifier.zIndex((shown.size - index).toFloat())) // first one on top
        }
        if (extra > 0) {
            Box(
                Modifier
                    .size(size)
                    .clip(CircleShape)
                    .background(MaterialTheme.colorScheme.secondaryContainer)
                    .border(2.dp, MaterialTheme.colorScheme.surface, CircleShape),
                contentAlignment = Alignment.Center,
            ) { Text("+$extra", style = MaterialTheme.typography.labelSmall) }
        }
    }
}

@Composable
fun Avatar(actor: Actor, size: Dp, modifier: Modifier = Modifier) {
    AsyncImage(
        model = actor.avatarUrl,
        contentDescription = null, // decorative: the text names the people
        contentScale = ContentScale.Crop,
        modifier = modifier
            .size(size)
            .clip(CircleShape)
            .border(2.dp, MaterialTheme.colorScheme.surface, CircleShape),
    )
}

/** Only this small function reads the minute clock, so only it recomposes each minute. */
@Composable
private fun AgeText(createdAtMs: Long, now: State<Long>) {
    Text(
        text = ageText(ageOf(createdAtMs, now.value)),
        style = MaterialTheme.typography.labelSmall,
        color = MaterialTheme.colorScheme.onSurfaceVariant,
    )
}

// ---- Friend request ------------------------------------------------------------------------

@Composable
fun FriendRequestRow(
    item: FriendRequest,
    now: State<Long>,
    onAccept: () -> Unit,
    onDecline: () -> Unit,
    modifier: Modifier = Modifier,
) {
    val accepted = item.status == RequestStatus.Accepted || item.inFlight == RequestAction.Accept
    val acceptDescription = acceptDescription(item.from.name)   // a screen reader says who, not just "Accept"
    val declineDescription = declineDescription(item.from.name)
    Card(modifier.fillMaxWidth()) {
        Column(Modifier.padding(12.dp)) {
            Row(verticalAlignment = Alignment.CenterVertically) {
                Avatar(item.from, 40.dp)
                Spacer(Modifier.width(12.dp))
                Column(Modifier.weight(1f)) {
                    Text(
                        text = if (accepted) friendsNowText(item.from.name) else friendRequestText(item.from.name),
                        style = MaterialTheme.typography.bodyMedium,
                    )
                    AgeText(item.createdAtMs, now)
                }
            }
            if (!accepted) {
                Row(Modifier.padding(top = 8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
                    Button(onClick = onAccept, modifier = Modifier.semantics { contentDescription = acceptDescription }) { Text(acceptLabel()) }
                    OutlinedButton(onClick = onDecline, modifier = Modifier.semantics { contentDescription = declineDescription }) { Text(declineLabel()) }
                }
            }
        }
    }
}
// ---- States --------------------------------------------------------------------------------

@Composable
private fun LoadingPlaceholder() {
    Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { CircularProgressIndicator() }
}

@Composable
private fun ErrorState(onRetry: () -> Unit) {
    Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally) {
        Text(errorText())
        Button(onClick = onRetry) { Text(retryLabel()) }
    }
}

9. The offer card and the countdown

  • OfferCard holds the State<Long> but does not read it. It reads expired, which changes once. That read happens in the content scope of the Card, so the tests count the image inside that scope.
  • CountdownText is the only composable that reads the tick for the text.
  • CountdownBar reads the tick inside drawBehind, so a tick repeats only the draw phase.
  • "tnum" (tabular figures) keeps every digit the same width. Words such as "1 day" and "2 days" still differ in width, so the line can move or wrap.
  • The text is not a live region. The Compose docs say live regions are a bad fit for content that updates often, such as countdown timers. The code here does not build a screen reader description for the countdown. The plan: hide the ticking text and the bar from accessibility, and give the card a rough description that changes each minute, so semantics do not update every second. Announce the result of Accept once, in a polite live region, and keep focus on the row. Accept and Decline carry the person's name in their description.
/**
 * The card does NOT read the tick. It reads only `expired`, which flips once.
 * The tick is read by CountdownText and by the progress bar's draw lambda.
 */
@Composable
fun OfferCard(
    offer: AlbumOffer,
    clock: ServerClock,
    onOrderClick: () -> Unit,
    modifier: Modifier = Modifier,
) {
    val now = rememberCountdownNow(clock, offer.expiresAtMs)
    val expired by remember(offer.expiresAtMs) {
        derivedStateOf { now.value >= offer.expiresAtMs }
    }

    Card(modifier.fillMaxWidth().testTag("offer")) {
        Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
            AsyncImage(
                model = offer.imageUrl,
                contentDescription = null,
                contentScale = ContentScale.Crop,
                modifier = Modifier.fillMaxWidth().height(160.dp),
            )
            Text(offer.title, style = MaterialTheme.typography.titleMedium)
            Text(offer.body, style = MaterialTheme.typography.bodyMedium)
            if (expired) {
                Text(offerEndedText(), style = MaterialTheme.typography.labelLarge)
            } else {
                CountdownBar(offer.createdAtMs, offer.expiresAtMs, now, Modifier.clearAndSetSemantics {}) // decorative
                CountdownText(offer.expiresAtMs, now)
                Button(onClick = onOrderClick) { Text(offer.ctaLabel) }
            }
        }
    }
}

/** Server time for one deadline: a tick each time the shown second changes, only while STARTED. */
@Composable
fun rememberCountdownNow(clock: ServerClock, deadlineMs: Long): State<Long> {
    val ticks = remember(clock, deadlineMs) { clock.countdownTicks(deadlineMs) }
    return ticks.collectAsStateWithLifecycle(initialValue = remember(clock) { clock.nowMs() })
}

/** The only composable that reads the tick for the text. A tick recomposes this and nothing above it. */
@Composable
private fun CountdownText(deadlineMs: Long, now: State<Long>, modifier: Modifier = Modifier) {
    Text(
        text = remainingText(remainingParts(deadlineMs, now.value)),
        style = MaterialTheme.typography.titleSmall.merge(TextStyle(fontFeatureSettings = "tnum")),
        modifier = modifier.testTag("countdown"),
    )
}

/**
 * A bar that shrinks as time passes. The read of `now` is inside drawBehind, so a tick
 * invalidates only the draw phase: no recomposition and no layout.
 */
@Composable
fun CountdownBar(startMs: Long, deadlineMs: Long, now: State<Long>, modifier: Modifier = Modifier) {
    val color = MaterialTheme.colorScheme.primary
    Box(
        modifier
            .fillMaxWidth()
            .height(4.dp)
            .drawBehind {
                val total = (deadlineMs - startMs).coerceAtLeast(1L)
                val left = (deadlineMs - now.value).coerceIn(0L, total)
                drawRect(color, size = Size(size.width * left / total, size.height))
            },
    )
}

10. Strings and plurals

Not compiled. In the test setup these are plain Kotlin stand-ins. In the app they come from strings.xml. Two things to know:

  • A plural resource picks its form from one number. "Ana and 2 others added 5 photos" has two numbers, so I build it from two parts. Some languages need the whole sentence in one resource, and you would split the cases there.
  • ListFormatter joins the parts with the right "and" for the locale ("2 days, 2 hours, 43 minutes and 5 seconds"). It needs API level 26. Below that, join with commas.
<resources>
    <string name="friend_request">%1$s sent you a friend request</string>
    <string name="friends_now">You and %1$s are now friends</string>
    <string name="accept">Accept</string>
    <string name="decline">Decline</string>
    <string name="offer_ended">This offer has ended</string>
    <string name="just_now">Just now</string>
    <string name="load_failed">Could not load your notifications</string>
    <string name="try_again">Try again</string>
    <string name="respond_failed">Could not answer. Try again.</string>
    <string name="request_gone">This request is no longer available.</string>
    <string name="refresh_failed">Could not refresh.</string>
    <string name="two_people">%1$s and %2$s</string>
    <string name="activity_added">%1$s added %2$s to %3$s</string>
    <string name="activity_edited">%1$s edited photos in %2$s</string>
    <plurals name="people_others">
        <item quantity="one">%1$s and %2$d other</item>
        <item quantity="other">%1$s and %2$d others</item>
    </plurals>
    <plurals name="photos">
        <item quantity="one">%d photo</item>
        <item quantity="other">%d photos</item>
    </plurals>
    <plurals name="minutes_ago"><item quantity="one">%d minute ago</item><item quantity="other">%d minutes ago</item></plurals>
    <plurals name="hours_ago"><item quantity="one">%d hour ago</item><item quantity="other">%d hours ago</item></plurals>
    <plurals name="days_ago"><item quantity="one">%d day ago</item><item quantity="other">%d days ago</item></plurals>
    <plurals name="days"><item quantity="one">%d day</item><item quantity="other">%d days</item></plurals>
    <plurals name="hours"><item quantity="one">%d hour</item><item quantity="other">%d hours</item></plurals>
    <plurals name="minutes"><item quantity="one">%d minute</item><item quantity="other">%d minutes</item></plurals>
    <plurals name="seconds"><item quantity="one">%d second</item><item quantity="other">%d seconds</item></plurals>
</resources>
@Composable
fun remainingText(r: Remaining): String {
    val parts = buildList {
        if (r.days > 0) add(pluralStringResource(R.plurals.days, r.days.toInt(), r.days))
        if (r.days > 0 || r.hours > 0) add(pluralStringResource(R.plurals.hours, r.hours.toInt(), r.hours))
        if (r.days > 0 || r.hours > 0 || r.minutes > 0) add(pluralStringResource(R.plurals.minutes, r.minutes.toInt(), r.minutes))
        add(pluralStringResource(R.plurals.seconds, r.seconds.toInt(), r.seconds))
    }
    return remember(parts) { ListFormatter.getInstance().format(parts) }   // android.icu.text, API 26+
}

@Composable
fun ageText(age: Age): String = when (age) {
    Age.JustNow -> stringResource(R.string.just_now)
    is Age.Minutes -> pluralStringResource(R.plurals.minutes_ago, age.n, age.n)
    is Age.Hours -> pluralStringResource(R.plurals.hours_ago, age.n, age.n)
    is Age.Days -> pluralStringResource(R.plurals.days_ago, age.n, age.n)
}

@Composable
fun activityText(item: PhotoActivity): String {
    val first = item.actors.firstOrNull()?.name.orEmpty()
    val who = when (item.actorCount) {
        0, 1 -> first
        2 -> stringResource(R.string.two_people, first, item.actors.getOrNull(1)?.name.orEmpty())
        else -> pluralStringResource(R.plurals.people_others, item.actorCount - 1, first, item.actorCount - 1)
    }
    return when (item.action) {
        PhotoAction.Added -> stringResource(
            R.string.activity_added, who,
            pluralStringResource(R.plurals.photos, item.photoCount, item.photoCount), item.albumTitle,
        )
        PhotoAction.Edited -> stringResource(R.string.activity_edited, who, item.albumTitle)
        PhotoAction.Unknown -> item.fallbackText.orEmpty()
    }
}

Wire it up in your own code. The clock is one instance for the app. The repository gets the app scope:

val clock = AnchoredServerClock({ SystemClock.elapsedRealtime() })
val repository = DefaultNotificationsRepository(api, clock, appScope)   // appScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)

val viewModel: NotificationsViewModel = viewModel(
    factory = viewModelFactory { initializer { NotificationsViewModel(repository) } },
)
NotificationsRoute(viewModel, clock, onOfferClick = { /* open the order flow */ }, onAlbumClick = { /* open the album */ })

11. A way to see recomposition in a test

The image composable in my tests is a stand-in that counts how often it runs. It is @NonSkippableComposable, so it runs every time its parent runs. If the card body ran again, the count would go up.

val imageComposeCounts = mutableMapOf<Any?, Int>()

@NonSkippableComposable
@Composable
fun AsyncImage(model: Any?, contentDescription: String?, modifier: Modifier = Modifier, contentScale: ContentScale = ContentScale.Fit) {
    SideEffect { imageComposeCounts[model] = (imageComposeCounts[model] ?: 0) + 1 }
    Box(modifier.testTag("img:$model"))
}

12. Tests

Unit tests, virtual time. The fake clock follows the test scheduler. The first test is the one in the case page: the ticker wakes at 0, 400, 1400, 2400 and 3400 ms. The second jumps the clock by 30 seconds and checks that the next reading is right.

/** Server time follows the test scheduler, plus an optional jump. */
@OptIn(ExperimentalCoroutinesApi::class)
class FakeClock(private val scope: TestScope, private val startMs: Long) : ServerClock {
    var jumpMs = 0L
    override fun nowMs() = startMs + scope.currentTime + jumpMs
}

class AnchoredClockTest {
    @Test fun `follows elapsed time and ignores wall clock changes`() {
        var elapsed = 1_000L
        var wall = 5_000_000L
        val clock = AnchoredServerClock({ elapsed }, { wall })
        assertEquals(5_000_000L, clock.nowMs())          // not synced: wall clock
        clock.sync(10_000L)
        elapsed += 2_500
        wall -= 3_600_000                                // user moves the clock back one hour
        assertEquals(12_500L, clock.nowMs())
        clock.sync(20_000L)                              // re-sync
        elapsed += 1
        assertEquals(20_001L, clock.nowMs())
    }
}

@OptIn(ExperimentalCoroutinesApi::class)
class CountdownTicksTest {
    @Test fun `ticks when the shown second changes and ends after the deadline`() = runTest {
        val clock = FakeClock(this, startMs = 0)
        val seen = mutableListOf<Pair<Long, Long>>() // (virtual time, shown seconds)
        clock.countdownTicks(deadlineMs = 3_400).collect { now ->
            seen += currentTime to remainingParts(3_400, now).seconds
        }
        assertEquals(listOf(0L to 4L, 400L to 3L, 1_400L to 2L, 2_400L to 1L, 3_400L to 0L), seen)
    }

    @Test fun `missed time is recovered from the clock, not counted`() = runTest {
        val clock = FakeClock(this, startMs = 0)
        val shown = mutableListOf<Long>()
        val job = launch { clock.countdownTicks(60_000).collect { shown += remainingParts(60_000, it).seconds } }
        testScheduler.advanceTimeBy(1_500)
        testScheduler.runCurrent()
        clock.jumpMs = 30_000   // as if the process was frozen for 30 s
        testScheduler.advanceTimeBy(600)
        testScheduler.runCurrent()
        assertEquals(28L, shown.last())   // 60 - 1.5 - 0.6 - 30 = 27.9 -> shows 28
        job.cancel()
    }

    @Test fun `an already expired deadline emits once`() = runTest {
        val clock = FakeClock(this, startMs = 10_000)
        val all = clock.countdownTicks(5_000).toList()
        assertEquals(1, all.size)
        assertTrue(remainingParts(5_000, all[0]).isOver)
    }
}

class FormatTest {
    @Test fun `rounds up and splits`() {
        val ms = ((2 * 86_400 + 2 * 3_600 + 43 * 60 + 5) * 1_000L)
        assertEquals(Remaining(2, 2, 43, 5), remainingParts(ms, 0))
        assertEquals(Remaining(0, 0, 0, 1), remainingParts(400, 0))   // 0.4 s left shows 1
        assertTrue(remainingParts(1_000, 1_000).isOver)
        assertTrue(remainingParts(1_000, 5_000).isOver)               // past: clamps to zero
    }
    @Test fun `age never goes negative`() {
        assertEquals(Age.JustNow, ageOf(createdAtMs = 10_000, nowMs = 5_000))
        assertEquals(Age.Minutes(12), ageOf(0, 12 * 60_000 + 59_000))
        assertEquals(Age.Hours(2), ageOf(0, 2 * 3_600_000L))
        assertEquals(Age.Days(3), ageOf(0, 3 * 86_400_000L + 5))
    }
}

ViewModel tests with a fake API. Three taps send one request. A slow refresh cannot overwrite a later Accept. A failure rolls back and sends one message. A timeout and a 409 refresh. A cancelled refresh does not leave the repository stuck. The helper settle runs the scheduler a few times, because the ViewModel, the repository and stateIn each hop through the dispatcher.

class FakeApi : NotificationsApi {
    var feedGate: CompletableDeferred<Unit>? = null
    var respondGate: CompletableDeferred<Unit>? = null
    var respondError: Exception? = null
    var respondCalls = 0
    var feedCalls = 0
    var respondStatus: String? = null
    var feed = FeedDto(1_000_000, listOf(
        FriendRequestDto("fr_1", 10, ActorDto("u1", "Sam"), "pending"),
        FriendRequestDto("fr_2", 20, ActorDto("u2", "Kim"), "pending"),
    ))
    override suspend fun notifications(limit: Int): FeedDto { feedCalls++; feedGate?.await(); return feed }
    override suspend fun respond(requestId: String, action: RequestAction): String {
        respondCalls++
        respondGate?.await()
        respondError?.let { throw it }
        return respondStatus ?: if (action == RequestAction.Accept) "accepted" else "declined"
    }
}

@OptIn(ExperimentalCoroutinesApi::class)
private fun TestScope.settle() { repeat(3) { testScheduler.advanceUntilIdle(); testScheduler.runCurrent() } }

class ViewModelTest {
    private val dispatcher = StandardTestDispatcher()
    @BeforeTest fun setUp() { Dispatchers.setMain(dispatcher) }
    @AfterTest fun tearDown() { Dispatchers.resetMain() }

    private fun TestScope.setup(api: FakeApi): Pair<NotificationsViewModel, () -> NotificationsUiState> {
        val clock = AnchoredServerClock({ currentTime })
        val repo = DefaultNotificationsRepository(api, clock, backgroundScope)
        val vm = NotificationsViewModel(repo)
        var last: NotificationsUiState = NotificationsUiState.Loading
        vm.uiState.onEach { last = it }.launchIn(backgroundScope)
        settle() // the ViewModel loads the feed itself
        return vm to { last }
    }
    private fun NotificationsUiState.requests() = (this as NotificationsUiState.Content).items.filterIsInstance<FriendRequest>()

    @Test fun `accept shows at once, then keeps the server status`() = runTest(dispatcher) {
        val api = FakeApi().apply { respondGate = CompletableDeferred() }
        val (vm, state) = setup(api)
        vm.onRespond("fr_1", RequestAction.Accept)
        settle()
        assertEquals(RequestAction.Accept, state().requests().first { it.id == "fr_1" }.inFlight)
        api.respondGate!!.complete(Unit)
        settle()
        val r = state().requests().first { it.id == "fr_1" }
        assertEquals(RequestStatus.Accepted, r.status); assertNull(r.inFlight)
    }

    @Test fun `second tap in a row sends one request`() = runTest(dispatcher) {
        val api = FakeApi().apply { respondGate = CompletableDeferred() }
        val (vm, _) = setup(api)
        vm.onRespond("fr_1", RequestAction.Accept)
        vm.onRespond("fr_1", RequestAction.Decline)
        vm.onRespond("fr_1", RequestAction.Accept)
        settle()
        assertEquals(1, api.respondCalls)
    }

Compose UI tests. These two show the contrast. In the first, a tick changes the text but the card image count does not move. In the second, the same read placed in the card body makes the count go up with every tick. advance steps the Compose test clock in 100 ms pieces. One big jump skips the frames that the recompositions need.

private class TestClock(private val now: () -> Long, val startMs: Long) : ServerClock {
    var reads = 0
    override fun nowMs(): Long { reads++; return startMs + now() }
}

private val offer = AlbumOffer("offer_1", 0, "Print your trip", "A hardcover album", "img-offer", "Order", expiresAtMs = 10_000)
private val activity = PhotoActivity("pa_1", 0, PhotoAction.Added, listOf(Actor("a", "Ana", "img-ana"), Actor("b", "Ben", "img-ben"), Actor("c", "Cy", "img-cy")), 5, 3, "al", "Lisbon", null)
@Test fun `a tick recomposes only the countdown text`() = runComposeUiTest {
    imageComposeCounts.clear()
    mainClock.autoAdvance = false
    val clock = TestClock({ mainClock.currentTime }, startMs = 0)
    setContent { WithOwner {
        Column {
            OfferCard(offer, clock, onOrderClick = {})
            PhotoActivityRow(activity, remember { mutableStateOf(0L) }, onClick = {})
        }
    } }
    mainClock.advanceTimeByFrame()
    onNodeWithTag("countdown").assertTextContains("10 seconds", substring = true)
    val offerBefore = imageComposeCounts["img-offer"]; val rowBefore = imageComposeCounts["img-ana"]
    advance(3_000L)
    onNodeWithTag("countdown").assertTextContains("7 seconds", substring = true)
    assertEquals(offerBefore, imageComposeCounts["img-offer"], "OfferCard body ran again on a tick")
    assertEquals(rowBefore, imageComposeCounts["img-ana"])
}

@Test fun `reading the tick in the card body recomposes the card every tick`() = runComposeUiTest {
    imageComposeCounts.clear()
    mainClock.autoAdvance = false
    val clock = TestClock({ mainClock.currentTime }, startMs = 0)
    setContent {
        WithOwner {
        val now by rememberCountdownNow(clock, offer.expiresAtMs)   // the read is HERE, in the card
        Column {
            AsyncImage("img-naive", null)
            Text10(remainingParts(offer.expiresAtMs, now).seconds)
        }
    } }
    mainClock.advanceTimeByFrame()
    val before = imageComposeCounts["img-naive"]!!
    advance(3_000L)
    assertTrue(imageComposeCounts["img-naive"]!! >= before + 3, "expected a recomposition per tick, got ${imageComposeCounts["img-naive"]} from $before")
}

More tests that I ran, not listed in full: the lifecycle stops the ticker (zero clock reads while stopped), a card far off screen stops ticking and starts again, a draw-only read repaints without composing or measuring, the second avatar overlaps the first, and inserting a row above the first visible row does not move the list.

On a device, use the same ideas with createComposeRule(), mainClock.autoAdvance = false, and a LocalLifecycleOwner that you move between STARTED and CREATED.

13. What to measure on a device

QuestionToolWhat I look for
Does the card body run on every tick?Layout Inspector, recomposition countsThe card count stays still. The text count goes up once a second
Which parameters are unstable?Compose compiler reportsRow models marked stable. No surprise List parameters
Is scrolling smooth?Macrobenchmark with FrameTimingMetric, and JankStats in productionFrame times, and the share of slow frames
Does the countdown wake the phone with the screen off?A system trace, and dumpsys batterystatsNo ticks from this app while stopped
How wrong is the clock?A metric: the share of Order taps that the server rejects as ended while the app showed time leftIt stays near zero, and does not rise after a release

14. More practice prompts

  • "The offer can be extended by the server while the user is looking at it." What changes in the card?
  • "There is a second countdown on the home screen." Where does the ticker live now?
  • "Add a Mark all as read button." Where does the state live, and what does the server need?
  • "The list must work offline." What is the source of truth, and what do you mark as unconfirmed?
  • "Users on slow phones see jank when the list refreshes." What do you check first?

Further reading (official docs)

Get the free one-page cheat sheet

One page: the five steps, a 45-minute time plan and two checklists. Print it or keep it on your phone.

We email you the cheat sheet after you confirm your address. You can also open it now from the Start here page. You also get a short email each week with a follow-up round, and a note when a new case is published. Unsubscribe any time. Privacy.