androidengineers.Book a session

Designing an E-commerce App

Exercise: Cart with Offline Support

exercise60 minHard

Build a shopping cart that works fully offline, syncs to the server when connectivity returns, and shows price validation warnings when prices changed during the offline period.

Goal

  • Cart is persisted in Room; UI always reads from Room
  • WorkManager syncs cart to server when network is available
  • Price validation on sync; shows banner for changed prices
  • Checkout disabled until validation passes

Step 1: Room Schema

@Entity(tableName = "cart_items")
data class CartItemEntity(
    @PrimaryKey val itemKey: String,        // "${productId}_${variantId}"
    val productId: String,
    val variantId: String,
    val name: String,
    val imageUrl: String,
    val quantity: Int,
    val localPrice: Long,                   // price when added (cents)
    val validatedPrice: Long?,              // price after server validation
    val maxQuantity: Int,
    val isAvailable: Boolean,
    val pendingSync: Boolean = true,        // not yet synced to server
    val updatedAt: Long = System.currentTimeMillis()
)

@Dao
interface CartDao {
    @Query("SELECT * FROM cart_items ORDER BY updated_at DESC")
    fun observeAll(): Flow<List<CartItemEntity>>

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun upsert(item: CartItemEntity)

    @Query("DELETE FROM cart_items WHERE item_key = :key")
    suspend fun remove(key: String)

    @Query("UPDATE cart_items SET pending_sync = 0, validated_price = :price WHERE item_key = :key")
    suspend fun markSynced(key: String, price: Long)

    @Query("SELECT * FROM cart_items WHERE pending_sync = 1")
    suspend fun getPendingSyncItems(): List<CartItemEntity>
}

Step 2: Cart Repository (Offline-First)

class CartRepository @Inject constructor(
    private val dao: CartDao,
    private val api: CartApi,
    private val workManager: WorkManager
) {
    // Always read from local Room
    val cart: Flow<Cart> = dao.observeAll()
        .map { items -> Cart(items = items.map { it.toDomain() }) }

    fun addItem(product: Product, variant: Variant) {
        val key = "${product.id}_${variant.id}"
        scope.launch {
            val existing = dao.observeAll().first().find { it.itemKey == key }
            dao.upsert(
                CartItemEntity(
                    itemKey = key,
                    productId = product.id,
                    variantId = variant.id,
                    name = product.name,
                    imageUrl = product.imageUrl,
                    quantity = (existing?.quantity ?: 0) + 1,
                    localPrice = variant.priceCents,
                    validatedPrice = null,
                    maxQuantity = variant.stockCount,
                    isAvailable = true,
                    pendingSync = true
                )
            )
            scheduleSync()
        }
    }

    private fun scheduleSync() {
        val work = OneTimeWorkRequestBuilder<CartSyncWorker>()
            .setConstraints(Constraints.Builder()
                .setRequiredNetworkType(NetworkType.CONNECTED)
                .build())
            .build()
        workManager.enqueueUniqueWork("cart_sync", ExistingWorkPolicy.REPLACE, work)
    }
}

Step 3: Cart Sync Worker

class CartSyncWorker @AssistedInject constructor(
    @Assisted context: Context,
    @Assisted params: WorkerParameters,
    private val dao: CartDao,
    private val api: CartApi
) : CoroutineWorker(context, params) {

    override suspend fun doWork(): Result {
        val pendingItems = dao.getPendingSyncItems()
        if (pendingItems.isEmpty()) return Result.success()

        return try {
            val request = SyncCartRequest(
                items = pendingItems.map {
                    SyncCartItem(
                        productId = it.productId,
                        variantId = it.variantId,
                        quantity = it.quantity
                    )
                }
            )

            val response = api.syncCart(request)

            // Update local items with server-validated prices
            response.validatedItems.forEach { serverItem ->
                val key = "${serverItem.productId}_${serverItem.variantId}"
                dao.markSynced(key, price = serverItem.currentPriceCents)

                // Update availability and stock limits
                if (!serverItem.isAvailable || serverItem.currentQuantity == 0) {
                    dao.upsert(dao.getPendingSyncItems()
                        .first { it.itemKey == key }
                        .copy(isAvailable = false, pendingSync = false)
                    )
                }
            }

            Result.success()
        } catch (e: IOException) {
            if (runAttemptCount < 3) Result.retry() else Result.failure()
        }
    }
}

