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> <selectionStates>
<SelectionState runConfigName="app"> <SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" /> <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> </SelectionState>
</selectionStates> </selectionStates>
</component> </component>

1
.idea/gradle.xml generated
View File

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

1
.idea/misc.xml generated
View File

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

6
.idea/studiobot.xml generated Normal file
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) # 📱 Aplikasi Absensi Akademik Berbasis Koordinat dan Foto (Mobile)
## 📌 Deskripsi Proyek ## 📌 Deskripsi Proyek
@ -19,52 +22,34 @@ Aplikasi ini dirancang untuk meningkatkan **validitas kehadiran mahasiswa**, den
--- ---
## 🚀 Fitur Utama ## 🚀 Fitur Utama
- 🔐 **Login Pengguna (Mahasiswa)** - ✍️ **Input Data Mahasiswa:** Memasukkan NPM dan Nama Lengkap.
- 📍 **Pengambilan Koordinat Lokasi (Latitude & Longitude)** - 📚 **Pilihan Mata Kuliah:** Memilih mata kuliah dari daftar yang tersedia.
- 🏫 **Validasi Lokasi Absensi (Radius Area)** - 📍 **Pengambilan Koordinat Lokasi:** Menggunakan GPS untuk mendapatkan lokasi presensi.
- 📸 **Pengambilan Foto Mahasiswa Saat Absensi** - 📸 **Pengambilan Foto Langsung:** Mengambil foto selfie sebagai bukti kehadiran.
- 🕒 **Pencatatan Waktu Absensi** - 🚀 **Pengiriman Data Absensi:** Mengirim semua data (profil, lokasi, foto) ke server secara *real-time*.
- 📄 **Riwayat Kehadiran Mahasiswa** - ✅ **Validasi Kelengkapan Data:** Tombol kirim hanya aktif jika semua data telah terisi lengkap.
- ⚠️ **Notifikasi Absensi Ditolak jika Tidak Valid** - ✨ **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 ## 🗺️ Mekanisme Absensi
1. Mahasiswa melakukan **login** 1. Mahasiswa **membuka aplikasi**.
2. Memilih menu **Absensi** 2. Mengisi **NPM, Nama Lengkap, dan memilih Mata Kuliah** pada kartu profil.
3. Sistem meminta: 3. Aplikasi secara otomatis meminta **izin akses lokasi** dan menampilkan koordinat.
- Izin **akses lokasi** 4. Mahasiswa menekan tombol **"Ambil Foto"** untuk mengambil gambar diri (selfie).
- Izin **akses kamera** 5. Setelah semua data lengkap, mahasiswa menekan tombol **"Kirim Absensi"**.
4. Aplikasi mengambil: 6. Data (profil, mata kuliah, foto, lokasi, dan waktu) dikirim ke server untuk divalidasi.
- 📍 **Koordinat lokasi mahasiswa** 7. Jika berhasil, aplikasi akan menampilkan **layar konfirmasi**.
- 📸 **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
--- ---
## 🛠️ Teknologi yang Digunakan ## 🛠️ Teknologi yang Digunakan
- **Platform** : Android - **Platform** : Android
- **Bahasa Pemrograman** : Kotlin / Java - **Bahasa Pemrograman** : Kotlin
- **Location Service** : - **UI Toolkit**: Jetpack Compose
- Google Maps API - **Location Service** : Fused Location Provider
- Fused Location Provider
- **Camera API** : CameraX / Camera2 - **Camera API** : CameraX / Camera2
- **Database** : Firebase / SQLite / MySQL
- **Storage** : Firebase Storage / Local Storage
- **IDE** : Android Studio - **IDE** : Android Studio
--- ---
@ -72,11 +57,41 @@ Aplikasi ini dirancang untuk meningkatkan **validitas kehadiran mahasiswa**, den
## 🔐 Izin Aplikasi (Permissions) ## 🔐 Izin Aplikasi (Permissions)
Aplikasi memerlukan izin berikut: Aplikasi memerlukan izin berikut:
- `ACCESS_FINE_LOCATION` - `ACCESS_FINE_LOCATION`
- `ACCESS_COARSE_LOCATION`
- `CAMERA` - `CAMERA`
- `INTERNET` - `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.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.activity.compose) implementation(libs.androidx.activity.compose)
implementation("androidx.activity:activity-compose:1.9.0")
implementation(platform(libs.androidx.compose.bom)) implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.compose.ui) implementation(libs.androidx.compose.ui)
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)
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) testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(libs.androidx.espresso.core)

View File

@ -2,6 +2,14 @@
<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"

View File

@ -1,47 +1,476 @@
package id.ac.ubharajaya.sistemakademik 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.os.Bundle
import android.provider.MediaStore
import android.util.Base64
import android.widget.Toast
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.fillMaxSize import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.padding import androidx.compose.animation.AnimatedContent
import androidx.compose.material3.Scaffold import androidx.compose.foundation.Image
import androidx.compose.material3.Text import androidx.compose.foundation.background
import androidx.compose.runtime.Composable 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.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 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() { class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
enableEdgeToEdge() enableEdgeToEdge()
setContent { setContent {
SistemAkademikTheme { SistemAkademikTheme {
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> Box(
Greeting( modifier = Modifier
name = "Android", .fillMaxSize()
modifier = Modifier.padding(innerPadding) .background(
) Brush.verticalGradient(
colors = listOf(
MaterialTheme.colorScheme.tertiaryContainer,
MaterialTheme.colorScheme.primaryContainer
)
)
)
) {
Scaffold(
modifier = Modifier.fillMaxSize(),
containerColor = Color.Transparent
) { innerPadding ->
AbsensiScreen(
modifier = Modifier.padding(innerPadding)
)
}
} }
} }
} }
} }
} }
@Composable /* ================= UI ================= */
fun Greeting(name: String, modifier: Modifier = Modifier) {
Text(
text = "Hello $name!",
modifier = modifier
)
}
@Preview(showBackground = true)
@Composable @Composable
fun GreetingPreview() { fun AbsensiBerhasilScreen(onReset: () -> Unit) {
SistemAkademikTheme { Column(
Greeting("Android") 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 = "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
)
}
// --- 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": []
}