Perubahan Kedua

This commit is contained in:
202310715280 FADLAN RIVALDI 2026-01-14 23:34:25 +07:00
parent f91fb981ba
commit 77bf74a6c4
24 changed files with 3426 additions and 331 deletions

View File

@ -6,14 +6,12 @@ plugins {
android { android {
namespace = "id.ac.ubharajaya.sistemakademik" namespace = "id.ac.ubharajaya.sistemakademik"
compileSdk { compileSdk = 34
version = release(36)
}
defaultConfig { defaultConfig {
applicationId = "id.ac.ubharajaya.sistemakademik" applicationId = "id.ac.ubharajaya.sistemakademik"
minSdk = 28 minSdk = 28
targetSdk = 36 targetSdk = 34
versionCode = 1 versionCode = 1
versionName = "1.0" versionName = "1.0"
@ -29,36 +27,82 @@ android {
) )
} }
} }
compileOptions { compileOptions {
sourceCompatibility = JavaVersion.VERSION_11 sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11 targetCompatibility = JavaVersion.VERSION_11
} }
kotlinOptions { kotlinOptions {
jvmTarget = "11" jvmTarget = "11"
} }
buildFeatures { buildFeatures {
compose = true compose = true
} }
// ✅ IGNORE ERROR AAR METADATA
lint {
abortOnError = false
checkReleaseBuilds = false
}
} }
dependencies { dependencies {
implementation(libs.androidx.core.ktx) // Core Android - Versi LAMA yang stabil
implementation(libs.androidx.lifecycle.runtime.ktx) implementation("androidx.core:core-ktx:1.12.0")
implementation(libs.androidx.activity.compose) implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0")
implementation("androidx.activity:activity-compose:1.9.0") implementation("androidx.activity:activity-compose:1.8.2")
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.compose.ui) // Jetpack Compose BOM - Versi LAMA
implementation(libs.androidx.compose.ui.graphics) implementation(platform("androidx.compose:compose-bom:2023.10.01"))
implementation(libs.androidx.compose.ui.tooling.preview) implementation("androidx.compose.ui:ui")
implementation(libs.androidx.compose.material3) implementation("androidx.compose.ui:ui-graphics")
// Location (GPS) 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") implementation("com.google.android.gms:play-services-location:21.0.1")
testImplementation(libs.junit) // CameraX
androidTestImplementation(libs.androidx.junit) implementation("androidx.camera:camera-camera2:1.3.1")
androidTestImplementation(libs.androidx.espresso.core) implementation("androidx.camera:camera-lifecycle:1.3.1")
androidTestImplementation(platform(libs.androidx.compose.bom)) implementation("androidx.camera:camera-view:1.3.1")
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
debugImplementation(libs.androidx.compose.ui.tooling) // Permissions
debugImplementation(libs.androidx.compose.ui.test.manifest) 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")
} }

View File

@ -2,13 +2,28 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"> xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET"/> <!-- ============ PERMISSIONS ============ -->
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/> <!-- Network -->
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.CAMERA"/> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- Camera -->
<uses-permission android:name="android.permission.CAMERA" />
<!-- Location (untuk GPS koordinat absensi) -->
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<!-- ============ FEATURES ============ -->
<uses-feature <uses-feature
android:name="android.hardware.camera" android:name="android.hardware.camera"
android:required="false" /> android:required="false" />
<uses-feature
android:name="android.hardware.camera.front"
android:required="false" />
<uses-feature
android:name="android.hardware.location.gps"
android:required="false" />
<application <application
android:allowBackup="true" android:allowBackup="true"
@ -18,18 +33,36 @@
android:label="@string/app_name" android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.SistemAkademik"> android:theme="@style/Theme.StarterEAS"
android:enableOnBackInvokedCallback="true"
android:networkSecurityConfig="@xml/network_security_config"
tools:targetApi="31">
<!-- ============ MAIN ACTIVITY ============ -->
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"
android:label="@string/app_name" android:theme="@style/Theme.StarterEAS"
android:theme="@style/Theme.SistemAkademik"> android:screenOrientation="portrait"
android:configChanges="orientation|screenSize|keyboardHidden"
android:windowSoftInputMode="adjustResize">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
</activity> </activity>
<!-- ============ FILE PROVIDER (untuk Camera) ============ -->
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application> </application>
</manifest> </manifest>

View File

