package id.ac.ubharajaya.sistemakademik import android.Manifest import android.annotation.SuppressLint import android.app.Activity import android.content.Context import android.content.Intent import android.content.pm.PackageManager import android.graphics.Bitmap import android.graphics.BitmapFactory import android.os.Bundle import android.provider.MediaStore import android.util.Base64 import android.widget.Toast import androidx.activity.ComponentActivity import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.activity.result.contract.ActivityResultContracts import androidx.camera.view.LifecycleCameraController import androidx.camera.view.PreviewView import androidx.compose.foundation.Canvas import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.* import androidx.compose.material3.* import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.window.Dialog import androidx.core.content.ContextCompat import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lint.kotlin.metadata.Visibility import com.google.android.gms.location.LocationServices import id.ac.ubharajaya.sistemakademik.ui.theme.SistemAkademikTheme import org.json.JSONObject import java.io.ByteArrayOutputStream import java.net.HttpURLConnection import java.net.URL import java.util.Date import kotlin.concurrent.thread import kotlin.math.atan2 import kotlin.math.cos import kotlin.math.sin import kotlin.math.sqrt //import androidx.compose.foundation.background //import androidx.compose.foundation.gestures.detectTapGestures //import androidx.compose.ui.input.pointer.pointerInput //import androidx.compose.ui.window.Dialog //import androidx.compose.foundation.lazy.LazyColumn //import androidx.compose.foundation.lazy.items import java.text.SimpleDateFormat import java.util.Locale import androidx.camera.core.ImageCapture import androidx.camera.core.ImageCaptureException import androidx.camera.core.ImageProxy import android.graphics.Matrix import androidx.camera.core.CameraSelector import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.sp /* ================= CONSTANTS ================= */ object AppConstants { // Backend API URL - GANTI SESUAI SERVER ANDA // const val BASE_URL = "http://10.0.2.2:5000" // Untuk emulator Android const val BASE_URL = "http://192.168.100.99:5000" // Untuk device fisik // Koordinat Kampus (UBHARA Jaya) // const val KAMPUS_LATITUDE = -6.223325 // const val KAMPUS_LONGITUDE = 107.009406 // Koordinat Saat ini const val KAMPUS_LATITUDE = -6.239513 const val KAMPUS_LONGITUDE = 107.089676 const val RADIUS_METER = 500.0 // Offset untuk privasi const val LATITUDE_OFFSET = 0.0001 const val LONGITUDE_OFFSET = 0.0001 // SharedPreferences const val PREF_NAME = "AbsensiPrefs" const val KEY_TOKEN = "token" const val KEY_ID_MAHASISWA = "id_mahasiswa" const val KEY_NPM = "npm" const val KEY_NAMA = "nama" const val KEY_JENKEL = "jenkel" const val KEY_FAKULTAS = "fakultas" const val KEY_JURUSAN = "jurusan" const val KEY_SEMESTER = "semester" } /* ================= DATA CLASSES ================= */ data class Mahasiswa( val idMahasiswa: Int, val npm: String, val nama: String, val jenkel: String, val fakultas: String, val jurusan: String, val semester: Int ) data class JadwalKelas( val idJadwal: Int, val hari: String, val jamMulai: String, val jamSelesai: String, val ruangan: String, val kodeMatkul: String, val namaMatkul: String, val sks: Int, val dosen: String, val sudahAbsen: Boolean, val statusAbsensi: String? = null ) data class RiwayatAbsensi( val idAbsensi: Int, val npm: String, val nama: String, val mataKuliah: String? = null, // Tambahkan ini val latitude: Double, val longitude: Double, val timestamp: String, val status: String, val createdAt: String, val jamMulai: String? = null, val jamSelesai: String? = null ) data class AbsensiStats( val totalAbsensi: Int, val absensiMingguIni: Int, val absensiBulanIni: Int ) /* ================= USER PREFERENCES ================= */ class UserPreferences(private val context: Context) { private val prefs = context.getSharedPreferences(AppConstants.PREF_NAME, Context.MODE_PRIVATE) fun saveUserData(token: String, mahasiswa: Mahasiswa) { prefs.edit().apply { putString(AppConstants.KEY_TOKEN, token) putInt(AppConstants.KEY_ID_MAHASISWA, mahasiswa.idMahasiswa) putString(AppConstants.KEY_NPM, mahasiswa.npm) putString(AppConstants.KEY_NAMA, mahasiswa.nama) putString(AppConstants.KEY_JENKEL, mahasiswa.jenkel) putString(AppConstants.KEY_FAKULTAS, mahasiswa.fakultas) putString(AppConstants.KEY_JURUSAN, mahasiswa.jurusan) putInt(AppConstants.KEY_SEMESTER, mahasiswa.semester) apply() } } fun getToken(): String? = prefs.getString(AppConstants.KEY_TOKEN, null) fun getMahasiswa(): Mahasiswa? { val token = getToken() ?: return null val npm = prefs.getString(AppConstants.KEY_NPM, "") ?: return null if (npm.isEmpty()) return null return Mahasiswa( idMahasiswa = prefs.getInt(AppConstants.KEY_ID_MAHASISWA, 0), npm = npm, nama = prefs.getString(AppConstants.KEY_NAMA, "") ?: "", jenkel = prefs.getString(AppConstants.KEY_JENKEL, "") ?: "", fakultas = prefs.getString(AppConstants.KEY_FAKULTAS, "") ?: "", jurusan = prefs.getString(AppConstants.KEY_JURUSAN, "") ?: "", semester = prefs.getInt(AppConstants.KEY_SEMESTER, 0) ) } fun isLoggedIn(): Boolean = getToken() != null && getMahasiswa() != null fun logout() { prefs.edit().clear().apply() } } /* ================= UTIL FUNCTIONS ================= */ fun bitmapToBase64(bitmap: Bitmap): String { // 1. Tentukan ukuran baru (Misal Max Lebar 600px) val maxDimension = 600 var newWidth = maxDimension var newHeight = (bitmap.height.toFloat() / bitmap.width.toFloat() * newWidth).toInt() // Jika gambar aslinya sudah kecil, jangan dibesarkan if (bitmap.width <= maxDimension) { newWidth = bitmap.width newHeight = bitmap.height } // 2. Lakukan Resize val resizedBitmap = Bitmap.createScaledBitmap(bitmap, newWidth, newHeight, true) // 3. Kompres ke ByteArray val outputStream = java.io.ByteArrayOutputStream() // Kualitas 50 sudah cukup jika resolusinya kecil resizedBitmap.compress(android.graphics.Bitmap.CompressFormat.JPEG, 50, outputStream) val byteArray = outputStream.toByteArray() // 4. Return Base64 return android.util.Base64.encodeToString(byteArray, android.util.Base64.NO_WRAP) } fun base64ToBitmap(base64: String): Bitmap? { return try { val decodedBytes = Base64.decode(base64, Base64.DEFAULT) BitmapFactory.decodeByteArray(decodedBytes, 0, decodedBytes.size) } catch (e: Exception) { null } } fun hitungJarak(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double { val R = 6371000.0 val dLat = Math.toRadians(lat2 - lat1) val dLon = Math.toRadians(lon2 - lon1) val a = sin(dLat / 2) * sin(dLat / 2) + cos(Math.toRadians(lat1)) * cos(Math.toRadians(lat2)) * sin(dLon / 2) * sin(dLon / 2) val c = 2 * atan2(sqrt(a), sqrt(1 - a)) return R * c } fun getCurrentTimestamp(): String { val sdf = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()) return sdf.format(Date()) } /* ================= API CALLS ================= */ fun registerMahasiswa( npm: String, password: String, nama: String, jenkel: String, fakultas: String, jurusan: String, semester: Int, onSuccess: (String, Mahasiswa) -> Unit, onError: (String) -> Unit ) { thread { try { val url = URL("${AppConstants.BASE_URL}/api/auth/register") val conn = url.openConnection() as HttpURLConnection conn.requestMethod = "POST" conn.setRequestProperty("Content-Type", "application/json") conn.doOutput = true conn.connectTimeout = 15000 conn.readTimeout = 15000 val json = JSONObject().apply { put("npm", npm); put("password", password); put("nama", nama) put("jenkel", jenkel); put("fakultas", fakultas); put("jurusan", jurusan); put("semester", semester) } conn.outputStream.use { it.write(json.toString().toByteArray()) } val responseCode = conn.responseCode val response = if (responseCode == 201) conn.inputStream.bufferedReader().readText() else conn.errorStream?.bufferedReader()?.readText() ?: "" conn.disconnect() if (responseCode == 201) { val data = JSONObject(response).getJSONObject("data") val token = data.getString("token") val mahasiswa = Mahasiswa(data.getInt("id_mahasiswa"), data.getString("npm"), data.getString("nama"), jenkel, fakultas, jurusan, semester) onSuccess(token, mahasiswa) } else { onError(ErrorHandler.parseHttpError(responseCode, response)) } } catch (e: Exception) { onError(ErrorHandler.parseException(e)) } } } fun loginMahasiswa( npm: String, password: String, onSuccess: (String, Mahasiswa) -> Unit, onError: (String) -> Unit ) { thread { try { val url = URL("${AppConstants.BASE_URL}/api/auth/login") val conn = url.openConnection() as HttpURLConnection conn.requestMethod = "POST" conn.setRequestProperty("Content-Type", "application/json") conn.doOutput = true conn.connectTimeout = 15000 conn.readTimeout = 15000 val json = JSONObject().apply { put("npm", npm); put("password", password) } conn.outputStream.use { it.write(json.toString().toByteArray()) } val responseCode = conn.responseCode val response = if (responseCode == 200) conn.inputStream.bufferedReader().readText() else conn.errorStream?.bufferedReader()?.readText() ?: "" conn.disconnect() if (responseCode == 200) { val data = JSONObject(response).getJSONObject("data") val mahasiswa = Mahasiswa(data.getInt("id_mahasiswa"), data.getString("npm"), data.getString("nama"), data.getString("jenkel"), data.getString("fakultas"), data.getString("jurusan"), data.getInt("semester")) onSuccess(data.getString("token"), mahasiswa) } else { onError(ErrorHandler.parseHttpError(responseCode, response)) } } catch (e: Exception) { onError(ErrorHandler.parseException(e)) } } } fun getJadwalToday( token: String, onSuccess: (List) -> Unit, onError: (String) -> Unit ) { thread { try { val url = URL("${AppConstants.BASE_URL}/api/jadwal/today") val conn = url.openConnection() as HttpURLConnection conn.requestMethod = "GET" conn.setRequestProperty("Authorization", "Bearer $token") conn.connectTimeout = 15000 conn.readTimeout = 15000 val responseCode = conn.responseCode val response = if (responseCode == 200) conn.inputStream.bufferedReader().readText() else conn.errorStream?.bufferedReader()?.readText() ?: "" conn.disconnect() if (responseCode == 200) { val dataArray = JSONObject(response).getJSONArray("data") val jadwalList = mutableListOf() for (i in 0 until dataArray.length()) { // 1. Definisikan variabel 'json' (Solusi error "unresolved json") val json = dataArray.getJSONObject(i) // 2. Parse data sesuai Data Model JadwalKelas Anda val idJadwal = json.getInt("id_jadwal") val hari = json.optString("hari", "") // Tambahan sesuai model val jamMulai = json.getString("jam_mulai") val jamSelesai = json.getString("jam_selesai") val ruangan = json.getString("ruangan") val kodeMatkul = json.getString("kode_matkul") val namaMatkul = json.getString("nama_matkul") val sks = json.getInt("sks") // Tambahan sesuai model val dosen = json.getString("dosen") val sudahAbsen = json.getBoolean("sudah_absen") // 3. Cek Status Absensi (Bisa Null) val statusAbsensi = if (json.has("status_absensi") && !json.isNull("status_absensi")) { json.getString("status_absensi") } else { null } // 4. Masukkan ke List jadwalList.add( JadwalKelas( idJadwal = idJadwal, hari = hari, jamMulai = jamMulai, jamSelesai = jamSelesai, ruangan = ruangan, kodeMatkul = kodeMatkul, namaMatkul = namaMatkul, sks = sks, dosen = dosen, sudahAbsen = sudahAbsen, statusAbsensi = statusAbsensi ) ) } onSuccess(jadwalList) } else { onError(ErrorHandler.parseHttpError(responseCode, response)) } } catch (e: Exception) { onError(ErrorHandler.parseException(e)) } } } fun submitAbsensiWithJadwal( token: String, idJadwal: Int, latitude: Double, longitude: Double, fotoBase64: String, status: String, onSuccess: (String) -> Unit, onError: (String) -> Unit ) { thread { try { val url = URL("${AppConstants.BASE_URL}/api/absensi/submit") val conn = url.openConnection() as HttpURLConnection conn.requestMethod = "POST" conn.setRequestProperty("Content-Type", "application/json") conn.setRequestProperty("Authorization", "Bearer $token") conn.doOutput = true conn.connectTimeout = 30000 // Timeout lebih lama untuk upload foto conn.readTimeout = 30000 val json = JSONObject().apply { put("id_jadwal", idJadwal); put("latitude", latitude); put("longitude", longitude) put("timestamp", getCurrentTimestamp()); put("foto_base64", fotoBase64); put("status", status) } conn.outputStream.use { it.write(json.toString().toByteArray()) } val responseCode = conn.responseCode val response = if (responseCode == 201) conn.inputStream.bufferedReader().readText() else conn.errorStream?.bufferedReader()?.readText() ?: "" conn.disconnect() if (responseCode == 201) { onSuccess(JSONObject(response).getJSONObject("data").getString("mata_kuliah")) } else { onError(ErrorHandler.parseHttpError(responseCode, response)) } } catch (e: Exception) { onError(ErrorHandler.parseException(e)) } } } fun getAbsensiHistory( token: String, startDate: String? = null, endDate: String? = null, onSuccess: (List) -> Unit, onError: (String) -> Unit ) { thread { try { var urlString = "${AppConstants.BASE_URL}/api/absensi/history" val params = mutableListOf() if (startDate != null) params.add("start_date=$startDate") if (endDate != null) params.add("end_date=$endDate") if (params.isNotEmpty()) { urlString += "?${params.joinToString("&")}" } val url = URL(urlString) val conn = url.openConnection() as HttpURLConnection conn.requestMethod = "GET" conn.setRequestProperty("Authorization", "Bearer $token") conn.connectTimeout = 15000 conn.readTimeout = 15000 val responseCode = conn.responseCode // Baca response body (sukses) atau error stream (gagal) val response = if (responseCode == 200) { conn.inputStream.bufferedReader().use { it.readText() } } else { conn.errorStream?.bufferedReader()?.use { it.readText() } ?: "" } conn.disconnect() if (responseCode == 200) { val jsonResponse = JSONObject(response) val dataArray = jsonResponse.getJSONArray("data") val riwayatList = mutableListOf() for (i in 0 until dataArray.length()) { val item = dataArray.getJSONObject(i) riwayatList.add( RiwayatAbsensi( idAbsensi = item.getInt("id_absensi"), npm = item.getString("npm"), nama = item.getString("nama"), mataKuliah = item.optString("mata_kuliah", null), latitude = item.getDouble("latitude"), longitude = item.getDouble("longitude"), timestamp = item.getString("timestamp"), status = item.getString("status"), createdAt = item.getString("created_at"), jamMulai = item.optString("jam_mulai", null), jamSelesai = item.optString("jam_selesai", null) ) ) } onSuccess(riwayatList) } else { // INTEGRASI ERROR HANDLER: // Parse pesan error dari server menggunakan helper val friendlyMessage = ErrorHandler.parseHttpError(responseCode, response) // Kirim kode error di depan pesan agar bisa dideteksi UI (misal: [401]) onError("[$responseCode] $friendlyMessage") } } catch (e: Exception) { // INTEGRASI ERROR HANDLER: Tangkap Exception (Timeout, No Internet, dll) onError(ErrorHandler.parseException(e)) } } } fun getAbsensiStats( token: String, onSuccess: (AbsensiStats) -> Unit, onError: (String) -> Unit ) { thread { try { val url = URL("${AppConstants.BASE_URL}/api/absensi/stats") val conn = url.openConnection() as HttpURLConnection conn.requestMethod = "GET" conn.setRequestProperty("Authorization", "Bearer $token") conn.connectTimeout = 15000 conn.readTimeout = 15000 val responseCode = conn.responseCode val response = if (responseCode == 200) { conn.inputStream.bufferedReader().use { it.readText() } } else { conn.errorStream?.bufferedReader()?.use { it.readText() } ?: "" } conn.disconnect() if (responseCode == 200) { val jsonResponse = JSONObject(response) val data = jsonResponse.getJSONObject("data") val stats = AbsensiStats( totalAbsensi = data.getInt("total_absensi"), absensiMingguIni = data.getInt("absensi_minggu_ini"), absensiBulanIni = data.getInt("absensi_bulan_ini") ) onSuccess(stats) } else { // INTEGRASI ERROR HANDLER val friendlyMessage = ErrorHandler.parseHttpError(responseCode, response) onError("[$responseCode] $friendlyMessage") } } catch (e: Exception) { // INTEGRASI ERROR HANDLER onError(ErrorHandler.parseException(e)) } } } fun getFotoAbsensi( token: String, idAbsensi: Int, onSuccess: (String) -> Unit, onError: (String) -> Unit ) { thread { try { val url = URL("${AppConstants.BASE_URL}/api/absensi/photo/$idAbsensi") val conn = url.openConnection() as HttpURLConnection conn.requestMethod = "GET" conn.setRequestProperty("Authorization", "Bearer $token") conn.connectTimeout = 15000 conn.readTimeout = 15000 val responseCode = conn.responseCode val response = if (responseCode == 200) { conn.inputStream.bufferedReader().use { it.readText() } } else { conn.errorStream?.bufferedReader()?.use { it.readText() } ?: "Error" } conn.disconnect() if (responseCode == 200) { val jsonResponse = JSONObject(response) val fotoBase64 = jsonResponse.getJSONObject("data").getString("foto_base64") onSuccess(fotoBase64) } else { val error = try { JSONObject(response).optString("error", "Gagal mengambil foto") } catch (e: Exception) { "Gagal mengambil foto" } onError(error) } } catch (e: Exception) { onError("Error: ${e.message}") } } } /* ================= MAIN ACTIVITY ================= */ class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdge() val userPrefs = UserPreferences(this) setContent { SistemAkademikTheme { var currentScreen by remember { mutableStateOf( if (userPrefs.isLoggedIn()) "main" else "login" ) } var mahasiswa by remember { mutableStateOf(userPrefs.getMahasiswa()) } var token by remember { mutableStateOf(userPrefs.getToken()) } Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> when (currentScreen) { "login" -> LoginScreen( modifier = Modifier.padding(innerPadding), onLoginSuccess = { t, m -> token = t mahasiswa = m userPrefs.saveUserData(t, m) currentScreen = "main" }, onNavigateToRegister = { currentScreen = "register" } ) "register" -> RegisterScreen( modifier = Modifier.padding(innerPadding), onRegisterSuccess = { t, m -> token = t mahasiswa = m userPrefs.saveUserData(t, m) currentScreen = "main" }, onNavigateToLogin = { currentScreen = "login" } ) "main" -> MainScreen( modifier = Modifier.padding(innerPadding), activity = this, token = token ?: "", mahasiswa = mahasiswa ?: Mahasiswa(0, "", "", "", "", "", 0), onLogout = { userPrefs.logout() token = null mahasiswa = null currentScreen = "login" } ) } } } } } } // ================= JADWAL SCREEN ================= @Composable fun JadwalScreen( modifier: Modifier = Modifier, token: String ) { var jadwalList by remember { mutableStateOf>(emptyList()) } var isLoading by remember { mutableStateOf(true) } var errorMessage by remember { mutableStateOf(null) } var hariIni by remember { mutableStateOf("") } val context = LocalContext.current val GoldPrimary = androidx.compose.ui.graphics.Color(0xFFB8860B) val MaroonSecondary = androidx.compose.ui.graphics.Color(0xFF800000) // Fungsi Load Data fun loadJadwal() { isLoading = true errorMessage = null getJadwalToday( token = token, onSuccess = { jadwal -> (context as? ComponentActivity)?.runOnUiThread { jadwalList = jadwal isLoading = false } }, onError = { error -> (context as? ComponentActivity)?.runOnUiThread { errorMessage = error isLoading = false } } ) } LaunchedEffect(Unit) { val hariMapping = mapOf( "Monday" to "Senin", "Tuesday" to "Selasa", "Wednesday" to "Rabu", "Thursday" to "Kamis", "Friday" to "Jumat", "Saturday" to "Sabtu", "Sunday" to "Minggu" ) hariIni = hariMapping[java.time.LocalDate.now().dayOfWeek.toString().toLowerCase().capitalize()] ?: "Senin" loadJadwal() } // Error State if (errorMessage != null && jadwalList.isEmpty()) { FullScreenErrorState(message = errorMessage!!, onRetry = { loadJadwal() }) return } Column( modifier = modifier .fillMaxSize() .background(androidx.compose.ui.graphics.Color(0xFFF8F9FA)) // Background abu muda .padding(horizontal = 24.dp) .verticalScroll(rememberScrollState()) ) { Spacer(modifier = Modifier.height(30.dp)) // Header Row(verticalAlignment = Alignment.CenterVertically) { // Kotak Tanggal/Hari Card( colors = CardDefaults.cardColors(containerColor = GoldPrimary), shape = androidx.compose.foundation.shape.RoundedCornerShape(12.dp) ) { Box( modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), contentAlignment = Alignment.Center ) { Text( text = hariIni, style = MaterialTheme.typography.titleMedium.copy(fontWeight = androidx.compose.ui.text.font.FontWeight.Bold), color = androidx.compose.ui.graphics.Color.White ) } } Spacer(modifier = Modifier.width(16.dp)) Column { Text( text = "Jadwal Kuliah", style = MaterialTheme.typography.headlineSmall.copy(fontWeight = androidx.compose.ui.text.font.FontWeight.Bold), color = androidx.compose.ui.graphics.Color.Black ) Text( text = "Semester Ganjil 2025/2026", // Bisa dibuat dinamis nanti style = MaterialTheme.typography.bodySmall, color = androidx.compose.ui.graphics.Color.Gray ) } } Spacer(modifier = Modifier.height(24.dp)) // Content if (isLoading) { Box(modifier = Modifier.fillMaxWidth().height(200.dp), contentAlignment = Alignment.Center) { CircularProgressIndicator(color = GoldPrimary) } } else if (jadwalList.isEmpty()) { // Empty State yang lebih cantik Column( modifier = Modifier.fillMaxWidth().padding(top = 40.dp), horizontalAlignment = Alignment.CenterHorizontally ) { Icon( imageVector = Icons.Default.EventBusy, contentDescription = null, modifier = Modifier.size(80.dp), tint = androidx.compose.ui.graphics.Color.LightGray ) Spacer(modifier = Modifier.height(16.dp)) Text( text = "Tidak ada kelas hari ini", style = MaterialTheme.typography.titleMedium.copy(fontWeight = androidx.compose.ui.text.font.FontWeight.Bold), color = androidx.compose.ui.graphics.Color.Gray ) Text( text = "Silakan istirahat atau cek tugas Anda", style = MaterialTheme.typography.bodyMedium, color = androidx.compose.ui.graphics.Color.LightGray ) } } else { // List Jadwal jadwalList.forEach { jadwal -> JadwalCard(jadwal = jadwal) Spacer(modifier = Modifier.height(16.dp)) } Spacer(modifier = Modifier.height(80.dp)) } } } @Composable fun JadwalCard(jadwal: JadwalKelas) { // Warna Tema UBHARA (Tetap satu warna) val GoldPrimary = androidx.compose.ui.graphics.Color(0xFFB8860B) Card( modifier = Modifier.fillMaxWidth(), shape = androidx.compose.foundation.shape.RoundedCornerShape(16.dp), colors = CardDefaults.cardColors(containerColor = androidx.compose.ui.graphics.Color.White), elevation = CardDefaults.cardElevation(defaultElevation = 4.dp) ) { Row(modifier = Modifier.height(IntrinsicSize.Min)) { // 1. Strip Kiri (Selalu Emas, tidak berubah warna lagi) Box( modifier = Modifier .fillMaxHeight() .width(6.dp) .background(GoldPrimary) ) Column(modifier = Modifier.padding(16.dp).weight(1f)) { // 2. Header: Kode Matkul & SKS (Badge Status DIHAPUS) Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { Text( text = "${jadwal.kodeMatkul} • ${jadwal.sks} SKS", style = MaterialTheme.typography.labelMedium.copy( fontWeight = androidx.compose.ui.text.font.FontWeight.Bold ), color = androidx.compose.ui.graphics.Color.Gray ) // (Bagian Badge/Chip Status sudah dihapus disini) } Spacer(modifier = Modifier.height(8.dp)) // 3. Nama Mata Kuliah (Selalu Hitam) Text( text = jadwal.namaMatkul, style = MaterialTheme.typography.titleMedium.copy( fontWeight = androidx.compose.ui.text.font.FontWeight.Bold ), color = androidx.compose.ui.graphics.Color.Black ) Spacer(modifier = Modifier.height(4.dp)) // 4. Nama Dosen Row(verticalAlignment = Alignment.CenterVertically) { Icon( imageVector = Icons.Default.Person, contentDescription = null, modifier = Modifier.size(14.dp), tint = androidx.compose.ui.graphics.Color.Gray ) Spacer(modifier = Modifier.width(4.dp)) Text( text = jadwal.dosen, style = MaterialTheme.typography.bodySmall, color = androidx.compose.ui.graphics.Color.Gray ) } Spacer(modifier = Modifier.height(12.dp)) Divider(color = androidx.compose.ui.graphics.Color.LightGray.copy(alpha = 0.3f)) Spacer(modifier = Modifier.height(12.dp)) // 5. Waktu & Ruangan Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween ) { // Waktu Row(verticalAlignment = Alignment.CenterVertically) { Icon( imageVector = Icons.Default.AccessTime, contentDescription = null, modifier = Modifier.size(16.dp), tint = GoldPrimary ) Spacer(modifier = Modifier.width(6.dp)) // Format jam (HH:mm) val jamMulaiStr = if(jadwal.jamMulai.length >= 5) jadwal.jamMulai.substring(0,5) else jadwal.jamMulai val jamSelesaiStr = if(jadwal.jamSelesai.length >= 5) jadwal.jamSelesai.substring(0,5) else jadwal.jamSelesai Text( text = "$jamMulaiStr - $jamSelesaiStr", style = MaterialTheme.typography.bodyMedium.copy( fontWeight = androidx.compose.ui.text.font.FontWeight.SemiBold ), color = androidx.compose.ui.graphics.Color.Gray ) } // Ruangan Row(verticalAlignment = Alignment.CenterVertically) { Icon( imageVector = Icons.Default.MeetingRoom, contentDescription = null, modifier = Modifier.size(16.dp), tint = GoldPrimary ) Spacer(modifier = Modifier.width(6.dp)) Text( text = jadwal.ruangan, style = MaterialTheme.typography.bodyMedium.copy( fontWeight = androidx.compose.ui.text.font.FontWeight.SemiBold ), color = androidx.compose.ui.graphics.Color.Gray ) } } } } } } // ================= REGISTER SCREEN (UI BARU) ================= @OptIn(ExperimentalMaterial3Api::class) @Composable fun RegisterScreen( modifier: Modifier = Modifier, onRegisterSuccess: (String, Mahasiswa) -> Unit, onNavigateToLogin: () -> Unit ) { // State Form var npm by remember { mutableStateOf("") } var password by remember { mutableStateOf("") } var confirmPassword by remember { mutableStateOf("") } var nama by remember { mutableStateOf("") } var jenkel by remember { mutableStateOf("L") } // Default Laki-laki var fakultas by remember { mutableStateOf("") } var jurusan by remember { mutableStateOf("") } var semester by remember { mutableStateOf("") } // State UI var showPassword by remember { mutableStateOf(false) } var showConfirmPassword by remember { mutableStateOf(false) } var isLoading by remember { mutableStateOf(false) } var errorMessage by remember { mutableStateOf(null) } val context = LocalContext.current // Warna Tema (Lokal) val GoldPrimary = androidx.compose.ui.graphics.Color(0xFFB8860B) val GoldLight = androidx.compose.ui.graphics.Color(0xFFDAA520) val MaroonSecondary = androidx.compose.ui.graphics.Color(0xFF800000) // Error Dialog if (errorMessage != null) { ErrorDialog(message = errorMessage!!, onDismiss = { errorMessage = null }) } Box(modifier = modifier.fillMaxSize()) { // 1. Header Background (Lengkungan Emas) Box( modifier = Modifier .fillMaxWidth() .height(220.dp) // Sedikit lebih pendek dari login karena konten banyak .background( brush = androidx.compose.ui.graphics.Brush.verticalGradient( colors = listOf(GoldPrimary, GoldLight) ), shape = androidx.compose.foundation.shape.RoundedCornerShape(bottomStart = 60.dp, bottomEnd = 60.dp) ) ) // 2. Konten Utama Column( modifier = Modifier .fillMaxSize() .padding(horizontal = 24.dp) .verticalScroll(rememberScrollState()), horizontalAlignment = Alignment.CenterHorizontally ) { Spacer(modifier = Modifier.height(30.dp)) // Icon Header Kecil Surface( shape = CircleShape, color = androidx.compose.ui.graphics.Color.White.copy(alpha = 0.2f), modifier = Modifier.size(60.dp) ) { Box(contentAlignment = Alignment.Center) { Icon( imageVector = Icons.Default.PersonAdd, contentDescription = "Register", tint = androidx.compose.ui.graphics.Color.White, modifier = Modifier.size(30.dp) ) } } Spacer(modifier = Modifier.height(16.dp)) Text( text = "Registrasi Mahasiswa", style = MaterialTheme.typography.headlineSmall.copy( fontWeight = androidx.compose.ui.text.font.FontWeight.Bold, color = androidx.compose.ui.graphics.Color.White ) ) Text( text = "Lengkapi data diri Anda", style = MaterialTheme.typography.bodyMedium.copy( color = androidx.compose.ui.graphics.Color.White.copy(alpha = 0.9f) ) ) Spacer(modifier = Modifier.height(30.dp)) // 3. Card Form Input Card( modifier = Modifier.fillMaxWidth(), shape = androidx.compose.foundation.shape.RoundedCornerShape(24.dp), colors = CardDefaults.cardColors(containerColor = androidx.compose.ui.graphics.Color.White), elevation = CardDefaults.cardElevation(defaultElevation = 8.dp) ) { Column( modifier = Modifier.padding(20.dp), horizontalAlignment = Alignment.CenterHorizontally ) { // --- DATA AKUN --- Text("Data Akun", style = MaterialTheme.typography.labelLarge, color = GoldPrimary) Spacer(modifier = Modifier.height(8.dp)) // NPM OutlinedTextField( value = npm, onValueChange = { npm = it }, label = { Text("NPM") }, leadingIcon = { Icon(Icons.Default.Badge, null, tint = GoldPrimary) }, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), modifier = Modifier.fillMaxWidth(), singleLine = true, shape = androidx.compose.foundation.shape.RoundedCornerShape(12.dp), colors = OutlinedTextFieldDefaults.colors(focusedBorderColor = GoldPrimary, cursorColor = GoldPrimary) ) Spacer(modifier = Modifier.height(8.dp)) // Password OutlinedTextField( value = password, onValueChange = { password = it }, label = { Text("Password") }, leadingIcon = { Icon(Icons.Default.Lock, null, tint = GoldPrimary) }, visualTransformation = if (showPassword) VisualTransformation.None else PasswordVisualTransformation(), trailingIcon = { IconButton(onClick = { showPassword = !showPassword }) { Icon(if (showPassword) Icons.Default.Visibility else Icons.Default.VisibilityOff, null) } }, modifier = Modifier.fillMaxWidth(), singleLine = true, shape = androidx.compose.foundation.shape.RoundedCornerShape(12.dp), colors = OutlinedTextFieldDefaults.colors(focusedBorderColor = GoldPrimary, cursorColor = GoldPrimary) ) Spacer(modifier = Modifier.height(8.dp)) // Confirm Password OutlinedTextField( value = confirmPassword, onValueChange = { confirmPassword = it }, label = { Text("Konfirmasi Password") }, leadingIcon = { Icon(Icons.Default.LockReset, null, tint = GoldPrimary) }, visualTransformation = if (showConfirmPassword) VisualTransformation.None else PasswordVisualTransformation(), trailingIcon = { IconButton(onClick = { showConfirmPassword = !showConfirmPassword }) { Icon(if (showConfirmPassword) Icons.Default.Visibility else Icons.Default.VisibilityOff, null) } }, modifier = Modifier.fillMaxWidth(), singleLine = true, shape = androidx.compose.foundation.shape.RoundedCornerShape(12.dp), colors = OutlinedTextFieldDefaults.colors(focusedBorderColor = GoldPrimary, cursorColor = GoldPrimary) ) Spacer(modifier = Modifier.height(16.dp)) Divider(color = androidx.compose.ui.graphics.Color.LightGray.copy(alpha = 0.5f)) Spacer(modifier = Modifier.height(16.dp)) // --- DATA PRIBADI --- Text("Data Pribadi", style = MaterialTheme.typography.labelLarge, color = GoldPrimary) Spacer(modifier = Modifier.height(8.dp)) // Nama OutlinedTextField( value = nama, onValueChange = { nama = it }, label = { Text("Nama Lengkap") }, leadingIcon = { Icon(Icons.Default.Person, null, tint = GoldPrimary) }, modifier = Modifier.fillMaxWidth(), singleLine = true, shape = androidx.compose.foundation.shape.RoundedCornerShape(12.dp), colors = OutlinedTextFieldDefaults.colors(focusedBorderColor = GoldPrimary, cursorColor = GoldPrimary) ) Spacer(modifier = Modifier.height(12.dp)) // Gender Selector (Custom Buttons) Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { val isL = jenkel == "L" OutlinedButton( onClick = { jenkel = "L" }, modifier = Modifier.weight(1f), shape = androidx.compose.foundation.shape.RoundedCornerShape(12.dp), colors = ButtonDefaults.outlinedButtonColors( containerColor = if (isL) GoldPrimary else androidx.compose.ui.graphics.Color.Transparent, contentColor = if (isL) androidx.compose.ui.graphics.Color.White else androidx.compose.ui.graphics.Color.Gray ), border = androidx.compose.foundation.BorderStroke(1.dp, if (isL) GoldPrimary else androidx.compose.ui.graphics.Color.Gray) ) { Text("Laki-laki") } val isP = jenkel == "P" OutlinedButton( onClick = { jenkel = "P" }, modifier = Modifier.weight(1f), shape = androidx.compose.foundation.shape.RoundedCornerShape(12.dp), colors = ButtonDefaults.outlinedButtonColors( containerColor = if (isP) GoldPrimary else androidx.compose.ui.graphics.Color.Transparent, contentColor = if (isP) androidx.compose.ui.graphics.Color.White else androidx.compose.ui.graphics.Color.Gray ), border = androidx.compose.foundation.BorderStroke(1.dp, if (isP) GoldPrimary else androidx.compose.ui.graphics.Color.Gray) ) { Text("Perempuan") } } Spacer(modifier = Modifier.height(12.dp)) // Fakultas OutlinedTextField( value = fakultas, onValueChange = { fakultas = it }, label = { Text("Fakultas") }, leadingIcon = { Icon(Icons.Default.School, null, tint = GoldPrimary) }, modifier = Modifier.fillMaxWidth(), singleLine = true, shape = androidx.compose.foundation.shape.RoundedCornerShape(12.dp), colors = OutlinedTextFieldDefaults.colors(focusedBorderColor = GoldPrimary, cursorColor = GoldPrimary) ) Spacer(modifier = Modifier.height(8.dp)) // Jurusan & Semester (Row) Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { OutlinedTextField( value = jurusan, onValueChange = { jurusan = it }, label = { Text("Jurusan") }, leadingIcon = { Icon(Icons.Default.Book, null, tint = GoldPrimary) }, modifier = Modifier.weight(1.5f), singleLine = true, shape = androidx.compose.foundation.shape.RoundedCornerShape(12.dp), colors = OutlinedTextFieldDefaults.colors(focusedBorderColor = GoldPrimary, cursorColor = GoldPrimary) ) OutlinedTextField( value = semester, onValueChange = { if (it.isEmpty() || it.toIntOrNull() != null) semester = it }, label = { Text("Sms") }, placeholder = { Text("1-8") }, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), modifier = Modifier.weight(1f), singleLine = true, shape = androidx.compose.foundation.shape.RoundedCornerShape(12.dp), colors = OutlinedTextFieldDefaults.colors(focusedBorderColor = GoldPrimary, cursorColor = GoldPrimary) ) } Spacer(modifier = Modifier.height(32.dp)) // Tombol Daftar (Gradient) Button( onClick = { errorMessage = null if (npm.length < 8 || password.length < 6 || nama.isEmpty()) { errorMessage = "Mohon lengkapi data dengan benar (Password min 6 karakter)" } else if (password != confirmPassword) { errorMessage = "Konfirmasi password tidak cocok" } else { isLoading = true registerMahasiswa( npm = npm.trim(), password = password, nama = nama.trim(), jenkel = jenkel, fakultas = fakultas.trim(), jurusan = jurusan.trim(), semester = semester.toIntOrNull() ?: 1, onSuccess = { token, mhs -> (context as? ComponentActivity)?.runOnUiThread { isLoading = false onRegisterSuccess(token, mhs) } }, onError = { error -> (context as? ComponentActivity)?.runOnUiThread { isLoading = false errorMessage = error } } ) } }, modifier = Modifier.fillMaxWidth().height(54.dp), shape = androidx.compose.foundation.shape.RoundedCornerShape(12.dp), colors = ButtonDefaults.buttonColors(containerColor = androidx.compose.ui.graphics.Color.Transparent), contentPadding = PaddingValues() ) { Box( modifier = Modifier.fillMaxSize().background( brush = androidx.compose.ui.graphics.Brush.horizontalGradient( colors = listOf(GoldPrimary, MaroonSecondary) ), shape = androidx.compose.foundation.shape.RoundedCornerShape(12.dp) ), contentAlignment = Alignment.Center ) { if (isLoading) { CircularProgressIndicator(modifier = Modifier.size(24.dp), color = androidx.compose.ui.graphics.Color.White) } else { Text("DAFTAR SEKARANG", style = MaterialTheme.typography.titleMedium.copy(fontWeight = androidx.compose.ui.text.font.FontWeight.Bold)) } } } Spacer(modifier = Modifier.height(16.dp)) // Navigasi Login Row(verticalAlignment = Alignment.CenterVertically) { Text("Sudah punya akun?", style = MaterialTheme.typography.bodyMedium, color = androidx.compose.ui.graphics.Color.Gray) TextButton(onClick = onNavigateToLogin, enabled = !isLoading) { Text("Masuk", style = MaterialTheme.typography.bodyMedium.copy(fontWeight = androidx.compose.ui.text.font.FontWeight.Bold), color = MaroonSecondary) } } } } Spacer(modifier = Modifier.height(40.dp)) } } } // ================= LOGIN SCREEN ================= @OptIn(ExperimentalMaterial3Api::class) @Composable fun LoginScreen( modifier: Modifier = Modifier, onLoginSuccess: (String, Mahasiswa) -> Unit, onNavigateToRegister: () -> Unit // Fitur Register TETAP ADA ) { var npm by remember { mutableStateOf("") } var password by remember { mutableStateOf("") } var showPassword by remember { mutableStateOf(false) } var isLoading by remember { mutableStateOf(false) } var errorMessage by remember { mutableStateOf(null) } val context = LocalContext.current // Definisi Warna Lokal (Agar langsung jalan tanpa ubah Theme.kt dulu) val GoldPrimary = androidx.compose.ui.graphics.Color(0xFFB8860B) val GoldLight = androidx.compose.ui.graphics.Color(0xFFDAA520) val MaroonSecondary = androidx.compose.ui.graphics.Color(0xFF800000) val MaroonLight = androidx.compose.ui.graphics.Color(0xFFA52A2A) // Handler Error Dialog if (errorMessage != null) { ErrorDialog(message = errorMessage!!, onDismiss = { errorMessage = null }) } Box(modifier = modifier.fillMaxSize()) { // 1. Background Header (Lengkungan Gradasi Emas) Box( modifier = Modifier .fillMaxWidth() .height(260.dp) .background( brush = androidx.compose.ui.graphics.Brush.verticalGradient( colors = listOf(GoldPrimary, GoldLight) ), shape = androidx.compose.foundation.shape.RoundedCornerShape(bottomStart = 60.dp, bottomEnd = 60.dp) ) ) // 2. Konten Utama Column( modifier = Modifier .fillMaxSize() .padding(horizontal = 24.dp) .verticalScroll(rememberScrollState()), // Agar bisa discroll di layar kecil horizontalAlignment = Alignment.CenterHorizontally ) { Spacer(modifier = Modifier.height(40.dp)) Surface( shape = CircleShape, color = androidx.compose.ui.graphics.Color.White, shadowElevation = 8.dp, modifier = Modifier.size(100.dp) ) { Box(contentAlignment = Alignment.Center) { Image( // Pastikan ID ini sesuai nama file Anda painter = painterResource(id = R.drawable.logo_ubhara), contentDescription = "Logo UBHARA", modifier = Modifier .fillMaxSize(), // Mengikuti ukuran wadah (dikurangi padding) contentScale = ContentScale.Fit // Agar logo tidak terpotong/gepeng ) } } Spacer(modifier = Modifier.height(24.dp)) // Judul Aplikasi Text( text = "Sistem Akademik", style = MaterialTheme.typography.headlineMedium.copy( fontWeight = androidx.compose.ui.text.font.FontWeight.Bold, color = androidx.compose.ui.graphics.Color.White ) ) Text( text = "UBHARA Jaya", style = MaterialTheme.typography.titleMedium.copy( color = androidx.compose.ui.graphics.Color.White.copy(alpha = 0.9f) ) ) Spacer(modifier = Modifier.height(40.dp)) // 3. Card Form Input Card( modifier = Modifier.fillMaxWidth(), shape = androidx.compose.foundation.shape.RoundedCornerShape(24.dp), colors = CardDefaults.cardColors(containerColor = androidx.compose.ui.graphics.Color.White), elevation = CardDefaults.cardElevation(defaultElevation = 10.dp) ) { Column( modifier = Modifier.padding(24.dp), horizontalAlignment = Alignment.CenterHorizontally ) { Text( text = "Login Mahasiswa", style = MaterialTheme.typography.titleLarge.copy( fontWeight = androidx.compose.ui.text.font.FontWeight.Bold ), color = GoldPrimary ) Spacer(modifier = Modifier.height(24.dp)) // Input NPM OutlinedTextField( value = npm, onValueChange = { npm = it }, label = { Text("NPM") }, placeholder = { Text("Masukkan NPM") }, leadingIcon = { Icon(Icons.Default.Badge, contentDescription = null, tint = GoldPrimary) }, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), modifier = Modifier.fillMaxWidth(), singleLine = true, enabled = !isLoading, shape = androidx.compose.foundation.shape.RoundedCornerShape(12.dp), colors = OutlinedTextFieldDefaults.colors( focusedBorderColor = GoldPrimary, focusedLabelColor = GoldPrimary, cursorColor = GoldPrimary, focusedTextColor = androidx.compose.ui.graphics.Color.Black, unfocusedTextColor = androidx.compose.ui.graphics.Color.Black ) ) Spacer(modifier = Modifier.height(16.dp)) // Input Password OutlinedTextField( value = password, onValueChange = { password = it }, label = { Text("Password") }, leadingIcon = { Icon(Icons.Default.Lock, contentDescription = null, tint = GoldPrimary) }, visualTransformation = if (showPassword) VisualTransformation.None else PasswordVisualTransformation(), trailingIcon = { IconButton(onClick = { showPassword = !showPassword }) { Icon( imageVector = if (showPassword) Icons.Default.Visibility else Icons.Default.VisibilityOff, contentDescription = "Toggle", tint = androidx.compose.ui.graphics.Color.Gray ) } }, modifier = Modifier.fillMaxWidth(), singleLine = true, enabled = !isLoading, shape = androidx.compose.foundation.shape.RoundedCornerShape(12.dp), colors = OutlinedTextFieldDefaults.colors( focusedBorderColor = GoldPrimary, focusedLabelColor = GoldPrimary, cursorColor = GoldPrimary, focusedTextColor = androidx.compose.ui.graphics.Color.Black, unfocusedTextColor = androidx.compose.ui.graphics.Color.Black ) ) Spacer(modifier = Modifier.height(32.dp)) // Tombol Login (Gradient Style) Button( onClick = { errorMessage = null if (npm.isEmpty() || password.isEmpty()) { errorMessage = "NPM dan Password wajib diisi" return@Button } isLoading = true loginMahasiswa( npm = npm.trim(), password = password, onSuccess = { token, mhs -> (context as? ComponentActivity)?.runOnUiThread { isLoading = false onLoginSuccess(token, mhs) } }, onError = { error -> (context as? ComponentActivity)?.runOnUiThread { isLoading = false errorMessage = error } } ) }, modifier = Modifier .fillMaxWidth() .height(54.dp), shape = androidx.compose.foundation.shape.RoundedCornerShape(12.dp), colors = ButtonDefaults.buttonColors(containerColor = androidx.compose.ui.graphics.Color.Transparent), contentPadding = PaddingValues() // Hilangkan padding default agar gradient full ) { // Background Gradient untuk Tombol Box( modifier = Modifier .fillMaxSize() .background( brush = androidx.compose.ui.graphics.Brush.horizontalGradient( colors = listOf(GoldPrimary, MaroonSecondary) ), shape = androidx.compose.foundation.shape.RoundedCornerShape(12.dp) ), contentAlignment = Alignment.Center ) { if (isLoading) { CircularProgressIndicator( modifier = Modifier.size(24.dp), color = androidx.compose.ui.graphics.Color.White ) } else { Text( text = "MASUK", style = MaterialTheme.typography.titleMedium.copy( fontWeight = androidx.compose.ui.text.font.FontWeight.Bold, letterSpacing = 1.sp ), color = androidx.compose.ui.graphics.Color.White ) } } } Spacer(modifier = Modifier.height(16.dp)) // Tombol Navigasi ke Register (TETAP ADA) Row( verticalAlignment = Alignment.CenterVertically ) { Text( text = "Belum punya akun?", style = MaterialTheme.typography.bodyMedium, color = androidx.compose.ui.graphics.Color.Gray ) TextButton(onClick = onNavigateToRegister, enabled = !isLoading) { Text( text = "Daftar di sini", style = MaterialTheme.typography.bodyMedium.copy( fontWeight = androidx.compose.ui.text.font.FontWeight.Bold ), color = MaroonSecondary ) } } } } Spacer(modifier = Modifier.height(30.dp)) } } } // ================= RIWAYAT SCREEN ================= @Composable fun RiwayatScreen( modifier: Modifier = Modifier, activity: ComponentActivity, token: String ) { val context = LocalContext.current val scrollState = rememberScrollState() // Warna Tema val GoldPrimary = androidx.compose.ui.graphics.Color(0xFFB8860B) val MaroonSecondary = androidx.compose.ui.graphics.Color(0xFF800000) // State (Tetap Sama) var riwayatList by remember { mutableStateOf>(emptyList()) } var stats by remember { mutableStateOf(null) } var isLoading by remember { mutableStateOf(true) } var errorMessage by remember { mutableStateOf(null) } var showFilterDialog by remember { mutableStateOf(false) } var showFotoDialog by remember { mutableStateOf(false) } var selectedFoto by remember { mutableStateOf(null) } var isLoadingFoto by remember { mutableStateOf(false) } var startDate by remember { mutableStateOf(null) } var endDate by remember { mutableStateOf(null) } var filterActive by remember { mutableStateOf(false) } fun loadData() { isLoading = true errorMessage = null getAbsensiHistory( token = token, startDate = startDate, endDate = endDate, onSuccess = { riwayat -> activity.runOnUiThread { riwayatList = riwayat; isLoading = false } }, onError = { error -> activity.runOnUiThread { errorMessage = error; isLoading = false } } ) getAbsensiStats( token = token, onSuccess = { statsData -> activity.runOnUiThread { stats = statsData } }, onError = {} ) } LaunchedEffect(Unit) { loadData() } // --- DIALOGS (Foto & Filter) --- // (Kode dialog filter & foto SAMA PERSIS dengan sebelumnya, tidak perlu diubah logic-nya) if (showFotoDialog && selectedFoto != null) { Dialog( onDismissRequest = { showFotoDialog = false }, // Properti ini membuat Dialog bisa di-custom ukurannya (bisa full width) properties = androidx.compose.ui.window.DialogProperties( usePlatformDefaultWidth = false ) ) { // Background Gelap Transparan (Scrim) Box( modifier = Modifier .fillMaxSize() .background(androidx.compose.ui.graphics.Color.Black.copy(alpha = 0.85f)) .clickable { showFotoDialog = false }, // Klik area gelap untuk tutup contentAlignment = Alignment.Center ) { // Kartu Foto Card( modifier = Modifier .fillMaxWidth(0.9f) // Lebar 90% layar .fillMaxHeight(0.75f) // Tinggi 75% layar (Agar "sedikit fullscreen") .clickable(enabled = false) {}, // Agar klik di kartu tidak menutup dialog shape = androidx.compose.foundation.shape.RoundedCornerShape(24.dp), colors = CardDefaults.cardColors(containerColor = androidx.compose.ui.graphics.Color.White), // Border Emas sesuai tema border = androidx.compose.foundation.BorderStroke(2.dp, GoldPrimary) ) { Column( modifier = Modifier.fillMaxSize() ) { // 1. Header Kartu Box( modifier = Modifier .fillMaxWidth() .background(GoldPrimary.copy(alpha = 0.1f)) .padding(vertical = 16.dp), contentAlignment = Alignment.Center ) { Text( text = "Bukti Absensi", style = MaterialTheme.typography.titleLarge.copy( fontWeight = androidx.compose.ui.text.font.FontWeight.Bold ), color = GoldPrimary ) } // 2. Area Foto (Mengisi sisa ruang) Box( modifier = Modifier .weight(1f) .fillMaxWidth() .background(androidx.compose.ui.graphics.Color.Black), // Background foto hitam contentAlignment = Alignment.Center ) { Image( bitmap = selectedFoto!!.asImageBitmap(), contentDescription = "Foto Absensi Full", modifier = Modifier.fillMaxSize(), // Fit agar seluruh foto terlihat (tidak terpotong), // ganti ke .Crop jika ingin foto memenuhi kotak tapi terpotong contentScale = androidx.compose.ui.layout.ContentScale.Fit ) } // 3. Footer (Tombol Tutup Maroon) Box( modifier = Modifier .fillMaxWidth() .padding(16.dp) ) { Button( onClick = { showFotoDialog = false }, modifier = Modifier .fillMaxWidth() .height(50.dp), shape = androidx.compose.foundation.shape.RoundedCornerShape(12.dp), colors = ButtonDefaults.buttonColors( containerColor = MaroonSecondary, // Warna Merah Kampus contentColor = androidx.compose.ui.graphics.Color.White ) ) { Text( "Tutup", style = MaterialTheme.typography.titleMedium.copy( fontWeight = androidx.compose.ui.text.font.FontWeight.Bold ) ) } } } } } } } // --- UI CONTENT --- Column( modifier = modifier .fillMaxSize() .background(androidx.compose.ui.graphics.Color(0xFFF8F9FA)) .padding(horizontal = 24.dp) .verticalScroll(scrollState) ) { Spacer(modifier = Modifier.height(30.dp)) // Header & Filter Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { Column { Text( text = "Riwayat Absensi", style = MaterialTheme.typography.headlineSmall.copy(fontWeight = androidx.compose.ui.text.font.FontWeight.Bold), color = androidx.compose.ui.graphics.Color.Black ) if (filterActive) { Text( text = "${startDate} - ${endDate}", style = MaterialTheme.typography.labelSmall, color = GoldPrimary ) } } } Spacer(modifier = Modifier.height(24.dp)) // List Content if (isLoading) { Box(modifier = Modifier.fillMaxWidth().height(200.dp), contentAlignment = Alignment.Center) { CircularProgressIndicator(color = GoldPrimary) } } else if (riwayatList.isEmpty()) { Column(modifier = Modifier.fillMaxWidth().padding(top = 40.dp), horizontalAlignment = Alignment.CenterHorizontally) { Text("📭", style = MaterialTheme.typography.displayMedium) Text("Belum ada data", style = MaterialTheme.typography.titleMedium, color = androidx.compose.ui.graphics.Color.Gray) } } else { riwayatList.forEach { riwayat -> RiwayatCard( riwayat = riwayat, onLihatFoto = { id -> isLoadingFoto = true getFotoAbsensi(token, id, { b64 -> activity.runOnUiThread { isLoadingFoto = false; selectedFoto = base64ToBitmap(b64); showFotoDialog = true } }, { err -> activity.runOnUiThread { isLoadingFoto = false; Toast.makeText(context, err, Toast.LENGTH_SHORT).show() } } ) } ) Spacer(modifier = Modifier.height(12.dp)) } Spacer(modifier = Modifier.height(80.dp)) } } if (isLoadingFoto) { Box(modifier = Modifier.fillMaxSize().background(androidx.compose.ui.graphics.Color.Black.copy(alpha = 0.5f)), contentAlignment = Alignment.Center) { CircularProgressIndicator() } } } // Komponen Card Riwayat Baru @Composable fun RiwayatCard( riwayat: RiwayatAbsensi, onLihatFoto: (Int) -> Unit ) { val GoldPrimary = androidx.compose.ui.graphics.Color(0xFFB8860B) Card( modifier = Modifier.fillMaxWidth(), shape = androidx.compose.foundation.shape.RoundedCornerShape(16.dp), colors = CardDefaults.cardColors(containerColor = androidx.compose.ui.graphics.Color.White), elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) ) { Column(modifier = Modifier.padding(16.dp)) { // HEADER: Teks Matkul (Kiri) & Badge Status (Kanan) Row( modifier = Modifier.fillMaxWidth(), // Alignment Top agar jika teks 2 baris, badge tetap di pojok kanan atas verticalAlignment = Alignment.Top ) { // 1. KOLOM TEKS (Gunakan weight 1f agar tidak menabrak badge) Column( modifier = Modifier .weight(1f) // KUNCI UTAMA: Ambil sisa ruang .padding(end = 12.dp) // Beri jarak dengan badge ) { Text( text = formatTanggalCard(riwayat.timestamp), style = MaterialTheme.typography.labelMedium, color = androidx.compose.ui.graphics.Color.Gray ) Spacer(modifier = Modifier.height(4.dp)) Text( text = riwayat.mataKuliah ?: "-", style = MaterialTheme.typography.titleMedium.copy( fontWeight = androidx.compose.ui.text.font.FontWeight.Bold, // Opsional: Atur tinggi baris agar lebih lega lineHeight = 20.sp ), color = androidx.compose.ui.graphics.Color.Black, // Batasi maksimal 2 baris agar kartu tidak terlalu tinggi maxLines = 2, // Jika lebih dari 2 baris, potong dengan "..." overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis ) } // 2. BADGE STATUS (Ukuran statis sesuai konten) Surface( shape = androidx.compose.foundation.shape.RoundedCornerShape(8.dp), color = if (riwayat.status == "HADIR") androidx.compose.ui.graphics.Color(0xFFE8F5E9) else androidx.compose.ui.graphics.Color(0xFFFFEBEE) ) { Text( text = riwayat.status, modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp), style = MaterialTheme.typography.labelSmall.copy(fontWeight = androidx.compose.ui.text.font.FontWeight.Bold), color = if (riwayat.status == "HADIR") androidx.compose.ui.graphics.Color(0xFF2E7D32) else androidx.compose.ui.graphics.Color(0xFFC62828) ) } } Spacer(modifier = Modifier.height(16.dp)) Divider(color = androidx.compose.ui.graphics.Color.LightGray.copy(alpha = 0.2f)) Spacer(modifier = Modifier.height(12.dp)) // FOOTER: Jam & Tombol Lihat Foto Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { // Info Jam Row(verticalAlignment = Alignment.CenterVertically) { Icon( Icons.Default.AccessTime, contentDescription = null, modifier = Modifier.size(16.dp), tint = GoldPrimary ) Spacer(modifier = Modifier.width(6.dp)) val waktuText = if (riwayat.jamMulai != null && riwayat.jamSelesai != null) { "${riwayat.jamMulai.take(5)} - ${riwayat.jamSelesai.take(5)}" } else { formatJam(riwayat.timestamp) } Text( text = waktuText, style = MaterialTheme.typography.bodyMedium, color = androidx.compose.ui.graphics.Color.Gray ) } // Tombol Lihat Foto Row( modifier = Modifier.clickable { onLihatFoto(riwayat.idAbsensi) }, verticalAlignment = Alignment.CenterVertically ) { Text( text = "Lihat Foto", style = MaterialTheme.typography.labelSmall.copy(fontWeight = androidx.compose.ui.text.font.FontWeight.SemiBold), color = GoldPrimary ) Spacer(modifier = Modifier.width(4.dp)) Icon( Icons.Default.ArrowForwardIos, contentDescription = null, modifier = Modifier.size(10.dp), tint = GoldPrimary ) } } } } } // ========== HELPER FUNCTIONS ========== // Helper untuk memparsing tanggal dari String MySQL (YYYY-MM-DD HH:mm:ss) fun parseTimestamp(timestamp: String): Date? { return try { // Ganti 'T' dengan spasi jaga-jaga jika formatnya ISO8601 val cleanTimestamp = timestamp.replace("T", " ") val sdf = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()) sdf.parse(cleanTimestamp) } catch (e: Exception) { null } } fun formatTanggalCard(timestamp: String): String { val date = parseTimestamp(timestamp) ?: return timestamp // Format: "Sel, 13 Jan 2026" val outputFormat = SimpleDateFormat("EEE, d MMM yyyy", Locale("id", "ID")) return outputFormat.format(date) } fun formatJam(timestamp: String): String { val date = parseTimestamp(timestamp) ?: return "00:00" // Format: "14:30" val outputFormat = SimpleDateFormat("HH:mm", Locale.getDefault()) return outputFormat.format(date) } // ================= MAIN SCREEN ================= @Composable fun MainScreen( modifier: Modifier = Modifier, activity: ComponentActivity, token: String, mahasiswa: Mahasiswa, onLogout: () -> Unit ) { var selectedTab by remember { mutableStateOf(0) } // Warna Tema val GoldPrimary = androidx.compose.ui.graphics.Color(0xFFB8860B) val MaroonSecondary = androidx.compose.ui.graphics.Color(0xFF800000) Scaffold( bottomBar = { // Card elevation untuk memberi efek bayangan halus di atas nav bar Surface( shadowElevation = 16.dp, color = androidx.compose.ui.graphics.Color.White, shape = androidx.compose.foundation.shape.RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp) ) { NavigationBar( containerColor = androidx.compose.ui.graphics.Color.White, tonalElevation = 0.dp ) { val items = listOf( Triple(0, "Absensi", Icons.Default.Home), Triple(1, "Kelas", Icons.Default.School), // Ganti icon Schedule jadi School biar beda Triple(2, "Riwayat", Icons.Default.History), Triple(3, "Profil", Icons.Default.Person) ) items.forEach { (index, label, icon) -> NavigationBarItem( icon = { Icon(icon, contentDescription = label) }, label = { Text(label, style = MaterialTheme.typography.labelSmall) }, selected = selectedTab == index, onClick = { selectedTab = index }, colors = NavigationBarItemDefaults.colors( selectedIconColor = GoldPrimary, selectedTextColor = GoldPrimary, indicatorColor = GoldPrimary.copy(alpha = 0.15f), // Lingkaran highlight halus unselectedIconColor = androidx.compose.ui.graphics.Color.Gray, unselectedTextColor = androidx.compose.ui.graphics.Color.Gray ) ) } } } } ) { padding -> // Background abu-abu sangat muda untuk seluruh layar agar konten putih menonjol Box(modifier = Modifier .fillMaxSize() .background(androidx.compose.ui.graphics.Color(0xFFF8F9FA)) .padding(padding) ) { when (selectedTab) { 0 -> AbsensiScreenWithJadwal( modifier = Modifier, activity = activity, token = token, mahasiswa = mahasiswa ) 1 -> JadwalScreen(modifier = Modifier, token = token) 2 -> RiwayatScreen(modifier = Modifier, activity = activity, token = token) 3 -> ProfilScreen(modifier = Modifier, mahasiswa = mahasiswa, onLogout = onLogout) } } } } // ================= PROFIL SCREEN ================= @Composable fun ProfilScreen( modifier: Modifier = Modifier, mahasiswa: Mahasiswa, onLogout: () -> Unit ) { var showLogoutDialog by remember { mutableStateOf(false) } val scrollState = rememberScrollState() // Warna Tema val GoldPrimary = androidx.compose.ui.graphics.Color(0xFFB8860B) val MaroonSecondary = androidx.compose.ui.graphics.Color(0xFF800000) Column( modifier = modifier .fillMaxSize() .background(androidx.compose.ui.graphics.Color(0xFFF8F9FA)) .verticalScroll(scrollState), horizontalAlignment = Alignment.CenterHorizontally ) { // 1. Header Profile (Background & Avatar) Box( modifier = Modifier .fillMaxWidth() .height(280.dp) ) { // Background Lengkung Box( modifier = Modifier .fillMaxWidth() .height(180.dp) .background( brush = androidx.compose.ui.graphics.Brush.verticalGradient( colors = listOf(GoldPrimary, androidx.compose.ui.graphics.Color(0xFFDAA520)) ), shape = androidx.compose.foundation.shape.RoundedCornerShape(bottomStart = 50.dp, bottomEnd = 50.dp) ) ) // Avatar & Nama (Floating di tengah) Column( modifier = Modifier .align(Alignment.Center) .padding(top = 60.dp), // Turunkan sedikit agar avatar setengah di background horizontalAlignment = Alignment.CenterHorizontally ) { // Avatar Besar Surface( shape = CircleShape, color = androidx.compose.ui.graphics.Color.White, shadowElevation = 8.dp, border = androidx.compose.foundation.BorderStroke(4.dp, androidx.compose.ui.graphics.Color.White), modifier = Modifier.size(120.dp) ) { Box(contentAlignment = Alignment.Center) { Text( text = mahasiswa.nama.take(1).uppercase(), style = MaterialTheme.typography.displayMedium.copy(fontWeight = androidx.compose.ui.text.font.FontWeight.Bold), color = GoldPrimary ) } } Spacer(modifier = Modifier.height(16.dp)) // Nama & Status Text( text = mahasiswa.nama, style = MaterialTheme.typography.titleLarge.copy(fontWeight = androidx.compose.ui.text.font.FontWeight.Bold), color = androidx.compose.ui.graphics.Color.Black ) Text( text = "Mahasiswa Aktif", style = MaterialTheme.typography.bodyMedium, color = androidx.compose.ui.graphics.Color(0xFF2E7D32) // Hijau status ) } } Spacer(modifier = Modifier.height(16.dp)) // 2. Data Akademik Card Column(modifier = Modifier.padding(horizontal = 24.dp)) { Text( text = "Informasi Akademik", style = MaterialTheme.typography.titleMedium.copy(fontWeight = androidx.compose.ui.text.font.FontWeight.Bold), color = androidx.compose.ui.graphics.Color.Gray, modifier = Modifier.padding(bottom = 8.dp, start = 4.dp) ) Card( modifier = Modifier.fillMaxWidth(), shape = androidx.compose.foundation.shape.RoundedCornerShape(16.dp), colors = CardDefaults.cardColors(containerColor = androidx.compose.ui.graphics.Color.White), elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) ) { Column(modifier = Modifier.padding(20.dp)) { ProfilItem(Icons.Default.Badge, "NPM", mahasiswa.npm, GoldPrimary) Divider(color = androidx.compose.ui.graphics.Color.LightGray.copy(alpha = 0.2f), modifier = Modifier.padding(vertical = 12.dp)) ProfilItem(Icons.Default.School, "Fakultas", mahasiswa.fakultas, GoldPrimary) Divider(color = androidx.compose.ui.graphics.Color.LightGray.copy(alpha = 0.2f), modifier = Modifier.padding(vertical = 12.dp)) ProfilItem(Icons.Default.Book, "Jurusan", mahasiswa.jurusan, GoldPrimary) Divider(color = androidx.compose.ui.graphics.Color.LightGray.copy(alpha = 0.2f), modifier = Modifier.padding(vertical = 12.dp)) ProfilItem(Icons.Default.Timeline, "Semester", "Semester ${mahasiswa.semester}", GoldPrimary) } } Spacer(modifier = Modifier.height(24.dp)) // 3. Data Pribadi Card Text( text = "Data Pribadi", style = MaterialTheme.typography.titleMedium.copy(fontWeight = androidx.compose.ui.text.font.FontWeight.Bold), color = androidx.compose.ui.graphics.Color.Gray, modifier = Modifier.padding(bottom = 8.dp, start = 4.dp) ) Card( modifier = Modifier.fillMaxWidth(), shape = androidx.compose.foundation.shape.RoundedCornerShape(16.dp), colors = CardDefaults.cardColors(containerColor = androidx.compose.ui.graphics.Color.White), elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) ) { Column(modifier = Modifier.padding(20.dp)) { ProfilItem( icon = if(mahasiswa.jenkel == "L") Icons.Default.Male else Icons.Default.Female, label = "Jenis Kelamin", value = if (mahasiswa.jenkel == "L") "Laki-laki" else "Perempuan", tint = GoldPrimary ) } } Spacer(modifier = Modifier.height(40.dp)) // 4. Logout Button Button( onClick = { showLogoutDialog = true }, modifier = Modifier .fillMaxWidth() .height(50.dp), shape = androidx.compose.foundation.shape.RoundedCornerShape(12.dp), colors = ButtonDefaults.buttonColors(containerColor = MaroonSecondary) ) { Icon(Icons.Default.ExitToApp, contentDescription = null, tint = androidx.compose.ui.graphics.Color.White) Spacer(modifier = Modifier.width(8.dp)) Text( "Keluar Aplikasi", style = MaterialTheme.typography.titleMedium.copy(fontWeight = androidx.compose.ui.text.font.FontWeight.Bold), color = androidx.compose.ui.graphics.Color.White ) } // Versi App Spacer(modifier = Modifier.height(16.dp)) Text( text = "Versi 1.0.0", style = MaterialTheme.typography.bodySmall, color = androidx.compose.ui.graphics.Color.LightGray ) Spacer(modifier = Modifier.height(30.dp)) } } // Dialog Konfirmasi Logout if (showLogoutDialog) { AlertDialog( onDismissRequest = { showLogoutDialog = false }, title = { Text("Konfirmasi Keluar", color = MaroonSecondary) }, text = { Text("Apakah Anda yakin ingin keluar dari akun ini?", color = androidx.compose.ui.graphics.Color.Gray) }, confirmButton = { TextButton(onClick = { showLogoutDialog = false; onLogout() }) { Text("Ya, Keluar", color = MaroonSecondary, fontWeight = androidx.compose.ui.text.font.FontWeight.Bold) } }, dismissButton = { TextButton(onClick = { showLogoutDialog = false }) { Text("Batal", color = androidx.compose.ui.graphics.Color.Gray) } }, containerColor = androidx.compose.ui.graphics.Color.White, icon = { Icon(Icons.Default.Warning, null, tint = MaroonSecondary) } ) } } @Composable fun ProfilItem( icon: androidx.compose.ui.graphics.vector.ImageVector, label: String, value: String, tint: androidx.compose.ui.graphics.Color ) { Row(verticalAlignment = Alignment.CenterVertically) { Surface( shape = androidx.compose.foundation.shape.RoundedCornerShape(8.dp), color = tint.copy(alpha = 0.1f), modifier = Modifier.size(40.dp) ) { Box(contentAlignment = Alignment.Center) { Icon(icon, contentDescription = null, tint = tint, modifier = Modifier.size(20.dp)) } } Spacer(modifier = Modifier.width(16.dp)) Column { Text(text = label, style = MaterialTheme.typography.bodySmall, color = androidx.compose.ui.graphics.Color.Gray) Text(text = value, style = MaterialTheme.typography.bodyLarge.copy(fontWeight = androidx.compose.ui.text.font.FontWeight.SemiBold), color = androidx.compose.ui.graphics.Color.Black) } } } @Composable fun KameraAbsensi( requireFaceDetection: Boolean, // <--- PARAMETER BARU onImageCaptured: (Bitmap) -> Unit, onClose: () -> Unit, onError: (String) -> Unit ) { val context = LocalContext.current val lifecycleOwner = androidx.lifecycle.compose.LocalLifecycleOwner.current val cameraController = remember { androidx.camera.view.LifecycleCameraController(context) } // LOGIKA DEFAULT KAMERA: // Jika Wajib Wajah (Hadir) -> Kamera Depan // Jika Dokumen (Sakit/Izin) -> Kamera Belakang var cameraSelector by remember { mutableStateOf( if (requireFaceDetection) CameraSelector.DEFAULT_FRONT_CAMERA else CameraSelector.DEFAULT_BACK_CAMERA ) } var isFaceDetected by remember { mutableStateOf(false) } Box(modifier = Modifier.fillMaxSize().background(Color.Black)) { // 1. PREVIEW KAMERA androidx.compose.ui.viewinterop.AndroidView( modifier = Modifier.fillMaxSize(), factory = { ctx -> androidx.camera.view.PreviewView(ctx).apply { scaleType = androidx.camera.view.PreviewView.ScaleType.FILL_CENTER implementationMode = androidx.camera.view.PreviewView.ImplementationMode.COMPATIBLE controller = cameraController cameraController.bindToLifecycle(lifecycleOwner) } }, update = { cameraController.cameraSelector = cameraSelector // HANYA PASANG ANALYZER JIKA BUTUH DETEKSI WAJAH if (requireFaceDetection) { cameraController.setImageAnalysisAnalyzer( ContextCompat.getMainExecutor(context), WajahAnalyzer { detected -> isFaceDetected = detected } ) } else { // Jika mode dokumen, hapus analyzer agar ringan cameraController.clearImageAnalysisAnalyzer() } } ) // 2. OVERLAY (UI DI ATAS KAMERA) if (requireFaceDetection) { // === MODE WAJAH (HADIR) === Box(modifier = Modifier.fillMaxSize().padding(40.dp), contentAlignment = Alignment.Center) { val color = if (isFaceDetected) Color.Green else Color.Red androidx.compose.foundation.Canvas(modifier = Modifier.fillMaxSize()) { drawRect(color = color, style = androidx.compose.ui.graphics.drawscope.Stroke(width = 8f)) } if (!isFaceDetected) { Text( text = "Wajah Tidak Terdeteksi", color = Color.Red, style = MaterialTheme.typography.titleMedium, modifier = Modifier.background(Color.White.copy(0.7f)).padding(8.dp) ) } } } else { // === MODE DOKUMEN (SAKIT/IZIN) === // Tampilkan bingkai statis putih (sebagai panduan foto surat) Box(modifier = Modifier.fillMaxSize().padding(60.dp), contentAlignment = Alignment.Center) { androidx.compose.foundation.Canvas(modifier = Modifier.fillMaxSize()) { drawRect(color = Color.White.copy(alpha = 0.5f), style = androidx.compose.ui.graphics.drawscope.Stroke(width = 4f)) } Text( text = "Foto Surat/Bukti", color = Color.White, style = MaterialTheme.typography.titleMedium, modifier = Modifier.align(Alignment.TopCenter).padding(top = 16.dp).background(Color.Black.copy(0.5f)).padding(8.dp) ) } } // 3. TOMBOL KONTROL IconButton( onClick = onClose, modifier = Modifier.align(Alignment.TopStart).padding(16.dp).background(Color.Black.copy(0.5f), CircleShape) ) { Icon(Icons.Default.Close, null, tint = Color.White) } // Tombol Switch Kamera IconButton( onClick = { cameraSelector = if (cameraSelector == CameraSelector.DEFAULT_FRONT_CAMERA) { CameraSelector.DEFAULT_BACK_CAMERA } else { CameraSelector.DEFAULT_FRONT_CAMERA } }, modifier = Modifier.align(Alignment.TopEnd).padding(16.dp).background(Color.Black.copy(0.5f), CircleShape) ) { Icon(Icons.Default.Refresh, contentDescription = "Ganti Kamera", tint = Color.White) } // Tombol Shutter // Enable: Selalu TRUE jika mode Dokumen, atau Jika Wajah Terdeteksi di mode Hadir val isShutterEnabled = !requireFaceDetection || isFaceDetected Button( onClick = { takePhoto(cameraController, context, onImageCaptured, onError) }, enabled = isShutterEnabled, modifier = Modifier.align(Alignment.BottomCenter).padding(bottom = 40.dp).size(80.dp), shape = CircleShape, colors = ButtonDefaults.buttonColors( containerColor = if (isShutterEnabled) Color(0xFFB8860B) else Color.Gray ), contentPadding = PaddingValues(0.dp) ) { Icon(Icons.Default.CameraAlt, null, tint = Color.White, modifier = Modifier.size(32.dp)) } } } @Composable fun KameraDeteksiWajah( onImageCaptured: (Bitmap) -> Unit, onClose: () -> Unit, onError: (String) -> Unit ) { val context = LocalContext.current val lifecycleOwner = androidx.lifecycle.compose.LocalLifecycleOwner.current // Inisialisasi Controller val cameraController = remember { androidx.camera.view.LifecycleCameraController(context) } // STATE: Pilihan Kamera (Default: Depan) var cameraSelector by remember { mutableStateOf(CameraSelector.DEFAULT_FRONT_CAMERA) } // State deteksi wajah var isFaceDetected by remember { mutableStateOf(false) } Box(modifier = Modifier.fillMaxSize().background(Color.Black)) { // 1. PREVIEW KAMERA androidx.compose.ui.viewinterop.AndroidView( modifier = Modifier.fillMaxSize(), factory = { ctx -> androidx.camera.view.PreviewView(ctx).apply { scaleType = androidx.camera.view.PreviewView.ScaleType.FILL_CENTER implementationMode = androidx.camera.view.PreviewView.ImplementationMode.COMPATIBLE controller = cameraController // Controller dipasang di sini cameraController.bindToLifecycle(lifecycleOwner) } }, update = { // UPDATE PENTING: Set Camera Selector setiap kali state berubah cameraController.cameraSelector = cameraSelector // Pasang Analyzer Wajah cameraController.setImageAnalysisAnalyzer( ContextCompat.getMainExecutor(context), WajahAnalyzer { detected -> isFaceDetected = detected } ) } ) // 2. OVERLAY KOTAK INDIKATOR Box(modifier = Modifier.fillMaxSize().padding(40.dp), contentAlignment = Alignment.Center) { val color = if (isFaceDetected) Color.Green else Color.Red androidx.compose.foundation.Canvas(modifier = Modifier.fillMaxSize()) { drawRect(color = color, style = androidx.compose.ui.graphics.drawscope.Stroke(width = 8f)) } if (!isFaceDetected) { Text( text = "Wajah Tidak Terdeteksi", color = Color.Red, style = MaterialTheme.typography.titleMedium, modifier = Modifier.background(Color.White.copy(0.7f)).padding(8.dp) ) } } // 3. TOMBOL KONTROL // A. Tombol Kembali (Pojok Kiri Atas) IconButton( onClick = onClose, modifier = Modifier.align(Alignment.TopStart).padding(16.dp).background(Color.Black.copy(0.5f), CircleShape) ) { Icon(Icons.Default.Close, null, tint = Color.White) } // B. Tombol Ganti Kamera (Pojok Kanan Atas) - BARU! IconButton( onClick = { // Logic Switch: Jika Depan -> Belakang, Jika Belakang -> Depan cameraSelector = if (cameraSelector == CameraSelector.DEFAULT_FRONT_CAMERA) { CameraSelector.DEFAULT_BACK_CAMERA } else { CameraSelector.DEFAULT_FRONT_CAMERA } }, modifier = Modifier .align(Alignment.TopEnd) .padding(16.dp) .background(Color.Black.copy(0.5f), CircleShape) ) { // Menggunakan icon Refresh sebagai simbol switch (atau Icons.Filled.Cameraswitch jika library extended ada) Icon(Icons.Default.Refresh, contentDescription = "Ganti Kamera", tint = Color.White) } // C. Tombol Shutter (Tengah Bawah) Button( onClick = { takePhoto(cameraController, context, onImageCaptured, onError) }, enabled = isFaceDetected, modifier = Modifier.align(Alignment.BottomCenter).padding(bottom = 40.dp).size(80.dp), shape = CircleShape, colors = ButtonDefaults.buttonColors( containerColor = if (isFaceDetected) Color(0xFFB8860B) else Color.Gray ), contentPadding = PaddingValues(0.dp) ) { Icon(Icons.Default.CameraAlt, null, tint = Color.White, modifier = Modifier.size(32.dp)) } } } // Fungsi Helper Take Photo (Versi Fix Manual Bitmap) fun takePhoto( controller: androidx.camera.view.LifecycleCameraController, context: android.content.Context, onPhotoTaken: (Bitmap) -> Unit, onError: (String) -> Unit ) { controller.takePicture( ContextCompat.getMainExecutor(context), object : androidx.camera.core.ImageCapture.OnImageCapturedCallback() { override fun onCaptureSuccess(image: androidx.camera.core.ImageProxy) { try { val buffer = image.planes[0].buffer val bytes = ByteArray(buffer.remaining()) buffer.get(bytes) val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) // Putar gambar jika miring val rotation = image.imageInfo.rotationDegrees val finalBitmap = if (rotation != 0) { val matrix = android.graphics.Matrix() matrix.postRotate(rotation.toFloat()) Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true) } else bitmap onPhotoTaken(finalBitmap) } catch (e: Exception) { onError("Gagal: ${e.message}") } finally { image.close() } } override fun onError(exception: androidx.camera.core.ImageCaptureException) { onError("Error Kamera: ${exception.message}") } } ) } // ================= ABSENSI SCREEN (UI DASHBOARD BARU) ================= @SuppressLint("NewApi") @OptIn(ExperimentalMaterial3Api::class) @Composable fun AbsensiScreenWithJadwal( modifier: Modifier = Modifier, activity: ComponentActivity, token: String, mahasiswa: Mahasiswa ) { val context = LocalContext.current val scrollState = rememberScrollState() // --- WARNA TEMA --- val GoldPrimary = Color(0xFFB8860B) val GoldLight = Color(0xFFDAA520) val MaroonSecondary = Color(0xFF800000) val GreenSuccess = Color(0xFF2E7D32) val RedError = Color(0xFFC62828) // --- STATE --- var lokasiStatus by remember { mutableStateOf("Memuat lokasi...") } var isDalamArea by remember { mutableStateOf(false) } var latitude by remember { mutableStateOf(null) } var longitude by remember { mutableStateOf(null) } var foto by remember { mutableStateOf(null) } // STATE PENTING UNTUK KAMERA var showCamera by remember { mutableStateOf(false) } var isLoading by remember { mutableStateOf(false) } var jarakKeKampus by remember { mutableStateOf(null) } var errorMessage by remember { mutableStateOf(null) } var jadwalList by remember { mutableStateOf>(emptyList()) } var selectedJadwal by remember { mutableStateOf(null) } var showJadwalDialog by remember { mutableStateOf(false) } var selectedStatus by remember { mutableStateOf("HADIR") } // --- LOCATION LAUNCHER --- val fusedLocationClient = LocationServices.getFusedLocationProviderClient(context) val locationPermissionLauncher = rememberLauncherForActivityResult( ActivityResultContracts.RequestPermission() ) { granted -> if (granted) { if (ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED) { fusedLocationClient.lastLocation.addOnSuccessListener { location -> if (location != null) { val isFakeGps = if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S) location.isMock else location.isFromMockProvider if (isFakeGps) { latitude = null; longitude = null; jarakKeKampus = null lokasiStatus = "⛔ Fake GPS Terdeteksi!"; isDalamArea = false errorMessage = "⚠️ Matikan aplikasi Fake GPS Anda!" } else { latitude = location.latitude; longitude = location.longitude val jarak = hitungJarak(location.latitude, location.longitude, AppConstants.KAMPUS_LATITUDE, AppConstants.KAMPUS_LONGITUDE) jarakKeKampus = jarak isDalamArea = jarak <= AppConstants.RADIUS_METER lokasiStatus = if (isDalamArea) "Di Dalam Area Kampus (${String.format("%.0f", jarak)}m)" else "Di Luar Area Kampus (${String.format("%.0f", jarak)}m)" } } else { lokasiStatus = "❌ Lokasi tidak tersedia" } } } } } // --- CAMERA PERMISSION LAUNCHER (UPDATE LOGIC) --- val cameraPermissionLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> if (granted) { // JIKA DIIZINKAN, BUKA KAMERA CUSTOM KITA showCamera = true } else { Toast.makeText(context, "Izin kamera ditolak", Toast.LENGTH_SHORT).show() } } // Load Data Awal LaunchedEffect(Unit) { getJadwalToday(token = token, onSuccess = { jadwal -> activity.runOnUiThread { jadwalList = jadwal.filter { !it.sudahAbsen } } }, onError = {}) locationPermissionLauncher.launch(Manifest.permission.ACCESS_FINE_LOCATION) } // Dialog Error & Jadwal if (errorMessage != null) ErrorDialog(message = errorMessage!!, onDismiss = { errorMessage = null }) if (showJadwalDialog) { AlertDialog( onDismissRequest = { showJadwalDialog = false }, title = { Text("Pilih Mata Kuliah", fontWeight = FontWeight.Bold, color = GoldPrimary) }, text = { Column { if (jadwalList.isEmpty()) Text("Tidak ada kelas aktif saat ini.") else { jadwalList.forEach { jadwal -> Card( modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp).clickable { selectedJadwal = jadwal; showJadwalDialog = false }, colors = CardDefaults.cardColors(containerColor = Color(0xFFF5F5F5)), border = BorderStroke(1.dp, Color.LightGray) ) { Column(modifier = Modifier.padding(12.dp)) { Text(jadwal.namaMatkul, fontWeight = FontWeight.Bold, color = GoldPrimary) Text("${jadwal.jamMulai.substring(0,5)} - ${jadwal.jamSelesai.substring(0,5)} • ${jadwal.ruangan}", style = MaterialTheme.typography.bodySmall) } } } } } }, confirmButton = { TextButton(onClick = { showJadwalDialog = false }) { Text("Tutup", color = MaroonSecondary) } }, containerColor = Color.White ) } // ================== LOGIKA UTAMA UI ================== // Jika showCamera == true, tampilkan KameraDeteksiWajah FULL SCREEN if (showCamera) { val isModeWajah = (selectedStatus == "HADIR") KameraAbsensi( requireFaceDetection = isModeWajah, // <--- KIRIM PARAMETER INI onImageCaptured = { bitmap -> foto = bitmap showCamera = false }, onClose = { showCamera = false }, onError = { msg -> Toast.makeText(context, msg, Toast.LENGTH_SHORT).show() showCamera = false } ) // // KameraDeteksiWajah( // onImageCaptured = { bitmap -> // foto = bitmap // Simpan hasil foto // showCamera = false // Tutup kamera, kembali ke dashboard // }, // onClose = { showCamera = false }, // onError = { msg -> // Toast.makeText(context, msg, Toast.LENGTH_SHORT).show() // showCamera = false // } // ) } else { // JIKA showCamera == false, TAMPILKAN DASHBOARD BIASA Box(modifier = modifier.fillMaxSize()) { // 1. Header Background Box( modifier = Modifier .fillMaxWidth() .height(200.dp) .background( brush = androidx.compose.ui.graphics.Brush.verticalGradient(colors = listOf(GoldPrimary, GoldLight)), shape = RoundedCornerShape(bottomStart = 40.dp, bottomEnd = 40.dp) ) ) Column(modifier = Modifier.fillMaxSize().verticalScroll(scrollState)) { // 2. Profile Section Row(modifier = Modifier.fillMaxWidth().padding(start = 24.dp, end = 24.dp, top = 40.dp), verticalAlignment = Alignment.CenterVertically) { Surface(shape = CircleShape, color = Color.White, modifier = Modifier.size(56.dp), shadowElevation = 4.dp) { Box(contentAlignment = Alignment.Center) { Text(text = mahasiswa.nama.take(1).uppercase(), style = MaterialTheme.typography.headlineSmall.copy(fontWeight = FontWeight.Bold), color = GoldPrimary) } } Spacer(modifier = Modifier.width(16.dp)) Column { Text(text = "Halo, ${mahasiswa.nama.split(" ").first()}", style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.Bold), color = Color.White) Text(text = "${mahasiswa.npm} | ${mahasiswa.jurusan}", style = MaterialTheme.typography.bodyMedium, color = Color.White.copy(alpha = 0.9f)) } } Spacer(modifier = Modifier.height(24.dp)) // 3. Status Lokasi Card(modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp), shape = RoundedCornerShape(20.dp), colors = CardDefaults.cardColors(containerColor = Color.White), elevation = CardDefaults.cardElevation(defaultElevation = 10.dp)) { Row(modifier = Modifier.padding(20.dp), verticalAlignment = Alignment.CenterVertically) { Surface(shape = CircleShape, color = if (isDalamArea) GreenSuccess.copy(alpha = 0.1f) else RedError.copy(alpha = 0.1f), modifier = Modifier.size(50.dp)) { Box(contentAlignment = Alignment.Center) { Icon(imageVector = if (isDalamArea) Icons.Default.LocationOn else Icons.Default.WrongLocation, contentDescription = null, tint = if (isDalamArea) GreenSuccess else RedError, modifier = Modifier.size(24.dp)) } } Spacer(modifier = Modifier.width(16.dp)) Column { Text(text = "Status Lokasi", style = MaterialTheme.typography.labelMedium, color = Color.Gray) Text(text = lokasiStatus, style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold), color = if (isDalamArea) GreenSuccess else RedError) } } } Spacer(modifier = Modifier.height(24.dp)) // 4. Form Absensi Column(modifier = Modifier.padding(horizontal = 24.dp)) { Text(text = "Formulir Absensi", style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold), color = Color.Black) Spacer(modifier = Modifier.height(12.dp)) // Selector Matkul Card(modifier = Modifier.fillMaxWidth().clickable { showJadwalDialog = true }, shape = RoundedCornerShape(16.dp), colors = CardDefaults.cardColors(containerColor = Color.White), border = BorderStroke(1.dp, Color(0xFFEEEEEE))) { Row(modifier = Modifier.padding(16.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { Column(modifier = Modifier.weight(1f)) { Text("Mata Kuliah", style = MaterialTheme.typography.labelSmall, color = Color.Gray) Text(text = selectedJadwal?.namaMatkul ?: "Pilih mata kuliah...", style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold), color = if(selectedJadwal != null) GoldPrimary else Color.Gray) } Icon(Icons.Default.KeyboardArrowDown, null, tint = Color.Gray) } } Spacer(modifier = Modifier.height(16.dp)) // Status Kehadiran Text(text = "Status Kehadiran", style = MaterialTheme.typography.labelSmall, color = Color.Gray) Spacer(modifier = Modifier.height(8.dp)) Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { listOf("HADIR", "SAKIT", "IZIN").forEach { status -> val isSelected = selectedStatus == status val baseColor = when(status) { "HADIR"->GoldPrimary; "SAKIT"->Color(0xFFE65100); else->Color(0xFF1565C0) } OutlinedButton( onClick = { selectedStatus = status }, modifier = Modifier.weight(1f), shape = RoundedCornerShape(12.dp), colors = ButtonDefaults.outlinedButtonColors(containerColor = if (isSelected) baseColor else Color.Transparent, contentColor = if (isSelected) Color.White else Color.Gray), border = BorderStroke(1.dp, if (isSelected) baseColor else Color.LightGray), contentPadding = PaddingValues(0.dp) ) { Text(text = status, style = MaterialTheme.typography.labelSmall.copy(fontWeight = FontWeight.Bold)) } } } if (selectedStatus != "HADIR") { Spacer(modifier = Modifier.height(8.dp)) Card(colors = CardDefaults.cardColors(containerColor = Color(0xFFE3F2FD))) { Row(modifier = Modifier.padding(8.dp), verticalAlignment = Alignment.CenterVertically) { Icon(Icons.Default.Info, null, modifier = Modifier.size(16.dp), tint = Color(0xFF1565C0)) Spacer(modifier = Modifier.width(8.dp)) Text(text = "Wajib sertakan foto bukti sakit/surat izin.", style = MaterialTheme.typography.bodySmall, color = Color(0xFF0D47A1)) } } } Spacer(modifier = Modifier.height(16.dp)) // --- AREA FOTO (UPDATE: MEMBUKA KAMERA DETEKSI WAJAH) --- Card( modifier = Modifier .fillMaxWidth() .height(200.dp) .clickable { // Buka kamera (izin akan dicek di launcher) cameraPermissionLauncher.launch(Manifest.permission.CAMERA) }, shape = RoundedCornerShape(16.dp), colors = CardDefaults.cardColors(containerColor = Color(0xFFF8F9FA)), border = BorderStroke(2.dp, GoldPrimary.copy(alpha = 0.5f)) ) { Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { if (foto != null) { // TAMPILAN JIKA SUDAH ADA FOTO Image( bitmap = foto!!.asImageBitmap(), contentDescription = "Foto", modifier = Modifier.fillMaxSize(), contentScale = ContentScale.Crop ) // Tombol Retake kecil Box(modifier = Modifier.fillMaxSize().padding(12.dp), contentAlignment = Alignment.BottomEnd) { Surface(shape = CircleShape, color = Color.White) { Icon(Icons.Default.Refresh, null, modifier = Modifier.padding(8.dp), tint = GoldPrimary) } } } else { // TAMPILAN JIKA BELUM ADA FOTO (PLACEHOLDER) Column(horizontalAlignment = Alignment.CenterHorizontally) { // 1. Tentukan Ikon & Teks berdasarkan Status val icon = if (selectedStatus == "HADIR") Icons.Default.Face else Icons.Default.Description val text = if (selectedStatus == "HADIR") "Ketuk untuk Scan Wajah" else "Ketuk untuk Foto Surat/Bukti" Icon( imageVector = icon, contentDescription = null, modifier = Modifier.size(48.dp), tint = GoldPrimary.copy(alpha = 0.5f) ) Spacer(modifier = Modifier.height(8.dp)) Text( text = text, color = Color.Gray, fontWeight = FontWeight.Bold ) } } } } Spacer(modifier = Modifier.height(16.dp)) // Tombol Submit Button( onClick = { if (selectedJadwal == null) { errorMessage = "⚠️ Pilih mata kuliah terlebih dahulu!"; return@Button } if (latitude == null) { errorMessage = "⚠️ Lokasi tidak valid / Fake GPS terdeteksi!"; return@Button } if (foto == null) { errorMessage = "⚠️ Wajib scan wajah!"; return@Button } if (selectedStatus == "HADIR" && !isDalamArea) { errorMessage = "❌ Untuk status HADIR, harus di area kampus!"; return@Button } isLoading = true submitAbsensiWithJadwal( token = token, idJadwal = selectedJadwal!!.idJadwal, latitude = latitude!! + AppConstants.LATITUDE_OFFSET, longitude = longitude!! + AppConstants.LONGITUDE_OFFSET, fotoBase64 = bitmapToBase64(foto!!), status = selectedStatus, onSuccess = { activity.runOnUiThread { isLoading = false; foto = null; selectedJadwal = null; selectedStatus = "HADIR" Toast.makeText(context, "✅ Absensi berhasil!", Toast.LENGTH_LONG).show() getJadwalToday(token, { j -> activity.runOnUiThread { jadwalList = j.filter { !it.sudahAbsen } } }, {}) } }, onError = { err -> activity.runOnUiThread { isLoading = false; errorMessage = err } } ) }, modifier = Modifier.fillMaxWidth().height(56.dp), shape = RoundedCornerShape(16.dp), colors = ButtonDefaults.buttonColors(containerColor = Color.Transparent), contentPadding = PaddingValues(), enabled = !isLoading ) { Box( modifier = Modifier.fillMaxSize().background( brush = if (!isLoading && selectedJadwal != null && foto != null) androidx.compose.ui.graphics.Brush.horizontalGradient(colors = listOf(GoldPrimary, MaroonSecondary)) else androidx.compose.ui.graphics.Brush.linearGradient(colors = listOf(Color.Gray, Color.LightGray)), shape = RoundedCornerShape(16.dp) ), contentAlignment = Alignment.Center ) { if (isLoading) CircularProgressIndicator(color = Color.White) else Row(verticalAlignment = Alignment.CenterVertically) { Icon(Icons.Default.CheckCircle, null, tint = Color.White) Spacer(modifier = Modifier.width(8.dp)) Text("KIRIM ABSENSI", fontWeight = FontWeight.Bold, color = Color.White) } } } Spacer(modifier = Modifier.height(40.dp)) } } } } }