Compare commits

..

No commits in common. "ca6d6e2d333ef30df1cbb47e89ce6cf2f3af31d2" and "46b74d7099cdaf925ca8634a2e21e5b391f81e03" have entirely different histories.

38 changed files with 49 additions and 1144 deletions

View File

@ -4,14 +4,6 @@
<selectionStates>
<SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" />
<DropdownSelection timestamp="2026-01-13T05:20:56.137492Z">
<Target type="DEFAULT_BOOT">
<handle>
<DeviceId pluginId="PhysicalDevice" identifier="serial=RR8TA08RD8Z" />
</handle>
</Target>
</DropdownSelection>
<DialogSelection />
</SelectionState>
</selectionStates>
</component>

View File

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

1
.idea/gradle.xml generated
View File

@ -1,6 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GradleMigrationSettings" migrationVersion="1" />
<component name="GradleSettings">
<option name="linkedExternalProjectsSettings">
<GradleProjectSettings>

View File

@ -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
.idea/misc.xml generated
View File

@ -1,3 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">

6
.idea/studiobot.xml generated
View File

@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="StudioBotProjectSettings">
<option name="shareContext" value="OptedIn" />
</component>
</project>

View File

@ -1,20 +1,12 @@
# 📱 Aplikasi Absensi Akademik Berbasis Koordinat dan Foto (Mobile)
## 📌 Deskripsi Proyek
Proyek ini merupakan **Tugas Akhir EAS (Evaluasi Akhir Semester)** yang dikembangkan oleh:
- **Nama** : Faris Naufal Priatna
- **NPM** : 202310715123
Tujuan proyek ini adalah membangun **aplikasi akademik berbasis mobile** dengan fokus pada **fitur absensi menggunakan data koordinat (GPS) dan pengambilan foto mahasiswa**.
Proyek ini merupakan **Tugas Project Akhir Mata Kuliah Pemrograman Mobile** yang bertujuan untuk membangun **aplikasi akademik berbasis mobile** dengan fokus pada **fitur absensi menggunakan data koordinat (GPS) dan pengambilan foto mahasiswa**.
Aplikasi ini dirancang untuk meningkatkan **validitas kehadiran mahasiswa**, dengan memastikan bahwa absensi hanya dapat dilakukan apabila mahasiswa:
1. Berada pada **lokasi yang telah ditentukan**, dan
2. Melakukan **pengambilan foto (selfie) secara langsung saat absensi**
Aplikasi ini **dibantu dikembangkan oleh AI ChatGPT** untuk memberikan saran teknis, struktur kode, dan pengembangan fitur tambahan.
---
## 🎯 Tujuan Proyek
@ -47,7 +39,7 @@ Aplikasi ini **dibantu dikembangkan oleh AI ChatGPT** untuk memberikan saran tek
- 📍 **Koordinat lokasi mahasiswa**
- 📸 **Foto mahasiswa secara real-time**
5. Sistem melakukan validasi:
- Absensi harus menggunakan foto
- Lokasi berada dalam **radius absensi**
- Foto berhasil diambil
6. Jika valid → **Absensi berhasil**
7. Jika tidak valid → **Absensi ditolak**
@ -83,36 +75,8 @@ Aplikasi memerlukan izin berikut:
- `ACCESS_COARSE_LOCATION`
- `CAMERA`
- `INTERNET`
- `WRITE_EXTERNAL_STORAGE` (opsional, jika diperlukan)
- `WRITE_EXTERNAL_STORAGE` (jika diperlukan)
---
## 📂 Mockup
![mockup](Mockup.png)
---
## Catatan
- Kembangkan project dari starter yang sudah disediakan, **tidak membuat dari awal**.
- Koordinat bisa ditambah/kurangi sedikit agar tidak memunculkan lokasi rumah masing-masing, data awal tetap diambil dari GPS.
---
## Pengecekan
- https://ntfy.ubharajaya.ac.id/EAS
- https://docs.google.com/spreadsheets/d/1jH15MfnNgpPGuGeid0hYfFUHCEFbCmg8afTyyLZs/edit?gid=0#gid=0
---
## Webhook
- Test: https://n8n.lab.ubharajaya.ac.id/webhook-test/23c6993d-1792-48fb-ad1c-ffc78a3e6254
- Production: https://n8n.lab.ubharajaya.ac.id/webhook/23c6993d-1792-48fb-ad1c-ffc78a3e6254
---
## 📄 Identitas Pengembang
- **Nama** : Faris Naufal Priatna
- **NPM** : 202310715123
- **Jenis Proyek** : Tugas Akhir EAS
- **Bantuan Pengembangan** : AI ChatGPT
Output Aplikasi yang sudah jadi
## 📂 Struktur Proyek (Contoh)