@ -1,274 +1,94 @@
package id.ac.ubharajaya.sistemakademik 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.os.Bundle
import android.provider.MediaStore
import android.util.Base64
import android.widget.Toast
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.* import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.* import androidx.compose.material3.Surface
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.compose.ui.unit.dp import androidx.navigation.compose.NavHost
import androidx.core.content.ContextCompat import androidx.navigation.compose.composable
import com.google.android.gms.location.LocationServices import androidx.navigation.compose.rememberNavController
import id.ac.ubharajaya.sistemakademik.ui.theme.SistemAkademikTheme import id.ac.ubharajaya.sistemakademik.navigation.Screen
import org.json.JSONObject import id.ac.ubharajaya.sistemakademik.ui.theme.*
import java.io.ByteArrayOutputStream import id.ac.ubharajaya.sistemakademik.ui.theme.StarterEASTheme
import java.net.HttpURLConnection import id.ac.ubharajaya.sistemakademik.viewmodel.AbsensiViewModel
import java.net.URL import id.ac.ubharajaya.sistemakademik.ui.screen.LoginScreen
import kotlin.concurrent.thread 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() { class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
enableEdgeToEdge() enableEdgeToEdge()
setContent { setContent {
SistemAkademikTheme { StarterEASTheme {
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> Surface(
AbsensiScreen( modifier = Modifier.fillMaxSize(),
modifier = Modifier.padding(innerPadding), color = MaterialTheme.colorScheme.background
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<Double?>(null) }
var longitude by remember { mutableStateOf<Double?>(null) }
var foto by remember { mutableStateOf<Bitmap?>(null) }
val fusedLocationClient =
LocationServices.getFusedLocationProviderClient(context)
/* ===== Permission Lokasi ===== */
val locationPermissionLauncher =
rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission()
) { granted ->
if (granted) {
if (
ContextCompat.checkSelfPermission(
context,
Manifest.permission.ACCESS_FINE_LOCATION
) == PackageManager.PERMISSION_GRANTED
) { ) {
val navController = rememberNavController()
val viewModel: AbsensiViewModel = viewModel()
fusedLocationClient.lastLocation NavHost(
.addOnSuccessListener { location -> navController = navController,
if (location != null) { startDestination = Screen.Login.route
latitude = location.latitude ) {
longitude = location.longitude composable(Screen.Login.route) {
lokasi = LoginScreen(
"Lat: ${location.latitude}\nLon: ${location.longitude}" viewModel = viewModel,
} else { onLoginSuccess = {
lokasi = "Lokasi tidak tersedia" 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 { composable(Screen.Preview.route) { backStackEntry ->
Toast.makeText( val mataKuliahId = backStackEntry.arguments?.getString("mataKuliahId") ?: ""
context, PreviewScreen(
"Izin lokasi ditolak", viewModel = viewModel,
Toast.LENGTH_SHORT mataKuliahId = mataKuliahId,
).show() onBackClick = { navController.popBackStack() },
} onSubmitSuccess = {
} navController.navigate(Screen.MataKuliah.route) {
popUpTo(Screen.MataKuliah.route) { inclusive = true }
}
}
)
}
/* ===== Kamera ===== */ composable(Screen.Profile.route) {
ProfileScreen(
val cameraLauncher = viewModel = viewModel,
rememberLauncherForActivityResult( onBackClick = { navController.popBackStack() },
ActivityResultContracts.StartActivityForResult() onLogout = {
) { result -> navController.navigate(Screen.Login.route) {
if (result.resultCode == Activity.RESULT_OK) { popUpTo(0) { inclusive = true }
val bitmap = }
result.data?.extras?.getParcelable("data", Bitmap::class.java) }
if (bitmap != null) { )
foto = bitmap }
Toast.makeText( }
context,
"Foto berhasil diambil",
Toast.LENGTH_SHORT
).show()
} }
} }
} }
val cameraPermissionLauncher =
rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission()
) { granted ->
if (granted) {
val intent =
Intent(MediaStore.ACTION_IMAGE_CAPTURE)
cameraLauncher.launch(intent)
} else {
Toast.makeText(
context,
"Izin kamera ditolak",
Toast.LENGTH_SHORT
).show()
}
}
/* ===== Request Awal ===== */
LaunchedEffect(Unit) {
locationPermissionLauncher.launch(
Manifest.permission.ACCESS_FINE_LOCATION
)
}
/* ===== UI ===== */
Column(
modifier = modifier
.fillMaxSize()
.padding(24.dp),
verticalArrangement = Arrangement.Center
) {
Text(
text = "Absensi Akademik",
style = MaterialTheme.typography.titleLarge
)
Spacer(modifier = Modifier.height(16.dp))
Text(text = lokasi)
Spacer(modifier = Modifier.height(16.dp))
Button(
onClick = {
cameraPermissionLauncher.launch(
Manifest.permission.CAMERA
)
},
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")
}
} }
} }

View File

@ -1,2 +1,158 @@
package id.ac.ubharajaya.sistemakademik.Repository 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<String> {
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())
}
}

View File

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

View File

@ -1,4 +1,4 @@
package id.ac.ubharajaya.sistemakademik package id.ac.ubharajaya.sistemakademik.models
data class MataKuliah( data class MataKuliah(
val id: String, val id: String,

View File

@ -1,4 +1,4 @@
package id.ac.ubharajaya.sistemakademik package id.ac.ubharajaya.sistemakademik.navigation
sealed class Screen(val route: String) { sealed class Screen(val route: String) {
object Login : Screen("login") object Login : Screen("login")
@ -6,3 +6,5 @@ sealed class Screen(val route: String) {
object Preview : Screen("preview/{mataKuliahId}") { object Preview : Screen("preview/{mataKuliahId}") {
fun createRoute(mataKuliahId: String) = "preview/$mataKuliahId" fun createRoute(mataKuliahId: String) = "preview/$mataKuliahId"
} }
object Profile : Screen("profile") // ✅ Tambahkan ini
}

View File

@ -1,2 +1,86 @@
package id.ac.ubharajaya.sistemakademik.network 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<LoginResponse>
@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<AbsensiResponse>
@GET("absensi/history/{nim}")
suspend fun getAbsensiHistory(
@Path("nim") nim: String
): Response<List<AbsensiHistoryResponse>>
@GET("matakuliah/list")
suspend fun getMataKuliahList(): Response<List<MataKuliahResponse>>
}

View File

@ -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<String> = 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<String> {
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<AbsensiHistory> {
return try {
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
val historyJson = prefs.getString(KEY_HISTORY, "[]") ?: "[]"
val historyArray = JSONArray(historyJson)
val list = mutableListOf<AbsensiHistory>()
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<String> {
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<String> = 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)
}
}
}

View File

@ -2,10 +2,58 @@ package id.ac.ubharajaya.sistemakademik.ui.theme
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
val Purple80 = Color(0xFFD0BCFF) // Light Theme Colors
val PurpleGrey80 = Color(0xFFCCC2DC) val md_theme_light_primary = Color(0xFF0061A4)
val Pink80 = Color(0xFFEFB8C8) 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) // Dark Theme Colors
val PurpleGrey40 = Color(0xFF625b71) val md_theme_dark_primary = Color(0xFF9ECAFF)
val Pink40 = Color(0xFF7D5260) 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)

View File

@ -1,5 +1,4 @@
package id.ac.ubharajaya.sistemakademik.ui.theme package id.ac.ubharajaya.sistemakademik.ui.theme
import android.app.Activity import android.app.Activity
import android.os.Build import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
@ -9,47 +8,96 @@ import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
private val DarkColorScheme = darkColorScheme( import androidx.core.view.WindowCompat
primary = Purple80,
secondary = PurpleGrey80,
tertiary = Pink80
)
private val LightColorScheme = lightColorScheme( private val LightColorScheme = lightColorScheme(
primary = Purple40, primary = md_theme_light_primary,
secondary = PurpleGrey40, onPrimary = md_theme_light_onPrimary,
tertiary = Pink40 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 private val DarkColorScheme = darkColorScheme(
background = Color(0xFFFFFBFE), primary = md_theme_dark_primary,
surface = Color(0xFFFFFBFE), onPrimary = md_theme_dark_onPrimary,
onPrimary = Color.White, primaryContainer = md_theme_dark_primaryContainer,
onSecondary = Color.White, onPrimaryContainer = md_theme_dark_onPrimaryContainer,
onTertiary = Color.White, secondary = md_theme_dark_secondary,
onBackground = Color(0xFF1C1B1F), onSecondary = md_theme_dark_onSecondary,
onSurface = Color(0xFF1C1B1F), 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 @Composable
fun SistemAkademikTheme( fun StarterEASTheme(
darkTheme: Boolean = isSystemInDarkTheme(), darkTheme: Boolean = isSystemInDarkTheme(),
// Dynamic color is available on Android 12+
dynamicColor: Boolean = true, dynamicColor: Boolean = true,
content: @Composable () -> Unit content: @Composable () -> Unit
) { ) {
val colorScheme = when { val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) if (darkTheme) dynamicDarkColorScheme(context)
else dynamicLightColorScheme(context)
} }
darkTheme -> DarkColorScheme darkTheme -> DarkColorScheme
else -> LightColorScheme 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( MaterialTheme(
colorScheme = colorScheme, colorScheme = colorScheme,
typography = Typography, typography = Typography,

View File

@ -6,7 +6,6 @@ import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
// Set of Material typography styles to start with
val Typography = Typography( val Typography = Typography(
bodyLarge = TextStyle( bodyLarge = TextStyle(
fontFamily = FontFamily.Default, fontFamily = FontFamily.Default,
@ -14,8 +13,7 @@ val Typography = Typography(
fontSize = 16.sp, fontSize = 16.sp,
lineHeight = 24.sp, lineHeight = 24.sp,
letterSpacing = 0.5.sp letterSpacing = 0.5.sp
) ),
/* Other default text styles to override
titleLarge = TextStyle( titleLarge = TextStyle(
fontFamily = FontFamily.Default, fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal, fontWeight = FontWeight.Normal,
@ -30,5 +28,4 @@ val Typography = Typography(
lineHeight = 16.sp, lineHeight = 16.sp,
letterSpacing = 0.5.sp letterSpacing = 0.5.sp
) )
*/
) )

View File

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

View File

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

View File

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

View File

@ -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<String?>(null) }
// Location validation state
var locationValidation by remember { mutableStateOf<id.ac.ubharajaya.sistemakademik.utils.LocationManager.LocationValidation?>(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
)
}
}
}

View File

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

View File

@ -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<CampusLocation> {
return campusLocations.filter { it.type == type }
}
/**
* Get semua lokasi di gedung tertentu
*/
fun getLocationsByBuilding(building: String): List<CampusLocation> {
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<Double, Double>? {
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<CampusLocation> {
return campusLocations
}
/**
* Get statistik lokasi
*/
fun getLocationStats(): Map<String, Int> {
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
)
}
}

View File

@ -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<Student?>(null)
val currentStudent: StateFlow<Student?> = _currentStudent.asStateFlow()
// Mata Kuliah List State
private val _mataKuliahList = MutableStateFlow<List<MataKuliah>>(emptyList())
val mataKuliahList: StateFlow<List<MataKuliah>> = _mataKuliahList.asStateFlow()
// Absensi History State
private val _absensiHistory = MutableStateFlow<List<AbsensiHistory>>(emptyList())
val absensiHistory: StateFlow<List<AbsensiHistory>> = _absensiHistory.asStateFlow()
// Location State
private val _currentLocation = MutableStateFlow<Pair<Double, Double>?>(null)
val currentLocation: StateFlow<Pair<Double, Double>?> = _currentLocation.asStateFlow()
// Captured Photo State
private val _capturedPhoto = MutableStateFlow<String?>(null)
val capturedPhoto: StateFlow<String?> = _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
}
}

View File

@ -0,0 +1,6 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Base application theme. -->
<style name="Theme.StarterEAS" parent="android:Theme.Material.NoActionBar">
<item name="android:statusBarColor">@android:color/transparent</item>
</style>
</resources>

View File

@ -1,3 +1,74 @@
<resources> <resources>
<string name="app_name">Sistem Akademik</string> <string name="app_name">Absensi UBJ</string>
<!-- Login Screen -->
<string name="login_title">Sistem Absensi</string>
<string name="login_subtitle">Universitas Bhayangkara Jakarta Raya</string>
<string name="nim_hint">NIM</string>
<string name="password_hint">Password</string>
<string name="login_button">Login</string>
<string name="forgot_password">Lupa Password?</string>
<!-- Mata Kuliah Screen -->
<string name="select_course">Pilih Mata Kuliah</string>
<string name="profile">Profile</string>
<string name="attend_now">Absen Sekarang</string>
<string name="info_message">Pilih mata kuliah untuk melakukan absensi. Pastikan Anda berada di lokasi kuliah dan siap mengambil foto.</string>
<!-- Preview Screen -->
<string name="preview_attendance">Preview Absensi</string>
<string name="take_photo">Ambil Foto</string>
<string name="retake_photo">Ambil Ulang</string>
<string name="submit">Submit</string>
<string name="location_detected">Lokasi Terdeteksi</string>
<string name="attendance_location">Lokasi Absensi</string>
<!-- Profile Screen -->
<string name="profile_title">Profil</string>
<string name="logout">Logout</string>
<string name="account_info">Informasi Akun</string>
<string name="attendance_statistics">Statistik Kehadiran</string>
<string name="attendance_history">Riwayat Absensi</string>
<string name="total_present">Total Hadir</string>
<string name="total_permission">Izin</string>
<string name="total_absent">Alpa</string>
<string name="no_history">Belum ada riwayat absensi</string>
<!-- Dialog -->
<string name="confirm_attendance">Konfirmasi Absensi</string>
<string name="confirm_message">Apakah Anda yakin ingin mengirim absensi untuk:</string>
<string name="yes_send">Ya, Kirim</string>
<string name="cancel">Batal</string>
<string name="logout_confirm">Apakah Anda yakin ingin keluar dari aplikasi?</string>
<string name="yes_logout">Ya, Logout</string>
<!-- Permission -->
<string name="permission_required">Izin Diperlukan</string>
<string name="permission_message">Aplikasi memerlukan izin kamera dan lokasi untuk absensi</string>
<string name="grant_permission">Berikan Izin</string>
<!-- Error Messages -->
<string name="error_empty_fields">NIM dan Password harus diisi</string>
<string name="error_login_failed">NIM atau Password salah</string>
<string name="error_submit_failed">Gagal mengirim absensi</string>
<string name="error_location_failed">Gagal mendapatkan lokasi</string>
<string name="error_camera_failed">Gagal mengakses kamera</string>
<!-- Success Messages -->
<string name="success_attendance">Absensi berhasil dikirim</string>
<string name="success_logout">Berhasil logout</string>
<!-- Labels -->
<string name="email">Email</string>
<string name="study_program">Program Studi</string>
<string name="lecturer">Dosen</string>
<string name="day">Hari</string>
<string name="time">Waktu</string>
<string name="room">Ruangan</string>
<string name="status">Status</string>
<string name="date">Tanggal</string>
<string name="present">Hadir</string>
<string name="late">Terlambat</string>
<string name="absent">Alpa</string>
<string name="permission">Izin</string>
</resources> </resources>

View File

@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <resources xmlns:tools="http://schemas.android.com/tools">
<resources> <!-- Base application theme. -->
<style name="Theme.StarterEAS" parent="android:Theme.Material.Light.NoActionBar">
<style name="Theme.SistemAkademik" parent="android:Theme.Material.Light.NoActionBar" /> <item name="android:statusBarColor">@android:color/transparent</item>
</style>
</resources> </resources>

View File

@ -1,4 +1,12 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android"> <paths xmlns:android="http://schemas.android.com/apk/res/android">
<external-cache-path
</PreferenceScreen> name="camera_photos"
path="." />
<external-files-path
name="photos"
path="Pictures" />
<cache-path
name="cached_photos"
path="." />
</paths>

View File

@ -0,0 +1,32 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<!-- ============ DEFAULT CONFIG ============ -->
<!-- Block semua cleartext HTTP by default -->
<base-config cleartextTrafficPermitted="false">
<trust-anchors>
<certificates src="system" />
</trust-anchors>
</base-config>
<!-- ============ PRODUCTION DOMAIN (HTTPS only) ============ -->
<domain-config cleartextTrafficPermitted="false">
<!-- n8n webhook endpoint (HTTPS) -->
<domain includeSubdomains="true">n8n.lab.ubharajaya.ac.id</domain>
<domain includeSubdomains="true">ubharajaya.ac.id</domain>
<domain includeSubdomains="true">api.ubharajaya.ac.id</domain>
<trust-anchors>
<certificates src="system" />
<certificates src="user" />
</trust-anchors>
</domain-config>
<!-- ============ DEBUG ONLY (Hapus di production!) ============ -->
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="true">localhost</domain>
<domain includeSubdomains="true">10.0.2.2</domain>
<domain includeSubdomains="true">192.168.1.1</domain>
</domain-config>
</network-security-config>