skills/ar4mirez/samuel/android-compose

android-compose

SKILL.md

Android Jetpack Compose Guide

Applies to: Android SDK 24+, Jetpack Compose 1.5+, Kotlin 1.9+, Material Design 3

Core Principles

  1. Declarative UI: Describe what the UI should look like, not how to build it step-by-step
  2. Unidirectional Data Flow: State flows down, events flow up -- always
  3. Composable Functions Are Cheap: The framework handles when to recompose; keep composables pure
  4. State Hoisting: Lift state to the nearest common ancestor that needs it
  5. Lifecycle Awareness: Collect flows with collectAsStateWithLifecycle(), never raw collectAsState()

Guardrails

Composable Conventions

  • Every composable accepts modifier: Modifier = Modifier as its first optional parameter
  • Keep composables small and focused (one responsibility per function)
  • Use @Preview on every reusable component with representative data
  • Never perform side effects (network, DB, analytics) directly inside a composable body
  • Use LaunchedEffect, DisposableEffect, or SideEffect for side-effect work
  • Prefer stateless composables; hoist state to the caller or ViewModel
// BAD: side effects in composition
@Composable
fun UserList(users: List<User>) {
    analytics.trackScreenView("user_list") // fires on every recomposition
    LazyColumn { ... }
}

// GOOD: side effect in LaunchedEffect
@Composable
fun UserList(users: List<User>) {
    LaunchedEffect(Unit) {
        analytics.trackScreenView("user_list")
    }
    LazyColumn { ... }
}

State Management

  • Use StateFlow in ViewModel, collect with collectAsStateWithLifecycle()
  • Model UI state as a sealed interface (Loading, Success, Error)
  • Never expose MutableStateFlow publicly from a ViewModel
  • Use remember for local composable state; rememberSaveable when it must survive config changes
  • Use derivedStateOf for expensive computations that depend on other state
// Sealed UI state pattern
sealed interface HomeUiState {
    data object Loading : HomeUiState
    data class Success(val users: List<User>) : HomeUiState
    data class Error(val message: String) : HomeUiState
}

// ViewModel exposes immutable StateFlow
@HiltViewModel
class HomeViewModel @Inject constructor(
    private val userRepository: UserRepository,
) : ViewModel() {
    private val _uiState = MutableStateFlow<HomeUiState>(HomeUiState.Loading)
    val uiState: StateFlow<HomeUiState> = _uiState.asStateFlow()

    fun loadUsers() {
        viewModelScope.launch {
            _uiState.value = HomeUiState.Loading
            userRepository.getUsers()
                .catch { e -> _uiState.value = HomeUiState.Error(e.message ?: "Unknown error") }
                .collect { users -> _uiState.value = HomeUiState.Success(users) }
        }
    }
}

// Screen collects lifecycle-aware
@Composable
fun HomeScreen(viewModel: HomeViewModel = hiltViewModel()) {
    val uiState by viewModel.uiState.collectAsStateWithLifecycle()
    when (val state = uiState) {
        is HomeUiState.Loading -> LoadingIndicator()
        is HomeUiState.Success -> UserList(state.users)
        is HomeUiState.Error -> ErrorMessage(state.message, onRetry = viewModel::loadUsers)
    }
}

Navigation

  • Define routes as a sealed class or sealed interface for type safety
  • Never pass complex objects as navigation arguments; pass IDs and load from ViewModel
  • Clear back stack correctly with popUpTo and inclusive when navigating to auth screens
  • Use hiltViewModel() inside composable {} blocks for scoped ViewModels
sealed class Screen(val route: String) {
    data object Login : Screen("login")
    data object Home : Screen("home")
    data object Detail : Screen("detail/{userId}") {
        fun createRoute(userId: String) = "detail/$userId"
    }
}

@Composable
fun AppNavigation() {
    val navController = rememberNavController()
    NavHost(navController = navController, startDestination = Screen.Home.route) {
        composable(Screen.Home.route) {
            HomeScreen(onUserClick = { userId ->
                navController.navigate(Screen.Detail.createRoute(userId))
            })
        }
        composable(
            route = Screen.Detail.route,
            arguments = listOf(navArgument("userId") { type = NavType.StringType }),
        ) { backStackEntry ->
            val userId = backStackEntry.arguments?.getString("userId") ?: ""
            DetailScreen(userId = userId, onBack = { navController.popBackStack() })
        }
    }
}

