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
| Minutes | Step | What I have drawn by the end | Skip if late |
|---|---|---|---|
| 0 to 5 | Scope and constraints | The seen/ask table, defaults | Fewer questions |
| 5 to 13 | Data and contracts | The sealed model, the calls | The call table |
| 13 to 21 | Client structure | Screen, ViewModel, repository, clock; the LazyColumn | One line per box |
| 21 to 38 | Make the hard part work | The clock, the ticker, where the tick is read, Accept and Decline | The progress bar |
| 38 to 45 | Break it, ship it, roll it back | Top failures, tests, the flag | Two 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
| Call | Request | Success | Errors the app handles |
|---|---|---|---|
GET /v1/notifications?limit=30 | none | 200 with the JSON above | Network or 5xx: keep the old list, show a message. First load: error state with Try again |
POST /v1/friend-requests/{id}/accept | none | 200 with the new status | 5xx: roll back. Timeout or 409: unknown, refresh. 404, 410: remove the row |
POST /v1/friend-requests/{id}/decline | none | 200 with the new status | The same |
401 on any call | The 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
refreshandrespondtake turns on oneMutex, 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.
respondruns in the app scope. Leaving the screen does not cancel a tap that was already sent.- Every
catch (e: Exception)is preceded by aCancellationExceptionrethrow. 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 = truefor the next screen. - 404 and 410 remove the row. A timeout (
IOException), a 409 or a status the app does not know isUnknown: the ViewModel refreshes and shows what the server holds. A 5xx isFailed: 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
inFlightis 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.zIndexputs 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. AgeTextis 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
OfferCardholds theState<Long>but does not read it. It readsexpired, which changes once. That read happens in the content scope of theCard, so the tests count the image inside that scope.CountdownTextis the only composable that reads the tick for the text.CountdownBarreads the tick insidedrawBehind, 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.
ListFormatterjoins 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
| Question | Tool | What I look for |
|---|---|---|
| Does the card body run on every tick? | Layout Inspector, recomposition counts | The card count stays still. The text count goes up once a second |
| Which parameters are unstable? | Compose compiler reports | Row models marked stable. No surprise List parameters |
| Is scrolling smooth? | Macrobenchmark with FrameTimingMetric, and JankStats in production | Frame times, and the share of slow frames |
| Does the countdown wake the phone with the screen off? | A system trace, and dumpsys batterystats | No 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 left | It 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)
- The case: design a notifications screen in Jetpack Compose
- Compose performance best practices
- Lists and grids in Compose
- Strong skipping mode
- Fix stability issues
- Collect flows in the UI
- SystemClock
- Test synchronization in Compose
- ListFormatter
- kotlinx.serialization polymorphism
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.