Compare commits
No commits in common. "21cb4efc2a89a4f2d6b42f01e58d5571576d6f08" and "49d9ce215a6a9c96daa48fae6444d47f45b3523b" have entirely different histories.
21cb4efc2a
...
49d9ce215a
8
.idea/deploymentTargetSelector.xml
generated
8
.idea/deploymentTargetSelector.xml
generated
@ -4,14 +4,6 @@
|
||||
<selectionStates>
|
||||
<SelectionState runConfigName="app">
|
||||
<option name="selectionMode" value="DROPDOWN" />
|
||||
<DropdownSelection timestamp="2026-01-13T05:20:56.137492Z">
|
||||
<Target type="DEFAULT_BOOT">
|
||||
<handle>
|
||||
<DeviceId pluginId="PhysicalDevice" identifier="serial=RR8TA08RD8Z" />
|
||||
</handle>
|
||||
</Target>
|
||||
</DropdownSelection>
|
||||
<DialogSelection />
|
||||
</SelectionState>
|
||||
</selectionStates>
|
||||
</component>
|
||||
|
||||
13
.idea/deviceManager.xml
generated
13
.idea/deviceManager.xml
generated
@ -1,13 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="DeviceTable">
|
||||
<option name="columnSorters">
|
||||
<list>
|
||||
<ColumnSorterState>
|
||||
<option name="column" value="Name" />
|
||||
<option name="order" value="ASCENDING" />
|
||||
</ColumnSorterState>
|
||||
</list>
|
||||
</option>
|
||||
</component>
|
||||
</project>
|
||||
1
.idea/gradle.xml
generated
1
.idea/gradle.xml
generated
@ -1,6 +1,5 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="GradleMigrationSettings" migrationVersion="1" />
|
||||
<component name="GradleSettings">
|
||||
<option name="linkedExternalProjectsSettings">
|
||||
<GradleProjectSettings>
|
||||
|
||||
1
.idea/misc.xml
generated
1
.idea/misc.xml
generated
@ -1,3 +1,4 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ExternalStorageConfigurationManager" enabled="true" />
|
||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
|
||||
|
||||
6
.idea/studiobot.xml
generated
6
.idea/studiobot.xml
generated
@ -1,6 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="StudioBotProjectSettings">
|
||||
<option name="shareContext" value="OptedIn" />
|
||||
</component>
|
||||
</project>
|
||||
BIN
Mockup.png
BIN
Mockup.png
Binary file not shown.
|
Before Width: | Height: | Size: 715 KiB |
96
README.md
96
README.md
@ -1,95 +1,3 @@
|
||||
# 📱 Aplikasi Absensi Akademik Berbasis Koordinat dan Foto (Mobile)
|
||||
# Aplikasi Sistem Akademik 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
|
||||

