Membangun Aplikasi Kasir Android dengan Kotlin: Panduan Praktis dari Nol

Memiliki sistem kasir yang efisien adalah kunci sukses bagi banyak bisnis, mulai dari toko kelontong, kafe, hingga usaha kecil menengah. Tinggalkan cara manual atau aplikasi yang kaku, saatnya kita belajar membangun sendiri aplikasi kasir Android modern menggunakan Kotlin. Ini bukan sekadar teori, tapi panduan praktis yang akan membawa Anda dari nol hingga memiliki fondasi aplikasi kasir yang fungsional.

Sebagai developer, Anda pasti tahu betapa pentingnya performa, kemudahan pengembangan, dan maintainability sebuah aplikasi. Kotlin dengan segala keunggulannya adalah pilihan tepat untuk pengembangan Android saat ini. Artikel ini akan memandu Anda melalui arsitektur, pemilihan database, desain UI, hingga implementasi fitur inti seperti manajemen produk dan transaksi penjualan.

Daftar Isi sembunyikan

Mengapa Membangun Aplikasi Kasir Android Sendiri?

Di pasar memang banyak aplikasi kasir siap pakai. Namun, ada beberapa alasan kuat mengapa developer seringkali memilih untuk membangunnya sendiri atau mengkustomisasi:

  • Kustomisasi Tanpa Batas: Setiap bisnis memiliki alur kerja unik. Dengan membangun sendiri, Anda bisa menyesuaikan fitur persis dengan kebutuhan, tanpa harus berkompromi dengan batasan aplikasi pihak ketiga.
  • Kontrol Penuh Data: Data penjualan dan pelanggan adalah aset berharga. Memiliki aplikasi sendiri berarti Anda memiliki kontrol penuh atas bagaimana data disimpan, diakses, dan diamankan.
  • Mengurangi Biaya Jangka Panjang: Meskipun investasi awal mungkin lebih besar, jangka panjangnya Anda terbebas dari biaya langganan bulanan atau tahunan yang terus-menerus.
  • Pembelajaran Berharga: Bagi developer, ini adalah proyek yang sangat baik untuk mengasah skill Android development, khususnya dalam manajemen data, UI/UX, dan arsitektur aplikasi yang scalable.

Kita akan menggunakan Kotlin sebagai bahasa pemrograman utama dan beberapa library Android modern untuk memastikan aplikasi kita robust dan mudah dikembangkan lebih lanjut.

Persiapan Awal: Tools dan Pengetahuan Dasar

Sebelum kita terjun langsung ke coding, pastikan Anda memiliki prasyarat berikut:

  • Android Studio: Lingkungan pengembangan terintegrasi (IDE) resmi dari Google. Pastikan sudah terinstal dengan versi terbaru.
  • Dasar-dasar Kotlin: Pemahaman tentang sintaks dasar Kotlin, seperti variabel, fungsi, class, object, dan null safety.
  • Dasar-dasar Pengembangan Android: Familiar dengan konsep Activity, Fragment, Layout XML, dan siklus hidup komponen Android.
  • Dasar-dasar SQL: Pemahaman dasar tentang cara kerja database relasional akan sangat membantu, meskipun kita akan menggunakan library abstraksi.

Tidak perlu menjadi expert, yang penting Anda siap belajar dan mencoba.

Memilih Arsitektur Aplikasi yang Tepat: MVVM

Untuk aplikasi Android modern, arsitektur yang bersih dan terstruktur sangat penting. Kita akan mengadopsi pola MVVM (Model-View-ViewModel), yang direkomendasikan oleh Google dan sangat cocok untuk Kotlin serta Jetpack Components.

  • Model: Merepresentasikan data dan logika bisnis aplikasi. Ini bisa berupa kelas data (seperti produk, transaksi), repository untuk interaksi database, atau API service.
  • View: Komponen UI seperti Activity atau Fragment. Tugasnya hanya menampilkan data dari ViewModel dan mengirimkan event interaksi pengguna ke ViewModel. View tidak boleh memiliki logika bisnis.
  • ViewModel: Menjembatani antara Model dan View. ViewModel bertanggung jawab untuk menyediakan data ke View, memproses logika bisnis, dan merespons event dari View. Ia dirancang untuk bertahan melewati perubahan konfigurasi (misalnya rotasi layar).

Penggunaan LiveData atau Flow untuk observasi data dari ViewModel ke View akan memastikan UI selalu update secara reaktif dan aman dari kebocoran memori.

Fitur Inti Aplikasi Kasir yang Akan Kita Bangun

