Address & Checkout
The checkout flow
Step 1: AddressViewModel (shared)
package com.codingeasily.karteasy.ui.address
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.Address
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
/** Shared by the address list and the add/edit form. */
class AddressViewModel(app: Application) : AndroidViewModel(app) {
private val repo = app.kartApp.repository
private val _state = MutableStateFlow<UiState<List<Address>>>(UiState.Loading)
val state: StateFlow<UiState<List<Address>>> = _state
private val _saved = MutableSharedFlow<Address>()
val saved: SharedFlow<Address> = _saved
private val _messages = MutableSharedFlow<String>()
val messages: SharedFlow<String> = _messages
private val _saving = MutableStateFlow(false)
val saving: StateFlow<Boolean> = _saving
fun load() {
viewModelScope.launch {
if (_state.value !is UiState.Success) _state.value = UiState.Loading
_state.value = when (val r = repo.addresses()) {
is Result.Success -> UiState.Success(r.data)
is Result.Error -> UiState.Error(r.message)
}
}
}
fun find(id: Int): Address? = (_state.value as? UiState.Success)?.data?.firstOrNull { it.id == id }
fun save(a: Address) {
if (_saving.value) return
_saving.value = true
viewModelScope.launch {
when (val r = repo.saveAddress(a)) {
is Result.Success -> { _messages.emit(r.message); _saved.emit(r.data) }
is Result.Error -> _messages.emit(r.message)
}
_saving.value = false
}
}
fun delete(a: Address) {
viewModelScope.launch {
when (val r = repo.deleteAddress(a.id)) {
is Result.Success -> { _messages.emit(r.message); load() }
is Result.Error -> _messages.emit(r.message)
}
}
}
}Step 2: Address list
The same screen works in two modes. From the Account screen or drawer it is a normal list (tap = edit). From checkout it opens with pick = true and every card gets a Deliver here button.
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout 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.recyclerview.widget.RecyclerView
android:id="@+id/list"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
android:paddingTop="6dp"
android:paddingBottom="96dp"
tools:listitem="@layout/item_address" />
<include
android:id="@+id/state"
layout="@layout/layout_state" />
<com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton
android:id="@+id/add_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|end"
android:layout_margin="16dp"
android:text="@string/add_address"
app:icon="@drawable/ic_add" />
</androidx.coordinatorlayout.widget.CoordinatorLayout><?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"
android:clickable="true"
android:focusable="true">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal">
<TextView
android:id="@+id/name"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:textColor="@color/text_primary"
android:textStyle="bold"
tools:text="Riya Sharma · 9876543210" />
<TextView
android:id="@+id/default_badge"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@drawable/bg_pill"
android:backgroundTint="@color/brand_light"
android:paddingStart="10dp"
android:paddingTop="2dp"
android:paddingEnd="10dp"
android:paddingBottom="2dp"
android:text="@string/default_label"
android:textColor="@color/brand"
android:textSize="12sp"
android:textStyle="bold" />
</LinearLayout>
<TextView
android:id="@+id/address"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:textColor="@color/text_secondary"
tools:text="12 MG Road, Indore, Madhya Pradesh - 452001" />
<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/deliver_here"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:backgroundTint="@color/accent"
android:minHeight="40dp"
android:text="@string/deliver_here"
android:textColor="@android:color/white"
android:visibility="gone" />
<Space
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_weight="1" />
<com.google.android.material.button.MaterialButton
android:id="@+id/edit"
style="@style/Widget.Material3.Button.TextButton.Icon"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/edit"
app:icon="@drawable/ic_edit" />
<com.google.android.material.button.MaterialButton
android:id="@+id/delete"
style="@style/Widget.Material3.Button.TextButton.Icon"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/delete"
android:textColor="@color/sale"
app:icon="@drawable/ic_delete"
app:iconTint="@color/sale" />
</LinearLayout>
</LinearLayout>
</com.google.android.material.card.MaterialCardView>package com.codingeasily.karteasy.ui.address
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import com.codingeasily.karteasy.R
import com.codingeasily.karteasy.data.model.Address
import com.codingeasily.karteasy.databinding.ItemAddressBinding
import com.codingeasily.karteasy.util.visibleIf
class AddressAdapter(
private val pickMode: Boolean,
private val onPick: (Address) -> Unit,
private val onEdit: (Address) -> Unit,
private val onDelete: (Address) -> Unit
) : RecyclerView.Adapter<AddressAdapter.Holder>() {
var items: List<Address> = emptyList()
set(value) {
field = value
notifyDataSetChanged()
}
inner class Holder(val b: ItemAddressBinding) : RecyclerView.ViewHolder(b.root)
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
Holder(ItemAddressBinding.inflate(LayoutInflater.from(parent.context), parent, false))
override fun getItemCount() = items.size
override fun onBindViewHolder(holder: Holder, position: Int) {
val a = items[position]
val b = holder.b
b.name.text = b.root.context.getString(R.string.name_phone, a.name, a.phone)
b.address.text = a.fullText()
b.defaultBadge.visibleIf(a.isDefault)
b.deliverHere.visibleIf(pickMode)
b.deliverHere.setOnClickListener { onPick(a) }
b.edit.setOnClickListener { onEdit(a) }
b.delete.setOnClickListener { onDelete(a) }
b.root.setOnClickListener { if (pickMode) onPick(a) else onEdit(a) }
}
}package com.codingeasily.karteasy.ui.address
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.activityViewModels
import androidx.navigation.fragment.findNavController
import androidx.recyclerview.widget.LinearLayoutManager
import com.codingeasily.karteasy.R
import com.codingeasily.karteasy.data.api.UiState
import com.codingeasily.karteasy.data.model.Address
import com.codingeasily.karteasy.databinding.FragmentAddressListBinding
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.loading
import com.codingeasily.karteasy.util.snack
import com.google.android.material.dialog.MaterialAlertDialogBuilder
class AddressListFragment : Fragment() {
companion object {
/** true when opened from checkout: tapping an address selects it and goes back. */
const val ARG_PICK = "pick"
const val RESULT_ADDRESS_ID = "selected_address_id"
}
private var _binding: FragmentAddressListBinding? = null
private val binding get() = _binding!!
// activityViewModels: the edit screen shares the same ViewModel (and list).
private val vm: AddressViewModel by activityViewModels()
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
_binding = FragmentAddressListBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val pick = arguments?.getBoolean(ARG_PICK) == true
val adapter = AddressAdapter(
pickMode = pick,
onPick = ::pickAddress,
onEdit = { openEditor(it.id) },
onDelete = ::confirmDelete
)
binding.list.layoutManager = LinearLayoutManager(requireContext())
binding.list.adapter = adapter
binding.addButton.setOnClickListener { openEditor(0) }
collectWhenStarted(vm.state) { state ->
when (state) {
is UiState.Loading -> binding.state.loading()
is UiState.Error -> binding.state.error(state.message) { vm.load() }
is UiState.Success -> {
adapter.items = state.data
if (state.data.isEmpty()) binding.state.empty(R.drawable.ic_location, R.string.no_addresses)
else binding.state.done()
}
}
}
collectWhenStarted(vm.messages) { snack(it) }
}
override fun onResume() {
super.onResume()
vm.load()
}
private fun pickAddress(a: Address) {
// Send the id to the previous screen (Checkout) and go back.
findNavController().previousBackStackEntry?.savedStateHandle?.set(RESULT_ADDRESS_ID, a.id)
findNavController().navigateUp()
}
private fun openEditor(id: Int) {
findNavController().navigate(R.id.addressEditFragment, bundleOf(AddressEditFragment.ARG_ADDRESS_ID to id))
}
private fun confirmDelete(a: Address) {
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.delete_address)
.setMessage(a.fullText())
.setNegativeButton(R.string.cancel, null)
.setPositiveButton(R.string.delete) { _, _ -> vm.delete(a) }
.show()
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
}Step 3: Add / edit address form
The State field is a Material exposed dropdown menu (MaterialAutoCompleteTextView with inputType="none") filled from arrays.xml. Every field is validated before saving, with the error shown under that field.
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/name_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/full_name">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/name_input"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:autofillHints="name"
android:inputType="textPersonName|textCapWords" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/phone_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:hint="@string/mobile"
app:prefixText="+91 ">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/phone_input"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:autofillHints="phone"
android:inputType="phone"
android:maxLength="10" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/line1_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:hint="@string/address_line1">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/line1_input"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:autofillHints="postalAddress"
android:inputType="textPostalAddress|textCapWords" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/line2_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:hint="@string/address_line2">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/line2_input"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textPostalAddress|textCapWords" />
</com.google.android.material.textfield.TextInputLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:orientation="horizontal">
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/city_layout"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginEnd="6dp"
android:layout_weight="1"
android:hint="@string/city">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/city_input"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textCapWords" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/pincode_layout"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="6dp"
android:layout_weight="1"
android:hint="@string/pincode">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/pincode_input"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:autofillHints="postalCode"
android:inputType="number"
android:maxLength="6" />
</com.google.android.material.textfield.TextInputLayout>
</LinearLayout>
<!-- Exposed dropdown menu = Material spinner -->
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/state_layout"
style="@style/Widget.Material3.TextInputLayout.OutlinedBox.ExposedDropdownMenu"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:hint="@string/state">
<com.google.android.material.textfield.MaterialAutoCompleteTextView
android:id="@+id/state_input"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="none" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.materialswitch.MaterialSwitch
android:id="@+id/default_switch"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:text="@string/make_default" />
<com.google.android.material.button.MaterialButton
android:id="@+id/save_button"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:text="@string/save_address" />
</LinearLayout>
</ScrollView><?xml version="1.0" encoding="utf-8"?>
<resources>
<string-array name="indian_states">
<item>Andaman and Nicobar Islands</item>
<item>Andhra Pradesh</item>
<item>Arunachal Pradesh</item>
<item>Assam</item>
<item>Bihar</item>
<item>Chandigarh</item>
<item>Chhattisgarh</item>
<item>Dadra and Nagar Haveli and Daman and Diu</item>
<item>Delhi</item>
<item>Goa</item>
<item>Gujarat</item>
<item>Haryana</item>
<item>Himachal Pradesh</item>
<item>Jammu and Kashmir</item>
<item>Jharkhand</item>
<item>Karnataka</item>
<item>Kerala</item>
<item>Ladakh</item>
<item>Lakshadweep</item>
<item>Madhya Pradesh</item>
<item>Maharashtra</item>
<item>Manipur</item>
<item>Meghalaya</item>
<item>Mizoram</item>
<item>Nagaland</item>
<item>Odisha</item>
<item>Puducherry</item>
<item>Punjab</item>
<item>Rajasthan</item>
<item>Sikkim</item>
<item>Tamil Nadu</item>
<item>Telangana</item>
<item>Tripura</item>
<item>Uttar Pradesh</item>
<item>Uttarakhand</item>
<item>West Bengal</item>
</string-array>
</resources>package com.codingeasily.karteasy.ui.address
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ArrayAdapter
import androidx.appcompat.app.AppCompatActivity
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.navigation.fragment.findNavController
import com.codingeasily.karteasy.R
import com.codingeasily.karteasy.data.model.Address
import com.codingeasily.karteasy.databinding.FragmentAddressEditBinding
import com.codingeasily.karteasy.util.Validator
import com.codingeasily.karteasy.util.collectWhenStarted
import com.codingeasily.karteasy.util.snack
import com.google.android.material.textfield.TextInputLayout
class AddressEditFragment : Fragment() {
companion object {
const val ARG_ADDRESS_ID = "addressId"
}
private var _binding: FragmentAddressEditBinding? = null
private val binding get() = _binding!!
private val vm: AddressViewModel by activityViewModels()
private var editingId = 0
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
_binding = FragmentAddressEditBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
editingId = arguments?.getInt(ARG_ADDRESS_ID) ?: 0
(requireActivity() as AppCompatActivity).supportActionBar?.setTitle(
if (editingId > 0) R.string.edit_address else R.string.add_address
)
// State dropdown (exposed dropdown menu)
val states = resources.getStringArray(R.array.indian_states)
binding.stateInput.setAdapter(ArrayAdapter(requireContext(), android.R.layout.simple_list_item_1, states))
if (savedInstanceState == null) vm.find(editingId)?.let { fill(it) }
binding.saveButton.setOnClickListener { save() }
collectWhenStarted(vm.saving) { binding.saveButton.isEnabled = !it }
collectWhenStarted(vm.saved) { findNavController().navigateUp() }
collectWhenStarted(vm.messages) { snack(it) }
}
private fun fill(a: Address) {
binding.nameInput.setText(a.name)
binding.phoneInput.setText(a.phone)
binding.line1Input.setText(a.line1)
binding.line2Input.setText(a.line2)
binding.cityInput.setText(a.city)
binding.stateInput.setText(a.state, false)
binding.pincodeInput.setText(a.pincode)
binding.defaultSwitch.isChecked = a.isDefault
}
private fun save() {
val name = binding.nameInput.text.toString()
val phone = binding.phoneInput.text.toString()
val line1 = binding.line1Input.text.toString()
val city = binding.cityInput.text.toString()
val state = binding.stateInput.text.toString()
val pin = binding.pincodeInput.text.toString()
fun check(layout: TextInputLayout, ok: Boolean, msg: Int): Boolean {
layout.error = if (ok) null else getString(msg)
return ok
}
val valid = listOf(
check(binding.nameLayout, Validator.name(name), R.string.err_name),
check(binding.phoneLayout, Validator.phone(phone), R.string.err_phone),
check(binding.line1Layout, line1.trim().length >= 5, R.string.err_address),
check(binding.cityLayout, city.isNotBlank(), R.string.err_required),
check(binding.stateLayout, state.isNotBlank(), R.string.err_required),
check(binding.pincodeLayout, Validator.pincode(pin), R.string.err_pincode)
).all { it }
if (!valid) return
vm.save(
Address(
id = editingId, name = name, phone = phone, line1 = line1,
line2 = binding.line2Input.text.toString(), city = city, state = state,
pincode = pin, isDefault = binding.defaultSwitch.isChecked
)
)
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
}Step 4: Send the chosen address back
Navigation has a simple way to return a value to the previous screen: write it into the previous back-stack entry's SavedStateHandle, then go back. Checkout observes that key.
Step 5: Checkout layout
Four cards — address, coupon, payment, price — and the bottom bar with the total and Place order. When a coupon is applied the input row is replaced by a green "SAVE10 applied" row with Remove.
<?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="4dp">
<!-- 1. Delivery address -->
<com.google.android.material.card.MaterialCardView
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:drawablePadding="8dp"
android:text="@string/deliver_to"
app:drawableStartCompat="@drawable/ic_location"
app:drawableTint="@color/brand" />
<LinearLayout
android:id="@+id/address_card"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal">
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/address_name"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textColor="@color/text_primary"
android:textStyle="bold"
tools:text="Riya Sharma · 9876543210" />
<TextView
android:id="@+id/address_text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:textColor="@color/text_secondary"
tools:text="12 MG Road, Indore, Madhya Pradesh - 452001" />
</LinearLayout>
<com.google.android.material.button.MaterialButton
android:id="@+id/change_address"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:text="@string/change" />
</LinearLayout>
<com.google.android.material.button.MaterialButton
android:id="@+id/add_address"
style="@style/Widget.KartEasy.Button.Outlined"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/add_delivery_address"
android:visibility="gone" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<TextView
android:id="@+id/items_text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingStart="16dp"
android:paddingEnd="16dp"
android:textColor="@color/text_secondary"
tools:text="3 items in this order" />
<!-- 2. Coupon -->
<com.google.android.material.card.MaterialCardView
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:drawablePadding="8dp"
android:text="@string/apply_coupon"
app:drawableStartCompat="@drawable/ic_offer"
app:drawableTint="@color/accent" />
<LinearLayout
android:id="@+id/coupon_form"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/coupon_layout"
style="@style/Widget.Material3.TextInputLayout.OutlinedBox.Dense"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:hint="@string/coupon_code">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/coupon_input"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textCapCharacters"
android:maxLength="30"
android:maxLines="1" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.button.MaterialButton
android:id="@+id/apply_coupon"
style="@style/Widget.Material3.Button.TonalButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginTop="4dp"
android:text="@string/apply" />
</LinearLayout>
<com.google.android.material.chip.ChipGroup
android:id="@+id/coupon_chips"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp" />
<LinearLayout
android:id="@+id/applied_coupon"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@drawable/bg_offer_card"
android:gravity="center_vertical"
android:orientation="horizontal"
android:padding="12dp"
android:visibility="gone">
<TextView
android:id="@+id/applied_coupon_text"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:textColor="@color/success"
android:textStyle="bold"
tools:text="SAVE10 applied · you save ₹259.70" />
<com.google.android.material.button.MaterialButton
android:id="@+id/remove_coupon"
style="@style/Widget.Material3.Button.TextButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/remove"
android:textColor="@color/sale" />
</LinearLayout>
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<!-- 3. Payment -->
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:layout_marginEnd="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/payment_method" />
<com.google.android.material.radiobutton.MaterialRadioButton
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:checked="true"
android:text="@string/cash_on_delivery" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingStart="32dp"
android:text="@string/cod_note"
android:textColor="@color/text_secondary"
android:textSize="13sp" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<!-- 4. Price -->
<include
android:id="@+id/summary"
layout="@layout/layout_price_summary" />
</LinearLayout>
</androidx.core.widget.NestedScrollView>
<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.progressindicator.CircularProgressIndicator
android:id="@+id/placing_progress"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
android:indeterminate="true"
android:visibility="gone"
app:indicatorSize="24dp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/place_order"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:backgroundTint="@color/accent"
android:paddingStart="28dp"
android:paddingEnd="28dp"
android:text="@string/place_order"
android:textColor="@android:color/white" />
</LinearLayout>
<include
android:id="@+id/state"
layout="@layout/layout_state" />
</androidx.constraintlayout.widget.ConstraintLayout>Step 6: CheckoutViewModel
On start, the cart, addresses and coupons are loaded in parallel with async — three requests in the time of one. The default address is pre-selected. placeOrder() blocks double taps with placing.
package com.codingeasily.karteasy.ui.checkout
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.codingeasily.karteasy.data.api.Result
import com.codingeasily.karteasy.data.model.Address
import com.codingeasily.karteasy.data.model.Cart
import com.codingeasily.karteasy.data.model.Coupon
import com.codingeasily.karteasy.data.model.PlacedOrder
import com.codingeasily.karteasy.util.kartApp
import kotlinx.coroutines.async
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 CheckoutViewModel(app: Application) : AndroidViewModel(app) {
data class State(
val loading: Boolean = true,
val error: String? = null,
val cart: Cart? = null,
val addresses: List<Address> = emptyList(),
val selectedAddressId: Int = 0,
val coupons: List<Coupon> = emptyList(),
val appliedCoupon: String = "",
val placing: Boolean = false
) {
val selectedAddress: Address? get() = addresses.firstOrNull { it.id == selectedAddressId }
}
sealed class Event {
data class Message(val text: String) : Event()
data class CouponError(val text: String) : Event()
data class OrderPlaced(val order: PlacedOrder) : Event()
}
private val repo = app.kartApp.repository
private val _state = MutableStateFlow(State())
val state: StateFlow<State> = _state
private val _events = MutableSharedFlow<Event>()
val events: SharedFlow<Event> = _events
init { load() }
/** Cart, addresses and coupons are loaded in parallel with async. */
fun load() {
_state.value = _state.value.copy(loading = true, error = null)
viewModelScope.launch {
val cartJob = async { repo.cart(_state.value.appliedCoupon.ifEmpty { null }) }
val addrJob = async { repo.addresses() }
val couponJob = async { repo.coupons() }
val cart = cartJob.await()
val addresses = addrJob.await()
val coupons = couponJob.await()
if (cart is Result.Error) {
_state.value = _state.value.copy(loading = false, error = cart.message); return@launch
}
if (addresses is Result.Error) {
_state.value = _state.value.copy(loading = false, error = addresses.message); return@launch
}
val list = (addresses as Result.Success).data
val keep = _state.value.selectedAddressId
val selected = list.firstOrNull { it.id == keep } ?: list.firstOrNull { it.isDefault } ?: list.firstOrNull()
_state.value = _state.value.copy(
loading = false,
cart = (cart as Result.Success).data,
addresses = list,
selectedAddressId = selected?.id ?: 0,
coupons = (coupons as? Result.Success)?.data ?: emptyList()
)
}
}
fun selectAddress(id: Int) {
_state.value = _state.value.copy(selectedAddressId = id)
}
fun applyCoupon(code: String) {
if (code.isBlank()) return
viewModelScope.launch {
when (val r = repo.applyCoupon(code)) {
is Result.Success -> {
_state.value = _state.value.copy(cart = r.data, appliedCoupon = r.data.couponCode ?: "")
_events.emit(Event.Message(r.message))
}
is Result.Error -> _events.emit(Event.CouponError(r.message))
}
}
}
fun removeCoupon() {
viewModelScope.launch {
val r = repo.cart()
if (r is Result.Success) _state.value = _state.value.copy(cart = r.data, appliedCoupon = "")
}
}
fun placeOrder() {
val s = _state.value
if (s.placing) return
val address = s.selectedAddress
if (address == null) {
viewModelScope.launch { _events.emit(Event.Message("Please add a delivery address.")) }
return
}
_state.value = s.copy(placing = true)
viewModelScope.launch {
when (val r = repo.placeOrder(address.id, s.appliedCoupon)) {
is Result.Success -> _events.emit(Event.OrderPlaced(r.data))
is Result.Error -> {
_events.emit(Event.Message(r.message))
load() // stock or prices may have changed: show the latest cart
}
}
_state.value = _state.value.copy(placing = false)
}
}
}Step 7: CheckoutFragment
Coupon chips are created in code from the API list — tapping one fills the code and applies it. A coupon error is shown inside the coupon field (couponLayout.error), other messages as a Snackbar. After a successful order we show the notification and open the success screen.
package com.codingeasily.karteasy.ui.checkout
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 com.codingeasily.karteasy.R
import com.codingeasily.karteasy.databinding.FragmentCheckoutBinding
import com.codingeasily.karteasy.notify.Notifier
import com.codingeasily.karteasy.ui.address.AddressListFragment
import com.codingeasily.karteasy.ui.cart.bind
import com.codingeasily.karteasy.ui.orders.OrderSuccessFragment
import com.codingeasily.karteasy.util.Format
import com.codingeasily.karteasy.util.collectWhenStarted
import com.codingeasily.karteasy.util.done
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.codingeasily.karteasy.util.visibleIf
import com.google.android.material.chip.Chip
class CheckoutFragment : Fragment() {
private var _binding: FragmentCheckoutBinding? = null
private val binding get() = _binding!!
private val vm: CheckoutViewModel by viewModels()
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
_binding = FragmentCheckoutBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
// The address list screen sends the chosen address back through our SavedStateHandle.
val handle = findNavController().getBackStackEntry(R.id.checkoutFragment).savedStateHandle
handle.getLiveData<Int>(AddressListFragment.RESULT_ADDRESS_ID).observe(viewLifecycleOwner) { id ->
vm.load()
vm.selectAddress(id)
handle.remove<Int>(AddressListFragment.RESULT_ADDRESS_ID)
}
binding.changeAddress.setOnClickListener {
findNavController().navigate(R.id.addressListFragment, bundleOf(AddressListFragment.ARG_PICK to true))
}
binding.addAddress.setOnClickListener {
findNavController().navigate(R.id.addressListFragment, bundleOf(AddressListFragment.ARG_PICK to true))
}
binding.applyCoupon.setOnClickListener {
binding.couponLayout.error = null
vm.applyCoupon(binding.couponInput.text.toString())
}
binding.removeCoupon.setOnClickListener {
binding.couponInput.setText("")
vm.removeCoupon()
}
binding.placeOrder.setOnClickListener { vm.placeOrder() }
collectWhenStarted(vm.state) { s ->
when {
s.loading && s.cart == null -> { binding.content.hide(); binding.bottomBar.hide(); binding.state.loading() }
s.error != null && s.cart == null -> {
binding.content.hide(); binding.bottomBar.hide(); binding.state.error(s.error) { vm.load() }
}
else -> {
binding.state.done(); binding.content.show(); binding.bottomBar.show()
render(s)
}
}
}
collectWhenStarted(vm.events) { e ->
when (e) {
is CheckoutViewModel.Event.Message -> snack(e.text)
is CheckoutViewModel.Event.CouponError -> binding.couponLayout.error = e.text
is CheckoutViewModel.Event.OrderPlaced -> {
val total = Format.price(e.order.total)
Notifier.orderPlaced(requireContext(), e.order.orderId, e.order.orderNo, total)
findNavController().navigate(
R.id.action_checkout_to_success,
bundleOf(
OrderSuccessFragment.ARG_ORDER_ID to e.order.orderId,
OrderSuccessFragment.ARG_ORDER_NO to e.order.orderNo,
OrderSuccessFragment.ARG_TOTAL to total
)
)
}
}
}
}
private fun render(s: CheckoutViewModel.State) {
val cart = s.cart ?: return
val a = s.selectedAddress
binding.addressCard.visibleIf(a != null)
binding.addAddress.visibleIf(a == null)
if (a != null) {
binding.addressName.text = getString(R.string.name_phone, a.name, a.phone)
binding.addressText.text = a.fullText()
}
binding.itemsText.text = resources.getQuantityString(R.plurals.items_in_order, cart.itemCount, cart.itemCount)
binding.summary.bind(cart)
binding.total.text = Format.price(cart.total)
// Applied coupon row vs. coupon input
val applied = s.appliedCoupon.isNotEmpty()
binding.appliedCoupon.visibleIf(applied)
binding.couponForm.visibleIf(!applied)
binding.appliedCouponText.text = getString(R.string.coupon_applied, s.appliedCoupon, Format.price(cart.discount))
// Available coupons as chips: tap to fill the code.
binding.couponChips.removeAllViews()
s.coupons.forEach { c ->
val chip = Chip(requireContext()).apply {
text = getString(R.string.coupon_chip, c.code, c.title)
isCheckable = false
setOnClickListener {
binding.couponInput.setText(c.code)
binding.couponLayout.error = null
vm.applyCoupon(c.code)
}
}
binding.couponChips.addView(chip)
}
binding.couponChips.visibleIf(!applied && s.coupons.isNotEmpty())
binding.placeOrder.isEnabled = !s.placing && a != null && cart.itemCount > 0
binding.placeOrder.setText(if (s.placing) R.string.placing_order else R.string.place_order)
binding.placingProgress.visibleIf(s.placing)
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
}Adding UPI / cards later
// Where an online payment would go (KartEasy ships with Cash on Delivery only): // 1. placeOrder() creates the order on the server with status "pending_payment" // 2. open the payment gateway's Android SDK checkout with order_no + total // 3. the gateway calls YOUR server (webhook) – the server verifies the signature // and marks the order as paid // 4. the app refreshes the order: status "placed" + paid // Never mark an order as paid from the app itself.
The server side of this is described in the Orders API lesson.
