Milestone 1
Kotlin model plus Compose note list and form.
This tutorial ignores XML layout paths and teaches Kotlin, Jetpack Compose, Coroutines, Flow, LiveData, MVVM, MVI, Dagger 2, Hilt, KMP, on-device ML with LiteRT/TensorFlow, testing, and CI/CD through one capstone app: Field Notes.
Field Notes is a field-worker note app. A user creates notes, labels them, syncs them through a repository boundary, observes state reactively, validates business rules from shared KMP code, classifies note content on-device, and protects the workflow with tests and CI.
Kotlin model plus Compose note list and form.
MVVM/MVI, coroutines, Flow, DI, and fake ML seam.
KMP validation, LiteRT integration point, tests, and CI/CD.
| Feature added to Field Notes | Concept learned | Proof |
|---|---|---|
| Create note model | Kotlin data classes, sealed interfaces | Pure validation function and unit test. |
| Render note list | Jetpack Compose, state, LazyColumn | Form, add button, empty state, stable row keys. |
| Load/save notes asynchronously | Coroutines | No blocking UI while repository simulates latency. |
| Observe note updates | Flow and StateFlow | Adding a note updates all collectors. |
| Support existing architecture | LiveData bridge | Legacy LiveData state can still render in Compose. |
| Own screen behavior | MVVM and MVI | State, intents, reducer, and ViewModel are explicit. |
| Inject dependencies | Dagger 2 concepts and Hilt implementation | ViewModel receives repository/classifier instead of creating them. |
| Share rules | KMP | Validation lives in common code and is tested outside Android. |
| Auto-label notes | TensorFlow/LiteRT seam | Fake classifier first, real model later behind same interface. |
| Protect delivery | Testing and CI/CD | Unit, coroutine, ViewModel, Compose UI tests run in GitHub Actions. |
Create a new Android Studio project using Empty Activity, Kotlin, and Jetpack Compose. Name it FieldNotes. Run the default app once before adding architecture, networking, DI, KMP, or ML.
Start with the smallest data model. This teaches immutable values, data classes, sealed interfaces, and pure functions.
data class FieldNote(
val id: Long,
val title: String,
val body: String,
val status: NoteStatus = NoteStatus.Open
)
sealed interface NoteStatus {
data object Open : NoteStatus
data object Done : NoteStatus
data class Blocked(val reason: String) : NoteStatus
}
fun FieldNote.isValid(): Boolean {
return title.trim().length >= 3 && body.trim().isNotEmpty()
}
Compose is declarative: state goes in, UI comes out. Build a list screen before adding architecture.
@Composable
fun NotesScreen(
notes: List<FieldNote>,
title: String,
onTitleChange: (String) -> Unit,
onAdd: () -> Unit
) {
Column(modifier = Modifier.padding(16.dp)) {
OutlinedTextField(
value = title,
onValueChange = onTitleChange,
label = { Text("Note title") }
)
Button(enabled = title.trim().length >= 3, onClick = onAdd) {
Text("Add note")
}
LazyColumn {
items(notes, key = { it.id }) { note ->
Text(note.title)
}
}
}
}
Use suspending functions for work that may take time. Keep blocking work away from the main thread.
interface NotesRepository {
suspend fun loadNotes(): List<FieldNote>
suspend fun saveNote(note: FieldNote)
}
class FakeNotesRepository : NotesRepository {
private val notes = mutableListOf<FieldNote>()
override suspend fun loadNotes(): List<FieldNote> {
delay(300)
return notes.toList()
}
override suspend fun saveNote(note: FieldNote) {
delay(100)
notes += note
}
}
Use Flow for streams of values and StateFlow for current UI state. A ViewModel can collect repository changes and expose a stable state object.
class ReactiveNotesRepository {
private val notes = MutableStateFlow<List<FieldNote>>(emptyList())
fun observeNotes(): StateFlow<List<FieldNote>> = notes.asStateFlow()
suspend fun add(note: FieldNote) {
notes.update { current -> current + note }
}
}
class NotesViewModel(
private val repository: ReactiveNotesRepository
) : ViewModel() {
val notes: StateFlow<List<FieldNote>> = repository.observeNotes()
}
Modern Compose apps usually prefer StateFlow, but LiveData still appears in many Android codebases. Learn it so you can work with existing apps.
class LegacyNotesViewModel : ViewModel() {
private val _title = MutableLiveData("")
val title: LiveData<String> = _title
fun updateTitle(value: String) {
_title.value = value
}
}
@Composable
fun LegacyTitle(viewModel: LegacyNotesViewModel) {
val title by viewModel.title.observeAsState("")
Text("Current title: $title")
}
MVVM keeps UI rendering separate from data decisions. The screen emits events; the ViewModel changes state.
data class NotesUiState(
val title: String = "",
val notes: List<FieldNote> = emptyList(),
val isLoading: Boolean = false,
val error: String? = null
)
class NotesViewModel(
private val repository: ReactiveNotesRepository
) : ViewModel() {
private val _uiState = MutableStateFlow(NotesUiState())
val uiState = _uiState.asStateFlow()
fun onTitleChange(value: String) {
_uiState.update { it.copy(title = value) }
}
}
MVI is useful when screen behavior becomes complex. Define user intents, reduce them into state, and keep side effects explicit.
sealed interface NotesIntent {
data class TitleChanged(val value: String) : NotesIntent
data object AddClicked : NotesIntent
data class NoteClicked(val id: Long) : NotesIntent
}
fun reduce(state: NotesUiState, intent: NotesIntent): NotesUiState {
return when (intent) {
is NotesIntent.TitleChanged -> state.copy(title = intent.value)
NotesIntent.AddClicked -> state.copy(isLoading = true)
is NotesIntent.NoteClicked -> state
}
}
Dagger 2 teaches the underlying dependency graph. Hilt is the recommended Android integration for most apps because it reduces manual component wiring.
// Dagger-style concept
@Module
class RepositoryModule {
@Provides
fun provideRepository(): NotesRepository {
return FakeNotesRepository()
}
}
@Component(modules = [RepositoryModule::class])
interface AppComponent {
fun repository(): NotesRepository
}
// Hilt-style Android implementation
@HiltAndroidApp
class FieldNotesApp : Application()
@Module
@InstallIn(SingletonComponent::class)
object AppModule {
@Provides
fun provideRepository(): NotesRepository = FakeNotesRepository()
}
@HiltViewModel
class NotesViewModel @Inject constructor(
private val repository: NotesRepository
) : ViewModel()
Test the behavior that can break product flows. Do not test every line mechanically.
@Test
fun titleChangeUpdatesUiState() = runTest {
val viewModel = NotesViewModel(FakeNotesRepository())
viewModel.onTitleChange("Visit clinic")
assertEquals("Visit clinic", viewModel.uiState.value.title)
}
@Test
fun invalidShortTitleIsRejected() {
val note = FieldNote(1, "No", "Body")
assertFalse(note.isValid())
}
@Test
fun addButtonIsDisabledForShortTitle() {
composeTestRule.setContent {
NotesScreen(notes = emptyList(), title = "No", onTitleChange = {}, onAdd = {})
}
composeTestRule.onNodeWithText("Add note").assertIsNotEnabled()
}
Kotlin Multiplatform is strongest when shared code is stable business logic. Do not start by sharing Compose UI unless the product and team are ready for that complexity.
// shared/src/commonMain/kotlin/NoteRules.kt
object NoteRules {
fun validateTitle(value: String): ValidationResult {
return when {
value.trim().isEmpty() -> ValidationResult.Invalid("Title is required")
value.trim().length < 3 -> ValidationResult.Invalid("Use at least 3 characters")
else -> ValidationResult.Valid
}
}
}
sealed interface ValidationResult {
data object Valid : ValidationResult
data class Invalid(val reason: String) : ValidationResult
}
For Android apps, TensorFlow Lite is now presented through Google AI Edge LiteRT docs. Keep ML behind an interface so the rest of the app does not depend on model runtime details.
interface NoteClassifier {
suspend fun classify(text: String): NoteLabel
}
enum class NoteLabel { Health, Education, Finance, General }
class FakeNoteClassifier : NoteClassifier {
override suspend fun classify(text: String): NoteLabel {
return when {
"clinic" in text.lowercase() -> NoteLabel.Health
"school" in text.lowercase() -> NoteLabel.Education
"tax" in text.lowercase() -> NoteLabel.Finance
else -> NoteLabel.General
}
}
}
A basic CI pipeline should compile, run unit tests, and upload reports. Release signing and Play deployment come later after the quality gate is stable.
name: Android CI
on:
pull_request:
push:
branches: [ main, master ]
jobs:
test:
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
Multi-select: Which choices are correct for this modern Android path?
Drag order: Arrange the MVVM data flow.
Matching: Pick the right tool for the job.