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.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
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.filled.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
|
||||
import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
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.VisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.lint.kotlin.metadata.Visibility
|
||||
import com.google.android.gms.location.LocationServices
|
||||
@ -45,14 +49,20 @@ 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
|
||||
//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 ================= */
|
||||
|
||||
@ -109,6 +119,26 @@ data class JadwalKelas(
|
||||
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 ================= */
|
||||
|
||||
class UserPreferences(private val context: Context) {
|
||||
@ -368,8 +398,6 @@ fun submitAbsensi(
|
||||
}
|
||||
}
|
||||
|
||||
// TAMBAHKAN FUNGSI API BARU setelah fungsi submitAbsensi
|
||||
|
||||
fun getJadwalToday(
|
||||
token: String,
|
||||
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 ================= */
|
||||
|
||||
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) ================= */
|
||||
|
||||
@Composable
|
||||
@ -1163,11 +1947,17 @@ fun MainScreen(
|
||||
onClick = { selectedTab = 1 }
|
||||
)
|
||||
NavigationBarItem(
|
||||
icon = { Icon(Icons.Default.Person, "Profil") },
|
||||
label = { Text("Profil") },
|
||||
icon = { Icon(Icons.Default.History, "Riwayat") },
|
||||
label = { Text("Riwayat") },
|
||||
selected = selectedTab == 2,
|
||||
onClick = { selectedTab = 2 }
|
||||
)
|
||||
NavigationBarItem(
|
||||
icon = { Icon(Icons.Default.Person, "Profil") },
|
||||
label = { Text("Profil") },
|
||||
selected = selectedTab == 3,
|
||||
onClick = { selectedTab = 3 }
|
||||
)
|
||||
}
|
||||
}
|
||||
) { padding ->
|
||||
@ -1182,7 +1972,12 @@ fun MainScreen(
|
||||
modifier = Modifier.padding(padding),
|
||||
token = token
|
||||
)
|
||||
2 -> ProfilScreen(
|
||||
2 -> RiwayatScreen(
|
||||
modifier = Modifier.padding(padding),
|
||||
activity = activity,
|
||||
token = token
|
||||
)
|
||||
3 -> ProfilScreen(
|
||||
modifier = Modifier.padding(padding),
|
||||
mahasiswa = mahasiswa,
|
||||
onLogout = onLogout
|
||||
|
||||
@ -600,10 +600,7 @@ def submit_absensi():
|
||||
def get_history():
|
||||
"""
|
||||
Endpoint untuk mendapatkan riwayat absensi
|
||||
|
||||
Query Parameters:
|
||||
- start_date (optional): YYYY-MM-DD
|
||||
- end_date (optional): YYYY-MM-DD
|
||||
UPDATE: Join dengan jadwal_kelas untuk ambil jam_mulai & jam_selesai
|
||||
"""
|
||||
try:
|
||||
id_mahasiswa = request.user_data['id_mahasiswa']
|
||||
@ -616,30 +613,48 @@ def get_history():
|
||||
|
||||
cursor = connection.cursor(dictionary=True)
|
||||
|
||||
# Query dasar
|
||||
# QUERY UPDATE: Join ke tabel jadwal_kelas (alias j)
|
||||
query = """
|
||||
SELECT id_absensi, npm, nama, latitude, longitude, timestamp, status, created_at
|
||||
FROM absensi
|
||||
WHERE id_mahasiswa = %s
|
||||
SELECT
|
||||
a.id_absensi,
|
||||
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]
|
||||
|
||||
# Filter berdasarkan tanggal
|
||||
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])
|
||||
elif start_date:
|
||||
query += " AND DATE(timestamp) >= %s"
|
||||
query += " AND DATE(a.timestamp) >= %s"
|
||||
params.append(start_date)
|
||||
elif end_date:
|
||||
query += " AND DATE(timestamp) <= %s"
|
||||
query += " AND DATE(a.timestamp) <= %s"
|
||||
params.append(end_date)
|
||||
|
||||
query += " ORDER BY timestamp DESC"
|
||||
query += " ORDER BY a.timestamp DESC"
|
||||
|
||||
cursor.execute(query, params)
|
||||
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()
|
||||
connection.close()
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user