Pembaruan Aplikasi Absensi Wajah

This commit is contained in:
202310715320 AHMAR RAFLY MARYADI 2026-01-14 23:20:43 +07:00
parent ed435ffbc1
commit a0f0c4c995
11 changed files with 1147 additions and 261 deletions

View File

@ -4,10 +4,10 @@
<selectionStates> <selectionStates>
<SelectionState runConfigName="app"> <SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" /> <option name="selectionMode" value="DROPDOWN" />
<DropdownSelection timestamp="2026-01-13T05:20:56.137492Z"> <DropdownSelection timestamp="2026-01-14T12:44:40.572110100Z">
<Target type="DEFAULT_BOOT"> <Target type="DEFAULT_BOOT">
<handle> <handle>
<DeviceId pluginId="PhysicalDevice" identifier="serial=RR8TA08RD8Z" /> <DeviceId pluginId="LocalEmulator" identifier="path=C:\Users\Ahmar Rafly\.android\avd\Pixel_6_Pro.avd" />
</handle> </handle>
</Target> </Target>
</DropdownSelection> </DropdownSelection>

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>

134
README.md
View File

@ -1,97 +1,75 @@
# 📱 Aplikasi Absensi Akademik Berbasis Koordinat dan Foto (Mobile) # 🎓 Sistem Akademik - Aplikasi Absensi Mahasiswa Pintar
## 📌 Deskripsi Proyek ## 📌 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 **Sistem Akademik** adalah solusi absensi mobile modern yang dirancang untuk Mata Kuliah Pemrograman Mobile. Aplikasi ini mengedepankan keamanan dan validitas data dengan menggabungkan teknologi **Geofencing**, **Liveness Detection**, dan **Pencatatan Terintegrasi** untuk mencegah kecurangan absensi.
Aplikasi ini dirancang untuk meningkatkan **validitas kehadiran mahasiswa**, dengan memastikan bahwa absensi hanya dapat dilakukan apabila mahasiswa:
1. Berada pada **lokasi yang telah ditentukan**, dan
2. Melakukan **pengambilan foto (selfie) secara langsung saat absensi**
---
## 🎯 Tujuan Proyek
- Mengimplementasikan **Location-Based Service (LBS)** pada aplikasi mobile
- Mengintegrasikan **kamera perangkat** untuk dokumentasi absensi
- Mencegah kecurangan absensi (titip absen)
- Mengembangkan aplikasi mobile akademik berbasis Android
- Melatih kemampuan perancangan dan implementasi aplikasi mobile
--- ---
## 🚀 Fitur Utama ## 🚀 Fitur Utama
- 🔐 **Login Pengguna (Mahasiswa)**
- 📍 **Pengambilan Koordinat Lokasi (Latitude & Longitude)** ### 🔐 Manajemen Akun & Keamanan
- 🏫 **Validasi Lokasi Absensi (Radius Area)** - **Login Mahasiswa**: Autentikasi menggunakan NPM dan Nama Lengkap secara manual.
- 📸 **Pengambilan Foto Mahasiswa Saat Absensi** - **Logout Akun**: Fitur keluar untuk membersihkan sesi dan kembali ke halaman login.
- 🕒 **Pencatatan Waktu Absensi** - **Database Akun Lokal**: Aktivitas dan riwayat tersimpan secara privat sesuai NPM yang sedang login menggunakan Room Database.
- 📄 **Riwayat Kehadiran Mahasiswa**
- ⚠️ **Notifikasi Absensi Ditolak jika Tidak Valid** ### 📍 Validasi Lokasi (Geofencing)
- **Radius Kampus**: Absensi status "Hadir" hanya dapat dilakukan jika mahasiswa berada dalam radius **50 meter** dari pusat kampus (UBHARA Jaya Bekasi).
- **Masking Koordinat**: Koordinat yang dikirim ke server diberikan sedikit *offset* otomatis untuk menjaga privasi lokasi mahasiswa.
### 📸 Verifikasi Wajah (Liveness Detection)
- **Deteksi Kedip**: Syarat wajib berkedip untuk memverifikasi mahasiswa adalah orang asli (bukan foto/gambar).
- **Face Centering & Alignment**: Validasi posisi wajah wajib berada di **tengah layar** dan menghadap **lurus ke depan** sebelum sistem mengambil gambar.
- **Brightness Override**: Layar otomatis mencerahkan cahaya hingga 100% saat verifikasi untuk hasil foto wajah yang terang dan jelas.
- **Kondisional Kamera**:
- Status **Hadir**: Menggunakan sensor Liveness (ML Kit).
- Status **Ijin/Sakit**: Menggunakan kamera standar untuk dokumentasi bukti biasa.
### 🕒 Validasi Waktu & Jadwal
- **Sistem Jadwal Ketat**: Tombol kirim hanya aktif sesuai jam perkuliahan:
- **Pemrograman Perangkat Bergerak**: 13:30 - 16:00.
- **Keamanan Siber**: 16:15 - 18:45.
- **Input Keterangan (Opsional)**: Tersedia kolom input khusus untuk memberikan alasan tambahan jika mahasiswa memilih status **Ijin** atau **Sakit**.
### 📄 Riwayat Absensi
- **Penyimpanan Permanen**: Riwayat tersimpan di database lokal sehingga tidak hilang saat aplikasi ditutup atau perangkat di-restart.
- **Format Detail Per Baris**: Menampilkan informasi secara terstruktur:
1. Foto/Wajah yang diambil.
2. Waktu pengambilan absensi.
3. Nama Mata Kuliah & Ruangan.
4. Status Kehadiran.
5. **Teks Keterangan** (Muncul khusus jika Ijin/Sakit diisi).
6. Lokasi (Koordinat GPS).
--- ---
## 🗺️ Mekanisme Absensi Berbasis Lokasi dan Foto ## 🛠️ Detail Teknis
1. Mahasiswa melakukan **login**
2. Memilih menu **Absensi**
3. Sistem meminta:
- Izin **akses lokasi**
- Izin **akses kamera**
4. Aplikasi mengambil:
- 📍 **Koordinat lokasi mahasiswa**
- 📸 **Foto mahasiswa secara real-time**
5. Sistem melakukan validasi:
- Lokasi berada dalam **radius absensi**
- Foto berhasil diambil
6. Jika valid → **Absensi berhasil**
7. Jika tidak valid → **Absensi ditolak**
--- ### Daftar Mata Kuliah & Ruangan
1. **Pemrograman Perangkat Bergerak**
- Jadwal: 13:30 - 16:00 | Ruangan: Grha Tanoto [W-104]
2. **Keamanan Siber**
- Jadwal: 16:15 - 18:45 | Ruangan: R. Said Soekanto [SS-405]
## 📸 Pengambilan Foto Saat Absensi ### Teknologi Utama
- Foto diambil menggunakan **kamera depan (selfie)** - **UI**: Jetpack Compose (Material 3) dengan fitur **Searchbar** pada dropdown mata kuliah.
- Foto hanya dapat diambil **saat proses absensi** - **Database**: Room Persistence Library (SQLite) untuk isolasi data per akun.
- Foto disimpan sebagai **bukti kehadiran** - **Vision**: Google ML Kit Face Detection.
- Foto dapat digunakan untuk: - **Location**: Fused Location Provider API (GPS Presisi).
- Verifikasi manual oleh dosen
- Dokumentasi akademik
---
## 🛠️ Teknologi yang Digunakan
- **Platform** : Android
- **Bahasa Pemrograman** : Kotlin / Java
- **Location Service** :
- Google Maps API
- Fused Location Provider
- **Camera API** : CameraX / Camera2
- **Database** : Firebase / SQLite / MySQL
- **Storage** : Firebase Storage / Local Storage
- **IDE** : Android Studio
--- ---
## 🔐 Izin Aplikasi (Permissions) ## 🔐 Izin Aplikasi (Permissions)
Aplikasi memerlukan izin berikut: Aplikasi meminta izin secara otomatis saat pertama kali dijalankan:
- `ACCESS_FINE_LOCATION` - `ACCESS_FINE_LOCATION` (Lokasi akurat)
- `ACCESS_COARSE_LOCATION` - `CAMERA` (Verifikasi wajah & foto bukti)
- `CAMERA` - `INTERNET` (Sinkronisasi ke sistem n8n)
- `INTERNET`
- `WRITE_EXTERNAL_STORAGE` (jika diperlukan)
--- ---
## 📂 Mockup ## 📂 Link Terkait
![mockup](Mockup.png) - **Webhook Production**: `https://n8n.lab.ubharajaya.ac.id/webhook/23c6993d-1792-48fb-ad1c-ffc78a3e6254`
gambar mockup dibuat oleh AI - **Pengecekan Excel**: [Data Absensi Google Sheets](https://docs.google.com/spreadsheets/d/1jH15MfnNgpPGuGeid0hYfY7fFUHCEFbCmg8afTyyLZs/edit?gid=0#gid=0)
- **Notifikasi Sistem**: [ntfy.sh/EAS](https://ntfy.ubharajaya.ac.id/EAS)
## Catatan: ---
- Starter project ini dibuat berbantukan AI *Dibuat untuk Project Akhir Mata Kuliah Pemrograman Mobile 2025/2026.*
- Kembangkan project dari starter yang sudah disediakan, jangan membuat dari awal.
- Untuk koordinat bisa ditambah/kurangi angka tertentu agar tidak memunculkan koordinat rumah masing-masing, data awal tetap dari GPS.
## Pengecekan:
- https://ntfy.ubharajaya.ac.id/EAS
- https://docs.google.com/spreadsheets/d/1jH15MfnNgpPGuGeid0hYfY7fFUHCEFbCmg8afTyyLZs/edit?gid=0#gid=0
## Webhook:
- test: https://n8n.lab.ubharajaya.ac.id/webhook-test/23c6993d-1792-48fb-ad1c-ffc78a3e6254
- production: https://n8n.lab.ubharajaya.ac.id/webhook/23c6993d-1792-48fb-ad1c-ffc78a3e6254

View File

@ -2,13 +2,12 @@ plugins {
alias(libs.plugins.android.application) alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose) alias(libs.plugins.kotlin.compose)
id("com.google.devtools.ksp") version "2.0.21-1.0.27"
} }
android { android {
namespace = "id.ac.ubharajaya.sistemakademik" namespace = "id.ac.ubharajaya.sistemakademik"
compileSdk { compileSdk = 36
version = release(36)
}
defaultConfig { defaultConfig {
applicationId = "id.ac.ubharajaya.sistemakademik" applicationId = "id.ac.ubharajaya.sistemakademik"
@ -45,15 +44,30 @@ dependencies {
implementation(libs.androidx.core.ktx) implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.activity.compose) implementation(libs.androidx.activity.compose)
implementation("androidx.activity:activity-compose:1.9.0")
implementation(platform(libs.androidx.compose.bom)) implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.compose.ui) implementation(libs.androidx.compose.ui)
implementation(libs.androidx.compose.ui.graphics) implementation(libs.androidx.compose.ui.graphics)
implementation(libs.androidx.compose.ui.tooling.preview) implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.compose.material3) implementation(libs.androidx.compose.material3)
// Location (GPS) // Location (GPS)
implementation("com.google.android.gms:play-services-location:21.0.1") implementation("com.google.android.gms:play-services-location:21.0.1")
// CameraX
implementation(libs.androidx.camera.core)
implementation(libs.androidx.camera.camera2)
implementation(libs.androidx.camera.lifecycle)
implementation(libs.androidx.camera.view)
implementation(libs.androidx.camera.extensions)
// ML Kit Face Detection
implementation(libs.mlkit.face.detection)
// Room
implementation(libs.androidx.room.runtime)
implementation(libs.androidx.room.ktx)
ksp(libs.androidx.room.compiler)
testImplementation(libs.junit) testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(libs.androidx.espresso.core)

View File

@ -6,6 +6,7 @@
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/> <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/> <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
<uses-permission android:name="android.permission.CAMERA"/> <uses-permission android:name="android.permission.CAMERA"/>
<uses-feature <uses-feature
android:name="android.hardware.camera" android:name="android.hardware.camera"
android:required="false" /> android:required="false" />
@ -19,6 +20,7 @@
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.SistemAkademik"> android:theme="@style/Theme.SistemAkademik">
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"
@ -26,10 +28,15 @@
android:theme="@style/Theme.SistemAkademik"> android:theme="@style/Theme.SistemAkademik">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
</activity> </activity>
<activity
android:name=".LivenessDetectionActivity"
android:exported="false"
android:theme="@style/Theme.SistemAkademik" />
</application> </application>
</manifest> </manifest>

View File

@ -0,0 +1,342 @@
package id.ac.ubharajaya.sistemakademik
import android.Manifest
import android.annotation.SuppressLint
import android.app.Activity
import android.content.pm.PackageManager
import android.graphics.*
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.util.Log
import android.view.WindowManager
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.camera.core.*
import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.camera.view.PreviewView
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.ContextCompat
import androidx.lifecycle.compose.LocalLifecycleOwner
import com.google.mlkit.vision.common.InputImage
import com.google.mlkit.vision.face.FaceDetection
import com.google.mlkit.vision.face.FaceDetectorOptions
import id.ac.ubharajaya.sistemakademik.ui.theme.SistemAkademikTheme
import java.io.ByteArrayOutputStream
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
enum class LivenessStep(val instruction: String) {
BLINK("Silakan Berkedip"),
NOD("Silakan Mengangguk"),
SHAKE("Gelengkan Kepala ke Kiri atau ke Kanan"),
SUCCESS("Verifikasi Berhasil!")
}
class LivenessDetectionActivity : ComponentActivity() {
private lateinit var cameraExecutor: ExecutorService
companion object {
var latestCapturedBitmap: Bitmap? = null
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
cameraExecutor = Executors.newSingleThreadExecutor()
// Maksimalkan kecerahan layar saat verifikasi wajah
val layoutParams = window.attributes
layoutParams.screenBrightness = WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_FULL
window.attributes = layoutParams
setContent {
SistemAkademikTheme {
LivenessPermissionGate {
LivenessDetectionScreen(
onSuccess = { bitmap ->
Handler(Looper.getMainLooper()).post {
try {
latestCapturedBitmap = Bitmap.createScaledBitmap(bitmap, 480, 640, true)
setResult(Activity.RESULT_OK)
finish()
} catch (e: Exception) {
Log.e("Liveness", "Error finishing activity", e)
setResult(Activity.RESULT_CANCELED)
finish()
}
}
},
executor = cameraExecutor
)
}
}
}
}
override fun onDestroy() {
super.onDestroy()
cameraExecutor.shutdown()
}
}
@Composable
fun LivenessPermissionGate(content: @Composable () -> Unit) {
val context = LocalContext.current
var hasPermission by remember {
mutableStateOf(
ContextCompat.checkSelfPermission(
context,
Manifest.permission.CAMERA
) == PackageManager.PERMISSION_GRANTED
)
}
val launcher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission()
) { granted ->
hasPermission = granted
if (!granted) {
Toast.makeText(context, "Izin kamera diperlukan", Toast.LENGTH_LONG).show()
}
}
LaunchedEffect(Unit) {
if (!hasPermission) {
launcher.launch(Manifest.permission.CAMERA)
}
}
if (hasPermission) {
content()
} else {
Box(modifier = Modifier.fillMaxSize().background(Color.Black), contentAlignment = Alignment.Center) {
CircularProgressIndicator(color = Color.White)
}
}
}
@OptIn(ExperimentalGetImage::class)
@Composable
fun LivenessDetectionScreen(
onSuccess: (Bitmap) -> Unit,
executor: ExecutorService
) {
val lifecycleOwner = LocalLifecycleOwner.current
var currentStep by remember { mutableStateOf(LivenessStep.BLINK) }
var progress by remember { mutableStateOf(0f) }
var isProcessingSuccess by remember { mutableStateOf(false) }
var isCentered by remember { mutableStateOf(false) }
val options = FaceDetectorOptions.Builder()
.setPerformanceMode(FaceDetectorOptions.PERFORMANCE_MODE_FAST)
.setClassificationMode(FaceDetectorOptions.CLASSIFICATION_MODE_ALL)
.build()
val detector = remember { FaceDetection.getClient(options) }
Box(modifier = Modifier.fillMaxSize().background(Color.Black)) {
AndroidView(
factory = { ctx ->
val previewView = PreviewView(ctx)
val cameraProviderFuture = ProcessCameraProvider.getInstance(ctx)
cameraProviderFuture.addListener({
val cameraProvider = cameraProviderFuture.get()
val preview = Preview.Builder().build().also {
it.setSurfaceProvider(previewView.surfaceProvider)
}
val imageAnalysis = ImageAnalysis.Builder()
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
.setOutputImageFormat(ImageAnalysis.OUTPUT_IMAGE_FORMAT_YUV_420_888)
.build()
imageAnalysis.setAnalyzer(executor) { imageProxy ->
if (isProcessingSuccess) {
imageProxy.close()
return@setAnalyzer
}
val mediaImage = imageProxy.image
if (mediaImage != null) {
val image = InputImage.fromMediaImage(mediaImage, imageProxy.imageInfo.rotationDegrees)
detector.process(image)
.addOnSuccessListener { faces ->
if (faces.isNotEmpty()) {
val face = faces[0]
val box = face.boundingBox
val centerX = imageProxy.width / 2f
val centerY = imageProxy.height / 2f
val faceX = box.centerX().toFloat()
val faceY = box.centerY().toFloat()
val diffX = Math.abs(faceX - centerX)
val diffY = Math.abs(faceY - centerY)
val thresholdX = imageProxy.width * 0.15f
val thresholdY = imageProxy.height * 0.15f
isCentered = diffX < thresholdX && diffY < thresholdY
if (isCentered) {
val isFacingFront = Math.abs(face.headEulerAngleY) < 10f && Math.abs(face.headEulerAngleX) < 10f
when (currentStep) {
LivenessStep.BLINK -> {
val leftOpen = face.leftEyeOpenProbability ?: 1.0f
val rightOpen = face.rightEyeOpenProbability ?: 1.0f
if (leftOpen < 0.2f && rightOpen < 0.2f) {
progress = 0.33f
currentStep = LivenessStep.NOD
}
}
LivenessStep.NOD -> {
if (Math.abs(face.headEulerAngleX) > 15f) {
progress = 0.66f
currentStep = LivenessStep.SHAKE
}
}
LivenessStep.SHAKE -> {
if (progress < 0.9f && Math.abs(face.headEulerAngleY) > 20f) {
progress = 0.9f
}
}
else -> {}
}
if (progress >= 0.9f && isFacingFront && !isProcessingSuccess) {
isProcessingSuccess = true
progress = 1.0f
val bitmap = imageProxy.yuvToBitmap()
if (bitmap != null) {
onSuccess(bitmap)
} else {
isProcessingSuccess = false
}
}
}
} else {
isCentered = false
}
imageProxy.close()
}
.addOnFailureListener {
imageProxy.close()
}
} else {
imageProxy.close()
}
}
val cameraSelector = CameraSelector.DEFAULT_FRONT_CAMERA
try {
cameraProvider.unbindAll()
cameraProvider.bindToLifecycle(lifecycleOwner, cameraSelector, preview, imageAnalysis)
} catch (e: Exception) {
Log.e("Liveness", "Binding failed", e)
}
}, ContextCompat.getMainExecutor(ctx))
previewView
},
modifier = Modifier.fillMaxSize()
)
Column(
modifier = Modifier.fillMaxSize().padding(24.dp),
verticalArrangement = Arrangement.SpaceBetween,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(text = "Verifikasi Wajah", style = MaterialTheme.typography.headlineSmall, color = Color.White)
Box(modifier = Modifier.size(280.dp).background(Color.Transparent, CircleShape).padding(4.dp)) {
CircularProgressIndicator(
progress = { progress },
modifier = Modifier.fillMaxSize(),
color = if (isCentered) Color.Green else Color.Red,
strokeWidth = 8.dp
)
}
Card(
colors = CardDefaults.cardColors(
containerColor = if (isCentered) Color.White.copy(alpha = 0.8f) else MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.9f)
),
modifier = Modifier.fillMaxWidth()
) {
Column(modifier = Modifier.padding(16.dp), horizontalAlignment = Alignment.CenterHorizontally) {
val instructionText = when {
!isCentered -> "Posisikan Wajah di Tengah Lingkaran"
progress >= 0.9f -> "Posisikan Wajah Lurus ke Depan"
else -> currentStep.instruction
}
Text(
text = instructionText,
style = MaterialTheme.typography.titleLarge,
color = if (isCentered) Color.Black else MaterialTheme.colorScheme.onErrorContainer
)
Spacer(modifier = Modifier.height(8.dp))
LinearProgressIndicator(progress = { progress }, modifier = Modifier.fillMaxWidth())
}
}
}
}
}
fun ImageProxy.yuvToBitmap(): Bitmap? {
return try {
val nv21 = yuv420888ToNv21(this)
val yuvImage = YuvImage(nv21, ImageFormat.NV21, width, height, null)
val out = ByteArrayOutputStream()
yuvImage.compressToJpeg(Rect(0, 0, width, height), 90, out)
val imageBytes = out.toByteArray()
val bitmap = BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.size)
val matrix = Matrix()
matrix.postRotate(imageInfo.rotationDegrees.toFloat())
matrix.postScale(-1f, 1f) // Mirror
Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true)
} catch (e: Exception) {
Log.e("Liveness", "YUV conversion error", e)
null
}
}
private fun yuv420888ToNv21(image: ImageProxy): ByteArray {
val pixelCount = image.width * image.height
val pixelSizeBits = ImageFormat.getBitsPerPixel(ImageFormat.YUV_420_888)
val outputBuffer = ByteArray(pixelCount * pixelSizeBits / 8)
val imagePlanes = image.planes
imagePlanes[0].buffer.get(outputBuffer, 0, pixelCount)
val uBuffer = imagePlanes[1].buffer
val vBuffer = imagePlanes[2].buffer
val uRowStride = imagePlanes[1].rowStride
val vRowStride = imagePlanes[2].rowStride
val uPixelStride = imagePlanes[1].pixelStride
val vPixelStride = imagePlanes[2].pixelStride
var pos = pixelCount
for (row in 0 until image.height / 2) {
for (col in 0 until image.width / 2) {
outputBuffer[pos++] = vBuffer.get(row * vRowStride + col * vPixelStride)
outputBuffer[pos++] = uBuffer.get(row * uRowStride + col * uPixelStride)
}
}
return outputBuffer
}

