From skills
House rules and load-bearing patterns for production Android with Jetpack Compose + MVI. Covers the MviViewModel<State,Intent,Effect> contract pattern, DispatcherProvider, collectAsStateWithLifecycle, type-safe Navigation Compose (@Serializable routes) + Navigation3 forward path, Koin DI (not Hilt), strong skipping defaults, edge-to-edge mandate, Material 3 / Expressive + adaptive layouts, Room + DataStore, Turbine + MockK + Robolectric testing scaffold, Baseline Profiles, R8 full mode, composable parameter ordering, module layout, and anti-pattern catalog. **Does not** re-teach elementary Compose (recomposition, remember, LaunchedEffect basics), standard Kotlin/coroutine patterns, or basic Material 3 usage -- assume that baseline. Use when writing or reviewing any Android/Compose code.
How this skill is triggered — by the user, by Claude, or both
Slash command
/skills:android-expertThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
Opinion and house-rule reference for **Jetpack Compose + MVI** on modern Android (Kotlin 2.x / K2, Compose BOM, `targetSdk 36+`). **Assumes baseline Compose/Kotlin knowledge** -- this file only covers what's house-specific, load-bearing, or shifts year-to-year.
Opinion and house-rule reference for Jetpack Compose + MVI on modern Android (Kotlin 2.x / K2, Compose BOM, targetSdk 36+). Assumes baseline Compose/Kotlin knowledge -- this file only covers what's house-specific, load-bearing, or shifts year-to-year.
Non-negotiable defaults:
MviViewModel<State, Intent, Effect> with a *Contract holder. UDF only; no two-way binding.data class; intents and effects are sealed interface.collectAsStateWithLifecycle(), never collectAsState().@Serializable data object / data class. String routes are banned.modifier: Modifier = Modifier as the last parameter with a default.koin-compose-viewmodel), not Hilt.connection, not conn). camelCase acronyms (updateMfa, not updateMFA).org.jetbrains.kotlin.plugin.compose). Do NOT add androidx.compose.compiler:compiler or set kotlinCompilerExtensionVersion. Both are obsolete.@Stable / @Immutable are almost never needed (see section 8).collectAsState() -> collectAsStateWithLifecycle(). Plain version keeps collecting while backgrounded.PredictiveBackHandler -> NavigationBackHandler / Navigation Event API. Deprecated API splits into onBackCancelled / onBackCompleted with NavigationEventState.transitionState.targetSdk 36. enableEdgeToEdge() before setContent { }. The opt-out manifest flag is ignored.navigation-compose. Nav2 still maintained -- adopt Nav3 in greenfield only.material3; opt in via MaterialExpressiveTheme { ... }.@Serializable typed routes are the only correct way to declare navigation destinations.rootProject.name = "app"
include(":app")
include(":core:common") // MviViewModel, DispatcherProvider, Analytics, CrashReporter
include(":core:model") // Domain types: data classes only, no Android deps
include(":core:database") // Room DAOs + Repositories + Mappers
include(":core:network") // HTTP client, DTOs
include(":core:preferences") // DataStore
include(":ui:design") // Theme, colors, reusable components
include(":feature:home")
include(":feature:detail")
Dependency graph: :app -> :feature:* -> :core:* -> (external libs). :feature:* may also depend on :ui:design.
Rules:
:feature:home, not :feature:homeFeature.core/util or core/helpers. core/common holds framework primitives. Everything else is domain-grouped.:core:model has zero Android dependencies -- data classes + enums only. Liftable to KMP without changes.:data/:domain/:presentation layered split. Domain-driven modularisation: each core/<domain> owns entities, DAOs, repositories, and mappers end-to-end.MviViewModel base classpackage com.example.app.core.common
abstract class MviViewModel<State, Intent, Effect>(
initialState: State,
) : ViewModel() {
private val _state = MutableStateFlow(initialState)
val state: StateFlow<State> = _state.asStateFlow()
private val _effects = Channel<Effect>(Channel.BUFFERED) // NOT SharedFlow -- buffers while UI rebuilds
val effects: Flow<Effect> = _effects.receiveAsFlow()
protected val currentState: State get() = _state.value // don't call _state.value inside update{}
fun dispatch(intent: Intent) {
viewModelScope.launch { handleIntent(intent) }
}
protected abstract suspend fun handleIntent(intent: Intent)
protected fun setState(reduce: State.() -> State) {
_state.update(reduce) // lock-free, retries on conflict
}
protected suspend fun sendEffect(effect: Effect) {
_effects.send(effect)
}
}
*Contract holderobject ItemListContract {
data class State(
val items: List<ItemUiModel> = emptyList(), // all fields have defaults
val favorites: List<ItemUiModel> = emptyList(),
val isLoading: Boolean = true,
val isRefreshing: Boolean = false,
val error: UiError? = null, // persistent errors as state field
val searchQuery: String = "",
val deleteConfirmation: Long? = null,
)
sealed interface Intent { // sealed interface, not sealed class
data class Search(val query: String) : Intent
data class SelectItem(val itemId: Long) : Intent
data class ConfirmDelete(val itemId: Long) : Intent
data object DismissDelete : Intent // data object, never bare object
data class ToggleFavorite(val itemId: Long, val isFavorite: Boolean) : Intent
data object AddItem : Intent
data object Refresh : Intent
}
sealed interface Effect {
data class NavigateToDetail(val itemId: Long) : Effect
data class NavigateToAddItem(val prefillLabel: String? = null) : Effect
data class ShowError( // transient messages as effects
override val message: String? = null,
@param:StringRes override val messageRes: Int? = null,
override val messageArgs: List<Any> = emptyList(),
) : Effect, LocalizedMessage
}
}
Localised messages use a LocalizedMessage interface (messageRes: Int? + messageArgs: List<Any>) so effects carry a @StringRes that resolves at the UI layer. Never stringify at the VM layer.
class ItemListViewModel(
private val repository: ItemRepository,
private val dispatchers: DispatcherProvider, // injected, not Dispatchers.IO directly
private val analytics: Analytics,
private val crashReporter: CrashReporter,
) : MviViewModel<ItemListContract.State, ItemListContract.Intent, ItemListContract.Effect>(
initialState = ItemListContract.State(),
) {
init { observeItems() } // long-lived observers start at construction
private fun observeItems() {
combine(
searchQuery.debounce(300).distinctUntilChanged()
.flatMapLatest { q ->
if (q.isBlank()) repository.observeAll() else repository.search(q)
},
repository.observeFavorites(),
) { items, favorites ->
items.map { it.toUiModel() } to favorites.map { it.toUiModel() }
}.onEach { (models, favs) ->
setState { copy(items = models, favorites = favs, isLoading = false, error = null) }
}.launchIn(viewModelScope)
}
override suspend fun handleIntent(intent: ItemListContract.Intent) {
when (intent) { // exhaustive when on sealed interface
is Intent.Search -> searchQuery.value = intent.query
is Intent.SelectItem -> sendEffect(Effect.NavigateToDetail(intent.itemId))
is Intent.ConfirmDelete -> handleConfirmDelete(intent.itemId)
// ...
}
}
private suspend fun handleConfirmDelete(itemId: Long) {
setState { copy(deleteConfirmation = null) }
try {
withContext(dispatchers.io) { repository.delete(itemId) }
sendEffect(Effect.ShowInfo(messageRes = R.string.items_message_deleted))
} catch (exception: Exception) {
crashReporter.record(exception) // dashboard sees it
setState { copy(error = UiError(messageRes = R.string.items_message_delete_failed)) }
}
}
}
@Composable
fun ItemListScreen(
onNavigateToDetail: (Long) -> Unit, // nav callbacks hoisted, no NavController
onNavigateToAddItem: (String?) -> Unit,
viewModel: ItemListViewModel = koinViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
LaunchedEffect(Unit) { // canonical effect collector
viewModel.effects.collect { effect ->
when (effect) {
is Effect.NavigateToDetail -> onNavigateToDetail(effect.itemId)
is Effect.ShowError -> bannerState.show(effect.resolve(context))
}
}
}
ItemListContent(state = state, onIntent = viewModel::dispatch, modifier = Modifier.fillMaxSize())
}
Split stateful Screen from stateless Content. Content(state, onIntent, modifier) is what previews and tests drive.
@Composable
fun ChoiceButton(
text: String, // 1. required data
onClick: () -> Unit, // 2. required callbacks
isSelected: Boolean = false, // 3. optional data with defaults
modifier: Modifier = Modifier, // 4. modifier ALWAYS last
) { ... }
// content: @Composable () -> Unit comes AFTER modifier as trailing lambda
interface DispatcherProvider {
val main: CoroutineDispatcher // always Dispatchers.Main.immediate, not .Main
val io: CoroutineDispatcher
val default: CoroutineDispatcher
}
class DefaultDispatcherProvider : DispatcherProvider {
override val main = Dispatchers.Main.immediate
override val io = Dispatchers.IO
override val default = Dispatchers.Default
}
In tests, supply an object returning StandardTestDispatcher / UnconfinedTestDispatcher for all three.
// Merge sources into UI state:
combine(searchQuery.debounce(300).distinctUntilChanged(), reachability) { q, reach ->
buildUiModel(q, reach)
}.onEach { setState { copy(models = it) } }.launchIn(viewModelScope)
// Switch flow on new input (cancels prior):
queryFlow.flatMapLatest { q -> repository.search(q) }
// Read-only derived StateFlow (no VM mutation):
val uiState: StateFlow<UiState> = repository.observe()
.map { it.toUiState() }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), UiState.Loading)
// Debounce without dropping first emission:
val debouncedKey = merge(keyFlow.take(1), keyFlow.drop(1).debounce(400))
WhileSubscribed(5_000) is the canonical started strategy -- 5 seconds covers config changes without keeping upstream hot forever. flowOn applies upstream only.
Routes are always @Serializable data object / data class, never strings.
@Serializable data object ItemListRoute
@Serializable data class ItemDetailRoute(val itemId: Long)
fun NavGraphBuilder.itemsNavGraph(navController: NavController) {
composable<ItemListRoute> {
ItemListScreen(
onNavigateToDetail = { navController.navigate(ItemDetailRoute(it)) },
)
}
composable<ItemDetailRoute> { entry ->
val route = entry.toRoute<ItemDetailRoute>()
ItemDetailScreen(itemId = route.itemId, onNavigateBack = { navController.popBackStack() })
}
}
XxxNavigation.kt per feature with routes + NavGraphBuilder.xxxNavGraph(...). :app wires them in AppNavHost.org.jetbrains.kotlin.plugin.serialization at every module declaring routes.Back stack is a plain SnapshotStateList<NavKey> -- add, removeLastOrNull, replaceAll directly.
@Serializable sealed interface HomeRoute : NavKey {
@Serializable data object Feed : HomeRoute
@Serializable data class Detail(val id: String) : HomeRoute
}
@Composable
fun AppNavHost() {
val backStack = rememberNavBackStack(HomeRoute.Feed)
NavDisplay(
backStack = backStack,
onBack = { backStack.removeLastOrNull() },
entryProvider = entryProvider {
entry<HomeRoute.Feed> { FeedScreen(onItemClick = { backStack.add(HomeRoute.Detail(it)) }) }
entry<HomeRoute.Detail> { key -> DetailScreen(id = key.id) }
},
)
}
Dependencies: navigation3-runtime, navigation3-ui, lifecycle-viewmodel-navigation3 (NavEntry-scoped VMs).
Decision rule: greenfield -> Nav3 (verify its minSdk floor). Existing Nav2 -> do NOT migrate reactively.
Koin, not Hilt. Better KMP ergonomics, readable error messages, no codegen overhead.
val commonModule = module {
single<DispatcherProvider> { DefaultDispatcherProvider() }
single<Analytics> { FirebaseAnalyticsTracker(FirebaseAnalytics.getInstance(androidContext())) }
}
val databaseModule = module {
single { Room.databaseBuilder(androidContext(), AppDatabase::class.java, "app.db")
.fallbackToDestructiveMigration(true).build() }
single { get<AppDatabase>().itemDao() }
single { ItemRepository(get()) }
}
val viewModelModule = module {
viewModel { ItemListViewModel(get(), get(), get(), get()) }
viewModel { params -> ItemDetailViewModel(params.get(), get(), get()) } // params.get() is positional
}
single<Interface> { Impl(get()) } -- expose interface, not concrete class.androidContext() instead of injecting Context.GlobalContext inside classes. Constructor injection everywhere. The ONE exception: composables use koinViewModel() / koinInject().// Composable injection
@Composable
fun ItemDetailScreen(itemId: Long) {
val viewModel: ItemDetailViewModel = koinViewModel { parametersOf(itemId) }
}
Strong skipping is on by default. Rules:
@Stable / @Immutable are mostly unnecessary. Keep only when the type has an expensive equals() or is in a dependency the compiler can't reason about.List<T> (immutable view) or ImmutableList<T>. A MutableList<T> field destabilises the enclosing data class.Modifier.offset { offset.value } defers to layout phase, skipping composition.derivedStateOf for computed scalars, not bare remember.Compiler reports for perf investigation:
composeCompiler {
reportsDestination = layout.buildDirectory.dir("compose_compiler")
metricsDestination = layout.buildDirectory.dir("compose_compiler")
}
JUnit 4 + Robolectric + MockK + Turbine + kotlinx-coroutines-test. No JUnit 5 on the Android side.
@OptIn(ExperimentalCoroutinesApi::class)
class ItemListViewModelTest {
private lateinit var testDispatcher: TestDispatcher
private lateinit var dispatchers: DispatcherProvider
@Before fun setup() {
testDispatcher = StandardTestDispatcher()
dispatchers = object : DispatcherProvider {
override val main = testDispatcher
override val io = testDispatcher
override val default = testDispatcher
}
Dispatchers.setMain(testDispatcher)
}
@After fun tearDown() {
Dispatchers.resetMain()
clearAllMocks(); unmockkAll()
}
@Test fun `load emits items then clears loading`() = runTest(testDispatcher) {
val vm = ItemListViewModel(mockk(relaxed = true), dispatchers, mockk(relaxed = true))
vm.state.test {
assertEquals(ItemListContract.State(isLoading = true), awaitItem())
val loaded = awaitItem()
assertFalse(loaded.isLoading)
cancelAndIgnoreRemainingEvents()
}
}
@Test fun `delete emits ShowInfo effect`() = runTest(testDispatcher) {
val vm = ItemListViewModel(repository, dispatchers, analytics)
vm.effects.test {
vm.dispatch(ItemListContract.Intent.ConfirmDelete(itemId = 1L))
advanceUntilIdle()
assertTrue(awaitItem() is ItemListContract.Effect.ShowInfo)
cancelAndIgnoreRemainingEvents()
}
}
}
Key points: StandardTestDispatcher() + Dispatchers.setMain(testDispatcher) gives virtual time. Pass the same dispatcher to runTest. Fakes over mocks when the collaborator has behaviour.
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class ItemListScreenTest {
@get:Rule val composeRule = createComposeRule()
@Test fun `empty state shows CTA`() {
composeRule.setContent { AppTheme { ItemListContent(state = State(isLoading = false), onIntent = {}) } }
composeRule.onNodeWithTag("add_item_fab").performClick()
}
}
Drive the stateless Content, not the stateful Screen. onNodeWithTag over onNodeWithText. Apply Modifier.testTag("...") on every interactive element.
flow.test { assertEquals(Loading, awaitItem()); assertEquals(Success(data), awaitItem()); cancelAndIgnoreRemainingEvents() }
turbineScope {
val a = flowA.testIn(this); val b = flowB.testIn(this)
assertEquals(1, a.awaitItem()); assertEquals("x", b.awaitItem())
a.cancel(); b.cancel()
}
Roborazzi is the current choice. Combine with ComposablePreviewScanner to turn every @Preview into a snapshot test automatically.
data class AppExtendedColors(val cardBorder: Color, val subtleText: Color, val activeElement: Color)
val LocalAppExtendedColors = staticCompositionLocalOf { DarkExtendedColors }
val MaterialTheme.appColors: AppExtendedColors
@Composable @ReadOnlyComposable get() = LocalAppExtendedColors.current
@Composable
fun AppTheme(darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit) {
val colorScheme = if (darkTheme) DarkColorScheme else LightColorScheme
CompositionLocalProvider(LocalAppExtendedColors provides if (darkTheme) DarkExtendedColors else LightExtendedColors) {
MaterialTheme(colorScheme = colorScheme, typography = AppTypography, shapes = AppShapes, content = content)
}
}
data class via staticCompositionLocalOf, exposed as MaterialTheme.appColors.@ReadOnlyComposable on the getter skips composer check.MaterialExpressiveTheme { ... } for interior surfaces. Many components still @ExperimentalMaterial3ExpressiveApi.NavigationSuiteScaffold(
navigationSuiteItems = { /* items */ },
layoutType = NavigationSuiteScaffoldDefaults.calculateFromAdaptiveInfo(currentWindowAdaptiveInfo()),
) { content() }
Swaps between NavigationBar / NavigationRail / PermanentDrawer by WindowSizeClass. For list/detail: ListDetailPaneScaffold from adaptive-layout.
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge() // BEFORE setContent
super.onCreate(savedInstanceState)
setContent { AppTheme { App() } }
}
Apply Modifier.safeDrawingPadding() on root scaffold. Cannot opt out on Android 16.
@Entity + @Dao + Repository + Mapper per table. Domain types have no Room annotations; entities have no business logic.ksp("androidx.room:room-compiler").@TypeConverters live next to the entity, not in a global Converters.kt.runBlocking to read -- expose Flow<Prefs>.EncryptedSharedPreferences, not DataStore.id("androidx.baselineprofile") to :app and :baselineprofile.ReportDrawnWhen { uiIsReady }.release {
isMinifyEnabled = true; isShrinkResources = true
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
}
Remove any legacy android.enableR8.fullMode=false from gradle.properties.
AppTheme. Render the stateless Content, not the stateful Screen.@PreviewLightDark, @PreviewScreenSizes, custom multi-preview annotation.ComposablePreviewScanner turns previews into screenshot tests.kotlin.compose plugin is NOT a compiler artifactIn Kotlin 2.x, alias(libs.plugins.kotlin.compose) IS the Compose Compiler. Do NOT add androidx.compose.compiler:compiler as a dependency or set kotlinCompilerExtensionVersion.
Convention plugins go in build-logic/ included build. buildSrc is no longer recommended -- invalidates build cache on every change.
Formatting: spotless with ktlint. ./gradlew spotlessApply before every commit.
| # | Anti-pattern | Correct approach |
|---|---|---|
| 1 | collectAsState() on anything hot | collectAsStateWithLifecycle() always |
| 2 | Suspend function in composable body | LaunchedEffect or hoist to VM |
| 3 | MutableList in State data class | List<T> or ImmutableList<T> |
| 4 | object Foo : Intent (bare object) | data object Foo : Intent |
| 5 | SharedFlow(replay=0) for one-shots | Channel(BUFFERED).receiveAsFlow() |
| 6 | Hard-coded Dispatchers.IO in repo | Inject DispatcherProvider |
| 7 | NavController inside screen composable | Hoist navigation callbacks to nav graph |
| 8 | Passing NavBackStackEntry down | Pull args with toRoute<Route>() at entry |
| 9 | remember {} for persistence | rememberSaveable or hoist to VM |
| 10 | Prophylactic @Stable/@Immutable | Only after compiler report shows unskippable restarts |
| 11 | Chained flowOn calls | One flowOn at end of upstream-heavy section |
| 12 | .value on StateFlow inside update {} | Use the reducer receiver |
| 13 | ViewModelFactory | Koin viewModel { } IS the factory |
| 14 | LiveData<Event<T>> / SingleLiveEvent | VM Channel<Effect> |
| 15 | stopKoin() in production | Koin is app-scoped; never tear down |
| 16 | Strings in code | stringResource(R.string.foo). Effects carry @StringRes Int |
| 17 | core/util / core/helpers | Real domain module or inline next to caller |
| 18 | @Serializable route without serialization plugin | Apply kotlin-serialization plugin to the module |
| 19 | material-icons-extended in release | Pulls 10 MB. Use individual icons from material-icons-core |
| 20 | LazyColumn items without stable key | Always key = { it.id } |
"How do I add a new screen?"
feature/foo/FooContract.kt -- State, Intent, Effect.FooViewModel : MviViewModel<...>(State()). Override handleIntent. Observers in init {}.FooContent(state, onIntent, modifier) + stateful FooScreen(onNavigate..., viewModel = koinViewModel()).viewModelModule: viewModel { FooViewModel(get(), ...) }.feature/foo/navigation/FooNavigation.kt -- @Serializable data object FooRoute + NavGraphBuilder.fooNavGraph(...).:app's AppNavHost.FooViewModelTest (MockK + Turbine), FooScreenTest (Robolectric + FooContent)."Where does this code go?"
| Kind | Module |
|---|---|
| Domain type (data class, enum) | :core:model |
| Repository + Room DAO + entity | :core:<domain> |
| Reusable composable | :ui:design |
| Screen, contract, VM | :feature:<name> |
| MVI base, dispatchers, analytics | :core:common |
| Nav host, DI modules, Application | :app |
"Which effect API?"
| Need | API |
|---|---|
| Collecting a flow | collectAsStateWithLifecycle() |
| Suspend work on entry | LaunchedEffect(key) { } |
| Non-suspend setup/teardown | DisposableEffect(key) { onDispose { } } |
| Computed value from hot state | derivedStateOf { } |
| VM effects Channel | LaunchedEffect(Unit) { vm.effects.collect { } } |
| Compose state as Flow (rare) | snapshotFlow { } |
npx claudepluginhub abnegate/claudes --plugin skillsCreates, edits, and optimizes skills for Claude Code, including drafting, evaluating with test prompts, iterating on performance, and improving skill descriptions for better triggering accuracy.