Compare commits

...

10 Commits

Author SHA1 Message Date
5b39cd9673 Frist Commit 2026-01-14 20:26:15 +07:00
e88ada64b2 first commit 2026-01-14 20:09:29 +07:00
900cf99681 Update Readme 2026-01-13 22:31:09 +07:00
8aad590097 first commit 2026-01-13 22:06:07 +07:00
ed435ffbc1 update readme 2026-01-13 15:51:52 +07:00
926d3e0a14 add n8n workflow script 2026-01-13 14:37:01 +07:00
cddaf87d88 update readme 2026-01-13 13:59:42 +07:00
c9cc99baa2 update readme 2026-01-13 09:50:58 +07:00
2a00b834c7 real time location 2026-01-13 09:37:52 +07:00
4d7fc844e2 real time location 2026-01-13 09:37:39 +07:00
25 changed files with 1482 additions and 326 deletions

View File

@ -4,6 +4,14 @@
<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>

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

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

6
.idea/studiobot.xml generated Normal file
View File

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

115
README.md
View File

@ -1,83 +1,74 @@
# 📱 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**.
## 👤 Identitas Mahasiswa
- **Nama:** Dirson Ali Wardana
- **NPM:** 202310715246---
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**
## 📌 Deskripsi Proyek
Proyek ini merupakan **Tugas Akhir / Project Akhir Mata Kuliah Pemrograman Mobile**. Aplikasi ini dikembangkan untuk mengelola absensi mahasiswa secara cerdas dengan validasi ganda: **Lokasi GPS (Geofencing)** dan **Verifikasi Wajah (Selfie)**.
Aplikasi memastikan kehadiran mahasiswa valid apabila mahasiswa berada dalam radius lokasi kampus dan melakukan pengambilan foto selfie sebagai bukti kehadiran fisik yang kemudian dikirimkan ke server pemantau (n8n & Google Sheets).
---
## 🎯 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
## 📝 Change Log (Modifikasi & Pengembangan)
Berdasarkan *starter project* yang disediakan, berikut adalah catatan perubahan signifikan yang telah saya implementasikan:
I. **Dropdown Mata Kuliah & Integrasi Data**: Menambahkan fitur pilihan mata kuliah dan memastikan data `namaMatkul` terkirim dengan benar ke server melalui payload JSON.
II. **Fragment Preview (History Logic)**: Mengimplementasikan `PreviewFragment` yang merangkum seluruh data (Foto, Koordinat, Timestamp) sebelum dikirim, serta menambahkan logika penyimpanan otomatis ke `DataHolder.historyList` setelah pengiriman berhasil.
III. **Konversi Base64**: Mengimplementasikan fungsi `base64ToBitmap` dan pengiriman `foto_base64` agar foto selfie mahasiswa dapat tersimpan dan dilihat di server.
IV. **Modernisasi UI (Material Card & Progress Bar)**: Menggunakan `MaterialCardView` untuk tampilan foto preview dan menambahkan `ProgressBar` (Loading) untuk memberikan feedback visual saat proses pengiriman data ke server.
V. **Network Threading**: Menggunakan `kotlin.concurrent.thread` untuk menangani proses pengiriman data ke Webhook agar aplikasi tidak *freeze* (ANR) saat melakukan request jaringan.
---
## 🚀 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
- 🔐 **Login Mahasiswa**: Autentikasi otomatis dengan data profil tetap (Hardcoded).
- 🎓 **Mata Kuliah Selector**: Memilih mata kuliah aktif sebelum melakukan absensi.
- 📍 **GPS Tracking**: Pengambilan koordinat Latitude dan Longitude secara presisi.
- 📸 **Selfie Verification**: Pratinjau foto selfie sebelum data dikirim ke server.
- 📄 **Real-time History**: Daftar riwayat absensi yang langsung terupdate menggunakan `RecyclerView` / `LazyColumn`.
- 📡 **Webhook Integration**: Pengiriman data langsung ke sistem n8n Ubhara Jaya.
---
## 🛠️ 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
- **Bahasa**: Kotlin
- **UI Framework**: Android XML (View System) & Jetpack Compose (Interoperability).
- **Network**: `HttpURLConnection` dengan format data **JSON**.
- **Kamera**: CameraX API.
- **Lokasi**: Fused Location Provider Client.
- **Data Manager**: `DataHolder` (Singleton) untuk manajemen data antar layar.
---
## 🗺️ Mekanisme Absensi
1. Mahasiswa memilih mata kuliah.
2. Aplikasi mengambil lokasi dan mahasiswa melakukan foto selfie.
3. Di halaman **Preview**, mahasiswa memvalidasi data (Nama, NPM, Lokasi, dan Foto).
4. Klik **Submit**: Data dikonversi ke JSON dan dikirim ke Webhook.
5. Jika berhasil (Response 200), data otomatis masuk ke menu **Riwayat**.
---
## 🔐 Izin Aplikasi (Permissions)
Aplikasi memerlukan izin berikut:
- `ACCESS_FINE_LOCATION`
- `ACCESS_COARSE_LOCATION`
- `CAMERA`
- `INTERNET`
- `WRITE_EXTERNAL_STORAGE` (jika diperlukan)
Aplikasi memerlukan akses berikut:
- `android.permission.CAMERA`: Untuk mengambil foto selfie.
- `android.permission.ACCESS_FINE_LOCATION`: Untuk validasi lokasi presisi.
- `android.permission.INTERNET`: Untuk mengirim data absensi ke n8n.
---
## 📂 Mockup
![mockup](Mockup.png)
## ⚠️ Disclaimer & Catatan
- **PENGGUNAAN AI**: Proyek ini dikembangkan dengan bantuan **Kecerdasan Buatan (AI)** (ChatGPT/Claude) dalam proses debugging Gradle, perbaikan error `XMLStreamException`, dan penyusunan struktur pengiriman data JSON pada `PreviewFragment.kt`.
- **PENGEMBANGAN**: Aplikasi ini merupakan hasil modifikasi intensif dari *starter project* yang disediakan untuk memenuhi standar capaian project akhir EAS 2025/2026.
---
## 🔗 Link Monitoring & Data
- **Monitoring (ntfy)**: [ntfy.ubharajaya.ac.id/EAS](https://ntfy.ubharajaya.ac.id/EAS)
- **Data Spreadsheet**: [Cek Data Absensi](https://docs.google.com/spreadsheets/d/1jH15MfnNgpPGuGeid0hYfY7fFUHCEFbCmg8afTyyLZs/edit?gid=0#gid=0)
- **Webhook Production**: `https://n8n.lab.ubharajaya.ac.id/webhook/23c6993d-1792-48fb-ad1c-ffc78a3e6254`
---
*Dibuat untuk memenuhi Tugas Project Akhir Mata Kuliah Pemrograman Mobile.*

View File

@ -51,8 +51,25 @@ dependencies {
implementation(libs.androidx.compose.ui.graphics)
implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.compose.material3)
implementation ("com.google.android.material:material:1.11.0")
// Location (GPS)
implementation("com.google.android.gms:play-services-location:21.0.1")
// Navigation Component
implementation("androidx.navigation:navigation-fragment-ktx:2.7.6")
implementation("androidx.navigation:navigation-ui-ktx:2.7.6")
// ViewModel & LiveData
implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.2")
implementation("androidx.lifecycle:lifecycle-livedata-ktx:2.6.2")
// Google Play Services Location
implementation("com.google.android.gms:play-services-location:21.0.1")
// Gson untuk parsing JSON
implementation("com.google.code.gson:gson:2.10.1")
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)

View File

@ -1,271 +1,24 @@
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.Bundle
import android.provider.MediaStore
import android.util.Base64
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.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
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 id.ac.ubharajaya.sistemakademik.ui.theme.SistemAkademikTheme
import org.json.JSONObject
import java.io.ByteArrayOutputStream
import java.net.HttpURLConnection
import java.net.URL
import kotlin.concurrent.thread
import androidx.appcompat.app.AppCompatActivity
import androidx.navigation.fragment.NavHostFragment
import androidx.navigation.ui.setupWithNavController
import com.google.android.material.bottomnavigation.BottomNavigationView
/* ================= UTIL ================= */
fun bitmapToBase64(bitmap: Bitmap): String {
val outputStream = ByteArrayOutputStream()
bitmap.compress(Bitmap.CompressFormat.JPEG, 80, outputStream)
return Base64.encodeToString(outputStream.toByteArray(), Base64.NO_WRAP)
}
fun kirimKeN8n(
context: ComponentActivity,
latitude: Double,
longitude: Double,
foto: Bitmap
) {
thread {
try {
val url = URL("https://n8n-s.pii.or.id/webhook/dcef8a16-d333-4259-a201-bd12ba780761")
val conn = url.openConnection() as HttpURLConnection
conn.requestMethod = "POST"
conn.setRequestProperty("Content-Type", "application/json")
conn.doOutput = true
val json = JSONObject().apply {
put("latitude", latitude)
put("longitude", longitude)
put("timestamp", System.currentTimeMillis())
put("foto_base64", bitmapToBase64(foto))
}
conn.outputStream.use {
it.write(json.toString().toByteArray())
}
val responseCode = conn.responseCode
context.runOnUiThread {
Toast.makeText(
context,
if (responseCode == 200)
"Absensi diterima server"
else
"Absensi ditolak server",
Toast.LENGTH_SHORT
).show()
}
conn.disconnect()
} catch (e: Exception) {
context.runOnUiThread {
Toast.makeText(
context,
"Gagal kirim ke server",
Toast.LENGTH_SHORT
).show()
}
}
}
}
/* ================= ACTIVITY ================= */
class MainActivity : ComponentActivity() {
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContentView(R.layout.activity_main)
setContent {
SistemAkademikTheme {
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
AbsensiScreen(
modifier = Modifier.padding(innerPadding),
activity = this
)
}
}
}
}
}
/* ================= UI ================= */
@Composable
fun AbsensiScreen(
modifier: Modifier = Modifier,
activity: ComponentActivity
) {
val context = LocalContext.current
var lokasi by remember { mutableStateOf("Koordinat: -") }
var latitude by remember { mutableStateOf<Double?>(null) }
var longitude by remember { mutableStateOf<Double?>(null) }
var foto by remember { mutableStateOf<Bitmap?>(null) }
val fusedLocationClient =
LocationServices.getFusedLocationProviderClient(context)
/* ===== Permission Lokasi ===== */
val locationPermissionLauncher =
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 =
"Lat: ${location.latitude}\nLon: ${location.longitude}"
} else {
lokasi = "Lokasi tidak tersedia"
}
}
.addOnFailureListener {
lokasi = "Gagal mengambil lokasi"
}
}
} else {
Toast.makeText(
context,
"Izin lokasi ditolak",
Toast.LENGTH_SHORT
).show()
}
}
/* ===== Kamera ===== */
val cameraLauncher =
rememberLauncherForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { result ->
if (result.resultCode == Activity.RESULT_OK) {
val bitmap =
result.data?.extras?.get("data") as? Bitmap
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 ditolak",
Toast.LENGTH_SHORT
).show()
}
}
/* ===== Request Awal ===== */
LaunchedEffect(Unit) {
locationPermissionLauncher.launch(
Manifest.permission.ACCESS_FINE_LOCATION
)
}
/* ===== UI ===== */
Column(
modifier = modifier
.fillMaxSize()
.padding(24.dp),
verticalArrangement = Arrangement.Center
) {
Text(
text = "Absensi Akademik",
style = MaterialTheme.typography.titleLarge
)
Spacer(modifier = Modifier.height(16.dp))
Text(text = lokasi)
Spacer(modifier = Modifier.height(16.dp))
Button(
onClick = {
cameraPermissionLauncher.launch(
Manifest.permission.CAMERA
)
},
modifier = Modifier.fillMaxWidth()
) {
Text("Ambil Foto")
}
Spacer(modifier = Modifier.height(12.dp))
Button(
onClick = {
if (latitude != null && longitude != null && foto != null) {
kirimKeN8n(
activity,
latitude!!,
longitude!!,
foto!!
)
} else {
Toast.makeText(
context,
"Lokasi atau foto belum lengkap",
Toast.LENGTH_SHORT
).show()
}
},
modifier = Modifier.fillMaxWidth()
) {
Text("Kirim Absensi")
}
// Setup Navigation
val navHostFragment = supportFragmentManager
.findFragmentById(R.id.nav_host_fragment) as NavHostFragment
val navController = navHostFragment.navController
// Setup Bottom Navigation
val bottomNav = findViewById<BottomNavigationView>(R.id.bottom_navigation)
bottomNav.setupWithNavController(navController)
}
}

