Compose and the design system
Build Android UI with Jetpack Compose, HATheme, and the `HA*` components. Previews, the Compose catalog, widgets, and navigation.
All new UI in the Android app is Jetpack Compose. No new XML layouts, data binding, or view binding. New widgets use Jetpack Glance; the old RemoteViews widgets are not extended anymore.
The app has its own design system on top of Material 3, in :common under io.homeassistant.companion.android.common.compose:
HAThemeapplies the Home Assistant colors in light and dark mode. Apply it once insetContent, not inside your screen composable.HA*components, likeHAAccentButton,HAFilledButton,HATextField,HACheckbox,HASwitch,HALoading,HADropdownMenu, andHAModalBottomSheet. They live in thecomposablepackage next to the theme. Use them instead of Material components.LocalHAColorScheme.currentfor colors. Never hard-code a color.HATextStylefor typography, andHADimens.SPACE*for spacing and sizes. No magic numbers in padding.
Raw Material 3 components get the wrong colors in light or dark mode. If the HA* wrapper you need doesn't exist yet, create it first, in its own pull request, with screenshot tests and a page in the Compose catalog.
You will also find HomeAssistantAppTheme, a theme based on Material 2. It is deprecated, and only kept until the remaining screens that use it are migrated. Don't use it for new UI, and don't mix Material 2 and Material 3 in one screen.
Debug builds include a catalog of all HA* components. Open it from the developer playground with Start HA Compose Catalog. Check it before you build something new.
Follow the UI architecture: a stateful screen that collects the ViewModel state, and a stateless content composable that renders it. That is state hoisting, and it keeps your composables reusable and easy to preview. On top of that:
- Keep composables small. Split a large screen into small private composables, and move them to their own file when a file grows.
- Pass only what a composable needs, not the whole state.
- No logic in the UI. No sorting, filtering, or mapping in a composable. The state carries display-ready data. If you must compute something during composition,
rememberit. - Name files after the component, like
SettingsScreen.ktorUserCard.kt. The stateless layer is*Screenor*Content.*Viewis legacy. - Accessibility: set a semantic
Roleon custom clickable rows, likeModifier.clickable(role = Role.Checkbox), and give images a content description. - Errors and feedback go in a snackbar, not a toast.
- Text comes from
stringResource(R.string.*), with the strings incommon/src/main/res/values/strings.xml.
Add a @Preview for every major composable, wrapped in HAThemeForPreview, which also draws the right background:
@Preview
@Composable
private fun SettingsScreenPreview() {
HAThemeForPreview {
SettingsScreenContent(
uiState = SettingsUiState.Default,
onToggleSetting = {},
)
}
}Previews are the quickest way to iterate: no build, no server, no onboarding. Add several, for each meaningful state, for light and dark mode, and for different screen sizes. Screenshot tests render the same composables, so a good preview is halfway to a screenshot test.
Once the app runs, Compose live edit and hot reload in Android Studio show many changes without a rebuild. Structural changes still need one.
The app uses Navigation Compose and is moving to one activity with one navigation graph, built from feature subgraphs. New screens go in the graph; old activities are migrated over time.
Use type-safe routes:
@Serializable
data class FeatureRoute(val value: Int)
fun NavGraphBuilder.featureNavGraph(navController: NavController) {
composable<FeatureRoute> {
FeatureScreen(
value = it.toRoute<FeatureRoute>().value,
onNavigateBack = { navController.popBackStack() },
)
}
}Navigation needs tests, including the back stack.