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>
|
<selectionStates>
|
||||||
<SelectionState runConfigName="app">
|
<SelectionState runConfigName="app">
|
||||||
<option name="selectionMode" value="DROPDOWN" />
|
<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>
|
</SelectionState>
|
||||||
</selectionStates>
|
</selectionStates>
|
||||||
</component>
|
</component>
|
||||||
|
|||||||
1
.idea/gradle.xml
generated
1
.idea/gradle.xml
generated
@ -1,5 +1,6 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<project version="4">
|
<project version="4">
|
||||||
|
<component name="GradleMigrationSettings" migrationVersion="1" />
|
||||||
<component name="GradleSettings">
|
<component name="GradleSettings">
|
||||||
<option name="linkedExternalProjectsSettings">
|
<option name="linkedExternalProjectsSettings">
|
||||||
<GradleProjectSettings>
|
<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">
|
<project version="4">
|
||||||
<component name="ExternalStorageConfigurationManager" enabled="true" />
|
<component name="ExternalStorageConfigurationManager" enabled="true" />
|
||||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
|
<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
|
## 📌 Deskripsi Proyek
|
||||||
Proyek ini merupakan **Tugas Project Akhir Mata Kuliah Pemrograman Mobile** yang bertujuan untuk membangun **aplikasi akademik berbasis mobile** dengan fokus pada **fitur absensi menggunakan data koordinat (GPS) dan pengambilan foto mahasiswa**.
|
Aplikasi **Sistem Akademik** adalah solusi absensi mobile modern yang dirancang untuk Mata Kuliah Pemrograman Mobile. Aplikasi ini mengedepankan keamanan dan validitas data dengan menggabungkan teknologi **Geofencing**, **Liveness Detection**, dan **Pencatatan Terintegrasi** untuk mencegah kecurangan absensi.
|
||||||
|
|
||||||
Aplikasi ini dirancang untuk meningkatkan **validitas kehadiran mahasiswa**, dengan memastikan bahwa absensi hanya dapat dilakukan apabila mahasiswa:
|
|
||||||
1. Berada pada **lokasi yang telah ditentukan**, dan
|
|
||||||
2. Melakukan **pengambilan foto (selfie) secara langsung saat absensi**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎯 Tujuan Proyek
|
|
||||||
- Mengimplementasikan **Location-Based Service (LBS)** pada aplikasi mobile
|
|
||||||
- Mengintegrasikan **kamera perangkat** untuk dokumentasi absensi
|
|
||||||
- Mencegah kecurangan absensi (titip absen)
|
|
||||||
- Mengembangkan aplikasi mobile akademik berbasis Android
|
|
||||||
- Melatih kemampuan perancangan dan implementasi aplikasi mobile
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🚀 Fitur Utama
|
## 🚀 Fitur Utama
|
||||||
- 🔐 **Login Pengguna (Mahasiswa)**
|
|
||||||
- 📍 **Pengambilan Koordinat Lokasi (Latitude & Longitude)**
|
### 🔐 Manajemen Akun & Keamanan
|
||||||
- 🏫 **Validasi Lokasi Absensi (Radius Area)**
|
- **Login Mahasiswa**: Autentikasi menggunakan NPM dan Nama Lengkap secara manual.
|
||||||
- 📸 **Pengambilan Foto Mahasiswa Saat Absensi**
|
- **Logout Akun**: Fitur keluar untuk membersihkan sesi dan kembali ke halaman login.
|
||||||
- 🕒 **Pencatatan Waktu Absensi**
|
- **Database Akun Lokal**: Aktivitas dan riwayat tersimpan secara privat sesuai NPM yang sedang login menggunakan Room Database.
|
||||||
- 📄 **Riwayat Kehadiran Mahasiswa**
|
|
||||||
- ⚠️ **Notifikasi Absensi Ditolak jika Tidak Valid**
|
### 📍 Validasi Lokasi (Geofencing)
|
||||||
|
- **Radius Kampus**: Absensi status "Hadir" hanya dapat dilakukan jika mahasiswa berada dalam radius **50 meter** dari pusat kampus (UBHARA Jaya Bekasi).
|
||||||
|
- **Masking Koordinat**: Koordinat yang dikirim ke server diberikan sedikit *offset* otomatis untuk menjaga privasi lokasi mahasiswa.
|
||||||
|
|
||||||
|
### 📸 Verifikasi Wajah (Liveness Detection)
|
||||||
|
- **Deteksi Kedip**: Syarat wajib berkedip untuk memverifikasi mahasiswa adalah orang asli (bukan foto/gambar).
|
||||||
|
- **Face Centering & Alignment**: Validasi posisi wajah wajib berada di **tengah layar** dan menghadap **lurus ke depan** sebelum sistem mengambil gambar.
|
||||||
|
- **Brightness Override**: Layar otomatis mencerahkan cahaya hingga 100% saat verifikasi untuk hasil foto wajah yang terang dan jelas.
|
||||||
|
- **Kondisional Kamera**:
|
||||||
|
- Status **Hadir**: Menggunakan sensor Liveness (ML Kit).
|
||||||
|
- Status **Ijin/Sakit**: Menggunakan kamera standar untuk dokumentasi bukti biasa.
|
||||||
|
|
||||||
|
### 🕒 Validasi Waktu & Jadwal
|
||||||
|
- **Sistem Jadwal Ketat**: Tombol kirim hanya aktif sesuai jam perkuliahan:
|
||||||
|
- **Pemrograman Perangkat Bergerak**: 13:30 - 16:00.
|
||||||
|
- **Keamanan Siber**: 16:15 - 18:45.
|
||||||
|
- **Input Keterangan (Opsional)**: Tersedia kolom input khusus untuk memberikan alasan tambahan jika mahasiswa memilih status **Ijin** atau **Sakit**.
|
||||||
|
|
||||||
|
### 📄 Riwayat Absensi
|
||||||
|
- **Penyimpanan Permanen**: Riwayat tersimpan di database lokal sehingga tidak hilang saat aplikasi ditutup atau perangkat di-restart.
|
||||||
|
- **Format Detail Per Baris**: Menampilkan informasi secara terstruktur:
|
||||||
|
1. Foto/Wajah yang diambil.
|
||||||
|
2. Waktu pengambilan absensi.
|
||||||
|
3. Nama Mata Kuliah & Ruangan.
|
||||||
|
4. Status Kehadiran.
|
||||||
|
5. **Teks Keterangan** (Muncul khusus jika Ijin/Sakit diisi).
|
||||||
|
6. Lokasi (Koordinat GPS).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🗺️ Mekanisme Absensi Berbasis Lokasi dan Foto
|
## 🛠️ Detail Teknis
|
||||||
1. Mahasiswa melakukan **login**
|
|
||||||
2. Memilih menu **Absensi**
|
|
||||||
3. Sistem meminta:
|
|
||||||
- Izin **akses lokasi**
|
|
||||||
- Izin **akses kamera**
|
|
||||||
4. Aplikasi mengambil:
|
|
||||||
- 📍 **Koordinat lokasi mahasiswa**
|
|
||||||
- 📸 **Foto mahasiswa secara real-time**
|
|
||||||
5. Sistem melakukan validasi:
|
|
||||||
- Lokasi berada dalam **radius absensi**
|
|
||||||
- Foto berhasil diambil
|
|
||||||
6. Jika valid → **Absensi berhasil**
|
|
||||||
7. Jika tidak valid → **Absensi ditolak**
|
|
||||||
|
|
||||||
---
|
### Daftar Mata Kuliah & Ruangan
|
||||||
|
1. **Pemrograman Perangkat Bergerak**
|
||||||
|
- Jadwal: 13:30 - 16:00 | Ruangan: Grha Tanoto [W-104]
|
||||||
|
2. **Keamanan Siber**
|
||||||
|
- Jadwal: 16:15 - 18:45 | Ruangan: R. Said Soekanto [SS-405]
|
||||||
|
|
||||||
## 📸 Pengambilan Foto Saat Absensi
|
### Teknologi Utama
|
||||||
- Foto diambil menggunakan **kamera depan (selfie)**
|
- **UI**: Jetpack Compose (Material 3) dengan fitur **Searchbar** pada dropdown mata kuliah.
|
||||||
- Foto hanya dapat diambil **saat proses absensi**
|
- **Database**: Room Persistence Library (SQLite) untuk isolasi data per akun.
|
||||||
- Foto disimpan sebagai **bukti kehadiran**
|
- **Vision**: Google ML Kit Face Detection.
|
||||||
- Foto dapat digunakan untuk:
|
- **Location**: Fused Location Provider API (GPS Presisi).
|
||||||
- Verifikasi manual oleh dosen
|
|
||||||
- Dokumentasi akademik
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🛠️ Teknologi yang Digunakan
|
|
||||||
- **Platform** : Android
|
|
||||||
- **Bahasa Pemrograman** : Kotlin / Java
|
|
||||||
- **Location Service** :
|
|
||||||
- Google Maps API
|
|
||||||
- Fused Location Provider
|
|
||||||
- **Camera API** : CameraX / Camera2
|
|
||||||
- **Database** : Firebase / SQLite / MySQL
|
|
||||||
- **Storage** : Firebase Storage / Local Storage
|
|
||||||
- **IDE** : Android Studio
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🔐 Izin Aplikasi (Permissions)
|
## 🔐 Izin Aplikasi (Permissions)
|
||||||
Aplikasi memerlukan izin berikut:
|
Aplikasi meminta izin secara otomatis saat pertama kali dijalankan:
|
||||||
- `ACCESS_FINE_LOCATION`
|
- `ACCESS_FINE_LOCATION` (Lokasi akurat)
|
||||||
- `ACCESS_COARSE_LOCATION`
|
- `CAMERA` (Verifikasi wajah & foto bukti)
|
||||||
- `CAMERA`
|
- `INTERNET` (Sinkronisasi ke sistem n8n)
|
||||||
- `INTERNET`
|
|
||||||
- `WRITE_EXTERNAL_STORAGE` (jika diperlukan)
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📂 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.android.application)
|
||||||
alias(libs.plugins.kotlin.android)
|
alias(libs.plugins.kotlin.android)
|
||||||
alias(libs.plugins.kotlin.compose)
|
alias(libs.plugins.kotlin.compose)
|
||||||
|
id("com.google.devtools.ksp") version "2.0.21-1.0.27"
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "id.ac.ubharajaya.sistemakademik"
|
namespace = "id.ac.ubharajaya.sistemakademik"
|
||||||
compileSdk {
|
compileSdk = 36
|
||||||
version = release(36)
|
|
||||||
}
|
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "id.ac.ubharajaya.sistemakademik"
|
applicationId = "id.ac.ubharajaya.sistemakademik"
|
||||||
@ -50,6 +49,25 @@ dependencies {
|
|||||||
implementation(libs.androidx.compose.ui.graphics)
|
implementation(libs.androidx.compose.ui.graphics)
|
||||||
implementation(libs.androidx.compose.ui.tooling.preview)
|
implementation(libs.androidx.compose.ui.tooling.preview)
|
||||||
implementation(libs.androidx.compose.material3)
|
implementation(libs.androidx.compose.material3)
|
||||||
|
|
||||||
|
// Location (GPS)
|
||||||
|
implementation("com.google.android.gms:play-services-location:21.0.1")
|
||||||
|
|
||||||
|
// CameraX
|
||||||
|
implementation(libs.androidx.camera.core)
|
||||||
|
implementation(libs.androidx.camera.camera2)
|
||||||
|
implementation(libs.androidx.camera.lifecycle)
|
||||||
|
implementation(libs.androidx.camera.view)
|
||||||
|
implementation(libs.androidx.camera.extensions)
|
||||||
|
|
||||||
|
// ML Kit Face Detection
|
||||||
|
implementation(libs.mlkit.face.detection)
|
||||||
|
|
||||||
|
// Room
|
||||||
|
implementation(libs.androidx.room.runtime)
|
||||||
|
implementation(libs.androidx.room.ktx)
|
||||||
|
ksp(libs.androidx.room.compiler)
|
||||||
|
|
||||||
testImplementation(libs.junit)
|
testImplementation(libs.junit)
|
||||||
androidTestImplementation(libs.androidx.junit)
|
androidTestImplementation(libs.androidx.junit)
|
||||||
androidTestImplementation(libs.androidx.espresso.core)
|
androidTestImplementation(libs.androidx.espresso.core)
|
||||||
|
|||||||
@ -2,6 +2,15 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:tools="http://schemas.android.com/tools">
|
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
|
<application
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||||
@ -11,6 +20,7 @@
|
|||||||
android:roundIcon="@mipmap/ic_launcher_round"
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@style/Theme.SistemAkademik">
|
android:theme="@style/Theme.SistemAkademik">
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
@ -18,10 +28,15 @@
|
|||||||
android:theme="@style/Theme.SistemAkademik">
|
android:theme="@style/Theme.SistemAkademik">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
|
<activity
|
||||||
|
android:name=".LivenessDetectionActivity"
|
||||||
|
android:exported="false"
|
||||||
|
android:theme="@style/Theme.SistemAkademik" />
|
||||||
|
|
||||||
</application>
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
||||||
@ -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
|
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.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.ComponentActivity
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.Image
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.runtime.Composable
|
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.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 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() {
|
class MainActivity : ComponentActivity() {
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
@ -19,29 +169,305 @@ class MainActivity : ComponentActivity() {
|
|||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
setContent {
|
setContent {
|
||||||
SistemAkademikTheme {
|
SistemAkademikTheme {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val db = remember { AbsensiDatabase.getDatabase(context) }
|
||||||
|
val dao = db.absensiDao()
|
||||||
|
val coroutineScope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
var isLoggedIn by remember { mutableStateOf(false) }
|
||||||
|
var currentUser by remember { mutableStateOf<UserEntity?>(null) }
|
||||||
|
|
||||||
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
|
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
|
||||||
Greeting(
|
if (!isLoggedIn) {
|
||||||
name = "Android",
|
LoginScreen(onLoginSuccess = { npm, nama ->
|
||||||
modifier = Modifier.padding(innerPadding)
|
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
|
@Composable
|
||||||
fun Greeting(name: String, modifier: Modifier = Modifier) {
|
fun MainNavigation(
|
||||||
Text(
|
modifier: Modifier = Modifier,
|
||||||
text = "Hello $name!",
|
activity: ComponentActivity,
|
||||||
modifier = modifier
|
user: UserEntity,
|
||||||
)
|
onLogout: () -> Unit
|
||||||
|
) {
|
||||||
|
var selectedTab by remember { mutableIntStateOf(0) }
|
||||||
|
val context = LocalContext.current
|
||||||
|
val db = remember { AbsensiDatabase.getDatabase(context) }
|
||||||
|
val dao = db.absensiDao()
|
||||||
|
val historyEntities by dao.getAllHistoryByNpm(user.npm).collectAsState(initial = emptyList())
|
||||||
|
val coroutineScope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
Column(modifier = modifier.fillMaxSize()) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text(text = "Halo, ${user.nama}", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||||
|
IconButton(onClick = onLogout) {
|
||||||
|
Icon(Icons.Default.ExitToApp, contentDescription = "Logout", tint = MaterialTheme.colorScheme.error)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Preview(showBackground = true)
|
TabRow(selectedTabIndex = selectedTab) {
|
||||||
|
Tab(selected = selectedTab == 0, onClick = { selectedTab = 0 }) {
|
||||||
|
Text(modifier = Modifier.padding(16.dp), text = "Absensi")
|
||||||
|
}
|
||||||
|
Tab(selected = selectedTab == 1, onClick = { selectedTab = 1 }) {
|
||||||
|
Text(modifier = Modifier.padding(16.dp), text = "Riwayat")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
when (selectedTab) {
|
||||||
|
0 -> AbsensiScreen(
|
||||||
|
activity = activity,
|
||||||
|
npm = user.npm,
|
||||||
|
nama = user.nama,
|
||||||
|
onAbsensiSuccess = { record ->
|
||||||
|
coroutineScope.launch {
|
||||||
|
val outputStream = ByteArrayOutputStream()
|
||||||
|
record.foto?.compress(Bitmap.CompressFormat.JPEG, 50, outputStream)
|
||||||
|
dao.insert(AbsensiEntity(
|
||||||
|
npm = user.npm,
|
||||||
|
timestamp = record.timestamp,
|
||||||
|
lokasi = record.lokasi,
|
||||||
|
status = record.status,
|
||||||
|
matkul = record.matkul,
|
||||||
|
keterangan = record.keterangan,
|
||||||
|
fotoBlob = outputStream.toByteArray()
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
1 -> HistoryScreen(history = historyEntities)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ================= UI SCREENS ================= */
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@SuppressLint("MissingPermission")
|
||||||
@Composable
|
@Composable
|
||||||
fun GreetingPreview() {
|
fun AbsensiScreen(
|
||||||
SistemAkademikTheme {
|
activity: ComponentActivity,
|
||||||
Greeting("Android")
|
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"
|
lifecycleRuntimeKtx = "2.9.4"
|
||||||
activityCompose = "1.11.0"
|
activityCompose = "1.11.0"
|
||||||
composeBom = "2024.09.00"
|
composeBom = "2024.09.00"
|
||||||
|
camerax = "1.4.1"
|
||||||
|
mlkitFace = "16.1.7"
|
||||||
|
room = "2.6.1"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||||
@ -25,8 +28,22 @@ androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-
|
|||||||
androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
|
androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
|
||||||
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
|
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
|
||||||
|
|
||||||
|
# CameraX
|
||||||
|
androidx-camera-core = { group = "androidx.camera", name = "camera-core", version.ref = "camerax" }
|
||||||
|
androidx-camera-camera2 = { group = "androidx.camera", name = "camera-camera2", version.ref = "camerax" }
|
||||||
|
androidx-camera-lifecycle = { group = "androidx.camera", name = "camera-lifecycle", version.ref = "camerax" }
|
||||||
|
androidx-camera-view = { group = "androidx.camera", name = "camera-view", version.ref = "camerax" }
|
||||||
|
androidx-camera-extensions = { group = "androidx.camera", name = "camera-extensions", version.ref = "camerax" }
|
||||||
|
|
||||||
|
# ML Kit
|
||||||
|
mlkit-face-detection = { group = "com.google.mlkit", name = "face-detection", version.ref = "mlkitFace" }
|
||||||
|
|
||||||
|
# Room
|
||||||
|
androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
|
||||||
|
androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
|
||||||
|
androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
|
||||||
|
|
||||||
[plugins]
|
[plugins]
|
||||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||||
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
||||||
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
||||||
|
|
||||||
|
|||||||
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