Compare commits

..

10 Commits

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

View File

@ -4,6 +4,14 @@
<selectionStates>
<SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" />
<DropdownSelection timestamp="2026-01-13T05:20:56.137492Z">
<Target type="DEFAULT_BOOT">
<handle>
<DeviceId pluginId="PhysicalDevice" identifier="serial=RR8TA08RD8Z" />
</handle>
</Target>
</DropdownSelection>
<DialogSelection />
</SelectionState>
</selectionStates>
</component>

1
.idea/gradle.xml generated
View File

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

1
.idea/misc.xml generated
View File

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

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

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

View File

@ -0,0 +1,45 @@
kotlin version: 2.0.21
error message: Failed connecting to the daemon in 4 retries
error message: Daemon compilation failed: Could not connect to Kotlin compile daemon
java.lang.RuntimeException: Could not connect to Kotlin compile daemon
at org.jetbrains.kotlin.compilerRunner.GradleKotlinCompilerWork.compileWithDaemon(GradleKotlinCompilerWork.kt:214)
at org.jetbrains.kotlin.compilerRunner.GradleKotlinCompilerWork.compileWithDaemonOrFallbackImpl(GradleKotlinCompilerWork.kt:159)
at org.jetbrains.kotlin.compilerRunner.GradleKotlinCompilerWork.run(GradleKotlinCompilerWork.kt:111)
at org.jetbrains.kotlin.compilerRunner.GradleCompilerRunnerWithWorkers$GradleKotlinCompilerWorkAction.execute(GradleCompilerRunnerWithWorkers.kt:76)
at org.gradle.workers.internal.DefaultWorkerServer.execute(DefaultWorkerServer.java:63)
at org.gradle.workers.internal.NoIsolationWorkerFactory$1$1.create(NoIsolationWorkerFactory.java:66)
at org.gradle.workers.internal.NoIsolationWorkerFactory$1$1.create(NoIsolationWorkerFactory.java:62)
at org.gradle.internal.classloader.ClassLoaderUtils.executeInClassloader(ClassLoaderUtils.java:100)
at org.gradle.workers.internal.NoIsolationWorkerFactory$1.lambda$execute$0(NoIsolationWorkerFactory.java:62)
at org.gradle.workers.internal.AbstractWorker$1.call(AbstractWorker.java:44)
at org.gradle.workers.internal.AbstractWorker$1.call(AbstractWorker.java:41)
at org.gradle.internal.operations.DefaultBuildOperationRunner$CallableBuildOperationWorker.execute(DefaultBuildOperationRunner.java:210)
at org.gradle.internal.operations.DefaultBuildOperationRunner$CallableBuildOperationWorker.execute(DefaultBuildOperationRunner.java:205)
at org.gradle.internal.operations.DefaultBuildOperationRunner$2.execute(DefaultBuildOperationRunner.java:67)
at org.gradle.internal.operations.DefaultBuildOperationRunner$2.execute(DefaultBuildOperationRunner.java:60)
at org.gradle.internal.operations.DefaultBuildOperationRunner.execute(DefaultBuildOperationRunner.java:167)
at org.gradle.internal.operations.DefaultBuildOperationRunner.execute(DefaultBuildOperationRunner.java:60)
at org.gradle.internal.operations.DefaultBuildOperationRunner.call(DefaultBuildOperationRunner.java:54)
at org.gradle.workers.internal.AbstractWorker.executeWrappedInBuildOperation(AbstractWorker.java:41)
at org.gradle.workers.internal.NoIsolationWorkerFactory$1.execute(NoIsolationWorkerFactory.java:59)
at org.gradle.workers.internal.DefaultWorkerExecutor.lambda$submitWork$0(DefaultWorkerExecutor.java:174)
at java.base/java.util.concurrent.FutureTask.run(Unknown Source)
at org.gradle.internal.work.DefaultConditionalExecutionQueue$ExecutionRunner.runExecution(DefaultConditionalExecutionQueue.java:194)
at org.gradle.internal.work.DefaultConditionalExecutionQueue$ExecutionRunner.access$700(DefaultConditionalExecutionQueue.java:127)
at org.gradle.internal.work.DefaultConditionalExecutionQueue$ExecutionRunner$1.run(DefaultConditionalExecutionQueue.java:169)
at org.gradle.internal.Factories$1.create(Factories.java:31)
at org.gradle.internal.work.DefaultWorkerLeaseService.withLocks(DefaultWorkerLeaseService.java:263)
at org.gradle.internal.work.DefaultWorkerLeaseService.runAsWorkerThread(DefaultWorkerLeaseService.java:127)
at org.gradle.internal.work.DefaultWorkerLeaseService.runAsWorkerThread(DefaultWorkerLeaseService.java:132)
at org.gradle.internal.work.DefaultConditionalExecutionQueue$ExecutionRunner.runBatch(DefaultConditionalExecutionQueue.java:164)
at org.gradle.internal.work.DefaultConditionalExecutionQueue$ExecutionRunner.run(DefaultConditionalExecutionQueue.java:133)
at java.base/java.util.concurrent.Executors$RunnableAdapter.call(Unknown Source)
at java.base/java.util.concurrent.FutureTask.run(Unknown Source)
at org.gradle.internal.concurrent.ExecutorPolicy$CatchAndRecordFailures.onExecute(ExecutorPolicy.java:64)
at org.gradle.internal.concurrent.AbstractManagedExecutor$1.run(AbstractManagedExecutor.java:48)
at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(Unknown Source)
at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(Unknown Source)
at java.base/java.lang.Thread.run(Unknown Source)

