diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml
new file mode 100644
index 0000000..f0c6ad0
--- /dev/null
+++ b/.idea/inspectionProfiles/Project_Default.xml
@@ -0,0 +1,50 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.kotlin/errors/errors-1768292897859.log b/.kotlin/errors/errors-1768292897859.log
new file mode 100644
index 0000000..1219b50
--- /dev/null
+++ b/.kotlin/errors/errors-1768292897859.log
@@ -0,0 +1,4 @@
+kotlin version: 2.0.21
+error message: The daemon has terminated unexpectedly on startup attempt #1 with error code: 0. The daemon process output:
+ 1. Kotlin compile daemon is ready
+
diff --git a/app/src/main/java/id/ac/ubharajaya/sistemakademik/MainActivity.kt b/app/src/main/java/id/ac/ubharajaya/sistemakademik/MainActivity.kt
index ad91cb6..20f7eda 100644
--- a/app/src/main/java/id/ac/ubharajaya/sistemakademik/MainActivity.kt
+++ b/app/src/main/java/id/ac/ubharajaya/sistemakademik/MainActivity.kt
@@ -15,18 +15,40 @@ import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.core.Spring
+import androidx.compose.animation.core.spring
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.slideInVertically
import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
+import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
import androidx.core.content.ContextCompat
import com.google.android.gms.location.LocationServices
+import com.google.android.gms.location.Priority
import id.ac.ubharajaya.sistemakademik.ui.theme.SistemAkademikTheme
+import kotlinx.coroutines.delay
import org.json.JSONObject
import java.io.ByteArrayOutputStream
import java.net.HttpURLConnection
@@ -37,13 +59,9 @@ import kotlin.concurrent.thread
const val KAMPUS_LAT = -6.222967558410948
const val KAMPUS_LON = 107.00931291609834
-const val MAX_RADIUS = 50
-// meter
+const val MAX_RADIUS = 50 // meter
-fun hitungJarak(
- lat1: Double, lon1: Double,
- lat2: Double, lon2: Double
-): Float {
+fun hitungJarak(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Float {
val hasil = FloatArray(1)
Location.distanceBetween(lat1, lon1, lat2, lon2, hasil)
return hasil[0]
@@ -58,62 +76,39 @@ fun bitmapToBase64(bitmap: Bitmap): String {
}
fun kirimKeN8n(
- context: ComponentActivity,
- npm: String,
- nama: String,
- mataKuliah: String,
- latitude: Double,
- longitude: Double,
- foto: Bitmap,
- status: String
+ context: ComponentActivity, npm: String, nama: String, mataKuliah: String,
+ latitude: Double, longitude: Double, foto: Bitmap, status: String,
+ onFinished: () -> Unit
) {
thread {
try {
val url = URL("https://n8n.lab.ubharajaya.ac.id/webhook/23c6993d-1792-48fb-ad1c-ffc78a3e6254")
val conn = url.openConnection() as HttpURLConnection
-
conn.requestMethod = "POST"
conn.setRequestProperty("Content-Type", "application/json")
conn.doOutput = true
-
val json = JSONObject().apply {
- put("npm", npm)
- put("nama", nama)
- put("mata_kuliah", mataKuliah)
- put("latitude", latitude)
- put("longitude", longitude)
- put("timestamp", System.currentTimeMillis())
- put("status", status)
+ put("npm", npm); put("nama", nama); put("mata_kuliah", mataKuliah)
+ put("latitude", latitude); put("longitude", longitude)
+ put("timestamp", System.currentTimeMillis()); put("status", status)
put("foto_base64", bitmapToBase64(foto))
}
-
- conn.outputStream.use {
- it.write(json.toString().toByteArray())
- }
-
+ conn.outputStream.use { it.write(json.toString().toByteArray()) }
val responseCode = conn.responseCode
-
context.runOnUiThread {
Toast.makeText(
context,
- if (responseCode == 200)
- "Absensi $status"
- else
- "Server menolak absensi",
+ if (responseCode == 200) "Absensi $status berhasil dikirim" else "Server menolak absensi",
Toast.LENGTH_SHORT
).show()
}
-
conn.disconnect()
-
} catch (_: Exception) {
context.runOnUiThread {
- Toast.makeText(
- context,
- "Gagal kirim ke server",
- Toast.LENGTH_SHORT
- ).show()
+ Toast.makeText(context, "Gagal kirim ke server", Toast.LENGTH_SHORT).show()
}
+ } finally {
+ context.runOnUiThread(onFinished)
}
}
}
@@ -121,247 +116,234 @@ fun kirimKeN8n(
/* ================= ACTIVITY ================= */
class MainActivity : ComponentActivity() {
-
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
-
setContent {
SistemAkademikTheme {
- Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
- AbsensiScreen(
- modifier = Modifier.padding(innerPadding),
- activity = this
- )
- }
+ AbsensiScreen(activity = this)
}
}
}
}
-/* ================= UI ================= */
+/* ================= UI UTAMA ================= */
@Composable
-fun AbsensiScreen(
- modifier: Modifier = Modifier,
- activity: ComponentActivity
-) {
+fun AbsensiScreen(activity: ComponentActivity) {
val context = LocalContext.current
+ // States
var npm by remember { mutableStateOf("") }
var nama by remember { mutableStateOf("") }
var mataKuliah by remember { mutableStateOf("") }
-
- var lokasi by remember { mutableStateOf("Koordinat: -") }
+ var lokasi by remember { mutableStateOf(null) }
var latitude by remember { mutableStateOf(null) }
var longitude by remember { mutableStateOf(null) }
var foto by remember { mutableStateOf(null) }
+ var isUploading by remember { mutableStateOf(false) }
+ var isFetchingLocation by remember { mutableStateOf(false) }
- val fusedLocationClient =
- LocationServices.getFusedLocationProviderClient(context)
+ val fusedLocationClient = remember { LocationServices.getFusedLocationProviderClient(context) }
- /* ===== Permission Lokasi ===== */
-
- 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) {
- latitude = location.latitude
- longitude = location.longitude
- lokasi =
- "Lat: ${location.latitude}\nLon: ${location.longitude}"
- } else {
- lokasi = "Lokasi tidak tersedia"
- }
- }
- }
- } else {
- Toast.makeText(
- context,
- "Izin lokasi ditolak",
- Toast.LENGTH_SHORT
- ).show()
- }
+ fun requestLocationUpdate() {
+ if (ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED) {
+ lokasi = "Izin lokasi belum diberikan."
+ return
}
-
- /* ===== Kamera ===== */
-
- val cameraLauncher =
- rememberLauncherForActivityResult(
- ActivityResultContracts.StartActivityForResult()
- ) { result ->
- if (result.resultCode == Activity.RESULT_OK) {
- val bitmap = result.data?.extras?.get("data") as? Bitmap
- if (bitmap != null) {
- foto = bitmap
- Toast.makeText(
- context,
- "Foto berhasil diambil",
- Toast.LENGTH_SHORT
- ).show()
+ isFetchingLocation = true
+ lokasi = "Mencari lokasi..."
+ fusedLocationClient.getCurrentLocation(Priority.PRIORITY_HIGH_ACCURACY, null)
+ .addOnSuccessListener { location: Location? ->
+ if (location != null) {
+ latitude = location.latitude
+ longitude = location.longitude
+ lokasi = "Lat: ${String.format("%.6f", location.latitude)}\nLon: ${String.format("%.6f", location.longitude)}"
} else {
- Toast.makeText(
- context,
- "Gagal mengambil foto",
- Toast.LENGTH_SHORT
- ).show()
+ lokasi = "Gagal mendapatkan lokasi. Coba lagi."
}
+ isFetchingLocation = false
}
- }
-
- val cameraPermissionLauncher =
- rememberLauncherForActivityResult(
- ActivityResultContracts.RequestPermission()
- ) { granted ->
- if (granted) {
- val intent = Intent(MediaStore.ACTION_IMAGE_CAPTURE)
- cameraLauncher.launch(intent)
- } else {
- Toast.makeText(
- context,
- "Izin kamera ditolak",
- Toast.LENGTH_SHORT
- ).show()
+ .addOnFailureListener {
+ lokasi = "Gagal mendapatkan lokasi. Coba lagi."
+ isFetchingLocation = false
}
- }
-
- LaunchedEffect(Unit) {
- locationPermissionLauncher.launch(
- Manifest.permission.ACCESS_FINE_LOCATION
- )
}
- /* ===== UI ===== */
+ val locationPermissionLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
+ if (granted) requestLocationUpdate() else {
+ Toast.makeText(context, "Izin lokasi ditolak", Toast.LENGTH_SHORT).show()
+ lokasi = "Izin lokasi ditolak."
+ }
+ }
- Column(
- modifier = modifier
- .fillMaxSize()
- .padding(24.dp),
- verticalArrangement = Arrangement.Center
- ) {
+ val cameraLauncher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
+ if (result.resultCode == Activity.RESULT_OK) {
+ val bitmap = result.data?.extras?.get("data") as? Bitmap
+ if (bitmap != null) foto = bitmap else Toast.makeText(context, "Gagal mengambil foto", Toast.LENGTH_SHORT).show()
+ }
+ }
- Text(
- text = "Absensi Akademik",
- style = MaterialTheme.typography.titleLarge
+ val cameraPermissionLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
+ if (granted) cameraLauncher.launch(Intent(MediaStore.ACTION_IMAGE_CAPTURE))
+ else Toast.makeText(context, "Izin kamera ditolak", Toast.LENGTH_SHORT).show()
+ }
+
+ LaunchedEffect(Unit) { locationPermissionLauncher.launch(Manifest.permission.ACCESS_FINE_LOCATION) }
+
+ Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
+ val gradient = Brush.linearGradient(
+ colors = listOf(MaterialTheme.colorScheme.primary.copy(alpha = 0.18f), Color.Transparent),
+ start = Offset(0f, 0f),
+ end = Offset(Float.POSITIVE_INFINITY, Float.POSITIVE_INFINITY)
)
-
- Spacer(modifier = Modifier.height(16.dp))
-
- OutlinedTextField(
- value = npm,
- onValueChange = { npm = it },
- label = { Text("NPM") },
- modifier = Modifier.fillMaxWidth()
- )
-
- OutlinedTextField(
- value = nama,
- onValueChange = { nama = it },
- label = { Text("Nama") },
- modifier = Modifier.fillMaxWidth()
- )
-
- OutlinedTextField(
- value = mataKuliah,
- onValueChange = { mataKuliah = it },
- label = { Text("Mata Kuliah") },
- modifier = Modifier.fillMaxWidth()
- )
-
- Spacer(modifier = Modifier.height(12.dp))
-
- Text(text = lokasi)
-
- Spacer(modifier = Modifier.height(12.dp))
-
- Button(
- onClick = {
- cameraPermissionLauncher.launch(
- Manifest.permission.CAMERA
- )
- },
- modifier = Modifier.fillMaxWidth()
+ LazyColumn(
+ modifier = Modifier.fillMaxSize().background(gradient).padding(WindowInsets.systemBars.asPaddingValues()),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ contentPadding = PaddingValues(horizontal = 24.dp, vertical = 16.dp)
) {
- Text("Ambil Foto")
- }
+ item { AnimatedHeader(delay = 0) }
+ item { Spacer(modifier = Modifier.height(24.dp)) }
- /* ===== PREVIEW FOTO ===== */
+ item { AnimatedInputCard(npm, nama, mataKuliah, { npm = it }, { nama = it }, { mataKuliah = it }) }
+ item { Spacer(modifier = Modifier.height(24.dp)) }
- if (foto != null) {
- Spacer(modifier = Modifier.height(12.dp))
- Image(
- bitmap = foto!!.asImageBitmap(),
- contentDescription = "Preview Foto Absensi",
- modifier = Modifier
- .fillMaxWidth()
- .height(220.dp),
- contentScale = ContentScale.Crop
- )
- }
+ item {
+ AnimatedActionSection(isFetchingLocation, { requestLocationUpdate() }, { cameraPermissionLauncher.launch(Manifest.permission.CAMERA) }, lokasi)
+ }
+ item { Spacer(modifier = Modifier.height(24.dp)) }
- Spacer(modifier = Modifier.height(12.dp))
+ item { AnimatedPhotoPreview(foto) }
+ item { Spacer(modifier = Modifier.height(24.dp)) }
- Button(
- onClick = {
- if (
- npm.isNotEmpty() &&
- nama.isNotEmpty() &&
- mataKuliah.isNotEmpty() &&
- latitude != null &&
- longitude != null &&
- foto != null
- ) {
-
- val jarak = hitungJarak(
- latitude!!,
- longitude!!,
- KAMPUS_LAT,
- KAMPUS_LON
- )
-
- val status =
- if (jarak <= MAX_RADIUS) "HADIR" else "DITOLAK"
-
- if (status == "HADIR") {
- kirimKeN8n(
- activity,
- npm,
- nama,
- mataKuliah,
- latitude!!,
- longitude!!,
- foto!!,
- status
- )
- } else {
- Toast.makeText(
- context,
- "Absensi ditolak (di luar area)",
- Toast.LENGTH_LONG
- ).show()
+ item {
+ val canSubmit = npm.isNotEmpty() && nama.isNotEmpty() && mataKuliah.isNotEmpty() && latitude != null && longitude != null && foto != null
+ AnimatedSubmitButton(
+ enabled = canSubmit && !isUploading,
+ isUploading = isUploading,
+ onClick = {
+ isUploading = true
+ val jarak = hitungJarak(latitude!!, longitude!!, KAMPUS_LAT, KAMPUS_LON)
+ val statusAbsen = if (jarak <= MAX_RADIUS) "HADIR" else "DITOLAK"
+ if (statusAbsen == "HADIR") {
+ kirimKeN8n(activity, npm, nama, mataKuliah, latitude!!, longitude!!, foto!!, statusAbsen) { isUploading = false }
+ } else {
+ val jarakKm = String.format("%.2f", jarak / 1000)
+ Toast.makeText(context, "Absensi DITOLAK: Anda berada ${jarakKm}km dari kampus.", Toast.LENGTH_LONG).show()
+ isUploading = false
+ }
}
-
- } else {
- Toast.makeText(
- context,
- "Data absensi belum lengkap",
- Toast.LENGTH_SHORT
- ).show()
- }
- },
- modifier = Modifier.fillMaxWidth()
- ) {
- Text("Kirim Absensi")
+ )
+ }
}
}
}
+
+/* ================= KOMPONEN UI DENGAN ANIMASI ================= */
+
+@Composable
+fun AnimatedHeader(delay: Long = 0) {
+ var visible by remember { mutableStateOf(false) }
+ LaunchedEffect(Unit) { delay(delay); visible = true }
+ AnimatedVisibility(visible = visible, enter = fadeIn(spring(stiffness = Spring.StiffnessLow)) + slideInVertically(spring(Spring.DampingRatioMediumBouncy, Spring.StiffnessLow), initialOffsetY = { -it })) {
+ Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.fillMaxWidth()) {
+ Text("E-ABSENSI", style = MaterialTheme.typography.displaySmall, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.primary)
+ Text("Universitas Bhayangkara Jakarta Raya", style = MaterialTheme.typography.titleMedium, color = Color.Gray)
+ }
+ }
+}
+
+@Composable
+fun AnimatedInputCard(
+ npm: String, nama: String, mataKuliah: String,
+ onNpmChange: (String) -> Unit, onNamaChange: (String) -> Unit, onMataKuliahChange: (String) -> Unit
+) {
+ var cardVisible by remember { mutableStateOf(false) }
+ LaunchedEffect(Unit) { delay(200); cardVisible = true }
+
+ AnimatedVisibility(visible = cardVisible, enter = fadeIn(spring(stiffness = Spring.StiffnessLow)) + slideInVertically(spring(Spring.DampingRatioMediumBouncy, Spring.StiffnessLow), initialOffsetY = { it / 2 })) {
+ Card(modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(16.dp), elevation = CardDefaults.cardElevation(4.dp)) {
+ Column(modifier = Modifier.padding(20.dp)) {
+ Text("Data Mahasiswa", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
+ Spacer(modifier = Modifier.height(16.dp))
+ OutlinedTextField(value = npm, onValueChange = onNpmChange, label = { Text("NPM") }, modifier = Modifier.fillMaxWidth(), keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), leadingIcon = { Icon(Icons.Default.Person, "NPM") })
+ Spacer(modifier = Modifier.height(8.dp))
+ OutlinedTextField(value = nama, onValueChange = onNamaChange, label = { Text("Nama Lengkap") }, modifier = Modifier.fillMaxWidth(), keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), leadingIcon = { Icon(Icons.Default.Badge, "Nama") })
+ Spacer(modifier = Modifier.height(8.dp))
+ OutlinedTextField(value = mataKuliah, onValueChange = onMataKuliahChange, label = { Text("Mata Kuliah") }, modifier = Modifier.fillMaxWidth(), keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), leadingIcon = { Icon(Icons.Default.Book, "Mata Kuliah") })
+ }
+ }
+ }
+}
+
+@Composable
+fun AnimatedActionSection(isFetchingLocation: Boolean, onRefresh: () -> Unit, onTakePhoto: () -> Unit, lokasi: String?) {
+ var visible by remember { mutableStateOf(false) }
+ LaunchedEffect(Unit) { delay(400); visible = true }
+
+ AnimatedVisibility(visible = visible, enter = fadeIn(spring(stiffness = Spring.StiffnessLow)) + slideInVertically(spring(Spring.DampingRatioMediumBouncy, Spring.StiffnessLow), initialOffsetY = { it / 2 })) {
+ Column {
+ StatusRowWithRefresh(icon = Icons.Default.LocationOn, title = "Lokasi GPS", status = lokasi ?: "Meminta izin...", isRefreshing = isFetchingLocation, onRefresh = onRefresh)
+ Spacer(modifier = Modifier.height(16.dp))
+ ActionButton(text = "Ambil Foto Kehadiran", icon = Icons.Default.CameraAlt, onClick = onTakePhoto)
+ }
+ }
+}
+
+@Composable
+fun AnimatedPhotoPreview(foto: Bitmap?) {
+ AnimatedVisibility(visible = foto != null, enter = fadeIn(spring(stiffness = Spring.StiffnessMedium)) + slideInVertically(spring(Spring.DampingRatioMediumBouncy, Spring.StiffnessMedium), initialOffsetY = { it / 2 })) {
+ Card(shape = RoundedCornerShape(16.dp), elevation = CardDefaults.cardElevation(4.dp)) {
+ Box(contentAlignment = Alignment.Center) {
+ Image(bitmap = foto!!.asImageBitmap(), "Preview Foto Absensi", modifier = Modifier.fillMaxWidth().height(250.dp).clip(RoundedCornerShape(16.dp)), contentScale = ContentScale.Crop)
+ Icon(Icons.Default.CheckCircle, "Foto Diambil", tint = Color.White.copy(alpha = 0.8f), modifier = Modifier.size(60.dp))
+ }
+ }
+ }
+}
+
+@Composable
+fun AnimatedSubmitButton(enabled: Boolean, isUploading: Boolean, onClick: () -> Unit) {
+ var visible by remember { mutableStateOf(false) }
+ LaunchedEffect(Unit) { delay(600); visible = true }
+
+ AnimatedVisibility(visible = visible, enter = fadeIn(spring(stiffness = Spring.StiffnessLow)) + slideInVertically(spring(Spring.DampingRatioMediumBouncy, Spring.StiffnessLow), initialOffsetY = { it / 2 })) {
+ Button(onClick = onClick, enabled = enabled, modifier = Modifier.fillMaxWidth().height(56.dp)) {
+ if (isUploading) {
+ CircularProgressIndicator(color = Color.White, modifier = Modifier.size(24.dp))
+ } else {
+ Text("KIRIM ABSENSI", fontWeight = FontWeight.Bold)
+ }
+ }
+ }
+}
+
+/* ================= KOMPONEN UI STANDAR ================= */
+
+@Composable
+fun StatusRowWithRefresh(icon: ImageVector, title: String, status: String, isRefreshing: Boolean, onRefresh: () -> Unit) {
+ Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
+ Icon(icon, contentDescription = title, tint = MaterialTheme.colorScheme.primary)
+ Spacer(modifier = Modifier.width(16.dp))
+ Column(modifier = Modifier.weight(1f)) {
+ Text(title, fontWeight = FontWeight.Bold, fontSize = 16.sp)
+ Text(status, style = MaterialTheme.typography.bodySmall, color = Color.Gray)
+ }
+ if (isRefreshing) {
+ CircularProgressIndicator(modifier = Modifier.size(24.dp))
+ } else {
+ IconButton(onClick = onRefresh) {
+ Icon(Icons.Default.Refresh, "Cari Ulang Lokasi")
+ }
+ }
+ }
+}
+
+@Composable
+fun ActionButton(text: String, icon: ImageVector, onClick: () -> Unit) {
+ FilledTonalButton(onClick = onClick, modifier = Modifier.fillMaxWidth().height(56.dp), shape = RoundedCornerShape(16.dp)) {
+ Icon(icon, contentDescription = null)
+ Spacer(modifier = Modifier.width(12.dp))
+ Text(text, fontWeight = FontWeight.Bold)
+ }
+}