ADDING SCREENS SETIAP FITUR DAN MATKUL SAAT ABSENSI
This commit is contained in:
parent
926d3e0a14
commit
ce05e1a617
13
.idea/deviceManager.xml
generated
Normal file
13
.idea/deviceManager.xml
generated
Normal 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>
|
||||
8
.idea/markdown.xml
generated
Normal file
8
.idea/markdown.xml
generated
Normal 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>
|
||||
@ -8,6 +8,8 @@ Aplikasi ini dirancang untuk meningkatkan **validitas kehadiran mahasiswa**, den
|
||||
2. Melakukan **pengambilan foto (selfie) secara langsung saat absensi**
|
||||
|
||||
---
|
||||
Nama : HAGA DALPINTO GINTING
|
||||
NPM : 202310715176
|
||||
|
||||
## 🎯 Tujuan Proyek
|
||||
- Mengimplementasikan **Location-Based Service (LBS)** pada aplikasi mobile
|
||||
|
||||
@ -1,23 +1,38 @@
|
||||
/**
|
||||
* FILE: build.gradle.kts (Module :app)
|
||||
* LOKASI: app/build.gradle.kts
|
||||
*
|
||||
* CARA IMPLEMENTASI:
|
||||
* 1. Buka file app/build.gradle.kts di Android Studio
|
||||
* 2. REPLACE semua isinya dengan kode ini
|
||||
* 3. Klik "Sync Now" di pojok kanan atas
|
||||
* 4. Tunggu sampai sync selesai
|
||||
*/
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.kotlin.android)
|
||||
alias(libs.plugins.kotlin.compose)
|
||||
id("kotlin-kapt")
|
||||
id("kotlin-parcelize")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "id.ac.ubharajaya.sistemakademik"
|
||||
compileSdk {
|
||||
version = release(36)
|
||||
}
|
||||
compileSdk = 35
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "id.ac.ubharajaya.sistemakademik"
|
||||
minSdk = 28
|
||||
targetSdk = 36
|
||||
minSdk = 26
|
||||
targetSdk = 35
|
||||
versionCode = 1
|
||||
versionName = "1.0"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
|
||||
vectorDrawables {
|
||||
useSupportLibrary = true
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
@ -29,36 +44,114 @@ android {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_11
|
||||
targetCompatibility = JavaVersion.VERSION_11
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = "11"
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
}
|
||||
|
||||
composeOptions {
|
||||
kotlinCompilerExtensionVersion = "1.5.8"
|
||||
}
|
||||
|
||||
packaging {
|
||||
resources {
|
||||
excludes += "/META-INF/{AL2.0,LGPL2.1}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(libs.androidx.core.ktx)
|
||||
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||
implementation(libs.androidx.activity.compose)
|
||||
implementation("androidx.activity:activity-compose:1.9.0")
|
||||
implementation(platform(libs.androidx.compose.bom))
|
||||
implementation(libs.androidx.compose.ui)
|
||||
implementation(libs.androidx.compose.ui.graphics)
|
||||
implementation(libs.androidx.compose.ui.tooling.preview)
|
||||
implementation(libs.androidx.compose.material3)
|
||||
// Location (GPS)
|
||||
implementation("com.google.android.gms:play-services-location:21.0.1")
|
||||
|
||||
testImplementation(libs.junit)
|
||||
androidTestImplementation(libs.androidx.junit)
|
||||
androidTestImplementation(libs.androidx.espresso.core)
|
||||
androidTestImplementation(platform(libs.androidx.compose.bom))
|
||||
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
|
||||
debugImplementation(libs.androidx.compose.ui.tooling)
|
||||
debugImplementation(libs.androidx.compose.ui.test.manifest)
|
||||
// ==================== CORE ANDROID ====================
|
||||
implementation("androidx.core:core-ktx:1.12.0")
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0")
|
||||
implementation("androidx.activity:activity-compose:1.8.2")
|
||||
|
||||
// ==================== COMPOSE ====================
|
||||
val composeBom = "2024.02.00"
|
||||
implementation(platform("androidx.compose:compose-bom:$composeBom"))
|
||||
implementation("androidx.compose.ui:ui")
|
||||
implementation("androidx.compose.ui:ui-graphics")
|
||||
implementation("androidx.compose.ui:ui-tooling-preview")
|
||||
implementation("androidx.compose.material3:material3")
|
||||
|
||||
// Compose Lifecycle
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.7.0")
|
||||
|
||||
// ==================== NAVIGATION ====================
|
||||
val navVersion = "2.7.6"
|
||||
implementation("androidx.navigation:navigation-compose:$navVersion")
|
||||
|
||||
// ==================== LOCATION SERVICES ====================
|
||||
implementation("com.google.android.gms:play-services-location:21.1.0")
|
||||
|
||||
// ==================== ROOM DATABASE ====================
|
||||
val roomVersion = "2.6.1"
|
||||
implementation("androidx.room:room-runtime:$roomVersion")
|
||||
implementation("androidx.room:room-ktx:$roomVersion")
|
||||
kapt("androidx.room:room-compiler:$roomVersion")
|
||||
|
||||
// ==================== DATASTORE PREFERENCES ====================
|
||||
implementation("androidx.datastore:datastore-preferences:1.0.0")
|
||||
|
||||
// ==================== IMAGE LOADING (COIL) ====================
|
||||
implementation("io.coil-kt:coil-compose:2.5.0")
|
||||
|
||||
// ==================== COROUTINES ====================
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.7.3")
|
||||
|
||||
// ==================== MATERIAL ICONS EXTENDED ====================
|
||||
implementation("androidx.compose.material:material-icons-extended:1.5.4")
|
||||
|
||||
// ==================== JSON ====================
|
||||
implementation("org.json:json:20231013")
|
||||
implementation("com.google.code.gson:gson:2.10.1")
|
||||
|
||||
// ==================== TESTING ====================
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
androidTestImplementation("androidx.test.ext:junit:1.1.5")
|
||||
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
|
||||
androidTestImplementation(platform("androidx.compose:compose-bom:$composeBom"))
|
||||
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
|
||||
|
||||
// Debug
|
||||
debugImplementation("androidx.compose.ui:ui-tooling")
|
||||
debugImplementation("androidx.compose.ui:ui-test-manifest")
|
||||
}
|
||||
|
||||
/**
|
||||
* PENJELASAN DEPENDENCIES:
|
||||
*
|
||||
* 1. NAVIGATION COMPOSE
|
||||
* - Untuk navigasi antar screen
|
||||
*
|
||||
* 2. ROOM DATABASE
|
||||
* - Database lokal untuk menyimpan riwayat absensi
|
||||
* - PENTING: Butuh plugin kapt
|
||||
*
|
||||
* 3. DATASTORE PREFERENCES
|
||||
* - Untuk menyimpan data login user
|
||||
* - Pengganti SharedPreferences yang lebih modern
|
||||
*
|
||||
* 4. GOOGLE LOCATION SERVICES
|
||||
* - Untuk mendapatkan koordinat GPS
|
||||
*
|
||||
* 5. COIL
|
||||
* - Untuk loading dan display image
|
||||
*
|
||||
* 6. COROUTINES
|
||||
* - Untuk async operations (database, network)
|
||||
*
|
||||
* 7. MATERIAL ICONS EXTENDED
|
||||
* - Icon-icon tambahan untuk UI
|
||||
*/
|
||||
@ -1,14 +1,52 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
FILE: AndroidManifest.xml
|
||||
LOKASI: app/src/main/AndroidManifest.xml
|
||||
|
||||
CARA IMPLEMENTASI:
|
||||
1. Buka file AndroidManifest.xml di Android Studio
|
||||
2. REPLACE semua isinya dengan kode ini
|
||||
3. Save file
|
||||
-->
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<!-- ==================== PERMISSIONS ==================== -->
|
||||
|
||||
<!-- Permission untuk akses lokasi GPS -->
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||
|
||||
<!-- Permission untuk akses kamera -->
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
|
||||
<!-- Permission untuk akses internet (kirim data ke webhook) -->
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
|
||||
<!-- Permission untuk akses network state -->
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
|
||||
<!-- Optional: Untuk menyimpan foto (jika dibutuhkan) -->
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||
android:maxSdkVersion="28"
|
||||
tools:ignore="ScopedStorage" />
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
|
||||
android:maxSdkVersion="32" />
|
||||
|
||||
<!-- Feature camera (optional, tapi bagus untuk filter di Play Store) -->
|
||||
<uses-feature
|
||||
android:name="android.hardware.camera"
|
||||
android:required="false" />
|
||||
<uses-feature
|
||||
android:name="android.hardware.camera.autofocus"
|
||||
android:required="false" />
|
||||
|
||||
<!-- Feature location -->
|
||||
<uses-feature
|
||||
android:name="android.hardware.location.gps"
|
||||
android:required="true" />
|
||||
|
||||
<!-- ==================== APPLICATION ==================== -->
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
@ -18,18 +56,63 @@
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.SistemAkademik">
|
||||
android:theme="@style/Theme.SistemAkademik"
|
||||
android:usesCleartextTraffic="true"
|
||||
tools:targetApi="31">
|
||||
|
||||
<!-- Main Activity -->
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:label="@string/app_name"
|
||||
android:theme="@style/Theme.SistemAkademik">
|
||||
android:theme="@style/Theme.SistemAkademik"
|
||||
android:screenOrientation="portrait"
|
||||
android:configChanges="orientation|screenSize">
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- Google Play Services Location -->
|
||||
<meta-data
|
||||
android:name="com.google.android.gms.version"
|
||||
android:value="@integer/google_play_services_version" />
|
||||
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
|
||||
<!--
|
||||
PENJELASAN PERMISSIONS:
|
||||
|
||||
1. ACCESS_FINE_LOCATION & ACCESS_COARSE_LOCATION
|
||||
- Untuk mendapatkan koordinat GPS
|
||||
- FINE = Akurasi tinggi (GPS)
|
||||
- COARSE = Akurasi rendah (Network)
|
||||
|
||||
2. CAMERA
|
||||
- Untuk mengambil foto selfie
|
||||
|
||||
3. INTERNET & ACCESS_NETWORK_STATE
|
||||
- Untuk kirim data ke webhook
|
||||
- Cek status koneksi
|
||||
|
||||
4. WRITE/READ_EXTERNAL_STORAGE
|
||||
- Optional, untuk simpan foto ke storage
|
||||
- Hanya untuk Android < 10
|
||||
|
||||
5. usesCleartextTraffic="true"
|
||||
- Mengizinkan HTTP (bukan HTTPS)
|
||||
- Diperlukan jika webhook menggunakan HTTP
|
||||
- Untuk production, sebaiknya gunakan HTTPS
|
||||
|
||||
6. screenOrientation="portrait"
|
||||
- Mengunci orientasi portrait
|
||||
- Bisa dihapus jika mau support landscape
|
||||
|
||||
CATATAN PENTING:
|
||||
- Semua permission di atas sudah di-request secara runtime di code
|
||||
- User harus approve permission saat pertama kali buka aplikasi
|
||||
- Jika permission ditolak, fitur terkait tidak akan berfungsi
|
||||
-->
|
||||
@ -1,274 +1,207 @@
|
||||
package id.ac.ubharajaya.sistemakademik
|
||||
|
||||
import android.Manifest
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.graphics.Bitmap
|
||||
import android.os.Bundle
|
||||
import android.provider.MediaStore
|
||||
import android.util.Base64
|
||||
import android.widget.Toast
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.google.android.gms.location.LocationServices
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.navigation.NavType
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import androidx.navigation.navArgument
|
||||
import com.google.gson.Gson
|
||||
import id.ac.ubharajaya.sistemakademik.data.MataKuliah
|
||||
import id.ac.ubharajaya.sistemakademik.screens.*
|
||||
import id.ac.ubharajaya.sistemakademik.ui.theme.SistemAkademikTheme
|
||||
import org.json.JSONObject
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import kotlin.concurrent.thread
|
||||
import java.net.URLDecoder
|
||||
import java.net.URLEncoder
|
||||
import java.nio.charset.StandardCharsets
|
||||
|
||||
/* ================= UTIL ================= */
|
||||
|
||||
fun bitmapToBase64(bitmap: Bitmap): String {
|
||||
val outputStream = ByteArrayOutputStream()
|
||||
bitmap.compress(Bitmap.CompressFormat.JPEG, 80, outputStream)
|
||||
return Base64.encodeToString(outputStream.toByteArray(), Base64.NO_WRAP)
|
||||
}
|
||||
|
||||
fun kirimKeN8n(
|
||||
context: ComponentActivity,
|
||||
latitude: Double,
|
||||
longitude: Double,
|
||||
foto: Bitmap
|
||||
) {
|
||||
thread {
|
||||
try {
|
||||
val url = URL("https://n8n.lab.ubharajaya.ac.id/webhook/23c6993d-1792-48fb-ad1c-ffc78a3e6254")
|
||||
// test URL val url = URL("https://n8n.lab.ubharajaya.ac.id/webhook-test/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", "12345")
|
||||
put("nama","Arif R D")
|
||||
put("latitude", latitude)
|
||||
put("longitude", longitude)
|
||||
put("timestamp", System.currentTimeMillis())
|
||||
put("foto_base64", bitmapToBase64(foto))
|
||||
}
|
||||
|
||||
conn.outputStream.use {
|
||||
it.write(json.toString().toByteArray())
|
||||
}
|
||||
|
||||
val responseCode = conn.responseCode
|
||||
|
||||
context.runOnUiThread {
|
||||
Toast.makeText(
|
||||
context,
|
||||
if (responseCode == 200)
|
||||
"Absensi diterima server"
|
||||
else
|
||||
"Absensi ditolak server",
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
|
||||
conn.disconnect()
|
||||
|
||||
} catch (_: Exception) {
|
||||
context.runOnUiThread {
|
||||
Toast.makeText(
|
||||
context,
|
||||
"Gagal kirim ke server",
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ================= ACTIVITY ================= */
|
||||
/**
|
||||
* FILE: MainActivity.kt (UPDATED dengan SelectMatakuliah)
|
||||
*
|
||||
* Deskripsi:
|
||||
* Main Activity dengan Navigation Compose
|
||||
* FITUR BARU: Pilih mata kuliah sebelum absen
|
||||
*
|
||||
* Flow Baru:
|
||||
* Dashboard → SelectMatakuliah → Absensi → Success
|
||||
*/
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
|
||||
setContent {
|
||||
SistemAkademikTheme {
|
||||
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
|
||||
AbsensiScreen(
|
||||
modifier = Modifier.padding(innerPadding),
|
||||
activity = this
|
||||
)
|
||||
}
|
||||
AppNavigation()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ================= UI ================= */
|
||||
/**
|
||||
* Navigation Routes
|
||||
*/
|
||||
object Routes {
|
||||
const val LOGIN = "login"
|
||||
const val DASHBOARD = "dashboard"
|
||||
const val SELECT_MATAKULIAH = "select_matakuliah"
|
||||
const val ABSENSI = "absensi/{matakuliahJson}"
|
||||
const val SUCCESS = "success"
|
||||
const val HISTORY = "history"
|
||||
const val SCHEDULE = "schedule"
|
||||
|
||||
fun createAbsensiRoute(mataKuliah: MataKuliah): String {
|
||||
val json = Gson().toJson(mataKuliah)
|
||||
val encodedJson = URLEncoder.encode(json, StandardCharsets.UTF_8.toString())
|
||||
return "absensi/$encodedJson"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* App Navigation
|
||||
*/
|
||||
@Composable
|
||||
fun AbsensiScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
activity: ComponentActivity
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
fun AppNavigation() {
|
||||
val navController = rememberNavController()
|
||||
val gson = Gson()
|
||||
|
||||
var lokasi by remember { mutableStateOf("Koordinat: -") }
|
||||
var latitude by remember { mutableStateOf<Double?>(null) }
|
||||
var longitude by remember { mutableStateOf<Double?>(null) }
|
||||
var foto by remember { mutableStateOf<Bitmap?>(null) }
|
||||
|
||||
val fusedLocationClient =
|
||||
LocationServices.getFusedLocationProviderClient(context)
|
||||
|
||||
/* ===== Permission Lokasi ===== */
|
||||
|
||||
val locationPermissionLauncher =
|
||||
rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.RequestPermission()
|
||||
) { granted ->
|
||||
if (granted) {
|
||||
|
||||
if (
|
||||
ContextCompat.checkSelfPermission(
|
||||
context,
|
||||
Manifest.permission.ACCESS_FINE_LOCATION
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = Routes.LOGIN
|
||||
) {
|
||||
|
||||
fusedLocationClient.lastLocation
|
||||
.addOnSuccessListener { location ->
|
||||
if (location != null) {
|
||||
latitude = location.latitude
|
||||
longitude = location.longitude
|
||||
lokasi =
|
||||
"Lat: ${location.latitude}\nLon: ${location.longitude}"
|
||||
} else {
|
||||
lokasi = "Lokasi tidak tersedia"
|
||||
// Login Screen
|
||||
composable(Routes.LOGIN) {
|
||||
LoginScreen(
|
||||
onLoginSuccess = {
|
||||
navController.navigate(Routes.DASHBOARD) {
|
||||
popUpTo(Routes.LOGIN) { inclusive = true }
|
||||
}
|
||||
}
|
||||
.addOnFailureListener {
|
||||
lokasi = "Gagal mengambil lokasi"
|
||||
}
|
||||
}
|
||||
|
||||
} else {
|
||||
Toast.makeText(
|
||||
context,
|
||||
"Izin lokasi ditolak",
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== Kamera ===== */
|
||||
|
||||
val cameraLauncher =
|
||||
rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.StartActivityForResult()
|
||||
) { result ->
|
||||
if (result.resultCode == Activity.RESULT_OK) {
|
||||
val bitmap =
|
||||
result.data?.extras?.getParcelable("data", Bitmap::class.java)
|
||||
if (bitmap != null) {
|
||||
foto = bitmap
|
||||
Toast.makeText(
|
||||
context,
|
||||
"Foto berhasil diambil",
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val cameraPermissionLauncher =
|
||||
rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.RequestPermission()
|
||||
) { granted ->
|
||||
if (granted) {
|
||||
val intent =
|
||||
Intent(MediaStore.ACTION_IMAGE_CAPTURE)
|
||||
cameraLauncher.launch(intent)
|
||||
} else {
|
||||
Toast.makeText(
|
||||
context,
|
||||
"Izin kamera ditolak",
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== Request Awal ===== */
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
locationPermissionLauncher.launch(
|
||||
Manifest.permission.ACCESS_FINE_LOCATION
|
||||
)
|
||||
}
|
||||
|
||||
/* ===== UI ===== */
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.padding(24.dp),
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
|
||||
Text(
|
||||
text = "Absensi Akademik",
|
||||
style = MaterialTheme.typography.titleLarge
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Text(text = lokasi)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
cameraPermissionLauncher.launch(
|
||||
Manifest.permission.CAMERA
|
||||
)
|
||||
// Dashboard Screen
|
||||
composable(Routes.DASHBOARD) {
|
||||
DashboardScreen(
|
||||
onNavigateToAbsensi = {
|
||||
// Navigasi ke SelectMatakuliah dulu (BARU!)
|
||||
navController.navigate(Routes.SELECT_MATAKULIAH)
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text("Ambil Foto")
|
||||
onNavigateToHistory = {
|
||||
navController.navigate(Routes.HISTORY)
|
||||
},
|
||||
onNavigateToSchedule = {
|
||||
navController.navigate(Routes.SCHEDULE)
|
||||
},
|
||||
onLogout = {
|
||||
navController.navigate(Routes.LOGIN) {
|
||||
popUpTo(0) { inclusive = true }
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
if (latitude != null && longitude != null && foto != null) {
|
||||
kirimKeN8n(
|
||||
activity,
|
||||
latitude!!,
|
||||
longitude!!,
|
||||
foto!!
|
||||
// Select Mata Kuliah Screen (BARU!)
|
||||
composable(Routes.SELECT_MATAKULIAH) {
|
||||
SelectMatakuliahScreen(
|
||||
onNavigateBack = {
|
||||
navController.popBackStack()
|
||||
},
|
||||
onMatakuliahSelected = { mataKuliah ->
|
||||
// Navigasi ke Absensi dengan data MK
|
||||
val route = Routes.createAbsensiRoute(mataKuliah)
|
||||
navController.navigate(route)
|
||||
}
|
||||
)
|
||||
} else {
|
||||
Toast.makeText(
|
||||
context,
|
||||
"Lokasi atau foto belum lengkap",
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
|
||||
// Absensi Screen (UPDATED - terima parameter MK)
|
||||
composable(
|
||||
route = Routes.ABSENSI,
|
||||
arguments = listOf(
|
||||
navArgument("matakuliahJson") {
|
||||
type = NavType.StringType
|
||||
}
|
||||
)
|
||||
) { backStackEntry ->
|
||||
val matakuliahJson = backStackEntry.arguments?.getString("matakuliahJson")
|
||||
val decodedJson = URLDecoder.decode(matakuliahJson, StandardCharsets.UTF_8.toString())
|
||||
val mataKuliah = gson.fromJson(decodedJson, MataKuliah::class.java)
|
||||
|
||||
AbsensiScreen(
|
||||
mataKuliah = mataKuliah,
|
||||
onNavigateToSuccess = {
|
||||
navController.navigate(Routes.SUCCESS) {
|
||||
popUpTo(Routes.DASHBOARD)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text("Kirim Absensi")
|
||||
onNavigateBack = {
|
||||
navController.popBackStack()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Success Screen
|
||||
composable(Routes.SUCCESS) {
|
||||
SuccessScreen(
|
||||
onNavigateToDashboard = {
|
||||
navController.navigate(Routes.DASHBOARD) {
|
||||
popUpTo(Routes.DASHBOARD) { inclusive = true }
|
||||
}
|
||||
},
|
||||
onNavigateToHistory = {
|
||||
navController.navigate(Routes.HISTORY) {
|
||||
popUpTo(Routes.DASHBOARD)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// History Screen
|
||||
composable(Routes.HISTORY) {
|
||||
HistoryScreen(
|
||||
onNavigateBack = {
|
||||
navController.popBackStack()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Schedule Screen
|
||||
composable(Routes.SCHEDULE) {
|
||||
ScheduleScreen(
|
||||
onNavigateBack = {
|
||||
navController.popBackStack()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PENJELASAN FLOW BARU:
|
||||
*
|
||||
* 1. LOGIN → DASHBOARD
|
||||
* - Sama seperti sebelumnya
|
||||
*
|
||||
* 2. DASHBOARD → SELECT_MATAKULIAH → ABSENSI → SUCCESS
|
||||
* - User klik "Mulai Absensi"
|
||||
* - Masuk ke screen pilih mata kuliah (filter hari ini)
|
||||
* - User pilih MK
|
||||
* - Data MK di-pass ke AbsensiScreen via navigation argument (JSON)
|
||||
* - Absensi berhasil → Success screen
|
||||
*
|
||||
* 3. Passing MataKuliah Object:
|
||||
* - Convert MataKuliah → JSON → URL Encode
|
||||
* - Pass via navigation argument
|
||||
* - Di destination: URL Decode → JSON → MataKuliah object
|
||||
*
|
||||
* 4. Database Update:
|
||||
* - AbsensiEntity sekarang punya field mata kuliah
|
||||
* - History menampilkan nama mata kuliah
|
||||
* - Filter MK yang sudah diabsen hari ini
|
||||
*/
|
||||
@ -0,0 +1,93 @@
|
||||
package id.ac.ubharajaya.sistemakademik.data
|
||||
|
||||
import androidx.room.*
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
/**
|
||||
* FILE: AbsensiDao.kt
|
||||
* LOKASI: data/AbsensiDao.kt
|
||||
*
|
||||
* Deskripsi:
|
||||
* Data Access Object (DAO) untuk operasi database absensi
|
||||
* Berisi query-query untuk insert, update, delete, dan get data
|
||||
*/
|
||||
|
||||
@Dao
|
||||
interface AbsensiDao {
|
||||
|
||||
/**
|
||||
* Insert data absensi baru ke database
|
||||
* @return ID dari record yang baru diinsert
|
||||
*/
|
||||
@Insert
|
||||
suspend fun insert(absensi: AbsensiEntity): Long
|
||||
|
||||
/**
|
||||
* Update data absensi yang sudah ada
|
||||
*/
|
||||
@Update
|
||||
suspend fun update(absensi: AbsensiEntity)
|
||||
|
||||
/**
|
||||
* Delete data absensi
|
||||
*/
|
||||
@Delete
|
||||
suspend fun delete(absensi: AbsensiEntity)
|
||||
|
||||
/**
|
||||
* Get semua data absensi, diurutkan dari yang terbaru
|
||||
* Menggunakan Flow agar otomatis update UI ketika ada perubahan data
|
||||
*/
|
||||
@Query("SELECT * FROM absensi ORDER BY timestamp DESC")
|
||||
fun getAllAbsensi(): Flow<List<AbsensiEntity>>
|
||||
|
||||
/**
|
||||
* Get data absensi berdasarkan NPM
|
||||
*/
|
||||
@Query("SELECT * FROM absensi WHERE npm = :npm ORDER BY timestamp DESC")
|
||||
fun getAbsensiByNpm(npm: String): Flow<List<AbsensiEntity>>
|
||||
|
||||
/**
|
||||
* Get data absensi berdasarkan ID
|
||||
*/
|
||||
@Query("SELECT * FROM absensi WHERE id = :id")
|
||||
suspend fun getAbsensiById(id: Int): AbsensiEntity?
|
||||
|
||||
/**
|
||||
* Get jumlah total absensi
|
||||
*/
|
||||
@Query("SELECT COUNT(*) FROM absensi")
|
||||
suspend fun getTotalAbsensi(): Int
|
||||
|
||||
/**
|
||||
* Get jumlah absensi hari ini
|
||||
* @param tanggal Format: "14 Jan 2026"
|
||||
*/
|
||||
@Query("SELECT COUNT(*) FROM absensi WHERE tanggal = :tanggal")
|
||||
suspend fun getAbsensiHariIniCount(tanggal: String): Int
|
||||
|
||||
/**
|
||||
* Get list absensi hari ini (BARU untuk filter MK sudah absen)
|
||||
* @param tanggal Format: "14 Jan 2026"
|
||||
*/
|
||||
@Query("SELECT * FROM absensi WHERE tanggal = :tanggal ORDER BY timestamp DESC")
|
||||
fun getAbsensiHariIni(tanggal: String): Flow<List<AbsensiEntity>>
|
||||
|
||||
/**
|
||||
* Get absensi yang belum terkirim ke server
|
||||
*/
|
||||
@Query("SELECT * FROM absensi WHERE statusKirim = 0 ORDER BY timestamp ASC")
|
||||
suspend fun getAbsensiPending(): List<AbsensiEntity>
|
||||
|
||||
/**
|
||||
* Update status kirim absensi
|
||||
*/
|
||||
@Query("UPDATE absensi SET statusKirim = :status WHERE id = :id")
|
||||
suspend fun updateStatusKirim(id: Int, status: Boolean)
|
||||
|
||||
/**
|
||||
* Delete semua data absensi (untuk testing/reset)
|
||||
*/
|
||||
@Query("DELETE FROM absensi")
|
||||
suspend fun deleteAll()
|
||||
}
|
||||
@ -0,0 +1,49 @@
|
||||
package id.ac.ubharajaya.sistemakademik.data
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
/**
|
||||
* FILE: AbsensiEntity.kt
|
||||
* LOKASI: data/AbsensiEntity.kt
|
||||
*
|
||||
* Deskripsi:
|
||||
* Entity class untuk tabel absensi di Room Database
|
||||
* Setiap object AbsensiEntity merepresentasikan 1 record absensi
|
||||
*/
|
||||
|
||||
@Entity(tableName = "absensi")
|
||||
data class AbsensiEntity(
|
||||
@PrimaryKey(autoGenerate = true)
|
||||
val id: Int = 0,
|
||||
|
||||
// Data Mahasiswa
|
||||
val npm: String,
|
||||
val nama: String,
|
||||
|
||||
// Data Mata Kuliah (BARU!)
|
||||
val kodeMatakuliah: String, // Contoh: "INFO-3527"
|
||||
val namaMatakuliah: String, // Contoh: "Pemrograman Mobile"
|
||||
val dosenMatakuliah: String, // Contoh: "Dr. Ahmad Wijaya"
|
||||
val ruanganMatakuliah: String, // Contoh: "Lab Komputer 1"
|
||||
val waktuMatakuliah: String, // Contoh: "08:00 - 10:00"
|
||||
|
||||
// Data Lokasi
|
||||
val latitude: Double,
|
||||
val longitude: Double,
|
||||
val jarak: Double, // Jarak dari kampus dalam meter
|
||||
val lokasiValid: Boolean, // Apakah lokasi dalam radius
|
||||
|
||||
// Data Waktu
|
||||
val timestamp: Long, // Timestamp dalam milliseconds
|
||||
val tanggal: String, // Format: "14 Jan 2026"
|
||||
val waktu: String, // Format: "09:15 WIB"
|
||||
val hari: String, // Format: "Rabu"
|
||||
|
||||
// Data Foto (Base64)
|
||||
val fotoBase64: String,
|
||||
|
||||
// Status
|
||||
val statusKirim: Boolean = false, // Apakah sudah terkirim ke webhook
|
||||
val pesanError: String? = null // Pesan error jika gagal kirim
|
||||
)
|
||||
@ -0,0 +1,80 @@
|
||||
package id.ac.ubharajaya.sistemakademik.data
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room.Database
|
||||
import androidx.room.Room
|
||||
import androidx.room.RoomDatabase
|
||||
|
||||
/**
|
||||
* FILE: AppDatabase.kt
|
||||
* LOKASI: data/AppDatabase.kt
|
||||
*
|
||||
* Deskripsi:
|
||||
* Konfigurasi Room Database untuk aplikasi
|
||||
* Menggunakan Singleton pattern agar hanya ada 1 instance database
|
||||
*/
|
||||
|
||||
@Database(
|
||||
entities = [AbsensiEntity::class],
|
||||
version = 1,
|
||||
exportSchema = false
|
||||
)
|
||||
abstract class AppDatabase : RoomDatabase() {
|
||||
|
||||
// Abstract function untuk mendapatkan DAO
|
||||
abstract fun absensiDao(): AbsensiDao
|
||||
|
||||
companion object {
|
||||
// Singleton instance
|
||||
@Volatile
|
||||
private var INSTANCE: AppDatabase? = null
|
||||
|
||||
/**
|
||||
* Get atau create database instance
|
||||
* Menggunakan synchronized untuk thread-safety
|
||||
*
|
||||
* @param context Application context
|
||||
* @return Database instance
|
||||
*/
|
||||
fun getDatabase(context: Context): AppDatabase {
|
||||
// Jika instance sudah ada, return instance tersebut
|
||||
return INSTANCE ?: synchronized(this) {
|
||||
// Double-check locking untuk memastikan hanya 1 instance
|
||||
val instance = Room.databaseBuilder(
|
||||
context.applicationContext,
|
||||
AppDatabase::class.java,
|
||||
"sistem_akademik_database" // ✅ DIPERBAIKI: Gunakan string langsung
|
||||
)
|
||||
// Fallback strategy jika ada perubahan schema
|
||||
.fallbackToDestructiveMigration()
|
||||
.build()
|
||||
|
||||
INSTANCE = instance
|
||||
instance
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy database instance (untuk testing)
|
||||
*/
|
||||
fun destroyInstance() {
|
||||
INSTANCE = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* CARA PAKAI:
|
||||
*
|
||||
* // Di Activity/Screen:
|
||||
* val database = AppDatabase.getDatabase(context)
|
||||
* val dao = database.absensiDao()
|
||||
*
|
||||
* // Insert data:
|
||||
* lifecycleScope.launch {
|
||||
* val id = dao.insert(absensiEntity)
|
||||
* }
|
||||
*
|
||||
* // Get data:
|
||||
* val allAbsensi = dao.getAllAbsensi().collectAsState(initial = emptyList())
|
||||
*/
|
||||
@ -0,0 +1,30 @@
|
||||
package id.ac.ubharajaya.sistemakademik.data
|
||||
|
||||
import android.os.Parcelable
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
/**
|
||||
* FILE: MataKuliah.kt
|
||||
* LOKASI: data/MataKuliah.kt
|
||||
*
|
||||
* Deskripsi:
|
||||
* Data class untuk Mata Kuliah
|
||||
* Digunakan untuk passing data antar screen
|
||||
*/
|
||||
|
||||
@Parcelize
|
||||
data class MataKuliah(
|
||||
val kode: String, // Kode MK: "INFO-3527"
|
||||
val nama: String, // Nama MK: "Pemrograman Mobile"
|
||||
val dosen: String, // Nama Dosen: "Dr. Ahmad Wijaya"
|
||||
val ruangan: String, // Ruangan: "Lab Komputer 1"
|
||||
val waktu: String, // Waktu: "08:00 - 10:00"
|
||||
val hari: String, // Hari: "Senin"
|
||||
val sks: Int = 3 // SKS (default 3)
|
||||
) : Parcelable
|
||||
|
||||
/**
|
||||
* CATATAN:
|
||||
* - @Parcelize digunakan agar bisa di-pass lewat Navigation argument
|
||||
* - Parcelable lebih efisien daripada Serializable untuk Android
|
||||
*/
|
||||
@ -0,0 +1,115 @@
|
||||
package id.ac.ubharajaya.sistemakademik.data
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.*
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import id.ac.ubharajaya.sistemakademik.utils.Constants
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
/**
|
||||
* FILE: UserPreferences.kt
|
||||
* LOKASI: data/UserPreferences.kt
|
||||
*
|
||||
* Deskripsi:
|
||||
* Class untuk menyimpan dan mengambil data user (login state)
|
||||
* Menggunakan DataStore Preferences (pengganti SharedPreferences yang lebih modern)
|
||||
*/
|
||||
|
||||
// Extension property untuk DataStore
|
||||
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "user_preferences")
|
||||
|
||||
class UserPreferences(private val context: Context) {
|
||||
|
||||
// Keys untuk menyimpan data
|
||||
private object PreferenceKeys {
|
||||
val NPM = stringPreferencesKey(Constants.PREF_NPM)
|
||||
val NAMA = stringPreferencesKey(Constants.PREF_NAMA)
|
||||
val IS_LOGGED_IN = booleanPreferencesKey(Constants.PREF_IS_LOGGED_IN)
|
||||
}
|
||||
|
||||
/**
|
||||
* Simpan data login user
|
||||
*/
|
||||
suspend fun saveUserData(npm: String, nama: String) {
|
||||
context.dataStore.edit { preferences ->
|
||||
preferences[PreferenceKeys.NPM] = npm
|
||||
preferences[PreferenceKeys.NAMA] = nama
|
||||
preferences[PreferenceKeys.IS_LOGGED_IN] = true
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get NPM user
|
||||
*/
|
||||
val npm: Flow<String> = context.dataStore.data
|
||||
.map { preferences ->
|
||||
preferences[PreferenceKeys.NPM] ?: ""
|
||||
}
|
||||
|
||||
/**
|
||||
* Get nama user
|
||||
*/
|
||||
val nama: Flow<String> = context.dataStore.data
|
||||
.map { preferences ->
|
||||
preferences[PreferenceKeys.NAMA] ?: ""
|
||||
}
|
||||
|
||||
/**
|
||||
* Get status login
|
||||
*/
|
||||
val isLoggedIn: Flow<Boolean> = context.dataStore.data
|
||||
.map { preferences ->
|
||||
preferences[PreferenceKeys.IS_LOGGED_IN] ?: false
|
||||
}
|
||||
|
||||
/**
|
||||
* Logout user (hapus semua data)
|
||||
*/
|
||||
suspend fun logout() {
|
||||
context.dataStore.edit { preferences ->
|
||||
preferences.clear()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get semua data user sekaligus
|
||||
*/
|
||||
data class UserData(
|
||||
val npm: String,
|
||||
val nama: String,
|
||||
val isLoggedIn: Boolean
|
||||
)
|
||||
|
||||
val userData: Flow<UserData> = context.dataStore.data
|
||||
.map { preferences ->
|
||||
UserData(
|
||||
npm = preferences[PreferenceKeys.NPM] ?: "",
|
||||
nama = preferences[PreferenceKeys.NAMA] ?: "",
|
||||
isLoggedIn = preferences[PreferenceKeys.IS_LOGGED_IN] ?: false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* CARA PAKAI:
|
||||
*
|
||||
* // Di Activity/Screen:
|
||||
* val userPreferences = UserPreferences(context)
|
||||
*
|
||||
* // Simpan data login:
|
||||
* lifecycleScope.launch {
|
||||
* userPreferences.saveUserData("202310715176", "HAGA DALPINTO GINTING")
|
||||
* }
|
||||
*
|
||||
* // Baca data:
|
||||
* val userData by userPreferences.userData.collectAsState(
|
||||
* initial = UserPreferences.UserData("", "", false)
|
||||
* )
|
||||
*
|
||||
* // Logout:
|
||||
* lifecycleScope.launch {
|
||||
* userPreferences.logout()
|
||||
* }
|
||||
*/
|
||||
@ -0,0 +1,687 @@
|
||||
package id.ac.ubharajaya.sistemakademik.screens
|
||||
|
||||
import android.Manifest
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.graphics.Bitmap
|
||||
import android.provider.MediaStore
|
||||
import android.util.Base64
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
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.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.google.android.gms.location.LocationServices
|
||||
import id.ac.ubharajaya.sistemakademik.data.AbsensiEntity
|
||||
import id.ac.ubharajaya.sistemakademik.data.AppDatabase
|
||||
import id.ac.ubharajaya.sistemakademik.data.UserPreferences
|
||||
import id.ac.ubharajaya.sistemakademik.utils.Constants
|
||||
import id.ac.ubharajaya.sistemakademik.utils.LocationValidator
|
||||
import kotlinx.coroutines.launch
|
||||
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
|
||||
|
||||
/**
|
||||
* FILE: AbsensiScreen.kt
|
||||
* LOKASI: screens/AbsensiScreen.kt
|
||||
*
|
||||
* Deskripsi:
|
||||
* Screen untuk melakukan absensi dengan:
|
||||
* - Deteksi lokasi GPS
|
||||
* - Validasi radius (harus di dalam area kampus)
|
||||
* - Pengambilan foto selfie
|
||||
* - Preview foto sebelum kirim
|
||||
* - Kirim data ke webhook
|
||||
* - Simpan ke database lokal
|
||||
*/
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun AbsensiScreen(
|
||||
mataKuliah: id.ac.ubharajaya.sistemakademik.data.MataKuliah, // Parameter BARU!
|
||||
onNavigateToSuccess: () -> Unit,
|
||||
onNavigateBack: () -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val userPreferences = remember { UserPreferences(context) }
|
||||
val database = remember { AppDatabase.getDatabase(context) }
|
||||
|
||||
// Get user data
|
||||
val userData by userPreferences.userData.collectAsStateWithLifecycle(
|
||||
initialValue = UserPreferences.UserData("", "", false)
|
||||
)
|
||||
|
||||
// State
|
||||
var latitude by remember { mutableStateOf<Double?>(null) }
|
||||
var longitude by remember { mutableStateOf<Double?>(null) }
|
||||
var isLocationValid by remember { mutableStateOf(false) }
|
||||
var distance by remember { mutableStateOf(0.0) }
|
||||
var foto by remember { mutableStateOf<Bitmap?>(null) }
|
||||
var isLoading by remember { mutableStateOf(false) }
|
||||
var statusMessage by remember { mutableStateOf(Constants.MSG_MENUNGGU_LOKASI) }
|
||||
|
||||
// Warna
|
||||
val primaryGreen = Color(0xFF2E7D32)
|
||||
val lightGreen = Color(0xFF66BB6A)
|
||||
val errorRed = Color(0xFFD32F2F)
|
||||
|
||||
val fusedLocationClient = remember {
|
||||
LocationServices.getFusedLocationProviderClient(context)
|
||||
}
|
||||
|
||||
// Permission Launchers
|
||||
val locationPermissionLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.RequestPermission()
|
||||
) { granted ->
|
||||
if (granted) {
|
||||
getLocation(
|
||||
fusedLocationClient = fusedLocationClient,
|
||||
context = context,
|
||||
onSuccess = { lat, lon ->
|
||||
latitude = lat
|
||||
longitude = lon
|
||||
|
||||
// Validasi lokasi
|
||||
val (valid, dist) = LocationValidator.isLocationValid(lat, lon)
|
||||
isLocationValid = valid
|
||||
distance = dist
|
||||
statusMessage = LocationValidator.getStatusMessage(valid, dist)
|
||||
},
|
||||
onFailure = {
|
||||
statusMessage = "Gagal mengambil lokasi"
|
||||
Toast.makeText(context, "Gagal mengambil lokasi", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
)
|
||||
} else {
|
||||
Toast.makeText(context, "Izin lokasi ditolak", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
val cameraLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.StartActivityForResult()
|
||||
) { result ->
|
||||
if (result.resultCode == Activity.RESULT_OK) {
|
||||
val bitmap = result.data?.extras?.getParcelable("data", Bitmap::class.java)
|
||||
if (bitmap != null) {
|
||||
foto = bitmap
|
||||
Toast.makeText(context, "Foto berhasil diambil", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val cameraPermissionLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.RequestPermission()
|
||||
) { granted ->
|
||||
if (granted) {
|
||||
val intent = Intent(MediaStore.ACTION_IMAGE_CAPTURE)
|
||||
cameraLauncher.launch(intent)
|
||||
} else {
|
||||
Toast.makeText(context, "Izin kamera ditolak", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
// Auto request location on start
|
||||
LaunchedEffect(Unit) {
|
||||
locationPermissionLauncher.launch(Manifest.permission.ACCESS_FINE_LOCATION)
|
||||
}
|
||||
|
||||
// UI
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Column {
|
||||
Text(
|
||||
text = "Absen Kehadiran",
|
||||
fontSize = 16.sp
|
||||
)
|
||||
Text(
|
||||
text = mataKuliah.nama,
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Normal
|
||||
)
|
||||
}
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onNavigateBack) {
|
||||
Icon(Icons.Default.ArrowBack, "Back")
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = primaryGreen,
|
||||
titleContentColor = Color.White,
|
||||
navigationIconContentColor = Color.White
|
||||
)
|
||||
)
|
||||
}
|
||||
) { paddingValues ->
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(16.dp)
|
||||
) {
|
||||
|
||||
// Info Mata Kuliah Card (BARU!)
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = lightGreen.copy(alpha = 0.15f)
|
||||
),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp)
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.School,
|
||||
contentDescription = null,
|
||||
tint = primaryGreen,
|
||||
modifier = Modifier.size(28.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = mataKuliah.nama,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 15.sp
|
||||
)
|
||||
Text(
|
||||
text = "${mataKuliah.kode} • ${mataKuliah.waktu}",
|
||||
fontSize = 12.sp,
|
||||
color = Color.Gray
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Divider()
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Person,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(14.dp),
|
||||
tint = Color.Gray
|
||||
)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text(
|
||||
text = mataKuliah.dosen,
|
||||
fontSize = 12.sp,
|
||||
color = Color.Gray
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.LocationOn,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(14.dp),
|
||||
tint = Color.Gray
|
||||
)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text(
|
||||
text = mataKuliah.ruangan,
|
||||
fontSize = 12.sp,
|
||||
color = Color.Gray,
|
||||
maxLines = 1
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Status Lokasi Card
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = if (isLocationValid)
|
||||
lightGreen.copy(alpha = 0.2f)
|
||||
else
|
||||
errorRed.copy(alpha = 0.1f)
|
||||
),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp)
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (isLocationValid)
|
||||
Icons.Default.CheckCircle
|
||||
else
|
||||
Icons.Default.Cancel,
|
||||
contentDescription = null,
|
||||
tint = if (isLocationValid) primaryGreen else errorRed,
|
||||
modifier = Modifier.size(32.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Column {
|
||||
Text(
|
||||
text = if (latitude != null)
|
||||
"Cek Lokasi: ${if (isLocationValid) "Dalam Area Absensi ✓" else "Di Luar Area Absensi ✗"}"
|
||||
else
|
||||
"Mendeteksi Lokasi...",
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 14.sp,
|
||||
color = if (isLocationValid) primaryGreen else errorRed
|
||||
)
|
||||
|
||||
if (latitude != null && longitude != null) {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = "Jarak: ${LocationValidator.formatDistance(distance)}",
|
||||
fontSize = 12.sp,
|
||||
color = Color.Gray
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (latitude != null && longitude != null) {
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Divider()
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
Text(
|
||||
text = "Koordinat Anda:",
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
Text(
|
||||
text = "Lat: ${"%.6f".format(latitude)}",
|
||||
fontSize = 11.sp,
|
||||
color = Color.Gray
|
||||
)
|
||||
Text(
|
||||
text = "Lon: ${"%.6f".format(longitude)}",
|
||||
fontSize = 11.sp,
|
||||
color = Color.Gray
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Ambil Foto Section
|
||||
Text(
|
||||
text = "Ambil Foto Selfie",
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// Preview Foto
|
||||
if (foto != null) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(300.dp),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
elevation = CardDefaults.cardElevation(4.dp)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
Image(
|
||||
bitmap = foto!!.asImageBitmap(),
|
||||
contentDescription = "Preview Foto",
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
|
||||
// Icon check di corner
|
||||
Icon(
|
||||
imageVector = Icons.Default.CheckCircle,
|
||||
contentDescription = null,
|
||||
tint = primaryGreen,
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.padding(8.dp)
|
||||
.size(32.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
} else {
|
||||
// Placeholder jika belum ada foto
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(200.dp),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = Color(0xFFF5F5F5)
|
||||
)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.CameraAlt,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(64.dp),
|
||||
tint = Color.Gray
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = "Belum ada foto",
|
||||
color = Color.Gray
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
|
||||
// Button Ambil Foto
|
||||
Button(
|
||||
onClick = {
|
||||
cameraPermissionLauncher.launch(Manifest.permission.CAMERA)
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = if (foto != null) lightGreen else primaryGreen
|
||||
),
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.CameraAlt,
|
||||
contentDescription = null
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(if (foto != null) "Ambil Ulang Foto" else "AMBIL FOTO")
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
// Button Kirim Absensi
|
||||
Button(
|
||||
onClick = {
|
||||
if (latitude == null || longitude == null) {
|
||||
Toast.makeText(context, "Menunggu data lokasi...", Toast.LENGTH_SHORT).show()
|
||||
return@Button
|
||||
}
|
||||
|
||||
if (!isLocationValid) {
|
||||
Toast.makeText(
|
||||
context,
|
||||
"Anda di luar area kampus! Jarak: ${LocationValidator.formatDistance(distance)}",
|
||||
Toast.LENGTH_LONG
|
||||
).show()
|
||||
return@Button
|
||||
}
|
||||
|
||||
if (foto == null) {
|
||||
Toast.makeText(context, Constants.MSG_FOTO_BELUM_DIAMBIL, Toast.LENGTH_SHORT).show()
|
||||
return@Button
|
||||
}
|
||||
|
||||
// Semua validasi OK, kirim absensi
|
||||
isLoading = true
|
||||
|
||||
scope.launch {
|
||||
try {
|
||||
// Siapkan data absensi
|
||||
val calendar = Calendar.getInstance()
|
||||
val dateFormat = SimpleDateFormat("dd MMM yyyy", Locale("id", "ID"))
|
||||
val timeFormat = SimpleDateFormat("HH:mm", Locale("id", "ID"))
|
||||
val dayFormat = SimpleDateFormat("EEEE", Locale("id", "ID"))
|
||||
|
||||
val absensiEntity = AbsensiEntity(
|
||||
npm = userData.npm.ifEmpty { Constants.NPM },
|
||||
nama = userData.nama.ifEmpty { Constants.NAMA },
|
||||
// Data Mata Kuliah (BARU!)
|
||||
kodeMatakuliah = mataKuliah.kode,
|
||||
namaMatakuliah = mataKuliah.nama,
|
||||
dosenMatakuliah = mataKuliah.dosen,
|
||||
ruanganMatakuliah = mataKuliah.ruangan,
|
||||
waktuMatakuliah = mataKuliah.waktu,
|
||||
// Data Lokasi
|
||||
latitude = latitude!!,
|
||||
longitude = longitude!!,
|
||||
jarak = distance,
|
||||
lokasiValid = isLocationValid,
|
||||
timestamp = System.currentTimeMillis(),
|
||||
tanggal = dateFormat.format(calendar.time),
|
||||
waktu = "${timeFormat.format(calendar.time)} WIB",
|
||||
hari = dayFormat.format(calendar.time),
|
||||
fotoBase64 = bitmapToBase64(foto!!),
|
||||
statusKirim = false
|
||||
)
|
||||
|
||||
// Simpan ke database lokal
|
||||
val id = database.absensiDao().insert(absensiEntity)
|
||||
|
||||
// Kirim ke webhook
|
||||
kirimKeWebhook(
|
||||
context = context,
|
||||
absensi = absensiEntity.copy(id = id.toInt()),
|
||||
onSuccess = {
|
||||
scope.launch {
|
||||
// Update status kirim di database
|
||||
database.absensiDao().updateStatusKirim(id.toInt(), true)
|
||||
|
||||
isLoading = false
|
||||
Toast.makeText(
|
||||
context,
|
||||
"Absensi berhasil dicatat!",
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
onNavigateToSuccess()
|
||||
}
|
||||
},
|
||||
onFailure = { error ->
|
||||
scope.launch {
|
||||
isLoading = false
|
||||
Toast.makeText(
|
||||
context,
|
||||
"Data tersimpan lokal. Error: $error",
|
||||
Toast.LENGTH_LONG
|
||||
).show()
|
||||
onNavigateToSuccess()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
} catch (e: Exception) {
|
||||
isLoading = false
|
||||
Toast.makeText(
|
||||
context,
|
||||
"Error: ${e.message}",
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(56.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = primaryGreen,
|
||||
disabledContainerColor = Color.Gray
|
||||
),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
enabled = !isLoading && isLocationValid && foto != null
|
||||
) {
|
||||
if (isLoading) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(24.dp),
|
||||
color = Color.White
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Send,
|
||||
contentDescription = null
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = "KIRIM ABSENSI",
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Info validasi
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = Color(0xFFFFF3E0)
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Info,
|
||||
contentDescription = null,
|
||||
tint = Color(0xFFFF6F00),
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = "Pastikan Anda berada dalam radius ${Constants.RADIUS_METER.toInt()}m dari kampus",
|
||||
fontSize = 12.sp,
|
||||
color = Color(0xFFE65100)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Helper Functions
|
||||
|
||||
private fun getLocation(
|
||||
fusedLocationClient: com.google.android.gms.location.FusedLocationProviderClient,
|
||||
context: android.content.Context,
|
||||
onSuccess: (Double, Double) -> Unit,
|
||||
onFailure: () -> Unit
|
||||
) {
|
||||
if (ContextCompat.checkSelfPermission(
|
||||
context,
|
||||
Manifest.permission.ACCESS_FINE_LOCATION
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
fusedLocationClient.lastLocation
|
||||
.addOnSuccessListener { location ->
|
||||
if (location != null) {
|
||||
onSuccess(location.latitude, location.longitude)
|
||||
} else {
|
||||
onFailure()
|
||||
}
|
||||
}
|
||||
.addOnFailureListener {
|
||||
onFailure()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun bitmapToBase64(bitmap: Bitmap): String {
|
||||
val outputStream = ByteArrayOutputStream()
|
||||
bitmap.compress(Bitmap.CompressFormat.JPEG, 80, outputStream)
|
||||
return Base64.encodeToString(outputStream.toByteArray(), Base64.NO_WRAP)
|
||||
}
|
||||
|
||||
private fun kirimKeWebhook(
|
||||
context: android.content.Context,
|
||||
absensi: AbsensiEntity,
|
||||
onSuccess: () -> Unit,
|
||||
onFailure: (String) -> Unit
|
||||
) {
|
||||
thread {
|
||||
try {
|
||||
val url = URL(Constants.WEBHOOK_URL_ACTIVE)
|
||||
val conn = url.openConnection() as HttpURLConnection
|
||||
|
||||
conn.requestMethod = "POST"
|
||||
conn.setRequestProperty("Content-Type", "application/json")
|
||||
conn.doOutput = true
|
||||
conn.connectTimeout = 10000
|
||||
conn.readTimeout = 10000
|
||||
|
||||
val json = JSONObject().apply {
|
||||
put("npm", absensi.npm)
|
||||
put("nama", absensi.nama)
|
||||
put("latitude", absensi.latitude)
|
||||
put("longitude", absensi.longitude)
|
||||
put("jarak", absensi.jarak)
|
||||
put("timestamp", absensi.timestamp)
|
||||
put("tanggal", absensi.tanggal)
|
||||
put("waktu", absensi.waktu)
|
||||
put("foto_base64", absensi.fotoBase64)
|
||||
}
|
||||
|
||||
conn.outputStream.use {
|
||||
it.write(json.toString().toByteArray())
|
||||
}
|
||||
|
||||
val responseCode = conn.responseCode
|
||||
conn.disconnect()
|
||||
|
||||
if (responseCode == 200 || responseCode == 201) {
|
||||
onSuccess()
|
||||
} else {
|
||||
onFailure("HTTP $responseCode")
|
||||
}
|
||||
|
||||
} catch (e: Exception) {
|
||||
onFailure(e.message ?: "Unknown error")
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,305 @@
|
||||
package id.ac.ubharajaya.sistemakademik.screens
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import id.ac.ubharajaya.sistemakademik.data.UserPreferences
|
||||
import id.ac.ubharajaya.sistemakademik.utils.Constants
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* FILE: DashboardScreen.kt
|
||||
* LOKASI: screens/DashboardScreen.kt
|
||||
*
|
||||
* Deskripsi:
|
||||
* Screen menu utama setelah login
|
||||
* Menampilkan:
|
||||
* - Header dengan nama user dan lokasi kampus
|
||||
* - Menu navigasi: Jadwal Kuliah, Mulai Absensi, Riwayat Absensi
|
||||
* - Map preview (optional)
|
||||
*/
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun DashboardScreen(
|
||||
onNavigateToAbsensi: () -> Unit,
|
||||
onNavigateToHistory: () -> Unit,
|
||||
onNavigateToSchedule: () -> Unit,
|
||||
onLogout: () -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val userPreferences = remember { UserPreferences(context) }
|
||||
|
||||
// Get user data
|
||||
val userData by userPreferences.userData.collectAsStateWithLifecycle(
|
||||
initialValue = UserPreferences.UserData("", "", false)
|
||||
)
|
||||
|
||||
// Warna tema
|
||||
val primaryGreen = Color(0xFF2E7D32)
|
||||
val lightGreen = Color(0xFF66BB6A)
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
"Menu Absensi",
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = primaryGreen,
|
||||
titleContentColor = Color.White
|
||||
),
|
||||
actions = {
|
||||
IconButton(onClick = {
|
||||
scope.launch {
|
||||
userPreferences.logout()
|
||||
onLogout()
|
||||
}
|
||||
}) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Logout,
|
||||
contentDescription = "Logout",
|
||||
tint = Color.White
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) { paddingValues ->
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues)
|
||||
.padding(16.dp)
|
||||
) {
|
||||
|
||||
// Header Card - Info User
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = lightGreen.copy(alpha = 0.15f)
|
||||
),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Selamat Datang, Budi!",
|
||||
fontSize = 20.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = primaryGreen
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Person,
|
||||
contentDescription = null,
|
||||
tint = Color.Gray,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = userData.nama.ifEmpty { Constants.NAMA },
|
||||
fontSize = 14.sp,
|
||||
color = Color.Gray
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Badge,
|
||||
contentDescription = null,
|
||||
tint = Color.Gray,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = "NPM: ${userData.npm.ifEmpty { Constants.NPM }}",
|
||||
fontSize = 14.sp,
|
||||
color = Color.Gray
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
// Menu Cards
|
||||
// Menu 1: Jadwal Kuliah
|
||||
MenuCard(
|
||||
title = "Jadwal Kuliah",
|
||||
icon = Icons.Default.Schedule,
|
||||
backgroundColor = Color(0xFFE8F5E9),
|
||||
iconColor = primaryGreen,
|
||||
onClick = onNavigateToSchedule
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// Menu 2: Mulai Absensi (Primary Action)
|
||||
MenuCard(
|
||||
title = "MULAI ABSENSI",
|
||||
icon = Icons.Default.CameraAlt,
|
||||
backgroundColor = primaryGreen,
|
||||
iconColor = Color.White,
|
||||
textColor = Color.White,
|
||||
isPrimary = true,
|
||||
onClick = onNavigateToAbsensi
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// Menu 3: Riwayat Absensi
|
||||
MenuCard(
|
||||
title = "Riwayat Absensi",
|
||||
icon = Icons.Default.History,
|
||||
backgroundColor = Color(0xFFE8F5E9),
|
||||
iconColor = primaryGreen,
|
||||
onClick = onNavigateToHistory
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
// Info Lokasi Kampus
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = Color(0xFFFFF3E0)
|
||||
),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.LocationOn,
|
||||
contentDescription = null,
|
||||
tint = Color(0xFFFF6F00),
|
||||
modifier = Modifier.size(40.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Column {
|
||||
Text(
|
||||
text = "Lokasi: ${Constants.KAMPUS_NAMA}",
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 14.sp
|
||||
)
|
||||
Text(
|
||||
text = Constants.KAMPUS_ALAMAT,
|
||||
fontSize = 12.sp,
|
||||
color = Color.Gray
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
// Footer info
|
||||
Text(
|
||||
text = "📍 Pastikan lokasi GPS aktif untuk absensi",
|
||||
fontSize = 12.sp,
|
||||
color = Color.Gray,
|
||||
modifier = Modifier.align(Alignment.CenterHorizontally)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reusable Menu Card Component
|
||||
*/
|
||||
@Composable
|
||||
fun MenuCard(
|
||||
title: String,
|
||||
icon: ImageVector,
|
||||
backgroundColor: Color,
|
||||
iconColor: Color,
|
||||
textColor: Color = Color.Black,
|
||||
isPrimary: Boolean = false,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(if (isPrimary) 70.dp else 65.dp)
|
||||
.clickable(onClick = onClick),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = backgroundColor
|
||||
),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
elevation = CardDefaults.cardElevation(
|
||||
defaultElevation = if (isPrimary) 4.dp else 2.dp
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 20.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
tint = iconColor,
|
||||
modifier = Modifier.size(if (isPrimary) 32.dp else 28.dp)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
|
||||
Text(
|
||||
text = title,
|
||||
fontSize = if (isPrimary) 18.sp else 16.sp,
|
||||
fontWeight = if (isPrimary) FontWeight.Bold else FontWeight.Medium,
|
||||
color = textColor
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* CATATAN IMPLEMENTASI:
|
||||
*
|
||||
* 1. Dashboard menampilkan 3 menu utama:
|
||||
* - Jadwal Kuliah (untuk melihat jadwal - bisa dikembangkan)
|
||||
* - Mulai Absensi (menu utama - navigasi ke AbsensiScreen)
|
||||
* - Riwayat Absensi (melihat history absensi)
|
||||
*
|
||||
* 2. Header menampilkan nama dan NPM dari UserPreferences
|
||||
*
|
||||
* 3. Tombol logout ada di TopBar kanan atas
|
||||
*
|
||||
* 4. Untuk production:
|
||||
* - Tambahkan fitur jadwal kuliah yang proper
|
||||
* - Tambahkan notifikasi jika ada jadwal kuliah hari ini
|
||||
* - Tambahkan statistik kehadiran
|
||||
*/
|
||||
@ -0,0 +1,455 @@
|
||||
package id.ac.ubharajaya.sistemakademik.screens
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import id.ac.ubharajaya.sistemakademik.data.AbsensiEntity
|
||||
import id.ac.ubharajaya.sistemakademik.data.AppDatabase
|
||||
import id.ac.ubharajaya.sistemakademik.utils.LocationValidator
|
||||
|
||||
/**
|
||||
* FILE: HistoryScreen.kt
|
||||
* LOKASI: screens/HistoryScreen.kt
|
||||
*
|
||||
* Deskripsi:
|
||||
* Screen untuk melihat riwayat absensi mahasiswa
|
||||
* Menampilkan list semua absensi yang pernah dilakukan
|
||||
*/
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun HistoryScreen(
|
||||
onNavigateBack: () -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val database = remember { AppDatabase.getDatabase(context) }
|
||||
|
||||
// Get data absensi dari database
|
||||
val absensiList by database.absensiDao()
|
||||
.getAllAbsensi()
|
||||
.collectAsStateWithLifecycle(initialValue = emptyList())
|
||||
|
||||
// State untuk detail dialog
|
||||
var selectedAbsensi by remember { mutableStateOf<AbsensiEntity?>(null) }
|
||||
var showDetailDialog by remember { mutableStateOf(false) }
|
||||
|
||||
val primaryGreen = Color(0xFF2E7D32)
|
||||
val lightGreen = Color(0xFF66BB6A)
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Riwayat Absensi") },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onNavigateBack) {
|
||||
Icon(Icons.Default.ArrowBack, "Back")
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = primaryGreen,
|
||||
titleContentColor = Color.White,
|
||||
navigationIconContentColor = Color.White
|
||||
)
|
||||
)
|
||||
}
|
||||
) { paddingValues ->
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues)
|
||||
) {
|
||||
|
||||
// Header Stats
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = lightGreen.copy(alpha = 0.15f)
|
||||
),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
horizontalArrangement = Arrangement.SpaceAround
|
||||
) {
|
||||
StatItem(
|
||||
icon = Icons.Default.CheckCircle,
|
||||
label = "Total Absensi",
|
||||
value = "${absensiList.size}",
|
||||
color = primaryGreen
|
||||
)
|
||||
|
||||
Divider(
|
||||
modifier = Modifier
|
||||
.height(40.dp)
|
||||
.width(1.dp)
|
||||
)
|
||||
|
||||
StatItem(
|
||||
icon = Icons.Default.CloudDone,
|
||||
label = "Terkirim",
|
||||
value = "${absensiList.count { it.statusKirim }}",
|
||||
color = primaryGreen
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// List Absensi
|
||||
if (absensiList.isEmpty()) {
|
||||
// Empty state
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.HistoryToggleOff,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(80.dp),
|
||||
tint = Color.Gray
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text(
|
||||
text = "Belum ada riwayat absensi",
|
||||
color = Color.Gray,
|
||||
fontSize = 16.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
items(absensiList) { absensi ->
|
||||
AbsensiItem(
|
||||
absensi = absensi,
|
||||
onClick = {
|
||||
selectedAbsensi = absensi
|
||||
showDetailDialog = true
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Detail Dialog
|
||||
if (showDetailDialog && selectedAbsensi != null) {
|
||||
AbsensiDetailDialog(
|
||||
absensi = selectedAbsensi!!,
|
||||
onDismiss = { showDetailDialog = false }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StatItem(
|
||||
icon: androidx.compose.ui.graphics.vector.ImageVector,
|
||||
label: String,
|
||||
value: String,
|
||||
color: Color
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
tint = color,
|
||||
modifier = Modifier.size(32.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = value,
|
||||
fontSize = 24.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = color
|
||||
)
|
||||
Text(
|
||||
text = label,
|
||||
fontSize = 12.sp,
|
||||
color = Color.Gray
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AbsensiItem(
|
||||
absensi: AbsensiEntity,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
val primaryGreen = Color(0xFF2E7D32)
|
||||
val errorRed = Color(0xFFD32F2F)
|
||||
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
elevation = CardDefaults.cardElevation(2.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
|
||||
// Status Icon
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(48.dp)
|
||||
.clip(CircleShape)
|
||||
.background(
|
||||
if (absensi.lokasiValid)
|
||||
primaryGreen.copy(alpha = 0.2f)
|
||||
else
|
||||
errorRed.copy(alpha = 0.2f)
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (absensi.lokasiValid)
|
||||
Icons.Default.CheckCircle
|
||||
else
|
||||
Icons.Default.Cancel,
|
||||
contentDescription = null,
|
||||
tint = if (absensi.lokasiValid) primaryGreen else errorRed,
|
||||
modifier = Modifier.size(28.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
|
||||
// Info
|
||||
Column(
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
// Nama Mata Kuliah (BARU!)
|
||||
Text(
|
||||
text = absensi.namaMatakuliah,
|
||||
fontSize = 15.sp,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
|
||||
// Hari & Tanggal
|
||||
Text(
|
||||
text = "${absensi.hari}, ${absensi.tanggal}",
|
||||
fontSize = 13.sp,
|
||||
color = Color.Gray,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.AccessTime,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(14.dp),
|
||||
tint = Color.Gray
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text(
|
||||
text = "${absensi.waktuMatakuliah} • ${absensi.waktu}",
|
||||
fontSize = 12.sp,
|
||||
color = Color.Gray
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
// Status badge
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(4.dp),
|
||||
color = if (absensi.lokasiValid)
|
||||
primaryGreen.copy(alpha = 0.15f)
|
||||
else
|
||||
errorRed.copy(alpha = 0.15f)
|
||||
) {
|
||||
Text(
|
||||
text = if (absensi.lokasiValid) "Valid" else "Invalid",
|
||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp),
|
||||
fontSize = 10.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = if (absensi.lokasiValid) primaryGreen else errorRed
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
|
||||
if (absensi.statusKirim) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.CloudDone,
|
||||
contentDescription = "Terkirim",
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = primaryGreen
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = Icons.Default.CloudOff,
|
||||
contentDescription = "Belum terkirim",
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = Color.Gray
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Icon(
|
||||
imageVector = Icons.Default.ChevronRight,
|
||||
contentDescription = "Detail",
|
||||
tint = Color.Gray
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AbsensiDetailDialog(
|
||||
absensi: AbsensiEntity,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
val primaryGreen = Color(0xFF2E7D32)
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
icon = {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Info,
|
||||
contentDescription = null,
|
||||
tint = primaryGreen
|
||||
)
|
||||
},
|
||||
title = {
|
||||
Text(
|
||||
text = "Detail Absensi",
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
},
|
||||
text = {
|
||||
Column {
|
||||
// Info Mata Kuliah (BARU!)
|
||||
Text(
|
||||
text = "📚 Mata Kuliah",
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = primaryGreen
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
DetailRow("Kode", absensi.kodeMatakuliah)
|
||||
DetailRow("Nama MK", absensi.namaMatakuliah)
|
||||
DetailRow("Dosen", absensi.dosenMatakuliah)
|
||||
DetailRow("Ruangan", absensi.ruanganMatakuliah)
|
||||
DetailRow("Jam Kuliah", absensi.waktuMatakuliah)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Divider()
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// Info Mahasiswa
|
||||
Text(
|
||||
text = "👤 Mahasiswa",
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = primaryGreen
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
DetailRow("Nama", absensi.nama)
|
||||
DetailRow("NPM", absensi.npm)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Divider()
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// Info Waktu & Lokasi
|
||||
Text(
|
||||
text = "📍 Waktu & Lokasi",
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = primaryGreen
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
DetailRow("Hari", absensi.hari)
|
||||
DetailRow("Tanggal", absensi.tanggal)
|
||||
DetailRow("Waktu Absen", absensi.waktu)
|
||||
DetailRow("Jarak", LocationValidator.formatDistance(absensi.jarak))
|
||||
DetailRow("Status Lokasi", if (absensi.lokasiValid) "✓ Valid" else "✗ Invalid")
|
||||
DetailRow("Status Kirim", if (absensi.statusKirim) "✓ Terkirim" else "⌛ Pending")
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text("TUTUP", color = primaryGreen)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DetailRow(label: String, value: String) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "$label:",
|
||||
fontSize = 13.sp,
|
||||
color = Color.Gray,
|
||||
modifier = Modifier.width(100.dp)
|
||||
)
|
||||
Text(
|
||||
text = value,
|
||||
fontSize = 13.sp,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* CATATAN IMPLEMENTASI:
|
||||
*
|
||||
* 1. Screen ini menampilkan list semua absensi dari Room Database
|
||||
*
|
||||
* 2. Menggunakan LazyColumn untuk efisiensi jika data banyak
|
||||
*
|
||||
* 3. Ada statistik singkat di atas (total & terkirim)
|
||||
*
|
||||
* 4. Click item untuk melihat detail lengkap
|
||||
*
|
||||
* 5. Data otomatis update karena menggunakan Flow dari Room
|
||||
*/
|
||||
@ -0,0 +1,303 @@
|
||||
package id.ac.ubharajaya.sistemakademik.screens
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.*
|
||||
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.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
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.text.input.VisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import id.ac.ubharajaya.sistemakademik.data.UserPreferences
|
||||
import id.ac.ubharajaya.sistemakademik.utils.Constants
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* FILE: LoginScreen.kt
|
||||
* LOKASI: screens/LoginScreen.kt
|
||||
*
|
||||
* Deskripsi:
|
||||
* Screen untuk login mahasiswa
|
||||
* Input: NPM dan Password
|
||||
* Validasi sederhana: NPM harus sesuai dengan Constants.NPM
|
||||
*/
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun LoginScreen(
|
||||
onLoginSuccess: () -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val userPreferences = remember { UserPreferences(context) }
|
||||
|
||||
// State untuk form input
|
||||
var npm by remember { mutableStateOf("") }
|
||||
var password by remember { mutableStateOf("") }
|
||||
var passwordVisible by remember { mutableStateOf(false) }
|
||||
var errorMessage by remember { mutableStateOf("") }
|
||||
var isLoading by remember { mutableStateOf(false) }
|
||||
|
||||
// Warna tema hijau akademik
|
||||
val primaryGreen = Color(0xFF2E7D32)
|
||||
val lightGreen = Color(0xFF66BB6A)
|
||||
|
||||
// Check jika sudah login sebelumnya
|
||||
val userData by userPreferences.userData.collectAsStateWithLifecycle(
|
||||
initialValue = UserPreferences.UserData("", "", false)
|
||||
)
|
||||
|
||||
LaunchedEffect(userData.isLoggedIn) {
|
||||
if (userData.isLoggedIn) {
|
||||
onLoginSuccess()
|
||||
}
|
||||
}
|
||||
|
||||
// UI
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = Color.White
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
|
||||
// Logo/Icon Graduation Cap
|
||||
Icon(
|
||||
imageVector = Icons.Default.School,
|
||||
contentDescription = "Logo",
|
||||
modifier = Modifier.size(80.dp),
|
||||
tint = primaryGreen
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Title
|
||||
Text(
|
||||
text = "Absensi Akademik",
|
||||
fontSize = 28.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = primaryGreen
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Text(
|
||||
text = Constants.KAMPUS_NAMA,
|
||||
fontSize = 14.sp,
|
||||
color = Color.Gray
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(40.dp))
|
||||
|
||||
// Input NPM
|
||||
OutlinedTextField(
|
||||
value = npm,
|
||||
onValueChange = {
|
||||
npm = it
|
||||
errorMessage = ""
|
||||
},
|
||||
label = { Text("NPM") },
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Badge,
|
||||
contentDescription = null
|
||||
)
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Number
|
||||
),
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = primaryGreen,
|
||||
focusedLabelColor = primaryGreen
|
||||
)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Input Password
|
||||
OutlinedTextField(
|
||||
value = password,
|
||||
onValueChange = {
|
||||
password = it
|
||||
errorMessage = ""
|
||||
},
|
||||
label = { Text("Password") },
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Lock,
|
||||
contentDescription = null
|
||||
)
|
||||
},
|
||||
trailingIcon = {
|
||||
IconButton(onClick = { passwordVisible = !passwordVisible }) {
|
||||
Icon(
|
||||
imageVector = if (passwordVisible)
|
||||
Icons.Default.Visibility
|
||||
else
|
||||
Icons.Default.VisibilityOff,
|
||||
contentDescription = if (passwordVisible)
|
||||
"Hide password"
|
||||
else
|
||||
"Show password"
|
||||
)
|
||||
}
|
||||
},
|
||||
visualTransformation = if (passwordVisible)
|
||||
VisualTransformation.None
|
||||
else
|
||||
PasswordVisualTransformation(),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Password
|
||||
),
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = primaryGreen,
|
||||
focusedLabelColor = primaryGreen
|
||||
)
|
||||
)
|
||||
|
||||
// Error Message
|
||||
if (errorMessage.isNotEmpty()) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = errorMessage,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
fontSize = 14.sp
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
// Button Login
|
||||
Button(
|
||||
onClick = {
|
||||
// Validasi input
|
||||
when {
|
||||
npm.isEmpty() || password.isEmpty() -> {
|
||||
errorMessage = "NPM dan Password harus diisi"
|
||||
}
|
||||
npm != Constants.NPM -> {
|
||||
errorMessage = "NPM tidak valid"
|
||||
}
|
||||
password.length < 4 -> {
|
||||
errorMessage = "Password minimal 4 karakter"
|
||||
}
|
||||
else -> {
|
||||
// Login berhasil
|
||||
isLoading = true
|
||||
scope.launch {
|
||||
userPreferences.saveUserData(
|
||||
npm = Constants.NPM,
|
||||
nama = Constants.NAMA
|
||||
)
|
||||
isLoading = false
|
||||
onLoginSuccess()
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(50.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = primaryGreen
|
||||
),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
enabled = !isLoading
|
||||
) {
|
||||
if (isLoading) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(24.dp),
|
||||
color = Color.White
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
text = "LOGIN",
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Forgot Password (Optional - bisa dihapus)
|
||||
TextButton(onClick = {
|
||||
// TODO: Implement forgot password
|
||||
}) {
|
||||
Text(
|
||||
text = "Forgot Password?",
|
||||
color = primaryGreen
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(40.dp))
|
||||
|
||||
// Info untuk testing
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = lightGreen.copy(alpha = 0.1f)
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "ℹ️ Info Login",
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 14.sp
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = "NPM: ${Constants.NPM}",
|
||||
fontSize = 12.sp,
|
||||
color = Color.Gray
|
||||
)
|
||||
Text(
|
||||
text = "Password: Bebas (minimal 4 karakter)",
|
||||
fontSize = 12.sp,
|
||||
color = Color.Gray
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* CATATAN IMPLEMENTASI:
|
||||
*
|
||||
* 1. Screen ini melakukan validasi sederhana:
|
||||
* - NPM harus sesuai dengan Constants.NPM
|
||||
* - Password minimal 4 karakter (tidak ada validasi server)
|
||||
*
|
||||
* 2. Data login disimpan di DataStore Preferences
|
||||
*
|
||||
* 3. Jika sudah pernah login, langsung masuk ke Dashboard
|
||||
*
|
||||
* 4. Untuk production, sebaiknya:
|
||||
* - Tambahkan enkripsi password
|
||||
* - Validasi ke server/database
|
||||
* - Implementasi forgot password yang proper
|
||||
*/
|
||||
@ -0,0 +1,292 @@
|
||||
package id.ac.ubharajaya.sistemakademik.screens
|
||||
|
||||
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.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
/**
|
||||
* FILE: ScheduleScreen.kt
|
||||
* LOKASI: screens/ScheduleScreen.kt
|
||||
*
|
||||
* Deskripsi:
|
||||
* Screen untuk menampilkan jadwal kuliah (placeholder)
|
||||
* Bisa dikembangkan lebih lanjut sesuai kebutuhan
|
||||
*/
|
||||
|
||||
// Data class untuk jadwal
|
||||
data class JadwalKuliah(
|
||||
val hari: String,
|
||||
val waktu: String,
|
||||
val mataKuliah: String,
|
||||
val dosen: String,
|
||||
val ruangan: String
|
||||
)
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ScheduleScreen(
|
||||
onNavigateBack: () -> Unit
|
||||
) {
|
||||
val primaryGreen = Color(0xFF2E7D32)
|
||||
val lightGreen = Color(0xFF66BB6A)
|
||||
|
||||
// Data jadwal real dari tabel (bisa diganti dengan data real)
|
||||
val jadwalList = remember {
|
||||
listOf(
|
||||
JadwalKuliah(
|
||||
hari = "Senin",
|
||||
waktu = "10:45 - 13:15",
|
||||
mataKuliah = "Interaksi Manusia dan Komputer",
|
||||
dosen = "Dian Hartanti, S.Kom., MMSI",
|
||||
ruangan = "UBJ-BKS || R. Said Soekanto || SS-409"
|
||||
),
|
||||
JadwalKuliah(
|
||||
hari = "Senin",
|
||||
waktu = "13:30 - 16:00",
|
||||
mataKuliah = "Kecerdasan Buatan",
|
||||
dosen = "Hendarman Lubis, S.Kom., M.Kom.",
|
||||
ruangan = "UBJ-BKS || R. Said Soekanto || SS-419"
|
||||
),
|
||||
JadwalKuliah(
|
||||
hari = "Selasa",
|
||||
waktu = "10:45 - 13:15",
|
||||
mataKuliah = "Pembelajaran Mesin",
|
||||
dosen = "Mukhlis, S.Kom, MT",
|
||||
ruangan = "UBJ-BKS || R. Said Soekanto || SS-408"
|
||||
),
|
||||
JadwalKuliah(
|
||||
hari = "Rabu",
|
||||
waktu = "08:00 - 10:30",
|
||||
mataKuliah = "Keamanan Siber",
|
||||
dosen = "Asep Ramdhani Mahbub, S.Kom., M.Kom.",
|
||||
ruangan = "UBJ-BKS || R. Said Soekanto || SS-412"
|
||||
),
|
||||
JadwalKuliah(
|
||||
hari = "Kamis",
|
||||
waktu = "08:00 - 09:40",
|
||||
mataKuliah = "Manajemen Sekuriti",
|
||||
dosen = "Ratna Salkiawati, S.T., M.Kom",
|
||||
ruangan = "UBJ-BKS || Grha Tanoto || W-105"
|
||||
),
|
||||
JadwalKuliah(
|
||||
hari = "Kamis",
|
||||
waktu = "13:30 - 16:00",
|
||||
mataKuliah = "Pemrograman Perangkat Bergerak",
|
||||
dosen = "Arif Rifai Dwiyanto, ST., MTI",
|
||||
ruangan = "UBJ-BKS || Grha Tanoto || W-104"
|
||||
),
|
||||
JadwalKuliah(
|
||||
hari = "Jumat",
|
||||
waktu = "08:00 - 10:30",
|
||||
mataKuliah = "Manajemen Proyek Perangkat Lunak",
|
||||
dosen = "M. Hadi Prayitno, S.Kom, M.Kom.",
|
||||
ruangan = "UBJ-BKS || R. Said Soekanto || SS-412"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Jadwal Kuliah") },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onNavigateBack) {
|
||||
Icon(Icons.Default.ArrowBack, "Back")
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = primaryGreen,
|
||||
titleContentColor = Color.White,
|
||||
navigationIconContentColor = Color.White
|
||||
)
|
||||
)
|
||||
}
|
||||
) { paddingValues ->
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues)
|
||||
) {
|
||||
|
||||
// Info Card
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = lightGreen.copy(alpha = 0.15f)
|
||||
),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Info,
|
||||
contentDescription = null,
|
||||
tint = primaryGreen,
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Column {
|
||||
Text(
|
||||
text = "Semester Ganjil 2025/2026",
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 14.sp
|
||||
)
|
||||
Text(
|
||||
text = "Total ${jadwalList.size} Mata Kuliah",
|
||||
fontSize = 12.sp,
|
||||
color = Color.Gray
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// List Jadwal
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
items(jadwalList) { jadwal ->
|
||||
JadwalCard(jadwal)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun JadwalCard(jadwal: JadwalKuliah) {
|
||||
val primaryGreen = Color(0xFF2E7D32)
|
||||
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
elevation = CardDefaults.cardElevation(2.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp)
|
||||
) {
|
||||
// Header - Hari & Waktu
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.CalendarToday,
|
||||
contentDescription = null,
|
||||
tint = primaryGreen,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = jadwal.hari,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 14.sp,
|
||||
color = primaryGreen
|
||||
)
|
||||
}
|
||||
|
||||
Surface(
|
||||
shape = RoundedCornerShape(4.dp),
|
||||
color = primaryGreen.copy(alpha = 0.15f)
|
||||
) {
|
||||
Text(
|
||||
text = jadwal.waktu,
|
||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
fontSize = 11.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = primaryGreen
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Divider()
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// Mata Kuliah
|
||||
Text(
|
||||
text = jadwal.mataKuliah,
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// Dosen
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Person,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = Color.Gray
|
||||
)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text(
|
||||
text = jadwal.dosen,
|
||||
fontSize = 13.sp,
|
||||
color = Color.Gray
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
// Ruangan
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.LocationOn,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = Color.Gray
|
||||
)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text(
|
||||
text = jadwal.ruangan,
|
||||
fontSize = 13.sp,
|
||||
color = Color.Gray
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* CATATAN IMPLEMENTASI:
|
||||
*
|
||||
* 1. Ini adalah screen placeholder untuk jadwal kuliah
|
||||
*
|
||||
* 2. Menggunakan dummy data static
|
||||
*
|
||||
* 3. Untuk production, bisa dikembangkan dengan:
|
||||
* - Ambil data dari API/Database
|
||||
* - Filtering berdasarkan hari
|
||||
* - Notifikasi jadwal kuliah hari ini
|
||||
* - Link ke Google Calendar
|
||||
*
|
||||
* 4. Bisa juga ditambahkan fitur:
|
||||
* - Absensi langsung dari jadwal
|
||||
* - Informasi tugas/quiz
|
||||
* - Materi kuliah
|
||||
*/
|
||||
@ -0,0 +1,449 @@
|
||||
package id.ac.ubharajaya.sistemakademik.screens
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
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.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import id.ac.ubharajaya.sistemakademik.data.AppDatabase
|
||||
import id.ac.ubharajaya.sistemakademik.data.MataKuliah
|
||||
import kotlinx.coroutines.launch
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
|
||||
/**
|
||||
* FILE: SelectMatakuliahScreen.kt
|
||||
* LOKASI: screens/SelectMatakuliahScreen.kt
|
||||
*
|
||||
* Deskripsi:
|
||||
* Screen untuk memilih mata kuliah yang akan diabsen
|
||||
* Menampilkan jadwal hari ini dan filter MK yang sudah diabsen
|
||||
*/
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun SelectMatakuliahScreen(
|
||||
onNavigateBack: () -> Unit,
|
||||
onMatakuliahSelected: (MataKuliah) -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val database = remember { AppDatabase.getDatabase(context) }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val primaryGreen = Color(0xFF2E7D32)
|
||||
val lightGreen = Color(0xFF66BB6A)
|
||||
|
||||
// Get hari ini
|
||||
val hariIni = remember {
|
||||
SimpleDateFormat("EEEE", Locale("id", "ID")).format(Date())
|
||||
}
|
||||
|
||||
val tanggalHariIni = remember {
|
||||
SimpleDateFormat("dd MMM yyyy", Locale("id", "ID")).format(Date())
|
||||
}
|
||||
|
||||
// Jadwal lengkap (hardcoded - bisa diganti dengan dari database)
|
||||
val semuaJadwal = remember {
|
||||
listOf(
|
||||
MataKuliah(
|
||||
kode = "INFO-3527",
|
||||
nama = "Pemrograman Mobile",
|
||||
dosen = "Dr. Ahmad Wijaya",
|
||||
ruangan = "Lab Komputer 1",
|
||||
waktu = "08:00 - 10:00",
|
||||
hari = "Senin",
|
||||
sks = 3
|
||||
),
|
||||
MataKuliah(
|
||||
kode = "INFO-3528",
|
||||
nama = "Interaksi Manusia dan Komputer",
|
||||
dosen = "Dian Hartanti, S.Kom., MMSI",
|
||||
ruangan = "UBJ-BKS || R. Said Soekanto || SS-409",
|
||||
waktu = "10:45 - 13:15",
|
||||
hari = "Senin",
|
||||
sks = 3
|
||||
),
|
||||
MataKuliah(
|
||||
kode = "INFO-3530",
|
||||
nama = "Kecerdasan Buatan",
|
||||
dosen = "Hendarman Lubis, S.Kom., M.Kom.",
|
||||
ruangan = "UBJ-BKS || R. Said Soekanto || SS-419",
|
||||
waktu = "13:30 - 16:00",
|
||||
hari = "Senin",
|
||||
sks = 3
|
||||
),
|
||||
MataKuliah(
|
||||
kode = "INFO-3531",
|
||||
nama = "Pembelajaran Mesin",
|
||||
dosen = "Mukhlis, S.Kom, MT",
|
||||
ruangan = "UBJ-BKS || R. Said Soekanto || SS-408",
|
||||
waktu = "10:45 - 13:15",
|
||||
hari = "Selasa",
|
||||
sks = 3
|
||||
),
|
||||
MataKuliah(
|
||||
kode = "INFO-3532",
|
||||
nama = "Keamanan Siber",
|
||||
dosen = "Asep Ramdhani Mahbub, S.Kom., M.Kom.",
|
||||
ruangan = "UBJ-BKS || R. Said Soekanto || SS-412",
|
||||
waktu = "08:00 - 10:30",
|
||||
hari = "Rabu",
|
||||
sks = 3
|
||||
),
|
||||
MataKuliah(
|
||||
kode = "MKDU-2007",
|
||||
nama = "Manajemen Sekuriti",
|
||||
dosen = "Ratna Salkiawati, S.T., M.Kom",
|
||||
ruangan = "UBJ-BKS || Grha Tanoto || W-105",
|
||||
waktu = "08:00 - 09:40",
|
||||
hari = "Kamis",
|
||||
sks = 2
|
||||
),
|
||||
MataKuliah(
|
||||
kode = "INFO-3529",
|
||||
nama = "Pemrograman Perangkat Bergerak",
|
||||
dosen = "Arif Rifai Dwiyanto, ST., MTI",
|
||||
ruangan = "UBJ-BKS || Grha Tanoto || W-104",
|
||||
waktu = "13:30 - 16:00",
|
||||
hari = "Kamis",
|
||||
sks = 3
|
||||
),
|
||||
MataKuliah(
|
||||
kode = "INFO-3533",
|
||||
nama = "Manajemen Proyek Perangkat Lunak",
|
||||
dosen = "M. Hadi Prayitno, S.Kom, M.Kom.",
|
||||
ruangan = "UBJ-BKS || R. Said Soekanto || SS-412",
|
||||
waktu = "08:00 - 10:30",
|
||||
hari = "Jumat",
|
||||
sks = 3
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// Filter jadwal hari ini
|
||||
val jadwalHariIni = semuaJadwal.filter { it.hari == hariIni }
|
||||
|
||||
// Get absensi hari ini dari database
|
||||
val absensiHariIni by database.absensiDao()
|
||||
.getAbsensiHariIni(tanggalHariIni)
|
||||
.collectAsStateWithLifecycle(initialValue = emptyList())
|
||||
|
||||
// Kode MK yang sudah diabsen hari ini
|
||||
val kodeMkSudahAbsen = absensiHariIni.map { it.kodeMatakuliah }
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Pilih Mata Kuliah") },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onNavigateBack) {
|
||||
Icon(Icons.Default.ArrowBack, "Back")
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = primaryGreen,
|
||||
titleContentColor = Color.White,
|
||||
navigationIconContentColor = Color.White
|
||||
)
|
||||
)
|
||||
}
|
||||
) { paddingValues ->
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues)
|
||||
) {
|
||||
|
||||
// Header Info
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = lightGreen.copy(alpha = 0.15f)
|
||||
),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.CalendarToday,
|
||||
contentDescription = null,
|
||||
tint = primaryGreen,
|
||||
modifier = Modifier.size(32.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Column {
|
||||
Text(
|
||||
text = "Jadwal Kuliah Hari Ini",
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 16.sp
|
||||
)
|
||||
Text(
|
||||
text = "$hariIni, $tanggalHariIni",
|
||||
fontSize = 13.sp,
|
||||
color = Color.Gray
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = "${jadwalHariIni.size} mata kuliah tersedia",
|
||||
fontSize = 12.sp,
|
||||
color = primaryGreen,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// List Mata Kuliah
|
||||
if (jadwalHariIni.isEmpty()) {
|
||||
// Tidak ada jadwal hari ini
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.EventBusy,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(80.dp),
|
||||
tint = Color.Gray
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text(
|
||||
text = "Tidak ada jadwal kuliah hari ini",
|
||||
fontSize = 16.sp,
|
||||
color = Color.Gray,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = "Nikmati hari liburmu! 🎉",
|
||||
fontSize = 14.sp,
|
||||
color = Color.Gray
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
items(jadwalHariIni) { matakuliah ->
|
||||
val sudahAbsen = kodeMkSudahAbsen.contains(matakuliah.kode)
|
||||
|
||||
MataKuliahCard(
|
||||
mataKuliah = matakuliah,
|
||||
sudahAbsen = sudahAbsen,
|
||||
onClick = {
|
||||
if (!sudahAbsen) {
|
||||
onMatakuliahSelected(matakuliah)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MataKuliahCard(
|
||||
mataKuliah: MataKuliah,
|
||||
sudahAbsen: Boolean,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
val primaryGreen = Color(0xFF2E7D32)
|
||||
val lightGreen = Color(0xFF66BB6A)
|
||||
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(enabled = !sudahAbsen, onClick = onClick),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
elevation = CardDefaults.cardElevation(
|
||||
defaultElevation = if (sudahAbsen) 1.dp else 3.dp
|
||||
),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = if (sudahAbsen)
|
||||
Color(0xFFF5F5F5)
|
||||
else
|
||||
Color.White
|
||||
)
|
||||
) {
|
||||
Box {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp)
|
||||
) {
|
||||
// Header - Waktu & Status
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(6.dp),
|
||||
color = if (sudahAbsen)
|
||||
Color.Gray.copy(alpha = 0.2f)
|
||||
else
|
||||
primaryGreen.copy(alpha = 0.15f)
|
||||
) {
|
||||
Text(
|
||||
text = mataKuliah.waktu,
|
||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp),
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = if (sudahAbsen) Color.Gray else primaryGreen
|
||||
)
|
||||
}
|
||||
|
||||
if (sudahAbsen) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.CheckCircle,
|
||||
contentDescription = "Sudah Absen",
|
||||
tint = primaryGreen,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text(
|
||||
text = "Sudah Absen",
|
||||
fontSize = 11.sp,
|
||||
color = primaryGreen,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// Nama Mata Kuliah
|
||||
Text(
|
||||
text = mataKuliah.nama,
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = if (sudahAbsen) Color.Gray else Color.Black
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
// Kode MK
|
||||
Text(
|
||||
text = mataKuliah.kode,
|
||||
fontSize = 12.sp,
|
||||
color = Color.Gray,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Divider()
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// Dosen
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Person,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = if (sudahAbsen) Color.Gray else Color(0xFF666666)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text(
|
||||
text = mataKuliah.dosen,
|
||||
fontSize = 13.sp,
|
||||
color = if (sudahAbsen) Color.Gray else Color(0xFF666666)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
|
||||
// Ruangan
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.LocationOn,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = if (sudahAbsen) Color.Gray else Color(0xFF666666)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text(
|
||||
text = mataKuliah.ruangan,
|
||||
fontSize = 13.sp,
|
||||
color = if (sudahAbsen) Color.Gray else Color(0xFF666666),
|
||||
maxLines = 1
|
||||
)
|
||||
}
|
||||
|
||||
if (!sudahAbsen) {
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// Button Absen Sekarang
|
||||
Button(
|
||||
onClick = onClick,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = primaryGreen
|
||||
),
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.CameraAlt,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text("ABSEN SEKARANG")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Overlay untuk card yang sudah diabsen
|
||||
if (sudahAbsen) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.matchParentSize()
|
||||
.background(Color.White.copy(alpha = 0.3f))
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* CATATAN IMPLEMENTASI:
|
||||
*
|
||||
* 1. Screen ini menampilkan jadwal kuliah HARI INI saja
|
||||
* 2. Filter otomatis MK yang sudah diabsen (disable & tampil checklist)
|
||||
* 3. Click card → navigasi ke AbsensiScreen dengan data MK
|
||||
* 4. Data jadwal hardcoded (bisa diganti dengan dari API/Database)
|
||||
* 5. Validasi sudah absen berdasarkan tanggal & kode MK
|
||||
*/
|
||||
@ -0,0 +1,272 @@
|
||||
package id.ac.ubharajaya.sistemakademik.screens
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import kotlinx.coroutines.delay
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
|
||||
/**
|
||||
* FILE: SuccessScreen.kt
|
||||
* LOKASI: screens/SuccessScreen.kt
|
||||
*
|
||||
* Deskripsi:
|
||||
* Screen konfirmasi absensi berhasil
|
||||
* Menampilkan:
|
||||
* - Icon checklist
|
||||
* - Pesan sukses
|
||||
* - Waktu absensi
|
||||
* - Tombol navigasi
|
||||
*/
|
||||
|
||||
@Composable
|
||||
fun SuccessScreen(
|
||||
onNavigateToDashboard: () -> Unit,
|
||||
onNavigateToHistory: () -> Unit
|
||||
) {
|
||||
val primaryGreen = Color(0xFF2E7D32)
|
||||
val lightGreen = Color(0xFF66BB6A)
|
||||
|
||||
// Get waktu saat ini
|
||||
val currentTime = remember {
|
||||
val calendar = Calendar.getInstance()
|
||||
val timeFormat = SimpleDateFormat("HH:mm", Locale("id", "ID"))
|
||||
val dateFormat = SimpleDateFormat("EEEE, dd MMMM yyyy", Locale("id", "ID"))
|
||||
|
||||
Pair(
|
||||
timeFormat.format(calendar.time),
|
||||
dateFormat.format(calendar.time)
|
||||
)
|
||||
}
|
||||
|
||||
// Auto navigate after 3 seconds (optional)
|
||||
var countdown by remember { mutableStateOf(5) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
while (countdown > 0) {
|
||||
delay(1000)
|
||||
countdown--
|
||||
}
|
||||
// Bisa uncomment ini kalau mau auto navigate
|
||||
// onNavigateToDashboard()
|
||||
}
|
||||
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = Color.White
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
|
||||
// Success Icon
|
||||
Card(
|
||||
modifier = Modifier.size(120.dp),
|
||||
shape = RoundedCornerShape(60.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = lightGreen.copy(alpha = 0.2f)
|
||||
)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.CheckCircle,
|
||||
contentDescription = "Success",
|
||||
modifier = Modifier.size(80.dp),
|
||||
tint = primaryGreen
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
// Title
|
||||
Text(
|
||||
text = "Absensi Berhasil!",
|
||||
fontSize = 28.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = primaryGreen,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Text(
|
||||
text = "Kehadiran Anda telah tercatat",
|
||||
fontSize = 16.sp,
|
||||
color = Color.Gray,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
// Info Card
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = lightGreen.copy(alpha = 0.1f)
|
||||
),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(20.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
// Checklist items
|
||||
CheckItem("✓ Lokasi Tervalidasi", primaryGreen)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
CheckItem("✓ Foto Terekam", primaryGreen)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Divider()
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Waktu
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.AccessTime,
|
||||
contentDescription = null,
|
||||
tint = Color.Gray,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Column {
|
||||
Text(
|
||||
text = "Waktu: ${currentTime.first} WIB",
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 14.sp
|
||||
)
|
||||
Text(
|
||||
text = currentTime.second,
|
||||
fontSize = 12.sp,
|
||||
color = Color.Gray
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// Status
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.CloudDone,
|
||||
contentDescription = null,
|
||||
tint = primaryGreen,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = "Berhasil tercatat!",
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 14.sp,
|
||||
color = primaryGreen
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
// Button Lihat Riwayat
|
||||
Button(
|
||||
onClick = onNavigateToHistory,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(50.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = primaryGreen
|
||||
),
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.History,
|
||||
contentDescription = null
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text("LIHAT RIWAYAT")
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// Button Kembali ke Dashboard
|
||||
OutlinedButton(
|
||||
onClick = onNavigateToDashboard,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(50.dp),
|
||||
colors = ButtonDefaults.outlinedButtonColors(
|
||||
contentColor = primaryGreen
|
||||
),
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Home,
|
||||
contentDescription = null
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text("KEMBALI KE MENU")
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
// Countdown info (optional)
|
||||
if (countdown > 0) {
|
||||
Text(
|
||||
text = "Otomatis kembali dalam $countdown detik",
|
||||
fontSize = 12.sp,
|
||||
color = Color.Gray
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CheckItem(text: String, color: Color) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
fontSize = 14.sp,
|
||||
color = color,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* CATATAN IMPLEMENTASI:
|
||||
*
|
||||
* 1. Screen ini menampilkan konfirmasi bahwa absensi berhasil
|
||||
*
|
||||
* 2. Menampilkan waktu absensi yang tepat
|
||||
*
|
||||
* 3. Ada 2 tombol navigasi:
|
||||
* - Lihat Riwayat: ke HistoryScreen
|
||||
* - Kembali ke Menu: ke Dashboard
|
||||
*
|
||||
* 4. Optional: Auto navigate setelah beberapa detik
|
||||
*/
|
||||
@ -0,0 +1,66 @@
|
||||
package id.ac.ubharajaya.sistemakademik.utils
|
||||
|
||||
/**
|
||||
* FILE: Constants.kt
|
||||
* LOKASI: utils/Constants.kt
|
||||
*
|
||||
* Deskripsi:
|
||||
* File ini berisi konstanta-konstanta yang digunakan di seluruh aplikasi
|
||||
* seperti koordinat kampus, radius validasi, URL webhook, dll.
|
||||
*/
|
||||
|
||||
object Constants {
|
||||
|
||||
// ==================== DATA MAHASISWA ====================
|
||||
const val NPM = "202310715176"
|
||||
const val NAMA = "HAGA DALPINTO GINTING"
|
||||
|
||||
// ==================== KOORDINAT KAMPUS ====================
|
||||
// Universitas Bhayangkara Jakarta Raya - Bekasi Utara
|
||||
// Jl. Raya Perjuangan No.81, Bekasi Utara
|
||||
const val KAMPUS_LATITUDE = -6.224190375334469
|
||||
const val KAMPUS_LONGITUDE = 107.00928773168418
|
||||
const val KAMPUS_NAMA = "Universitas Bhayangkara Jakarta Raya"
|
||||
const val KAMPUS_ALAMAT = "Jl. Raya Perjuangan No.81, Bekasi Utara"
|
||||
|
||||
// ==================== VALIDASI LOKASI ====================
|
||||
// Radius dalam METER untuk validasi absensi
|
||||
// Mahasiswa harus berada dalam radius ini dari koordinat kampus
|
||||
const val RADIUS_METER = 100.0 // 100 meter
|
||||
|
||||
// Untuk testing, bisa diubah jadi lebih besar:
|
||||
// const val RADIUS_METER = 5000.0 // 5 km untuk testing
|
||||
|
||||
// ==================== WEBHOOK URLs ====================
|
||||
// URL untuk mengirim data absensi ke server N8N
|
||||
const val WEBHOOK_URL_PRODUCTION = "https://n8n.lab.ubharajaya.ac.id/webhook/23c6993d-1792-48fb-ad1c-ffc78a3e6254"
|
||||
const val WEBHOOK_URL_TEST = "https://n8n.lab.ubharajaya.ac.id/webhook-test/23c6993d-1792-48fb-ad1c-ffc78a3e6254"
|
||||
|
||||
// Pilih URL yang aktif (ubah ke TEST untuk testing)
|
||||
const val WEBHOOK_URL_ACTIVE = WEBHOOK_URL_PRODUCTION
|
||||
|
||||
// ==================== PREFERENCES KEYS ====================
|
||||
// Key untuk menyimpan data di SharedPreferences/DataStore
|
||||
const val PREF_NPM = "pref_npm"
|
||||
const val PREF_NAMA = "pref_nama"
|
||||
const val PREF_IS_LOGGED_IN = "pref_is_logged_in"
|
||||
|
||||
// ==================== DATABASE ====================
|
||||
const val DATABASE_NAME = "absensi_database"
|
||||
const val DATABASE_VERSION = 1
|
||||
|
||||
// ==================== MESSAGES ====================
|
||||
const val MSG_LOKASI_VALID = "✓ Lokasi Valid - Dalam Area Kampus"
|
||||
const val MSG_LOKASI_INVALID = "✗ Lokasi Tidak Valid - Di Luar Area Kampus"
|
||||
const val MSG_MENUNGGU_LOKASI = "⌛ Menunggu Data Lokasi..."
|
||||
const val MSG_FOTO_BELUM_DIAMBIL = "Silakan ambil foto terlebih dahulu"
|
||||
const val MSG_ABSENSI_BERHASIL = "Absensi Berhasil Dicatat!"
|
||||
const val MSG_ABSENSI_GAGAL = "Absensi Gagal!"
|
||||
|
||||
// ==================== PERMISSIONS ====================
|
||||
val REQUIRED_PERMISSIONS = arrayOf(
|
||||
android.Manifest.permission.ACCESS_FINE_LOCATION,
|
||||
android.Manifest.permission.ACCESS_COARSE_LOCATION,
|
||||
android.Manifest.permission.CAMERA
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,112 @@
|
||||
package id.ac.ubharajaya.sistemakademik.utils
|
||||
|
||||
import kotlin.math.*
|
||||
|
||||
/**
|
||||
* FILE: LocationValidator.kt
|
||||
* LOKASI: utils/LocationValidator.kt
|
||||
*
|
||||
* Deskripsi:
|
||||
* Class untuk menghitung jarak antara 2 koordinat menggunakan Haversine Formula
|
||||
* dan memvalidasi apakah lokasi mahasiswa berada dalam radius kampus
|
||||
*/
|
||||
|
||||
object LocationValidator {
|
||||
|
||||
// Radius bumi dalam meter
|
||||
private const val EARTH_RADIUS_METER = 6371000.0
|
||||
|
||||
/**
|
||||
* Menghitung jarak antara 2 koordinat menggunakan Haversine Formula
|
||||
*
|
||||
* Formula ini digunakan untuk menghitung jarak terpendek antara 2 titik
|
||||
* di permukaan bumi (great-circle distance)
|
||||
*
|
||||
* @param lat1 Latitude titik 1 (dalam derajat)
|
||||
* @param lon1 Longitude titik 1 (dalam derajat)
|
||||
* @param lat2 Latitude titik 2 (dalam derajat)
|
||||
* @param lon2 Longitude titik 2 (dalam derajat)
|
||||
* @return Jarak dalam METER
|
||||
*/
|
||||
fun calculateDistance(
|
||||
lat1: Double,
|
||||
lon1: Double,
|
||||
lat2: Double,
|
||||
lon2: Double
|
||||
): Double {
|
||||
// Konversi derajat ke radian
|
||||
val lat1Rad = Math.toRadians(lat1)
|
||||
val lon1Rad = Math.toRadians(lon1)
|
||||
val lat2Rad = Math.toRadians(lat2)
|
||||
val lon2Rad = Math.toRadians(lon2)
|
||||
|
||||
// Selisih koordinat
|
||||
val dLat = lat2Rad - lat1Rad
|
||||
val dLon = lon2Rad - lon1Rad
|
||||
|
||||
// Haversine Formula
|
||||
val a = sin(dLat / 2).pow(2) +
|
||||
cos(lat1Rad) * cos(lat2Rad) *
|
||||
sin(dLon / 2).pow(2)
|
||||
|
||||
val c = 2 * atan2(sqrt(a), sqrt(1 - a))
|
||||
|
||||
// Jarak dalam meter
|
||||
return EARTH_RADIUS_METER * c
|
||||
}
|
||||
|
||||
/**
|
||||
* Validasi apakah lokasi mahasiswa berada dalam radius kampus
|
||||
*
|
||||
* @param userLat Latitude mahasiswa
|
||||
* @param userLon Longitude mahasiswa
|
||||
* @param campusLat Latitude kampus
|
||||
* @param campusLon Longitude kampus
|
||||
* @param radiusMeter Radius validasi dalam meter
|
||||
* @return Pair(isValid, distance)
|
||||
* - isValid: true jika dalam radius, false jika di luar
|
||||
* - distance: jarak dalam meter
|
||||
*/
|
||||
fun isLocationValid(
|
||||
userLat: Double,
|
||||
userLon: Double,
|
||||
campusLat: Double = Constants.KAMPUS_LATITUDE,
|
||||
campusLon: Double = Constants.KAMPUS_LONGITUDE,
|
||||
radiusMeter: Double = Constants.RADIUS_METER
|
||||
): Pair<Boolean, Double> {
|
||||
|
||||
val distance = calculateDistance(userLat, userLon, campusLat, campusLon)
|
||||
val isValid = distance <= radiusMeter
|
||||
|
||||
return Pair(isValid, distance)
|
||||
}
|
||||
|
||||
/**
|
||||
* Format jarak ke string yang mudah dibaca
|
||||
*
|
||||
* @param distanceMeter Jarak dalam meter
|
||||
* @return String format "X.XX m" atau "X.XX km"
|
||||
*/
|
||||
fun formatDistance(distanceMeter: Double): String {
|
||||
return if (distanceMeter < 1000) {
|
||||
"%.0f m".format(distanceMeter)
|
||||
} else {
|
||||
"%.2f km".format(distanceMeter / 1000)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get status message berdasarkan validasi lokasi
|
||||
*
|
||||
* @param isValid Apakah lokasi valid
|
||||
* @param distance Jarak dari kampus
|
||||
* @return Pesan status
|
||||
*/
|
||||
fun getStatusMessage(isValid: Boolean, distance: Double): String {
|
||||
return if (isValid) {
|
||||
"${Constants.MSG_LOKASI_VALID}\nJarak: ${formatDistance(distance)}"
|
||||
} else {
|
||||
"${Constants.MSG_LOKASI_INVALID}\nJarak: ${formatDistance(distance)}"
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user