Aplikasi kasir yang kita buat akan fokus pada beberapa fitur esensial:

  • Manajemen Produk: Menambahkan, mengedit, menghapus, dan melihat daftar produk lengkap dengan harga dan stok.
  • Transaksi Penjualan: Proses penambahan produk ke keranjang belanja, perhitungan total, dan penyimpanan transaksi.
  • Riwayat Transaksi: Melihat daftar transaksi yang sudah dilakukan.

Fitur lain seperti integrasi printer, barcode scanner, atau manajemen pelanggan bisa dikembangkan kemudian, setelah fondasi inti ini kuat.

Langkah Demi Langkah: Membangun Aplikasi Kasir

Langkah 1: Setup Proyek Baru di Android Studio

Buka Android Studio dan buat proyek baru:

  1. Pilih “New Project”.
  2. Pilih “Empty Activity” dan klik Next.
  3. Isi detail proyek:
    • Name: KasirApp
    • Package name: com.tubianto.kasirapp (sesuaikan dengan nama domain Anda)
    • Save location: Pilih lokasi penyimpanan proyek.
    • Language: Kotlin
    • Minimum SDK version: Pilih API 21 atau yang lebih baru (misal API 24).
  4. Klik “Finish”. Tunggu Android Studio selesai menyiapkan proyek.

Setelah proyek siap, tambahkan dependency yang diperlukan di file build.gradle (Module :app):

// Room components
implementation "androidx.room:room-runtime:2.6.1"
kapt "androidx.room:room-compiler:2.6.1"
implementation "androidx.room:room-ktx:2.6.1" // For Coroutines support

// Lifecycle components (ViewModel & LiveData)
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0"
implementation "androidx.lifecycle:lifecycle-livedata-ktx:2.7.0"
implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.7.0"

// Activity & Fragment KTX
implementation "androidx.activity:activity-ktx:1.9.0"
implementation "androidx.fragment:fragment-ktx:1.7.0"

// Material Design
implementation "com.google.android.material:material:1.12.0"

// Coroutines
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3"

Jangan lupa tambahkan plugin kotlin-kapt di bagian atas file build.gradle (Module :app):

plugins {
    id 'com.android.application'
    id 'org.jetbrains.kotlin.android'
    id 'kotlin-kapt' // Tambahkan ini
}

Kemudian Sync Project with Gradle Files.

Langkah 2: Mendesain Struktur Database dengan Room Persistence Library

Room adalah lapisan abstraksi di atas SQLite yang memudahkan kita mengelola database. Kita akan membuat entity untuk Produk dan Transaksi.

Entity (Product.kt)

Buat file data/Product.kt:

package com.tubianto.kasirapp.data

import androidx.room.Entity
import androidx.room.PrimaryKey

@Entity(tableName = "products")
data class Product(
    @PrimaryKey(autoGenerate = true)
    val id: Int = 0,
    val name: String,
    val price: Double,
    var stock: Int
)

DAO (ProductDao.kt)

Buat file data/ProductDao.kt:

package com.tubianto.kasirapp.data

import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Update
import kotlinx.coroutines.flow.Flow

@Dao
interface ProductDao {
    @Query("SELECT * FROM products ORDER BY name ASC")
    fun getAllProducts(): Flow<List<Product>>

    @Query("SELECT * FROM products WHERE id = :id")
    suspend fun getProductById(id: Int): Product?

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insertProduct(product: Product)

    @Update
    suspend fun updateProduct(product: Product)

    @Delete
    suspend fun deleteProduct(product: Product)
}

Database (AppDatabase.kt)

Buat file data/AppDatabase.kt:

package com.tubianto.kasirapp.data

import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase

@Database(entities = [Product::class], version = 1, exportSchema = false)
abstract class AppDatabase : RoomDatabase() {
    abstract fun productDao(): ProductDao

    companion object {
        @Volatile
        private var INSTANCE: AppDatabase? = null

        fun getDatabase(context: Context): AppDatabase {
            return INSTANCE ?: synchronized(this) {
                val instance = Room.databaseBuilder(
                    context.applicationContext,
                    AppDatabase::class.java,
                    "kasir_app_database"
                ).fallbackToDestructiveMigration() // Hanya untuk pengembangan, jangan di produksi
                 .build()
                INSTANCE = instance
                instance
            }
        }
    }
}

Penting: fallbackToDestructiveMigration() digunakan agar database bisa direset saat ada perubahan skema (entity) selama pengembangan. Jangan gunakan ini di aplikasi produksi karena akan menghapus semua data pengguna. Untuk produksi, gunakan migrasi database yang tepat.

