Cart Screen
The screen
Step 1: layouts
The cart list sits inside a NestedScrollView together with the free-delivery card and the price summary, with nestedScrollingEnabled="false" so everything scrolls as one page. The total and the Checkout button are fixed at the bottom.
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.core.widget.NestedScrollView
android:id="@+id/content"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintBottom_toTopOf="@id/bottom_bar"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingTop="8dp">
<!-- Free delivery progress -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:layout_marginEnd="12dp"
android:background="@drawable/bg_offer_card"
android:orientation="vertical"
android:padding="12dp">
<TextView
android:id="@+id/free_delivery"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:drawablePadding="8dp"
android:gravity="center_vertical"
android:textColor="@color/text_primary"
android:textStyle="bold"
app:drawableStartCompat="@drawable/ic_truck"
app:drawableTint="@color/brand"
tools:text="Add ₹120 more for FREE delivery" />
<com.google.android.material.progressindicator.LinearProgressIndicator
android:id="@+id/free_delivery_bar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:max="100"
app:trackCornerRadius="4dp"
app:trackThickness="6dp" />
</LinearLayout>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/list"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:nestedScrollingEnabled="false"
tools:itemCount="2"
tools:listitem="@layout/item_cart" />
<include
android:id="@+id/summary"
layout="@layout/layout_price_summary" />
</LinearLayout>
</androidx.core.widget.NestedScrollView>
<com.google.android.material.progressindicator.LinearProgressIndicator
android:id="@+id/updating"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:indeterminate="true"
android:visibility="invisible"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<LinearLayout
android:id="@+id/bottom_bar"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:background="@drawable/bg_bottom_bar"
android:elevation="8dp"
android:gravity="center_vertical"
android:orientation="horizontal"
android:padding="12dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent">
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/total"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/text_primary"
android:textSize="20sp"
android:textStyle="bold"
tools:text="₹2,337" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/total_amount"
android:textColor="@color/text_secondary"
android:textSize="12sp" />
</LinearLayout>
<com.google.android.material.button.MaterialButton
android:id="@+id/checkout"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:backgroundTint="@color/accent"
android:paddingStart="32dp"
android:paddingEnd="32dp"
android:text="@string/checkout"
android:textColor="@android:color/white" />
</LinearLayout>
<include
android:id="@+id/state"
layout="@layout/layout_state" />
</androidx.constraintlayout.widget.ConstraintLayout><?xml version="1.0" encoding="utf-8"?>
<com.google.android.material.card.MaterialCardView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:layout_marginTop="6dp"
android:layout_marginEnd="12dp"
android:layout_marginBottom="6dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="12dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<com.google.android.material.imageview.ShapeableImageView
android:id="@+id/image"
android:layout_width="88dp"
android:layout_height="88dp"
android:contentDescription="@string/product_image"
android:scaleType="centerCrop"
app:shapeAppearanceOverlay="@style/ShapeAppearance.KartEasy.Rounded" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/name"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="2"
android:textColor="@color/text_primary"
tools:text="Smart Watch Pro" />
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:baselineAligned="true"
android:orientation="horizontal">
<TextView
android:id="@+id/price"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="@style/TextAppearance.KartEasy.Price"
tools:text="₹1,899" />
<TextView
android:id="@+id/mrp"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:textColor="@color/text_secondary"
android:textSize="12sp"
tools:text="₹4,999" />
</LinearLayout>
<TextView
android:id="@+id/warning"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:textColor="@color/sale"
android:textSize="12sp"
android:textStyle="bold"
android:visibility="gone"
tools:text="Out of stock" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:gravity="center_vertical"
android:orientation="horizontal">
<com.google.android.material.button.MaterialButton
android:id="@+id/minus"
style="@style/Widget.KartEasy.QtyButton"
android:layout_width="36dp"
android:layout_height="36dp"
android:contentDescription="@string/decrease"
android:text="−" />
<TextView
android:id="@+id/qty"
android:layout_width="36dp"
android:layout_height="wrap_content"
android:gravity="center"
android:textColor="@color/text_primary"
android:textStyle="bold"
tools:text="2" />
<com.google.android.material.button.MaterialButton
android:id="@+id/plus"
style="@style/Widget.KartEasy.QtyButton"
android:layout_width="36dp"
android:layout_height="36dp"
android:contentDescription="@string/increase"
android:text="+" />
<TextView
android:id="@+id/line_total"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:gravity="end"
android:textColor="@color/text_secondary"
android:textSize="13sp"
tools:text="Total: ₹3,798" />
</LinearLayout>
</LinearLayout>
</LinearLayout>
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_marginTop="10dp"
android:background="@color/divider" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<com.google.android.material.button.MaterialButton
android:id="@+id/save"
style="@style/Widget.Material3.Button.TextButton.Icon"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/save_for_later"
app:icon="@drawable/ic_heart_outline" />
<com.google.android.material.button.MaterialButton
android:id="@+id/remove"
style="@style/Widget.Material3.Button.TextButton.Icon"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/remove"
android:textColor="@color/sale"
app:icon="@drawable/ic_delete"
app:iconTint="@color/sale" />
</LinearLayout>
</LinearLayout>
</com.google.android.material.card.MaterialCardView>Step 2: the shared price summary
Cart and Checkout show the same price box. It is one layout, included in both screens, and one extension function LayoutPriceSummaryBinding.bind(cart) fills it — plurals for "Price (3 items)" and the coupon row only when a coupon is applied.
<?xml version="1.0" encoding="utf-8"?>
<!-- Price details card. Filled by PriceSummary.kt: binding.summary.bind(cart) -->
<com.google.android.material.card.MaterialCardView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="12dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
style="@style/KartEasy.CardTitle"
android:text="@string/price_details" />
<LinearLayout style="@style/KartEasy.SummaryRow">
<TextView
android:id="@+id/items_label"
style="@style/KartEasy.SummaryLabel"
tools:text="Price (3 items)" />
<TextView
android:id="@+id/mrp_value"
style="@style/KartEasy.SummaryValue"
tools:text="₹5,997" />
</LinearLayout>
<LinearLayout style="@style/KartEasy.SummaryRow">
<TextView
style="@style/KartEasy.SummaryLabel"
android:text="@string/product_discount" />
<TextView
android:id="@+id/product_discount_value"
style="@style/KartEasy.SummaryValue"
android:textColor="@color/success"
tools:text="− ₹3,400" />
</LinearLayout>
<LinearLayout
android:id="@+id/coupon_row"
style="@style/KartEasy.SummaryRow"
android:visibility="gone">
<TextView
android:id="@+id/coupon_label"
style="@style/KartEasy.SummaryLabel"
tools:text="Coupon (SAVE10)" />
<TextView
android:id="@+id/coupon_value"
style="@style/KartEasy.SummaryValue"
android:textColor="@color/success"
tools:text="− ₹259.70" />
</LinearLayout>
<LinearLayout style="@style/KartEasy.SummaryRow">
<TextView
style="@style/KartEasy.SummaryLabel"
android:text="@string/delivery_charges" />
<TextView
android:id="@+id/delivery_value"
style="@style/KartEasy.SummaryValue"
tools:text="FREE" />
</LinearLayout>
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_marginTop="8dp"
android:layout_marginBottom="8dp"
android:background="@color/divider" />
<LinearLayout style="@style/KartEasy.SummaryRow">
<TextView
style="@style/KartEasy.SummaryLabel"
android:text="@string/total_amount"
android:textColor="@color/text_primary"
android:textStyle="bold" />
<TextView
android:id="@+id/total_value"
style="@style/KartEasy.SummaryValue"
android:textStyle="bold"
tools:text="₹2,337.30" />
</LinearLayout>
<TextView
android:id="@+id/savings"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:textColor="@color/success"
android:textStyle="bold"
tools:text="You save ₹3,659.70 on this order" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>package com.codingeasily.karteasy.ui.cart
import com.codingeasily.karteasy.R
import com.codingeasily.karteasy.data.model.Cart
import com.codingeasily.karteasy.databinding.LayoutPriceSummaryBinding
import com.codingeasily.karteasy.util.Format
import com.codingeasily.karteasy.util.visibleIf
/** Fills layout_price_summary.xml. Used by both Cart and Checkout. */
fun LayoutPriceSummaryBinding.bind(cart: Cart) {
val ctx = root.context
itemsLabel.text = ctx.resources.getQuantityString(R.plurals.price_items, cart.itemCount, cart.itemCount)
mrpValue.text = Format.price(cart.mrpTotal)
productDiscountValue.text = ctx.getString(R.string.minus_amount, Format.price(cart.mrpTotal - cart.subtotal))
couponRow.visibleIf(cart.discount > 0)
couponLabel.text = ctx.getString(R.string.coupon_label, cart.couponCode ?: "")
couponValue.text = ctx.getString(R.string.minus_amount, Format.price(cart.discount))
deliveryValue.text = if (cart.deliveryFee > 0) Format.price(cart.deliveryFee) else ctx.getString(R.string.free)
totalValue.text = Format.price(cart.total)
savings.visibleIf(cart.youSave > 0)
savings.text = ctx.getString(R.string.you_save, Format.price(cart.youSave))
}Step 3: CartAdapter
The minus button is disabled at 1 (use Remove instead), the plus button at the stock or 10. Unavailable items are faded to 60% and show a red warning.
package com.codingeasily.karteasy.ui.cart
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import com.codingeasily.karteasy.R
import com.codingeasily.karteasy.data.model.CartItem
import com.codingeasily.karteasy.databinding.ItemCartBinding
import com.codingeasily.karteasy.util.Format
import com.codingeasily.karteasy.util.load
import com.codingeasily.karteasy.util.strike
import com.codingeasily.karteasy.util.visibleIf
class CartAdapter(
private val onQty: (CartItem, Int) -> Unit,
private val onRemove: (CartItem) -> Unit,
private val onSave: (CartItem) -> Unit,
private val onOpen: (CartItem) -> Unit
) : ListAdapter<CartItem, CartAdapter.Holder>(DIFF) {
inner class Holder(val b: ItemCartBinding) : RecyclerView.ViewHolder(b.root)
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
Holder(ItemCartBinding.inflate(LayoutInflater.from(parent.context), parent, false))
override fun onBindViewHolder(holder: Holder, position: Int) {
val item = getItem(position)
val b = holder.b
val ctx = b.root.context
b.image.load(item.image)
b.name.text = item.name
b.price.text = Format.price(item.price)
b.mrp.strike(Format.price(item.mrp))
b.mrp.visibleIf(item.mrp > item.price)
b.qty.text = item.qty.toString()
b.lineTotal.text = ctx.getString(R.string.line_total, Format.price(item.lineTotal))
b.minus.isEnabled = item.qty > 1
b.plus.isEnabled = item.qty < minOf(10, item.stock)
b.warning.visibleIf(!item.available)
b.warning.text = if (item.stock <= 0) ctx.getString(R.string.out_of_stock)
else ctx.getString(R.string.only_left, item.stock)
b.root.alpha = if (item.available) 1f else 0.6f
b.minus.setOnClickListener { onQty(item, -1) }
b.plus.setOnClickListener { onQty(item, +1) }
b.remove.setOnClickListener { onRemove(item) }
b.save.setOnClickListener { onSave(item) }
b.image.setOnClickListener { onOpen(item) }
b.name.setOnClickListener { onOpen(item) }
}
companion object {
val DIFF = object : DiffUtil.ItemCallback<CartItem>() {
override fun areItemsTheSame(a: CartItem, b: CartItem) = a.productId == b.productId
override fun areContentsTheSame(a: CartItem, b: CartItem) = a == b
}
}
}Step 4: CartViewModel
Every action (− / +, remove, undo, move to wishlist) goes through update(): it ignores taps while a request is running (busy) and replaces the whole cart with the server's answer. The screen can never show totals that the server did not calculate.
package com.codingeasily.karteasy.ui.cart
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.codingeasily.karteasy.data.api.Result
import com.codingeasily.karteasy.data.api.UiState
import com.codingeasily.karteasy.data.model.Cart
import com.codingeasily.karteasy.data.model.CartItem
import com.codingeasily.karteasy.util.kartApp
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
class CartViewModel(app: Application) : AndroidViewModel(app) {
private val repo = app.kartApp.repository
private val _state = MutableStateFlow<UiState<Cart>>(UiState.Loading)
val state: StateFlow<UiState<Cart>> = _state
private val _busy = MutableStateFlow(false)
val busy: StateFlow<Boolean> = _busy
private val _messages = MutableSharedFlow<String>()
val messages: SharedFlow<String> = _messages
fun load() {
viewModelScope.launch {
if (_state.value !is UiState.Success) _state.value = UiState.Loading
_state.value = when (val r = repo.cart()) {
is Result.Success -> UiState.Success(r.data)
is Result.Error -> UiState.Error(r.message)
}
}
}
fun changeQty(item: CartItem, delta: Int) = update { repo.updateCart(item.productId, item.qty + delta) }
fun remove(item: CartItem) = update { repo.removeFromCart(item.productId) }
/** Undo for the "Item removed" snackbar. */
fun restore(item: CartItem) = update { repo.addToCart(item.productId, item.qty) }
fun moveToWishlist(item: CartItem) {
viewModelScope.launch {
val r = repo.toggleWishlist(item.productId)
if (r is Result.Success && !r.data.inWishlist) repo.toggleWishlist(item.productId) // was already there
update { repo.removeFromCart(item.productId) }
_messages.emit("Moved to wishlist.")
}
}
/** Every cart call returns the full new cart, so the screen simply shows that. */
private fun update(call: suspend () -> Result<Cart>) {
if (_busy.value) return
_busy.value = true
viewModelScope.launch {
when (val r = call()) {
is Result.Success -> _state.value = UiState.Success(r.data)
is Result.Error -> _messages.emit(r.message)
}
_busy.value = false
}
}
}Step 5: CartFragment
The fragment shows the empty state with "Start shopping" when there are no items, otherwise the list, the summary, the bottom total and the free-delivery card.
package com.codingeasily.karteasy.ui.cart
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.os.bundleOf
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.navigation.fragment.findNavController
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.codingeasily.karteasy.R
import com.codingeasily.karteasy.data.api.UiState
import com.codingeasily.karteasy.data.model.CartItem
import com.codingeasily.karteasy.databinding.FragmentCartBinding
import com.codingeasily.karteasy.ui.product.ProductDetailFragment
import com.codingeasily.karteasy.util.Format
import com.codingeasily.karteasy.util.collectWhenStarted
import com.codingeasily.karteasy.util.done
import com.codingeasily.karteasy.util.empty
import com.codingeasily.karteasy.util.error
import com.codingeasily.karteasy.util.hide
import com.codingeasily.karteasy.util.loading
import com.codingeasily.karteasy.util.show
import com.codingeasily.karteasy.util.snack
import com.google.android.material.snackbar.Snackbar
class CartFragment : Fragment() {
companion object {
/** Must match FREE_DELIVERY_ABOVE in the PHP config. */
const val FREE_DELIVERY_ABOVE = 499.0
}
private var _binding: FragmentCartBinding? = null
private val binding get() = _binding!!
private val vm: CartViewModel by viewModels()
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
_binding = FragmentCartBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val adapter = CartAdapter(
onQty = { item, d -> vm.changeQty(item, d) },
onRemove = ::removeWithUndo,
onSave = { vm.moveToWishlist(it) },
onOpen = { findNavController().navigate(R.id.productDetailFragment, bundleOf(ProductDetailFragment.ARG_PRODUCT_ID to it.productId)) }
)
binding.list.layoutManager = LinearLayoutManager(requireContext())
binding.list.adapter = adapter
// Swipe left or right to remove an item.
ItemTouchHelper(object : ItemTouchHelper.SimpleCallback(0, ItemTouchHelper.LEFT or ItemTouchHelper.RIGHT) {
override fun onMove(rv: RecyclerView, vh: RecyclerView.ViewHolder, t: RecyclerView.ViewHolder) = false
override fun onSwiped(vh: RecyclerView.ViewHolder, direction: Int) {
val pos = vh.bindingAdapterPosition
adapter.notifyItemChanged(pos) // put the row back; the new cart list removes it
removeWithUndo(adapter.currentList[pos])
}
}).attachToRecyclerView(binding.list)
binding.checkout.setOnClickListener { findNavController().navigate(R.id.action_cart_to_checkout) }
collectWhenStarted(vm.state) { state ->
when (state) {
is UiState.Loading -> { binding.content.hide(); binding.bottomBar.hide(); binding.state.loading() }
is UiState.Error -> { binding.content.hide(); binding.bottomBar.hide(); binding.state.error(state.message) { vm.load() } }
is UiState.Success -> {
val cart = state.data
adapter.submitList(cart.items)
if (cart.items.isEmpty()) {
binding.content.hide(); binding.bottomBar.hide()
binding.state.empty(R.drawable.ic_cart_empty, R.string.cart_empty, R.string.start_shopping) {
findNavController().navigate(R.id.homeFragment)
}
return@collectWhenStarted
}
binding.state.done(); binding.content.show(); binding.bottomBar.show()
binding.summary.bind(cart)
binding.total.text = Format.price(cart.total)
binding.checkout.isEnabled = cart.itemCount > 0
// "Add ₹120 more for FREE delivery" + progress bar
val left = FREE_DELIVERY_ABOVE - cart.subtotal
if (left > 0) {
binding.freeDelivery.text = getString(R.string.free_delivery_left, Format.price(left))
binding.freeDeliveryBar.progress = ((cart.subtotal / FREE_DELIVERY_ABOVE) * 100).toInt()
binding.freeDeliveryBar.show()
} else {
binding.freeDelivery.setText(R.string.free_delivery_unlocked)
binding.freeDeliveryBar.hide()
}
}
}
}
collectWhenStarted(vm.busy) { binding.updating.visibility = if (it) View.VISIBLE else View.INVISIBLE }
collectWhenStarted(vm.messages) { snack(it) }
}
override fun onResume() {
super.onResume()
vm.load() // the cart may have changed on another screen
}
private fun removeWithUndo(item: CartItem) {
vm.remove(item)
Snackbar.make(binding.root, getString(R.string.removed_item, item.name), Snackbar.LENGTH_LONG)
.setAction(R.string.undo) { vm.restore(item) }
.setAnchorView(binding.bottomBar)
.show()
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
}Swipe to remove + Undo
ItemTouchHelper detects a left or right swipe. We immediately ask the API to remove the item and show a Snackbar with Undo; Undo simply adds the same product and quantity back. The Snackbar is anchored above the bottom bar so it does not cover the Checkout button.
The cart badge
The number on the Cart tab is a Material BadgeDrawable. It follows repository.cartCount, which every cart call updates — so adding from Home, Detail or Wishlist changes the badge instantly.
// MainActivity.kt – the repository's cartCount drives the badge on the Cart tab
launch {
app.repository.cartCount.collect { count ->
val badge = binding.bottomNav.getOrCreateBadge(R.id.cartFragment)
badge.isVisible = count > 0
badge.number = count
badge.backgroundColor = ContextCompat.getColor(this@MainActivity, R.color.sale)
}
}