Compare commits

..

No commits in common. "a389bd1432d2db23e0f06767de69e4b24a3b45f3" and "741a6a8fd856f89cb17bb60c3cb7d5d94287c0dc" have entirely different histories.

4 changed files with 496 additions and 305 deletions

View File

@ -1,50 +0,0 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="ComposePreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="ComposePreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="ComposePreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="ComposePreviewNotSupportedInUnitTestFiles" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="GlancePreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="GlancePreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="GlancePreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="GlancePreviewNotSupportedInUnitTestFiles" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewAnnotationInFunctionWithParameters" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewApiLevelMustBeValid" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewDeviceShouldUseNewSpec" enabled="true" level="WEAK WARNING" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewFontScaleMustBeGreaterThanZero" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewMultipleParameterProviders" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewParameterProviderOnFirstParameter" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewPickerAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
</profile>
</component>

View File

@ -1,4 +0,0 @@
kotlin version: 2.0.21
error message: The daemon has terminated unexpectedly on startup attempt #1 with error code: 0. The daemon process output:
1. Kotlin compile daemon is ready

View File

@ -1,14 +1,3 @@
📱 README UAS - Aplikasi Absensi Akademik Berbasis Koordinat dan Foto
Nama: Muhammad Yusron Amrullah
Pemograman Perangkat Bergerak Dibuat: 14 Januari 2026
Status: ✅ Project Dikembangkan (Bukan Dibuat Ulang)
Tujuan: Tugas Project Akhir Mata Kuliah Pemrograman Mobile
Pengerjaan ini dibantu dengan ai
🎯 Ringkasan Proyek
Proyek ini adalah pengembangan dari Starter Project yang sudah disediakan, bukan membuat dari awal. Kami mengambil codebase yang ada dan mengembangkannya dengan fitur-fitur baru, perbaikan bug, dan peningkatan UI/UX.
# 📱 Aplikasi Absensi Akademik Berbasis Koordinat dan Foto (Mobile) # 📱 Aplikasi Absensi Akademik Berbasis Koordinat dan Foto (Mobile)
## 📌 Deskripsi Proyek ## 📌 Deskripsi Proyek

View File