View File

@ -45,14 +45,11 @@ dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.activity.compose)
implementation("androidx.activity:activity-compose:1.9.0")
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.compose.ui)
implementation(libs.androidx.compose.ui.graphics)
implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.compose.material3)
// Location (GPS)
implementation("com.google.android.gms:play-services-location:21.0.1")
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)

View File

@ -1,46 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="id.ac.ubharajaya.sistemakademik">
xmlns:tools="http://schemas.android.com/tools">
<!-- ===== PERMISSIONS ===== -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.CAMERA" />
<!-- ===== FEATURES (OPTIONAL TAPI AMAN) ===== -->
<uses-feature
android:name="android.hardware.camera"
android:required="true" />
<!-- ===== APPLICATION ===== -->
<application
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.SistemAkademik">
<!-- ===== LOGIN ACTIVITY (LAUNCHER) ===== -->
<activity
android:name=".LoginActivity"
android:name=".MainActivity"
android:exported="true"
android:screenOrientation="portrait">
android:label="@string/app_name"
android:theme="@style/Theme.SistemAkademik">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<!-- ===== MAIN ACTIVITY ===== -->
<activity
android:name=".MainActivity"
android:exported="false"
android:screenOrientation="portrait" />
</application>
</manifest>
</manifest>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

View File

@ -1,33 +0,0 @@
package id.ac.ubharajaya.sistemakademik
import android.graphics.Bitmap
import android.util.Base64
import org.json.JSONObject
import java.io.ByteArrayOutputStream
data class Absensi(
val npm: String,
val nama: String,
val latitude: Double,
val longitude: Double,
val waktu: String,
val foto: Bitmap
) {
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)
// Convert Bitmap to Base64
val byteArrayOutputStream = ByteArrayOutputStream()
foto.compress(Bitmap.CompressFormat.JPEG, 80, byteArrayOutputStream)
val byteArray = byteArrayOutputStream.toByteArray()
val encodedImage = Base64.encodeToString(byteArray, Base64.DEFAULT)
json.put("foto", encodedImage)
return json
}
}

View File

@ -1,108 +0,0 @@
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.foundation.Image
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import id.ac.ubharajaya.sistemakademik.ui.theme.SistemAkademikTheme
class LoginActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
SistemAkademikTheme {
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
LoginScreen(modifier = Modifier.padding(innerPadding))
}
}
}
}
}
@Composable
fun LoginScreen(modifier: Modifier = Modifier) {
val context = LocalContext.current
var npm by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
Column(
modifier = modifier
.fillMaxSize()
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
// LOGO KAMPUS
Image(
painter = painterResource(R.drawable.logo_absensi), // logo kamu
contentDescription = "Logo Kampus",
modifier = Modifier.size(120.dp)
)
Spacer(modifier = Modifier.height(24.dp))
// Judul Login
Text(
text = "Login Mahasiswa",
style = MaterialTheme.typography.titleLarge
)
Spacer(modifier = Modifier.height(16.dp))
// Input NPM
OutlinedTextField(
value = npm,
onValueChange = { npm = it },
label = { Text("NPM") },
shape = RoundedCornerShape(12.dp),
modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)
)
Spacer(modifier = Modifier.height(12.dp))
// Input Password
OutlinedTextField(
value = password,
onValueChange = { password = it },
label = { Text("Password") },
shape = RoundedCornerShape(12.dp),
modifier = Modifier.fillMaxWidth(),
visualTransformation = PasswordVisualTransformation()
)
Spacer(modifier = Modifier.height(16.dp))
// Tombol Login
Button(
onClick = {
if (npm == "202310715200" && password == "123") {
val intent = Intent(context, MainActivity::class.java)
context.startActivity(intent)
} else {
Toast.makeText(context, "NPM atau password salah", Toast.LENGTH_SHORT).show()
}
},
shape = RoundedCornerShape(12.dp),
modifier = Modifier.fillMaxWidth()
) {
Text("Login")
}
}
}

