Tuesday, June 16, 2026

PPB EAS - Aplikasi Member Timezone

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

   

Wednesday, June 10, 2026

PPB Pertemuan 14 - Aplikasi News REST API

Integrasi Web API pada Aplikasi "News App"

Muhammad Risyad Himawan Putra - 5025231205

 

Aplikasi NewsApp ini berfungsi untuk menampilkan daftar berita secara real-time. Data berita diambil langsung dari internet menggunakan News API. Request HTTP ditangani oleh library Retrofit, sedangkan untuk UI dirancang secara deklaratif menggunakan Jetpack Compose. Arsitektur aplikasi ini menerapkan pola MVVM (Model-View-ViewModel) agar kode program menjadi lebih terstruktur, modular, dan memisahkan logika bisnis dari tampilan utama.

 Source Code GitHub: https://github.com/MRHP1/Mobile-Programming/tree/main/NewsApp

1. Deskripsi & Alur Utama Jalannya Aplikasi

Secara garis besar, aplikasi ini bekerja dengan alur sebagai berikut:

  • Pengambilan Data API: Aplikasi melakukan request ke endpoint News API di background thread agar aplikasi terlihat seamless saat memuat data.
  • Pengelolaan State UI: Hasil request dari API dipetakan ke dalam 3 state utama di ViewModel, yaitu: Loading (saat mengunduh data), Success (ketika data berhasil didapatkan), dan Error (jika koneksi internet/APIbermasalah).
  • Navigasi Halaman: Menggunakan Navigation Compose untuk berpindah dari halaman daftar berita (HomeScreen) ke halaman detail berita (DetailScreen).

2. ApiService.kt

package com.example.newsapp.data.api import com.example.newsapp.data.model.NewsResponse import retrofit2.http.GET import retrofit2.http.Query interface ApiService { @GET("top-headlines") suspend fun getTopHeadlines( @Query("country") country: String = "us", @Query("apiKey") apiKey: String ): NewsResponse }

Penjelasan: File ApiService berupa interface yang berfungsi mendefinisikan rute atau endpoint API yang akan dipanggil. Di sini kita membuat fungsi getTopHeadlines dengan metode HTTP @GET("top-headlines"). Parameter query seperti asal negara (country) dan token API (apiKey) ditambahkan menggunakan anotasi @Query. Kata kunci suspend digunakan agar fungsi ini bisa dipanggil di dalam Coroutine secara asinkron tanpa memblokir thread UI utama.


3. RetrofitClient.kt

package com.example.newsapp.data.api import retrofit2.Retrofit import retrofit2.converter.gson.GsonConverterFactory object RetrofitClient { private const val BASE_URL = "https://newsapi.org/v2/" val apiService: ApiService by lazy { Retrofit.Builder() .baseUrl(BASE_URL) .addConverterFactory(GsonConverterFactory.create()) .build() .create(ApiService::class.java) } }

Penjelasan: Kita membuat RetrofitClient sebagai sebuah Singleton (menggunakan keyword object). Singleton ini bertugas menginisialisasi library Retrofit dengan menentukan alamat dasar server (BASE_URL) serta menyematkan GsonConverterFactory agar data respons berformat JSON dari API secara otomatis diubah menjadi objek Kotlin. Pemanggilan delegasi by lazy bertujuan agar inisialisasi ini hanya memakan memori ketika pertama kali diakses.


4. NewsRepository.kt

package com.example.newsapp.data.repository import com.example.newsapp.BuildConfig import com.example.newsapp.data.api.RetrofitClient class NewsRepository { suspend fun getNews() = RetrofitClient.apiService.getTopHeadlines( apiKey = BuildConfig.API_KEY ) }

Penjelasan: Kelas NewsRepository ini berperan sebagai layer pemisah data (Data Layer). Dengan memisahkan pemanggilan API di dalam repositori ini, ViewModel tidak perlu tahu detail teknis bagaimana data diambil. Keamanan token API juga tetap terjaga karena kita memuat API_KEY langsung dari konfigurasi lokal gradle (BuildConfig).


