This commit is contained in:
202310715096 JEREMIA SEBASTIAN MARPAUNG 2026-01-14 16:52:43 +07:00
parent ed435ffbc1
commit d4619f44e7
4 changed files with 399 additions and 151 deletions

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)

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,10 +57,8 @@ 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)
--- ---
@ -95,3 +78,20 @@ gambar mockup dibuat oleh AI
## Webhook: ## Webhook:
- test: https://n8n.lab.ubharajaya.ac.id/webhook-test/23c6993d-1792-48fb-ad1c-ffc78a3e6254 - 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 - 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

@ -51,6 +51,7 @@ 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)
implementation("androidx.compose.material:material-icons-extended-android:1.6.8")
// Location (GPS) // Location (GPS)
implementation("com.google.android.gms:play-services-location:21.0.1") implementation("com.google.android.gms:play-services-location:21.0.1")

View File

@ -5,6 +5,7 @@ import android.app.Activity
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.graphics.Bitmap import android.graphics.Bitmap
import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.provider.MediaStore import android.provider.MediaStore
import android.util.Base64 import android.util.Base64
@ -14,11 +15,30 @@ 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.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.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.material3.*
import androidx.compose.runtime.* 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.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.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import com.google.android.gms.location.LocationServices import com.google.android.gms.location.LocationServices
@ -39,55 +59,70 @@ fun bitmapToBase64(bitmap: Bitmap): String {
fun kirimKeN8n( fun kirimKeN8n(
context: ComponentActivity, context: ComponentActivity,
npm: String,
nama: String,
mataKuliah: String,
latitude: Double, latitude: Double,
longitude: Double, longitude: Double,
foto: Bitmap foto: Bitmap,
onResult: (Boolean) -> Unit
) { ) {
thread { thread {
var isSuccess = false
try { try {
val url = URL("https://n8n.lab.ubharajaya.ac.id/webhook/23c6993d-1792-48fb-ad1c-ffc78a3e6254") val url = URL("https://n8n.lab.ubharajaya.ac.id/webhook/23c6993d-1792-48fb-ad1c-ffc78a3e6254")
// test URL val url = URL("https://n8n.lab.ubharajaya.ac.id/webhook-test/23c6993d-1792-48fb-ad1c-ffc78a3e6254")
val conn = url.openConnection() as HttpURLConnection val conn = url.openConnection() as HttpURLConnection
conn.requestMethod = "POST" conn.requestMethod = "POST"
conn.setRequestProperty("Content-Type", "application/json") conn.setRequestProperty("Content-Type", "application/json; utf-8")
conn.setRequestProperty("Accept", "application/json")
conn.doOutput = true conn.doOutput = true
val json = JSONObject().apply { val json = JSONObject().apply {
put("npm", "12345") put("npm", npm)
put("nama","Arif R D") put("nama", nama)
put("mata_kuliah", mataKuliah)
put("latitude", latitude) put("latitude", latitude)
put("longitude", longitude) put("longitude", longitude)
put("timestamp", System.currentTimeMillis()) put("timestamp", System.currentTimeMillis())
put("foto_base64", bitmapToBase64(foto)) put("foto_base64", bitmapToBase64(foto))
} }
val jsonString = json.toString()
conn.outputStream.use { conn.outputStream.use {
it.write(json.toString().toByteArray()) it.write(jsonString.toByteArray(Charsets.UTF_8))
} }
val responseCode = conn.responseCode val responseCode = conn.responseCode
val responseMessage = conn.responseMessage
isSuccess = responseCode == 200
context.runOnUiThread { context.runOnUiThread {
Toast.makeText( Toast.makeText(
context, context,
if (responseCode == 200) if (isSuccess) {
"Absensi diterima server" "Absensi diterima server"
else } else {
"Absensi ditolak server", "Absensi ditolak server (Kode: $responseCode - $responseMessage)"
Toast.LENGTH_SHORT },
Toast.LENGTH_LONG
).show() ).show()
} }
conn.disconnect() conn.disconnect()
} catch (_: Exception) { } catch (e: Exception) {
context.runOnUiThread { context.runOnUiThread {
Toast.makeText( Toast.makeText(
context, context,
"Gagal kirim ke server", "Gagal kirim ke server: ${e.message}",
Toast.LENGTH_SHORT Toast.LENGTH_LONG
).show() ).show()
e.printStackTrace()
}
} finally {
context.runOnUiThread {
onResult(isSuccess)
} }
} }
} }
@ -103,11 +138,26 @@ class MainActivity : ComponentActivity() {
setContent { setContent {
SistemAkademikTheme { SistemAkademikTheme {
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> Box(
AbsensiScreen( modifier = Modifier
modifier = Modifier.padding(innerPadding), .fillMaxSize()
activity = this .background(
) Brush.verticalGradient(
colors = listOf(
MaterialTheme.colorScheme.tertiaryContainer,
MaterialTheme.colorScheme.primaryContainer
)
)
)
) {
Scaffold(
modifier = Modifier.fillMaxSize(),
containerColor = Color.Transparent
) { innerPadding ->
AbsensiScreen(
modifier = Modifier.padding(innerPadding)
)
}
} }
} }
} }
@ -116,77 +166,109 @@ class MainActivity : ComponentActivity() {
/* ================= UI ================= */ /* ================= UI ================= */
@Composable
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 = "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 @Composable
fun AbsensiScreen( fun AbsensiScreen(
modifier: Modifier = Modifier, modifier: Modifier = Modifier
activity: ComponentActivity
) { ) {
val context = LocalContext.current val context = LocalContext.current
val activity = context as ComponentActivity
var lokasi by remember { mutableStateOf("Koordinat: -") } 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 latitude by remember { mutableStateOf<Double?>(null) }
var longitude by remember { mutableStateOf<Double?>(null) } var longitude by remember { mutableStateOf<Double?>(null) }
var foto by remember { mutableStateOf<Bitmap?>(null) } var foto by remember { mutableStateOf<Bitmap?>(null) }
var isLoading by remember { mutableStateOf(false) }
var absensiBerhasil by remember { mutableStateOf(false) }
val fusedLocationClient = val fusedLocationClient =
LocationServices.getFusedLocationProviderClient(context) LocationServices.getFusedLocationProviderClient(context)
/* ===== Permission Lokasi ===== */
val locationPermissionLauncher = val locationPermissionLauncher =
rememberLauncherForActivityResult( rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission() ActivityResultContracts.RequestPermission()
) { granted -> ) { granted ->
if (granted) { if (granted) {
if (ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED) {
if ( lokasi = "Mencari lokasi..."
ContextCompat.checkSelfPermission(
context,
Manifest.permission.ACCESS_FINE_LOCATION
) == PackageManager.PERMISSION_GRANTED
) {
fusedLocationClient.lastLocation fusedLocationClient.lastLocation
.addOnSuccessListener { location -> .addOnSuccessListener { location ->
if (location != null) { if (location != null) {
latitude = location.latitude latitude = location.latitude
longitude = location.longitude longitude = location.longitude
lokasi = lokasi = String.format("Lat: %.6f\nLon: %.6f", location.latitude, location.longitude)
"Lat: ${location.latitude}\nLon: ${location.longitude}"
} else { } else {
lokasi = "Lokasi tidak tersedia" lokasi = "Gagal mendapatkan lokasi. Coba lagi."
} }
} }
.addOnFailureListener { .addOnFailureListener {
lokasi = "Gagal mengambil lokasi" lokasi = "Gagal mendapatkan lokasi: ${it.message}"
} }
} }
} else { } else {
Toast.makeText( lokasi = "Izin lokasi ditolak."
context, Toast.makeText(context, "Izin lokasi diperlukan untuk absensi.", Toast.LENGTH_LONG).show()
"Izin lokasi ditolak",
Toast.LENGTH_SHORT
).show()
} }
} }
/* ===== Kamera ===== */
val cameraLauncher = val cameraLauncher =
rememberLauncherForActivityResult( rememberLauncherForActivityResult(
ActivityResultContracts.StartActivityForResult() ActivityResultContracts.StartActivityForResult()
) { result -> ) { result ->
if (result.resultCode == Activity.RESULT_OK) { if (result.resultCode == Activity.RESULT_OK) {
val bitmap = val bitmap = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
result.data?.extras?.getParcelable("data", Bitmap::class.java) result.data?.extras?.getParcelable("data", Bitmap::class.java)
} else {
@Suppress("DEPRECATION")
result.data?.extras?.getParcelable("data") as? Bitmap
}
if (bitmap != null) { if (bitmap != null) {
foto = bitmap foto = bitmap
Toast.makeText( Toast.makeText(context, "Foto berhasil diambil", Toast.LENGTH_SHORT).show()
context,
"Foto berhasil diambil",
Toast.LENGTH_SHORT
).show()
} }
} }
} }
@ -196,79 +278,199 @@ fun AbsensiScreen(
ActivityResultContracts.RequestPermission() ActivityResultContracts.RequestPermission()
) { granted -> ) { granted ->
if (granted) { if (granted) {
val intent = val intent = Intent(MediaStore.ACTION_IMAGE_CAPTURE)
Intent(MediaStore.ACTION_IMAGE_CAPTURE)
cameraLauncher.launch(intent) cameraLauncher.launch(intent)
} else { }
Toast.makeText( else {
context, Toast.makeText(context, "Izin kamera ditolak", Toast.LENGTH_SHORT).show()
"Izin kamera ditolak",
Toast.LENGTH_SHORT
).show()
} }
} }
/* ===== Request Awal ===== */
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
locationPermissionLauncher.launch( locationPermissionLauncher.launch(Manifest.permission.ACCESS_FINE_LOCATION)
Manifest.permission.ACCESS_FINE_LOCATION
)
} }
/* ===== UI ===== */ fun resetForm() {
absensiBerhasil = false
npm = ""
nama = ""
mataKuliah = ""
foto = null
lokasi = "Meminta izin lokasi..."
latitude = null
longitude = null
locationPermissionLauncher.launch(Manifest.permission.ACCESS_FINE_LOCATION)
}
Column( AnimatedContent(targetState = absensiBerhasil, label = "Layout Switcher") { isSuccess ->
modifier = modifier if (isSuccess) {
.fillMaxSize() AbsensiBerhasilScreen(onReset = ::resetForm)
.padding(24.dp), } else {
verticalArrangement = Arrangement.Center Column(
) { modifier = modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text( // --- Header ---
text = "Absensi Akademik", Column(horizontalAlignment = Alignment.CenterHorizontally) {
style = MaterialTheme.typography.titleLarge Text(
) text = "Absensi Akademik",
style = MaterialTheme.typography.headlineSmall,
Spacer(modifier = Modifier.height(16.dp)) fontWeight = FontWeight.Bold
)
Text(text = lokasi) Spacer(modifier = Modifier.height(4.dp))
Text(
Spacer(modifier = Modifier.height(16.dp)) text = "Isi data berikut untuk melakukan absensi",
style = MaterialTheme.typography.bodyMedium,
Button( color = MaterialTheme.colorScheme.onSurfaceVariant,
onClick = { textAlign = TextAlign.Center
cameraPermissionLauncher.launch(
Manifest.permission.CAMERA
)
},
modifier = Modifier.fillMaxWidth()
) {
Text("Ambil Foto")
}
Spacer(modifier = Modifier.height(12.dp))
Button(
onClick = {
if (latitude != null && longitude != null && foto != null) {
kirimKeN8n(
activity,
latitude!!,
longitude!!,
foto!!
) )
} else {
Toast.makeText(
context,
"Lokasi atau foto belum lengkap",
Toast.LENGTH_SHORT
).show()
} }
},
modifier = Modifier.fillMaxWidth()
) { // --- User Info Card ---
Text("Kirim Absensi") 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")
}
}
}
} }
} }
} }