Langkah 3: Membuat Repository

Repository adalah jembatan antara ViewModel dan sumber data (dalam kasus ini, Room database). Buat file data/ProductRepository.kt:

package com.tubianto.kasirapp.data

import kotlinx.coroutines.flow.Flow

class ProductRepository(private val productDao: ProductDao) {
    val allProducts: Flow<List<Product>> = productDao.getAllProducts()

    suspend fun insert(product: Product) {
        productDao.insertProduct(product)
    }

    suspend fun update(product: Product) {
        productDao.updateProduct(product)
    }

    suspend fun delete(product: Product) {
        productDao.deleteProduct(product)
    }

    suspend fun getProductById(id: Int): Product? {
        return productDao.getProductById(id)
    }
}

Langkah 4: Desain UI untuk Manajemen Produk

Kita akan membuat dua Activity/Fragment: satu untuk menampilkan daftar produk dan satu lagi untuk menambah/mengedit produk.

Layout Daftar Produk (activity_main.xml)

Untuk saat ini, kita akan menggunakan MainActivity sebagai daftar produk. Ubah activity_main.xml:

<?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"
    tools:context=".MainActivity">

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/recyclerViewProducts"
        android:layout_width="0dp"
        android:layout_height="0dp"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintBottom_toTopOf="@id/fabAddProduct"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        tools:listitem="@layout/item_product"
        android:paddingBottom="8dp"
        android:clipToPadding="false"/>

    <com.google.android.material.floatingactionbutton.FloatingActionButton
        android:id="@+id/fabAddProduct"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_margin="16dp"
        android:clickable="true"
        android:focusable="true"
        android:contentDescription="Add new product"
        android:src="@drawable/ic_add"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintBottom_toBottomOf="parent"/>

</androidx.constraintlayout.widget.ConstraintLayout>

Buat ikon ic_add di res/drawable (klik kanan drawable -> New -> Vector Asset, cari “add”).

Layout Item Produk (item_product.xml)

Buat file layout/item_product.xml untuk setiap item di RecyclerView:

<?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_marginHorizontal="16dp"
    android:layout_marginTop="8dp"
    app:cardElevation="2dp">

    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:padding="16dp">

        <TextView
            android:id="@+id/tvProductName"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:textStyle="bold"
            android:textSize="18sp"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent"
            app:layout_constraintEnd_toStartOf="@+id/tvProductPrice"
            android:layout_marginEnd="8dp"
            tools:text="Nama Produk A" />

        <TextView
            android:id="@+id/tvProductPrice"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:textSize="16sp"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintTop_toTopOf="parent"
            tools:text="Rp 15.000" />

        <TextView
            android:id="@+id/tvProductStock"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:textSize="14sp"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toBottomOf="@+id/tvProductName"
            android:layout_marginTop="4dp"
            tools:text="Stok: 50" />

        <ImageView
            android:id="@+id/btnEdit"
            android:layout_width="24dp"
            android:layout_height="24dp"
            android:src="@drawable/ic_edit"
            app:layout_constraintEnd_toStartOf="@+id/btnDelete"
            app:layout_constraintBottom_toBottomOf="parent"
            android:layout_marginEnd="16dp"
            android:contentDescription="Edit Product"
            app:tint="?attr/colorOnSurfaceVariant" />

        <ImageView
            android:id="@+id/btnDelete"
            android:layout_width="24dp"
            android:layout_height="24dp"
            android:src="@drawable/ic_delete"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintBottom_toBottomOf="parent"
            android:contentDescription="Delete Product"
            app:tint="?attr/colorError" />

    </androidx.constraintlayout.widget.ConstraintLayout>
</com.google.android.material.card.MaterialCardView>

Buat ikon ic_edit dan ic_delete dari Vector Asset.

Layout Tambah/Edit Produk (activity_add_edit_product.xml)

Buat file layout/activity_add_edit_product.xml:

