Build a junior-level business catalogue app.
This page gives a complete first version of the Phase 2 app. It includes navigation, list/detail screens, search, filters, favourites, and device actions without forcing beginners to debug Room or Retrofit before they understand the app flow.
Add the one required dependency
Open app/build.gradle.kts. Add Navigation Compose if your project does not already have it. Use the latest stable version shown by Android Studio if it suggests a newer one.
dependencies {
implementation("androidx.navigation:navigation-compose:2.9.8")
}
Sync Gradle after adding the dependency. If sync fails, fix that before editing Kotlin files.
Use this beginner file structure
Keep the first version in one Kotlin file. After it works, split it into files as a refactor exercise.
Paste this complete MainActivity.kt
Replace the package line with your package name. This is intentionally compact, but it is complete enough to run.
package com.example.catalogue
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.FilterChip
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
MaterialTheme {
Surface(modifier = Modifier.fillMaxSize()) {
CatalogueApp()
}
}
}
}
}
data class Business(
val id: Int,
val name: String,
val category: String,
val city: String,
val phone: String,
val address: String,
val description: String
)
val sampleBusinesses = listOf(
Business(1, "Asha Clinic", "Clinic", "Shivamogga", "9876543210", "MG Road, Shivamogga", "General consultation and family care."),
Business(2, "Future Steps School", "School", "Davanagere", "9876500001", "PB Road, Davanagere", "Primary school with activity-based learning."),
Business(3, "RankPlus Coaching", "Coaching", "Mysuru", "9876500002", "VV Mohalla, Mysuru", "PUC, CET, and NEET coaching batches."),
Business(4, "Prakash & Co Tax Services", "Services", "Hubballi", "9876500003", "Vidyanagar, Hubballi", "GST, accounting, and small business tax filing."),
Business(5, "Care Dental Studio", "Clinic", "Mangaluru", "9876500004", "Kadri, Mangaluru", "Dental consultation, cleaning, and cosmetic dentistry.")
)
@Composable
fun CatalogueApp() {
val navController = rememberNavController()
var favourites by remember { mutableStateOf(setOf<Int>()) }
NavHost(navController = navController, startDestination = "list") {
composable("list") {
BusinessListScreen(
businesses = sampleBusinesses,
favourites = favourites,
onOpen = { id -> navController.navigate("detail/$id") },
onOpenFavourites = { navController.navigate("favourites") },
onOpenSettings = { navController.navigate("settings") },
onToggleFavourite = { id ->
favourites = if (id in favourites) favourites - id else favourites + id
}
)
}
composable(
route = "detail/{businessId}",
arguments = listOf(navArgument("businessId") { type = NavType.IntType })
) { entry ->
val id = entry.arguments?.getInt("businessId")
val business = sampleBusinesses.firstOrNull { it.id == id }
if (business == null) {
ErrorScreen(message = "Business not found.")
} else {
BusinessDetailScreen(
business = business,
isFavourite = business.id in favourites,
onBack = { navController.popBackStack() },
onToggleFavourite = {
favourites = if (business.id in favourites) {
favourites - business.id
} else {
favourites + business.id
}
}
)
}
}
composable("favourites") {
val favouriteBusinesses = sampleBusinesses.filter { it.id in favourites }
SimpleListScreen(
title = "Favourites",
businesses = favouriteBusinesses,
emptyMessage = "No favourites yet.",
onOpen = { id -> navController.navigate("detail/$id") },
onBack = { navController.popBackStack() }
)
}
composable("settings") {
SettingsScreen(onBack = { navController.popBackStack() })
}
}
}
@Composable
fun BusinessListScreen(
businesses: List<Business>,
favourites: Set<Int>,
onOpen: (Int) -> Unit,
onOpenFavourites: () -> Unit,
onOpenSettings: () -> Unit,
onToggleFavourite: (Int) -> Unit
) {
var query by remember { mutableStateOf("") }
var category by remember { mutableStateOf<String?>(null) }
val categories = listOf("Clinic", "School", "Coaching", "Services")
val visibleBusinesses = filterBusinesses(businesses, query, category)
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text("Karnataka Business Catalogue", style = MaterialTheme.typography.headlineMedium)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(onClick = onOpenFavourites) { Text("Favourites") }
Button(onClick = onOpenSettings) { Text("Settings") }
}
OutlinedTextField(
modifier = Modifier.fillMaxWidth(),
value = query,
onValueChange = { query = it },
label = { Text("Search by name or city") },
singleLine = true
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
categories.forEach { item ->
FilterChip(
selected = category == item,
onClick = { category = if (category == item) null else item },
label = { Text(item) }
)
}
}
if (visibleBusinesses.isEmpty()) {
Text("No businesses match this search. Try clearing filters.")
} else {
LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) {
items(visibleBusinesses, key = { it.id }) { business ->
BusinessRow(
business = business,
isFavourite = business.id in favourites,
onOpen = { onOpen(business.id) },
onToggleFavourite = { onToggleFavourite(business.id) }
)
}
}
}
}
}
fun filterBusinesses(
businesses: List<Business>,
query: String,
category: String?
): List<Business> {
val cleanQuery = query.trim().lowercase()
return businesses.filter { business ->
val matchesQuery = cleanQuery.isBlank() ||
business.name.lowercase().contains(cleanQuery) ||
business.city.lowercase().contains(cleanQuery)
val matchesCategory = category == null || business.category == category
matchesQuery && matchesCategory
}
}
@Composable
fun BusinessRow(
business: Business,
isFavourite: Boolean,
onOpen: () -> Unit,
onToggleFavourite: () -> Unit
) {
Card(modifier = Modifier.fillMaxWidth().clickable(onClick = onOpen)) {
Column(modifier = Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(business.name, fontWeight = FontWeight.Bold)
Text("${business.category} • ${business.city}")
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(onClick = onOpen) { Text("Open") }
Button(onClick = onToggleFavourite) {
Text(if (isFavourite) "Saved" else "Save")
}
}
}
}
}
@Composable
fun BusinessDetailScreen(
business: Business,
isFavourite: Boolean,
onBack: () -> Unit,
onToggleFavourite: () -> Unit
) {
val context = LocalContext.current
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Button(onClick = onBack) { Text("Back") }
Text(business.name, style = MaterialTheme.typography.headlineMedium)
Text("${business.category} in ${business.city}")
Text(business.description)
Text(business.address)
Text("Phone: ${business.phone}")
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(onClick = onToggleFavourite) { Text(if (isFavourite) "Remove saved" else "Save") }
Button(onClick = { openDialer(context, business.phone) }) { Text("Call") }
Button(onClick = { openMap(context, business.address) }) { Text("Map") }
Button(onClick = { shareBusiness(context, business) }) { Text("Share") }
}
}
}
@Composable
fun SimpleListScreen(
title: String,
businesses: List<Business>,
emptyMessage: String,
onOpen: (Int) -> Unit,
onBack: () -> Unit
) {
Column(modifier = Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Button(onClick = onBack) { Text("Back") }
Text(title, style = MaterialTheme.typography.headlineMedium)
if (businesses.isEmpty()) {
Text(emptyMessage)
} else {
businesses.forEach { business ->
BusinessRow(business, isFavourite = true, onOpen = { onOpen(business.id) }, onToggleFavourite = {})
}
}
}
}
@Composable
fun SettingsScreen(onBack: () -> Unit) {
Column(modifier = Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Button(onClick = onBack) { Text("Back") }
Text("Settings", style = MaterialTheme.typography.headlineMedium)
Text("Phase 2 keeps settings simple. In the next upgrade, save selected city and language in DataStore.")
}
}
@Composable
fun ErrorScreen(message: String) {
Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
Text("Something went wrong", style = MaterialTheme.typography.headlineMedium)
Text(message)
}
}
fun openDialer(context: Context, phone: String) {
context.startActivity(Intent(Intent.ACTION_DIAL, Uri.parse("tel:$phone")))
}
fun openMap(context: Context, address: String) {
val uri = Uri.parse("geo:0,0?q=${Uri.encode(address)}")
context.startActivity(Intent(Intent.ACTION_VIEW, uri))
}
fun shareBusiness(context: Context, business: Business) {
val text = "${business.name}\n${business.category} in ${business.city}\n${business.phone}\n${business.address}"
val intent = Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(Intent.EXTRA_TEXT, text)
}
context.startActivity(Intent.createChooser(intent, "Share business"))
}
@Preview(showBackground = true)
@Composable
fun CataloguePreview() {
MaterialTheme {
CatalogueApp()
}
}
What this tutorial intentionally postpones
The app above uses in-memory favourites. That means favourites reset when the app process restarts. This is acceptable for learning navigation and UI flow. The next upgrade is storage.
| Persist selected language/city | Use DataStore because it is a small preference. |
|---|---|
| Persist favourites | Use Room because favourite business ids are structured records. |
| Load real catalogue | Use Retrofit or Ktor, map DTOs to UI models, and show loading/error states. |
Common errors and fixes
Checkpoint before Phase 3
- The list, detail, favourites, and settings routes work.
- Search and category filters work together.
- Empty state appears for impossible searches.
- Call, map, and share buttons open external apps or fail in a way you can explain.
- You can explain why this version uses in-memory state and what Room/DataStore would improve.
- You can complete the Phase 2 quiz lab without guessing.