diff --git a/.idea/deviceManager.xml b/.idea/deviceManager.xml
new file mode 100644
index 0000000..91f9558
--- /dev/null
+++ b/.idea/deviceManager.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
\ No newline at end of file
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/.idea/markdown.xml b/.idea/markdown.xml
new file mode 100644
index 0000000..c61ea33
--- /dev/null
+++ b/.idea/markdown.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/README.md b/README.md
index 9871f13..7730b49 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,15 @@
# 📱 Aplikasi Absensi Akademik Berbasis Koordinat dan Foto (Mobile)
+**NAMA :** **Syahril Achmad Fahrezi**
+
+**NPM :** **202310715211**
+
+**MATA KULIAH** : **PERMROGRAMAN PERANGKAT BERGERAK**
+
+**- INI DIKEMBANGKAN BUKAN BUAT DARI AWAL -**
+
## 📌 Deskripsi Proyek
+
Proyek ini merupakan **Tugas Project Akhir Mata Kuliah Pemrograman Mobile** yang bertujuan untuk membangun **aplikasi akademik berbasis mobile** dengan fokus pada **fitur absensi menggunakan data koordinat (GPS) dan pengambilan foto mahasiswa**.
Aplikasi ini dirancang untuk meningkatkan **validitas kehadiran mahasiswa**, dengan memastikan bahwa absensi hanya dapat dilakukan apabila mahasiswa:
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 c774502..1981282 100644
--- a/app/src/main/java/id/ac/ubharajaya/sistemakademik/MainActivity.kt
+++ b/app/src/main/java/id/ac/ubharajaya/sistemakademik/MainActivity.kt
@@ -5,6 +5,7 @@ import android.app.Activity
import android.content.Intent
import android.content.pm.PackageManager
import android.graphics.Bitmap
+import android.location.Location
import android.os.Bundle
import android.provider.MediaStore
import android.util.Base64
@@ -14,11 +15,26 @@ import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.animation.*
+import androidx.compose.animation.core.*
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.*
import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.scale
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.font.FontStyle
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.unit.sp
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import com.google.android.gms.location.LocationServices
@@ -31,6 +47,22 @@ import kotlin.concurrent.thread
/* ================= UTIL ================= */
+// Koordinat Kampus Universitas Bhayangkara Jakarta Raya
+const val KAMPUS_LATITUDE = -6.2642
+const val KAMPUS_LONGITUDE = 107.0008
+const val RADIUS_METER = 100.0
+
+fun hitungJarak(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Float {
+ val results = FloatArray(1)
+ Location.distanceBetween(lat1, lon1, lat2, lon2, results)
+ return results[0]
+}
+
+fun cekDalamRadius(latitude: Double, longitude: Double): Boolean {
+ val jarak = hitungJarak(latitude, longitude, KAMPUS_LATITUDE, KAMPUS_LONGITUDE)
+ return jarak <= RADIUS_METER
+}
+
fun bitmapToBase64(bitmap: Bitmap): String {
val outputStream = ByteArrayOutputStream()
bitmap.compress(Bitmap.CompressFormat.JPEG, 80, outputStream)
@@ -41,12 +73,13 @@ fun kirimKeN8n(
context: ComponentActivity,
latitude: Double,
longitude: Double,
- foto: Bitmap
+ foto: Bitmap,
+ onSuccess: () -> Unit,
+ onError: () -> Unit
) {
thread {
try {
val url = URL("https://n8n.lab.ubharajaya.ac.id/webhook/23c6993d-1792-48fb-ad1c-ffc78a3e6254")
-// test URL val url = URL("https://n8n.lab.ubharajaya.ac.id/webhook-test/23c6993d-1792-48fb-ad1c-ffc78a3e6254")
val conn = url.openConnection() as HttpURLConnection
conn.requestMethod = "POST"
@@ -54,8 +87,8 @@ fun kirimKeN8n(
conn.doOutput = true
val json = JSONObject().apply {
- put("npm", "12345")
- put("nama","Arif R D")
+ put("npm", "202310715211")
+ put("nama", "Syahril Achmad Fahrezi")
put("latitude", latitude)
put("longitude", longitude)
put("timestamp", System.currentTimeMillis())
@@ -69,25 +102,18 @@ fun kirimKeN8n(
val responseCode = conn.responseCode
context.runOnUiThread {
- Toast.makeText(
- context,
- if (responseCode == 200)
- "Absensi diterima server"
- else
- "Absensi ditolak server",
- Toast.LENGTH_SHORT
- ).show()
+ if (responseCode == 200) {
+ onSuccess()
+ } else {
+ onError()
+ }
}
conn.disconnect()
} catch (_: Exception) {
context.runOnUiThread {
- Toast.makeText(
- context,
- "Gagal kirim ke server",
- Toast.LENGTH_SHORT
- ).show()
+ onError()
}
}
}
@@ -123,152 +149,441 @@ fun AbsensiScreen(
) {
val context = LocalContext.current
- var lokasi by remember { mutableStateOf("Koordinat: -") }
var latitude by remember { mutableStateOf(null) }
var longitude by remember { mutableStateOf(null) }
var foto by remember { mutableStateOf(null) }
+ var dalamRadius by remember { mutableStateOf(false) }
+ var jarak by remember { mutableStateOf(null) }
+ var isLoading by remember { mutableStateOf(false) }
+ var showSuccess by remember { mutableStateOf(false) }
+ var expandedMataKuliah by remember { mutableStateOf(false) }
+ var selectedMataKuliah by remember { mutableStateOf("Pilih Mata Kuliah") }
- val fusedLocationClient =
- LocationServices.getFusedLocationProviderClient(context)
+ val mataKuliahList = listOf(
+ "Pemrograman Perangkat Bergerak",
+ "Deep Learning",
+ "Keamanan Siber"
+ )
+
+ val fusedLocationClient = LocationServices.getFusedLocationProviderClient(context)
+
+ val scale by animateFloatAsState(
+ targetValue = if (showSuccess) 1f else 0f,
+ animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy), label = ""
+ )
/* ===== Permission Lokasi ===== */
-
val locationPermissionLauncher =
- rememberLauncherForActivityResult(
- ActivityResultContracts.RequestPermission()
- ) { granted ->
+ rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
if (granted) {
-
- if (
- ContextCompat.checkSelfPermission(
+ 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}"
+ dalamRadius = cekDalamRadius(location.latitude, location.longitude)
+ jarak = hitungJarak(
+ location.latitude,
+ location.longitude,
+ KAMPUS_LATITUDE,
+ KAMPUS_LONGITUDE
+ )
} else {
- lokasi = "Lokasi tidak tersedia"
+ Toast.makeText(context, "Lokasi tidak tersedia", Toast.LENGTH_SHORT).show()
}
}
- .addOnFailureListener {
- lokasi = "Gagal mengambil lokasi"
- }
}
-
} else {
- Toast.makeText(
- context,
- "Izin lokasi ditolak",
- Toast.LENGTH_SHORT
- ).show()
+ Toast.makeText(context, "Izin lokasi ditolak", Toast.LENGTH_SHORT).show()
}
}
/* ===== Kamera ===== */
-
val cameraLauncher =
- rememberLauncherForActivityResult(
- ActivityResultContracts.StartActivityForResult()
- ) { result ->
+ rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode == Activity.RESULT_OK) {
- val bitmap =
- result.data?.extras?.getParcelable("data", Bitmap::class.java)
+ val bitmap = result.data?.extras?.get("data") as? Bitmap
if (bitmap != null) {
foto = bitmap
- Toast.makeText(
- context,
- "Foto berhasil diambil",
- Toast.LENGTH_SHORT
- ).show()
+ Toast.makeText(context, "Foto berhasil diambil", Toast.LENGTH_SHORT).show()
}
}
}
val cameraPermissionLauncher =
- rememberLauncherForActivityResult(
- ActivityResultContracts.RequestPermission()
- ) { granted ->
+ rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
if (granted) {
- val intent =
- Intent(MediaStore.ACTION_IMAGE_CAPTURE)
+ val intent = Intent(MediaStore.ACTION_IMAGE_CAPTURE)
cameraLauncher.launch(intent)
} else {
- Toast.makeText(
- context,
- "Izin kamera ditolak",
- Toast.LENGTH_SHORT
- ).show()
+ Toast.makeText(context, "Izin kamera ditolak", Toast.LENGTH_SHORT).show()
}
}
/* ===== Request Awal ===== */
-
LaunchedEffect(Unit) {
- locationPermissionLauncher.launch(
- Manifest.permission.ACCESS_FINE_LOCATION
- )
+ locationPermissionLauncher.launch(Manifest.permission.ACCESS_FINE_LOCATION)
}
/* ===== UI ===== */
-
- Column(
+ Box(
modifier = modifier
.fillMaxSize()
- .padding(24.dp),
- verticalArrangement = Arrangement.Center
- ) {
-
- Text(
- text = "Absensi Akademik",
- style = MaterialTheme.typography.titleLarge
- )
-
- Spacer(modifier = Modifier.height(16.dp))
-
- Text(text = lokasi)
-
- Spacer(modifier = Modifier.height(16.dp))
-
- Button(
- onClick = {
- cameraPermissionLauncher.launch(
- Manifest.permission.CAMERA
+ .background(
+ Brush.verticalGradient(
+ colors = listOf(
+ Color(0xFF1a1a2e),
+ Color(0xFF16213e),
+ Color(0xFF0f3460)
+ )
)
- },
- modifier = Modifier.fillMaxWidth()
+ )
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(24.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(20.dp)
) {
- Text("Ambil Foto")
+ Spacer(modifier = Modifier.height(40.dp))
+
+ // Header
+ Text(
+ text = "Absensi Akademik",
+ style = MaterialTheme.typography.headlineMedium,
+ fontWeight = FontWeight.Bold,
+ color = Color.White
+ )
+
+ Text(
+ text = "Universitas Bhayangkara Jakarta Raya",
+ style = MaterialTheme.typography.bodyMedium,
+ color = Color.White.copy(alpha = 0.9f)
+ )
+
+ Spacer(modifier = Modifier.height(20.dp))
+
+ // Status Lokasi Card
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ shape = RoundedCornerShape(20.dp),
+ colors = CardDefaults.cardColors(
+ containerColor = Color(0xFF0a0a0f).copy(alpha = 0.7f)
+ ),
+ elevation = CardDefaults.cardElevation(8.dp)
+ ) {
+ Column(
+ modifier = Modifier.padding(20.dp),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ // Icon Status
+ Box(
+ modifier = Modifier
+ .size(80.dp)
+ .background(
+ color = if (dalamRadius) Color(0xFF10B981).copy(alpha = 0.2f)
+ else Color(0xFFEF4444).copy(alpha = 0.2f),
+ shape = RoundedCornerShape(40.dp)
+ ),
+ contentAlignment = Alignment.Center
+ ) {
+ Text(
+ text = if (dalamRadius) "✓" else "✗",
+ style = MaterialTheme.typography.displayMedium,
+ color = if (dalamRadius) Color(0xFF10B981) else Color(0xFFEF4444),
+ fontWeight = FontWeight.Bold
+ )
+ }
+
+ Spacer(modifier = Modifier.height(12.dp))
+
+ Text(
+ text = if (dalamRadius) "Dalam Area Kampus" else "Di Luar Area Kampus",
+ fontSize = 20.sp,
+ fontWeight = FontWeight.Bold,
+ fontStyle = FontStyle.Normal,
+ color = if (dalamRadius) Color(0xFF9D4EDD) else Color(0xFFEF4444)
+ )
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ if (jarak != null) {
+ Text(
+ text = "Jarak: ${String.format("%.1f", jarak)} meter",
+ style = MaterialTheme.typography.bodyMedium,
+ color = Color(0xFFB8B8D0)
+ )
+ }
+
+ if (latitude != null && longitude != null) {
+ Text(
+ text = "Lat: ${String.format("%.6f", latitude)}, Lon: ${String.format("%.6f", longitude)}",
+ style = MaterialTheme.typography.bodySmall,
+ color = Color(0xFF8B8BA0)
+ )
+ }
+ }
+ }
+
+ // Foto Preview
+ AnimatedVisibility(
+ visible = foto != null,
+ enter = fadeIn() + expandVertically(),
+ exit = fadeOut() + shrinkVertically()
+ ) {
+ Card(
+ modifier = Modifier.size(200.dp),
+ shape = RoundedCornerShape(20.dp),
+ elevation = CardDefaults.cardElevation(8.dp)
+ ) {
+ Box(
+ modifier = Modifier.fillMaxSize(),
+ contentAlignment = Alignment.Center
+ ) {
+ foto?.let {
+ Image(
+ bitmap = it.asImageBitmap(),
+ contentDescription = "Foto Absensi",
+ modifier = Modifier.fillMaxSize()
+ )
+ }
+ }
+ }
+ }
+
+ Spacer(modifier = Modifier.weight(1f))
+
+ // Dropdown Mata Kuliah
+ Card(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable { expandedMataKuliah = !expandedMataKuliah },
+ shape = RoundedCornerShape(16.dp),
+ colors = CardDefaults.cardColors(
+ containerColor = Color(0xFF0a0a0f).copy(alpha = 0.7f)
+ ),
+ elevation = CardDefaults.cardElevation(4.dp)
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp)
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ text = selectedMataKuliah,
+ fontSize = 16.sp,
+ color = if (selectedMataKuliah == "Pilih Mata Kuliah")
+ Color(0xFF8B8BA0) else Color.White,
+ fontWeight = FontWeight.Medium
+ )
+ Text(
+ text = if (expandedMataKuliah) "â–²" else "â–¼",
+ color = Color(0xFF9D4EDD),
+ fontSize = 12.sp
+ )
+ }
+
+ AnimatedVisibility(visible = expandedMataKuliah) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(top = 12.dp)
+ ) {
+ mataKuliahList.forEach { matkul ->
+ Text(
+ text = matkul,
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable {
+ selectedMataKuliah = matkul
+ expandedMataKuliah = false
+ }
+ .padding(vertical = 12.dp),
+ fontSize = 15.sp,
+ color = Color(0xFFB8B8D0),
+ fontWeight = FontWeight.Normal
+ )
+ if (matkul != mataKuliahList.last()) {
+ Spacer(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(1.dp)
+ .background(Color(0xFF3a3a4e))
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+
+ Spacer(modifier = Modifier.height(12.dp))
+
+ // Tombol Ambil Foto
+ Button(
+ onClick = {
+ cameraPermissionLauncher.launch(Manifest.permission.CAMERA)
+ },
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(56.dp),
+ shape = RoundedCornerShape(16.dp),
+ colors = ButtonDefaults.buttonColors(
+ containerColor = Color(0xFF7B2CBF),
+ contentColor = Color.White
+ ),
+ elevation = ButtonDefaults.buttonElevation(8.dp)
+ ) {
+ Text(
+ text = "📷 ${if (foto == null) "Ambil Foto" else "Ambil Ulang Foto"}",
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.Bold
+ )
+ }
+
+ // Tombol Kirim Absensi
+ Button(
+ onClick = {
+ when {
+ !dalamRadius -> {
+ Toast.makeText(
+ context,
+ "Anda di luar area kampus!",
+ Toast.LENGTH_LONG
+ ).show()
+ }
+ foto == null -> {
+ Toast.makeText(
+ context,
+ "Silakan ambil foto terlebih dahulu!",
+ Toast.LENGTH_SHORT
+ ).show()
+ }
+ selectedMataKuliah == "Pilih Mata Kuliah" -> {
+ Toast.makeText(
+ context,
+ "Silakan pilih mata kuliah terlebih dahulu!",
+ Toast.LENGTH_SHORT
+ ).show()
+ }
+ latitude == null || longitude == null -> {
+ Toast.makeText(
+ context,
+ "Lokasi belum terdeteksi!",
+ Toast.LENGTH_SHORT
+ ).show()
+ }
+ else -> {
+ isLoading = true
+ kirimKeN8n(
+ activity,
+ latitude!!,
+ longitude!!,
+ foto!!,
+ onSuccess = {
+ isLoading = false
+ showSuccess = true
+ Toast.makeText(
+ context,
+ "Absensi berhasil dikirim!",
+ Toast.LENGTH_SHORT
+ ).show()
+ },
+ onError = {
+ isLoading = false
+ Toast.makeText(
+ context,
+ "Gagal mengirim absensi!",
+ Toast.LENGTH_SHORT
+ ).show()
+ }
+ )
+ }
+ }
+ },
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(56.dp),
+ shape = RoundedCornerShape(16.dp),
+ colors = ButtonDefaults.buttonColors(
+ containerColor = if (dalamRadius && foto != null && selectedMataKuliah != "Pilih Mata Kuliah")
+ Color(0xFF9D4EDD) else Color(0xFF3a3a4e),
+ contentColor = Color.White
+ ),
+ elevation = ButtonDefaults.buttonElevation(8.dp),
+ enabled = !isLoading
+ ) {
+ if (isLoading) {
+ CircularProgressIndicator(
+ modifier = Modifier.size(24.dp),
+ color = Color.White,
+ strokeWidth = 3.dp
+ )
+ Spacer(modifier = Modifier.width(8.dp))
+ Text("Mengirim...")
+ } else {
+ Text(
+ text = "📤 Kirim Absensi",
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.Bold
+ )
+ }
+ }
+
+ Spacer(modifier = Modifier.height(20.dp))
}
- Spacer(modifier = Modifier.height(12.dp))
-
- Button(
- onClick = {
- if (latitude != null && longitude != null && foto != null) {
- kirimKeN8n(
- activity,
- latitude!!,
- longitude!!,
- foto!!
- )
- } else {
- Toast.makeText(
- context,
- "Lokasi atau foto belum lengkap",
- Toast.LENGTH_SHORT
- ).show()
+ // Success Animation
+ if (showSuccess) {
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(Color.Black.copy(alpha = 0.5f)),
+ contentAlignment = Alignment.Center
+ ) {
+ Card(
+ modifier = Modifier
+ .scale(scale)
+ .padding(40.dp),
+ shape = RoundedCornerShape(24.dp),
+ colors = CardDefaults.cardColors(containerColor = Color(0xFF0a0a0f))
+ ) {
+ Column(
+ modifier = Modifier.padding(32.dp),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Text(
+ text = "✓",
+ style = MaterialTheme.typography.displayLarge,
+ color = Color(0xFF9D4EDD),
+ fontWeight = FontWeight.Bold
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+ Text(
+ text = "Absensi Berhasil!",
+ style = MaterialTheme.typography.headlineSmall,
+ fontWeight = FontWeight.Bold,
+ color = Color.White
+ )
+ }
}
- },
- modifier = Modifier.fillMaxWidth()
- ) {
- Text("Kirim Absensi")
+ }
+
+ LaunchedEffect(Unit) {
+ kotlinx.coroutines.delay(2000)
+ showSuccess = false
+ }
}
}
-}
+}
\ No newline at end of file