Your AI Feature Deserves Better Than a Spinner: A Jetpack Compose Response Card A developer published a Jetpack Compose implementation example for an Android writing-assistant response card that models AI output states with a sealed interface (Idle, Loading, Answer, Error) instead of independent booleans, driving an AnimatedContent block and a primary button that disables during loading. The snippet uses Compose Foundation, Animation, and Material 3, and the author notes it is illustrative UI code with no model, API key, or network request involved. 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