|
||||
|
||||
## Catatan:
|
||||
- Kembangkan project dari starter yang sudah disediakan, tidak 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
|
||||
Untuk
|
||||
@ -42,20 +42,14 @@ android {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation("com.google.android.gms:play-services-location:21.0.1")
|
||||
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)
|
||||
implementation("androidx.compose.material:material-icons-extended:1.6.7")
|
||||
// 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)
|
||||
|
||||
@ -1,17 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-feature android:name="android.hardware.camera" android:required="false" />
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
|
||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
|
||||
<uses-permission android:name="android.permission.CAMERA"/>
|
||||
<uses-feature
|
||||
android:name="android.hardware.camera"
|
||||
android:required="false" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
|
||||
@ -1,371 +1,47 @@
|
||||
package id.ac.ubharajaya.sistemakademik
|
||||
|
||||
import android.Manifest
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.graphics.Bitmap
|
||||
import android.location.Location
|
||||
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.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.google.android.gms.location.LocationServices
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import id.ac.ubharajaya.sistemakademik.ui.theme.SistemAkademikTheme
|
||||
import org.json.JSONObject
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
import kotlin.concurrent.thread
|
||||
|
||||
// =============== MAIN ACTIVITY & NAVIGATION ===============
|
||||
class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
setContent {
|
||||
SistemAkademikTheme {
|
||||
AppNavigation(activity = this)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AppNavigation(activity: ComponentActivity) {
|
||||
var currentScreen by remember { mutableStateOf("login") }
|
||||
var npm by remember { mutableStateOf("") }
|
||||
var nama by remember { mutableStateOf("") }
|
||||
var loginTime by remember { mutableStateOf(0L) }
|
||||
|
||||
when (currentScreen) {
|
||||
"login" -> LoginScreen(onLoginSuccess = { loggedInNpm, loggedInNama, time ->
|
||||
npm = loggedInNpm
|
||||
nama = loggedInNama
|
||||
loginTime = time
|
||||
currentScreen = "menu"
|
||||
})
|
||||
"menu" -> MenuScreen(
|
||||
nama = nama,
|
||||
npm = npm,
|
||||
onAbsenClick = { currentScreen = "absensi" },
|
||||
onRiwayatClick = { currentScreen = "riwayat" },
|
||||
onLogout = {
|
||||
currentScreen = "login"
|
||||
npm = ""
|
||||
nama = ""
|
||||
}
|
||||
)
|
||||
"absensi" -> AbsensiScreen(activity = activity, npm = npm, nama = nama, loginTime = loginTime, onNavigateBack = {
|
||||
currentScreen = "menu"
|
||||
})
|
||||
"riwayat" -> RiwayatScreen(onNavigateBack = { currentScreen = "menu" })
|
||||
}
|
||||
}
|
||||
|
||||
// =============== LOGIN SCREEN ===============
|
||||
|
||||
@Composable
|
||||
fun LoginScreen(onLoginSuccess: (npm: String, nama: String, loginTime: Long) -> Unit) {
|
||||
var npm by remember { mutableStateOf("") }
|
||||
var nama by remember { mutableStateOf("") }
|
||||
val context = LocalContext.current
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize().padding(32.dp),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text("Login Absensi", style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold)
|
||||
Spacer(Modifier.height(32.dp))
|
||||
|
||||
OutlinedTextField(value = npm, onValueChange = { npm = it }, label = { Text("NPM") }, modifier = Modifier.fillMaxWidth(), singleLine = true, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number))
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
OutlinedTextField(value = nama, onValueChange = { nama = it }, label = { Text("Nama Lengkap") }, modifier = Modifier.fillMaxWidth(), singleLine = true, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text))
|
||||
Spacer(Modifier.height(32.dp))
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
if (npm.isNotBlank() && nama.isNotBlank()) {
|
||||
onLoginSuccess(npm, nama, System.currentTimeMillis())
|
||||
} else {
|
||||
Toast.makeText(context, "NPM dan Nama tidak boleh kosong", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth().height(50.dp)
|
||||
) {
|
||||
Text("LOGIN")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =============== MENU SCREEN ===============
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun MenuScreen(nama: String, npm: String, onAbsenClick: () -> Unit, onRiwayatClick: () -> Unit, onLogout: () -> Unit) {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
CenterAlignedTopAppBar(
|
||||
title = { Text("Menu Utama", color = Color.White) },
|
||||
navigationIcon = { IconButton(onClick = onLogout) { Icon(Icons.Default.ArrowBack, "Logout", tint = Color.White) } },
|
||||
colors = TopAppBarDefaults.centerAlignedTopAppBarColors(containerColor = Color(0xFF388E3C))
|
||||
)
|
||||
}
|
||||
) { innerPadding ->
|
||||
Column(
|
||||
modifier = Modifier.padding(innerPadding).fillMaxSize().padding(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Card(modifier = Modifier.fillMaxWidth(), elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text("Selamat Datang,", style = MaterialTheme.typography.bodyLarge, color = Color.Gray)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(nama, fontWeight = FontWeight.Bold, fontSize = 22.sp)
|
||||
Text("($npm)", fontSize = 16.sp, color = Color.Gray)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
MenuButton(text = "Absen Kehadiran", icon = Icons.Filled.CameraAlt, onClick = onAbsenClick)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
MenuButton(text = "Riwayat Absensi", icon = Icons.Filled.History, onClick = onRiwayatClick)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MenuButton(text: String, icon: ImageVector, onClick: () -> Unit) {
|
||||
Button(
|
||||
onClick = onClick,
|
||||
modifier = Modifier.fillMaxWidth().height(55.dp),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.primary)
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(icon, contentDescription = null, modifier = Modifier.size(24.dp))
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Text(text, fontSize = 16.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =============== RIWAYAT SCREEN (PLACEHOLDER) ===============
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun RiwayatScreen(onNavigateBack: () -> Unit) {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
CenterAlignedTopAppBar(
|
||||
title = { Text("Riwayat Absensi", color = Color.White) },
|
||||
navigationIcon = { IconButton(onClick = onNavigateBack) { Icon(Icons.Default.ArrowBack, "Kembali", tint = Color.White) } },
|
||||
colors = TopAppBarDefaults.centerAlignedTopAppBarColors(containerColor = Color(0xFF388E3C))
|
||||
)
|
||||
}
|
||||
) { innerPadding ->
|
||||
Box(modifier = Modifier.padding(innerPadding).fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text("Halaman Riwayat Absensi (Segera Hadir)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// =============== ABSENSI SCREEN ===============
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun AbsensiScreen(activity: ComponentActivity, npm: String, nama: String, loginTime: Long, onNavigateBack: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
var lokasiStatus by remember { mutableStateOf("Mengecek izin lokasi...") }
|
||||
var isLocationReady by remember { mutableStateOf(false) }
|
||||
var foto by remember { mutableStateOf<Bitmap?>(null) }
|
||||
var isUploading by remember { mutableStateOf(false) }
|
||||
var userLocation by remember { mutableStateOf<Location?>(null) }
|
||||
var mataKuliah by remember { mutableStateOf("") }
|
||||
|
||||
val fusedLocationClient = remember { LocationServices.getFusedLocationProviderClient(context) }
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
val requestPermissionLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { permissions ->
|
||||
if (permissions[Manifest.permission.ACCESS_FINE_LOCATION] == true) {
|
||||
lokasiStatus = "Mendapatkan koordinat..."
|
||||
try {
|
||||
fusedLocationClient.lastLocation.addOnSuccessListener { loc ->
|
||||
if (loc != null) {
|
||||
userLocation = loc
|
||||
isLocationReady = true
|
||||
lokasiStatus = "Lokasi berhasil didapatkan"
|
||||
} else {
|
||||
isLocationReady = false
|
||||
lokasiStatus = "Gagal mendapatkan lokasi. Pastikan GPS aktif."
|
||||
}
|
||||
}
|
||||
} catch (e: SecurityException) {
|
||||
isLocationReady = false
|
||||
lokasiStatus = "Izin lokasi dicabut."
|
||||
}
|
||||
} else {
|
||||
lokasiStatus = "Izin lokasi ditolak."
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) { requestPermissionLauncher.launch(arrayOf(Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.CAMERA)) }
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
CenterAlignedTopAppBar(
|
||||
title = { Text("Absen Kehadiran", color = Color.White) },
|
||||
navigationIcon = { IconButton(onClick = onNavigateBack) { Icon(Icons.Default.ArrowBack, "Kembali", tint = Color.White) } },
|
||||
colors = TopAppBarDefaults.centerAlignedTopAppBarColors(containerColor = Color(0xFF388E3C))
|
||||
)
|
||||
}
|
||||
) { innerPadding ->
|
||||
Column(
|
||||
modifier = Modifier.padding(innerPadding).fillMaxSize().padding(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = mataKuliah,
|
||||
onValueChange = { mataKuliah = it },
|
||||
label = { Text("Mata Kuliah") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(if (isLocationReady) Icons.Filled.CheckCircle else Icons.Filled.Close, "Status Lokasi", tint = if (isLocationReady) Color(0xFF2E7D32) else Color.Red, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(lokasiStatus, color = if (isLocationReady) Color.DarkGray else Color.Red, fontSize = 14.sp)
|
||||
}
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Box(Modifier.size(200.dp).clip(CircleShape).background(Color.LightGray), contentAlignment = Alignment.Center) {
|
||||
if (foto != null) {
|
||||
Image(foto!!.asImageBitmap(), "Foto Selfie", Modifier.fillMaxSize(), contentScale = ContentScale.Crop)
|
||||
} else {
|
||||
Icon(Icons.Filled.Person, "Placeholder", modifier = Modifier.size(70.dp), tint = Color.Gray)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(24.dp))
|
||||
Button(
|
||||
onClick = { cameraLauncher.launch(Intent(MediaStore.ACTION_IMAGE_CAPTURE)) },
|
||||
enabled = isLocationReady, // Button enabled only when location is ready
|
||||
modifier = Modifier.fillMaxWidth(0.8f).height(50.dp)
|
||||
) {
|
||||
Text("1. AMBIL FOTO")
|
||||
}
|
||||
Spacer(Modifier.height(16.dp))
|
||||
if (isLocationReady && foto != null && mataKuliah.isNotBlank()) {
|
||||
if (isUploading) {
|
||||
CircularProgressIndicator()
|
||||
} else {
|
||||
Button(
|
||||
onClick = {
|
||||
isUploading = true
|
||||
kirimKeServer(activity, npm, nama, userLocation?.latitude ?: 0.0, userLocation?.longitude ?: 0.0, foto!!, loginTime, mataKuliah) { success ->
|
||||
if (success) {
|
||||
Toast.makeText(activity, "Absensi Berhasil!", Toast.LENGTH_LONG).show()
|
||||
onNavigateBack()
|
||||
} else {
|
||||
isUploading = false
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(0.8f).height(50.dp),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF1B5E20))
|
||||
) {
|
||||
Text("2. KIRIM ABSENSI")
|
||||
}
|
||||
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
|
||||
Greeting(
|
||||
name = "Android",
|
||||
modifier = Modifier.padding(innerPadding)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =============== NETWORK & UTILS ===============
|
||||
@Composable
|
||||
fun Greeting(name: String, modifier: Modifier = Modifier) {
|
||||
Text(
|
||||
text = "Hello $name!",
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
|
||||
fun kirimKeServer(activity: Activity, npm: String, nama: String, lat: Double, lon: Double, img: Bitmap, loginTime: Long, mataKuliah: String, onFinish: (Boolean) -> Unit) {
|
||||
thread {
|
||||
var success = false
|
||||
try {
|
||||
val url = URL("https://n8n.lab.ubharajaya.ac.id/webhook/23c6993d-1792-48fb-ad1c-ffc78a3e6254")
|
||||
val conn = url.openConnection() as HttpURLConnection
|
||||
conn.requestMethod = "POST"
|
||||
conn.setRequestProperty("Content-Type", "application/json")
|
||||
conn.doOutput = true
|
||||
|
||||
val json = JSONObject().apply {
|
||||
put("npm", npm)
|
||||
put("nama", nama)
|
||||
put("latitude", lat)
|
||||
put("longitude", lon)
|
||||
put("timestamp", loginTime)
|
||||
put("mata_kuliah", mataKuliah)
|
||||
put("foto_base64", bitmapToBase64(img))
|
||||
}
|
||||
|
||||
conn.outputStream.write(json.toString().toByteArray())
|
||||
|
||||
val code = conn.responseCode
|
||||
success = code == 200
|
||||
if (!success) {
|
||||
activity.runOnUiThread {
|
||||
Toast.makeText(activity, "Gagal mengirim absensi. Kode: $code", Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
activity.runOnUiThread {
|
||||
Toast.makeText(activity, "Error: Gagal terhubung ke server.", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
} finally {
|
||||
activity.runOnUiThread { onFinish(success) }
|
||||
}
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
fun GreetingPreview() {
|
||||
SistemAkademikTheme {
|
||||
Greeting("Android")
|
||||
}
|
||||
}
|
||||
|
||||
fun bitmapToBase64(bitmap: Bitmap): String {
|
||||
val outputStream = ByteArrayOutputStream()
|
||||
bitmap.compress(Bitmap.CompressFormat.JPEG, 70, outputStream)
|
||||
return Base64.encodeToString(outputStream.toByteArray(), Base64.NO_WRAP)
|
||||
}
|
||||
}
|
||||
@ -1,225 +0,0 @@
|
||||
{
|
||||
"name": "EAS",
|
||||
"nodes": [
|
||||
{
|
||||
"parameters": {
|
||||
"method": "POST",
|
||||
"url": "https://ntfy.ubharajaya.ac.id/EAS",
|
||||
"sendBody": true,
|
||||
"contentType": "raw",
|
||||
"body": "=Absensi: {{ $json.body.nama }} NPM: {{ $json.body.npm }}",
|
||||
"options": {}
|
||||
},
|
||||
"type": "n8n-nodes-base.httpRequest",
|
||||
"typeVersion": 4.3,
|
||||
"position": [
|
||||
-272,
|
||||
-240
|
||||
],
|
||||
"id": "83504eec-6d20-46d7-9ea1-509ae4ee8660",
|
||||
"name": "NTFY HTTP Request"
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"httpMethod": "POST",
|
||||
"path": "23c6993d-1792-48fb-ad1c-ffc78a3e6254",
|
||||
"options": {}
|
||||
},
|
||||
"type": "n8n-nodes-base.webhook",
|
||||
"typeVersion": 2.1,
|
||||
"position": [
|
||||
-864,
|
||||
-112
|
||||
],
|
||||
"id": "9ed3d2db-2d50-40b5-8408-7404edd48442",
|
||||
"name": "Webhook Absensi",
|
||||
"webhookId": "23c6993d-1792-48fb-ad1c-ffc78a3e6254"
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"operation": "append",
|
||||
"documentId": {
|
||||
"__rl": true,
|
||||
"value": "1jH15MfnNgpPGuGeid0hYfY7fFUHCEFbCmg8afTyyLZs",
|
||||
"mode": "id"
|
||||
},
|
||||
"sheetName": {
|
||||
"__rl": true,
|
||||
"value": "Absensi",
|
||||
"mode": "name"
|
||||
},
|
||||
"columns": {
|
||||
"mappingMode": "defineBelow",
|
||||
"value": {
|
||||
"latitude": "={{ $json.body.latitude }}",
|
||||
"longitude": "={{ $json.body.longitude }}",
|
||||
"timestamp": "={{ $json.body.timestamp }}",
|
||||
"foto_base64": "={{ $json.body.foto_base64 }}",
|
||||
"nama": "={{ $json.body.nama }}",
|
||||
"npm": "={{ $json.body.npm }}"
|
||||
},
|
||||
"matchingColumns": [],
|
||||
"schema": [
|
||||
{
|
||||
"id": "timestamp",
|
||||
"displayName": "timestamp",
|
||||
"required": false,
|
||||
"defaultMatch": false,
|
||||
"display": true,
|
||||
"type": "string",
|
||||
"canBeUsedToMatch": true,
|
||||
"removed": false
|
||||
},
|
||||
{
|
||||
"id": "npm",
|
||||
"displayName": "npm",
|
||||
"required": false,
|
||||
"defaultMatch": false,
|
||||
"display": true,
|
||||
"type": "string",
|
||||
"canBeUsedToMatch": true,
|
||||
"removed": false
|
||||
},
|
||||
{
|
||||
"id": "nama",
|
||||
"displayName": "nama",
|
||||
"required": false,
|
||||
"defaultMatch": false,
|
||||
"display": true,
|
||||
"type": "string",
|
||||
"canBeUsedToMatch": true,
|
||||
"removed": false
|
||||
},
|
||||
{
|
||||
"id": "latitude",
|
||||
"displayName": "latitude",
|
||||
"required": false,
|
||||
"defaultMatch": false,
|
||||
"display": true,
|
||||
"type": "string",
|
||||
"canBeUsedToMatch": true,
|
||||
"removed": false
|
||||
},
|
||||
{
|
||||
"id": "longitude",
|
||||
"displayName": "longitude",
|
||||
"required": false,
|
||||
"defaultMatch": false,
|
||||
"display": true,
|
||||
"type": "string",
|
||||
"canBeUsedToMatch": true,
|
||||
"removed": false
|
||||
},
|
||||
{
|
||||
"id": "photo",
|
||||
"displayName": "photo",
|
||||
"required": false,
|
||||
"defaultMatch": false,
|
||||
"display": true,
|
||||
"type": "string",
|
||||
"canBeUsedToMatch": true,
|
||||
"removed": false
|
||||
},
|
||||
{
|
||||
"id": "status",
|
||||
"displayName": "status",
|
||||
"required": false,
|
||||
"defaultMatch": false,
|
||||
"display": true,
|
||||
"type": "string",
|
||||
"canBeUsedToMatch": true,
|
||||
"removed": false
|
||||
},
|
||||
{
|
||||
"id": "foto_base64",
|
||||
"displayName": "foto_base64",
|
||||
"required": false,
|
||||
"defaultMatch": false,
|
||||
"display": true,
|
||||
"type": "string",
|
||||
"canBeUsedToMatch": true,
|
||||
"removed": false
|
||||
}
|
||||
],
|
||||
"attemptToConvertTypes": false,
|
||||
"convertFieldsToString": false
|
||||
},
|
||||
"options": {}
|
||||
},
|
||||
"type": "n8n-nodes-base.googleSheets",
|
||||
"typeVersion": 4.7,
|
||||
"position": [
|
||||
-272,
|
||||
-32
|
||||
],
|
||||
"id": "cd83a9fa-ea00-4a20-aa31-846bfe044aeb",
|
||||
"name": "Append row in sheet",
|
||||
"credentials": {
|
||||
"googleSheetsOAuth2Api": {
|
||||
"id": "hNVNhkTQbqkJ3C56",
|
||||
"name": "Google Sheets account"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"jsCode": "// Loop over input items and add a new field called 'myNewField' to the JSON of each one\nfor (const item of $input.all()) {\n item.json.myNewField = 1;\n}\n\nreturn $input.all();"
|
||||
},
|
||||
"type": "n8n-nodes-base.code",
|
||||
"typeVersion": 2,
|
||||
"position": [
|
||||
-528,
|
||||
-240
|
||||
],
|
||||
"id": "4ed9edf6-4562-41b6-afd0-89c96991454a",
|
||||
"name": "Code in JavaScript"
|
||||
}
|
||||
],
|
||||
"pinData": {},
|
||||
"connections": {
|
||||
"Webhook Absensi": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Append row in sheet",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
},
|
||||
{
|
||||
"node": "Code in JavaScript",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"NTFY HTTP Request": {
|
||||
"main": [
|
||||
[]
|
||||
]
|
||||
},
|
||||
"Code in JavaScript": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "NTFY HTTP Request",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"active": true,
|
||||
"settings": {
|
||||
"executionOrder": "v1",
|
||||
"availableInMCP": false
|
||||
},
|
||||
"versionId": "49466b31-67ce-49b7-af37-33cd28d7092d",
|
||||
"meta": {
|
||||
"templateCredsSetupCompleted": true,
|
||||
"instanceId": "b8ffac81bb85d267c3296e074b3e692ecef11caeef79fa72af892085548f350a"
|
||||
},
|
||||
"id": "E_gxZpNrN3G5ibejHcTFS",
|
||||
"tags": []
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user