Compare commits
No commits in common. "0f0192b00e8df206f685cea55cae7f852d4f6b5c" and "46b74d7099cdaf925ca8634a2e21e5b391f81e03" have entirely different histories.
0f0192b00e
...
46b74d7099
BIN
.gitignore
vendored
BIN
.gitignore
vendored
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -1,2 +0,0 @@
|
||||
#Wed Jan 14 14:27:06 WIB 2026
|
||||
gradle.version=8.13
|
||||
Binary file not shown.
@ -1,2 +0,0 @@
|
||||
#Wed Jan 14 14:26:49 WIB 2026
|
||||
java.home=C\:\\Program Files\\Android\\Android Studio\\jbr
|
||||
Binary file not shown.
1258
.idea/caches/deviceStreaming.xml
generated
1258
.idea/caches/deviceStreaming.xml
generated
File diff suppressed because it is too large
Load Diff
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>
|
||||
|
||||
50
.idea/inspectionProfiles/Project_Default.xml
generated
50
.idea/inspectionProfiles/Project_Default.xml
generated
@ -1,50 +0,0 @@
|
||||
<component name="InspectionProjectProfileManager">
|
||||
<profile version="1.0">
|
||||
<option name="myName" value="Project Default" />
|
||||
<inspection_tool class="ComposePreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="ComposePreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="ComposePreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="ComposePreviewNotSupportedInUnitTestFiles" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="GlancePreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="GlancePreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="GlancePreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="GlancePreviewNotSupportedInUnitTestFiles" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewAnnotationInFunctionWithParameters" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewApiLevelMustBeValid" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewDeviceShouldUseNewSpec" enabled="true" level="WEAK WARNING" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewFontScaleMustBeGreaterThanZero" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewMultipleParameterProviders" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewParameterProviderOnFirstParameter" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewPickerAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
</inspection_tool>
|
||||
</profile>
|
||||
</component>
|
||||
8
.idea/markdown.xml
generated
8
.idea/markdown.xml
generated
@ -1,8 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="MarkdownSettings">
|
||||
<option name="previewPanelProviderInfo">
|
||||
<ProviderInfo name="Compose (experimental)" className="com.intellij.markdown.compose.preview.ComposePanelProvider" />
|
||||
</option>
|
||||
</component>
|
||||
</project>
|
||||
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>
|
||||
17
README.md
17
README.md
@ -79,19 +79,4 @@ Aplikasi memerlukan izin berikut:
|
||||
|
||||
---
|
||||
|
||||
## 📂 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
|
||||
## 📂 Struktur Proyek (Contoh)
|
||||
|
||||
@ -45,15 +45,11 @@ dependencies {
|
||||
implementation(libs.androidx.core.ktx)
|
||||
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||
implementation(libs.androidx.activity.compose)
|
||||
implementation("androidx.activity:activity-compose:1.9.0")
|
||||
implementation(platform(libs.androidx.compose.bom))
|
||||
implementation(libs.androidx.compose.ui)
|
||||
implementation(libs.androidx.compose.ui.graphics)
|
||||
implementation(libs.androidx.compose.ui.tooling.preview)
|
||||
implementation(libs.androidx.compose.material3)
|
||||
// Location (GPS)
|
||||
implementation("com.google.android.gms:play-services-location:21.0.1")
|
||||
|
||||
testImplementation(libs.junit)
|
||||
androidTestImplementation(libs.androidx.junit)
|
||||
androidTestImplementation(libs.androidx.espresso.core)
|
||||
|
||||
@ -2,15 +2,6 @@
|
||||
<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.ACCESS_COARSE_LOCATION"/>
|
||||
<uses-permission android:name="android.permission.CAMERA"/>
|
||||
|
||||
<uses-feature
|
||||
android:name="android.hardware.camera"
|
||||
android:required="false" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
@ -20,27 +11,17 @@
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.SistemAkademik">
|
||||
|
||||
<!-- 🔥 SPLASH SCREEN (LAUNCHER) -->
|
||||
<activity
|
||||
android:name=".SplashActivity"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN"/>
|
||||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- LOGIN -->
|
||||
<activity
|
||||
android:name=".LoginActivity"
|
||||
android:exported="true"/>
|
||||
|
||||
<!-- MAIN -->
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"/>
|
||||
android:exported="true"
|
||||
android:label="@string/app_name"
|
||||
android:theme="@style/Theme.SistemAkademik">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@ -1,44 +0,0 @@
|
||||
package id.ac.ubharajaya.sistemakademik
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.util.Base64
|
||||
import org.json.JSONObject
|
||||
import java.io.ByteArrayOutputStream
|
||||
|
||||
/**
|
||||
* Data class untuk menyimpan informasi absensi mahasiswa
|
||||
*/
|
||||
data class Absensi(
|
||||
val npm: String,
|
||||
val nama: String,
|
||||
val latitude: Double,
|
||||
val longitude: Double,
|
||||
val waktu: String,
|
||||
val foto: Bitmap? // ⬅️ HARUS nullable
|
||||
) {
|
||||
/**
|
||||
* Convert objek Absensi menjadi JSON
|
||||
*/
|
||||
fun toJson(): JSONObject {
|
||||
val json = JSONObject()
|
||||
json.put("npm", npm)
|
||||
json.put("nama", nama)
|
||||
json.put("latitude", latitude)
|
||||
json.put("longitude", longitude)
|
||||
json.put("waktu", waktu)
|
||||
json.put("timestamp", System.currentTimeMillis())
|
||||
json.put("foto_base64", foto?.let { bitmapToBase64(it) })
|
||||
return json
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun bitmapToBase64(bitmap: Bitmap): String {
|
||||
val outputStream = ByteArrayOutputStream()
|
||||
bitmap.compress(Bitmap.CompressFormat.JPEG, 80, outputStream)
|
||||
return Base64.encodeToString(
|
||||
outputStream.toByteArray(),
|
||||
Base64.NO_WRAP
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,182 +0,0 @@
|
||||
package id.ac.ubharajaya.sistemakademik
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.widget.Toast
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.animation.*
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.AccountCircle
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
class LoginActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContent {
|
||||
|
||||
val typography = Typography(
|
||||
titleLarge = MaterialTheme.typography.titleLarge.copy(
|
||||
fontFamily = FontFamily.SansSerif,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 22.sp
|
||||
)
|
||||
)
|
||||
|
||||
MaterialTheme(
|
||||
typography = typography,
|
||||
colorScheme = if (isSystemInDarkTheme())
|
||||
darkColorScheme()
|
||||
else
|
||||
lightColorScheme()
|
||||
) {
|
||||
LoginScreen()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LoginScreen() {
|
||||
val context = LocalContext.current
|
||||
var npm by remember { mutableStateOf("") }
|
||||
var password by remember { mutableStateOf("") }
|
||||
var showCard by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(Unit) { showCard = true }
|
||||
|
||||
val buttonGradient = Brush.horizontalGradient(
|
||||
listOf(Color(0xFF1565C0), Color(0xFF42A5F5))
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black) // 🔥 BACKGROUND HITAM
|
||||
.padding(24.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = showCard,
|
||||
enter = fadeIn() + scaleIn(initialScale = 0.9f)
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(22.dp),
|
||||
elevation = CardDefaults.cardElevation(14.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(28.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.logo),
|
||||
contentDescription = "Logo",
|
||||
modifier = Modifier
|
||||
.size(120.dp)
|
||||
.clip(CircleShape)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
|
||||
Text(
|
||||
"Absensi PPB",
|
||||
style = MaterialTheme.typography.titleLarge
|
||||
)
|
||||
|
||||
Text(
|
||||
"Login Mahasiswa",
|
||||
fontSize = 14.sp,
|
||||
color = Color.Gray
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(28.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
value = npm,
|
||||
onValueChange = { npm = it },
|
||||
label = { Text("NPM") },
|
||||
leadingIcon = {
|
||||
Icon(Icons.Default.AccountCircle, contentDescription = null)
|
||||
},
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
value = password,
|
||||
onValueChange = { password = it },
|
||||
label = { Text("Password") },
|
||||
leadingIcon = {
|
||||
Icon(Icons.Default.Lock, contentDescription = null)
|
||||
},
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
if (npm == "202310715307" && password == "yoo23") {
|
||||
context.startActivity(
|
||||
Intent(context, MainActivity::class.java)
|
||||
)
|
||||
} else {
|
||||
Toast.makeText(
|
||||
context,
|
||||
"NPM atau password salah",
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
},
|
||||
colors = ButtonDefaults.buttonColors(containerColor = Color.Transparent),
|
||||
contentPadding = PaddingValues(),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(48.dp)
|
||||
.background(
|
||||
brush = buttonGradient,
|
||||
shape = RoundedCornerShape(14.dp)
|
||||
)
|
||||
) {
|
||||
Text(
|
||||
"Login",
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,205 +1,28 @@
|
||||
package id.ac.ubharajaya.sistemakademik
|
||||
|
||||
import android.Manifest
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.graphics.Bitmap
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.provider.MediaStore
|
||||
import android.widget.Toast
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.animateContentSize
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.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.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.google.android.gms.location.LocationServices
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import id.ac.ubharajaya.sistemakademik.ui.theme.SistemAkademikTheme
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
import kotlin.concurrent.thread
|
||||
|
||||
/* ================== KONFIG LOCKOUT ================== */
|
||||
const val LOCKOUT_DURATION = 10 * 60 * 1000L // 1 menit
|
||||
|
||||
/* ================== REPOSITORY ================== */
|
||||
class AbsensiRepository {
|
||||
fun kirimAbsensi(absensi: Absensi, onResult: (Boolean) -> Unit) {
|
||||
thread {
|
||||
try {
|
||||
val url = java.net.URL(
|
||||
"https://n8n.lab.ubharajaya.ac.id/webhook/23c6993d-1792-48fb-ad1c-ffc78a3e6254"
|
||||
)
|
||||
val conn = url.openConnection() as java.net.HttpURLConnection
|
||||
conn.requestMethod = "POST"
|
||||
conn.setRequestProperty("Content-Type", "application/json")
|
||||
conn.doOutput = true
|
||||
|
||||
conn.outputStream.use {
|
||||
it.write(absensi.toJson().toString().toByteArray())
|
||||
}
|
||||
|
||||
onResult(conn.responseCode == 200)
|
||||
conn.disconnect()
|
||||
} catch (e: Exception) {
|
||||
onResult(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ================== VIEWMODEL ================== */
|
||||
class AbsensiViewModel(
|
||||
private val repo: AbsensiRepository,
|
||||
private val activity: ComponentActivity
|
||||
) : ViewModel() {
|
||||
|
||||
var lokasi by mutableStateOf("Koordinat: -")
|
||||
private set
|
||||
|
||||
var latitude by mutableStateOf<Double?>(null)
|
||||
private set
|
||||
|
||||
var longitude by mutableStateOf<Double?>(null)
|
||||
private set
|
||||
|
||||
var foto by mutableStateOf<Bitmap?>(null)
|
||||
private set
|
||||
|
||||
var absensiList = mutableStateListOf<Absensi>()
|
||||
|
||||
var waktuAbsensi by mutableStateOf<String?>(null)
|
||||
private set
|
||||
|
||||
var isLoading by mutableStateOf(false)
|
||||
private set
|
||||
|
||||
var sisaLockout by mutableStateOf(0L)
|
||||
private set
|
||||
|
||||
init {
|
||||
startLockoutCountdown()
|
||||
}
|
||||
|
||||
fun updateLokasi(lat: Double, lon: Double) {
|
||||
latitude = lat
|
||||
longitude = lon
|
||||
lokasi = "Lat: $lat\nLon: $lon"
|
||||
}
|
||||
|
||||
fun updateFoto(bitmap: Bitmap) {
|
||||
foto = bitmap
|
||||
}
|
||||
|
||||
private fun getLastAbsensiTime(): Long {
|
||||
val prefs = activity.getSharedPreferences("absensi_prefs", Activity.MODE_PRIVATE)
|
||||
return prefs.getLong("last_absensi_time", 0L)
|
||||
}
|
||||
|
||||
private fun simpanWaktuAbsensi() {
|
||||
val prefs = activity.getSharedPreferences("absensi_prefs", Activity.MODE_PRIVATE)
|
||||
prefs.edit().putLong("last_absensi_time", System.currentTimeMillis()).apply()
|
||||
}
|
||||
|
||||
private fun startLockoutCountdown() {
|
||||
viewModelScope.launch {
|
||||
while (true) {
|
||||
val diff = System.currentTimeMillis() - getLastAbsensiTime()
|
||||
sisaLockout =
|
||||
if (diff < LOCKOUT_DURATION) LOCKOUT_DURATION - diff else 0L
|
||||
delay(1000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun kirimAbsensi() {
|
||||
if (sisaLockout > 0) {
|
||||
Toast.makeText(
|
||||
activity,
|
||||
"Sudah absen, tunggu ${(sisaLockout / 1000) / 60} menit",
|
||||
Toast.LENGTH_LONG
|
||||
).show()
|
||||
return
|
||||
}
|
||||
|
||||
if (latitude == null || longitude == null || foto == null) {
|
||||
Toast.makeText(activity, "Lokasi atau foto belum lengkap", Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
|
||||
val waktu = SimpleDateFormat(
|
||||
"dd MMM yyyy HH:mm:ss",
|
||||
Locale.getDefault()
|
||||
).format(System.currentTimeMillis())
|
||||
|
||||
val absensi = Absensi(
|
||||
npm = "202310715307",
|
||||
nama = "Satrio Putra Wardani",
|
||||
latitude = latitude!!,
|
||||
longitude = longitude!!,
|
||||
waktu = waktu,
|
||||
foto = foto
|
||||
)
|
||||
|
||||
isLoading = true
|
||||
repo.kirimAbsensi(absensi) { success ->
|
||||
viewModelScope.launch {
|
||||
isLoading = false
|
||||
if (success) {
|
||||
absensiList.add(absensi)
|
||||
waktuAbsensi = waktu
|
||||
simpanWaktuAbsensi()
|
||||
Toast.makeText(activity, "Absensi berhasil", Toast.LENGTH_SHORT).show()
|
||||
} else {
|
||||
Toast.makeText(activity, "Gagal kirim absensi", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ================== MAIN ACTIVITY ================== */
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
class MainActivity : ComponentActivity() {
|
||||
|
||||
private lateinit var viewModel: AbsensiViewModel
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
|
||||
viewModel = AbsensiViewModel(AbsensiRepository(), this)
|
||||
|
||||
setContent {
|
||||
SistemAkademikTheme {
|
||||
Scaffold(
|
||||
topBar = { TopBarLogout(this) }
|
||||
) { padding ->
|
||||
AbsensiScreen(
|
||||
modifier = Modifier.padding(padding),
|
||||
viewModel = viewModel
|
||||
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
|
||||
Greeting(
|
||||
name = "Android",
|
||||
modifier = Modifier.padding(innerPadding)
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -207,174 +30,18 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
/* ================== TOP BAR ================== */
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun TopBarLogout(activity: ComponentActivity) {
|
||||
val context = LocalContext.current
|
||||
TopAppBar(
|
||||
title = {
|
||||
fun Greeting(name: String, modifier: Modifier = Modifier) {
|
||||
Text(
|
||||
" Sistem Akademik ",
|
||||
style = MaterialTheme.typography.titleLarge.copy(
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = Color.White
|
||||
)
|
||||
)
|
||||
},
|
||||
actions = {
|
||||
TextButton(onClick = {
|
||||
val intent = Intent(context, LoginActivity::class.java)
|
||||
intent.flags =
|
||||
Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
|
||||
context.startActivity(intent)
|
||||
}) {
|
||||
Text(
|
||||
"Logout",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = Color.Red
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/* ================== UI ================== */
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun AbsensiScreen(modifier: Modifier, viewModel: AbsensiViewModel) {
|
||||
|
||||
val context = LocalContext.current
|
||||
|
||||
val locationPermissionLauncher =
|
||||
rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
|
||||
if (granted) {
|
||||
LocationServices.getFusedLocationProviderClient(context)
|
||||
.lastLocation.addOnSuccessListener {
|
||||
it?.let { loc ->
|
||||
viewModel.updateLokasi(loc.latitude, loc.longitude)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val cameraLauncher =
|
||||
rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {
|
||||
if (it.resultCode == Activity.RESULT_OK) {
|
||||
val bitmap =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU)
|
||||
it.data?.extras?.getParcelable("data", Bitmap::class.java)
|
||||
else it.data?.extras?.getParcelable("data")
|
||||
bitmap?.let { bmp -> viewModel.updateFoto(bmp) }
|
||||
}
|
||||
}
|
||||
|
||||
val cameraPermissionLauncher =
|
||||
rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) {
|
||||
if (it) cameraLauncher.launch(Intent(MediaStore.ACTION_IMAGE_CAPTURE))
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
locationPermissionLauncher.launch(Manifest.permission.ACCESS_FINE_LOCATION)
|
||||
}
|
||||
|
||||
Column(
|
||||
text = "Hello $name!",
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.White)
|
||||
.padding(24.dp)
|
||||
) {
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = "Absensi Akademik",
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.headlineSmall.copy(
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = Color.Black
|
||||
)
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
elevation = CardDefaults.cardElevation(6.dp)
|
||||
) {
|
||||
Column(Modifier.padding(16.dp)) {
|
||||
Text(
|
||||
"Lokasi:",
|
||||
style = MaterialTheme.typography.bodyLarge.copy(
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = Color.White
|
||||
)
|
||||
)
|
||||
Text(
|
||||
viewModel.lokasi,
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
color = Color.White
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
Button(
|
||||
onClick = { cameraPermissionLauncher.launch(Manifest.permission.CAMERA) },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text("Ambil Foto")
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
Button(
|
||||
onClick = { viewModel.kirimAbsensi() },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = !viewModel.isLoading
|
||||
) {
|
||||
if (viewModel.isLoading)
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(22.dp),
|
||||
strokeWidth = 2.dp
|
||||
)
|
||||
else Text("Kirim Absensi")
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(24.dp))
|
||||
|
||||
LazyColumn {
|
||||
items(viewModel.absensiList) { item ->
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 12.dp)
|
||||
.animateContentSize()
|
||||
) {
|
||||
Column(Modifier.padding(16.dp)) {
|
||||
Text(
|
||||
item.nama,
|
||||
style = MaterialTheme.typography.bodyLarge.copy(
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = Color.White
|
||||
)
|
||||
)
|
||||
Text(item.npm, color = Color.White)
|
||||
Text(item.waktu, color = Color.White)
|
||||
item.foto?.let {
|
||||
Image(
|
||||
bitmap = it.asImageBitmap(),
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(150.dp)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
fun GreetingPreview() {
|
||||
SistemAkademikTheme {
|
||||
Greeting("Android")
|
||||
}
|
||||
}
|
||||
@ -1,62 +0,0 @@
|
||||
package id.ac.ubharajaya.sistemakademik
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
class SplashActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContent {
|
||||
SplashScreen {
|
||||
startActivity(Intent(this, LoginActivity::class.java))
|
||||
finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SplashScreen(onFinish: () -> Unit) {
|
||||
LaunchedEffect(Unit) {
|
||||
delay(2000) // 2 detik
|
||||
onFinish()
|
||||
}
|
||||
|
||||
// 🔥 GRADIENT ABU-ABU MODERN
|
||||
val gradient = Brush.verticalGradient(
|
||||
listOf(
|
||||
Color(0xFF2C2C2C), // Dark Grey
|
||||
Color(0xFF757575) // Light Grey
|
||||
)
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(gradient),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.logo),
|
||||
contentDescription = "Logo",
|
||||
modifier = Modifier
|
||||
.size(140.dp)
|
||||
.clip(CircleShape)
|
||||
)
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 31 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 55 KiB |
@ -1,8 +0,0 @@
|
||||
## This file must *NOT* be checked into Version Control Systems,
|
||||
# as it contains information specific to your local configuration.
|
||||
#
|
||||
# Location of the SDK. This is only used by Gradle.
|
||||
# For customization when using a Version Control System, please read the
|
||||
# header note.
|
||||
#Wed Jan 14 14:26:50 WIB 2026
|
||||
sdk.dir=C\:\\Users\\Lenovo\\AppData\\Local\\Android\\Sdk
|
||||
@ -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