Material 3 Theming

  • Always wrap the app root in your custom MaterialTheme composable
  • Support dynamic color on Android 12+ with dynamicLightColorScheme / dynamicDarkColorScheme
  • Define color tokens in a dedicated Color.kt; typography in Type.kt; theme in Theme.kt
  • Use MaterialTheme.colorScheme.* and MaterialTheme.typography.* instead of hardcoded values
  • Support both light and dark themes from day one
@Composable
fun MyAppTheme(
    darkTheme: Boolean = isSystemInDarkTheme(),
    dynamicColor: Boolean = true,
    content: @Composable () -> Unit,
) {
    val colorScheme = when {
        dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
            val context = LocalContext.current
            if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
        }
        darkTheme -> DarkColorScheme
        else -> LightColorScheme
    }
    MaterialTheme(colorScheme = colorScheme, typography = Typography, content = content)
}

Dependency Injection (Hilt)

  • Annotate Application with @HiltAndroidApp, Activity with @AndroidEntryPoint
  • Annotate ViewModels with @HiltViewModel and use @Inject constructor
  • Use hiltViewModel() in composables, never construct ViewModels manually
  • Organize DI modules by layer: AppModule, NetworkModule, DatabaseModule

Lifecycle & Effects

Effect Trigger Use for
LaunchedEffect(key) On enter + when key changes One-shot loads, navigation events
DisposableEffect(key) On enter + when key changes (with cleanup) Listeners, observers, callbacks
SideEffect After every successful recomposition Syncing compose state to non-compose
rememberCoroutineScope() Manual launch from callbacks Button clicks launching coroutines
// One-shot load on screen enter
LaunchedEffect(Unit) { viewModel.loadData() }

// Clean up a listener when leaving composition
DisposableEffect(lifecycleOwner) {
    val observer = LifecycleEventObserver { _, event -> ... }
    lifecycleOwner.lifecycle.addObserver(observer)
    onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
}

Project Structure

app/src/main/java/com/example/myapp/
├── MyApplication.kt               # @HiltAndroidApp
├── MainActivity.kt                # @AndroidEntryPoint, setContent {}
├── navigation/
│   └── AppNavigation.kt           # NavHost, route definitions
├── ui/
│   ├── theme/                     # Color.kt, Type.kt, Theme.kt
│   ├── screens/                   # Feature screens
│   │   ├── home/
│   │   │   ├── HomeScreen.kt      # Composable
│   │   │   └── HomeViewModel.kt   # ViewModel + UiState
│   │   └── detail/
│   │       ├── DetailScreen.kt
│   │       └── DetailViewModel.kt
│   └── components/                # Shared composables
│       ├── LoadingIndicator.kt
│       └── ErrorMessage.kt
├── data/
│   ├── model/                     # Domain models (@Serializable)
│   ├── remote/                    # Retrofit service, DTOs
│   ├── local/                     # Room database, DAOs
│   └── repository/                # Repository implementations
└── di/                            # Hilt modules
    ├── AppModule.kt
    └── NetworkModule.kt
  • ui/screens/: One sub-package per feature, each with Screen.kt + ViewModel.kt
  • ui/components/: Reusable composables shared across screens
  • data/: Models, repositories, API services; no Compose dependencies here
  • di/: Hilt modules providing singletons and scoped dependencies

Key Dependencies

// build.gradle.kts (app)
val composeBom = platform("androidx.compose:compose-bom:2024.01.00")
implementation(composeBom)
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.material3:material3")
implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.activity:activity-compose:1.8.2")
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.7.0")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0")
implementation("androidx.navigation:navigation-compose:2.7.6")
implementation("androidx.hilt:hilt-navigation-compose:1.1.0")
implementation("com.google.dagger:hilt-android:2.50")
ksp("com.google.dagger:hilt-compiler:2.50")
implementation("io.coil-kt:coil-compose:2.5.0")