<?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"
    android:padding="16dp"
    tools:context=".AddEditProductActivity">

    <com.google.android.material.textfield.TextInputLayout
        android:id="@+id/tilProductName"
        style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:hint="Nama Produk"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toEndOf="parent">

        <com.google.android.material.textfield.TextInputEditText
            android:id="@+id/etProductName"
            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/tilProductPrice"
        style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:hint="Harga Produk (Rp)"
        android:layout_marginTop="16dp"
        app:layout_constraintTop_toBottomOf="@id/tilProductName"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toEndOf="parent">

        <com.google.android.material.textfield.TextInputEditText
            android:id="@+id/etProductPrice"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:inputType="numberDecimal" />
    </com.google.android.material.textfield.TextInputLayout>

    <com.google.android.material.textfield.TextInputLayout
        android:id="@+id/tilProductStock"
        style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:hint="Stok Produk"
        android:layout_marginTop="16dp"
        app:layout_constraintTop_toBottomOf="@id/tilProductPrice"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toEndOf="parent">

        <com.google.android.material.textfield.TextInputEditText
            android:id="@+id/etProductStock"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:inputType="number" />
    </com.google.android.material.textfield.TextInputLayout>

    <Button
        android:id="@+id/btnSaveProduct"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:text="Simpan Produk"
        android:layout_marginTop="24dp"
        app:layout_constraintTop_toBottomOf="@id/tilProductStock"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toEndOf="parent"/>

</androidx.constraintlayout.widget.ConstraintLayout>

Langkah 5: Mengimplementasikan ViewModel dan UI Logic

ProductViewModel.kt

Buat file viewmodel/ProductViewModel.kt:

package com.tubianto.kasirapp.viewmodel

import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.asLiveData
import androidx.lifecycle.viewModelScope
import com.tubianto.kasirapp.data.Product
import com.tubianto.kasirapp.data.ProductRepository
import kotlinx.coroutines.launch

class ProductViewModel(private val repository: ProductRepository) : ViewModel() {

    val allProducts = repository.allProducts.asLiveData()

    fun insert(product: Product) = viewModelScope.launch {
        repository.insert(product)
    }

    fun update(product: Product) = viewModelScope.launch {
        repository.update(product)
    }

    fun delete(product: Product) = viewModelScope.launch {
        repository.delete(product)
    }

    suspend fun getProductById(id: Int): Product? {
        return repository.getProductById(id)
    }
}

class ProductViewModelFactory(private val repository: ProductRepository) : ViewModelProvider.Factory {
    override fun <T : ViewModel> create(modelClass: Class<T>): T {
        if (modelClass.isAssignableFrom(ProductViewModel::class.java)) {
            @Suppress("UNCHECKED_CAST")
            return ProductViewModel(repository) as T
        }
        throw IllegalArgumentException("Unknown ViewModel class")
    }
}

ProductAdapter.kt

Buat file adapter/ProductAdapter.kt:

package com.tubianto.kasirapp.adapter

import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.TextView
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import com.tubianto.kasirapp.R
import com.tubianto.kasirapp.data.Product
import java.text.NumberFormat
import java.util.Locale

class ProductAdapter(
    private val onDeleteClick: (Product) -> Unit,
    private val onEditClick: (Product) -> Unit
) : ListAdapter<Product, ProductAdapter.ProductViewHolder>(ProductsComparator()) {

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ProductViewHolder {
        return ProductViewHolder.create(parent)
    }

    override fun onBindViewHolder(holder: ProductViewHolder, position: Int) {
        val current = getItem(position)
        holder.bind(current)
        holder.btnDelete.setOnClickListener { onDeleteClick(current) }
        holder.btnEdit.setOnClickListener { onEditClick(current) }
    }

    class ProductViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
        private val tvProductName: TextView = itemView.findViewById(R.id.tvProductName)
        private val tvProductPrice: TextView = itemView.findViewById(R.id.tvProductPrice)
        private val tvProductStock: TextView = itemView.findViewById(R.id.tvProductStock)
        val btnDelete: ImageView = itemView.findViewById(R.id.btnDelete)
        val btnEdit: ImageView = itemView.findViewById(R.id.btnEdit)

        fun bind(product: Product) {
            tvProductName.text = product.name
            val formattedPrice = NumberFormat.getCurrencyInstance(Locale("id", "ID")).format(product.price)
            tvProductPrice.text = formattedPrice
            tvProductStock.text = "Stok: ${product.stock}"
        }

        companion object {
            fun create(parent: ViewGroup): ProductViewHolder {
                val view: View = LayoutInflater.from(parent.context)
                    .inflate(R.layout.item_product, parent, false)
                return ProductViewHolder(view)
            }
        }
    }

    class ProductsComparator : DiffUtil.ItemCallback<Product>() {
        override fun areItemsTheSame(oldItem: Product, newItem: Product): Boolean {
            return oldItem == newItem
        }

        override fun areContentsTheSame(oldItem: Product, newItem: Product): Boolean {
            return oldItem.id == newItem.id
        }
    }
}

MainActivity.kt (Daftar Produk)

Ubah MainActivity.kt:

package com.tubianto.kasirapp

