# Your AI Feature Deserves Better Than a Spinner: A Jetpack Compose Response Card

> Source: <https://dev.to/prem_singh_1997/your-ai-feature-deserves-better-than-a-spinner-a-jetpack-compose-response-card-4n83>
> Published: 2026-10-18 07:38:22+00:00

An AI response can take a moment. During that pause, the interface has a job: explain what is happening, preserve context, and give the user a way forward.

Let's design a compact response card for an Android writing assistant. The visual direction is simple: a rounded Material surface, a clear heading, generous spacing, and one primary action. The interesting part is how the card behaves when the answer isn't ready.

This is a UI implementation example, not an AI integration. The response text is supplied by the caller. No model, API key, or network request is involved.

Avoid a collection of independent booleans such as `isLoading`, `hasError`, and `hasResult`. Those can accidentally describe contradictory combinations.

```
sealed interface AssistantState {
    data object Idle : AssistantState
    data object Loading : AssistantState
    data class Answer(val text: String) : AssistantState
    data object Error : AssistantState
}
```

Each state should answer a user question:

| State | What the interface explains | 
|---|---|
| Idle | What can I do here? | 
| Loading | Did my action start? | 
| Answer | What did I get back? | 
| Error | How can I recover? | 

The following snippet uses Compose Foundation, Animation, and Material 3 inside an existing Compose app. Include the standard imports from those libraries. It is an illustrative snippet; it has not been compiled or device-tested as a standalone project.

``` php
@Composable
fun AssistantCard(
    state: AssistantState,
    onGenerate: () -> Unit,
    modifier: Modifier = Modifier,
) {
    val colors = MaterialTheme.colorScheme

    Surface(
        modifier = modifier.fillMaxWidth(),
        shape = RoundedCornerShape(28.dp),
        color = colors.surfaceContainer,
        tonalElevation = 2.dp,
    ) {
        Column(
            modifier = Modifier.padding(24.dp),
            verticalArrangement = Arrangement.spacedBy(16.dp),
        ) {
            Text(
                text = "Writing assistant",
                style = MaterialTheme.typography.titleLarge,
                color = colors.onSurface,
            )
            Text(
                text = "Turn a rough note into a clearer draft.",
                style = MaterialTheme.typography.bodyMedium,
                color = colors.onSurfaceVariant,
            )

            AnimatedContent(
                targetState = state,
                label = "assistant_content",
                modifier = Modifier.fillMaxWidth(),
            ) { current ->
                when (current) {
                    AssistantState.Idle -> Text(
                        "Ready when you are.",
                        color = colors.onSurface,
                    )
                    AssistantState.Loading -> Row(
                        horizontalArrangement = Arrangement.spacedBy(12.dp),
                        verticalAlignment = Alignment.CenterVertically,
                    ) {
                        CircularProgressIndicator(
                            modifier = Modifier.size(24.dp),
                            strokeWidth = 2.dp,
                        )
                        Text("Preparing your draft…", color = colors.onSurface)
                    }
                    is AssistantState.Answer -> Text(
                        text = current.text,
                        style = MaterialTheme.typography.bodyLarge,
                        color = colors.onSurface,
                    )
                    AssistantState.Error -> Text(
                        text = "Couldn't prepare a draft. Please try again.",
                        color = colors.error,
                    )
                }
            }

            Button(
                onClick = onGenerate,
                enabled = state != AssistantState.Loading,
                modifier = Modifier.fillMaxWidth(),
            ) {
                Text(
                    when (state) {
                        AssistantState.Loading -> "Preparing…"
                        AssistantState.Error -> "Try again"
                        is AssistantState.Answer -> "Generate another draft"
                        AssistantState.Idle -> "Generate draft"
                    }
                )
            }
        }
    }
}
```

The card receives immutable state and emits an event. It doesn't own the request. This makes it easier to preview each state and connect the same interface to a fake response or a real service. Choose the state owner based on the logic involved; business logic typically belongs in a screen-level state holder such as a ViewModel. See the [Android state-hoisting guidance](https://developer.android.com/develop/ui/compose/state-hoisting).

`AnimatedContent` uses its lambda parameter (`current`) to render the transitioning state. Keep that detail: reading the outer `state` instead can break the intended transition behavior. The [official animation documentation](https://developer.android.com/develop/ui/compose/animation/composables-modifiers) explains the API.

**Use motion to explain change.** A gentle content transition is enough here. Constant shimmer or a dramatic glow can compete with the answer. For streamed responses, keep the answer container stable rather than restarting a transition for every token.

**Be honest about progress.** This example shows indeterminate progress. Display a percentage only when the underlying work can actually measure it.

**Design the recovery path.** An error message without an action is a dead end. Preserve the original input outside this card so retry doesn't require typing it again. A real request owner should also reject duplicate requests and handle cancellation.

**Check more than the screenshot.** Test large font sizes, dark mode, long answers, and TalkBack. This sample leaves announcements to the surrounding screen; consider a concise polite live-region announcement when a result becomes available. Avoid announcing every streamed token. Read the [Compose semantics guidance](https://developer.android.com/develop/ui/compose/accessibility/semantics).

**Keep the result identifiable.** Label AI-generated output and let the user review it before using it elsewhere. A polished surface should never imply the answer has been verified.

Render the card in all four states before connecting a model. If the loading and error views feel like afterthoughts, the feature isn't finished yet.

What would you add next: cancellation, editable output, or a copy action?

Prem Singh · Lead Android Engineer · Kotlin, Jetpack Compose, and practical Android engineering
