A shopping cart must stay consistent with the server's state: prices can change, inventory can run out, and discounts can expire. Getting this wrong leads to orders placed at incorrect prices or for out-of-stock items.
Cart Data Model
data class CartState(
val items: List<CartItem> = emptyList(),
val subtotal: Money = Money.ZERO,
val discount: Money = Money.ZERO,
val tax: Money = Money.ZERO,
val total: Money = Money.ZERO,
val validationErrors: List<CartError> = emptyList(),
val isLoading: Boolean = false
)
data class CartItem(
val productId: String,
val variantId: String,
val name: String,
val imageUrl: String,
val quantity: Int,
val unitPrice: Money, // price at time of adding
val currentPrice: Money, // price as of last server sync
val maxQuantity: Int, // current stock
val isAvailable: Boolean
)
data class Money(val cents: Long, val currencyCode: String = "USD") {
companion object { val ZERO = Money(0) }
operator fun plus(other: Money) = Money(cents + other.cents, currencyCode)
fun format(): String = "$${cents / 100}.${(cents % 100).toString().padStart(2, '0')}"
}
sealed class CartError {
data class PriceChanged(val productId: String, val oldPrice: Money, val newPrice: Money) : CartError()
data class OutOfStock(val productId: String, val maxAvailable: Int) : CartError()
data class DiscountExpired(val code: String) : CartError()
}
Cart Sync Strategy
class CartRepository(
private val api: CartApi,
private val localStore: CartLocalStore // DataStore
) {
// Local-first: read from local, sync with server
val cartState: Flow<CartState> = localStore.cart
.onStart { syncWithServer() } // sync on first collection
suspend fun syncWithServer() {
try {
val serverCart = api.getCart()
val localCart = localStore.getCart()
// Validate prices and availability
val errors = mutableListOf<CartError>()
val updatedItems = localCart.items.map { localItem ->
val serverItem = serverCart.items.find { it.productId == localItem.productId }
?: return@map localItem.copy(isAvailable = false)
if (serverItem.currentPrice != localItem.currentPrice) {
errors.add(CartError.PriceChanged(
localItem.productId,
oldPrice = localItem.unitPrice,
newPrice = serverItem.currentPrice
))
}
if (localItem.quantity > serverItem.maxQuantity) {
errors.add(CartError.OutOfStock(localItem.productId, serverItem.maxQuantity))
}
localItem.copy(
currentPrice = serverItem.currentPrice,
maxQuantity = serverItem.maxQuantity,
isAvailable = serverItem.isAvailable
)
}
localStore.updateCart(localCart.copy(
items = updatedItems,
validationErrors = errors
))
recalculateTotals()
} catch (e: IOException) {
// Keep local cart; show warning that prices may be outdated
}
}
fun addItem(product: Product, variant: Variant, quantity: Int = 1) {
val currentItems = localStore.getCart().items.toMutableList()
val existingIndex = currentItems.indexOfFirst {
it.productId == product.id && it.variantId == variant.id
}
if (existingIndex >= 0) {
val existing = currentItems[existingIndex]
val newQty = minOf(existing.quantity + quantity, existing.maxQuantity)
currentItems[existingIndex] = existing.copy(quantity = newQty)
} else {
currentItems.add(CartItem(
productId = product.id,
variantId = variant.id,
name = product.name,
imageUrl = product.imageUrl,
quantity = quantity,
unitPrice = variant.price,
currentPrice = variant.price,
maxQuantity = variant.stockCount,
isAvailable = true
))
}
localStore.updateItems(currentItems)
recalculateTotals()
}
private fun recalculateTotals() {
val items = localStore.getCart().items
val subtotal = items.fold(Money.ZERO) { acc, item ->
acc + Money(item.currentPrice.cents * item.quantity)
}
// Apply discount codes, compute tax server-side
localStore.updateTotals(subtotal = subtotal)
}
}
Price Change Alert UI
@Composable
fun CartValidationBanner(errors: List<CartError>, onAcknowledge: () -> Unit) {
if (errors.isEmpty()) return
Column(
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.errorContainer)
.padding(16.dp)
) {
errors.forEach { error ->
when (error) {
is CartError.PriceChanged ->
Text("Price for item changed from ${error.oldPrice.format()} to ${error.newPrice.format()}")
is CartError.OutOfStock ->
Text("Only ${error.maxAvailable} left in stock — quantity adjusted")
is CartError.DiscountExpired ->
Text("Discount code '${error.code}' has expired")
}
}
Spacer(Modifier.height(8.dp))
Button(onClick = onAcknowledge) { Text("I understand, continue") }
}
}
Quantity Limits Enforcement
@Composable
fun QuantitySelector(
quantity: Int,
maxQuantity: Int,
onQuantityChanged: (Int) -> Unit
) {
Row(verticalAlignment = Alignment.CenterVertically) {
IconButton(
onClick = { onQuantityChanged(quantity - 1) },
enabled = quantity > 1
) {
Icon(Icons.Default.Remove, "Decrease")
}
Text("$quantity", modifier = Modifier.width(32.dp), textAlign = TextAlign.Center)
IconButton(
onClick = { onQuantityChanged(quantity + 1) },
enabled = quantity < maxQuantity // enforce stock limit
) {
Icon(Icons.Default.Add, "Increase")
}
if (quantity >= maxQuantity) {
Text("(max)", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.error)
}
}
}
Key Takeaways
| Pattern | Rule |
|---|---|
| Server sync on open | Always validate cart against server before showing totals |
| Show price changes | Alert users to price changes before checkout; don't silently update |
| Enforce stock limits | Never allow quantity > available stock |
| Local-first cart | Read from local; sync in background; keep UI responsive |
| Totals server-side | Always recalculate totals server-side at checkout; client total is display-only |
| Discount validation | Validate discount codes server-side; mark as expired in UI if rejected |