This commit is contained in:
SKILLISSUE1 2026-01-14 00:20:47 +07:00
parent ed435ffbc1
commit bde4891e40
4 changed files with 229 additions and 169 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>

8
.idea/markdown.xml generated Normal file
View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="MarkdownSettings">
<option name="previewPanelProviderInfo">
<ProviderInfo name="Compose (experimental)" className="com.intellij.markdown.compose.preview.ComposePanelProvider" />
</option>
</component>
</project>

View File

@ -3,7 +3,6 @@ package id.ac.ubharajaya.sistemakademik
import android.Manifest import android.Manifest
import android.app.Activity import android.app.Activity
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager
import android.graphics.Bitmap import android.graphics.Bitmap
import android.os.Bundle import android.os.Bundle
import android.provider.MediaStore import android.provider.MediaStore
@ -14,10 +13,16 @@ 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 android.annotation.SuppressLint
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
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
@ -28,66 +33,74 @@ import java.io.ByteArrayOutputStream
import java.net.HttpURLConnection import java.net.HttpURLConnection
import java.net.URL import java.net.URL
import kotlin.concurrent.thread import kotlin.concurrent.thread
import android.content.pm.PackageManager
/* ================= UTIL ================= */
/* ================= UTIL (TIDAK DIUBAH) ================= */
fun bitmapToBase64(bitmap: Bitmap): String { fun bitmapToBase64(bitmap: Bitmap): String {
val outputStream = ByteArrayOutputStream() val out = ByteArrayOutputStream()
bitmap.compress(Bitmap.CompressFormat.JPEG, 80, outputStream) bitmap.compress(Bitmap.CompressFormat.JPEG, 80, out)
return Base64.encodeToString(outputStream.toByteArray(), Base64.NO_WRAP) return Base64.encodeToString(out.toByteArray(), Base64.NO_WRAP)
}
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 =
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(Math.toRadians(lat1)) *
Math.cos(Math.toRadians(lat2)) *
Math.sin(dLon / 2) * Math.sin(dLon / 2)
val c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
return R * c
} }
fun kirimKeN8n( fun kirimKeN8n(
context: ComponentActivity, activity: ComponentActivity,
latitude: Double, npm: String,
longitude: Double, nama: String,
foto: Bitmap matkul: String,
lat: Double,
lon: Double,
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"
conn.setRequestProperty("Content-Type", "application/json") conn.setRequestProperty("Content-Type", "application/json")
conn.doOutput = true conn.doOutput = true
val json = JSONObject().apply { val json = JSONObject().apply {
put("npm", "12345")
put("nama","Arif R D")
put("latitude", latitude)
put("longitude", longitude)
put("timestamp", System.currentTimeMillis()) put("timestamp", System.currentTimeMillis())
put("ip_addr", "android")
put("npm", npm)
put("nama", nama)
put("latitude", lat)
put("longitude", lon)
put("mata_kuliah", matkul)
put("status", status)
put("foto_base64", bitmapToBase64(foto)) put("foto_base64", bitmapToBase64(foto))
} }
conn.outputStream.use { conn.outputStream.use { it.write(json.toString().toByteArray()) }
it.write(json.toString().toByteArray())
}
val responseCode = conn.responseCode val responseCode = conn.responseCode
activity.runOnUiThread {
context.runOnUiThread {
Toast.makeText( Toast.makeText(
context, activity,
if (responseCode == 200) if (responseCode == 200) "Absensi berhasil" else "Absensi ditolak",
"Absensi diterima server"
else
"Absensi ditolak server",
Toast.LENGTH_SHORT Toast.LENGTH_SHORT
).show() ).show()
} }
conn.disconnect() conn.disconnect()
} catch (e: Exception) {
} catch (_: Exception) { activity.runOnUiThread {
context.runOnUiThread { Toast.makeText(activity, "Gagal kirim absensi", Toast.LENGTH_SHORT).show()
Toast.makeText(
context,
"Gagal kirim ke server",
Toast.LENGTH_SHORT
).show()
} }
} }
} }
@ -96,19 +109,12 @@ fun kirimKeN8n(
/* ================= ACTIVITY ================= */ /* ================= ACTIVITY ================= */
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
enableEdgeToEdge() enableEdgeToEdge()
setContent { setContent {
SistemAkademikTheme { SistemAkademikTheme {
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> AbsensiScreen(this)
AbsensiScreen(
modifier = Modifier.padding(innerPadding),
activity = this
)
}
} }
} }
} }
@ -117,158 +123,141 @@ class MainActivity : ComponentActivity() {
/* ================= UI ================= */ /* ================= UI ================= */
@Composable @Composable
fun AbsensiScreen( fun AbsensiScreen(activity: ComponentActivity) {
modifier: Modifier = Modifier,
activity: ComponentActivity
) {
val context = LocalContext.current val context = LocalContext.current
var lokasi by remember { mutableStateOf("Koordinat: -") } var npm by remember { mutableStateOf("") }
var latitude by remember { mutableStateOf<Double?>(null) } var nama by remember { mutableStateOf("") }
var longitude by remember { mutableStateOf<Double?>(null) } var matkul by remember { mutableStateOf("") }
var lat by remember { mutableStateOf<Double?>(null) }
var lon by remember { mutableStateOf<Double?>(null) }
var lokasi by remember { mutableStateOf("Lokasi belum didapat") }
var foto by remember { mutableStateOf<Bitmap?>(null) } var foto by remember { mutableStateOf<Bitmap?>(null) }
val fusedLocationClient = val LAT_ABSEN = -6.255
LocationServices.getFusedLocationProviderClient(context) val LON_ABSEN = 106.618
val RADIUS = 30.0 // meter (REALISTIS)
/* ===== Permission Lokasi ===== */ val fusedClient = LocationServices.getFusedLocationProviderClient(context)
val locationPermissionLauncher = val locationPermissionLauncher =
rememberLauncherForActivityResult( rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) {
ActivityResultContracts.RequestPermission() if (it && ContextCompat.checkSelfPermission(
) { 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"
}
}
.addOnFailureListener {
lokasi = "Gagal mengambil lokasi"
}
}
} else {
Toast.makeText(
context, context,
"Izin lokasi ditolak", Manifest.permission.ACCESS_FINE_LOCATION
Toast.LENGTH_SHORT ) == PackageManager.PERMISSION_GRANTED
).show() ) {
ambilLokasi(fusedClient) { la, lo ->
lat = la
lon = lo
lokasi = "Lat: $la | Lon: $lo"
}
} }
} }
/* ===== Kamera ===== */
val cameraLauncher = val cameraLauncher =
rememberLauncherForActivityResult( rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {
ActivityResultContracts.StartActivityForResult() if (it.resultCode == Activity.RESULT_OK) {
) { result -> foto = it.data?.extras?.get("data") as? Bitmap
if (result.resultCode == Activity.RESULT_OK) {
val bitmap =
result.data?.extras?.getParcelable("data", Bitmap::class.java)
if (bitmap != null) {
foto = bitmap
Toast.makeText(
context,
"Foto berhasil diambil",
Toast.LENGTH_SHORT
).show()
}
} }
} }
val cameraPermissionLauncher = val cameraPermissionLauncher =
rememberLauncherForActivityResult( rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) {
ActivityResultContracts.RequestPermission() if (it) cameraLauncher.launch(Intent(MediaStore.ACTION_IMAGE_CAPTURE))
) { granted ->
if (granted) {
val intent =
Intent(MediaStore.ACTION_IMAGE_CAPTURE)
cameraLauncher.launch(intent)
} else {
Toast.makeText(
context,
"Izin kamera ditolak",
Toast.LENGTH_SHORT
).show()
}
} }
/* ===== Request Awal ===== */
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
locationPermissionLauncher.launch( locationPermissionLauncher.launch(Manifest.permission.ACCESS_FINE_LOCATION)
Manifest.permission.ACCESS_FINE_LOCATION
)
} }
/* ===== UI ===== */ Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Card(
Column( modifier = Modifier
modifier = modifier .fillMaxWidth()
.fillMaxSize() .padding(20.dp),
.padding(24.dp), shape = RoundedCornerShape(16.dp),
verticalArrangement = Arrangement.Center elevation = CardDefaults.cardElevation(8.dp)
) {
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
)
},
modifier = Modifier.fillMaxWidth()
) { ) {
Text("Ambil Foto") Column(
} modifier = Modifier.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Spacer(modifier = Modifier.height(12.dp)) Text("Absensi Akademik", style = MaterialTheme.typography.titleLarge)
Button( OutlinedTextField(npm, { npm = it }, label = { Text("NPM") }, modifier = Modifier.fillMaxWidth())
onClick = { OutlinedTextField(nama, { nama = it }, label = { Text("Nama") }, modifier = Modifier.fillMaxWidth())
if (latitude != null && longitude != null && foto != null) { OutlinedTextField(matkul, { matkul = it }, label = { Text("Mata Kuliah") }, modifier = Modifier.fillMaxWidth())
kirimKeN8n(
activity, Text(lokasi, style = MaterialTheme.typography.bodySmall)
latitude!!,
longitude!!, Button(
foto!! modifier = Modifier.fillMaxWidth(),
) onClick = { cameraPermissionLauncher.launch(Manifest.permission.CAMERA) }
} else { ) {
Toast.makeText( Text("Ambil Foto")
context,
"Lokasi atau foto belum lengkap",
Toast.LENGTH_SHORT
).show()
} }
},
modifier = Modifier.fillMaxWidth() foto?.let {
) { Image(
Text("Kirim Absensi") bitmap = it.asImageBitmap(),
contentDescription = null,
modifier = Modifier
.fillMaxWidth()
.height(200.dp),
contentScale = ContentScale.Crop
)
}
Button(
modifier = Modifier.fillMaxWidth(),
onClick = {
if (npm.isBlank() || nama.isBlank() || matkul.isBlank()
|| lat == null || lon == null || foto == null
) {
Toast.makeText(context, "Data belum lengkap", Toast.LENGTH_SHORT).show()
return@Button
}
val jarak = hitungJarak(lat!!, lon!!, LAT_ABSEN, LON_ABSEN)
// 🔴 BLOK TOTAL JIKA DI LUAR RADIUS
if (jarak > RADIUS) {
Toast.makeText(
context,
"Anda berada di luar radius absensi",
Toast.LENGTH_LONG
).show()
return@Button
}
// ✅ HANYA JIKA VALID BARU KIRIM
kirimKeN8n(
activity,
npm,
nama,
matkul,
lat!!,
lon!!,
foto!!,
"VALID"
)
}
) {
Text("Kirim Absensi")
}
}
} }
} }
} }
@SuppressLint("MissingPermission")
fun ambilLokasi(
client: com.google.android.gms.location.FusedLocationProviderClient,
onResult: (Double, Double) -> Unit
) {
client.lastLocation.addOnSuccessListener {
if (it != null) onResult(it.latitude, it.longitude)
}
}