first commit

This commit is contained in:
Fadhlul Wafi 2026-01-13 22:21:02 +07:00
parent 926d3e0a14
commit 21cb4efc2a
4 changed files with 339 additions and 224 deletions

13
.idea/deviceManager.xml generated Normal file
View File

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DeviceTable">
<option name="columnSorters">
<list>
<ColumnSorterState>
<option name="column" value="Name" />
<option name="order" value="ASCENDING" />
</ColumnSorterState>
</list>
</option>
</component>
</project>

View File

@ -42,6 +42,7 @@ android {
} }
dependencies { dependencies {
implementation("com.google.android.gms:play-services-location:21.0.1")
implementation(libs.androidx.core.ktx) implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.activity.compose) implementation(libs.androidx.activity.compose)
@ -51,6 +52,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:1.6.7")
// 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

@ -1,7 +1,10 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"> xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-feature android:name="android.hardware.camera" android:required="false" />
<uses-permission android:name="android.permission.INTERNET"/> <uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/> <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/> <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>

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.location.Location
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,12 +15,28 @@ 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.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.* 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.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.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.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.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import com.google.android.gms.location.LocationServices import com.google.android.gms.location.LocationServices
import id.ac.ubharajaya.sistemakademik.ui.theme.SistemAkademikTheme import id.ac.ubharajaya.sistemakademik.ui.theme.SistemAkademikTheme
@ -27,248 +44,328 @@ import org.json.JSONObject
import java.io.ByteArrayOutputStream import java.io.ByteArrayOutputStream
import java.net.HttpURLConnection import java.net.HttpURLConnection
import java.net.URL import java.net.URL
import java.text.SimpleDateFormat
import java.util.*
import kotlin.concurrent.thread import kotlin.concurrent.thread
/* ================= UTIL ================= */ // =============== MAIN ACTIVITY & NAVIGATION ===============
class MainActivity : ComponentActivity() {
fun bitmapToBase64(bitmap: Bitmap): String { override fun onCreate(savedInstanceState: Bundle?) {
val outputStream = ByteArrayOutputStream() super.onCreate(savedInstanceState)
bitmap.compress(Bitmap.CompressFormat.JPEG, 80, outputStream) enableEdgeToEdge()
return Base64.encodeToString(outputStream.toByteArray(), Base64.NO_WRAP) setContent {
SistemAkademikTheme {
AppNavigation(activity = this)
}
}
}
} }
fun kirimKeN8n( @Composable
context: ComponentActivity, fun AppNavigation(activity: ComponentActivity) {
latitude: Double, var currentScreen by remember { mutableStateOf("login") }
longitude: Double, var npm by remember { mutableStateOf("") }
foto: Bitmap 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<Bitmap?>(null) }
var isUploading by remember { mutableStateOf(false) }
var userLocation by remember { mutableStateOf<Location?>(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 { thread {
var success = 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")
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("latitude", latitude) put("latitude", lat)
put("longitude", longitude) put("longitude", lon)
put("timestamp", System.currentTimeMillis()) put("timestamp", loginTime)
put("foto_base64", bitmapToBase64(foto)) put("mata_kuliah", mataKuliah)
put("foto_base64", bitmapToBase64(img))
} }
conn.outputStream.use { conn.outputStream.write(json.toString().toByteArray())
it.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()
}
} }
} catch (e: Exception) {
val responseCode = conn.responseCode e.printStackTrace()
activity.runOnUiThread {
context.runOnUiThread { Toast.makeText(activity, "Error: Gagal terhubung ke server.", Toast.LENGTH_SHORT).show()
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()
} }
} finally {
activity.runOnUiThread { onFinish(success) }
} }
} }
} }
/* ================= ACTIVITY ================= */ fun bitmapToBase64(bitmap: Bitmap): String {
val outputStream = ByteArrayOutputStream()
class MainActivity : ComponentActivity() { bitmap.compress(Bitmap.CompressFormat.JPEG, 70, outputStream)
return Base64.encodeToString(outputStream.toByteArray(), Base64.NO_WRAP)
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<Double?>(null) }
var longitude by remember { mutableStateOf<Double?>(null) }
var foto by remember { mutableStateOf<Bitmap?>(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")
}
}
} }