Compare commits
No commits in common. "a389bd1432d2db23e0f06767de69e4b24a3b45f3" and "741a6a8fd856f89cb17bb60c3cb7d5d94287c0dc" have entirely different histories.
a389bd1432
...
741a6a8fd8
50
.idea/inspectionProfiles/Project_Default.xml
generated
50
.idea/inspectionProfiles/Project_Default.xml
generated
@ -1,50 +0,0 @@
|
||||
<component name="InspectionProjectProfileManager">
|
||||
<profile version="1.0">
|
||||
<option name="myName" value="Project Default" />
|
||||
<inspection_tool class="ComposePreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="ComposePreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="ComposePreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="ComposePreviewNotSupportedInUnitTestFiles" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="GlancePreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="GlancePreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="GlancePreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="GlancePreviewNotSupportedInUnitTestFiles" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewAnnotationInFunctionWithParameters" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewApiLevelMustBeValid" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewDeviceShouldUseNewSpec" enabled="true" level="WEAK WARNING" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewFontScaleMustBeGreaterThanZero" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewMultipleParameterProviders" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewParameterProviderOnFirstParameter" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewPickerAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
</inspection_tool>
|
||||
</profile>
|
||||
</component>
|
||||
@ -1,4 +0,0 @@
|
||||
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
|
||||
|
||||
11
README.md
11
README.md
@ -1,14 +1,3 @@
|
||||
📱 README UAS - Aplikasi Absensi Akademik Berbasis Koordinat dan Foto
|
||||
Nama: Muhammad Yusron Amrullah
|
||||
Pemograman Perangkat Bergerak Dibuat: 14 Januari 2026
|
||||
Status: ✅ Project Dikembangkan (Bukan Dibuat Ulang)
|
||||
Tujuan: Tugas Project Akhir Mata Kuliah Pemrograman Mobile
|
||||
Pengerjaan ini dibantu dengan ai
|
||||
|
||||
|
||||
🎯 Ringkasan Proyek
|
||||
Proyek ini adalah pengembangan dari Starter Project yang sudah disediakan, bukan membuat dari awal. Kami mengambil codebase yang ada dan mengembangkannya dengan fitur-fitur baru, perbaikan bug, dan peningkatan UI/UX.
|
||||
|
||||
# 📱 Aplikasi Absensi Akademik Berbasis Koordinat dan Foto (Mobile)
|
||||
|
||||
## 📌 Deskripsi Proyek
|
||||
|
||||
@ -5,7 +5,6 @@ import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.graphics.Bitmap
|
||||
import android.location.Location
|
||||
import android.os.Bundle
|
||||
import android.provider.MediaStore
|
||||
import android.util.Base64
|
||||
@ -15,15 +14,12 @@ import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.Spring
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
@ -33,88 +29,71 @@ import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.app.ActivityCompat
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.NavType
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import androidx.navigation.navArgument
|
||||
import com.google.android.gms.location.LocationServices
|
||||
import com.google.android.gms.location.Priority
|
||||
import id.ac.ubharajaya.sistemakademik.ui.theme.SistemAkademikTheme
|
||||
import kotlinx.coroutines.delay
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
import kotlin.concurrent.thread
|
||||
import kotlin.math.atan2
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.sin
|
||||
import kotlin.math.sqrt
|
||||
|
||||
/* ================= KONFIGURASI LOKASI ================= */
|
||||
/* ================= DATA CLASS ================= */
|
||||
data class AttendanceRecord(val timestamp: Long, val status: String)
|
||||
|
||||
const val KAMPUS_LAT = -6.222967558410948
|
||||
const val KAMPUS_LON = 107.00931291609834
|
||||
const val MAX_RADIUS = 50 // meter
|
||||
|
||||
fun hitungJarak(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Float {
|
||||
val hasil = FloatArray(1)
|
||||
Location.distanceBetween(lat1, lon1, lat2, lon2, hasil)
|
||||
return hasil[0]
|
||||
}
|
||||
|
||||
/* ================= UTIL ================= */
|
||||
/* ================= UTILS ================= */
|
||||
|
||||
fun bitmapToBase64(bitmap: Bitmap): String {
|
||||
val outputStream = ByteArrayOutputStream()
|
||||
bitmap.compress(Bitmap.CompressFormat.JPEG, 80, outputStream)
|
||||
bitmap.compress(Bitmap.CompressFormat.JPEG, 70, outputStream)
|
||||
return Base64.encodeToString(outputStream.toByteArray(), Base64.NO_WRAP)
|
||||
}
|
||||
|
||||
fun kirimKeN8n(
|
||||
context: ComponentActivity, npm: String, nama: String, mataKuliah: String,
|
||||
latitude: Double, longitude: Double, foto: Bitmap, status: String,
|
||||
onFinished: () -> Unit
|
||||
) {
|
||||
thread {
|
||||
try {
|
||||
val url = URL("https://n8n.lab.ubharajaya.ac.id/webhook/23c6993d-1792-48fb-ad1c-ffc78a3e6254")
|
||||
val conn = url.openConnection() as HttpURLConnection
|
||||
conn.requestMethod = "POST"
|
||||
conn.setRequestProperty("Content-Type", "application/json")
|
||||
conn.doOutput = true
|
||||
val json = JSONObject().apply {
|
||||
put("npm", npm); put("nama", nama); put("mata_kuliah", mataKuliah)
|
||||
put("latitude", latitude); put("longitude", longitude)
|
||||
put("timestamp", System.currentTimeMillis()); put("status", status)
|
||||
put("foto_base64", bitmapToBase64(foto))
|
||||
}
|
||||
conn.outputStream.use { it.write(json.toString().toByteArray()) }
|
||||
val responseCode = conn.responseCode
|
||||
context.runOnUiThread {
|
||||
Toast.makeText(
|
||||
context,
|
||||
if (responseCode == 200) "Absensi $status berhasil dikirim" else "Server menolak absensi",
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
conn.disconnect()
|
||||
} catch (_: Exception) {
|
||||
context.runOnUiThread {
|
||||
Toast.makeText(context, "Gagal kirim ke server", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
} finally {
|
||||
context.runOnUiThread(onFinished)
|
||||
}
|
||||
object LocationUtils {
|
||||
private const val KAMPUS_LATITUDE = -6.2576
|
||||
private const val KAMPUS_LONGITUDE = 106.9746
|
||||
private const val MAX_RADIUS_METERS = 100.0 // Radius 100 meter
|
||||
|
||||
fun isWithinCampusRadius(lat: Double, lon: Double): Boolean {
|
||||
val earthRadius = 6371000.0 // in meters
|
||||
val dLat = Math.toRadians(lat - KAMPUS_LATITUDE)
|
||||
val dLon = Math.toRadians(lon - KAMPUS_LONGITUDE)
|
||||
val lat1 = Math.toRadians(KAMPUS_LATITUDE)
|
||||
val lat2 = Math.toRadians(lat)
|
||||
|
||||
val a = sin(dLat / 2) * sin(dLat / 2) +
|
||||
sin(dLon / 2) * sin(dLon / 2) * cos(lat1) * cos(lat2)
|
||||
val c = 2 * atan2(sqrt(a), sqrt(1 - a))
|
||||
|
||||
return (earthRadius * c) <= MAX_RADIUS_METERS
|
||||
}
|
||||
}
|
||||
|
||||
/* ================= ACTIVITY ================= */
|
||||
/* ================= ACTIVITY ================= */
|
||||
|
||||
/* ================= MAIN ACTIVITY ================= */
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
@ -122,229 +101,506 @@ class MainActivity : ComponentActivity() {
|
||||
enableEdgeToEdge()
|
||||
setContent {
|
||||
SistemAkademikTheme {
|
||||
AbsensiScreen(activity = this)
|
||||
AppNavigation(activity = this)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ================= UI UTAMA ================= */
|
||||
/* ================= NAVIGATION ================= */
|
||||
|
||||
@Composable
|
||||
fun AbsensiScreen(activity: ComponentActivity) {
|
||||
val context = LocalContext.current
|
||||
fun AppNavigation(activity: ComponentActivity) {
|
||||
val navController = rememberNavController()
|
||||
|
||||
// States
|
||||
NavHost(navController = navController, startDestination = "login") {
|
||||
composable("login") {
|
||||
LoginScreen(navController = navController)
|
||||
}
|
||||
|
||||
composable(
|
||||
route = "menu/{npm}/{nama}",
|
||||
arguments = listOf(
|
||||
navArgument("npm") { type = NavType.StringType },
|
||||
navArgument("nama") { type = NavType.StringType }
|
||||
)
|
||||
) { backStackEntry ->
|
||||
val npm = backStackEntry.arguments?.getString("npm") ?: "N/A"
|
||||
val nama = backStackEntry.arguments?.getString("nama") ?: "N/A"
|
||||
MenuScreen(navController = navController, npm = npm, nama = nama)
|
||||
}
|
||||
|
||||
composable(
|
||||
route = "absensi/{npm}/{nama}",
|
||||
arguments = listOf(
|
||||
navArgument("npm") { type = NavType.StringType },
|
||||
navArgument("nama") { type = NavType.StringType }
|
||||
)
|
||||
) { backStackEntry ->
|
||||
val npm = backStackEntry.arguments?.getString("npm") ?: "N/A"
|
||||
val nama = backStackEntry.arguments?.getString("nama") ?: "N/A"
|
||||
AbsensiScreen(
|
||||
activity = activity,
|
||||
navController = navController,
|
||||
npm = npm,
|
||||
nama = nama
|
||||
)
|
||||
}
|
||||
|
||||
composable(
|
||||
route = "history/{npm}",
|
||||
arguments = listOf(navArgument("npm") { type = NavType.StringType })
|
||||
) { backStackEntry ->
|
||||
val npm = backStackEntry.arguments?.getString("npm") ?: "N/A"
|
||||
HistoryScreen(navController = navController, npm = npm, activity = activity)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ================= UI SCREENS ================= */
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun LoginScreen(navController: NavController) {
|
||||
var npm by remember { mutableStateOf("") }
|
||||
var nama by remember { mutableStateOf("") }
|
||||
var mataKuliah by remember { mutableStateOf("") }
|
||||
var lokasi by remember { mutableStateOf<String?>(null) }
|
||||
var latitude by remember { mutableStateOf<Double?>(null) }
|
||||
var longitude by remember { mutableStateOf<Double?>(null) }
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
CenterAlignedTopAppBar(
|
||||
title = { Text("Login Absensi", color = Color.White, fontSize = 18.sp) },
|
||||
colors = TopAppBarDefaults.centerAlignedTopAppBarColors(containerColor = Color(0xFF2E7D32))
|
||||
)
|
||||
}
|
||||
) { innerPadding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(innerPadding)
|
||||
.fillMaxSize()
|
||||
.background(Color(0xFFF8FBF8))
|
||||
.padding(horizontal = 32.dp),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Icon(Icons.Default.Person, contentDescription = null, modifier = Modifier.size(100.dp), tint = Color(0xFF2E7D32))
|
||||
Spacer(modifier = Modifier.height(40.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
value = npm,
|
||||
onValueChange = { npm = it },
|
||||
label = { Text("NPM") },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number, imeAction = ImeAction.Next),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
value = nama,
|
||||
onValueChange = { nama = it },
|
||||
label = { Text("Nama Lengkap") },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
if (npm.isNotBlank() && nama.isNotBlank()) {
|
||||
navController.navigate("menu/$npm/$nama")
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth().height(50.dp),
|
||||
enabled = npm.isNotBlank() && nama.isNotBlank(),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF1B5E20)),
|
||||
shape = RoundedCornerShape(10.dp)
|
||||
) {
|
||||
Text("LOGIN", fontWeight = FontWeight.Bold, color = Color.White)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun MenuScreen(navController: NavController, npm: String, nama: String) {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
CenterAlignedTopAppBar(
|
||||
title = { Text("Menu Utama", color = Color.White, fontSize = 18.sp) },
|
||||
colors = TopAppBarDefaults.centerAlignedTopAppBarColors(containerColor = Color(0xFF2E7D32)),
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { navController.popBackStack() }) {
|
||||
Icon(Icons.Default.ArrowBack, contentDescription = "Logout", tint = Color.White)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) { innerPadding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(innerPadding)
|
||||
.fillMaxSize()
|
||||
.background(Color(0xFFF8FBF8))
|
||||
.padding(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text("Selamat Datang,", style = MaterialTheme.typography.bodyLarge)
|
||||
Text("$nama ($npm)", fontWeight = FontWeight.Bold, fontSize = 20.sp)
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
// Tombol Absensi
|
||||
Button(
|
||||
onClick = { navController.navigate("absensi/$npm/$nama") },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(60.dp),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
) {
|
||||
Icon(Icons.Filled.CameraAlt, contentDescription = null, modifier = Modifier.size(24.dp))
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text("Absen Kehadiran", fontSize = 16.sp)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Tombol Riwayat
|
||||
Button(
|
||||
onClick = { navController.navigate("history/$npm") },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(60.dp),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
) {
|
||||
Icon(Icons.Default.History, contentDescription = null, modifier = Modifier.size(24.dp))
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text("Riwayat Absensi", fontSize = 16.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun AbsensiScreen(activity: ComponentActivity, navController: NavController, npm: String, nama: String) {
|
||||
val context = LocalContext.current
|
||||
|
||||
var lokasiStatus by remember { mutableStateOf("Mengecek Lokasi...") }
|
||||
var locationAvailable by remember { mutableStateOf(false) }
|
||||
var foto by remember { mutableStateOf<Bitmap?>(null) }
|
||||
var isUploading by remember { mutableStateOf(false) }
|
||||
var isFetchingLocation by remember { mutableStateOf(false) }
|
||||
var latitude by remember { mutableStateOf<Double?>(null) }
|
||||
var longitude by remember { mutableStateOf<Double?>(null) }
|
||||
var matakuliah by remember { mutableStateOf("") }
|
||||
|
||||
val fusedLocationClient = remember { LocationServices.getFusedLocationProviderClient(context) }
|
||||
|
||||
fun requestLocationUpdate() {
|
||||
if (ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED) {
|
||||
lokasi = "Izin lokasi belum diberikan."
|
||||
return
|
||||
}
|
||||
isFetchingLocation = true
|
||||
lokasi = "Mencari lokasi..."
|
||||
fusedLocationClient.getCurrentLocation(Priority.PRIORITY_HIGH_ACCURACY, null)
|
||||
.addOnSuccessListener { location: Location? ->
|
||||
if (location != null) {
|
||||
latitude = location.latitude
|
||||
longitude = location.longitude
|
||||
lokasi = "Lat: ${String.format("%.6f", location.latitude)}\nLon: ${String.format("%.6f", location.longitude)}"
|
||||
} else {
|
||||
lokasi = "Gagal mendapatkan lokasi. Coba lagi."
|
||||
}
|
||||
isFetchingLocation = false
|
||||
}
|
||||
.addOnFailureListener {
|
||||
lokasi = "Gagal mendapatkan lokasi. Coba lagi."
|
||||
isFetchingLocation = false
|
||||
}
|
||||
}
|
||||
|
||||
val locationPermissionLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
|
||||
if (granted) requestLocationUpdate() else {
|
||||
Toast.makeText(context, "Izin lokasi ditolak", Toast.LENGTH_SHORT).show()
|
||||
lokasi = "Izin lokasi ditolak."
|
||||
}
|
||||
}
|
||||
|
||||
val cameraLauncher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||
val cameraLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.StartActivityForResult()
|
||||
) { result ->
|
||||
if (result.resultCode == Activity.RESULT_OK) {
|
||||
val bitmap = result.data?.extras?.get("data") as? Bitmap
|
||||
if (bitmap != null) foto = bitmap else Toast.makeText(context, "Gagal mengambil foto", Toast.LENGTH_SHORT).show()
|
||||
if (bitmap != null) foto = bitmap
|
||||
}
|
||||
}
|
||||
|
||||
val cameraPermissionLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
|
||||
if (granted) cameraLauncher.launch(Intent(MediaStore.ACTION_IMAGE_CAPTURE))
|
||||
else Toast.makeText(context, "Izin kamera ditolak", Toast.LENGTH_SHORT).show()
|
||||
val requestPermissionLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.RequestMultiplePermissions()
|
||||
) { permissions ->
|
||||
if (permissions[Manifest.permission.ACCESS_FINE_LOCATION] == true) {
|
||||
if (ActivityCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED) {
|
||||
fusedLocationClient.getCurrentLocation(Priority.PRIORITY_HIGH_ACCURACY, null)
|
||||
.addOnSuccessListener { loc ->
|
||||
if (loc != null) {
|
||||
latitude = loc.latitude
|
||||
longitude = loc.longitude
|
||||
locationAvailable = true
|
||||
lokasiStatus = if (LocationUtils.isWithinCampusRadius(loc.latitude, loc.longitude)) {
|
||||
"Di Dalam Area Kampus"
|
||||
} else {
|
||||
"Di Luar Area Kampus"
|
||||
}
|
||||
} else {
|
||||
lokasiStatus = "Gagal Mendapatkan Lokasi"
|
||||
locationAvailable = false
|
||||
}
|
||||
}.addOnFailureListener {
|
||||
lokasiStatus = "Gagal Mendapatkan Lokasi"
|
||||
locationAvailable = false
|
||||
}
|
||||
}
|
||||
} else {
|
||||
lokasiStatus = "Izin lokasi ditolak."
|
||||
locationAvailable = false
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) { locationPermissionLauncher.launch(Manifest.permission.ACCESS_FINE_LOCATION) }
|
||||
|
||||
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
|
||||
val gradient = Brush.linearGradient(
|
||||
colors = listOf(MaterialTheme.colorScheme.primary.copy(alpha = 0.18f), Color.Transparent),
|
||||
start = Offset(0f, 0f),
|
||||
end = Offset(Float.POSITIVE_INFINITY, Float.POSITIVE_INFINITY)
|
||||
LaunchedEffect(Unit) {
|
||||
requestPermissionLauncher.launch(
|
||||
arrayOf(Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.CAMERA)
|
||||
)
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize().background(gradient).padding(WindowInsets.systemBars.asPaddingValues()),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
contentPadding = PaddingValues(horizontal = 24.dp, vertical = 16.dp)
|
||||
) {
|
||||
item { AnimatedHeader(delay = 0) }
|
||||
item { Spacer(modifier = Modifier.height(24.dp)) }
|
||||
|
||||
item { AnimatedInputCard(npm, nama, mataKuliah, { npm = it }, { nama = it }, { mataKuliah = it }) }
|
||||
item { Spacer(modifier = Modifier.height(24.dp)) }
|
||||
|
||||
item {
|
||||
AnimatedActionSection(isFetchingLocation, { requestLocationUpdate() }, { cameraPermissionLauncher.launch(Manifest.permission.CAMERA) }, lokasi)
|
||||
}
|
||||
item { Spacer(modifier = Modifier.height(24.dp)) }
|
||||
|
||||
item { AnimatedPhotoPreview(foto) }
|
||||
item { Spacer(modifier = Modifier.height(24.dp)) }
|
||||
Scaffold(
|
||||
topBar = {
|
||||
CenterAlignedTopAppBar(
|
||||
title = { Text("Absen Kehadiran", color = Color.White, fontSize = 18.sp) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { navController.popBackStack() }) {
|
||||
Icon(Icons.Default.ArrowBack, contentDescription = "Kembali", tint = Color.White)
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.centerAlignedTopAppBarColors(containerColor = Color(0xFF2E7D32))
|
||||
)
|
||||
}
|
||||
) { innerPadding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(innerPadding)
|
||||
.fillMaxSize()
|
||||
.background(Color(0xFFF8FBF8))
|
||||
.padding(horizontal = 16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text("Selamat Datang,", style = MaterialTheme.typography.bodyLarge)
|
||||
Text("$nama ($npm)", fontWeight = FontWeight.Bold, fontSize = 18.sp)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
item {
|
||||
val canSubmit = npm.isNotEmpty() && nama.isNotEmpty() && mataKuliah.isNotEmpty() && latitude != null && longitude != null && foto != null
|
||||
AnimatedSubmitButton(
|
||||
enabled = canSubmit && !isUploading,
|
||||
isUploading = isUploading,
|
||||
OutlinedTextField(
|
||||
value = matakuliah,
|
||||
onValueChange = { matakuliah = it },
|
||||
label = { Text("Mata Kuliah") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
|
||||
Row(
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = "Status Lokasi: $lokasiStatus ",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = if (locationAvailable) Color(0xFF2E7D32) else Color.Red
|
||||
)
|
||||
if (locationAvailable) {
|
||||
Icon(Icons.Default.CheckCircle, "OK", tint = Color(0xFF2E7D32), modifier = Modifier.size(16.dp))
|
||||
} else {
|
||||
Icon(Icons.Default.Close, "Error", tint = Color.Red, modifier = Modifier.size(16.dp))
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
Text("Ambil Foto Selfie", fontWeight = FontWeight.SemiBold, fontSize = 16.sp)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(220.dp)
|
||||
.clip(CircleShape)
|
||||
.background(Color(0xFFE0E0E0)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
if (foto != null) {
|
||||
Image(
|
||||
bitmap = foto!!.asImageBitmap(),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
} else {
|
||||
Icon(Icons.Default.Person, null, modifier = Modifier.size(80.dp), tint = Color.White)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(40.dp))
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
val intent = Intent(MediaStore.ACTION_IMAGE_CAPTURE)
|
||||
cameraLauncher.launch(intent)
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(0.7f).height(50.dp),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF4CAF50)),
|
||||
shape = RoundedCornerShape(10.dp)
|
||||
) {
|
||||
Text("AMBIL FOTO", fontWeight = FontWeight.Bold, color = Color.White)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
isUploading = true
|
||||
val jarak = hitungJarak(latitude!!, longitude!!, KAMPUS_LAT, KAMPUS_LON)
|
||||
val statusAbsen = if (jarak <= MAX_RADIUS) "HADIR" else "DITOLAK"
|
||||
if (statusAbsen == "HADIR") {
|
||||
kirimKeN8n(activity, npm, nama, mataKuliah, latitude!!, longitude!!, foto!!, statusAbsen) { isUploading = false }
|
||||
} else {
|
||||
val jarakKm = String.format("%.2f", jarak / 1000)
|
||||
Toast.makeText(context, "Absensi DITOLAK: Anda berada ${jarakKm}km dari kampus.", Toast.LENGTH_LONG).show()
|
||||
if (latitude != null && longitude != null && foto != null) {
|
||||
kirimKeServer(activity, npm, nama, matakuliah, latitude!!, longitude!!, foto!!) {
|
||||
isUploading = false
|
||||
if (it) navController.popBackStack()
|
||||
}
|
||||
} else {
|
||||
isUploading = false
|
||||
Toast.makeText(context, "Lokasi atau foto belum siap.", Toast.LENGTH_LONG).show()
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(0.7f).height(50.dp),
|
||||
enabled = foto != null && locationAvailable && matakuliah.isNotBlank() && !isUploading,
|
||||
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF1B5E20)),
|
||||
shape = RoundedCornerShape(10.dp)
|
||||
) {
|
||||
Text("KIRIM ABSENSI", fontWeight = FontWeight.Bold)
|
||||
}
|
||||
|
||||
|
||||
if (isUploading) {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
CircularProgressIndicator(color = Color(0xFF2E7D32))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun HistoryScreen(navController: NavController, npm: String, activity: Activity) {
|
||||
var historyList by remember { mutableStateOf<List<AttendanceRecord>>(emptyList()) }
|
||||
var isLoading by remember { mutableStateOf(true) }
|
||||
|
||||
LaunchedEffect(npm) {
|
||||
fetchHistoryFromServer(npm, activity) {
|
||||
historyList = it
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
CenterAlignedTopAppBar(
|
||||
title = { Text("Riwayat Absensi", color = Color.White) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { navController.popBackStack() }) {
|
||||
Icon(Icons.Default.ArrowBack, "Kembali", tint = Color.White)
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.centerAlignedTopAppBarColors(Color(0xFF2E7D32))
|
||||
)
|
||||
}
|
||||
) { innerPadding ->
|
||||
if (isLoading) {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
} else if (historyList.isEmpty()) {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text("Tidak ada riwayat absensi.")
|
||||
}
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.padding(innerPadding)
|
||||
.fillMaxSize()
|
||||
.padding(16.dp)
|
||||
) {
|
||||
items(historyList) { record ->
|
||||
val date = SimpleDateFormat("dd MMMM yyyy, HH:mm", Locale.getDefault()).format(Date(record.timestamp))
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp),
|
||||
elevation = CardDefaults.cardElevation(2.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.padding(16.dp)
|
||||
.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(text = date, fontWeight = FontWeight.Medium)
|
||||
Text(
|
||||
text = record.status,
|
||||
color = if (record.status == "Sukses") Color(0xFF2E7D32) else Color.Red,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ================= KOMPONEN UI DENGAN ANIMASI ================= */
|
||||
|
||||
@Composable
|
||||
fun AnimatedHeader(delay: Long = 0) {
|
||||
var visible by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(Unit) { delay(delay); visible = true }
|
||||
AnimatedVisibility(visible = visible, enter = fadeIn(spring(stiffness = Spring.StiffnessLow)) + slideInVertically(spring(Spring.DampingRatioMediumBouncy, Spring.StiffnessLow), initialOffsetY = { -it })) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.fillMaxWidth()) {
|
||||
Text("E-ABSENSI", style = MaterialTheme.typography.displaySmall, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.primary)
|
||||
Text("Universitas Bhayangkara Jakarta Raya", style = MaterialTheme.typography.titleMedium, color = Color.Gray)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AnimatedInputCard(
|
||||
npm: String, nama: String, mataKuliah: String,
|
||||
onNpmChange: (String) -> Unit, onNamaChange: (String) -> Unit, onMataKuliahChange: (String) -> Unit
|
||||
) {
|
||||
var cardVisible by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(Unit) { delay(200); cardVisible = true }
|
||||
|
||||
AnimatedVisibility(visible = cardVisible, enter = fadeIn(spring(stiffness = Spring.StiffnessLow)) + slideInVertically(spring(Spring.DampingRatioMediumBouncy, Spring.StiffnessLow), initialOffsetY = { it / 2 })) {
|
||||
Card(modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(16.dp), elevation = CardDefaults.cardElevation(4.dp)) {
|
||||
Column(modifier = Modifier.padding(20.dp)) {
|
||||
Text("Data Mahasiswa", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
OutlinedTextField(value = npm, onValueChange = onNpmChange, label = { Text("NPM") }, modifier = Modifier.fillMaxWidth(), keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), leadingIcon = { Icon(Icons.Default.Person, "NPM") })
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
OutlinedTextField(value = nama, onValueChange = onNamaChange, label = { Text("Nama Lengkap") }, modifier = Modifier.fillMaxWidth(), keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), leadingIcon = { Icon(Icons.Default.Badge, "Nama") })
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
OutlinedTextField(value = mataKuliah, onValueChange = onMataKuliahChange, label = { Text("Mata Kuliah") }, modifier = Modifier.fillMaxWidth(), keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), leadingIcon = { Icon(Icons.Default.Book, "Mata Kuliah") })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AnimatedActionSection(isFetchingLocation: Boolean, onRefresh: () -> Unit, onTakePhoto: () -> Unit, lokasi: String?) {
|
||||
var visible by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(Unit) { delay(400); visible = true }
|
||||
|
||||
AnimatedVisibility(visible = visible, enter = fadeIn(spring(stiffness = Spring.StiffnessLow)) + slideInVertically(spring(Spring.DampingRatioMediumBouncy, Spring.StiffnessLow), initialOffsetY = { it / 2 })) {
|
||||
Column {
|
||||
StatusRowWithRefresh(icon = Icons.Default.LocationOn, title = "Lokasi GPS", status = lokasi ?: "Meminta izin...", isRefreshing = isFetchingLocation, onRefresh = onRefresh)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
ActionButton(text = "Ambil Foto Kehadiran", icon = Icons.Default.CameraAlt, onClick = onTakePhoto)
|
||||
}
|
||||
}
|
||||
}
|
||||
/* ================= NETWORK LOGIC ================= */
|
||||
|
||||
@Composable
|
||||
fun AnimatedPhotoPreview(foto: Bitmap?) {
|
||||
AnimatedVisibility(visible = foto != null, enter = fadeIn(spring(stiffness = Spring.StiffnessMedium)) + slideInVertically(spring(Spring.DampingRatioMediumBouncy, Spring.StiffnessMedium), initialOffsetY = { it / 2 })) {
|
||||
Card(shape = RoundedCornerShape(16.dp), elevation = CardDefaults.cardElevation(4.dp)) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Image(bitmap = foto!!.asImageBitmap(), "Preview Foto Absensi", modifier = Modifier.fillMaxWidth().height(250.dp).clip(RoundedCornerShape(16.dp)), contentScale = ContentScale.Crop)
|
||||
Icon(Icons.Default.CheckCircle, "Foto Diambil", tint = Color.White.copy(alpha = 0.8f), modifier = Modifier.size(60.dp))
|
||||
fun kirimKeServer(activity: Activity, npm: String, nama: String, matakuliah: String, lat: Double, lon: Double, img: Bitmap, onFinish: (Boolean) -> Unit) {
|
||||
thread {
|
||||
var isSuccess = false
|
||||
try {
|
||||
val url = URL("https://n8n.lab.ubharajaya.ac.id/webhook/23c6993d-1792-48fb-ad1c-ffc78a3e6254")
|
||||
val conn = url.openConnection() as HttpURLConnection
|
||||
conn.requestMethod = "POST"
|
||||
conn.setRequestProperty("Content-Type", "application/json")
|
||||
conn.doOutput = true
|
||||
|
||||
val json = JSONObject().apply {
|
||||
put("npm", npm)
|
||||
put("nama", nama)
|
||||
put("mata_kuliah", matakuliah) // FIX: Changed from "matakuliah" to "mata_kuliah"
|
||||
put("latitude", lat)
|
||||
put("longitude", lon)
|
||||
put("timestamp", System.currentTimeMillis())
|
||||
put("foto_base64", bitmapToBase64(img))
|
||||
}
|
||||
|
||||
conn.outputStream.write(json.toString().toByteArray())
|
||||
|
||||
val code = conn.responseCode
|
||||
isSuccess = code == 200
|
||||
activity.runOnUiThread {
|
||||
Toast.makeText(activity, if(isSuccess) "Absensi Sukses!" else "Absensi Gagal: Server Error $code", Toast.LENGTH_LONG).show()
|
||||
onFinish(isSuccess)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
activity.runOnUiThread {
|
||||
Toast.makeText(activity, "Error: Gagal terhubung ke server.", Toast.LENGTH_SHORT).show()
|
||||
onFinish(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AnimatedSubmitButton(enabled: Boolean, isUploading: Boolean, onClick: () -> Unit) {
|
||||
var visible by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(Unit) { delay(600); visible = true }
|
||||
fun fetchHistoryFromServer(npm: String, activity: Activity, onResult: (List<AttendanceRecord>) -> Unit) {
|
||||
thread {
|
||||
try {
|
||||
// PENTING: URL ini hanya contoh. Ganti dengan URL API Anda untuk mengambil riwayat.
|
||||
val url = URL("https://n8n.lab.ubharajaya.ac.id/webhook-test/riwayat-absensi?npm=$npm")
|
||||
val conn = url.openConnection() as HttpURLConnection
|
||||
conn.requestMethod = "GET"
|
||||
|
||||
AnimatedVisibility(visible = visible, enter = fadeIn(spring(stiffness = Spring.StiffnessLow)) + slideInVertically(spring(Spring.DampingRatioMediumBouncy, Spring.StiffnessLow), initialOffsetY = { it / 2 })) {
|
||||
Button(onClick = onClick, enabled = enabled, modifier = Modifier.fillMaxWidth().height(56.dp)) {
|
||||
if (isUploading) {
|
||||
CircularProgressIndicator(color = Color.White, modifier = Modifier.size(24.dp))
|
||||
if (conn.responseCode == 200) {
|
||||
val response = conn.inputStream.bufferedReader().use { it.readText() }
|
||||
val jsonArray = JSONArray(response)
|
||||
val records = mutableListOf<AttendanceRecord>()
|
||||
for (i in 0 until jsonArray.length()) {
|
||||
val jsonObject = jsonArray.getJSONObject(i)
|
||||
records.add(
|
||||
AttendanceRecord(
|
||||
timestamp = jsonObject.getLong("timestamp"),
|
||||
status = jsonObject.getString("status")
|
||||
)
|
||||
)
|
||||
}
|
||||
activity.runOnUiThread { onResult(records) }
|
||||
} else {
|
||||
Text("KIRIM ABSENSI", fontWeight = FontWeight.Bold)
|
||||
activity.runOnUiThread { onResult(emptyList()) }
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
activity.runOnUiThread { onResult(emptyList()) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ================= KOMPONEN UI STANDAR ================= */
|
||||
|
||||
@Composable
|
||||
fun StatusRowWithRefresh(icon: ImageVector, title: String, status: String, isRefreshing: Boolean, onRefresh: () -> Unit) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
|
||||
Icon(icon, contentDescription = title, tint = MaterialTheme.colorScheme.primary)
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(title, fontWeight = FontWeight.Bold, fontSize = 16.sp)
|
||||
Text(status, style = MaterialTheme.typography.bodySmall, color = Color.Gray)
|
||||
}
|
||||
if (isRefreshing) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(24.dp))
|
||||
} else {
|
||||
IconButton(onClick = onRefresh) {
|
||||
Icon(Icons.Default.Refresh, "Cari Ulang Lokasi")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ActionButton(text: String, icon: ImageVector, onClick: () -> Unit) {
|
||||
FilledTonalButton(onClick = onClick, modifier = Modifier.fillMaxWidth().height(56.dp), shape = RoundedCornerShape(16.dp)) {
|
||||
Icon(icon, contentDescription = null)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Text(text, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user