View File

@ -0,0 +1,58 @@
package id.ac.ubharajaya.sistemakademik.adapter
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.util.Base64
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView
import id.ac.ubharajaya.sistemakademik.R
import id.ac.ubharajaya.sistemakademik.data.AbsensiData
import java.text.SimpleDateFormat
import java.util.*
class HistoryAdapter(
private val dataList: List<AbsensiData>
) : RecyclerView.Adapter<HistoryAdapter.ViewHolder>() {
class ViewHolder(view: View) : RecyclerView.ViewHolder(view) {
val ivThumbnail: ImageView = view.findViewById(R.id.ivThumbnail)
val tvNama: TextView = view.findViewById(R.id.tvHistoryNama)
val tvTanggal: TextView = view.findViewById(R.id.tvHistoryTanggal)
val tvLokasi: TextView = view.findViewById(R.id.tvHistoryLokasi)
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val view = LayoutInflater.from(parent.context)
.inflate(R.layout.item_history, parent, false)
return ViewHolder(view)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val data = dataList[position]
// Set foto thumbnail
val bitmap = base64ToBitmap(data.fotoBase64)
holder.ivThumbnail.setImageBitmap(bitmap)
// Set nama
holder.tvNama.text = data.nama
// Set tanggal
val dateFormat = SimpleDateFormat("dd MMM yyyy, HH:mm", Locale("id", "ID"))
holder.tvTanggal.text = "📅 ${dateFormat.format(Date(data.timestamp))}"
// Set lokasi
holder.tvLokasi.text = "📍 Lat: ${data.latitude}, Lon: ${data.longitude}"
}
override fun getItemCount() = dataList.size
private fun base64ToBitmap(base64: String): Bitmap {
val decodedBytes = Base64.decode(base64, Base64.NO_WRAP)
return BitmapFactory.decodeByteArray(decodedBytes, 0, decodedBytes.size)
}
}

