HA Android UI
Use this skill when creating or changing screens, ViewModels, navigation, or widgets.
The screen pattern is documented in depth at https://developers.home-assistant.io/docs/android/ui_architecture.
Compose Only
- All new UI uses Jetpack Compose; no new XML, databinding, or viewbinding. Legacy XML screens are migrated over time.
- All new widgets use Jetpack Glance; legacy RemoteViews widgets must not be extended.
Design System
- Use
io.homeassistant.companion.android.common.compose.theme.HATheme and the HA* components (HAButton, HAText, HATextField, HACheckbox, HALoading, HADropdownMenu, HAModalBottomSheet, ...) instead of Material components directly. Apply the theme in setContent, not inside the screen composable.
- Never mix Material 2 and Material 3 in a screen; use
HATextStyle for typography, HADimens.SPACE* tokens for spacing and sizes — no magic numbers for padding.
- Access colors via
LocalHAColorScheme.current with the tokens from HAColorScheme; never hardcode colors. Raw Material 3 elements get the wrong light/dark colors — if an HA* wrapper is missing, create it first (own PR, added to the Compose catalog with screenshots) instead of styling a Material component inline.
- Use Snackbars for errors and transient feedback, not Toasts.
- All displayed strings live in the value files in
:common, English only, accessed with stringResource(R.string.*) — never hardcode displayed text. Only edit common/src/main/res/values/strings.xml: translations come from Lokalise and the translated values-*/strings.xml files are gitignored — never add or edit them.
Screen Structure
Name composables descriptively after the UI component (SettingsScreen.kt, UserCard.kt). Split every screen into a stateful entry point and a stateless content composable, hoisting state:
@Composable
internal fun SettingsScreen(viewModel: SettingsViewModel) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
SettingsScreenContent(uiState = state,
}
@Composable
internal fun SettingsScreenContent(uiState: SettingsUiState, onToggleSetting: (String) -> Unit) {
// Stateless: renders uiState and reports interactions through callbacks.
}
Recurring review feedback on screen composables:
- Keep composables small: split large screens into small private composables (see recent screens like
AssistSettingsScreen), and move a composable to its own file when the file grows too big.
- Pass child composables only the parameters they need, never the whole ViewState.
- No logic in the UI layer: no filtering, sorting, or mapping collections inside a composable — the ViewState carries display-ready data (for example a prebuilt
HADropdownItem list). remember any value that must be computed in composition so it doesn't rebuild on every recomposition.
- Name the stateless layer
*Screen/*Content; *View is legacy.
- Set the semantic
Role on custom clickable rows (e.g. Modifier.clickable(role = Role.Checkbox)) for accessibility.
- Disable/gate action buttons on input validity via the ViewState, and surface errors to the user (snackbar or state), with validation living in the ViewModel.
- Don't seed local UI state once from a value that can still change (
LaunchedEffect(Unit) reading a flow-backed field) — derive it from the current state so it stays in sync.
Add a @Preview for major composables using HAThemeForPreview:
@Preview
@Composable
private fun SettingsScreenPreview() {
HAThemeForPreview {
SettingsScreenContent(uiState = SettingsUiState.Default,
}
}
ViewModel
- Use MVVM; when logic grows complex, move to MVI with a single
viewState Flow. Extract complex logic into UseCase classes and interact with data through repositories.
- ViewModels never reference Compose or platform UI types; they expose state through Flow:
@HiltViewModel
class DashboardViewModel @Inject constructor(
private val getDashboard: GetUserDashboardUseCase,
) : ViewModel() {
private val _uiState = MutableStateFlow<UiState>(UiState.Loading)
val uiState: StateFlow<UiState> = _uiState.asStateFlow()
}
- State classes are immutable (
val, immutable collections, copy() for changes) and cheap to read: never compute in a get() that Compose evaluates on every recomposition — pre-compute when producing the state, in the ViewModel on a background dispatcher (Default), never on Main. Iterating a server's full entity list on the Main thread is a recurring review finding.
- One ViewState is the single source of truth: don't keep parallel maps or extra mutable fields in the ViewModel that mirror what the state already holds — read and update the state itself.
- ViewModels never expose Compose or platform types (no
Context, no icon or lazy-list types); resolve those in the Compose layer and hoist callbacks instead of passing UI objects down.
- Model distinct screen modes as a sealed hierarchy (
Loading / Content / Error) so when stays exhaustive.
- One-shot effects (navigation, snackbar) are events on a consumed-once Flow, never replayed state.
Navigation
- Jetpack Navigation Compose, aiming for one Activity with one navigation graph made of feature sub-graphs. New screens must be in the graph; legacy Activities get migrated.
- Use type-safe routes with
@Serializable data/object classes:
@Serializable
data class FeatureRoute(val value: Int)
fun NavGraphBuilder.featureNavGraph(navController: NavController) {
composable<FeatureRoute> {
FeatureScreen(
value = it.toRoute<FeatureRoute>().value,
navController.popBackStack() },
)
}
}
- All navigation needs tests, including back and forward stack behavior.
1---2name: ha-android-ui3description: Home Assistant Android UI guidance. Use when building or reviewing Compose screens, ViewModels, screen state, navigation, dialogs, widgets, or anything using HATheme and the design system.4---56# HA Android UI78Use this skill when creating or changing screens, ViewModels, navigation, or widgets.910The screen pattern is documented in depth at https://developers.home-assistant.io/docs/android/ui_architecture.1112## Compose Only1314- All new UI uses Jetpack Compose; no new XML, databinding, or viewbinding. Legacy XML screens are migrated over time.15- All new widgets use Jetpack Glance; legacy RemoteViews widgets must not be extended.1617## Design System1819- Use `io.homeassistant.companion.android.common.compose.theme.HATheme` and the `HA*` components (`HAButton`, `HAText`, `HATextField`, `HACheckbox`, `HALoading`, `HADropdownMenu`, `HAModalBottomSheet`, ...) instead of Material components directly. Apply the theme in `setContent`, not inside the screen composable.20- Never mix Material 2 and Material 3 in a screen; use `HATextStyle` for typography, `HADimens.SPACE*` tokens for spacing and sizes — no magic numbers for padding.21- Access colors via `LocalHAColorScheme.current` with the tokens from `HAColorScheme`; never hardcode colors. Raw Material 3 elements get the wrong light/dark colors — if an `HA*` wrapper is missing, create it first (own PR, added to the Compose catalog with screenshots) instead of styling a Material component inline.22- Use Snackbars for errors and transient feedback, not Toasts.23- All displayed strings live in the value files in `:common`, English only, accessed with `stringResource(R.string.*)` — never hardcode displayed text. Only edit `common/src/main/res/values/strings.xml`: translations come from Lokalise and the translated `values-*/strings.xml` files are gitignored — never add or edit them.2425## Screen Structure2627Name composables descriptively after the UI component (`SettingsScreen.kt`, `UserCard.kt`). Split every screen into a stateful entry point and a stateless content composable, hoisting state:2829```kotlin30@Composable31internal fun SettingsScreen(viewModel: SettingsViewModel) {32 val state by viewModel.uiState.collectAsStateWithLifecycle()33 SettingsScreenContent(uiState = state, onToggleSetting = viewModel::onToggleSetting)34}3536@Composable37internal fun SettingsScreenContent(uiState: SettingsUiState, onToggleSetting: (String) -> Unit) {38 // Stateless: renders uiState and reports interactions through callbacks.39}40```4142Recurring review feedback on screen composables:4344- Keep composables small: split large screens into small private composables (see recent screens like `AssistSettingsScreen`), and move a composable to its own file when the file grows too big.45- Pass child composables only the parameters they need, never the whole ViewState.46- No logic in the UI layer: no filtering, sorting, or mapping collections inside a composable — the ViewState carries display-ready data (for example a prebuilt `HADropdownItem` list). `remember` any value that must be computed in composition so it doesn't rebuild on every recomposition.47- Name the stateless layer `*Screen`/`*Content`; `*View` is legacy.48- Set the semantic `Role` on custom clickable rows (e.g. `Modifier.clickable(role = Role.Checkbox)`) for accessibility.49- Disable/gate action buttons on input validity via the ViewState, and surface errors to the user (snackbar or state), with validation living in the ViewModel.50- Don't seed local UI state once from a value that can still change (`LaunchedEffect(Unit)` reading a flow-backed field) — derive it from the current state so it stays in sync.5152Add a `@Preview` for major composables using `HAThemeForPreview`:5354```kotlin55@Preview56@Composable57private fun SettingsScreenPreview() {58 HAThemeForPreview {59 SettingsScreenContent(uiState = SettingsUiState.Default, onToggleSetting = {})60 }61}62```6364## ViewModel6566- Use MVVM; when logic grows complex, move to MVI with a single `viewState` Flow. Extract complex logic into UseCase classes and interact with data through repositories.67- ViewModels never reference Compose or platform UI types; they expose state through Flow:6869```kotlin70@HiltViewModel71class DashboardViewModel @Inject constructor(72 private val getDashboard: GetUserDashboardUseCase,73) : ViewModel() {74 private val _uiState = MutableStateFlow<UiState>(UiState.Loading)75 val uiState: StateFlow<UiState> = _uiState.asStateFlow()76}77```7879- State classes are immutable (`val`, immutable collections, `copy()` for changes) and cheap to read: never compute in a `get()` that Compose evaluates on every recomposition — pre-compute when producing the state, in the ViewModel on a background dispatcher (`Default`), never on Main. Iterating a server's full entity list on the Main thread is a recurring review finding.80- One ViewState is the single source of truth: don't keep parallel maps or extra mutable fields in the ViewModel that mirror what the state already holds — read and update the state itself.81- ViewModels never expose Compose or platform types (no `Context`, no icon or lazy-list types); resolve those in the Compose layer and hoist callbacks instead of passing UI objects down.82- Model distinct screen modes as a sealed hierarchy (`Loading` / `Content` / `Error`) so `when` stays exhaustive.83- One-shot effects (navigation, snackbar) are events on a consumed-once Flow, never replayed state.8485## Navigation8687- Jetpack Navigation Compose, aiming for one Activity with one navigation graph made of feature sub-graphs. New screens must be in the graph; legacy Activities get migrated.88- Use type-safe routes with `@Serializable` data/object classes:8990```kotlin91@Serializable92data class FeatureRoute(val value: Int)9394fun NavGraphBuilder.featureNavGraph(navController: NavController) {95 composable<FeatureRoute> {96 FeatureScreen(97 value = it.toRoute<FeatureRoute>().value,98 onNavigateBack = { navController.popBackStack() },99 )100 }101}102```103104- All navigation needs tests, including back and forward stack behavior.