Compare commits

..

10 Commits

Author SHA1 Message Date
a0f0c4c995 Pembaruan Aplikasi Absensi Wajah 2026-01-14 23:20:43 +07:00
ed435ffbc1 update readme 2026-01-13 15:51:52 +07:00
926d3e0a14 add n8n workflow script 2026-01-13 14:37:01 +07:00
cddaf87d88 update readme 2026-01-13 13:59:42 +07:00
c9cc99baa2 update readme 2026-01-13 09:50:58 +07:00
2a00b834c7 real time location 2026-01-13 09:37:52 +07:00
4d7fc844e2 real time location 2026-01-13 09:37:39 +07:00
d4d1b27209 real time location 2026-01-13 09:34:37 +07:00
3e66ebcf9e mockup 2026-01-12 22:07:16 +07:00
46b74d7099 mockup 2026-01-12 15:32:34 +07:00
16 changed files with 1469 additions and 90 deletions

View File

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

1
.idea/gradle.xml generated
View File

@ -1,5 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GradleMigrationSettings" migrationVersion="1" />
<component name="GradleSettings">
<option name="linkedExternalProjectsSettings">
<GradleProjectSettings>

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>

1
.idea/misc.xml generated
View File

@ -1,4 +1,3 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">

6
.idea/studiobot.xml generated Normal file
View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="StudioBotProjectSettings">
<option name="shareContext" value="OptedIn" />
</component>
</project>

BIN
Mockup.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 715 KiB

121
README.md
View File

@ -1,82 +1,75 @@
# 📱 Aplikasi Absensi Akademik Berbasis Koordinat dan Foto (Mobile)
# 🎓 Sistem Akademik - Aplikasi Absensi Mahasiswa Pintar
## 📌 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:
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
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.
---
## 🚀 Fitur Utama
- 🔐 **Login Pengguna (Mahasiswa)**
- 📍 **Pengambilan Koordinat Lokasi (Latitude & Longitude)**
- 🏫 **Validasi Lokasi Absensi (Radius Area)**
- 📸 **Pengambilan Foto Mahasiswa Saat Absensi**
- 🕒 **Pencatatan Waktu Absensi**
- 📄 **Riwayat Kehadiran Mahasiswa**
- ⚠️ **Notifikasi Absensi Ditolak jika Tidak Valid**
### 🔐 Manajemen Akun & Keamanan
- **Login Mahasiswa**: Autentikasi menggunakan NPM dan Nama Lengkap secara manual.
- **Logout Akun**: Fitur keluar untuk membersihkan sesi dan kembali ke halaman login.
- **Database Akun Lokal**: Aktivitas dan riwayat tersimpan secara privat sesuai NPM yang sedang login menggunakan Room Database.
### 📍 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
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**
## 🛠️ Detail Teknis
---
### 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
- Foto diambil menggunakan **kamera depan (selfie)**
- Foto hanya dapat diambil **saat proses absensi**
- Foto disimpan sebagai **bukti kehadiran**
- Foto dapat digunakan untuk:
- 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
### Teknologi Utama
- **UI**: Jetpack Compose (Material 3) dengan fitur **Searchbar** pada dropdown mata kuliah.
- **Database**: Room Persistence Library (SQLite) untuk isolasi data per akun.
- **Vision**: Google ML Kit Face Detection.
- **Location**: Fused Location Provider API (GPS Presisi).
---
## 🔐 Izin Aplikasi (Permissions)
Aplikasi memerlukan izin berikut:
- `ACCESS_FINE_LOCATION`
- `ACCESS_COARSE_LOCATION`
- `CAMERA`
- `INTERNET`
- `WRITE_EXTERNAL_STORAGE` (jika diperlukan)
Aplikasi meminta izin secara otomatis saat pertama kali dijalankan:
- `ACCESS_FINE_LOCATION` (Lokasi akurat)
- `CAMERA` (Verifikasi wajah & foto bukti)
- `INTERNET` (Sinkronisasi ke sistem n8n)
---
## 📂 Struktur Proyek (Contoh)
## 📂 Link Terkait
- **Webhook Production**: `https://n8n.lab.ubharajaya.ac.id/webhook/23c6993d-1792-48fb-ad1c-ffc78a3e6254`
- **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)
---
*Dibuat untuk Project Akhir Mata Kuliah Pemrograman Mobile 2025/2026.*