View File

@ -1,570 +1,47 @@
package id.ac.ubharajaya.sistemakademik
import android.Manifest
import android.app.Activity
import android.content.Intent
import android.content.pm.PackageManager
import android.graphics.Bitmap
import android.os.Build
import android.os.Bundle
import android.provider.MediaStore
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.*
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.RoundedCornerShape
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import com.google.android.gms.location.LocationServices
import androidx.compose.ui.tooling.preview.Preview
import id.ac.ubharajaya.sistemakademik.ui.theme.SistemAkademikTheme
import java.text.SimpleDateFormat
import java.util.*
import kotlin.concurrent.thread
import java.net.HttpURLConnection
import java.net.URL
// Warna Akademik Biru Konsisten
val AkademikBlue = Color(0xFF1565C0)
val AkademikLightBlue = Color(0xFFE3F2FD)
val AkademikDarkBlue = Color(0xFF0D47A1)
// ================== UTILS ==================
fun kirimKeN8n(context: ComponentActivity, absensi: Absensi) {
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 = absensi.toJson()
conn.outputStream.use { it.write(json.toString().toByteArray()) }
val responseCode = conn.responseCode
context.runOnUiThread {
Toast.makeText(
context,
if (responseCode == 200) "Absensi berhasil dikirim" else "Absensi gagal dikirim",
Toast.LENGTH_SHORT
).show()
}
conn.disconnect()
} catch (_: Exception) {
context.runOnUiThread {
Toast.makeText(context, "Gagal terhubung ke server", Toast.LENGTH_SHORT).show()
}
}
}
}
// ================== MAIN ACTIVITY ==================
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
SistemAkademikTheme {
Surface(
modifier = Modifier.fillMaxSize(),
color = AkademikLightBlue
) {
AbsensiScreen(activity = this)
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
Greeting(
name = "Android",
modifier = Modifier.padding(innerPadding)
)
}
}
}
}
}
// ================== COMPOSABLE UI ==================
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AbsensiScreen(activity: ComponentActivity) {
val context = LocalContext.current
val session = remember { SessionManager(context) }
fun Greeting(name: String, modifier: Modifier = Modifier) {
Text(
text = "Hello $name!",
modifier = modifier
)
}
var lokasi by remember { mutableStateOf("Menunggu lokasi...") }
var latitude by remember { mutableStateOf<Double?>(null) }
var longitude by remember { mutableStateOf<Double?>(null) }
var foto by remember { mutableStateOf<Bitmap?>(null) }
var waktuAbsensi by remember { mutableStateOf<String?>(null) }
var isLoading by remember { mutableStateOf(false) }
var showLogoutDialog by remember { mutableStateOf(false) }
val absensiList = remember { mutableStateListOf<Absensi>() }
val fusedLocationClient = LocationServices.getFusedLocationProviderClient(context)
// ===== Permission & Kamera =====
val locationPermissionLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
if (granted) {
if (ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION)
== PackageManager.PERMISSION_GRANTED
) {
fusedLocationClient.lastLocation
.addOnSuccessListener { location ->
if (location != null) {
latitude = location.latitude
longitude = location.longitude
lokasi = "${String.format("%.6f", location.latitude)}, ${String.format("%.6f", location.longitude)}"
} else {
lokasi = "Lokasi tidak tersedia"
}
}
.addOnFailureListener { lokasi = "Gagal mengambil lokasi" }
}
} else {
Toast.makeText(context, "Izin lokasi diperlukan", Toast.LENGTH_SHORT).show()
}
}
val cameraLauncher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode == Activity.RESULT_OK) {
val bitmap: Bitmap? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
result.data?.extras?.getParcelable("data", Bitmap::class.java)
} else {
@Suppress("DEPRECATION")
result.data?.extras?.getParcelable("data")
}
if (bitmap != null) {
foto = bitmap
Toast.makeText(context, "Foto berhasil diambil", Toast.LENGTH_SHORT).show()
}
}
}
val cameraPermissionLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
if (granted) {
val intent = Intent(MediaStore.ACTION_IMAGE_CAPTURE)
cameraLauncher.launch(intent)
} else {
Toast.makeText(context, "Izin kamera diperlukan", Toast.LENGTH_SHORT).show()
}
}
LaunchedEffect(Unit) {
locationPermissionLauncher.launch(Manifest.permission.ACCESS_FINE_LOCATION)
}
// Logout Dialog
if (showLogoutDialog) {
AlertDialog(
onDismissRequest = { showLogoutDialog = false },
title = { Text("Konfirmasi Keluar") },
text = { Text("Apakah Anda yakin ingin keluar dari sistem?") },
confirmButton = {
TextButton(
onClick = {
session.logout()
context.startActivity(Intent(context, LoginActivity::class.java))
(context as ComponentActivity).finish()
}
) {
Text("Ya", color = Color.Red, fontWeight = FontWeight.Bold)
}
},
dismissButton = {
TextButton(onClick = { showLogoutDialog = false }) {
Text("Batal", color = AkademikBlue)
}
}
)
}
// ================== UI ==================
Scaffold(
topBar = {
TopAppBar(
title = {
Column {
Text(
"Sistem Absensi Mahasiswa",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold
)
Text(
"Universitas Bhayangkara Jakarta Raya",
style = MaterialTheme.typography.bodySmall
)
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = AkademikBlue,
titleContentColor = Color.White
),
actions = {
TextButton(onClick = { showLogoutDialog = true }) {
Text("Keluar", color = Color.White, fontWeight = FontWeight.Bold)
}
}
)
}
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.padding(16.dp)
) {
// CARD PROFIL MAHASISWA
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
colors = CardDefaults.cardColors(
containerColor = Color.White
),
elevation = CardDefaults.cardElevation(defaultElevation = 3.dp)
) {
Row(
modifier = Modifier.padding(20.dp),
verticalAlignment = Alignment.CenterVertically
) {
Surface(
modifier = Modifier.size(70.dp),
shape = RoundedCornerShape(12.dp),
color = AkademikLightBlue
) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text(
"IA",
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold,
color = AkademikBlue
)
}
}
Spacer(modifier = Modifier.width(16.dp))
Column {
Text(
text = session.getUserName() ?: "Indris Alpasela",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
color = AkademikDarkBlue
)
Text(
text = "NPM: ${session.getUserNpm() ?: "202310715200"}",
style = MaterialTheme.typography.bodyLarge,
color = Color.Gray
)
}
}
}
Spacer(modifier = Modifier.height(16.dp))
// INFO CARD: LOKASI & WAKTU
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
colors = CardDefaults.cardColors(containerColor = Color.White),
elevation = CardDefaults.cardElevation(defaultElevation = 3.dp)
) {
Column(modifier = Modifier.padding(16.dp)) {
// Lokasi
Row(verticalAlignment = Alignment.Top) {
Text(
"📍",
style = MaterialTheme.typography.headlineSmall
)
Spacer(modifier = Modifier.width(12.dp))
Column {
Text(
"Lokasi Saat Ini",
style = MaterialTheme.typography.labelMedium,
color = Color.Gray
)
Text(
lokasi,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium
)
}
}
waktuAbsensi?.let {
Spacer(modifier = Modifier.height(16.dp))
Divider()
Spacer(modifier = Modifier.height(16.dp))
// Waktu Absensi
Row(verticalAlignment = Alignment.Top) {
Text(
"🕐",
style = MaterialTheme.typography.headlineSmall
)
Spacer(modifier = Modifier.width(12.dp))
Column {
Text(
"Waktu Absensi Terakhir",
style = MaterialTheme.typography.labelMedium,
color = Color.Gray
)
Text(
it,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium
)
}
}
}
}
}
Spacer(modifier = Modifier.height(16.dp))
// PREVIEW FOTO
AnimatedVisibility(visible = foto != null) {
Column {
Card(
modifier = Modifier
.fillMaxWidth()
.height(240.dp),
shape = RoundedCornerShape(12.dp),
elevation = CardDefaults.cardElevation(defaultElevation = 3.dp)
) {
Box(modifier = Modifier.fillMaxSize()) {
foto?.let {
Image(
bitmap = it.asImageBitmap(),
contentDescription = "Preview Foto",
modifier = Modifier.fillMaxSize()
)
}
Surface(
modifier = Modifier
.align(Alignment.TopEnd)
.padding(8.dp),
shape = RoundedCornerShape(8.dp),
color = Color.White.copy(alpha = 0.9f)
) {
Text(
"Preview",
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.Bold,
color = AkademikBlue
)
}
}
}
Spacer(modifier = Modifier.height(16.dp))
}
}
// TOMBOL AMBIL FOTO
Button(
onClick = { cameraPermissionLauncher.launch(Manifest.permission.CAMERA) },
modifier = Modifier
.fillMaxWidth()
.height(60.dp),
colors = ButtonDefaults.buttonColors(containerColor = AkademikBlue),
shape = RoundedCornerShape(12.dp),
elevation = ButtonDefaults.buttonElevation(defaultElevation = 4.dp)
) {
Text(
"📷",
style = MaterialTheme.typography.headlineMedium
)
Spacer(modifier = Modifier.width(12.dp))
Text(
"Ambil Foto",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
}
Spacer(modifier = Modifier.height(12.dp))
// TOMBOL KIRIM ABSENSI
Button(
onClick = {
if (latitude != null && longitude != null && foto != null) {
isLoading = true
val waktu = SimpleDateFormat("dd MMMM yyyy, HH:mm:ss", Locale("id", "ID")).format(System.currentTimeMillis())
waktuAbsensi = waktu
val absensi = Absensi(
npm = session.getUserNpm() ?: "202310715200",
nama = session.getUserName() ?: "Indris Alpasela",
latitude = latitude!!,
longitude = longitude!!,
waktu = waktu,
foto = foto!!
)
kirimKeN8n(activity, absensi)
absensiList.add(0, absensi)
isLoading = false
} else {
Toast.makeText(
context,
"Mohon ambil foto dan pastikan lokasi aktif",
Toast.LENGTH_LONG
).show()
}
},
modifier = Modifier
.fillMaxWidth()
.height(60.dp),
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF2E7D32)),
shape = RoundedCornerShape(12.dp),
enabled = !isLoading,
elevation = ButtonDefaults.buttonElevation(defaultElevation = 4.dp)
) {
if (isLoading) {
CircularProgressIndicator(
color = Color.White,
modifier = Modifier.size(28.dp)
)
} else {
Text(
"✉️",
style = MaterialTheme.typography.headlineMedium
)
Spacer(modifier = Modifier.width(12.dp))
Text(
"Kirim Absensi",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
}
}
Spacer(modifier = Modifier.height(20.dp))
// RIWAYAT ABSENSI
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
"Riwayat Kehadiran",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
color = AkademikDarkBlue
)
if (absensiList.isNotEmpty()) {
Surface(
shape = RoundedCornerShape(20.dp),
color = AkademikLightBlue
) {
Text(
"${absensiList.size}",
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.Bold,
color = AkademikBlue
)
}
}
}
Spacer(modifier = Modifier.height(12.dp))
if (absensiList.isEmpty()) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = Color.White),
shape = RoundedCornerShape(12.dp)
) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(40.dp),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
"📋",
style = MaterialTheme.typography.displayMedium
)
Spacer(modifier = Modifier.height(12.dp))
Text(
"Belum ada riwayat kehadiran",
color = Color.Gray,
style = MaterialTheme.typography.bodyLarge
)
}
}
}
} else {
LazyColumn {
items(absensiList) { item ->
Card(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 6.dp),
elevation = CardDefaults.cardElevation(defaultElevation = 3.dp),
shape = RoundedCornerShape(12.dp),
colors = CardDefaults.cardColors(containerColor = Color.White)
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
// Foto Kecil
Image(
bitmap = item.foto.asImageBitmap(),
contentDescription = "Foto Absensi",
modifier = Modifier
.size(80.dp)
.clip(RoundedCornerShape(8.dp))
)
Spacer(modifier = Modifier.width(16.dp))
// Info
Column(modifier = Modifier.weight(1f)) {
Text(
item.nama,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = AkademikDarkBlue
)
Text(
item.npm,
style = MaterialTheme.typography.bodyMedium,
color = Color.Gray
)
Spacer(modifier = Modifier.height(6.dp))
Text(
item.waktu,
style = MaterialTheme.typography.bodySmall,
color = Color.Gray
)
}
// Status
Text(
"",
style = MaterialTheme.typography.headlineMedium
)
}
}
}
}
}
}
@Preview(showBackground = true)
@Composable
fun GreetingPreview() {
SistemAkademikTheme {
Greeting("Android")
}
}

