push
This commit is contained in:
parent
ed435ffbc1
commit
d4619f44e7
45
.kotlin/errors/errors-1768377432549.log
Normal file
45
.kotlin/errors/errors-1768377432549.log
Normal 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)
|
||||
|
||||
|
||||
78
README.md
78
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)
|
||||
|
||||
---
|
||||
|
||||
@ -95,3 +78,20 @@ 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
|
||||
|
||||
---
|
||||
|
||||
## ✨ 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.
|
||||
|
||||
|
||||
|
||||
@ -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")
|
||||
|
||||
|
||||
@ -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<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)
|
||||
|
||||
/* ===== 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)
|
||||
}
|
||||
|
||||
AnimatedContent(targetState = absensiBerhasil, label = "Layout Switcher") { isSuccess ->
|
||||
if (isSuccess) {
|
||||
AbsensiBerhasilScreen(onReset = ::resetForm)
|
||||
} else {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.padding(24.dp),
|
||||
verticalArrangement = Arrangement.Center
|
||||
.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.titleLarge
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Text(text = lokasi)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
cameraPermissionLauncher.launch(
|
||||
Manifest.permission.CAMERA
|
||||
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
|
||||
)
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text("Ambil Foto")
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
Button(
|
||||
// --- 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 = {
|
||||
if (latitude != null && longitude != null && foto != null) {
|
||||
kirimKeN8n(
|
||||
activity,
|
||||
latitude!!,
|
||||
longitude!!,
|
||||
foto!!
|
||||
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 {
|
||||
Toast.makeText(
|
||||
context,
|
||||
"Lokasi atau foto belum lengkap",
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
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()
|
||||
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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user