diff --git a/.idea/deviceManager.xml b/.idea/deviceManager.xml new file mode 100644 index 0000000..91f9558 --- /dev/null +++ b/.idea/deviceManager.xml @@ -0,0 +1,13 @@ + + + + + + \ No newline at end of file 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/.idea/markdown.xml b/.idea/markdown.xml new file mode 100644 index 0000000..c61ea33 --- /dev/null +++ b/.idea/markdown.xml @@ -0,0 +1,8 @@ + + + + + + \ No newline at end of file diff --git a/README.md b/README.md index 9871f13..7730b49 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,15 @@ # 📱 Aplikasi Absensi Akademik Berbasis Koordinat dan Foto (Mobile) +**NAMA :** **Syahril Achmad Fahrezi** + +**NPM :** **202310715211** + +**MATA KULIAH** : **PERMROGRAMAN PERANGKAT BERGERAK** + +**- INI DIKEMBANGKAN BUKAN BUAT DARI AWAL -** + ## 📌 Deskripsi Proyek + Proyek ini merupakan **Tugas Project Akhir Mata Kuliah Pemrograman Mobile** yang bertujuan untuk membangun **aplikasi akademik berbasis mobile** dengan fokus pada **fitur absensi menggunakan data koordinat (GPS) dan pengambilan foto mahasiswa**. Aplikasi ini dirancang untuk meningkatkan **validitas kehadiran mahasiswa**, dengan memastikan bahwa absensi hanya dapat dilakukan apabila mahasiswa: 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 c774502..1981282 100644 --- a/app/src/main/java/id/ac/ubharajaya/sistemakademik/MainActivity.kt +++ b/app/src/main/java/id/ac/ubharajaya/sistemakademik/MainActivity.kt @@ -5,6 +5,7 @@ import android.app.Activity import android.content.Intent import android.content.pm.PackageManager import android.graphics.Bitmap +import android.location.Location import android.os.Bundle import android.provider.MediaStore import android.util.Base64 @@ -14,11 +15,26 @@ import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.* +import androidx.compose.animation.core.* +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.* import androidx.compose.runtime.* +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.scale +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat import com.google.android.gms.location.LocationServices @@ -31,6 +47,22 @@ import kotlin.concurrent.thread /* ================= UTIL ================= */ +// Koordinat Kampus Universitas Bhayangkara Jakarta Raya +const val KAMPUS_LATITUDE = -6.2642 +const val KAMPUS_LONGITUDE = 107.0008 +const val RADIUS_METER = 100.0 + +fun hitungJarak(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Float { + val results = FloatArray(1) + Location.distanceBetween(lat1, lon1, lat2, lon2, results) + return results[0] +} + +fun cekDalamRadius(latitude: Double, longitude: Double): Boolean { + val jarak = hitungJarak(latitude, longitude, KAMPUS_LATITUDE, KAMPUS_LONGITUDE) + return jarak <= RADIUS_METER +} + fun bitmapToBase64(bitmap: Bitmap): String { val outputStream = ByteArrayOutputStream() bitmap.compress(Bitmap.CompressFormat.JPEG, 80, outputStream) @@ -41,12 +73,13 @@ fun kirimKeN8n( context: ComponentActivity, latitude: Double, longitude: Double, - foto: Bitmap + foto: Bitmap, + onSuccess: () -> Unit, + onError: () -> Unit ) { thread { try { val url = URL("https://n8n.lab.ubharajaya.ac.id/webhook/23c6993d-1792-48fb-ad1c-ffc78a3e6254") -// test URL val url = URL("https://n8n.lab.ubharajaya.ac.id/webhook-test/23c6993d-1792-48fb-ad1c-ffc78a3e6254") val conn = url.openConnection() as HttpURLConnection conn.requestMethod = "POST" @@ -54,8 +87,8 @@ fun kirimKeN8n( conn.doOutput = true val json = JSONObject().apply { - put("npm", "12345") - put("nama","Arif R D") + put("npm", "202310715211") + put("nama", "Syahril Achmad Fahrezi") put("latitude", latitude) put("longitude", longitude) put("timestamp", System.currentTimeMillis()) @@ -69,25 +102,18 @@ fun kirimKeN8n( val responseCode = conn.responseCode context.runOnUiThread { - Toast.makeText( - context, - if (responseCode == 200) - "Absensi diterima server" - else - "Absensi ditolak server", - Toast.LENGTH_SHORT - ).show() + if (responseCode == 200) { + onSuccess() + } else { + onError() + } } conn.disconnect() } catch (_: Exception) { context.runOnUiThread { - Toast.makeText( - context, - "Gagal kirim ke server", - Toast.LENGTH_SHORT - ).show() + onError() } } } @@ -123,152 +149,441 @@ fun AbsensiScreen( ) { val context = LocalContext.current - var lokasi by remember { mutableStateOf("Koordinat: -") } var latitude by remember { mutableStateOf(null) } var longitude by remember { mutableStateOf(null) } var foto by remember { mutableStateOf(null) } + var dalamRadius by remember { mutableStateOf(false) } + var jarak by remember { mutableStateOf(null) } + var isLoading by remember { mutableStateOf(false) } + var showSuccess by remember { mutableStateOf(false) } + var expandedMataKuliah by remember { mutableStateOf(false) } + var selectedMataKuliah by remember { mutableStateOf("Pilih Mata Kuliah") } - val fusedLocationClient = - LocationServices.getFusedLocationProviderClient(context) + val mataKuliahList = listOf( + "Pemrograman Perangkat Bergerak", + "Deep Learning", + "Keamanan Siber" + ) + + val fusedLocationClient = LocationServices.getFusedLocationProviderClient(context) + + val scale by animateFloatAsState( + targetValue = if (showSuccess) 1f else 0f, + animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy), label = "" + ) /* ===== Permission Lokasi ===== */ - val locationPermissionLauncher = - rememberLauncherForActivityResult( - ActivityResultContracts.RequestPermission() - ) { granted -> + rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> if (granted) { - - if ( - ContextCompat.checkSelfPermission( + 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}" + dalamRadius = cekDalamRadius(location.latitude, location.longitude) + jarak = hitungJarak( + location.latitude, + location.longitude, + KAMPUS_LATITUDE, + KAMPUS_LONGITUDE + ) } else { - lokasi = "Lokasi tidak tersedia" + Toast.makeText(context, "Lokasi tidak tersedia", Toast.LENGTH_SHORT).show() } } - .addOnFailureListener { - lokasi = "Gagal mengambil lokasi" - } } - } else { - Toast.makeText( - context, - "Izin lokasi ditolak", - Toast.LENGTH_SHORT - ).show() + Toast.makeText(context, "Izin lokasi ditolak", Toast.LENGTH_SHORT).show() } } /* ===== Kamera ===== */ - val cameraLauncher = - rememberLauncherForActivityResult( - ActivityResultContracts.StartActivityForResult() - ) { result -> + rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> if (result.resultCode == Activity.RESULT_OK) { - val bitmap = - result.data?.extras?.getParcelable("data", Bitmap::class.java) + val bitmap = result.data?.extras?.get("data") as? Bitmap if (bitmap != null) { foto = bitmap - Toast.makeText( - context, - "Foto berhasil diambil", - Toast.LENGTH_SHORT - ).show() + Toast.makeText(context, "Foto berhasil diambil", Toast.LENGTH_SHORT).show() } } } val cameraPermissionLauncher = - rememberLauncherForActivityResult( - ActivityResultContracts.RequestPermission() - ) { granted -> + rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> if (granted) { - val intent = - Intent(MediaStore.ACTION_IMAGE_CAPTURE) + val intent = Intent(MediaStore.ACTION_IMAGE_CAPTURE) cameraLauncher.launch(intent) } else { - Toast.makeText( - context, - "Izin kamera ditolak", - Toast.LENGTH_SHORT - ).show() + Toast.makeText(context, "Izin kamera ditolak", Toast.LENGTH_SHORT).show() } } /* ===== Request Awal ===== */ - LaunchedEffect(Unit) { - locationPermissionLauncher.launch( - Manifest.permission.ACCESS_FINE_LOCATION - ) + locationPermissionLauncher.launch(Manifest.permission.ACCESS_FINE_LOCATION) } /* ===== UI ===== */ - - Column( + Box( modifier = modifier .fillMaxSize() - .padding(24.dp), - verticalArrangement = Arrangement.Center - ) { - - 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 + .background( + Brush.verticalGradient( + colors = listOf( + Color(0xFF1a1a2e), + Color(0xFF16213e), + Color(0xFF0f3460) + ) ) - }, - modifier = Modifier.fillMaxWidth() + ) + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(20.dp) ) { - Text("Ambil Foto") + Spacer(modifier = Modifier.height(40.dp)) + + // Header + Text( + text = "Absensi Akademik", + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + color = Color.White + ) + + Text( + text = "Universitas Bhayangkara Jakarta Raya", + style = MaterialTheme.typography.bodyMedium, + color = Color.White.copy(alpha = 0.9f) + ) + + Spacer(modifier = Modifier.height(20.dp)) + + // Status Lokasi Card + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(20.dp), + colors = CardDefaults.cardColors( + containerColor = Color(0xFF0a0a0f).copy(alpha = 0.7f) + ), + elevation = CardDefaults.cardElevation(8.dp) + ) { + Column( + modifier = Modifier.padding(20.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + // Icon Status + Box( + modifier = Modifier + .size(80.dp) + .background( + color = if (dalamRadius) Color(0xFF10B981).copy(alpha = 0.2f) + else Color(0xFFEF4444).copy(alpha = 0.2f), + shape = RoundedCornerShape(40.dp) + ), + contentAlignment = Alignment.Center + ) { + Text( + text = if (dalamRadius) "✓" else "✗", + style = MaterialTheme.typography.displayMedium, + color = if (dalamRadius) Color(0xFF10B981) else Color(0xFFEF4444), + fontWeight = FontWeight.Bold + ) + } + + Spacer(modifier = Modifier.height(12.dp)) + + Text( + text = if (dalamRadius) "Dalam Area Kampus" else "Di Luar Area Kampus", + fontSize = 20.sp, + fontWeight = FontWeight.Bold, + fontStyle = FontStyle.Normal, + color = if (dalamRadius) Color(0xFF9D4EDD) else Color(0xFFEF4444) + ) + + Spacer(modifier = Modifier.height(8.dp)) + + if (jarak != null) { + Text( + text = "Jarak: ${String.format("%.1f", jarak)} meter", + style = MaterialTheme.typography.bodyMedium, + color = Color(0xFFB8B8D0) + ) + } + + if (latitude != null && longitude != null) { + Text( + text = "Lat: ${String.format("%.6f", latitude)}, Lon: ${String.format("%.6f", longitude)}", + style = MaterialTheme.typography.bodySmall, + color = Color(0xFF8B8BA0) + ) + } + } + } + + // Foto Preview + AnimatedVisibility( + visible = foto != null, + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically() + ) { + Card( + modifier = Modifier.size(200.dp), + shape = RoundedCornerShape(20.dp), + elevation = CardDefaults.cardElevation(8.dp) + ) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + foto?.let { + Image( + bitmap = it.asImageBitmap(), + contentDescription = "Foto Absensi", + modifier = Modifier.fillMaxSize() + ) + } + } + } + } + + Spacer(modifier = Modifier.weight(1f)) + + // Dropdown Mata Kuliah + Card( + modifier = Modifier + .fillMaxWidth() + .clickable { expandedMataKuliah = !expandedMataKuliah }, + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors( + containerColor = Color(0xFF0a0a0f).copy(alpha = 0.7f) + ), + elevation = CardDefaults.cardElevation(4.dp) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = selectedMataKuliah, + fontSize = 16.sp, + color = if (selectedMataKuliah == "Pilih Mata Kuliah") + Color(0xFF8B8BA0) else Color.White, + fontWeight = FontWeight.Medium + ) + Text( + text = if (expandedMataKuliah) "▲" else "▼", + color = Color(0xFF9D4EDD), + fontSize = 12.sp + ) + } + + AnimatedVisibility(visible = expandedMataKuliah) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(top = 12.dp) + ) { + mataKuliahList.forEach { matkul -> + Text( + text = matkul, + modifier = Modifier + .fillMaxWidth() + .clickable { + selectedMataKuliah = matkul + expandedMataKuliah = false + } + .padding(vertical = 12.dp), + fontSize = 15.sp, + color = Color(0xFFB8B8D0), + fontWeight = FontWeight.Normal + ) + if (matkul != mataKuliahList.last()) { + Spacer( + modifier = Modifier + .fillMaxWidth() + .height(1.dp) + .background(Color(0xFF3a3a4e)) + ) + } + } + } + } + } + } + + Spacer(modifier = Modifier.height(12.dp)) + + // Tombol Ambil Foto + Button( + onClick = { + cameraPermissionLauncher.launch(Manifest.permission.CAMERA) + }, + modifier = Modifier + .fillMaxWidth() + .height(56.dp), + shape = RoundedCornerShape(16.dp), + colors = ButtonDefaults.buttonColors( + containerColor = Color(0xFF7B2CBF), + contentColor = Color.White + ), + elevation = ButtonDefaults.buttonElevation(8.dp) + ) { + Text( + text = "📷 ${if (foto == null) "Ambil Foto" else "Ambil Ulang Foto"}", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + } + + // Tombol Kirim Absensi + Button( + onClick = { + when { + !dalamRadius -> { + Toast.makeText( + context, + "Anda di luar area kampus!", + Toast.LENGTH_LONG + ).show() + } + foto == null -> { + Toast.makeText( + context, + "Silakan ambil foto terlebih dahulu!", + Toast.LENGTH_SHORT + ).show() + } + selectedMataKuliah == "Pilih Mata Kuliah" -> { + Toast.makeText( + context, + "Silakan pilih mata kuliah terlebih dahulu!", + Toast.LENGTH_SHORT + ).show() + } + latitude == null || longitude == null -> { + Toast.makeText( + context, + "Lokasi belum terdeteksi!", + Toast.LENGTH_SHORT + ).show() + } + else -> { + isLoading = true + kirimKeN8n( + activity, + latitude!!, + longitude!!, + foto!!, + onSuccess = { + isLoading = false + showSuccess = true + Toast.makeText( + context, + "Absensi berhasil dikirim!", + Toast.LENGTH_SHORT + ).show() + }, + onError = { + isLoading = false + Toast.makeText( + context, + "Gagal mengirim absensi!", + Toast.LENGTH_SHORT + ).show() + } + ) + } + } + }, + modifier = Modifier + .fillMaxWidth() + .height(56.dp), + shape = RoundedCornerShape(16.dp), + colors = ButtonDefaults.buttonColors( + containerColor = if (dalamRadius && foto != null && selectedMataKuliah != "Pilih Mata Kuliah") + Color(0xFF9D4EDD) else Color(0xFF3a3a4e), + contentColor = Color.White + ), + elevation = ButtonDefaults.buttonElevation(8.dp), + enabled = !isLoading + ) { + if (isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + color = Color.White, + strokeWidth = 3.dp + ) + Spacer(modifier = Modifier.width(8.dp)) + Text("Mengirim...") + } else { + Text( + text = "📤 Kirim Absensi", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + } + } + + Spacer(modifier = Modifier.height(20.dp)) } - Spacer(modifier = Modifier.height(12.dp)) - - Button( - onClick = { - if (latitude != null && longitude != null && foto != null) { - kirimKeN8n( - activity, - latitude!!, - longitude!!, - foto!! - ) - } else { - Toast.makeText( - context, - "Lokasi atau foto belum lengkap", - Toast.LENGTH_SHORT - ).show() + // Success Animation + if (showSuccess) { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.5f)), + contentAlignment = Alignment.Center + ) { + Card( + modifier = Modifier + .scale(scale) + .padding(40.dp), + shape = RoundedCornerShape(24.dp), + colors = CardDefaults.cardColors(containerColor = Color(0xFF0a0a0f)) + ) { + Column( + modifier = Modifier.padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "✓", + style = MaterialTheme.typography.displayLarge, + color = Color(0xFF9D4EDD), + fontWeight = FontWeight.Bold + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = "Absensi Berhasil!", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + color = Color.White + ) + } } - }, - modifier = Modifier.fillMaxWidth() - ) { - Text("Kirim Absensi") + } + + LaunchedEffect(Unit) { + kotlinx.coroutines.delay(2000) + showSuccess = false + } } } -} +} \ No newline at end of file