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