View File

@ -1,41 +0,0 @@
package id.ac.ubharajaya.sistemakademik
import android.content.Context
import android.content.SharedPreferences
class SessionManager(context: Context) {
private val prefs: SharedPreferences =
context.getSharedPreferences("AbsensiSession", Context.MODE_PRIVATE)
companion object {
private const val KEY_IS_LOGGED_IN = "isLoggedIn"
private const val KEY_USER_NAME = "userName"
private const val KEY_USER_NPM = "userNpm"
}
fun saveLoginSession(nama: String, npm: String) {
val editor = prefs.edit()
editor.putBoolean(KEY_IS_LOGGED_IN, true)
editor.putString(KEY_USER_NAME, nama)
editor.putString(KEY_USER_NPM, npm)
editor.apply()
}
fun isLoggedIn(): Boolean {
return prefs.getBoolean(KEY_IS_LOGGED_IN, false)
}
fun getUserName(): String? {
return prefs.getString(KEY_USER_NAME, null)
}
fun getUserNpm(): String? {
return prefs.getString(KEY_USER_NPM, null)
}
fun logout() {
val editor = prefs.edit()
editor.clear()
editor.apply()
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 125 KiB

View File

@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>

View File

@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 982 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.6 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 7.6 KiB

View File

@ -1,4 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#DE0505</color>
</resources>

View File

@ -1,3 +1,3 @@
<resources>
<string name="app_name">Absensi Wajah</string>
<string name="app_name">Sistem Akademik</string>
</resources>

View File

@ -1,28 +0,0 @@
diff.astextplain.textconv=astextplain
filter.lfs.clean=git-lfs clean -- %f
filter.lfs.smudge=git-lfs smudge -- %f
filter.lfs.process=git-lfs filter-process
filter.lfs.required=true
http.sslbackend=openssl
http.sslcainfo=C:/Program Files/Git/mingw64/etc/ssl/certs/ca-bundle.crt
core.autocrlf=true
core.fscache=true
core.symlinks=false
pull.rebase=false
credential.helper=manager
credential.https://dev.azure.com.usehttppath=true
init.defaultbranch=master
credential.https://git.lab.ubharajaya.ac.id.provider=generic
user.name=Faris Naufal Priatna
user.email=202310715123@mhs.ubharajaya.ac.id
core.repositoryformatversion=0
core.filemode=false
core.bare=false
core.logallrefupdates=true
core.symlinks=false
core.ignorecase=true
submodule.active=.
remote.origin.url=https://git.lab.ubharajaya.ac.id/administrator/Starter-EAS-2025-2026.git
remote.origin.fetch=+refs/heads/*:refs/remotes/origin/*
branch.main.remote=origin
branch.main.merge=refs/heads/main

View File

@ -1,225 +0,0 @@
{
"name": "EAS",
"nodes": [
{
"parameters": {
"method": "POST",
"url": "https://ntfy.ubharajaya.ac.id/EAS",
"sendBody": true,
"contentType": "raw",
"body": "=Absensi: {{ $json.body.nama }} NPM: {{ $json.body.npm }}",
"options": {}
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.3,
"position": [
-272,
-240
],
"id": "83504eec-6d20-46d7-9ea1-509ae4ee8660",
"name": "NTFY HTTP Request"
},
{
"parameters": {
"httpMethod": "POST",
"path": "23c6993d-1792-48fb-ad1c-ffc78a3e6254",
"options": {}
},
"type": "n8n-nodes-base.webhook",
"typeVersion": 2.1,
"position": [
-864,
-112
],
"id": "9ed3d2db-2d50-40b5-8408-7404edd48442",
"name": "Webhook Absensi",
"webhookId": "23c6993d-1792-48fb-ad1c-ffc78a3e6254"
},
{
"parameters": {
"operation": "append",
"documentId": {
"__rl": true,
"value": "1jH15MfnNgpPGuGeid0hYfY7fFUHCEFbCmg8afTyyLZs",
"mode": "id"
},
"sheetName": {
"__rl": true,
"value": "Absensi",
"mode": "name"
},
"columns": {
"mappingMode": "defineBelow",
"value": {
"latitude": "={{ $json.body.latitude }}",
"longitude": "={{ $json.body.longitude }}",
"timestamp": "={{ $json.body.timestamp }}",
"foto_base64": "={{ $json.body.foto_base64 }}",
"nama": "={{ $json.body.nama }}",
"npm": "={{ $json.body.npm }}"
},
"matchingColumns": [],
"schema": [
{
"id": "timestamp",
"displayName": "timestamp",
"required": false,
"defaultMatch": false,
"display": true,
"type": "string",
"canBeUsedToMatch": true,
"removed": false
},
{
"id": "npm",
"displayName": "npm",
"required": false,
"defaultMatch": false,
"display": true,
"type": "string",
"canBeUsedToMatch": true,
"removed": false
},
{
"id": "nama",
"displayName": "nama",
"required": false,
"defaultMatch": false,
"display": true,
"type": "string",
"canBeUsedToMatch": true,
"removed": false
},
{
"id": "latitude",
"displayName": "latitude",
"required": false,
"defaultMatch": false,
"display": true,
"type": "string",
"canBeUsedToMatch": true,
"removed": false
},
{
"id": "longitude",
"displayName": "longitude",
"required": false,
"defaultMatch": false,
"display": true,
"type": "string",
"canBeUsedToMatch": true,
"removed": false
},
{
"id": "photo",
"displayName": "photo",
"required": false,
"defaultMatch": false,
"display": true,
"type": "string",
"canBeUsedToMatch": true,
"removed": false
},
{
"id": "status",
"displayName": "status",
"required": false,
"defaultMatch": false,
"display": true,
"type": "string",
"canBeUsedToMatch": true,
"removed": false
},
{
"id": "foto_base64",
"displayName": "foto_base64",
"required": false,
"defaultMatch": false,
"display": true,
"type": "string",
"canBeUsedToMatch": true,
"removed": false
}
],
"attemptToConvertTypes": false,
"convertFieldsToString": false
},
"options": {}
},
"type": "n8n-nodes-base.googleSheets",
"typeVersion": 4.7,
"position": [
-272,
-32
],
"id": "cd83a9fa-ea00-4a20-aa31-846bfe044aeb",
"name": "Append row in sheet",
"credentials": {
"googleSheetsOAuth2Api": {
"id": "hNVNhkTQbqkJ3C56",
"name": "Google Sheets account"
}
}
},
{
"parameters": {
"jsCode": "// Loop over input items and add a new field called 'myNewField' to the JSON of each one\nfor (const item of $input.all()) {\n item.json.myNewField = 1;\n}\n\nreturn $input.all();"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
-528,
-240
],
"id": "4ed9edf6-4562-41b6-afd0-89c96991454a",
"name": "Code in JavaScript"
}
],
"pinData": {},
"connections": {
"Webhook Absensi": {
"main": [
[
{
"node": "Append row in sheet",
"type": "main",
"index": 0
},
{
"node": "Code in JavaScript",
"type": "main",
"index": 0
}
]
]
},
"NTFY HTTP Request": {
"main": [
[]
]
},
"Code in JavaScript": {
"main": [
[
{
"node": "NTFY HTTP Request",
"type": "main",
"index": 0
}
]
]
}
},
"active": true,
"settings": {
"executionOrder": "v1",
"availableInMCP": false
},
"versionId": "49466b31-67ce-49b7-af37-33cd28d7092d",
"meta": {
"templateCredsSetupCompleted": true,
"instanceId": "b8ffac81bb85d267c3296e074b3e692ecef11caeef79fa72af892085548f350a"
},
"id": "E_gxZpNrN3G5ibejHcTFS",
"tags": []
}