From d4619f44e799fa5a586aa7b6fb18b1d7d94a6012 Mon Sep 17 00:00:00 2001 From: 202310715096-JEREMIA-SEBASTIAN-MARPAUNG <202310715096@mhs.ubharajaya.ac.id> Date: Wed, 14 Jan 2026 16:52:43 +0700 Subject: [PATCH] push --- .kotlin/errors/errors-1768377432549.log | 45 ++ README.md | 80 ++-- app/build.gradle.kts | 1 + .../ubharajaya/sistemakademik/MainActivity.kt | 424 +++++++++++++----- 4 files changed, 399 insertions(+), 151 deletions(-) create mode 100644 .kotlin/errors/errors-1768377432549.log diff --git a/.kotlin/errors/errors-1768377432549.log b/.kotlin/errors/errors-1768377432549.log new file mode 100644 index 0000000..88b88b0 --- /dev/null +++ b/.kotlin/errors/errors-1768377432549.log @@ -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) + + diff --git a/README.md b/README.md index 9871f13..962f9f0 100644 --- a/README.md +++ b/README.md @@ -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,10 +57,8 @@ 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) --- @@ -94,4 +77,21 @@ gambar mockup dibuat oleh AI ## 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 \ No newline at end of file +- 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. + + diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 7d76378..dc452c1 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -51,6 +51,7 @@ dependencies { 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") diff --git a/app/src/main/java/id/ac/ubharajaya/sistemakademik/MainActivity.kt b/app/src/main/java/id/ac/ubharajaya/sistemakademik/MainActivity.kt index c774502..6933977 100644 --- a/app/src/main/java/id/ac/ubharajaya/sistemakademik/MainActivity.kt +++ b/app/src/main/java/id/ac/ubharajaya/sistemakademik/MainActivity.kt @@ -5,6 +5,7 @@ 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 @@ -14,11 +15,30 @@ import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge 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.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 @@ -39,55 +59,70 @@ fun bitmapToBase64(bitmap: Bitmap): String { fun kirimKeN8n( context: ComponentActivity, + npm: String, + nama: String, + mataKuliah: String, latitude: Double, longitude: Double, - foto: Bitmap + 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") -// test URL val url = URL("https://n8n.lab.ubharajaya.ac.id/webhook-test/23c6993d-1792-48fb-ad1c-ffc78a3e6254") val conn = url.openConnection() as HttpURLConnection 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 val json = JSONObject().apply { - put("npm", "12345") - put("nama","Arif R D") + 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(json.toString().toByteArray()) + it.write(jsonString.toByteArray(Charsets.UTF_8)) } val responseCode = conn.responseCode + val responseMessage = conn.responseMessage + isSuccess = responseCode == 200 context.runOnUiThread { Toast.makeText( context, - if (responseCode == 200) + if (isSuccess) { "Absensi diterima server" - else - "Absensi ditolak server", - Toast.LENGTH_SHORT + } else { + "Absensi ditolak server (Kode: $responseCode - $responseMessage)" + }, + Toast.LENGTH_LONG ).show() } conn.disconnect() - } catch (_: Exception) { + } catch (e: Exception) { context.runOnUiThread { Toast.makeText( context, - "Gagal kirim ke server", - Toast.LENGTH_SHORT + "Gagal kirim ke server: ${e.message}", + Toast.LENGTH_LONG ).show() + e.printStackTrace() + } + } finally { + context.runOnUiThread { + onResult(isSuccess) } } } @@ -103,11 +138,26 @@ class MainActivity : ComponentActivity() { setContent { SistemAkademikTheme { - Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> - AbsensiScreen( - modifier = Modifier.padding(innerPadding), - activity = this - ) + 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) + ) + } } } } @@ -116,77 +166,109 @@ class MainActivity : ComponentActivity() { /* ================= 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 fun AbsensiScreen( - modifier: Modifier = Modifier, - activity: ComponentActivity + modifier: Modifier = Modifier ) { 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(null) } var longitude by remember { mutableStateOf(null) } var foto by remember { mutableStateOf(null) } + var isLoading by remember { mutableStateOf(false) } + var absensiBerhasil by remember { mutableStateOf(false) } val fusedLocationClient = LocationServices.getFusedLocationProviderClient(context) - /* ===== Permission Lokasi ===== */ - val locationPermissionLauncher = rememberLauncherForActivityResult( ActivityResultContracts.RequestPermission() ) { granted -> if (granted) { - - if ( - ContextCompat.checkSelfPermission( - context, - Manifest.permission.ACCESS_FINE_LOCATION - ) == PackageManager.PERMISSION_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 = - "Lat: ${location.latitude}\nLon: ${location.longitude}" + lokasi = String.format("Lat: %.6f\nLon: %.6f", location.latitude, location.longitude) } else { - lokasi = "Lokasi tidak tersedia" + lokasi = "Gagal mendapatkan lokasi. Coba lagi." } } .addOnFailureListener { - lokasi = "Gagal mengambil lokasi" + lokasi = "Gagal mendapatkan lokasi: ${it.message}" } } - } else { - Toast.makeText( - context, - "Izin lokasi ditolak", - Toast.LENGTH_SHORT - ).show() + lokasi = "Izin lokasi ditolak." + Toast.makeText(context, "Izin lokasi diperlukan untuk absensi.", Toast.LENGTH_LONG).show() } } - /* ===== Kamera ===== */ - val cameraLauncher = rememberLauncherForActivityResult( ActivityResultContracts.StartActivityForResult() ) { result -> 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) + } 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() + Toast.makeText(context, "Foto berhasil diambil", Toast.LENGTH_SHORT).show() } } } @@ -196,79 +278,199 @@ fun AbsensiScreen( ActivityResultContracts.RequestPermission() ) { granted -> if (granted) { - val intent = - Intent(MediaStore.ACTION_IMAGE_CAPTURE) + val intent = Intent(MediaStore.ACTION_IMAGE_CAPTURE) cameraLauncher.launch(intent) - } else { - Toast.makeText( - context, - "Izin kamera ditolak", - Toast.LENGTH_SHORT - ).show() + } + else { + Toast.makeText(context, "Izin kamera ditolak", Toast.LENGTH_SHORT).show() } } - /* ===== Request Awal ===== */ - LaunchedEffect(Unit) { - locationPermissionLauncher.launch( - Manifest.permission.ACCESS_FINE_LOCATION - ) + locationPermissionLauncher.launch(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( - modifier = modifier - .fillMaxSize() - .padding(24.dp), - verticalArrangement = Arrangement.Center - ) { + 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) + ) { - Text( - text = "Absensi Akademik", - style = MaterialTheme.typography.titleLarge - ) - - Spacer(modifier = Modifier.height(16.dp)) - - Text(text = lokasi) - - Spacer(modifier = Modifier.height(16.dp)) - - Button( - onClick = { - 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!! + // --- 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 ) - } else { - Toast.makeText( - context, - "Lokasi atau foto belum lengkap", - Toast.LENGTH_SHORT - ).show() } - }, - modifier = Modifier.fillMaxWidth() - ) { - Text("Kirim Absensi") + + + // --- 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") + } + } + } } } }