Compare commits

...

10 Commits

Author SHA1 Message Date
putraa-afk
0f0192b00e Initial commit 2026-01-14 20:53:15 +07:00
putraa-afk
462f9c315a Initial commit 2026-01-14 20:45:44 +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
d4d1b27209 real time location 2026-01-13 09:34:37 +07:00
3e66ebcf9e mockup 2026-01-12 22:07:16 +07:00
36 changed files with 2268 additions and 29 deletions

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.

View File

Binary file not shown.

View File

@ -0,0 +1,2 @@
#Wed Jan 14 14:27:06 WIB 2026
gradle.version=8.13

Binary file not shown.

View File

@ -0,0 +1,2 @@
#Wed Jan 14 14:26:49 WIB 2026
java.home=C\:\\Program Files\\Android\\Android Studio\\jbr

BIN
.gradle/file-system.probe Normal file

Binary file not shown.

View File

1258
.idea/caches/deviceStreaming.xml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -4,6 +4,14 @@
<selectionStates> <selectionStates>
<SelectionState runConfigName="app"> <SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" /> <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> </SelectionState>
</selectionStates> </selectionStates>
</component> </component>

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

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

1
.idea/gradle.xml generated
View File

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

View File

@ -0,0 +1,50 @@
<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 Normal file
View File

@ -0,0 +1,8 @@
<?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
View File

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

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>

View File

@ -79,4 +79,19 @@ Aplikasi memerlukan izin berikut:
--- ---
## 📂 Struktur Proyek (Contoh) ## 📂 Mockup
![mockup](Mockup.png)
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

View File

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

View File

@ -2,6 +2,15 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"> 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 <application
android:allowBackup="true" android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules" android:dataExtractionRules="@xml/data_extraction_rules"
@ -11,17 +20,27 @@
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.SistemAkademik"> android:theme="@style/Theme.SistemAkademik">
<activity
android:name=".MainActivity"
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" /> <!-- 🔥 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> </intent-filter>
</activity> </activity>
<!-- LOGIN -->
<activity
android:name=".LoginActivity"
android:exported="true"/>
<!-- MAIN -->
<activity
android:name=".MainActivity"
android:exported="true"/>
</application> </application>
</manifest> </manifest>

View File

@ -0,0 +1,44 @@
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
)
}
}
}

View File

@ -0,0 +1,182 @@
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
)
}
}
}
}
}
}

View File

@ -1,28 +1,205 @@
package id.ac.ubharajaya.sistemakademik 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.os.Bundle
import android.provider.MediaStore
import android.widget.Toast
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.fillMaxSize import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.padding import androidx.compose.animation.animateContentSize
import androidx.compose.material3.Scaffold import androidx.compose.foundation.Image
import androidx.compose.material3.Text import androidx.compose.foundation.background
import androidx.compose.runtime.Composable import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview 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 id.ac.ubharajaya.sistemakademik.ui.theme.SistemAkademikTheme 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() { class MainActivity : ComponentActivity() {
private lateinit var viewModel: AbsensiViewModel
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
enableEdgeToEdge() enableEdgeToEdge()
viewModel = AbsensiViewModel(AbsensiRepository(), this)
setContent { setContent {
SistemAkademikTheme { SistemAkademikTheme {
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> Scaffold(
Greeting( topBar = { TopBarLogout(this) }
name = "Android", ) { padding ->
modifier = Modifier.padding(innerPadding) AbsensiScreen(
modifier = Modifier.padding(padding),
viewModel = viewModel
) )
} }
} }
@ -30,18 +207,174 @@ class MainActivity : ComponentActivity() {
} }
} }
/* ================== TOP BAR ================== */
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun Greeting(name: String, modifier: Modifier = Modifier) { fun TopBarLogout(activity: ComponentActivity) {
Text( val context = LocalContext.current
text = "Hello $name!", TopAppBar(
modifier = modifier title = {
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
)
}
}
) )
} }
@Preview(showBackground = true) /* ================== UI ================== */
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun GreetingPreview() { fun AbsensiScreen(modifier: Modifier, viewModel: AbsensiViewModel) {
SistemAkademikTheme {
Greeting("Android") 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(
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))
)
}
}
}
}
}
} }
} }

View File

@ -0,0 +1,62 @@
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.

After

Width:  |  Height:  |  Size: 31 KiB

BIN
app/src/main/res/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

8
local.properties Normal file
View File

@ -0,0 +1,8 @@
## 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

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