5. NewsViewModel.kt

package com.example.newsapp.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.example.newsapp.data.model.Article import com.example.newsapp.data.repository.NewsRepository import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch sealed class NewsUiState { object Loading : NewsUiState() data class Success(val articles: List<Article>) : NewsUiState() data class Error(val message: String) : NewsUiState() } class NewsViewModel : ViewModel() { private val repository = NewsRepository() private val _uiState = MutableStateFlow<NewsUiState>(NewsUiState.Loading) val uiState = _uiState.asStateFlow() init { loadNews() } fun loadNews() { viewModelScope.launch { try { _uiState.value = NewsUiState.Loading val response = repository.getNews() _uiState.value = NewsUiState.Success(response.articles) } catch (e: Exception) { _uiState.value = NewsUiState.Error( e.message ?: "Terjadi kesalahan koneksi" ) } } } }

Penjelasan: Di dalam NewsViewModel, kita membagi kondisi layar menjadi tiga status menggunakan sealed class NewsUiState. ViewModel memantau perubahan status ini melalui MutableStateFlow. Saat pertama kali program berjalan, blok init akan langsung menjalankan fungsi loadNews() secara asinkron di dalam lingkup viewModelScope. Jika data berhasil diambil, state berubah menjadi Success, dan jika gagal (karena koneksi mati atau salah URL), state berganti ke Error agar UI dapat menampilkan instruksi selanjutnya kepada pengguna.


6. NavGraph.kt

package com.example.newsapp.navigation import androidx.compose.runtime.* import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavType import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController import androidx.navigation.navArgument import com.example.newsapp.ui.screens.DetailScreen import com.example.newsapp.ui.screens.HomeScreen import com.example.newsapp.viewmodel.NewsUiState import com.example.newsapp.viewmodel.NewsViewModel @Composable fun AppNavGraph() { val navController = rememberNavController() val viewModel: NewsViewModel = viewModel() NavHost( navController = navController, startDestination = "home" ) { composable("home") { HomeScreen( viewModel = viewModel, onDetailClick = { articleIndex -> navController.navigate("detail/$articleIndex") } ) } composable( route = "detail/{articleIndex}", arguments = listOf( navArgument("articleIndex") { type = NavType.IntType } ) ) { backStackEntry -> val articleIndex = backStackEntry.arguments?.getInt("articleIndex") ?: 0 val state by viewModel.uiState.collectAsState() if (state is NewsUiState.Success) { val articles = (state as NewsUiState.Success).articles if (articleIndex in articles.indices) { DetailScreen( article = articles[articleIndex], onBackClick = { navController.popBackStack() } ) } } } } }

Penjelasan: Kita mengontrol alur perpindahan halaman menggunakan library Jetpack Navigation. Objek navController bertindak sebagai pengatur navigasi. Di halaman utama, ketika salah satu kartu berita diklik, kita akan berpindah rute ke halaman detail dengan menyelipkan parameter indeks artikel (articleIndex). Pada blok rute "detail/{articleIndex}", parameter tersebut ditangkap untuk menampilkan konten berita spesifik sesuai dengan data yang dipilih.


7. NewsCard.kt

@Composable fun FeaturedNewsCard( article: Article, onClick: () -> Unit ) { var visible by remember { mutableStateOf(false) } LaunchedEffect(Unit) { visible = true } AnimatedVisibility( visible = visible, enter = fadeIn() + slideInVertically { -40 } ) { Card( modifier = Modifier .fillMaxWidth() .height(220.dp) .clickable { onClick() }, shape = RoundedCornerShape(20.dp), elevation = CardDefaults.cardElevation(defaultElevation = 8.dp) ) { Box(modifier = Modifier.fillMaxSize()) { AsyncImage( model = article.urlToImage, contentDescription = article.title, modifier = Modifier.fillMaxSize(), contentScale = ContentScale.Crop ) Box( modifier = Modifier .fillMaxSize() .background( Brush.verticalGradient( colors = listOf( GradientStart.copy(alpha = 0.3f), GradientEnd.copy(alpha = 0.85f) ) ) ) ) Column( modifier = Modifier .fillMaxSize() .padding(20.dp), verticalArrangement = Arrangement.Bottom ) { Text( text = article.title, style = MaterialTheme.typography.titleLarge, color = Color.White, fontWeight = FontWeight.Bold, maxLines = 3, overflow = TextOverflow.Ellipsis ) } } } } }

