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. Introducing navigation-compose would 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 and MaterialExpressiveTheme ship in the same artifact). MedTimerTheme wraps MaterialExpressiveTheme, not the classic MaterialTheme. Do not pull in Material 2 alongside it.
  • Hilt + Compose (androidx.hilt:hilt-navigation-compose) — obtain ViewModels via hiltViewModel().
  • Lifecycle Compose (androidx.lifecycle:lifecycle-runtime-compose) — for collectAsStateWithLifecycle(). Reserved for the few StateFlow / Flow sources 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 are ImmutableList / PersistentList, not List, 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:designsystem as a separate module until :core:ui is 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 PascalCase and return Unit. A function returning a value should not be @Composable.
  • Function names describe what the UI is, not what it does: MedicineCard, not ShowMedicine.
  • 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).
  • @Preview and tests target the stateless overload — construct a MutableOverviewScreenState and pass it as OverviewScreenState.
  • 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.
  • state is the screen's state-holder interface (next section); the composable reads its properties directly. No collectAsStateWithLifecycle() 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 with produceState at 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 the XxxScreen composable and its ViewModel.
  • Every interface property has a val-shaped contract even when the implementation is var — the UI must not be able to mutate state.
  • List/collection properties are ImmutableList / PersistentList (from kotlinx.collections.immutable), not List. 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 as val on the interface. This is the analog of a combined StateFlow — 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…ScreenState class 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 / MutableXxxState pair 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 StateFlow of the screen state. Repository Flows are collected inside the ViewModel and written into _state.*; the screen reads state.* 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 UiState data class.
  • No allocation of a new immutable UiState on every change — snapshot state mutates in place under Compose's transactional model.
  • derivedStateOf integrates 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 a combine pyramid.

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-ViewModel scope (e.g. an application-scoped repository state).
  • You need Flow operators (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…ScreenState instance and is the only writer.
  • The screen-level composable consumes viewModel.state directly.
  • 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). Must onDispose { … }.
  • 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 a State<T>.
  • snapshotFlow { … } — convert Compose State reads into a cold Flow for use inside a LaunchedEffect.

Don't use an Effect to…

  • Collect a Flow from a ViewModel for UI state. Use collectAsStateWithLifecycle(). A LaunchedEffect { flow.collect { … } } for UI state reintroduces the lifecycle bugs collectAsStateWithLifecycle solves.
  • Derive data for rendering. Compute it during composition; the Compose Compiler will skip unchanged work. Use derivedStateOf only 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 key to 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. DisposableEffect requires onDispose; 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 rememberUpdatedState so the Effect captures the latest value without restarting.
  • One Effect, one concern.

Performance

Follow Compose performance best practices.

  • Provide stable keys for LazyColumn / LazyRow items. items(reminders, key = { it.id }) { … } lets Compose skip recomposition for items that just moved.
  • Pass minimal, immutable parameters. Header(title: String, subtitle: String) over Header(news: News) — the smaller the input set, the smaller the recomposition scope.
  • Mark stable data with @Immutable / @Stable when 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 remember what your ViewModel already cached. Calculations belong in the ViewModel; remember is for per-composition memoization.

Material 3 Expressive theming and previews

  • One theme: MedTimerTheme in :core:ui, wrapping MaterialExpressiveTheme. Define colors via Material 3's ColorScheme, 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, expressive FloatingActionButton variants, 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 Expressive MotionScheme in the theme — don't hard-code TextStyle, corner radius, or animation specs per screen.
  • Every screen-level composable has a @Preview for light and dark. Use a @MedTimerPreview multipreview 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 *Content split.

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-rightarrow_right.xml, caret-down-fillcaret_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() and OverviewState.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:height to 16dp and viewportWidth/viewportHeight to 16. (This differs from Material Symbols' 24/960 space.)
  • Use directional icons (arrow_right, chevron_left, chevron_right, …) with android:autoMirrored="true" so they flip in RTL.
  • Reference them in Compose with Icon(painterResource(R.drawable.<name>), contentDescription = …). Icon applies tint (defaults to LocalContentColor), so the drawable's android:fillColor is just a placeholder — keep it the solid ?android:colorForeground used by the other icons.
  • Icon-only controls (e.g. chips, the calendar nav buttons) still need an accessible label: pass a real contentDescription (a stringResource), which is also what UiAutomator instrumented tests target via By.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 *Content composable is the unit under test.
  • Use createComposeRule() from ui-test-junit4; Robolectric makes the test runnable on the JVM (no emulator).
  • Query by semantics first (onNodeWithText, onNodeWithContentDescription), then by testTag only as a last resort.
  • Don't assert on recomposition counts or memoization — those are compiler details.

Sources

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.