Android Development
Structured guidance for building modern Android applications with Kotlin, Jetpack Compose, Material Design 3, and current Android architecture patterns. Covers project structure, Compose UI development, theming, navigation, architecture, data layer, lifecycle management, and testing strategies specific to production Android applications.
When to Use This Skill
Use this skill for:
- Setting up a new Android project with multi-module Gradle configuration and version catalogs
- Building UI screens with Jetpack Compose, state hoisting, and recomposition optimization
- Implementing Material Design 3 theming with dynamic color, custom color schemes, and dark theme support
- Setting up Compose Navigation with type-safe routes, nested graphs, and deep links
- Designing MVVM or MVI architecture with ViewModel, UiState, Repository pattern, and Hilt DI
- Implementing the data layer with Room, DataStore, Retrofit or Ktor, and Paging 3
- Handling Android lifecycle, coroutine scoping, side effects, and background work with WorkManager
- Writing unit tests, Compose UI tests, and integration tests for Android components
Trigger phrases: "android app", "kotlin android", "jetpack compose", "compose ui", "material design", "material you", "viewmodel", "android navigation", "room database", "hilt", "gradle version catalog", "android testing", "compose preview", "mvvm", "mvi", "datastore", "paging 3", "workmanager", "compose state", "recomposition"
What This Skill Does
Provides Android development patterns including:
- Project Structure: Multi-module Gradle setup, version catalogs, build conventions, ProGuard/R8 configuration
- Jetpack Compose: Composable functions, state management, Modifier chains, previews, recomposition optimization
- Material Design 3: Dynamic color, custom themes, typography, shapes, dark theme, Material You adaptation
- Navigation: Compose Navigation with type-safe routes, nested graphs, bottom navigation, deep links
- Architecture: MVVM with ViewModel and UiState, Repository pattern, UseCases, Hilt dependency injection
- Data Layer: Room database, DataStore preferences, Retrofit/Ktor networking, offline-first caching, Paging 3
- Lifecycle: LaunchedEffect, DisposableEffect, lifecycle-aware Flow collection, WorkManager, foreground services
- Testing: JUnit 5 unit tests, Compose testing with ComposeTestRule, Robolectric, Hilt testing, UI automation
Instructions
Step 1: Project Structure and Gradle Configuration
A well-organized Android project uses multi-module architecture, version catalogs for dependency management, and convention plugins to keep build files DRY.
Recommended Module Structure:
my-app/
├── app/ # Application module (wiring, DI, navigation)
│ ├── build.gradle.kts
│ └── src/main/
│ ├── AndroidManifest.xml
│ └── kotlin/com/example/myapp/
│ ├── MyApplication.kt
│ ├── MainActivity.kt
│ └── navigation/
│ └── AppNavGraph.kt
├── core/
│ ├── common/ # Shared utilities, extension functions
│ ├── data/ # Repository implementations, data sources
│ ├── database/ # Room database, DAOs, entities
│ ├── datastore/ # DataStore preferences
│ ├── domain/ # Use cases, domain models, repository interfaces
│ ├── model/ # Shared data models
│ ├── network/ # Retrofit/Ktor service definitions, DTOs
│ └── ui/ # Shared Compose components, theme
├── feature/
│ ├── home/ # Home screen feature module
│ ├── profile/ # Profile feature module
│ └── settings/ # Settings feature module
├── build-logic/ # Convention plugins
│ └── convention/
│ └── src/main/kotlin/
│ ├── AndroidApplicationConventionPlugin.kt
│ ├── AndroidLibraryConventionPlugin.kt
│ └── AndroidComposeConventionPlugin.kt
├── gradle/
│ └── libs.versions.toml # Version catalog
├── build.gradle.kts # Root build file
├── settings.gradle.kts
└── gradle.properties
Version Catalog (gradle/libs.versions.toml):
[versions]
agp = "8.7.3"
kotlin = "2.1.0"
ksp = "2.1.0-1.0.29"
compose-bom = "2024.12.01"
compose-compiler = "1.5.15"
hilt = "2.53.1"
room = "2.6.1"
lifecycle = "2.8.7"
navigation = "2.8.5"
retrofit = "2.11.0"
okhttp = "4.12.0"
coroutines = "1.9.0"
paging = "3.3.5"
datastore = "1.1.1"
work = "2.10.0"
junit5 = "5.11.4"
truth = "1.4.4"
turbine = "1.2.0"
robolectric = "4.14.1"
[libraries]
# Compose BOM aligns all Compose library versions
compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "compose-bom" }
compose-ui = { group = "androidx.compose.ui", name = "ui" }
compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
compose-material3 = { group = "androidx.compose.material3", name = "material3" }
compose-material-icons = { group = "androidx.compose.material", name = "material-icons-extended" }
# Architecture components
lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle" }
lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" }
navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigation" }
# Hilt
hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" }
hilt-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "hilt" }
hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version = "1.2.0" }
hilt-testing = { group = "com.google.dagger", name = "hilt-android-testing", version.ref = "hilt" }
# Room
room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
room-paging = { group = "androidx.room", name = "room-paging", version.ref = "room" }
room-testing = { group = "androidx.room", name = "room-testing", version.ref = "room" }
# Networking
retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" }
retrofit-converter-kotlinx = { group = "com.squareup.retrofit2", name = "converter-kotlinx-serialization", version.ref = "retrofit" }
okhttp-logging = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" }
# DataStore and Paging
datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" }
paging-runtime = { group = "androidx.paging", name = "paging-runtime", version.ref = "paging" }
paging-compose = { group = "androidx.paging", name = "paging-compose", version.ref = "paging" }
# WorkManager
work-runtime = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "work" }
work-testing = { group = "androidx.work", name = "work-testing", version.ref = "work" }
# Testing
junit5-api = { group = "org.junit.jupiter", name = "junit-jupiter-api", version.ref = "junit5" }
junit5-engine = { group = "org.junit.jupiter", name = "junit-jupiter-engine", version.ref = "junit5" }
junit5-params = { group = "org.junit.jupiter", name = "junit-jupiter-params", version.ref = "junit5" }
truth = { group = "com.google.truth", name = "truth", version.ref = "truth" }
turbine = { group = "app.cash.turbine", name = "turbine", version.ref = "turbine" }
coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutines" }
robolectric = { group = "org.robolectric", name = "robolectric", version.ref = "robolectric" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
android-library = { id = "com.android.library", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
room = { id = "androidx.room", version.ref = "room" }
Application Module (app/build.gradle.kts):
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.ksp)
alias(libs.plugins.hilt)
}
android {
namespace = "com.example.myapp"
compileSdk = 35
defaultConfig {
applicationId = "com.example.myapp"
minSdk = 26
targetSdk = 35
versionCode = 1
versionName = "1.0.0"
testInstrumentationRunner = "com.example.myapp.testing.HiltTestRunner"
}
buildTypes {
debug {
isDebuggable = true
applicationIdSuffix = ".debug"
versionNameSuffix = "-debug"
}
release {
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
)
signingConfig = signingConfigs.getByName("release")
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
freeCompilerArgs += listOf(
"-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi",
"-opt-in=androidx.compose.material3.ExperimentalMaterial3Api",
)
}
buildFeatures {
compose = true
buildConfig = true
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
}
dependencies {
// Feature modules
implementation(project(":feature:home"))
implementation(project(":feature:profile"))
implementation(project(":feature:settings"))
// Core modules
implementation(project(":core:common"))
implementation(project(":core:data"))
implementation(project(":core:domain"))
implementation(project(":core:ui"))
// Compose
implementation(platform(libs.compose.bom))
implementation(libs.compose.ui)
implementation(libs.compose.ui.graphics)
implementation(libs.compose.material3)
implementation(libs.compose.ui.tooling.preview)
debugImplementation(libs.compose.ui.tooling)
debugImplementation(libs.compose.ui.test.manifest)
// Architecture
implementation(libs.lifecycle.runtime.compose)
implementation(libs.lifecycle.viewmodel.compose)
implementation(libs.navigation.compose)
// Hilt
implementation(libs.hilt.android)
ksp(libs.hilt.compiler)
implementation(libs.hilt.navigation.compose)
// Testing
testImplementation(libs.junit5.api)
testRuntimeOnly(libs.junit5.engine)
testImplementation(libs.truth)
testImplementation(libs.coroutines.test)
androidTestImplementation(platform(libs.compose.bom))
androidTestImplementation(libs.compose.ui.test.junit4)
androidTestImplementation(libs.hilt.testing)
kspAndroidTest(libs.hilt.compiler)
}
ProGuard/R8 Rules (app/proguard-rules.pro):
# Kotlin serialization
-keepattributes *Annotation*, InnerClasses
-dontnote kotlinx.serialization.AnnotationsKt
-keepclassmembers @kotlinx.serialization.Serializable class ** {
*** Companion;
}
-keepclasseswithmembers class **$$serializer {
*** INSTANCE;
}
# Retrofit
-keepattributes Signature, Exceptions
-keep,allowshrinking,allowoptimization interface * {
@retrofit2.http.* <methods>;
}
-dontwarn javax.annotation.**
-dontwarn kotlin.Unit
# Room
-keep class * extends androidx.room.RoomDatabase
-keep @androidx.room.Entity class *
-dontwarn androidx.room.paging.**
# Hilt
-keep class dagger.hilt.** { *; }
-keep class javax.inject.** { *; }
-keep class * extends dagger.hilt.android.internal.managers.ViewComponentManager$FragmentContextWrapper { *; }
Key Gradle Configuration Principles:
- Use a version catalog (
libs.versions.toml) as the single source of truth for all dependency versions across modules - Apply the Compose BOM to align all Compose library versions automatically, avoiding version conflicts
- Enable R8 minification and resource shrinking in the release build type to reduce APK size
- Set
minSdk = 26(Android 8.0) as a practical baseline that covers over 95% of active devices - Use convention plugins in
build-logic/to share common configuration across feature and core modules - Always set an
applicationIdSuffixon debug builds to allow side-by-side installation with release builds
Step 2: Jetpack Compose Fundamentals
Jetpack Compose uses a declarative, function-based approach to UI. Understanding composable functions, state management, recomposition, and Modifier chains is essential for building performant Compose UIs.
Composable Functions and State Hoisting:
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
/**
* Stateful wrapper that owns the search query state.
* Use this pattern at the screen level, hoisting state up to the
* nearest common ancestor that needs it.
*/
@Composable
fun SearchScreen(
onNavigateToResult: (query: String) -> Unit,
modifier: Modifier = Modifier,
) {
// rememberSaveable survives configuration changes (rotation, process death)
var query by rememberSaveable { mutableStateOf("") }
var isSearching by rememberSaveable { mutableStateOf(false) }
SearchContent(
query = query,
isSearching = isSearching,
query = it },
isSearching = true
onNavigateToResult(query)
},
modifier = modifier,
)
}
/**
* Stateless composable that receives all state as parameters.
* This pattern makes the component testable, previewable, and reusable.
*/
@Composable
fun SearchContent(
query: String,
isSearching: Boolean,
onQueryChange: (String) -> Unit,
onSearch: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier
.fillMaxSize()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
OutlinedTextField(
value = query,
label = { Text("Search") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Button(
enabled = query.isNotBlank() && !isSearching,
modifier = Modifier.fillMaxWidth(),
) {
if (isSearching) {
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
strokeWidth = 2.dp,
)
Spacer(modifier = Modifier.width(8.dp))
}
Text(if (isSearching) "Searching..." else "Search")
}
}
}
Stable Types and Recomposition Optimization:
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
/**
* Mark data classes as @Immutable when all properties are val and
* use immutable types. This tells the Compose compiler the class
* will never change after construction, enabling recomposition skipping.
*/
@Immutable
data class ArticleUiModel(
val id: String,
val title: String,
val summary: String,
val imageUrl: String?,
val publishedAt: String,
val isBookmarked: Boolean,
)
/**
* Use @Stable for classes where the Compose compiler cannot infer stability
* (e.g., classes with mutable internal state that is observed correctly).
*/
@Stable
class ArticleListState(
val articles: List<ArticleUiModel>,
val isLoading: Boolean,
val errorMessage: String?,
) {
companion object {
val Empty = ArticleListState(
articles = emptyList(),
isLoading = false,
errorMessage = null,
)
}
}
/**
* Use derivedStateOf to avoid unnecessary recompositions when the derived
* value has not actually changed, even if the source state has.
*/
@Composable
fun ArticleList(
articles: List<ArticleUiModel>,
modifier: Modifier = Modifier,
) {
// Only recomposes when the count actually changes, not on every list update
val bookmarkCount by remember(articles) {
derivedStateOf { articles.count { it.isBookmarked } }
}
Column(modifier = modifier) {
Text(
text = "$bookmarkCount bookmarked",
style = MaterialTheme.typography.labelMedium,
)
// Use key() to help Compose identify items across recompositions
articles.forEach { article ->
key(article.id) {
ArticleCard(article = article)
}
}
}
}
Modifier Chain Best Practices:
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
/**
* Modifier order matters. Each modifier wraps the previous one.
* Common pattern: size/padding -> shape/clip -> background -> content padding -> interaction.
*/
@Composable
fun ArticleCard(
article: ArticleUiModel,
onClick: () -> Unit = {},
modifier: Modifier = Modifier,
) {
Card(
modifier = modifier
// 1. External spacing (caller controls placement)
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp)
// 2. Shadow before clip so it renders outside the shape
.shadow(elevation = 2.dp, shape = RoundedCornerShape(12.dp))
// 3. Clip to shape for rounded corners on ripple and content
.clip(RoundedCornerShape(12.dp))
// 4. Clickable after clip so the ripple respects the shape
.clickable(onClick = onClick)
// 5. Accessibility: provide a content description for screen readers
.semantics {
contentDescription = "Article: ${article.title}"
},
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = article.title,
style = MaterialTheme.typography.titleMedium,
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = article.summary,
style = MaterialTheme.typography.bodyMedium,
maxLines = 3,
)
}
}
}
Compose Previews:
import androidx.compose.material3.Surface
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
class ArticlePreviewProvider : PreviewParameterProvider<ArticleUiModel> {
override val values: Sequence<ArticleUiModel> = sequenceOf(
ArticleUiModel(
id = "1",
title = "Jetpack Compose Best Practices",
summary = "Learn how to build performant UIs with Compose...",
imageUrl = null,
publishedAt = "2024-12-01",
isBookmarked = false,
),
ArticleUiModel(
id = "2",
title = "A Very Long Title That Should Wrap to Multiple Lines in the Card Layout",
summary = "Short summary.",
imageUrl = "https://example.com/image.jpg",
publishedAt = "2024-11-15",
isBookmarked = true,
),
)
}
@Preview(showBackground = true, name = "Light Mode")
@Preview(showBackground = true, name = "Dark Mode",
uiMode = android.content.res.Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun ArticleCardPreview(
@PreviewParameter(ArticlePreviewProvider::class) article: ArticleUiModel,
) {
MyAppTheme {
Surface {
ArticleCard(article = article,
}
}
}
@Preview(showBackground = true, widthDp = 360, heightDp = 640)
@Composable
private fun SearchScreenPreview() {
MyAppTheme {
SearchContent(
query = "Kotlin",
isSearching = false,
)
}
}
Key Compose Principles:
- Hoist state to the lowest common ancestor that needs it. Stateless composables are easier to test and preview
- Use
rememberSaveablefor state that must survive configuration changes; userememberfor transient UI state only - Mark data classes with
@Immutableor@Stableto help the Compose compiler skip unnecessary recompositions - Always accept a
modifier: Modifier = Modifierparameter as the last optional parameter on public composables - Use
derivedStateOfwhen computing a value from other state objects to avoid redundant recompositions - Modifier order matters: size and padding modifiers wrap outer to inner, and clickable should come after clip for correct ripple bounds
Step 3: Material Design 3 Theming
Material Design 3 (Material You) introduces dynamic color, updated component styles, and a flexible theming system. A well-structured theme ensures consistent visual identity across the entire application.
Color Scheme and Dynamic Color:
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
// Define custom colors using Material 3 tonal palette roles
private val LightColorScheme = lightColorScheme(
primary = Color(0xFF1B6D3D),
primaryContainer = Color(0xFFA5F5B8),
secondary = Color(0xFF4F6353),
secondaryContainer = Color(0xFFD1E8D4),
tertiary = Color(0xFF3A656F),
tertiaryContainer = Color(0xFFBEEAF6),
error = Color(0xFFBA1A1A),
errorContainer = Color(0xFFFFDAD6),
background = Color(0xFFFBFDF8),
surface = Color(0xFFFBFDF8),
surfaceVariant = Color(0xFFDCE5DB),
outline = Color(0xFF717971),
outlineVariant = Color(0xFFC0C9BF),
)
private val DarkColorScheme = darkColorScheme(
primary = Color(0xFF8AD89E),
primaryContainer = Color(0xFF00522B),
secondary = Color(0xFFB6CCB8),
secondaryContainer = Color(0xFF374B3C),
tertiary = Color(0xFFA2CED9),
tertiaryContainer = Color(0xFF204D56),
error = Color(0xFFFFB4AB),
errorContainer = Color(0xFF93000A),
background = Color(0xFF191C19),
surface = Color(0xFF191C19),
surfaceVariant = Color(0xFF414941),
outline = Color(0xFF8B938A),
outlineVariant = Color(0xFF414941),
)
@Composable
fun MyAppTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
dynamicColor: Boolean = true,
content: @Composable () -> Unit,
) {
val colorScheme = when {
// Dynamic color is available on Android 12+ (API 31)
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 = AppTypography,
shapes = AppShapes,
content = content,
)
}
Typography:
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
val InterFontFamily = FontFamily(
Font(R.font.inter_regular, FontWeight.Normal),
Font(R.font.inter_medium, FontWeight.Medium),
Font(R.font.inter_semibold, FontWeight.SemiBold),
Font(R.font.inter_bold, FontWeight.Bold),
)
val AppTypography = Typography(
displayLarge = TextStyle(
fontFamily = InterFontFamily,
fontWeight = FontWeight.Normal,
fontSize = 57.sp,
lineHeight = 64.sp,
letterSpacing = (-0.25).sp,
),
headlineLarge = TextStyle(
fontFamily = InterFontFamily,
fontWeight = FontWeight.SemiBold,
fontSize = 32.sp,
lineHeight = 40.sp,
),
headlineMedium = TextStyle(
fontFamily = InterFontFamily,
fontWeight = FontWeight.SemiBold,
fontSize = 28.sp,
lineHeight = 36.sp,
),
titleLarge = TextStyle(
fontFamily = InterFontFamily,
fontWeight = FontWeight.Medium,
fontSize = 22.sp,
lineHeight = 28.sp,
),
titleMedium = TextStyle(
fontFamily = InterFontFamily,
fontWeight = FontWeight.Medium,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.15.sp,
),
bodyLarge = TextStyle(
fontFamily = InterFontFamily,
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.5.sp,
),
bodyMedium = TextStyle(
fontFamily = InterFontFamily,
fontWeight = FontWeight.Normal,
fontSize = 14.sp,
lineHeight = 20.sp,
letterSpacing = 0.25.sp,
),
labelLarge = TextStyle(
fontFamily = InterFontFamily,
fontWeight = FontWeight.Medium,
fontSize = 14.sp,
lineHeight = 20.sp,
letterSpacing = 0.1.sp,
),
labelMedium = TextStyle(
fontFamily = InterFontFamily,
fontWeight = FontWeight.Medium,
fontSize = 12.sp,
lineHeight = 16.sp,
letterSpacing = 0.5.sp,
),
)
Shapes:
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Shapes
import androidx.compose.ui.unit.dp
val AppShapes = Shapes(
extraSmall = RoundedCornerShape(4.dp),
small = RoundedCornerShape(8.dp),
medium = RoundedCornerShape(12.dp),
large = RoundedCornerShape(16.dp),
extraLarge = RoundedCornerShape(28.dp),
)
Using Theme Values in Composables:
@Composable
fun StatusBadge(
label: String,
isActive: Boolean,
modifier: Modifier = Modifier,
) {
val containerColor = if (isActive) {
MaterialTheme.colorScheme.primaryContainer
} else {
MaterialTheme.colorScheme.surfaceVariant
}
val contentColor = if (isActive) {
MaterialTheme.colorScheme.onPrimaryContainer
} else {
MaterialTheme.colorScheme.onSurfaceVariant
}
Surface(
modifier = modifier,
shape = MaterialTheme.shapes.small,
color = containerColor,
contentColor = contentColor,
) {
Text(
text = label,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
style = MaterialTheme.typography.labelMedium,
)
}
}
Key Theming Principles:
- Use
dynamicColorSchemeon Android 12+ devices to automatically extract colors from the user's wallpaper, falling back to your custom color scheme on older devices - Always define both light and dark color schemes. Use
isSystemInDarkTheme()to follow the system setting - Reference
MaterialTheme.colorScheme,MaterialTheme.typography, andMaterialTheme.shapesin composables instead of hardcoding values - Use semantic color roles (
primary,secondary,error,surface,surfaceVariant) rather than raw color values to maintain consistency - Test your theme with both dynamic color enabled and disabled, and verify readability in both light and dark modes
Step 4: Navigation
Compose Navigation provides a declarative, type-safe way to manage screen transitions, deep links, and nested navigation graphs.
Type-Safe Route Definitions:
import kotlinx.serialization.Serializable
/**
* Define routes as @Serializable data classes or objects.
* This approach provides compile-time safety for navigation arguments.
*/
sealed interface Route {
@Serializable
data object Home : Route
@Serializable
data object Profile : Route
@Serializable
data object Settings : Route
@Serializable
data class ArticleDetail(val articleId: String) : Route
@Serializable
data class UserProfile(val userId: String, val tab: String = "posts") : Route
}
/**
* Top-level navigation destinations for bottom navigation.
*/
enum class TopLevelDestination(
val route: Route,
val selectedIcon: ImageVector,
val unselectedIcon: ImageVector,
val label: String,
) {
HOME(
route = Route.Home,
selectedIcon = Icons.Filled.Home,
unselectedIcon = Icons.Outlined.Home,
label = "Home",
),
PROFILE(
route = Route.Profile,
selectedIcon = Icons.Filled.Person,
unselectedIcon = Icons.Outlined.Person,
label = "Profile",
),
SETTINGS(
route = Route.Settings,
selectedIcon = Icons.Filled.Settings,
unselectedIcon = Icons.Outlined.Settings,
label = "Settings",
),
}
Navigation Host with Bottom Navigation:
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.navigation.NavDestination.Companion.hasRoute
import androidx.navigation.NavDestination.Companion.hierarchy
import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import androidx.navigation.toRoute
@Composable
fun MainScreen() {
val navController = rememberNavController()
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentDestination = navBackStackEntry?.destination
// Determine whether to show bottom navigation
val showBottomBar = TopLevelDestination.entries.any { dest ->
currentDestination?.hasRoute(dest.route::class) == true
}
Scaffold(
bottomBar = {
if (showBottomBar) {
NavigationBar {
TopLevelDestination.entries.forEach { destination ->
val selected = currentDestination?.hierarchy?.any {
it.hasRoute(destination.route::class)
} == true
NavigationBarItem(
selected = selected,
navController.navigate(destination.route) {
// Pop up to the start destination to avoid
// building up a large back stack
popUpTo(navController.graph.findStartDestination().id) {
saveState = true
}
launchSingleTop = true
restoreState = true
}
},
icon = {
Icon(
imageVector = if (selected) destination.selectedIcon
else destination.unselectedIcon,
contentDescription = destination.label,
)
},
label = { Text(destination.label) },
)
}
}
}
},
) { innerPadding ->
NavHost(
navController = navController,
startDestination = Route.Home,
modifier = Modifier.padding(innerPadding),
) {
composable<Route.Home> {
HomeScreen(
articleId ->
navController.navigate(Route.ArticleDetail(articleId))
},
)
}
composable<Route.Profile> {
ProfileScreen(
userId ->
navController.navigate(Route.UserProfile(userId))
},
)
}
composable<Route.Settings> {
SettingsScreen()
}
composable<Route.ArticleDetail> { backStackEntry ->
val route = backStackEntry.toRoute<Route.ArticleDetail>()
ArticleDetailScreen(articleId = route.articleId)
}
composable<Route.UserProfile> { backStackEntry ->
val route = backStackEntry.toRoute<Route.UserProfile>()
UserProfileScreen(userId = route.userId, initialTab = route.tab)
}
}
}
}
Deep Links:
import androidx.navigation.navDeepLink
// Inside the NavHost builder:
composable<Route.ArticleDetail>(
deepLinks = listOf(
navDeepLink<Route.ArticleDetail>(
basePath = "https://myapp.example.com/articles",
),
),
) { backStackEntry ->
val route = backStackEntry.toRoute<Route.ArticleDetail>()
ArticleDetailScreen(articleId = route.articleId)
}
AndroidManifest.xml Deep Link Configuration:
<activity android:name=".MainActivity"
android:exported="true">
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https"
android:host="myapp.example.com"
android:pathPrefix="/articles" />
</intent-filter>
</activity>
Key Navigation Principles:
- Define routes as
@Serializabledata classes or objects to get compile-time safety for navigation arguments - Use
popUpTowithsaveState = trueandrestoreState = trueon bottom navigation items to preserve each tab's back stack - Hide the bottom navigation bar on detail screens by checking whether the current destination is a top-level route
- Use
launchSingleTop = trueto prevent duplicate destinations on repeated taps - Configure deep links both in the
NavHostand inAndroidManifest.xmlwithandroid:autoVerify="true"for App Links
Step 5: Architecture Patterns
Modern Android architecture follows a unidirectional data flow pattern with clear separation between UI, domain, and data layers. ViewModel exposes UI state, the domain layer contains business logic, and the data layer manages data sources.
UiState and ViewModel:
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import javax.inject.Inject
/**
* Sealed interface for UI state. Each subtype represents a distinct
* state the screen can be in. Prefer a single sealed hierarchy over
* multiple boolean flags to make impossible states unrepresentable.
*/
sealed interface ArticleListUiState {
data object Loading : ArticleListUiState
data class Success(
val articles: List<ArticleUiModel>,
val isRefreshing: Boolean = false,
) : ArticleListUiState
data class Error(
val message: String,
val canRetry: Boolean = true,
) : ArticleListUiState
}
/**
* One-shot events that the UI should handle exactly once (navigation,
* snackbar, etc.). Use a Channel or SharedFlow, not StateFlow.
*/
sealed interface ArticleListEvent {
data class ShowSnackbar(val message: String) : ArticleListEvent
data class NavigateToDetail(val articleId: String) : ArticleListEvent
}
/**
* User actions that the UI sends to the ViewModel.
*/
sealed interface ArticleListAction {
data object LoadArticles : ArticleListAction
data object Refresh : ArticleListAction
data class ToggleBookmark(val articleId: String) : ArticleListAction
data class ArticleClicked(val articleId: String) : ArticleListAction
}
@HiltViewModel
class ArticleListViewModel @Inject constructor(
private val getArticlesUseCase: GetArticlesUseCase,
private val toggleBookmarkUseCase: ToggleBookmarkUseCase,
) : ViewModel() {
private val _uiState = MutableStateFlow<ArticleListUiState>(ArticleListUiState.Loading)
val uiState: StateFlow<ArticleListUiState> = _uiState.asStateFlow()
private val _events = MutableSharedFlow<ArticleListEvent>(extraBufferCapacity = 1)
val events: SharedFlow<ArticleListEvent> = _events.asSharedFlow()
init {
onAction(ArticleListAction.LoadArticles)
}
fun onAction(action: ArticleListAction) {
when (action) {
is ArticleListAction.LoadArticles -> loadArticles()
is ArticleListAction.Refresh -> refresh()
is ArticleListAction.ToggleBookmark -> toggleBookmark(action.articleId)
is ArticleListAction.ArticleClicked -> {
_events.tryEmit(ArticleListEvent.NavigateToDetail(action.articleId))
}
}
}
private fun loadArticles() {
viewModelScope.launch {
_uiState.value = ArticleListUiState.Loading
getArticlesUseCase()
.catch { e ->
_uiState.value = ArticleListUiState.Error(
message = e.message ?: "Failed to load articles",
)
}
…(truncated)