BIN
Mockup.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 715 KiB

View File

@ -1,3 +1,6 @@
Nama : Jeremia Sebastian Marpaung
NPM : 202310715096
# 📱 Aplikasi Absensi Akademik Berbasis Koordinat dan Foto (Mobile)
## 📌 Deskripsi Proyek
@ -19,52 +22,34 @@ Aplikasi ini dirancang untuk meningkatkan **validitas kehadiran mahasiswa**, den
---
## 🚀 Fitur Utama
- 🔐 **Login Pengguna (Mahasiswa)**
- 📍 **Pengambilan Koordinat Lokasi (Latitude & Longitude)**
- 🏫 **Validasi Lokasi Absensi (Radius Area)**
- 📸 **Pengambilan Foto Mahasiswa Saat Absensi**
- 🕒 **Pencatatan Waktu Absensi**
- 📄 **Riwayat Kehadiran Mahasiswa**
- ⚠️ **Notifikasi Absensi Ditolak jika Tidak Valid**
- ✍️ **Input Data Mahasiswa:** Memasukkan NPM dan Nama Lengkap.
- 📚 **Pilihan Mata Kuliah:** Memilih mata kuliah dari daftar yang tersedia.
- 📍 **Pengambilan Koordinat Lokasi:** Menggunakan GPS untuk mendapatkan lokasi presensi.
- 📸 **Pengambilan Foto Langsung:** Mengambil foto selfie sebagai bukti kehadiran.
- 🚀 **Pengiriman Data Absensi:** Mengirim semua data (profil, lokasi, foto) ke server secara *real-time*.
- ✅ **Validasi Kelengkapan Data:** Tombol kirim hanya aktif jika semua data telah terisi lengkap.
- ✨ **Antarmuka Modern:** Tampilan yang bersih dengan latar belakang gradien dan layout berbasis kartu.
- 🎉 **Konfirmasi Keberhasilan:** Menampilkan layar konfirmasi setelah absensi berhasil dikirim.
---
## 🗺️ 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**
---
## 📸 Pengambilan Foto Saat Absensi
- Foto diambil menggunakan **kamera depan (selfie)**
- Foto hanya dapat diambil **saat proses absensi**
- Foto disimpan sebagai **bukti kehadiran**
- Foto dapat digunakan untuk:
- Verifikasi manual oleh dosen
- Dokumentasi akademik
## 🗺️ Mekanisme Absensi
1. Mahasiswa **membuka aplikasi**.
2. Mengisi **NPM, Nama Lengkap, dan memilih Mata Kuliah** pada kartu profil.
3. Aplikasi secara otomatis meminta **izin akses lokasi** dan menampilkan koordinat.
4. Mahasiswa menekan tombol **"Ambil Foto"** untuk mengambil gambar diri (selfie).
5. Setelah semua data lengkap, mahasiswa menekan tombol **"Kirim Absensi"**.
6. Data (profil, mata kuliah, foto, lokasi, dan waktu) dikirim ke server untuk divalidasi.
7. Jika berhasil, aplikasi akan menampilkan **layar konfirmasi**.
---
## 🛠️ Teknologi yang Digunakan
- **Platform** : Android
- **Bahasa Pemrograman** : Kotlin / Java
- **Location Service** :
- Google Maps API
- Fused Location Provider
- **Bahasa Pemrograman** : Kotlin
- **UI Toolkit**: Jetpack Compose
- **Location Service** : Fused Location Provider
- **Camera API** : CameraX / Camera2
- **Database** : Firebase / SQLite / MySQL
- **Storage** : Firebase Storage / Local Storage
- **IDE** : Android Studio
---
@ -72,11 +57,41 @@ Aplikasi ini dirancang untuk meningkatkan **validitas kehadiran mahasiswa**, den
## 🔐 Izin Aplikasi (Permissions)
Aplikasi memerlukan izin berikut:
- `ACCESS_FINE_LOCATION`
- `ACCESS_COARSE_LOCATION`
- `CAMERA`
- `INTERNET`
- `WRITE_EXTERNAL_STORAGE` (jika diperlukan)
---
## 📂 Struktur Proyek (Contoh)
## 📂 Mockup
![mockup](Mockup.png)
gambar mockup dibuat oleh AI
## Catatan:
- Starter project ini dibuat berbantukan AI
- Kembangkan project dari starter yang sudah disediakan, jangan membuat dari awal.
- Untuk koordinat bisa ditambah/kurangi angka tertentu agar tidak memunculkan koordinat rumah masing-masing, data awal tetap dari GPS.
## Pengecekan:
- https://ntfy.ubharajaya.ac.id/EAS
- https://docs.google.com/spreadsheets/d/1jH15MfnNgpPGuGeid0hYfY7fFUHCEFbCmg8afTyyLZs/edit?gid=0#gid=0
## Webhook:
- test: https://n8n.lab.ubharajaya.ac.id/webhook-test/23c6993d-1792-48fb-ad1c-ffc78a3e6254
- production: https://n8n.lab.ubharajaya.ac.id/webhook/23c6993d-1792-48fb-ad1c-ffc78a3e6254
---
## ✨ Changelog
### Versi 1.1 - Penyempurnaan UI/UX & Fitur
- **UI Refresh & Latar Belakang Gradien:** Mengubah latar belakang menjadi gradien dinamis yang lebih menarik secara visual, dari `tertiaryContainer` ke `primaryContainer`.
- **Layar Konfirmasi Absensi:** Menambahkan layar konfirmasi setelah absensi berhasil, memberikan umpan balik yang lebih jelas kepada pengguna.
- **Fitur Pilihan Mata Kuliah:** Menambahkan dropdown untuk memilih mata kuliah, dan data mata kuliah kini ikut dikirim ke server.
- **Peningkatan Tata Letak:** Menggunakan `Card` untuk mengelompokkan elemen UI, membuat antarmuka lebih terstruktur dan modern.
### Versi 1.0 - Rilis Awal
- **Fungsionalitas Dasar:** Implementasi fitur absensi dengan pengambilan lokasi GPS dan foto.
- **Antarmuka Awal:** Desain antarmuka pengguna awal menggunakan Jetpack Compose.
- **Integrasi Server:** Mengirim data absensi ke server n8n.