Penjelasan: Kita membuat komponen kartu utama (FeaturedNewsCard) untuk menampilkan berita terpopuler di bagian paling atas.


8. Halaman Utama Feed Berita (HomeScreen.kt)

package com.example.newsapp.ui.screens import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.example.newsapp.ui.components.CompactNewsCard import com.example.newsapp.ui.components.FeaturedNewsCard import com.example.newsapp.viewmodel.NewsUiState import com.example.newsapp.viewmodel.NewsViewModel @Composable fun HomeScreen( viewModel: NewsViewModel, onDetailClick: (Int) -> Unit ) { val state by viewModel.uiState.collectAsState() Scaffold { innerPadding -> when (state) { is NewsUiState.Loading -> { Box( modifier = Modifier.fillMaxSize().padding(innerPadding), contentAlignment = Alignment.Center ) { CircularProgressIndicator() } } is NewsUiState.Success -> { val articles = (state as NewsUiState.Success).articles LazyColumn( modifier = Modifier.fillMaxSize().padding(innerPadding), contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp) ) { if (articles.isNotEmpty()) { item { FeaturedNewsCard(article = articles[0], onClick = { onDetailClick(0) }) } } itemsIndexed(articles.drop(1)) { index, article -> CompactNewsCard(article = article, onClick = { onDetailClick(index + 1) }) } } } is NewsUiState.Error -> { val error = state as NewsUiState.Error Box( modifier = Modifier.fillMaxSize().padding(innerPadding), contentAlignment = Alignment.Center ) { Button(onClick = { viewModel.loadNews() }) { Text("Coba Lagi") } } } } } }

Penjelasan: Layar utama HomeScreen memproses status data yang diteruskan oleh ViewModel. Menggunakan struktur percabangan when (state) untuk menampilkan komponen UI yang sesuai: memutar spinner pemuatan data (CircularProgressIndicator) saat loading, merender baris list secara efisien menggunakan LazyColumn ketika sukses, atau menyodorkan tombol "Coba Lagi" (Retry) ketika terjadi error jaringan.


9. Activity Utama Aplikasi (MainActivity.kt)

package com.example.newsapp import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import com.example.newsapp.navigation.AppNavGraph import com.example.newsapp.ui.theme.NewsAppTheme class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdge() setContent { NewsAppTheme { AppNavGraph() } } } }

Penjelasan: MainActivity bertindak sebagai entry point sistem operasi Android untuk menjalankan aplikasi. Fungsi enableEdgeToEdge() dipasang untuk membuat area status bar dan navigation bar di bawah layar menjadi transparan agar UI terlihat penuh dan bersih. Pada setContent, kita melakukan navigasi rute aplikasi (AppNavGraph()) di dalam tema global NewsAppTheme.


Demonstrasi

1. Kondisi Error Loading
Tampilan error saat aplikasi gagal mengambil data JSON dari News API.    

2. Berita Berhasil Tampil
Berita utama dipajang di kartu besar teratas, sementara berita pelengkap lainnya disusun ke bawah secara vertikal.

3. Layar Detail Artikel
Saat item diklik, detail halaman memuat isi teks berita secara penuh lengkap dengan tombol kembali.

PPB EAS - Aplikasi Member Timezone

Manajemen Keanggotaan & Loyalty Program Timezone dengan Room Database dan Jetpack Compose Aryaka Leorgi Epridaka - 5025231117 Muhammad ...