import android.content.Intent
import android.os.Bundle
import android.widget.Toast
import androidx.activity.viewModels
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.Observer
import androidx.recyclerview.widget.LinearLayoutManager
import com.tubianto.kasirapp.adapter.ProductAdapter
import com.tubianto.kasirapp.data.AppDatabase
import com.tubianto.kasirapp.data.Product
import com.tubianto.kasirapp.data.ProductRepository
import com.tubianto.kasirapp.databinding.ActivityMainBinding
import com.tubianto.kasirapp.viewmodel.ProductViewModel
import com.tubianto.kasirapp.viewmodel.ProductViewModelFactory

class MainActivity : AppCompatActivity() {

    private lateinit var binding: ActivityMainBinding
    private val productViewModel: ProductViewModel by viewModels {
        ProductViewModelFactory(ProductRepository(AppDatabase.getDatabase(applicationContext).productDao()))
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(binding.root)

        val productAdapter = ProductAdapter(
            onDeleteClick = { product -> confirmDeleteProduct(product) },
            onEditClick = { product -> navigateToAddEditProduct(product.id) }
        )
        binding.recyclerViewProducts.adapter = productAdapter
        binding.recyclerViewProducts.layoutManager = LinearLayoutManager(this)

        productViewModel.allProducts.observe(this, Observer { products ->
            products?.let { productAdapter.submitList(it) }
            if (products.isEmpty()) {
                Toast.makeText(this, "Belum ada produk. Tambahkan produk baru!", Toast.LENGTH_LONG).show()
            }
        })

        binding.fabAddProduct.setOnClickListener {
            navigateToAddEditProduct(0) // 0 means new product
        }
    }

    private fun confirmDeleteProduct(product: Product) {
        AlertDialog.Builder(this)
            .setTitle("Hapus Produk")
            .setMessage("Apakah Anda yakin ingin menghapus produk '${product.name}'?")
            .setPositiveButton("Hapus") { _, _ ->
                productViewModel.delete(product)
                Toast.makeText(this, "${product.name} dihapus", Toast.LENGTH_SHORT).show()
            }
            .setNegativeButton("Batal", null)
            .show()
    }

    private fun navigateToAddEditProduct(productId: Int) {
        val intent = Intent(this, AddEditProductActivity::class.java).apply {
            putExtra(AddEditProductActivity.EXTRA_PRODUCT_ID, productId)
        }
        startActivity(intent)
    }
}

AddEditProductActivity.kt

Buat file AddEditProductActivity.kt:

package com.tubianto.kasirapp

import android.os.Bundle
import android.widget.Toast
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import com.tubianto.kasirapp.data.AppDatabase
import com.tubianto.kasirapp.data.Product
import com.tubianto.kasirapp.data.ProductRepository
import com.tubianto.kasirapp.databinding.ActivityAddEditProductBinding
import com.tubianto.kasirapp.viewmodel.ProductViewModel
import com.tubianto.kasirapp.viewmodel.ProductViewModelFactory
import kotlinx.coroutines.launch

class AddEditProductActivity : AppCompatActivity() {

    private lateinit var binding: ActivityAddEditProductBinding
    private val productViewModel: ProductViewModel by viewModels {
        ProductViewModelFactory(ProductRepository(AppDatabase.getDatabase(applicationContext).productDao()))
    }
    private var productId: Int = 0

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityAddEditProductBinding.inflate(layoutInflater)
        setContentView(binding.root)

        supportActionBar?.setDisplayHomeAsUpEnabled(true)

        productId = intent.getIntExtra(EXTRA_PRODUCT_ID, 0)

        if (productId != 0) {
            title = "Edit Produk"
            lifecycleScope.launch {
                val product = productViewModel.getProductById(productId)
                product?.let {
                    binding.etProductName.setText(it.name)
                    binding.etProductPrice.setText(it.price.toString())
                    binding.etProductStock.setText(it.stock.toString())
                }
            }
        } else {
            title = "Tambah Produk Baru"
        }