View File

@ -0,0 +1,6 @@
package id.ac.ubharajaya.sistemakademik.data
data class MataKuliah(
val kode: String,
val nama: String
)

View File

@ -0,0 +1,28 @@
package id.ac.ubharajaya.sistemakademik.data
data class UserProfile(
var nama: String = "",
var npm: String = ""
)
data class AbsensiData(
val nama: String,
val npm: String,
val latitude: Double,
val longitude: Double,
val timestamp: Long,
val fotoBase64: String,
// 🔽 FIELD BARU (WAJIB DITARUH SEBELUM DEFAULT VALUE)
val kodeMatkul: String,
val namaMatkul: String,
// 🔽 FIELD DENGAN DEFAULT VALUE HARUS PALING BAWAH
val alamat: String = "Alamat tidak tersedia"
)
// Singleton untuk menyimpan data sementara
object DataHolder {
var userProfile: UserProfile = UserProfile()
var currentAbsensi: AbsensiData? = null
val historyList = mutableListOf<AbsensiData>()
}

View File

@ -0,0 +1,202 @@
package id.ac.ubharajaya.sistemakademik.ui.fragments
import android.Manifest
import android.app.Activity
import android.content.Intent
import android.content.pm.PackageManager
import android.graphics.Bitmap
import android.os.Bundle
import android.provider.MediaStore
import android.util.Base64
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.*
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.ContextCompat
import androidx.fragment.app.Fragment
import androidx.navigation.fragment.findNavController
import com.google.android.gms.location.LocationServices
import id.ac.ubharajaya.sistemakademik.R
import id.ac.ubharajaya.sistemakademik.data.AbsensiData
import id.ac.ubharajaya.sistemakademik.data.DataHolder
import id.ac.ubharajaya.sistemakademik.data.MataKuliah
import id.ac.ubharajaya.sistemakademik.utils.DummyData
import java.io.ByteArrayOutputStream
class AbsensiFragment : Fragment() {
private lateinit var ivPreview: ImageView
private lateinit var tvLokasi: TextView
private lateinit var btnAmbilFoto: Button
private lateinit var btnLanjut: Button
private lateinit var spinnerMatkul: Spinner
private var foto: Bitmap? = null
private var latitude: Double? = null
private var longitude: Double? = null
private var selectedMatkul: MataKuliah? = null
private val fusedLocationClient by lazy {
LocationServices.getFusedLocationProviderClient(requireActivity())
}
// ===== Permission Launcher =====
private val locationPermissionLauncher =
registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
if (granted) ambilLokasiGPS()
else Toast.makeText(context, "Izin lokasi ditolak", Toast.LENGTH_SHORT).show()
}
private val cameraPermissionLauncher =
registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
if (granted) bukaKamera()
else Toast.makeText(context, "Izin kamera ditolak", Toast.LENGTH_SHORT).show()
}
private val cameraLauncher =
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode == Activity.RESULT_OK) {
val bitmap = result.data?.extras?.get("data") as? Bitmap
bitmap?.let {
foto = it
ivPreview.setImageBitmap(it)
cekKelengkapanData()
Toast.makeText(context, "Foto berhasil diambil!", Toast.LENGTH_SHORT).show()
}
}
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View = inflater.inflate(R.layout.fragment_absensi, container, false)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
ivPreview = view.findViewById(R.id.ivPreviewFoto)
tvLokasi = view.findViewById(R.id.tvLokasi)
btnAmbilFoto = view.findViewById(R.id.btnAmbilFoto)
btnLanjut = view.findViewById(R.id.btnLanjutPreview)
spinnerMatkul = view.findViewById(R.id.spinnerMatkul)
setupSpinnerMatkul()
requestLocationPermission()
btnAmbilFoto.setOnClickListener { requestCameraPermission() }
btnLanjut.setOnClickListener {
simpanDataSementara()
findNavController().navigate(R.id.action_absensi_to_preview)
}
}
// ===== Mata Kuliah =====
private fun setupSpinnerMatkul() {
val matkulList = DummyData.mataKuliahList
val adapter = ArrayAdapter(
requireContext(),
android.R.layout.simple_spinner_dropdown_item,
matkulList.map { "${it.kode} - ${it.nama}" }
)
spinnerMatkul.adapter = adapter
spinnerMatkul.onItemSelectedListener =
object : AdapterView.OnItemSelectedListener {
override fun onItemSelected(
parent: AdapterView<*>, view: View?, position: Int, id: Long
) {
selectedMatkul = matkulList[position]
cekKelengkapanData()
}
override fun onNothingSelected(parent: AdapterView<*>) {
selectedMatkul = null
cekKelengkapanData()
}
}
}
// ===== Location =====
private fun requestLocationPermission() {
if (ContextCompat.checkSelfPermission(
requireContext(),
Manifest.permission.ACCESS_FINE_LOCATION
) == PackageManager.PERMISSION_GRANTED
) {
ambilLokasiGPS()
} else {
locationPermissionLauncher.launch(Manifest.permission.ACCESS_FINE_LOCATION)
}
}
private fun ambilLokasiGPS() {
fusedLocationClient.lastLocation.addOnSuccessListener { location ->
if (location != null) {
latitude = location.latitude
longitude = location.longitude
tvLokasi.text =
"📍 GPS Ready\nLat: ${location.latitude}\nLon: ${location.longitude}"
cekKelengkapanData()
} else {
tvLokasi.text = "❌ Lokasi tidak tersedia"
}
}
}
// ===== Camera =====
private fun requestCameraPermission() {
if (ContextCompat.checkSelfPermission(
requireContext(),
Manifest.permission.CAMERA
) == PackageManager.PERMISSION_GRANTED
) {
bukaKamera()
} else {
cameraPermissionLauncher.launch(Manifest.permission.CAMERA)
}
}
private fun bukaKamera() {
cameraLauncher.launch(Intent(MediaStore.ACTION_IMAGE_CAPTURE))
}
// ===== Validation =====
private fun cekKelengkapanData() {
btnLanjut.isEnabled =
foto != null &&
latitude != null &&
longitude != null &&
selectedMatkul != null
}
// ===== Save =====
private fun simpanDataSementara() {
val bitmap = foto ?: return
val lat = latitude ?: return
val lon = longitude ?: return
val matkul = selectedMatkul ?: return
DataHolder.currentAbsensi = AbsensiData(
nama = DataHolder.userProfile.nama,
npm = DataHolder.userProfile.npm,
latitude = lat,
longitude = lon,
timestamp = System.currentTimeMillis(),
fotoBase64 = bitmapToBase64(bitmap),
alamat = "Alamat akan diambil saat preview",
kodeMatkul = matkul.kode,
namaMatkul = matkul.nama
)
}
private fun bitmapToBase64(bitmap: Bitmap): String {
val outputStream = ByteArrayOutputStream()
bitmap.compress(Bitmap.CompressFormat.JPEG, 80, outputStream)
return Base64.encodeToString(outputStream.toByteArray(), Base64.NO_WRAP)
}
}

