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(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") 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) }