EAS-202310715128-ARIFNURKHAYAN(APLIKASI-ABSENSI-LBS)

This commit is contained in:
202310715128 ARIF NURKHAYAN 2026-01-13 23:56:04 +07:00
parent ed435ffbc1
commit c7f91078d9
4 changed files with 186 additions and 25 deletions

13
.idea/deviceManager.xml generated Normal file
View File

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DeviceTable">
<option name="columnSorters">
<list>
<ColumnSorterState>
<option name="column" value="Name" />
<option name="order" value="ASCENDING" />
</ColumnSorterState>
</list>
</option>
</component>
</project>

View File

@ -0,0 +1,50 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="ComposePreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="ComposePreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="ComposePreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="ComposePreviewNotSupportedInUnitTestFiles" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="GlancePreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="GlancePreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="GlancePreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="GlancePreviewNotSupportedInUnitTestFiles" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewAnnotationInFunctionWithParameters" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewApiLevelMustBeValid" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewDeviceShouldUseNewSpec" enabled="true" level="WEAK WARNING" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewFontScaleMustBeGreaterThanZero" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewMultipleParameterProviders" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewParameterProviderOnFirstParameter" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewPickerAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
</profile>
</component>

View File

@ -94,4 +94,10 @@ gambar mockup dibuat oleh AI
## Webhook: ## Webhook:
- test: https://n8n.lab.ubharajaya.ac.id/webhook-test/23c6993d-1792-48fb-ad1c-ffc78a3e6254 - test: https://n8n.lab.ubharajaya.ac.id/webhook-test/23c6993d-1792-48fb-ad1c-ffc78a3e6254
- production: https://n8n.lab.ubharajaya.ac.id/webhook/23c6993d-1792-48fb-ad1c-ffc78a3e6254 - production: https://n8n.lab.ubharajaya.ac.id/webhook/23c6993d-1792-48fb-ad1c-ffc78a3e6254
## Acknowledgment
Pengembangan dan penyusunan aplikasi ini dilakukan dengan bantuan kecerdasan buatan (AI)
sebagai alat pendukung dalam proses brainstorming, penjelasan konsep, serta perbaikan struktur kode.
Seluruh implementasi, pengujian, dan penyesuaian akhir dilakukan oleh penulis.

View File

