How to Make a POS App
How to Vibe Code a Native Android POS App
Stay in your creative flow. Direct AI models with high-level retail intent, stitch together a Jetpack Compose Point-of-Sale register with live stock tracking, promo discounts, and itemized receipts, and run it on your phone or tablet in under 30 minutes.
Core Stack
Kotlin Compose
Order Engine
Reactive Cart + Stock
AI Partner
Claude / Gemini
Target Time
20โ30 Mins
The Vibe Coding Mobile Philosophy
Building a Point of Sale (POS) terminal traditionally meant weeks of wiring RecyclerViews, SQLite inventory triggers, tax rounding helpers, and payment state machines. Vibe coding flips the process: you define your catalog categories, tax/discount rules, and checkout flow, while conversational AI writes the reactive Compose state and receipt generator.
Old-School Mobile Stress
- Manually syncing cart quantities with RecyclerView adapter positions.
- Floating-point rounding bugs on tax, tips, and percentage discounts.
- Writing brittle XML layouts that break between phones and 10-inch tablets.
- Spending days mocking simple receipt previews and payment dialogs.
The Vibe Coder Way
- Prompt AI for a reactive product grid with instant category filtering.
- Get automatic subtotal, promo discount, and tax calculations in real time.
- Deduct inventory stock counts automatically when an order is completed.
- Generate itemized digital receipts ready for Bluetooth thermal printing.
The 4-Step Blueprint
From an empty Android Studio project to a fast cashier checkout register.
Empty Compose Setup
Create a new Empty Activity (Compose) project in Android Studio named VibePOS. Material 3 provides grids, filter chips, and receipt modals out of the box.
LazyVerticalGrid adapts cleanly from compact handheld phones to wide countertop tablets.
Configure Your Register
Choose your store vertical, inventory tracking level, and checkout workflow in the configurator below, then paste the prompt into Claude or Gemini.
Single-File Drop-In
Paste the generated code into MainActivity.kt to test category filtering, VIP discount math, tender selection, and itemized receipts.
FontFamily.Monospace so it mirrors a real 58mm/80mm thermal paper printout.
Add Hardware & Persistence
Use the Prompt Vault below to add cash-tendered change calculation, stock management sheets, and end-of-shift Z-reports.
Vibe Prompt Configurator
Select your store vertical, inventory engine, and checkout features. Copy the generated prompt directly into Claude, Cursor, or Gemini.
Essential Elements of a POS App
A Point of Sale (POS) app powers retail and hospitality checkout counters. Make sure your register includes these 6 core capabilities:
-
Accept Multiple Payment Methods
Support credit and debit cards, NFC mobile wallets (Google Pay / Apple Pay), gift cards, and cash with instant change-due calculation.
-
Track Sales & Inventory Stock
Monitor product stock levels in real time, show low-stock alerts on item tiles, and automatically decrement inventory when orders complete.
-
Generate Receipts & Promo Discounts
Calculate percentage promos and sales tax accurately, and generate itemized digital or printed receipts for every transaction.
-
Manage Customer Loyalty Profiles
Keep track of repeat customers, contact details, purchase history, and VIP loyalty discounts right at the checkout counter.
-
Connect Thermal Receipt Printers
Format receipts in fixed-width monospace columns ready to send to wireless Wi-Fi or Bluetooth ESC/POS thermal receipt printers.
-
Real-Time Shift & Revenue Summary
Give store owners an instant header readout of completed orders and gross shift revenue so they always know how the day is performing.
The "Golden" Single-File Compose Template
Replace the contents of your MainActivity.kt with this to run a working POS Register right now.
package com.example.vibepos
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
data class PosProduct(
val id: Int,
val name: String,
val category: String,
val price: Double,
val stock: Int
)
data class ReceiptRecord(
val orderNumber: Int,
val items: List<Pair<PosProduct, Int>>,
val subtotal: Double,
val discount: Double,
val tax: Double,
val total: Double,
val paymentMethod: String
)
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
MaterialTheme(
colorScheme = darkColorScheme(
primary = Color(0xFF10B981),
background = Color(0xFF0B1320),
surface = Color(0xFF162032)
)
) {
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
PosTerminalScreen()
}
}
}
}
}
@Composable
fun PosTerminalScreen() {
val categories = listOf("All", "Coffee", "Bakery", "Merch")
var selectedCategory by remember { mutableStateOf("All") }
val catalog = remember {
mutableStateListOf(
PosProduct(1, "Oat Cortado", "Coffee", 4.75, 14),
PosProduct(2, "Cold Brew Tonic", "Coffee", 5.50, 9),
PosProduct(3, "Matcha Latte", "Coffee", 5.25, 11),
PosProduct(4, "Almond Croissant", "Bakery", 4.50, 6),
PosProduct(5, "Cardamom Bun", "Bakery", 4.25, 8),
PosProduct(6, "Ceramic Tumbler", "Merch", 24.00, 4)
)
}
val cart = remember { mutableStateMapOf<Int, Int>() }
var applyVipDiscount by remember { mutableStateOf(false) }
var paymentMethod by remember { mutableStateOf("Card Tap") }
var completedOrders by remember { mutableIntStateOf(0) }
var dailyRevenue by remember { mutableDoubleStateOf(0.0) }
var activeReceipt by remember { mutableStateOf<ReceiptRecord?>(null) }
val filteredProducts = catalog.filter {
selectedCategory == "All" || it.category == selectedCategory
}
val cartEntries = cart.entries.mapNotNull { (id, qty) ->
catalog.find { it.id == id }?.let { it to qty }
}
val subtotal = cartEntries.sumOf { (prod, qty) -> prod.price * qty }
val discount = if (applyVipDiscount) subtotal * 0.10 else 0.0
val taxable = (subtotal - discount).coerceAtLeast(0.0)
val tax = taxable * 0.08
val grandTotal = taxable + tax
Column(modifier = Modifier.fillMaxSize().padding(14.dp)) {
// Top Shift Summary Bar
Row(
modifier = Modifier.fillMaxWidth().padding(bottom = 10.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column {
Text("VibePOS Register #01", fontSize = 20.sp, fontWeight = FontWeight.ExtraBold, color = Color.White)
Text("Orders: $completedOrders โข Shift Sales: $${String.format("%.2f", dailyRevenue)}", fontSize = 12.sp, color = Color(0xFF34D399))
}
FilterChip(
selected = applyVipDiscount,
onClick = { applyVipDiscount = !applyVipDiscount },
label = { Text(if (applyVipDiscount) "VIP 10% ON" else "VIP Promo (10%)") }
)
}
// Category Filter Pills
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(bottom = 10.dp)) {
items(categories) { cat ->
FilterChip(
selected = selectedCategory == cat,
onClick = { selectedCategory = cat },
label = { Text(cat) }
)
}
}
// Product Tile Grid
LazyVerticalGrid(
columns = GridCells.Fixed(2),
verticalArrangement = Arrangement.spacedBy(10.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier.weight(1f)
) {
items(filteredProducts, key = { it.id }) { product ->
val inCart = cart[product.id] ?: 0
val remaining = product.stock - inCart
Card(
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(
containerColor = if (remaining > 0) MaterialTheme.colorScheme.surface else Color(0xFF1E293B).copy(alpha = 0.5f)
),
modifier = Modifier
.fillMaxWidth()
.clickable(enabled = remaining > 0) {
cart[product.id] = inCart + 1
}
) {
Column(modifier = Modifier.padding(12.dp)) {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(product.category.uppercase(), fontSize = 10.sp, color = Color(0xFF94A3B8), fontWeight = FontWeight.Bold)
Text(
text = if (remaining > 0) "$remaining left" else "SOLD OUT",
fontSize = 10.sp,
color = if (remaining > 3) Color(0xFF34D399) else Color(0xFFF87171),
fontWeight = FontWeight.Bold
)
}
Spacer(modifier = Modifier.height(6.dp))
Text(product.name, fontSize = 15.sp, fontWeight = FontWeight.Bold, color = Color.White)
Spacer(modifier = Modifier.height(4.dp))
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
Text("$${String.format("%.2f", product.price)}", fontSize = 14.sp, color = Color(0xFF38BDF8), fontWeight = FontWeight.SemiBold)
if (inCart > 0) {
Box(
modifier = Modifier.clip(RoundedCornerShape(8.dp)).background(Color(0xFF10B981)).padding(horizontal = 8.dp, vertical = 2.dp)
) {
Text("x$inCart", fontSize = 11.sp, color = Color.Black, fontWeight = FontWeight.ExtraBold)
}
}
}
}
}
}
}
Spacer(modifier = Modifier.height(10.dp))
// Order Ticket & Checkout Dock
Card(
shape = RoundedCornerShape(18.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)
) {
Column(modifier = Modifier.padding(14.dp)) {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
Text("Current Ticket (${cartEntries.sumOf { it.second }} items)", fontWeight = FontWeight.Bold, color = Color.White, fontSize = 14.sp)
if (cart.isNotEmpty()) {
TextButton(onClick = { cart.clear() }) {
Text("Clear Ticket", fontSize = 12.sp, color = Color(0xFFF87171))
}
}
}
if (cartEntries.isNotEmpty()) {
LazyColumn(modifier = Modifier.heightIn(max = 96.dp)) {
items(cartEntries) { (prod, qty) ->
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text("${qty}x ${prod.name}", fontSize = 13.sp, color = Color(0xFFE2E8F0))
Text("$${String.format("%.2f", prod.price * qty)}", fontSize = 13.sp, color = Color.White, fontWeight = FontWeight.Medium)
}
}
}
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp), color = Color(0xFF334155))
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(bottom = 8.dp)) {
listOf("Card Tap", "Cash", "NFC Wallet").forEach { tender ->
FilterChip(
selected = paymentMethod == tender,
onClick = { paymentMethod = tender },
label = { Text(tender, fontSize = 12.sp) }
)
}
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column {
Text("Tax (8%): $${String.format("%.2f", tax)}", fontSize = 11.sp, color = Color(0xFF94A3B8))
Text("Total: $${String.format("%.2f", grandTotal)}", fontSize = 20.sp, fontWeight = FontWeight.ExtraBold, color = Color(0xFF34D399))
}
Button(
enabled = cartEntries.isNotEmpty(),
onClick = {
cartEntries.forEach { (prod, qty) ->
val idx = catalog.indexOfFirst { it.id == prod.id }
if (idx != -1) {
catalog[idx] = catalog[idx].copy(stock = (catalog[idx].stock - qty).coerceAtLeast(0))
}
}
completedOrders += 1
dailyRevenue += grandTotal
activeReceipt = ReceiptRecord(
orderNumber = 100 + completedOrders,
items = cartEntries,
subtotal = subtotal,
discount = discount,
tax = tax,
total = grandTotal,
paymentMethod = paymentMethod
)
cart.clear()
},
shape = RoundedCornerShape(12.dp)
) {
Text("Charge $${String.format("%.2f", grandTotal)}", fontWeight = FontWeight.Bold)
}
}
}
}
}
activeReceipt?.let { receipt ->
AlertDialog(
onDismissRequest = { activeReceipt = null },
title = { Text("RECEIPT #${receipt.orderNumber}", fontFamily = FontFamily.Monospace, fontWeight = FontWeight.Bold) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text("TENDER: ${receipt.paymentMethod}", fontFamily = FontFamily.Monospace, fontSize = 12.sp)
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
receipt.items.forEach { (prod, qty) ->
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text("${qty}x ${prod.name}", fontFamily = FontFamily.Monospace, fontSize = 12.sp)
Text("$${String.format("%.2f", prod.price * qty)}", fontFamily = FontFamily.Monospace, fontSize = 12.sp)
}
}
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
if (receipt.discount > 0) {
Text("VIP DISCOUNT: -$${String.format("%.2f", receipt.discount)}", fontFamily = FontFamily.Monospace, fontSize = 12.sp, color = Color(0xFF34D399))
}
Text("TAX (8%): $${String.format("%.2f", receipt.tax)}", fontFamily = FontFamily.Monospace, fontSize = 12.sp)
Text("TOTAL PAID: $${String.format("%.2f", receipt.total)}", fontFamily = FontFamily.Monospace, fontWeight = FontWeight.ExtraBold, fontSize = 14.sp)
}
},
confirmButton = {
Button(onClick = { activeReceipt = null }) {
Text("Done / New Order")
}
}
)
}
}
How to Debug POS & Cart Glitches
Keep inventory state and currency math rock-solid with these three debugging patterns:
Stock Count Doesn't Redraw on Tile
When updating an item inside a mutableStateListOf, replace the instance with .copy() so Compose detects the change:
catalog[idx] = catalog[idx].copy(stock = newStock)
Floating-Point Price Drift
Always format display totals to 2 decimal places or ask your AI for BigDecimal math:
"Format all currency labels with String.format(Locale.US, '%.2f', amount) or BigDecimal RoundingMode.HALF_UP."
Tablet Split-Screen Layout
Make your register shine on 10-inch countertop tablets by prompting: "Use BoxWithConstraints so screens wider than 600.dp show a 2-pane split layout (Catalog Grid left, Ticket right)."
The Vibe Prompt Vault
Copy-paste prompts to add cash change calculation, stock management, and ESC/POS thermal receipt reports.
๐ต Prompt: Add Quick Cash Tendered & Change Due Calculator
"When the 'Cash' payment tender is selected in PosTerminalScreen, show quick-tap cash denomination chips (Exact, $10, $20, $50) plus a custom tendered input field. Automatically calculate and display 'Change Due' both in the checkout bar and on the printed receipt modal."
๐ฆ Prompt: Add Inventory Manager Modal to Restock & Create Items
"Add a 'Manage Stock' button in the top bar that opens a ModalBottomSheet where the cashier can increment/decrement stock counts for existing PosProducts or add a brand-new product (Name, Category, Price, Initial Stock) to the catalog."
๐จ๏ธ Prompt: Add End-of-Shift Z-Report & Bluetooth ESC/POS Formatter
"Store all completed ReceiptRecords in a state list and add a 'Shift Report' dialog that aggregates total gross sales, total discounts, tax collected, and revenue broken down by payment tender (Card Tap vs. Cash vs. NFC Wallet), formatted as a 32-column ESC/POS text block with a Copy/Share button."