Compare commits
10 Commits
cbe7e50b96
...
a0f0c4c995
| Author | SHA1 | Date | |
|---|---|---|---|
| a0f0c4c995 | |||
| ed435ffbc1 | |||
| 926d3e0a14 | |||
| cddaf87d88 | |||
| c9cc99baa2 | |||
| 2a00b834c7 | |||
| 4d7fc844e2 | |||
| d4d1b27209 | |||
| 3e66ebcf9e | |||
| 46b74d7099 |
8
.idea/deploymentTargetSelector.xml
generated
8
.idea/deploymentTargetSelector.xml
generated
@ -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
1
.idea/gradle.xml
generated
@ -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
8
.idea/markdown.xml
generated
Normal 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
1
.idea/misc.xml
generated
@ -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
6
.idea/studiobot.xml
generated
Normal 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
BIN
Mockup.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 715 KiB |
121
README.md
121
README.md
@ -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.*
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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>
|
||||
@ -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
|
||||
}
|
||||
@ -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,29 +169,305 @@ 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
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
225
n8n-workflow-EAS.json
Normal 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": []
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user