Manajemen Keanggotaan & Loyalty Program Timezone dengan Room Database dan Jetpack Compose
Aryaka Leorgi Epridaka - 5025231117
Muhammad Risyad Himawan Putra - 5025231205
Aplikasi TimezoneMembership ini berfungsi sebagai sistem manajemen keanggotaan digital untuk pusat hiburan Timezone. Aplikasi mendukung dua peran pengguna: Member (yang dapat melakukan top-up saldo Tizo, bermain mesin arcade, menukarkan reward points, dan melihat riwayat transaksi) serta Admin (yang memiliki dashboard untuk mengelola seluruh data member). Seluruh data disimpan secara lokal menggunakan Room Database (SQLite). UI dirancang secara deklaratif menggunakan Jetpack Compose dengan Material 3, dan arsitektur aplikasi menerapkan pola MVVM (Model-View-ViewModel) agar kode program menjadi terstruktur, modular, serta memisahkan logika bisnis dari tampilan.
Source Code GitHub: https://github.com/AryakaLeorgi/TimezoneMembership
Link Project Requirement: https://drive.google.com/file/d/1nmoltS56WvYHeTYYmfm3NbdxOTZ_FyRd/view?usp=sharing
Link PPT: https://canva.link/qsfw5yh1cqmy3fp
Link Aplikasi: https://github.com/AryakaLeorgi/TimezoneMembership/releases/download/apk/TimezoneMembership.apk
Link Infografis: https://canva.link/o43x2oh4pywauij
Link YouTube: https://youtu.be/Qy8J54NoRQU
1. Deskripsi & Alur Utama Jalannya Aplikasi
Secara garis besar, aplikasi ini bekerja dengan alur sebagai berikut:
- Autentikasi Pengguna: Aplikasi menyediakan sistem login dan registrasi. Terdapat fitur "Remember Me" yang memungkinkan auto-login di sesi berikutnya tanpa perlu memasukkan ulang kredensial.
- Manajemen Saldo & Poin: Member dapat melakukan top-up saldo Tizo melalui 6 tier paket, bermain mesin arcade (simulasi) yang mengurangi saldo dan menambah reward points, serta menukarkan reward points dengan berbagai hadiah.
- Pencatatan Transaksi: Setiap operasi (top-up, bermain, redeem) dijalankan dalam Room @Transaction untuk menjamin atomicity, yaitu update saldo member dan insert catatan transaksi berjalan sebagai satu kesatuan.
- Navigasi Halaman: Menggunakan Navigation Compose untuk berpindah antar halaman, seperti AuthScreen, Admin Dashboard, Member Detail, Top-Up, Play, Redeem, dan History.
- Pengelolaan State UI: Seluruh perubahan data dari database diamati secara reaktif melalui Flow dan StateFlow, sehingga UI selalu menampilkan data terkini tanpa perlu di-refresh manual.
2. Entity Data Model - Member.kt
package com.example.timezonemembership.data.model
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "members")
data class Member(
@PrimaryKey(autoGenerate = true)
val id: Int = 0,
val name: String,
val email: String,
val phone: String,
val tizoBalance: Int = 0,
val rewardPoints: Int = 0,
val password: String,
val role: String = "MEMBER", // "MEMBER" or "ADMIN"
val isRemembered: Boolean = false
)
Penjelasan: File Member.kt mendefinisikan entitas tabel members di Room Database. Anotasi @Entity menandai kelas ini sebagai tabel SQLite, sedangkan @PrimaryKey(autoGenerate = true) membuat kolom id terisi otomatis secara inkremental. Setiap member menyimpan data profil (nama, email, telepon), saldo Tizo dan reward points untuk sistem loyalitas, password untuk autentikasi, kolom role yang membedakan antara "MEMBER" dan "ADMIN", serta flag isRemembered yang mendukung fitur auto-login agar pengguna tidak perlu login ulang setiap kali membuka aplikasi.
3. Entity Data Model - Transaction.kt
package com.example.timezonemembership.data.model
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
@Entity(
tableName = "transactions",
foreignKeys = [
ForeignKey(
entity = Member::class,
parentColumns = ["id"],
childColumns = ["memberId"],
onDelete = ForeignKey.CASCADE
)
],
indices = [Index(value = ["memberId"])]
)
data class Transaction(
@PrimaryKey(autoGenerate = true)
val id: Int = 0,
val memberId: Int,
val transactionType: String, // "TOPUP", "PLAY", "REDEEM"
val description: String,
val tizoMutation: Int, // positive for credit, negative for debit
val pointMutation: Int, // positive for credit, negative for debit
val timestamp: Long = System.currentTimeMillis()
)
Penjelasan: Entitas Transaction mencatat seluruh riwayat transaksi yang dilakukan member. Relasi antar tabel dibangun menggunakan ForeignKey yang menghubungkan kolom memberId di tabel transactions dengan kolom id di tabel members. Parameter onDelete = ForeignKey.CASCADE memastikan ketika sebuah record member dihapus, seluruh transaksi miliknya juga ikut terhapus secara otomatis. Anotasi @Index pada kolom memberId berfungsi mempercepat query pencarian transaksi berdasarkan member tertentu. Kolom tizoMutation dan pointMutation menggunakan nilai positif untuk kredit (penambahan) dan negatif untuk debit (pengurangan), sehingga satu model bisa merepresentasikan semua jenis transaksi.
4. Constants.kt - Konstanta & Aturan Bisnis
package com.example.timezonemembership.data.model
object Constants {
// ==================== TOP-UP TIERS ====================
data class TopUpTier(
val name: String,
val priceRp: Int,
val tizoAmount: Int,
val rewardPoints: Int
)
val TOP_UP_TIERS = listOf(
TopUpTier("Tier 1", 100_000, 100, 10),
TopUpTier("Tier 2", 200_000, 200, 25),
TopUpTier("Tier 3", 300_000, 300, 40),
TopUpTier("Tier 4", 500_000, 500, 75),
TopUpTier("Tier 5", 1_000_000, 1000, 200),
TopUpTier("Tier 6", 2_000_000, 2000, 500),
)
// ==================== MACHINE CATEGORIES ====================
data class MachineCategory(
val name: String,
val description: String,
val tizoCost: Int,
val rewardPoints: Int
)
val MACHINE_CATEGORIES = listOf(
MachineCategory("Mesin Ringan", "Capit boneka kecil, dsb", 5, 2),
MachineCategory("Mesin Sedang", "Arcade standard, balapan", 10, 3),
MachineCategory("Mesin Berat", "VR, mesin tiket besar", 15, 4),
)
// ==================== REDEEM REWARDS ====================
// Berisi 3 kategori hadiah: Gratis Bermain, Merchandise, dan Diskon Top-Up
// Total 9 jenis hadiah yang dapat ditukarkan dengan reward points
val ALL_REWARDS = REDEEM_FREE_PLAY + REDEEM_MERCHANDISE + REDEEM_DISCOUNT
}
Penjelasan: File Constants.kt berperan sebagai Single Source of Truth untuk seluruh aturan bisnis aplikasi. Menggunakan keyword object menjadikannya Singleton yang bisa diakses dari mana saja tanpa perlu membuat instance. Di dalamnya terdefinisi 6 tier paket top-up (dari Rp100.000 hingga Rp2.000.000) dengan masing-masing memberikan jumlah Tizo dan bonus reward points yang berbeda, 3 kategori mesin arcade dengan biaya dan poin yang bervariasi, serta 9 jenis hadiah penukaran yang terbagi ke dalam tiga kategori (Gratis Bermain, Merchandise, dan Diskon). Dengan memusatkan semua konstanta di satu tempat, perubahan aturan bisnis cukup dilakukan di file ini tanpa perlu mengubah kode di banyak tempat.
5. MemberDao.kt - Data Access Object
package com.example.timezonemembership.data.dao
import androidx.room.*
import com.example.timezonemembership.data.model.Member
import kotlinx.coroutines.flow.Flow
@Dao
interface MemberDao {
@Query("SELECT * FROM members ORDER BY id DESC")
fun getAllMembers(): Flow<List<Member>>
@Query("SELECT * FROM members WHERE role = :role ORDER BY id DESC")
fun getAllMembersByRole(role: String): Flow<List<Member>>
@Query("SELECT * FROM members WHERE id = :id")
fun getMemberById(id: Int): Flow<Member?>
@Query("SELECT * FROM members WHERE id = :id")
suspend fun getMemberByIdOnce(id: Int): Member?
@Query("SELECT * FROM members WHERE email = :email AND password = :password LIMIT 1")
suspend fun login(email: String, password: String): Member?
@Query("SELECT * FROM members WHERE isRemembered = 1 LIMIT 1")
suspend fun getRememberedMember(): Member?
@Query("UPDATE members SET isRemembered = 0")
suspend fun clearAllRemembered()
@Query("UPDATE members SET isRemembered = :isRemembered WHERE id = :id")
suspend fun setRemembered(id: Int, isRemembered: Boolean)
@Query("SELECT COUNT(*) FROM members WHERE role = 'MEMBER'")
fun getMemberCount(): Flow<Int>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertMember(member: Member): Long
@Update
suspend fun updateMember(member: Member)
}
Penjelasan: Interface MemberDao mendefinisikan seluruh operasi akses database untuk tabel members. Anotasi @Dao memberi tahu Room untuk men-generate implementasi SQL secara otomatis. Terdapat dua pola return type yang digunakan: fungsi yang mengembalikan Flow (seperti getAllMembers dan getMemberById) bersifat reaktif dan akan otomatis mengirim data terbaru setiap kali tabel berubah, sedangkan fungsi bertanda suspend (seperti login dan getMemberByIdOnce) hanya mengambil data sekali di dalam coroutine. Perhatikan juga fungsi getRememberedMember, clearAllRemembered, dan setRemembered yang secara khusus menangani mekanisme auto-login. Selain itu, terdapat juga TransactionDao yang menangani operasi CRUD untuk tabel transaksi.
6. TimezoneDatabase.kt - Room Database Singleton
package com.example.timezonemembership.data.database
import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import com.example.timezonemembership.data.dao.MemberDao
import com.example.timezonemembership.data.dao.TransactionDao
import com.example.timezonemembership.data.model.Member
import com.example.timezonemembership.data.model.Transaction
@Database(
entities = [Member::class, Transaction::class],
version = 2,
exportSchema = false
)
abstract class TimezoneDatabase : RoomDatabase() {
abstract fun memberDao(): MemberDao
abstract fun transactionDao(): TransactionDao
companion object {
@Volatile
private var INSTANCE: TimezoneDatabase? = null
fun getInstance(context: Context): TimezoneDatabase {
return INSTANCE ?: synchronized(this) {
val instance = Room.databaseBuilder(
context.applicationContext,
TimezoneDatabase::class.java,
"timezone_membership_db"
)
.fallbackToDestructiveMigration()
.build()
INSTANCE = instance
instance
}
}
}
}
Penjelasan: Kelas TimezoneDatabase merupakan konfigurasi utama Room Database. Anotasi @Database mendaftarkan kedua entity (Member dan Transaction) ke dalam database ini. Pola Singleton diterapkan melalui companion object dengan anotasi @Volatile dan blok synchronized untuk memastikan hanya satu instance database yang aktif di seluruh aplikasi, sekaligus menjamin thread-safety ketika diakses dari berbagai coroutine secara bersamaan. Metode fallbackToDestructiveMigration() dipilih agar database direset otomatis jika terjadi perubahan skema antar versi, cocok untuk fase pengembangan dan demo.
7. TimezoneRepository.kt - Repository Layer
package com.example.timezonemembership.data.repository
import com.example.timezonemembership.data.dao.MemberDao
import com.example.timezonemembership.data.dao.TransactionDao
import com.example.timezonemembership.data.model.Constants
import com.example.timezonemembership.data.model.Member
import com.example.timezonemembership.data.model.Transaction
class TimezoneRepository(
private val memberDao: MemberDao,
private val transactionDao: TransactionDao
) {
// ==================== AUTH OPERATIONS ====================
suspend fun login(email: String, password: String, rememberMe: Boolean): Member? {
val member = memberDao.login(email, password)
if (member != null) {
if (rememberMe) {
memberDao.clearAllRemembered()
memberDao.setRemembered(member.id, true)
return member.copy(isRemembered = true)
} else {
memberDao.clearAllRemembered()
return member.copy(isRemembered = false)
}
}
return null
}
// ==================== TOP-UP ====================
@androidx.room.Transaction
suspend fun performTopUp(memberId: Int, tier: Constants.TopUpTier): Boolean {
val member = memberDao.getMemberByIdOnce(memberId) ?: return false
val updatedMember = member.copy(
tizoBalance = member.tizoBalance + tier.tizoAmount,
rewardPoints = member.rewardPoints + tier.rewardPoints
)
memberDao.updateMember(updatedMember)
val transaction = Transaction(
memberId = memberId,
transactionType = "TOPUP",
description = "Top-Up ${tier.name} (Rp${formatCurrency(tier.priceRp)})",
tizoMutation = tier.tizoAmount,
pointMutation = tier.rewardPoints
)
transactionDao.insertTransaction(transaction)
return true
}
// Pola yang sama diterapkan untuk performPlay() dan performRedeem()
// dengan validasi saldo/poin sebelum eksekusi transaksi.
}
Penjelasan: Kelas TimezoneRepository berperan sebagai layer pemisah data (Data Layer) yang mengenkapsulasi seluruh logika akses database. ViewModel tidak perlu tahu detail teknis bagaimana data diambil atau dimanipulasi. Fungsi login mencocokkan email dan password, lalu menangani mekanisme "Remember Me" dengan membersihkan flag lama dan menyimpan flag baru. Fungsi performTopUp menggunakan anotasi @Transaction dari Room untuk memastikan dua operasi (update saldo member dan insert catatan transaksi) berjalan secara atomik. Artinya, jika salah satu operasi gagal, seluruh perubahan akan di-rollback sehingga data tetap konsisten. Pola yang sama diterapkan untuk operasi bermain (performPlay) dan penukaran hadiah (performRedeem), masing-masing dengan validasi kecukupan saldo atau poin.
8. MemberViewModel.kt - ViewModel
package com.example.timezonemembership.viewmodel
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.example.timezonemembership.data.database.TimezoneDatabase
import com.example.timezonemembership.data.model.Constants
import com.example.timezonemembership.data.model.Member
import com.example.timezonemembership.data.repository.TimezoneRepository
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
class MemberViewModel(application: Application) : AndroidViewModel(application) {
private val repository: TimezoneRepository
private val _currentUser = MutableStateFlow<Member?>(null)
val currentUser: StateFlow<Member?> = _currentUser.asStateFlow()
private val _isCheckingAuth = MutableStateFlow(true)
val isCheckingAuth: StateFlow<Boolean> = _isCheckingAuth.asStateFlow()
val allMembers: StateFlow<List<Member>>
val memberCount: StateFlow<Int>
private val _uiMessage = MutableStateFlow<String?>(null)
val uiMessage: StateFlow<String?> = _uiMessage.asStateFlow()
private val _authError = MutableStateFlow<String?>(null)
val authError: StateFlow<String?> = _authError.asStateFlow()
init {
val database = TimezoneDatabase.getInstance(application)
repository = TimezoneRepository(database.memberDao(), database.transactionDao())
allMembers = repository.getAllMembersByRole("MEMBER")
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
memberCount = repository.getMemberCount()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 0)
checkRememberedUser()
}
fun login(email: String, password: String, rememberMe: Boolean) {
viewModelScope.launch {
_authError.value = null
val member = repository.login(email, password, rememberMe)
if (member != null) {
_currentUser.value = member
_uiMessage.value = "Login berhasil! Selamat datang, ${member.name}."
} else {
_authError.value = "Email atau password salah!"
}
}
}
fun performTopUp(memberId: Int, tier: Constants.TopUpTier) {
viewModelScope.launch {
val success = repository.performTopUp(memberId, tier)
if (success) {
_uiMessage.value = "Top-Up ${tier.name} berhasil! +${tier.tizoAmount} Tizo"
// Refresh currentUser jika yang di-update adalah user yang sedang login
if (_currentUser.value?.id == memberId) {
val updated = repository.getMemberByEmail(_currentUser.value!!.email)
_currentUser.value = updated
}
} else {
_uiMessage.value = "Top-Up gagal. Silakan coba lagi."
}
}
}
// Fungsi serupa: register(), logout(), performPlay(), performRedeem()
}
Penjelasan: MemberViewModel menjadi penghubung utama antara data dan tampilan. Kelas ini meng-extend AndroidViewModel (bukan ViewModel biasa) karena membutuhkan Application context untuk menginisialisasi Room Database. Beberapa MutableStateFlow digunakan untuk menyimpan state UI yang berbeda: currentUser melacak pengguna yang sedang login, isCheckingAuth menandai proses pengecekan auto-login, uiMessage menyimpan pesan feedback, dan authError menyimpan error autentikasi. Flow dari repository dikonversi menjadi StateFlow menggunakan operator stateIn dengan SharingStarted.WhileSubscribed(5000), yang berarti Flow tetap aktif selama 5 detik setelah subscriber terakhir berhenti mengamati, mengoptimalkan penggunaan sumber daya saat rotasi layar. Setiap operasi bisnis dijalankan di dalam viewModelScope.launch agar berjalan secara asinkron tanpa memblokir thread UI utama.
9. NavGraph.kt - Navigasi Antar Halaman
package com.example.timezonemembership.ui.navigation
import androidx.compose.runtime.*
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.*
import androidx.navigation.compose.*
import com.example.timezonemembership.ui.screen.*
import com.example.timezonemembership.viewmodel.MemberViewModel
import com.example.timezonemembership.viewmodel.TransactionViewModel
object Routes {
const val SPLASH = "splash"
const val AUTH = "auth"
const val ADMIN_DASHBOARD = "adminDashboard"
const val ADD_MEMBER = "addMember"
const val MEMBER_DETAIL = "memberDetail/{memberId}"
const val TOP_UP = "topUp/{memberId}"
const val PLAY = "play/{memberId}"
const val REDEEM = "redeem/{memberId}"
const val HISTORY = "history/{memberId}"
fun memberDetail(memberId: Int) = "memberDetail/$memberId"
fun topUp(memberId: Int) = "topUp/$memberId"
fun play(memberId: Int) = "play/$memberId"
fun redeem(memberId: Int) = "redeem/$memberId"
fun history(memberId: Int) = "history/$memberId"
}
@Composable
fun NavGraph() {
val navController = rememberNavController()
val memberViewModel: MemberViewModel = viewModel()
val transactionViewModel: TransactionViewModel = viewModel()
val currentUser by memberViewModel.uiState.collectAsState()
val isCheckingAuth by memberViewModel.isCheckingAuth.collectAsState()
// Auto-login: Navigasi otomatis berdasarkan status autentikasi
LaunchedEffect(currentUser, isCheckingAuth) {
if (!isCheckingAuth) {
val user = currentUser
if (user != null) {
if (user.role == "ADMIN") {
navController.navigate(Routes.ADMIN_DASHBOARD) { /* popUpTo ... */ }
} else {
navController.navigate(Routes.memberDetail(user.id)) { /* popUpTo ... */ }
}
} else {
navController.navigate(Routes.AUTH) { popUpTo(0) { inclusive = true } }
}
}
}
NavHost(navController = navController, startDestination = Routes.SPLASH) {
composable(Routes.AUTH) {
AuthScreen(viewModel = memberViewModel, onAuthSuccess = { role, id ->
val dest = if (role == "ADMIN") Routes.ADMIN_DASHBOARD else Routes.memberDetail(id)
navController.navigate(dest) { popUpTo(Routes.AUTH) { inclusive = true } }
})
}
composable(Routes.ADMIN_DASHBOARD) {
HomeScreen(viewModel = memberViewModel,
onAddMember = { navController.navigate(Routes.ADD_MEMBER) },
onMemberClick = { id -> navController.navigate(Routes.memberDetail(id)) },
onLogout = { navController.navigate(Routes.AUTH) { /* popUpTo ... */ } }
)
}
composable(Routes.MEMBER_DETAIL,
arguments = listOf(navArgument("memberId") { type = NavType.IntType })
) { backStackEntry ->
val memberId = backStackEntry.arguments?.getInt("memberId") ?: return@composable
MemberDetailScreen(viewModel = memberViewModel, memberId = memberId,
onTopUp = { navController.navigate(Routes.topUp(memberId)) },
onPlay = { navController.navigate(Routes.play(memberId)) },
onRedeem = { navController.navigate(Routes.redeem(memberId)) },
onHistory = { navController.navigate(Routes.history(memberId)) },
onNavigateBack = { navController.popBackStack() },
onLogout = { navController.navigate(Routes.AUTH) { /* popUpTo ... */ } }
)
}
// Route serupa untuk TopUp, Play, Redeem, dan History screen
}
}
Penjelasan: Kita mengontrol alur perpindahan halaman menggunakan library Jetpack Navigation. Objek Routes menyimpan seluruh definisi rute sebagai konstanta string, termasuk rute dengan parameter dinamis seperti "memberDetail/{memberId}". Fungsi helper seperti Routes.memberDetail(id) memudahkan pembuatan rute dengan parameter. Bagian paling menarik adalah blok LaunchedEffect yang memantau perubahan status autentikasi secara reaktif: jika pengguna yang diingat ditemukan di database, navigasi otomatis dialihkan ke dashboard (untuk Admin) atau halaman profil (untuk Member) tanpa perlu login ulang. Setiap rute dengan parameter {memberId} didefinisikan menggunakan navArgument bertipe IntType, dan nilai parameter tersebut diekstrak dari backStackEntry.arguments untuk diteruskan ke screen terkait.
10. Halaman Login - AuthScreen.kt
@Composable
private fun LoginPage(
viewModel: MemberViewModel,
registrationSuccess: Boolean,
onDismissSuccess: () -> Unit,
onNavigateToRegister: () -> Unit
) {
var email by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
var rememberMe by remember { mutableStateOf(false) }
var emailError by remember { mutableStateOf(false) }
var passwordError by remember { mutableStateOf(false) }
val authError by viewModel.authError.collectAsState()
Box(modifier = Modifier.fillMaxSize().background(LightBackground)) {
Column(modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
// Brand Header
Text(text = "TIMEZONE", style = MaterialTheme.typography.displayMedium,
color = BluePrimary, fontWeight = FontWeight.Black, letterSpacing = 3.sp)
Text(text = "Membership & Loyalty", color = OnLightSurfaceVariant)
// Login Form Card
Card(shape = RoundedCornerShape(20.dp), colors = CardDefaults.cardColors(containerColor = LightCard)) {
Column(modifier = Modifier.padding(24.dp)) {
Text(text = "Masuk ke Akun", fontWeight = FontWeight.Bold)
// Email Field
OutlinedTextField(value = email, onValueChange = { email = it; emailError = false },
leadingIcon = { Icon(Icons.Default.Email, tint = BluePrimary) },
isError = emailError, shape = RoundedCornerShape(12.dp))
// Password Field
OutlinedTextField(value = password, onValueChange = { password = it; passwordError = false },
leadingIcon = { Icon(Icons.Default.Lock, tint = BluePrimary) },
isError = passwordError, visualTransformation = PasswordVisualTransformation())
// Remember Me Checkbox
Row {
Checkbox(checked = rememberMe, onCheckedChange = { rememberMe = it })
Text(text = "Ingat Akun Saya")
}
// Login Button
Button(onClick = {
emailError = email.isBlank()
passwordError = password.isBlank()
if (!emailError && !passwordError) {
viewModel.login(email.trim(), password, rememberMe)
}
}, colors = ButtonDefaults.buttonColors(containerColor = BluePrimary)
) { Text("Masuk", fontWeight = FontWeight.Bold) }
}
}
// Link ke halaman registrasi
TextButton(onClick = onNavigateToRegister) {
Text("Daftar Sekarang", color = BluePrimary, fontWeight = FontWeight.Bold)
}
}
}
}
Penjelasan: Composable LoginPage merupakan bagian dari AuthScreen yang menangani alur masuk pengguna. State lokal seperti email, password, rememberMe, dan flag error dikelola menggunakan remember dan mutableStateOf agar Compose dapat melakukan recomposition secara efisien ketika nilainya berubah. Error autentikasi dari ViewModel diamati melalui collectAsState() untuk menampilkan pesan kesalahan secara reaktif. Saat tombol "Masuk" ditekan, validasi sederhana dilakukan terlebih dahulu (memeriksa apakah field kosong), kemudian fungsi viewModel.login() dipanggil. Halaman registrasi menggunakan pola yang serupa namun dengan validasi yang lebih ketat meliputi format email via regex, nomor telepon harus diawali "08" atau "+62", password minimal 6 karakter, dan pencocokan konfirmasi password.
11. Kartu Member Digital - MemberDetailScreen.kt
// ==================== MEMBER CARD ====================
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(24.dp),
colors = CardDefaults.cardColors(containerColor = BluePrimaryDark),
elevation = CardDefaults.cardElevation(defaultElevation = 6.dp)
) {
Box(
modifier = Modifier.fillMaxWidth().background(
Brush.verticalGradient(
colors = listOf(BluePrimaryDark, BluePrimary, BluePrimaryLight)
)
)
) {
Column(modifier = Modifier.fillMaxWidth().padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(text = "TIMEZONE", color = GoldSecondary, fontWeight = FontWeight.Bold,
letterSpacing = 2.sp)
Text(text = if (m.role == "ADMIN") "ADMIN CARD" else "MEMBERSHIP CARD",
color = LightSurface.copy(alpha = 0.7f), letterSpacing = 4.sp)
// Avatar Inisial
Box(modifier = Modifier.size(80.dp).clip(CircleShape)
.background(LightSurface.copy(alpha = 0.15f)),
contentAlignment = Alignment.Center
) {
Text(text = m.name.take(1).uppercase(), color = LightSurface,
style = MaterialTheme.typography.displayMedium, fontWeight = FontWeight.Bold)
}
Text(text = m.name, color = LightSurface, fontWeight = FontWeight.Bold,
style = MaterialTheme.typography.headlineMedium)
Row {
Icon(Icons.Default.Email, tint = LightSurface.copy(alpha = 0.7f))
Text(text = m.email, color = LightSurface.copy(alpha = 0.7f))
}
Row {
Icon(Icons.Default.Phone, tint = LightSurface.copy(alpha = 0.7f))
Text(text = m.phone, color = LightSurface.copy(alpha = 0.7f))
}
// QR Placeholder
Box(modifier = Modifier.size(100.dp).clip(RoundedCornerShape(12.dp))
.background(LightSurface.copy(alpha = 0.15f))
) {
Text(text = "QR", color = LightSurface, fontWeight = FontWeight.Black)
Text(text = "ID: ${m.id}", color = LightSurface.copy(alpha = 0.7f))
}
}
}
}
Penjelasan: Cuplikan ini menunjukkan bagian kartu keanggotaan digital yang ditampilkan di MemberDetailScreen. Desain kartu menggunakan Brush.verticalGradient dengan tiga warna biru (gelap ke terang) untuk menciptakan efek gradient yang menyerupai kartu fisik. Di bagian atas, terdapat teks branding "TIMEZONE" dengan warna emas (GoldSecondary) dan label "MEMBERSHIP CARD" atau "ADMIN CARD" yang menyesuaikan role pengguna. Avatar berbentuk lingkaran menampilkan inisial huruf pertama nama member menggunakan m.name.take(1).uppercase(). Informasi kontak (email dan nomor telepon) ditampilkan dengan opacity rendah (alpha = 0.7f) agar tidak terlalu dominan. Di bagian bawah terdapat placeholder QR code yang menampilkan ID member. Selain kartu ini, halaman juga menampilkan dua card saldo (Tizo dan Reward Points) serta empat tombol menu layanan (Top-Up, Bermain, Redeem, Riwayat).
12. Activity Utama Aplikasi - MainActivity.kt
package com.example.timezonemembership
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import com.example.timezonemembership.ui.navigation.NavGraph
import com.example.timezonemembership.ui.theme.TimezoneMembershipTheme
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
TimezoneMembershipTheme {
NavGraph()
}
}
}
}
Penjelasan: MainActivity bertindak sebagai entry point sistem operasi Android untuk menjalankan aplikasi. Fungsi enableEdgeToEdge() dipasang untuk membuat area status bar dan navigation bar menjadi transparan, sehingga tampilan UI terasa penuh dan modern. Di dalam blok setContent, seluruh UI dibungkus dalam TimezoneMembershipTheme yang mengaplikasikan palet warna light theme kustom (Royal Blue, Gold, Teal) dan konfigurasi typography ke seluruh komponen Compose. Komponen NavGraph() kemudian mengambil alih pengelolaan navigasi antar seluruh halaman aplikasi.
Screenshot Aplikasi
