commit 5989cb38b0db1ab9ccf49d98c3518f661ed053e4
Author: 202310715043 MUHAMMAD RAFLY AL FATHIR <202310715043@mhs.ubharajaya.ac.id>
Date: Wed Jan 14 21:22:32 2026 +0700
project
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..aa724b7
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,15 @@
+*.iml
+.gradle
+/local.properties
+/.idea/caches
+/.idea/libraries
+/.idea/modules.xml
+/.idea/workspace.xml
+/.idea/navEditor.xml
+/.idea/assetWizardSettings.xml
+.DS_Store
+/build
+/captures
+.externalNativeBuild
+.cxx
+local.properties
diff --git a/.idea/.gitignore b/.idea/.gitignore
new file mode 100644
index 0000000..26d3352
--- /dev/null
+++ b/.idea/.gitignore
@@ -0,0 +1,3 @@
+# Default ignored files
+/shelf/
+/workspace.xml
diff --git a/.idea/.name b/.idea/.name
new file mode 100644
index 0000000..836cb8b
--- /dev/null
+++ b/.idea/.name
@@ -0,0 +1 @@
+Sistem Akademik
\ No newline at end of file
diff --git a/.idea/AndroidProjectSystem.xml b/.idea/AndroidProjectSystem.xml
new file mode 100644
index 0000000..4a53bee
--- /dev/null
+++ b/.idea/AndroidProjectSystem.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/compiler.xml b/.idea/compiler.xml
new file mode 100644
index 0000000..b86273d
--- /dev/null
+++ b/.idea/compiler.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml
new file mode 100644
index 0000000..b77ef91
--- /dev/null
+++ b/.idea/deploymentTargetSelector.xml
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/deviceManager.xml b/.idea/deviceManager.xml
new file mode 100644
index 0000000..91f9558
--- /dev/null
+++ b/.idea/deviceManager.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/gradle.xml b/.idea/gradle.xml
new file mode 100644
index 0000000..639c779
--- /dev/null
+++ b/.idea/gradle.xml
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml
new file mode 100644
index 0000000..f0c6ad0
--- /dev/null
+++ b/.idea/inspectionProfiles/Project_Default.xml
@@ -0,0 +1,50 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/migrations.xml b/.idea/migrations.xml
new file mode 100644
index 0000000..f8051a6
--- /dev/null
+++ b/.idea/migrations.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/misc.xml b/.idea/misc.xml
new file mode 100644
index 0000000..b2c751a
--- /dev/null
+++ b/.idea/misc.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/runConfigurations.xml b/.idea/runConfigurations.xml
new file mode 100644
index 0000000..16660f1
--- /dev/null
+++ b/.idea/runConfigurations.xml
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/studiobot.xml b/.idea/studiobot.xml
new file mode 100644
index 0000000..539e3b8
--- /dev/null
+++ b/.idea/studiobot.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/vcs.xml b/.idea/vcs.xml
new file mode 100644
index 0000000..94a25f7
--- /dev/null
+++ b/.idea/vcs.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/Mockup.png b/Mockup.png
new file mode 100644
index 0000000..4690bf3
Binary files /dev/null and b/Mockup.png differ
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..9871f13
--- /dev/null
+++ b/README.md
@@ -0,0 +1,97 @@
+# 📱 Aplikasi Absensi Akademik Berbasis Koordinat dan Foto (Mobile)
+
+## 📌 Deskripsi Proyek
+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**
+
+---
+
+## 🎯 Tujuan Proyek
+- Mengimplementasikan **Location-Based Service (LBS)** pada aplikasi mobile
+- Mengintegrasikan **kamera perangkat** untuk dokumentasi absensi
+- Mencegah kecurangan absensi (titip absen)
+- Mengembangkan aplikasi mobile akademik berbasis Android
+- Melatih kemampuan perancangan dan implementasi aplikasi mobile
+
+---
+
+## 🚀 Fitur Utama
+- 🔐 **Login Pengguna (Mahasiswa)**
+- 📍 **Pengambilan Koordinat Lokasi (Latitude & Longitude)**
+- 🏫 **Validasi Lokasi Absensi (Radius Area)**
+- 📸 **Pengambilan Foto Mahasiswa Saat Absensi**
+- 🕒 **Pencatatan Waktu Absensi**
+- 📄 **Riwayat Kehadiran Mahasiswa**
+- ⚠️ **Notifikasi Absensi Ditolak jika Tidak Valid**
+
+---
+
+## 🗺️ Mekanisme Absensi Berbasis Lokasi dan Foto
+1. Mahasiswa melakukan **login**
+2. Memilih menu **Absensi**
+3. Sistem meminta:
+ - Izin **akses lokasi**
+ - Izin **akses kamera**
+4. Aplikasi mengambil:
+ - 📍 **Koordinat lokasi mahasiswa**
+ - 📸 **Foto mahasiswa secara real-time**
+5. Sistem melakukan validasi:
+ - Lokasi berada dalam **radius absensi**
+ - Foto berhasil diambil
+6. Jika valid → **Absensi berhasil**
+7. Jika tidak valid → **Absensi ditolak**
+
+---
+
+## 📸 Pengambilan Foto Saat Absensi
+- Foto diambil menggunakan **kamera depan (selfie)**
+- Foto hanya dapat diambil **saat proses absensi**
+- Foto disimpan sebagai **bukti kehadiran**
+- Foto dapat digunakan untuk:
+ - Verifikasi manual oleh dosen
+ - Dokumentasi akademik
+
+---
+
+## 🛠️ Teknologi yang Digunakan
+- **Platform** : Android
+- **Bahasa Pemrograman** : Kotlin / Java
+- **Location Service** :
+ - Google Maps API
+ - Fused Location Provider
+- **Camera API** : CameraX / Camera2
+- **Database** : Firebase / SQLite / MySQL
+- **Storage** : Firebase Storage / Local Storage
+- **IDE** : Android Studio
+
+---
+
+## 🔐 Izin Aplikasi (Permissions)
+Aplikasi memerlukan izin berikut:
+- `ACCESS_FINE_LOCATION`
+- `ACCESS_COARSE_LOCATION`
+- `CAMERA`
+- `INTERNET`
+- `WRITE_EXTERNAL_STORAGE` (jika diperlukan)
+
+---
+
+## 📂 Mockup
+
+gambar mockup dibuat oleh AI
+
+## Catatan:
+- Starter project ini dibuat berbantukan AI
+- Kembangkan project dari starter yang sudah disediakan, jangan membuat dari awal.
+- Untuk koordinat bisa ditambah/kurangi angka tertentu agar tidak memunculkan koordinat rumah masing-masing, data awal tetap dari GPS.
+
+## Pengecekan:
+- https://ntfy.ubharajaya.ac.id/EAS
+- https://docs.google.com/spreadsheets/d/1jH15MfnNgpPGuGeid0hYfY7fFUHCEFbCmg8afTyyLZs/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
\ No newline at end of file
diff --git a/app/.gitignore b/app/.gitignore
new file mode 100644
index 0000000..42afabf
--- /dev/null
+++ b/app/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
new file mode 100644
index 0000000..7d76378
--- /dev/null
+++ b/app/build.gradle.kts
@@ -0,0 +1,64 @@
+plugins {
+ alias(libs.plugins.android.application)
+ alias(libs.plugins.kotlin.android)
+ alias(libs.plugins.kotlin.compose)
+}
+
+android {
+ namespace = "id.ac.ubharajaya.sistemakademik"
+ compileSdk {
+ version = release(36)
+ }
+
+ defaultConfig {
+ applicationId = "id.ac.ubharajaya.sistemakademik"
+ minSdk = 28
+ targetSdk = 36
+ versionCode = 1
+ versionName = "1.0"
+
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ }
+
+ buildTypes {
+ release {
+ isMinifyEnabled = false
+ proguardFiles(
+ getDefaultProguardFile("proguard-android-optimize.txt"),
+ "proguard-rules.pro"
+ )
+ }
+ }
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_11
+ targetCompatibility = JavaVersion.VERSION_11
+ }
+ kotlinOptions {
+ jvmTarget = "11"
+ }
+ buildFeatures {
+ compose = true
+ }
+}
+
+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)
+ androidTestImplementation(platform(libs.androidx.compose.bom))
+ androidTestImplementation(libs.androidx.compose.ui.test.junit4)
+ debugImplementation(libs.androidx.compose.ui.tooling)
+ debugImplementation(libs.androidx.compose.ui.test.manifest)
+}
\ No newline at end of file
diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro
new file mode 100644
index 0000000..481bb43
--- /dev/null
+++ b/app/proguard-rules.pro
@@ -0,0 +1,21 @@
+# Add project specific ProGuard rules here.
+# You can control the set of applied configuration files using the
+# proguardFiles setting in build.gradle.
+#
+# For more details, see
+# http://developer.android.com/guide/developing/tools/proguard.html
+
+# If your project uses WebView with JS, uncomment the following
+# and specify the fully qualified class name to the JavaScript interface
+# class:
+#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
+# public *;
+#}
+
+# Uncomment this to preserve the line number information for
+# debugging stack traces.
+#-keepattributes SourceFile,LineNumberTable
+
+# If you keep the line number information, uncomment this to
+# hide the original source file name.
+#-renamesourcefileattribute SourceFile
\ No newline at end of file
diff --git a/app/src/androidTest/java/id/ac/ubharajaya/sistemakademik/ExampleInstrumentedTest.kt b/app/src/androidTest/java/id/ac/ubharajaya/sistemakademik/ExampleInstrumentedTest.kt
new file mode 100644
index 0000000..ef22af9
--- /dev/null
+++ b/app/src/androidTest/java/id/ac/ubharajaya/sistemakademik/ExampleInstrumentedTest.kt
@@ -0,0 +1,24 @@
+package id.ac.ubharajaya.sistemakademik
+
+import androidx.test.platform.app.InstrumentationRegistry
+import androidx.test.ext.junit.runners.AndroidJUnit4
+
+import org.junit.Test
+import org.junit.runner.RunWith
+
+import org.junit.Assert.*
+
+/**
+ * Instrumented test, which will execute on an Android device.
+ *
+ * See [testing documentation](http://d.android.com/tools/testing).
+ */
+@RunWith(AndroidJUnit4::class)
+class ExampleInstrumentedTest {
+ @Test
+ fun useAppContext() {
+ // Context of the app under test.
+ val appContext = InstrumentationRegistry.getInstrumentation().targetContext
+ assertEquals("id.ac.ubharajaya.sistemakademik", appContext.packageName)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..12645fd
--- /dev/null
+++ b/app/src/main/AndroidManifest.xml
@@ -0,0 +1,57 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/java/id/ac/ubharajaya/sistemakademik/MainActivity.kt b/app/src/main/java/id/ac/ubharajaya/sistemakademik/MainActivity.kt
new file mode 100644
index 0000000..37519c8
--- /dev/null
+++ b/app/src/main/java/id/ac/ubharajaya/sistemakademik/MainActivity.kt
@@ -0,0 +1,104 @@
+package id.ac.ubharajaya.sistemakademik
+
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.activity.enableEdgeToEdge
+import androidx.compose.foundation.layout.*
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.*
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Modifier
+import id.ac.ubharajaya.sistemakademik.ui.screens.AbsensiScreen
+import id.ac.ubharajaya.sistemakademik.ui.screens.LoginScreen
+import id.ac.ubharajaya.sistemakademik.ui.screens.ProfilScreen
+import id.ac.ubharajaya.sistemakademik.ui.screens.RiwayatScreen
+import id.ac.ubharajaya.sistemakademik.ui.theme.SistemAkademikTheme
+import id.ac.ubharajaya.sistemakademik.utils.SessionManager
+
+class MainActivity : ComponentActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ enableEdgeToEdge()
+
+ val sessionManager = SessionManager(this)
+
+ setContent {
+ SistemAkademikTheme {
+ var isLoggedIn by remember { mutableStateOf(sessionManager.isLoggedIn()) }
+
+ Surface(modifier = Modifier.fillMaxSize()) {
+ if (isLoggedIn) {
+ MainNavigation(
+ activity = this,
+ sessionManager = sessionManager,
+ onLogout = {
+ sessionManager.logout()
+ isLoggedIn = false
+ }
+ )
+ } else {
+ LoginScreen(
+ onLoginSuccess = {
+ isLoggedIn = true
+ },
+ sessionManager = sessionManager
+ )
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun MainNavigation(
+ activity: ComponentActivity,
+ sessionManager: SessionManager,
+ onLogout: () -> Unit
+) {
+ var selectedTab by remember { mutableStateOf(0) }
+
+ Scaffold(
+ bottomBar = {
+ NavigationBar {
+ NavigationBarItem(
+ selected = selectedTab == 0,
+ onClick = { selectedTab = 0 },
+ icon = { Icon(Icons.Default.CheckCircle, "Absensi") },
+ label = { Text("Absensi") }
+ )
+ NavigationBarItem(
+ selected = selectedTab == 1,
+ onClick = { selectedTab = 1 },
+ icon = { Icon(Icons.Default.List, "Riwayat") },
+ label = { Text("Riwayat") }
+ )
+ NavigationBarItem(
+ selected = selectedTab == 2,
+ onClick = { selectedTab = 2 },
+ icon = { Icon(Icons.Default.Person, "Profil") },
+ label = { Text("Profil") }
+ )
+ }
+ }
+ ) { padding ->
+ when (selectedTab) {
+ 0 -> AbsensiScreen(
+ modifier = Modifier.padding(padding),
+ activity = activity,
+ sessionManager = sessionManager
+ )
+ 1 -> RiwayatScreen(
+ modifier = Modifier.padding(padding),
+ sessionManager = sessionManager
+ )
+ 2 -> ProfilScreen(
+ modifier = Modifier.padding(padding),
+ sessionManager = sessionManager,
+ onLogout = onLogout
+ )
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/id/ac/ubharajaya/sistemakademik/models/DataModels.kt b/app/src/main/java/id/ac/ubharajaya/sistemakademik/models/DataModels.kt
new file mode 100644
index 0000000..e5bb54b
--- /dev/null
+++ b/app/src/main/java/id/ac/ubharajaya/sistemakademik/models/DataModels.kt
@@ -0,0 +1,18 @@
+package id.ac.ubharajaya.sistemakademik.models
+
+data class User(
+ val npm: String,
+ val nama: String,
+ val password: String
+)
+
+data class AbsensiRecord(
+ val id: String,
+ val npm: String,
+ val nama: String,
+ val timestamp: Long,
+ val latitude: Double,
+ val longitude: Double,
+ val status: String, // "Valid" atau "Ditolak"
+ val alasan: String = ""
+)
\ No newline at end of file
diff --git a/app/src/main/java/id/ac/ubharajaya/sistemakademik/ui/screens/AbsensiScreen.kt b/app/src/main/java/id/ac/ubharajaya/sistemakademik/ui/screens/AbsensiScreen.kt
new file mode 100644
index 0000000..c907247
--- /dev/null
+++ b/app/src/main/java/id/ac/ubharajaya/sistemakademik/ui/screens/AbsensiScreen.kt
@@ -0,0 +1,273 @@
+package id.ac.ubharajaya.sistemakademik.ui.screens
+
+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.provider.MediaStore
+import android.widget.Toast
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.*
+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.asImageBitmap
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.unit.dp
+import androidx.core.content.ContextCompat
+import com.google.android.gms.location.LocationServices
+import com.google.android.gms.location.Priority
+import com.google.android.gms.tasks.CancellationTokenSource
+import id.ac.ubharajaya.sistemakademik.utils.LocationUtils
+import id.ac.ubharajaya.sistemakademik.utils.NetworkUtils
+import id.ac.ubharajaya.sistemakademik.utils.SessionManager
+import kotlin.random.Random
+
+@Composable
+fun AbsensiScreen(
+ modifier: Modifier = Modifier,
+ activity: ComponentActivity,
+ sessionManager: SessionManager
+) {
+ val context = LocalContext.current
+
+ var lokasi by remember { mutableStateOf("Mendeteksi lokasi...") }
+
+ // GPS ASLI (PRIVATE)
+ var latitude by remember { mutableStateOf(null) }
+ var longitude by remember { mutableStateOf(null) }
+
+ // GPS AMAN (PUBLIC)
+ var safeLatitude by remember { mutableStateOf(null) }
+ var safeLongitude by remember { mutableStateOf(null) }
+
+ var foto by remember { mutableStateOf(null) }
+ var isValidLocation by remember { mutableStateOf(null) }
+ var locationMessage by remember { mutableStateOf("") }
+ var isLoading by remember { mutableStateOf(false) }
+
+ val fusedLocationClient = remember {
+ LocationServices.getFusedLocationProviderClient(context)
+ }
+
+ fun getLocation() {
+ if (
+ ContextCompat.checkSelfPermission(
+ context,
+ Manifest.permission.ACCESS_FINE_LOCATION
+ ) == PackageManager.PERMISSION_GRANTED
+ ) {
+ isLoading = true
+ val cancellationToken = CancellationTokenSource()
+
+ fusedLocationClient.getCurrentLocation(
+ Priority.PRIORITY_HIGH_ACCURACY,
+ cancellationToken.token
+ ).addOnSuccessListener { location ->
+ isLoading = false
+ if (location != null) {
+
+ // GPS ASLI
+ latitude = location.latitude
+ longitude = location.longitude
+
+ // OFFSET PRIVASI
+ val offset = Random.nextDouble(-0.0005, 0.0005)
+
+ // GPS AMAN
+ safeLatitude = latitude!! + offset
+ safeLongitude = longitude!! - offset
+
+ // TAMPILKAN YANG AMAN
+ lokasi = "Lat: %.6f\nLon: %.6f".format(
+ safeLatitude,
+ safeLongitude
+ )
+
+ // VALIDASI PAKAI GPS ASLI
+ val validasi = LocationUtils.validasiLokasi(
+ latitude!!,
+ longitude!!
+ )
+ isValidLocation = validasi.first
+ locationMessage = validasi.second
+
+ } else {
+ lokasi = "Lokasi tidak ditemukan"
+ isValidLocation = null
+ }
+ }.addOnFailureListener {
+ isLoading = false
+ lokasi = "Gagal mengambil lokasi"
+ isValidLocation = null
+ }
+ }
+ }
+
+ val locationPermissionLauncher = rememberLauncherForActivityResult(
+ ActivityResultContracts.RequestPermission()
+ ) { granted ->
+ if (granted) getLocation()
+ 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 = 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") as? Bitmap
+ }
+
+ bitmap?.let {
+ foto = it
+ 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)
+ }
+
+ Column(
+ modifier = modifier
+ .fillMaxSize()
+ .padding(24.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+
+ Text("Absensi Kehadiran", style = MaterialTheme.typography.headlineMedium)
+ Text(
+ "Halo, ${sessionManager.getNama()}",
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+
+ Card(modifier = Modifier.fillMaxWidth()) {
+ Column(modifier = Modifier.padding(16.dp)) {
+ Text("Status Lokasi", style = MaterialTheme.typography.labelLarge)
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ if (isLoading) {
+ CircularProgressIndicator()
+ } else {
+ Text(lokasi)
+ if (locationMessage.isNotEmpty()) {
+ Text(
+ locationMessage,
+ color = if (isValidLocation == true)
+ MaterialTheme.colorScheme.primary
+ else
+ MaterialTheme.colorScheme.error
+ )
+ }
+ }
+
+ TextButton(onClick = { getLocation() }) {
+ Icon(Icons.Default.Refresh, null)
+ Spacer(modifier = Modifier.width(4.dp))
+ Text("Perbarui Lokasi")
+ }
+ }
+ }
+
+ if (foto != null) {
+ Card {
+ Column(
+ modifier = Modifier.padding(16.dp),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Text("Foto Berhasil Diambil")
+ Spacer(modifier = Modifier.height(8.dp))
+ Image(
+ bitmap = foto!!.asImageBitmap(),
+ contentDescription = null,
+ modifier = Modifier
+ .size(120.dp)
+ .clip(CircleShape)
+ )
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ Button(
+ onClick = { foto = null },
+ colors = ButtonDefaults.buttonColors(
+ containerColor = MaterialTheme.colorScheme.error
+ )
+ ) {
+ Icon(Icons.Default.Delete, null)
+ Spacer(modifier = Modifier.width(6.dp))
+ Text("Hapus Foto")
+ }
+ }
+ }
+ }
+
+ Spacer(modifier = Modifier.weight(1f))
+
+ Button(
+ onClick = {
+ cameraPermissionLauncher.launch(Manifest.permission.CAMERA)
+ },
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Icon(Icons.Default.Add, null)
+ Spacer(modifier = Modifier.width(8.dp))
+ Text("Ambil Foto")
+ }
+
+ Button(
+ onClick = {
+ NetworkUtils.kirimAbsensiKeServer(
+ activity,
+ sessionManager.getNpm(),
+ sessionManager.getNama(),
+ safeLatitude!!,
+ safeLongitude!!,
+ foto!!,
+ sessionManager,
+ onSuccess = {
+ Toast.makeText(context, "✅ Absensi berhasil!", Toast.LENGTH_LONG).show()
+ foto = null
+ getLocation()
+ },
+ onError = {
+ Toast.makeText(context, "⚠️ $it", Toast.LENGTH_LONG).show()
+ }
+ )
+ },
+ enabled = (safeLatitude != null && safeLongitude != null && foto != null),
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Icon(Icons.Default.Send, null)
+ Spacer(modifier = Modifier.width(8.dp))
+ Text("Kirim Absensi")
+ }
+ }
+}
diff --git a/app/src/main/java/id/ac/ubharajaya/sistemakademik/ui/screens/LoginScreen.kt b/app/src/main/java/id/ac/ubharajaya/sistemakademik/ui/screens/LoginScreen.kt
new file mode 100644
index 0000000..615a832
--- /dev/null
+++ b/app/src/main/java/id/ac/ubharajaya/sistemakademik/ui/screens/LoginScreen.kt
@@ -0,0 +1,155 @@
+package id.ac.ubharajaya.sistemakademik.ui.screens
+
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.*
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.text.input.PasswordVisualTransformation
+import androidx.compose.ui.text.input.VisualTransformation
+import androidx.compose.ui.unit.dp
+import id.ac.ubharajaya.sistemakademik.utils.Constants
+import id.ac.ubharajaya.sistemakademik.utils.SessionManager
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun LoginScreen(
+ onLoginSuccess: () -> Unit,
+ sessionManager: SessionManager
+) {
+ var npm by remember { mutableStateOf("") }
+ var password by remember { mutableStateOf("") }
+ var passwordVisible by remember { mutableStateOf(false) }
+ var errorMessage by remember { mutableStateOf("") }
+ var isLoading by remember { mutableStateOf(false) }
+
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(24.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center
+ ) {
+ Icon(
+ imageVector = Icons.Default.AccountCircle,
+ contentDescription = null,
+ modifier = Modifier.size(80.dp),
+ tint = MaterialTheme.colorScheme.primary
+ )
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ Text(
+ text = "Sistem Absensi Akademik",
+ style = MaterialTheme.typography.headlineMedium
+ )
+
+ Text(
+ text = "Universitas Bhayangkara Jakarta Raya",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+
+ Spacer(modifier = Modifier.height(32.dp))
+
+ OutlinedTextField(
+ value = npm,
+ onValueChange = {
+ npm = it
+ errorMessage = ""
+ },
+ label = { Text("NPM") },
+ leadingIcon = { Icon(Icons.Default.Person, null) },
+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
+ modifier = Modifier.fillMaxWidth(),
+ singleLine = true
+ )
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ OutlinedTextField(
+ value = password,
+ onValueChange = {
+ password = it
+ errorMessage = ""
+ },
+ label = { Text("Password") },
+ leadingIcon = { Icon(Icons.Default.Lock, null) },
+ trailingIcon = {
+ IconButton(onClick = { passwordVisible = !passwordVisible }) {
+ Icon(
+ if (passwordVisible) Icons.Default.Done else Icons.Default.Lock,
+ null
+ )
+ }
+ },
+ visualTransformation = if (passwordVisible)
+ VisualTransformation.None
+ else
+ PasswordVisualTransformation(),
+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
+ modifier = Modifier.fillMaxWidth(),
+ singleLine = true
+ )
+
+ if (errorMessage.isNotEmpty()) {
+ Spacer(modifier = Modifier.height(8.dp))
+ Text(
+ text = errorMessage,
+ color = MaterialTheme.colorScheme.error,
+ style = MaterialTheme.typography.bodySmall
+ )
+ }
+
+ Spacer(modifier = Modifier.height(24.dp))
+
+ Button(
+ onClick = {
+ if (npm.isEmpty() || password.isEmpty()) {
+ errorMessage = "NPM dan Password harus diisi"
+ return@Button
+ }
+
+ isLoading = true
+
+ val user = Constants.DUMMY_USERS.find {
+ it.npm == npm && it.password == password
+ }
+
+ if (user != null) {
+ sessionManager.saveLogin(user.npm, user.nama)
+ onLoginSuccess()
+ } else {
+ errorMessage = "NPM atau Password salah"
+ isLoading = false
+ }
+ },
+ modifier = Modifier.fillMaxWidth(),
+ enabled = !isLoading
+ ) {
+ if (isLoading) {
+ CircularProgressIndicator(
+ modifier = Modifier.size(20.dp),
+ color = MaterialTheme.colorScheme.onPrimary
+ )
+ } else {
+ Text("Login")
+ }
+ }
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.secondaryContainer
+ )
+ ) {
+
+ }
+ }
+ }
diff --git a/app/src/main/java/id/ac/ubharajaya/sistemakademik/ui/screens/ProfilScreen.kt b/app/src/main/java/id/ac/ubharajaya/sistemakademik/ui/screens/ProfilScreen.kt
new file mode 100644
index 0000000..ce719cd
--- /dev/null
+++ b/app/src/main/java/id/ac/ubharajaya/sistemakademik/ui/screens/ProfilScreen.kt
@@ -0,0 +1,119 @@
+package id.ac.ubharajaya.sistemakademik.ui.screens
+
+import androidx.compose.foundation.layout.*
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.*
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import id.ac.ubharajaya.sistemakademik.utils.LocationUtils
+import id.ac.ubharajaya.sistemakademik.utils.SessionManager
+
+@Composable
+fun ProfilScreen(
+ modifier: Modifier = Modifier,
+ sessionManager: SessionManager,
+ onLogout: () -> Unit
+) {
+ var showLogoutDialog by remember { mutableStateOf(false) }
+
+ if (showLogoutDialog) {
+ AlertDialog(
+ onDismissRequest = { showLogoutDialog = false },
+ title = { Text("Konfirmasi Logout") },
+ text = { Text("Apakah Anda yakin ingin keluar?") },
+ confirmButton = {
+ TextButton(onClick = {
+ showLogoutDialog = false
+ onLogout()
+ }) {
+ Text("Ya, Keluar")
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = { showLogoutDialog = false }) {
+ Text("Batal")
+ }
+ }
+ )
+ }
+
+ Column(
+ modifier = modifier
+ .fillMaxSize()
+ .padding(24.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ Text(
+ text = "Profil Saya",
+ style = MaterialTheme.typography.headlineMedium
+ )
+
+ Card(modifier = Modifier.fillMaxWidth()) {
+ Column(
+ modifier = Modifier.padding(24.dp),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Icon(
+ Icons.Default.AccountCircle,
+ contentDescription = null,
+ modifier = Modifier.size(80.dp),
+ tint = MaterialTheme.colorScheme.primary
+ )
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ Text(
+ text = sessionManager.getNama(),
+ style = MaterialTheme.typography.headlineSmall
+ )
+
+ Text(
+ text = "NPM: ${sessionManager.getNpm()}",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+
+ Card(modifier = Modifier.fillMaxWidth()) {
+ Column(modifier = Modifier.padding(16.dp)) {
+ Text(
+ text = "Informasi Kampus",
+ style = MaterialTheme.typography.titleMedium
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ Text(
+ text = "Koordinat Kampus:",
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Text(
+ text = "Lat: ${LocationUtils.KAMPUS_LAT}\nLon: ${LocationUtils.KAMPUS_LON}",
+ style = MaterialTheme.typography.bodySmall
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ Text(
+ text = "Radius Absensi: ${LocationUtils.RADIUS_METER.toInt()} meter",
+ style = MaterialTheme.typography.bodySmall
+ )
+ }
+ }
+
+ Spacer(modifier = Modifier.weight(1f))
+
+ Button(
+ onClick = { showLogoutDialog = true },
+ modifier = Modifier.fillMaxWidth(),
+ colors = ButtonDefaults.buttonColors(
+ containerColor = MaterialTheme.colorScheme.error
+ )
+ ) {
+ Icon(Icons.Default.ExitToApp, null)
+ Spacer(modifier = Modifier.width(8.dp))
+ Text("Logout")
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/id/ac/ubharajaya/sistemakademik/ui/screens/RiwayatScreen.kt b/app/src/main/java/id/ac/ubharajaya/sistemakademik/ui/screens/RiwayatScreen.kt
new file mode 100644
index 0000000..30ae78f
--- /dev/null
+++ b/app/src/main/java/id/ac/ubharajaya/sistemakademik/ui/screens/RiwayatScreen.kt
@@ -0,0 +1,140 @@
+package id.ac.ubharajaya.sistemakademik.ui.screens
+
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.*
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import id.ac.ubharajaya.sistemakademik.utils.SessionManager
+import java.text.SimpleDateFormat
+import java.util.*
+
+@Composable
+fun RiwayatScreen(
+ modifier: Modifier = Modifier,
+ sessionManager: SessionManager
+) {
+ var history by remember { mutableStateOf(sessionManager.getAbsensiHistory()) }
+ val dateFormat = remember { SimpleDateFormat("dd MMM yyyy, HH:mm", Locale("id", "ID")) }
+
+ Column(modifier = modifier.fillMaxSize()) {
+ Surface(
+ modifier = Modifier.fillMaxWidth(),
+ color = MaterialTheme.colorScheme.surface,
+ tonalElevation = 3.dp
+ ) {
+ Column(modifier = Modifier.padding(24.dp)) {
+ Text(
+ text = "Riwayat Absensi",
+ style = MaterialTheme.typography.headlineMedium
+ )
+ Text(
+ text = "${history.size} catatan",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+
+ if (history.isEmpty()) {
+ Box(
+ modifier = Modifier.fillMaxSize(),
+ contentAlignment = Alignment.Center
+ ) {
+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
+ Icon(
+ Icons.Default.List,
+ contentDescription = null,
+ modifier = Modifier.size(64.dp),
+ tint = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+ Text(
+ text = "Belum ada riwayat absensi",
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+ } else {
+ LazyColumn(
+ modifier = Modifier.fillMaxSize(),
+ contentPadding = PaddingValues(16.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ items(history) { record ->
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ colors = CardDefaults.cardColors(
+ containerColor = if (record.status == "Valid")
+ MaterialTheme.colorScheme.primaryContainer
+ else
+ MaterialTheme.colorScheme.errorContainer
+ )
+ ) {
+ Column(modifier = Modifier.padding(16.dp)) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ text = record.status,
+ style = MaterialTheme.typography.labelLarge,
+ color = if (record.status == "Valid")
+ MaterialTheme.colorScheme.primary
+ else
+ MaterialTheme.colorScheme.error
+ )
+
+ Icon(
+ if (record.status == "Valid")
+ Icons.Default.CheckCircle
+ else
+ Icons.Default.Warning,
+ contentDescription = null,
+ tint = if (record.status == "Valid")
+ MaterialTheme.colorScheme.primary
+ else
+ MaterialTheme.colorScheme.error
+ )
+ }
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ Text(
+ text = dateFormat.format(Date(record.timestamp)),
+ style = MaterialTheme.typography.bodyMedium
+ )
+
+ Text(
+ text = "Lat: %.6f, Lon: %.6f".format(
+ record.latitude,
+ record.longitude
+ ),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+
+ if (record.alasan.isNotEmpty()) {
+ Spacer(modifier = Modifier.height(4.dp))
+ Text(
+ text = record.alasan,
+ style = MaterialTheme.typography.bodySmall,
+ color = if (record.status == "Valid")
+ MaterialTheme.colorScheme.primary
+ else
+ MaterialTheme.colorScheme.error
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/id/ac/ubharajaya/sistemakademik/ui/theme/Color.kt b/app/src/main/java/id/ac/ubharajaya/sistemakademik/ui/theme/Color.kt
new file mode 100644
index 0000000..c75702c
--- /dev/null
+++ b/app/src/main/java/id/ac/ubharajaya/sistemakademik/ui/theme/Color.kt
@@ -0,0 +1,11 @@
+package id.ac.ubharajaya.sistemakademik.ui.theme
+
+import androidx.compose.ui.graphics.Color
+
+val Purple80 = Color(0xFFD0BCFF)
+val PurpleGrey80 = Color(0xFFCCC2DC)
+val Pink80 = Color(0xFFEFB8C8)
+
+val Purple40 = Color(0xFF6650a4)
+val PurpleGrey40 = Color(0xFF625b71)
+val Pink40 = Color(0xFF7D5260)
\ No newline at end of file
diff --git a/app/src/main/java/id/ac/ubharajaya/sistemakademik/ui/theme/Theme.kt b/app/src/main/java/id/ac/ubharajaya/sistemakademik/ui/theme/Theme.kt
new file mode 100644
index 0000000..1b2db88
--- /dev/null
+++ b/app/src/main/java/id/ac/ubharajaya/sistemakademik/ui/theme/Theme.kt
@@ -0,0 +1,58 @@
+package id.ac.ubharajaya.sistemakademik.ui.theme
+
+import android.app.Activity
+import android.os.Build
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.darkColorScheme
+import androidx.compose.material3.dynamicDarkColorScheme
+import androidx.compose.material3.dynamicLightColorScheme
+import androidx.compose.material3.lightColorScheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.platform.LocalContext
+
+private val DarkColorScheme = darkColorScheme(
+ primary = Purple80,
+ secondary = PurpleGrey80,
+ tertiary = Pink80
+)
+
+private val LightColorScheme = lightColorScheme(
+ primary = Purple40,
+ secondary = PurpleGrey40,
+ tertiary = Pink40
+
+ /* Other default colors to override
+ background = Color(0xFFFFFBFE),
+ surface = Color(0xFFFFFBFE),
+ onPrimary = Color.White,
+ onSecondary = Color.White,
+ onTertiary = Color.White,
+ onBackground = Color(0xFF1C1B1F),
+ onSurface = Color(0xFF1C1B1F),
+ */
+)
+
+@Composable
+fun SistemAkademikTheme(
+ darkTheme: Boolean = isSystemInDarkTheme(),
+ // Dynamic color is available on Android 12+
+ dynamicColor: Boolean = true,
+ content: @Composable () -> Unit
+) {
+ val colorScheme = when {
+ dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
+ val context = LocalContext.current
+ if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
+ }
+
+ darkTheme -> DarkColorScheme
+ else -> LightColorScheme
+ }
+
+ MaterialTheme(
+ colorScheme = colorScheme,
+ typography = Typography,
+ content = content
+ )
+}
\ No newline at end of file
diff --git a/app/src/main/java/id/ac/ubharajaya/sistemakademik/ui/theme/Type.kt b/app/src/main/java/id/ac/ubharajaya/sistemakademik/ui/theme/Type.kt
new file mode 100644
index 0000000..e2982e7
--- /dev/null
+++ b/app/src/main/java/id/ac/ubharajaya/sistemakademik/ui/theme/Type.kt
@@ -0,0 +1,34 @@
+package id.ac.ubharajaya.sistemakademik.ui.theme
+
+import androidx.compose.material3.Typography
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.sp
+
+// Set of Material typography styles to start with
+val Typography = Typography(
+ bodyLarge = TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.Normal,
+ fontSize = 16.sp,
+ lineHeight = 24.sp,
+ letterSpacing = 0.5.sp
+ )
+ /* Other default text styles to override
+ titleLarge = TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.Normal,
+ fontSize = 22.sp,
+ lineHeight = 28.sp,
+ letterSpacing = 0.sp
+ ),
+ labelSmall = TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.Medium,
+ fontSize = 11.sp,
+ lineHeight = 16.sp,
+ letterSpacing = 0.5.sp
+ )
+ */
+)
\ No newline at end of file
diff --git a/app/src/main/java/id/ac/ubharajaya/sistemakademik/utils/Constants.kt b/app/src/main/java/id/ac/ubharajaya/sistemakademik/utils/Constants.kt
new file mode 100644
index 0000000..31112ba
--- /dev/null
+++ b/app/src/main/java/id/ac/ubharajaya/sistemakademik/utils/Constants.kt
@@ -0,0 +1,10 @@
+package id.ac.ubharajaya.sistemakademik.utils
+
+import id.ac.ubharajaya.sistemakademik.models.User
+
+object Constants {
+ // Data dummy user untuk login (dalam produksi, ini dari server/database)
+ val DUMMY_USERS = listOf(
+ User("202310715043", "M Rafly Al Fathir", "password123"),
+ )
+}
\ No newline at end of file
diff --git a/app/src/main/java/id/ac/ubharajaya/sistemakademik/utils/LocationUtils.kt b/app/src/main/java/id/ac/ubharajaya/sistemakademik/utils/LocationUtils.kt
new file mode 100644
index 0000000..33c69f1
--- /dev/null
+++ b/app/src/main/java/id/ac/ubharajaya/sistemakademik/utils/LocationUtils.kt
@@ -0,0 +1,25 @@
+package id.ac.ubharajaya.sistemakademik.utils
+
+import android.location.Location
+
+object LocationUtils {
+ // Koordinat kampus Ubhara Jaya (sesuaikan dengan lokasi kampus Anda)
+ const val KAMPUS_LAT = -6.175392
+ const val KAMPUS_LON = 106.827153
+ const val RADIUS_METER = 9999999.0 // Radius 100 meter
+
+ fun hitungJarak(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Float {
+ val results = FloatArray(1)
+ Location.distanceBetween(lat1, lon1, lat2, lon2, results)
+ return results[0]
+ }
+
+ fun validasiLokasi(latitude: Double, longitude: Double): Pair {
+ val jarak = hitungJarak(latitude, longitude, KAMPUS_LAT, KAMPUS_LON)
+ return if (jarak <= RADIUS_METER) {
+ Pair(true, "Lokasi Valid (${jarak.toInt()}m dari kampus)")
+ } else {
+ Pair(false, "Lokasi terlalu jauh (${jarak.toInt()}m dari kampus, max ${RADIUS_METER.toInt()}m)")
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/id/ac/ubharajaya/sistemakademik/utils/NetworkUtils.kt b/app/src/main/java/id/ac/ubharajaya/sistemakademik/utils/NetworkUtils.kt
new file mode 100644
index 0000000..14c4285
--- /dev/null
+++ b/app/src/main/java/id/ac/ubharajaya/sistemakademik/utils/NetworkUtils.kt
@@ -0,0 +1,106 @@
+package id.ac.ubharajaya.sistemakademik.utils
+
+import android.graphics.Bitmap
+import android.util.Base64
+import androidx.activity.ComponentActivity
+import id.ac.ubharajaya.sistemakademik.models.AbsensiRecord
+import org.json.JSONObject
+import java.io.ByteArrayOutputStream
+import java.net.HttpURLConnection
+import java.net.URL
+import java.util.*
+import kotlin.concurrent.thread
+
+object NetworkUtils {
+
+ private const val WEBHOOK_URL = "https://n8n.lab.ubharajaya.ac.id/webhook/23c6993d-1792-48fb-ad1c-ffc78a3e6254"
+
+ fun bitmapToBase64(bitmap: Bitmap): String {
+ val outputStream = ByteArrayOutputStream()
+ bitmap.compress(Bitmap.CompressFormat.JPEG, 80, outputStream)
+ return Base64.encodeToString(outputStream.toByteArray(), Base64.NO_WRAP)
+ }
+
+ fun kirimAbsensiKeServer(
+ context: ComponentActivity,
+ npm: String,
+ nama: String,
+ latitude: Double,
+ longitude: Double,
+ foto: Bitmap,
+ sessionManager: SessionManager,
+ onSuccess: (AbsensiRecord) -> Unit,
+ onError: (String) -> Unit
+ ) {
+ thread {
+ try {
+ val validasi = LocationUtils.validasiLokasi(latitude, longitude)
+
+ val record = AbsensiRecord(
+ id = UUID.randomUUID().toString(),
+ npm = npm,
+ nama = nama,
+ timestamp = System.currentTimeMillis(),
+ latitude = latitude,
+ longitude = longitude,
+ status = if (validasi.first) "Valid" else "Ditolak",
+ alasan = validasi.second
+ )
+
+ sessionManager.saveAbsensiRecord(record)
+
+ if (!validasi.first) {
+ context.runOnUiThread {
+ onError(validasi.second)
+ }
+ return@thread
+ }
+
+ val url = URL(WEBHOOK_URL)
+ val conn = url.openConnection() as HttpURLConnection
+
+ conn.requestMethod = "POST"
+ conn.setRequestProperty("Content-Type", "application/json")
+ conn.doOutput = true
+ conn.connectTimeout = 10000
+ conn.readTimeout = 10000
+
+ val json = JSONObject().apply {
+ put("npm", npm)
+ put("nama", nama)
+ put("latitude", latitude)
+ put("longitude", longitude)
+ put("timestamp", System.currentTimeMillis())
+ put("foto_base64", bitmapToBase64(foto))
+ put("jarak_meter", LocationUtils.hitungJarak(
+ latitude, longitude,
+ LocationUtils.KAMPUS_LAT,
+ LocationUtils.KAMPUS_LON
+ ).toInt())
+ }
+
+ conn.outputStream.use {
+ it.write(json.toString().toByteArray())
+ }
+
+ val responseCode = conn.responseCode
+
+ context.runOnUiThread {
+ if (responseCode == 200) {
+ onSuccess(record)
+ } else {
+ onError("Server menolak absensi (kode: $responseCode)")
+ }
+ }
+
+ conn.disconnect()
+
+ } catch (e: Exception) {
+ e.printStackTrace()
+ context.runOnUiThread {
+ onError("Gagal mengirim ke server: ${e.message}")
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/id/ac/ubharajaya/sistemakademik/utils/SessionManager.kt b/app/src/main/java/id/ac/ubharajaya/sistemakademik/utils/SessionManager.kt
new file mode 100644
index 0000000..dbe50b4
--- /dev/null
+++ b/app/src/main/java/id/ac/ubharajaya/sistemakademik/utils/SessionManager.kt
@@ -0,0 +1,72 @@
+package id.ac.ubharajaya.sistemakademik.utils
+
+import android.content.Context
+import id.ac.ubharajaya.sistemakademik.models.AbsensiRecord
+import org.json.JSONArray
+import org.json.JSONObject
+
+class SessionManager(private val context: Context) {
+ private val prefs = context.getSharedPreferences("absensi_prefs", Context.MODE_PRIVATE)
+
+ fun saveLogin(npm: String, nama: String) {
+ prefs.edit().apply {
+ putBoolean("isLoggedIn", true)
+ putString("npm", npm)
+ putString("nama", nama)
+ apply()
+ }
+ }
+
+ fun isLoggedIn(): Boolean = prefs.getBoolean("isLoggedIn", false)
+
+ fun getNpm(): String = prefs.getString("npm", "") ?: ""
+
+ fun getNama(): String = prefs.getString("nama", "") ?: ""
+
+ fun logout() {
+ prefs.edit().clear().apply()
+ }
+
+ fun saveAbsensiRecord(record: AbsensiRecord) {
+ val records = getAbsensiHistory().toMutableList()
+ records.add(0, record)
+
+ val jsonArray = JSONArray()
+ records.take(50).forEach { r ->
+ jsonArray.put(JSONObject().apply {
+ put("id", r.id)
+ put("npm", r.npm)
+ put("nama", r.nama)
+ put("timestamp", r.timestamp)
+ put("latitude", r.latitude)
+ put("longitude", r.longitude)
+ put("status", r.status)
+ put("alasan", r.alasan)
+ })
+ }
+
+ prefs.edit().putString("absensi_history", jsonArray.toString()).apply()
+ }
+
+ fun getAbsensiHistory(): List {
+ val jsonString = prefs.getString("absensi_history", "[]") ?: "[]"
+ val jsonArray = JSONArray(jsonString)
+ val records = mutableListOf()
+
+ for (i in 0 until jsonArray.length()) {
+ val json = jsonArray.getJSONObject(i)
+ records.add(AbsensiRecord(
+ id = json.getString("id"),
+ npm = json.getString("npm"),
+ nama = json.getString("nama"),
+ timestamp = json.getLong("timestamp"),
+ latitude = json.getDouble("latitude"),
+ longitude = json.getDouble("longitude"),
+ status = json.getString("status"),
+ alasan = json.optString("alasan", "")
+ ))
+ }
+
+ return records
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml
new file mode 100644
index 0000000..07d5da9
--- /dev/null
+++ b/app/src/main/res/drawable/ic_launcher_background.xml
@@ -0,0 +1,170 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml
new file mode 100644
index 0000000..2b068d1
--- /dev/null
+++ b/app/src/main/res/drawable/ic_launcher_foreground.xml
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/mipmap-anydpi/ic_launcher.xml b/app/src/main/res/mipmap-anydpi/ic_launcher.xml
new file mode 100644
index 0000000..6f3b755
--- /dev/null
+++ b/app/src/main/res/mipmap-anydpi/ic_launcher.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml
new file mode 100644
index 0000000..6f3b755
--- /dev/null
+++ b/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/app/src/main/res/mipmap-hdpi/ic_launcher.webp
new file mode 100644
index 0000000..c209e78
Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ
diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp
new file mode 100644
index 0000000..b2dfe3d
Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/app/src/main/res/mipmap-mdpi/ic_launcher.webp
new file mode 100644
index 0000000..4f0f1d6
Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp
new file mode 100644
index 0000000..62b611d
Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp
new file mode 100644
index 0000000..948a307
Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp
new file mode 100644
index 0000000..1b9a695
Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp
new file mode 100644
index 0000000..28d4b77
Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp
new file mode 100644
index 0000000..9287f50
Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp
new file mode 100644
index 0000000..aa7d642
Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp
new file mode 100644
index 0000000..9126ae3
Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ
diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml
new file mode 100644
index 0000000..f8c6127
--- /dev/null
+++ b/app/src/main/res/values/colors.xml
@@ -0,0 +1,10 @@
+
+
+ #FFBB86FC
+ #FF6200EE
+ #FF3700B3
+ #FF03DAC5
+ #FF018786
+ #FF000000
+ #FFFFFFFF
+
\ No newline at end of file
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
new file mode 100644
index 0000000..de92dbc
--- /dev/null
+++ b/app/src/main/res/values/strings.xml
@@ -0,0 +1,3 @@
+
+ Sistem Akademik
+
\ No newline at end of file
diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml
new file mode 100644
index 0000000..d515270
--- /dev/null
+++ b/app/src/main/res/values/themes.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/xml/backup_rules.xml b/app/src/main/res/xml/backup_rules.xml
new file mode 100644
index 0000000..4df9255
--- /dev/null
+++ b/app/src/main/res/xml/backup_rules.xml
@@ -0,0 +1,13 @@
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/xml/data_extraction_rules.xml b/app/src/main/res/xml/data_extraction_rules.xml
new file mode 100644
index 0000000..9ee9997
--- /dev/null
+++ b/app/src/main/res/xml/data_extraction_rules.xml
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml
new file mode 100644
index 0000000..bb31c26
--- /dev/null
+++ b/app/src/main/res/xml/file_paths.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/test/java/id/ac/ubharajaya/sistemakademik/ExampleUnitTest.kt b/app/src/test/java/id/ac/ubharajaya/sistemakademik/ExampleUnitTest.kt
new file mode 100644
index 0000000..6bdefc7
--- /dev/null
+++ b/app/src/test/java/id/ac/ubharajaya/sistemakademik/ExampleUnitTest.kt
@@ -0,0 +1,17 @@
+package id.ac.ubharajaya.sistemakademik
+
+import org.junit.Test
+
+import org.junit.Assert.*
+
+/**
+ * Example local unit test, which will execute on the development machine (host).
+ *
+ * See [testing documentation](http://d.android.com/tools/testing).
+ */
+class ExampleUnitTest {
+ @Test
+ fun addition_isCorrect() {
+ assertEquals(4, 2 + 2)
+ }
+}
\ No newline at end of file
diff --git a/build.gradle.kts b/build.gradle.kts
new file mode 100644
index 0000000..952b930
--- /dev/null
+++ b/build.gradle.kts
@@ -0,0 +1,6 @@
+// Top-level build file where you can add configuration options common to all sub-projects/modules.
+plugins {
+ alias(libs.plugins.android.application) apply false
+ alias(libs.plugins.kotlin.android) apply false
+ alias(libs.plugins.kotlin.compose) apply false
+}
\ No newline at end of file
diff --git a/gradle.properties b/gradle.properties
new file mode 100644
index 0000000..20e2a01
--- /dev/null
+++ b/gradle.properties
@@ -0,0 +1,23 @@
+# Project-wide Gradle settings.
+# IDE (e.g. Android Studio) users:
+# Gradle settings configured through the IDE *will override*
+# any settings specified in this file.
+# For more details on how to configure your build environment visit
+# http://www.gradle.org/docs/current/userguide/build_environment.html
+# Specifies the JVM arguments used for the daemon process.
+# The setting is particularly useful for tweaking memory settings.
+org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
+# When configured, Gradle will run in incubating parallel mode.
+# This option should only be used with decoupled projects. For more details, visit
+# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects
+# org.gradle.parallel=true
+# AndroidX package structure to make it clearer which packages are bundled with the
+# Android operating system, and which are packaged with your app's APK
+# https://developer.android.com/topic/libraries/support-library/androidx-rn
+android.useAndroidX=true
+# Kotlin code style for this project: "official" or "obsolete":
+kotlin.code.style=official
+# Enables namespacing of each library's R class so that its R class includes only the
+# resources declared in the library itself and none from the library's dependencies,
+# thereby reducing the size of the R class for that library
+android.nonTransitiveRClass=true
\ No newline at end of file
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
new file mode 100644
index 0000000..7d255c8
--- /dev/null
+++ b/gradle/libs.versions.toml
@@ -0,0 +1,32 @@
+[versions]
+agp = "8.13.2"
+kotlin = "2.0.21"
+coreKtx = "1.17.0"
+junit = "4.13.2"
+junitVersion = "1.3.0"
+espressoCore = "3.7.0"
+lifecycleRuntimeKtx = "2.9.4"
+activityCompose = "1.11.0"
+composeBom = "2024.09.00"
+
+[libraries]
+androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
+junit = { group = "junit", name = "junit", version.ref = "junit" }
+androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
+androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
+androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
+androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
+androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
+androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" }
+androidx-compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
+androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
+androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
+androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
+androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
+androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
+
+[plugins]
+android-application = { id = "com.android.application", version.ref = "agp" }
+kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
+kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
+
diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 0000000..8bdaf60
Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..6c01a8b
--- /dev/null
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,8 @@
+#Mon Jan 12 10:23:56 WIB 2026
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
+networkTimeout=10000
+validateDistributionUrl=true
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/gradlew b/gradlew
new file mode 100644
index 0000000..ef07e01
--- /dev/null
+++ b/gradlew
@@ -0,0 +1,251 @@
+#!/bin/sh
+
+#
+# Copyright © 2015 the original authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# SPDX-License-Identifier: Apache-2.0
+#
+
+##############################################################################
+#
+# Gradle start up script for POSIX generated by Gradle.
+#
+# Important for running:
+#
+# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
+# noncompliant, but you have some other compliant shell such as ksh or
+# bash, then to run this script, type that shell name before the whole
+# command line, like:
+#
+# ksh Gradle
+#
+# Busybox and similar reduced shells will NOT work, because this script
+# requires all of these POSIX shell features:
+# * functions;
+# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
+# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
+# * compound commands having a testable exit status, especially «case»;
+# * various built-in commands including «command», «set», and «ulimit».
+#
+# Important for patching:
+#
+# (2) This script targets any POSIX shell, so it avoids extensions provided
+# by Bash, Ksh, etc; in particular arrays are avoided.
+#
+# The "traditional" practice of packing multiple parameters into a
+# space-separated string is a well documented source of bugs and security
+# problems, so this is (mostly) avoided, by progressively accumulating
+# options in "$@", and eventually passing that to Java.
+#
+# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
+# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
+# see the in-line comments for details.
+#
+# There are tweaks for specific operating systems such as AIX, CygWin,
+# Darwin, MinGW, and NonStop.
+#
+# (3) This script is generated from the Groovy template
+# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
+# within the Gradle project.
+#
+# You can find Gradle at https://github.com/gradle/gradle/.
+#
+##############################################################################
+
+# Attempt to set APP_HOME
+
+# Resolve links: $0 may be a link
+app_path=$0
+
+# Need this for daisy-chained symlinks.
+while
+ APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
+ [ -h "$app_path" ]
+do
+ ls=$( ls -ld "$app_path" )
+ link=${ls#*' -> '}
+ case $link in #(
+ /*) app_path=$link ;; #(
+ *) app_path=$APP_HOME$link ;;
+ esac
+done
+
+# This is normally unused
+# shellcheck disable=SC2034
+APP_BASE_NAME=${0##*/}
+# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
+APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD=maximum
+
+warn () {
+ echo "$*"
+} >&2
+
+die () {
+ echo
+ echo "$*"
+ echo
+ exit 1
+} >&2
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "$( uname )" in #(
+ CYGWIN* ) cygwin=true ;; #(
+ Darwin* ) darwin=true ;; #(
+ MSYS* | MINGW* ) msys=true ;; #(
+ NONSTOP* ) nonstop=true ;;
+esac
+
+CLASSPATH="\\\"\\\""
+
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD=$JAVA_HOME/jre/sh/java
+ else
+ JAVACMD=$JAVA_HOME/bin/java
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD=java
+ if ! command -v java >/dev/null 2>&1
+ then
+ die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+fi
+
+# Increase the maximum file descriptors if we can.
+if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
+ case $MAX_FD in #(
+ max*)
+ # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ MAX_FD=$( ulimit -H -n ) ||
+ warn "Could not query maximum file descriptor limit"
+ esac
+ case $MAX_FD in #(
+ '' | soft) :;; #(
+ *)
+ # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ ulimit -n "$MAX_FD" ||
+ warn "Could not set maximum file descriptor limit to $MAX_FD"
+ esac
+fi
+
+# Collect all arguments for the java command, stacking in reverse order:
+# * args from the command line
+# * the main class name
+# * -classpath
+# * -D...appname settings
+# * --module-path (only if needed)
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if "$cygwin" || "$msys" ; then
+ APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
+ CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
+
+ JAVACMD=$( cygpath --unix "$JAVACMD" )
+
+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
+ for arg do
+ if
+ case $arg in #(
+ -*) false ;; # don't mess with options #(
+ /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
+ [ -e "$t" ] ;; #(
+ *) false ;;
+ esac
+ then
+ arg=$( cygpath --path --ignore --mixed "$arg" )
+ fi
+ # Roll the args list around exactly as many times as the number of
+ # args, so each arg winds up back in the position where it started, but
+ # possibly modified.
+ #
+ # NB: a `for` loop captures its iteration list before it begins, so
+ # changing the positional parameters here affects neither the number of
+ # iterations, nor the values presented in `arg`.
+ shift # remove old arg
+ set -- "$@" "$arg" # push replacement arg
+ done
+fi
+
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+
+# Collect all arguments for the java command:
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
+# and any embedded shellness will be escaped.
+# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
+# treated as '${Hostname}' itself on the command line.
+
+set -- \
+ "-Dorg.gradle.appname=$APP_BASE_NAME" \
+ -classpath "$CLASSPATH" \
+ -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
+ "$@"
+
+# Stop when "xargs" is not available.
+if ! command -v xargs >/dev/null 2>&1
+then
+ die "xargs is not available"
+fi
+
+# Use "xargs" to parse quoted args.
+#
+# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
+#
+# In Bash we could simply go:
+#
+# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
+# set -- "${ARGS[@]}" "$@"
+#
+# but POSIX shell has neither arrays nor command substitution, so instead we
+# post-process each arg (as a line of input to sed) to backslash-escape any
+# character that might be a shell metacharacter, then use eval to reverse
+# that process (while maintaining the separation between arguments), and wrap
+# the whole thing up as a single "set" statement.
+#
+# This will of course break if any of these variables contains a newline or
+# an unmatched quote.
+#
+
+eval "set -- $(
+ printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
+ xargs -n1 |
+ sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
+ tr '\n' ' '
+ )" '"$@"'
+
+exec "$JAVACMD" "$@"
diff --git a/gradlew.bat b/gradlew.bat
new file mode 100644
index 0000000..db3a6ac
--- /dev/null
+++ b/gradlew.bat
@@ -0,0 +1,94 @@
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+@rem SPDX-License-Identifier: Apache-2.0
+@rem
+
+@if "%DEBUG%"=="" @echo off
+@rem ##########################################################################
+@rem
+@rem Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%"=="" set DIRNAME=.
+@rem This is normally unused
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if %ERRORLEVEL% equ 0 goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=
+
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
+
+:end
+@rem End local scope for the variables with windows NT shell
+if %ERRORLEVEL% equ 0 goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+set EXIT_CODE=%ERRORLEVEL%
+if %EXIT_CODE% equ 0 set EXIT_CODE=1
+if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
+exit /b %EXIT_CODE%
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
diff --git a/n8n-workflow-EAS.json b/n8n-workflow-EAS.json
new file mode 100644
index 0000000..cf26602
--- /dev/null
+++ b/n8n-workflow-EAS.json
@@ -0,0 +1,225 @@
+{
+ "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": []
+}
\ No newline at end of file
diff --git a/settings.gradle.kts b/settings.gradle.kts
new file mode 100644
index 0000000..99b1b95
--- /dev/null
+++ b/settings.gradle.kts
@@ -0,0 +1,24 @@
+pluginManagement {
+ repositories {
+ google {
+ content {
+ includeGroupByRegex("com\\.android.*")
+ includeGroupByRegex("com\\.google.*")
+ includeGroupByRegex("androidx.*")
+ }
+ }
+ mavenCentral()
+ gradlePluginPortal()
+ }
+}
+dependencyResolutionManagement {
+ repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+ repositories {
+ google()
+ mavenCentral()
+ }
+}
+
+rootProject.name = "Sistem Akademik"
+include(":app")
+
\ No newline at end of file