Shopping apps combine nearly everything you have learned: a list from the network, images, a database, state shared between two screens and exact money. This project builds the core of one. The products come from the free Fake Store API, the cart lives in a Room database so it survives closing the app, and the checkout is a demo that clears the cart and takes no payment.
A real store needs more: sign-in, addresses, a server that creates orders, a payment provider and legal pages. The lesson shows the correct place for each of these, but it does not pretend that this demo is a finished shop.
Concepts in this project
- Network plus database: products are loaded with Retrofit, and the cart is stored with Room. The cart screen reads the database through a Flow, so it updates by itself.
- Exact money: prices are converted to cents when a product is added to the cart, and totals are sums of integers.
asMoney()formats them. - Upsert: adding the same product again increases its quantity.
@Upserthandles both insert and update. - Derived state: the total and the cart count come from the lines Flow, so they cannot go out of sync.
- Bottom navigation: a
NavigationBarswitches between Shop and Cart, and the Cart label shows the live item count. - Payments: never handle card numbers in your app. A real checkout sends the cart to your server, which creates an order and uses a payment provider's SDK (lesson 26).
Create the project
- New Empty Activity project
Shop, packagecom.example.shop. - Add the Retrofit, serialization, Room, KSP, Coil and icons libraries from the Project 12 block, then sync. Add the INTERNET permission.
- Create the files below.
`Gradle dependencies for projects 11 to 21 (use the block for this project)`
// ---- Project 11 Calculator: no extra libraries. Test dependency:
testImplementation("junit:junit:4.13.2")
// ---- Project 12 E-commerce
plugins { id("com.google.devtools.ksp"); kotlin("plugin.serialization") }
implementation("com.squareup.retrofit2:retrofit:2.11.0")
implementation("com.squareup.retrofit2:converter-kotlinx-serialization:2.11.0")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
implementation("androidx.room:room-runtime:2.6.1")
implementation("androidx.room:room-ktx:2.6.1")
ksp("androidx.room:room-compiler:2.6.1")
implementation("io.coil-kt:coil-compose:2.7.0")
implementation("androidx.compose.material:material-icons-extended")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")
// ---- Project 13 Chat (Firebase)
// project-level plugins: id("com.google.gms.google-services")
implementation(platform("com.google.firebase:firebase-bom:33.7.0"))
implementation("com.google.firebase:firebase-auth")
implementation("com.google.firebase:firebase-firestore")
implementation("com.google.firebase:firebase-messaging")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.9.0")
// Also put google-services.json (from your Firebase console) in the app folder.
// ---- Project 14 Fitness
implementation("androidx.room:room-runtime:2.6.1")
implementation("androidx.room:room-ktx:2.6.1")
ksp("androidx.room:room-compiler:2.6.1")
// No library for the sensor: it is part of Android.
// ---- Project 15 Food delivery
implementation("com.google.maps.android:maps-compose:6.2.1")
implementation("com.google.android.gms:play-services-maps:19.0.0")
// Manifest, inside <application>:
// <meta-data android:name="com.google.android.geo.API_KEY" android:value="YOUR_KEY"/>
// Keep the key out of public git: read it from local.properties with the Secrets Gradle Plugin.
// ---- Project 16 Music player
implementation("androidx.media3:media3-exoplayer:1.5.0")
implementation("androidx.media3:media3-session:1.5.0")
implementation("androidx.media3:media3-ui:1.5.0")
// ---- Project 17 QR scanner
implementation("androidx.camera:camera-core:1.4.1")
implementation("androidx.camera:camera-camera2:1.4.1")
implementation("androidx.camera:camera-lifecycle:1.4.1")
implementation("androidx.camera:camera-view:1.4.1")
implementation("com.google.mlkit:barcode-scanning:17.3.0")
// ---- Project 18 Banking
implementation("androidx.biometric:biometric:1.1.0")
implementation("com.squareup.retrofit2:retrofit:2.11.0")
implementation("com.squareup.retrofit2:converter-kotlinx-serialization:2.11.0")
implementation("com.squareup.okhttp3:okhttp:4.12.0")
// ---- Project 19 AI chatbot
implementation("androidx.room:room-runtime:2.6.1")
implementation("androidx.room:room-ktx:2.6.1")
ksp("androidx.room:room-compiler:2.6.1")
implementation("com.squareup.okhttp3:okhttp:4.12.0")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0")
// ---- Project 20 Video streaming
implementation("androidx.media3:media3-exoplayer:1.5.0")
implementation("androidx.media3:media3-exoplayer-hls:1.5.0")
implementation("androidx.media3:media3-ui:1.5.0")
// ---- Project 21 Job portal
implementation("com.squareup.retrofit2:retrofit:2.11.0")
implementation("com.squareup.retrofit2:converter-kotlinx-serialization:2.11.0")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
Shop/app/src/main/java/com/example/shop/
ShopApi.kt DTOs and Retrofit interface
CartDb.kt entity, DAO, database
ShopViewModel.kt products state, cart actions, totals
ShopApp.kt tabs, product grid, cart screen
MainActivity.kt builds Retrofit and Room
app/src/test/.../ShopTest.kt
The code
`app/src/main/java/com/example/shop/ShopApi.kt`
package com.example.shop
import kotlinx.serialization.Serializable
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.POST
import retrofit2.http.Path
@Serializable
data class ProductDto(
val id: Int,
val title: String,
val price: Double,
val description: String = "",
val category: String = "",
val image: String = "",
)
@Serializable
data class LoginRequest(val username: String, val password: String)
@Serializable
data class LoginResponse(val token: String)
interface ShopApi {
@GET("products")
suspend fun products(): List<ProductDto>
@GET("products/{id}")
suspend fun product(@Path("id") id: Int): ProductDto
@POST("auth/login")
suspend fun login(@Body body: LoginRequest): LoginResponse
}
`app/src/main/java/com/example/shop/CartDb.kt`
package com.example.shop
import androidx.room.Dao
import androidx.room.Database
import androidx.room.Entity
import androidx.room.PrimaryKey
import androidx.room.Query
import androidx.room.RoomDatabase
import androidx.room.Upsert
import kotlinx.coroutines.flow.Flow
// price is stored in cents so totals are exact
@Entity(tableName = "cart_line")
data class CartLine(
@PrimaryKey val productId: Int,
val title: String,
val image: String,
val priceCents: Long,
val quantity: Int,
)
@Dao
interface CartDao {
@Query("SELECT * FROM cart_line ORDER BY title")
fun lines(): Flow<List<CartLine>>
@Query("SELECT * FROM cart_line WHERE productId = :id")
suspend fun find(id: Int): CartLine?
@Upsert
suspend fun save(line: CartLine)
@Query("DELETE FROM cart_line WHERE productId = :id")
suspend fun remove(id: Int)
@Query("DELETE FROM cart_line")
suspend fun clear()
}
@Database(entities = [CartLine::class], version = 1, exportSchema = false)
abstract class CartDatabase : RoomDatabase() {
abstract fun cart(): CartDao
}
`app/src/main/java/com/example/shop/ShopViewModel.kt`
package com.example.shop
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
sealed interface ProductsState {
data object Loading : ProductsState
data class Ready(val items: List<ProductDto>) : ProductsState
data class Failed(val message: String) : ProductsState
}
class ShopViewModel(
private val api: ShopApi,
private val cart: CartDao,
) : ViewModel() {
private val _products = MutableStateFlow<ProductsState>(ProductsState.Loading)
val products: StateFlow<ProductsState> = _products.asStateFlow()
val lines: StateFlow<List<CartLine>> = cart.lines()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
val totalCents: StateFlow<Long> = cart.lines()
.map { list -> list.sumOf { it.priceCents * it.quantity } }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 0L)
private val _orderPlaced = MutableStateFlow(false)
val orderPlaced: StateFlow<Boolean> = _orderPlaced.asStateFlow()
init {
load()
}
fun load() {
_products.value = ProductsState.Loading
viewModelScope.launch {
_products.value = try {
ProductsState.Ready(api.products())
} catch (e: Exception) {
ProductsState.Failed("Could not load products. Check your connection.")
}
}
}
fun add(p: ProductDto) {
viewModelScope.launch {
val existing = cart.find(p.id)
val line = existing?.copy(quantity = existing.quantity + 1)
?: CartLine(p.id, p.title, p.image, Math.round(p.price * 100), 1)
cart.save(line)
}
}
fun change(line: CartLine, delta: Int) {
viewModelScope.launch {
val q = line.quantity + delta
if (q <= 0) cart.remove(line.productId) else cart.save(line.copy(quantity = q))
}
}
// Demo only. A real app sends the cart to your server, which creates the order
// and talks to the payment provider.
fun placeDemoOrder() {
viewModelScope.launch {
cart.clear()
_orderPlaced.value = true
}
}
fun orderSeen() {
_orderPlaced.value = false
}
}
fun Long.asMoney(): String {
val whole = this / 100
val cents = (this % 100).toString().padStart(2, '0')
return "$whole.$cents"
}
`app/src/main/java/com/example/shop/ShopApp.kt`
package com.example.shop
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.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
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.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.ShoppingCart
import coil.compose.AsyncImage
@Composable
fun ShopApp(vm: ShopViewModel) {
var tab by remember { mutableStateOf(0) }
val lines by vm.lines.collectAsState()
val placed by vm.orderPlaced.collectAsState()
Scaffold(
bottomBar = {
NavigationBar {
NavigationBarItem(
selected = tab == 0,
onClick = { tab = 0 },
icon = { Icon(Icons.Default.Home, contentDescription = null) },
label = { Text("Shop") },
)
NavigationBarItem(
selected = tab == 1,
onClick = { tab = 1 },
icon = { Icon(Icons.Default.ShoppingCart, contentDescription = null) },
label = { Text("Cart (" + lines.sumOf { it.quantity } + ")") },
)
}
},
) { padding ->
val modifier = Modifier.padding(padding)
if (tab == 0) {
ProductGrid(vm, modifier)
} else {
CartScreen(vm, placed, modifier)
}
}
}
@Composable
private fun ProductGrid(vm: ShopViewModel, modifier: Modifier) {
val state by vm.products.collectAsState()
when (val s = state) {
ProductsState.Loading -> Column(modifier.fillMaxSize(), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally) {
CircularProgressIndicator()
}
is ProductsState.Failed -> Column(modifier.fillMaxSize(), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally) {
Text(s.message)
Button(onClick = vm::load) { Text("Try again") }
}
is ProductsState.Ready -> LazyVerticalGrid(
columns = GridCells.Fixed(2),
modifier = modifier,
contentPadding = androidx.compose.foundation.layout.PaddingValues(8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(s.items, key = { it.id }) { p ->
Card {
Column(Modifier.padding(8.dp)) {
AsyncImage(
model = p.image,
contentDescription = p.title,
contentScale = ContentScale.Fit,
modifier = Modifier
.fillMaxWidth()
.height(110.dp),
)
Text(p.title, maxLines = 2, modifier = Modifier.padding(top = 6.dp))
Text("$" + Math.round(p.price * 100).asMoney())
Button(onClick = { vm.add(p) }, modifier = Modifier.fillMaxWidth()) {
Text("Add to cart")
}
}
}
}
}
}
}
@Composable
private fun CartScreen(vm: ShopViewModel, placed: Boolean, modifier: Modifier) {
val lines by vm.lines.collectAsState()
val total by vm.totalCents.collectAsState()
LaunchedEffect(placed) {
// keep the confirmation visible until the user adds something new
}
Column(modifier.fillMaxSize().padding(12.dp)) {
if (placed && lines.isEmpty()) {
Text("Demo order placed. No payment was taken.")
} else if (lines.isEmpty()) {
Text("Your cart is empty.")
}
LazyColumn(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(8.dp)) {
items(lines, key = { it.productId }) { line ->
Card {
Row(Modifier.padding(8.dp), verticalAlignment = Alignment.CenterVertically) {
AsyncImage(model = line.image, contentDescription = null, modifier = Modifier.size(48.dp))
Column(Modifier.weight(1f).padding(horizontal = 8.dp)) {
Text(line.title, maxLines = 2)
Text("$" + (line.priceCents * line.quantity).asMoney())
}
OutlinedButton(onClick = { vm.change(line, -1) }) { Text("-") }
Text(line.quantity.toString(), modifier = Modifier.padding(horizontal = 8.dp))
OutlinedButton(onClick = { vm.change(line, 1) }) { Text("+") }
}
}
}
}
if (lines.isNotEmpty()) {
Text("Total: $" + total.asMoney(), modifier = Modifier.padding(vertical = 8.dp))
Button(onClick = vm::placeDemoOrder, modifier = Modifier.fillMaxWidth()) {
Text("Place demo order")
}
}
}
}
`app/src/main/java/com/example/shop/MainActivity.kt`
package com.example.shop
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.material3.MaterialTheme
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.room.Room
import retrofit2.converter.kotlinx.serialization.asConverterFactory
import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaType
import retrofit2.Retrofit
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val json = Json { ignoreUnknownKeys = true }
val api = Retrofit.Builder()
.baseUrl("https://fakestoreapi.com/")
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
.build()
.create(ShopApi::class.java)
val db = Room.databaseBuilder(applicationContext, CartDatabase::class.java, "cart.db").build()
setContent {
MaterialTheme {
val vm: ShopViewModel = viewModel(
factory = object : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return ShopViewModel(api, db.cart()) as T
}
},
)
ShopApp(vm)
}
}
}
}
`app/src/test/java/com/example/shop/ShopTest.kt`
package com.example.shop
import org.junit.Assert.assertEquals
import org.junit.Test
class ShopTest {
@Test
fun centsFormatWithTwoDigits() {
assertEquals("19.70", 1970L.asMoney())
assertEquals("0.05", 5L.asMoney())
assertEquals("100.00", 10000L.asMoney())
}
}
How it works
ShopViewModelloads products once and exposesLoading,ReadyandFailed. A Try again button callsload().addfinds the product in the cart. If it is there the quantity grows, otherwise a new line is created with the price in cents.change(line, delta)removes a line when its quantity reaches zero.- The grid uses
LazyVerticalGridwith two columns andAsyncImagefrom Coil for pictures. placeDemoOrderclears the cart and shows a message. No money moves.
Expected output
The Shop tab shows a two-column grid of products with a picture, title, price and an Add to cart button. Tap Add to cart on two products and the second tab reads Cart (2). Open it: each line has a picture, title, line price and - and + buttons. Press + and the total rises. Press - to zero and the line disappears. Close the app completely and reopen it: the cart is still there. Place demo order empties the cart and shows "Demo order placed. No payment was taken." Turn on airplane mode before first launch and the grid shows the error message and a Try again button.
Your task
- Build the app and fill the cart with three products.
- Add a category filter using chips, built from the products'
categoryfield. - Add a product detail screen with Navigation Compose that shows the description.
- Add the login call from the API and keep the token encrypted, as in project 18.
How to check it worked
- The cart survives closing the app.
- Totals equal the sum of the lines, with two decimals.
- You can explain why the payment step must be on a server.