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

@ -95,3 +95,9 @@ gambar mockup dibuat oleh AI
## Webhook:
- 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
## 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.pm.PackageManager
import android.graphics.Bitmap
import android.location.Location
import android.os.Bundle
import android.provider.MediaStore
import android.util.Base64
@ -14,10 +15,13 @@ import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
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.unit.dp
import androidx.core.content.ContextCompat
@ -29,6 +33,21 @@ import java.net.HttpURLConnection
import java.net.URL
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 ================= */
fun bitmapToBase64(bitmap: Bitmap): String {
@ -39,14 +58,17 @@ fun bitmapToBase64(bitmap: Bitmap): String {
fun kirimKeN8n(
context: ComponentActivity,
npm: String,
nama: String,
mataKuliah: String,
latitude: Double,
longitude: Double,
foto: Bitmap
foto: Bitmap,
status: String
) {
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,11 +76,13 @@ fun kirimKeN8n(
conn.doOutput = true
val json = JSONObject().apply {
put("npm", "12345")
put("nama","Arif R D")
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))
}
@ -72,9 +96,9 @@ fun kirimKeN8n(
Toast.makeText(
context,
if (responseCode == 200)
"Absensi diterima server"
"Absensi $status"
else
"Absensi ditolak server",
"Server menolak absensi",
Toast.LENGTH_SHORT
).show()
}
@ -123,6 +147,10 @@ fun AbsensiScreen(
) {
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 latitude by remember { mutableStateOf<Double?>(null) }
var longitude by remember { mutableStateOf<Double?>(null) }
@ -138,14 +166,12 @@ fun AbsensiScreen(
ActivityResultContracts.RequestPermission()
) { granted ->
if (granted) {
if (
ContextCompat.checkSelfPermission(
context,
Manifest.permission.ACCESS_FINE_LOCATION
) == PackageManager.PERMISSION_GRANTED
) {
fusedLocationClient.lastLocation
.addOnSuccessListener { location ->
if (location != null) {
@ -157,11 +183,7 @@ fun AbsensiScreen(
lokasi = "Lokasi tidak tersedia"
}
}
.addOnFailureListener {
lokasi = "Gagal mengambil lokasi"
}
}
} else {
Toast.makeText(
context,
@ -178,8 +200,7 @@ fun AbsensiScreen(
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(
@ -187,6 +208,12 @@ fun AbsensiScreen(
"Foto berhasil diambil",
Toast.LENGTH_SHORT
).show()
} else {
Toast.makeText(
context,
"Gagal mengambil foto",
Toast.LENGTH_SHORT
).show()
}
}
}
@ -196,8 +223,7 @@ fun AbsensiScreen(
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(
@ -208,8 +234,6 @@ fun AbsensiScreen(
}
}
/* ===== Request Awal ===== */
LaunchedEffect(Unit) {
locationPermissionLauncher.launch(
Manifest.permission.ACCESS_FINE_LOCATION
@ -232,9 +256,32 @@ fun AbsensiScreen(
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(16.dp))
Spacer(modifier = Modifier.height(12.dp))
Button(
onClick = {
@ -247,21 +294,66 @@ fun AbsensiScreen(
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))
Button(
onClick = {
if (latitude != null && longitude != null && foto != null) {
kirimKeN8n(
activity,
if (
npm.isNotEmpty() &&
nama.isNotEmpty() &&
mataKuliah.isNotEmpty() &&
latitude != null &&
longitude != null &&
foto != null
) {
val jarak = hitungJarak(
latitude!!,
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,
"Lokasi atau foto belum lengkap",
"Absensi ditolak (di luar area)",
Toast.LENGTH_LONG
).show()
}
} else {
Toast.makeText(
context,
"Data absensi belum lengkap",
Toast.LENGTH_SHORT
).show()
}