View File

@ -0,0 +1,57 @@
package id.ac.ubharajaya.sistemakademik.ui.fragments
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.fragment.app.Fragment
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import id.ac.ubharajaya.sistemakademik.R
import id.ac.ubharajaya.sistemakademik.adapter.HistoryAdapter
import id.ac.ubharajaya.sistemakademik.data.DataHolder
class HistoryFragment : Fragment() {
private lateinit var rvHistory: RecyclerView
private lateinit var tvEmpty: TextView
private lateinit var adapter: HistoryAdapter
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
return inflater.inflate(R.layout.fragment_history, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
rvHistory = view.findViewById(R.id.rvHistory)
tvEmpty = view.findViewById(R.id.tvEmptyState)
adapter = HistoryAdapter(DataHolder.historyList)
rvHistory.layoutManager = LinearLayoutManager(context)
rvHistory.adapter = adapter
cekDataKosong()
}
override fun onResume() {
super.onResume()
adapter.notifyDataSetChanged()
cekDataKosong()
}
private fun cekDataKosong() {
if (DataHolder.historyList.isEmpty()) {
tvEmpty.visibility = View.VISIBLE
rvHistory.visibility = View.GONE
} else {
tvEmpty.visibility = View.GONE
rvHistory.visibility = View.VISIBLE
}
}
}

