2026-01-13 22:21:02 +07:00

372 lines
16 KiB
Kotlin

package id.ac.ubharajaya.sistemakademik
import android.Manifest
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
import android.widget.Toast
import androidx.activity.ComponentActivity
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
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
// =============== MAIN ACTIVITY & NAVIGATION ===============
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
SistemAkademikTheme {
AppNavigation(activity = this)
}
}
}
}
@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<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 {
var success = false
try {
val url = URL("https://n8n.lab.ubharajaya.ac.id/webhook/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", npm)
put("nama", nama)
put("latitude", lat)
put("longitude", lon)
put("timestamp", loginTime)
put("mata_kuliah", mataKuliah)
put("foto_base64", bitmapToBase64(img))
}
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()
}
}
} catch (e: Exception) {
e.printStackTrace()
activity.runOnUiThread {
Toast.makeText(activity, "Error: Gagal terhubung ke server.", Toast.LENGTH_SHORT).show()
}
} finally {
activity.runOnUiThread { onFinish(success) }
}
}
}
fun bitmapToBase64(bitmap: Bitmap): String {
val outputStream = ByteArrayOutputStream()
bitmap.compress(Bitmap.CompressFormat.JPEG, 70, outputStream)
return Base64.encodeToString(outputStream.toByteArray(), Base64.NO_WRAP)
}