@ -5,6 +5,7 @@ import android.app.Activity
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.graphics.Bitmap import android.graphics.Bitmap
import android.location.Location
import android.os.Bundle import android.os.Bundle
import android.provider.MediaStore import android.provider.MediaStore
import android.util.Base64 import android.util.Base64
@ -14,10 +15,13 @@ import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
@ -29,6 +33,21 @@ import java.net.HttpURLConnection
import java.net.URL import java.net.URL
import kotlin.concurrent.thread import kotlin.concurrent.thread
/* ================= KONFIGURASI LOKASI ================= */
const val KAMPUS_LAT = -6.222967558410948
const val KAMPUS_LON = 107.00931291609834
const val MAX_RADIUS = 50 // meter
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]
}
/* ================= UTIL ================= */ /* ================= UTIL ================= */
fun bitmapToBase64(bitmap: Bitmap): String { fun bitmapToBase64(bitmap: Bitmap): String {
@ -39,14 +58,17 @@ fun bitmapToBase64(bitmap: Bitmap): String {
fun kirimKeN8n( fun kirimKeN8n(
context: ComponentActivity, context: ComponentActivity,
npm: String,
nama: String,
mataKuliah: String,
latitude: Double, latitude: Double,
longitude: Double, longitude: Double,
foto: Bitmap foto: Bitmap,
status: String
) { ) {
thread { thread {
try { try {
val url = URL("https://n8n.lab.ubharajaya.ac.id/webhook/23c6993d-1792-48fb-ad1c-ffc78a3e6254") 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 val conn = url.openConnection() as HttpURLConnection
conn.requestMethod = "POST" conn.requestMethod = "POST"
@ -54,11 +76,13 @@ fun kirimKeN8n(
conn.doOutput = true conn.doOutput = true
val json = JSONObject().apply { val json = JSONObject().apply {
put("npm", "12345") put("npm", npm)
put("nama","Arif R D") put("nama", nama)
put("mata_kuliah", mataKuliah)
put("latitude", latitude) put("latitude", latitude)
put("longitude", longitude) put("longitude", longitude)
put("timestamp", System.currentTimeMillis()) put("timestamp", System.currentTimeMillis())
put("status", status)
put("foto_base64", bitmapToBase64(foto)) put("foto_base64", bitmapToBase64(foto))
} }
@ -72,9 +96,9 @@ fun kirimKeN8n(
Toast.makeText( Toast.makeText(
context, context,
if (responseCode == 200) if (responseCode == 200)
"Absensi diterima server" "Absensi $status"
else else
"Absensi ditolak server", "Server menolak absensi",
Toast.LENGTH_SHORT Toast.LENGTH_SHORT
).show() ).show()
} }
@ -123,6 +147,10 @@ fun AbsensiScreen(
) { ) {
val context = LocalContext.current val context = LocalContext.current
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("Koordinat: -") }
var latitude by remember { mutableStateOf<Double?>(null) } var latitude by remember { mutableStateOf<Double?>(null) }
var longitude by remember { mutableStateOf<Double?>(null) } var longitude by remember { mutableStateOf<Double?>(null) }
@ -138,14 +166,12 @@ fun AbsensiScreen(
ActivityResultContracts.RequestPermission() ActivityResultContracts.RequestPermission()
) { granted -> ) { granted ->
if (granted) { if (granted) {
if ( if (
ContextCompat.checkSelfPermission( ContextCompat.checkSelfPermission(
context, context,
Manifest.permission.ACCESS_FINE_LOCATION Manifest.permission.ACCESS_FINE_LOCATION
) == PackageManager.PERMISSION_GRANTED ) == PackageManager.PERMISSION_GRANTED
) { ) {
fusedLocationClient.lastLocation fusedLocationClient.lastLocation
.addOnSuccessListener { location -> .addOnSuccessListener { location ->
if (location != null) { if (location != null) {
@ -157,11 +183,7 @@ fun AbsensiScreen(
lokasi = "Lokasi tidak tersedia" lokasi = "Lokasi tidak tersedia"
} }
} }
.addOnFailureListener {
lokasi = "Gagal mengambil lokasi"
}
} }
} else { } else {
Toast.makeText( Toast.makeText(
context, context,
@ -178,8 +200,7 @@ fun AbsensiScreen(
ActivityResultContracts.StartActivityForResult() ActivityResultContracts.StartActivityForResult()
) { result -> ) { result ->
if (result.resultCode == Activity.RESULT_OK) { if (result.resultCode == Activity.RESULT_OK) {
val bitmap = val bitmap = result.data?.extras?.get("data") as? Bitmap
result.data?.extras?.getParcelable("data", Bitmap::class.java)
if (bitmap != null) { if (bitmap != null) {
foto = bitmap foto = bitmap
Toast.makeText( Toast.makeText(
@ -187,6 +208,12 @@ fun AbsensiScreen(
"Foto berhasil diambil", "Foto berhasil diambil",
Toast.LENGTH_SHORT Toast.LENGTH_SHORT
).show() ).show()
} else {
Toast.makeText(
context,
"Gagal mengambil foto",
Toast.LENGTH_SHORT
).show()
} }
} }
} }
@ -196,8 +223,7 @@ fun AbsensiScreen(
ActivityResultContracts.RequestPermission() ActivityResultContracts.RequestPermission()
) { granted -> ) { granted ->
if (granted) { if (granted) {
val intent = val intent = Intent(MediaStore.ACTION_IMAGE_CAPTURE)
Intent(MediaStore.ACTION_IMAGE_CAPTURE)
cameraLauncher.launch(intent) cameraLauncher.launch(intent)
} else { } else {
Toast.makeText( Toast.makeText(
@ -208,8 +234,6 @@ fun AbsensiScreen(
} }
} }
/* ===== Request Awal ===== */
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
locationPermissionLauncher.launch( locationPermissionLauncher.launch(
Manifest.permission.ACCESS_FINE_LOCATION Manifest.permission.ACCESS_FINE_LOCATION
@ -232,9 +256,32 @@ fun AbsensiScreen(
Spacer(modifier = Modifier.height(16.dp)) 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) Text(text = lokasi)
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(12.dp))
Button( Button(
onClick = { onClick = {
@ -247,21 +294,66 @@ fun AbsensiScreen(
Text("Ambil Foto") Text("Ambil Foto")
} }
/* ===== PREVIEW FOTO ===== */
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
)
}
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
Button( Button(
onClick = { onClick = {
if (latitude != null && longitude != null && foto != null) { if (
kirimKeN8n( npm.isNotEmpty() &&
activity, nama.isNotEmpty() &&
mataKuliah.isNotEmpty() &&
latitude != null &&
longitude != null &&
foto != null
) {
val jarak = hitungJarak(
latitude!!, latitude!!,
longitude!!, longitude!!,
foto!! 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()
}
} else { } else {
Toast.makeText( Toast.makeText(
context, context,
"Lokasi atau foto belum lengkap", "Data absensi belum lengkap",
Toast.LENGTH_SHORT Toast.LENGTH_SHORT
).show() ).show()
} }