// Testing
testImplementation("junit:junit:4.13.2")
testImplementation("io.mockk:mockk:1.13.9")
testImplementation("app.cash.turbine:turbine:1.0.0")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3")
androidTestImplementation(composeBom)
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
debugImplementation("androidx.compose.ui:ui-tooling")
debugImplementation("androidx.compose.ui:ui-test-manifest")

Testing

ViewModel Unit Tests

  • Use StandardTestDispatcher + Dispatchers.setMain() for coroutine control
  • Use Turbine for StateFlow assertions
  • Use MockK with coEvery / coVerify for suspend function mocking
  • Reset dispatcher in @After with Dispatchers.resetMain()
@OptIn(ExperimentalCoroutinesApi::class)
class HomeViewModelTest {
    private val testDispatcher = StandardTestDispatcher()
    private val userRepository = mockk<UserRepository>()

    @Before fun setup() { Dispatchers.setMain(testDispatcher) }
    @After fun tearDown() { Dispatchers.resetMain() }

    @Test
    fun `loadUsers emits Success state on valid data`() = runTest {
        val users = listOf(User(id = "1", email = "a@b.com", name = "Alice"))
        coEvery { userRepository.getUsers() } returns flowOf(users)

        val viewModel = HomeViewModel(userRepository)
        advanceUntilIdle()

        viewModel.uiState.test {
            val state = awaitItem()
            assertTrue(state is HomeUiState.Success)
            assertEquals(users, (state as HomeUiState.Success).users)
        }
    }
}

Compose UI Tests

  • Use createComposeRule() for isolated composable tests
  • Query nodes with onNodeWithText, onNodeWithTag, onNodeWithContentDescription
  • Assert with assertExists(), assertIsDisplayed(), assertIsEnabled()
  • Perform actions with performClick(), performTextInput()
class HomeScreenTest {
    @get:Rule val composeTestRule = createComposeRule()

    @Test
    fun displays_user_name_when_loaded() {
        composeTestRule.setContent {
            MyAppTheme {
                UserCard(user = User("1", "a@b.com", "Alice"), onClick = {}, onDeleteClick = {})
            }
        }
        composeTestRule.onNodeWithText("Alice").assertIsDisplayed()
        composeTestRule.onNodeWithText("a@b.com").assertIsDisplayed()
    }
}

Commands

# Build
./gradlew assembleDebug            # Debug APK
./gradlew assembleRelease          # Release APK
./gradlew bundleRelease            # AAB for Play Store

# Test
./gradlew test                     # Unit tests
./gradlew connectedAndroidTest     # Instrumented tests on device/emulator

# Quality
./gradlew lint                     # Android Lint
./gradlew ktlintCheck              # Code style check
./gradlew ktlintFormat             # Auto-fix formatting
./gradlew detekt                   # Static analysis

# Install & Run
./gradlew installDebug             # Install on connected device
./gradlew clean                    # Clean build artifacts

Best Practices Summary

Do

  • Use collectAsStateWithLifecycle() for all Flow collection in composables
  • Use remember {} for expensive local computations
  • Use LaunchedEffect for one-shot side effects, DisposableEffect for cleanup
  • Keep composables stateless; hoist state to ViewModel or caller
  • Use sealed interfaces for UI state with exhaustive when expressions
  • Provide contentDescription on all interactive and decorative icons
  • Use Modifier chaining; accept modifier parameter in every public composable
  • Preview every reusable component with @Preview

Avoid

  • Performing I/O, network calls, or analytics tracking directly in composable bodies
  • Exposing MutableStateFlow or MutableState from ViewModels
  • Using !! operator in production code
  • Hardcoding colors, dimensions, or strings (use theme tokens and resources)
  • Creating ViewModels manually (use hiltViewModel())
  • Passing complex objects through navigation arguments (pass IDs instead)
  • Ignoring configuration changes (use rememberSaveable when needed)

References

For detailed UI patterns, animation, testing strategies, performance, and accessibility guidance, see:

External References

Weekly Installs
5
Repository
ar4mirez/samuel
GitHub Stars
3
First Seen
Mar 1, 2026
Installed on
cline5
github-copilot5
codex5
kimi-cli5
gemini-cli5
cursor5