Compare commits
No commits in common. "a0f0c4c99530be12237e06aa292167613b45b263" and "cbe7e50b9621330a16f900cded7f674a939644a5" have entirely different histories.
a0f0c4c995
...
cbe7e50b96
8
.idea/deploymentTargetSelector.xml
generated
8
.idea/deploymentTargetSelector.xml
generated
@ -4,14 +4,6 @@
|
|||||||
<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,6 +1,5 @@
|
|||||||
<?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
8
.idea/markdown.xml
generated
@ -1,8 +0,0 @@
|
|||||||
<?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,3 +1,4 @@
|
|||||||
|
<?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
6
.idea/studiobot.xml
generated
@ -1,6 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="StudioBotProjectSettings">
|
|
||||||
<option name="shareContext" value="OptedIn" />
|
|
||||||
</component>
|
|
||||||
</project>
|
|
||||||
BIN
Mockup.png
BIN
Mockup.png
Binary file not shown.
|
Before Width: | Height: | Size: 715 KiB |
121
README.md
121
README.md
@ -1,75 +1,82 @@
|
|||||||
# 🎓 Sistem Akademik - Aplikasi Absensi Mahasiswa Pintar
|
# 📱 Aplikasi Absensi Akademik Berbasis Koordinat dan Foto (Mobile)
|
||||||
|
|
||||||
## 📌 Deskripsi Proyek
|
## 📌 Deskripsi Proyek
|
||||||
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.
|
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
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🚀 Fitur Utama
|
## 🚀 Fitur Utama
|
||||||
|
- 🔐 **Login Pengguna (Mahasiswa)**
|
||||||
### 🔐 Manajemen Akun & Keamanan
|
- 📍 **Pengambilan Koordinat Lokasi (Latitude & Longitude)**
|
||||||
- **Login Mahasiswa**: Autentikasi menggunakan NPM dan Nama Lengkap secara manual.
|
- 🏫 **Validasi Lokasi Absensi (Radius Area)**
|
||||||
- **Logout Akun**: Fitur keluar untuk membersihkan sesi dan kembali ke halaman login.
|
- 📸 **Pengambilan Foto Mahasiswa Saat Absensi**
|
||||||
- **Database Akun Lokal**: Aktivitas dan riwayat tersimpan secara privat sesuai NPM yang sedang login menggunakan Room Database.
|
- 🕒 **Pencatatan Waktu Absensi**
|
||||||
|
- 📄 **Riwayat Kehadiran Mahasiswa**
|
||||||
### 📍 Validasi Lokasi (Geofencing)
|
- ⚠️ **Notifikasi Absensi Ditolak jika Tidak Valid**
|
||||||
- **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).
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🛠️ Detail Teknis
|
## 🗺️ 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**
|
||||||
|
|
||||||
### 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]
|
|
||||||
|
|
||||||
### Teknologi Utama
|
## 📸 Pengambilan Foto Saat Absensi
|
||||||
- **UI**: Jetpack Compose (Material 3) dengan fitur **Searchbar** pada dropdown mata kuliah.
|
- Foto diambil menggunakan **kamera depan (selfie)**
|
||||||
- **Database**: Room Persistence Library (SQLite) untuk isolasi data per akun.
|
- Foto hanya dapat diambil **saat proses absensi**
|
||||||
- **Vision**: Google ML Kit Face Detection.
|
- Foto disimpan sebagai **bukti kehadiran**
|
||||||
- **Location**: Fused Location Provider API (GPS Presisi).
|
- 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
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🔐 Izin Aplikasi (Permissions)
|
## 🔐 Izin Aplikasi (Permissions)
|
||||||
Aplikasi meminta izin secara otomatis saat pertama kali dijalankan:
|
Aplikasi memerlukan izin berikut:
|
||||||
- `ACCESS_FINE_LOCATION` (Lokasi akurat)
|
- `ACCESS_FINE_LOCATION`
|
||||||
- `CAMERA` (Verifikasi wajah & foto bukti)
|
- `ACCESS_COARSE_LOCATION`
|
||||||
- `INTERNET` (Sinkronisasi ke sistem n8n)
|
- `CAMERA`
|
||||||
|
- `INTERNET`
|
||||||
|
- `WRITE_EXTERNAL_STORAGE` (jika diperlukan)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📂 Link Terkait
|
## 📂 Struktur Proyek (Contoh)
|
||||||
- **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,12 +2,13 @@ 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 = 36
|
compileSdk {
|
||||||
|
version = release(36)
|
||||||
|
}
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "id.ac.ubharajaya.sistemakademik"
|
applicationId = "id.ac.ubharajaya.sistemakademik"
|
||||||
@ -49,25 +50,6 @@ 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,15 +2,6 @@
|
|||||||
<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"
|
||||||
@ -20,7 +11,6 @@
|
|||||||
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"
|
||||||
@ -28,15 +18,10 @@
|
|||||||
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>
|
||||||
@ -1,342 +0,0 @@
|
|||||||
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,167 +1,17 @@
|
|||||||
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.activity.result.contract.ActivityResultContracts
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.Image
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.runtime.Composable
|
||||||
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.draw.clip
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
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?) {
|
||||||
@ -169,305 +19,29 @@ 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 ->
|
||||||
if (!isLoggedIn) {
|
Greeting(
|
||||||
LoginScreen(onLoginSuccess = { npm, nama ->
|
name = "Android",
|
||||||
coroutineScope.launch {
|
modifier = Modifier.padding(innerPadding)
|
||||||
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 MainNavigation(
|
fun Greeting(name: String, modifier: Modifier = Modifier) {
|
||||||
modifier: Modifier = Modifier,
|
Text(
|
||||||
activity: ComponentActivity,
|
text = "Hello $name!",
|
||||||
user: UserEntity,
|
modifier = modifier
|
||||||
onLogout: () -> Unit
|
|
||||||
) {
|
|
||||||
var selectedTab by remember { mutableIntStateOf(0) }
|
|
||||||
val context = LocalContext.current
|
|
||||||
val db = remember { AbsensiDatabase.getDatabase(context) }
|
|
||||||
val dao = db.absensiDao()
|
|
||||||
val historyEntities by dao.getAllHistoryByNpm(user.npm).collectAsState(initial = emptyList())
|
|
||||||
val coroutineScope = rememberCoroutineScope()
|
|
||||||
|
|
||||||
Column(modifier = modifier.fillMaxSize()) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Text(text = "Halo, ${user.nama}", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
|
||||||
IconButton(onClick = onLogout) {
|
|
||||||
Icon(Icons.Default.ExitToApp, contentDescription = "Logout", tint = MaterialTheme.colorScheme.error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
TabRow(selectedTabIndex = selectedTab) {
|
|
||||||
Tab(selected = selectedTab == 0, onClick = { selectedTab = 0 }) {
|
|
||||||
Text(modifier = Modifier.padding(16.dp), text = "Absensi")
|
|
||||||
}
|
|
||||||
Tab(selected = selectedTab == 1, onClick = { selectedTab = 1 }) {
|
|
||||||
Text(modifier = Modifier.padding(16.dp), text = "Riwayat")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
when (selectedTab) {
|
|
||||||
0 -> AbsensiScreen(
|
|
||||||
activity = activity,
|
|
||||||
npm = user.npm,
|
|
||||||
nama = user.nama,
|
|
||||||
onAbsensiSuccess = { record ->
|
|
||||||
coroutineScope.launch {
|
|
||||||
val outputStream = ByteArrayOutputStream()
|
|
||||||
record.foto?.compress(Bitmap.CompressFormat.JPEG, 50, outputStream)
|
|
||||||
dao.insert(AbsensiEntity(
|
|
||||||
npm = user.npm,
|
|
||||||
timestamp = record.timestamp,
|
|
||||||
lokasi = record.lokasi,
|
|
||||||
status = record.status,
|
|
||||||
matkul = record.matkul,
|
|
||||||
keterangan = record.keterangan,
|
|
||||||
fotoBlob = outputStream.toByteArray()
|
|
||||||
))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
1 -> HistoryScreen(history = historyEntities)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ================= UI SCREENS ================= */
|
@Preview(showBackground = true)
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@SuppressLint("MissingPermission")
|
|
||||||
@Composable
|
@Composable
|
||||||
fun AbsensiScreen(
|
fun GreetingPreview() {
|
||||||
activity: ComponentActivity,
|
SistemAkademikTheme {
|
||||||
npm: String,
|
Greeting("Android")
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1,82 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,119 +0,0 @@
|
|||||||
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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,120 +0,0 @@
|
|||||||
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,9 +8,6 @@ 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" }
|
||||||
@ -28,22 +25,8 @@ 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" }
|
||||||
|
|
||||||
|
|||||||
@ -1,225 +0,0 @@
|
|||||||
{
|
|
||||||
"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