diff --git a/.idea/deviceManager.xml b/.idea/deviceManager.xml
new file mode 100644
index 0000000..91f9558
--- /dev/null
+++ b/.idea/deviceManager.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/markdown.xml b/.idea/markdown.xml
new file mode 100644
index 0000000..c61ea33
--- /dev/null
+++ b/.idea/markdown.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/README.md b/README.md
index a07d04a..3ba104d 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 7d76378..6069b93 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -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)
-}
\ No newline at end of file
+ // ==================== 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
+ */
\ No newline at end of file
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 4619836..754d3f8 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -1,14 +1,52 @@
+
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ android:theme="@style/Theme.SistemAkademik"
+ android:usesCleartextTraffic="true"
+ tools:targetApi="31">
+
+
+ android:theme="@style/Theme.SistemAkademik"
+ android:screenOrientation="portrait"
+ android:configChanges="orientation|screenSize">
+
-
+
+
+
+
-
\ No newline at end of file
+
+
+
\ No newline at end of file
diff --git a/app/src/main/java/id/ac/ubharajaya/sistemakademik/MainActivity.kt b/app/src/main/java/id/ac/ubharajaya/sistemakademik/MainActivity.kt
index c774502..4c38e26 100644
--- a/app/src/main/java/id/ac/ubharajaya/sistemakademik/MainActivity.kt
+++ b/app/src/main/java/id/ac/ubharajaya/sistemakademik/MainActivity.kt
@@ -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(null) }
- var longitude by remember { mutableStateOf(null) }
- var foto by remember { mutableStateOf(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
+ */
\ No newline at end of file
diff --git a/app/src/main/java/id/ac/ubharajaya/sistemakademik/data/AbsensiDao.kt b/app/src/main/java/id/ac/ubharajaya/sistemakademik/data/AbsensiDao.kt
new file mode 100644
index 0000000..39816ba
--- /dev/null
+++ b/app/src/main/java/id/ac/ubharajaya/sistemakademik/data/AbsensiDao.kt
@@ -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>
+
+ /**
+ * Get data absensi berdasarkan NPM
+ */
+ @Query("SELECT * FROM absensi WHERE npm = :npm ORDER BY timestamp DESC")
+ fun getAbsensiByNpm(npm: String): Flow>
+
+ /**
+ * 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>
+
+ /**
+ * Get absensi yang belum terkirim ke server
+ */
+ @Query("SELECT * FROM absensi WHERE statusKirim = 0 ORDER BY timestamp ASC")
+ suspend fun getAbsensiPending(): List
+
+ /**
+ * 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()
+}
\ No newline at end of file
diff --git a/app/src/main/java/id/ac/ubharajaya/sistemakademik/data/AbsensiEntity.kt b/app/src/main/java/id/ac/ubharajaya/sistemakademik/data/AbsensiEntity.kt
new file mode 100644
index 0000000..efa6d0b
--- /dev/null
+++ b/app/src/main/java/id/ac/ubharajaya/sistemakademik/data/AbsensiEntity.kt
@@ -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
+)
\ No newline at end of file
diff --git a/app/src/main/java/id/ac/ubharajaya/sistemakademik/data/AppDatabase.kt b/app/src/main/java/id/ac/ubharajaya/sistemakademik/data/AppDatabase.kt
new file mode 100644
index 0000000..faaa8a6
--- /dev/null
+++ b/app/src/main/java/id/ac/ubharajaya/sistemakademik/data/AppDatabase.kt
@@ -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())
+ */
\ No newline at end of file
diff --git a/app/src/main/java/id/ac/ubharajaya/sistemakademik/data/Matakuliah.kt b/app/src/main/java/id/ac/ubharajaya/sistemakademik/data/Matakuliah.kt
new file mode 100644
index 0000000..9c30140
--- /dev/null
+++ b/app/src/main/java/id/ac/ubharajaya/sistemakademik/data/Matakuliah.kt
@@ -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
+ */
\ No newline at end of file
diff --git a/app/src/main/java/id/ac/ubharajaya/sistemakademik/data/UserPreferences.kt b/app/src/main/java/id/ac/ubharajaya/sistemakademik/data/UserPreferences.kt
new file mode 100644
index 0000000..d4795c0
--- /dev/null
+++ b/app/src/main/java/id/ac/ubharajaya/sistemakademik/data/UserPreferences.kt
@@ -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 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 = context.dataStore.data
+ .map { preferences ->
+ preferences[PreferenceKeys.NPM] ?: ""
+ }
+
+ /**
+ * Get nama user
+ */
+ val nama: Flow = context.dataStore.data
+ .map { preferences ->
+ preferences[PreferenceKeys.NAMA] ?: ""
+ }
+
+ /**
+ * Get status login
+ */
+ val isLoggedIn: Flow = 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 = 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()
+ * }
+ */
\ No newline at end of file
diff --git a/app/src/main/java/id/ac/ubharajaya/sistemakademik/screens/AbsensiScreen.kt b/app/src/main/java/id/ac/ubharajaya/sistemakademik/screens/AbsensiScreen.kt
new file mode 100644
index 0000000..88eeb85
--- /dev/null
+++ b/app/src/main/java/id/ac/ubharajaya/sistemakademik/screens/AbsensiScreen.kt
@@ -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(null) }
+ var longitude by remember { mutableStateOf(null) }
+ var isLocationValid by remember { mutableStateOf(false) }
+ var distance by remember { mutableStateOf(0.0) }
+ var foto by remember { mutableStateOf(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")
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/id/ac/ubharajaya/sistemakademik/screens/DashboardScreen.kt b/app/src/main/java/id/ac/ubharajaya/sistemakademik/screens/DashboardScreen.kt
new file mode 100644
index 0000000..8631fbe
--- /dev/null
+++ b/app/src/main/java/id/ac/ubharajaya/sistemakademik/screens/DashboardScreen.kt
@@ -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
+ */
\ No newline at end of file
diff --git a/app/src/main/java/id/ac/ubharajaya/sistemakademik/screens/HistoryScreen.kt b/app/src/main/java/id/ac/ubharajaya/sistemakademik/screens/HistoryScreen.kt
new file mode 100644
index 0000000..e61f057
--- /dev/null
+++ b/app/src/main/java/id/ac/ubharajaya/sistemakademik/screens/HistoryScreen.kt
@@ -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(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
+ */
\ No newline at end of file
diff --git a/app/src/main/java/id/ac/ubharajaya/sistemakademik/screens/LoginScreen.kt b/app/src/main/java/id/ac/ubharajaya/sistemakademik/screens/LoginScreen.kt
new file mode 100644
index 0000000..f75dbe8
--- /dev/null
+++ b/app/src/main/java/id/ac/ubharajaya/sistemakademik/screens/LoginScreen.kt
@@ -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
+ */
\ No newline at end of file
diff --git a/app/src/main/java/id/ac/ubharajaya/sistemakademik/screens/ScheduleScreen.kt b/app/src/main/java/id/ac/ubharajaya/sistemakademik/screens/ScheduleScreen.kt
new file mode 100644
index 0000000..9d494ec
--- /dev/null
+++ b/app/src/main/java/id/ac/ubharajaya/sistemakademik/screens/ScheduleScreen.kt
@@ -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
+ */
\ No newline at end of file
diff --git a/app/src/main/java/id/ac/ubharajaya/sistemakademik/screens/SelectMataKuliahScreen.kt b/app/src/main/java/id/ac/ubharajaya/sistemakademik/screens/SelectMataKuliahScreen.kt
new file mode 100644
index 0000000..71bff03
--- /dev/null
+++ b/app/src/main/java/id/ac/ubharajaya/sistemakademik/screens/SelectMataKuliahScreen.kt
@@ -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
+ */
\ No newline at end of file
diff --git a/app/src/main/java/id/ac/ubharajaya/sistemakademik/screens/SuccsessScreen.kt b/app/src/main/java/id/ac/ubharajaya/sistemakademik/screens/SuccsessScreen.kt
new file mode 100644
index 0000000..625e47e
--- /dev/null
+++ b/app/src/main/java/id/ac/ubharajaya/sistemakademik/screens/SuccsessScreen.kt
@@ -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
+ */
\ No newline at end of file
diff --git a/app/src/main/java/id/ac/ubharajaya/sistemakademik/utils/Constants.kt b/app/src/main/java/id/ac/ubharajaya/sistemakademik/utils/Constants.kt
new file mode 100644
index 0000000..4195708
--- /dev/null
+++ b/app/src/main/java/id/ac/ubharajaya/sistemakademik/utils/Constants.kt
@@ -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
+ )
+}
\ No newline at end of file
diff --git a/app/src/main/java/id/ac/ubharajaya/sistemakademik/utils/LocationValidator.kt b/app/src/main/java/id/ac/ubharajaya/sistemakademik/utils/LocationValidator.kt
new file mode 100644
index 0000000..4a3b8db
--- /dev/null
+++ b/app/src/main/java/id/ac/ubharajaya/sistemakademik/utils/LocationValidator.kt
@@ -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 {
+
+ 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)}"
+ }
+ }
+}
\ No newline at end of file