        binding.btnSaveProduct.setOnClickListener {
            saveProduct()
        }
    }

    override fun onSupportNavigateUp(): Boolean {
        onBackPressedDispatcher.onBackPressed()
        return true
    }

    private fun saveProduct() {
        val name = binding.etProductName.text.toString().trim()
        val priceStr = binding.etProductPrice.text.toString().trim()
        val stockStr = binding.etProductStock.text.toString().trim()

        if (name.isEmpty() || priceStr.isEmpty() || stockStr.isEmpty()) {
            Toast.makeText(this, "Semua kolom harus diisi", Toast.LENGTH_SHORT).show()
            return
        }

        val price = priceStr.toDoubleOrNull()
        val stock = stockStr.toIntOrNull()

        if (price == null || stock == null || price <= 0 || stock < 0) {
            Toast.makeText(this, "Harga atau Stok tidak valid", Toast.LENGTH_SHORT).show()
            return
        }

        if (productId == 0) { // New product
            val newProduct = Product(name = name, price = price, stock = stock)
            productViewModel.insert(newProduct)
            Toast.makeText(this, "$name berhasil ditambahkan", Toast.LENGTH_SHORT).show()
        } else { // Existing product
            val updatedProduct = Product(id = productId, name = name, price = price, stock = stock)
            productViewModel.update(updatedProduct)
            Toast.makeText(this, "$name berhasil diperbarui", Toast.LENGTH_SHORT).show()
        }
        finish() // Close activity after saving
    }

    companion object {
        const val EXTRA_PRODUCT_ID = "extra_product_id"
    }
}

Langkah 6: Membuat Fitur Transaksi Penjualan (Keranjang & Checkout)

Untuk fitur ini, kita akan membutuhkan beberapa entitas tambahan: `Transaction`, `TransactionItem`. Ini akan menjadi modul yang lebih kompleks, namun intinya sama dengan manajemen produk: buat `Entity`, `DAO`, `Repository`, `ViewModel`, dan UI.

Berikut adalah ide dasarnya:

Entity (Transaction.kt dan TransactionItem.kt)

// data/Transaction.kt
package com.tubianto.kasirapp.data

import androidx.room.Entity
import androidx.room.PrimaryKey

@Entity(tableName = "transactions")
data class Transaction(
    @PrimaryKey(autoGenerate = true)
    val id: Int = 0,
    val transactionDate: Long = System.currentTimeMillis(),
    val totalAmount: Double,
    val paymentAmount: Double,
    val changeAmount: Double
)

// data/TransactionItem.kt
package com.tubianto.kasirapp.data

import androidx.room.Entity
import androidx.room.ForeignKey

@Entity(tableName = "transaction_items",
    primaryKeys = ["transactionId", "productId"],
    foreignKeys = [
        ForeignKey(entity = Transaction::class,
            parentColumns = ["id"],
            childColumns = ["transactionId"],
            onDelete = ForeignKey.CASCADE),
        ForeignKey(entity = Product::class,
            parentColumns = ["id"],
            childColumns = ["productId"],
            onDelete = ForeignKey.RESTRICT)
    ]
)
data class TransactionItem(
    val transactionId: Int,
    val productId: Int,
    val productName: String, // Simpan nama produk saat transaksi untuk history
    val quantity: Int,
    val pricePerUnit: Double
)

Perbarui AppDatabase.kt untuk menyertakan entitas baru:

@Database(entities = [Product::class, Transaction::class, TransactionItem::class], version = 2, exportSchema = false)
abstract class AppDatabase : RoomDatabase() {
    abstract fun productDao(): ProductDao
    abstract fun transactionDao(): TransactionDao // Akan dibuat selanjutnya
    // ...
}

Dan Anda harus menulis DAO dan Repository untuk transaksi, serta UI (Activity/Fragment) untuk menampilkan produk yang bisa ditambahkan ke keranjang, proses checkout, dan menampilkan riwayat transaksi.

Tips untuk Transaksi:

  • Buat Activity/Fragment baru (misal `SaleActivity`) yang menampilkan daftar produk yang bisa diklik untuk ditambahkan ke keranjang.
  • Gunakan RecyclerView lain untuk menampilkan item di keranjang.
  • Ketika checkout, kurangi stok produk di database dan simpan data transaksi beserta item-itemnya.
  • Gunakan @Transaction anotasi di DAO untuk memastikan operasi transaksi (misal mengurangi stok dan menyimpan data transaksi) dilakukan secara atomik.

Langkah 7: Menampilkan Riwayat Transaksi

Buat Activity atau Fragment baru untuk menampilkan daftar transaksi dari database. Setiap item transaksi bisa diklik untuk melihat detail transaksi, termasuk produk apa saja yang dibeli.

Pengalaman dan Pertimbangan Praktis dalam Pengembangan Aplikasi Kasir

