Project Setup, Gradle, and Course Rules
Build: a clean Compose app named ModernAndroidCourse. The first rule is simple: the app must compile after every module.
Baseline dependencies
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
}
android {
namespace = "com.example.modernandroid"
compileSdk = 36
defaultConfig {
applicationId = "com.example.modernandroid"
minSdk = 26
targetSdk = 36
versionCode = 1
versionName = "1.0"
}
}
dependencies {
implementation(platform("androidx.compose:compose-bom:2026.08.00"))
implementation("androidx.activity:activity-compose:1.12.0")
implementation("androidx.compose.material3:material3")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.9.2")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2")
testImplementation("junit:junit:4.13.2")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2")
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
}
Kotlin: Types, Data Classes, Null Safety, and Sealed Models
Build: a pure Kotlin expense model. This module has no Android UI. That is intentional: app correctness starts before the screen.
package com.example.modernandroid.model
data class Expense(
val id: Long,
val title: String,
val amount: Double,
val category: ExpenseCategory,
val note: String? = null
)
sealed interface ExpenseCategory {
data object Food : ExpenseCategory
data object Travel : ExpenseCategory
data object Medical : ExpenseCategory
data object Education : ExpenseCategory
data object Other : ExpenseCategory
}
sealed interface ValidationResult {
data object Valid : ValidationResult
data class Invalid(val reason: String) : ValidationResult
}
object ExpenseRules {
fun validate(title: String, amount: Double): ValidationResult {
return when {
title.trim().length < 3 -> ValidationResult.Invalid("Title must have at least 3 characters")
amount <= 0.0 -> ValidationResult.Invalid("Amount must be greater than zero")
amount > 1_000_000.0 -> ValidationResult.Invalid("Amount is unusually high")
else -> ValidationResult.Valid
}
}
}
class ExpenseRulesTest {
@Test fun rejectsShortTitle() {
val result = ExpenseRules.validate("No", 100.0)
assertTrue(result is ValidationResult.Invalid)
}
@Test fun acceptsValidExpense() {
val result = ExpenseRules.validate("Clinic visit", 800.0)
assertEquals(ValidationResult.Valid, result)
}
}
Jetpack Compose: Layout, Lists, Forms, and Events
Build: an expense entry screen with a form, list, empty state, and stable item keys.
@Composable
fun ExpenseScreen(
expenses: List<Expense>,
title: String,
amount: String,
onTitleChange: (String) -> Unit,
onAmountChange: (String) -> Unit,
onAdd: () -> Unit
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text("Expense Tracker", style = MaterialTheme.typography.headlineMedium)
OutlinedTextField(value = title, onValueChange = onTitleChange, label = { Text("Title") })
OutlinedTextField(value = amount, onValueChange = onAmountChange, label = { Text("Amount") })
Button(onClick = onAdd, enabled = title.length >= 3 && amount.toDoubleOrNull() != null) {
Text("Add expense")
}
if (expenses.isEmpty()) {
Text("No expenses yet.")
} else {
LazyColumn {
items(expenses, key = { it.id }) { expense ->
ExpenseRow(expense)
}
}
}
}
}
@Composable
fun ExpenseRow(expense: Expense) {
Card(modifier = Modifier.fillMaxWidth().padding(top = 8.dp)) {
Column(modifier = Modifier.padding(12.dp)) {
Text(expense.title, fontWeight = FontWeight.Bold)
Text("₹${expense.amount}")
}
}
}
Compose State: remember, State Hoisting, and Recomposition
Build: a local-state version before introducing ViewModel. This teaches what state is before architecture hides it.
@Composable
fun LocalExpenseApp() {
var nextId by remember { mutableStateOf(1L) }
var title by remember { mutableStateOf("") }
var amount by remember { mutableStateOf("") }
var expenses by remember { mutableStateOf(emptyList<Expense>()) }
ExpenseScreen(
expenses = expenses,
title = title,
amount = amount,
onTitleChange = { title = it },
onAmountChange = { amount = it },
onAdd = {
val parsedAmount = amount.toDoubleOrNull() ?: return@ExpenseScreen
expenses = expenses + Expense(nextId++, title, parsedAmount, ExpenseCategory.Other)
title = ""
amount = ""
}
)
}
Coroutines: suspend, scopes, dispatchers, and structured concurrency
Build: a repository that simulates loading from disk or network without blocking the UI.
interface ExpenseRepository {
suspend fun loadExpenses(): List<Expense>
suspend fun saveExpense(expense: Expense)
}
class FakeExpenseRepository : ExpenseRepository {
private val items = mutableListOf<Expense>()
override suspend fun loadExpenses(): List<Expense> {
delay(300)
return items.toList()
}
override suspend fun saveExpense(expense: Expense) {
delay(100)
items += expense
}
}
class ExpenseRepositoryTest {
@Test fun saveThenLoadReturnsExpense() = runTest {
val repository = FakeExpenseRepository()
repository.saveExpense(Expense(1, "Bus ticket", 40.0, ExpenseCategory.Travel))
val result = repository.loadExpenses()
assertEquals("Bus ticket", result.single().title)
}
}
Flow, StateFlow, SharedFlow, and Channels
Build: a reactive store. Use StateFlow for current state. Use SharedFlow for one-off events such as snackbars. Use Channel only when exactly-one-consumer delivery is required.
class ExpenseStore {
private val _expenses = MutableStateFlow<List<Expense>>(emptyList())
val expenses: StateFlow<List<Expense>> = _expenses.asStateFlow()
private val _messages = MutableSharedFlow<String>()
val messages: SharedFlow<String> = _messages.asSharedFlow()
suspend fun add(expense: Expense) {
_expenses.update { current -> current + expense }
_messages.emit("Expense added")
}
}
@Test fun addEmitsUpdatedList() = runTest {
val store = ExpenseStore()
store.add(Expense(1, "Book", 300.0, ExpenseCategory.Education))
assertEquals(1, store.expenses.value.size)
}
LiveData: reading and maintaining existing Android apps
Build: a small LiveData bridge. You need this because many mature apps still expose LiveData from ViewModels or Room DAOs.
class LegacySummaryViewModel : ViewModel() {
private val _total = MutableLiveData(0.0)
val total: LiveData<Double> = _total
fun update(expenses: List<Expense>) {
_total.value = expenses.sumOf { it.amount }
}
}
@Composable
fun TotalSummary(viewModel: LegacySummaryViewModel) {
val total by viewModel.total.observeAsState(0.0)
Text("Total: ₹$total")
}
MVVM: ViewModel, UI State, Repository
Build: production-shaped state ownership. The screen renders `ExpenseUiState`; the ViewModel owns transitions; repository owns data.
data class ExpenseUiState(
val isLoading: Boolean = false,
val title: String = "",
val amount: String = "",
val expenses: List<Expense> = emptyList(),
val error: String? = null
)
class ExpenseViewModel(
private val repository: ExpenseRepository
) : ViewModel() {
private val _uiState = MutableStateFlow(ExpenseUiState())
val uiState: StateFlow<ExpenseUiState> = _uiState.asStateFlow()
fun load() = viewModelScope.launch {
_uiState.update { it.copy(isLoading = true, error = null) }
runCatching { repository.loadExpenses() }
.onSuccess { items -> _uiState.update { it.copy(isLoading = false, expenses = items) } }
.onFailure { error -> _uiState.update { it.copy(isLoading = false, error = error.message) } }
}
}
MVI: Intents, Reducer, Effects
Build: a reducer for screens with many user actions. MVI should make behavior easier to audit, not harder.
sealed interface ExpenseIntent {
data class TitleChanged(val value: String) : ExpenseIntent
data class AmountChanged(val value: String) : ExpenseIntent
data object AddClicked : ExpenseIntent
}
sealed interface ExpenseEffect {
data class ShowMessage(val text: String) : ExpenseEffect
}
fun reduce(state: ExpenseUiState, intent: ExpenseIntent): ExpenseUiState {
return when (intent) {
is ExpenseIntent.TitleChanged -> state.copy(title = intent.value)
is ExpenseIntent.AmountChanged -> state.copy(amount = intent.value)
ExpenseIntent.AddClicked -> state.copy(isLoading = true)
}
}
@Test fun titleIntentUpdatesTitle() {
val result = reduce(ExpenseUiState(), ExpenseIntent.TitleChanged("Clinic"))
assertEquals("Clinic", result.title)
}
Dagger 2: the dependency graph underneath Hilt
Build: a plain Dagger graph. This teaches what Hilt later automates.
@Module
class DataModule {
@Provides
fun provideExpenseRepository(): ExpenseRepository {
return FakeExpenseRepository()
}
}
@Component(modules = [DataModule::class])
interface AppComponent {
fun expenseRepository(): ExpenseRepository
}
Hilt: Android dependency injection
Build: Android-integrated DI for Application, ViewModel, and repository.
@HiltAndroidApp
class ModernAndroidApp : Application()
@Module
@InstallIn(SingletonComponent::class)
object AppModule {
@Provides
fun provideExpenseRepository(): ExpenseRepository = FakeExpenseRepository()
}
@HiltViewModel
class ExpenseViewModel @Inject constructor(
private val repository: ExpenseRepository
) : ViewModel()
Kotlin Multiplatform: shared business rules
Build: a shared validation module. Do not start KMP by sharing UI. Start with deterministic rules that both Android and another platform can reuse.
object SharedExpenseRules {
fun validateAmount(value: Double): ValidationResult {
return when {
value <= 0.0 -> ValidationResult.Invalid("Amount must be positive")
value > 1_000_000.0 -> ValidationResult.Invalid("Amount requires review")
else -> ValidationResult.Valid
}
}
}
TensorFlow / LiteRT: on-device classification
Build: a classifier seam first, then plug in LiteRT. The interface matters because models change.
interface ExpenseClassifier {
suspend fun classify(text: String): ExpenseLabel
}
enum class ExpenseLabel { Medical, Education, Travel, Food, General }
class RuleBasedExpenseClassifier : ExpenseClassifier {
override suspend fun classify(text: String): ExpenseLabel {
val value = text.lowercase()
return when {
"clinic" in value || "doctor" in value -> ExpenseLabel.Medical
"school" in value || "course" in value -> ExpenseLabel.Education
"bus" in value || "train" in value -> ExpenseLabel.Travel
"hotel" in value || "meal" in value -> ExpenseLabel.Food
else -> ExpenseLabel.General
}
}
}
class LiteRtExpenseClassifier(
private val modelPath: String
) : ExpenseClassifier {
override suspend fun classify(text: String): ExpenseLabel {
// 1. Tokenize or vectorize input text.
// 2. Run LiteRT interpreter or generated model API.
// 3. Map model output index to ExpenseLabel.
// Keep this class isolated from ViewModel and Compose.
return ExpenseLabel.General
}
}
Testing the whole stack
Build: a test matrix. A serious Android tutorial must say which concept is tested where.
| Layer | Test | Failure caught |
|---|---|---|
| Kotlin rules | JVM unit test | Invalid amount accepted. |
| Reducer | Pure unit test | Intent creates wrong state. |
| Coroutine repository | `runTest` | Save/load behavior wrong. |
| Flow store | Emission test | Collectors do not receive updates. |
| ViewModel | Fake repository | Loading/error state regression. |
| Compose screen | UI test | Button enabled for invalid form. |
| DI | Fake module or constructor injection | Real dependency leaks into tests. |
| KMP | commonTest | Shared rule differs from Android rule. |
| ML | Fake classifier or model fixture | Wrong label mapping. |
@Test fun addButtonDisabledForInvalidAmount() {
composeTestRule.setContent {
ExpenseScreen(
expenses = emptyList(),
title = "Clinic",
amount = "abc",
onTitleChange = {},
onAmountChange = {},
onAdd = {}
)
}
composeTestRule.onNodeWithText("Add expense").assertIsNotEnabled()
}
CI/CD: automated verification
Build: a GitHub Actions workflow that compiles and runs unit tests. Add emulator UI tests only after the unit gate is stable.
name: Android CI
on:
pull_request:
push:
branches: [ main, master ]
jobs:
verify:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 17
- uses: gradle/actions/setup-gradle@v4
- run: ./gradlew testDebugUnitTest
- run: ./gradlew assembleDebug
Quizzes, matching, and drag-and-drop code blocks
Multi-select: Which statements are correct?
Drag order: Arrange the MVI loop.
Matching: Pick the best tool.