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