View File

@ -0,0 +1,194 @@
package id.ac.ubharajaya.sistemakademik.ui.fragments
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.os.Bundle
import android.util.Base64
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Button
import android.widget.ImageView
import android.widget.ProgressBar
import android.widget.TextView
import android.widget.Toast
import androidx.fragment.app.Fragment
import androidx.navigation.fragment.findNavController
import id.ac.ubharajaya.sistemakademik.R
import id.ac.ubharajaya.sistemakademik.data.DataHolder
import org.json.JSONObject
import java.net.HttpURLConnection
import java.net.URL
import java.text.SimpleDateFormat
import java.util.*
import kotlin.concurrent.thread
class PreviewFragment : Fragment() {
private lateinit var ivFoto: ImageView
private lateinit var tvNama: TextView
private lateinit var tvNpm: TextView
private lateinit var tvTimestamp: TextView
private lateinit var tvKoordinat: TextView
private lateinit var tvAlamat: TextView
private lateinit var tvStatus: TextView
private lateinit var btnSubmit: Button
private lateinit var btnKembali: Button
private lateinit var progressBar: ProgressBar
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
return inflater.inflate(R.layout.fragment_preview, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
ivFoto = view.findViewById(R.id.ivFotoPreview)
tvNama = view.findViewById(R.id.tvNama)
tvNpm = view.findViewById(R.id.tvNpm)
tvTimestamp = view.findViewById(R.id.tvTimestamp)
tvKoordinat = view.findViewById(R.id.tvKoordinat)
tvAlamat = view.findViewById(R.id.tvAlamat)
tvStatus = view.findViewById(R.id.tvStatusValidasi)
btnSubmit = view.findViewById(R.id.btnSubmitAbsensi)
btnKembali = view.findViewById(R.id.btnKembali)
progressBar = view.findViewById(R.id.progressBar)
tampilkanDataPreview()
btnSubmit.setOnClickListener {
kirimAbsensi()
}
btnKembali.setOnClickListener {
findNavController().popBackStack()
}
}
private fun tampilkanDataPreview() {
val absensi = DataHolder.currentAbsensi ?: run {
Toast.makeText(context, "Data tidak ditemukan", Toast.LENGTH_SHORT).show()
findNavController().popBackStack()
return
}
// Tampilkan foto
val bitmap = base64ToBitmap(absensi.fotoBase64)
ivFoto.setImageBitmap(bitmap)
// Tampilkan data mahasiswa
tvNama.text = "👤 Nama: ${absensi.nama}"
tvNpm.text = "🎓 NPM: ${absensi.npm}"
// Tampilkan timestamp
val dateFormat = SimpleDateFormat("dd MMM yyyy, HH:mm:ss", Locale("id", "ID"))
val tanggal = dateFormat.format(Date(absensi.timestamp))
tvTimestamp.text = "$tanggal WIB"
// Tampilkan koordinat
tvKoordinat.text = "🌍 Lat: ${absensi.latitude}, Lon: ${absensi.longitude}"
// Tampilkan alamat (dummy dulu)
tvAlamat.text = "📮 ${absensi.alamat}"
// Status validasi (dummy - selalu valid)
tvStatus.text = "✅ Dalam Radius Kampus (0m dari lokasi)"
tvStatus.setTextColor(resources.getColor(android.R.color.holo_green_dark, null))
}
private fun base64ToBitmap(base64: String): Bitmap {
val decodedBytes = Base64.decode(base64, Base64.NO_WRAP)
return BitmapFactory.decodeByteArray(decodedBytes, 0, decodedBytes.size)
}
private fun kirimAbsensi() {
val absensi = DataHolder.currentAbsensi ?: return
// Tampilkan loading
progressBar.visibility = View.VISIBLE
btnSubmit.isEnabled = false
btnKembali.isEnabled = false
thread {
try {
val url = URL("https://n8n.lab.ubharajaya.ac.id/webhook/23c6993d-1792-48fb-ad1c-ffc78a3e6254")
val conn = url.openConnection() as HttpURLConnection
conn.requestMethod = "POST"
conn.setRequestProperty("Content-Type", "application/json")
conn.doOutput = true
val json = JSONObject().apply {
// 1⃣ HARUS ADA
put("timestamp", absensi.timestamp)
// 2⃣ IP address (kalau belum ada, kirim dummy)
put("ip_addr", "android")
// 3⃣ Identitas
put("npm", absensi.npm)
put("nama", absensi.nama)
// 4⃣ Lokasi
put("latitude", absensi.latitude)
put("longitude", absensi.longitude)
// 5⃣ MATA KULIAH (INI YANG KOSONG KEMARIN)
put("mata_kuliah", absensi.namaMatkul)
// atau kalau mau sekalian kode:
// put("mata_kuliah", "${absensi.kodeMatkul} - ${absensi.namaMatkul}")
// 6⃣ Photo (boleh string apa aja / URL / label)
put("photo", "camera")
// 7⃣ Status
put("status", "hadir")
// 8⃣ FOTO BASE64
put("foto_base64", absensi.fotoBase64)
}
conn.outputStream.use {
it.write(json.toString().toByteArray())
}
val responseCode = conn.responseCode
activity?.runOnUiThread {
progressBar.visibility = View.GONE
btnSubmit.isEnabled = true
btnKembali.isEnabled = true
if (responseCode == 200) {
Toast.makeText(context, "✅ Absensi berhasil dikirim!", Toast.LENGTH_LONG).show()
// Simpan ke history
DataHolder.historyList.add(0, absensi)
// Kembali ke halaman absensi
findNavController().popBackStack()
} else {
Toast.makeText(context, "❌ Gagal kirim (Code: $responseCode)", Toast.LENGTH_SHORT).show()
}
}
conn.disconnect()
} catch (e: Exception) {
activity?.runOnUiThread {
progressBar.visibility = View.GONE
btnSubmit.isEnabled = true
btnKembali.isEnabled = true
Toast.makeText(context, "❌ Error: ${e.message}", Toast.LENGTH_SHORT).show()
}
}
}
}
}

