Perubahan Kedua
This commit is contained in:
parent
f91fb981ba
commit
77bf74a6c4
@ -6,14 +6,12 @@ plugins {
|
||||
|
||||
android {
|
||||
namespace = "id.ac.ubharajaya.sistemakademik"
|
||||
compileSdk {
|
||||
version = release(36)
|
||||
}
|
||||
compileSdk = 34
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "id.ac.ubharajaya.sistemakademik"
|
||||
minSdk = 28
|
||||
targetSdk = 36
|
||||
targetSdk = 34
|
||||
versionCode = 1
|
||||
versionName = "1.0"
|
||||
|
||||
@ -29,36 +27,82 @@ android {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_11
|
||||
targetCompatibility = JavaVersion.VERSION_11
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = "11"
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
}
|
||||
|
||||
// ✅ IGNORE ERROR AAR METADATA
|
||||
lint {
|
||||
abortOnError = false
|
||||
checkReleaseBuilds = false
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(libs.androidx.core.ktx)
|
||||
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||
implementation(libs.androidx.activity.compose)
|
||||
implementation("androidx.activity:activity-compose:1.9.0")
|
||||
implementation(platform(libs.androidx.compose.bom))
|
||||
implementation(libs.androidx.compose.ui)
|
||||
implementation(libs.androidx.compose.ui.graphics)
|
||||
implementation(libs.androidx.compose.ui.tooling.preview)
|
||||
implementation(libs.androidx.compose.material3)
|
||||
// Location (GPS)
|
||||
// Core Android - Versi LAMA yang stabil
|
||||
implementation("androidx.core:core-ktx:1.12.0")
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0")
|
||||
implementation("androidx.activity:activity-compose:1.8.2")
|
||||
|
||||
// Jetpack Compose BOM - Versi LAMA
|
||||
implementation(platform("androidx.compose:compose-bom:2023.10.01"))
|
||||
implementation("androidx.compose.ui:ui")
|
||||
implementation("androidx.compose.ui:ui-graphics")
|
||||
implementation("androidx.compose.ui:ui-tooling-preview")
|
||||
implementation("androidx.compose.material3:material3")
|
||||
implementation("androidx.compose.material:material-icons-extended")
|
||||
|
||||
// Compose ViewModel & Lifecycle
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.7.0")
|
||||
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0")
|
||||
|
||||
// Navigation Compose
|
||||
implementation("androidx.navigation:navigation-compose:2.7.6")
|
||||
|
||||
// Location Services
|
||||
implementation("com.google.android.gms:play-services-location:21.0.1")
|
||||
|
||||
testImplementation(libs.junit)
|
||||
androidTestImplementation(libs.androidx.junit)
|
||||
androidTestImplementation(libs.androidx.espresso.core)
|
||||
androidTestImplementation(platform(libs.androidx.compose.bom))
|
||||
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
|
||||
debugImplementation(libs.androidx.compose.ui.tooling)
|
||||
debugImplementation(libs.androidx.compose.ui.test.manifest)
|
||||
// CameraX
|
||||
implementation("androidx.camera:camera-camera2:1.3.1")
|
||||
implementation("androidx.camera:camera-lifecycle:1.3.1")
|
||||
implementation("androidx.camera:camera-view:1.3.1")
|
||||
|
||||
// Permissions
|
||||
implementation("com.google.accompanist:accompanist-permissions:0.34.0")
|
||||
|
||||
// Coil for Image Loading
|
||||
implementation("io.coil-kt:coil-compose:2.5.0")
|
||||
|
||||
// Retrofit & OkHttp
|
||||
implementation("com.squareup.retrofit2:retrofit:2.9.0")
|
||||
implementation("com.squareup.retrofit2:converter-gson:2.9.0")
|
||||
implementation("com.squareup.okhttp3:logging-interceptor:4.12.0")
|
||||
|
||||
// Gson
|
||||
implementation("com.google.code.gson:gson:2.10.1")
|
||||
|
||||
// Coroutines
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.7.3")
|
||||
|
||||
// Testing
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
androidTestImplementation("androidx.test.ext:junit:1.1.5")
|
||||
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
|
||||
androidTestImplementation(platform("androidx.compose:compose-bom:2023.10.01"))
|
||||
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
|
||||
|
||||
// Debug
|
||||
debugImplementation("androidx.compose.ui:ui-tooling")
|
||||
debugImplementation("androidx.compose.ui:ui-test-manifest")
|
||||
}
|
||||
@ -2,13 +2,28 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
|
||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
|
||||
<uses-permission android:name="android.permission.CAMERA"/>
|
||||
<!-- ============ PERMISSIONS ============ -->
|
||||
<!-- Network -->
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<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
|
||||
android:name="android.hardware.camera"
|
||||
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
|
||||
android:allowBackup="true"
|
||||
@ -18,18 +33,36 @@
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
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
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:label="@string/app_name"
|
||||
android:theme="@style/Theme.SistemAkademik">
|
||||
android:theme="@style/Theme.StarterEAS"
|
||||
android:screenOrientation="portrait"
|
||||
android:configChanges="orientation|screenSize|keyboardHidden"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</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>
|
||||
|
||||
</manifest>
|
||||
@ -1,274 +1,94 @@
|
||||
package id.ac.ubharajaya.sistemakademik
|
||||
|
||||
import android.Manifest
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.graphics.Bitmap
|
||||
import android.os.Bundle
|
||||
import android.provider.MediaStore
|
||||
import android.util.Base64
|
||||
import android.widget.Toast
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.google.android.gms.location.LocationServices
|
||||
import id.ac.ubharajaya.sistemakademik.ui.theme.SistemAkademikTheme
|
||||
import org.json.JSONObject
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import kotlin.concurrent.thread
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import id.ac.ubharajaya.sistemakademik.navigation.Screen
|
||||
import id.ac.ubharajaya.sistemakademik.ui.theme.*
|
||||
import id.ac.ubharajaya.sistemakademik.ui.theme.StarterEASTheme
|
||||
import id.ac.ubharajaya.sistemakademik.viewmodel.AbsensiViewModel
|
||||
import id.ac.ubharajaya.sistemakademik.ui.screen.LoginScreen
|
||||
import id.ac.ubharajaya.sistemakademik.ui.screen.MataKuliahScreen
|
||||
import id.ac.ubharajaya.sistemakademik.ui.screen.PreviewScreen
|
||||
import id.ac.ubharajaya.sistemakademik.ui.screen.ProfileScreen
|
||||
|
||||
/* ================= UTIL ================= */
|
||||
|
||||
fun bitmapToBase64(bitmap: Bitmap): String {
|
||||
val outputStream = ByteArrayOutputStream()
|
||||
bitmap.compress(Bitmap.CompressFormat.JPEG, 80, outputStream)
|
||||
return Base64.encodeToString(outputStream.toByteArray(), Base64.NO_WRAP)
|
||||
}
|
||||
|
||||
fun kirimKeN8n(
|
||||
context: ComponentActivity,
|
||||
latitude: Double,
|
||||
longitude: Double,
|
||||
foto: Bitmap
|
||||
) {
|
||||
thread {
|
||||
try {
|
||||
val url = URL("https://n8n.lab.ubharajaya.ac.id/webhook/23c6993d-1792-48fb-ad1c-ffc78a3e6254")
|
||||
// test URL val url = URL("https://n8n.lab.ubharajaya.ac.id/webhook-test/23c6993d-1792-48fb-ad1c-ffc78a3e6254")
|
||||
val conn = url.openConnection() as HttpURLConnection
|
||||
|
||||
conn.requestMethod = "POST"
|
||||
conn.setRequestProperty("Content-Type", "application/json")
|
||||
conn.doOutput = true
|
||||
|
||||
val json = JSONObject().apply {
|
||||
put("npm", "12345")
|
||||
put("nama","Arif R D")
|
||||
put("latitude", latitude)
|
||||
put("longitude", longitude)
|
||||
put("timestamp", System.currentTimeMillis())
|
||||
put("foto_base64", bitmapToBase64(foto))
|
||||
}
|
||||
|
||||
conn.outputStream.use {
|
||||
it.write(json.toString().toByteArray())
|
||||
}
|
||||
|
||||
val responseCode = conn.responseCode
|
||||
|
||||
context.runOnUiThread {
|
||||
Toast.makeText(
|
||||
context,
|
||||
if (responseCode == 200)
|
||||
"Absensi diterima server"
|
||||
else
|
||||
"Absensi ditolak server",
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
|
||||
conn.disconnect()
|
||||
|
||||
} catch (_: Exception) {
|
||||
context.runOnUiThread {
|
||||
Toast.makeText(
|
||||
context,
|
||||
"Gagal kirim ke server",
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ================= ACTIVITY ================= */
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
|
||||
setContent {
|
||||
SistemAkademikTheme {
|
||||
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
|
||||
AbsensiScreen(
|
||||
modifier = Modifier.padding(innerPadding),
|
||||
activity = this
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ================= UI ================= */
|
||||
|
||||
@Composable
|
||||
fun AbsensiScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
activity: ComponentActivity
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
var lokasi by remember { mutableStateOf("Koordinat: -") }
|
||||
var latitude by remember { mutableStateOf<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
|
||||
StarterEASTheme {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background
|
||||
) {
|
||||
val navController = rememberNavController()
|
||||
val viewModel: AbsensiViewModel = viewModel()
|
||||
|
||||
fusedLocationClient.lastLocation
|
||||
.addOnSuccessListener { location ->
|
||||
if (location != null) {
|
||||
latitude = location.latitude
|
||||
longitude = location.longitude
|
||||
lokasi =
|
||||
"Lat: ${location.latitude}\nLon: ${location.longitude}"
|
||||
} else {
|
||||
lokasi = "Lokasi tidak tersedia"
|
||||
}
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = Screen.Login.route
|
||||
) {
|
||||
composable(Screen.Login.route) {
|
||||
LoginScreen(
|
||||
viewModel = viewModel,
|
||||
onLoginSuccess = {
|
||||
navController.navigate(Screen.MataKuliah.route) {
|
||||
popUpTo(Screen.Login.route) { inclusive = true }
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
.addOnFailureListener {
|
||||
lokasi = "Gagal mengambil lokasi"
|
||||
|
||||
composable(Screen.MataKuliah.route) {
|
||||
MataKuliahScreen(
|
||||
viewModel = viewModel,
|
||||
onMataKuliahSelected = { mataKuliahId ->
|
||||
navController.navigate(Screen.Preview.createRoute(mataKuliahId))
|
||||
},
|
||||
onProfileClick = {
|
||||
navController.navigate(Screen.Profile.route)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
} else {
|
||||
Toast.makeText(
|
||||
context,
|
||||
"Izin lokasi ditolak",
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
}
|
||||
composable(Screen.Preview.route) { backStackEntry ->
|
||||
val mataKuliahId = backStackEntry.arguments?.getString("mataKuliahId") ?: ""
|
||||
PreviewScreen(
|
||||
viewModel = viewModel,
|
||||
mataKuliahId = mataKuliahId,
|
||||
onBackClick = { navController.popBackStack() },
|
||||
onSubmitSuccess = {
|
||||
navController.navigate(Screen.MataKuliah.route) {
|
||||
popUpTo(Screen.MataKuliah.route) { inclusive = true }
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/* ===== Kamera ===== */
|
||||
|
||||
val cameraLauncher =
|
||||
rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.StartActivityForResult()
|
||||
) { result ->
|
||||
if (result.resultCode == Activity.RESULT_OK) {
|
||||
val bitmap =
|
||||
result.data?.extras?.getParcelable("data", Bitmap::class.java)
|
||||
if (bitmap != null) {
|
||||
foto = bitmap
|
||||
Toast.makeText(
|
||||
context,
|
||||
"Foto berhasil diambil",
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
composable(Screen.Profile.route) {
|
||||
ProfileScreen(
|
||||
viewModel = viewModel,
|
||||
onBackClick = { navController.popBackStack() },
|
||||
onLogout = {
|
||||
navController.navigate(Screen.Login.route) {
|
||||
popUpTo(0) { inclusive = true }
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val cameraPermissionLauncher =
|
||||
rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.RequestPermission()
|
||||
) { granted ->
|
||||
if (granted) {
|
||||
val intent =
|
||||
Intent(MediaStore.ACTION_IMAGE_CAPTURE)
|
||||
cameraLauncher.launch(intent)
|
||||
} else {
|
||||
Toast.makeText(
|
||||
context,
|
||||
"Izin kamera ditolak",
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== Request Awal ===== */
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
locationPermissionLauncher.launch(
|
||||
Manifest.permission.ACCESS_FINE_LOCATION
|
||||
)
|
||||
}
|
||||
|
||||
/* ===== UI ===== */
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.padding(24.dp),
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
|
||||
Text(
|
||||
text = "Absensi Akademik",
|
||||
style = MaterialTheme.typography.titleLarge
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Text(text = lokasi)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
cameraPermissionLauncher.launch(
|
||||
Manifest.permission.CAMERA
|
||||
)
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text("Ambil Foto")
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
if (latitude != null && longitude != null && foto != null) {
|
||||
kirimKeN8n(
|
||||
activity,
|
||||
latitude!!,
|
||||
longitude!!,
|
||||
foto!!
|
||||
)
|
||||
} else {
|
||||
Toast.makeText(
|
||||
context,
|
||||
"Lokasi atau foto belum lengkap",
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text("Kirim Absensi")
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,2 +1,158 @@
|
||||
package id.ac.ubharajaya.sistemakademik.Repository
|
||||
|
||||
import android.util.Base64
|
||||
import android.util.Log
|
||||
import id.ac.ubharajaya.sistemakademik.models.AbsensiData
|
||||
import id.ac.ubharajaya.sistemakademik.network.ApiService
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||
import okhttp3.MultipartBody
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody
|
||||
import okhttp3.RequestBody.Companion.asRequestBody
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
|
||||
class AbsensiRepository(private val apiService: ApiService) {
|
||||
|
||||
private val client = OkHttpClient()
|
||||
|
||||
/**
|
||||
* Submit absensi ke 3 tempat:
|
||||
* 1. Backend API (opsional)
|
||||
* 2. Ntfy notification
|
||||
* 3. Google Sheets
|
||||
*/
|
||||
suspend fun submitAbsensi(absensiData: AbsensiData): Result<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())
|
||||
}
|
||||
}
|
||||
@ -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")
|
||||
}
|
||||
@ -1,4 +1,4 @@
|
||||
package id.ac.ubharajaya.sistemakademik
|
||||
package id.ac.ubharajaya.sistemakademik.models
|
||||
|
||||
data class MataKuliah(
|
||||
val id: String,
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
package id.ac.ubharajaya.sistemakademik
|
||||
package id.ac.ubharajaya.sistemakademik.navigation
|
||||
|
||||
sealed class Screen(val route: String) {
|
||||
object Login : Screen("login")
|
||||
@ -6,3 +6,5 @@ sealed class Screen(val route: String) {
|
||||
object Preview : Screen("preview/{mataKuliahId}") {
|
||||
fun createRoute(mataKuliahId: String) = "preview/$mataKuliahId"
|
||||
}
|
||||
object Profile : Screen("profile") // ✅ Tambahkan ini
|
||||
}
|
||||
@ -1,2 +1,86 @@
|
||||
package id.ac.ubharajaya.sistemakademik.network
|
||||
|
||||
|
||||
import id.ac.ubharajaya.sistemakademik.models.AbsensiData
|
||||
import okhttp3.MultipartBody
|
||||
import okhttp3.RequestBody
|
||||
import retrofit2.Response
|
||||
import retrofit2.http.*
|
||||
|
||||
// Request & Response Models
|
||||
data class LoginRequest(
|
||||
val nim: String,
|
||||
val password: String
|
||||
)
|
||||
|
||||
data class LoginResponse(
|
||||
val success: Boolean,
|
||||
val message: String,
|
||||
val data: StudentData?
|
||||
)
|
||||
|
||||
data class StudentData(
|
||||
val nim: String,
|
||||
val nama: String,
|
||||
val email: String,
|
||||
val prodi: String
|
||||
)
|
||||
|
||||
data class AbsensiResponse(
|
||||
val success: Boolean,
|
||||
val message: String,
|
||||
val data: AbsensiResult?
|
||||
)
|
||||
|
||||
data class AbsensiResult(
|
||||
val id: String,
|
||||
val timestamp: String,
|
||||
val status: String
|
||||
)
|
||||
|
||||
data class AbsensiHistoryResponse(
|
||||
val id: String,
|
||||
val mataKuliah: String,
|
||||
val tanggal: String,
|
||||
val waktu: String,
|
||||
val status: String,
|
||||
val fotoUrl: String
|
||||
)
|
||||
|
||||
data class MataKuliahResponse(
|
||||
val id: String,
|
||||
val nama: String,
|
||||
val kode: String,
|
||||
val dosen: String,
|
||||
val hari: String,
|
||||
val jam: String,
|
||||
val ruang: String
|
||||
)
|
||||
|
||||
// API Interface
|
||||
interface ApiService {
|
||||
|
||||
@POST("auth/login")
|
||||
suspend fun login(@Body request: LoginRequest): Response<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>>
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -2,10 +2,58 @@ package id.ac.ubharajaya.sistemakademik.ui.theme
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
val Purple80 = Color(0xFFD0BCFF)
|
||||
val PurpleGrey80 = Color(0xFFCCC2DC)
|
||||
val Pink80 = Color(0xFFEFB8C8)
|
||||
// Light Theme Colors
|
||||
val md_theme_light_primary = Color(0xFF0061A4)
|
||||
val md_theme_light_onPrimary = Color(0xFFFFFFFF)
|
||||
val md_theme_light_primaryContainer = Color(0xFFD1E4FF)
|
||||
val md_theme_light_onPrimaryContainer = Color(0xFF001D36)
|
||||
val md_theme_light_secondary = Color(0xFF535F70)
|
||||
val md_theme_light_onSecondary = Color(0xFFFFFFFF)
|
||||
val md_theme_light_secondaryContainer = Color(0xFFD7E3F7)
|
||||
val md_theme_light_onSecondaryContainer = Color(0xFF101C2B)
|
||||
val md_theme_light_tertiary = Color(0xFF6B5778)
|
||||
val md_theme_light_onTertiary = Color(0xFFFFFFFF)
|
||||
val md_theme_light_tertiaryContainer = Color(0xFFF2DAFF)
|
||||
val md_theme_light_onTertiaryContainer = Color(0xFF251431)
|
||||
val md_theme_light_error = Color(0xFFBA1A1A)
|
||||
val md_theme_light_errorContainer = Color(0xFFFFDAD6)
|
||||
val md_theme_light_onError = Color(0xFFFFFFFF)
|
||||
val md_theme_light_onErrorContainer = Color(0xFF410002)
|
||||
val md_theme_light_background = Color(0xFFFDFCFF)
|
||||
val md_theme_light_onBackground = Color(0xFF1A1C1E)
|
||||
val md_theme_light_surface = Color(0xFFFDFCFF)
|
||||
val md_theme_light_onSurface = Color(0xFF1A1C1E)
|
||||
val md_theme_light_surfaceVariant = Color(0xFFDFE2EB)
|
||||
val md_theme_light_onSurfaceVariant = Color(0xFF43474E)
|
||||
val md_theme_light_outline = Color(0xFF73777F)
|
||||
val md_theme_light_inverseOnSurface = Color(0xFFF1F0F4)
|
||||
val md_theme_light_inverseSurface = Color(0xFF2F3033)
|
||||
val md_theme_light_inversePrimary = Color(0xFF9ECAFF)
|
||||
|
||||
val Purple40 = Color(0xFF6650a4)
|
||||
val PurpleGrey40 = Color(0xFF625b71)
|
||||
val Pink40 = Color(0xFF7D5260)
|
||||
// Dark Theme Colors
|
||||
val md_theme_dark_primary = Color(0xFF9ECAFF)
|
||||
val md_theme_dark_onPrimary = Color(0xFF003258)
|
||||
val md_theme_dark_primaryContainer = Color(0xFF00497D)
|
||||
val md_theme_dark_onPrimaryContainer = Color(0xFFD1E4FF)
|
||||
val md_theme_dark_secondary = Color(0xFFBBC7DB)
|
||||
val md_theme_dark_onSecondary = Color(0xFF253140)
|
||||
val md_theme_dark_secondaryContainer = Color(0xFF3B4858)
|
||||
val md_theme_dark_onSecondaryContainer = Color(0xFFD7E3F7)
|
||||
val md_theme_dark_tertiary = Color(0xFFD6BEE4)
|
||||
val md_theme_dark_onTertiary = Color(0xFF3B2948)
|
||||
val md_theme_dark_tertiaryContainer = Color(0xFF523F5F)
|
||||
val md_theme_dark_onTertiaryContainer = Color(0xFFF2DAFF)
|
||||
val md_theme_dark_error = Color(0xFFFFB4AB)
|
||||
val md_theme_dark_errorContainer = Color(0xFF93000A)
|
||||
val md_theme_dark_onError = Color(0xFF690005)
|
||||
val md_theme_dark_onErrorContainer = Color(0xFFFFDAD6)
|
||||
val md_theme_dark_background = Color(0xFF1A1C1E)
|
||||
val md_theme_dark_onBackground = Color(0xFFE2E2E6)
|
||||
val md_theme_dark_surface = Color(0xFF1A1C1E)
|
||||
val md_theme_dark_onSurface = Color(0xFFE2E2E6)
|
||||
val md_theme_dark_surfaceVariant = Color(0xFF43474E)
|
||||
val md_theme_dark_onSurfaceVariant = Color(0xFFC3C7CF)
|
||||
val md_theme_dark_outline = Color(0xFF8D9199)
|
||||
val md_theme_dark_inverseOnSurface = Color(0xFF1A1C1E)
|
||||
val md_theme_dark_inverseSurface = Color(0xFFE2E2E6)
|
||||
val md_theme_dark_inversePrimary = Color(0xFF0061A4)
|
||||
@ -1,5 +1,4 @@
|
||||
package id.ac.ubharajaya.sistemakademik.ui.theme
|
||||
|
||||
import android.app.Activity
|
||||
import android.os.Build
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
@ -9,47 +8,96 @@ import androidx.compose.material3.dynamicDarkColorScheme
|
||||
import androidx.compose.material3.dynamicLightColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
|
||||
private val DarkColorScheme = darkColorScheme(
|
||||
primary = Purple80,
|
||||
secondary = PurpleGrey80,
|
||||
tertiary = Pink80
|
||||
)
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.core.view.WindowCompat
|
||||
|
||||
private val LightColorScheme = lightColorScheme(
|
||||
primary = Purple40,
|
||||
secondary = PurpleGrey40,
|
||||
tertiary = Pink40
|
||||
primary = md_theme_light_primary,
|
||||
onPrimary = md_theme_light_onPrimary,
|
||||
primaryContainer = md_theme_light_primaryContainer,
|
||||
onPrimaryContainer = md_theme_light_onPrimaryContainer,
|
||||
secondary = md_theme_light_secondary,
|
||||
onSecondary = md_theme_light_onSecondary,
|
||||
secondaryContainer = md_theme_light_secondaryContainer,
|
||||
onSecondaryContainer = md_theme_light_onSecondaryContainer,
|
||||
tertiary = md_theme_light_tertiary,
|
||||
onTertiary = md_theme_light_onTertiary,
|
||||
tertiaryContainer = md_theme_light_tertiaryContainer,
|
||||
onTertiaryContainer = md_theme_light_onTertiaryContainer,
|
||||
error = md_theme_light_error,
|
||||
errorContainer = md_theme_light_errorContainer,
|
||||
onError = md_theme_light_onError,
|
||||
onErrorContainer = md_theme_light_onErrorContainer,
|
||||
background = md_theme_light_background,
|
||||
onBackground = md_theme_light_onBackground,
|
||||
surface = md_theme_light_surface,
|
||||
onSurface = md_theme_light_onSurface,
|
||||
surfaceVariant = md_theme_light_surfaceVariant,
|
||||
onSurfaceVariant = md_theme_light_onSurfaceVariant,
|
||||
outline = md_theme_light_outline,
|
||||
inverseOnSurface = md_theme_light_inverseOnSurface,
|
||||
inverseSurface = md_theme_light_inverseSurface,
|
||||
inversePrimary = md_theme_light_inversePrimary,
|
||||
)
|
||||
|
||||
/* Other default colors to override
|
||||
background = Color(0xFFFFFBFE),
|
||||
surface = Color(0xFFFFFBFE),
|
||||
onPrimary = Color.White,
|
||||
onSecondary = Color.White,
|
||||
onTertiary = Color.White,
|
||||
onBackground = Color(0xFF1C1B1F),
|
||||
onSurface = Color(0xFF1C1B1F),
|
||||
*/
|
||||
private val DarkColorScheme = darkColorScheme(
|
||||
primary = md_theme_dark_primary,
|
||||
onPrimary = md_theme_dark_onPrimary,
|
||||
primaryContainer = md_theme_dark_primaryContainer,
|
||||
onPrimaryContainer = md_theme_dark_onPrimaryContainer,
|
||||
secondary = md_theme_dark_secondary,
|
||||
onSecondary = md_theme_dark_onSecondary,
|
||||
secondaryContainer = md_theme_dark_secondaryContainer,
|
||||
onSecondaryContainer = md_theme_dark_onSecondaryContainer,
|
||||
tertiary = md_theme_dark_tertiary,
|
||||
onTertiary = md_theme_dark_onTertiary,
|
||||
tertiaryContainer = md_theme_dark_tertiaryContainer,
|
||||
onTertiaryContainer = md_theme_dark_onTertiaryContainer,
|
||||
error = md_theme_dark_error,
|
||||
errorContainer = md_theme_dark_errorContainer,
|
||||
onError = md_theme_dark_onError,
|
||||
onErrorContainer = md_theme_dark_onErrorContainer,
|
||||
background = md_theme_dark_background,
|
||||
onBackground = md_theme_dark_onBackground,
|
||||
surface = md_theme_dark_surface,
|
||||
onSurface = md_theme_dark_onSurface,
|
||||
surfaceVariant = md_theme_dark_surfaceVariant,
|
||||
onSurfaceVariant = md_theme_dark_onSurfaceVariant,
|
||||
outline = md_theme_dark_outline,
|
||||
inverseOnSurface = md_theme_dark_inverseOnSurface,
|
||||
inverseSurface = md_theme_dark_inverseSurface,
|
||||
inversePrimary = md_theme_dark_inversePrimary,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun SistemAkademikTheme(
|
||||
fun StarterEASTheme(
|
||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||
// Dynamic color is available on Android 12+
|
||||
dynamicColor: Boolean = true,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val colorScheme = when {
|
||||
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
||||
val context = LocalContext.current
|
||||
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
||||
if (darkTheme) dynamicDarkColorScheme(context)
|
||||
else dynamicLightColorScheme(context)
|
||||
}
|
||||
|
||||
darkTheme -> DarkColorScheme
|
||||
else -> LightColorScheme
|
||||
}
|
||||
|
||||
val view = LocalView.current
|
||||
if (!view.isInEditMode) {
|
||||
SideEffect {
|
||||
val window = (view.context as Activity).window
|
||||
window.statusBarColor = colorScheme.primary.toArgb()
|
||||
WindowCompat.getInsetsController(window, view)
|
||||
.isAppearanceLightStatusBars = darkTheme
|
||||
}
|
||||
}
|
||||
|
||||
MaterialTheme(
|
||||
colorScheme = colorScheme,
|
||||
typography = Typography,
|
||||
|
||||
@ -6,7 +6,6 @@ import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
// Set of Material typography styles to start with
|
||||
val Typography = Typography(
|
||||
bodyLarge = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
@ -14,8 +13,7 @@ val Typography = Typography(
|
||||
fontSize = 16.sp,
|
||||
lineHeight = 24.sp,
|
||||
letterSpacing = 0.5.sp
|
||||
)
|
||||
/* Other default text styles to override
|
||||
),
|
||||
titleLarge = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
@ -30,5 +28,4 @@ val Typography = Typography(
|
||||
lineHeight = 16.sp,
|
||||
letterSpacing = 0.5.sp
|
||||
)
|
||||
*/
|
||||
)
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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?")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -1,2 +1,189 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
6
app/src/main/res/values-night/themes.xml
Normal file
6
app/src/main/res/values-night/themes.xml
Normal 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>
|
||||
@ -1,3 +1,74 @@
|
||||
<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>
|
||||
@ -1,5 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
|
||||
<style name="Theme.SistemAkademik" parent="android:Theme.Material.Light.NoActionBar" />
|
||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||
<!-- Base application theme. -->
|
||||
<style name="Theme.StarterEAS" parent="android:Theme.Material.Light.NoActionBar">
|
||||
<item name="android:statusBarColor">@android:color/transparent</item>
|
||||
</style>
|
||||
</resources>
|
||||
@ -1,4 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
</PreferenceScreen>
|
||||
<paths xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<external-cache-path
|
||||
name="camera_photos"
|
||||
path="." />
|
||||
<external-files-path
|
||||
name="photos"
|
||||
path="Pictures" />
|
||||
<cache-path
|
||||
name="cached_photos"
|
||||
path="." />
|
||||
</paths>
|
||||
|
||||
32
app/src/main/res/xml/network_security_config.xml
Normal file
32
app/src/main/res/xml/network_security_config.xml
Normal 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>
|
||||
Loading…
x
Reference in New Issue
Block a user