669 lines
27 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.os.Bundle
import android.provider.MediaStore
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.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.DateRange
import androidx.compose.material.icons.filled.Home
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
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.core.content.ContextCompat
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import com.google.android.gms.location.LocationServices
import id.ac.ubharajaya.sistemakademik.config.AttendanceConfig
import id.ac.ubharajaya.sistemakademik.models.AttendanceState
import id.ac.ubharajaya.sistemakademik.models.AttendanceStatus
import id.ac.ubharajaya.sistemakademik.models.Course
import id.ac.ubharajaya.sistemakademik.models.LocationData
import id.ac.ubharajaya.sistemakademik.network.N8nService
import id.ac.ubharajaya.sistemakademik.ui.components.ErrorAlertCard
import id.ac.ubharajaya.sistemakademik.ui.components.LocationStatusCard
import id.ac.ubharajaya.sistemakademik.ui.components.PhotoPreviewCard
import id.ac.ubharajaya.sistemakademik.ui.components.SubmitButtonWithLoader
import id.ac.ubharajaya.sistemakademik.ui.screens.HistoryScreen
import id.ac.ubharajaya.sistemakademik.ui.screens.LoginScreen
import id.ac.ubharajaya.sistemakademik.ui.theme.SistemAkademikTheme
import id.ac.ubharajaya.sistemakademik.ui.viewmodel.UserViewModel
import id.ac.ubharajaya.sistemakademik.utils.AuthService
import id.ac.ubharajaya.sistemakademik.utils.CourseService
import id.ac.ubharajaya.sistemakademik.utils.ImageUtils
import id.ac.ubharajaya.sistemakademik.utils.LocationValidator
import kotlin.concurrent.thread
import androidx.compose.foundation.layout.heightIn
/* ================= ACTIVITY ================= */
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
SistemAkademikTheme {
AppNavigation(activity = this)
}
}
}
}
@Composable
fun AppNavigation(activity: ComponentActivity) {
val navController = rememberNavController()
val authService = remember { AuthService(activity) }
val userViewModel: UserViewModel = viewModel()
val startDestination = if (authService.isLoggedIn()) "main" else "login"
NavHost(navController = navController, startDestination = startDestination) {
composable("login") {
LoginScreen {
nama, npm ->
authService.login(nama, npm)
userViewModel.setUser(nama, npm)
navController.navigate("main") {
popUpTo("login") { inclusive = true }
}
}
}
composable("main") {
MainScreen(activity = activity, userViewModel = userViewModel, navController = navController)
}
}
}
@Composable
fun MainScreen(activity: ComponentActivity, userViewModel: UserViewModel, navController: NavController) {
val mainNavController = rememberNavController()
Scaffold(
bottomBar = {
BottomNavigationBar(navController = mainNavController)
}
) {
NavHost(
navController = mainNavController,
startDestination = "absensi",
modifier = Modifier.padding(it)
) {
composable("absensi") {
AbsensiScreen(activity = activity, userViewModel = userViewModel, appNavController = navController)
}
composable("riwayat") {
HistoryScreen()
}
}
}
}
@Composable
fun BottomNavigationBar(navController: androidx.navigation.NavController) {
val items = listOf(
"absensi" to Icons.Default.Home,
"riwayat" to Icons.Default.DateRange
)
var selectedItem by remember { mutableStateOf("absensi") }
NavigationBar {
items.forEach { (route, icon) ->
NavigationBarItem(
icon = { Icon(icon, contentDescription = route) },
label = { Text(route) },
selected = selectedItem == route,
onClick = {
selectedItem = route
navController.navigate(route) {
popUpTo(navController.graph.startDestinationId) {
saveState = true
}
launchSingleTop = true
restoreState = true
}
}
)
}
}
}
/* ================= UI ================= */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AbsensiScreen(
modifier: Modifier = Modifier,
activity: ComponentActivity,
userViewModel: UserViewModel,
appNavController: NavController
) {
val context = LocalContext.current
val authService = remember { AuthService(context) }
// State management
var state by remember {
mutableStateOf(
AttendanceState()
)
}
val studentName by userViewModel.nama
val studentNpm by userViewModel.npm
var isEditing by remember { mutableStateOf(false) }
var selectedStatus by remember { mutableStateOf(AttendanceStatus.PRESENT) }
var courses by remember { mutableStateOf<List<Course>>(emptyList()) }
var selectedCourse by remember { mutableStateOf<Course?>(null) }
var showCourseSelector by remember { mutableStateOf(false) }
var searchQuery by remember { mutableStateOf("") }
val fusedLocationClient = LocationServices.getFusedLocationProviderClient(context)
val n8nService = remember { N8nService(activity) }
val courseService = remember { CourseService(context) }
// Initialize courses
LaunchedEffect(Unit) {
courseService.initializeSampleData()
courses = courseService.getCourses()
selectedCourse = courseService.getSelectedCourse() ?: courses.firstOrNull()
}
// Update location when location data changes
LaunchedEffect(state.location, selectedStatus) {
if (selectedStatus == AttendanceStatus.PRESENT) {
state = if (state.location != null) {
state.copy(
validationResult = state.validationResult.copy(
isValid = true, // Always valid for "Hadir" to allow submission
message = "Lokasi valid untuk status Hadir"
)
)
} else {
state.copy(
validationResult = state.validationResult.copy(
isValid = false,
message = "Mencari lokasi..."
)
)
}
} else {
state = state.copy(
validationResult = state.validationResult.copy(
isValid = true,
message = "Tidak perlu validasi lokasi untuk status ini"
)
)
}
}
/* ===== Permission Launchers ===== */
val locationPermissionLauncher =
rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission()
) { granted ->
if (granted) {
state = state.copy(isLoadingLocation = true)
if (
ContextCompat.checkSelfPermission(
context,
Manifest.permission.ACCESS_FINE_LOCATION
) == PackageManager.PERMISSION_GRANTED
) {
fusedLocationClient.lastLocation
.addOnSuccessListener { location ->
if (location != null) {
state = state.copy(
location = LocationData(
latitude = location.latitude,
longitude = location.longitude,
accuracy = location.accuracy
),
isLoadingLocation = false,
isLocationPermissionGranted = true
)
} else {
state = state.copy(
errorMessage = "Lokasi tidak tersedia. Pastikan GPS aktif.",
isLoadingLocation = false
)
}
}
.addOnFailureListener {
state = state.copy(
errorMessage = "Gagal mengambil lokasi: ${it.message}",
isLoadingLocation = false
)
}
}
} else {
state = state.copy(
errorMessage = "Izin lokasi ditolak. Aktifkan di pengaturan aplikasi."
)
Toast.makeText(
context,
"Izin lokasi ditolak",
Toast.LENGTH_SHORT
).show()
}
}
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) {
state = state.copy(foto = bitmap)
Toast.makeText(
context,
"✓ Foto berhasil diambil",
Toast.LENGTH_SHORT
).show()
}
}
}
val cameraPermissionLauncher =
rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission()
) { granted ->
if (granted) {
state = state.copy(isCameraPermissionGranted = true)
val intent = Intent(MediaStore.ACTION_IMAGE_CAPTURE)
cameraLauncher.launch(intent)
} else {
state = state.copy(
errorMessage = "Izin kamera ditolak. Aktifkan di pengaturan aplikasi."
)
Toast.makeText(
context,
"Izin kamera ditolak",
Toast.LENGTH_SHORT
).show()
}
}
/* ===== Request Awal ===== */
LaunchedEffect(Unit) {
locationPermissionLauncher.launch(
Manifest.permission.ACCESS_FINE_LOCATION
)
}
fun getStatusLabel(status: AttendanceStatus): String {
return when (status) {
AttendanceStatus.PRESENT -> "Hadir"
AttendanceStatus.SICK -> "Sakit"
AttendanceStatus.EXCUSED -> "Izin"
else -> status.name
}
}
/* ===== UI ===== */
Column(
modifier = modifier
.fillMaxSize()
.padding(16.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
text = "Absensi Akademik",
style = MaterialTheme.typography.headlineSmall
)
// Error Alert
ErrorAlertCard(
message = state.errorMessage,
onDismiss = {
state = state.copy(errorMessage = null)
}
)
// Student and Course Information Card
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer),
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
if (isEditing) {
OutlinedTextField(
value = studentName ?: "",
onValueChange = { userViewModel.nama.value = it },
label = { Text("Nama Mahasiswa") },
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = studentNpm ?: "",
onValueChange = { userViewModel.npm.value = it },
label = { Text("NPM") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.fillMaxWidth()
)
Button(onClick = { isEditing = false }) {
Text("Simpan")
}
} else {
Text(
text = "Informasi Mahasiswa",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onPrimaryContainer
)
Text(
text = "Nama: ${studentName ?: ""}",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onPrimaryContainer
)
Text(
text = "NPM: ${studentNpm ?: ""}",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onPrimaryContainer
)
Row {
Button(onClick = { isEditing = true }) {
Text("Ubah Data")
}
Spacer(modifier = Modifier.width(8.dp))
Button(onClick = {
authService.logout()
appNavController.navigate("login") {
popUpTo("main") { inclusive = true }
}
}) {
Text("Logout")
}
}
}
Divider(modifier = Modifier.padding(vertical = 4.dp))
Text(
text = "Mata Kuliah",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onPrimaryContainer
)
Button(
onClick = { showCourseSelector = true },
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary
)
) {
Text(
text = selectedCourse?.courseName ?: "Pilih Mata Kuliah",
modifier = Modifier.weight(1f)
)
}
if (selectedCourse != null) {
Text(
text = "Kode: ${selectedCourse!!.courseCode} | Dosen: ${selectedCourse!!.lecturer}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onPrimaryContainer
)
}
}
}
// Course Selector Dialog
if (showCourseSelector) {
AlertDialog(
onDismissRequest = { showCourseSelector = false },
title = { Text("Pilih Mata Kuliah") },
text = {
Column(
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 300.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
OutlinedTextField(
value = searchQuery,
onValueChange = { searchQuery = it },
label = { Text("Cari Mata Kuliah") },
modifier = Modifier.fillMaxWidth()
)
val filteredCourses = courses.filter {
it.courseName.contains(searchQuery, ignoreCase = true) ||
it.courseCode.contains(searchQuery, ignoreCase = true)
}
Column(
modifier = Modifier.verticalScroll(rememberScrollState())
) {
filteredCourses.forEach { course ->
Button(
onClick = {
selectedCourse = course
courseService.setSelectedCourse(course)
showCourseSelector = false
searchQuery = ""
},
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(
containerColor = if (selectedCourse?.courseId == course.courseId)
MaterialTheme.colorScheme.primary
else
MaterialTheme.colorScheme.secondary
)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Text(
text = course.courseName,
style = MaterialTheme.typography.labelMedium
)
Text(
text = "${course.courseCode} - ${course.lecturer}",
style = MaterialTheme.typography.labelSmall
)
}
}
}
}
}
},
confirmButton = {
TextButton(onClick = { showCourseSelector = false }) {
Text("Tutup")
}
}
)
}
// Location and Photo section
if (selectedStatus == AttendanceStatus.PRESENT) {
// Location Status Card
LocationStatusCard(
latitude = state.location?.latitude,
longitude = state.location?.longitude,
validationMessage = state.validationResult.message,
isLoading = state.isLoadingLocation
)
}
PhotoPreviewCard(
bitmap = state.foto,
onRetake = {
state = state.copy(foto = null)
cameraPermissionLauncher.launch(Manifest.permission.CAMERA)
}
)
// Attendance Status Dropdown
var expanded by remember { mutableStateOf(false) }
val items = listOf(AttendanceStatus.PRESENT, AttendanceStatus.SICK, AttendanceStatus.EXCUSED)
Box {
ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { expanded = !expanded }) {
OutlinedTextField(
value = getStatusLabel(selectedStatus),
onValueChange = {},
readOnly = true,
label = { Text("Status Kehadiran") },
trailingIcon = {
ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded)
},
modifier = Modifier
.fillMaxWidth()
.menuAnchor()
)
ExposedDropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false }
) {
items.forEach { status ->
DropdownMenuItem(
text = { Text(getStatusLabel(status)) },
onClick = {
selectedStatus = status
expanded = false
}
)
}
}
}
}
// Buttons
Button(
onClick = {
cameraPermissionLauncher.launch(
Manifest.permission.CAMERA
)
},
modifier = Modifier.fillMaxWidth(),
enabled = !state.isLoadingSubmit
) {
Text(if (state.foto == null) "Ambil Foto" else "Ganti Foto")
}
SubmitButtonWithLoader(
text = "Kirim Absensi",
onClick = {
val isPresent = selectedStatus == AttendanceStatus.PRESENT
val canSubmit = if (isPresent) {
selectedCourse != null && state.location != null && state.foto != null && state.validationResult.isValid
} else {
selectedCourse != null && state.foto != null
}
if (canSubmit) {
state = state.copy(isLoadingSubmit = true)
val dummyLocation by lazy { LocationData(0.0, 0.0, 0.0f) }
n8nService.submitAttendanceWithCourse(
npm = studentNpm ?: "",
nama = studentName ?: "",
courseId = selectedCourse!!.courseId,
courseCode = selectedCourse!!.courseCode,
courseName = selectedCourse!!.courseName,
latitude = if (isPresent) state.location!!.latitude else dummyLocation.latitude,
longitude = if (isPresent) state.location!!.longitude else dummyLocation.longitude,
foto = state.foto!!,
isTest = false,
status = selectedStatus, // Add status to the call
callback = object : N8nService.SubmitCallback {
override fun onSuccess(responseCode: Int, message: String) {
state = state.copy(
isLoadingSubmit = false,
errorMessage = null
)
// Save to local database
val photoBase64 = state.foto?.let { ImageUtils.bitmapToBase64(it) } ?: ""
val attendance = id.ac.ubharajaya.sistemakademik.models.Attendance(
npm = studentNpm ?: "",
nama = studentName ?: "",
courseId = selectedCourse!!.courseId,
courseCode = selectedCourse!!.courseCode,
courseName = selectedCourse!!.courseName,
latitude = if (isPresent) state.location!!.latitude else dummyLocation.latitude,
longitude = if (isPresent) state.location!!.longitude else dummyLocation.longitude,
timestamp = System.currentTimeMillis(),
date = courseService.getCurrentDate(),
time = courseService.formatTime(System.currentTimeMillis()),
status = selectedStatus,
isValid = if (isPresent) state.validationResult.isValid else true,
submissionResult = "Success: $message",
photoBase64 = photoBase64
)
courseService.saveAttendance(attendance)
// Reset after successful submission
thread {
Thread.sleep(2000)
state = state.copy(
foto = null,
location = null
)
}
}
override fun onError(error: Throwable, message: String) {
state = state.copy(
isLoadingSubmit = false,
errorMessage = message
)
}
}
)
}
},
isLoading = state.isLoadingSubmit,
isEnabled = if (selectedStatus == AttendanceStatus.PRESENT) {
selectedCourse != null && state.location != null && state.foto != null && state.validationResult.isValid
} else {
selectedCourse != null && state.foto != null
}
)
Spacer(modifier = Modifier.height(16.dp))
}
}