View File

@ -0,0 +1,67 @@
package id.ac.ubharajaya.sistemakademik.ui.fragments
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Button
import android.widget.TextView
import android.widget.Toast
import androidx.fragment.app.Fragment
import com.google.android.material.textfield.TextInputEditText
import id.ac.ubharajaya.sistemakademik.R
import id.ac.ubharajaya.sistemakademik.data.DataHolder
class ProfileFragment : Fragment() {
private lateinit var etNama: TextInputEditText
private lateinit var etNpm: TextInputEditText
private lateinit var btnSimpan: Button
private lateinit var tvInfo: TextView
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
return inflater.inflate(R.layout.fragment_profile, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
etNama = view.findViewById(R.id.etNama)
etNpm = view.findViewById(R.id.etNpm)
btnSimpan = view.findViewById(R.id.btnSimpanProfile)
tvInfo = view.findViewById(R.id.tvProfileInfo)
// Load data yang sudah disimpan
etNama.setText(DataHolder.userProfile.nama)
etNpm.setText(DataHolder.userProfile.npm)
if (DataHolder.userProfile.nama.isNotEmpty()) {
tampilkanInfo()
}
btnSimpan.setOnClickListener {
val nama = etNama.text.toString().trim()
val npm = etNpm.text.toString().trim()
if (nama.isEmpty() || npm.isEmpty()) {
Toast.makeText(context, "Nama dan NPM harus diisi!", Toast.LENGTH_SHORT).show()
return@setOnClickListener
}
// Simpan ke DataHolder
DataHolder.userProfile.nama = nama
DataHolder.userProfile.npm = npm
Toast.makeText(context, "Profile berhasil disimpan!", Toast.LENGTH_SHORT).show()
tampilkanInfo()
}
}
private fun tampilkanInfo() {
tvInfo.visibility = View.VISIBLE
tvInfo.text = "✅ Profile tersimpan\n${DataHolder.userProfile.nama}\n${DataHolder.userProfile.npm}"
}
}