View File

@ -1,94 +1,162 @@
package id.ac.ubharajaya.sistemakademik package id.ac.ubharajaya.sistemakademik
import android.Manifest import android.Manifest
import android.annotation.SuppressLint
import android.app.Activity 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.graphics.BitmapFactory
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
import android.util.Log
import android.widget.Toast import android.widget.Toast
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent 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.background
import androidx.compose.foundation.clickable
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.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.material.icons.filled.ExitToApp
import androidx.compose.material.icons.filled.Search
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.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.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.PopupProperties
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import com.google.android.gms.location.LocationServices import com.google.android.gms.location.LocationServices
import com.google.android.gms.location.Priority
import com.google.android.gms.tasks.CancellationTokenSource
import id.ac.ubharajaya.sistemakademik.data.AbsensiDatabase
import id.ac.ubharajaya.sistemakademik.data.AbsensiEntity
import id.ac.ubharajaya.sistemakademik.data.UserEntity
import id.ac.ubharajaya.sistemakademik.ui.LoginScreen
import id.ac.ubharajaya.sistemakademik.ui.theme.SistemAkademikTheme import id.ac.ubharajaya.sistemakademik.ui.theme.SistemAkademikTheme
import kotlinx.coroutines.launch
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
/* ================= DATA MODELS ================= */
data class AbsensiRecord(
val id: Int = 0,
val timestamp: String,
val lokasi: String,
val status: String,
val matkul: String,
val keterangan: String = "",
val foto: Bitmap? = null
)
/* ================= UTIL ================= */ /* ================= UTIL ================= */
fun bitmapToBase64(bitmap: Bitmap): String { fun bitmapToBase64(bitmap: Bitmap): String {
val outputStream = ByteArrayOutputStream() val outputStream = ByteArrayOutputStream()
bitmap.compress(Bitmap.CompressFormat.JPEG, 80, outputStream) bitmap.compress(Bitmap.CompressFormat.JPEG, 70, outputStream)
return Base64.encodeToString(outputStream.toByteArray(), Base64.NO_WRAP) return Base64.encodeToString(outputStream.toByteArray(), Base64.NO_WRAP)
} }
fun calculateDistance(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Float {
val results = FloatArray(1)
Location.distanceBetween(lat1, lon1, lat2, lon2, results)
return results[0]
}
fun isTimeInRange(matkul: String): Boolean {
val now = Calendar.getInstance()
val currentMinutes = now.get(Calendar.HOUR_OF_DAY) * 60 + now.get(Calendar.MINUTE)
return when {
matkul.contains("Pemrograman Perangkat Bergerak") -> currentMinutes in (13 * 60 + 30)..(16 * 60)
matkul.contains("Keamanan Siber") -> currentMinutes in (16 * 60 + 15)..(18 * 60 + 45)
else -> true
}
}
fun getMatkulJadwal(matkul: String): String {
return when {
matkul.contains("Pemrograman Perangkat Bergerak") -> "13:30 - 16:00"
matkul.contains("Keamanan Siber") -> "16:15 - 18:45"
else -> "-"
}
}
fun getMatkulRuangan(matkul: String): String {
return when {
matkul.contains("Pemrograman Perangkat Bergerak") -> "Grha Tanoto | W-104"
matkul.contains("Keamanan Siber") -> "R. Said Soekanto | SS-405"
else -> "-"
}
}
fun kirimKeN8n( fun kirimKeN8n(
context: ComponentActivity, context: ComponentActivity,
npm: String,
nama: String,
latitude: Double, latitude: Double,
longitude: Double, longitude: Double,
foto: Bitmap foto: Bitmap,
matkul: String,
statusAbsensi: String,
keterangan: String,
onResult: (Boolean, String) -> Unit
) { ) {
thread { thread {
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")
// test URL val url = URL("https://n8n.lab.ubharajaya.ac.id/webhook-test/23c6993d-1792-48fb-ad1c-ffc78a3e6254")
val conn = url.openConnection() as HttpURLConnection val conn = url.openConnection() as HttpURLConnection
val maskedLat = latitude + 0.000123
val maskedLon = longitude - 0.000123
val photoBase64 = bitmapToBase64(foto)
val json = JSONObject()
json.put("npm", npm)
json.put("nama", nama)
json.put("latitude", maskedLat)
json.put("longitude", maskedLon)
json.put("mata_kuliah", matkul)
json.put("status", statusAbsensi)
json.put("keterangan", keterangan)
json.put("foto_base64", photoBase64)
json.put("photo", photoBase64)
json.put("timestamp", System.currentTimeMillis())
val postData = json.toString().toByteArray(Charsets.UTF_8)
conn.requestMethod = "POST" conn.requestMethod = "POST"
conn.setRequestProperty("Content-Type", "application/json") conn.setRequestProperty("Content-Type", "application/json; charset=UTF-8")
conn.doOutput = true conn.doOutput = true
conn.setFixedLengthStreamingMode(postData.size)
val json = JSONObject().apply { conn.outputStream.use { it.write(postData) }
put("npm", "12345")
put("nama","Arif R D")
put("latitude", latitude)
put("longitude", longitude)
put("timestamp", System.currentTimeMillis())
put("foto_base64", bitmapToBase64(foto))
}
conn.outputStream.use {
it.write(json.toString().toByteArray())
}
val responseCode = conn.responseCode val responseCode = conn.responseCode
val isSuccess = responseCode == 200
context.runOnUiThread { context.runOnUiThread { onResult(isSuccess, if (isSuccess) "Berhasil Terkirim" else "Gagal ($responseCode)") }
Toast.makeText(
context,
if (responseCode == 200)
"Absensi diterima server"
else
"Absensi ditolak server",
Toast.LENGTH_SHORT
).show()
}
conn.disconnect() conn.disconnect()
} catch (e: Exception) {
} catch (_: Exception) { context.runOnUiThread { onResult(false, "Error: ${e.message}") }
context.runOnUiThread {
Toast.makeText(
context,
"Gagal kirim ke server",
Toast.LENGTH_SHORT
).show()
}
} }
} }
} }
@ -96,179 +164,310 @@ fun kirimKeN8n(
/* ================= ACTIVITY ================= */ /* ================= ACTIVITY ================= */
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
enableEdgeToEdge() enableEdgeToEdge()
setContent { setContent {
SistemAkademikTheme { SistemAkademikTheme {
val context = LocalContext.current
val db = remember { AbsensiDatabase.getDatabase(context) }
val dao = db.absensiDao()
val coroutineScope = rememberCoroutineScope()
var isLoggedIn by remember { mutableStateOf(false) }
var currentUser by remember { mutableStateOf<UserEntity?>(null) }
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
AbsensiScreen( if (!isLoggedIn) {
modifier = Modifier.padding(innerPadding), LoginScreen(onLoginSuccess = { npm, nama ->
activity = this coroutineScope.launch {
) val user = UserEntity(npm, nama, "password")
dao.registerUser(user)
currentUser = user
isLoggedIn = true
}
})
} else {
MainNavigation(
modifier = Modifier.padding(innerPadding),
activity = this,
user = currentUser!!,
onLogout = {
isLoggedIn = false
currentUser = null
}
)
}
} }
} }
} }
} }
} }
/* ================= UI ================= */ @Composable
fun MainNavigation(
modifier: Modifier = Modifier,
activity: ComponentActivity,
user: UserEntity,
onLogout: () -> Unit
) {
var selectedTab by remember { mutableIntStateOf(0) }
val context = LocalContext.current
val db = remember { AbsensiDatabase.getDatabase(context) }
val dao = db.absensiDao()
val historyEntities by dao.getAllHistoryByNpm(user.npm).collectAsState(initial = emptyList())
val coroutineScope = rememberCoroutineScope()
Column(modifier = modifier.fillMaxSize()) {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(text = "Halo, ${user.nama}", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
IconButton(onClick = onLogout) {
Icon(Icons.Default.ExitToApp, contentDescription = "Logout", tint = MaterialTheme.colorScheme.error)
}
}
TabRow(selectedTabIndex = selectedTab) {
Tab(selected = selectedTab == 0, onClick = { selectedTab = 0 }) {
Text(modifier = Modifier.padding(16.dp), text = "Absensi")
}
Tab(selected = selectedTab == 1, onClick = { selectedTab = 1 }) {
Text(modifier = Modifier.padding(16.dp), text = "Riwayat")
}
}
when (selectedTab) {
0 -> AbsensiScreen(
activity = activity,
npm = user.npm,
nama = user.nama,
onAbsensiSuccess = { record ->
coroutineScope.launch {
val outputStream = ByteArrayOutputStream()
record.foto?.compress(Bitmap.CompressFormat.JPEG, 50, outputStream)
dao.insert(AbsensiEntity(
npm = user.npm,
timestamp = record.timestamp,
lokasi = record.lokasi,
status = record.status,
matkul = record.matkul,
keterangan = record.keterangan,
fotoBlob = outputStream.toByteArray()
))
}
}
)
1 -> HistoryScreen(history = historyEntities)
}
}
}
/* ================= UI SCREENS ================= */
@OptIn(ExperimentalMaterial3Api::class)
@SuppressLint("MissingPermission")
@Composable @Composable
fun AbsensiScreen( fun AbsensiScreen(
modifier: Modifier = Modifier, activity: ComponentActivity,
activity: ComponentActivity npm: String,
nama: String,
onAbsensiSuccess: (AbsensiRecord) -> Unit
) { ) {
val context = LocalContext.current val context = LocalContext.current
var lokasi by remember { mutableStateOf("Mencari lokasi...") }
var lokasi by remember { mutableStateOf("Koordinat: -") }
var latitude by remember { mutableStateOf<Double?>(null) } var latitude by remember { mutableStateOf<Double?>(null) }
var longitude by remember { mutableStateOf<Double?>(null) } var longitude by remember { mutableStateOf<Double?>(null) }
var foto by remember { mutableStateOf<Bitmap?>(null) } var foto by remember { mutableStateOf<Bitmap?>(null) }
var keterangan by remember { mutableStateOf("") }
var isSending by remember { mutableStateOf(false) }
var distanceToCampus by remember { mutableFloatStateOf(-1f) }
val campusLat = -6.224228
val campusLon = 107.009291
val maxRadius = 50f
val listMatkul = listOf("Pemrograman Perangkat Bergerak", "Keamanan Siber")
val fusedLocationClient = var matkulExpanded by remember { mutableStateOf(false) }
LocationServices.getFusedLocationProviderClient(context) var selectedMatkul by remember { mutableStateOf(listMatkul[0]) }
var matkulSearchQuery by remember { mutableStateOf("") }
val filteredMatkul = listMatkul.filter { it.contains(matkulSearchQuery, ignoreCase = true) }
/* ===== Permission Lokasi ===== */ val listStatus = listOf("Hadir", "Ijin", "Sakit")
var statusExpanded by remember { mutableStateOf(false) }
var selectedStatus by remember { mutableStateOf(listStatus[0]) }
val locationPermissionLauncher = val fusedLocationClient = remember { LocationServices.getFusedLocationProviderClient(context) }
rememberLauncherForActivityResult( val requestLocation = {
ActivityResultContracts.RequestPermission() if (ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED) {
) { granted -> fusedLocationClient.getCurrentLocation(Priority.PRIORITY_HIGH_ACCURACY, CancellationTokenSource().token)
if (granted) { .addOnSuccessListener { location ->
if (location != null) {
if ( latitude = location.latitude
ContextCompat.checkSelfPermission( longitude = location.longitude
context, lokasi = "Lat: ${String.format("%.6f", location.latitude)}, Lon: ${String.format("%.6f", location.longitude)}"
Manifest.permission.ACCESS_FINE_LOCATION distanceToCampus = calculateDistance(location.latitude, location.longitude, campusLat, campusLon)
) == 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"
}
}
.addOnFailureListener {
lokasi = "Gagal mengambil lokasi"
}
} }
} 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?.getParcelable("data", Bitmap::class.java)
if (bitmap != null) {
foto = bitmap
Toast.makeText(
context,
"Foto berhasil diambil",
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()
}
}
/* ===== Request Awal ===== */
LaunchedEffect(Unit) {
locationPermissionLauncher.launch(
Manifest.permission.ACCESS_FINE_LOCATION
)
} }
/* ===== UI ===== */ val livenessLauncher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode == Activity.RESULT_OK) {
val bitmap = LivenessDetectionActivity.latestCapturedBitmap
if (bitmap != null) foto = bitmap
}
}
val normalCameraLauncher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode == Activity.RESULT_OK) {
val bitmap = result.data?.extras?.getParcelable("data", Bitmap::class.java)
if (bitmap != null) foto = bitmap
}
}
LaunchedEffect(Unit) { requestLocation() }
Column( Column(
modifier = modifier modifier = Modifier.fillMaxSize().padding(16.dp).verticalScroll(rememberScrollState()),
.fillMaxSize() horizontalAlignment = Alignment.CenterHorizontally
.padding(24.dp),
verticalArrangement = Arrangement.Center
) { ) {
Spacer(modifier = Modifier.height(8.dp))
Text( Card(modifier = Modifier.size(180.dp)) {
text = "Absensi Akademik", if (foto != null) {
style = MaterialTheme.typography.titleLarge Image(bitmap = foto!!.asImageBitmap(), contentDescription = null, modifier = Modifier.fillMaxSize(), contentScale = ContentScale.Crop)
) } else {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text("Belum Ambil Foto", style = MaterialTheme.typography.bodySmall)
}
}
}
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
Text(text = lokasi) // Mata Kuliah Dropdown - FIXED SENSITIVITY
Box(modifier = Modifier.fillMaxWidth()) {
OutlinedTextField(
value = "$selectedMatkul (${getMatkulJadwal(selectedMatkul)})",
onValueChange = {},
readOnly = true,
label = { Text("Mata Kuliah") },
trailingIcon = { Icon(Icons.Default.ArrowDropDown, null) },
modifier = Modifier.fillMaxWidth()
)
Box(modifier = Modifier.matchParentSize().clickable { matkulExpanded = true })
Spacer(modifier = Modifier.height(16.dp)) DropdownMenu(
expanded = matkulExpanded,
Button( onDismissRequest = { matkulExpanded = false },
onClick = { properties = PopupProperties(focusable = true),
cameraPermissionLauncher.launch( modifier = Modifier.fillMaxWidth(0.9f)
Manifest.permission.CAMERA ) {
OutlinedTextField(
value = matkulSearchQuery,
onValueChange = { matkulSearchQuery = it },
modifier = Modifier.fillMaxWidth().padding(8.dp),
placeholder = { Text("Cari mata kuliah...") },
leadingIcon = { Icon(Icons.Default.Search, null) },
singleLine = true,
colors = OutlinedTextFieldDefaults.colors(focusedContainerColor = MaterialTheme.colorScheme.surface, unfocusedContainerColor = MaterialTheme.colorScheme.surface)
) )
}, filteredMatkul.forEach { item ->
modifier = Modifier.fillMaxWidth() DropdownMenuItem(text = { Text("$item (${getMatkulJadwal(item)})") }, onClick = { selectedMatkul = item; matkulExpanded = false; matkulSearchQuery = "" })
) { }
Text("Ambil Foto") }
} }
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
// Status Dropdown
Box(modifier = Modifier.fillMaxWidth()) {
OutlinedTextField(value = selectedStatus, onValueChange = {}, readOnly = true, label = { Text("Status Kehadiran") }, trailingIcon = { Icon(Icons.Default.ArrowDropDown, null) }, modifier = Modifier.fillMaxWidth())
Box(modifier = Modifier.matchParentSize().clickable { statusExpanded = true })
DropdownMenu(expanded = statusExpanded, onDismissRequest = { statusExpanded = false }, modifier = Modifier.fillMaxWidth(0.9f)) {
listStatus.forEach { item -> DropdownMenuItem(text = { Text(item) }, onClick = { selectedStatus = item; statusExpanded = false; foto = null }) }
}
}
if (selectedStatus == "Ijin" || selectedStatus == "Sakit") {
Spacer(modifier = Modifier.height(12.dp))
OutlinedTextField(value = keterangan, onValueChange = { keterangan = it }, label = { Text("Keterangan (Opsional)") }, modifier = Modifier.fillMaxWidth(), singleLine = false, maxLines = 3)
}
Spacer(modifier = Modifier.height(16.dp))
val isTimeOk = selectedStatus != "Hadir" || isTimeInRange(selectedMatkul)
val isLocationOk = selectedStatus != "Hadir" || (distanceToCampus in 0f..maxRadius)
Text(text = lokasi, style = MaterialTheme.typography.bodySmall)
if (selectedStatus == "Hadir") {
if (distanceToCampus >= 0) {
Text(text = if (isLocationOk) "Berada di area kampus" else "Di luar area kampus (${distanceToCampus.toInt()}m)", color = if (isLocationOk) MaterialTheme.colorScheme.primary else Color.Red, style = MaterialTheme.typography.labelSmall)
}
if (!isTimeOk) {
Text(text = "belum masuk/sudah lewat jam absensi", color = Color.Red, style = MaterialTheme.typography.labelSmall)
}
}
Spacer(modifier = Modifier.height(16.dp))
Button(onClick = { if (selectedStatus == "Hadir") livenessLauncher.launch(Intent(context, LivenessDetectionActivity::class.java)) else normalCameraLauncher.launch(Intent(MediaStore.ACTION_IMAGE_CAPTURE)) }, modifier = Modifier.fillMaxWidth()) {
Text(if (selectedStatus == "Hadir") "Mulai Verifikasi Wajah" else "Ambil Foto Bukti")
}
Spacer(modifier = Modifier.height(12.dp))
Button( Button(
onClick = { onClick = {
if (latitude != null && longitude != null && foto != null) { if (latitude != null && longitude != null && foto != null) {
kirimKeN8n( isSending = true
activity, kirimKeN8n(activity, npm, nama, latitude!!, longitude!!, foto!!, selectedMatkul, selectedStatus, keterangan) { success, msg ->
latitude!!, isSending = false; Toast.makeText(context, msg, Toast.LENGTH_LONG).show()
longitude!!, if (success) onAbsensiSuccess(AbsensiRecord(timestamp = SimpleDateFormat("HH:mm:ss dd/MM/yyyy", Locale.getDefault()).format(Date()), lokasi = lokasi, status = selectedStatus, matkul = selectedMatkul, keterangan = keterangan, foto = foto))
foto!! }
) } else Toast.makeText(context, "Lengkapi Foto/Lokasi", Toast.LENGTH_SHORT).show()
} else {
Toast.makeText(
context,
"Lokasi atau foto belum lengkap",
Toast.LENGTH_SHORT
).show()
}
}, },
enabled = !isSending && isLocationOk && isTimeOk,
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) { ) {
Text("Kirim Absensi") if (isSending) CircularProgressIndicator(modifier = Modifier.size(24.dp)) else Text("Kirim Absensi")
}
}
}
@Composable
fun HistoryScreen(history: List<AbsensiEntity>) {
Column(modifier = Modifier.fillMaxSize()) {
if (history.isEmpty()) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text("Belum ada riwayat")
}
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
items(history) { record ->
HistoryRecordCard(record)
}
}
}
}
}
@Composable
fun HistoryRecordCard(record: AbsensiEntity) {
Card(modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(12.dp), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), border = androidx.compose.foundation.BorderStroke(0.5.dp, MaterialTheme.colorScheme.outlineVariant)) {
Row(modifier = Modifier.padding(10.dp), verticalAlignment = Alignment.CenterVertically) {
if (record.fotoBlob != null) {
val bitmap = BitmapFactory.decodeByteArray(record.fotoBlob, 0, record.fotoBlob.size)
if (bitmap != null) Image(bitmap = bitmap.asImageBitmap(), contentDescription = null, modifier = Modifier.size(60.dp).clip(RoundedCornerShape(8.dp)), contentScale = ContentScale.Crop)
} else Box(modifier = Modifier.size(60.dp).background(MaterialTheme.colorScheme.surfaceVariant, RoundedCornerShape(8.dp)), contentAlignment = Alignment.Center) { Text("N/A", style = MaterialTheme.typography.labelSmall) }
Spacer(modifier = Modifier.width(12.dp))
Column {
Text(record.timestamp, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.primary)
Text("Mata Kuliah: ${record.matkul}", style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Bold)
Text("Status: ${record.status}", style = MaterialTheme.typography.bodySmall)
if (record.keterangan.isNotEmpty()) {
Text("Keterangan: ${record.keterangan}", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
Text("Lokasi: ${record.lokasi}", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.outline)
}
} }
} }
} }

View File

@ -0,0 +1,82 @@
package id.ac.ubharajaya.sistemakademik.data
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import androidx.room.*
import kotlinx.coroutines.flow.Flow
import java.io.ByteArrayOutputStream
@Entity(tableName = "users")
data class UserEntity(
@PrimaryKey val npm: String,
val nama: String,
val password: String
)
@Entity(tableName = "absensi_history")
data class AbsensiEntity(
@PrimaryKey(autoGenerate = true) val id: Int = 0,
val npm: String,
val timestamp: String,
val lokasi: String,
val status: String,
val matkul: String,
val keterangan: String = "",
val fotoBlob: ByteArray?
)
class Converters {
@TypeConverter
fun fromBitmap(bitmap: Bitmap?): ByteArray? {
if (bitmap == null) return null
val outputStream = ByteArrayOutputStream()
bitmap.compress(Bitmap.CompressFormat.JPEG, 50, outputStream)
return outputStream.toByteArray()
}
@TypeConverter
fun toBitmap(byteArray: ByteArray?): Bitmap? {
return byteArray?.let { BitmapFactory.decodeByteArray(it, 0, it.size) }
}
}
@Dao
interface AbsensiDao {
@Query("SELECT * FROM absensi_history WHERE npm = :npm ORDER BY id DESC")
fun getAllHistoryByNpm(npm: String): Flow<List<AbsensiEntity>>
@Insert
suspend fun insert(absensi: AbsensiEntity)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun registerUser(user: UserEntity)
@Query("SELECT * FROM users WHERE npm = :npm LIMIT 1")
suspend fun getUser(npm: String): UserEntity?
}
@Database(entities = [AbsensiEntity::class, UserEntity::class], version = 3, exportSchema = false)
@TypeConverters(Converters::class)
abstract class AbsensiDatabase : RoomDatabase() {
abstract fun absensiDao(): AbsensiDao
companion object {
@Volatile
private var INSTANCE: AbsensiDatabase? = null
fun getDatabase(context: Context): AbsensiDatabase {
return INSTANCE ?: synchronized(this) {
val instance = Room.databaseBuilder(
context.applicationContext,
AbsensiDatabase::class.java,
"absensi_db"
)
.fallbackToDestructiveMigration()
.build()
INSTANCE = instance
instance
}
}
}
}

View File

@ -0,0 +1,119 @@
package id.ac.ubharajaya.sistemakademik.ui
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
@Composable
fun LoginScreen(
onLoginSuccess: (npm: String, nama: String) -> Unit
) {
var npm by remember { mutableStateOf("") }
var nama by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
var errorMessage by remember { mutableStateOf("") }
val focusManager = LocalFocusManager.current
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "Login Mahasiswa",
style = MaterialTheme.typography.headlineMedium
)
Spacer(modifier = Modifier.height(32.dp))
OutlinedTextField(
value = npm,
onValueChange = { npm = it },
label = { Text("NPM") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Number,
imeAction = ImeAction.Next
),
keyboardActions = KeyboardActions(
onNext = { focusManager.moveFocus(FocusDirection.Down) }
)
)
Spacer(modifier = Modifier.height(16.dp))
OutlinedTextField(
value = nama,
onValueChange = { nama = it },
label = { Text("Nama Lengkap") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Text,
imeAction = ImeAction.Next
),
keyboardActions = KeyboardActions(
onNext = { focusManager.moveFocus(FocusDirection.Down) }
)
)
Spacer(modifier = Modifier.height(16.dp))
OutlinedTextField(
value = password,
onValueChange = { password = it },
label = { Text("Password") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Done
),
keyboardActions = KeyboardActions(
onDone = {
focusManager.clearFocus()
if (npm.isNotEmpty() && nama.isNotEmpty() && password.isNotEmpty()) {
onLoginSuccess(npm, nama)
}
}
)
)
if (errorMessage.isNotEmpty()) {
Text(
text = errorMessage,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(top = 8.dp)
)
}
Spacer(modifier = Modifier.height(24.dp))
Button(
onClick = {
if (npm.isNotEmpty() && nama.isNotEmpty() && password.isNotEmpty()) {
onLoginSuccess(npm, nama)
} else {
errorMessage = "NPM, Nama, dan Password tidak boleh kosong"
}
},
modifier = Modifier.fillMaxWidth()
) {
Text("Login")
}
}
}

View File

@ -0,0 +1,120 @@
package id.ac.ubharajaya.sistemakademik.ui
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
@Composable
fun RegisterScreen(
onRegisterSuccess: () -> Unit,
onBackToLogin: () -> Unit
) {
var npm by remember { mutableStateOf("") }
var nama by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
var errorMessage by remember { mutableStateOf("") }
val focusManager = LocalFocusManager.current
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "Daftar Mahasiswa",
style = MaterialTheme.typography.headlineMedium
)
Spacer(modifier = Modifier.height(32.dp))
OutlinedTextField(
value = npm,
onValueChange = { npm = it },
label = { Text("NPM") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Number,
imeAction = ImeAction.Next
),
keyboardActions = KeyboardActions(
onNext = { focusManager.moveFocus(FocusDirection.Down) }
)
)
Spacer(modifier = Modifier.height(16.dp))
OutlinedTextField(
value = nama,
onValueChange = { nama = it },
label = { Text("Nama Lengkap") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Text,
imeAction = ImeAction.Next
),
keyboardActions = KeyboardActions(
onNext = { focusManager.moveFocus(FocusDirection.Down) }
)
)
Spacer(modifier = Modifier.height(16.dp))
OutlinedTextField(
value = password,
onValueChange = { password = it },
label = { Text("Password") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Done
),
keyboardActions = KeyboardActions(
onDone = { focusManager.clearFocus() }
)
)
if (errorMessage.isNotEmpty()) {
Text(
text = errorMessage,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(top = 8.dp)
)
}
Spacer(modifier = Modifier.height(24.dp))
Button(
onClick = {
if (npm.isNotEmpty() && nama.isNotEmpty() && password.isNotEmpty()) {
// Simulasi pendaftaran berhasil
onRegisterSuccess()
} else {
errorMessage = "Semua field harus diisi"
}
},
modifier = Modifier.fillMaxWidth()
) {
Text("Daftar")
}
TextButton(onClick = onBackToLogin) {
Text("Sudah punya akun? Login di sini")
}
}
}

