From 21cb4efc2a89a4f2d6b42f01e58d5571576d6f08 Mon Sep 17 00:00:00 2001 From: Fadhlul Wafi Date: Tue, 13 Jan 2026 22:21:02 +0700 Subject: [PATCH] first commit --- .idea/deviceManager.xml | 13 + app/build.gradle.kts | 2 + app/src/main/AndroidManifest.xml | 5 +- .../ubharajaya/sistemakademik/MainActivity.kt | 543 +++++++++++------- 4 files changed, 339 insertions(+), 224 deletions(-) create mode 100644 .idea/deviceManager.xml 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) }