2759 lines
123 KiB
Kotlin
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))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} |