View File

@ -0,0 +1,11 @@
package id.ac.ubharajaya.sistemakademik.utils
import id.ac.ubharajaya.sistemakademik.data.MataKuliah
object DummyData {
val mataKuliahList = listOf(
MataKuliah("IF301", "Pemrograman Mobile"),
MataKuliah("IF302", "Kecerdasan Buatan"),
MataKuliah("IF303", "Basis Data")
)
}

View File

@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.fragment.app.FragmentContainerView
android:id="@+id/nav_host_fragment"
android:name="androidx.navigation.fragment.NavHostFragment"
android:layout_width="0dp"
android:layout_height="0dp"
app:defaultNavHost="true"
app:layout_constraintBottom_toTopOf="@id/bottom_navigation"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:navGraph="@navigation/nav_graph" />
<com.google.android.material.bottomnavigation.BottomNavigationView
android:id="@+id/bottom_navigation"
android:layout_width="0dp"
android:layout_height="wrap_content"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:menu="@menu/bottom_nav_menu" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -0,0 +1,58 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="24dp"
android:gravity="center">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Ambil Absensi"
android:textSize="28sp"
android:textStyle="bold"
android:layout_marginBottom="32dp"/>
<ImageView
android:id="@+id/ivPreviewFoto"
android:layout_width="200dp"
android:layout_height="200dp"
android:background="@android:color/darker_gray"
android:scaleType="centerCrop"
android:layout_marginBottom="24dp"/>
<TextView
android:id="@+id/tvLokasi"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Menunggu GPS..."
android:textSize="14sp"
android:textAlignment="center"
android:layout_marginBottom="32dp"/>
<Spinner
android:id="@+id/spinnerMatkul"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="12dp"/>
<Button
android:id="@+id/btnAmbilFoto"
android:layout_width="match_parent"
android:layout_height="60dp"
android:text="📷 Ambil Foto"
android:textSize="16sp"/>
<Button
android:id="@+id/btnLanjutPreview"
android:layout_width="match_parent"
android:layout_height="60dp"
android:text="Lanjut ke Preview"
android:textSize="16sp"
android:layout_marginTop="12dp"
android:enabled="false"/>
</LinearLayout>

View File

@ -0,0 +1,32 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Riwayat Absensi"
android:textSize="24sp"
android:textStyle="bold"
android:layout_marginBottom="16dp"/>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rvHistory"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"/>
<TextView
android:id="@+id/tvEmptyState"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Belum ada riwayat absensi"
android:textSize="16sp"
android:layout_gravity="center"
android:layout_marginTop="100dp"
android:visibility="gone"/>
</LinearLayout>

View File

@ -0,0 +1,176 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fillViewport="true">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="24dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Preview Absensi"
android:textSize="24sp"
android:textStyle="bold"
android:layout_gravity="center"
android:layout_marginBottom="24dp"/>
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:cardElevation="4dp"
app:cardCornerRadius="16dp"
android:layout_marginBottom="16dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="📸 Foto Absensi"
android:textSize="16sp"
android:textStyle="bold"
android:layout_marginBottom="12dp"/>
<ImageView
android:id="@+id/ivFotoPreview"
android:layout_width="match_parent"
android:layout_height="300dp"
android:scaleType="centerCrop"
android:background="@android:color/darker_gray"/>
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:cardElevation="4dp"
app:cardCornerRadius="16dp"
android:layout_marginBottom="16dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="👤 Data Mahasiswa"
android:textSize="16sp"
android:textStyle="bold"
android:layout_marginBottom="8dp"/>
<TextView
android:id="@+id/tvNama"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Nama: -"
android:textSize="14sp"
android:layout_marginBottom="4dp"/>
<TextView
android:id="@+id/tvNpm"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="NPM: -"
android:textSize="14sp"/>
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:cardElevation="4dp"
app:cardCornerRadius="16dp"
android:layout_marginBottom="16dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="📍 Lokasi &amp; Waktu"
android:textSize="16sp"
android:textStyle="bold"
android:layout_marginBottom="8dp"/>
<TextView
android:id="@+id/tvTimestamp"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="⏰ -"
android:textSize="14sp"
android:layout_marginBottom="4dp"/>
<TextView
android:id="@+id/tvKoordinat"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="🌍 -"
android:textSize="14sp"
android:layout_marginBottom="4dp"/>
<TextView
android:id="@+id/tvAlamat"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="📮 -"
android:textSize="14sp"
android:layout_marginBottom="8dp"/>
<TextView
android:id="@+id/tvStatusValidasi"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="✅ Dalam Radius Kampus"
android:textSize="14sp"
android:textStyle="bold"
android:textColor="#4CAF50"/>
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<Button
android:id="@+id/btnSubmitAbsensi"
android:layout_width="match_parent"
android:layout_height="60dp"
android:text="✅ Submit Absensi"
android:textSize="16sp"
android:layout_marginTop="8dp"/>
<Button
android:id="@+id/btnKembali"
android:layout_width="match_parent"
android:layout_height="60dp"
android:text="🔄 Ambil Ulang Foto"
android:textSize="16sp"
android:layout_marginTop="8dp"
style="@style/Widget.Material3.Button.OutlinedButton"/>
<ProgressBar
android:id="@+id/progressBar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:layout_marginTop="16dp"
android:visibility="gone"/>
</LinearLayout>
</ScrollView>