View File

@ -45,11 +45,16 @@ dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.activity.compose)
implementation("androidx.activity:activity-compose:1.9.0")
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.compose.ui)
implementation(libs.androidx.compose.ui.graphics)
implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.compose.material3)
implementation("androidx.compose.material:material-icons-extended-android:1.6.8")
// Location (GPS)
implementation("com.google.android.gms:play-services-location:21.0.1")
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)

View File

@ -2,6 +2,14 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
<uses-permission android:name="android.permission.CAMERA"/>
<uses-feature
android:name="android.hardware.camera"
android:required="false" />
<application
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"

View File

@ -1,27 +1,160 @@
package id.ac.ubharajaya.sistemakademik
import android.Manifest
import android.app.Activity
import android.content.Intent
import android.content.pm.PackageManager
import android.graphics.Bitmap
import android.os.Build
import android.os.Bundle
import android.provider.MediaStore
import android.util.Base64
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedContent
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material.icons.filled.CameraAlt
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.LocationOn
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
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.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import com.google.android.gms.location.LocationServices
import id.ac.ubharajaya.sistemakademik.ui.theme.SistemAkademikTheme
import org.json.JSONObject
import java.io.ByteArrayOutputStream
import java.net.HttpURLConnection
import java.net.URL
import kotlin.concurrent.thread
/* ================= UTIL ================= */
fun bitmapToBase64(bitmap: Bitmap): String {
val outputStream = ByteArrayOutputStream()
bitmap.compress(Bitmap.CompressFormat.JPEG, 80, outputStream)
return Base64.encodeToString(outputStream.toByteArray(), Base64.NO_WRAP)
}
fun kirimKeN8n(
context: ComponentActivity,
npm: String,
nama: String,
mataKuliah: String,
latitude: Double,
longitude: Double,
foto: Bitmap,
onResult: (Boolean) -> Unit
) {
thread {
var isSuccess = false
try {
val url = URL("https://n8n.lab.ubharajaya.ac.id/webhook/23c6993d-1792-48fb-ad1c-ffc78a3e6254")
val conn = url.openConnection() as HttpURLConnection
conn.requestMethod = "POST"
conn.setRequestProperty("Content-Type", "application/json; utf-8")
conn.setRequestProperty("Accept", "application/json")
conn.doOutput = true
val json = JSONObject().apply {
put("npm", npm)
put("nama", nama)
put("mata_kuliah", mataKuliah)
put("latitude", latitude)
put("longitude", longitude)
put("timestamp", System.currentTimeMillis())
put("foto_base64", bitmapToBase64(foto))
}
val jsonString = json.toString()
conn.outputStream.use {
it.write(jsonString.toByteArray(Charsets.UTF_8))
}
val responseCode = conn.responseCode
val responseMessage = conn.responseMessage
isSuccess = responseCode == 200
context.runOnUiThread {
Toast.makeText(
context,
if (isSuccess) {
"Absensi diterima server"
} else {
"Absensi ditolak server (Kode: $responseCode - $responseMessage)"
},
Toast.LENGTH_LONG
).show()
}
conn.disconnect()
} catch (e: Exception) {
context.runOnUiThread {
Toast.makeText(
context,
"Gagal kirim ke server: ${e.message}",
Toast.LENGTH_LONG
).show()
e.printStackTrace()
}
} finally {
context.runOnUiThread {
onResult(isSuccess)
}
}
}
}
/* ================= ACTIVITY ================= */
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
SistemAkademikTheme {
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
Greeting(
name = "Android",
Box(
modifier = Modifier
.fillMaxSize()
.background(
Brush.verticalGradient(
colors = listOf(
MaterialTheme.colorScheme.tertiaryContainer,
MaterialTheme.colorScheme.primaryContainer
)
)
)
) {
Scaffold(
modifier = Modifier.fillMaxSize(),
containerColor = Color.Transparent
) { innerPadding ->
AbsensiScreen(
modifier = Modifier.padding(innerPadding)
)
}
@ -29,19 +162,315 @@ class MainActivity : ComponentActivity() {
}
}
}
}
/* ================= UI ================= */
@Composable
fun Greeting(name: String, modifier: Modifier = Modifier) {
fun AbsensiBerhasilScreen(onReset: () -> Unit) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Icon(
imageVector = Icons.Default.CheckCircle,
contentDescription = "Absensi Berhasil",
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(120.dp)
)
Spacer(modifier = Modifier.height(24.dp))
Text(
text = "Hello $name!",
text = "Absensi Berhasil",
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Data kehadiran Anda telah berhasil direkam oleh sistem.",
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(48.dp))
Button(
onClick = onReset,
modifier = Modifier.fillMaxWidth()
) {
Text("Lakukan Absensi Lagi")
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AbsensiScreen(
modifier: Modifier = Modifier
) {
val context = LocalContext.current
val activity = context as ComponentActivity
var npm by remember { mutableStateOf("") }
var nama by remember { mutableStateOf("") }
var mataKuliah by remember { mutableStateOf("") }
var isMenuMataKuliahExpanded by remember { mutableStateOf(false) }
val daftarMataKuliah = listOf("Pemrograman Perangkat Bergerak", "Kecerdasan Buatan", "Pembelajaran Mesin", "Interaksi Manusia dan Komputer", "Keamanan Siber", "Manajemen Proyek Perangkat Lunak", "Manajemen Sekuriti")
var lokasi by remember { mutableStateOf("Meminta izin lokasi...") }
var latitude by remember { mutableStateOf<Double?>(null) }
var longitude by remember { mutableStateOf<Double?>(null) }
var foto by remember { mutableStateOf<Bitmap?>(null) }
var isLoading by remember { mutableStateOf(false) }
var absensiBerhasil by remember { mutableStateOf(false) }
val fusedLocationClient =
LocationServices.getFusedLocationProviderClient(context)
val locationPermissionLauncher =
rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission()
) { granted ->
if (granted) {
if (ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED) {
lokasi = "Mencari lokasi..."
fusedLocationClient.lastLocation
.addOnSuccessListener { location ->
if (location != null) {
latitude = location.latitude
longitude = location.longitude
lokasi = String.format("Lat: %.6f\nLon: %.6f", location.latitude, location.longitude)
} else {
lokasi = "Gagal mendapatkan lokasi. Coba lagi."
}
}
.addOnFailureListener {
lokasi = "Gagal mendapatkan lokasi: ${it.message}"
}
}
} else {
lokasi = "Izin lokasi ditolak."
Toast.makeText(context, "Izin lokasi diperlukan untuk absensi.", Toast.LENGTH_LONG).show()
}
}
val cameraLauncher =
rememberLauncherForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { result ->
if (result.resultCode == Activity.RESULT_OK) {
val bitmap = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
result.data?.extras?.getParcelable("data", Bitmap::class.java)
} else {
@Suppress("DEPRECATION")
result.data?.extras?.getParcelable("data") as? Bitmap
}
if (bitmap != null) {
foto = bitmap
Toast.makeText(context, "Foto berhasil diambil", Toast.LENGTH_SHORT).show()
}
}
}
val cameraPermissionLauncher =
rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission()
) { granted ->
if (granted) {
val intent = Intent(MediaStore.ACTION_IMAGE_CAPTURE)
cameraLauncher.launch(intent)
}
else {
Toast.makeText(context, "Izin kamera ditolak", Toast.LENGTH_SHORT).show()
}
}
LaunchedEffect(Unit) {
locationPermissionLauncher.launch(Manifest.permission.ACCESS_FINE_LOCATION)
}
fun resetForm() {
absensiBerhasil = false
npm = ""
nama = ""
mataKuliah = ""
foto = null
lokasi = "Meminta izin lokasi..."
latitude = null
longitude = null
locationPermissionLauncher.launch(Manifest.permission.ACCESS_FINE_LOCATION)
}
AnimatedContent(targetState = absensiBerhasil, label = "Layout Switcher") { isSuccess ->
if (isSuccess) {
AbsensiBerhasilScreen(onReset = ::resetForm)
} else {
Column(
modifier = modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// --- Header ---
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = "Absensi Akademik",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "Isi data berikut untuk melakukan absensi",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
}
@Preview(showBackground = true)
@Composable
fun GreetingPreview() {
SistemAkademikTheme {
Greeting("Android")
// --- User Info Card ---
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp)) {
OutlinedTextField(
value = npm,
onValueChange = { npm = it },
label = { Text("NPM (Nomor Pokok Mahasiswa)") },
modifier = Modifier.fillMaxWidth(),
singleLine = true
)
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = nama,
onValueChange = { nama = it },
label = { Text("Nama Lengkap") },
modifier = Modifier.fillMaxWidth(),
singleLine = true
)
Spacer(modifier = Modifier.height(8.dp))
ExposedDropdownMenuBox(
expanded = isMenuMataKuliahExpanded,
onExpandedChange = { isMenuMataKuliahExpanded = !isMenuMataKuliahExpanded }
) {
OutlinedTextField(
value = mataKuliah,
onValueChange = {}, // readOnly
readOnly = true,
label = { Text("Mata Kuliah") },
trailingIcon = {
ExposedDropdownMenuDefaults.TrailingIcon(expanded = isMenuMataKuliahExpanded)
},
modifier = Modifier.menuAnchor().fillMaxWidth()
)
ExposedDropdownMenu(
expanded = isMenuMataKuliahExpanded,
onDismissRequest = { isMenuMataKuliahExpanded = false }
) {
daftarMataKuliah.forEach { item ->
DropdownMenuItem(
text = { Text(item) },
onClick = {
mataKuliah = item
isMenuMataKuliahExpanded = false
}
)
}
}
}
}
}
// --- Attendance Data Card ---
Card(modifier = Modifier.fillMaxWidth()) {
Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.padding(vertical = 24.dp)) {
// -- Photo --
Box(
modifier = Modifier
.size(140.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.secondaryContainer),
contentAlignment = Alignment.Center
) {
if (foto != null) {
Image(
bitmap = foto!!.asImageBitmap(),
contentDescription = "Foto Absensi",
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
} else {
Icon(
imageVector = Icons.Default.CameraAlt,
contentDescription = "Placeholder Kamera",
modifier = Modifier.size(50.dp),
tint = MaterialTheme.colorScheme.onSecondaryContainer
)
}
}
Spacer(modifier = Modifier.height(16.dp))
Button(
onClick = { cameraPermissionLauncher.launch(Manifest.permission.CAMERA) },
) {
Icon(Icons.Default.CameraAlt, contentDescription = null, modifier = Modifier.size(ButtonDefaults.IconSize))
Spacer(Modifier.size(ButtonDefaults.IconSpacing))
Text(if (foto == null) "Ambil Foto" else "Ambil Ulang Foto")
}
HorizontalDivider(modifier = Modifier.padding(vertical = 24.dp, horizontal = 16.dp))
// -- Location --
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(horizontal = 16.dp)) {
Icon(Icons.Default.LocationOn, contentDescription = "Ikon Lokasi", tint = MaterialTheme.colorScheme.primary)
Spacer(modifier = Modifier.width(16.dp))
Column {
Text("Lokasi Anda Saat Ini", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
Spacer(modifier = Modifier.height(4.dp))
Text(lokasi, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
}
// --- Submit Button ---
val isDataLengkap = npm.isNotBlank() && nama.isNotBlank() && mataKuliah.isNotBlank() && latitude != null && longitude != null && foto != null
Button(
onClick = {
if (isDataLengkap) {
isLoading = true
kirimKeN8n(activity, npm, nama, mataKuliah, latitude!!, longitude!!, foto!!) { success ->
isLoading = false
if (success) {
absensiBerhasil = true
}
}
} else {
Toast.makeText(context, "Harap lengkapi semua data terlebih dahulu", Toast.LENGTH_SHORT).show()
}
},
modifier = Modifier
.fillMaxWidth()
.height(48.dp),
enabled = isDataLengkap && !isLoading
) {
if (isLoading) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
color = MaterialTheme.colorScheme.onPrimary,
strokeWidth = 2.dp
)
} else {
Icon(Icons.AutoMirrored.Filled.Send, contentDescription = null, modifier = Modifier.size(ButtonDefaults.IconSize))
Spacer(Modifier.size(ButtonDefaults.IconSpacing))
Text("Kirim Absensi")
}
}
}
}
}
}

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

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