You have now met nearly everything the official Android programmes cover: Kotlin, Compose, state, navigation, data, architecture, testing, accessibility, performance, release and sharing code. The final step is to use it all together in one complete app that you can put in a portfolio, show to an employer, or publish.
This capstone follows the shape of the official programmes' final projects (for example My City and the architecture pathway's offline-first app) but the app itself is original. You will plan it, build it layer by layer, test it, review its accessibility, and prepare it for release. Pointers back to the lessons that taught each skill are given throughout, so you can revisit anything that feels rusty. The official pathways remain the reference: https://developer.android.com/courses.
By the end you will have:
- A plan with user stories, screens and a data model.
- An architecture with UI, domain and data layers, built on Room, a repository, ViewModels, Compose and type-safe Navigation.
- An offline-first feature using the database as the source of truth.
- Unit and UI tests that guard the important behaviour.
- An accessibility review and a release checklist.
- A portfolio README for the project.
Big idea: a capstone is not about the number of features. It is about finishing a small thing well and being able to explain every decision.
Honesty note: the code in this lesson is written for Compose with Material 3, Room 3 (
androidx.room3, lesson 34) and Hilt (lesson 65). I could not compile it in my environment, so type it in, read compiler messages carefully, and check imports against the official setup pages. Version numbers are placeholders ("latest stable").
This lesson draws on all phases. Specifically: composables and state (lessons 15 to 19), lists and navigation (lessons 21 and 26), ViewModel and Flow (lessons 25 and 33), Room (lesson 34), Retrofit if you add a network source (lesson 29), testing (lessons 20 and 45), accessibility (lessons 46 to 48), release (lessons 49 to 53), architecture and Hilt (lessons 60 to 65).
The app: MarketMate
Problem. Traders and households in Ibadan see prices of foodstuff change from market to market and from week to week (garri, rice, tomatoes, beans). People rely on memory or phone calls. MarketMate lets a user record prices they see at markets, browse and search them, see the price history of an item, and find the cheapest recent price. It works offline, because market stalls often have poor signal.
(You may choose a different original app, such as a school fee helper or a church-event planner. Keep the same shape: a list, a detail screen, a form, a database and a few rules.)
User stories
| # | As a... | I want to... | So that... |
|---|---|---|---|
| 1 | shopper | add a price (item, market, amount, date) | I remember what I saw |
| 2 | shopper | see recent prices in a list and search by item | I can find what I need |
| 3 | shopper | open an item and see all its prices by market | I can compare |
| 4 | shopper | see which market is cheapest in the last 30 days | I know where to buy |
| 5 | shopper | delete a mistaken entry | my data stays correct |
| 6 | any user | use the app without internet | I can record in the market |
| 7 | user with a screen reader or large text | use every feature | the app is inclusive |
Screens
[Prices list] --(tap item)--> [Item detail: history + cheapest market]
|
+--(FAB)--> [Add price form]
Data model
PriceEntry
id Long (auto)
itemName String "Garri (white, 1 paint bucket)"
marketName String "Bodija"
amountKobo Long money as whole kobo, never Double
recordedAtMs Long epoch millis
Rules (the domain)
- Amount must be greater than zero.
- Item and market names are trimmed and must not be blank.
- "Cheapest market" = the market with the lowest average price for the item in the last 30 days; ties are broken alphabetically.
Non-functional requirements
Works offline; no network needed in version 1; supports dark mode and font scaling; every control has at least a 48dp touch target; every icon has a content description or is decorative.
Checkpoint (lessons 60 to 63): which parts are UI, domain and data? UI: three screens and ViewModels. Domain: validation and the cheapest-market rule. Data: Room and the repository.
Create a project MarketMate (package com.example.marketmate, minimum SDK 24) using the Empty Activity template (lesson 3). Add dependencies through the version catalog (lessons 3 and 34): Compose BOM, Material 3, Navigation Compose, kotlinx.serialization, Lifecycle (ViewModel Compose and runtime Compose), Room 3 with KSP, Hilt with KSP, and test libraries. Use "latest stable" for each.
Package layout:
com.example.marketmate
├─ MarketMateApp.kt @HiltAndroidApp
├─ MainActivity.kt
├─ data/ Room entity, DAO, database, repository
├─ domain/ models, use cases, validation
├─ ui/
│ ├─ theme/
│ ├─ list/ (ListScreen, ListViewModel)
│ ├─ detail/ (DetailScreen, DetailViewModel)
│ ├─ add/ (AddScreen, AddViewModel)
│ └─ navigation/ (routes, NavHost)
└─ di/ Hilt modules
The dependency rule (lesson 60): ui → domain → data; nothing points upwards. One module is enough for this size (lesson 65 explained when to split).
Entity, DAO and database
// data/PriceEntry.kt
@Entity(tableName = "price_entries")
data class PriceEntryEntity(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val itemName: String,
val marketName: String,
val amountKobo: Long,
val recordedAtMs: Long
)
// data/PriceDao.kt
@Dao
interface PriceDao {
@Query("SELECT * FROM price_entries ORDER BY recordedAtMs DESC")
fun observeAll(): Flow<List<PriceEntryEntity>>
@Query("SELECT * FROM price_entries WHERE itemName = :item ORDER BY recordedAtMs DESC")
fun observeForItem(item: String): Flow<List<PriceEntryEntity>>
@Insert
suspend fun insert(entry: PriceEntryEntity)
@Query("DELETE FROM price_entries WHERE id = :id")
suspend fun delete(id: Long)
}
// data/MarketDatabase.kt
@Database(entities = [PriceEntryEntity::class], version = 1, exportSchema = true)
abstract class MarketDatabase : RoomDatabase() {
abstract fun priceDao(): PriceDao
}
(Imports: androidx.room3.*; for Room 2.x change to androidx.room.* as in lesson 34.)
Domain model and mapper
// domain/PriceEntry.kt
data class PriceEntry(
val id: Long,
val itemName: String,
val marketName: String,
val amountNaira: Double, // for display only; computed from kobo
val amountKobo: Long,
val recordedAt: Instant
)
fun PriceEntryEntity.toModel() = PriceEntry(
id = id,
itemName = itemName,
marketName = marketName,
amountNaira = amountKobo / 100.0,
amountKobo = amountKobo,
recordedAt = Instant.ofEpochMilli(recordedAtMs)
)
Money is stored as whole kobo (lesson 62); the Double here is only for display.
Repository
// data/PriceRepository.kt
interface PriceRepository {
fun observeAll(): Flow<List<PriceEntry>>
fun observeForItem(item: String): Flow<List<PriceEntry>>
suspend fun add(item: String, market: String, amountKobo: Long, recordedAtMs: Long)
suspend fun delete(id: Long)
}
class OfflinePriceRepository @Inject constructor(
private val dao: PriceDao,
@IoDispatcher private val io: CoroutineDispatcher
) : PriceRepository {
override fun observeAll() = dao.observeAll()
.map { list -> list.map { it.toModel() } }
.flowOn(io)
override fun observeForItem(item: String) = dao.observeForItem(item)
.map { list -> list.map { it.toModel() } }
.flowOn(io)
override suspend fun add(item: String, market: String, amountKobo: Long, recordedAtMs: Long) =
withContext(io) {
dao.insert(PriceEntryEntity(itemName = item, marketName = market,
amountKobo = amountKobo, recordedAtMs = recordedAtMs))
}
override suspend fun delete(id: Long) = withContext(io) { dao.delete(id) }
}
Hilt modules (lesson 65)
@Module
@InstallIn(SingletonComponent::class)
object DataModule {
@Provides @Singleton
fun provideDb(@ApplicationContext context: Context): MarketDatabase =
Room.databaseBuilder<MarketDatabase>(context, "marketmate.db")
.setDriver(AndroidSQLiteDriver())
.build()
@Provides fun provideDao(db: MarketDatabase): PriceDao = db.priceDao()
@Provides @IoDispatcher fun provideIo(): CoroutineDispatcher = Dispatchers.IO
}
@Module
@InstallIn(SingletonComponent::class)
abstract class RepositoryModule {
@Binds @Singleton abstract fun bindRepo(impl: OfflinePriceRepository): PriceRepository
}
Checkpoint: run the app (it still shows the template screen). Add a temporary insert in a test to confirm the database works before building any UI. Building on top of unverified layers is how bugs hide.
Validation
// domain/ValidatePrice.kt
sealed interface ValidationResult {
data class Valid(val item: String, val market: String, val amountKobo: Long) : ValidationResult
data class Invalid(val itemError: String? = null, val marketError: String? = null, val amountError: String? = null) : ValidationResult
}
class ValidatePriceUseCase @Inject constructor() {
operator fun invoke(item: String, market: String, amountText: String): ValidationResult {
val cleanItem = item.trim()
val cleanMarket = market.trim()
val naira = amountText.trim().toDoubleOrNull()
val itemError = if (cleanItem.isEmpty()) "Enter the item" else null
val marketError = if (cleanMarket.isEmpty()) "Enter the market" else null
val amountError = when {
naira == null -> "Enter a number"
naira <= 0.0 -> "Amount must be more than zero"
else -> null
}
return if (itemError == null && marketError == null && amountError == null) {
ValidationResult.Valid(cleanItem, cleanMarket, Math.round(naira!! * 100))
} else {
ValidationResult.Invalid(itemError, marketError, amountError)
}
}
}
The cheapest-market rule
// domain/CheapestMarket.kt
data class MarketAverage(val market: String, val averageKobo: Long, val samples: Int)
class FindCheapestMarketUseCase @Inject constructor() {
operator fun invoke(entries: List<PriceEntry>, now: Instant, days: Long = 30): MarketAverage? {
val cutoff = now.minus(days, ChronoUnit.DAYS)
return entries
.filter { it.recordedAt >= cutoff }
.groupBy { it.marketName }
.map { (market, list) ->
MarketAverage(market, list.sumOf { it.amountKobo } / list.size, list.size)
}
.minWithOrNull(compareBy<MarketAverage> { it.averageKobo }.thenBy { it.market })
}
}
now is a parameter, not Instant.now(), so the rule can be tested with fixed dates (lesson 63).
Tests for the rules
class FindCheapestMarketUseCaseTest {
private val now = Instant.parse("2026-10-24T10:00:00Z")
private fun entry(market: String, kobo: Long, daysAgo: Long) = PriceEntry(
id = 0, itemName = "Garri", marketName = market,
amountNaira = kobo / 100.0, amountKobo = kobo, recordedAt = now.minus(daysAgo, ChronoUnit.DAYS)
)
@Test
fun picks_lowest_average_in_window() {
val entries = listOf(
entry("Bodija", 200_000, 2), entry("Bodija", 220_000, 5),
entry("Dugbe", 190_000, 3)
)
assertEquals("Dugbe", FindCheapestMarketUseCase()(entries, now)?.market)
}
@Test
fun ignores_entries_older_than_30_days() {
val entries = listOf(entry("Dugbe", 100_000, 45), entry("Bodija", 200_000, 1))
assertEquals("Bodija", FindCheapestMarketUseCase()(entries, now)?.market)
}
@Test
fun returns_null_when_no_recent_entries() {
assertNull(FindCheapestMarketUseCase()(listOf(entry("Dugbe", 1, 60)), now))
}
}
class ValidatePriceUseCaseTest {
@Test
fun rejects_zero_and_blank() {
val result = ValidatePriceUseCase()(" ", "Bodija", "0") as ValidationResult.Invalid
assertNotNull(result.itemError)
assertNotNull(result.amountError)
}
@Test
fun converts_naira_to_kobo() {
val result = ValidatePriceUseCase()("Garri", "Bodija", "2500") as ValidationResult.Valid
assertEquals(250_000L, result.amountKobo)
}
}
Checkpoint: run
./gradlew test. All rule tests must pass before you write a single composable.
Type-safe routes (lesson 55)
@Serializable object ListRoute
@Serializable data class DetailRoute(val itemName: String)
@Serializable object AddRoute
List ViewModel and state (lesson 61)
data class ListUiState(
val query: String = "",
val entries: List<PriceEntry> = emptyList(),
val loading: Boolean = true
)
@HiltViewModel
class ListViewModel @Inject constructor(
private val repository: PriceRepository,
private val savedState: SavedStateHandle
) : ViewModel() {
private val query = savedState.getStateFlow("query", "")
val uiState: StateFlow<ListUiState> =
combine(repository.observeAll(), query) { entries, q ->
ListUiState(
query = q,
entries = entries.filter { it.itemName.contains(q, ignoreCase = true) },
loading = false
)
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), ListUiState())
fun onQueryChange(value: String) { savedState["query"] = value }
fun delete(id: Long) { viewModelScope.launch { repository.delete(id) } }
}
The list screen
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ListScreen(
onOpenItem: (String) -> Unit,
onAdd: () -> Unit,
viewModel: ListViewModel = hiltViewModel()
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
Scaffold(
topBar = { TopAppBar(title = { Text("MarketMate") }) },
floatingActionButton = {
FloatingActionButton(onClick = onAdd) {
Icon(Icons.Default.Add, contentDescription = "Add a price")
}
}
) { padding ->
Column(Modifier.padding(padding)) {
OutlinedTextField(
value = state.query,
onValueChange = viewModel::onQueryChange,
label = { Text("Search items") },
singleLine = true,
modifier = Modifier.fillMaxWidth().padding(16.dp)
)
when {
state.loading -> Box(Modifier.fillMaxSize(), Alignment.Center) { CircularProgressIndicator() }
state.entries.isEmpty() -> EmptyState("No prices yet. Tap + to add the first one.")
else -> LazyColumn(contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp)) {
items(state.entries, key = { it.id }) { entry ->
PriceRow(
entry = entry,
onClick = { onOpenItem(entry.itemName) },
onDelete = { viewModel.delete(entry.id) }
)
}
}
}
}
}
}
@Composable
fun PriceRow(entry: PriceEntry, onClick: () -> Unit, onDelete: () -> Unit) {
val price = formatNaira(entry.amountKobo)
ListItem(
headlineContent = { Text(entry.itemName) },
supportingContent = { Text("${entry.marketName} · ${formatDate(entry.recordedAt)}") },
trailingContent = {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(price, style = MaterialTheme.typography.titleMedium)
IconButton(onClick = onDelete) {
Icon(Icons.Default.Delete, contentDescription = "Delete ${entry.itemName} at $price")
}
}
},
modifier = Modifier.clickable(onClick = onClick, onClickLabel = "Open ${entry.itemName}")
)
}
fun formatNaira(kobo: Long): String =
NumberFormat.getCurrencyInstance(Locale("en", "NG")).format(kobo / 100.0)
fun formatDate(instant: Instant): String =
DateTimeFormatter.ofPattern("d MMM yyyy").withZone(ZoneId.systemDefault()).format(instant)
@Composable
fun EmptyState(message: String) {
Box(Modifier.fillMaxSize().padding(32.dp), Alignment.Center) {
Text(message, style = MaterialTheme.typography.bodyLarge, textAlign = TextAlign.Center)
}
}
Detail ViewModel and screen
The detail screen reads the item name from the route (via SavedStateHandle, lesson 55):
data class DetailUiState(
val itemName: String = "",
val entries: List<PriceEntry> = emptyList(),
val cheapest: MarketAverage? = null
)
@HiltViewModel
class DetailViewModel @Inject constructor(
savedState: SavedStateHandle,
repository: PriceRepository,
findCheapest: FindCheapestMarketUseCase
) : ViewModel() {
private val itemName = savedState.toRoute<DetailRoute>().itemName
val uiState: StateFlow<DetailUiState> = repository.observeForItem(itemName)
.map { entries -> DetailUiState(itemName, entries, findCheapest(entries, Instant.now())) }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), DetailUiState(itemName))
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DetailScreen(onBack: () -> Unit, viewModel: DetailViewModel = hiltViewModel()) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
Scaffold(
topBar = {
TopAppBar(
title = { Text(state.itemName) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
}
)
}
) { padding ->
LazyColumn(Modifier.padding(padding), contentPadding = PaddingValues(16.dp)) {
item {
state.cheapest?.let {
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(16.dp)) {
Text("Cheapest in the last 30 days", style = MaterialTheme.typography.labelLarge)
Text("${it.market}: ${formatNaira(it.averageKobo)} (${it.samples} entries)",
style = MaterialTheme.typography.titleMedium)
}
}
} ?: Text("Not enough recent prices to compare markets.")
Spacer(Modifier.height(16.dp))
Text("History", style = MaterialTheme.typography.titleLarge)
}
items(state.entries, key = { it.id }) { entry ->
ListItem(
headlineContent = { Text(formatNaira(entry.amountKobo)) },
supportingContent = { Text("${entry.marketName} · ${formatDate(entry.recordedAt)}") }
)
}
}
}
}
(savedState.toRoute<DetailRoute>() comes from Navigation's type-safe API; check the import against the current Navigation release.)
State and ViewModel
data class AddUiState(
val item: String = "",
val market: String = "",
val amount: String = "",
val errors: ValidationResult.Invalid? = null,
val saved: Boolean = false
)
@HiltViewModel
class AddViewModel @Inject constructor(
private val repository: PriceRepository,
private val validate: ValidatePriceUseCase
) : ViewModel() {
private val _state = MutableStateFlow(AddUiState())
val state: StateFlow<AddUiState> = _state.asStateFlow()
fun onItem(v: String) = _state.update { it.copy(item = v, errors = null) }
fun onMarket(v: String) = _state.update { it.copy(market = v, errors = null) }
fun onAmount(v: String) = _state.update { it.copy(amount = v, errors = null) }
fun save() {
val s = _state.value
when (val result = validate(s.item, s.market, s.amount)) {
is ValidationResult.Invalid -> _state.update { it.copy(errors = result) }
is ValidationResult.Valid -> viewModelScope.launch {
repository.add(result.item, result.market, result.amountKobo, System.currentTimeMillis())
_state.update { it.copy(saved = true) }
}
}
}
}
The form screen
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AddScreen(onDone: () -> Unit, viewModel: AddViewModel = hiltViewModel()) {
val state by viewModel.state.collectAsStateWithLifecycle()
LaunchedEffect(state.saved) { if (state.saved) onDone() }
Scaffold(topBar = {
TopAppBar(
title = { Text("Add a price") },
navigationIcon = { IconButton(onClick = onDone) { Icon(Icons.AutoMirrored.Filled.ArrowBack, "Cancel") } }
)
}) { padding ->
Column(
Modifier.padding(padding).padding(16.dp).verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
FormField("Item (for example, Garri, 1 paint bucket)", state.item, viewModel::onItem, state.errors?.itemError)
FormField("Market", state.market, viewModel::onMarket, state.errors?.marketError)
FormField(
"Price in naira", state.amount, viewModel::onAmount, state.errors?.amountError,
keyboardType = KeyboardType.Decimal
)
Button(onClick = viewModel::save, modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp)) {
Text("Save price")
}
}
}
}
@Composable
fun FormField(
label: String, value: String, onChange: (String) -> Unit, error: String?,
keyboardType: KeyboardType = KeyboardType.Text
) {
OutlinedTextField(
value = value,
onValueChange = onChange,
label = { Text(label) },
isError = error != null,
supportingText = { error?.let { Text(it) } },
keyboardOptions = KeyboardOptions(keyboardType = keyboardType),
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
}
Navigation host and activity
@Composable
fun MarketMateNavHost() {
val navController = rememberNavController()
NavHost(navController, startDestination = ListRoute) {
composable<ListRoute> {
ListScreen(
onOpenItem = { navController.navigate(DetailRoute(it)) },
onAdd = { navController.navigate(AddRoute) }
)
}
composable<DetailRoute> { DetailScreen(onBack = { navController.popBackStack() }) }
composable<AddRoute> { AddScreen(onDone = { navController.popBackStack() }) }
}
}
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent { MarketMateTheme { MarketMateNavHost() } }
}
}
Checkpoint: run the app. Add three prices for the same item at two markets. Confirm the list, search, detail history and cheapest market. Rotate the phone and kill the app; the data must remain.
A ViewModel test
class AddViewModelTest {
@get:Rule val mainRule = MainDispatcherRule()
@Test
fun invalid_input_sets_errors_and_does_not_save() = runTest {
val repo = FakePriceRepository()
val vm = AddViewModel(repo, ValidatePriceUseCase())
vm.save()
assertNotNull(vm.state.value.errors)
assertTrue(repo.added.isEmpty())
}
@Test
fun valid_input_saves_and_marks_saved() = runTest {
val repo = FakePriceRepository()
val vm = AddViewModel(repo, ValidatePriceUseCase())
vm.onItem("Garri"); vm.onMarket("Bodija"); vm.onAmount("2500")
vm.save()
advanceUntilIdle()
assertEquals(1, repo.added.size)
assertTrue(vm.state.value.saved)
}
}
class FakePriceRepository : PriceRepository {
val added = mutableListOf<Triple<String, String, Long>>()
private val all = MutableStateFlow<List<PriceEntry>>(emptyList())
override fun observeAll() = all
override fun observeForItem(item: String) = all.map { list -> list.filter { it.itemName == item } }
override suspend fun add(item: String, market: String, amountKobo: Long, recordedAtMs: Long) {
added += Triple(item, market, amountKobo)
}
override suspend fun delete(id: Long) {}
}
A Compose UI test
class AddScreenTest {
@get:Rule val rule = createComposeRule()
@Test
fun shows_error_when_amount_missing() {
rule.setContent { MarketMateTheme { AddScreenPreviewHost() } } // hosts AddScreen with a fake ViewModel
rule.onNodeWithText("Save price").performClick()
rule.onNodeWithText("Enter a number").assertExists()
}
}
Accessibility review (lessons 46 to 48)
Run through this checklist and fix everything you find:
- TalkBack: every action reachable; announcements are meaningful ("Delete Garri at ₦2,500", not "button").
- Touch targets: at least 48dp (the delete icon button is; check custom controls).
- Font scale 200%: nothing truncated or overlapping; the list rows grow.
- Colour contrast: text and error states meet contrast guidance, in light and dark themes.
- Not colour alone: errors show text, not only red.
- Focus order and keyboard: Tab moves in reading order; IME action "Next" moves between fields.
- Accessibility Scanner and Compose accessibility checks in tests (lesson 48): zero warnings, or each one explained.
Performance review (lesson 57)
List uses key = { it.id }; no heavy work in composables (formatting is cheap; if lists grow, remember formatted strings or format in the ViewModel); build a release variant and scroll a 1,000-row list on a low-end device or emulator; add a Baseline Profile if start-up matters.
Release checklist (lessons 49 to 53)
[ ] applicationId and versionName/versionCode set; package name is yours
[ ] Release build: minify on (R8), shrink resources; test the release build, not just debug
[ ] Keep rules checked (Room, Hilt, kotlinx.serialization) - app does not crash on release
[ ] Signing: upload key created; Play App Signing enabled; keystore backed up safely
[ ] Target API meets the current Google Play requirement (check Play Console help)
[ ] App icon (adaptive) and screenshots (phone, and tablet if supported)
[ ] Store listing: title, short and full description, privacy policy URL
[ ] Data safety form: this version collects no data and has no network
[ ] Content rating questionnaire completed
[ ] Internal test track install on a real device; then closed test if your account type requires one
[ ] Crash-free and ANR-free in testing; accessibility review done
The portfolio README
A good README is part of the product:
# MarketMate
Record and compare foodstuff prices across Ibadan markets. Works offline.
## Features
- Add, search and delete price entries
- Item history and "cheapest market in the last 30 days"
- Works with no internet; data stored on the device
## Tech
Kotlin, Jetpack Compose (Material 3), Navigation (type-safe routes), Room, Hilt, Coroutines/Flow
## Architecture
UI (Compose + ViewModel) -> Domain (use cases) -> Data (Room repository). Unidirectional data flow.
[diagram]
## Testing
Unit tests for domain rules and ViewModels; Compose UI tests; accessibility review notes in /docs.
## What I learned / next steps
...Add two or three screenshots and, if you published it, the Play Store link.
A capstone is only valuable if you can defend it. Before you call the project finished, imagine an interviewer or a teammate reading your repository for the first time, and prepare short, honest answers to the questions below. Writing them down also becomes the "Architecture" section of your README.
Why is the database the source of truth? Because the app must work with no signal in the market. The UI reads only from Room through a Flow, so a new entry appears instantly, and there is no second copy of the data that can disagree with the first (lesson 60).
Why does money use whole kobo? Floating-point numbers cannot represent many decimal amounts exactly, so repeated arithmetic drifts. Whole units are exact. The Double in the domain model exists only for display, and is derived, never stored.
Why are there use cases for such small rules? Validation and the cheapest-market rule are the parts most likely to change and most worth testing. Moving them out of the ViewModel means they are plain Kotlin that runs in milliseconds, and the ViewModel stays short (lesson 63). If a rule is trivial, you may decide a use case is overkill; the skill is making that call deliberately.
Why SavedStateHandle for the search query? The ViewModel survives rotation but not process death. The query is small and cheap to save, so it survives both, while the big data is reloaded from the database (lesson 61).
Why type-safe routes? A route is a Kotlin type, so the compiler checks the arguments, and a typo in a string cannot send the app to a missing screen (lesson 55).
What would you do differently with a server? Keep the database as the source of truth, add a remote data source, and a sync worker with WorkManager, as in lesson 64. The UI and domain layers would not change, which is the evidence that the layering worked.
What are the limits of this app? There is no cloud backup, no user accounts, no price validation against outliers, and no unit conversion between "paint bucket" and "kilogram". Naming limits honestly shows maturity. Choose one limit and write how you would address it.
If you cannot answer one of these questions, return to the lesson named in brackets, change the code or your understanding, and then continue.
Exercise 1: Add an edit feature
Allow editing an existing entry's amount. List the files you must change in each layer, and the new tests you would write.
Data: add @Update or a query to the DAO and an update method on the repository. Domain: reuse ValidatePriceUseCase. UI: an edit route or reuse the Add screen with an optional id; the ViewModel loads the entry into state. Tests: repository fake supports update; ViewModel test for loading and saving an edit; rule tests already cover validation; a UI test that editing changes the displayed amount.
Exercise 2: Handle a new rule
The shop owner says prices from more than 90 days ago must show a "may be out of date" label. Where does the rule go and how do you test it?
The rule ("is stale") is business logic: put it in the domain layer as a small function or use case taking now as a parameter, for example fun PriceEntry.isStale(now: Instant) = recordedAt < now.minus(90, ChronoUnit.DAYS). The composable only shows the label if isStale is true. Test with fixed dates on both sides of the 90-day boundary.
Exercise 3: Prepare for a tablet
Apply lesson 58 to the app. Describe what you would change.
Use a window size class: on compact, keep the current list-to-detail navigation; on medium and expanded, show list and detail side by side (list-detail scaffold or a Row), and use a navigation rail if more top-level destinations appear. Keep selection state in the ViewModel (selectedItem) so unfolding or rotating preserves it, and cap form width with widthIn.
Mistake #1: Starting with the UI before the data and rules are tested. Fix: build and test bottom-up, as in Phases 3 and 4.
Mistake #2: A bigger scope than you can finish. Fix: cut features, not quality.
Mistake #3: Money in Double. Fix: whole kobo, with Double only for display.
Mistake #4: Calling Instant.now() inside rules. Fix: pass now in.
Mistake #5: Logic in composables. Fix: move it to use cases and ViewModels.
Mistake #6: Testing only the happy path. Fix: test blank, zero, boundaries.
Mistake #7: Leaving accessibility to the end. Fix: review each screen as you finish it.
Mistake #8: Testing only debug builds. Fix: run the release build before publishing.
Mistake #9: No README or screenshots. Fix: treat presentation as part of the work.
Mistake #10: Copying code you do not understand. Fix: explain each layer in your own words (the "Explain Your Decisions" phase).
Question 1. Why is now passed as a parameter to the cheapest-market use case?
So the rule is deterministic and can be tested with fixed dates.
Question 2. Where does validation belong?
In the domain layer as a use case, with the ViewModel showing the resulting errors.
Question 3. Why does the list screen read from Room via Flow rather than refresh manually?
Room re-emits when the table changes, so the UI updates automatically from a single source of truth.
Question 4. Name two accessibility checks for the form screen.
Errors shown as text (not colour alone), labelled fields, 48dp touch targets, TalkBack announcements, and correct behaviour at large font sizes.
Question 5. What should you test before publishing?
The minified release build on a real device, including database operations and navigation.
Go deeper: official resources
- Android courses and pathways
- Guide to app architecture
- Publish your app (Play Console)
- Now in Android sample
Project goal: Finish MarketMate (or your chosen original app), review it honestly against a rubric, and deliver it.
Requirements:
- Complete Phases 1 to 8 for MarketMate or an original app of similar size.
- Add one feature of your own (for example, edit, export to CSV with the share sheet, a per-market filter, or a weekly price chart using Canvas from lesson 56).
- At least ten automated tests across domain, ViewModel, repository (with Room in-memory) and one UI test.
- Complete the accessibility checklist and record findings and fixes.
- Produce a release build, test it on a real device, and store the signed bundle safely.
- Write the portfolio README with screenshots.
- Fill in the rubric below honestly and write a half-page reflection.
Stretch goal: make the app adaptive (lesson 58), add a Baseline Profile (lesson 57), or move the domain and data layers into a Kotlin Multiplatform module (lessons 66 to 70).
The self-review rubric (score each 0 to 3: 0 = missing, 1 = partly, 2 = good, 3 = excellent):
Area What a "3" looks like
Planning User stories, screens, data model, rules written before coding
Architecture Clear UI/domain/data layers; dependencies point one way
State and UI Immutable UI state; state hoisting; no logic in composables
Data Room as source of truth; money in kobo; repository interface
Rules and tests Boundary tests; fakes not mocks; tests run in seconds
Navigation Type-safe routes; back behaviour correct; state survives rotation
Accessibility TalkBack, 200% font, contrast, 48dp targets all checked and fixed
Performance Keys on lists; release build tested; no obvious jank
Release readiness Signed release build tested; listing and data safety answers drafted
Presentation README, screenshots, honest "what I learned"
Total: __ / 30 (24+ = portfolio ready; 18-23 = strong, polish; below 18 = revisit the lessons)
A sample reflection:
What went well: building bottom-up made the UI easy; the pure rules caught two boundary bugs early.
What I would change: I put date formatting in the composable; I would move it to a small formatter and test it.
What was hardest: type-safe navigation arguments and the SavedStateHandle for the detail screen.
Next: add editing, then an adaptive layout for tablets, then publish to an internal test track.
An in-memory repository test completes the data coverage:
class OfflinePriceRepositoryTest {
private lateinit var db: MarketDatabase
@Before fun setUp() {
db = Room.inMemoryDatabaseBuilder<MarketDatabase>(ApplicationProvider.getApplicationContext())
.setDriver(AndroidSQLiteDriver())
.build()
}
@After fun tearDown() = db.close()
@Test
fun add_then_observe_returns_the_entry() = runTest {
val repo = OfflinePriceRepository(db.priceDao(), StandardTestDispatcher(testScheduler))
repo.add("Garri", "Bodija", 250_000, 1_000)
val all = repo.observeAll().first()
assertEquals("Garri", all.single().itemName)
assertEquals(250_000, all.single().amountKobo)
}
}
Open your finished project in Android Studio. Check that the .gitignore file the wizard created is in the project root. It keeps local.properties, build folders and keystores out of Git. Never commit passwords or signing keys.
Choose Git > Create Git Repository… (or VCS > Enable Version Control Integration in older versions), select the project folder, then open Git > Commit, tick every file, and write the message “Add Android lesson 71 project”.
On github.com click New repository, name it android-lesson-71-capstone-project-a-complete-android-app, set it to Public, leave Add a README unticked, and click Create repository.
Back in Android Studio choose Git > Manage Remotes…, add the repository URL as origin, then choose Git > Push… and push your main branch. Prefer the terminal? Run git remote add origin <your-url> then git push -u origin main.
Add a README.md with a screenshot of your app running and a short description. Employers and clients read READMEs first. Your project is now part of your public portfolio. 🎉
Lesson 71 complete! 🎉
You covered:
- Lesson Introduction
- Prerequisite Concepts
- Phase 1: Choose and Plan the App
- Phase 2: Project Setup and Architecture
- Phase 3: The Data Layer
- Phase 4: The Domain Layer
- Phase 5: The UI Layer: List and Detail
- Phase 6: The Add Form
- Phase 7: Tests, Accessibility and Performance Review
- Phase 8: Release Checklist and Portfolio
- Explain Your Decisions
- Phase 9: Guided Practice Exercises
- Common Mistakes (and How to Fix Them)
- Knowledge Check