Sebagai developer, ada beberapa hal yang sering saya temui dan perlu dipertimbangkan saat membangun aplikasi kasir:

  • UI/UX: Aplikasi kasir harus sangat intuitif dan cepat. Tombol besar, input yang jelas, dan alur kerja yang minim klik sangat penting. Penggunaan Material Design dari Google membantu menjaga konsistensi. Saya sering mendapati developer terlalu fokus pada fitur teknis dan melupakan usability di lapangan.
  • Performa Database: Untuk aplikasi kasir dengan banyak transaksi, performa database adalah krusial. Room sangat baik untuk kasus offline, tetapi pastikan query Anda efisien, terutama saat mengambil riwayat transaksi dalam jumlah besar. Hindari mengambil semua data sekaligus jika tidak diperlukan.
  • Penanganan Stok: Logika penanganan stok harus robust. Pastikan stok terupdate dengan benar setelah penjualan, pengembalian, atau penambahan. Perhatikan juga kasus di mana beberapa kasir mengakses stok yang sama (meskipun Room akan menangani konkurensi database lokal, ini penting jika nanti beralih ke backend cloud).
  • Backup & Restore: Data penjualan sangat penting. Pertimbangkan cara melakukan backup database lokal ke cloud (misal Google Drive) atau export ke CSV. Ini sering terlupakan di awal, tapi sangat vital.
  • Integrasi Hardware: Banyak aplikasi kasir memerlukan integrasi dengan printer termal Bluetooth, barcode scanner, atau laci kasir. Ini bisa menjadi tantangan tersendiri dan biasanya melibatkan SDK atau API dari produsen hardware.
  • Skalabilitas: Jika aplikasi Anda berkembang dan membutuhkan sinkronisasi data antar perangkat atau analitik lebih lanjut, Anda mungkin perlu beralih ke solusi backend berbasis cloud seperti Firebase Firestore atau server kustom dengan PostgreSQL.

Masalah yang Sering Terjadi dan Solusinya

Dalam pengalaman saya mengembangkan aplikasi serupa, ada beberapa masalah umum yang sering muncul:

1. Database Schema Migration (Perubahan Skema Database)

Gejala: Aplikasi crash dengan pesan error seperti “Schema changed, but no migration was supplied.” atau “Expected X found Y”. Ini terjadi saat Anda mengubah struktur Entity (misalnya menambah kolom baru, mengubah tipe data) dan versi database tidak diupdate.

Penyebab: Versi database di @Database(version = X) tidak dinaikkan, atau Anda menaikkan versi tetapi tidak menyediakan objek Migration yang memberitahu Room cara mengubah skema dari versi lama ke versi baru.

Solusi:

  1. Untuk Pengembangan: Gunakan .fallbackToDestructiveMigration() di builder Room (seperti yang sudah kita lakukan). Ini akan menghapus database lama dan membuatnya ulang, cocok untuk saat Anda tidak peduli dengan data yang hilang.
  2. Untuk Produksi: Anda harus menyediakan objek Migration. Naikkan versi database, lalu definisikan migrasi:
    val MIGRATION_1_2 = object : Migration(1, 2) {
        override fun migrate(database: SupportSQLiteDatabase) {
            database.execSQL("ALTER TABLE products ADD COLUMN description TEXT DEFAULT '' NOT NULL")
        }
    }
    // Lalu di AppDatabase.getDatabase
    // .addMigrations(MIGRATION_1_2)
    

2. UI Janky atau Lambat (Tidak Responsif)

Gejala: Aplikasi terasa berat, scrolling RecyclerView tidak mulus, atau ada jeda saat melakukan operasi tertentu.

Penyebab: Operasi berat (seperti query database yang kompleks, perhitungan data besar, atau operasi I/O) dilakukan di Main Thread (UI Thread).

Solusi:

  1. Gunakan Coroutines: Room DAO method dengan suspend sudah otomatis dijalankan di background. Pastikan logika bisnis di ViewModel juga menggunakan Coroutines (misal viewModelScope.launch) untuk operasi yang blocking.
  2. Optimasi RecyclerView: Pastikan DiffUtil di Adapter diimplementasikan dengan benar untuk meminimalkan update UI. Gunakan ViewHolder pattern yang efisien.
  3. Profil Performa: Gunakan Android Profiler di Android Studio untuk mengidentifikasi bottleneck di UI Thread.

3. State Management yang Buruk (Data Hilang saat Rotasi Layar)

Gejala: Data di EditText atau kondisi UI lainnya hilang saat perangkat dirotasi atau saat aplikasi di-kill oleh sistem (misalnya karena low memory).

Penyebab: Aktivitas di-destroy dan dibuat ulang saat perubahan konfigurasi (rotasi) atau saat sistem membersihkan memori. Data tidak disimpan atau dipulihkan dengan benar.

