diff --git a/.idea/deviceManager.xml b/.idea/deviceManager.xml
new file mode 100644
index 0000000..91f9558
--- /dev/null
+++ b/.idea/deviceManager.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 7d76378..d355f6b 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -42,6 +42,7 @@ android {
}
dependencies {
+ implementation("com.google.android.gms:play-services-location:21.0.1")
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.activity.compose)
@@ -51,6 +52,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:1.6.7")
// Location (GPS)
implementation("com.google.android.gms:play-services-location:21.0.1")
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 4619836..e116c31 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -1,7 +1,10 @@
-
+
+
+
+
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..82826fe 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.location.Location
import android.os.Bundle
import android.provider.MediaStore
import android.util.Base64
@@ -14,12 +15,28 @@ import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.*
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.Color
+import androidx.compose.ui.graphics.asImageBitmap
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
import androidx.core.content.ContextCompat
import com.google.android.gms.location.LocationServices
import id.ac.ubharajaya.sistemakademik.ui.theme.SistemAkademikTheme
@@ -27,248 +44,328 @@ import org.json.JSONObject
import java.io.ByteArrayOutputStream
import java.net.HttpURLConnection
import java.net.URL
+import java.text.SimpleDateFormat
+import java.util.*
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)
+// =============== MAIN ACTIVITY & NAVIGATION ===============
+class MainActivity : ComponentActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ enableEdgeToEdge()
+ setContent {
+ SistemAkademikTheme {
+ AppNavigation(activity = this)
+ }
+ }
+ }
}
-fun kirimKeN8n(
- context: ComponentActivity,
- latitude: Double,
- longitude: Double,
- foto: Bitmap
-) {
+@Composable
+fun AppNavigation(activity: ComponentActivity) {
+ var currentScreen by remember { mutableStateOf("login") }
+ var npm by remember { mutableStateOf("") }
+ var nama by remember { mutableStateOf("") }
+ var loginTime by remember { mutableStateOf(0L) }
+
+ when (currentScreen) {
+ "login" -> LoginScreen(onLoginSuccess = { loggedInNpm, loggedInNama, time ->
+ npm = loggedInNpm
+ nama = loggedInNama
+ loginTime = time
+ currentScreen = "menu"
+ })
+ "menu" -> MenuScreen(
+ nama = nama,
+ npm = npm,
+ onAbsenClick = { currentScreen = "absensi" },
+ onRiwayatClick = { currentScreen = "riwayat" },
+ onLogout = {
+ currentScreen = "login"
+ npm = ""
+ nama = ""
+ }
+ )
+ "absensi" -> AbsensiScreen(activity = activity, npm = npm, nama = nama, loginTime = loginTime, onNavigateBack = {
+ currentScreen = "menu"
+ })
+ "riwayat" -> RiwayatScreen(onNavigateBack = { currentScreen = "menu" })
+ }
+}
+
+// =============== LOGIN SCREEN ===============
+
+@Composable
+fun LoginScreen(onLoginSuccess: (npm: String, nama: String, loginTime: Long) -> Unit) {
+ var npm by remember { mutableStateOf("") }
+ var nama by remember { mutableStateOf("") }
+ val context = LocalContext.current
+
+ Column(
+ modifier = Modifier.fillMaxSize().padding(32.dp),
+ verticalArrangement = Arrangement.Center,
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Text("Login Absensi", style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold)
+ Spacer(Modifier.height(32.dp))
+
+ OutlinedTextField(value = npm, onValueChange = { npm = it }, label = { Text("NPM") }, modifier = Modifier.fillMaxWidth(), singleLine = true, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number))
+ Spacer(Modifier.height(16.dp))
+
+ OutlinedTextField(value = nama, onValueChange = { nama = it }, label = { Text("Nama Lengkap") }, modifier = Modifier.fillMaxWidth(), singleLine = true, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text))
+ Spacer(Modifier.height(32.dp))
+
+ Button(
+ onClick = {
+ if (npm.isNotBlank() && nama.isNotBlank()) {
+ onLoginSuccess(npm, nama, System.currentTimeMillis())
+ } else {
+ Toast.makeText(context, "NPM dan Nama tidak boleh kosong", Toast.LENGTH_SHORT).show()
+ }
+ },
+ modifier = Modifier.fillMaxWidth().height(50.dp)
+ ) {
+ Text("LOGIN")
+ }
+ }
+}
+
+// =============== MENU SCREEN ===============
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun MenuScreen(nama: String, npm: String, onAbsenClick: () -> Unit, onRiwayatClick: () -> Unit, onLogout: () -> Unit) {
+ Scaffold(
+ topBar = {
+ CenterAlignedTopAppBar(
+ title = { Text("Menu Utama", color = Color.White) },
+ navigationIcon = { IconButton(onClick = onLogout) { Icon(Icons.Default.ArrowBack, "Logout", tint = Color.White) } },
+ colors = TopAppBarDefaults.centerAlignedTopAppBarColors(containerColor = Color(0xFF388E3C))
+ )
+ }
+ ) { innerPadding ->
+ Column(
+ modifier = Modifier.padding(innerPadding).fillMaxSize().padding(16.dp),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Spacer(modifier = Modifier.height(16.dp))
+ Card(modifier = Modifier.fillMaxWidth(), elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)) {
+ Column(
+ modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Text("Selamat Datang,", style = MaterialTheme.typography.bodyLarge, color = Color.Gray)
+ Spacer(modifier = Modifier.height(4.dp))
+ Text(nama, fontWeight = FontWeight.Bold, fontSize = 22.sp)
+ Text("($npm)", fontSize = 16.sp, color = Color.Gray)
+ }
+ }
+ Spacer(modifier = Modifier.height(32.dp))
+ MenuButton(text = "Absen Kehadiran", icon = Icons.Filled.CameraAlt, onClick = onAbsenClick)
+ Spacer(modifier = Modifier.height(16.dp))
+ MenuButton(text = "Riwayat Absensi", icon = Icons.Filled.History, onClick = onRiwayatClick)
+ }
+ }
+}
+
+@Composable
+fun MenuButton(text: String, icon: ImageVector, onClick: () -> Unit) {
+ Button(
+ onClick = onClick,
+ modifier = Modifier.fillMaxWidth().height(55.dp),
+ shape = RoundedCornerShape(12.dp),
+ colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.primary)
+ ) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Icon(icon, contentDescription = null, modifier = Modifier.size(24.dp))
+ Spacer(Modifier.width(12.dp))
+ Text(text, fontSize = 16.sp)
+ }
+ }
+}
+
+// =============== RIWAYAT SCREEN (PLACEHOLDER) ===============
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun RiwayatScreen(onNavigateBack: () -> Unit) {
+ Scaffold(
+ topBar = {
+ CenterAlignedTopAppBar(
+ title = { Text("Riwayat Absensi", color = Color.White) },
+ navigationIcon = { IconButton(onClick = onNavigateBack) { Icon(Icons.Default.ArrowBack, "Kembali", tint = Color.White) } },
+ colors = TopAppBarDefaults.centerAlignedTopAppBarColors(containerColor = Color(0xFF388E3C))
+ )
+ }
+ ) { innerPadding ->
+ Box(modifier = Modifier.padding(innerPadding).fillMaxSize(), contentAlignment = Alignment.Center) {
+ Text("Halaman Riwayat Absensi (Segera Hadir)")
+ }
+ }
+}
+
+
+// =============== ABSENSI SCREEN ===============
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun AbsensiScreen(activity: ComponentActivity, npm: String, nama: String, loginTime: Long, onNavigateBack: () -> Unit) {
+ val context = LocalContext.current
+ var lokasiStatus by remember { mutableStateOf("Mengecek izin lokasi...") }
+ var isLocationReady by remember { mutableStateOf(false) }
+ var foto by remember { mutableStateOf(null) }
+ var isUploading by remember { mutableStateOf(false) }
+ var userLocation by remember { mutableStateOf(null) }
+ var mataKuliah by remember { mutableStateOf("") }
+
+ val fusedLocationClient = remember { LocationServices.getFusedLocationProviderClient(context) }
+
+ val cameraLauncher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
+ if (result.resultCode == Activity.RESULT_OK) {
+ val bitmap = result.data?.extras?.get("data") as? Bitmap
+ if (bitmap != null) foto = bitmap
+ }
+ }
+
+ val requestPermissionLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { permissions ->
+ if (permissions[Manifest.permission.ACCESS_FINE_LOCATION] == true) {
+ lokasiStatus = "Mendapatkan koordinat..."
+ try {
+ fusedLocationClient.lastLocation.addOnSuccessListener { loc ->
+ if (loc != null) {
+ userLocation = loc
+ isLocationReady = true
+ lokasiStatus = "Lokasi berhasil didapatkan"
+ } else {
+ isLocationReady = false
+ lokasiStatus = "Gagal mendapatkan lokasi. Pastikan GPS aktif."
+ }
+ }
+ } catch (e: SecurityException) {
+ isLocationReady = false
+ lokasiStatus = "Izin lokasi dicabut."
+ }
+ } else {
+ lokasiStatus = "Izin lokasi ditolak."
+ }
+ }
+
+ LaunchedEffect(Unit) { requestPermissionLauncher.launch(arrayOf(Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.CAMERA)) }
+
+ Scaffold(
+ topBar = {
+ CenterAlignedTopAppBar(
+ title = { Text("Absen Kehadiran", color = Color.White) },
+ navigationIcon = { IconButton(onClick = onNavigateBack) { Icon(Icons.Default.ArrowBack, "Kembali", tint = Color.White) } },
+ colors = TopAppBarDefaults.centerAlignedTopAppBarColors(containerColor = Color(0xFF388E3C))
+ )
+ }
+ ) { innerPadding ->
+ Column(
+ modifier = Modifier.padding(innerPadding).fillMaxSize().padding(16.dp),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ OutlinedTextField(
+ value = mataKuliah,
+ onValueChange = { mataKuliah = it },
+ label = { Text("Mata Kuliah") },
+ modifier = Modifier.fillMaxWidth(),
+ singleLine = true
+ )
+ Spacer(Modifier.height(16.dp))
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Icon(if (isLocationReady) Icons.Filled.CheckCircle else Icons.Filled.Close, "Status Lokasi", tint = if (isLocationReady) Color(0xFF2E7D32) else Color.Red, modifier = Modifier.size(18.dp))
+ Spacer(Modifier.width(8.dp))
+ Text(lokasiStatus, color = if (isLocationReady) Color.DarkGray else Color.Red, fontSize = 14.sp)
+ }
+ Spacer(Modifier.height(16.dp))
+ Box(Modifier.size(200.dp).clip(CircleShape).background(Color.LightGray), contentAlignment = Alignment.Center) {
+ if (foto != null) {
+ Image(foto!!.asImageBitmap(), "Foto Selfie", Modifier.fillMaxSize(), contentScale = ContentScale.Crop)
+ } else {
+ Icon(Icons.Filled.Person, "Placeholder", modifier = Modifier.size(70.dp), tint = Color.Gray)
+ }
+ }
+ Spacer(Modifier.height(24.dp))
+ Button(
+ onClick = { cameraLauncher.launch(Intent(MediaStore.ACTION_IMAGE_CAPTURE)) },
+ enabled = isLocationReady, // Button enabled only when location is ready
+ modifier = Modifier.fillMaxWidth(0.8f).height(50.dp)
+ ) {
+ Text("1. AMBIL FOTO")
+ }
+ Spacer(Modifier.height(16.dp))
+ if (isLocationReady && foto != null && mataKuliah.isNotBlank()) {
+ if (isUploading) {
+ CircularProgressIndicator()
+ } else {
+ Button(
+ onClick = {
+ isUploading = true
+ kirimKeServer(activity, npm, nama, userLocation?.latitude ?: 0.0, userLocation?.longitude ?: 0.0, foto!!, loginTime, mataKuliah) { success ->
+ if (success) {
+ Toast.makeText(activity, "Absensi Berhasil!", Toast.LENGTH_LONG).show()
+ onNavigateBack()
+ } else {
+ isUploading = false
+ }
+ }
+ },
+ modifier = Modifier.fillMaxWidth(0.8f).height(50.dp),
+ colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF1B5E20))
+ ) {
+ Text("2. KIRIM ABSENSI")
+ }
+ }
+ }
+ }
+ }
+}
+
+// =============== NETWORK & UTILS ===============
+
+fun kirimKeServer(activity: Activity, npm: String, nama: String, lat: Double, lon: Double, img: Bitmap, loginTime: Long, mataKuliah: String, onFinish: (Boolean) -> Unit) {
thread {
+ var success = 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.doOutput = true
val json = JSONObject().apply {
- put("npm", "12345")
- put("nama","Arif R D")
- put("latitude", latitude)
- put("longitude", longitude)
- put("timestamp", System.currentTimeMillis())
- put("foto_base64", bitmapToBase64(foto))
+ put("npm", npm)
+ put("nama", nama)
+ put("latitude", lat)
+ put("longitude", lon)
+ put("timestamp", loginTime)
+ put("mata_kuliah", mataKuliah)
+ put("foto_base64", bitmapToBase64(img))
}
- conn.outputStream.use {
- it.write(json.toString().toByteArray())
+ conn.outputStream.write(json.toString().toByteArray())
+
+ val code = conn.responseCode
+ success = code == 200
+ if (!success) {
+ activity.runOnUiThread {
+ Toast.makeText(activity, "Gagal mengirim absensi. Kode: $code", Toast.LENGTH_LONG).show()
+ }
}
-
- val responseCode = conn.responseCode
-
- context.runOnUiThread {
- Toast.makeText(
- context,
- if (responseCode == 200)
- "Absensi diterima server"
- else
- "Absensi ditolak server",
- Toast.LENGTH_SHORT
- ).show()
- }
-
- conn.disconnect()
-
- } catch (_: Exception) {
- context.runOnUiThread {
- Toast.makeText(
- context,
- "Gagal kirim ke server",
- Toast.LENGTH_SHORT
- ).show()
+ } catch (e: Exception) {
+ e.printStackTrace()
+ activity.runOnUiThread {
+ Toast.makeText(activity, "Error: Gagal terhubung ke server.", Toast.LENGTH_SHORT).show()
}
+ } finally {
+ activity.runOnUiThread { onFinish(success) }
}
}
}
-/* ================= ACTIVITY ================= */
-
-class MainActivity : ComponentActivity() {
-
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
- enableEdgeToEdge()
-
- setContent {
- SistemAkademikTheme {
- Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
- AbsensiScreen(
- modifier = Modifier.padding(innerPadding),
- activity = this
- )
- }
- }
- }
- }
-}
-
-/* ================= UI ================= */
-
-@Composable
-fun AbsensiScreen(
- modifier: Modifier = Modifier,
- activity: ComponentActivity
-) {
- val context = LocalContext.current
-
- var lokasi by remember { mutableStateOf("Koordinat: -") }
- var latitude by remember { mutableStateOf(null) }
- var longitude by remember { mutableStateOf(null) }
- var foto by remember { mutableStateOf(null) }
-
- 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
- ) {
-
- fusedLocationClient.lastLocation
- .addOnSuccessListener { location ->
- if (location != null) {
- latitude = location.latitude
- longitude = location.longitude
- lokasi =
- "Lat: ${location.latitude}\nLon: ${location.longitude}"
- } else {
- lokasi = "Lokasi tidak tersedia"
- }
- }
- .addOnFailureListener {
- lokasi = "Gagal mengambil lokasi"
- }
- }
-
- } else {
- Toast.makeText(
- context,
- "Izin lokasi ditolak",
- Toast.LENGTH_SHORT
- ).show()
- }
- }
-
- /* ===== Kamera ===== */
-
- val cameraLauncher =
- rememberLauncherForActivityResult(
- ActivityResultContracts.StartActivityForResult()
- ) { result ->
- if (result.resultCode == Activity.RESULT_OK) {
- val bitmap =
- result.data?.extras?.getParcelable("data", Bitmap::class.java)
- 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()
- }
- }
-
- /* ===== Request Awal ===== */
-
- LaunchedEffect(Unit) {
- locationPermissionLauncher.launch(
- Manifest.permission.ACCESS_FINE_LOCATION
- )
- }
-
- /* ===== UI ===== */
-
- Column(
- modifier = modifier
- .fillMaxSize()
- .padding(24.dp),
- verticalArrangement = Arrangement.Center
- ) {
-
- 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!!
- )
- } else {
- Toast.makeText(
- context,
- "Lokasi atau foto belum lengkap",
- Toast.LENGTH_SHORT
- ).show()
- }
- },
- modifier = Modifier.fillMaxWidth()
- ) {
- Text("Kirim Absensi")
- }
- }
+fun bitmapToBase64(bitmap: Bitmap): String {
+ val outputStream = ByteArrayOutputStream()
+ bitmap.compress(Bitmap.CompressFormat.JPEG, 70, outputStream)
+ return Base64.encodeToString(outputStream.toByteArray(), Base64.NO_WRAP)
}