Coding Guidelines
June 5, 2026 · View on GitHub
Audience: all contributors. Status: Compose is the agreed target for new UI in medTimer, but it is not yet in the codebase. This document defines the conventions so the first Compose PR — and the migration that follows — lands on a shared baseline.
For the general Kotlin/Android conventions Compose code must also obey, see kotlin-android.md; for testing, see testing.md.
Adoption strategy — per-screen migration via ComposeView
medTimer today is built on Fragments + XML layouts + the Navigation component. Compose lands incrementally:
- New screens are pure Compose, hosted in a Fragment via
ComposeView(setContent { … }). The Navigation graph keeps routing; only the Fragment's view is Compose. - Existing screens are not rewritten unless the change requires it (substantial feature work on that screen). Don't bundle "while we're here, port to Compose" into an unrelated PR.
- Keep the Navigation component, not
navigation-compose, while both worlds coexist. Introducingnavigation-composewould mean running two routing systems in parallel — defer that decision until most screens are Compose.
Stack
When Compose lands, the stack will be:
- Compose BOM (latest stable) — pin every Compose artifact through the BOM in
gradle/libs.versions.toml; never mix BOM-managed and hand-pinned Compose versions. - Material 3 Expressive — the design system. Delivered via
androidx.compose.material3:material3(the Expressive components andMaterialExpressiveThemeship in the same artifact).MedTimerThemewrapsMaterialExpressiveTheme, not the classicMaterialTheme. Do not pull in Material 2 alongside it. - Hilt + Compose (
androidx.hilt:hilt-navigation-compose) — obtain ViewModels viahiltViewModel(). - Lifecycle Compose (
androidx.lifecycle:lifecycle-runtime-compose) — forcollectAsStateWithLifecycle(). Reserved for the fewStateFlow/Flowsources you still consume from a composable (see State holders); the default state-holder pattern doesn't need it. - kotlinx-collections-immutable (
org.jetbrains.kotlinx:kotlinx-collections-immutable) — required. State-holder list properties areImmutableList/PersistentList, notList, so Compose treats them as stable and can skip recomposition. - Compose Compiler — Kotlin Compose Compiler plugin (Kotlin 2.x), enabled per module. Strong-skipping mode is on by default.
Module placement
Compose theme and reusable composables live in :core:ui — the same module that already holds shared XML resources.
MedTimerTheme(Material 3 Expressive colors, typography, shapes, and motion), reusable design-system composables (buttons, cards, dialogs, etc.), and@Preview-friendly sample data go in:core:ui.- Feature-specific composables (screens, screen-specific components) stay in the relevant
:feature:*module. - Do not create
:core:designsystemas a separate module until:core:uiis genuinely overloaded; one shared UI module is enough at the current size.
Composable conventions
Follow Compose's API guidelines for Jetpack Compose-based libraries and the Compose architecture guide.
- Composable functions are
PascalCaseand returnUnit. A function returning a value should not be@Composable. - Function names describe what the UI is, not what it does:
MedicineCard, notShowMedicine. - Modifier is the first optional parameter, defaults to
Modifier, and is passed through. Modifier order is significant — don't reorder modifiers without understanding the effect. - No side effects in the composable body. Don't call suspend functions, launch coroutines, register observers, or mutate external state directly from the body — use the Effect APIs (next section).
- Hoist state. Leaf composables are stateless: they take state in via parameters and report events via lambdas. Stateful wrappers live one level up.
Screen pattern — stateful Screen + stateless Screen (overloaded)
Every screen is split into two overloads of the same name, disambiguated by their parameter sets:
// Stateful overload: pulls state from the ViewModel; the Fragment calls this one.
@Composable
fun OverviewScreen(
viewModel: OverviewViewModel,
onEditEvent: (Int) -> Unit,
modifier: Modifier = Modifier,
) {
// Collect any non-snapshot sources (e.g. a secondary ViewModel's Flow) here with produceState.
OverviewScreen(
state = viewModel.state,
onMarkTaken = viewModel::onMarkTaken,
onSnooze = viewModel::onSnooze,
onEditEvent = onEditEvent,
modifier = modifier,
)
}
// Stateless overload: pure function of its inputs — the @Preview and test target.
@Composable
fun OverviewScreen(
state: OverviewScreenState,
onMarkTaken: (ReminderId) -> Unit,
onSnooze: (ReminderId) -> Unit,
onEditEvent: (Int) -> Unit,
modifier: Modifier = Modifier,
) { /* … */ }
- Both overloads share the same name. The Kotlin compiler distinguishes them by parameter types; callers pass either a ViewModel (stateful) or a state object (stateless/preview).
@Previewand tests target the stateless overload — construct aMutableOverviewScreenStateand pass it asOverviewScreenState.- The stateful overload is the only place ViewModel references live. Don't reach for a ViewModel from a leaf composable.
- The stateless overload must not import the ViewModel type.
stateis the screen's state-holder interface (next section); the composable reads its properties directly. NocollectAsStateWithLifecycle()is needed — Compose snapshot state is already reactive.- When the stateful overload needs additional data from a secondary source (e.g. a second ViewModel's
Flow), collect it withproduceStateat the top of the stateful overload and pass the resulting value as a parameter to the stateless overload.
State holders
medTimer's default state pattern for Compose screens is a state-holder pair: an immutable interface that the UI sees, and a mutable implementation that the
ViewModel writes to.
This replaces the more common StateFlow<UiState> + collectAsStateWithLifecycle() pattern as the default; see When to use
StateFlow instead below for the exceptions.
The pattern
// 1. Read-only interface — the UI's contract.
interface OverviewScreenState {
val isInSelectionMode: Boolean
val isLoading: Boolean
val reminders: ImmutableList<ReminderItem>
// Derived values are part of the contract.
val selectedReminders: ImmutableList<ReminderItem>
}
// 2. Mutable implementation — owned by the ViewModel.
class MutableOverviewScreenState : OverviewScreenState {
override var isInSelectionMode by mutableStateOf(false)
override var isLoading by mutableStateOf(false)
override var reminders by mutableStateOf(persistentListOf<ReminderItem>())
override val selectedReminders by derivedStateOf {
reminders.filter { it.isSelected }.toImmutableList()
}
// Internal state (e.g. debounce buffers) lives here but stays off the interface.
var debouncedSearchText by mutableStateOf("")
}
// 3. ViewModel exposes the interface; keeps the mutable instance private.
@HiltViewModel
class OverviewViewModel @Inject constructor(
remindersRepository: RemindersRepository,
) : ViewModel() {
private val _state = MutableOverviewScreenState()
val state: OverviewScreenState get() = _state
init {
remindersRepository.observeAll()
.onEach { _state.reminders = it.toPersistentList() }
.launchIn(viewModelScope)
}
fun onMarkTaken(id: ReminderId) { /* updates _state */
}
fun onSnooze(id: ReminderId) { /* updates _state */
}
}
Rules
- Name the pair
XxxScreenState(interface) +MutableXxxScreenState(class). One pair per screen, colocated with theXxxScreencomposable and itsViewModel. - Every interface property has a
val-shaped contract even when the implementation isvar— the UI must not be able to mutate state. - List/collection properties are
ImmutableList/PersistentList(fromkotlinx.collections.immutable), notList. This is what makes the state holder a stable parameter; without it, Compose can't skip recomposition. - Derived values use
by derivedStateOf { … }in the mutable class and surface asvalon the interface. This is the analog of acombinedStateFlow— only recomputes when its inputs change, and only triggers recomposition for readers when the derived value actually changes. - Mutate from the ViewModel only.
Composables read
state.*; they never write. All writes go through ViewModel methods (onMarkTaken,enterSelectionMode, …) that touch_state.*. - Internal-only fields stay off the interface.
Debounce buffers, scratch state, and other implementation details live on the
Mutable…ScreenStateclass but are not in the interface — the UI never sees them. - Compose nested state holders.
When a sub-feature has its own state surface (a filter bar, a multi-select tracker), give it its own
XxxState/MutableXxxStatepair and expose the interface as a property on the parent state. Hierarchical state holders keep each layer testable on its own. - The ViewModel does not expose a
StateFlowof the screen state. RepositoryFlows are collected inside the ViewModel and written into_state.*; the screen readsstate.*directly.
Why this pattern (vs. StateFlow<UiState>)
- Granular recomposition: only the composables that read a specific property recompose when it changes, instead of every reader of a
UiStatedata class. - No allocation of a new immutable
UiStateon every change — snapshot state mutates in place under Compose's transactional model. derivedStateOfintegrates with the snapshot system, so derived properties don't trigger spurious recompositions when their inputs change but the derived value doesn't.- Hierarchical composition: nested state holders fall out naturally; the equivalent with
StateFlow<UiState>quickly becomes acombinepyramid.
When to use StateFlow instead
Stick with StateFlow<T> (and collectAsStateWithLifecycle() at the call site) when:
- The state needs non-Compose consumers — a Fragment view, a Worker, a widget update path, a JVM test that doesn't run on the Compose runtime.
- The state is shared across screens and lives on a non-
ViewModelscope (e.g. an application-scoped repository state). - You need
Flowoperators (debounce,combine,distinctUntilChanged) on the publicly exposed shape itself, not just internally.
In all of these, the screen-local UI state can still use the state-holder pattern; the StateFlow lives upstream and is collected inside the ViewModel into
_state.*.
Local UI state
State that lives entirely within one composable — and would never be of interest to the ViewModel — stays in the composable:
- Use
remember { mutableStateOf(…) }for transient local state (e.g. whether a dropdown is expanded). - Use
rememberSaveable { mutableStateOf(…) }for local state that must survive configuration change or process death (text-field draft, selected tab).
If two composables need to coordinate, hoist the state up; if the screen needs it, hoist it into the screen's state holder.
State hoisting and UDF
Follow the state hoisting and unidirectional data flow pattern.
- State flows down as the state-holder interface (or as individual parameters for leaf composables); events flow up as lambdas.
- The ViewModel owns the
Mutable…ScreenStateinstance and is the only writer. - The screen-level composable consumes
viewModel.statedirectly. - Leaf composables take the smallest slice they need — a
ReminderItem, not the whole screen state — so their recomposition scope stays tight.
Side effects — use them sparingly
Composables must be side-effect free. When a composable genuinely needs to interact with the outside world, reach for the right Effect API — and only then.
Use an Effect to…
Synchronize with something outside React-style state — and only then:
LaunchedEffect(key1, …)— run a suspend block tied to the composable's lifecycle. Restarts when keys change; cancels on exit.DisposableEffect(key1, …)— set up something with explicit cleanup (a listener, an observer). MustonDispose { … }.rememberCoroutineScope()— launch coroutines from event handlers (a click, a swipe), not from the composable body.SideEffect { … }— push Compose state to non-Compose code (analytics, third-party SDK) after a successful composition.produceState(initialValue, key1, …)— convert a non-Compose async source (e.g. a callback API) into aState<T>.snapshotFlow { … }— convert ComposeStatereads into a coldFlowfor use inside aLaunchedEffect.
Don't use an Effect to…
- Collect a Flow from a ViewModel for UI state.
Use
collectAsStateWithLifecycle(). ALaunchedEffect { flow.collect { … } }for UI state reintroduces the lifecycle bugscollectAsStateWithLifecyclesolves. - Derive data for rendering.
Compute it during composition; the Compose Compiler will skip unchanged work.
Use
derivedStateOfonly when the derived value updates less frequently than its inputs and you can prove it earns its keep. - Respond to a user event.
Put the logic in the event handler (
onClick,onValueChange), where intent is known. - Reset state when a prop changes.
Pass a different
keyto the composable to reset its subtree, or compute the value during composition. - Notify the parent of a change. Call the parent's lambda from the event handler that caused the change, not from an Effect that watches the value.
When you do use an Effect
- Always clean up.
DisposableEffectrequiresonDispose;LaunchedEffect's coroutine is cancelled for you on exit but any subscriptions you opened inside it must be torn down before you return. - Include every reactive value as a key, or wrap a callback in
rememberUpdatedStateso the Effect captures the latest value without restarting. - One Effect, one concern.
Performance
Follow Compose performance best practices.
- Provide stable keys for
LazyColumn/LazyRowitems.items(reminders, key = { it.id }) { … }lets Compose skip recomposition for items that just moved. - Pass minimal, immutable parameters.
Header(title: String, subtitle: String)overHeader(news: News)— the smaller the input set, the smaller the recomposition scope. - Mark stable data with
@Immutable/@Stablewhen the compiler can't infer it (custom classes that wrap collections, third-party types). Don't sprinkle these annotations; use them where the compiler would otherwise mark the type unstable. - Defer state reads.
Lambda-based modifiers (
Modifier.offset { … },Modifier.drawBehind { … }) read state in the layout/draw phase, skipping composition. - Don't
rememberwhat your ViewModel already cached. Calculations belong in the ViewModel;rememberis for per-composition memoization.
Material 3 Expressive theming and previews
- One theme:
MedTimerThemein:core:ui, wrappingMaterialExpressiveTheme. Define colors via Material 3'sColorScheme, with light and dark schemes. Support dynamic colors (dynamicLightColorScheme/dynamicDarkColorScheme) on Android 12+ when the user enables them, with a fall-back to the static brand colors. - Prefer Expressive components (
ButtonGroup, expressiveFloatingActionButtonvariants, the Expressive motion scheme) over their classic Material 3 counterparts when both exist — that is the whole reason for choosing the Expressive theme. - Type, shape, and motion come from
Typography,Shapes, and the ExpressiveMotionSchemein the theme — don't hard-codeTextStyle, corner radius, or animation specs per screen. - Every screen-level composable has a
@Previewfor light and dark. Use a@MedTimerPreviewmultipreview annotation (@Preview+ dark variant + minimum supported font scale) defined in:core:ui. - Previews must render without a real ViewModel — that is the whole point of the stateless
*Contentsplit.
Icons — vendored Bootstrap Icons
Bootstrap Icons are the icon set for medTimer — both the legacy XML/View UI and new
Compose UI. This is not a Compose-only convention; it is the app-wide standard. The existing drawables in :core:ui
(bell, cart2, chevron_left, box_seam, calendar_event, link, repeat, …) are all vendored Bootstrap Icons —
match them.
Do not depend on androidx.compose.material:material-icons-extended or material-icons-core, and do not vendor
Material Symbols. New Compose icons must come from Bootstrap Icons so the whole app stays visually consistent.
Vendor each icon as a vector drawable in :core:ui/src/main/res/drawable/, named after the Bootstrap icon with hyphens
replaced by underscores (e.g. arrow-right → arrow_right.xml, caret-down-fill → caret_down_fill.xml). Putting them in
:core:ui makes them reachable from every :feature:* module via com.futsch1.medtimer.core.ui.R.drawable.<name>.
- Reuse before you vendor. Check
:core:ui/src/main/res/drawable/for an existing Bootstrap icon first, and prefer the shared semantic mappings —ReminderType.getIcon()andOverviewState.getImage()in:core:ui— over hand-picking a drawable per call site. Don't add a near-duplicate of an icon that already exists. - Geometry: 16×16. Bootstrap Icons are drawn on a 16-unit canvas, so set
android:width/android:heightto16dpandviewportWidth/viewportHeightto16. (This differs from Material Symbols' 24/960 space.) - Use directional icons (
arrow_right,chevron_left,chevron_right, …) withandroid:autoMirrored="true"so they flip in RTL. - Reference them in Compose with
Icon(painterResource(R.drawable.<name>), contentDescription = …).Iconappliestint(defaults toLocalContentColor), so the drawable'sandroid:fillColoris just a placeholder — keep it the solid?android:colorForegroundused by the other icons. - Icon-only controls (e.g. chips, the calendar nav buttons) still need an accessible label: pass a real
contentDescription(astringResource), which is also what UiAutomator instrumented tests target viaBy.desc(...).
How to get the drawable
From the web (preferred): open icons.getbootstrap.com, pick the icon, and copy its
SVG. Bootstrap SVGs already use a viewBox="0 0 16 16", and the SVG <path d="…"> grammar is identical to Android's
android:pathData, so the path string drops straight in — no coordinate-space translation or <group> wrapper needed.
Wrap it as:
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="16dp" android:height="16dp"
android:viewportWidth="16" android:viewportHeight="16">
<path android:pathData="<the SVG d attribute>" android:fillColor="?android:colorForeground"/>
</vector>
Carry over fill-rule="evenodd" as android:fillType="evenOdd", and add android:autoMirrored="true" for directional
glyphs. Multi-<path> Bootstrap icons (e.g. calendar3) become one <path> element per SVG path.
Android Studio: New → Vector Asset → Local file and point it at the downloaded .svg; the wizard writes the
<vector> for you. Rename it to the underscored Bootstrap name afterwards.
Testing — pointer
See testing.md for the full Compose testing section. In brief:
- The stateless
*Contentcomposable is the unit under test. - Use
createComposeRule()fromui-test-junit4; Robolectric makes the test runnable on the JVM (no emulator). - Query by semantics first (
onNodeWithText,onNodeWithContentDescription), then bytestTagonly as a last resort. - Don't assert on recomposition counts or memoization — those are compiler details.
Sources
- Android Developers — Thinking in Compose, Compose architecture, State and Jetpack Compose, State hoisting, Side-effects in Compose, Compose performance best practices, Compose lifecycle ( 2025–2026).
- Compose API guidelines for Jetpack Compose-based libraries — AndroidX.
- Material 3 for Compose and Material 3 Expressive.
- Now in Android — reference patterns for Hilt + Compose + multi-module.
Last reviewed: 2026-06-05 · Compose: adopted on the Statistics screen (StatisticsFragment → ComposeView); remaining screens still use XML layouts + Fragments. Icons standardised on Bootstrap Icons.