Riwayat Absensi Mahasiswa
This commit is contained in:
parent
89355bfbb7
commit
be950a83da
4
.kotlin/errors/errors-1768321427846.log
Normal file
4
.kotlin/errors/errors-1768321427846.log
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
kotlin version: 2.0.21
|
||||||
|
error message: The daemon has terminated unexpectedly on startup attempt #1 with error code: 0. The daemon process output:
|
||||||
|
1. Kotlin compile daemon is ready
|
||||||
|
|
||||||
@ -18,6 +18,7 @@ import androidx.activity.compose.setContent
|
|||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.foundation.Image
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
@ -27,6 +28,8 @@ import androidx.compose.foundation.verticalScroll
|
|||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.*
|
import androidx.compose.material.icons.filled.*
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
|
||||||
|
import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
@ -37,6 +40,7 @@ import androidx.compose.ui.text.input.KeyboardType
|
|||||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
import androidx.compose.ui.text.input.VisualTransformation
|
import androidx.compose.ui.text.input.VisualTransformation
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.window.Dialog
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.lint.kotlin.metadata.Visibility
|
import androidx.lint.kotlin.metadata.Visibility
|
||||||
import com.google.android.gms.location.LocationServices
|
import com.google.android.gms.location.LocationServices
|
||||||
@ -45,14 +49,20 @@ import org.json.JSONObject
|
|||||||
import java.io.ByteArrayOutputStream
|
import java.io.ByteArrayOutputStream
|
||||||
import java.net.HttpURLConnection
|
import java.net.HttpURLConnection
|
||||||
import java.net.URL
|
import java.net.URL
|
||||||
import java.text.SimpleDateFormat
|
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
import java.util.Locale
|
|
||||||
import kotlin.concurrent.thread
|
import kotlin.concurrent.thread
|
||||||
import kotlin.math.atan2
|
import kotlin.math.atan2
|
||||||
import kotlin.math.cos
|
import kotlin.math.cos
|
||||||
import kotlin.math.sin
|
import kotlin.math.sin
|
||||||
import kotlin.math.sqrt
|
import kotlin.math.sqrt
|
||||||
|
//import androidx.compose.foundation.background
|
||||||
|
//import androidx.compose.foundation.gestures.detectTapGestures
|
||||||
|
//import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
//import androidx.compose.ui.window.Dialog
|
||||||
|
//import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
//import androidx.compose.foundation.lazy.items
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
/* ================= CONSTANTS ================= */
|
/* ================= CONSTANTS ================= */
|
||||||
|
|
||||||
@ -109,6 +119,26 @@ data class JadwalKelas(
|
|||||||
val sudahAbsen: Boolean
|
val sudahAbsen: Boolean
|
||||||
)
|
)
|
||||||
|
|
||||||
|
data class RiwayatAbsensi(
|
||||||
|
val idAbsensi: Int,
|
||||||
|
val npm: String,
|
||||||
|
val nama: String,
|
||||||
|
val mataKuliah: String? = null, // Tambahkan ini
|
||||||
|
val latitude: Double,
|
||||||
|
val longitude: Double,
|
||||||
|
val timestamp: String,
|
||||||
|
val status: String,
|
||||||
|
val createdAt: String,
|
||||||
|
val jamMulai: String? = null,
|
||||||
|
val jamSelesai: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
data class AbsensiStats(
|
||||||
|
val totalAbsensi: Int,
|
||||||
|
val absensiMingguIni: Int,
|
||||||
|
val absensiBulanIni: Int
|
||||||
|
)
|
||||||
|
|
||||||
/* ================= USER PREFERENCES ================= */
|
/* ================= USER PREFERENCES ================= */
|
||||||
|
|
||||||
class UserPreferences(private val context: Context) {
|
class UserPreferences(private val context: Context) {
|
||||||
@ -368,8 +398,6 @@ fun submitAbsensi(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TAMBAHKAN FUNGSI API BARU setelah fungsi submitAbsensi
|
|
||||||
|
|
||||||
fun getJadwalToday(
|
fun getJadwalToday(
|
||||||
token: String,
|
token: String,
|
||||||
onSuccess: (List<JadwalKelas>) -> Unit,
|
onSuccess: (List<JadwalKelas>) -> Unit,
|
||||||
@ -491,6 +519,178 @@ fun submitAbsensiWithJadwal(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun getAbsensiHistory(
|
||||||
|
token: String,
|
||||||
|
startDate: String? = null,
|
||||||
|
endDate: String? = null,
|
||||||
|
onSuccess: (List<RiwayatAbsensi>) -> Unit,
|
||||||
|
onError: (String) -> Unit
|
||||||
|
) {
|
||||||
|
thread {
|
||||||
|
try {
|
||||||
|
// 1. Setup URL
|
||||||
|
var urlString = "${AppConstants.BASE_URL}/api/absensi/history"
|
||||||
|
|
||||||
|
// Tambahkan query parameters jika ada filter
|
||||||
|
val params = mutableListOf<String>()
|
||||||
|
if (startDate != null) params.add("start_date=$startDate")
|
||||||
|
if (endDate != null) params.add("end_date=$endDate")
|
||||||
|
|
||||||
|
if (params.isNotEmpty()) {
|
||||||
|
urlString += "?${params.joinToString("&")}"
|
||||||
|
}
|
||||||
|
|
||||||
|
val url = URL(urlString)
|
||||||
|
val conn = url.openConnection() as HttpURLConnection
|
||||||
|
conn.requestMethod = "GET"
|
||||||
|
conn.setRequestProperty("Authorization", "Bearer $token")
|
||||||
|
conn.connectTimeout = 15000
|
||||||
|
conn.readTimeout = 15000
|
||||||
|
|
||||||
|
// 2. Definisi Variabel responseCode dan response (PENTING: Jangan dihapus)
|
||||||
|
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()
|
||||||
|
|
||||||
|
// 3. Logika Parsing
|
||||||
|
if (responseCode == 200) {
|
||||||
|
// Parse String response menjadi JSONObject
|
||||||
|
val jsonResponse = JSONObject(response)
|
||||||
|
|
||||||
|
// Ambil Array 'data'
|
||||||
|
val dataArray = jsonResponse.getJSONArray("data")
|
||||||
|
val riwayatList = mutableListOf<RiwayatAbsensi>()
|
||||||
|
|
||||||
|
for (i in 0 until dataArray.length()) {
|
||||||
|
val item = dataArray.getJSONObject(i)
|
||||||
|
riwayatList.add(
|
||||||
|
RiwayatAbsensi(
|
||||||
|
idAbsensi = item.getInt("id_absensi"),
|
||||||
|
npm = item.getString("npm"),
|
||||||
|
nama = item.getString("nama"),
|
||||||
|
mataKuliah = item.optString("mata_kuliah", null),
|
||||||
|
latitude = item.getDouble("latitude"),
|
||||||
|
longitude = item.getDouble("longitude"),
|
||||||
|
timestamp = item.getString("timestamp"),
|
||||||
|
status = item.getString("status"),
|
||||||
|
createdAt = item.getString("created_at"),
|
||||||
|
// Ambil data jadwal (jam mulai & selesai)
|
||||||
|
jamMulai = item.optString("jam_mulai", null),
|
||||||
|
jamSelesai = item.optString("jam_selesai", null)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
onSuccess(riwayatList)
|
||||||
|
} else {
|
||||||
|
val error = try {
|
||||||
|
JSONObject(response).optString("error", "Gagal mengambil riwayat")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
"Gagal mengambil riwayat"
|
||||||
|
}
|
||||||
|
onError(error)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
onError("Error: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getAbsensiStats(
|
||||||
|
token: String,
|
||||||
|
onSuccess: (AbsensiStats) -> Unit,
|
||||||
|
onError: (String) -> Unit
|
||||||
|
) {
|
||||||
|
thread {
|
||||||
|
try {
|
||||||
|
val url = URL("${AppConstants.BASE_URL}/api/absensi/stats")
|
||||||
|
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 data = jsonResponse.getJSONObject("data")
|
||||||
|
|
||||||
|
val stats = AbsensiStats(
|
||||||
|
totalAbsensi = data.getInt("total_absensi"),
|
||||||
|
absensiMingguIni = data.getInt("absensi_minggu_ini"),
|
||||||
|
absensiBulanIni = data.getInt("absensi_bulan_ini")
|
||||||
|
)
|
||||||
|
|
||||||
|
onSuccess(stats)
|
||||||
|
} else {
|
||||||
|
val error = try {
|
||||||
|
JSONObject(response).optString("error", "Gagal mengambil statistik")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
"Gagal mengambil statistik"
|
||||||
|
}
|
||||||
|
onError(error)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
onError("Error: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getFotoAbsensi(
|
||||||
|
token: String,
|
||||||
|
idAbsensi: Int,
|
||||||
|
onSuccess: (String) -> Unit,
|
||||||
|
onError: (String) -> Unit
|
||||||
|
) {
|
||||||
|
thread {
|
||||||
|
try {
|
||||||
|
val url = URL("${AppConstants.BASE_URL}/api/absensi/photo/$idAbsensi")
|
||||||
|
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 fotoBase64 = jsonResponse.getJSONObject("data").getString("foto_base64")
|
||||||
|
onSuccess(fotoBase64)
|
||||||
|
} else {
|
||||||
|
val error = try {
|
||||||
|
JSONObject(response).optString("error", "Gagal mengambil foto")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
"Gagal mengambil foto"
|
||||||
|
}
|
||||||
|
onError(error)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
onError("Error: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* ================= MAIN ACTIVITY ================= */
|
/* ================= MAIN ACTIVITY ================= */
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
@ -1135,6 +1335,590 @@ fun LoginScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========== SCREEN BARU: RIWAYAT ABSENSI ==========
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun RiwayatScreen(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
activity: ComponentActivity,
|
||||||
|
token: String
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
var riwayatList by remember { mutableStateOf<List<RiwayatAbsensi>>(emptyList()) }
|
||||||
|
var stats by remember { mutableStateOf<AbsensiStats?>(null) }
|
||||||
|
var isLoading by remember { mutableStateOf(true) }
|
||||||
|
var showFilterDialog by remember { mutableStateOf(false) }
|
||||||
|
var showFotoDialog by remember { mutableStateOf(false) }
|
||||||
|
var selectedFoto by remember { mutableStateOf<Bitmap?>(null) }
|
||||||
|
var isLoadingFoto by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
// Filter states
|
||||||
|
var startDate by remember { mutableStateOf<String?>(null) }
|
||||||
|
var endDate by remember { mutableStateOf<String?>(null) }
|
||||||
|
var filterActive by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
val scrollState = rememberScrollState()
|
||||||
|
|
||||||
|
// Load data function
|
||||||
|
fun loadData() {
|
||||||
|
isLoading = true
|
||||||
|
getAbsensiHistory(
|
||||||
|
token = token,
|
||||||
|
startDate = startDate,
|
||||||
|
endDate = endDate,
|
||||||
|
onSuccess = { riwayat ->
|
||||||
|
activity.runOnUiThread {
|
||||||
|
riwayatList = riwayat
|
||||||
|
isLoading = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError = { error ->
|
||||||
|
activity.runOnUiThread {
|
||||||
|
isLoading = false
|
||||||
|
Toast.makeText(context, "❌ $error", Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
getAbsensiStats(
|
||||||
|
token = token,
|
||||||
|
onSuccess = { statsData ->
|
||||||
|
activity.runOnUiThread {
|
||||||
|
stats = statsData
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError = {}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initial load
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
loadData()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dialog Foto Full Screen
|
||||||
|
if (showFotoDialog && selectedFoto != null) {
|
||||||
|
Dialog(onDismissRequest = { showFotoDialog = false }) {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.wrapContentHeight(),
|
||||||
|
shape = MaterialTheme.shapes.large
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "📸 Foto Absensi",
|
||||||
|
style = MaterialTheme.typography.titleLarge
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
Image(
|
||||||
|
bitmap = selectedFoto!!.asImageBitmap(),
|
||||||
|
contentDescription = "Foto Absensi",
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.heightIn(max = 500.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = { showFotoDialog = false },
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Text("Tutup")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dialog Filter
|
||||||
|
if (showFilterDialog) {
|
||||||
|
FilterDateDialog(
|
||||||
|
startDate = startDate,
|
||||||
|
endDate = endDate,
|
||||||
|
onDismiss = { showFilterDialog = false },
|
||||||
|
onApply = { start, end ->
|
||||||
|
startDate = start
|
||||||
|
endDate = end
|
||||||
|
filterActive = (start != null || end != null)
|
||||||
|
isLoading = true
|
||||||
|
loadData()
|
||||||
|
showFilterDialog = false
|
||||||
|
},
|
||||||
|
onReset = {
|
||||||
|
startDate = null
|
||||||
|
endDate = null
|
||||||
|
filterActive = false
|
||||||
|
isLoading = true
|
||||||
|
loadData()
|
||||||
|
showFilterDialog = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(modifier = modifier.fillMaxSize()) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.verticalScroll(scrollState)
|
||||||
|
.padding(24.dp)
|
||||||
|
) {
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
// Header
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
text = "📋 Riwayat Absensi",
|
||||||
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
// Refresh Button
|
||||||
|
IconButton(onClick = { loadData() }) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Refresh,
|
||||||
|
contentDescription = "Refresh"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter Button
|
||||||
|
IconButton(onClick = { showFilterDialog = true }) {
|
||||||
|
Icon(
|
||||||
|
imageVector = if (filterActive) Icons.Default.FilterAlt else Icons.Default.FilterList,
|
||||||
|
contentDescription = "Filter",
|
||||||
|
tint = if (filterActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
// Statistik Cards
|
||||||
|
if (stats != null) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
StatsCard(
|
||||||
|
title = "Total",
|
||||||
|
value = stats!!.totalAbsensi.toString(),
|
||||||
|
icon = Icons.Default.CheckCircle,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
StatsCard(
|
||||||
|
title = "Minggu Ini",
|
||||||
|
value = stats!!.absensiMingguIni.toString(),
|
||||||
|
icon = Icons.Default.DateRange,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
StatsCard(
|
||||||
|
title = "Bulan Ini",
|
||||||
|
value = stats!!.absensiBulanIni.toString(),
|
||||||
|
icon = Icons.Default.CalendarToday,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter Info
|
||||||
|
if (filterActive) {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.primaryContainer
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.FilterAlt,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(16.dp)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text(
|
||||||
|
text = "Filter aktif: ${startDate ?: "..."} s/d ${endDate ?: "..."}",
|
||||||
|
style = MaterialTheme.typography.bodySmall
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Loading
|
||||||
|
if (isLoading) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(32.dp),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Empty State
|
||||||
|
else if (riwayatList.isEmpty()) {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(32.dp).fillMaxWidth(),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "📭",
|
||||||
|
style = MaterialTheme.typography.displayMedium
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
Text(
|
||||||
|
text = "Belum ada riwayat absensi",
|
||||||
|
style = MaterialTheme.typography.titleMedium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// List Riwayat
|
||||||
|
else {
|
||||||
|
riwayatList.forEach { riwayat ->
|
||||||
|
RiwayatCard(
|
||||||
|
riwayat = riwayat,
|
||||||
|
onLihatFoto = { idAbsensi ->
|
||||||
|
isLoadingFoto = true
|
||||||
|
getFotoAbsensi(
|
||||||
|
token = token,
|
||||||
|
idAbsensi = idAbsensi,
|
||||||
|
onSuccess = { fotoBase64 ->
|
||||||
|
val bitmap = base64ToBitmap(fotoBase64)
|
||||||
|
activity.runOnUiThread {
|
||||||
|
isLoadingFoto = false
|
||||||
|
if (bitmap != null) {
|
||||||
|
selectedFoto = bitmap
|
||||||
|
showFotoDialog = true
|
||||||
|
} else {
|
||||||
|
Toast.makeText(context, "❌ Gagal load foto", Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError = { error ->
|
||||||
|
activity.runOnUiThread {
|
||||||
|
isLoadingFoto = false
|
||||||
|
Toast.makeText(context, "❌ $error", Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Loading Foto Overlay
|
||||||
|
if (isLoadingFoto) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(MaterialTheme.colorScheme.surface.copy(alpha = 0.7f)),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ========== COMPONENTS ==========
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun StatsCard(
|
||||||
|
title: String,
|
||||||
|
value: String,
|
||||||
|
icon: androidx.compose.ui.graphics.vector.ImageVector,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
Card(
|
||||||
|
modifier = modifier,
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.secondaryContainer
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(12.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = icon,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(24.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
Text(
|
||||||
|
text = value,
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = title,
|
||||||
|
style = MaterialTheme.typography.bodySmall
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun RiwayatCard(
|
||||||
|
riwayat: RiwayatAbsensi,
|
||||||
|
onLihatFoto: (Int) -> Unit
|
||||||
|
) {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
shape = androidx.compose.foundation.shape.RoundedCornerShape(16.dp),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
// Warna background kartu transparan gelap (sesuai tema)
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.padding(16.dp)) {
|
||||||
|
// === HEADER: Tanggal (Kiri) & Status (Kanan) ===
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = formatTanggalCard(riwayat.timestamp),
|
||||||
|
style = MaterialTheme.typography.titleSmall.copy(
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
Surface(
|
||||||
|
shape = androidx.compose.foundation.shape.RoundedCornerShape(8.dp),
|
||||||
|
color = if (riwayat.status.uppercase() == "HADIR")
|
||||||
|
androidx.compose.ui.graphics.Color(0xFF6552A8) // Ungu gelap
|
||||||
|
else
|
||||||
|
MaterialTheme.colorScheme.errorContainer
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = riwayat.status.uppercase(),
|
||||||
|
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||||
|
style = MaterialTheme.typography.labelSmall.copy(
|
||||||
|
fontWeight = androidx.compose.ui.text.font.FontWeight.Bold
|
||||||
|
),
|
||||||
|
color = androidx.compose.ui.graphics.Color.White
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
// === BODY: Mata Kuliah ===
|
||||||
|
if (!riwayat.mataKuliah.isNullOrEmpty()) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Book,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(20.dp),
|
||||||
|
tint = androidx.compose.ui.graphics.Color(0xFF4FC3F7) // Cyan
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(12.dp))
|
||||||
|
Text(
|
||||||
|
text = riwayat.mataKuliah,
|
||||||
|
style = MaterialTheme.typography.titleMedium.copy(
|
||||||
|
fontWeight = androidx.compose.ui.text.font.FontWeight.SemiBold
|
||||||
|
),
|
||||||
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
// === BODY: Waktu (LOGIKA BARU) ===
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Schedule,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(20.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(12.dp))
|
||||||
|
|
||||||
|
// Logika: Jika ada jamMulai/Selesai, pakai itu. Jika tidak, pakai timestamp.
|
||||||
|
val waktuText = if (riwayat.jamMulai != null && riwayat.jamSelesai != null) {
|
||||||
|
val mulai = if (riwayat.jamMulai.length >= 5) riwayat.jamMulai.substring(0, 5) else riwayat.jamMulai
|
||||||
|
val selesai = if (riwayat.jamSelesai.length >= 5) riwayat.jamSelesai.substring(0, 5) else riwayat.jamSelesai
|
||||||
|
"Pukul $mulai - $selesai"
|
||||||
|
} else {
|
||||||
|
"Absen: ${formatJam(riwayat.timestamp)}"
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = waktuText,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
// === BODY: Lokasi (INI YANG HILANG SEBELUMNYA) ===
|
||||||
|
Row(verticalAlignment = Alignment.Top) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.LocationOn,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(20.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(12.dp))
|
||||||
|
Text(
|
||||||
|
text = "${String.format("%.6f", riwayat.latitude)}, ${String.format("%.6f", riwayat.longitude)}",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
// === FOOTER: Tombol Lihat Foto ===
|
||||||
|
Button(
|
||||||
|
onClick = { onLihatFoto(riwayat.idAbsensi) },
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(45.dp),
|
||||||
|
shape = androidx.compose.foundation.shape.RoundedCornerShape(12.dp),
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = androidx.compose.ui.graphics.Color(0xFF006064), // Teal gelap
|
||||||
|
contentColor = androidx.compose.ui.graphics.Color.White
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Image,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(18.dp)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text("Lihat Foto", style = MaterialTheme.typography.labelLarge)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun FilterDateDialog(
|
||||||
|
startDate: String?,
|
||||||
|
endDate: String?,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onApply: (String?, String?) -> Unit,
|
||||||
|
onReset: () -> Unit
|
||||||
|
) {
|
||||||
|
var tempStartDate by remember { mutableStateOf(startDate) }
|
||||||
|
var tempEndDate by remember { mutableStateOf(endDate) }
|
||||||
|
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text("Filter Tanggal") },
|
||||||
|
text = {
|
||||||
|
Column {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = tempStartDate ?: "",
|
||||||
|
onValueChange = { tempStartDate = it },
|
||||||
|
label = { Text("Tanggal Mulai") },
|
||||||
|
placeholder = { Text("YYYY-MM-DD") },
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = tempEndDate ?: "",
|
||||||
|
onValueChange = { tempEndDate = it },
|
||||||
|
label = { Text("Tanggal Akhir") },
|
||||||
|
placeholder = { Text("YYYY-MM-DD") },
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "Format: YYYY-MM-DD (contoh: 2026-01-13)",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = { onApply(tempStartDate, tempEndDate) }) {
|
||||||
|
Text("Terapkan")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
Row {
|
||||||
|
TextButton(onClick = onReset) {
|
||||||
|
Text("Reset")
|
||||||
|
}
|
||||||
|
TextButton(onClick = onDismiss) {
|
||||||
|
Text("Batal")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== HELPER FUNCTIONS ==========
|
||||||
|
|
||||||
|
// Helper untuk memparsing tanggal dari String MySQL (YYYY-MM-DD HH:mm:ss)
|
||||||
|
fun parseTimestamp(timestamp: String): Date? {
|
||||||
|
return try {
|
||||||
|
// Ganti 'T' dengan spasi jaga-jaga jika formatnya ISO8601
|
||||||
|
val cleanTimestamp = timestamp.replace("T", " ")
|
||||||
|
val sdf = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault())
|
||||||
|
sdf.parse(cleanTimestamp)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun formatTanggalCard(timestamp: String): String {
|
||||||
|
val date = parseTimestamp(timestamp) ?: return timestamp
|
||||||
|
// Format: "Sel, 13 Jan 2026"
|
||||||
|
val outputFormat = SimpleDateFormat("EEE, d MMM yyyy", Locale("id", "ID"))
|
||||||
|
return outputFormat.format(date)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun formatJam(timestamp: String): String {
|
||||||
|
val date = parseTimestamp(timestamp) ?: return "00:00"
|
||||||
|
// Format: "14:30"
|
||||||
|
val outputFormat = SimpleDateFormat("HH:mm", Locale.getDefault())
|
||||||
|
return outputFormat.format(date)
|
||||||
|
}
|
||||||
|
|
||||||
/* ================= MAIN SCREEN (Bottom Navigation) ================= */
|
/* ================= MAIN SCREEN (Bottom Navigation) ================= */
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@ -1163,11 +1947,17 @@ fun MainScreen(
|
|||||||
onClick = { selectedTab = 1 }
|
onClick = { selectedTab = 1 }
|
||||||
)
|
)
|
||||||
NavigationBarItem(
|
NavigationBarItem(
|
||||||
icon = { Icon(Icons.Default.Person, "Profil") },
|
icon = { Icon(Icons.Default.History, "Riwayat") },
|
||||||
label = { Text("Profil") },
|
label = { Text("Riwayat") },
|
||||||
selected = selectedTab == 2,
|
selected = selectedTab == 2,
|
||||||
onClick = { selectedTab = 2 }
|
onClick = { selectedTab = 2 }
|
||||||
)
|
)
|
||||||
|
NavigationBarItem(
|
||||||
|
icon = { Icon(Icons.Default.Person, "Profil") },
|
||||||
|
label = { Text("Profil") },
|
||||||
|
selected = selectedTab == 3,
|
||||||
|
onClick = { selectedTab = 3 }
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
) { padding ->
|
) { padding ->
|
||||||
@ -1182,7 +1972,12 @@ fun MainScreen(
|
|||||||
modifier = Modifier.padding(padding),
|
modifier = Modifier.padding(padding),
|
||||||
token = token
|
token = token
|
||||||
)
|
)
|
||||||
2 -> ProfilScreen(
|
2 -> RiwayatScreen(
|
||||||
|
modifier = Modifier.padding(padding),
|
||||||
|
activity = activity,
|
||||||
|
token = token
|
||||||
|
)
|
||||||
|
3 -> ProfilScreen(
|
||||||
modifier = Modifier.padding(padding),
|
modifier = Modifier.padding(padding),
|
||||||
mahasiswa = mahasiswa,
|
mahasiswa = mahasiswa,
|
||||||
onLogout = onLogout
|
onLogout = onLogout
|
||||||
|
|||||||
@ -600,10 +600,7 @@ def submit_absensi():
|
|||||||
def get_history():
|
def get_history():
|
||||||
"""
|
"""
|
||||||
Endpoint untuk mendapatkan riwayat absensi
|
Endpoint untuk mendapatkan riwayat absensi
|
||||||
|
UPDATE: Join dengan jadwal_kelas untuk ambil jam_mulai & jam_selesai
|
||||||
Query Parameters:
|
|
||||||
- start_date (optional): YYYY-MM-DD
|
|
||||||
- end_date (optional): YYYY-MM-DD
|
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
id_mahasiswa = request.user_data['id_mahasiswa']
|
id_mahasiswa = request.user_data['id_mahasiswa']
|
||||||
@ -616,30 +613,48 @@ def get_history():
|
|||||||
|
|
||||||
cursor = connection.cursor(dictionary=True)
|
cursor = connection.cursor(dictionary=True)
|
||||||
|
|
||||||
# Query dasar
|
# QUERY UPDATE: Join ke tabel jadwal_kelas (alias j)
|
||||||
query = """
|
query = """
|
||||||
SELECT id_absensi, npm, nama, latitude, longitude, timestamp, status, created_at
|
SELECT
|
||||||
FROM absensi
|
a.id_absensi,
|
||||||
WHERE id_mahasiswa = %s
|
a.npm,
|
||||||
|
a.nama,
|
||||||
|
a.mata_kuliah,
|
||||||
|
a.latitude,
|
||||||
|
a.longitude,
|
||||||
|
a.timestamp,
|
||||||
|
a.status,
|
||||||
|
a.created_at,
|
||||||
|
j.jam_mulai,
|
||||||
|
j.jam_selesai
|
||||||
|
FROM absensi a
|
||||||
|
LEFT JOIN jadwal_kelas j ON a.id_jadwal = j.id_jadwal
|
||||||
|
WHERE a.id_mahasiswa = %s
|
||||||
"""
|
"""
|
||||||
params = [id_mahasiswa]
|
params = [id_mahasiswa]
|
||||||
|
|
||||||
# Filter berdasarkan tanggal
|
|
||||||
if start_date and end_date:
|
if start_date and end_date:
|
||||||
query += " AND DATE(timestamp) BETWEEN %s AND %s"
|
query += " AND DATE(a.timestamp) BETWEEN %s AND %s"
|
||||||
params.extend([start_date, end_date])
|
params.extend([start_date, end_date])
|
||||||
elif start_date:
|
elif start_date:
|
||||||
query += " AND DATE(timestamp) >= %s"
|
query += " AND DATE(a.timestamp) >= %s"
|
||||||
params.append(start_date)
|
params.append(start_date)
|
||||||
elif end_date:
|
elif end_date:
|
||||||
query += " AND DATE(timestamp) <= %s"
|
query += " AND DATE(a.timestamp) <= %s"
|
||||||
params.append(end_date)
|
params.append(end_date)
|
||||||
|
|
||||||
query += " ORDER BY timestamp DESC"
|
query += " ORDER BY a.timestamp DESC"
|
||||||
|
|
||||||
cursor.execute(query, params)
|
cursor.execute(query, params)
|
||||||
history = cursor.fetchall()
|
history = cursor.fetchall()
|
||||||
|
|
||||||
|
# Konversi objek timedelta/time ke string
|
||||||
|
for item in history:
|
||||||
|
if item['jam_mulai']:
|
||||||
|
item['jam_mulai'] = str(item['jam_mulai'])
|
||||||
|
if item['jam_selesai']:
|
||||||
|
item['jam_selesai'] = str(item['jam_selesai'])
|
||||||
|
|
||||||
cursor.close()
|
cursor.close()
|
||||||
connection.close()
|
connection.close()
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user