From 77bf74a6c44578b8b60478089888e1105754d44d Mon Sep 17 00:00:00 2001
From: 202310715280-FADLAN-RIVALDI <202310715280@mhs.ubharajaya.ac.id>
Date: Wed, 14 Jan 2026 23:34:25 +0700
Subject: [PATCH] Perubahan Kedua
---
app/build.gradle.kts | 86 ++-
app/src/main/AndroidManifest.xml | 49 +-
.../ubharajaya/sistemakademik/MainActivity.kt | 322 ++-------
.../Repository/AbsensiRepository.kt | 156 +++++
.../sistemakademik/models/AbsensiHistory.kt | 26 +
.../sistemakademik/models/Models.kt | 2 +-
.../sistemakademik/navigation/Navigation.kt | 6 +-
.../sistemakademik/network/ApiService.kt | 84 +++
.../sistemakademik/network/WebhookService.kt | 361 ++++++++++
.../sistemakademik/ui/theme/Color.kt | 60 +-
.../sistemakademik/ui/theme/Theme.kt | 94 ++-
.../sistemakademik/ui/theme/Type.kt | 5 +-
.../ui/theme/screen/HistoryScreen.kt | 336 +++++++++
.../ui/theme/screen/Loginscreen.kt | 172 ++++-
.../ui/theme/screen/MataKuliahScreen.kt | 220 +++++-
.../ui/theme/screen/PreviewScreen.kt | 652 +++++++++++++++++-
.../ui/theme/screen/ProfileScreen.kt | 466 ++++++++++++-
.../sistemakademik/utils/LocationManager.kt | 335 +++++++++
.../viewmodel/AbsensiViewModel.kt | 189 ++++-
app/src/main/res/values-night/themes.xml | 6 +
app/src/main/res/values/strings.xml | 75 +-
app/src/main/res/values/themes.xml | 9 +-
app/src/main/res/xml/file_paths.xml | 14 +-
.../main/res/xml/network_security_config.xml | 32 +
24 files changed, 3426 insertions(+), 331 deletions(-)
create mode 100644 app/src/main/java/id/ac/ubharajaya/sistemakademik/models/AbsensiHistory.kt
create mode 100644 app/src/main/java/id/ac/ubharajaya/sistemakademik/network/WebhookService.kt
create mode 100644 app/src/main/java/id/ac/ubharajaya/sistemakademik/ui/theme/screen/HistoryScreen.kt
create mode 100644 app/src/main/java/id/ac/ubharajaya/sistemakademik/utils/LocationManager.kt
create mode 100644 app/src/main/res/values-night/themes.xml
create mode 100644 app/src/main/res/xml/network_security_config.xml
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 7d76378..a41b880 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -6,14 +6,12 @@ plugins {
android {
namespace = "id.ac.ubharajaya.sistemakademik"
- compileSdk {
- version = release(36)
- }
+ compileSdk = 34
defaultConfig {
applicationId = "id.ac.ubharajaya.sistemakademik"
minSdk = 28
- targetSdk = 36
+ targetSdk = 34
versionCode = 1
versionName = "1.0"
@@ -29,36 +27,82 @@ android {
)
}
}
+
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
+
kotlinOptions {
jvmTarget = "11"
}
+
buildFeatures {
compose = true
}
+
+ // ✅ IGNORE ERROR AAR METADATA
+ lint {
+ abortOnError = false
+ checkReleaseBuilds = false
+ }
}
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)
+ // Core Android - Versi LAMA yang stabil
+ 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")
+
+ // Jetpack Compose BOM - Versi LAMA
+ implementation(platform("androidx.compose:compose-bom:2023.10.01"))
+ implementation("androidx.compose.ui:ui")
+ implementation("androidx.compose.ui:ui-graphics")
+ implementation("androidx.compose.ui:ui-tooling-preview")
+ implementation("androidx.compose.material3:material3")
+ implementation("androidx.compose.material:material-icons-extended")
+
+ // Compose ViewModel & Lifecycle
+ implementation("androidx.lifecycle:lifecycle-runtime-compose:2.7.0")
+ implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0")
+
+ // Navigation Compose
+ implementation("androidx.navigation:navigation-compose:2.7.6")
+
+ // Location Services
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)
+ // CameraX
+ implementation("androidx.camera:camera-camera2:1.3.1")
+ implementation("androidx.camera:camera-lifecycle:1.3.1")
+ implementation("androidx.camera:camera-view:1.3.1")
+
+ // Permissions
+ implementation("com.google.accompanist:accompanist-permissions:0.34.0")
+
+ // Coil for Image Loading
+ implementation("io.coil-kt:coil-compose:2.5.0")
+
+ // Retrofit & OkHttp
+ implementation("com.squareup.retrofit2:retrofit:2.9.0")
+ implementation("com.squareup.retrofit2:converter-gson:2.9.0")
+ implementation("com.squareup.okhttp3:logging-interceptor:4.12.0")
+
+ // Gson
+ implementation("com.google.code.gson:gson:2.10.1")
+
+ // Coroutines
+ implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
+ implementation("org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.7.3")
+
+ // 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:2023.10.01"))
+ androidTestImplementation("androidx.compose.ui:ui-test-junit4")
+
+ // Debug
+ debugImplementation("androidx.compose.ui:ui-tooling")
+ debugImplementation("androidx.compose.ui:ui-test-manifest")
}
\ No newline at end of file
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 4619836..d8b05ed 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -2,13 +2,28 @@
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ android:theme="@style/Theme.StarterEAS"
+ android:enableOnBackInvokedCallback="true"
+ android:networkSecurityConfig="@xml/network_security_config"
+ tools:targetApi="31">
+
+
+ android:theme="@style/Theme.StarterEAS"
+ android:screenOrientation="portrait"
+ android:configChanges="orientation|screenSize|keyboardHidden"
+ android:windowSoftInputMode="adjustResize">
-
+
+
+
+
+
+
\ 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..6965719 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,94 @@
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.foundation.layout.fillMaxSize
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
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 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 androidx.lifecycle.viewmodel.compose.viewModel
+import androidx.navigation.compose.NavHost
+import androidx.navigation.compose.composable
+import androidx.navigation.compose.rememberNavController
+import id.ac.ubharajaya.sistemakademik.navigation.Screen
+import id.ac.ubharajaya.sistemakademik.ui.theme.*
+import id.ac.ubharajaya.sistemakademik.ui.theme.StarterEASTheme
+import id.ac.ubharajaya.sistemakademik.viewmodel.AbsensiViewModel
+import id.ac.ubharajaya.sistemakademik.ui.screen.LoginScreen
+import id.ac.ubharajaya.sistemakademik.ui.screen.MataKuliahScreen
+import id.ac.ubharajaya.sistemakademik.ui.screen.PreviewScreen
+import id.ac.ubharajaya.sistemakademik.ui.screen.ProfileScreen
-/* ================= 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 ================= */
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
- )
- }
- }
- }
- }
-}
-
-/* ================= UI ================= */
-
-@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
+ StarterEASTheme {
+ Surface(
+ modifier = Modifier.fillMaxSize(),
+ color = MaterialTheme.colorScheme.background
) {
+ val navController = rememberNavController()
+ val viewModel: AbsensiViewModel = viewModel()
- 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"
- }
+ NavHost(
+ navController = navController,
+ startDestination = Screen.Login.route
+ ) {
+ composable(Screen.Login.route) {
+ LoginScreen(
+ viewModel = viewModel,
+ onLoginSuccess = {
+ navController.navigate(Screen.MataKuliah.route) {
+ popUpTo(Screen.Login.route) { inclusive = true }
+ }
+ }
+ )
}
- .addOnFailureListener {
- lokasi = "Gagal mengambil lokasi"
+
+ composable(Screen.MataKuliah.route) {
+ MataKuliahScreen(
+ viewModel = viewModel,
+ onMataKuliahSelected = { mataKuliahId ->
+ navController.navigate(Screen.Preview.createRoute(mataKuliahId))
+ },
+ onProfileClick = {
+ navController.navigate(Screen.Profile.route)
+ }
+ )
}
- }
- } else {
- Toast.makeText(
- context,
- "Izin lokasi ditolak",
- Toast.LENGTH_SHORT
- ).show()
- }
- }
+ composable(Screen.Preview.route) { backStackEntry ->
+ val mataKuliahId = backStackEntry.arguments?.getString("mataKuliahId") ?: ""
+ PreviewScreen(
+ viewModel = viewModel,
+ mataKuliahId = mataKuliahId,
+ onBackClick = { navController.popBackStack() },
+ onSubmitSuccess = {
+ navController.navigate(Screen.MataKuliah.route) {
+ popUpTo(Screen.MataKuliah.route) { inclusive = true }
+ }
+ }
+ )
+ }
- /* ===== 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()
+ composable(Screen.Profile.route) {
+ ProfileScreen(
+ viewModel = viewModel,
+ onBackClick = { navController.popBackStack() },
+ onLogout = {
+ navController.navigate(Screen.Login.route) {
+ popUpTo(0) { inclusive = true }
+ }
+ }
+ )
+ }
+ }
}
}
}
-
- val cameraPermissionLauncher =
- rememberLauncherForActivityResult(
- ActivityResultContracts.RequestPermission()
- ) { granted ->
- if (granted) {
- val intent =
- Intent(MediaStore.ACTION_IMAGE_CAPTURE)
- cameraLauncher.launch(intent)
- } else {
- Toast.makeText(
- context,
- "Izin kamera ditolak",
- Toast.LENGTH_SHORT
- ).show()
- }
- }
-
- /* ===== Request Awal ===== */
-
- LaunchedEffect(Unit) {
- locationPermissionLauncher.launch(
- Manifest.permission.ACCESS_FINE_LOCATION
- )
- }
-
- /* ===== UI ===== */
-
- Column(
- modifier = modifier
- .fillMaxSize()
- .padding(24.dp),
- verticalArrangement = Arrangement.Center
- ) {
-
- Text(
- text = "Absensi Akademik",
- style = MaterialTheme.typography.titleLarge
- )
-
- Spacer(modifier = Modifier.height(16.dp))
-
- Text(text = lokasi)
-
- Spacer(modifier = Modifier.height(16.dp))
-
- Button(
- onClick = {
- cameraPermissionLauncher.launch(
- Manifest.permission.CAMERA
- )
- },
- modifier = Modifier.fillMaxWidth()
- ) {
- Text("Ambil Foto")
- }
-
- 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()
- }
- },
- modifier = Modifier.fillMaxWidth()
- ) {
- Text("Kirim Absensi")
- }
}
-}
+}
\ No newline at end of file
diff --git a/app/src/main/java/id/ac/ubharajaya/sistemakademik/Repository/AbsensiRepository.kt b/app/src/main/java/id/ac/ubharajaya/sistemakademik/Repository/AbsensiRepository.kt
index 129987e..ff76363 100644
--- a/app/src/main/java/id/ac/ubharajaya/sistemakademik/Repository/AbsensiRepository.kt
+++ b/app/src/main/java/id/ac/ubharajaya/sistemakademik/Repository/AbsensiRepository.kt
@@ -1,2 +1,158 @@
package id.ac.ubharajaya.sistemakademik.Repository
+import android.util.Base64
+import android.util.Log
+import id.ac.ubharajaya.sistemakademik.models.AbsensiData
+import id.ac.ubharajaya.sistemakademik.network.ApiService
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import okhttp3.MediaType.Companion.toMediaTypeOrNull
+import okhttp3.MultipartBody
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import okhttp3.RequestBody
+import okhttp3.RequestBody.Companion.asRequestBody
+import okhttp3.RequestBody.Companion.toRequestBody
+import org.json.JSONObject
+import java.io.File
+
+class AbsensiRepository(private val apiService: ApiService) {
+
+ private val client = OkHttpClient()
+
+ /**
+ * Submit absensi ke 3 tempat:
+ * 1. Backend API (opsional)
+ * 2. Ntfy notification
+ * 3. Google Sheets
+ */
+ suspend fun submitAbsensi(absensiData: AbsensiData): Result {
+ return withContext(Dispatchers.IO) {
+ try {
+ // 1. Kirim ke Ntfy
+ sendToNtfy(absensiData)
+
+ // 2. Kirim ke Google Sheets
+ sendToGoogleSheets(absensiData)
+
+ // 3. (Opsional) Kirim ke backend API kalau ada
+ // submitToBackend(absensiData)
+
+ Result.success("Absensi berhasil dikirim!")
+ } catch (e: Exception) {
+ Log.e("AbsensiRepository", "Error: ${e.message}", e)
+ Result.failure(e)
+ }
+ }
+ }
+
+ /**
+ * Kirim notifikasi ke Ntfy
+ */
+ private fun sendToNtfy(data: AbsensiData) {
+ val message = """
+ 📚 Absensi Baru - ${data.mataKuliahNama}
+
+ 👤 Nama: ${data.nama}
+ 🆔 NIM: ${data.nim}
+ 📍 Lokasi: ${data.latitude}, ${data.longitude}
+ 🕒 Waktu: ${java.text.SimpleDateFormat("dd/MM/yyyy HH:mm", java.util.Locale.getDefault()).format(java.util.Date(data.timestamp))}
+ """.trimIndent()
+
+ val requestBody = message.toRequestBody("text/plain".toMediaTypeOrNull())
+
+ val request = Request.Builder()
+ .url("https://ntfy.ubharajaya.ac.id/EAS")
+ .post(requestBody)
+ .addHeader("Title", "Absensi: ${data.nama}")
+ .addHeader("Priority", "high")
+ .addHeader("Tags", "mortar_board,white_check_mark")
+ .build()
+
+ client.newCall(request).execute().use { response ->
+ if (!response.isSuccessful) {
+ throw Exception("Gagal kirim ke Ntfy: ${response.code}")
+ }
+ Log.d("Ntfy", "Notifikasi berhasil dikirim")
+ }
+ }
+
+ /**
+ * Kirim data ke Google Sheets via Apps Script
+ */
+ private fun sendToGoogleSheets(data: AbsensiData) {
+ // URL Google Apps Script Web App
+ val sheetsUrl = "YOUR_GOOGLE_APPS_SCRIPT_URL" // Ganti dengan URL Apps Script
+
+ // Convert foto ke base64 (opsional, kalau mau kirim foto)
+ val fotoBase64 = try {
+ val photoFile = File(data.fotoPath)
+ if (photoFile.exists()) {
+ val bytes = photoFile.readBytes()
+ Base64.encodeToString(bytes, Base64.NO_WRAP)
+ } else null
+ } catch (e: Exception) {
+ null
+ }
+
+ val json = JSONObject().apply {
+ put("nim", data.nim)
+ put("nama", data.nama)
+ put("mataKuliahId", data.mataKuliahId)
+ put("mataKuliahNama", data.mataKuliahNama)
+ put("latitude", data.latitude)
+ put("longitude", data.longitude)
+ put("timestamp", java.text.SimpleDateFormat(
+ "dd/MM/yyyy HH:mm:ss",
+ java.util.Locale.getDefault()
+ ).format(java.util.Date(data.timestamp)))
+ put("foto", fotoBase64 ?: "")
+ }
+
+ val requestBody = json.toString()
+ .toRequestBody("application/json".toMediaTypeOrNull())
+
+ val request = Request.Builder()
+ .url(sheetsUrl)
+ .post(requestBody)
+ .build()
+
+ client.newCall(request).execute().use { response ->
+ if (!response.isSuccessful) {
+ throw Exception("Gagal kirim ke Google Sheets: ${response.code}")
+ }
+ Log.d("GoogleSheets", "Data berhasil dikirim")
+ }
+ }
+
+ /**
+ * (Opsional) Submit ke backend API
+ */
+ private suspend fun submitToBackend(data: AbsensiData) {
+ val photoFile = File(data.fotoPath)
+ val photoPart = MultipartBody.Part.createFormData(
+ "foto",
+ photoFile.name,
+ photoFile.asRequestBody("image/jpeg".toMediaTypeOrNull())
+ )
+
+ val response = apiService.submitAbsensi(
+ mataKuliahId = data.mataKuliahId.toRequestBody(),
+ mataKuliahNama = data.mataKuliahNama.toRequestBody(),
+ nim = data.nim.toString().toRequestBody(),
+ nama = data.nama.toRequestBody(),
+ latitude = data.latitude.toString().toRequestBody(),
+ longitude = data.longitude.toString().toRequestBody(),
+ timestamp = data.timestamp.toString().toRequestBody(),
+ foto = photoPart
+ )
+
+ if (!response.isSuccessful) {
+ throw Exception("Gagal submit ke backend")
+ }
+ }
+
+ private fun String.toRequestBody(): RequestBody {
+ return this.toRequestBody("text/plain".toMediaTypeOrNull())
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/id/ac/ubharajaya/sistemakademik/models/AbsensiHistory.kt b/app/src/main/java/id/ac/ubharajaya/sistemakademik/models/AbsensiHistory.kt
new file mode 100644
index 0000000..d8b41e1
--- /dev/null
+++ b/app/src/main/java/id/ac/ubharajaya/sistemakademik/models/AbsensiHistory.kt
@@ -0,0 +1,26 @@
+package id.ac.ubharajaya.sistemakademik.models
+data class AbsensiHistory(
+ val id: Long,
+ val timestamp: Long,
+ val nim: String,
+ val nama: String,
+ val mataKuliah: String,
+ val latitude: Double,
+ val longitude: Double,
+ val fotoPath: String,
+ val success: Boolean,
+ val message: String,
+ val createdAt: String
+) {
+ fun getFormattedDate(): String {
+ val sdf = java.text.SimpleDateFormat("dd MMM yyyy, HH:mm", java.util.Locale("id", "ID"))
+ return sdf.format(java.util.Date(timestamp))
+ }
+
+ fun getStatusText(): String = if (success) "Berhasil" else "Pending"
+
+ fun getStatusColor(): Int = if (success)
+ android.graphics.Color.parseColor("#4CAF50")
+ else
+ android.graphics.Color.parseColor("#FF9800")
+}
\ No newline at end of file
diff --git a/app/src/main/java/id/ac/ubharajaya/sistemakademik/models/Models.kt b/app/src/main/java/id/ac/ubharajaya/sistemakademik/models/Models.kt
index add5282..9f60905 100644
--- a/app/src/main/java/id/ac/ubharajaya/sistemakademik/models/Models.kt
+++ b/app/src/main/java/id/ac/ubharajaya/sistemakademik/models/Models.kt
@@ -1,4 +1,4 @@
-package id.ac.ubharajaya.sistemakademik
+package id.ac.ubharajaya.sistemakademik.models
data class MataKuliah(
val id: String,
diff --git a/app/src/main/java/id/ac/ubharajaya/sistemakademik/navigation/Navigation.kt b/app/src/main/java/id/ac/ubharajaya/sistemakademik/navigation/Navigation.kt
index 6840c53..32e0f3b 100644
--- a/app/src/main/java/id/ac/ubharajaya/sistemakademik/navigation/Navigation.kt
+++ b/app/src/main/java/id/ac/ubharajaya/sistemakademik/navigation/Navigation.kt
@@ -1,8 +1,10 @@
-package id.ac.ubharajaya.sistemakademik
+package id.ac.ubharajaya.sistemakademik.navigation
sealed class Screen(val route: String) {
object Login : Screen("login")
object MataKuliah : Screen("mata_kuliah")
object Preview : Screen("preview/{mataKuliahId}") {
fun createRoute(mataKuliahId: String) = "preview/$mataKuliahId"
- }
\ No newline at end of file
+ }
+ object Profile : Screen("profile") // ✅ Tambahkan ini
+}
\ No newline at end of file
diff --git a/app/src/main/java/id/ac/ubharajaya/sistemakademik/network/ApiService.kt b/app/src/main/java/id/ac/ubharajaya/sistemakademik/network/ApiService.kt
index a40c5f6..5724920 100644
--- a/app/src/main/java/id/ac/ubharajaya/sistemakademik/network/ApiService.kt
+++ b/app/src/main/java/id/ac/ubharajaya/sistemakademik/network/ApiService.kt
@@ -1,2 +1,86 @@
package id.ac.ubharajaya.sistemakademik.network
+
+import id.ac.ubharajaya.sistemakademik.models.AbsensiData
+import okhttp3.MultipartBody
+import okhttp3.RequestBody
+import retrofit2.Response
+import retrofit2.http.*
+
+// Request & Response Models
+data class LoginRequest(
+ val nim: String,
+ val password: String
+)
+
+data class LoginResponse(
+ val success: Boolean,
+ val message: String,
+ val data: StudentData?
+)
+
+data class StudentData(
+ val nim: String,
+ val nama: String,
+ val email: String,
+ val prodi: String
+)
+
+data class AbsensiResponse(
+ val success: Boolean,
+ val message: String,
+ val data: AbsensiResult?
+)
+
+data class AbsensiResult(
+ val id: String,
+ val timestamp: String,
+ val status: String
+)
+
+data class AbsensiHistoryResponse(
+ val id: String,
+ val mataKuliah: String,
+ val tanggal: String,
+ val waktu: String,
+ val status: String,
+ val fotoUrl: String
+)
+
+data class MataKuliahResponse(
+ val id: String,
+ val nama: String,
+ val kode: String,
+ val dosen: String,
+ val hari: String,
+ val jam: String,
+ val ruang: String
+)
+
+// API Interface
+interface ApiService {
+
+ @POST("auth/login")
+ suspend fun login(@Body request: LoginRequest): Response
+
+ @Multipart
+ @POST("absensi/submit")
+ suspend fun submitAbsensi(
+ @Part("mataKuliahId") mataKuliahId: RequestBody,
+ @Part("mataKuliahNama") mataKuliahNama: RequestBody,
+ @Part("nim") nim: RequestBody,
+ @Part("nama") nama: RequestBody,
+ @Part("latitude") latitude: RequestBody,
+ @Part("longitude") longitude: RequestBody,
+ @Part("timestamp") timestamp: RequestBody,
+ @Part foto: MultipartBody.Part
+ ): Response
+
+ @GET("absensi/history/{nim}")
+ suspend fun getAbsensiHistory(
+ @Path("nim") nim: String
+ ): Response>
+
+ @GET("matakuliah/list")
+ suspend fun getMataKuliahList(): Response>
+}
\ No newline at end of file
diff --git a/app/src/main/java/id/ac/ubharajaya/sistemakademik/network/WebhookService.kt b/app/src/main/java/id/ac/ubharajaya/sistemakademik/network/WebhookService.kt
new file mode 100644
index 0000000..018328f
--- /dev/null
+++ b/app/src/main/java/id/ac/ubharajaya/sistemakademik/network/WebhookService.kt
@@ -0,0 +1,361 @@
+package id.ac.ubharajaya.sistemakademik.network
+
+import android.content.Context
+import android.graphics.Bitmap
+import android.graphics.BitmapFactory
+import android.util.Base64
+import android.util.Log
+import id.ac.ubharajaya.sistemakademik.models.AbsensiData
+import id.ac.ubharajaya.sistemakademik.models.AbsensiHistory
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import org.json.JSONArray
+import org.json.JSONObject
+import java.io.ByteArrayOutputStream
+import java.io.File
+import java.net.HttpURLConnection
+import java.net.URL
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
+
+class WebhookService(private val context: Context) {
+
+ companion object {
+ private const val TAG = "WebhookService"
+ private const val WEBHOOK_PRODUCTION =
+ "https://n8n.lab.ubharajaya.ac.id/webhook/23c6993d-1792-48fb-ad1c-ffc78a3e6254"
+ private const val WEBHOOK_TEST =
+ "https://n8n.lab.ubharajaya.ac.id/webhook-test/23c6993d-1792-48fb-ad1c-ffc78a3e6254"
+
+ private const val WEBHOOK_URL = WEBHOOK_PRODUCTION
+ private const val PREFS_NAME = "absensi_history"
+ private const val KEY_HISTORY = "history_data"
+
+ // Kompres image ke max 500KB
+ private const val MAX_IMAGE_SIZE_KB = 500
+ }
+
+ /**
+ * Submit absensi ke webhook n8n dengan retry mechanism
+ */
+ suspend fun submitAbsensi(
+ absensiData: AbsensiData
+ ): Result = withContext(Dispatchers.IO) {
+ var lastException: Exception? = null
+
+ // Retry sampai 3 kali
+ repeat(3) { attempt ->
+ try {
+ Log.d(TAG, "📤 Attempt ${attempt + 1}: Mengirim absensi...")
+
+ // 1. Convert & compress foto
+ val fotoBase64 = convertAndCompressImage(absensiData.fotoPath)
+ if (fotoBase64 == null) {
+ throw Exception("Gagal memproses foto")
+ }
+
+ // 2. Siapkan JSON payload
+ val json = buildJsonPayload(absensiData, fotoBase64)
+
+ Log.d(TAG, "📦 JSON size: ${json.toString().length / 1024}KB")
+ Log.d(TAG, "🎯 Mata Kuliah: ${absensiData.mataKuliahNama}")
+
+ // 3. Kirim ke webhook
+ val result = sendToWebhook(json)
+
+ if (result.isSuccess) {
+ // Simpan ke history jika berhasil
+ saveToHistory(absensiData, true, "Berhasil dikirim")
+ return@withContext result
+ }
+
+ } catch (e: Exception) {
+ Log.e(TAG, "❌ Attempt ${attempt + 1} failed", e)
+ lastException = e
+
+ if (attempt < 2) {
+ // Tunggu sebelum retry
+ kotlinx.coroutines.delay(2000L * (attempt + 1))
+ }
+ }
+ }
+
+ // Semua attempt gagal - simpan ke pending
+ val errorMsg = lastException?.message ?: "Unknown error"
+ saveToHistory(absensiData, false, errorMsg)
+
+ Result.failure(lastException ?: Exception("Gagal mengirim absensi setelah 3 percobaan"))
+ }
+
+ /**
+ * Build JSON payload
+ */
+ private fun buildJsonPayload(data: AbsensiData, fotoBase64: String): JSONObject {
+ return JSONObject().apply {
+ put("timestamp", data.timestamp)
+ put("ip_addr", "android_app")
+ put("npm", data.nim)
+ put("nama", data.nama)
+ put("latitude", data.latitude)
+ put("longitude", data.longitude)
+ put("mata_kuliah", data.mataKuliahNama)
+ put("status", "hadir")
+ put("photo", "camera")
+ put("foto_base64", fotoBase64)
+
+ // Tambahan info
+ put("device", "android")
+ put("app_version", "1.0")
+ }
+ }
+
+ /**
+ * Kirim data ke webhook
+ */
+ private fun sendToWebhook(json: JSONObject): Result {
+ var connection: HttpURLConnection? = null
+
+ try {
+ val url = URL(WEBHOOK_URL)
+ connection = url.openConnection() as HttpURLConnection
+
+ connection.apply {
+ requestMethod = "POST"
+ setRequestProperty("Content-Type", "application/json; charset=UTF-8")
+ setRequestProperty("Accept", "application/json")
+ setRequestProperty("User-Agent", "SistemAkademik-Android/1.0")
+ doOutput = true
+ doInput = true
+ connectTimeout = 30000
+ readTimeout = 30000
+ }
+
+ // Kirim data
+ connection.outputStream.use { os ->
+ val input = json.toString().toByteArray(Charsets.UTF_8)
+ os.write(input, 0, input.size)
+ os.flush()
+ }
+
+ // Cek response
+ val responseCode = connection.responseCode
+ Log.d(TAG, "📥 Response code: $responseCode")
+
+ when (responseCode) {
+ HttpURLConnection.HTTP_OK,
+ HttpURLConnection.HTTP_CREATED,
+ HttpURLConnection.HTTP_ACCEPTED -> {
+ val response = connection.inputStream.bufferedReader().use { it.readText() }
+ Log.d(TAG, "✅ Success: $response")
+ return Result.success("Absensi berhasil dikirim")
+ }
+ else -> {
+ val error = connection.errorStream?.bufferedReader()?.use { it.readText() }
+ ?: "No error details"
+ Log.e(TAG, "❌ Error $responseCode: $error")
+ return Result.failure(Exception("Server error: $responseCode"))
+ }
+ }
+
+ } catch (e: Exception) {
+ Log.e(TAG, "❌ Network error", e)
+ return Result.failure(e)
+ } finally {
+ connection?.disconnect()
+ }
+ }
+
+ /**
+ * Convert dan compress image ke Base64
+ * Max 500KB untuk menghindari timeout
+ */
+ private fun convertAndCompressImage(imagePath: String): String? {
+ return try {
+ val imageFile = File(imagePath)
+ if (!imageFile.exists()) {
+ Log.e(TAG, "❌ File tidak ada: $imagePath")
+ return null
+ }
+
+ // Decode original bitmap
+ var bitmap = BitmapFactory.decodeFile(imagePath)
+ if (bitmap == null) {
+ Log.e(TAG, "❌ Gagal decode bitmap")
+ return null
+ }
+
+ // Resize jika terlalu besar (max 1024px)
+ val maxDimension = 1024
+ if (bitmap.width > maxDimension || bitmap.height > maxDimension) {
+ val ratio = maxDimension.toFloat() / maxOf(bitmap.width, bitmap.height)
+ val newWidth = (bitmap.width * ratio).toInt()
+ val newHeight = (bitmap.height * ratio).toInt()
+ bitmap = Bitmap.createScaledBitmap(bitmap, newWidth, newHeight, true)
+ Log.d(TAG, "📐 Resized to ${newWidth}x${newHeight}")
+ }
+
+ // Compress dengan quality adjustment
+ var quality = 85
+ var base64: String
+ var outputStream: ByteArrayOutputStream
+
+ do {
+ outputStream = ByteArrayOutputStream()
+ bitmap.compress(Bitmap.CompressFormat.JPEG, quality, outputStream)
+ val sizeKB = outputStream.size() / 1024
+
+ base64 = Base64.encodeToString(outputStream.toByteArray(), Base64.NO_WRAP)
+
+ Log.d(TAG, "🖼️ Quality $quality% = ${sizeKB}KB")
+
+ if (sizeKB <= MAX_IMAGE_SIZE_KB || quality <= 50) break
+
+ quality -= 10
+ } while (true)
+
+ bitmap.recycle()
+
+ Log.d(TAG, "✅ Image compressed: ${outputStream.size() / 1024}KB, Quality: $quality%")
+ base64
+
+ } catch (e: Exception) {
+ Log.e(TAG, "❌ Error compress image", e)
+ null
+ }
+ }
+
+ // ==================== HISTORY MANAGEMENT ====================
+
+ /**
+ * Simpan ke history
+ */
+ private fun saveToHistory(data: AbsensiData, success: Boolean, message: String) {
+ try {
+ val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
+ val historyJson = prefs.getString(KEY_HISTORY, "[]") ?: "[]"
+ val historyArray = JSONArray(historyJson)
+
+ val historyItem = JSONObject().apply {
+ put("id", System.currentTimeMillis())
+ put("timestamp", data.timestamp)
+ put("nim", data.nim)
+ put("nama", data.nama)
+ put("mataKuliah", data.mataKuliahNama)
+ put("latitude", data.latitude)
+ put("longitude", data.longitude)
+ put("fotoPath", data.fotoPath)
+ put("success", success)
+ put("message", message)
+ put("createdAt", getCurrentDateTime())
+ }
+
+ historyArray.put(historyItem)
+
+ // Simpan (max 100 record terakhir)
+ if (historyArray.length() > 100) {
+ val newArray = JSONArray()
+ for (i in (historyArray.length() - 100) until historyArray.length()) {
+ newArray.put(historyArray.get(i))
+ }
+ prefs.edit().putString(KEY_HISTORY, newArray.toString()).apply()
+ } else {
+ prefs.edit().putString(KEY_HISTORY, historyArray.toString()).apply()
+ }
+
+ Log.d(TAG, "💾 Saved to history: ${if(success) "✅" else "⏳"} $message")
+
+ } catch (e: Exception) {
+ Log.e(TAG, "❌ Error saving history", e)
+ }
+ }
+
+ /**
+ * Get all history
+ */
+ fun getHistory(): List {
+ return try {
+ val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
+ val historyJson = prefs.getString(KEY_HISTORY, "[]") ?: "[]"
+ val historyArray = JSONArray(historyJson)
+
+ val list = mutableListOf()
+ for (i in 0 until historyArray.length()) {
+ val item = historyArray.getJSONObject(i)
+ list.add(
+ AbsensiHistory(
+ id = item.getLong("id"),
+ timestamp = item.getLong("timestamp"),
+ nim = item.getString("nim"),
+ nama = item.getString("nama"),
+ mataKuliah = item.getString("mataKuliah"),
+ latitude = item.getDouble("latitude"),
+ longitude = item.getDouble("longitude"),
+ fotoPath = item.getString("fotoPath"),
+ success = item.getBoolean("success"),
+ message = item.getString("message"),
+ createdAt = item.getString("createdAt")
+ )
+ )
+ }
+
+ list.reversed() // Terbaru di atas
+
+ } catch (e: Exception) {
+ Log.e(TAG, "❌ Error loading history", e)
+ emptyList()
+ }
+ }
+
+ /**
+ * Clear history
+ */
+ fun clearHistory() {
+ context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
+ .edit()
+ .remove(KEY_HISTORY)
+ .apply()
+ Log.d(TAG, "🗑️ History cleared")
+ }
+
+ /**
+ * Retry failed absensi
+ */
+ suspend fun retryFailedAbsensi(historyItem: AbsensiHistory): Result {
+ val absensiData = AbsensiData(
+ nim = historyItem.nim,
+ nama = historyItem.nama,
+ mataKuliahId = "",
+ mataKuliahNama = historyItem.mataKuliah,
+ timestamp = historyItem.timestamp,
+ latitude = historyItem.latitude,
+ longitude = historyItem.longitude,
+ fotoPath = historyItem.fotoPath
+ )
+ return submitAbsensi(absensiData)
+ }
+
+ private fun getCurrentDateTime(): String {
+ val sdf = SimpleDateFormat("dd MMM yyyy HH:mm:ss", Locale("id", "ID"))
+ return sdf.format(Date())
+ }
+
+ /**
+ * Test webhook
+ */
+ suspend fun testWebhook(): Result = withContext(Dispatchers.IO) {
+ try {
+ val json = JSONObject().apply {
+ put("test", true)
+ put("message", "Test dari Android app")
+ put("timestamp", System.currentTimeMillis())
+ }
+
+ val result = sendToWebhook(json)
+ result
+
+ } catch (e: Exception) {
+ Result.failure(e)
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/id/ac/ubharajaya/sistemakademik/ui/theme/Color.kt b/app/src/main/java/id/ac/ubharajaya/sistemakademik/ui/theme/Color.kt
index c75702c..be137ea 100644
--- a/app/src/main/java/id/ac/ubharajaya/sistemakademik/ui/theme/Color.kt
+++ b/app/src/main/java/id/ac/ubharajaya/sistemakademik/ui/theme/Color.kt
@@ -2,10 +2,58 @@ package id.ac.ubharajaya.sistemakademik.ui.theme
import androidx.compose.ui.graphics.Color
-val Purple80 = Color(0xFFD0BCFF)
-val PurpleGrey80 = Color(0xFFCCC2DC)
-val Pink80 = Color(0xFFEFB8C8)
+// Light Theme Colors
+val md_theme_light_primary = Color(0xFF0061A4)
+val md_theme_light_onPrimary = Color(0xFFFFFFFF)
+val md_theme_light_primaryContainer = Color(0xFFD1E4FF)
+val md_theme_light_onPrimaryContainer = Color(0xFF001D36)
+val md_theme_light_secondary = Color(0xFF535F70)
+val md_theme_light_onSecondary = Color(0xFFFFFFFF)
+val md_theme_light_secondaryContainer = Color(0xFFD7E3F7)
+val md_theme_light_onSecondaryContainer = Color(0xFF101C2B)
+val md_theme_light_tertiary = Color(0xFF6B5778)
+val md_theme_light_onTertiary = Color(0xFFFFFFFF)
+val md_theme_light_tertiaryContainer = Color(0xFFF2DAFF)
+val md_theme_light_onTertiaryContainer = Color(0xFF251431)
+val md_theme_light_error = Color(0xFFBA1A1A)
+val md_theme_light_errorContainer = Color(0xFFFFDAD6)
+val md_theme_light_onError = Color(0xFFFFFFFF)
+val md_theme_light_onErrorContainer = Color(0xFF410002)
+val md_theme_light_background = Color(0xFFFDFCFF)
+val md_theme_light_onBackground = Color(0xFF1A1C1E)
+val md_theme_light_surface = Color(0xFFFDFCFF)
+val md_theme_light_onSurface = Color(0xFF1A1C1E)
+val md_theme_light_surfaceVariant = Color(0xFFDFE2EB)
+val md_theme_light_onSurfaceVariant = Color(0xFF43474E)
+val md_theme_light_outline = Color(0xFF73777F)
+val md_theme_light_inverseOnSurface = Color(0xFFF1F0F4)
+val md_theme_light_inverseSurface = Color(0xFF2F3033)
+val md_theme_light_inversePrimary = Color(0xFF9ECAFF)
-val Purple40 = Color(0xFF6650a4)
-val PurpleGrey40 = Color(0xFF625b71)
-val Pink40 = Color(0xFF7D5260)
\ No newline at end of file
+// Dark Theme Colors
+val md_theme_dark_primary = Color(0xFF9ECAFF)
+val md_theme_dark_onPrimary = Color(0xFF003258)
+val md_theme_dark_primaryContainer = Color(0xFF00497D)
+val md_theme_dark_onPrimaryContainer = Color(0xFFD1E4FF)
+val md_theme_dark_secondary = Color(0xFFBBC7DB)
+val md_theme_dark_onSecondary = Color(0xFF253140)
+val md_theme_dark_secondaryContainer = Color(0xFF3B4858)
+val md_theme_dark_onSecondaryContainer = Color(0xFFD7E3F7)
+val md_theme_dark_tertiary = Color(0xFFD6BEE4)
+val md_theme_dark_onTertiary = Color(0xFF3B2948)
+val md_theme_dark_tertiaryContainer = Color(0xFF523F5F)
+val md_theme_dark_onTertiaryContainer = Color(0xFFF2DAFF)
+val md_theme_dark_error = Color(0xFFFFB4AB)
+val md_theme_dark_errorContainer = Color(0xFF93000A)
+val md_theme_dark_onError = Color(0xFF690005)
+val md_theme_dark_onErrorContainer = Color(0xFFFFDAD6)
+val md_theme_dark_background = Color(0xFF1A1C1E)
+val md_theme_dark_onBackground = Color(0xFFE2E2E6)
+val md_theme_dark_surface = Color(0xFF1A1C1E)
+val md_theme_dark_onSurface = Color(0xFFE2E2E6)
+val md_theme_dark_surfaceVariant = Color(0xFF43474E)
+val md_theme_dark_onSurfaceVariant = Color(0xFFC3C7CF)
+val md_theme_dark_outline = Color(0xFF8D9199)
+val md_theme_dark_inverseOnSurface = Color(0xFF1A1C1E)
+val md_theme_dark_inverseSurface = Color(0xFFE2E2E6)
+val md_theme_dark_inversePrimary = Color(0xFF0061A4)
\ No newline at end of file
diff --git a/app/src/main/java/id/ac/ubharajaya/sistemakademik/ui/theme/Theme.kt b/app/src/main/java/id/ac/ubharajaya/sistemakademik/ui/theme/Theme.kt
index 1b2db88..aa4c919 100644
--- a/app/src/main/java/id/ac/ubharajaya/sistemakademik/ui/theme/Theme.kt
+++ b/app/src/main/java/id/ac/ubharajaya/sistemakademik/ui/theme/Theme.kt
@@ -1,5 +1,4 @@
package id.ac.ubharajaya.sistemakademik.ui.theme
-
import android.app.Activity
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
@@ -9,47 +8,96 @@ import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.SideEffect
+import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
-
-private val DarkColorScheme = darkColorScheme(
- primary = Purple80,
- secondary = PurpleGrey80,
- tertiary = Pink80
-)
+import androidx.compose.ui.platform.LocalView
+import androidx.core.view.WindowCompat
private val LightColorScheme = lightColorScheme(
- primary = Purple40,
- secondary = PurpleGrey40,
- tertiary = Pink40
+ primary = md_theme_light_primary,
+ onPrimary = md_theme_light_onPrimary,
+ primaryContainer = md_theme_light_primaryContainer,
+ onPrimaryContainer = md_theme_light_onPrimaryContainer,
+ secondary = md_theme_light_secondary,
+ onSecondary = md_theme_light_onSecondary,
+ secondaryContainer = md_theme_light_secondaryContainer,
+ onSecondaryContainer = md_theme_light_onSecondaryContainer,
+ tertiary = md_theme_light_tertiary,
+ onTertiary = md_theme_light_onTertiary,
+ tertiaryContainer = md_theme_light_tertiaryContainer,
+ onTertiaryContainer = md_theme_light_onTertiaryContainer,
+ error = md_theme_light_error,
+ errorContainer = md_theme_light_errorContainer,
+ onError = md_theme_light_onError,
+ onErrorContainer = md_theme_light_onErrorContainer,
+ background = md_theme_light_background,
+ onBackground = md_theme_light_onBackground,
+ surface = md_theme_light_surface,
+ onSurface = md_theme_light_onSurface,
+ surfaceVariant = md_theme_light_surfaceVariant,
+ onSurfaceVariant = md_theme_light_onSurfaceVariant,
+ outline = md_theme_light_outline,
+ inverseOnSurface = md_theme_light_inverseOnSurface,
+ inverseSurface = md_theme_light_inverseSurface,
+ inversePrimary = md_theme_light_inversePrimary,
+)
- /* Other default colors to override
- background = Color(0xFFFFFBFE),
- surface = Color(0xFFFFFBFE),
- onPrimary = Color.White,
- onSecondary = Color.White,
- onTertiary = Color.White,
- onBackground = Color(0xFF1C1B1F),
- onSurface = Color(0xFF1C1B1F),
- */
+private val DarkColorScheme = darkColorScheme(
+ primary = md_theme_dark_primary,
+ onPrimary = md_theme_dark_onPrimary,
+ primaryContainer = md_theme_dark_primaryContainer,
+ onPrimaryContainer = md_theme_dark_onPrimaryContainer,
+ secondary = md_theme_dark_secondary,
+ onSecondary = md_theme_dark_onSecondary,
+ secondaryContainer = md_theme_dark_secondaryContainer,
+ onSecondaryContainer = md_theme_dark_onSecondaryContainer,
+ tertiary = md_theme_dark_tertiary,
+ onTertiary = md_theme_dark_onTertiary,
+ tertiaryContainer = md_theme_dark_tertiaryContainer,
+ onTertiaryContainer = md_theme_dark_onTertiaryContainer,
+ error = md_theme_dark_error,
+ errorContainer = md_theme_dark_errorContainer,
+ onError = md_theme_dark_onError,
+ onErrorContainer = md_theme_dark_onErrorContainer,
+ background = md_theme_dark_background,
+ onBackground = md_theme_dark_onBackground,
+ surface = md_theme_dark_surface,
+ onSurface = md_theme_dark_onSurface,
+ surfaceVariant = md_theme_dark_surfaceVariant,
+ onSurfaceVariant = md_theme_dark_onSurfaceVariant,
+ outline = md_theme_dark_outline,
+ inverseOnSurface = md_theme_dark_inverseOnSurface,
+ inverseSurface = md_theme_dark_inverseSurface,
+ inversePrimary = md_theme_dark_inversePrimary,
)
@Composable
-fun SistemAkademikTheme(
+fun StarterEASTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
- // Dynamic color is available on Android 12+
dynamicColor: Boolean = true,
content: @Composable () -> Unit
) {
val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
- if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
+ if (darkTheme) dynamicDarkColorScheme(context)
+ else dynamicLightColorScheme(context)
}
-
darkTheme -> DarkColorScheme
else -> LightColorScheme
}
+ val view = LocalView.current
+ if (!view.isInEditMode) {
+ SideEffect {
+ val window = (view.context as Activity).window
+ window.statusBarColor = colorScheme.primary.toArgb()
+ WindowCompat.getInsetsController(window, view)
+ .isAppearanceLightStatusBars = darkTheme
+ }
+ }
+
MaterialTheme(
colorScheme = colorScheme,
typography = Typography,
diff --git a/app/src/main/java/id/ac/ubharajaya/sistemakademik/ui/theme/Type.kt b/app/src/main/java/id/ac/ubharajaya/sistemakademik/ui/theme/Type.kt
index e2982e7..cd5f9f7 100644
--- a/app/src/main/java/id/ac/ubharajaya/sistemakademik/ui/theme/Type.kt
+++ b/app/src/main/java/id/ac/ubharajaya/sistemakademik/ui/theme/Type.kt
@@ -6,7 +6,6 @@ import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
-// Set of Material typography styles to start with
val Typography = Typography(
bodyLarge = TextStyle(
fontFamily = FontFamily.Default,
@@ -14,8 +13,7 @@ val Typography = Typography(
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.5.sp
- )
- /* Other default text styles to override
+ ),
titleLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
@@ -30,5 +28,4 @@ val Typography = Typography(
lineHeight = 16.sp,
letterSpacing = 0.5.sp
)
- */
)
\ No newline at end of file
diff --git a/app/src/main/java/id/ac/ubharajaya/sistemakademik/ui/theme/screen/HistoryScreen.kt b/app/src/main/java/id/ac/ubharajaya/sistemakademik/ui/theme/screen/HistoryScreen.kt
new file mode 100644
index 0000000..e056e3e
--- /dev/null
+++ b/app/src/main/java/id/ac/ubharajaya/sistemakademik/ui/theme/screen/HistoryScreen.kt
@@ -0,0 +1,336 @@
+package id.ac.ubharajaya.sistemakademik.ui.theme.screen
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.shape.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.layout.ContentScale
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import coil.compose.AsyncImage
+import id.ac.ubharajaya.sistemakademik.models.AbsensiHistory
+import id.ac.ubharajaya.sistemakademik.network.WebhookService
+import kotlinx.coroutines.launch
+import java.io.File
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun HistoryScreen(
+ onBack: () -> Unit
+) {
+ val context = LocalContext.current
+ val webhookService = remember { WebhookService(context) }
+ val scope = rememberCoroutineScope()
+
+ var historyList by remember { mutableStateOf>(emptyList()) }
+ var isLoading by remember { mutableStateOf(true) }
+ var showClearDialog by remember { mutableStateOf(false) }
+
+ // Load history
+ LaunchedEffect(Unit) {
+ historyList = webhookService.getHistory()
+ isLoading = false
+ }
+
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = { Text("History Absensi") },
+ navigationIcon = {
+ IconButton(onClick = onBack) {
+ Icon(Icons.Default.ArrowBack, "Kembali")
+ }
+ },
+ actions = {
+ if (historyList.isNotEmpty()) {
+ IconButton(onClick = { showClearDialog = true }) {
+ Icon(Icons.Default.Delete, "Hapus Semua")
+ }
+ }
+ },
+ colors = TopAppBarDefaults.topAppBarColors(
+ containerColor = MaterialTheme.colorScheme.primary,
+ titleContentColor = Color.White,
+ navigationIconContentColor = Color.White,
+ actionIconContentColor = Color.White
+ )
+ )
+ }
+ ) { padding ->
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(padding)
+ ) {
+ when {
+ isLoading -> {
+ CircularProgressIndicator(
+ modifier = Modifier.align(Alignment.Center)
+ )
+ }
+ historyList.isEmpty() -> {
+ EmptyHistoryView()
+ }
+ else -> {
+ LazyColumn(
+ modifier = Modifier.fillMaxSize(),
+ contentPadding = PaddingValues(16.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ item {
+ SummaryCard(historyList)
+ }
+
+ items(historyList) { item ->
+ HistoryItem(
+ item = item,
+ onRetry = { history ->
+ scope.launch {
+ isLoading = true
+ val result = webhookService.retryFailedAbsensi(history)
+ historyList = webhookService.getHistory()
+ isLoading = false
+ }
+ }
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+
+ // Dialog konfirmasi hapus
+ if (showClearDialog) {
+ AlertDialog(
+ onDismissRequest = { showClearDialog = false },
+ title = { Text("Hapus Semua History?") },
+ text = { Text("Semua riwayat absensi akan dihapus. Aksi ini tidak dapat dibatalkan.") },
+ confirmButton = {
+ TextButton(
+ onClick = {
+ webhookService.clearHistory()
+ historyList = emptyList()
+ showClearDialog = false
+ }
+ ) {
+ Text("Hapus", color = Color.Red)
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = { showClearDialog = false }) {
+ Text("Batal")
+ }
+ }
+ )
+ }
+}
+
+@Composable
+fun SummaryCard(historyList: List) {
+ val successCount = historyList.count { it.success }
+ val pendingCount = historyList.size - successCount
+
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.primaryContainer
+ )
+ ) {
+ Column(
+ modifier = Modifier.padding(16.dp)
+ ) {
+ Text(
+ text = "Ringkasan",
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.Bold
+ )
+ Spacer(modifier = Modifier.height(12.dp))
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceEvenly
+ ) {
+ StatItem(
+ icon = Icons.Default.CheckCircle,
+ label = "Berhasil",
+ value = successCount.toString(),
+ color = Color(0xFF4CAF50)
+ )
+ StatItem(
+ icon = Icons.Default.Info,
+ label = "Pending",
+ value = pendingCount.toString(),
+ color = Color(0xFFFF9800)
+ )
+ StatItem(
+ icon = Icons.Default.List,
+ label = "Total",
+ value = historyList.size.toString(),
+ color = MaterialTheme.colorScheme.primary
+ )
+ }
+ }
+ }
+}
+
+@Composable
+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)
+ )
+ Text(
+ text = value,
+ style = MaterialTheme.typography.titleLarge,
+ fontWeight = FontWeight.Bold,
+ color = color
+ )
+ Text(
+ text = label,
+ style = MaterialTheme.typography.bodySmall,
+ color = Color.Gray
+ )
+ }
+}
+
+@Composable
+fun HistoryItem(
+ item: AbsensiHistory,
+ onRetry: (AbsensiHistory) -> Unit
+) {
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ elevation = CardDefaults.cardElevation(2.dp)
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(12.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ // Foto
+ if (File(item.fotoPath).exists()) {
+ AsyncImage(
+ model = item.fotoPath,
+ contentDescription = "Foto",
+ modifier = Modifier
+ .size(60.dp)
+ .clip(RoundedCornerShape(8.dp)),
+ contentScale = ContentScale.Crop
+ )
+ } else {
+ Box(
+ modifier = Modifier
+ .size(60.dp)
+ .clip(RoundedCornerShape(8.dp))
+ .background(Color.Gray),
+ contentAlignment = Alignment.Center
+ ) {
+ Icon(
+ Icons.Default.Person,
+ contentDescription = null,
+ tint = Color.White
+ )
+ }
+ }
+
+ Spacer(modifier = Modifier.width(12.dp))
+
+ // Info
+ Column(
+ modifier = Modifier.weight(1f)
+ ) {
+ Text(
+ text = item.mataKuliah,
+ style = MaterialTheme.typography.titleSmall,
+ fontWeight = FontWeight.Bold
+ )
+ Text(
+ text = item.nama,
+ style = MaterialTheme.typography.bodySmall,
+ color = Color.Gray
+ )
+ Text(
+ text = item.getFormattedDate(),
+ style = MaterialTheme.typography.bodySmall,
+ color = Color.Gray
+ )
+
+ // Status badge
+ Box(
+ modifier = Modifier
+ .padding(top = 4.dp)
+ .background(
+ color = if (item.success) Color(0xFF4CAF50).copy(alpha = 0.1f)
+ else Color(0xFFFF9800).copy(alpha = 0.1f),
+ shape = RoundedCornerShape(4.dp)
+ )
+ .padding(horizontal = 8.dp, vertical = 2.dp)
+ ) {
+ Text(
+ text = item.getStatusText(),
+ style = MaterialTheme.typography.labelSmall,
+ color = if (item.success) Color(0xFF4CAF50) else Color(0xFFFF9800),
+ fontWeight = FontWeight.Medium
+ )
+ }
+ }
+
+ // Retry button untuk yang pending
+ if (!item.success) {
+ IconButton(
+ onClick = { onRetry(item) }
+ ) {
+ Icon(
+ Icons.Default.Refresh,
+ contentDescription = "Retry",
+ tint = MaterialTheme.colorScheme.primary
+ )
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun EmptyHistoryView() {
+ Column(
+ modifier = Modifier.fillMaxSize(),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center
+ ) {
+ Icon(
+ imageVector = Icons.Default.DateRange,
+ contentDescription = null,
+ modifier = Modifier.size(80.dp),
+ tint = Color.Gray
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+ Text(
+ text = "Belum Ada History",
+ style = MaterialTheme.typography.titleMedium,
+ color = Color.Gray
+ )
+ Text(
+ text = "Riwayat absensi akan muncul di sini",
+ style = MaterialTheme.typography.bodySmall,
+ color = Color.Gray
+ )
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/id/ac/ubharajaya/sistemakademik/ui/theme/screen/Loginscreen.kt b/app/src/main/java/id/ac/ubharajaya/sistemakademik/ui/theme/screen/Loginscreen.kt
index 85e80c1..3c6cb70 100644
--- a/app/src/main/java/id/ac/ubharajaya/sistemakademik/ui/theme/screen/Loginscreen.kt
+++ b/app/src/main/java/id/ac/ubharajaya/sistemakademik/ui/theme/screen/Loginscreen.kt
@@ -1,2 +1,172 @@
-package id.ac.ubharajaya.sistemakademik.ui.theme.screen
+package id.ac.ubharajaya.sistemakademik.ui.screen
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Lock
+import androidx.compose.material.icons.filled.Person
+import androidx.compose.material.icons.filled.Visibility
+import androidx.compose.material.icons.filled.VisibilityOff
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+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 id.ac.ubharajaya.sistemakademik.viewmodel.AbsensiViewModel
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun LoginScreen(
+ viewModel: AbsensiViewModel,
+ onLoginSuccess: () -> Unit
+) {
+ var nim 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) }
+
+ Surface(
+ modifier = Modifier.fillMaxSize(),
+ color = MaterialTheme.colorScheme.background
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(24.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center
+ ) {
+ // Logo atau Icon
+ Icon(
+ imageVector = Icons.Default.Person,
+ contentDescription = "Logo",
+ modifier = Modifier.size(100.dp),
+ tint = MaterialTheme.colorScheme.primary
+ )
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ // Title
+ Text(
+ text = "Sistem Absensi",
+ style = MaterialTheme.typography.headlineMedium,
+ fontWeight = FontWeight.Bold
+ )
+
+ Text(
+ text = "Universitas Bhayangkara Jakarta Raya",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+
+ Spacer(modifier = Modifier.height(32.dp))
+
+ // NIM TextField
+ OutlinedTextField(
+ value = nim,
+ onValueChange = {
+ nim = it
+ errorMessage = ""
+ },
+ label = { Text("NIM") },
+ leadingIcon = {
+ Icon(Icons.Default.Person, contentDescription = null)
+ },
+ modifier = Modifier.fillMaxWidth(),
+ singleLine = true,
+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
+ isError = errorMessage.isNotEmpty()
+ )
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ // Password TextField
+ OutlinedTextField(
+ value = password,
+ onValueChange = {
+ password = it
+ errorMessage = ""
+ },
+ label = { Text("Password") },
+ leadingIcon = {
+ Icon(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),
+ isError = errorMessage.isNotEmpty()
+ )
+
+ // Error Message
+ if (errorMessage.isNotEmpty()) {
+ Spacer(modifier = Modifier.height(8.dp))
+ Text(
+ text = errorMessage,
+ color = MaterialTheme.colorScheme.error,
+ style = MaterialTheme.typography.bodySmall
+ )
+ }
+
+ Spacer(modifier = Modifier.height(24.dp))
+
+ // Login Button
+ Button(
+ onClick = {
+ if (nim.isEmpty() || password.isEmpty()) {
+ errorMessage = "NIM dan Password harus diisi"
+ return@Button
+ }
+
+ isLoading = true
+ val success = viewModel.login(nim, password)
+ isLoading = false
+
+ if (success) {
+ onLoginSuccess()
+ } else {
+ errorMessage = "NIM atau Password salah"
+ }
+ },
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(50.dp),
+ enabled = !isLoading
+ ) {
+ if (isLoading) {
+ CircularProgressIndicator(
+ modifier = Modifier.size(24.dp),
+ color = MaterialTheme.colorScheme.onPrimary
+ )
+ } else {
+ Text("Login", style = MaterialTheme.typography.titleMedium)
+ }
+ }
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ // Forgot Password
+ TextButton(onClick = { /* TODO */ }) {
+ Text("Lupa Password?")
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/id/ac/ubharajaya/sistemakademik/ui/theme/screen/MataKuliahScreen.kt b/app/src/main/java/id/ac/ubharajaya/sistemakademik/ui/theme/screen/MataKuliahScreen.kt
index 74a5ba0..6a1d4e8 100644
--- a/app/src/main/java/id/ac/ubharajaya/sistemakademik/ui/theme/screen/MataKuliahScreen.kt
+++ b/app/src/main/java/id/ac/ubharajaya/sistemakademik/ui/theme/screen/MataKuliahScreen.kt
@@ -1,4 +1,220 @@
-package id.ac.ubharajaya.sistemakademik.ui.theme.screen
+package id.ac.ubharajaya.sistemakademik.ui.screen
-class MataKuliahScreen {
+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.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.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import id.ac.ubharajaya.sistemakademik.models.MataKuliah
+import id.ac.ubharajaya.sistemakademik.viewmodel.AbsensiViewModel
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun MataKuliahScreen(
+ viewModel: AbsensiViewModel,
+ onMataKuliahSelected: (String) -> Unit,
+ onProfileClick: () -> Unit
+) {
+ val mataKuliahList by viewModel.mataKuliahList.collectAsState()
+ val currentStudent by viewModel.currentStudent.collectAsState()
+
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = {
+ Column {
+ Text("Pilih Mata Kuliah", style = MaterialTheme.typography.titleLarge)
+ currentStudent?.let {
+ Text(
+ text = it.nama,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+ },
+ actions = {
+ IconButton(onClick = onProfileClick) {
+ Icon(
+ imageVector = Icons.Default.AccountCircle,
+ contentDescription = "Profile"
+ )
+ }
+ },
+ colors = TopAppBarDefaults.topAppBarColors(
+ containerColor = MaterialTheme.colorScheme.primaryContainer
+ )
+ )
+ }
+ ) { paddingValues ->
+ LazyColumn(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(paddingValues)
+ .padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ items(mataKuliahList) { mataKuliah ->
+ MataKuliahCard(
+ mataKuliah = mataKuliah,
+ onClick = { onMataKuliahSelected(mataKuliah.id) }
+ )
+ }
+
+ // Info Card
+ item {
+ Card(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(top = 8.dp),
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.tertiaryContainer
+ )
+ ) {
+ Row(
+ modifier = Modifier.padding(16.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(
+ imageVector = Icons.Default.Info,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onTertiaryContainer
+ )
+ Spacer(modifier = Modifier.width(12.dp))
+ Text(
+ text = "Pilih mata kuliah untuk melakukan absensi. " +
+ "Pastikan Anda berada di lokasi kuliah dan siap mengambil foto.",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onTertiaryContainer
+ )
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun MataKuliahCard(
+ mataKuliah: MataKuliah,
+ onClick: () -> Unit
+) {
+ Card(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable(onClick = onClick),
+ elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
+ ) {
+ Column(
+ modifier = Modifier.padding(16.dp)
+ ) {
+ // Header
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ text = mataKuliah.nama,
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.Bold,
+ modifier = Modifier.weight(1f)
+ )
+ Surface(
+ shape = MaterialTheme.shapes.small,
+ color = MaterialTheme.colorScheme.primaryContainer
+ ) {
+ Text(
+ text = mataKuliah.kode,
+ modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onPrimaryContainer
+ )
+ }
+ }
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ // Dosen
+ Row(
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(
+ imageVector = Icons.Default.Person,
+ contentDescription = null,
+ modifier = Modifier.size(16.dp),
+ tint = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Spacer(modifier = Modifier.width(8.dp))
+ Text(
+ text = mataKuliah.dosen,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+
+ Spacer(modifier = Modifier.height(4.dp))
+
+ // Jadwal
+ Row(
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(
+ imageVector = Icons.Default.CalendarToday,
+ contentDescription = null,
+ modifier = Modifier.size(16.dp),
+ tint = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Spacer(modifier = Modifier.width(8.dp))
+ Text(
+ text = "${mataKuliah.hari}, ${mataKuliah.jam}",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+
+ Spacer(modifier = Modifier.height(4.dp))
+
+ // Ruangan
+ Row(
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(
+ imageVector = Icons.Default.LocationOn,
+ contentDescription = null,
+ modifier = Modifier.size(16.dp),
+ tint = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Spacer(modifier = Modifier.width(8.dp))
+ Text(
+ text = mataKuliah.ruang,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+
+ Spacer(modifier = Modifier.height(12.dp))
+
+ // Button
+ Button(
+ onClick = onClick,
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Icon(
+ imageVector = Icons.Default.Camera,
+ contentDescription = null,
+ modifier = Modifier.size(18.dp)
+ )
+ Spacer(modifier = Modifier.width(8.dp))
+ Text("Absen Sekarang")
+ }
+ }
+ }
}
\ No newline at end of file
diff --git a/app/src/main/java/id/ac/ubharajaya/sistemakademik/ui/theme/screen/PreviewScreen.kt b/app/src/main/java/id/ac/ubharajaya/sistemakademik/ui/theme/screen/PreviewScreen.kt
index 85e80c1..c02526a 100644
--- a/app/src/main/java/id/ac/ubharajaya/sistemakademik/ui/theme/screen/PreviewScreen.kt
+++ b/app/src/main/java/id/ac/ubharajaya/sistemakademik/ui/theme/screen/PreviewScreen.kt
@@ -1,2 +1,652 @@
-package id.ac.ubharajaya.sistemakademik.ui.theme.screen
+package id.ac.ubharajaya.sistemakademik.ui.screen
+import android.Manifest
+import android.content.Context
+import android.location.Location
+import androidx.camera.core.*
+import androidx.camera.lifecycle.ProcessCameraProvider
+import androidx.camera.view.PreviewView
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.layout.*
+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.platform.LocalContext
+import androidx.compose.ui.platform.LocalLifecycleOwner
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.viewinterop.AndroidView
+import androidx.core.content.ContextCompat
+import coil.compose.rememberAsyncImagePainter
+import com.google.accompanist.permissions.ExperimentalPermissionsApi
+import com.google.accompanist.permissions.rememberMultiplePermissionsState
+import com.google.android.gms.location.LocationServices
+import id.ac.ubharajaya.sistemakademik.models.AbsensiData
+import id.ac.ubharajaya.sistemakademik.viewmodel.AbsensiViewModel
+import java.io.File
+import java.text.SimpleDateFormat
+import java.util.*
+
+@OptIn(ExperimentalPermissionsApi::class, ExperimentalMaterial3Api::class)
+@Composable
+fun PreviewScreen(
+ viewModel: AbsensiViewModel,
+ mataKuliahId: String,
+ onBackClick: () -> Unit,
+ onSubmitSuccess: () -> Unit
+) {
+ val context = LocalContext.current
+ val lifecycleOwner = LocalLifecycleOwner.current
+
+ val mataKuliahList by viewModel.mataKuliahList.collectAsState()
+ val currentStudent by viewModel.currentStudent.collectAsState()
+ val currentLocation by viewModel.currentLocation.collectAsState()
+ val capturedPhoto by viewModel.capturedPhoto.collectAsState()
+
+ val mataKuliah = remember(mataKuliahId, mataKuliahList) {
+ mataKuliahList.find { it.id == mataKuliahId }
+ }
+
+ var imageCapture: ImageCapture? by remember { mutableStateOf(null) }
+ var showSubmitDialog by remember { mutableStateOf(false) }
+ var isSubmitting by remember { mutableStateOf(false) }
+ var errorMessage by remember { mutableStateOf(null) }
+
+ // Location validation state
+ var locationValidation by remember { mutableStateOf(null) }
+
+ // Validate location when coordinates change
+ LaunchedEffect(currentLocation) {
+ currentLocation?.let { (lat, lng) ->
+ locationValidation = id.ac.ubharajaya.sistemakademik.utils.LocationManager.validateLocation(lat, lng)
+ }
+ }
+
+ // Permission state
+ val permissionsState = rememberMultiplePermissionsState(
+ permissions = listOf(
+ Manifest.permission.CAMERA,
+ Manifest.permission.ACCESS_FINE_LOCATION,
+ Manifest.permission.ACCESS_COARSE_LOCATION
+ )
+ )
+
+ // Get location
+ LaunchedEffect(permissionsState.allPermissionsGranted) {
+ if (permissionsState.allPermissionsGranted) {
+ getLocation(context) { lat, lng ->
+ viewModel.updateLocation(lat, lng)
+ }
+ }
+ }
+
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = { Text("Preview Absensi") },
+ navigationIcon = {
+ IconButton(onClick = onBackClick) {
+ Icon(Icons.Default.ArrowBack, contentDescription = "Back")
+ }
+ }
+ )
+ }
+ ) { paddingValues ->
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(paddingValues)
+ ) {
+ // Mata Kuliah Info
+ Card(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp)
+ ) {
+ Column(modifier = Modifier.padding(16.dp)) {
+ Text(
+ text = mataKuliah?.nama ?: "Mata Kuliah",
+ style = MaterialTheme.typography.titleLarge,
+ fontWeight = FontWeight.Bold
+ )
+ Text(
+ text = "${mataKuliah?.kode} - ${mataKuliah?.dosen}",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+
+ if (!permissionsState.allPermissionsGranted) {
+ // Permission Request
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(16.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center
+ ) {
+ Icon(
+ imageVector = Icons.Default.Warning,
+ contentDescription = null,
+ modifier = Modifier.size(64.dp),
+ tint = MaterialTheme.colorScheme.error
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+ Text(
+ text = "Izin Diperlukan",
+ style = MaterialTheme.typography.titleLarge
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ Text(
+ text = "Aplikasi memerlukan izin kamera dan lokasi untuk absensi",
+ style = MaterialTheme.typography.bodyMedium
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+ Button(onClick = { permissionsState.launchMultiplePermissionRequest() }) {
+ Text("Berikan Izin")
+ }
+ }
+ } else if (capturedPhoto == null) {
+ // Camera Preview
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .weight(1f)
+ ) {
+ CameraPreview(
+ onImageCaptureReady = { imageCapture = it }
+ )
+
+ // Location Info Overlay
+ currentLocation?.let { (lat, lng) ->
+ Card(
+ modifier = Modifier
+ .align(Alignment.TopEnd)
+ .padding(16.dp),
+ colors = CardDefaults.cardColors(
+ containerColor = if (locationValidation?.isValid == true)
+ MaterialTheme.colorScheme.primaryContainer
+ else
+ MaterialTheme.colorScheme.errorContainer
+ )
+ ) {
+ Column(modifier = Modifier.padding(12.dp)) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Icon(
+ imageVector = if (locationValidation?.isValid == true)
+ Icons.Default.CheckCircle
+ else
+ Icons.Default.Warning,
+ contentDescription = null,
+ modifier = Modifier.size(16.dp),
+ tint = if (locationValidation?.isValid == true)
+ MaterialTheme.colorScheme.onPrimaryContainer
+ else
+ MaterialTheme.colorScheme.onErrorContainer
+ )
+ Spacer(modifier = Modifier.width(4.dp))
+ Text(
+ text = if (locationValidation?.isValid == true)
+ "Lokasi Valid"
+ else
+ "Lokasi Tidak Valid",
+ style = MaterialTheme.typography.labelSmall,
+ fontWeight = FontWeight.Bold,
+ color = if (locationValidation?.isValid == true)
+ MaterialTheme.colorScheme.onPrimaryContainer
+ else
+ MaterialTheme.colorScheme.onErrorContainer
+ )
+ }
+
+ locationValidation?.location?.let { loc ->
+ Spacer(modifier = Modifier.height(4.dp))
+ Text(
+ text = loc.name,
+ style = MaterialTheme.typography.bodySmall,
+ fontWeight = FontWeight.Medium
+ )
+ Text(
+ text = "${loc.building}${loc.floor?.let { " - $it" } ?: ""}",
+ style = MaterialTheme.typography.labelSmall
+ )
+ }
+
+ locationValidation?.let { validation ->
+ Text(
+ text = "${id.ac.ubharajaya.sistemakademik.utils.LocationManager.formatDistance(validation.distance)} dari lokasi",
+ style = MaterialTheme.typography.labelSmall
+ )
+ }
+
+ Spacer(modifier = Modifier.height(2.dp))
+ Text(
+ text = String.format("%.6f, %.6f", lat, lng),
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+ }
+ }
+
+ // Capture Button
+ Button(
+ onClick = {
+ imageCapture?.let {
+ takePicture(context, it) { photoPath ->
+ viewModel.setCapturedPhoto(photoPath)
+ }
+ }
+ },
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp)
+ .height(56.dp),
+ enabled = currentLocation != null
+ ) {
+ Icon(Icons.Default.Camera, contentDescription = null)
+ Spacer(modifier = Modifier.width(8.dp))
+ Text("Ambil Foto", style = MaterialTheme.typography.titleMedium)
+ }
+
+ // Warning jika lokasi tidak valid
+ if (locationValidation?.isValid == false) {
+ Card(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp),
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.errorContainer
+ )
+ ) {
+ Row(
+ modifier = Modifier.padding(12.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(
+ imageVector = Icons.Default.Warning,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onErrorContainer
+ )
+ Spacer(modifier = Modifier.width(8.dp))
+ Text(
+ text = locationValidation?.message ?: "Lokasi tidak valid",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onErrorContainer
+ )
+ }
+ }
+ Spacer(modifier = Modifier.height(16.dp))
+ }
+ } else {
+ // Photo Preview
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .weight(1f)
+ .padding(16.dp)
+ ) {
+ Card(
+ modifier = Modifier
+ .fillMaxWidth()
+ .weight(1f)
+ ) {
+ Image(
+ painter = rememberAsyncImagePainter(File(capturedPhoto)),
+ contentDescription = "Captured Photo",
+ modifier = Modifier.fillMaxSize()
+ )
+ }
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ // Location Info
+ currentLocation?.let { (lat, lng) ->
+ Card(modifier = Modifier.fillMaxWidth()) {
+ Column(modifier = Modifier.padding(16.dp)) {
+ // Status Header
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Icon(
+ imageVector = if (locationValidation?.isValid == true)
+ Icons.Default.CheckCircle
+ else
+ Icons.Default.Warning,
+ contentDescription = null,
+ tint = if (locationValidation?.isValid == true)
+ MaterialTheme.colorScheme.primary
+ else
+ MaterialTheme.colorScheme.error
+ )
+ Spacer(modifier = Modifier.width(8.dp))
+ Column {
+ Text(
+ text = "Status Lokasi",
+ style = MaterialTheme.typography.labelMedium
+ )
+ Text(
+ text = if (locationValidation?.isValid == true)
+ "Valid"
+ else
+ "Tidak Valid",
+ style = MaterialTheme.typography.bodySmall,
+ color = if (locationValidation?.isValid == true)
+ MaterialTheme.colorScheme.primary
+ else
+ MaterialTheme.colorScheme.error,
+ fontWeight = FontWeight.Bold
+ )
+ }
+ }
+
+ // Distance badge
+ locationValidation?.let { validation ->
+ Surface(
+ shape = MaterialTheme.shapes.small,
+ color = if (validation.isValid)
+ MaterialTheme.colorScheme.primaryContainer
+ else
+ MaterialTheme.colorScheme.errorContainer
+ ) {
+ Text(
+ text = id.ac.ubharajaya.sistemakademik.utils.LocationManager.formatDistance(validation.distance),
+ modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
+ style = MaterialTheme.typography.labelSmall,
+ fontWeight = FontWeight.Bold
+ )
+ }
+ }
+ }
+
+ Divider(modifier = Modifier.padding(vertical = 12.dp))
+
+ // Location Details
+ locationValidation?.location?.let { loc ->
+ LocationInfoRow(
+ icon = Icons.Default.Business,
+ label = "Gedung",
+ value = loc.building
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ LocationInfoRow(
+ icon = Icons.Default.Room,
+ label = "Ruangan",
+ value = loc.name
+ )
+ if (loc.floor != null) {
+ Spacer(modifier = Modifier.height(8.dp))
+ LocationInfoRow(
+ icon = Icons.Default.Layers,
+ label = "Lantai",
+ value = loc.floor
+ )
+ }
+ Spacer(modifier = Modifier.height(8.dp))
+ }
+
+ LocationInfoRow(
+ icon = Icons.Default.MyLocation,
+ label = "Koordinat",
+ value = String.format("%.6f, %.6f", lat, lng)
+ )
+
+ // Warning message jika tidak valid
+ if (locationValidation?.isValid == false) {
+ Spacer(modifier = Modifier.height(12.dp))
+ Surface(
+ modifier = Modifier.fillMaxWidth(),
+ color = MaterialTheme.colorScheme.errorContainer,
+ shape = MaterialTheme.shapes.small
+ ) {
+ Text(
+ text = locationValidation?.message ?: "",
+ modifier = Modifier.padding(12.dp),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onErrorContainer
+ )
+ }
+ }
+ }
+ }
+ }
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ // Buttons
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ OutlinedButton(
+ onClick = { viewModel.setCapturedPhoto(null) },
+ modifier = Modifier.weight(1f)
+ ) {
+ Icon(Icons.Default.Refresh, contentDescription = null)
+ Spacer(modifier = Modifier.width(8.dp))
+ Text("Ambil Ulang")
+ }
+
+ Button(
+ onClick = { showSubmitDialog = true },
+ modifier = Modifier.weight(1f),
+ enabled = locationValidation?.isValid == true
+ ) {
+ Icon(Icons.Default.Send, contentDescription = null)
+ Spacer(modifier = Modifier.width(8.dp))
+ Text("Submit")
+ }
+ }
+ }
+ }
+ }
+ }
+
+ // Submit Confirmation Dialog
+ if (showSubmitDialog) {
+ AlertDialog(
+ onDismissRequest = { showSubmitDialog = false },
+ icon = { Icon(Icons.Default.CheckCircle, contentDescription = null) },
+ title = { Text("Konfirmasi Absensi") },
+ text = {
+ Column {
+ Text("Apakah Anda yakin ingin mengirim absensi untuk:")
+ Spacer(modifier = Modifier.height(8.dp))
+ Text(
+ text = mataKuliah?.nama ?: "",
+ fontWeight = FontWeight.Bold
+ )
+ Spacer(modifier = Modifier.height(4.dp))
+ Text(
+ text = "Waktu: ${SimpleDateFormat("dd/MM/yyyy HH:mm", Locale.getDefault()).format(Date())}",
+ style = MaterialTheme.typography.bodySmall
+ )
+ }
+ },
+ confirmButton = {
+ Button(
+ onClick = {
+ isSubmitting = true
+ currentLocation?.let { (lat, lng) ->
+ currentStudent?.let { student ->
+ capturedPhoto?.let { photoPath ->
+ val absensiData = AbsensiData(
+ mataKuliahId = mataKuliahId,
+ mataKuliahNama = mataKuliah?.nama ?: "",
+ nim = student.nim,
+ nama = student.nama,
+ latitude = lat,
+ longitude = lng,
+ fotoPath = photoPath,
+ timestamp = System.currentTimeMillis()
+ )
+
+ viewModel.submitAbsensi(
+ context = context,
+ absensiData = absensiData,
+ onSuccess = {
+ isSubmitting = false
+ showSubmitDialog = false
+ onSubmitSuccess()
+ },
+ onError = { error ->
+ isSubmitting = false
+ errorMessage = error
+ }
+ )
+ }
+ }
+ }
+ },
+ enabled = !isSubmitting
+ ) {
+ if (isSubmitting) {
+ CircularProgressIndicator(
+ modifier = Modifier.size(16.dp),
+ color = MaterialTheme.colorScheme.onPrimary
+ )
+ } else {
+ Text("Ya, Kirim")
+ }
+ }
+ },
+ dismissButton = {
+ TextButton(
+ onClick = { showSubmitDialog = false },
+ enabled = !isSubmitting
+ ) {
+ Text("Batal")
+ }
+ }
+ )
+ }
+}
+
+@Composable
+fun CameraPreview(
+ onImageCaptureReady: (ImageCapture) -> Unit
+) {
+ val context = LocalContext.current
+ val lifecycleOwner = LocalLifecycleOwner.current
+
+ val previewView = remember { PreviewView(context) }
+
+ LaunchedEffect(Unit) {
+ val cameraProviderFuture = ProcessCameraProvider.getInstance(context)
+ cameraProviderFuture.addListener({
+ val cameraProvider = cameraProviderFuture.get()
+
+ val preview = Preview.Builder().build().also {
+ it.setSurfaceProvider(previewView.surfaceProvider)
+ }
+
+ val imageCapture = ImageCapture.Builder()
+ .setCaptureMode(ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY)
+ .build()
+
+ onImageCaptureReady(imageCapture)
+
+ val cameraSelector = CameraSelector.DEFAULT_FRONT_CAMERA
+
+ try {
+ cameraProvider.unbindAll()
+ cameraProvider.bindToLifecycle(
+ lifecycleOwner,
+ cameraSelector,
+ preview,
+ imageCapture
+ )
+ } catch (e: Exception) {
+ e.printStackTrace()
+ }
+ }, ContextCompat.getMainExecutor(context))
+ }
+
+ AndroidView(
+ factory = { previewView },
+ modifier = Modifier.fillMaxSize()
+ )
+}
+
+private fun getLocation(
+ context: Context,
+ onLocationReceived: (Double, Double) -> Unit
+) {
+ val fusedLocationClient = LocationServices.getFusedLocationProviderClient(context)
+
+ try {
+ fusedLocationClient.lastLocation.addOnSuccessListener { location: Location? ->
+ location?.let {
+ onLocationReceived(it.latitude, it.longitude)
+ }
+ }
+ } catch (e: SecurityException) {
+ e.printStackTrace()
+ }
+}
+
+private fun takePicture(
+ context: Context,
+ imageCapture: ImageCapture,
+ onPhotoSaved: (String) -> Unit
+) {
+ val photoFile = File(
+ context.externalCacheDir,
+ "absensi_${System.currentTimeMillis()}.jpg"
+ )
+
+ val outputOptions = ImageCapture.OutputFileOptions.Builder(photoFile).build()
+
+ imageCapture.takePicture(
+ outputOptions,
+ ContextCompat.getMainExecutor(context),
+ object : ImageCapture.OnImageSavedCallback {
+ override fun onImageSaved(output: ImageCapture.OutputFileResults) {
+ onPhotoSaved(photoFile.absolutePath)
+ }
+
+ override fun onError(exception: ImageCaptureException) {
+ exception.printStackTrace()
+ }
+ }
+ )
+}
+
+@Composable
+fun LocationInfoRow(
+ icon: androidx.compose.ui.graphics.vector.ImageVector,
+ label: String,
+ value: String
+) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(
+ imageVector = icon,
+ contentDescription = null,
+ modifier = Modifier.size(20.dp),
+ tint = MaterialTheme.colorScheme.primary
+ )
+ Spacer(modifier = Modifier.width(12.dp))
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = label,
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Text(
+ text = value,
+ style = MaterialTheme.typography.bodyMedium
+ )
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/id/ac/ubharajaya/sistemakademik/ui/theme/screen/ProfileScreen.kt b/app/src/main/java/id/ac/ubharajaya/sistemakademik/ui/theme/screen/ProfileScreen.kt
index 85e80c1..19697ce 100644
--- a/app/src/main/java/id/ac/ubharajaya/sistemakademik/ui/theme/screen/ProfileScreen.kt
+++ b/app/src/main/java/id/ac/ubharajaya/sistemakademik/ui/theme/screen/ProfileScreen.kt
@@ -1,2 +1,466 @@
-package id.ac.ubharajaya.sistemakademik.ui.theme.screen
+package id.ac.ubharajaya.sistemakademik.ui.screen
+
+import androidx.compose.foundation.Image
+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.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.layout.ContentScale
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import coil.compose.rememberAsyncImagePainter
+import id.ac.ubharajaya.sistemakademik.models.AbsensiHistory
+import id.ac.ubharajaya.sistemakademik.viewmodel.AbsensiViewModel
+import java.io.File
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun ProfileScreen(
+ viewModel: AbsensiViewModel,
+ onBackClick: () -> Unit,
+ onLogout: () -> Unit
+) {
+ val currentStudent by viewModel.currentStudent.collectAsState()
+ val absensiHistory by viewModel.absensiHistory.collectAsState()
+
+ var showLogoutDialog by remember { mutableStateOf(false) }
+
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = { Text("Profil") },
+ navigationIcon = {
+ IconButton(onClick = onBackClick) {
+ Icon(Icons.Default.ArrowBack, contentDescription = "Back")
+ }
+ },
+ actions = {
+ IconButton(onClick = { showLogoutDialog = true }) {
+ Icon(Icons.Default.Logout, contentDescription = "Logout")
+ }
+ }
+ )
+ }
+ ) { paddingValues ->
+ LazyColumn(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(paddingValues)
+ ) {
+ // Profile Header
+ item {
+ Card(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.primaryContainer
+ )
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(24.dp),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ // Profile Picture
+ Surface(
+ modifier = Modifier.size(100.dp),
+ shape = CircleShape,
+ color = MaterialTheme.colorScheme.primary
+ ) {
+ Icon(
+ imageVector = Icons.Default.Person,
+ contentDescription = null,
+ modifier = Modifier
+ .padding(20.dp)
+ .size(60.dp),
+ tint = MaterialTheme.colorScheme.onPrimary
+ )
+ }
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ // Student Info
+ currentStudent?.let { student ->
+ Text(
+ text = student.nama,
+ style = MaterialTheme.typography.headlineSmall,
+ fontWeight = FontWeight.Bold
+ )
+
+ Spacer(modifier = Modifier.height(4.dp))
+
+ Text(
+ text = student.nim,
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onPrimaryContainer
+ )
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ Surface(
+ shape = MaterialTheme.shapes.small,
+ color = MaterialTheme.colorScheme.primary
+ ) {
+ Text(
+ text = student.prodi,
+ modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.onPrimary
+ )
+ }
+ }
+ }
+ }
+ }
+
+ // Profile Details
+ item {
+ Card(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp)
+ ) {
+ Column(modifier = Modifier.padding(16.dp)) {
+ Text(
+ text = "Informasi Akun",
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.Bold
+ )
+
+ Spacer(modifier = Modifier.height(12.dp))
+
+ currentStudent?.let { student ->
+ ProfileInfoRow(
+ icon = Icons.Default.Email,
+ label = "Email",
+ value = student.email
+ )
+
+ Divider(modifier = Modifier.padding(vertical = 8.dp))
+
+ ProfileInfoRow(
+ icon = Icons.Default.School,
+ label = "Program Studi",
+ value = student.prodi
+ )
+
+ Divider(modifier = Modifier.padding(vertical = 8.dp))
+
+ ProfileInfoRow(
+ icon = Icons.Default.Badge,
+ label = "NIM",
+ value = student.nim
+ )
+ }
+ }
+ }
+ }
+
+ // Statistics
+ item {
+ Card(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp)
+ ) {
+ Column(modifier = Modifier.padding(16.dp)) {
+ Text(
+ text = "Statistik Kehadiran",
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.Bold
+ )
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceEvenly
+ ) {
+ StatisticItem(
+ value = absensiHistory.size.toString(),
+ label = "Total Hadir",
+ icon = Icons.Default.CheckCircle,
+ color = MaterialTheme.colorScheme.primary
+ )
+
+ StatisticItem(
+ value = "0",
+ label = "Izin",
+ icon = Icons.Default.EventNote,
+ color = MaterialTheme.colorScheme.tertiary
+ )
+
+ StatisticItem(
+ value = "0",
+ label = "Alpa",
+ icon = Icons.Default.Cancel,
+ color = MaterialTheme.colorScheme.error
+ )
+ }
+ }
+ }
+ }
+
+ // History Header
+ item {
+ Text(
+ text = "Riwayat Absensi",
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.Bold,
+ modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
+ )
+ }
+
+ // History List
+ if (absensiHistory.isEmpty()) {
+ item {
+ Card(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp, vertical = 8.dp)
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(32.dp),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Icon(
+ imageVector = Icons.Default.History,
+ contentDescription = null,
+ modifier = Modifier.size(48.dp),
+ tint = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ Text(
+ text = "Belum ada riwayat absensi",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+ }
+ } else {
+ items(absensiHistory) { history ->
+ AbsensiHistoryCard(history)
+ }
+ }
+
+ // Bottom Spacing
+ item {
+ Spacer(modifier = Modifier.height(16.dp))
+ }
+ }
+ }
+
+ // Logout Dialog
+ if (showLogoutDialog) {
+ AlertDialog(
+ onDismissRequest = { showLogoutDialog = false },
+ icon = { Icon(Icons.Default.Logout, contentDescription = null) },
+ title = { Text("Logout") },
+ text = { Text("Apakah Anda yakin ingin keluar dari aplikasi?") },
+ confirmButton = {
+ Button(
+ onClick = {
+ showLogoutDialog = false
+ viewModel.logout()
+ onLogout()
+ }
+ ) {
+ Text("Ya, Logout")
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = { showLogoutDialog = false }) {
+ Text("Batal")
+ }
+ }
+ )
+ }
+}
+
+@Composable
+fun ProfileInfoRow(
+ icon: androidx.compose.ui.graphics.vector.ImageVector,
+ label: String,
+ value: String
+) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(
+ imageVector = icon,
+ contentDescription = null,
+ modifier = Modifier.size(24.dp),
+ tint = MaterialTheme.colorScheme.primary
+ )
+
+ Spacer(modifier = Modifier.width(16.dp))
+
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = label,
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Text(
+ text = value,
+ style = MaterialTheme.typography.bodyMedium
+ )
+ }
+ }
+}
+
+@Composable
+fun StatisticItem(
+ value: String,
+ label: String,
+ icon: androidx.compose.ui.graphics.vector.ImageVector,
+ color: androidx.compose.ui.graphics.Color
+) {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Surface(
+ shape = CircleShape,
+ color = color.copy(alpha = 0.1f),
+ modifier = Modifier.size(56.dp)
+ ) {
+ Icon(
+ imageVector = icon,
+ contentDescription = null,
+ modifier = Modifier.padding(12.dp),
+ tint = color
+ )
+ }
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ Text(
+ text = value,
+ style = MaterialTheme.typography.titleLarge,
+ fontWeight = FontWeight.Bold,
+ color = color
+ )
+
+ Text(
+ text = label,
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+}
+
+@Composable
+fun AbsensiHistoryCard(history: AbsensiHistory) {
+ Card(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp, vertical = 4.dp)
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(12.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ // Photo
+ Surface(
+ modifier = Modifier.size(60.dp),
+ shape = MaterialTheme.shapes.medium,
+ color = MaterialTheme.colorScheme.surfaceVariant
+ ) {
+ if (history.foto.isNotEmpty()) {
+ Image(
+ painter = rememberAsyncImagePainter(File(history.foto)),
+ contentDescription = null,
+ contentScale = ContentScale.Crop,
+ modifier = Modifier.fillMaxSize()
+ )
+ } else {
+ Icon(
+ imageVector = Icons.Default.Person,
+ contentDescription = null,
+ modifier = Modifier.padding(12.dp)
+ )
+ }
+ }
+
+ Spacer(modifier = Modifier.width(12.dp))
+
+ // Info
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = history.mataKuliah,
+ style = MaterialTheme.typography.titleSmall,
+ fontWeight = FontWeight.Bold
+ )
+
+ Spacer(modifier = Modifier.height(4.dp))
+
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Icon(
+ imageVector = Icons.Default.CalendarToday,
+ contentDescription = null,
+ modifier = Modifier.size(14.dp),
+ tint = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Spacer(modifier = Modifier.width(4.dp))
+ Text(
+ text = history.tanggal,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Icon(
+ imageVector = Icons.Default.AccessTime,
+ contentDescription = null,
+ modifier = Modifier.size(14.dp),
+ tint = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Spacer(modifier = Modifier.width(4.dp))
+ Text(
+ text = history.waktu,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+
+ // Status Badge
+ Surface(
+ shape = MaterialTheme.shapes.small,
+ color = when (history.status) {
+ "Hadir" -> MaterialTheme.colorScheme.primaryContainer
+ "Terlambat" -> MaterialTheme.colorScheme.tertiaryContainer
+ else -> MaterialTheme.colorScheme.errorContainer
+ }
+ ) {
+ Text(
+ text = history.status,
+ modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
+ style = MaterialTheme.typography.labelSmall,
+ color = when (history.status) {
+ "Hadir" -> MaterialTheme.colorScheme.onPrimaryContainer
+ "Terlambat" -> MaterialTheme.colorScheme.onTertiaryContainer
+ else -> MaterialTheme.colorScheme.onErrorContainer
+ }
+ )
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/id/ac/ubharajaya/sistemakademik/utils/LocationManager.kt b/app/src/main/java/id/ac/ubharajaya/sistemakademik/utils/LocationManager.kt
new file mode 100644
index 0000000..4810cfa
--- /dev/null
+++ b/app/src/main/java/id/ac/ubharajaya/sistemakademik/utils/LocationManager.kt
@@ -0,0 +1,335 @@
+package id.ac.ubharajaya.sistemakademik.utils
+
+import kotlin.math.*
+
+/**
+ * Location Manager untuk validasi koordinat kampus dan deteksi ruangan
+ */
+object LocationManager {
+
+ // Data class untuk lokasi kampus
+ data class CampusLocation(
+ val id: String,
+ val name: String,
+ val building: String,
+ val latitude: Double,
+ val longitude: Double,
+ val radius: Double = 50.0, // dalam meter
+ val floor: String? = null,
+ val type: LocationType = LocationType.CLASSROOM
+ )
+
+ enum class LocationType {
+ CLASSROOM, // Ruang kelas
+ LABORATORY, // Laboratorium
+ LIBRARY, // Perpustakaan
+ AUDITORIUM, // Auditorium
+ CAFETERIA, // Kantin
+ OUTDOOR // Area outdoor
+ }
+
+ // Status validasi lokasi
+ data class LocationValidation(
+ val isValid: Boolean,
+ val location: CampusLocation?,
+ val distance: Double,
+ val message: String
+ )
+
+ /**
+ * KOORDINAT KAMPUS UBHARA JAKARTA
+ * Note: Ini contoh koordinat, sesuaikan dengan kampus kalian!
+ * Tips: Buka Google Maps, klik lokasi, copy koordinat
+ */
+
+ // Koordinat pusat kampus (untuk fallback)
+ private const val CAMPUS_CENTER_LAT = -6.256081 // Contoh: Area tengah kampus
+ private const val CAMPUS_CENTER_LNG = 106.618755
+
+ // Daftar lokasi di kampus
+ private val campusLocations = listOf(
+ // ===== GEDUNG A - FAKULTAS ILKOM =====
+ CampusLocation(
+ id = "gedung_a_lab101",
+ name = "Lab Komputer 101",
+ building = "Gedung A",
+ latitude = -6.301615867296438,
+ longitude = 107.01825381393571,
+ radius = 30000.00000,
+ floor = "Lantai 1",
+ type = LocationType.LABORATORY
+ ),
+ CampusLocation(
+ id = "gedung_a_lab102",
+ name = "Lab Komputer 102",
+ building = "Gedung A",
+ latitude = -6.256145,
+ longitude = 106.618823,
+ radius = 30.0,
+ floor = "Lantai 1",
+ type = LocationType.LABORATORY
+ ),
+ CampusLocation(
+ id = "gedung_a_lab201",
+ name = "Lab Multimedia",
+ building = "Gedung A",
+ latitude = -6.256089,
+ longitude = 106.618778,
+ radius = 30.0,
+ floor = "Lantai 2",
+ type = LocationType.LABORATORY
+ ),
+ CampusLocation(
+ id = "gedung_a_kelas301",
+ name = "Ruang Kelas A.301",
+ building = "Gedung A",
+ latitude = -6.256067,
+ longitude = 106.618756,
+ radius = 25.0,
+ floor = "Lantai 3",
+ type = LocationType.CLASSROOM
+ ),
+ CampusLocation(
+ id = "gedung_a_kelas302",
+ name = "Ruang Kelas A.302",
+ building = "Gedung A",
+ latitude = -6.256045,
+ longitude = 106.618734,
+ radius = 25.0,
+ floor = "Lantai 3",
+ type = LocationType.CLASSROOM
+ ),
+
+ // ===== GEDUNG B - FAKULTAS TEKNIK =====
+ CampusLocation(
+ id = "gedung_b_lab103",
+ name = "Lab Jaringan",
+ building = "Gedung B",
+ latitude = -6.255987,
+ longitude = 106.618912,
+ radius = 30.0,
+ floor = "Lantai 1",
+ type = LocationType.LABORATORY
+ ),
+ CampusLocation(
+ id = "gedung_b_kelas201",
+ name = "Ruang Kelas B.201",
+ building = "Gedung B",
+ latitude = -6.255934,
+ longitude = 106.618889,
+ radius = 25.0,
+ floor = "Lantai 2",
+ type = LocationType.CLASSROOM
+ ),
+
+ // ===== GEDUNG C - PERPUSTAKAAN =====
+ CampusLocation(
+ id = "perpustakaan_lt1",
+ name = "Perpustakaan Pusat",
+ building = "Gedung C",
+ latitude = -6.256234,
+ longitude = 106.618678,
+ radius = 40.0,
+ floor = "Lantai 1-3",
+ type = LocationType.LIBRARY
+ ),
+
+ // ===== GEDUNG D - AUDITORIUM =====
+ CampusLocation(
+ id = "auditorium_utama",
+ name = "Auditorium Utama",
+ building = "Gedung D",
+ latitude = -6.256312,
+ longitude = 106.618567,
+ radius = 50.0,
+ type = LocationType.AUDITORIUM
+ ),
+
+ // ===== AREA OUTDOOR =====
+ CampusLocation(
+ id = "lapangan_upacara",
+ name = "Lapangan Upacara",
+ building = "Area Outdoor",
+ latitude = -6.256156,
+ longitude = 106.618645,
+ radius = 60.0,
+ type = LocationType.OUTDOOR
+ ),
+ CampusLocation(
+ id = "kantin_kampus",
+ name = "Kantin Kampus",
+ building = "Area Kantin",
+ latitude = -6.256201,
+ longitude = 106.618890,
+ radius = 35.0,
+ type = LocationType.CAFETERIA
+ ),
+
+ // ===== TAMBAHKAN LOKASI LAIN SESUAI KAMPUS KALIAN =====
+ )
+
+ /**
+ * Hitung jarak antara dua koordinat menggunakan Haversine Formula
+ * @return jarak dalam meter
+ */
+ fun calculateDistance(
+ lat1: Double,
+ lon1: Double,
+ lat2: Double,
+ lon2: Double
+ ): Double {
+ val earthRadius = 6371000.0 // Radius bumi dalam meter
+
+ val dLat = Math.toRadians(lat2 - lat1)
+ val dLon = Math.toRadians(lon2 - lon1)
+
+ val a = sin(dLat / 2) * sin(dLat / 2) +
+ cos(Math.toRadians(lat1)) * cos(Math.toRadians(lat2)) *
+ sin(dLon / 2) * sin(dLon / 2)
+
+ val c = 2 * atan2(sqrt(a), sqrt(1 - a))
+
+ return earthRadius * c
+ }
+
+ /**
+ * Validasi apakah lokasi user berada di dalam kampus
+ * @return LocationValidation dengan detail lokasi
+ */
+ fun validateLocation(
+ userLat: Double,
+ userLng: Double
+ ): LocationValidation {
+ // Cari lokasi terdekat dari user
+ var nearestLocation: CampusLocation? = null
+ var minDistance = Double.MAX_VALUE
+
+ for (location in campusLocations) {
+ val distance = calculateDistance(
+ userLat, userLng,
+ location.latitude, location.longitude
+ )
+
+ // Jika dalam radius, return lokasi ini
+ if (distance <= location.radius) {
+ return LocationValidation(
+ isValid = true,
+ location = location,
+ distance = distance,
+ message = "Anda berada di ${location.name}, ${location.building}" +
+ (location.floor?.let { " - $it" } ?: "")
+ )
+ }
+
+ // Simpan lokasi terdekat
+ if (distance < minDistance) {
+ minDistance = distance
+ nearestLocation = location
+ }
+ }
+
+ // Jika tidak ada yang match, berikan info lokasi terdekat
+ return if (nearestLocation != null) {
+ LocationValidation(
+ isValid = false,
+ location = nearestLocation,
+ distance = minDistance,
+ message = "Anda terlalu jauh dari lokasi kuliah. " +
+ "Lokasi terdekat: ${nearestLocation.name} (${minDistance.toInt()}m)"
+ )
+ } else {
+ LocationValidation(
+ isValid = false,
+ location = null,
+ distance = minDistance,
+ message = "Anda berada di luar area kampus"
+ )
+ }
+ }
+
+ /**
+ * Cari lokasi berdasarkan ID
+ */
+ fun getLocationById(id: String): CampusLocation? {
+ return campusLocations.find { it.id == id }
+ }
+
+ /**
+ * Get semua lokasi berdasarkan tipe
+ */
+ fun getLocationsByType(type: LocationType): List {
+ return campusLocations.filter { it.type == type }
+ }
+
+ /**
+ * Get semua lokasi di gedung tertentu
+ */
+ fun getLocationsByBuilding(building: String): List {
+ return campusLocations.filter { it.building == building }
+ }
+
+ /**
+ * Check apakah koordinat dalam area kampus (general check)
+ */
+ fun isInCampusArea(lat: Double, lng: Double, maxRadius: Double = 500.0): Boolean {
+ val distance = calculateDistance(
+ lat, lng,
+ CAMPUS_CENTER_LAT, CAMPUS_CENTER_LNG
+ )
+ return distance <= maxRadius
+ }
+
+ /**
+ * Format jarak untuk ditampilkan
+ */
+ fun formatDistance(distanceInMeters: Double): String {
+ return when {
+ distanceInMeters < 1000 -> "${distanceInMeters.toInt()}m"
+ else -> String.format("%.2f km", distanceInMeters / 1000)
+ }
+ }
+
+ /**
+ * Get rekomendasi lokasi untuk mata kuliah tertentu
+ * Ini bisa disesuaikan dengan jadwal dan ruangan mata kuliah
+ */
+ fun getRecommendedLocation(mataKuliahId: String, ruangan: String): CampusLocation? {
+ // Cari berdasarkan nama ruangan
+ return campusLocations.find {
+ it.name.contains(ruangan, ignoreCase = true)
+ }
+ }
+
+ /**
+ * Generate mock location untuk testing (HANYA UNTUK DEVELOPMENT)
+ */
+ fun getMockLocation(locationId: String): Pair? {
+ val location = getLocationById(locationId)
+ return location?.let {
+ // Tambah sedikit random offset untuk variasi
+ val latOffset = (Math.random() - 0.5) * 0.0001 // ~5-10 meter
+ val lngOffset = (Math.random() - 0.5) * 0.0001
+ Pair(it.latitude + latOffset, it.longitude + lngOffset)
+ }
+ }
+
+ /**
+ * Get semua lokasi (untuk debugging)
+ */
+ fun getAllLocations(): List {
+ return campusLocations
+ }
+
+ /**
+ * Get statistik lokasi
+ */
+ fun getLocationStats(): Map {
+ return mapOf(
+ "total" to campusLocations.size,
+ "classrooms" to campusLocations.count { it.type == LocationType.CLASSROOM },
+ "laboratories" to campusLocations.count { it.type == LocationType.LABORATORY },
+ "libraries" to campusLocations.count { it.type == LocationType.LIBRARY },
+ "buildings" to campusLocations.map { it.building }.distinct().size
+ )
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/id/ac/ubharajaya/sistemakademik/viewmodel/AbsensiViewModel.kt b/app/src/main/java/id/ac/ubharajaya/sistemakademik/viewmodel/AbsensiViewModel.kt
index 494debf..aeedd83 100644
--- a/app/src/main/java/id/ac/ubharajaya/sistemakademik/viewmodel/AbsensiViewModel.kt
+++ b/app/src/main/java/id/ac/ubharajaya/sistemakademik/viewmodel/AbsensiViewModel.kt
@@ -1,2 +1,189 @@
-package id.ac.ubharajaya.sistemakademik.viewmodel
+package id.ac.ubharajaya.sistemakademik.viewmodel
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import id.ac.ubharajaya.sistemakademik.models.*
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
+
+class AbsensiViewModel : ViewModel() {
+
+ // Current Student State
+ private val _currentStudent = MutableStateFlow(null)
+ val currentStudent: StateFlow = _currentStudent.asStateFlow()
+
+ // Mata Kuliah List State
+ private val _mataKuliahList = MutableStateFlow>(emptyList())
+ val mataKuliahList: StateFlow> = _mataKuliahList.asStateFlow()
+
+ // Absensi History State
+ private val _absensiHistory = MutableStateFlow>(emptyList())
+ val absensiHistory: StateFlow> = _absensiHistory.asStateFlow()
+
+ // Location State
+ private val _currentLocation = MutableStateFlow?>(null)
+ val currentLocation: StateFlow?> = _currentLocation.asStateFlow()
+
+ // Captured Photo State
+ private val _capturedPhoto = MutableStateFlow(null)
+ val capturedPhoto: StateFlow = _capturedPhoto.asStateFlow()
+
+ init {
+ loadDummyMataKuliah()
+ }
+
+ /**
+ * Login function (dummy - replace with API call)
+ */
+ fun login(nim: String, password: String): Boolean {
+ // Dummy login - ganti dengan API call
+ if (nim.isNotEmpty() && password.isNotEmpty()) {
+ _currentStudent.value = Student(
+ nim = nim,
+ nama = "Nama Mahasiswa",
+ email = "$nim@mhs.ubharajaya.ac.id",
+ prodi = "Teknik Informatika"
+ )
+ return true
+ }
+ return false
+ }
+
+ /**
+ * Load dummy mata kuliah (replace dengan API call)
+ */
+ private fun loadDummyMataKuliah() {
+ _mataKuliahList.value = listOf(
+ MataKuliah(
+ id = "1",
+ nama = "Pemrograman Mobile",
+ kode = "IF123",
+ dosen = "Dr. Dosen A",
+ hari = "Senin",
+ jam = "08:00-10:00",
+ ruang = "Lab 101"
+ ),
+ MataKuliah(
+ id = "2",
+ nama = "Basis Data",
+ kode = "IF124",
+ dosen = "Dr. Dosen B",
+ hari = "Selasa",
+ jam = "10:00-12:00",
+ ruang = "Lab 102"
+ ),
+ MataKuliah(
+ id = "3",
+ nama = "Jaringan Komputer",
+ kode = "IF125",
+ dosen = "Dr. Dosen C",
+ hari = "Rabu",
+ jam = "13:00-15:00",
+ ruang = "Lab 103"
+ ),
+ MataKuliah(
+ id = "4",
+ nama = "Pemrograman Web",
+ kode = "IF126",
+ dosen = "Dr. Dosen D",
+ hari = "Kamis",
+ jam = "08:00-10:00",
+ ruang = "Lab 104"
+ ),
+ MataKuliah(
+ id = "5",
+ nama = "Kecerdasan Buatan",
+ kode = "IF127",
+ dosen = "Dr. Dosen E",
+ hari = "Jumat",
+ jam = "10:00-12:00",
+ ruang = "Lab 105"
+ )
+ )
+ }
+
+ /**
+ * Update current location
+ */
+ fun updateLocation(lat: Double, lng: Double) {
+ _currentLocation.value = Pair(lat, lng)
+ }
+
+ /**
+ * Set captured photo path
+ */
+ fun setCapturedPhoto(photoPath: String?) {
+ _capturedPhoto.value = photoPath
+ }
+
+ /**
+ * Submit absensi ke webhook n8n
+ * Data akan dikirim ke ntfy dan Google Sheets
+ */
+ fun submitAbsensi(
+ context: android.content.Context,
+ absensiData: AbsensiData,
+ onSuccess: () -> Unit,
+ onError: (String) -> Unit
+ ) {
+ viewModelScope.launch {
+ try {
+ // Kirim ke webhook n8n
+ val webhookService = id.ac.ubharajaya.sistemakademik.network.WebhookService()
+ val result = webhookService.submitAbsensi(context, absensiData)
+
+ result.fold(
+ onSuccess = { message ->
+ android.util.Log.d("AbsensiViewModel", "✅ $message")
+
+ // Add to history
+ val history = AbsensiHistory(
+ mataKuliah = absensiData.mataKuliahNama,
+ tanggal = java.text.SimpleDateFormat(
+ "dd/MM/yyyy",
+ java.util.Locale.getDefault()
+ ).format(java.util.Date()),
+ waktu = java.text.SimpleDateFormat(
+ "HH:mm",
+ java.util.Locale.getDefault()
+ ).format(java.util.Date()),
+ status = "Hadir",
+ foto = absensiData.fotoPath
+ )
+
+ _absensiHistory.value = listOf(history) + _absensiHistory.value
+
+ onSuccess()
+ },
+ onFailure = { exception ->
+ android.util.Log.e("AbsensiViewModel", "❌ Error: ${exception.message}")
+ onError(exception.message ?: "Gagal mengirim absensi")
+ }
+ )
+
+ } catch (e: Exception) {
+ android.util.Log.e("AbsensiViewModel", "❌ Exception: ${e.message}")
+ onError(e.message ?: "Terjadi kesalahan")
+ }
+ }
+ }
+
+ /**
+ * Logout
+ */
+ fun logout() {
+ _currentStudent.value = null
+ _absensiHistory.value = emptyList()
+ _currentLocation.value = null
+ _capturedPhoto.value = null
+ }
+
+ /**
+ * Clear captured photo
+ */
+ fun clearPhoto() {
+ _capturedPhoto.value = null
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/res/values-night/themes.xml b/app/src/main/res/values-night/themes.xml
new file mode 100644
index 0000000..862ae8d
--- /dev/null
+++ b/app/src/main/res/values-night/themes.xml
@@ -0,0 +1,6 @@
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index de92dbc..b7287f9 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -1,3 +1,74 @@
- Sistem Akademik
-
\ No newline at end of file
+ Absensi UBJ
+
+
+ Sistem Absensi
+ Universitas Bhayangkara Jakarta Raya
+ NIM
+ Password
+ Login
+ Lupa Password?
+
+
+ Pilih Mata Kuliah
+ Profile
+ Absen Sekarang
+ Pilih mata kuliah untuk melakukan absensi. Pastikan Anda berada di lokasi kuliah dan siap mengambil foto.
+
+
+ Preview Absensi
+ Ambil Foto
+ Ambil Ulang
+ Submit
+ Lokasi Terdeteksi
+ Lokasi Absensi
+
+
+ Profil
+ Logout
+ Informasi Akun
+ Statistik Kehadiran
+ Riwayat Absensi
+ Total Hadir
+ Izin
+ Alpa
+ Belum ada riwayat absensi
+
+
+ Konfirmasi Absensi
+ Apakah Anda yakin ingin mengirim absensi untuk:
+ Ya, Kirim
+ Batal
+ Apakah Anda yakin ingin keluar dari aplikasi?
+ Ya, Logout
+
+
+ Izin Diperlukan
+ Aplikasi memerlukan izin kamera dan lokasi untuk absensi
+ Berikan Izin
+
+
+ NIM dan Password harus diisi
+ NIM atau Password salah
+ Gagal mengirim absensi
+ Gagal mendapatkan lokasi
+ Gagal mengakses kamera
+
+
+ Absensi berhasil dikirim
+ Berhasil logout
+
+
+ Email
+ Program Studi
+ Dosen
+ Hari
+ Waktu
+ Ruangan
+ Status
+ Tanggal
+ Hadir
+ Terlambat
+ Alpa
+ Izin
+
diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml
index d515270..860af9e 100644
--- a/app/src/main/res/values/themes.xml
+++ b/app/src/main/res/values/themes.xml
@@ -1,5 +1,6 @@
-
-
-
-
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml
index a39aafb..0040a77 100644
--- a/app/src/main/res/xml/file_paths.xml
+++ b/app/src/main/res/xml/file_paths.xml
@@ -1,4 +1,12 @@
-
-
-
\ No newline at end of file
+
+
+
+
+
diff --git a/app/src/main/res/xml/network_security_config.xml b/app/src/main/res/xml/network_security_config.xml
new file mode 100644
index 0000000..e0a0fc7
--- /dev/null
+++ b/app/src/main/res/xml/network_security_config.xml
@@ -0,0 +1,32 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ n8n.lab.ubharajaya.ac.id
+ ubharajaya.ac.id
+ api.ubharajaya.ac.id
+
+
+
+
+
+
+
+
+
+ localhost
+ 10.0.2.2
+ 192.168.1.1
+
+
+
\ No newline at end of file