View File

@ -2,13 +2,12 @@ plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
id("com.google.devtools.ksp") version "2.0.21-1.0.27"
}
android {
namespace = "id.ac.ubharajaya.sistemakademik"
compileSdk {
version = release(36)
}
compileSdk = 36
defaultConfig {
applicationId = "id.ac.ubharajaya.sistemakademik"
@ -50,6 +49,25 @@ dependencies {
implementation(libs.androidx.compose.ui.graphics)
implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.compose.material3)
// Location (GPS)
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)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)

View File

@ -2,6 +2,15 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
<uses-permission android:name="android.permission.CAMERA"/>
<uses-feature
android:name="android.hardware.camera"
android:required="false" />
<application
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
@ -11,6 +20,7 @@
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.SistemAkademik">
<activity
android:name=".MainActivity"
android:exported="true"
@ -18,10 +28,15 @@
android:theme="@style/Theme.SistemAkademik">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity
android:name=".LivenessDetectionActivity"
android:exported="false"
android:theme="@style/Theme.SistemAkademik" />
</application>
</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,17 +1,167 @@
package id.ac.ubharajaya.sistemakademik
import android.Manifest
import android.annotation.SuppressLint
import android.app.Activity
import android.content.Intent
import android.content.pm.PackageManager
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.location.Location
import android.os.Bundle
import android.provider.MediaStore
import android.util.Base64
import android.util.Log
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
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.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.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
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.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.PopupProperties
import androidx.core.content.ContextCompat
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 kotlinx.coroutines.launch
import org.json.JSONObject
import java.io.ByteArrayOutputStream
import java.net.HttpURLConnection
import java.net.URL
import java.text.SimpleDateFormat
import java.util.*
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 ================= */
fun bitmapToBase64(bitmap: Bitmap): String {
val outputStream = ByteArrayOutputStream()
bitmap.compress(Bitmap.CompressFormat.JPEG, 70, outputStream)
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(
context: ComponentActivity,
npm: String,
nama: String,
latitude: Double,
longitude: Double,
foto: Bitmap,
matkul: String,
statusAbsensi: String,
keterangan: String,
onResult: (Boolean, String) -> Unit
) {
thread {
try {
val url = URL("https://n8n.lab.ubharajaya.ac.id/webhook/23c6993d-1792-48fb-ad1c-ffc78a3e6254")
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.setRequestProperty("Content-Type", "application/json; charset=UTF-8")
conn.doOutput = true
conn.setFixedLengthStreamingMode(postData.size)
conn.outputStream.use { it.write(postData) }
val responseCode = conn.responseCode
val isSuccess = responseCode == 200
context.runOnUiThread { onResult(isSuccess, if (isSuccess) "Berhasil Terkirim" else "Gagal ($responseCode)") }
conn.disconnect()
} catch (e: Exception) {
context.runOnUiThread { onResult(false, "Error: ${e.message}") }
}
}
}
/* ================= ACTIVITY ================= */
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
@ -19,11 +169,35 @@ class MainActivity : ComponentActivity() {
enableEdgeToEdge()
setContent {
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 ->
Greeting(
name = "Android",
modifier = Modifier.padding(innerPadding)
)
if (!isLoggedIn) {
LoginScreen(onLoginSuccess = { npm, nama ->
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
}
)
}
}
}
}
@ -31,17 +205,269 @@ class MainActivity : ComponentActivity() {
}
@Composable
fun Greeting(name: String, modifier: Modifier = Modifier) {
Text(
text = "Hello $name!",
modifier = modifier
)
}
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()
@Preview(showBackground = true)
@Composable
fun GreetingPreview() {
SistemAkademikTheme {
Greeting("Android")
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
fun AbsensiScreen(
activity: ComponentActivity,
npm: String,
nama: String,
onAbsensiSuccess: (AbsensiRecord) -> Unit
) {
val context = LocalContext.current
var lokasi by remember { mutableStateOf("Mencari lokasi...") }
var latitude by remember { mutableStateOf<Double?>(null) }
var longitude by remember { mutableStateOf<Double?>(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")
var matkulExpanded by remember { mutableStateOf(false) }
var selectedMatkul by remember { mutableStateOf(listMatkul[0]) }
var matkulSearchQuery by remember { mutableStateOf("") }
val filteredMatkul = listMatkul.filter { it.contains(matkulSearchQuery, ignoreCase = true) }
val listStatus = listOf("Hadir", "Ijin", "Sakit")
var statusExpanded by remember { mutableStateOf(false) }
var selectedStatus by remember { mutableStateOf(listStatus[0]) }
val fusedLocationClient = remember { LocationServices.getFusedLocationProviderClient(context) }
val requestLocation = {
if (ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED) {
fusedLocationClient.getCurrentLocation(Priority.PRIORITY_HIGH_ACCURACY, CancellationTokenSource().token)
.addOnSuccessListener { location ->
if (location != null) {
latitude = location.latitude
longitude = location.longitude
lokasi = "Lat: ${String.format("%.6f", location.latitude)}, Lon: ${String.format("%.6f", location.longitude)}"
distanceToCampus = calculateDistance(location.latitude, location.longitude, campusLat, campusLon)
}
}
}
}
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(
modifier = Modifier.fillMaxSize().padding(16.dp).verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.height(8.dp))
Card(modifier = Modifier.size(180.dp)) {
if (foto != null) {
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))
// 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 })
DropdownMenu(
expanded = matkulExpanded,
onDismissRequest = { matkulExpanded = false },
properties = PopupProperties(focusable = true),
modifier = Modifier.fillMaxWidth(0.9f)
) {
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 ->
DropdownMenuItem(text = { Text("$item (${getMatkulJadwal(item)})") }, onClick = { selectedMatkul = item; matkulExpanded = false; matkulSearchQuery = "" })
}
}
}
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(
onClick = {
if (latitude != null && longitude != null && foto != null) {
isSending = true
kirimKeN8n(activity, npm, nama, latitude!!, longitude!!, foto!!, selectedMatkul, selectedStatus, keterangan) { success, msg ->
isSending = false; Toast.makeText(context, msg, Toast.LENGTH_LONG).show()
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))
}
} else Toast.makeText(context, "Lengkapi Foto/Lokasi", Toast.LENGTH_SHORT).show()
},
enabled = !isSending && isLocationOk && isTimeOk,
modifier = Modifier.fillMaxWidth()
) {
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"
activityCompose = "1.11.0"
composeBom = "2024.09.00"
camerax = "1.4.1"
mlkitFace = "16.1.7"
room = "2.6.1"
[libraries]
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-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]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }

225
n8n-workflow-EAS.json Normal file
View File

@ -0,0 +1,225 @@
{
"name": "EAS",
"nodes": [
{
"parameters": {
"method": "POST",
"url": "https://ntfy.ubharajaya.ac.id/EAS",
"sendBody": true,
"contentType": "raw",
"body": "=Absensi: {{ $json.body.nama }} NPM: {{ $json.body.npm }}",
"options": {}
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.3,
"position": [
-272,
-240
],
"id": "83504eec-6d20-46d7-9ea1-509ae4ee8660",
"name": "NTFY HTTP Request"
},
{
"parameters": {
"httpMethod": "POST",
"path": "23c6993d-1792-48fb-ad1c-ffc78a3e6254",
"options": {}
},
"type": "n8n-nodes-base.webhook",
"typeVersion": 2.1,
"position": [
-864,
-112
],
"id": "9ed3d2db-2d50-40b5-8408-7404edd48442",
"name": "Webhook Absensi",
"webhookId": "23c6993d-1792-48fb-ad1c-ffc78a3e6254"
},
{
"parameters": {
"operation": "append",
"documentId": {
"__rl": true,
"value": "1jH15MfnNgpPGuGeid0hYfY7fFUHCEFbCmg8afTyyLZs",
"mode": "id"
},
"sheetName": {
"__rl": true,
"value": "Absensi",
"mode": "name"
},
"columns": {
"mappingMode": "defineBelow",
"value": {
"latitude": "={{ $json.body.latitude }}",
"longitude": "={{ $json.body.longitude }}",
"timestamp": "={{ $json.body.timestamp }}",
"foto_base64": "={{ $json.body.foto_base64 }}",
"nama": "={{ $json.body.nama }}",
"npm": "={{ $json.body.npm }}"
},
"matchingColumns": [],
"schema": [
{
"id": "timestamp",
"displayName": "timestamp",
"required": false,
"defaultMatch": false,
"display": true,
"type": "string",
"canBeUsedToMatch": true,
"removed": false
},
{
"id": "npm",
"displayName": "npm",
"required": false,
"defaultMatch": false,
"display": true,
"type": "string",
"canBeUsedToMatch": true,
"removed": false
},
{
"id": "nama",
"displayName": "nama",
"required": false,
"defaultMatch": false,
"display": true,
"type": "string",
"canBeUsedToMatch": true,
"removed": false
},
{
"id": "latitude",
"displayName": "latitude",
"required": false,
"defaultMatch": false,
"display": true,
"type": "string",
"canBeUsedToMatch": true,
"removed": false
},
{
"id": "longitude",
"displayName": "longitude",
"required": false,
"defaultMatch": false,
"display": true,
"type": "string",
"canBeUsedToMatch": true,
"removed": false
},
{
"id": "photo",
"displayName": "photo",
"required": false,
"defaultMatch": false,
"display": true,
"type": "string",
"canBeUsedToMatch": true,
"removed": false
},
{
"id": "status",
"displayName": "status",
"required": false,
"defaultMatch": false,
"display": true,
"type": "string",
"canBeUsedToMatch": true,
"removed": false
},
{
"id": "foto_base64",
"displayName": "foto_base64",
"required": false,
"defaultMatch": false,
"display": true,
"type": "string",
"canBeUsedToMatch": true,
"removed": false
}
],
"attemptToConvertTypes": false,
"convertFieldsToString": false
},
"options": {}
},
"type": "n8n-nodes-base.googleSheets",
"typeVersion": 4.7,
"position": [
-272,
-32
],
"id": "cd83a9fa-ea00-4a20-aa31-846bfe044aeb",
"name": "Append row in sheet",
"credentials": {
"googleSheetsOAuth2Api": {
"id": "hNVNhkTQbqkJ3C56",
"name": "Google Sheets account"
}
}
},
{
"parameters": {
"jsCode": "// Loop over input items and add a new field called 'myNewField' to the JSON of each one\nfor (const item of $input.all()) {\n item.json.myNewField = 1;\n}\n\nreturn $input.all();"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
-528,
-240
],
"id": "4ed9edf6-4562-41b6-afd0-89c96991454a",
"name": "Code in JavaScript"
}
],
"pinData": {},
"connections": {
"Webhook Absensi": {
"main": [
[
{
"node": "Append row in sheet",
"type": "main",
"index": 0
},
{
"node": "Code in JavaScript",
"type": "main",
"index": 0
}
]
]
},
"NTFY HTTP Request": {
"main": [
[]
]
},
"Code in JavaScript": {
"main": [
[
{
"node": "NTFY HTTP Request",
"type": "main",
"index": 0
}
]
]
}
},
"active": true,
"settings": {
"executionOrder": "v1",
"availableInMCP": false
},
"versionId": "49466b31-67ce-49b7-af37-33cd28d7092d",
"meta": {
"templateCredsSetupCompleted": true,
"instanceId": "b8ffac81bb85d267c3296e074b3e692ecef11caeef79fa72af892085548f350a"
},
"id": "E_gxZpNrN3G5ibejHcTFS",
"tags": []
}