@ -5,7 +5,6 @@ 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
@ -15,15 +14,12 @@ 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.animation.AnimatedVisibility
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.spring
import androidx.compose.animation.fadeIn
import androidx.compose.animation.slideInVertically
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
@ -33,88 +29,71 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Alignment 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.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale 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.font.FontWeight
import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.ImeAction
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.compose.ui.unit.sp
import androidx.core.content.ContextCompat import androidx.core.app.ActivityCompat
import androidx.navigation.NavController
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import com.google.android.gms.location.LocationServices import com.google.android.gms.location.LocationServices
import com.google.android.gms.location.Priority import com.google.android.gms.location.Priority
import id.ac.ubharajaya.sistemakademik.ui.theme.SistemAkademikTheme import id.ac.ubharajaya.sistemakademik.ui.theme.SistemAkademikTheme
import kotlinx.coroutines.delay import org.json.JSONArray
import org.json.JSONObject 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
import kotlin.math.atan2
import kotlin.math.cos
import kotlin.math.sin
import kotlin.math.sqrt
/* ================= KONFIGURASI LOKASI ================= */ /* ================= DATA CLASS ================= */
data class AttendanceRecord(val timestamp: Long, val status: String)
const val KAMPUS_LAT = -6.222967558410948 /* ================= UTILS ================= */
const val KAMPUS_LON = 107.00931291609834
const val MAX_RADIUS = 50 // meter
fun hitungJarak(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Float {
val hasil = FloatArray(1)
Location.distanceBetween(lat1, lon1, lat2, lon2, hasil)
return hasil[0]
}
/* ================= UTIL ================= */
fun bitmapToBase64(bitmap: Bitmap): String { fun bitmapToBase64(bitmap: Bitmap): String {
val outputStream = ByteArrayOutputStream() val outputStream = ByteArrayOutputStream()
bitmap.compress(Bitmap.CompressFormat.JPEG, 80, outputStream) bitmap.compress(Bitmap.CompressFormat.JPEG, 70, outputStream)
return Base64.encodeToString(outputStream.toByteArray(), Base64.NO_WRAP) return Base64.encodeToString(outputStream.toByteArray(), Base64.NO_WRAP)
} }
fun kirimKeN8n( object LocationUtils {
context: ComponentActivity, npm: String, nama: String, mataKuliah: String, private const val KAMPUS_LATITUDE = -6.2576
latitude: Double, longitude: Double, foto: Bitmap, status: String, private const val KAMPUS_LONGITUDE = 106.9746
onFinished: () -> Unit private const val MAX_RADIUS_METERS = 100.0 // Radius 100 meter
) {
thread { fun isWithinCampusRadius(lat: Double, lon: Double): Boolean {
try { val earthRadius = 6371000.0 // in meters
val url = URL("https://n8n.lab.ubharajaya.ac.id/webhook/23c6993d-1792-48fb-ad1c-ffc78a3e6254") val dLat = Math.toRadians(lat - KAMPUS_LATITUDE)
val conn = url.openConnection() as HttpURLConnection val dLon = Math.toRadians(lon - KAMPUS_LONGITUDE)
conn.requestMethod = "POST" val lat1 = Math.toRadians(KAMPUS_LATITUDE)
conn.setRequestProperty("Content-Type", "application/json") val lat2 = Math.toRadians(lat)
conn.doOutput = true
val json = JSONObject().apply { val a = sin(dLat / 2) * sin(dLat / 2) +
put("npm", npm); put("nama", nama); put("mata_kuliah", mataKuliah) sin(dLon / 2) * sin(dLon / 2) * cos(lat1) * cos(lat2)
put("latitude", latitude); put("longitude", longitude) val c = 2 * atan2(sqrt(a), sqrt(1 - a))
put("timestamp", System.currentTimeMillis()); put("status", status)
put("foto_base64", bitmapToBase64(foto)) return (earthRadius * c) <= MAX_RADIUS_METERS
}
conn.outputStream.use { it.write(json.toString().toByteArray()) }
val responseCode = conn.responseCode
context.runOnUiThread {
Toast.makeText(
context,
if (responseCode == 200) "Absensi $status berhasil dikirim" else "Server menolak absensi",
Toast.LENGTH_SHORT
).show()
}
conn.disconnect()
} catch (_: Exception) {
context.runOnUiThread {
Toast.makeText(context, "Gagal kirim ke server", Toast.LENGTH_SHORT).show()
}
} finally {
context.runOnUiThread(onFinished)
}
} }
} }
/* ================= ACTIVITY ================= */
/* ================= ACTIVITY ================= */ /* ================= MAIN ACTIVITY ================= */
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
@ -122,229 +101,506 @@ class MainActivity : ComponentActivity() {
enableEdgeToEdge() enableEdgeToEdge()
setContent { setContent {
SistemAkademikTheme { SistemAkademikTheme {
AbsensiScreen(activity = this) AppNavigation(activity = this)
} }
} }
} }
} }
/* ================= UI UTAMA ================= */ /* ================= NAVIGATION ================= */
@Composable @Composable
fun AbsensiScreen(activity: ComponentActivity) { fun AppNavigation(activity: ComponentActivity) {
val context = LocalContext.current val navController = rememberNavController()
// States NavHost(navController = navController, startDestination = "login") {
composable("login") {
LoginScreen(navController = navController)
}
composable(
route = "menu/{npm}/{nama}",
arguments = listOf(
navArgument("npm") { type = NavType.StringType },
navArgument("nama") { type = NavType.StringType }
)
) { backStackEntry ->
val npm = backStackEntry.arguments?.getString("npm") ?: "N/A"
val nama = backStackEntry.arguments?.getString("nama") ?: "N/A"
MenuScreen(navController = navController, npm = npm, nama = nama)
}
composable(
route = "absensi/{npm}/{nama}",
arguments = listOf(
navArgument("npm") { type = NavType.StringType },
navArgument("nama") { type = NavType.StringType }
)
) { backStackEntry ->
val npm = backStackEntry.arguments?.getString("npm") ?: "N/A"
val nama = backStackEntry.arguments?.getString("nama") ?: "N/A"
AbsensiScreen(
activity = activity,
navController = navController,
npm = npm,
nama = nama
)
}
composable(
route = "history/{npm}",
arguments = listOf(navArgument("npm") { type = NavType.StringType })
) { backStackEntry ->
val npm = backStackEntry.arguments?.getString("npm") ?: "N/A"
HistoryScreen(navController = navController, npm = npm, activity = activity)
}
}
}
/* ================= UI SCREENS ================= */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LoginScreen(navController: NavController) {
var npm by remember { mutableStateOf("") } var npm by remember { mutableStateOf("") }
var nama by remember { mutableStateOf("") } var nama by remember { mutableStateOf("") }
var mataKuliah by remember { mutableStateOf("") }
var lokasi by remember { mutableStateOf<String?>(null) } Scaffold(
var latitude by remember { mutableStateOf<Double?>(null) } topBar = {
var longitude by remember { mutableStateOf<Double?>(null) } CenterAlignedTopAppBar(
title = { Text("Login Absensi", color = Color.White, fontSize = 18.sp) },
colors = TopAppBarDefaults.centerAlignedTopAppBarColors(containerColor = Color(0xFF2E7D32))
)
}
) { innerPadding ->
Column(
modifier = Modifier
.padding(innerPadding)
.fillMaxSize()
.background(Color(0xFFF8FBF8))
.padding(horizontal = 32.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Icon(Icons.Default.Person, contentDescription = null, modifier = Modifier.size(100.dp), tint = Color(0xFF2E7D32))
Spacer(modifier = Modifier.height(40.dp))
OutlinedTextField(
value = npm,
onValueChange = { npm = it },
label = { Text("NPM") },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number, imeAction = ImeAction.Next),
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(16.dp))
OutlinedTextField(
value = nama,
onValueChange = { nama = it },
label = { Text("Nama Lengkap") },
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(24.dp))
Button(
onClick = {
if (npm.isNotBlank() && nama.isNotBlank()) {
navController.navigate("menu/$npm/$nama")
}
},
modifier = Modifier.fillMaxWidth().height(50.dp),
enabled = npm.isNotBlank() && nama.isNotBlank(),
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF1B5E20)),
shape = RoundedCornerShape(10.dp)
) {
Text("LOGIN", fontWeight = FontWeight.Bold, color = Color.White)
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MenuScreen(navController: NavController, npm: String, nama: String) {
Scaffold(
topBar = {
CenterAlignedTopAppBar(
title = { Text("Menu Utama", color = Color.White, fontSize = 18.sp) },
colors = TopAppBarDefaults.centerAlignedTopAppBarColors(containerColor = Color(0xFF2E7D32)),
navigationIcon = {
IconButton(onClick = { navController.popBackStack() }) {
Icon(Icons.Default.ArrowBack, contentDescription = "Logout", tint = Color.White)
}
}
)
}
) { innerPadding ->
Column(
modifier = Modifier
.padding(innerPadding)
.fillMaxSize()
.background(Color(0xFFF8FBF8))
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text("Selamat Datang,", style = MaterialTheme.typography.bodyLarge)
Text("$nama ($npm)", fontWeight = FontWeight.Bold, fontSize = 20.sp)
Spacer(modifier = Modifier.height(32.dp))
// Tombol Absensi
Button(
onClick = { navController.navigate("absensi/$npm/$nama") },
modifier = Modifier
.fillMaxWidth()
.height(60.dp),
shape = RoundedCornerShape(12.dp)
) {
Icon(Icons.Filled.CameraAlt, contentDescription = null, modifier = Modifier.size(24.dp))
Spacer(modifier = Modifier.width(8.dp))
Text("Absen Kehadiran", fontSize = 16.sp)
}
Spacer(modifier = Modifier.height(16.dp))
// Tombol Riwayat
Button(
onClick = { navController.navigate("history/$npm") },
modifier = Modifier
.fillMaxWidth()
.height(60.dp),
shape = RoundedCornerShape(12.dp)
) {
Icon(Icons.Default.History, contentDescription = null, modifier = Modifier.size(24.dp))
Spacer(modifier = Modifier.width(8.dp))
Text("Riwayat Absensi", fontSize = 16.sp)
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AbsensiScreen(activity: ComponentActivity, navController: NavController, npm: String, nama: String) {
val context = LocalContext.current
var lokasiStatus by remember { mutableStateOf("Mengecek Lokasi...") }
var locationAvailable by remember { mutableStateOf(false) }
var foto by remember { mutableStateOf<Bitmap?>(null) } var foto by remember { mutableStateOf<Bitmap?>(null) }
var isUploading by remember { mutableStateOf(false) } var isUploading by remember { mutableStateOf(false) }
var isFetchingLocation by remember { mutableStateOf(false) } var latitude by remember { mutableStateOf<Double?>(null) }
var longitude by remember { mutableStateOf<Double?>(null) }
var matakuliah by remember { mutableStateOf("") }
val fusedLocationClient = remember { LocationServices.getFusedLocationProviderClient(context) } val fusedLocationClient = remember { LocationServices.getFusedLocationProviderClient(context) }
fun requestLocationUpdate() { val cameraLauncher = rememberLauncherForActivityResult(
if (ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED) { ActivityResultContracts.StartActivityForResult()
lokasi = "Izin lokasi belum diberikan." ) { result ->
return
}
isFetchingLocation = true
lokasi = "Mencari lokasi..."
fusedLocationClient.getCurrentLocation(Priority.PRIORITY_HIGH_ACCURACY, null)
.addOnSuccessListener { location: Location? ->
if (location != null) {
latitude = location.latitude
longitude = location.longitude
lokasi = "Lat: ${String.format("%.6f", location.latitude)}\nLon: ${String.format("%.6f", location.longitude)}"
} else {
lokasi = "Gagal mendapatkan lokasi. Coba lagi."
}
isFetchingLocation = false
}
.addOnFailureListener {
lokasi = "Gagal mendapatkan lokasi. Coba lagi."
isFetchingLocation = false
}
}
val locationPermissionLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
if (granted) requestLocationUpdate() else {
Toast.makeText(context, "Izin lokasi ditolak", Toast.LENGTH_SHORT).show()
lokasi = "Izin lokasi ditolak."
}
}
val cameraLauncher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode == Activity.RESULT_OK) { if (result.resultCode == Activity.RESULT_OK) {
val bitmap = result.data?.extras?.get("data") as? Bitmap val bitmap = result.data?.extras?.get("data") as? Bitmap
if (bitmap != null) foto = bitmap else Toast.makeText(context, "Gagal mengambil foto", Toast.LENGTH_SHORT).show() if (bitmap != null) foto = bitmap
} }
} }
val cameraPermissionLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> val requestPermissionLauncher = rememberLauncherForActivityResult(
if (granted) cameraLauncher.launch(Intent(MediaStore.ACTION_IMAGE_CAPTURE)) ActivityResultContracts.RequestMultiplePermissions()
else Toast.makeText(context, "Izin kamera ditolak", Toast.LENGTH_SHORT).show() ) { permissions ->
if (permissions[Manifest.permission.ACCESS_FINE_LOCATION] == true) {
if (ActivityCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED) {
fusedLocationClient.getCurrentLocation(Priority.PRIORITY_HIGH_ACCURACY, null)
.addOnSuccessListener { loc ->
if (loc != null) {
latitude = loc.latitude
longitude = loc.longitude
locationAvailable = true
lokasiStatus = if (LocationUtils.isWithinCampusRadius(loc.latitude, loc.longitude)) {
"Di Dalam Area Kampus"
} else {
"Di Luar Area Kampus"
}
} else {
lokasiStatus = "Gagal Mendapatkan Lokasi"
locationAvailable = false
}
}.addOnFailureListener {
lokasiStatus = "Gagal Mendapatkan Lokasi"
locationAvailable = false
}
}
} else {
lokasiStatus = "Izin lokasi ditolak."
locationAvailable = false
}
} }
LaunchedEffect(Unit) { locationPermissionLauncher.launch(Manifest.permission.ACCESS_FINE_LOCATION) } LaunchedEffect(Unit) {
requestPermissionLauncher.launch(
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) { arrayOf(Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.CAMERA)
val gradient = Brush.linearGradient(
colors = listOf(MaterialTheme.colorScheme.primary.copy(alpha = 0.18f), Color.Transparent),
start = Offset(0f, 0f),
end = Offset(Float.POSITIVE_INFINITY, Float.POSITIVE_INFINITY)
) )
LazyColumn(
modifier = Modifier.fillMaxSize().background(gradient).padding(WindowInsets.systemBars.asPaddingValues()),
horizontalAlignment = Alignment.CenterHorizontally,
contentPadding = PaddingValues(horizontal = 24.dp, vertical = 16.dp)
) {
item { AnimatedHeader(delay = 0) }
item { Spacer(modifier = Modifier.height(24.dp)) }
item { AnimatedInputCard(npm, nama, mataKuliah, { npm = it }, { nama = it }, { mataKuliah = it }) }
item { Spacer(modifier = Modifier.height(24.dp)) }
item {
AnimatedActionSection(isFetchingLocation, { requestLocationUpdate() }, { cameraPermissionLauncher.launch(Manifest.permission.CAMERA) }, lokasi)
} }
item { Spacer(modifier = Modifier.height(24.dp)) }
item { AnimatedPhotoPreview(foto) } Scaffold(
item { Spacer(modifier = Modifier.height(24.dp)) } topBar = {
CenterAlignedTopAppBar(
title = { Text("Absen Kehadiran", color = Color.White, fontSize = 18.sp) },
navigationIcon = {
IconButton(onClick = { navController.popBackStack() }) {
Icon(Icons.Default.ArrowBack, contentDescription = "Kembali", tint = Color.White)
}
},
colors = TopAppBarDefaults.centerAlignedTopAppBarColors(containerColor = Color(0xFF2E7D32))
)
}
) { innerPadding ->
Column(
modifier = Modifier
.padding(innerPadding)
.fillMaxSize()
.background(Color(0xFFF8FBF8))
.padding(horizontal = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.height(16.dp))
Text("Selamat Datang,", style = MaterialTheme.typography.bodyLarge)
Text("$nama ($npm)", fontWeight = FontWeight.Bold, fontSize = 18.sp)
Spacer(modifier = Modifier.height(16.dp))
item { OutlinedTextField(
val canSubmit = npm.isNotEmpty() && nama.isNotEmpty() && mataKuliah.isNotEmpty() && latitude != null && longitude != null && foto != null value = matakuliah,
AnimatedSubmitButton( onValueChange = { matakuliah = it },
enabled = canSubmit && !isUploading, label = { Text("Mata Kuliah") },
isUploading = isUploading, singleLine = true,
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(16.dp))
Row(
modifier = Modifier.padding(top = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Status Lokasi: $lokasiStatus ",
style = MaterialTheme.typography.bodyMedium,
color = if (locationAvailable) Color(0xFF2E7D32) else Color.Red
)
if (locationAvailable) {
Icon(Icons.Default.CheckCircle, "OK", tint = Color(0xFF2E7D32), modifier = Modifier.size(16.dp))
} else {
Icon(Icons.Default.Close, "Error", tint = Color.Red, modifier = Modifier.size(16.dp))
}
}
Spacer(modifier = Modifier.height(20.dp))
Text("Ambil Foto Selfie", fontWeight = FontWeight.SemiBold, fontSize = 16.sp)
Spacer(modifier = Modifier.height(16.dp))
Box(
modifier = Modifier
.size(220.dp)
.clip(CircleShape)
.background(Color(0xFFE0E0E0)),
contentAlignment = Alignment.Center
) {
if (foto != null) {
Image(
bitmap = foto!!.asImageBitmap(),
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
} else {
Icon(Icons.Default.Person, null, modifier = Modifier.size(80.dp), tint = Color.White)
}
}
Spacer(modifier = Modifier.height(40.dp))
Button(
onClick = {
val intent = Intent(MediaStore.ACTION_IMAGE_CAPTURE)
cameraLauncher.launch(intent)
},
modifier = Modifier.fillMaxWidth(0.7f).height(50.dp),
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF4CAF50)),
shape = RoundedCornerShape(10.dp)
) {
Text("AMBIL FOTO", fontWeight = FontWeight.Bold, color = Color.White)
}
Spacer(modifier = Modifier.height(16.dp))
Button(
onClick = { onClick = {
isUploading = true isUploading = true
val jarak = hitungJarak(latitude!!, longitude!!, KAMPUS_LAT, KAMPUS_LON) if (latitude != null && longitude != null && foto != null) {
val statusAbsen = if (jarak <= MAX_RADIUS) "HADIR" else "DITOLAK" kirimKeServer(activity, npm, nama, matakuliah, latitude!!, longitude!!, foto!!) {
if (statusAbsen == "HADIR") {
kirimKeN8n(activity, npm, nama, mataKuliah, latitude!!, longitude!!, foto!!, statusAbsen) { isUploading = false }
} else {
val jarakKm = String.format("%.2f", jarak / 1000)
Toast.makeText(context, "Absensi DITOLAK: Anda berada ${jarakKm}km dari kampus.", Toast.LENGTH_LONG).show()
isUploading = false isUploading = false
if (it) navController.popBackStack()
}
} else {
isUploading = false
Toast.makeText(context, "Lokasi atau foto belum siap.", Toast.LENGTH_LONG).show()
}
},
modifier = Modifier.fillMaxWidth(0.7f).height(50.dp),
enabled = foto != null && locationAvailable && matakuliah.isNotBlank() && !isUploading,
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF1B5E20)),
shape = RoundedCornerShape(10.dp)
) {
Text("KIRIM ABSENSI", fontWeight = FontWeight.Bold)
}
if (isUploading) {
Spacer(modifier = Modifier.height(16.dp))
CircularProgressIndicator(color = Color(0xFF2E7D32))
} }
} }
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun HistoryScreen(navController: NavController, npm: String, activity: Activity) {
var historyList by remember { mutableStateOf<List<AttendanceRecord>>(emptyList()) }
var isLoading by remember { mutableStateOf(true) }
LaunchedEffect(npm) {
fetchHistoryFromServer(npm, activity) {
historyList = it
isLoading = false
}
}
Scaffold(
topBar = {
CenterAlignedTopAppBar(
title = { Text("Riwayat Absensi", color = Color.White) },
navigationIcon = {
IconButton(onClick = { navController.popBackStack() }) {
Icon(Icons.Default.ArrowBack, "Kembali", tint = Color.White)
}
},
colors = TopAppBarDefaults.centerAlignedTopAppBarColors(Color(0xFF2E7D32))
)
}
) { innerPadding ->
if (isLoading) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
} else if (historyList.isEmpty()) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text("Tidak ada riwayat absensi.")
}
} else {
LazyColumn(
modifier = Modifier
.padding(innerPadding)
.fillMaxSize()
.padding(16.dp)
) {
items(historyList) { record ->
val date = SimpleDateFormat("dd MMMM yyyy, HH:mm", Locale.getDefault()).format(Date(record.timestamp))
Card(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
elevation = CardDefaults.cardElevation(2.dp)
) {
Row(
modifier = Modifier
.padding(16.dp)
.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(text = date, fontWeight = FontWeight.Medium)
Text(
text = record.status,
color = if (record.status == "Sukses") Color(0xFF2E7D32) else Color.Red,
fontWeight = FontWeight.Bold
) )
} }
} }
} }
} }
/* ================= KOMPONEN UI DENGAN ANIMASI ================= */
@Composable
fun AnimatedHeader(delay: Long = 0) {
var visible by remember { mutableStateOf(false) }
LaunchedEffect(Unit) { delay(delay); visible = true }
AnimatedVisibility(visible = visible, enter = fadeIn(spring(stiffness = Spring.StiffnessLow)) + slideInVertically(spring(Spring.DampingRatioMediumBouncy, Spring.StiffnessLow), initialOffsetY = { -it })) {
Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.fillMaxWidth()) {
Text("E-ABSENSI", style = MaterialTheme.typography.displaySmall, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.primary)
Text("Universitas Bhayangkara Jakarta Raya", style = MaterialTheme.typography.titleMedium, color = Color.Gray)
} }
} }
} }
@Composable
fun AnimatedInputCard(
npm: String, nama: String, mataKuliah: String,
onNpmChange: (String) -> Unit, onNamaChange: (String) -> Unit, onMataKuliahChange: (String) -> Unit
) {
var cardVisible by remember { mutableStateOf(false) }
LaunchedEffect(Unit) { delay(200); cardVisible = true }
AnimatedVisibility(visible = cardVisible, enter = fadeIn(spring(stiffness = Spring.StiffnessLow)) + slideInVertically(spring(Spring.DampingRatioMediumBouncy, Spring.StiffnessLow), initialOffsetY = { it / 2 })) { /* ================= NETWORK LOGIC ================= */
Card(modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(16.dp), elevation = CardDefaults.cardElevation(4.dp)) {
Column(modifier = Modifier.padding(20.dp)) { fun kirimKeServer(activity: Activity, npm: String, nama: String, matakuliah: String, lat: Double, lon: Double, img: Bitmap, onFinish: (Boolean) -> Unit) {
Text("Data Mahasiswa", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) thread {
Spacer(modifier = Modifier.height(16.dp)) var isSuccess = false
OutlinedTextField(value = npm, onValueChange = onNpmChange, label = { Text("NPM") }, modifier = Modifier.fillMaxWidth(), keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), leadingIcon = { Icon(Icons.Default.Person, "NPM") }) try {
Spacer(modifier = Modifier.height(8.dp)) val url = URL("https://n8n.lab.ubharajaya.ac.id/webhook/23c6993d-1792-48fb-ad1c-ffc78a3e6254")
OutlinedTextField(value = nama, onValueChange = onNamaChange, label = { Text("Nama Lengkap") }, modifier = Modifier.fillMaxWidth(), keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), leadingIcon = { Icon(Icons.Default.Badge, "Nama") }) val conn = url.openConnection() as HttpURLConnection
Spacer(modifier = Modifier.height(8.dp)) conn.requestMethod = "POST"
OutlinedTextField(value = mataKuliah, onValueChange = onMataKuliahChange, label = { Text("Mata Kuliah") }, modifier = Modifier.fillMaxWidth(), keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), leadingIcon = { Icon(Icons.Default.Book, "Mata Kuliah") }) conn.setRequestProperty("Content-Type", "application/json")
conn.doOutput = true
val json = JSONObject().apply {
put("npm", npm)
put("nama", nama)
put("mata_kuliah", matakuliah) // FIX: Changed from "matakuliah" to "mata_kuliah"
put("latitude", lat)
put("longitude", lon)
put("timestamp", System.currentTimeMillis())
put("foto_base64", bitmapToBase64(img))
}
conn.outputStream.write(json.toString().toByteArray())
val code = conn.responseCode
isSuccess = code == 200
activity.runOnUiThread {
Toast.makeText(activity, if(isSuccess) "Absensi Sukses!" else "Absensi Gagal: Server Error $code", Toast.LENGTH_LONG).show()
onFinish(isSuccess)
}
} catch (e: Exception) {
activity.runOnUiThread {
Toast.makeText(activity, "Error: Gagal terhubung ke server.", Toast.LENGTH_SHORT).show()
onFinish(false)
} }
} }
} }
} }
@Composable fun fetchHistoryFromServer(npm: String, activity: Activity, onResult: (List<AttendanceRecord>) -> Unit) {
fun AnimatedActionSection(isFetchingLocation: Boolean, onRefresh: () -> Unit, onTakePhoto: () -> Unit, lokasi: String?) { thread {
var visible by remember { mutableStateOf(false) } try {
LaunchedEffect(Unit) { delay(400); visible = true } // PENTING: URL ini hanya contoh. Ganti dengan URL API Anda untuk mengambil riwayat.
val url = URL("https://n8n.lab.ubharajaya.ac.id/webhook-test/riwayat-absensi?npm=$npm")
val conn = url.openConnection() as HttpURLConnection
conn.requestMethod = "GET"
AnimatedVisibility(visible = visible, enter = fadeIn(spring(stiffness = Spring.StiffnessLow)) + slideInVertically(spring(Spring.DampingRatioMediumBouncy, Spring.StiffnessLow), initialOffsetY = { it / 2 })) { if (conn.responseCode == 200) {
Column { val response = conn.inputStream.bufferedReader().use { it.readText() }
StatusRowWithRefresh(icon = Icons.Default.LocationOn, title = "Lokasi GPS", status = lokasi ?: "Meminta izin...", isRefreshing = isFetchingLocation, onRefresh = onRefresh) val jsonArray = JSONArray(response)
Spacer(modifier = Modifier.height(16.dp)) val records = mutableListOf<AttendanceRecord>()
ActionButton(text = "Ambil Foto Kehadiran", icon = Icons.Default.CameraAlt, onClick = onTakePhoto) for (i in 0 until jsonArray.length()) {
val jsonObject = jsonArray.getJSONObject(i)
records.add(
AttendanceRecord(
timestamp = jsonObject.getLong("timestamp"),
status = jsonObject.getString("status")
)
)
} }
} activity.runOnUiThread { onResult(records) }
}
@Composable
fun AnimatedPhotoPreview(foto: Bitmap?) {
AnimatedVisibility(visible = foto != null, enter = fadeIn(spring(stiffness = Spring.StiffnessMedium)) + slideInVertically(spring(Spring.DampingRatioMediumBouncy, Spring.StiffnessMedium), initialOffsetY = { it / 2 })) {
Card(shape = RoundedCornerShape(16.dp), elevation = CardDefaults.cardElevation(4.dp)) {
Box(contentAlignment = Alignment.Center) {
Image(bitmap = foto!!.asImageBitmap(), "Preview Foto Absensi", modifier = Modifier.fillMaxWidth().height(250.dp).clip(RoundedCornerShape(16.dp)), contentScale = ContentScale.Crop)
Icon(Icons.Default.CheckCircle, "Foto Diambil", tint = Color.White.copy(alpha = 0.8f), modifier = Modifier.size(60.dp))
}
}
}
}
@Composable
fun AnimatedSubmitButton(enabled: Boolean, isUploading: Boolean, onClick: () -> Unit) {
var visible by remember { mutableStateOf(false) }
LaunchedEffect(Unit) { delay(600); visible = true }
AnimatedVisibility(visible = visible, enter = fadeIn(spring(stiffness = Spring.StiffnessLow)) + slideInVertically(spring(Spring.DampingRatioMediumBouncy, Spring.StiffnessLow), initialOffsetY = { it / 2 })) {
Button(onClick = onClick, enabled = enabled, modifier = Modifier.fillMaxWidth().height(56.dp)) {
if (isUploading) {
CircularProgressIndicator(color = Color.White, modifier = Modifier.size(24.dp))
} else { } else {
Text("KIRIM ABSENSI", fontWeight = FontWeight.Bold) activity.runOnUiThread { onResult(emptyList()) }
}
} catch (e: Exception) {
activity.runOnUiThread { onResult(emptyList()) }
} }
} }
} }
}
/* ================= KOMPONEN UI STANDAR ================= */
@Composable
fun StatusRowWithRefresh(icon: ImageVector, title: String, status: String, isRefreshing: Boolean, onRefresh: () -> Unit) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
Icon(icon, contentDescription = title, tint = MaterialTheme.colorScheme.primary)
Spacer(modifier = Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) {
Text(title, fontWeight = FontWeight.Bold, fontSize = 16.sp)
Text(status, style = MaterialTheme.typography.bodySmall, color = Color.Gray)
}
if (isRefreshing) {
CircularProgressIndicator(modifier = Modifier.size(24.dp))
} else {
IconButton(onClick = onRefresh) {
Icon(Icons.Default.Refresh, "Cari Ulang Lokasi")
}
}
}
}
@Composable
fun ActionButton(text: String, icon: ImageVector, onClick: () -> Unit) {
FilledTonalButton(onClick = onClick, modifier = Modifier.fillMaxWidth().height(56.dp), shape = RoundedCornerShape(16.dp)) {
Icon(icon, contentDescription = null)
Spacer(modifier = Modifier.width(12.dp))
Text(text, fontWeight = FontWeight.Bold)
}
}