Techbase Academy
Android · Lesson 71

Capstone Project: A Complete Android App

14 phasesPhase 10: Self-Review Rubric and Final DeliveryAndroid Studio
Phase 1 of 14
Lesson Introduction

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").

✏️ Your Task
Practise what you just learned about Lesson Introduction. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 2 of 14
Prerequisite Concepts

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).

✏️ Your Task
Practise what you just learned about Prerequisite Concepts. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 3 of 14
Phase 1: Choose and Plan the App

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

text
[Prices list] --(tap item)--> [Item detail: history + cheapest market]
     |
     +--(FAB)--> [Add price form]

Data model

text
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.

✏️ Your Task
Practise what you just learned about Phase 1: Choose and Plan the App. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 4 of 14
Phase 2: Project Setup and Architecture

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:

text
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).

✏️ Your Task
Practise what you just learned about Phase 2: Project Setup and Architecture. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 5 of 14
Phase 3: The Data Layer

Entity, DAO and database

kotlin
// 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
)
kotlin
// 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)
}
kotlin
// 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

kotlin
// 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

kotlin
// 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)

kotlin
@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.

✏️ Your Task
Practise what you just learned about Phase 3: The Data Layer. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 6 of 14
Phase 4: The Domain Layer

Validation

kotlin
// 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

kotlin
// 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

kotlin
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.

✏️ Your Task
Practise what you just learned about Phase 4: The Domain Layer. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 7 of 14
Phase 5: The UI Layer: List and Detail

Type-safe routes (lesson 55)

kotlin
@Serializable object ListRoute
@Serializable data class DetailRoute(val itemName: String)
@Serializable object AddRoute

List ViewModel and state (lesson 61)

kotlin
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

kotlin
@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):

kotlin
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.)

✏️ Your Task
Practise what you just learned about Phase 5: The UI Layer: List and Detail. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 8 of 14
Phase 6: The Add Form

State and ViewModel

kotlin
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

kotlin
@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

kotlin
@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.

✏️ Your Task
Practise what you just learned about Phase 6: The Add Form. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 9 of 14
Phase 7: Tests, Accessibility and Performance Review

A ViewModel test

kotlin
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

kotlin
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:

  1. TalkBack: every action reachable; announcements are meaningful ("Delete Garri at ₦2,500", not "button").
  2. Touch targets: at least 48dp (the delete icon button is; check custom controls).
  3. Font scale 200%: nothing truncated or overlapping; the list rows grow.
  4. Colour contrast: text and error states meet contrast guidance, in light and dark themes.
  5. Not colour alone: errors show text, not only red.
  6. Focus order and keyboard: Tab moves in reading order; IME action "Next" moves between fields.
  7. 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.

✏️ Your Task
Practise what you just learned about Phase 7: Tests, Accessibility and Performance Review. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 10 of 14
Phase 8: Release Checklist and Portfolio

Release checklist (lessons 49 to 53)

text
[ ] 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:

markdown
# 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.

✏️ Your Task
Practise what you just learned about Phase 8: Release Checklist and Portfolio. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 11 of 14
Explain Your Decisions

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.

✏️ Your Task
Practise what you just learned about Explain Your Decisions. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 12 of 14
Phase 9: Guided Practice Exercises
🎯 Your Challenge

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.

🎯 Your Challenge

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.

🎯 Your Challenge

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.

✏️ Your Task
Practise what you just learned about Phase 9: Guided Practice Exercises. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 13 of 14
Common Mistakes (and How to Fix Them)

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).

✏️ Your Task
Practise what you just learned about Common Mistakes (and How to Fix Them). Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 14 of 14
Knowledge Check

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

✏️ Your Task
Practise what you just learned about Knowledge Check. Open Android Studio, type the examples by hand, change a value, and observe what happens.
🏗️ Build It — Mini Project
Phase 10: Self-Review Rubric and Final Delivery

Project goal: Finish MarketMate (or your chosen original app), review it honestly against a rubric, and deliver it.

Requirements:

  1. Complete Phases 1 to 8 for MarketMate or an original app of similar size.
  2. 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).
  3. At least ten automated tests across domain, ViewModel, repository (with Room in-memory) and one UI test.
  4. Complete the accessibility checklist and record findings and fixes.
  5. Produce a release build, test it on a real device, and store the signed bundle safely.
  6. Write the portfolio README with screenshots.
  7. 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):

text
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:

text
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:

kotlin
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)
 }
}
1

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.

2

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”.

3

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.

4

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.

5

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
All 72 lessons in Google Android Studio ·Techbase Academy ·© 2025–2026 Techbase Consultant Services, Ibadan
Esc
↑ ↓ to moveEnter to openEsc to close