Step 4: ViewModel and State

data class CartUiState(
    val items: List<CartItemUi> = emptyList(),
    val subtotal: String = "$0.00",
    val priceChanges: List<PriceChange> = emptyList(),
    val unavailableItems: List<String> = emptyList(),
    val canCheckout: Boolean = false,
    val isSyncing: Boolean = false
)

data class PriceChange(val name: String, val oldPrice: String, val newPrice: String)

@HiltViewModel
class CartViewModel @Inject constructor(
    private val repository: CartRepository,
    private val workManager: WorkManager
) : ViewModel() {

    val state: StateFlow<CartUiState> = repository.cart.map { cart ->
        val priceChanges = cart.items.mapNotNull { item ->
            if (item.validatedPrice != null && item.validatedPrice != item.localPrice) {
                PriceChange(
                    name = item.name,
                    oldPrice = Money(item.localPrice).format(),
                    newPrice = Money(item.validatedPrice).format()
                )
            } else null
        }

        val unavailable = cart.items.filter { !it.isAvailable }.map { it.name }

        CartUiState(
            items = cart.items.map { it.toUi() },
            subtotal = cart.subtotal.format(),
            priceChanges = priceChanges,
            unavailableItems = unavailable,
            canCheckout = priceChanges.isEmpty() && unavailable.isEmpty() && cart.items.isNotEmpty()
        )
    }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), CartUiState())

    fun acknowledgeChanges() = viewModelScope.launch {
        // Accept current validated prices as the new baseline
        repository.acceptValidatedPrices()
    }
}

Step 5: Cart UI

@Composable
fun CartScreen(viewModel: CartViewModel = hiltViewModel(), onCheckout: () -> Unit) {
    val state by viewModel.state.collectAsStateWithLifecycle()

    Column(Modifier.fillMaxSize()) {
        // Price change warning
        if (state.priceChanges.isNotEmpty()) {
            PriceChangeBanner(
                changes = state.priceChanges,
                onAcknowledge = viewModel::acknowledgeChanges
            )
        }

        // Unavailable items warning
        if (state.unavailableItems.isNotEmpty()) {
            Surface(color = MaterialTheme.colorScheme.errorContainer) {
                Text(
                    "Some items are out of stock: ${state.unavailableItems.joinToString()}",
                    modifier = Modifier.padding(16.dp),
                    color = MaterialTheme.colorScheme.onErrorContainer
                )
            }
        }

        LazyColumn(Modifier.weight(1f)) {
            items(state.items, key = { it.key }) { item ->
                CartItemRow(item = item)
            }
        }

        // Checkout bar
        Column(Modifier.padding(16.dp)) {
            Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
                Text("Subtotal", style = MaterialTheme.typography.titleMedium)
                Text(state.subtotal, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
            }
            Spacer(Modifier.height(8.dp))
            Button(
                onClick = onCheckout,
                enabled = state.canCheckout,
                modifier = Modifier.fillMaxWidth()
            ) {
                Text(if (state.canCheckout) "Checkout" else "Resolve issues to checkout")
            }
        }
    }
}

Verification Checklist

[ ] Add items while offline → items appear in cart immediately
[ ] Go online → sync worker runs; WorkManager shows RUNNING in observer
[ ] Changed price → banner shows old/new prices; checkout blocked
[ ] Acknowledge price change → banner dismissed; checkout enabled
[ ] Out-of-stock item → shown with error; checkout blocked
[ ] Force kill + reopen → cart persists from Room
[ ] Sync failure (bad network) → worker retries; cart still usable

YOUR LEARNING JOURNEY

0 of 177 available lessons completed

Progress saved in this browser. No account needed.
Exercise: Cart with Offline Support | Android System Design | Android Engineers