ADDING SCREENS SETIAP FITUR DAN MATKUL SAAT ABSENSI

This commit is contained in:
HagaDalpintoGinting 2026-01-14 16:30:30 +07:00
parent 926d3e0a14
commit ce05e1a617
20 changed files with 3714 additions and 274 deletions

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

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

8
.idea/markdown.xml generated Normal file
View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="MarkdownSettings">
<option name="previewPanelProviderInfo">
<ProviderInfo name="Compose (experimental)" className="com.intellij.markdown.compose.preview.ComposePanelProvider" />
</option>
</component>
</project>

View File

@ -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

View File

@ -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
*/

View File

@ -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"/>
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
<uses-permission android:name="android.permission.CAMERA"/>
<!-- ==================== 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
-->

View File

@ -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"
@Composable
fun AbsensiScreen(
modifier: Modifier = Modifier,
activity: ComponentActivity
) {
val context = LocalContext.current
var lokasi by remember { mutableStateOf("Koordinat: -") }
var latitude by remember { mutableStateOf<Double?>(null) }
var longitude by remember { mutableStateOf<Double?>(null) }
var foto by remember { mutableStateOf<Bitmap?>(null) }
val fusedLocationClient =
LocationServices.getFusedLocationProviderClient(context)
/* ===== Permission Lokasi ===== */
val locationPermissionLauncher =
rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission()
) { granted ->
if (granted) {
if (
ContextCompat.checkSelfPermission(
context,
Manifest.permission.ACCESS_FINE_LOCATION
) == PackageManager.PERMISSION_GRANTED
) {
fusedLocationClient.lastLocation
.addOnSuccessListener { location ->
if (location != null) {
latitude = location.latitude
longitude = location.longitude
lokasi =
"Lat: ${location.latitude}\nLon: ${location.longitude}"
} else {
lokasi = "Lokasi tidak tersedia"
}
}
.addOnFailureListener {
lokasi = "Gagal mengambil lokasi"
}
}
} else {
Toast.makeText(
context,
"Izin lokasi ditolak",
Toast.LENGTH_SHORT
).show()
}
}
/* ===== Kamera ===== */
val cameraLauncher =
rememberLauncherForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { result ->
if (result.resultCode == Activity.RESULT_OK) {
val bitmap =
result.data?.extras?.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
)
fun createAbsensiRoute(mataKuliah: MataKuliah): String {
val json = Gson().toJson(mataKuliah)
val encodedJson = URLEncoder.encode(json, StandardCharsets.UTF_8.toString())
return "absensi/$encodedJson"
}
}
/* ===== UI ===== */
/**
* App Navigation
*/
@Composable
fun AppNavigation() {
val navController = rememberNavController()
val gson = Gson()
Column(
modifier = modifier
.fillMaxSize()
.padding(24.dp),
verticalArrangement = Arrangement.Center
NavHost(
navController = navController,
startDestination = Routes.LOGIN
) {
Text(
text = "Absensi Akademik",
style = MaterialTheme.typography.titleLarge
)
Spacer(modifier = Modifier.height(16.dp))
Text(text = lokasi)
Spacer(modifier = Modifier.height(16.dp))
Button(
onClick = {
cameraPermissionLauncher.launch(
Manifest.permission.CAMERA
)
},
modifier = Modifier.fillMaxWidth()
) {
Text("Ambil Foto")
// Login Screen
composable(Routes.LOGIN) {
LoginScreen(
onLoginSuccess = {
navController.navigate(Routes.DASHBOARD) {
popUpTo(Routes.LOGIN) { inclusive = true }
}
}
)
}
Spacer(modifier = Modifier.height(12.dp))
Button(
onClick = {
if (latitude != null && longitude != null && foto != null) {
kirimKeN8n(
activity,
latitude!!,
longitude!!,
foto!!
)
} else {
Toast.makeText(
context,
"Lokasi atau foto belum lengkap",
Toast.LENGTH_SHORT
).show()
// Dashboard Screen
composable(Routes.DASHBOARD) {
DashboardScreen(
onNavigateToAbsensi = {
// Navigasi ke SelectMatakuliah dulu (BARU!)
navController.navigate(Routes.SELECT_MATAKULIAH)
},
onNavigateToHistory = {
navController.navigate(Routes.HISTORY)
},
onNavigateToSchedule = {
navController.navigate(Routes.SCHEDULE)
},
onLogout = {
navController.navigate(Routes.LOGIN) {
popUpTo(0) { inclusive = true }
}
}
},
modifier = Modifier.fillMaxWidth()
) {
Text("Kirim Absensi")
)
}
// 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)
}
)
}
// 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)
}
},
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
*/

View File

@ -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()
}

View File

@ -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
)

View File

@ -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())
*/

View File

@ -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
*/

View File

@ -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()
* }
*/

View File

@ -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")
}
}
}

View File

@ -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
*/

View File

@ -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
*/

View File

@ -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
*/

View File

@ -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
*/

View File

@ -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
*/

View File

@ -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
*/

View File

@ -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
)
}

View File

@ -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)}"
}
}
}