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>(emptyList()) } var selectedCourse by remember { mutableStateOf(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)) } }