Solusi:

  1. Gunakan ViewModel: ViewModel dirancang untuk bertahan melewati perubahan konfigurasi. Simpan semua data yang relevan di ViewModel, bukan langsung di Activity/Fragment.
  2. Gunakan Saved State Handle: Untuk data UI yang lebih kompleks dan perlu bertahan saat proses di-kill, gunakan SavedStateHandle di ViewModel Anda.
  3. Perhatikan Siklus Hidup: Pahami kapan onCreate, onStart, onStop dipanggil dan bagaimana mengelola resource dengan benar.

4. Validasi Input yang Kurang

Gejala: Data yang tidak valid (misal, harga negatif, stok teks) masuk ke database, menyebabkan error atau perhitungan yang salah.

Penyebab: Kurangnya atau tidak adanya validasi input dari pengguna sebelum data disimpan atau diproses.

Solusi:

  1. Validasi di UI: Gunakan TextInputLayout dengan error message, atau tampilkan Toast/Snackbar untuk memberi tahu pengguna jika input tidak valid.
  2. Validasi di ViewModel/Repository: Lakukan validasi tambahan di ViewModel atau Repository sebelum data disimpan ke database. Ini adalah lapisan keamanan kedua.
  3. Tipe Input: Gunakan android:inputType yang tepat di XML (misal numberDecimal untuk harga, number untuk stok) untuk membatasi input keyboard.

FAQ

Q: Database apa yang paling cocok untuk aplikasi kasir Android sederhana?

A: Untuk aplikasi kasir yang berjalan offline di satu perangkat, Room Persistence Library (di atas SQLite) adalah pilihan terbaik. Mudah digunakan, memiliki abstraksi yang kuat, dan mendukung Coroutines untuk operasi asinkron. Jika Anda membutuhkan sinkronisasi data antar perangkat atau backend cloud, pertimbangkan Firebase Firestore atau backend kustom.

Q: Apakah saya bisa menambahkan fitur barcode scanner?

A: Tentu saja! Anda bisa mengintegrasikan library barcode scanner seperti Google Mobile Vision API (sekarang bagian dari ML Kit) atau library pihak ketiga lainnya. Implementasinya biasanya melibatkan penggunaan kamera untuk memindai barcode dan mengambil nilai produk berdasarkan kode tersebut dari database.

Q: Bagaimana cara mengelola pembayaran dan mencetak struk?

A: Untuk pembayaran, Anda bisa memulai dengan pembayaran tunai sederhana (input uang diterima, hitung kembalian). Integrasi dengan gateway pembayaran digital (misal GoPay, OVO) lebih kompleks dan biasanya memerlukan SDK/API dari penyedia layanan tersebut. Untuk mencetak struk, Anda memerlukan printer thermal Bluetooth. Ini juga melibatkan penggunaan Bluetooth API Android dan SDK dari produsen printer untuk mengirim data cetak.

Q: Apakah aplikasi ini bisa multi-user atau terhubung ke beberapa perangkat?

A: Aplikasi yang kita buat di panduan ini adalah aplikasi single-device dan single-user. Untuk multi-user atau multi-perangkat, Anda memerlukan backend terpusat (server) dan database cloud (misal Firebase, PostgreSQL di VPS, dll.) untuk sinkronisasi data. Ini akan menambah kompleksitas yang signifikan pada arsitektur.

Kesimpulan

Membangun aplikasi kasir Android dengan Kotlin adalah proyek yang sangat rewarding, baik untuk pembelajaran maupun untuk solusi bisnis nyata. Dengan mengikuti panduan ini, Anda telah berhasil meletakkan fondasi yang kuat, mulai dari pemilihan arsitektur MVVM, penggunaan Room database, hingga implementasi manajemen produk dasar. Ini adalah titik awal yang solid untuk mengembangkan fitur-fitur yang lebih kompleks seperti transaksi penjualan, laporan detail, atau bahkan integrasi hardware.

Ingat, setiap baris kode adalah kesempatan untuk belajar. Jangan takut bereksperimen, tambahkan fitur yang Anda impikan, dan terus kembangkan aplikasi ini sesuai kebutuhan. Dunia teknologi terus bergerak, dan dengan dasar yang kuat ini, Anda siap membangun solusi inovatif Anda sendiri.

TAGS: Android Development, Kotlin, Aplikasi Kasir, POS App, Room Database, MVVM, Developer Tutorial, Software Engineering, Productivity Tools


Baca Juga

You May Also Like

Tinggalkan Balasan

Alamat email Anda tidak akan dipublikasikan. Ruas yang wajib ditandai *