EAS-202310715060-MuhammadYusronAmrullah
This commit is contained in:
parent
741a6a8fd8
commit
8326d50979
@ -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) }
|
|
||||||
} else {
|
|
||||||
activity.runOnUiThread { onResult(emptyList()) }
|
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
}
|
||||||
activity.runOnUiThread { onResult(emptyList()) }
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ================= 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 {
|
||||||
|
lokasi = "Lokasi tidak tersedia"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} 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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user