Merubah README, Mengembangkan Fitur Foto + Absen Hanya Disekitar Kampus + Fitur Absen Untuk Mata Kuliah Apa dan Membuat Tampilan Lebih Menarik

This commit is contained in:
202310715211 SYAHRIL ACHMAD FAHREZI 2026-01-14 21:38:45 +07:00
parent ed435ffbc1
commit d564e64af2
5 changed files with 506 additions and 111 deletions

13
.idea/deviceManager.xml generated Normal file
View File

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DeviceTable">
<option name="columnSorters">
<list>
<ColumnSorterState>
<option name="column" value="Name" />
<option name="order" value="ASCENDING" />
</ColumnSorterState>
</list>
</option>
</component>
</project>

View File

@ -0,0 +1,50 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="ComposePreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="ComposePreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="ComposePreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="ComposePreviewNotSupportedInUnitTestFiles" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="GlancePreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="GlancePreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="GlancePreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="GlancePreviewNotSupportedInUnitTestFiles" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewAnnotationInFunctionWithParameters" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewApiLevelMustBeValid" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewDeviceShouldUseNewSpec" enabled="true" level="WEAK WARNING" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewFontScaleMustBeGreaterThanZero" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewMultipleParameterProviders" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewParameterProviderOnFirstParameter" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewPickerAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
</profile>
</component>

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

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

View File

@ -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:

View File

@ -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<Double?>(null) }
var longitude by remember { mutableStateOf<Double?>(null) }
var foto by remember { mutableStateOf<Bitmap?>(null) }
var dalamRadius by remember { mutableStateOf(false) }
var jarak by remember { mutableStateOf<Float?>(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
.background(
Brush.verticalGradient(
colors = listOf(
Color(0xFF1a1a2e),
Color(0xFF16213e),
Color(0xFF0f3460)
)
)
)
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(20.dp)
) {
Spacer(modifier = Modifier.height(40.dp))
// Header
Text(
text = "Absensi Akademik",
style = MaterialTheme.typography.titleLarge
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold,
color = Color.White
)
Spacer(modifier = Modifier.height(16.dp))
Text(text = lokasi)
Spacer(modifier = Modifier.height(16.dp))
Button(
onClick = {
cameraPermissionLauncher.launch(
Manifest.permission.CAMERA
Text(
text = "Universitas Bhayangkara Jakarta Raya",
style = MaterialTheme.typography.bodyMedium,
color = Color.White.copy(alpha = 0.9f)
)
},
modifier = Modifier.fillMaxWidth()
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)
) {
Text("Ambil Foto")
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 = {
if (latitude != null && longitude != null && foto != null) {
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!!
)
} else {
foto!!,
onSuccess = {
isLoading = false
showSuccess = true
Toast.makeText(
context,
"Lokasi atau foto belum lengkap",
"Absensi berhasil dikirim!",
Toast.LENGTH_SHORT
).show()
},
onError = {
isLoading = false
Toast.makeText(
context,
"Gagal mengirim absensi!",
Toast.LENGTH_SHORT
).show()
}
)
}
}
},
modifier = Modifier.fillMaxWidth()
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
) {
Text("Kirim Absensi")
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))
}
// 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
)
}
}
}
LaunchedEffect(Unit) {
kotlinx.coroutines.delay(2000)
showSuccess = false
}
}
}
}