View File

@ -8,6 +8,9 @@ espressoCore = "3.7.0"
lifecycleRuntimeKtx = "2.9.4" lifecycleRuntimeKtx = "2.9.4"
activityCompose = "1.11.0" activityCompose = "1.11.0"
composeBom = "2024.09.00" composeBom = "2024.09.00"
camerax = "1.4.1"
mlkitFace = "16.1.7"
room = "2.6.1"
[libraries] [libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@ -25,8 +28,22 @@ androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-
androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" } androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
# CameraX
androidx-camera-core = { group = "androidx.camera", name = "camera-core", version.ref = "camerax" }
androidx-camera-camera2 = { group = "androidx.camera", name = "camera-camera2", version.ref = "camerax" }
androidx-camera-lifecycle = { group = "androidx.camera", name = "camera-lifecycle", version.ref = "camerax" }
androidx-camera-view = { group = "androidx.camera", name = "camera-view", version.ref = "camerax" }
androidx-camera-extensions = { group = "androidx.camera", name = "camera-extensions", version.ref = "camerax" }
# ML Kit
mlkit-face-detection = { group = "com.google.mlkit", name = "face-detection", version.ref = "mlkitFace" }
# Room
androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
[plugins] [plugins]
android-application = { id = "com.android.application", version.ref = "agp" } android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }