Initial commit

This commit is contained in:
putraa-afk 2026-01-14 20:45:44 +07:00
parent ed435ffbc1
commit 462f9c315a
10 changed files with 665 additions and 189 deletions

13
.idea/deviceManager.xml generated Normal file
View File

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DeviceTable">
<option name="columnSorters">
<list>
<ColumnSorterState>
<option name="column" value="Name" />
<option name="order" value="ASCENDING" />
</ColumnSorterState>
</list>
</option>
</component>
</project>

View File

@ -0,0 +1,50 @@
<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>

8
.idea/markdown.xml generated Normal file
View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="MarkdownSettings">
<option name="previewPanelProviderInfo">
<ProviderInfo name="Compose (experimental)" className="com.intellij.markdown.compose.preview.ComposePanelProvider" />
</option>
</component>
</project>

View File

@ -6,6 +6,7 @@
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/> <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/> <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
<uses-permission android:name="android.permission.CAMERA"/> <uses-permission android:name="android.permission.CAMERA"/>
<uses-feature <uses-feature
android:name="android.hardware.camera" android:name="android.hardware.camera"
android:required="false" /> android:required="false" />
@ -19,17 +20,27 @@
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.SistemAkademik"> android:theme="@style/Theme.SistemAkademik">
<!-- 🔥 SPLASH SCREEN (LAUNCHER) -->
<activity <activity
android:name=".MainActivity" android:name=".SplashActivity"
android:exported="true" android:exported="true">
android:label="@string/app_name"
android:theme="@style/Theme.SistemAkademik">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN"/> <action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/> <category android:name="android.intent.category.LAUNCHER"/>
</intent-filter> </intent-filter>
</activity> </activity>
<!-- LOGIN -->
<activity
android:name=".LoginActivity"
android:exported="true"/>
<!-- MAIN -->
<activity
android:name=".MainActivity"
android:exported="true"/>
</application> </application>
</manifest> </manifest>

View File

@ -0,0 +1,44 @@
package id.ac.ubharajaya.sistemakademik
import android.graphics.Bitmap
import android.util.Base64
import org.json.JSONObject
import java.io.ByteArrayOutputStream
/**
* Data class untuk menyimpan informasi absensi mahasiswa
*/
data class Absensi(
val npm: String,
val nama: String,
val latitude: Double,
val longitude: Double,
val waktu: String,
val foto: Bitmap? // ⬅️ HARUS nullable
) {
/**
* Convert objek Absensi menjadi JSON
*/
fun toJson(): JSONObject {
val json = JSONObject()
json.put("npm", npm)
json.put("nama", nama)
json.put("latitude", latitude)
json.put("longitude", longitude)
json.put("waktu", waktu)
json.put("timestamp", System.currentTimeMillis())
json.put("foto_base64", foto?.let { bitmapToBase64(it) })
return json
}
companion object {
fun bitmapToBase64(bitmap: Bitmap): String {
val outputStream = ByteArrayOutputStream()
bitmap.compress(Bitmap.CompressFormat.JPEG, 80, outputStream)
return Base64.encodeToString(
outputStream.toByteArray(),
Base64.NO_WRAP
)
}
}
}

View File

@ -0,0 +1,182 @@
package id.ac.ubharajaya.sistemakademik
import android.content.Intent
import android.os.Bundle
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.animation.*
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AccountCircle
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
class LoginActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
val typography = Typography(
titleLarge = MaterialTheme.typography.titleLarge.copy(
fontFamily = FontFamily.SansSerif,
fontWeight = FontWeight.Bold,
fontSize = 22.sp
)
)
MaterialTheme(
typography = typography,
colorScheme = if (isSystemInDarkTheme())
darkColorScheme()
else
lightColorScheme()
) {
LoginScreen()
}
}
}
}
@Composable
fun LoginScreen() {
val context = LocalContext.current
var npm by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
var showCard by remember { mutableStateOf(false) }
LaunchedEffect(Unit) { showCard = true }
val buttonGradient = Brush.horizontalGradient(
listOf(Color(0xFF1565C0), Color(0xFF42A5F5))
)
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black) // 🔥 BACKGROUND HITAM
.padding(24.dp),
contentAlignment = Alignment.Center
) {
AnimatedVisibility(
visible = showCard,
enter = fadeIn() + scaleIn(initialScale = 0.9f)
) {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(22.dp),
elevation = CardDefaults.cardElevation(14.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface
)
) {
Column(
modifier = Modifier.padding(28.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Image(
painter = painterResource(id = R.drawable.logo),
contentDescription = "Logo",
modifier = Modifier
.size(120.dp)
.clip(CircleShape)
)
Spacer(modifier = Modifier.height(20.dp))
Text(
"Absensi PPB",
style = MaterialTheme.typography.titleLarge
)
Text(
"Login Mahasiswa",
fontSize = 14.sp,
color = Color.Gray
)
Spacer(modifier = Modifier.height(28.dp))
OutlinedTextField(
value = npm,
onValueChange = { npm = it },
label = { Text("NPM") },
leadingIcon = {
Icon(Icons.Default.AccountCircle, contentDescription = null)
},
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(16.dp))
OutlinedTextField(
value = password,
onValueChange = { password = it },
label = { Text("Password") },
leadingIcon = {
Icon(Icons.Default.Lock, contentDescription = null)
},
visualTransformation = PasswordVisualTransformation(),
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(32.dp))
Button(
onClick = {
if (npm == "202310715307" && password == "yoo23") {
context.startActivity(
Intent(context, MainActivity::class.java)
)
} else {
Toast.makeText(
context,
"NPM atau password salah",
Toast.LENGTH_SHORT
).show()
}
},
colors = ButtonDefaults.buttonColors(containerColor = Color.Transparent),
contentPadding = PaddingValues(),
modifier = Modifier
.fillMaxWidth()
.height(48.dp)
.background(
brush = buttonGradient,
shape = RoundedCornerShape(14.dp)
)
) {
Text(
"Login",
color = Color.White,
fontWeight = FontWeight.Bold
)
}
}
}
}
}
}

View File

@ -3,110 +3,203 @@ package id.ac.ubharajaya.sistemakademik
import android.Manifest import android.Manifest
import android.app.Activity import android.app.Activity
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager
import android.graphics.Bitmap import android.graphics.Bitmap
import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.provider.MediaStore import android.provider.MediaStore
import android.util.Base64
import android.widget.Toast import android.widget.Toast
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent 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.animation.animateContentSize
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.google.android.gms.location.LocationServices import com.google.android.gms.location.LocationServices
import id.ac.ubharajaya.sistemakademik.ui.theme.SistemAkademikTheme import id.ac.ubharajaya.sistemakademik.ui.theme.SistemAkademikTheme
import org.json.JSONObject import kotlinx.coroutines.delay
import java.io.ByteArrayOutputStream import kotlinx.coroutines.launch
import java.net.HttpURLConnection import java.text.SimpleDateFormat
import java.net.URL import java.util.*
import kotlin.concurrent.thread import kotlin.concurrent.thread
/* ================= UTIL ================= */ /* ================== KONFIG LOCKOUT ================== */
const val LOCKOUT_DURATION = 10 * 60 * 1000L // 1 menit
fun bitmapToBase64(bitmap: Bitmap): String { /* ================== REPOSITORY ================== */
val outputStream = ByteArrayOutputStream() class AbsensiRepository {
bitmap.compress(Bitmap.CompressFormat.JPEG, 80, outputStream) fun kirimAbsensi(absensi: Absensi, onResult: (Boolean) -> Unit) {
return Base64.encodeToString(outputStream.toByteArray(), Base64.NO_WRAP)
}
fun kirimKeN8n(
context: ComponentActivity,
latitude: Double,
longitude: Double,
foto: Bitmap
) {
thread { thread {
try { try {
val url = URL("https://n8n.lab.ubharajaya.ac.id/webhook/23c6993d-1792-48fb-ad1c-ffc78a3e6254") val url = java.net.URL(
// test URL val url = URL("https://n8n.lab.ubharajaya.ac.id/webhook-test/23c6993d-1792-48fb-ad1c-ffc78a3e6254") "https://n8n.lab.ubharajaya.ac.id/webhook/23c6993d-1792-48fb-ad1c-ffc78a3e6254"
val conn = url.openConnection() as HttpURLConnection )
val conn = url.openConnection() as java.net.HttpURLConnection
conn.requestMethod = "POST" conn.requestMethod = "POST"
conn.setRequestProperty("Content-Type", "application/json") conn.setRequestProperty("Content-Type", "application/json")
conn.doOutput = true conn.doOutput = true
val json = JSONObject().apply {
put("npm", "12345")
put("nama","Arif R D")
put("latitude", latitude)
put("longitude", longitude)
put("timestamp", System.currentTimeMillis())
put("foto_base64", bitmapToBase64(foto))
}
conn.outputStream.use { conn.outputStream.use {
it.write(json.toString().toByteArray()) it.write(absensi.toJson().toString().toByteArray())
}
val responseCode = conn.responseCode
context.runOnUiThread {
Toast.makeText(
context,
if (responseCode == 200)
"Absensi diterima server"
else
"Absensi ditolak server",
Toast.LENGTH_SHORT
).show()
} }
onResult(conn.responseCode == 200)
conn.disconnect() conn.disconnect()
} catch (e: Exception) {
onResult(false)
}
}
}
}
} catch (_: Exception) { /* ================== VIEWMODEL ================== */
context.runOnUiThread { class AbsensiViewModel(
private val repo: AbsensiRepository,
private val activity: ComponentActivity
) : ViewModel() {
var lokasi by mutableStateOf("Koordinat: -")
private set
var latitude by mutableStateOf<Double?>(null)
private set
var longitude by mutableStateOf<Double?>(null)
private set
var foto by mutableStateOf<Bitmap?>(null)
private set
var absensiList = mutableStateListOf<Absensi>()
var waktuAbsensi by mutableStateOf<String?>(null)
private set
var isLoading by mutableStateOf(false)
private set
var sisaLockout by mutableStateOf(0L)
private set
init {
startLockoutCountdown()
}
fun updateLokasi(lat: Double, lon: Double) {
latitude = lat
longitude = lon
lokasi = "Lat: $lat\nLon: $lon"
}
fun updateFoto(bitmap: Bitmap) {
foto = bitmap
}
private fun getLastAbsensiTime(): Long {
val prefs = activity.getSharedPreferences("absensi_prefs", Activity.MODE_PRIVATE)
return prefs.getLong("last_absensi_time", 0L)
}
private fun simpanWaktuAbsensi() {
val prefs = activity.getSharedPreferences("absensi_prefs", Activity.MODE_PRIVATE)
prefs.edit().putLong("last_absensi_time", System.currentTimeMillis()).apply()
}
private fun startLockoutCountdown() {
viewModelScope.launch {
while (true) {
val diff = System.currentTimeMillis() - getLastAbsensiTime()
sisaLockout =
if (diff < LOCKOUT_DURATION) LOCKOUT_DURATION - diff else 0L
delay(1000)
}
}
}
fun kirimAbsensi() {
if (sisaLockout > 0) {
Toast.makeText( Toast.makeText(
context, activity,
"Gagal kirim ke server", "Sudah absen, tunggu ${(sisaLockout / 1000) / 60} menit",
Toast.LENGTH_SHORT Toast.LENGTH_LONG
).show() ).show()
return
}
if (latitude == null || longitude == null || foto == null) {
Toast.makeText(activity, "Lokasi atau foto belum lengkap", Toast.LENGTH_SHORT).show()
return
}
val waktu = SimpleDateFormat(
"dd MMM yyyy HH:mm:ss",
Locale.getDefault()
).format(System.currentTimeMillis())
val absensi = Absensi(
npm = "202310715307",
nama = "Satrio Putra Wardani",
latitude = latitude!!,
longitude = longitude!!,
waktu = waktu,
foto = foto
)
isLoading = true
repo.kirimAbsensi(absensi) { success ->
viewModelScope.launch {
isLoading = false
if (success) {
absensiList.add(absensi)
waktuAbsensi = waktu
simpanWaktuAbsensi()
Toast.makeText(activity, "Absensi berhasil", Toast.LENGTH_SHORT).show()
} else {
Toast.makeText(activity, "Gagal kirim absensi", Toast.LENGTH_SHORT).show()
}
} }
} }
} }
} }
/* ================= ACTIVITY ================= */ /* ================== MAIN ACTIVITY ================== */
@OptIn(ExperimentalMaterial3Api::class)
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
private lateinit var viewModel: AbsensiViewModel
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
enableEdgeToEdge() enableEdgeToEdge()
viewModel = AbsensiViewModel(AbsensiRepository(), this)
setContent { setContent {
SistemAkademikTheme { SistemAkademikTheme {
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> Scaffold(
topBar = { TopBarLogout(this) }
) { padding ->
AbsensiScreen( AbsensiScreen(
modifier = Modifier.padding(innerPadding), modifier = Modifier.padding(padding),
activity = this viewModel = viewModel
) )
} }
} }
@ -114,161 +207,174 @@ class MainActivity : ComponentActivity() {
} }
} }
/* ================= UI ================= */ /* ================== TOP BAR ================== */
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun AbsensiScreen( fun TopBarLogout(activity: ComponentActivity) {
modifier: Modifier = Modifier, val context = LocalContext.current
activity: ComponentActivity TopAppBar(
) { title = {
Text(
" Sistem Akademik ",
style = MaterialTheme.typography.titleLarge.copy(
fontWeight = FontWeight.SemiBold,
color = Color.White
)
)
},
actions = {
TextButton(onClick = {
val intent = Intent(context, LoginActivity::class.java)
intent.flags =
Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
context.startActivity(intent)
}) {
Text(
"Logout",
style = MaterialTheme.typography.labelLarge,
color = Color.Red
)
}
}
)
}
/* ================== UI ================== */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AbsensiScreen(modifier: Modifier, viewModel: AbsensiViewModel) {
val context = LocalContext.current val context = LocalContext.current
var lokasi by remember { mutableStateOf("Koordinat: -") }
var latitude by remember { mutableStateOf<Double?>(null) }
var longitude by remember { mutableStateOf<Double?>(null) }
var foto by remember { mutableStateOf<Bitmap?>(null) }
val fusedLocationClient =
LocationServices.getFusedLocationProviderClient(context)
/* ===== Permission Lokasi ===== */
val locationPermissionLauncher = val locationPermissionLauncher =
rememberLauncherForActivityResult( rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
ActivityResultContracts.RequestPermission()
) { granted ->
if (granted) { if (granted) {
LocationServices.getFusedLocationProviderClient(context)
if ( .lastLocation.addOnSuccessListener {
ContextCompat.checkSelfPermission( it?.let { loc ->
context, viewModel.updateLokasi(loc.latitude, loc.longitude)
Manifest.permission.ACCESS_FINE_LOCATION
) == PackageManager.PERMISSION_GRANTED
) {
fusedLocationClient.lastLocation
.addOnSuccessListener { location ->
if (location != null) {
latitude = location.latitude
longitude = location.longitude
lokasi =
"Lat: ${location.latitude}\nLon: ${location.longitude}"
} else {
lokasi = "Lokasi tidak tersedia"
} }
} }
.addOnFailureListener {
lokasi = "Gagal mengambil lokasi"
} }
} }
} else {
Toast.makeText(
context,
"Izin lokasi ditolak",
Toast.LENGTH_SHORT
).show()
}
}
/* ===== Kamera ===== */
val cameraLauncher = val cameraLauncher =
rememberLauncherForActivityResult( rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {
ActivityResultContracts.StartActivityForResult() if (it.resultCode == Activity.RESULT_OK) {
) { result ->
if (result.resultCode == Activity.RESULT_OK) {
val bitmap = val bitmap =
result.data?.extras?.getParcelable("data", Bitmap::class.java) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU)
if (bitmap != null) { it.data?.extras?.getParcelable("data", Bitmap::class.java)
foto = bitmap else it.data?.extras?.getParcelable("data")
Toast.makeText( bitmap?.let { bmp -> viewModel.updateFoto(bmp) }
context,
"Foto berhasil diambil",
Toast.LENGTH_SHORT
).show()
}
} }
} }
val cameraPermissionLauncher = val cameraPermissionLauncher =
rememberLauncherForActivityResult( rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) {
ActivityResultContracts.RequestPermission() if (it) cameraLauncher.launch(Intent(MediaStore.ACTION_IMAGE_CAPTURE))
) { granted ->
if (granted) {
val intent =
Intent(MediaStore.ACTION_IMAGE_CAPTURE)
cameraLauncher.launch(intent)
} else {
Toast.makeText(
context,
"Izin kamera ditolak",
Toast.LENGTH_SHORT
).show()
} }
}
/* ===== Request Awal ===== */
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
locationPermissionLauncher.launch( locationPermissionLauncher.launch(Manifest.permission.ACCESS_FINE_LOCATION)
Manifest.permission.ACCESS_FINE_LOCATION
)
} }
/* ===== UI ===== */
Column( Column(
modifier = modifier modifier = modifier
.fillMaxSize() .fillMaxSize()
.padding(24.dp), .background(Color.White)
verticalArrangement = Arrangement.Center .padding(24.dp)
) { ) {
Text( Text(
text = "Absensi Akademik", text = "Absensi Akademik",
style = MaterialTheme.typography.titleLarge modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.headlineSmall.copy(
fontWeight = FontWeight.Bold,
color = Color.Black
)
) )
Spacer(modifier = Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
Text(text = lokasi) Card(
modifier = Modifier.fillMaxWidth(),
elevation = CardDefaults.cardElevation(6.dp)
) {
Column(Modifier.padding(16.dp)) {
Text(
"Lokasi:",
style = MaterialTheme.typography.bodyLarge.copy(
fontWeight = FontWeight.SemiBold,
color = Color.White
)
)
Text(
viewModel.lokasi,
style = MaterialTheme.typography.bodyMedium.copy(
color = Color.White
)
)
}
}
Spacer(modifier = Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
Button( Button(
onClick = { onClick = { cameraPermissionLauncher.launch(Manifest.permission.CAMERA) },
cameraPermissionLauncher.launch(
Manifest.permission.CAMERA
)
},
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) { ) {
Text("Ambil Foto") Text("Ambil Foto")
} }
Spacer(modifier = Modifier.height(12.dp)) Spacer(Modifier.height(12.dp))
Button( Button(
onClick = { onClick = { viewModel.kirimAbsensi() },
if (latitude != null && longitude != null && foto != null) { modifier = Modifier.fillMaxWidth(),
kirimKeN8n( enabled = !viewModel.isLoading
activity,
latitude!!,
longitude!!,
foto!!
)
} else {
Toast.makeText(
context,
"Lokasi atau foto belum lengkap",
Toast.LENGTH_SHORT
).show()
}
},
modifier = Modifier.fillMaxWidth()
) { ) {
Text("Kirim Absensi") if (viewModel.isLoading)
CircularProgressIndicator(
modifier = Modifier.size(22.dp),
strokeWidth = 2.dp
)
else Text("Kirim Absensi")
}
Spacer(Modifier.height(24.dp))
LazyColumn {
items(viewModel.absensiList) { item ->
Card(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 12.dp)
.animateContentSize()
) {
Column(Modifier.padding(16.dp)) {
Text(
item.nama,
style = MaterialTheme.typography.bodyLarge.copy(
fontWeight = FontWeight.Bold,
color = Color.White
)
)
Text(item.npm, color = Color.White)
Text(item.waktu, color = Color.White)
item.foto?.let {
Image(
bitmap = it.asImageBitmap(),
contentDescription = null,
modifier = Modifier
.fillMaxWidth()
.height(150.dp)
.clip(RoundedCornerShape(12.dp))
)
}
}
}
}
} }
} }
} }

View File

@ -0,0 +1,62 @@
package id.ac.ubharajaya.sistemakademik
import android.content.Intent
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.delay
class SplashActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
SplashScreen {
startActivity(Intent(this, LoginActivity::class.java))
finish()
}
}
}
}
@Composable
fun SplashScreen(onFinish: () -> Unit) {
LaunchedEffect(Unit) {
delay(2000) // 2 detik
onFinish()
}
// 🔥 GRADIENT ABU-ABU MODERN
val gradient = Brush.verticalGradient(
listOf(
Color(0xFF2C2C2C), // Dark Grey
Color(0xFF757575) // Light Grey
)
)
Box(
modifier = Modifier
.fillMaxSize()
.background(gradient),
contentAlignment = Alignment.Center
) {
Image(
painter = painterResource(id = R.drawable.logo),
contentDescription = "Logo",
modifier = Modifier
.size(140.dp)
.clip(CircleShape)
)
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

BIN
app/src/main/res/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB