diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..f0c6ad0 --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,50 @@ + + + + \ No newline at end of file diff --git a/.kotlin/errors/errors-1768292897859.log b/.kotlin/errors/errors-1768292897859.log new file mode 100644 index 0000000..1219b50 --- /dev/null +++ b/.kotlin/errors/errors-1768292897859.log @@ -0,0 +1,4 @@ +kotlin version: 2.0.21 +error message: The daemon has terminated unexpectedly on startup attempt #1 with error code: 0. The daemon process output: + 1. Kotlin compile daemon is ready + diff --git a/app/src/main/java/id/ac/ubharajaya/sistemakademik/MainActivity.kt b/app/src/main/java/id/ac/ubharajaya/sistemakademik/MainActivity.kt index ad91cb6..20f7eda 100644 --- a/app/src/main/java/id/ac/ubharajaya/sistemakademik/MainActivity.kt +++ b/app/src/main/java/id/ac/ubharajaya/sistemakademik/MainActivity.kt @@ -15,18 +15,40 @@ import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring +import androidx.compose.animation.fadeIn +import androidx.compose.animation.slideInVertically import androidx.compose.foundation.Image +import androidx.compose.foundation.background import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +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.runtime.* +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import androidx.core.content.ContextCompat import com.google.android.gms.location.LocationServices +import com.google.android.gms.location.Priority import id.ac.ubharajaya.sistemakademik.ui.theme.SistemAkademikTheme +import kotlinx.coroutines.delay import org.json.JSONObject import java.io.ByteArrayOutputStream import java.net.HttpURLConnection @@ -37,13 +59,9 @@ import kotlin.concurrent.thread const val KAMPUS_LAT = -6.222967558410948 const val KAMPUS_LON = 107.00931291609834 -const val MAX_RADIUS = 50 -// meter +const val MAX_RADIUS = 50 // meter -fun hitungJarak( - lat1: Double, lon1: Double, - lat2: Double, lon2: Double -): Float { +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] @@ -58,62 +76,39 @@ fun bitmapToBase64(bitmap: Bitmap): String { } fun kirimKeN8n( - context: ComponentActivity, - npm: String, - nama: String, - mataKuliah: String, - latitude: Double, - longitude: Double, - foto: Bitmap, - status: String + context: ComponentActivity, npm: String, nama: String, mataKuliah: String, + latitude: Double, longitude: Double, foto: Bitmap, status: String, + onFinished: () -> Unit ) { thread { try { val url = URL("https://n8n.lab.ubharajaya.ac.id/webhook/23c6993d-1792-48fb-ad1c-ffc78a3e6254") val conn = url.openConnection() as HttpURLConnection - conn.requestMethod = "POST" conn.setRequestProperty("Content-Type", "application/json") conn.doOutput = true - val json = JSONObject().apply { - put("npm", npm) - put("nama", nama) - put("mata_kuliah", mataKuliah) - put("latitude", latitude) - put("longitude", longitude) - put("timestamp", System.currentTimeMillis()) - put("status", status) + 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)) } - - conn.outputStream.use { - it.write(json.toString().toByteArray()) - } - + conn.outputStream.use { it.write(json.toString().toByteArray()) } val responseCode = conn.responseCode - context.runOnUiThread { Toast.makeText( context, - if (responseCode == 200) - "Absensi $status" - else - "Server menolak absensi", + if (responseCode == 200) "Absensi $status berhasil dikirim" 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() + Toast.makeText(context, "Gagal kirim ke server", Toast.LENGTH_SHORT).show() } + } finally { + context.runOnUiThread(onFinished) } } } @@ -121,247 +116,234 @@ fun kirimKeN8n( /* ================= ACTIVITY ================= */ class MainActivity : ComponentActivity() { - override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdge() - setContent { SistemAkademikTheme { - Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> - AbsensiScreen( - modifier = Modifier.padding(innerPadding), - activity = this - ) - } + AbsensiScreen(activity = this) } } } } -/* ================= UI ================= */ +/* ================= UI UTAMA ================= */ @Composable -fun AbsensiScreen( - modifier: Modifier = Modifier, - activity: ComponentActivity -) { +fun AbsensiScreen(activity: ComponentActivity) { val context = LocalContext.current + // States 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(null) } var latitude by remember { mutableStateOf(null) } var longitude by remember { mutableStateOf(null) } var foto by remember { mutableStateOf(null) } + var isUploading by remember { mutableStateOf(false) } + var isFetchingLocation by remember { mutableStateOf(false) } - val fusedLocationClient = - LocationServices.getFusedLocationProviderClient(context) + val fusedLocationClient = remember { 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() - } + fun requestLocationUpdate() { + if (ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED) { + lokasi = "Izin lokasi belum diberikan." + return } - - /* ===== 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() + isFetchingLocation = true + lokasi = "Mencari lokasi..." + fusedLocationClient.getCurrentLocation(Priority.PRIORITY_HIGH_ACCURACY, null) + .addOnSuccessListener { location: Location? -> + if (location != null) { + latitude = location.latitude + longitude = location.longitude + lokasi = "Lat: ${String.format("%.6f", location.latitude)}\nLon: ${String.format("%.6f", location.longitude)}" } else { - Toast.makeText( - context, - "Gagal mengambil foto", - Toast.LENGTH_SHORT - ).show() + lokasi = "Gagal mendapatkan lokasi. Coba lagi." } + isFetchingLocation = false } - } - - 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() + .addOnFailureListener { + lokasi = "Gagal mendapatkan lokasi. Coba lagi." + isFetchingLocation = false } - } - - LaunchedEffect(Unit) { - locationPermissionLauncher.launch( - Manifest.permission.ACCESS_FINE_LOCATION - ) } - /* ===== UI ===== */ + val locationPermissionLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> + if (granted) requestLocationUpdate() else { + Toast.makeText(context, "Izin lokasi ditolak", Toast.LENGTH_SHORT).show() + lokasi = "Izin lokasi ditolak." + } + } - Column( - modifier = modifier - .fillMaxSize() - .padding(24.dp), - verticalArrangement = Arrangement.Center - ) { + 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 else Toast.makeText(context, "Gagal mengambil foto", Toast.LENGTH_SHORT).show() + } + } - Text( - text = "Absensi Akademik", - style = MaterialTheme.typography.titleLarge + val cameraPermissionLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> + if (granted) cameraLauncher.launch(Intent(MediaStore.ACTION_IMAGE_CAPTURE)) + else Toast.makeText(context, "Izin kamera ditolak", Toast.LENGTH_SHORT).show() + } + + LaunchedEffect(Unit) { locationPermissionLauncher.launch(Manifest.permission.ACCESS_FINE_LOCATION) } + + Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) { + val gradient = Brush.linearGradient( + colors = listOf(MaterialTheme.colorScheme.primary.copy(alpha = 0.18f), Color.Transparent), + start = Offset(0f, 0f), + end = Offset(Float.POSITIVE_INFINITY, Float.POSITIVE_INFINITY) ) - - 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() + LazyColumn( + modifier = Modifier.fillMaxSize().background(gradient).padding(WindowInsets.systemBars.asPaddingValues()), + horizontalAlignment = Alignment.CenterHorizontally, + contentPadding = PaddingValues(horizontal = 24.dp, vertical = 16.dp) ) { - Text("Ambil Foto") - } + item { AnimatedHeader(delay = 0) } + item { Spacer(modifier = Modifier.height(24.dp)) } - /* ===== PREVIEW FOTO ===== */ + item { AnimatedInputCard(npm, nama, mataKuliah, { npm = it }, { nama = it }, { mataKuliah = it }) } + item { Spacer(modifier = Modifier.height(24.dp)) } - 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 - ) - } + item { + AnimatedActionSection(isFetchingLocation, { requestLocationUpdate() }, { cameraPermissionLauncher.launch(Manifest.permission.CAMERA) }, lokasi) + } + item { Spacer(modifier = Modifier.height(24.dp)) } - Spacer(modifier = Modifier.height(12.dp)) + item { AnimatedPhotoPreview(foto) } + item { Spacer(modifier = Modifier.height(24.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() + item { + val canSubmit = npm.isNotEmpty() && nama.isNotEmpty() && mataKuliah.isNotEmpty() && latitude != null && longitude != null && foto != null + AnimatedSubmitButton( + enabled = canSubmit && !isUploading, + isUploading = isUploading, + onClick = { + isUploading = true + val jarak = hitungJarak(latitude!!, longitude!!, KAMPUS_LAT, KAMPUS_LON) + val statusAbsen = if (jarak <= MAX_RADIUS) "HADIR" else "DITOLAK" + if (statusAbsen == "HADIR") { + kirimKeN8n(activity, npm, nama, mataKuliah, latitude!!, longitude!!, foto!!, statusAbsen) { isUploading = false } + } else { + val jarakKm = String.format("%.2f", jarak / 1000) + Toast.makeText(context, "Absensi DITOLAK: Anda berada ${jarakKm}km dari kampus.", Toast.LENGTH_LONG).show() + isUploading = false + } } - - } else { - Toast.makeText( - context, - "Data absensi belum lengkap", - Toast.LENGTH_SHORT - ).show() - } - }, - modifier = Modifier.fillMaxWidth() - ) { - Text("Kirim Absensi") + ) + } } } } + +/* ================= KOMPONEN UI DENGAN ANIMASI ================= */ + +@Composable +fun AnimatedHeader(delay: Long = 0) { + var visible by remember { mutableStateOf(false) } + LaunchedEffect(Unit) { delay(delay); visible = true } + AnimatedVisibility(visible = visible, enter = fadeIn(spring(stiffness = Spring.StiffnessLow)) + slideInVertically(spring(Spring.DampingRatioMediumBouncy, Spring.StiffnessLow), initialOffsetY = { -it })) { + Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.fillMaxWidth()) { + Text("E-ABSENSI", style = MaterialTheme.typography.displaySmall, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.primary) + Text("Universitas Bhayangkara Jakarta Raya", style = MaterialTheme.typography.titleMedium, color = Color.Gray) + } + } +} + +@Composable +fun AnimatedInputCard( + npm: String, nama: String, mataKuliah: String, + onNpmChange: (String) -> Unit, onNamaChange: (String) -> Unit, onMataKuliahChange: (String) -> Unit +) { + var cardVisible by remember { mutableStateOf(false) } + LaunchedEffect(Unit) { delay(200); cardVisible = true } + + AnimatedVisibility(visible = cardVisible, enter = fadeIn(spring(stiffness = Spring.StiffnessLow)) + slideInVertically(spring(Spring.DampingRatioMediumBouncy, Spring.StiffnessLow), initialOffsetY = { it / 2 })) { + Card(modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(16.dp), elevation = CardDefaults.cardElevation(4.dp)) { + Column(modifier = Modifier.padding(20.dp)) { + Text("Data Mahasiswa", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + Spacer(modifier = Modifier.height(16.dp)) + OutlinedTextField(value = npm, onValueChange = onNpmChange, label = { Text("NPM") }, modifier = Modifier.fillMaxWidth(), keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), leadingIcon = { Icon(Icons.Default.Person, "NPM") }) + Spacer(modifier = Modifier.height(8.dp)) + OutlinedTextField(value = nama, onValueChange = onNamaChange, label = { Text("Nama Lengkap") }, modifier = Modifier.fillMaxWidth(), keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), leadingIcon = { Icon(Icons.Default.Badge, "Nama") }) + Spacer(modifier = Modifier.height(8.dp)) + OutlinedTextField(value = mataKuliah, onValueChange = onMataKuliahChange, label = { Text("Mata Kuliah") }, modifier = Modifier.fillMaxWidth(), keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), leadingIcon = { Icon(Icons.Default.Book, "Mata Kuliah") }) + } + } + } +} + +@Composable +fun AnimatedActionSection(isFetchingLocation: Boolean, onRefresh: () -> Unit, onTakePhoto: () -> Unit, lokasi: String?) { + var visible by remember { mutableStateOf(false) } + LaunchedEffect(Unit) { delay(400); visible = true } + + AnimatedVisibility(visible = visible, enter = fadeIn(spring(stiffness = Spring.StiffnessLow)) + slideInVertically(spring(Spring.DampingRatioMediumBouncy, Spring.StiffnessLow), initialOffsetY = { it / 2 })) { + Column { + StatusRowWithRefresh(icon = Icons.Default.LocationOn, title = "Lokasi GPS", status = lokasi ?: "Meminta izin...", isRefreshing = isFetchingLocation, onRefresh = onRefresh) + Spacer(modifier = Modifier.height(16.dp)) + ActionButton(text = "Ambil Foto Kehadiran", icon = Icons.Default.CameraAlt, onClick = onTakePhoto) + } + } +} + +@Composable +fun AnimatedPhotoPreview(foto: Bitmap?) { + AnimatedVisibility(visible = foto != null, enter = fadeIn(spring(stiffness = Spring.StiffnessMedium)) + slideInVertically(spring(Spring.DampingRatioMediumBouncy, Spring.StiffnessMedium), initialOffsetY = { it / 2 })) { + Card(shape = RoundedCornerShape(16.dp), elevation = CardDefaults.cardElevation(4.dp)) { + Box(contentAlignment = Alignment.Center) { + Image(bitmap = foto!!.asImageBitmap(), "Preview Foto Absensi", modifier = Modifier.fillMaxWidth().height(250.dp).clip(RoundedCornerShape(16.dp)), contentScale = ContentScale.Crop) + Icon(Icons.Default.CheckCircle, "Foto Diambil", tint = Color.White.copy(alpha = 0.8f), modifier = Modifier.size(60.dp)) + } + } + } +} + +@Composable +fun AnimatedSubmitButton(enabled: Boolean, isUploading: Boolean, onClick: () -> Unit) { + var visible by remember { mutableStateOf(false) } + LaunchedEffect(Unit) { delay(600); visible = true } + + AnimatedVisibility(visible = visible, enter = fadeIn(spring(stiffness = Spring.StiffnessLow)) + slideInVertically(spring(Spring.DampingRatioMediumBouncy, Spring.StiffnessLow), initialOffsetY = { it / 2 })) { + Button(onClick = onClick, enabled = enabled, modifier = Modifier.fillMaxWidth().height(56.dp)) { + if (isUploading) { + CircularProgressIndicator(color = Color.White, modifier = Modifier.size(24.dp)) + } else { + Text("KIRIM ABSENSI", fontWeight = FontWeight.Bold) + } + } + } +} + +/* ================= KOMPONEN UI STANDAR ================= */ + +@Composable +fun StatusRowWithRefresh(icon: ImageVector, title: String, status: String, isRefreshing: Boolean, onRefresh: () -> Unit) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { + Icon(icon, contentDescription = title, tint = MaterialTheme.colorScheme.primary) + Spacer(modifier = Modifier.width(16.dp)) + Column(modifier = Modifier.weight(1f)) { + Text(title, fontWeight = FontWeight.Bold, fontSize = 16.sp) + Text(status, style = MaterialTheme.typography.bodySmall, color = Color.Gray) + } + if (isRefreshing) { + CircularProgressIndicator(modifier = Modifier.size(24.dp)) + } else { + IconButton(onClick = onRefresh) { + Icon(Icons.Default.Refresh, "Cari Ulang Lokasi") + } + } + } +} + +@Composable +fun ActionButton(text: String, icon: ImageVector, onClick: () -> Unit) { + FilledTonalButton(onClick = onClick, modifier = Modifier.fillMaxWidth().height(56.dp), shape = RoundedCornerShape(16.dp)) { + Icon(icon, contentDescription = null) + Spacer(modifier = Modifier.width(12.dp)) + Text(text, fontWeight = FontWeight.Bold) + } +}