2759 lines
123 KiB
Kotlin

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<JadwalKelas>) -> 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<JadwalKelas>()
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<RiwayatAbsensi>) -> Unit,
onError: (String) -> Unit
) {
thread {
try {
var urlString = "${AppConstants.BASE_URL}/api/absensi/history"
val params = mutableListOf<String>()
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<RiwayatAbsensi>()
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<List<JadwalKelas>>(emptyList()) }
var isLoading by remember { mutableStateOf(true) }
var errorMessage by remember { mutableStateOf<String?>(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<String?>(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<String?>(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<List<RiwayatAbsensi>>(emptyList()) }
var stats by remember { mutableStateOf<AbsensiStats?>(null) }
var isLoading by remember { mutableStateOf(true) }
var errorMessage by remember { mutableStateOf<String?>(null) }
var showFilterDialog by remember { mutableStateOf(false) }
var showFotoDialog by remember { mutableStateOf(false) }
var selectedFoto by remember { mutableStateOf<Bitmap?>(null) }
var isLoadingFoto by remember { mutableStateOf(false) }
var startDate by remember { mutableStateOf<String?>(null) }
var endDate by remember { mutableStateOf<String?>(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<Double?>(null) }
var longitude by remember { mutableStateOf<Double?>(null) }
var foto by remember { mutableStateOf<Bitmap?>(null) }
// STATE PENTING UNTUK KAMERA
var showCamera by remember { mutableStateOf(false) }
var isLoading by remember { mutableStateOf(false) }
var jarakKeKampus by remember { mutableStateOf<Double?>(null) }
var errorMessage by remember { mutableStateOf<String?>(null) }
var jadwalList by remember { mutableStateOf<List<JadwalKelas>>(emptyList()) }
var selectedJadwal by remember { mutableStateOf<JadwalKelas?>(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))
}
}
}
}
}