View File

@ -0,0 +1,78 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="24dp"
android:gravity="center">
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:cardElevation="4dp"
app:cardCornerRadius="16dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="24dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Profil Mahasiswa"
android:textSize="24sp"
android:textStyle="bold"
android:layout_gravity="center"
android:layout_marginBottom="24dp"/>
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="Nama Lengkap"
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etNama"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textPersonName"/>
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="NPM"
android:layout_marginTop="16dp"
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etNpm"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="number"/>
</com.google.android.material.textfield.TextInputLayout>
<Button
android:id="@+id/btnSimpanProfile"
android:layout_width="match_parent"
android:layout_height="60dp"
android:text="Simpan Profile"
android:textSize="16sp"
android:layout_marginTop="24dp"/>
<TextView
android:id="@+id/tvProfileInfo"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:layout_gravity="center"
android:textAlignment="center"
android:visibility="gone"/>
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
</LinearLayout>

View File

@ -0,0 +1,59 @@
<?xml version="1.0" encoding="utf-8"?>
<com.google.android.material.card.MaterialCardView
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="8dp"
app:cardElevation="4dp"
app:cardCornerRadius="12dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:padding="16dp">
<ImageView
android:id="@+id/ivThumbnail"
android:layout_width="80dp"
android:layout_height="80dp"
android:scaleType="centerCrop"
android:background="@android:color/darker_gray"/>
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical"
android:layout_marginStart="16dp">
<TextView
android:id="@+id/tvHistoryNama"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Nama"
android:textSize="16sp"
android:textStyle="bold"/>
<TextView
android:id="@+id/tvHistoryTanggal"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Tanggal"
android:textSize="14sp"
android:layout_marginTop="4dp"/>
<TextView
android:id="@+id/tvHistoryLokasi"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Lokasi"
android:textSize="12sp"
android:layout_marginTop="4dp"/>
</LinearLayout>
</LinearLayout>
</com.google.android.material.card.MaterialCardView>

View File

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:id="@+id/profileFragment"
android:title="Profil"
android:icon="@android:drawable/ic_menu_myplaces" />
<item
android:id="@+id/absensiFragment"
android:title="Absensi"
android:icon="@android:drawable/ic_menu_camera" />
<item
android:id="@+id/historyFragment"
android:title="Riwayat"
android:icon="@android:drawable/ic_menu_recent_history" />
</menu>

View File

@ -0,0 +1,31 @@
<?xml version="1.0" encoding="utf-8"?>
<navigation xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/nav_graph"
app:startDestination="@id/profileFragment">
<fragment
android:id="@+id/profileFragment"
android:name="id.ac.ubharajaya.sistemakademik.ui.fragments.ProfileFragment"
android:label="Profil" />
<fragment
android:id="@+id/absensiFragment"
android:name="id.ac.ubharajaya.sistemakademik.ui.fragments.AbsensiFragment"
android:label="Absensi">
<action
android:id="@+id/action_absensi_to_preview"
app:destination="@id/previewFragment" />
</fragment>
<fragment
android:id="@+id/previewFragment"
android:name="id.ac.ubharajaya.sistemakademik.ui.fragments.PreviewFragment"
android:label="Preview" />
<fragment
android:id="@+id/historyFragment"
android:name="id.ac.ubharajaya.sistemakademik.ui.fragments.HistoryFragment"
android:label="Riwayat" />
</navigation>

View File

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.SistemAkademik"
parent="Theme.MaterialComponents.DayNight.NoActionBar">
<item name="textAppearanceBody1">
@style/TextAppearance.MaterialComponents.Body1
</item>
<item name="textAppearanceBody2">
@style/TextAppearance.MaterialComponents.Body2
</item>
<item name="textAppearanceButton">
@style/TextAppearance.MaterialComponents.Button
</item>
</style>
</resources>

View File

@ -1,5 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.SistemAkademik" parent="android:Theme.Material.Light.NoActionBar" />
<style name="Theme.SistemAkademik"
parent="Theme.MaterialComponents.DayNight.NoActionBar">
<!-- WAJIB buat TextInputLayout -->
<item name="textAppearanceBody1">
@style/TextAppearance.MaterialComponents.Body1
</item>
<item name="textAppearanceBody2">
@style/TextAppearance.MaterialComponents.Body2
</item>
<item name="textAppearanceButton">
@style/TextAppearance.MaterialComponents.Button
</item>
</style>
</resources>

225
n8n-workflow-EAS.json Normal file
View File

@ -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": []
}