From 89355bfbb7d59a8708b56d473799030055effd93 Mon Sep 17 00:00:00 2001 From: Raihan Ariq <202310715297@mhs.ubharajaya.ac.id> Date: Tue, 13 Jan 2026 22:44:33 +0700 Subject: [PATCH] Register, Fitur Kelas, Absensi sesuai waktu hari ini dan waktu kelas --- app/build.gradle.kts | 2 + app/src/main/AndroidManifest.xml | 21 +- .../ubharajaya/sistemakademik/MainActivity.kt | 1775 +++++++++++++++-- backend/app.py | 921 +++++++++ backend/requirements.txt | 8 + gradle/libs.versions.toml | 2 + 6 files changed, 2548 insertions(+), 181 deletions(-) create mode 100644 backend/app.py create mode 100644 backend/requirements.txt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 7d76378..d97e349 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -53,6 +53,8 @@ dependencies { implementation(libs.androidx.compose.material3) // Location (GPS) implementation("com.google.android.gms:play-services-location:21.0.1") + implementation("androidx.compose.material:material-icons-extended:1.6.0") + implementation(libs.androidx.compose.animation.core.lint) testImplementation(libs.junit) androidTestImplementation(libs.androidx.junit) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 4619836..ad0cdd6 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,13 +2,19 @@ - - - - + + + + + + + + + android:theme="@style/Theme.SistemAkademik" + android:usesCleartextTraffic="true" + tools:targetApi="31"> + - 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 40050dc..99a1f61 100644 --- a/app/src/main/java/id/ac/ubharajaya/sistemakademik/MainActivity.kt +++ b/app/src/main/java/id/ac/ubharajaya/sistemakademik/MainActivity.kt @@ -3,9 +3,11 @@ package id.ac.ubharajaya.sistemakademik import android.Manifest import android.annotation.SuppressLint import android.app.Activity +import android.content.Context import android.content.Intent import android.content.pm.PackageManager import android.graphics.Bitmap +import android.graphics.BitmapFactory import android.os.Bundle import android.provider.MediaStore import android.util.Base64 @@ -15,22 +17,143 @@ 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.clickable import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +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.asImageBitmap import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat +import androidx.lint.kotlin.metadata.Visibility 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.Date +import java.util.Locale import kotlin.concurrent.thread +import kotlin.math.atan2 +import kotlin.math.cos +import kotlin.math.sin +import kotlin.math.sqrt -/* ================= UTIL ================= */ +/* ================= CONSTANTS ================= */ + +object AppConstants { + // Backend API URL - GANTI SESUAI SERVER ANDA +// const val BASE_URL = "http://10.0.2.2:5000" // Untuk emulator Android + const val BASE_URL = "http://192.168.100.99:5000" // Untuk device fisik + + // Koordinat Kampus (UBHARA Jaya) +// const val KAMPUS_LATITUDE = -6.223325 +// const val KAMPUS_LONGITUDE = 107.009406 + const val KAMPUS_LATITUDE = -6.239513 + const val KAMPUS_LONGITUDE = 107.089676 + const val RADIUS_METER = 500.0 + + // Offset untuk privasi + const val LATITUDE_OFFSET = 0.0001 + const val LONGITUDE_OFFSET = 0.0001 + + // SharedPreferences + const val PREF_NAME = "AbsensiPrefs" + const val KEY_TOKEN = "token" + const val KEY_ID_MAHASISWA = "id_mahasiswa" + const val KEY_NPM = "npm" + const val KEY_NAMA = "nama" + const val KEY_JENKEL = "jenkel" + const val KEY_FAKULTAS = "fakultas" + const val KEY_JURUSAN = "jurusan" + const val KEY_SEMESTER = "semester" +} + +/* ================= DATA CLASSES ================= */ + +data class Mahasiswa( + val idMahasiswa: Int, + val npm: String, + val nama: String, + val jenkel: String, + val fakultas: String, + val jurusan: String, + val semester: Int +) + +data class JadwalKelas( + val idJadwal: Int, + val hari: String, + val jamMulai: String, + val jamSelesai: String, + val ruangan: String, + val kodeMatkul: String, + val namaMatkul: String, + val sks: Int, + val dosen: String, + val sudahAbsen: Boolean +) + +/* ================= USER PREFERENCES ================= */ + +class UserPreferences(private val context: Context) { + private val prefs = context.getSharedPreferences(AppConstants.PREF_NAME, Context.MODE_PRIVATE) + + fun saveUserData(token: String, mahasiswa: Mahasiswa) { + prefs.edit().apply { + putString(AppConstants.KEY_TOKEN, token) + putInt(AppConstants.KEY_ID_MAHASISWA, mahasiswa.idMahasiswa) + putString(AppConstants.KEY_NPM, mahasiswa.npm) + putString(AppConstants.KEY_NAMA, mahasiswa.nama) + putString(AppConstants.KEY_JENKEL, mahasiswa.jenkel) + putString(AppConstants.KEY_FAKULTAS, mahasiswa.fakultas) + putString(AppConstants.KEY_JURUSAN, mahasiswa.jurusan) + putInt(AppConstants.KEY_SEMESTER, mahasiswa.semester) + apply() + } + } + + fun getToken(): String? = prefs.getString(AppConstants.KEY_TOKEN, null) + + fun getMahasiswa(): Mahasiswa? { + val token = getToken() ?: return null + val npm = prefs.getString(AppConstants.KEY_NPM, "") ?: return null + if (npm.isEmpty()) return null + + return Mahasiswa( + idMahasiswa = prefs.getInt(AppConstants.KEY_ID_MAHASISWA, 0), + npm = npm, + nama = prefs.getString(AppConstants.KEY_NAMA, "") ?: "", + jenkel = prefs.getString(AppConstants.KEY_JENKEL, "") ?: "", + fakultas = prefs.getString(AppConstants.KEY_FAKULTAS, "") ?: "", + jurusan = prefs.getString(AppConstants.KEY_JURUSAN, "") ?: "", + semester = prefs.getInt(AppConstants.KEY_SEMESTER, 0) + ) + } + + fun isLoggedIn(): Boolean = getToken() != null && getMahasiswa() != null + + fun logout() { + prefs.edit().clear().apply() + } +} + +/* ================= UTIL FUNCTIONS ================= */ fun bitmapToBase64(bitmap: Bitmap): String { val outputStream = ByteArrayOutputStream() @@ -38,76 +161,596 @@ fun bitmapToBase64(bitmap: Bitmap): String { return Base64.encodeToString(outputStream.toByteArray(), Base64.NO_WRAP) } -fun kirimKeN8n( - context: ComponentActivity, - latitude: Double, - longitude: Double, - foto: Bitmap +fun base64ToBitmap(base64: String): Bitmap? { + return try { + val decodedBytes = Base64.decode(base64, Base64.DEFAULT) + BitmapFactory.decodeByteArray(decodedBytes, 0, decodedBytes.size) + } catch (e: Exception) { + null + } +} + +fun hitungJarak(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double { + val R = 6371000.0 + val dLat = Math.toRadians(lat2 - lat1) + val dLon = Math.toRadians(lon2 - lon1) + + val a = sin(dLat / 2) * sin(dLat / 2) + + cos(Math.toRadians(lat1)) * cos(Math.toRadians(lat2)) * + sin(dLon / 2) * sin(dLon / 2) + + val c = 2 * atan2(sqrt(a), sqrt(1 - a)) + return R * c +} + +fun getCurrentTimestamp(): String { + val sdf = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()) + return sdf.format(Date()) +} + +/* ================= API CALLS ================= */ + +fun registerMahasiswa( + npm: String, + password: String, + nama: String, + jenkel: String, + fakultas: String, + jurusan: String, + semester: Int, + onSuccess: (String, Mahasiswa) -> Unit, + onError: (String) -> Unit ) { thread { 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 url = URL("${AppConstants.BASE_URL}/api/auth/register") val conn = url.openConnection() as HttpURLConnection - conn.requestMethod = "POST" conn.setRequestProperty("Content-Type", "application/json") conn.doOutput = true + conn.connectTimeout = 15000 + conn.readTimeout = 15000 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("password", password) + put("nama", nama) + put("jenkel", jenkel) + put("fakultas", fakultas) + put("jurusan", jurusan) + put("semester", semester) } - conn.outputStream.use { - it.write(json.toString().toByteArray()) - } + conn.outputStream.use { it.write(json.toString().toByteArray()) } val responseCode = conn.responseCode - - context.runOnUiThread { - Toast.makeText( - context, - if (responseCode == 200) - "Absensi diterima server" - else - "Absensi ditolak server", - Toast.LENGTH_SHORT - ).show() + val response = if (responseCode == 201) { + conn.inputStream.bufferedReader().use { it.readText() } + } else { + conn.errorStream.bufferedReader().use { it.readText() } } conn.disconnect() - } catch (_: Exception) { - context.runOnUiThread { - Toast.makeText( - context, - "Gagal kirim ke server", - Toast.LENGTH_SHORT - ).show() + if (responseCode == 201) { + val jsonResponse = JSONObject(response) + val data = jsonResponse.getJSONObject("data") + val token = data.getString("token") + val mahasiswa = Mahasiswa( + idMahasiswa = data.getInt("id_mahasiswa"), + npm = data.getString("npm"), + nama = data.getString("nama"), + jenkel = jenkel, + fakultas = fakultas, + jurusan = jurusan, + semester = semester + ) + onSuccess(token, mahasiswa) + } else { + val error = JSONObject(response).optString("error", "Registrasi gagal") + onError(error) + } + } catch (e: Exception) { + onError("Error: ${e.message}") + } + } +} + +fun loginMahasiswa( + npm: String, + password: String, + onSuccess: (String, Mahasiswa) -> Unit, + onError: (String) -> Unit +) { + thread { + try { + val url = URL("${AppConstants.BASE_URL}/api/auth/login") + val conn = url.openConnection() as HttpURLConnection + conn.requestMethod = "POST" + conn.setRequestProperty("Content-Type", "application/json") + conn.doOutput = true + conn.connectTimeout = 15000 + conn.readTimeout = 15000 + + val json = JSONObject().apply { + put("npm", npm) + put("password", password) + } + + conn.outputStream.use { it.write(json.toString().toByteArray()) } + + val responseCode = conn.responseCode + val response = if (responseCode == 200) { + conn.inputStream.bufferedReader().use { it.readText() } + } else { + conn.errorStream.bufferedReader().use { it.readText() } + } + + conn.disconnect() + + if (responseCode == 200) { + val jsonResponse = JSONObject(response) + val data = jsonResponse.getJSONObject("data") + val token = data.getString("token") + val mahasiswa = Mahasiswa( + idMahasiswa = data.getInt("id_mahasiswa"), + npm = data.getString("npm"), + nama = data.getString("nama"), + jenkel = data.getString("jenkel"), + fakultas = data.getString("fakultas"), + jurusan = data.getString("jurusan"), + semester = data.getInt("semester") + ) + onSuccess(token, mahasiswa) + } else { + val error = JSONObject(response).optString("error", "Login gagal") + onError(error) + } + } catch (e: Exception) { + onError("Error: ${e.message}") + } + } +} + +fun submitAbsensi( + token: String, + latitude: Double, + longitude: Double, + fotoBase64: String, + status: String, + onSuccess: () -> Unit, + onError: (String) -> Unit +) { + thread { + try { + val url = URL("${AppConstants.BASE_URL}/api/absensi/submit") + val conn = url.openConnection() as HttpURLConnection + conn.requestMethod = "POST" + conn.setRequestProperty("Content-Type", "application/json") + conn.setRequestProperty("Authorization", "Bearer $token") + conn.doOutput = true + conn.connectTimeout = 30000 + conn.readTimeout = 30000 + + val json = JSONObject().apply { + put("latitude", latitude) + put("longitude", longitude) + put("timestamp", getCurrentTimestamp()) + put("photo", fotoBase64) + put("foto_base64", fotoBase64) + put("status", status) + } + + conn.outputStream.use { it.write(json.toString().toByteArray()) } + + val responseCode = conn.responseCode + val response = if (responseCode == 201) { + conn.inputStream.bufferedReader().use { it.readText() } + } else { + conn.errorStream?.bufferedReader()?.use { it.readText() } ?: "Unknown error" + } + + conn.disconnect() + + if (responseCode == 201) { + onSuccess() + } else { + val error = try { + JSONObject(response).optString("error", "Submit gagal") + } catch (e: Exception) { + "Submit gagal: $response" + } + onError(error) + } + } catch (e: Exception) { + onError("Error: ${e.message}") + } + } +} + +// TAMBAHKAN FUNGSI API BARU setelah fungsi submitAbsensi + +fun getJadwalToday( + token: String, + onSuccess: (List) -> Unit, + onError: (String) -> Unit +) { + thread { + try { + val url = URL("${AppConstants.BASE_URL}/api/jadwal/today") + val conn = url.openConnection() as HttpURLConnection + conn.requestMethod = "GET" + conn.setRequestProperty("Authorization", "Bearer $token") + conn.connectTimeout = 15000 + conn.readTimeout = 15000 + + val responseCode = conn.responseCode + val response = if (responseCode == 200) { + conn.inputStream.bufferedReader().use { it.readText() } + } else { + conn.errorStream?.bufferedReader()?.use { it.readText() } ?: "Error" + } + + conn.disconnect() + + if (responseCode == 200) { + val jsonResponse = JSONObject(response) + val dataArray = jsonResponse.getJSONArray("data") + val jadwalList = mutableListOf() + + for (i in 0 until dataArray.length()) { + val item = dataArray.getJSONObject(i) + jadwalList.add( + JadwalKelas( + idJadwal = item.getInt("id_jadwal"), + hari = item.getString("hari"), + jamMulai = item.getString("jam_mulai"), + jamSelesai = item.getString("jam_selesai"), + ruangan = item.getString("ruangan"), + kodeMatkul = item.getString("kode_matkul"), + namaMatkul = item.getString("nama_matkul"), + sks = item.getInt("sks"), + dosen = item.getString("dosen"), + sudahAbsen = item.getBoolean("sudah_absen") + ) + ) + } + + onSuccess(jadwalList) + } else { + val error = try { + JSONObject(response).optString("error", "Gagal mengambil jadwal") + } catch (e: Exception) { + "Gagal mengambil jadwal" + } + onError(error) + } + } catch (e: Exception) { + onError("Error: ${e.message}") + } + } +} + +fun submitAbsensiWithJadwal( + token: String, + idJadwal: Int, + latitude: Double, + longitude: Double, + fotoBase64: String, + status: String, + onSuccess: (String) -> Unit, + onError: (String) -> Unit +) { + thread { + try { + val url = URL("${AppConstants.BASE_URL}/api/absensi/submit") + val conn = url.openConnection() as HttpURLConnection + conn.requestMethod = "POST" + conn.setRequestProperty("Content-Type", "application/json") + conn.setRequestProperty("Authorization", "Bearer $token") + conn.doOutput = true + conn.connectTimeout = 30000 + conn.readTimeout = 30000 + + val json = JSONObject().apply { + put("id_jadwal", idJadwal) + put("latitude", latitude) + put("longitude", longitude) + put("timestamp", getCurrentTimestamp()) + put("photo", fotoBase64) + put("foto_base64", fotoBase64) + put("status", status) + } + + conn.outputStream.use { it.write(json.toString().toByteArray()) } + + val responseCode = conn.responseCode + val response = if (responseCode == 201) { + conn.inputStream.bufferedReader().use { it.readText() } + } else { + conn.errorStream?.bufferedReader()?.use { it.readText() } ?: "Unknown error" + } + + conn.disconnect() + + if (responseCode == 201) { + val jsonResponse = JSONObject(response) + val mataKuliah = jsonResponse.getJSONObject("data").getString("mata_kuliah") + onSuccess(mataKuliah) + } else { + val error = try { + JSONObject(response).optString("error", "Submit gagal") + } catch (e: Exception) { + "Submit gagal: $response" + } + onError(error) + } + } catch (e: Exception) { + onError("Error: ${e.message}") + } + } +} + +/* ================= MAIN ACTIVITY ================= */ + +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + + val userPrefs = UserPreferences(this) + + setContent { + SistemAkademikTheme { + var currentScreen by remember { mutableStateOf( + if (userPrefs.isLoggedIn()) "main" else "login" + ) } + var mahasiswa by remember { mutableStateOf(userPrefs.getMahasiswa()) } + var token by remember { mutableStateOf(userPrefs.getToken()) } + + Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> + when (currentScreen) { + "login" -> LoginScreen( + modifier = Modifier.padding(innerPadding), + onLoginSuccess = { t, m -> + token = t + mahasiswa = m + userPrefs.saveUserData(t, m) + currentScreen = "main" + }, + onNavigateToRegister = { + currentScreen = "register" + } + ) + + "register" -> RegisterScreen( + modifier = Modifier.padding(innerPadding), + onRegisterSuccess = { t, m -> + token = t + mahasiswa = m + userPrefs.saveUserData(t, m) + currentScreen = "main" + }, + onNavigateToLogin = { + currentScreen = "login" + } + ) + + "main" -> MainScreen( + modifier = Modifier.padding(innerPadding), + activity = this, + token = token ?: "", + mahasiswa = mahasiswa ?: Mahasiswa(0, "", "", "", "", "", 0), + onLogout = { + userPrefs.logout() + token = null + mahasiswa = null + currentScreen = "login" + } + ) + } + } } } } } -/* ================= ACTIVITY ================= */ +/* ================= JADWAL SCREEN ================= */ -class MainActivity : ComponentActivity() { +@Composable +fun JadwalScreen( + modifier: Modifier = Modifier, + token: String +) { + var jadwalList by remember { mutableStateOf>(emptyList()) } + var isLoading by remember { mutableStateOf(true) } + var errorMessage by remember { mutableStateOf("") } + var hariIni by remember { mutableStateOf("") } - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - enableEdgeToEdge() + val context = LocalContext.current + val scrollState = rememberScrollState() - setContent { - SistemAkademikTheme { - Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> - AbsensiScreen( - modifier = Modifier.padding(innerPadding), - activity = this + LaunchedEffect(Unit) { + getJadwalToday( + token = token, + onSuccess = { jadwal -> + (context as? ComponentActivity)?.runOnUiThread { + jadwalList = jadwal + isLoading = false + } + }, + onError = { error -> + (context as? ComponentActivity)?.runOnUiThread { + errorMessage = error + isLoading = false + Toast.makeText(context, "❌ $error", Toast.LENGTH_LONG).show() + } + } + ) + + // Get hari ini + val hariMapping = mapOf( + "Monday" to "Senin", + "Tuesday" to "Selasa", + "Wednesday" to "Rabu", + "Thursday" to "Kamis", + "Friday" to "Jumat", + "Saturday" to "Sabtu", + "Sunday" to "Minggu" + ) + hariIni = hariMapping[java.time.LocalDate.now().dayOfWeek.toString().toLowerCase().capitalize()] ?: "Senin" + } + + Column( + modifier = modifier + .fillMaxSize() + .padding(24.dp) + .verticalScroll(scrollState) + ) { + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = "📅 Jadwal Kelas Hari Ini", + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.primary + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = hariIni, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Spacer(modifier = Modifier.height(24.dp)) + + if (isLoading) { + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + } else if (jadwalList.isEmpty()) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant + ) + ) { + Column( + modifier = Modifier.padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "📭", + style = MaterialTheme.typography.displayMedium + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "Tidak ada kelas hari ini", + style = MaterialTheme.typography.titleMedium + ) + } + } + } else { + jadwalList.forEach { jadwal -> + JadwalCard(jadwal = jadwal) + Spacer(modifier = Modifier.height(12.dp)) + } + } + } +} + +@Composable +fun JadwalCard(jadwal: JadwalKelas) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = if (jadwal.sudahAbsen) + MaterialTheme.colorScheme.tertiaryContainer + else + MaterialTheme.colorScheme.secondaryContainer + ) + ) { + Column(modifier = Modifier.padding(16.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = jadwal.kodeMatkul, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary + ) + + if (jadwal.sudahAbsen) { + Surface( + shape = MaterialTheme.shapes.small, + color = MaterialTheme.colorScheme.tertiary + ) { + Text( + text = "✓ Sudah Absen", + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onTertiary + ) + } + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = jadwal.namaMatkul, + style = MaterialTheme.typography.titleMedium + ) + + Spacer(modifier = Modifier.height(4.dp)) + + Text( + text = "👨‍🏫 ${jadwal.dosen}", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Divider() + + Spacer(modifier = Modifier.height(8.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = Icons.Default.Schedule, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = "${jadwal.jamMulai.substring(0, 5)} - ${jadwal.jamSelesai.substring(0, 5)}", + style = MaterialTheme.typography.bodyMedium + ) + } + + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = Icons.Default.Room, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = jadwal.ruangan, + style = MaterialTheme.typography.bodyMedium ) } } @@ -115,162 +758,946 @@ class MainActivity : ComponentActivity() { } } -/* ================= UI ================= */ +/* ================= REGISTER SCREEN ================= */ -@SuppressLint("NewApi") @Composable -fun AbsensiScreen( +fun RegisterScreen( modifier: Modifier = Modifier, - activity: ComponentActivity + onRegisterSuccess: (String, Mahasiswa) -> Unit, + onNavigateToLogin: () -> Unit ) { + var npm by remember { mutableStateOf("") } + var password by remember { mutableStateOf("") } + var confirmPassword by remember { mutableStateOf("") } + var nama by remember { mutableStateOf("") } + var jenkel by remember { mutableStateOf("L") } + var fakultas by remember { mutableStateOf("") } + var jurusan by remember { mutableStateOf("") } + var semester by remember { mutableStateOf("") } + + var showPassword by remember { mutableStateOf(false) } + var isLoading by remember { mutableStateOf(false) } + var errorMessage by remember { mutableStateOf("") } + val context = LocalContext.current + val scrollState = rememberScrollState() - var lokasi by remember { mutableStateOf("Koordinat: -") } - var latitude by remember { mutableStateOf(null) } - var longitude by remember { mutableStateOf(null) } - var foto by remember { mutableStateOf(null) } + Column( + modifier = modifier + .fillMaxSize() + .padding(24.dp) + .verticalScroll(scrollState), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer(modifier = Modifier.height(24.dp)) - 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 + Text( + text = "📝 Registrasi Mahasiswa", + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.primary ) - } - /* ===== UI ===== */ + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = "UBHARA Jaya - Soreang", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Spacer(modifier = Modifier.height(32.dp)) + + OutlinedTextField( + value = npm, + onValueChange = { npm = it }, + label = { Text("NPM") }, + placeholder = { Text("Contoh: 2023010001") }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.fillMaxWidth(), + singleLine = true, + enabled = !isLoading + ) + + Spacer(modifier = Modifier.height(12.dp)) + + OutlinedTextField( + value = password, + onValueChange = { password = it }, + label = { Text("Password") }, + visualTransformation = if (showPassword) VisualTransformation.None else PasswordVisualTransformation(), + trailingIcon = { + IconButton(onClick = { showPassword = !showPassword }) { + Icon( + imageVector = if (showPassword) Icons.Default.Visibility else Icons.Default.VisibilityOff, + contentDescription = "Toggle password" + ) + } + }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + enabled = !isLoading + ) + + Spacer(modifier = Modifier.height(12.dp)) + + OutlinedTextField( + value = confirmPassword, + onValueChange = { confirmPassword = it }, + label = { Text("Konfirmasi Password") }, + visualTransformation = PasswordVisualTransformation(), + modifier = Modifier.fillMaxWidth(), + singleLine = true, + enabled = !isLoading + ) + + Spacer(modifier = Modifier.height(12.dp)) + + OutlinedTextField( + value = nama, + onValueChange = { nama = it }, + label = { Text("Nama Lengkap") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + enabled = !isLoading + ) + + Spacer(modifier = Modifier.height(12.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + FilterChip( + selected = jenkel == "L", + onClick = { jenkel = "L" }, + label = { Text("Laki-laki") }, + modifier = Modifier.weight(1f), + enabled = !isLoading + ) + FilterChip( + selected = jenkel == "P", + onClick = { jenkel = "P" }, + label = { Text("Perempuan") }, + modifier = Modifier.weight(1f), + enabled = !isLoading + ) + } + + Spacer(modifier = Modifier.height(12.dp)) + + OutlinedTextField( + value = fakultas, + onValueChange = { fakultas = it }, + label = { Text("Fakultas") }, + placeholder = { Text("Contoh: Teknik") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + enabled = !isLoading + ) + + Spacer(modifier = Modifier.height(12.dp)) + + OutlinedTextField( + value = jurusan, + onValueChange = { jurusan = it }, + label = { Text("Jurusan") }, + placeholder = { Text("Contoh: Informatika") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + enabled = !isLoading + ) + + Spacer(modifier = Modifier.height(12.dp)) + + OutlinedTextField( + value = semester, + onValueChange = { if (it.isEmpty() || it.toIntOrNull() != null) semester = it }, + label = { Text("Semester") }, + placeholder = { Text("1-14") }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.fillMaxWidth(), + singleLine = true, + enabled = !isLoading + ) + + if (errorMessage.isNotEmpty()) { + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = errorMessage, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall + ) + } + + Spacer(modifier = Modifier.height(24.dp)) + + Button( + onClick = { + errorMessage = "" + + when { + npm.length < 8 -> errorMessage = "NPM minimal 8 karakter" + password.length < 6 -> errorMessage = "Password minimal 6 karakter" + password != confirmPassword -> errorMessage = "Password tidak cocok" + nama.length < 3 -> errorMessage = "Nama minimal 3 karakter" + fakultas.isEmpty() -> errorMessage = "Fakultas wajib diisi" + jurusan.isEmpty() -> errorMessage = "Jurusan wajib diisi" + semester.toIntOrNull() == null || semester.toInt() !in 1..14 -> + errorMessage = "Semester harus antara 1-14" + else -> { + isLoading = true + registerMahasiswa( + npm = npm.trim(), + password = password, + nama = nama.trim(), + jenkel = jenkel, + fakultas = fakultas.trim(), + jurusan = jurusan.trim(), + semester = semester.toInt(), + onSuccess = { token, mhs -> + (context as? ComponentActivity)?.runOnUiThread { + isLoading = false + Toast.makeText(context, "✅ Registrasi berhasil!", Toast.LENGTH_SHORT).show() + onRegisterSuccess(token, mhs) + } + }, + onError = { error -> + (context as? ComponentActivity)?.runOnUiThread { + isLoading = false + errorMessage = error + Toast.makeText(context, "❌ $error", Toast.LENGTH_LONG).show() + } + } + ) + } + } + }, + modifier = Modifier.fillMaxWidth().height(50.dp), + enabled = !isLoading + ) { + if (isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + color = MaterialTheme.colorScheme.onPrimary + ) + } else { + Text("Daftar", style = MaterialTheme.typography.titleMedium) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + TextButton(onClick = onNavigateToLogin, enabled = !isLoading) { + Text("Sudah punya akun? Masuk di sini") + } + + Spacer(modifier = Modifier.height(24.dp)) + } +} + +/* ================= LOGIN SCREEN ================= */ + +@Composable +fun LoginScreen( + modifier: Modifier = Modifier, + onLoginSuccess: (String, Mahasiswa) -> Unit, + onNavigateToRegister: () -> Unit +) { + var npm by remember { mutableStateOf("") } + var password by remember { mutableStateOf("") } + var showPassword by remember { mutableStateOf(false) } + var isLoading by remember { mutableStateOf(false) } + var errorMessage by remember { mutableStateOf("") } + + val context = LocalContext.current Column( modifier = modifier .fillMaxSize() .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center ) { + Text( + text = "🎓 Absensi Akademik", + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.primary + ) + + Spacer(modifier = Modifier.height(8.dp)) Text( - text = "Absensi Akademik", - style = MaterialTheme.typography.titleLarge + text = "UBHARA Jaya - Soreang", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Spacer(modifier = Modifier.height(48.dp)) + + OutlinedTextField( + value = npm, + onValueChange = { npm = it }, + label = { Text("NPM") }, + placeholder = { Text("Contoh: 2023010001") }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.fillMaxWidth(), + singleLine = true, + enabled = !isLoading ) Spacer(modifier = Modifier.height(16.dp)) - Text(text = lokasi) + OutlinedTextField( + value = password, + onValueChange = { password = it }, + label = { Text("Password") }, + visualTransformation = if (showPassword) VisualTransformation.None else PasswordVisualTransformation(), + trailingIcon = { + IconButton(onClick = { showPassword = !showPassword }) { + Icon( + imageVector = if (showPassword) Icons.Default.Visibility else Icons.Default.VisibilityOff, + contentDescription = "Toggle password" + ) + } + }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + enabled = !isLoading + ) + + if (errorMessage.isNotEmpty()) { + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = errorMessage, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall + ) + } + + Spacer(modifier = Modifier.height(32.dp)) + + Button( + onClick = { + errorMessage = "" + + when { + npm.isEmpty() -> errorMessage = "NPM wajib diisi" + password.isEmpty() -> errorMessage = "Password wajib diisi" + else -> { + isLoading = true + loginMahasiswa( + npm = npm.trim(), + password = password, + onSuccess = { token, mhs -> + (context as? ComponentActivity)?.runOnUiThread { + isLoading = false + Toast.makeText(context, "✅ Login berhasil!", Toast.LENGTH_SHORT).show() + onLoginSuccess(token, mhs) + } + }, + onError = { error -> + (context as? ComponentActivity)?.runOnUiThread { + isLoading = false + errorMessage = error + Toast.makeText(context, "❌ $error", Toast.LENGTH_LONG).show() + } + } + ) + } + } + }, + modifier = Modifier.fillMaxWidth().height(50.dp), + enabled = !isLoading + ) { + if (isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + color = MaterialTheme.colorScheme.onPrimary + ) + } else { + Text("Masuk", style = MaterialTheme.typography.titleMedium) + } + } Spacer(modifier = Modifier.height(16.dp)) - Button( - onClick = { - cameraPermissionLauncher.launch( - Manifest.permission.CAMERA - ) - }, - modifier = Modifier.fillMaxWidth() - ) { - Text("Ambil Foto") + TextButton(onClick = onNavigateToRegister, enabled = !isLoading) { + Text("Belum punya akun? Daftar di sini") } - Spacer(modifier = Modifier.height(12.dp)) + Spacer(modifier = Modifier.height(16.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") + Text( + text = "📍 Pastikan Anda berada di area kampus saat absensi", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } +} + +/* ================= MAIN SCREEN (Bottom Navigation) ================= */ + +@Composable +fun MainScreen( + modifier: Modifier = Modifier, + activity: ComponentActivity, + token: String, + mahasiswa: Mahasiswa, + onLogout: () -> Unit +) { + var selectedTab by remember { mutableStateOf(0) } + + Scaffold( + bottomBar = { + NavigationBar { + NavigationBarItem( + icon = { Icon(Icons.Default.Home, "Absensi") }, + label = { Text("Absensi") }, + selected = selectedTab == 0, + onClick = { selectedTab = 0 } + ) + NavigationBarItem( + icon = { Icon(Icons.Default.Schedule, "Kelas") }, + label = { Text("Kelas") }, + selected = selectedTab == 1, + onClick = { selectedTab = 1 } + ) + NavigationBarItem( + icon = { Icon(Icons.Default.Person, "Profil") }, + label = { Text("Profil") }, + selected = selectedTab == 2, + onClick = { selectedTab = 2 } + ) + } + } + ) { padding -> + when (selectedTab) { + 0 -> AbsensiScreenWithJadwal( + modifier = Modifier.padding(padding), + activity = activity, + token = token, + mahasiswa = mahasiswa + ) + 1 -> JadwalScreen( + modifier = Modifier.padding(padding), + token = token + ) + 2 -> ProfilScreen( + modifier = Modifier.padding(padding), + mahasiswa = mahasiswa, + onLogout = onLogout + ) } } } + +/* ================= PROFIL SCREEN ================= */ + +@Composable +fun ProfilScreen( + modifier: Modifier = Modifier, + mahasiswa: Mahasiswa, + onLogout: () -> Unit +) { + var showLogoutDialog by remember { mutableStateOf(false) } + + Column( + modifier = modifier + .fillMaxSize() + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer(modifier = Modifier.height(24.dp)) + + Text( + text = "👤 Profil Mahasiswa", + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.primary + ) + + Spacer(modifier = Modifier.height(32.dp)) + + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer + ) + ) { + Column(modifier = Modifier.padding(20.dp)) { + ProfilItem("NPM", mahasiswa.npm) + Divider(modifier = Modifier.padding(vertical = 12.dp)) + ProfilItem("Nama", mahasiswa.nama) + Divider(modifier = Modifier.padding(vertical = 12.dp)) + ProfilItem("Jenis Kelamin", if (mahasiswa.jenkel == "L") "Laki-laki" else "Perempuan") + Divider(modifier = Modifier.padding(vertical = 12.dp)) + ProfilItem("Fakultas", mahasiswa.fakultas) + Divider(modifier = Modifier.padding(vertical = 12.dp)) + ProfilItem("Jurusan", mahasiswa.jurusan) + Divider(modifier = Modifier.padding(vertical = 12.dp)) + ProfilItem("Semester", mahasiswa.semester.toString()) + } + } + + Spacer(modifier = Modifier.weight(1f)) + + Button( + onClick = { showLogoutDialog = true }, + modifier = Modifier.fillMaxWidth().height(50.dp), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error + ) + ) { + Icon(Icons.Default.ExitToApp, contentDescription = null) + Spacer(modifier = Modifier.width(8.dp)) + Text("Keluar", style = MaterialTheme.typography.titleMedium) + } + + Spacer(modifier = Modifier.height(24.dp)) + } + + if (showLogoutDialog) { + AlertDialog( + onDismissRequest = { showLogoutDialog = false }, + icon = { Icon(Icons.Default.ExitToApp, contentDescription = null) }, + title = { Text("Keluar dari Aplikasi?") }, + text = { Text("Anda akan logout dan harus login kembali untuk menggunakan aplikasi.") }, + confirmButton = { + TextButton( + onClick = { + showLogoutDialog = false + onLogout() + } + ) { + Text("Keluar") + } + }, + dismissButton = { + TextButton(onClick = { showLogoutDialog = false }) { + Text("Batal") + } + } + ) + } +} + +@Composable +fun ProfilItem(label: String, value: String) { + Column { + Text( + text = label, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = value, + style = MaterialTheme.typography.bodyLarge + ) + } +} + +/* ================= ABSENSI SCREEN ================= */ + +@SuppressLint("NewApi") +@Composable +fun AbsensiScreenWithJadwal( + modifier: Modifier = Modifier, + activity: ComponentActivity, + token: String, + mahasiswa: Mahasiswa +) { + val context = LocalContext.current + + var lokasi by remember { mutableStateOf("📍 Koordinat: Memuat...") } + var latitude by remember { mutableStateOf(null) } + var longitude by remember { mutableStateOf(null) } + var foto by remember { mutableStateOf(null) } + var isLoading by remember { mutableStateOf(false) } + var jarakKeKampus by remember { mutableStateOf(null) } + + var jadwalList by remember { mutableStateOf>(emptyList()) } + var selectedJadwal by remember { mutableStateOf(null) } + var showJadwalDialog by remember { mutableStateOf(false) } + var isLoadingJadwal by remember { mutableStateOf(true) } + + val fusedLocationClient = LocationServices.getFusedLocationProviderClient(context) + + // Load jadwal + LaunchedEffect(Unit) { + getJadwalToday( + token = token, + onSuccess = { jadwal -> + activity.runOnUiThread { + jadwalList = jadwal.filter { !it.sudahAbsen } + isLoadingJadwal = false + } + }, + onError = { error -> + activity.runOnUiThread { + isLoadingJadwal = false + Toast.makeText(context, "⚠️ $error", Toast.LENGTH_SHORT).show() + } + } + ) + } + + 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 + + val jarak = hitungJarak( + location.latitude, + location.longitude, + AppConstants.KAMPUS_LATITUDE, + AppConstants.KAMPUS_LONGITUDE + ) + jarakKeKampus = jarak + + val statusLokasi = if (jarak <= AppConstants.RADIUS_METER) { + "✅ DI DALAM AREA" + } else { + "❌ DI LUAR AREA" + } + + lokasi = "📍 Lat: ${String.format("%.6f", location.latitude)}\n" + + "📍 Lon: ${String.format("%.6f", location.longitude)}\n" + + "📏 Jarak: ${String.format("%.0f", jarak)} m\n" + + "$statusLokasi" + } else { + lokasi = "❌ Lokasi tidak tersedia" + } + } + .addOnFailureListener { + lokasi = "❌ Gagal mengambil lokasi" + } + } + } else { + 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) { + 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() + } + } + + LaunchedEffect(Unit) { + locationPermissionLauncher.launch(Manifest.permission.ACCESS_FINE_LOCATION) + } + + // Dialog Pilih Jadwal + if (showJadwalDialog) { + AlertDialog( + onDismissRequest = { showJadwalDialog = false }, + title = { Text("Pilih Mata Kuliah") }, + text = { + Column { + if (jadwalList.isEmpty()) { + Text("Tidak ada kelas yang bisa diabsen saat ini") + } else { + jadwalList.forEach { jadwal -> + OutlinedCard( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp) + .clickable { + selectedJadwal = jadwal + showJadwalDialog = false + } + ) { + Column(modifier = Modifier.padding(12.dp)) { + Text( + text = jadwal.namaMatkul, + style = MaterialTheme.typography.titleSmall + ) + Text( + text = "${ + jadwal.jamMulai.substring( + 0, + 5 + ) + } - ${ + jadwal.jamSelesai.substring( + 0, + 5 + ) + } | ${jadwal.ruangan}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } + } + }, + confirmButton = { + TextButton(onClick = { showJadwalDialog = false }) { + Text("Tutup") + } + } + ) + } + + Column( + modifier = modifier + .fillMaxSize() + .padding(24.dp), + verticalArrangement = Arrangement.SpaceBetween + ) { + Column { + Text( + text = "Absensi Akademik", + style = MaterialTheme.typography.titleLarge + ) + Text( + text = "Halo, ${mahasiswa.nama}", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + Spacer(modifier = Modifier.height(24.dp)) + + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.Center + ) { + // Pilih Mata Kuliah + Card( + modifier = Modifier + .fillMaxWidth() + .clickable { showJadwalDialog = true }, + colors = CardDefaults.cardColors( + containerColor = if (selectedJadwal != null) + MaterialTheme.colorScheme.primaryContainer + else + MaterialTheme.colorScheme.surfaceVariant + ) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = "📚 Mata Kuliah", + style = MaterialTheme.typography.labelMedium + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = selectedJadwal?.namaMatkul ?: "Pilih mata kuliah", + style = MaterialTheme.typography.titleMedium + ) + if (selectedJadwal != null) { + Text( + text = "${ + selectedJadwal!!.jamMulai.substring( + 0, + 5 + ) + } - ${selectedJadwal!!.jamSelesai.substring(0, 5)}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + Icon( + imageVector = Icons.Default.ArrowDropDown, + contentDescription = null + ) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer + ) + ) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + text = "📍 Informasi Lokasi", + style = MaterialTheme.typography.titleMedium + ) + Spacer(modifier = Modifier.height(8.dp)) + Text(text = lokasi, style = MaterialTheme.typography.bodyMedium) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + if (foto != null) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.tertiaryContainer + ) + ) { + Column( + modifier = Modifier.padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "📸 Foto Anda", + style = MaterialTheme.typography.titleMedium + ) + Spacer(modifier = Modifier.height(8.dp)) + Image( + bitmap = foto!!.asImageBitmap(), + contentDescription = "Preview Foto", + modifier = Modifier + .size(200.dp) + .clip(CircleShape) + ) + } + } + } + } + + Column { + Button( + onClick = { + cameraPermissionLauncher.launch(Manifest.permission.CAMERA) + }, + modifier = Modifier.fillMaxWidth().height(50.dp), + enabled = !isLoading + ) { + Icon(Icons.Default.CameraAlt, contentDescription = null) + Spacer(modifier = Modifier.width(8.dp)) + Text("Ambil Foto") + } + + Spacer(modifier = Modifier.height(12.dp)) + + Button( + onClick = { + if (selectedJadwal == null) { + Toast.makeText( + context, + "⚠️ Pilih mata kuliah terlebih dahulu!", + Toast.LENGTH_SHORT + ).show() + return@Button + } + + if (latitude != null && longitude != null && foto != null) { + if (jarakKeKampus != null && jarakKeKampus!! > AppConstants.RADIUS_METER) { + Toast.makeText( + context, + "❌ Anda berada di luar area kampus!\nJarak: ${ + String.format( + "%.0f", + jarakKeKampus + ) + } m (Maks: ${AppConstants.RADIUS_METER.toInt()} m)", + Toast.LENGTH_LONG + ).show() + return@Button + } + + isLoading = true + + val latWithOffset = latitude!! + AppConstants.LATITUDE_OFFSET + val lonWithOffset = longitude!! + AppConstants.LONGITUDE_OFFSET + + submitAbsensiWithJadwal( + token = token, + idJadwal = selectedJadwal!!.idJadwal, + latitude = latWithOffset, + longitude = lonWithOffset, + fotoBase64 = bitmapToBase64(foto!!), + status = "HADIR", + onSuccess = { mataKuliah -> + activity.runOnUiThread { + isLoading = false + foto = null + selectedJadwal = null + Toast.makeText( + context, + "✅ Absensi $mataKuliah berhasil!", + Toast.LENGTH_LONG + ).show() + + // Refresh jadwal list + getJadwalToday( + token = token, + onSuccess = { jadwal -> + activity.runOnUiThread { + jadwalList = jadwal.filter { !it.sudahAbsen } + } + }, + onError = {} + ) + } + }, + onError = { error -> + activity.runOnUiThread { + isLoading = false + Toast.makeText(context, "❌ $error", Toast.LENGTH_LONG).show() + } + } + ) + } else { + val missingItems = mutableListOf() + if (latitude == null || longitude == null) missingItems.add("Lokasi") + if (foto == null) missingItems.add("Foto") + + Toast.makeText( + context, + "⚠️ ${missingItems.joinToString(" dan ")} belum lengkap!", + Toast.LENGTH_SHORT + ).show() + } + }, + modifier = Modifier.fillMaxWidth().height(50.dp), + enabled = !isLoading && foto != null && selectedJadwal != null + ) { + if (isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + color = MaterialTheme.colorScheme.onPrimary + ) + } else { + Icon(Icons.Default.Check, contentDescription = null) + Spacer(modifier = Modifier.width(8.dp)) + Text("Kirim Absensi") + } + } + } + } +} \ No newline at end of file diff --git a/backend/app.py b/backend/app.py new file mode 100644 index 0000000..43a1dc8 --- /dev/null +++ b/backend/app.py @@ -0,0 +1,921 @@ +""" +Backend API untuk Aplikasi Absensi Akademik +Python Flask + MySQL + JWT Authentication + +Requirements: +pip install flask flask-cors mysql-connector-python PyJWT bcrypt python-dotenv + +File Structure: +- app.py (main file) +- .env (konfigurasi) +- requirements.txt +""" + +from flask import Flask, request, jsonify +from flask_cors import CORS +import mysql.connector +from mysql.connector import Error +import jwt +import bcrypt +from datetime import datetime, timedelta +import os +from functools import wraps +import base64 +import requests + +app = Flask(__name__) +CORS(app) + +# ==================== KONFIGURASI ==================== + +# Ganti dengan konfigurasi MySQL Anda +DB_CONFIG = { + 'host': 'localhost', + 'user': 'root', + 'password': '@Rique03', # Ganti dengan password MySQL Anda + 'database': 'db_absensi_akademik' +} + +# Secret key untuk JWT (GANTI dengan random string yang aman!) +SECRET_KEY = 'ubhara-jaya-absensi-secret-2026-change-this' + +# ==================== DATABASE CONNECTION ==================== + +def get_db_connection(): + """Membuat koneksi ke database MySQL""" + try: + connection = mysql.connector.connect(**DB_CONFIG) + return connection + except Error as e: + print(f"Error connecting to MySQL: {e}") + return None + +def init_database(): + """Inisialisasi database dan tabel""" + connection = get_db_connection() + if connection is None: + return + + cursor = connection.cursor() + + try: + cursor.execute(f"CREATE DATABASE IF NOT EXISTS {DB_CONFIG['database']}") + cursor.execute(f"USE {DB_CONFIG['database']}") + + # Tabel Mahasiswa (sudah ada) + cursor.execute(""" + CREATE TABLE IF NOT EXISTS mahasiswa ( + id_mahasiswa INT AUTO_INCREMENT PRIMARY KEY, + npm VARCHAR(20) UNIQUE NOT NULL, + password VARCHAR(255) NOT NULL, + nama VARCHAR(100) NOT NULL, + jenkel ENUM('L', 'P') NOT NULL, + fakultas VARCHAR(100) NOT NULL, + jurusan VARCHAR(100) NOT NULL, + semester INT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX idx_npm (npm) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 + """) + + # TABEL BARU: Mata Kuliah + cursor.execute(""" + CREATE TABLE IF NOT EXISTS mata_kuliah ( + id_matkul INT AUTO_INCREMENT PRIMARY KEY, + kode_matkul VARCHAR(20) UNIQUE NOT NULL, + nama_matkul VARCHAR(100) NOT NULL, + sks INT NOT NULL, + semester INT NOT NULL, + dosen VARCHAR(100) NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + INDEX idx_kode (kode_matkul) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 + """) + + # TABEL BARU: Jadwal Kelas + cursor.execute(""" + CREATE TABLE IF NOT EXISTS jadwal_kelas ( + id_jadwal INT AUTO_INCREMENT PRIMARY KEY, + id_matkul INT NOT NULL, + hari ENUM('Senin', 'Selasa', 'Rabu', 'Kamis', 'Jumat', 'Sabtu', 'Minggu') NOT NULL, + jam_mulai TIME NOT NULL, + jam_selesai TIME NOT NULL, + ruangan VARCHAR(50) NOT NULL, + semester INT NOT NULL, + jurusan VARCHAR(100) NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (id_matkul) REFERENCES mata_kuliah(id_matkul) ON DELETE CASCADE, + INDEX idx_hari (hari), + INDEX idx_semester_jurusan (semester, jurusan) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 + """) + + # Tabel Absensi (UPDATE: tambah kolom mata_kuliah) + cursor.execute(""" + CREATE TABLE IF NOT EXISTS absensi ( + id_absensi INT AUTO_INCREMENT PRIMARY KEY, + id_mahasiswa INT NOT NULL, + npm VARCHAR(20) NOT NULL, + nama VARCHAR(100) NOT NULL, + id_jadwal INT NOT NULL, + mata_kuliah VARCHAR(100) NOT NULL, + latitude DECIMAL(10, 8) NOT NULL, + longitude DECIMAL(11, 8) NOT NULL, + timestamp DATETIME NOT NULL, + photo LONGTEXT, + foto_base64 LONGTEXT, + status VARCHAR(20) NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (id_mahasiswa) REFERENCES mahasiswa(id_mahasiswa) ON DELETE CASCADE, + FOREIGN KEY (id_jadwal) REFERENCES jadwal_kelas(id_jadwal) ON DELETE CASCADE, + INDEX idx_mahasiswa (id_mahasiswa), + INDEX idx_npm (npm), + INDEX idx_timestamp (timestamp), + INDEX idx_status (status), + INDEX idx_jadwal (id_jadwal) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 + """) + + connection.commit() + print("✅ Database dan tabel berhasil dibuat!") + + # INSERT DATA DUMMY MATA KULIAH (untuk testing) + cursor.execute("SELECT COUNT(*) FROM mata_kuliah") + if cursor.fetchone()[0] == 0: + dummy_matkul = [ + ('IF101', 'Pemrograman Mobile', 3, 5, 'Dr. Budi Santoso'), + ('IF102', 'Basis Data Lanjut', 3, 5, 'Dr. Siti Aminah'), + ('IF103', 'Jaringan Komputer', 3, 5, 'Dr. Ahmad Fauzi'), + ('IF104', 'Kecerdasan Buatan', 3, 5, 'Dr. Rina Wati'), + ] + cursor.executemany(""" + INSERT INTO mata_kuliah (kode_matkul, nama_matkul, sks, semester, dosen) + VALUES (%s, %s, %s, %s, %s) + """, dummy_matkul) + connection.commit() + print("✅ Data dummy mata kuliah berhasil ditambahkan!") + + # INSERT DATA DUMMY JADWAL KELAS (untuk testing) + cursor.execute("SELECT COUNT(*) FROM jadwal_kelas") + if cursor.fetchone()[0] == 0: + dummy_jadwal = [ + (1, 'Senin', '08:00:00', '10:30:00', 'Lab Komputer 1', 5, 'Informatika'), + (2, 'Senin', '13:00:00', '15:30:00', 'Ruang 301', 5, 'Informatika'), + (3, 'Selasa', '08:00:00', '10:30:00', 'Lab Jaringan', 5, 'Informatika'), + (4, 'Rabu', '10:30:00', '13:00:00', 'Ruang 302', 5, 'Informatika'), + (1, 'Kamis', '13:30:00', '16:00:00', 'Lab Komputer 2', 5, 'Informatika'), + ] + cursor.executemany(""" + INSERT INTO jadwal_kelas (id_matkul, hari, jam_mulai, jam_selesai, ruangan, semester, jurusan) + VALUES (%s, %s, %s, %s, %s, %s, %s) + """, dummy_jadwal) + connection.commit() + print("✅ Data dummy jadwal kelas berhasil ditambahkan!") + + except Error as e: + print(f"❌ Error creating tables: {e}") + finally: + cursor.close() + connection.close() + +# ==================== JWT HELPER ==================== + +def generate_token(id_mahasiswa, npm): + """Generate JWT token""" + payload = { + 'id_mahasiswa': id_mahasiswa, + 'npm': npm, + 'exp': datetime.utcnow() + timedelta(days=30) # Token berlaku 30 hari + } + return jwt.encode(payload, SECRET_KEY, algorithm='HS256') + +def token_required(f): + """Decorator untuk endpoint yang memerlukan authentication""" + @wraps(f) + def decorated(*args, **kwargs): + token = request.headers.get('Authorization') + + if not token: + return jsonify({'error': 'Token tidak ditemukan'}), 401 + + try: + # Format: "Bearer " + if token.startswith('Bearer '): + token = token.split(' ')[1] + + data = jwt.decode(token, SECRET_KEY, algorithms=['HS256']) + request.user_data = data + except jwt.ExpiredSignatureError: + return jsonify({'error': 'Token sudah kadaluarsa'}), 401 + except jwt.InvalidTokenError: + return jsonify({'error': 'Token tidak valid'}), 401 + + return f(*args, **kwargs) + + return decorated + +# ==================== API ENDPOINTS ==================== + +@app.route('/api/health', methods=['GET']) +def health_check(): + """Health check endpoint""" + return jsonify({ + 'status': 'OK', + 'message': 'Backend API Absensi Akademik Running', + 'timestamp': datetime.now().isoformat() + }) + +# ==================== REGISTRASI ==================== + +@app.route('/api/auth/register', methods=['POST']) +def register(): + """ + Endpoint registrasi mahasiswa baru + + Request Body: + { + "npm": "2023010001", + "password": "password123", + "nama": "John Doe", + "jenkel": "L", + "fakultas": "Teknik", + "jurusan": "Informatika", + "semester": 5 + } + """ + try: + data = request.get_json() + + # Validasi input + required_fields = ['npm', 'password', 'nama', 'jenkel', 'fakultas', 'jurusan', 'semester'] + for field in required_fields: + if field not in data or not data[field]: + return jsonify({'error': f'Field {field} wajib diisi'}), 400 + + # Validasi jenis kelamin + if data['jenkel'] not in ['L', 'P']: + return jsonify({'error': 'Jenis kelamin harus L atau P'}), 400 + + # Validasi semester + if not isinstance(data['semester'], int) or data['semester'] < 1 or data['semester'] > 14: + return jsonify({'error': 'Semester harus antara 1-14'}), 400 + + # Hash password + hashed_password = bcrypt.hashpw(data['password'].encode('utf-8'), bcrypt.gensalt()) + + connection = get_db_connection() + if connection is None: + return jsonify({'error': 'Gagal koneksi ke database'}), 500 + + cursor = connection.cursor() + + # Cek apakah NPM sudah terdaftar + cursor.execute("SELECT npm FROM mahasiswa WHERE npm = %s", (data['npm'],)) + if cursor.fetchone(): + cursor.close() + connection.close() + return jsonify({'error': 'NPM sudah terdaftar'}), 409 + + # Insert mahasiswa baru + insert_query = """ + INSERT INTO mahasiswa (npm, password, nama, jenkel, fakultas, jurusan, semester) + VALUES (%s, %s, %s, %s, %s, %s, %s) + """ + cursor.execute(insert_query, ( + data['npm'], + hashed_password.decode('utf-8'), + data['nama'], + data['jenkel'], + data['fakultas'], + data['jurusan'], + data['semester'] + )) + + connection.commit() + id_mahasiswa = cursor.lastrowid + + cursor.close() + connection.close() + + # Generate token + token = generate_token(id_mahasiswa, data['npm']) + + return jsonify({ + 'message': 'Registrasi berhasil', + 'data': { + 'id_mahasiswa': id_mahasiswa, + 'npm': data['npm'], + 'nama': data['nama'], + 'token': token + } + }), 201 + + except Exception as e: + return jsonify({'error': str(e)}), 500 + +# ==================== LOGIN ==================== + +@app.route('/api/auth/login', methods=['POST']) +def login(): + """ + Endpoint login mahasiswa + + Request Body: + { + "npm": "2023010001", + "password": "password123" + } + """ + try: + data = request.get_json() + + if not data.get('npm') or not data.get('password'): + return jsonify({'error': 'NPM dan password wajib diisi'}), 400 + + connection = get_db_connection() + if connection is None: + return jsonify({'error': 'Gagal koneksi ke database'}), 500 + + cursor = connection.cursor(dictionary=True) + + # Cari mahasiswa berdasarkan NPM + cursor.execute(""" + SELECT id_mahasiswa, npm, password, nama, jenkel, fakultas, jurusan, semester + FROM mahasiswa + WHERE npm = %s + """, (data['npm'],)) + + mahasiswa = cursor.fetchone() + + cursor.close() + connection.close() + + if not mahasiswa: + return jsonify({'error': 'NPM tidak ditemukan'}), 404 + + # Verifikasi password + if not bcrypt.checkpw(data['password'].encode('utf-8'), mahasiswa['password'].encode('utf-8')): + return jsonify({'error': 'Password salah'}), 401 + + # Generate token + token = generate_token(mahasiswa['id_mahasiswa'], mahasiswa['npm']) + + return jsonify({ + 'message': 'Login berhasil', + 'data': { + 'id_mahasiswa': mahasiswa['id_mahasiswa'], + 'npm': mahasiswa['npm'], + 'nama': mahasiswa['nama'], + 'jenkel': mahasiswa['jenkel'], + 'fakultas': mahasiswa['fakultas'], + 'jurusan': mahasiswa['jurusan'], + 'semester': mahasiswa['semester'], + 'token': token + } + }), 200 + + except Exception as e: + return jsonify({'error': str(e)}), 500 + +# ==================== PROFIL ==================== + +@app.route('/api/mahasiswa/profile', methods=['GET']) +@token_required +def get_profile(): + """ + Endpoint untuk mendapatkan profil mahasiswa + Memerlukan Authorization header dengan JWT token + """ + try: + id_mahasiswa = request.user_data['id_mahasiswa'] + + connection = get_db_connection() + if connection is None: + return jsonify({'error': 'Gagal koneksi ke database'}), 500 + + cursor = connection.cursor(dictionary=True) + + cursor.execute(""" + SELECT id_mahasiswa, npm, nama, jenkel, fakultas, jurusan, semester, created_at + FROM mahasiswa + WHERE id_mahasiswa = %s + """, (id_mahasiswa,)) + + mahasiswa = cursor.fetchone() + + cursor.close() + connection.close() + + if not mahasiswa: + return jsonify({'error': 'Profil tidak ditemukan'}), 404 + + return jsonify({ + 'message': 'Profil berhasil diambil', + 'data': mahasiswa + }), 200 + + except Exception as e: + return jsonify({'error': str(e)}), 500 + +# ==================== ABSENSI ==================== + +@app.route('/api/absensi/submit', methods=['POST']) +@token_required +def submit_absensi(): + """ + Endpoint untuk submit absensi (UPDATE: dengan validasi jadwal) + + Request Body: + { + "id_jadwal": 1, + "latitude": -6.223276, + "longitude": 107.009273, + "timestamp": "2026-01-13 14:30:00", + "foto_base64": "base64_string", + "status": "HADIR" + } + """ + try: + data = request.get_json() + id_mahasiswa = request.user_data['id_mahasiswa'] + npm = request.user_data['npm'] + + # Validasi input + required_fields = ['id_jadwal', 'latitude', 'longitude', 'timestamp', 'foto_base64', 'status'] + for field in required_fields: + if field not in data: + return jsonify({'error': f'Field {field} wajib diisi'}), 400 + + connection = get_db_connection() + if connection is None: + return jsonify({'error': 'Gagal koneksi ke database'}), 500 + + cursor = connection.cursor(dictionary=True) + + # Ambil nama mahasiswa + cursor.execute("SELECT nama FROM mahasiswa WHERE id_mahasiswa = %s", (id_mahasiswa,)) + mahasiswa = cursor.fetchone() + + if not mahasiswa: + cursor.close() + connection.close() + return jsonify({'error': 'Mahasiswa tidak ditemukan'}), 404 + + # Ambil info jadwal & mata kuliah + cursor.execute(""" + SELECT + j.id_jadwal, + j.jam_mulai, + j.jam_selesai, + m.nama_matkul + FROM jadwal_kelas j + JOIN mata_kuliah m ON j.id_matkul = m.id_matkul + WHERE j.id_jadwal = %s + """, (data['id_jadwal'],)) + + jadwal = cursor.fetchone() + + if not jadwal: + cursor.close() + connection.close() + return jsonify({'error': 'Jadwal tidak ditemukan'}), 404 + + # Validasi waktu absensi + from datetime import datetime, timedelta + + timestamp_absensi = datetime.strptime(data['timestamp'], '%Y-%m-%d %H:%M:%S') + waktu_absensi = timestamp_absensi.time() + + jam_mulai = jadwal['jam_mulai'] + jam_selesai = jadwal['jam_selesai'] + + # CONVERT timedelta ke time jika perlu + if isinstance(jam_mulai, timedelta): + total_seconds = int(jam_mulai.total_seconds()) + hours = total_seconds // 3600 + minutes = (total_seconds % 3600) // 60 + seconds = total_seconds % 60 + jam_mulai = datetime.strptime(f"{hours}:{minutes}:{seconds}", '%H:%M:%S').time() + elif isinstance(jam_mulai, str): + jam_mulai = datetime.strptime(jam_mulai, '%H:%M:%S').time() + + if isinstance(jam_selesai, timedelta): + total_seconds = int(jam_selesai.total_seconds()) + hours = total_seconds // 3600 + minutes = (total_seconds % 3600) // 60 + seconds = total_seconds % 60 + jam_selesai = datetime.strptime(f"{hours}:{minutes}:{seconds}", '%H:%M:%S').time() + elif isinstance(jam_selesai, str): + jam_selesai = datetime.strptime(jam_selesai, '%H:%M:%S').time() + + if not (jam_mulai <= waktu_absensi <= jam_selesai): + cursor.close() + connection.close() + return jsonify({ + 'error': 'Absensi di luar jam kelas', + 'detail': { + 'jam_mulai': str(jam_mulai), + 'jam_selesai': str(jam_selesai), + 'waktu_absensi': str(waktu_absensi) + } + }), 400 + + # Cek apakah sudah absen hari ini untuk jadwal ini + cursor.execute(""" + SELECT COUNT(*) as count + FROM absensi + WHERE id_mahasiswa = %s + AND id_jadwal = %s + AND DATE(timestamp) = DATE(%s) + """, (id_mahasiswa, data['id_jadwal'], data['timestamp'])) + + if cursor.fetchone()['count'] > 0: + cursor.close() + connection.close() + return jsonify({'error': 'Anda sudah absen untuk kelas ini hari ini'}), 400 + + # Insert absensi ke MySQL + insert_query = """ + INSERT INTO absensi ( + id_mahasiswa, npm, nama, id_jadwal, mata_kuliah, + latitude, longitude, timestamp, photo, foto_base64, status + ) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + """ + cursor.execute(insert_query, ( + id_mahasiswa, + npm, + mahasiswa['nama'], + data['id_jadwal'], + jadwal['nama_matkul'], + data['latitude'], + data['longitude'], + data['timestamp'], + data.get('photo', data['foto_base64']), + data['foto_base64'], + data['status'] + )) + + connection.commit() + id_absensi = cursor.lastrowid + + cursor.close() + connection.close() + + # KIRIM KE WEBHOOK N8N + try: + import requests + webhook_url = "https://n8n.lab.ubharajaya.ac.id/webhook/23c6993d-1792-48fb-ad1c-ffc78a3e6254" + webhook_payload = { + "npm": npm, + "nama": mahasiswa['nama'], + "mata_kuliah": jadwal['nama_matkul'], + "latitude": data['latitude'], + "longitude": data['longitude'], + "timestamp": data['timestamp'], + "photo": data['foto_base64'], + "foto_base64": data['foto_base64'], + "status": data['status'] + } + webhook_response = requests.post(webhook_url, json=webhook_payload, timeout=10) + print(f"✅ Webhook n8n: {webhook_response.status_code}") + except Exception as e: + print(f"⚠️ Webhook error: {e}") + + return jsonify({ + 'message': 'Absensi berhasil disimpan', + 'data': { + 'id_absensi': id_absensi, + 'mata_kuliah': jadwal['nama_matkul'], + 'status': data['status'] + } + }), 201 + + except Exception as e: + return jsonify({'error': str(e)}), 500 + +@app.route('/api/absensi/history', methods=['GET']) +@token_required +def get_history(): + """ + Endpoint untuk mendapatkan riwayat absensi + + Query Parameters: + - start_date (optional): YYYY-MM-DD + - end_date (optional): YYYY-MM-DD + """ + try: + id_mahasiswa = request.user_data['id_mahasiswa'] + start_date = request.args.get('start_date') + end_date = request.args.get('end_date') + + connection = get_db_connection() + if connection is None: + return jsonify({'error': 'Gagal koneksi ke database'}), 500 + + cursor = connection.cursor(dictionary=True) + + # Query dasar + query = """ + SELECT id_absensi, npm, nama, latitude, longitude, timestamp, status, created_at + FROM absensi + WHERE id_mahasiswa = %s + """ + params = [id_mahasiswa] + + # Filter berdasarkan tanggal + if start_date and end_date: + query += " AND DATE(timestamp) BETWEEN %s AND %s" + params.extend([start_date, end_date]) + elif start_date: + query += " AND DATE(timestamp) >= %s" + params.append(start_date) + elif end_date: + query += " AND DATE(timestamp) <= %s" + params.append(end_date) + + query += " ORDER BY timestamp DESC" + + cursor.execute(query, params) + history = cursor.fetchall() + + cursor.close() + connection.close() + + return jsonify({ + 'message': 'Riwayat berhasil diambil', + 'count': len(history), + 'data': history + }), 200 + + except Exception as e: + return jsonify({'error': str(e)}), 500 + +@app.route('/api/absensi/photo/', methods=['GET']) +@token_required +def get_photo(id_absensi): + """ + Endpoint untuk mendapatkan foto absensi + """ + try: + id_mahasiswa = request.user_data['id_mahasiswa'] + + connection = get_db_connection() + if connection is None: + return jsonify({'error': 'Gagal koneksi ke database'}), 500 + + cursor = connection.cursor(dictionary=True) + + cursor.execute(""" + SELECT foto_base64 + FROM absensi + WHERE id_absensi = %s AND id_mahasiswa = %s + """, (id_absensi, id_mahasiswa)) + + result = cursor.fetchone() + + cursor.close() + connection.close() + + if not result: + return jsonify({'error': 'Foto tidak ditemukan'}), 404 + + return jsonify({ + 'message': 'Foto berhasil diambil', + 'data': { + 'id_absensi': id_absensi, + 'foto_base64': result['foto_base64'] + } + }), 200 + + except Exception as e: + return jsonify({'error': str(e)}), 500 + +# ==================== STATISTIK ==================== + +@app.route('/api/absensi/stats', methods=['GET']) +@token_required +def get_stats(): + """ + Endpoint untuk mendapatkan statistik absensi mahasiswa + """ + try: + id_mahasiswa = request.user_data['id_mahasiswa'] + + connection = get_db_connection() + if connection is None: + return jsonify({'error': 'Gagal koneksi ke database'}), 500 + + cursor = connection.cursor(dictionary=True) + + # Total absensi + cursor.execute(""" + SELECT COUNT(*) as total FROM absensi WHERE id_mahasiswa = %s + """, (id_mahasiswa,)) + total = cursor.fetchone()['total'] + + # Absensi bulan ini + cursor.execute(""" + SELECT COUNT(*) as bulan_ini + FROM absensi + WHERE id_mahasiswa = %s + AND MONTH(timestamp) = MONTH(CURRENT_DATE()) + AND YEAR(timestamp) = YEAR(CURRENT_DATE()) + """, (id_mahasiswa,)) + bulan_ini = cursor.fetchone()['bulan_ini'] + + # Absensi minggu ini + cursor.execute(""" + SELECT COUNT(*) as minggu_ini + FROM absensi + WHERE id_mahasiswa = %s + AND YEARWEEK(timestamp, 1) = YEARWEEK(CURRENT_DATE(), 1) + """, (id_mahasiswa,)) + minggu_ini = cursor.fetchone()['minggu_ini'] + + cursor.close() + connection.close() + + return jsonify({ + 'message': 'Statistik berhasil diambil', + 'data': { + 'total_absensi': total, + 'absensi_bulan_ini': bulan_ini, + 'absensi_minggu_ini': minggu_ini + } + }), 200 + + except Exception as e: + return jsonify({'error': str(e)}), 500 + +# ==================== JADWAL KELAS ==================== + +@app.route('/api/jadwal/today', methods=['GET']) +@token_required +def get_jadwal_today(): + """ + Endpoint untuk mendapatkan jadwal kelas hari ini + berdasarkan semester dan jurusan mahasiswa + """ + try: + id_mahasiswa = request.user_data['id_mahasiswa'] + + connection = get_db_connection() + if connection is None: + return jsonify({'error': 'Gagal koneksi ke database'}), 500 + + cursor = connection.cursor(dictionary=True) + + # Ambil data mahasiswa + cursor.execute(""" + SELECT semester, jurusan FROM mahasiswa WHERE id_mahasiswa = %s + """, (id_mahasiswa,)) + mahasiswa = cursor.fetchone() + + if not mahasiswa: + cursor.close() + connection.close() + return jsonify({'error': 'Mahasiswa tidak ditemukan'}), 404 + + # Ambil hari ini dalam bahasa Indonesia + import locale + from datetime import datetime + + hari_mapping = { + 'Monday': 'Senin', + 'Tuesday': 'Selasa', + 'Wednesday': 'Rabu', + 'Thursday': 'Kamis', + 'Friday': 'Jumat', + 'Saturday': 'Sabtu', + 'Sunday': 'Minggu' + } + + hari_ini = hari_mapping.get(datetime.now().strftime('%A'), 'Senin') + + # Query jadwal hari ini + cursor.execute(""" + SELECT + j.id_jadwal, + j.hari, + j.jam_mulai, + j.jam_selesai, + j.ruangan, + m.kode_matkul, + m.nama_matkul, + m.sks, + m.dosen + FROM jadwal_kelas j + JOIN mata_kuliah m ON j.id_matkul = m.id_matkul + WHERE j.hari = %s + AND j.semester = %s + AND j.jurusan = %s + ORDER BY j.jam_mulai + """, (hari_ini, mahasiswa['semester'], mahasiswa['jurusan'])) + + jadwal = cursor.fetchall() + + # Cek apakah mahasiswa sudah absen untuk jadwal tertentu + for item in jadwal: + cursor.execute(""" + SELECT COUNT(*) as sudah_absen + FROM absensi + WHERE id_mahasiswa = %s + AND id_jadwal = %s + AND DATE(timestamp) = CURDATE() + """, (id_mahasiswa, item['id_jadwal'])) + + result = cursor.fetchone() + item['sudah_absen'] = result['sudah_absen'] > 0 + + # Format waktu + item['jam_mulai'] = str(item['jam_mulai']) + item['jam_selesai'] = str(item['jam_selesai']) + + cursor.close() + connection.close() + + return jsonify({ + 'message': 'Jadwal berhasil diambil', + 'hari': hari_ini, + 'count': len(jadwal), + 'data': jadwal + }), 200 + + except Exception as e: + return jsonify({'error': str(e)}), 500 + + +@app.route('/api/jadwal/check/', methods=['GET']) +@token_required +def check_jadwal_aktif(id_jadwal): + """ + Endpoint untuk cek apakah jadwal sedang aktif (dalam rentang waktu) + """ + try: + connection = get_db_connection() + if connection is None: + return jsonify({'error': 'Gagal koneksi ke database'}), 500 + + cursor = connection.cursor(dictionary=True) + + # Ambil jadwal + cursor.execute(""" + SELECT + j.id_jadwal, + j.jam_mulai, + j.jam_selesai, + m.nama_matkul + FROM jadwal_kelas j + JOIN mata_kuliah m ON j.id_matkul = m.id_matkul + WHERE j.id_jadwal = %s + """, (id_jadwal,)) + + jadwal = cursor.fetchone() + + cursor.close() + connection.close() + + if not jadwal: + return jsonify({'error': 'Jadwal tidak ditemukan'}), 404 + + # Cek waktu sekarang + from datetime import datetime, time + + waktu_sekarang = datetime.now().time() + jam_mulai = jadwal['jam_mulai'] + jam_selesai = jadwal['jam_selesai'] + + # Convert to time if needed + if isinstance(jam_mulai, str): + jam_mulai = datetime.strptime(jam_mulai, '%H:%M:%S').time() + if isinstance(jam_selesai, str): + jam_selesai = datetime.strptime(jam_selesai, '%H:%M:%S').time() + + is_aktif = jam_mulai <= waktu_sekarang <= jam_selesai + + return jsonify({ + 'message': 'Pengecekan jadwal berhasil', + 'data': { + 'id_jadwal': jadwal['id_jadwal'], + 'mata_kuliah': jadwal['nama_matkul'], + 'jam_mulai': str(jam_mulai), + 'jam_selesai': str(jam_selesai), + 'waktu_sekarang': str(waktu_sekarang), + 'is_aktif': is_aktif + } + }), 200 + + except Exception as e: + return jsonify({'error': str(e)}), 500 + +# ==================== RUN SERVER ==================== + +if __name__ == '__main__': + print("🚀 Menginisialisasi database...") + init_database() + print("🌐 Starting Flask server...") + print("📍 Backend API: http://localhost:5000") + print("📍 Health Check: http://localhost:5000/api/health") + app.run(debug=True, host='0.0.0.0', port=5000) \ No newline at end of file diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..96c82fd --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,8 @@ +# ==================== requirements.txt ==================== +flask==3.0.0 +flask-cors==4.0.0 +mysql-connector-python==8.2.0 +PyJWT==2.8.0 +bcrypt==4.1.2 +python-dotenv==1.0.0 +requests==2.31.0 \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7d255c8..8ed501d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -8,6 +8,7 @@ espressoCore = "3.7.0" lifecycleRuntimeKtx = "2.9.4" activityCompose = "1.11.0" composeBom = "2024.09.00" +animationCoreLint = "1.10.0" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -24,6 +25,7 @@ androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "u androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" } +androidx-compose-animation-core-lint = { group = "androidx.compose.animation", name = "animation-core-lint", version.ref = "animationCoreLint" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" }