EAS-202310715060-MuhammadYusronAmrullah

This commit is contained in:
202310715060 MUHAMMAD YUSRON AMRULLAH 2026-01-14 11:18:51 +07:00
parent 741a6a8fd8
commit 8326d50979

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
@ -15,534 +16,62 @@ 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.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
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.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.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.core.content.ContextCompat
import androidx.core.app.ActivityCompat
import androidx.navigation.NavController
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import com.google.android.gms.location.LocationServices import com.google.android.gms.location.LocationServices
import com.google.android.gms.location.Priority
import id.ac.ubharajaya.sistemakademik.ui.theme.SistemAkademikTheme import id.ac.ubharajaya.sistemakademik.ui.theme.SistemAkademikTheme
import org.json.JSONArray
import org.json.JSONObject import org.json.JSONObject
import java.io.ByteArrayOutputStream import java.io.ByteArrayOutputStream
import java.net.HttpURLConnection import java.net.HttpURLConnection
import java.net.URL import java.net.URL
import java.text.SimpleDateFormat
import java.util.*
import kotlin.concurrent.thread import kotlin.concurrent.thread
import kotlin.math.atan2
import kotlin.math.cos
import kotlin.math.sin
import kotlin.math.sqrt
/* ================= DATA CLASS ================= */ /* ================= KONFIGURASI LOKASI ================= */
data class AttendanceRecord(val timestamp: Long, val status: String)
/* ================= UTILS ================= */ 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 { fun bitmapToBase64(bitmap: Bitmap): String {
val outputStream = ByteArrayOutputStream() val outputStream = ByteArrayOutputStream()
bitmap.compress(Bitmap.CompressFormat.JPEG, 70, outputStream) bitmap.compress(Bitmap.CompressFormat.JPEG, 80, outputStream)
return Base64.encodeToString(outputStream.toByteArray(), Base64.NO_WRAP) return Base64.encodeToString(outputStream.toByteArray(), Base64.NO_WRAP)
} }
object LocationUtils { fun kirimKeN8n(
private const val KAMPUS_LATITUDE = -6.2576 context: ComponentActivity,
private const val KAMPUS_LONGITUDE = 106.9746 npm: String,
private const val MAX_RADIUS_METERS = 100.0 // Radius 100 meter nama: String,
mataKuliah: String,
fun isWithinCampusRadius(lat: Double, lon: Double): Boolean { latitude: Double,
val earthRadius = 6371000.0 // in meters longitude: Double,
val dLat = Math.toRadians(lat - KAMPUS_LATITUDE) foto: Bitmap,
val dLon = Math.toRadians(lon - KAMPUS_LONGITUDE) status: String
val lat1 = Math.toRadians(KAMPUS_LATITUDE) ) {
val lat2 = Math.toRadians(lat)
val a = sin(dLat / 2) * sin(dLat / 2) +
sin(dLon / 2) * sin(dLon / 2) * cos(lat1) * cos(lat2)
val c = 2 * atan2(sqrt(a), sqrt(1 - a))
return (earthRadius * c) <= MAX_RADIUS_METERS
}
}
/* ================= MAIN ACTIVITY ================= */
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
SistemAkademikTheme {
AppNavigation(activity = this)
}
}
}
}
/* ================= NAVIGATION ================= */
@Composable
fun AppNavigation(activity: ComponentActivity) {
val navController = rememberNavController()
NavHost(navController = navController, startDestination = "login") {
composable("login") {
LoginScreen(navController = navController)
}
composable(
route = "menu/{npm}/{nama}",
arguments = listOf(
navArgument("npm") { type = NavType.StringType },
navArgument("nama") { type = NavType.StringType }
)
) { backStackEntry ->
val npm = backStackEntry.arguments?.getString("npm") ?: "N/A"
val nama = backStackEntry.arguments?.getString("nama") ?: "N/A"
MenuScreen(navController = navController, npm = npm, nama = nama)
}
composable(
route = "absensi/{npm}/{nama}",
arguments = listOf(
navArgument("npm") { type = NavType.StringType },
navArgument("nama") { type = NavType.StringType }
)
) { backStackEntry ->
val npm = backStackEntry.arguments?.getString("npm") ?: "N/A"
val nama = backStackEntry.arguments?.getString("nama") ?: "N/A"
AbsensiScreen(
activity = activity,
navController = navController,
npm = npm,
nama = nama
)
}
composable(
route = "history/{npm}",
arguments = listOf(navArgument("npm") { type = NavType.StringType })
) { backStackEntry ->
val npm = backStackEntry.arguments?.getString("npm") ?: "N/A"
HistoryScreen(navController = navController, npm = npm, activity = activity)
}
}
}
/* ================= UI SCREENS ================= */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LoginScreen(navController: NavController) {
var npm by remember { mutableStateOf("") }
var nama by remember { mutableStateOf("") }
Scaffold(
topBar = {
CenterAlignedTopAppBar(
title = { Text("Login Absensi", color = Color.White, fontSize = 18.sp) },
colors = TopAppBarDefaults.centerAlignedTopAppBarColors(containerColor = Color(0xFF2E7D32))
)
}
) { innerPadding ->
Column(
modifier = Modifier
.padding(innerPadding)
.fillMaxSize()
.background(Color(0xFFF8FBF8))
.padding(horizontal = 32.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Icon(Icons.Default.Person, contentDescription = null, modifier = Modifier.size(100.dp), tint = Color(0xFF2E7D32))
Spacer(modifier = Modifier.height(40.dp))
OutlinedTextField(
value = npm,
onValueChange = { npm = it },
label = { Text("NPM") },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number, imeAction = ImeAction.Next),
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(16.dp))
OutlinedTextField(
value = nama,
onValueChange = { nama = it },
label = { Text("Nama Lengkap") },
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(24.dp))
Button(
onClick = {
if (npm.isNotBlank() && nama.isNotBlank()) {
navController.navigate("menu/$npm/$nama")
}
},
modifier = Modifier.fillMaxWidth().height(50.dp),
enabled = npm.isNotBlank() && nama.isNotBlank(),
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF1B5E20)),
shape = RoundedCornerShape(10.dp)
) {
Text("LOGIN", fontWeight = FontWeight.Bold, color = Color.White)
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MenuScreen(navController: NavController, npm: String, nama: String) {
Scaffold(
topBar = {
CenterAlignedTopAppBar(
title = { Text("Menu Utama", color = Color.White, fontSize = 18.sp) },
colors = TopAppBarDefaults.centerAlignedTopAppBarColors(containerColor = Color(0xFF2E7D32)),
navigationIcon = {
IconButton(onClick = { navController.popBackStack() }) {
Icon(Icons.Default.ArrowBack, contentDescription = "Logout", tint = Color.White)
}
}
)
}
) { innerPadding ->
Column(
modifier = Modifier
.padding(innerPadding)
.fillMaxSize()
.background(Color(0xFFF8FBF8))
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text("Selamat Datang,", style = MaterialTheme.typography.bodyLarge)
Text("$nama ($npm)", fontWeight = FontWeight.Bold, fontSize = 20.sp)
Spacer(modifier = Modifier.height(32.dp))
// Tombol Absensi
Button(
onClick = { navController.navigate("absensi/$npm/$nama") },
modifier = Modifier
.fillMaxWidth()
.height(60.dp),
shape = RoundedCornerShape(12.dp)
) {
Icon(Icons.Filled.CameraAlt, contentDescription = null, modifier = Modifier.size(24.dp))
Spacer(modifier = Modifier.width(8.dp))
Text("Absen Kehadiran", fontSize = 16.sp)
}
Spacer(modifier = Modifier.height(16.dp))
// Tombol Riwayat
Button(
onClick = { navController.navigate("history/$npm") },
modifier = Modifier
.fillMaxWidth()
.height(60.dp),
shape = RoundedCornerShape(12.dp)
) {
Icon(Icons.Default.History, contentDescription = null, modifier = Modifier.size(24.dp))
Spacer(modifier = Modifier.width(8.dp))
Text("Riwayat Absensi", fontSize = 16.sp)
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AbsensiScreen(activity: ComponentActivity, navController: NavController, npm: String, nama: String) {
val context = LocalContext.current
var lokasiStatus by remember { mutableStateOf("Mengecek Lokasi...") }
var locationAvailable by remember { mutableStateOf(false) }
var foto by remember { mutableStateOf<Bitmap?>(null) }
var isUploading by remember { mutableStateOf(false) }
var latitude by remember { mutableStateOf<Double?>(null) }
var longitude by remember { mutableStateOf<Double?>(null) }
var matakuliah by remember { mutableStateOf("") }
val fusedLocationClient = remember { LocationServices.getFusedLocationProviderClient(context) }
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
}
}
val requestPermissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
) { permissions ->
if (permissions[Manifest.permission.ACCESS_FINE_LOCATION] == true) {
if (ActivityCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED) {
fusedLocationClient.getCurrentLocation(Priority.PRIORITY_HIGH_ACCURACY, null)
.addOnSuccessListener { loc ->
if (loc != null) {
latitude = loc.latitude
longitude = loc.longitude
locationAvailable = true
lokasiStatus = if (LocationUtils.isWithinCampusRadius(loc.latitude, loc.longitude)) {
"Di Dalam Area Kampus"
} else {
"Di Luar Area Kampus"
}
} else {
lokasiStatus = "Gagal Mendapatkan Lokasi"
locationAvailable = false
}
}.addOnFailureListener {
lokasiStatus = "Gagal Mendapatkan Lokasi"
locationAvailable = false
}
}
} else {
lokasiStatus = "Izin lokasi ditolak."
locationAvailable = false
}
}
LaunchedEffect(Unit) {
requestPermissionLauncher.launch(
arrayOf(Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.CAMERA)
)
}
Scaffold(
topBar = {
CenterAlignedTopAppBar(
title = { Text("Absen Kehadiran", color = Color.White, fontSize = 18.sp) },
navigationIcon = {
IconButton(onClick = { navController.popBackStack() }) {
Icon(Icons.Default.ArrowBack, contentDescription = "Kembali", tint = Color.White)
}
},
colors = TopAppBarDefaults.centerAlignedTopAppBarColors(containerColor = Color(0xFF2E7D32))
)
}
) { innerPadding ->
Column(
modifier = Modifier
.padding(innerPadding)
.fillMaxSize()
.background(Color(0xFFF8FBF8))
.padding(horizontal = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.height(16.dp))
Text("Selamat Datang,", style = MaterialTheme.typography.bodyLarge)
Text("$nama ($npm)", fontWeight = FontWeight.Bold, fontSize = 18.sp)
Spacer(modifier = Modifier.height(16.dp))
OutlinedTextField(
value = matakuliah,
onValueChange = { matakuliah = it },
label = { Text("Mata Kuliah") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(16.dp))
Row(
modifier = Modifier.padding(top = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Status Lokasi: $lokasiStatus ",
style = MaterialTheme.typography.bodyMedium,
color = if (locationAvailable) Color(0xFF2E7D32) else Color.Red
)
if (locationAvailable) {
Icon(Icons.Default.CheckCircle, "OK", tint = Color(0xFF2E7D32), modifier = Modifier.size(16.dp))
} else {
Icon(Icons.Default.Close, "Error", tint = Color.Red, modifier = Modifier.size(16.dp))
}
}
Spacer(modifier = Modifier.height(20.dp))
Text("Ambil Foto Selfie", fontWeight = FontWeight.SemiBold, fontSize = 16.sp)
Spacer(modifier = Modifier.height(16.dp))
Box(
modifier = Modifier
.size(220.dp)
.clip(CircleShape)
.background(Color(0xFFE0E0E0)),
contentAlignment = Alignment.Center
) {
if (foto != null) {
Image(
bitmap = foto!!.asImageBitmap(),
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
} else {
Icon(Icons.Default.Person, null, modifier = Modifier.size(80.dp), tint = Color.White)
}
}
Spacer(modifier = Modifier.height(40.dp))
Button(
onClick = {
val intent = Intent(MediaStore.ACTION_IMAGE_CAPTURE)
cameraLauncher.launch(intent)
},
modifier = Modifier.fillMaxWidth(0.7f).height(50.dp),
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF4CAF50)),
shape = RoundedCornerShape(10.dp)
) {
Text("AMBIL FOTO", fontWeight = FontWeight.Bold, color = Color.White)
}
Spacer(modifier = Modifier.height(16.dp))
Button(
onClick = {
isUploading = true
if (latitude != null && longitude != null && foto != null) {
kirimKeServer(activity, npm, nama, matakuliah, latitude!!, longitude!!, foto!!) {
isUploading = false
if (it) navController.popBackStack()
}
} else {
isUploading = false
Toast.makeText(context, "Lokasi atau foto belum siap.", Toast.LENGTH_LONG).show()
}
},
modifier = Modifier.fillMaxWidth(0.7f).height(50.dp),
enabled = foto != null && locationAvailable && matakuliah.isNotBlank() && !isUploading,
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF1B5E20)),
shape = RoundedCornerShape(10.dp)
) {
Text("KIRIM ABSENSI", fontWeight = FontWeight.Bold)
}
if (isUploading) {
Spacer(modifier = Modifier.height(16.dp))
CircularProgressIndicator(color = Color(0xFF2E7D32))
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun HistoryScreen(navController: NavController, npm: String, activity: Activity) {
var historyList by remember { mutableStateOf<List<AttendanceRecord>>(emptyList()) }
var isLoading by remember { mutableStateOf(true) }
LaunchedEffect(npm) {
fetchHistoryFromServer(npm, activity) {
historyList = it
isLoading = false
}
}
Scaffold(
topBar = {
CenterAlignedTopAppBar(
title = { Text("Riwayat Absensi", color = Color.White) },
navigationIcon = {
IconButton(onClick = { navController.popBackStack() }) {
Icon(Icons.Default.ArrowBack, "Kembali", tint = Color.White)
}
},
colors = TopAppBarDefaults.centerAlignedTopAppBarColors(Color(0xFF2E7D32))
)
}
) { innerPadding ->
if (isLoading) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
} else if (historyList.isEmpty()) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text("Tidak ada riwayat absensi.")
}
} else {
LazyColumn(
modifier = Modifier
.padding(innerPadding)
.fillMaxSize()
.padding(16.dp)
) {
items(historyList) { record ->
val date = SimpleDateFormat("dd MMMM yyyy, HH:mm", Locale.getDefault()).format(Date(record.timestamp))
Card(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
elevation = CardDefaults.cardElevation(2.dp)
) {
Row(
modifier = Modifier
.padding(16.dp)
.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(text = date, fontWeight = FontWeight.Medium)
Text(
text = record.status,
color = if (record.status == "Sukses") Color(0xFF2E7D32) else Color.Red,
fontWeight = FontWeight.Bold
)
}
}
}
}
}
}
}
/* ================= NETWORK LOGIC ================= */
fun kirimKeServer(activity: Activity, npm: String, nama: String, matakuliah: String, lat: Double, lon: Double, img: Bitmap, onFinish: (Boolean) -> Unit) {
thread { thread {
var isSuccess = false
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")
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
@ -550,57 +79,289 @@ fun kirimKeServer(activity: Activity, npm: String, nama: String, matakuliah: Str
val json = JSONObject().apply { val json = JSONObject().apply {
put("npm", npm) put("npm", npm)
put("nama", nama) put("nama", nama)
put("mata_kuliah", matakuliah) // FIX: Changed from "matakuliah" to "mata_kuliah" put("mata_kuliah", mataKuliah)
put("latitude", lat) put("latitude", latitude)
put("longitude", lon) put("longitude", longitude)
put("timestamp", System.currentTimeMillis()) put("timestamp", System.currentTimeMillis())
put("foto_base64", bitmapToBase64(img)) put("status", status)
put("foto_base64", bitmapToBase64(foto))
} }
conn.outputStream.write(json.toString().toByteArray()) conn.outputStream.use {
it.write(json.toString().toByteArray())
val code = conn.responseCode
isSuccess = code == 200
activity.runOnUiThread {
Toast.makeText(activity, if(isSuccess) "Absensi Sukses!" else "Absensi Gagal: Server Error $code", Toast.LENGTH_LONG).show()
onFinish(isSuccess)
} }
} catch (e: Exception) {
activity.runOnUiThread { val responseCode = conn.responseCode
Toast.makeText(activity, "Error: Gagal terhubung ke server.", Toast.LENGTH_SHORT).show()
onFinish(false) context.runOnUiThread {
Toast.makeText(
context,
if (responseCode == 200)
"Absensi $status"
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()
} }
} }
} }
} }
fun fetchHistoryFromServer(npm: String, activity: Activity, onResult: (List<AttendanceRecord>) -> Unit) { /* ================= ACTIVITY ================= */
thread {
try {
// PENTING: URL ini hanya contoh. Ganti dengan URL API Anda untuk mengambil riwayat.
val url = URL("https://n8n.lab.ubharajaya.ac.id/webhook-test/riwayat-absensi?npm=$npm")
val conn = url.openConnection() as HttpURLConnection
conn.requestMethod = "GET"
if (conn.responseCode == 200) { class MainActivity : ComponentActivity() {
val response = conn.inputStream.bufferedReader().use { it.readText() }
val jsonArray = JSONArray(response) override fun onCreate(savedInstanceState: Bundle?) {
val records = mutableListOf<AttendanceRecord>() super.onCreate(savedInstanceState)
for (i in 0 until jsonArray.length()) { enableEdgeToEdge()
val jsonObject = jsonArray.getJSONObject(i)
records.add( setContent {
AttendanceRecord( SistemAkademikTheme {
timestamp = jsonObject.getLong("timestamp"), Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
status = jsonObject.getString("status") AbsensiScreen(
) modifier = Modifier.padding(innerPadding),
activity = this
) )
} }
activity.runOnUiThread { onResult(records) } }
}
}
}
/* ================= UI ================= */
@Composable
fun AbsensiScreen(
modifier: Modifier = Modifier,
activity: ComponentActivity
) {
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) }
var foto by remember { mutableStateOf<Bitmap?>(null) }
val fusedLocationClient =
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 { } else {
activity.runOnUiThread { onResult(emptyList()) } lokasi = "Lokasi tidak tersedia"
} }
} catch (e: Exception) { }
activity.runOnUiThread { onResult(emptyList()) } }
} else {
Toast.makeText(
context,
"Izin lokasi ditolak",
Toast.LENGTH_SHORT
).show()
}
}
/* ===== 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()
} else {
Toast.makeText(
context,
"Gagal mengambil foto",
Toast.LENGTH_SHORT
).show()
}
}
}
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()
}
}
LaunchedEffect(Unit) {
locationPermissionLauncher.launch(
Manifest.permission.ACCESS_FINE_LOCATION
)
}
/* ===== UI ===== */
Column(
modifier = modifier
.fillMaxSize()
.padding(24.dp),
verticalArrangement = Arrangement.Center
) {
Text(
text = "Absensi Akademik",
style = MaterialTheme.typography.titleLarge
)
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()
) {
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 (
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()
}
} else {
Toast.makeText(
context,
"Data absensi belum lengkap",
Toast.LENGTH_SHORT
).show()
}
},
modifier = Modifier.fillMaxWidth()
) {
Text("Kirim Absensi")
} }
} }
} }