Project 12: E-Commerce App with Products, Cart and Checkout

Android Development with Kotlin: 21 Real Projects · More Real-World Projects · 21 min read · Updated

Browse, add, pay on a server

  1. Products load from the API into the ViewModel.
  2. Add to cart saves a line in Room, with the price in cents.
  3. The cart screen watches Room, so totals update at once.
  4. In a real shop, checkout sends the cart to your server, which takes payment through a provider.

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. @Upsert handles 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 NavigationBar switches 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

  1. New Empty Activity project Shop, package com.example.shop.
  2. Add the Retrofit, serialization, Room, KSP, Coil and icons libraries from the Project 12 block, then sync. Add the INTERNET permission.
  3. 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

  • ShopViewModel loads products once and exposes Loading, Ready and Failed. A Try again button calls load().
  • add finds 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 LazyVerticalGrid with two columns and AsyncImage from Coil for pictures.
  • placeDemoOrder clears 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

  1. Build the app and fill the cart with three products.
  2. Add a category filter using chips, built from the products' category field.
  3. Add a product detail screen with Navigation Compose that shows the description.
  4. 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.

Frequently asked questions

Why store the cart in Room?

The cart should survive closing the app and work offline. Room also gives you a Flow, so the screen updates whenever the cart changes.

Should the app calculate the final price?

Only for display. The server must recalculate prices from its own data, because a modified app could send any price.

Can I take payments inside this app?

Use a payment provider SDK or hosted checkout, and confirm the order on your server. Never collect or store card numbers yourself.