From ce05e1a617ee7aa18562e016d36b2781bfb3ddb5 Mon Sep 17 00:00:00 2001 From: HagaDalpintoGinting Date: Wed, 14 Jan 2026 16:30:30 +0700 Subject: [PATCH] ADDING SCREENS SETIAP FITUR DAN MATKUL SAAT ABSENSI --- .idea/deviceManager.xml | 13 + .idea/markdown.xml | 8 + README.md | 2 + app/build.gradle.kts | 141 +++- app/src/main/AndroidManifest.xml | 101 ++- .../ubharajaya/sistemakademik/MainActivity.kt | 415 +++++------ .../sistemakademik/data/AbsensiDao.kt | 93 +++ .../sistemakademik/data/AbsensiEntity.kt | 49 ++ .../sistemakademik/data/AppDatabase.kt | 80 ++ .../sistemakademik/data/Matakuliah.kt | 30 + .../sistemakademik/data/UserPreferences.kt | 115 +++ .../sistemakademik/screens/AbsensiScreen.kt | 687 ++++++++++++++++++ .../sistemakademik/screens/DashboardScreen.kt | 305 ++++++++ .../sistemakademik/screens/HistoryScreen.kt | 455 ++++++++++++ .../sistemakademik/screens/LoginScreen.kt | 303 ++++++++ .../sistemakademik/screens/ScheduleScreen.kt | 292 ++++++++ .../screens/SelectMataKuliahScreen.kt | 449 ++++++++++++ .../sistemakademik/screens/SuccsessScreen.kt | 272 +++++++ .../sistemakademik/utils/Constants.kt | 66 ++ .../sistemakademik/utils/LocationValidator.kt | 112 +++ 20 files changed, 3714 insertions(+), 274 deletions(-) create mode 100644 .idea/deviceManager.xml create mode 100644 .idea/markdown.xml create mode 100644 app/src/main/java/id/ac/ubharajaya/sistemakademik/data/AbsensiDao.kt create mode 100644 app/src/main/java/id/ac/ubharajaya/sistemakademik/data/AbsensiEntity.kt create mode 100644 app/src/main/java/id/ac/ubharajaya/sistemakademik/data/AppDatabase.kt create mode 100644 app/src/main/java/id/ac/ubharajaya/sistemakademik/data/Matakuliah.kt create mode 100644 app/src/main/java/id/ac/ubharajaya/sistemakademik/data/UserPreferences.kt create mode 100644 app/src/main/java/id/ac/ubharajaya/sistemakademik/screens/AbsensiScreen.kt create mode 100644 app/src/main/java/id/ac/ubharajaya/sistemakademik/screens/DashboardScreen.kt create mode 100644 app/src/main/java/id/ac/ubharajaya/sistemakademik/screens/HistoryScreen.kt create mode 100644 app/src/main/java/id/ac/ubharajaya/sistemakademik/screens/LoginScreen.kt create mode 100644 app/src/main/java/id/ac/ubharajaya/sistemakademik/screens/ScheduleScreen.kt create mode 100644 app/src/main/java/id/ac/ubharajaya/sistemakademik/screens/SelectMataKuliahScreen.kt create mode 100644 app/src/main/java/id/ac/ubharajaya/sistemakademik/screens/SuccsessScreen.kt create mode 100644 app/src/main/java/id/ac/ubharajaya/sistemakademik/utils/Constants.kt create mode 100644 app/src/main/java/id/ac/ubharajaya/sistemakademik/utils/LocationValidator.kt diff --git a/.idea/deviceManager.xml b/.idea/deviceManager.xml new file mode 100644 index 0000000..91f9558 --- /dev/null +++ b/.idea/deviceManager.xml @@ -0,0 +1,13 @@ + + + + + + \ No newline at end of file diff --git a/.idea/markdown.xml b/.idea/markdown.xml new file mode 100644 index 0000000..c61ea33 --- /dev/null +++ b/.idea/markdown.xml @@ -0,0 +1,8 @@ + + + + + + \ No newline at end of file diff --git a/README.md b/README.md index a07d04a..3ba104d 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,8 @@ Aplikasi ini dirancang untuk meningkatkan **validitas kehadiran mahasiswa**, den 2. Melakukan **pengambilan foto (selfie) secara langsung saat absensi** --- +Nama : HAGA DALPINTO GINTING +NPM : 202310715176 ## đŸŽ¯ Tujuan Proyek - Mengimplementasikan **Location-Based Service (LBS)** pada aplikasi mobile diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 7d76378..6069b93 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,23 +1,38 @@ +/** + * FILE: build.gradle.kts (Module :app) + * LOKASI: app/build.gradle.kts + * + * CARA IMPLEMENTASI: + * 1. Buka file app/build.gradle.kts di Android Studio + * 2. REPLACE semua isinya dengan kode ini + * 3. Klik "Sync Now" di pojok kanan atas + * 4. Tunggu sampai sync selesai + */ + plugins { alias(libs.plugins.android.application) alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.compose) + id("kotlin-kapt") + id("kotlin-parcelize") } android { namespace = "id.ac.ubharajaya.sistemakademik" - compileSdk { - version = release(36) - } + compileSdk = 35 defaultConfig { applicationId = "id.ac.ubharajaya.sistemakademik" - minSdk = 28 - targetSdk = 36 + minSdk = 26 + targetSdk = 35 versionCode = 1 versionName = "1.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + + vectorDrawables { + useSupportLibrary = true + } } buildTypes { @@ -29,36 +44,114 @@ android { ) } } + compileOptions { sourceCompatibility = JavaVersion.VERSION_11 targetCompatibility = JavaVersion.VERSION_11 } + kotlinOptions { jvmTarget = "11" } + buildFeatures { compose = true } + + composeOptions { + kotlinCompilerExtensionVersion = "1.5.8" + } + + packaging { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + } + } } dependencies { - implementation(libs.androidx.core.ktx) - implementation(libs.androidx.lifecycle.runtime.ktx) - implementation(libs.androidx.activity.compose) - implementation("androidx.activity:activity-compose:1.9.0") - implementation(platform(libs.androidx.compose.bom)) - implementation(libs.androidx.compose.ui) - implementation(libs.androidx.compose.ui.graphics) - implementation(libs.androidx.compose.ui.tooling.preview) - implementation(libs.androidx.compose.material3) - // Location (GPS) - implementation("com.google.android.gms:play-services-location:21.0.1") - testImplementation(libs.junit) - androidTestImplementation(libs.androidx.junit) - androidTestImplementation(libs.androidx.espresso.core) - androidTestImplementation(platform(libs.androidx.compose.bom)) - androidTestImplementation(libs.androidx.compose.ui.test.junit4) - debugImplementation(libs.androidx.compose.ui.tooling) - debugImplementation(libs.androidx.compose.ui.test.manifest) -} \ No newline at end of file + // ==================== CORE ANDROID ==================== + implementation("androidx.core:core-ktx:1.12.0") + implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0") + implementation("androidx.activity:activity-compose:1.8.2") + + // ==================== COMPOSE ==================== + val composeBom = "2024.02.00" + implementation(platform("androidx.compose:compose-bom:$composeBom")) + implementation("androidx.compose.ui:ui") + implementation("androidx.compose.ui:ui-graphics") + implementation("androidx.compose.ui:ui-tooling-preview") + implementation("androidx.compose.material3:material3") + + // Compose Lifecycle + implementation("androidx.lifecycle:lifecycle-runtime-compose:2.7.0") + + // ==================== NAVIGATION ==================== + val navVersion = "2.7.6" + implementation("androidx.navigation:navigation-compose:$navVersion") + + // ==================== LOCATION SERVICES ==================== + implementation("com.google.android.gms:play-services-location:21.1.0") + + // ==================== ROOM DATABASE ==================== + val roomVersion = "2.6.1" + implementation("androidx.room:room-runtime:$roomVersion") + implementation("androidx.room:room-ktx:$roomVersion") + kapt("androidx.room:room-compiler:$roomVersion") + + // ==================== DATASTORE PREFERENCES ==================== + implementation("androidx.datastore:datastore-preferences:1.0.0") + + // ==================== IMAGE LOADING (COIL) ==================== + implementation("io.coil-kt:coil-compose:2.5.0") + + // ==================== COROUTINES ==================== + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.7.3") + + // ==================== MATERIAL ICONS EXTENDED ==================== + implementation("androidx.compose.material:material-icons-extended:1.5.4") + + // ==================== JSON ==================== + implementation("org.json:json:20231013") + implementation("com.google.code.gson:gson:2.10.1") + + // ==================== TESTING ==================== + testImplementation("junit:junit:4.13.2") + androidTestImplementation("androidx.test.ext:junit:1.1.5") + androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1") + androidTestImplementation(platform("androidx.compose:compose-bom:$composeBom")) + androidTestImplementation("androidx.compose.ui:ui-test-junit4") + + // Debug + debugImplementation("androidx.compose.ui:ui-tooling") + debugImplementation("androidx.compose.ui:ui-test-manifest") +} + +/** + * PENJELASAN DEPENDENCIES: + * + * 1. NAVIGATION COMPOSE + * - Untuk navigasi antar screen + * + * 2. ROOM DATABASE + * - Database lokal untuk menyimpan riwayat absensi + * - PENTING: Butuh plugin kapt + * + * 3. DATASTORE PREFERENCES + * - Untuk menyimpan data login user + * - Pengganti SharedPreferences yang lebih modern + * + * 4. GOOGLE LOCATION SERVICES + * - Untuk mendapatkan koordinat GPS + * + * 5. COIL + * - Untuk loading dan display image + * + * 6. COROUTINES + * - Untuk async operations (database, network) + * + * 7. MATERIAL ICONS EXTENDED + * - Icon-icon tambahan untuk UI + */ \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 4619836..754d3f8 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,14 +1,52 @@ + - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + android:theme="@style/Theme.SistemAkademik" + android:usesCleartextTraffic="true" + tools:targetApi="31"> + + + android:theme="@style/Theme.SistemAkademik" + android:screenOrientation="portrait" + android:configChanges="orientation|screenSize"> + - + + + + - \ No newline at end of file + + + \ No newline at end of file diff --git a/app/src/main/java/id/ac/ubharajaya/sistemakademik/MainActivity.kt b/app/src/main/java/id/ac/ubharajaya/sistemakademik/MainActivity.kt index c774502..4c38e26 100644 --- a/app/src/main/java/id/ac/ubharajaya/sistemakademik/MainActivity.kt +++ b/app/src/main/java/id/ac/ubharajaya/sistemakademik/MainActivity.kt @@ -1,274 +1,207 @@ package id.ac.ubharajaya.sistemakademik -import android.Manifest -import android.app.Activity -import android.content.Intent -import android.content.pm.PackageManager -import android.graphics.Bitmap import android.os.Bundle -import android.provider.MediaStore -import android.util.Base64 -import android.widget.Toast import androidx.activity.ComponentActivity -import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge -import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.foundation.layout.* -import androidx.compose.material3.* -import androidx.compose.runtime.* -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.unit.dp -import androidx.core.content.ContextCompat -import com.google.android.gms.location.LocationServices +import androidx.compose.runtime.Composable +import androidx.navigation.NavType +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import androidx.navigation.navArgument +import com.google.gson.Gson +import id.ac.ubharajaya.sistemakademik.data.MataKuliah +import id.ac.ubharajaya.sistemakademik.screens.* import id.ac.ubharajaya.sistemakademik.ui.theme.SistemAkademikTheme -import org.json.JSONObject -import java.io.ByteArrayOutputStream -import java.net.HttpURLConnection -import java.net.URL -import kotlin.concurrent.thread +import java.net.URLDecoder +import java.net.URLEncoder +import java.nio.charset.StandardCharsets -/* ================= UTIL ================= */ - -fun bitmapToBase64(bitmap: Bitmap): String { - val outputStream = ByteArrayOutputStream() - bitmap.compress(Bitmap.CompressFormat.JPEG, 80, outputStream) - return Base64.encodeToString(outputStream.toByteArray(), Base64.NO_WRAP) -} - -fun kirimKeN8n( - context: ComponentActivity, - latitude: Double, - longitude: Double, - foto: Bitmap -) { - thread { - try { - val url = URL("https://n8n.lab.ubharajaya.ac.id/webhook/23c6993d-1792-48fb-ad1c-ffc78a3e6254") -// test URL val url = URL("https://n8n.lab.ubharajaya.ac.id/webhook-test/23c6993d-1792-48fb-ad1c-ffc78a3e6254") - val conn = url.openConnection() as HttpURLConnection - - conn.requestMethod = "POST" - conn.setRequestProperty("Content-Type", "application/json") - conn.doOutput = true - - val json = JSONObject().apply { - put("npm", "12345") - put("nama","Arif R D") - put("latitude", latitude) - put("longitude", longitude) - put("timestamp", System.currentTimeMillis()) - put("foto_base64", bitmapToBase64(foto)) - } - - conn.outputStream.use { - it.write(json.toString().toByteArray()) - } - - val responseCode = conn.responseCode - - context.runOnUiThread { - Toast.makeText( - context, - if (responseCode == 200) - "Absensi diterima server" - else - "Absensi ditolak server", - Toast.LENGTH_SHORT - ).show() - } - - conn.disconnect() - - } catch (_: Exception) { - context.runOnUiThread { - Toast.makeText( - context, - "Gagal kirim ke server", - Toast.LENGTH_SHORT - ).show() - } - } - } -} - -/* ================= ACTIVITY ================= */ +/** + * FILE: MainActivity.kt (UPDATED dengan SelectMatakuliah) + * + * Deskripsi: + * Main Activity dengan Navigation Compose + * FITUR BARU: Pilih mata kuliah sebelum absen + * + * Flow Baru: + * Dashboard → SelectMatakuliah → Absensi → Success + */ class MainActivity : ComponentActivity() { - override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdge() setContent { SistemAkademikTheme { - Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> - AbsensiScreen( - modifier = Modifier.padding(innerPadding), - activity = this - ) - } + AppNavigation() } } } } -/* ================= UI ================= */ +/** + * Navigation Routes + */ +object Routes { + const val LOGIN = "login" + const val DASHBOARD = "dashboard" + const val SELECT_MATAKULIAH = "select_matakuliah" + const val ABSENSI = "absensi/{matakuliahJson}" + const val SUCCESS = "success" + const val HISTORY = "history" + const val SCHEDULE = "schedule" -@Composable -fun AbsensiScreen( - modifier: Modifier = Modifier, - activity: ComponentActivity -) { - val context = LocalContext.current - - var lokasi by remember { mutableStateOf("Koordinat: -") } - var latitude by remember { mutableStateOf(null) } - var longitude by remember { mutableStateOf(null) } - var foto by remember { mutableStateOf(null) } - - val fusedLocationClient = - LocationServices.getFusedLocationProviderClient(context) - - /* ===== Permission Lokasi ===== */ - - val locationPermissionLauncher = - rememberLauncherForActivityResult( - ActivityResultContracts.RequestPermission() - ) { granted -> - if (granted) { - - if ( - ContextCompat.checkSelfPermission( - context, - Manifest.permission.ACCESS_FINE_LOCATION - ) == PackageManager.PERMISSION_GRANTED - ) { - - fusedLocationClient.lastLocation - .addOnSuccessListener { location -> - if (location != null) { - latitude = location.latitude - longitude = location.longitude - lokasi = - "Lat: ${location.latitude}\nLon: ${location.longitude}" - } else { - lokasi = "Lokasi tidak tersedia" - } - } - .addOnFailureListener { - lokasi = "Gagal mengambil lokasi" - } - } - - } else { - Toast.makeText( - context, - "Izin lokasi ditolak", - Toast.LENGTH_SHORT - ).show() - } - } - - /* ===== Kamera ===== */ - - val cameraLauncher = - rememberLauncherForActivityResult( - ActivityResultContracts.StartActivityForResult() - ) { result -> - if (result.resultCode == Activity.RESULT_OK) { - val bitmap = - result.data?.extras?.getParcelable("data", Bitmap::class.java) - if (bitmap != null) { - foto = bitmap - Toast.makeText( - context, - "Foto berhasil diambil", - Toast.LENGTH_SHORT - ).show() - } - } - } - - val cameraPermissionLauncher = - rememberLauncherForActivityResult( - ActivityResultContracts.RequestPermission() - ) { granted -> - if (granted) { - val intent = - Intent(MediaStore.ACTION_IMAGE_CAPTURE) - cameraLauncher.launch(intent) - } else { - Toast.makeText( - context, - "Izin kamera ditolak", - Toast.LENGTH_SHORT - ).show() - } - } - - /* ===== Request Awal ===== */ - - LaunchedEffect(Unit) { - locationPermissionLauncher.launch( - Manifest.permission.ACCESS_FINE_LOCATION - ) + fun createAbsensiRoute(mataKuliah: MataKuliah): String { + val json = Gson().toJson(mataKuliah) + val encodedJson = URLEncoder.encode(json, StandardCharsets.UTF_8.toString()) + return "absensi/$encodedJson" } +} - /* ===== UI ===== */ +/** + * App Navigation + */ +@Composable +fun AppNavigation() { + val navController = rememberNavController() + val gson = Gson() - Column( - modifier = modifier - .fillMaxSize() - .padding(24.dp), - verticalArrangement = Arrangement.Center + NavHost( + navController = navController, + startDestination = Routes.LOGIN ) { - Text( - text = "Absensi Akademik", - style = MaterialTheme.typography.titleLarge - ) - - Spacer(modifier = Modifier.height(16.dp)) - - Text(text = lokasi) - - Spacer(modifier = Modifier.height(16.dp)) - - Button( - onClick = { - cameraPermissionLauncher.launch( - Manifest.permission.CAMERA - ) - }, - modifier = Modifier.fillMaxWidth() - ) { - Text("Ambil Foto") + // Login Screen + composable(Routes.LOGIN) { + LoginScreen( + onLoginSuccess = { + navController.navigate(Routes.DASHBOARD) { + popUpTo(Routes.LOGIN) { inclusive = true } + } + } + ) } - Spacer(modifier = Modifier.height(12.dp)) - - Button( - onClick = { - if (latitude != null && longitude != null && foto != null) { - kirimKeN8n( - activity, - latitude!!, - longitude!!, - foto!! - ) - } else { - Toast.makeText( - context, - "Lokasi atau foto belum lengkap", - Toast.LENGTH_SHORT - ).show() + // Dashboard Screen + composable(Routes.DASHBOARD) { + DashboardScreen( + onNavigateToAbsensi = { + // Navigasi ke SelectMatakuliah dulu (BARU!) + navController.navigate(Routes.SELECT_MATAKULIAH) + }, + onNavigateToHistory = { + navController.navigate(Routes.HISTORY) + }, + onNavigateToSchedule = { + navController.navigate(Routes.SCHEDULE) + }, + onLogout = { + navController.navigate(Routes.LOGIN) { + popUpTo(0) { inclusive = true } + } } - }, - modifier = Modifier.fillMaxWidth() - ) { - Text("Kirim Absensi") + ) + } + + // Select Mata Kuliah Screen (BARU!) + composable(Routes.SELECT_MATAKULIAH) { + SelectMatakuliahScreen( + onNavigateBack = { + navController.popBackStack() + }, + onMatakuliahSelected = { mataKuliah -> + // Navigasi ke Absensi dengan data MK + val route = Routes.createAbsensiRoute(mataKuliah) + navController.navigate(route) + } + ) + } + + // Absensi Screen (UPDATED - terima parameter MK) + composable( + route = Routes.ABSENSI, + arguments = listOf( + navArgument("matakuliahJson") { + type = NavType.StringType + } + ) + ) { backStackEntry -> + val matakuliahJson = backStackEntry.arguments?.getString("matakuliahJson") + val decodedJson = URLDecoder.decode(matakuliahJson, StandardCharsets.UTF_8.toString()) + val mataKuliah = gson.fromJson(decodedJson, MataKuliah::class.java) + + AbsensiScreen( + mataKuliah = mataKuliah, + onNavigateToSuccess = { + navController.navigate(Routes.SUCCESS) { + popUpTo(Routes.DASHBOARD) + } + }, + onNavigateBack = { + navController.popBackStack() + } + ) + } + + // Success Screen + composable(Routes.SUCCESS) { + SuccessScreen( + onNavigateToDashboard = { + navController.navigate(Routes.DASHBOARD) { + popUpTo(Routes.DASHBOARD) { inclusive = true } + } + }, + onNavigateToHistory = { + navController.navigate(Routes.HISTORY) { + popUpTo(Routes.DASHBOARD) + } + } + ) + } + + // History Screen + composable(Routes.HISTORY) { + HistoryScreen( + onNavigateBack = { + navController.popBackStack() + } + ) + } + + // Schedule Screen + composable(Routes.SCHEDULE) { + ScheduleScreen( + onNavigateBack = { + navController.popBackStack() + } + ) } } } + +/** + * PENJELASAN FLOW BARU: + * + * 1. LOGIN → DASHBOARD + * - Sama seperti sebelumnya + * + * 2. DASHBOARD → SELECT_MATAKULIAH → ABSENSI → SUCCESS + * - User klik "Mulai Absensi" + * - Masuk ke screen pilih mata kuliah (filter hari ini) + * - User pilih MK + * - Data MK di-pass ke AbsensiScreen via navigation argument (JSON) + * - Absensi berhasil → Success screen + * + * 3. Passing MataKuliah Object: + * - Convert MataKuliah → JSON → URL Encode + * - Pass via navigation argument + * - Di destination: URL Decode → JSON → MataKuliah object + * + * 4. Database Update: + * - AbsensiEntity sekarang punya field mata kuliah + * - History menampilkan nama mata kuliah + * - Filter MK yang sudah diabsen hari ini + */ \ No newline at end of file diff --git a/app/src/main/java/id/ac/ubharajaya/sistemakademik/data/AbsensiDao.kt b/app/src/main/java/id/ac/ubharajaya/sistemakademik/data/AbsensiDao.kt new file mode 100644 index 0000000..39816ba --- /dev/null +++ b/app/src/main/java/id/ac/ubharajaya/sistemakademik/data/AbsensiDao.kt @@ -0,0 +1,93 @@ +package id.ac.ubharajaya.sistemakademik.data + +import androidx.room.* +import kotlinx.coroutines.flow.Flow + +/** + * FILE: AbsensiDao.kt + * LOKASI: data/AbsensiDao.kt + * + * Deskripsi: + * Data Access Object (DAO) untuk operasi database absensi + * Berisi query-query untuk insert, update, delete, dan get data + */ + +@Dao +interface AbsensiDao { + + /** + * Insert data absensi baru ke database + * @return ID dari record yang baru diinsert + */ + @Insert + suspend fun insert(absensi: AbsensiEntity): Long + + /** + * Update data absensi yang sudah ada + */ + @Update + suspend fun update(absensi: AbsensiEntity) + + /** + * Delete data absensi + */ + @Delete + suspend fun delete(absensi: AbsensiEntity) + + /** + * Get semua data absensi, diurutkan dari yang terbaru + * Menggunakan Flow agar otomatis update UI ketika ada perubahan data + */ + @Query("SELECT * FROM absensi ORDER BY timestamp DESC") + fun getAllAbsensi(): Flow> + + /** + * Get data absensi berdasarkan NPM + */ + @Query("SELECT * FROM absensi WHERE npm = :npm ORDER BY timestamp DESC") + fun getAbsensiByNpm(npm: String): Flow> + + /** + * Get data absensi berdasarkan ID + */ + @Query("SELECT * FROM absensi WHERE id = :id") + suspend fun getAbsensiById(id: Int): AbsensiEntity? + + /** + * Get jumlah total absensi + */ + @Query("SELECT COUNT(*) FROM absensi") + suspend fun getTotalAbsensi(): Int + + /** + * Get jumlah absensi hari ini + * @param tanggal Format: "14 Jan 2026" + */ + @Query("SELECT COUNT(*) FROM absensi WHERE tanggal = :tanggal") + suspend fun getAbsensiHariIniCount(tanggal: String): Int + + /** + * Get list absensi hari ini (BARU untuk filter MK sudah absen) + * @param tanggal Format: "14 Jan 2026" + */ + @Query("SELECT * FROM absensi WHERE tanggal = :tanggal ORDER BY timestamp DESC") + fun getAbsensiHariIni(tanggal: String): Flow> + + /** + * Get absensi yang belum terkirim ke server + */ + @Query("SELECT * FROM absensi WHERE statusKirim = 0 ORDER BY timestamp ASC") + suspend fun getAbsensiPending(): List + + /** + * Update status kirim absensi + */ + @Query("UPDATE absensi SET statusKirim = :status WHERE id = :id") + suspend fun updateStatusKirim(id: Int, status: Boolean) + + /** + * Delete semua data absensi (untuk testing/reset) + */ + @Query("DELETE FROM absensi") + suspend fun deleteAll() +} \ No newline at end of file diff --git a/app/src/main/java/id/ac/ubharajaya/sistemakademik/data/AbsensiEntity.kt b/app/src/main/java/id/ac/ubharajaya/sistemakademik/data/AbsensiEntity.kt new file mode 100644 index 0000000..efa6d0b --- /dev/null +++ b/app/src/main/java/id/ac/ubharajaya/sistemakademik/data/AbsensiEntity.kt @@ -0,0 +1,49 @@ +package id.ac.ubharajaya.sistemakademik.data + +import androidx.room.Entity +import androidx.room.PrimaryKey + +/** + * FILE: AbsensiEntity.kt + * LOKASI: data/AbsensiEntity.kt + * + * Deskripsi: + * Entity class untuk tabel absensi di Room Database + * Setiap object AbsensiEntity merepresentasikan 1 record absensi + */ + +@Entity(tableName = "absensi") +data class AbsensiEntity( + @PrimaryKey(autoGenerate = true) + val id: Int = 0, + + // Data Mahasiswa + val npm: String, + val nama: String, + + // Data Mata Kuliah (BARU!) + val kodeMatakuliah: String, // Contoh: "INFO-3527" + val namaMatakuliah: String, // Contoh: "Pemrograman Mobile" + val dosenMatakuliah: String, // Contoh: "Dr. Ahmad Wijaya" + val ruanganMatakuliah: String, // Contoh: "Lab Komputer 1" + val waktuMatakuliah: String, // Contoh: "08:00 - 10:00" + + // Data Lokasi + val latitude: Double, + val longitude: Double, + val jarak: Double, // Jarak dari kampus dalam meter + val lokasiValid: Boolean, // Apakah lokasi dalam radius + + // Data Waktu + val timestamp: Long, // Timestamp dalam milliseconds + val tanggal: String, // Format: "14 Jan 2026" + val waktu: String, // Format: "09:15 WIB" + val hari: String, // Format: "Rabu" + + // Data Foto (Base64) + val fotoBase64: String, + + // Status + val statusKirim: Boolean = false, // Apakah sudah terkirim ke webhook + val pesanError: String? = null // Pesan error jika gagal kirim +) \ No newline at end of file diff --git a/app/src/main/java/id/ac/ubharajaya/sistemakademik/data/AppDatabase.kt b/app/src/main/java/id/ac/ubharajaya/sistemakademik/data/AppDatabase.kt new file mode 100644 index 0000000..faaa8a6 --- /dev/null +++ b/app/src/main/java/id/ac/ubharajaya/sistemakademik/data/AppDatabase.kt @@ -0,0 +1,80 @@ +package id.ac.ubharajaya.sistemakademik.data + +import android.content.Context +import androidx.room.Database +import androidx.room.Room +import androidx.room.RoomDatabase + +/** + * FILE: AppDatabase.kt + * LOKASI: data/AppDatabase.kt + * + * Deskripsi: + * Konfigurasi Room Database untuk aplikasi + * Menggunakan Singleton pattern agar hanya ada 1 instance database + */ + +@Database( + entities = [AbsensiEntity::class], + version = 1, + exportSchema = false +) +abstract class AppDatabase : RoomDatabase() { + + // Abstract function untuk mendapatkan DAO + abstract fun absensiDao(): AbsensiDao + + companion object { + // Singleton instance + @Volatile + private var INSTANCE: AppDatabase? = null + + /** + * Get atau create database instance + * Menggunakan synchronized untuk thread-safety + * + * @param context Application context + * @return Database instance + */ + fun getDatabase(context: Context): AppDatabase { + // Jika instance sudah ada, return instance tersebut + return INSTANCE ?: synchronized(this) { + // Double-check locking untuk memastikan hanya 1 instance + val instance = Room.databaseBuilder( + context.applicationContext, + AppDatabase::class.java, + "sistem_akademik_database" // ✅ DIPERBAIKI: Gunakan string langsung + ) + // Fallback strategy jika ada perubahan schema + .fallbackToDestructiveMigration() + .build() + + INSTANCE = instance + instance + } + } + + /** + * Destroy database instance (untuk testing) + */ + fun destroyInstance() { + INSTANCE = null + } + } +} + +/** + * CARA PAKAI: + * + * // Di Activity/Screen: + * val database = AppDatabase.getDatabase(context) + * val dao = database.absensiDao() + * + * // Insert data: + * lifecycleScope.launch { + * val id = dao.insert(absensiEntity) + * } + * + * // Get data: + * val allAbsensi = dao.getAllAbsensi().collectAsState(initial = emptyList()) + */ \ No newline at end of file diff --git a/app/src/main/java/id/ac/ubharajaya/sistemakademik/data/Matakuliah.kt b/app/src/main/java/id/ac/ubharajaya/sistemakademik/data/Matakuliah.kt new file mode 100644 index 0000000..9c30140 --- /dev/null +++ b/app/src/main/java/id/ac/ubharajaya/sistemakademik/data/Matakuliah.kt @@ -0,0 +1,30 @@ +package id.ac.ubharajaya.sistemakademik.data + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +/** + * FILE: MataKuliah.kt + * LOKASI: data/MataKuliah.kt + * + * Deskripsi: + * Data class untuk Mata Kuliah + * Digunakan untuk passing data antar screen + */ + +@Parcelize +data class MataKuliah( + val kode: String, // Kode MK: "INFO-3527" + val nama: String, // Nama MK: "Pemrograman Mobile" + val dosen: String, // Nama Dosen: "Dr. Ahmad Wijaya" + val ruangan: String, // Ruangan: "Lab Komputer 1" + val waktu: String, // Waktu: "08:00 - 10:00" + val hari: String, // Hari: "Senin" + val sks: Int = 3 // SKS (default 3) +) : Parcelable + +/** + * CATATAN: + * - @Parcelize digunakan agar bisa di-pass lewat Navigation argument + * - Parcelable lebih efisien daripada Serializable untuk Android + */ \ No newline at end of file diff --git a/app/src/main/java/id/ac/ubharajaya/sistemakademik/data/UserPreferences.kt b/app/src/main/java/id/ac/ubharajaya/sistemakademik/data/UserPreferences.kt new file mode 100644 index 0000000..d4795c0 --- /dev/null +++ b/app/src/main/java/id/ac/ubharajaya/sistemakademik/data/UserPreferences.kt @@ -0,0 +1,115 @@ +package id.ac.ubharajaya.sistemakademik.data + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.* +import androidx.datastore.preferences.preferencesDataStore +import id.ac.ubharajaya.sistemakademik.utils.Constants +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +/** + * FILE: UserPreferences.kt + * LOKASI: data/UserPreferences.kt + * + * Deskripsi: + * Class untuk menyimpan dan mengambil data user (login state) + * Menggunakan DataStore Preferences (pengganti SharedPreferences yang lebih modern) + */ + +// Extension property untuk DataStore +private val Context.dataStore: DataStore by preferencesDataStore(name = "user_preferences") + +class UserPreferences(private val context: Context) { + + // Keys untuk menyimpan data + private object PreferenceKeys { + val NPM = stringPreferencesKey(Constants.PREF_NPM) + val NAMA = stringPreferencesKey(Constants.PREF_NAMA) + val IS_LOGGED_IN = booleanPreferencesKey(Constants.PREF_IS_LOGGED_IN) + } + + /** + * Simpan data login user + */ + suspend fun saveUserData(npm: String, nama: String) { + context.dataStore.edit { preferences -> + preferences[PreferenceKeys.NPM] = npm + preferences[PreferenceKeys.NAMA] = nama + preferences[PreferenceKeys.IS_LOGGED_IN] = true + } + } + + /** + * Get NPM user + */ + val npm: Flow = context.dataStore.data + .map { preferences -> + preferences[PreferenceKeys.NPM] ?: "" + } + + /** + * Get nama user + */ + val nama: Flow = context.dataStore.data + .map { preferences -> + preferences[PreferenceKeys.NAMA] ?: "" + } + + /** + * Get status login + */ + val isLoggedIn: Flow = context.dataStore.data + .map { preferences -> + preferences[PreferenceKeys.IS_LOGGED_IN] ?: false + } + + /** + * Logout user (hapus semua data) + */ + suspend fun logout() { + context.dataStore.edit { preferences -> + preferences.clear() + } + } + + /** + * Get semua data user sekaligus + */ + data class UserData( + val npm: String, + val nama: String, + val isLoggedIn: Boolean + ) + + val userData: Flow = context.dataStore.data + .map { preferences -> + UserData( + npm = preferences[PreferenceKeys.NPM] ?: "", + nama = preferences[PreferenceKeys.NAMA] ?: "", + isLoggedIn = preferences[PreferenceKeys.IS_LOGGED_IN] ?: false + ) + } +} + +/** + * CARA PAKAI: + * + * // Di Activity/Screen: + * val userPreferences = UserPreferences(context) + * + * // Simpan data login: + * lifecycleScope.launch { + * userPreferences.saveUserData("202310715176", "HAGA DALPINTO GINTING") + * } + * + * // Baca data: + * val userData by userPreferences.userData.collectAsState( + * initial = UserPreferences.UserData("", "", false) + * ) + * + * // Logout: + * lifecycleScope.launch { + * userPreferences.logout() + * } + */ \ No newline at end of file diff --git a/app/src/main/java/id/ac/ubharajaya/sistemakademik/screens/AbsensiScreen.kt b/app/src/main/java/id/ac/ubharajaya/sistemakademik/screens/AbsensiScreen.kt new file mode 100644 index 0000000..88eeb85 --- /dev/null +++ b/app/src/main/java/id/ac/ubharajaya/sistemakademik/screens/AbsensiScreen.kt @@ -0,0 +1,687 @@ +package id.ac.ubharajaya.sistemakademik.screens + +import android.Manifest +import android.app.Activity +import android.content.Intent +import android.content.pm.PackageManager +import android.graphics.Bitmap +import android.provider.MediaStore +import android.util.Base64 +import android.widget.Toast +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.core.content.ContextCompat +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.google.android.gms.location.LocationServices +import id.ac.ubharajaya.sistemakademik.data.AbsensiEntity +import id.ac.ubharajaya.sistemakademik.data.AppDatabase +import id.ac.ubharajaya.sistemakademik.data.UserPreferences +import id.ac.ubharajaya.sistemakademik.utils.Constants +import id.ac.ubharajaya.sistemakademik.utils.LocationValidator +import kotlinx.coroutines.launch +import org.json.JSONObject +import java.io.ByteArrayOutputStream +import java.net.HttpURLConnection +import java.net.URL +import java.text.SimpleDateFormat +import java.util.* +import kotlin.concurrent.thread + +/** + * FILE: AbsensiScreen.kt + * LOKASI: screens/AbsensiScreen.kt + * + * Deskripsi: + * Screen untuk melakukan absensi dengan: + * - Deteksi lokasi GPS + * - Validasi radius (harus di dalam area kampus) + * - Pengambilan foto selfie + * - Preview foto sebelum kirim + * - Kirim data ke webhook + * - Simpan ke database lokal + */ + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AbsensiScreen( + mataKuliah: id.ac.ubharajaya.sistemakademik.data.MataKuliah, // Parameter BARU! + onNavigateToSuccess: () -> Unit, + onNavigateBack: () -> Unit +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + val userPreferences = remember { UserPreferences(context) } + val database = remember { AppDatabase.getDatabase(context) } + + // Get user data + val userData by userPreferences.userData.collectAsStateWithLifecycle( + initialValue = UserPreferences.UserData("", "", false) + ) + + // State + var latitude by remember { mutableStateOf(null) } + var longitude by remember { mutableStateOf(null) } + var isLocationValid by remember { mutableStateOf(false) } + var distance by remember { mutableStateOf(0.0) } + var foto by remember { mutableStateOf(null) } + var isLoading by remember { mutableStateOf(false) } + var statusMessage by remember { mutableStateOf(Constants.MSG_MENUNGGU_LOKASI) } + + // Warna + val primaryGreen = Color(0xFF2E7D32) + val lightGreen = Color(0xFF66BB6A) + val errorRed = Color(0xFFD32F2F) + + val fusedLocationClient = remember { + LocationServices.getFusedLocationProviderClient(context) + } + + // Permission Launchers + val locationPermissionLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission() + ) { granted -> + if (granted) { + getLocation( + fusedLocationClient = fusedLocationClient, + context = context, + onSuccess = { lat, lon -> + latitude = lat + longitude = lon + + // Validasi lokasi + val (valid, dist) = LocationValidator.isLocationValid(lat, lon) + isLocationValid = valid + distance = dist + statusMessage = LocationValidator.getStatusMessage(valid, dist) + }, + onFailure = { + statusMessage = "Gagal mengambil lokasi" + Toast.makeText(context, "Gagal mengambil lokasi", Toast.LENGTH_SHORT).show() + } + ) + } else { + Toast.makeText(context, "Izin lokasi ditolak", Toast.LENGTH_SHORT).show() + } + } + + val cameraLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.StartActivityForResult() + ) { result -> + if (result.resultCode == Activity.RESULT_OK) { + val bitmap = result.data?.extras?.getParcelable("data", Bitmap::class.java) + if (bitmap != null) { + foto = bitmap + Toast.makeText(context, "Foto berhasil diambil", Toast.LENGTH_SHORT).show() + } + } + } + + val cameraPermissionLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission() + ) { granted -> + if (granted) { + val intent = Intent(MediaStore.ACTION_IMAGE_CAPTURE) + cameraLauncher.launch(intent) + } else { + Toast.makeText(context, "Izin kamera ditolak", Toast.LENGTH_SHORT).show() + } + } + + // Auto request location on start + LaunchedEffect(Unit) { + locationPermissionLauncher.launch(Manifest.permission.ACCESS_FINE_LOCATION) + } + + // UI + Scaffold( + topBar = { + TopAppBar( + title = { + Column { + Text( + text = "Absen Kehadiran", + fontSize = 16.sp + ) + Text( + text = mataKuliah.nama, + fontSize = 12.sp, + fontWeight = FontWeight.Normal + ) + } + }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon(Icons.Default.ArrowBack, "Back") + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = primaryGreen, + titleContentColor = Color.White, + navigationIconContentColor = Color.White + ) + ) + } + ) { paddingValues -> + + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .verticalScroll(rememberScrollState()) + .padding(16.dp) + ) { + + // Info Mata Kuliah Card (BARU!) + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = lightGreen.copy(alpha = 0.15f) + ), + shape = RoundedCornerShape(12.dp) + ) { + Column( + modifier = Modifier.padding(16.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Default.School, + contentDescription = null, + tint = primaryGreen, + modifier = Modifier.size(28.dp) + ) + Spacer(modifier = Modifier.width(12.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = mataKuliah.nama, + fontWeight = FontWeight.Bold, + fontSize = 15.sp + ) + Text( + text = "${mataKuliah.kode} â€ĸ ${mataKuliah.waktu}", + fontSize = 12.sp, + color = Color.Gray + ) + } + } + + Spacer(modifier = Modifier.height(8.dp)) + Divider() + Spacer(modifier = Modifier.height(8.dp)) + + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Default.Person, + contentDescription = null, + modifier = Modifier.size(14.dp), + tint = Color.Gray + ) + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = mataKuliah.dosen, + fontSize = 12.sp, + color = Color.Gray + ) + } + + Spacer(modifier = Modifier.height(4.dp)) + + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Default.LocationOn, + contentDescription = null, + modifier = Modifier.size(14.dp), + tint = Color.Gray + ) + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = mataKuliah.ruangan, + fontSize = 12.sp, + color = Color.Gray, + maxLines = 1 + ) + } + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + // Status Lokasi Card + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = if (isLocationValid) + lightGreen.copy(alpha = 0.2f) + else + errorRed.copy(alpha = 0.1f) + ), + shape = RoundedCornerShape(12.dp) + ) { + Column( + modifier = Modifier.padding(16.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = if (isLocationValid) + Icons.Default.CheckCircle + else + Icons.Default.Cancel, + contentDescription = null, + tint = if (isLocationValid) primaryGreen else errorRed, + modifier = Modifier.size(32.dp) + ) + Spacer(modifier = Modifier.width(12.dp)) + Column { + Text( + text = if (latitude != null) + "Cek Lokasi: ${if (isLocationValid) "Dalam Area Absensi ✓" else "Di Luar Area Absensi ✗"}" + else + "Mendeteksi Lokasi...", + fontWeight = FontWeight.Bold, + fontSize = 14.sp, + color = if (isLocationValid) primaryGreen else errorRed + ) + + if (latitude != null && longitude != null) { + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "Jarak: ${LocationValidator.formatDistance(distance)}", + fontSize = 12.sp, + color = Color.Gray + ) + } + } + } + + if (latitude != null && longitude != null) { + Spacer(modifier = Modifier.height(12.dp)) + Divider() + Spacer(modifier = Modifier.height(12.dp)) + + Text( + text = "Koordinat Anda:", + fontSize = 12.sp, + fontWeight = FontWeight.Bold + ) + Text( + text = "Lat: ${"%.6f".format(latitude)}", + fontSize = 11.sp, + color = Color.Gray + ) + Text( + text = "Lon: ${"%.6f".format(longitude)}", + fontSize = 11.sp, + color = Color.Gray + ) + } + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + // Ambil Foto Section + Text( + text = "Ambil Foto Selfie", + fontSize = 16.sp, + fontWeight = FontWeight.Bold + ) + + Spacer(modifier = Modifier.height(8.dp)) + + // Preview Foto + if (foto != null) { + Card( + modifier = Modifier + .fillMaxWidth() + .height(300.dp), + shape = RoundedCornerShape(12.dp), + elevation = CardDefaults.cardElevation(4.dp) + ) { + Box( + modifier = Modifier.fillMaxSize() + ) { + Image( + bitmap = foto!!.asImageBitmap(), + contentDescription = "Preview Foto", + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop + ) + + // Icon check di corner + Icon( + imageVector = Icons.Default.CheckCircle, + contentDescription = null, + tint = primaryGreen, + modifier = Modifier + .align(Alignment.TopEnd) + .padding(8.dp) + .size(32.dp) + ) + } + } + + Spacer(modifier = Modifier.height(8.dp)) + } else { + // Placeholder jika belum ada foto + Card( + modifier = Modifier + .fillMaxWidth() + .height(200.dp), + shape = RoundedCornerShape(12.dp), + colors = CardDefaults.cardColors( + containerColor = Color(0xFFF5F5F5) + ) + ) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally + ) { + Icon( + imageVector = Icons.Default.CameraAlt, + contentDescription = null, + modifier = Modifier.size(64.dp), + tint = Color.Gray + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "Belum ada foto", + color = Color.Gray + ) + } + } + } + + Spacer(modifier = Modifier.height(8.dp)) + } + + // Button Ambil Foto + Button( + onClick = { + cameraPermissionLauncher.launch(Manifest.permission.CAMERA) + }, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.buttonColors( + containerColor = if (foto != null) lightGreen else primaryGreen + ), + shape = RoundedCornerShape(8.dp) + ) { + Icon( + imageVector = Icons.Default.CameraAlt, + contentDescription = null + ) + Spacer(modifier = Modifier.width(8.dp)) + Text(if (foto != null) "Ambil Ulang Foto" else "AMBIL FOTO") + } + + Spacer(modifier = Modifier.height(24.dp)) + + // Button Kirim Absensi + Button( + onClick = { + if (latitude == null || longitude == null) { + Toast.makeText(context, "Menunggu data lokasi...", Toast.LENGTH_SHORT).show() + return@Button + } + + if (!isLocationValid) { + Toast.makeText( + context, + "Anda di luar area kampus! Jarak: ${LocationValidator.formatDistance(distance)}", + Toast.LENGTH_LONG + ).show() + return@Button + } + + if (foto == null) { + Toast.makeText(context, Constants.MSG_FOTO_BELUM_DIAMBIL, Toast.LENGTH_SHORT).show() + return@Button + } + + // Semua validasi OK, kirim absensi + isLoading = true + + scope.launch { + try { + // Siapkan data absensi + val calendar = Calendar.getInstance() + val dateFormat = SimpleDateFormat("dd MMM yyyy", Locale("id", "ID")) + val timeFormat = SimpleDateFormat("HH:mm", Locale("id", "ID")) + val dayFormat = SimpleDateFormat("EEEE", Locale("id", "ID")) + + val absensiEntity = AbsensiEntity( + npm = userData.npm.ifEmpty { Constants.NPM }, + nama = userData.nama.ifEmpty { Constants.NAMA }, + // Data Mata Kuliah (BARU!) + kodeMatakuliah = mataKuliah.kode, + namaMatakuliah = mataKuliah.nama, + dosenMatakuliah = mataKuliah.dosen, + ruanganMatakuliah = mataKuliah.ruangan, + waktuMatakuliah = mataKuliah.waktu, + // Data Lokasi + latitude = latitude!!, + longitude = longitude!!, + jarak = distance, + lokasiValid = isLocationValid, + timestamp = System.currentTimeMillis(), + tanggal = dateFormat.format(calendar.time), + waktu = "${timeFormat.format(calendar.time)} WIB", + hari = dayFormat.format(calendar.time), + fotoBase64 = bitmapToBase64(foto!!), + statusKirim = false + ) + + // Simpan ke database lokal + val id = database.absensiDao().insert(absensiEntity) + + // Kirim ke webhook + kirimKeWebhook( + context = context, + absensi = absensiEntity.copy(id = id.toInt()), + onSuccess = { + scope.launch { + // Update status kirim di database + database.absensiDao().updateStatusKirim(id.toInt(), true) + + isLoading = false + Toast.makeText( + context, + "Absensi berhasil dicatat!", + Toast.LENGTH_SHORT + ).show() + onNavigateToSuccess() + } + }, + onFailure = { error -> + scope.launch { + isLoading = false + Toast.makeText( + context, + "Data tersimpan lokal. Error: $error", + Toast.LENGTH_LONG + ).show() + onNavigateToSuccess() + } + } + ) + + } catch (e: Exception) { + isLoading = false + Toast.makeText( + context, + "Error: ${e.message}", + Toast.LENGTH_SHORT + ).show() + } + } + }, + modifier = Modifier + .fillMaxWidth() + .height(56.dp), + colors = ButtonDefaults.buttonColors( + containerColor = primaryGreen, + disabledContainerColor = Color.Gray + ), + shape = RoundedCornerShape(8.dp), + enabled = !isLoading && isLocationValid && foto != null + ) { + if (isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + color = Color.White + ) + } else { + Icon( + imageVector = Icons.Default.Send, + contentDescription = null + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = "KIRIM ABSENSI", + fontSize = 16.sp, + fontWeight = FontWeight.Bold + ) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + // Info validasi + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = Color(0xFFFFF3E0) + ) + ) { + Row( + modifier = Modifier.padding(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Default.Info, + contentDescription = null, + tint = Color(0xFFFF6F00), + modifier = Modifier.size(20.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = "Pastikan Anda berada dalam radius ${Constants.RADIUS_METER.toInt()}m dari kampus", + fontSize = 12.sp, + color = Color(0xFFE65100) + ) + } + } + } + } +} + +// Helper Functions + +private fun getLocation( + fusedLocationClient: com.google.android.gms.location.FusedLocationProviderClient, + context: android.content.Context, + onSuccess: (Double, Double) -> Unit, + onFailure: () -> Unit +) { + if (ContextCompat.checkSelfPermission( + context, + Manifest.permission.ACCESS_FINE_LOCATION + ) == PackageManager.PERMISSION_GRANTED + ) { + fusedLocationClient.lastLocation + .addOnSuccessListener { location -> + if (location != null) { + onSuccess(location.latitude, location.longitude) + } else { + onFailure() + } + } + .addOnFailureListener { + onFailure() + } + } +} + +private fun bitmapToBase64(bitmap: Bitmap): String { + val outputStream = ByteArrayOutputStream() + bitmap.compress(Bitmap.CompressFormat.JPEG, 80, outputStream) + return Base64.encodeToString(outputStream.toByteArray(), Base64.NO_WRAP) +} + +private fun kirimKeWebhook( + context: android.content.Context, + absensi: AbsensiEntity, + onSuccess: () -> Unit, + onFailure: (String) -> Unit +) { + thread { + try { + val url = URL(Constants.WEBHOOK_URL_ACTIVE) + val conn = url.openConnection() as HttpURLConnection + + conn.requestMethod = "POST" + conn.setRequestProperty("Content-Type", "application/json") + conn.doOutput = true + conn.connectTimeout = 10000 + conn.readTimeout = 10000 + + val json = JSONObject().apply { + put("npm", absensi.npm) + put("nama", absensi.nama) + put("latitude", absensi.latitude) + put("longitude", absensi.longitude) + put("jarak", absensi.jarak) + put("timestamp", absensi.timestamp) + put("tanggal", absensi.tanggal) + put("waktu", absensi.waktu) + put("foto_base64", absensi.fotoBase64) + } + + conn.outputStream.use { + it.write(json.toString().toByteArray()) + } + + val responseCode = conn.responseCode + conn.disconnect() + + if (responseCode == 200 || responseCode == 201) { + onSuccess() + } else { + onFailure("HTTP $responseCode") + } + + } catch (e: Exception) { + onFailure(e.message ?: "Unknown error") + } + } +} \ No newline at end of file diff --git a/app/src/main/java/id/ac/ubharajaya/sistemakademik/screens/DashboardScreen.kt b/app/src/main/java/id/ac/ubharajaya/sistemakademik/screens/DashboardScreen.kt new file mode 100644 index 0000000..8631fbe --- /dev/null +++ b/app/src/main/java/id/ac/ubharajaya/sistemakademik/screens/DashboardScreen.kt @@ -0,0 +1,305 @@ +package id.ac.ubharajaya.sistemakademik.screens + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import id.ac.ubharajaya.sistemakademik.data.UserPreferences +import id.ac.ubharajaya.sistemakademik.utils.Constants +import kotlinx.coroutines.launch + +/** + * FILE: DashboardScreen.kt + * LOKASI: screens/DashboardScreen.kt + * + * Deskripsi: + * Screen menu utama setelah login + * Menampilkan: + * - Header dengan nama user dan lokasi kampus + * - Menu navigasi: Jadwal Kuliah, Mulai Absensi, Riwayat Absensi + * - Map preview (optional) + */ + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DashboardScreen( + onNavigateToAbsensi: () -> Unit, + onNavigateToHistory: () -> Unit, + onNavigateToSchedule: () -> Unit, + onLogout: () -> Unit +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + val userPreferences = remember { UserPreferences(context) } + + // Get user data + val userData by userPreferences.userData.collectAsStateWithLifecycle( + initialValue = UserPreferences.UserData("", "", false) + ) + + // Warna tema + val primaryGreen = Color(0xFF2E7D32) + val lightGreen = Color(0xFF66BB6A) + + Scaffold( + topBar = { + TopAppBar( + title = { + Text( + "Menu Absensi", + fontWeight = FontWeight.Bold + ) + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = primaryGreen, + titleContentColor = Color.White + ), + actions = { + IconButton(onClick = { + scope.launch { + userPreferences.logout() + onLogout() + } + }) { + Icon( + imageVector = Icons.Default.Logout, + contentDescription = "Logout", + tint = Color.White + ) + } + } + ) + } + ) { paddingValues -> + + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .padding(16.dp) + ) { + + // Header Card - Info User + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = lightGreen.copy(alpha = 0.15f) + ), + shape = RoundedCornerShape(12.dp) + ) { + Column( + modifier = Modifier.padding(16.dp) + ) { + Text( + text = "Selamat Datang, Budi!", + fontSize = 20.sp, + fontWeight = FontWeight.Bold, + color = primaryGreen + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Default.Person, + contentDescription = null, + tint = Color.Gray, + modifier = Modifier.size(20.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = userData.nama.ifEmpty { Constants.NAMA }, + fontSize = 14.sp, + color = Color.Gray + ) + } + + Spacer(modifier = Modifier.height(4.dp)) + + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Default.Badge, + contentDescription = null, + tint = Color.Gray, + modifier = Modifier.size(20.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = "NPM: ${userData.npm.ifEmpty { Constants.NPM }}", + fontSize = 14.sp, + color = Color.Gray + ) + } + } + } + + Spacer(modifier = Modifier.height(24.dp)) + + // Menu Cards + // Menu 1: Jadwal Kuliah + MenuCard( + title = "Jadwal Kuliah", + icon = Icons.Default.Schedule, + backgroundColor = Color(0xFFE8F5E9), + iconColor = primaryGreen, + onClick = onNavigateToSchedule + ) + + Spacer(modifier = Modifier.height(12.dp)) + + // Menu 2: Mulai Absensi (Primary Action) + MenuCard( + title = "MULAI ABSENSI", + icon = Icons.Default.CameraAlt, + backgroundColor = primaryGreen, + iconColor = Color.White, + textColor = Color.White, + isPrimary = true, + onClick = onNavigateToAbsensi + ) + + Spacer(modifier = Modifier.height(12.dp)) + + // Menu 3: Riwayat Absensi + MenuCard( + title = "Riwayat Absensi", + icon = Icons.Default.History, + backgroundColor = Color(0xFFE8F5E9), + iconColor = primaryGreen, + onClick = onNavigateToHistory + ) + + Spacer(modifier = Modifier.height(24.dp)) + + // Info Lokasi Kampus + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = Color(0xFFFFF3E0) + ), + shape = RoundedCornerShape(12.dp) + ) { + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Default.LocationOn, + contentDescription = null, + tint = Color(0xFFFF6F00), + modifier = Modifier.size(40.dp) + ) + Spacer(modifier = Modifier.width(12.dp)) + Column { + Text( + text = "Lokasi: ${Constants.KAMPUS_NAMA}", + fontWeight = FontWeight.Bold, + fontSize = 14.sp + ) + Text( + text = Constants.KAMPUS_ALAMAT, + fontSize = 12.sp, + color = Color.Gray + ) + } + } + } + + Spacer(modifier = Modifier.weight(1f)) + + // Footer info + Text( + text = "📍 Pastikan lokasi GPS aktif untuk absensi", + fontSize = 12.sp, + color = Color.Gray, + modifier = Modifier.align(Alignment.CenterHorizontally) + ) + } + } +} + +/** + * Reusable Menu Card Component + */ +@Composable +fun MenuCard( + title: String, + icon: ImageVector, + backgroundColor: Color, + iconColor: Color, + textColor: Color = Color.Black, + isPrimary: Boolean = false, + onClick: () -> Unit +) { + Card( + modifier = Modifier + .fillMaxWidth() + .height(if (isPrimary) 70.dp else 65.dp) + .clickable(onClick = onClick), + colors = CardDefaults.cardColors( + containerColor = backgroundColor + ), + shape = RoundedCornerShape(12.dp), + elevation = CardDefaults.cardElevation( + defaultElevation = if (isPrimary) 4.dp else 2.dp + ) + ) { + Row( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 20.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = iconColor, + modifier = Modifier.size(if (isPrimary) 32.dp else 28.dp) + ) + + Spacer(modifier = Modifier.width(16.dp)) + + Text( + text = title, + fontSize = if (isPrimary) 18.sp else 16.sp, + fontWeight = if (isPrimary) FontWeight.Bold else FontWeight.Medium, + color = textColor + ) + } + } +} + +/** + * CATATAN IMPLEMENTASI: + * + * 1. Dashboard menampilkan 3 menu utama: + * - Jadwal Kuliah (untuk melihat jadwal - bisa dikembangkan) + * - Mulai Absensi (menu utama - navigasi ke AbsensiScreen) + * - Riwayat Absensi (melihat history absensi) + * + * 2. Header menampilkan nama dan NPM dari UserPreferences + * + * 3. Tombol logout ada di TopBar kanan atas + * + * 4. Untuk production: + * - Tambahkan fitur jadwal kuliah yang proper + * - Tambahkan notifikasi jika ada jadwal kuliah hari ini + * - Tambahkan statistik kehadiran + */ \ No newline at end of file diff --git a/app/src/main/java/id/ac/ubharajaya/sistemakademik/screens/HistoryScreen.kt b/app/src/main/java/id/ac/ubharajaya/sistemakademik/screens/HistoryScreen.kt new file mode 100644 index 0000000..e61f057 --- /dev/null +++ b/app/src/main/java/id/ac/ubharajaya/sistemakademik/screens/HistoryScreen.kt @@ -0,0 +1,455 @@ +package id.ac.ubharajaya.sistemakademik.screens + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import id.ac.ubharajaya.sistemakademik.data.AbsensiEntity +import id.ac.ubharajaya.sistemakademik.data.AppDatabase +import id.ac.ubharajaya.sistemakademik.utils.LocationValidator + +/** + * FILE: HistoryScreen.kt + * LOKASI: screens/HistoryScreen.kt + * + * Deskripsi: + * Screen untuk melihat riwayat absensi mahasiswa + * Menampilkan list semua absensi yang pernah dilakukan + */ + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun HistoryScreen( + onNavigateBack: () -> Unit +) { + val context = LocalContext.current + val database = remember { AppDatabase.getDatabase(context) } + + // Get data absensi dari database + val absensiList by database.absensiDao() + .getAllAbsensi() + .collectAsStateWithLifecycle(initialValue = emptyList()) + + // State untuk detail dialog + var selectedAbsensi by remember { mutableStateOf(null) } + var showDetailDialog by remember { mutableStateOf(false) } + + val primaryGreen = Color(0xFF2E7D32) + val lightGreen = Color(0xFF66BB6A) + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Riwayat Absensi") }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon(Icons.Default.ArrowBack, "Back") + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = primaryGreen, + titleContentColor = Color.White, + navigationIconContentColor = Color.White + ) + ) + } + ) { paddingValues -> + + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + ) { + + // Header Stats + Card( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + colors = CardDefaults.cardColors( + containerColor = lightGreen.copy(alpha = 0.15f) + ), + shape = RoundedCornerShape(12.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceAround + ) { + StatItem( + icon = Icons.Default.CheckCircle, + label = "Total Absensi", + value = "${absensiList.size}", + color = primaryGreen + ) + + Divider( + modifier = Modifier + .height(40.dp) + .width(1.dp) + ) + + StatItem( + icon = Icons.Default.CloudDone, + label = "Terkirim", + value = "${absensiList.count { it.statusKirim }}", + color = primaryGreen + ) + } + } + + // List Absensi + if (absensiList.isEmpty()) { + // Empty state + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally + ) { + Icon( + imageVector = Icons.Default.HistoryToggleOff, + contentDescription = null, + modifier = Modifier.size(80.dp), + tint = Color.Gray + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = "Belum ada riwayat absensi", + color = Color.Gray, + fontSize = 16.sp + ) + } + } + } else { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + items(absensiList) { absensi -> + AbsensiItem( + absensi = absensi, + onClick = { + selectedAbsensi = absensi + showDetailDialog = true + } + ) + } + } + } + } + } + + // Detail Dialog + if (showDetailDialog && selectedAbsensi != null) { + AbsensiDetailDialog( + absensi = selectedAbsensi!!, + onDismiss = { showDetailDialog = false } + ) + } +} + +@Composable +private fun StatItem( + icon: androidx.compose.ui.graphics.vector.ImageVector, + label: String, + value: String, + color: Color +) { + Column( + horizontalAlignment = Alignment.CenterHorizontally + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = color, + modifier = Modifier.size(32.dp) + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = value, + fontSize = 24.sp, + fontWeight = FontWeight.Bold, + color = color + ) + Text( + text = label, + fontSize = 12.sp, + color = Color.Gray + ) + } +} + +@Composable +private fun AbsensiItem( + absensi: AbsensiEntity, + onClick: () -> Unit +) { + val primaryGreen = Color(0xFF2E7D32) + val errorRed = Color(0xFFD32F2F) + + Card( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick), + shape = RoundedCornerShape(12.dp), + elevation = CardDefaults.cardElevation(2.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + + // Status Icon + Box( + modifier = Modifier + .size(48.dp) + .clip(CircleShape) + .background( + if (absensi.lokasiValid) + primaryGreen.copy(alpha = 0.2f) + else + errorRed.copy(alpha = 0.2f) + ), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = if (absensi.lokasiValid) + Icons.Default.CheckCircle + else + Icons.Default.Cancel, + contentDescription = null, + tint = if (absensi.lokasiValid) primaryGreen else errorRed, + modifier = Modifier.size(28.dp) + ) + } + + Spacer(modifier = Modifier.width(16.dp)) + + // Info + Column( + modifier = Modifier.weight(1f) + ) { + // Nama Mata Kuliah (BARU!) + Text( + text = absensi.namaMatakuliah, + fontSize = 15.sp, + fontWeight = FontWeight.Bold + ) + + Spacer(modifier = Modifier.height(2.dp)) + + // Hari & Tanggal + Text( + text = "${absensi.hari}, ${absensi.tanggal}", + fontSize = 13.sp, + color = Color.Gray, + fontWeight = FontWeight.Medium + ) + + Spacer(modifier = Modifier.height(4.dp)) + + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Default.AccessTime, + contentDescription = null, + modifier = Modifier.size(14.dp), + tint = Color.Gray + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = "${absensi.waktuMatakuliah} â€ĸ ${absensi.waktu}", + fontSize = 12.sp, + color = Color.Gray + ) + } + + Spacer(modifier = Modifier.height(4.dp)) + + // Status badge + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Surface( + shape = RoundedCornerShape(4.dp), + color = if (absensi.lokasiValid) + primaryGreen.copy(alpha = 0.15f) + else + errorRed.copy(alpha = 0.15f) + ) { + Text( + text = if (absensi.lokasiValid) "Valid" else "Invalid", + modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp), + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + color = if (absensi.lokasiValid) primaryGreen else errorRed + ) + } + + Spacer(modifier = Modifier.width(8.dp)) + + if (absensi.statusKirim) { + Icon( + imageVector = Icons.Default.CloudDone, + contentDescription = "Terkirim", + modifier = Modifier.size(16.dp), + tint = primaryGreen + ) + } else { + Icon( + imageVector = Icons.Default.CloudOff, + contentDescription = "Belum terkirim", + modifier = Modifier.size(16.dp), + tint = Color.Gray + ) + } + } + } + + Icon( + imageVector = Icons.Default.ChevronRight, + contentDescription = "Detail", + tint = Color.Gray + ) + } + } +} + +@Composable +private fun AbsensiDetailDialog( + absensi: AbsensiEntity, + onDismiss: () -> Unit +) { + val primaryGreen = Color(0xFF2E7D32) + + AlertDialog( + onDismissRequest = onDismiss, + icon = { + Icon( + imageVector = Icons.Default.Info, + contentDescription = null, + tint = primaryGreen + ) + }, + title = { + Text( + text = "Detail Absensi", + fontWeight = FontWeight.Bold + ) + }, + text = { + Column { + // Info Mata Kuliah (BARU!) + Text( + text = "📚 Mata Kuliah", + fontSize = 12.sp, + fontWeight = FontWeight.Bold, + color = primaryGreen + ) + Spacer(modifier = Modifier.height(4.dp)) + DetailRow("Kode", absensi.kodeMatakuliah) + DetailRow("Nama MK", absensi.namaMatakuliah) + DetailRow("Dosen", absensi.dosenMatakuliah) + DetailRow("Ruangan", absensi.ruanganMatakuliah) + DetailRow("Jam Kuliah", absensi.waktuMatakuliah) + + Spacer(modifier = Modifier.height(8.dp)) + Divider() + Spacer(modifier = Modifier.height(8.dp)) + + // Info Mahasiswa + Text( + text = "👤 Mahasiswa", + fontSize = 12.sp, + fontWeight = FontWeight.Bold, + color = primaryGreen + ) + Spacer(modifier = Modifier.height(4.dp)) + DetailRow("Nama", absensi.nama) + DetailRow("NPM", absensi.npm) + + Spacer(modifier = Modifier.height(8.dp)) + Divider() + Spacer(modifier = Modifier.height(8.dp)) + + // Info Waktu & Lokasi + Text( + text = "📍 Waktu & Lokasi", + fontSize = 12.sp, + fontWeight = FontWeight.Bold, + color = primaryGreen + ) + Spacer(modifier = Modifier.height(4.dp)) + DetailRow("Hari", absensi.hari) + DetailRow("Tanggal", absensi.tanggal) + DetailRow("Waktu Absen", absensi.waktu) + DetailRow("Jarak", LocationValidator.formatDistance(absensi.jarak)) + DetailRow("Status Lokasi", if (absensi.lokasiValid) "✓ Valid" else "✗ Invalid") + DetailRow("Status Kirim", if (absensi.statusKirim) "✓ Terkirim" else "⌛ Pending") + } + }, + confirmButton = { + TextButton(onClick = onDismiss) { + Text("TUTUP", color = primaryGreen) + } + } + ) +} + +@Composable +private fun DetailRow(label: String, value: String) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp) + ) { + Text( + text = "$label:", + fontSize = 13.sp, + color = Color.Gray, + modifier = Modifier.width(100.dp) + ) + Text( + text = value, + fontSize = 13.sp, + fontWeight = FontWeight.Medium + ) + } +} + +/** + * CATATAN IMPLEMENTASI: + * + * 1. Screen ini menampilkan list semua absensi dari Room Database + * + * 2. Menggunakan LazyColumn untuk efisiensi jika data banyak + * + * 3. Ada statistik singkat di atas (total & terkirim) + * + * 4. Click item untuk melihat detail lengkap + * + * 5. Data otomatis update karena menggunakan Flow dari Room + */ \ No newline at end of file diff --git a/app/src/main/java/id/ac/ubharajaya/sistemakademik/screens/LoginScreen.kt b/app/src/main/java/id/ac/ubharajaya/sistemakademik/screens/LoginScreen.kt new file mode 100644 index 0000000..f75dbe8 --- /dev/null +++ b/app/src/main/java/id/ac/ubharajaya/sistemakademik/screens/LoginScreen.kt @@ -0,0 +1,303 @@ +package id.ac.ubharajaya.sistemakademik.screens + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import id.ac.ubharajaya.sistemakademik.data.UserPreferences +import id.ac.ubharajaya.sistemakademik.utils.Constants +import kotlinx.coroutines.launch + +/** + * FILE: LoginScreen.kt + * LOKASI: screens/LoginScreen.kt + * + * Deskripsi: + * Screen untuk login mahasiswa + * Input: NPM dan Password + * Validasi sederhana: NPM harus sesuai dengan Constants.NPM + */ + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun LoginScreen( + onLoginSuccess: () -> Unit +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + val userPreferences = remember { UserPreferences(context) } + + // State untuk form input + var npm by remember { mutableStateOf("") } + var password by remember { mutableStateOf("") } + var passwordVisible by remember { mutableStateOf(false) } + var errorMessage by remember { mutableStateOf("") } + var isLoading by remember { mutableStateOf(false) } + + // Warna tema hijau akademik + val primaryGreen = Color(0xFF2E7D32) + val lightGreen = Color(0xFF66BB6A) + + // Check jika sudah login sebelumnya + val userData by userPreferences.userData.collectAsStateWithLifecycle( + initialValue = UserPreferences.UserData("", "", false) + ) + + LaunchedEffect(userData.isLoggedIn) { + if (userData.isLoggedIn) { + onLoginSuccess() + } + } + + // UI + Surface( + modifier = Modifier.fillMaxSize(), + color = Color.White + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + + // Logo/Icon Graduation Cap + Icon( + imageVector = Icons.Default.School, + contentDescription = "Logo", + modifier = Modifier.size(80.dp), + tint = primaryGreen + ) + + Spacer(modifier = Modifier.height(16.dp)) + + // Title + Text( + text = "Absensi Akademik", + fontSize = 28.sp, + fontWeight = FontWeight.Bold, + color = primaryGreen + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = Constants.KAMPUS_NAMA, + fontSize = 14.sp, + color = Color.Gray + ) + + Spacer(modifier = Modifier.height(40.dp)) + + // Input NPM + OutlinedTextField( + value = npm, + onValueChange = { + npm = it + errorMessage = "" + }, + label = { Text("NPM") }, + leadingIcon = { + Icon( + imageVector = Icons.Default.Badge, + contentDescription = null + ) + }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Number + ), + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = primaryGreen, + focusedLabelColor = primaryGreen + ) + ) + + Spacer(modifier = Modifier.height(16.dp)) + + // Input Password + OutlinedTextField( + value = password, + onValueChange = { + password = it + errorMessage = "" + }, + label = { Text("Password") }, + leadingIcon = { + Icon( + imageVector = Icons.Default.Lock, + contentDescription = null + ) + }, + trailingIcon = { + IconButton(onClick = { passwordVisible = !passwordVisible }) { + Icon( + imageVector = if (passwordVisible) + Icons.Default.Visibility + else + Icons.Default.VisibilityOff, + contentDescription = if (passwordVisible) + "Hide password" + else + "Show password" + ) + } + }, + visualTransformation = if (passwordVisible) + VisualTransformation.None + else + PasswordVisualTransformation(), + modifier = Modifier.fillMaxWidth(), + singleLine = true, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Password + ), + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = primaryGreen, + focusedLabelColor = primaryGreen + ) + ) + + // Error Message + if (errorMessage.isNotEmpty()) { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = errorMessage, + color = MaterialTheme.colorScheme.error, + fontSize = 14.sp + ) + } + + Spacer(modifier = Modifier.height(24.dp)) + + // Button Login + Button( + onClick = { + // Validasi input + when { + npm.isEmpty() || password.isEmpty() -> { + errorMessage = "NPM dan Password harus diisi" + } + npm != Constants.NPM -> { + errorMessage = "NPM tidak valid" + } + password.length < 4 -> { + errorMessage = "Password minimal 4 karakter" + } + else -> { + // Login berhasil + isLoading = true + scope.launch { + userPreferences.saveUserData( + npm = Constants.NPM, + nama = Constants.NAMA + ) + isLoading = false + onLoginSuccess() + } + } + } + }, + modifier = Modifier + .fillMaxWidth() + .height(50.dp), + colors = ButtonDefaults.buttonColors( + containerColor = primaryGreen + ), + shape = RoundedCornerShape(8.dp), + enabled = !isLoading + ) { + if (isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + color = Color.White + ) + } else { + Text( + text = "LOGIN", + fontSize = 16.sp, + fontWeight = FontWeight.Bold + ) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + // Forgot Password (Optional - bisa dihapus) + TextButton(onClick = { + // TODO: Implement forgot password + }) { + Text( + text = "Forgot Password?", + color = primaryGreen + ) + } + + Spacer(modifier = Modifier.height(40.dp)) + + // Info untuk testing + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = lightGreen.copy(alpha = 0.1f) + ) + ) { + Column( + modifier = Modifier.padding(16.dp) + ) { + Text( + text = "â„šī¸ Info Login", + fontWeight = FontWeight.Bold, + fontSize = 14.sp + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "NPM: ${Constants.NPM}", + fontSize = 12.sp, + color = Color.Gray + ) + Text( + text = "Password: Bebas (minimal 4 karakter)", + fontSize = 12.sp, + color = Color.Gray + ) + } + } + } + } +} + +/** + * CATATAN IMPLEMENTASI: + * + * 1. Screen ini melakukan validasi sederhana: + * - NPM harus sesuai dengan Constants.NPM + * - Password minimal 4 karakter (tidak ada validasi server) + * + * 2. Data login disimpan di DataStore Preferences + * + * 3. Jika sudah pernah login, langsung masuk ke Dashboard + * + * 4. Untuk production, sebaiknya: + * - Tambahkan enkripsi password + * - Validasi ke server/database + * - Implementasi forgot password yang proper + */ \ No newline at end of file diff --git a/app/src/main/java/id/ac/ubharajaya/sistemakademik/screens/ScheduleScreen.kt b/app/src/main/java/id/ac/ubharajaya/sistemakademik/screens/ScheduleScreen.kt new file mode 100644 index 0000000..9d494ec --- /dev/null +++ b/app/src/main/java/id/ac/ubharajaya/sistemakademik/screens/ScheduleScreen.kt @@ -0,0 +1,292 @@ +package id.ac.ubharajaya.sistemakademik.screens + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +/** + * FILE: ScheduleScreen.kt + * LOKASI: screens/ScheduleScreen.kt + * + * Deskripsi: + * Screen untuk menampilkan jadwal kuliah (placeholder) + * Bisa dikembangkan lebih lanjut sesuai kebutuhan + */ + +// Data class untuk jadwal +data class JadwalKuliah( + val hari: String, + val waktu: String, + val mataKuliah: String, + val dosen: String, + val ruangan: String +) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ScheduleScreen( + onNavigateBack: () -> Unit +) { + val primaryGreen = Color(0xFF2E7D32) + val lightGreen = Color(0xFF66BB6A) + + // Data jadwal real dari tabel (bisa diganti dengan data real) + val jadwalList = remember { + listOf( + JadwalKuliah( + hari = "Senin", + waktu = "10:45 - 13:15", + mataKuliah = "Interaksi Manusia dan Komputer", + dosen = "Dian Hartanti, S.Kom., MMSI", + ruangan = "UBJ-BKS || R. Said Soekanto || SS-409" + ), + JadwalKuliah( + hari = "Senin", + waktu = "13:30 - 16:00", + mataKuliah = "Kecerdasan Buatan", + dosen = "Hendarman Lubis, S.Kom., M.Kom.", + ruangan = "UBJ-BKS || R. Said Soekanto || SS-419" + ), + JadwalKuliah( + hari = "Selasa", + waktu = "10:45 - 13:15", + mataKuliah = "Pembelajaran Mesin", + dosen = "Mukhlis, S.Kom, MT", + ruangan = "UBJ-BKS || R. Said Soekanto || SS-408" + ), + JadwalKuliah( + hari = "Rabu", + waktu = "08:00 - 10:30", + mataKuliah = "Keamanan Siber", + dosen = "Asep Ramdhani Mahbub, S.Kom., M.Kom.", + ruangan = "UBJ-BKS || R. Said Soekanto || SS-412" + ), + JadwalKuliah( + hari = "Kamis", + waktu = "08:00 - 09:40", + mataKuliah = "Manajemen Sekuriti", + dosen = "Ratna Salkiawati, S.T., M.Kom", + ruangan = "UBJ-BKS || Grha Tanoto || W-105" + ), + JadwalKuliah( + hari = "Kamis", + waktu = "13:30 - 16:00", + mataKuliah = "Pemrograman Perangkat Bergerak", + dosen = "Arif Rifai Dwiyanto, ST., MTI", + ruangan = "UBJ-BKS || Grha Tanoto || W-104" + ), + JadwalKuliah( + hari = "Jumat", + waktu = "08:00 - 10:30", + mataKuliah = "Manajemen Proyek Perangkat Lunak", + dosen = "M. Hadi Prayitno, S.Kom, M.Kom.", + ruangan = "UBJ-BKS || R. Said Soekanto || SS-412" + ) + ) + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Jadwal Kuliah") }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon(Icons.Default.ArrowBack, "Back") + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = primaryGreen, + titleContentColor = Color.White, + navigationIconContentColor = Color.White + ) + ) + } + ) { paddingValues -> + + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + ) { + + // Info Card + Card( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + colors = CardDefaults.cardColors( + containerColor = lightGreen.copy(alpha = 0.15f) + ), + shape = RoundedCornerShape(12.dp) + ) { + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Default.Info, + contentDescription = null, + tint = primaryGreen, + modifier = Modifier.size(24.dp) + ) + Spacer(modifier = Modifier.width(12.dp)) + Column { + Text( + text = "Semester Ganjil 2025/2026", + fontWeight = FontWeight.Bold, + fontSize = 14.sp + ) + Text( + text = "Total ${jadwalList.size} Mata Kuliah", + fontSize = 12.sp, + color = Color.Gray + ) + } + } + } + + // List Jadwal + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + items(jadwalList) { jadwal -> + JadwalCard(jadwal) + } + } + } + } +} + +@Composable +private fun JadwalCard(jadwal: JadwalKuliah) { + val primaryGreen = Color(0xFF2E7D32) + + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + elevation = CardDefaults.cardElevation(2.dp) + ) { + Column( + modifier = Modifier.padding(16.dp) + ) { + // Header - Hari & Waktu + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = Icons.Default.CalendarToday, + contentDescription = null, + tint = primaryGreen, + modifier = Modifier.size(18.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = jadwal.hari, + fontWeight = FontWeight.Bold, + fontSize = 14.sp, + color = primaryGreen + ) + } + + Surface( + shape = RoundedCornerShape(4.dp), + color = primaryGreen.copy(alpha = 0.15f) + ) { + Text( + text = jadwal.waktu, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + fontSize = 11.sp, + fontWeight = FontWeight.Medium, + color = primaryGreen + ) + } + } + + Spacer(modifier = Modifier.height(12.dp)) + Divider() + Spacer(modifier = Modifier.height(12.dp)) + + // Mata Kuliah + Text( + text = jadwal.mataKuliah, + fontSize = 16.sp, + fontWeight = FontWeight.Bold + ) + + Spacer(modifier = Modifier.height(8.dp)) + + // Dosen + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Default.Person, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = Color.Gray + ) + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = jadwal.dosen, + fontSize = 13.sp, + color = Color.Gray + ) + } + + Spacer(modifier = Modifier.height(4.dp)) + + // Ruangan + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Default.LocationOn, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = Color.Gray + ) + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = jadwal.ruangan, + fontSize = 13.sp, + color = Color.Gray + ) + } + } + } +} + +/** + * CATATAN IMPLEMENTASI: + * + * 1. Ini adalah screen placeholder untuk jadwal kuliah + * + * 2. Menggunakan dummy data static + * + * 3. Untuk production, bisa dikembangkan dengan: + * - Ambil data dari API/Database + * - Filtering berdasarkan hari + * - Notifikasi jadwal kuliah hari ini + * - Link ke Google Calendar + * + * 4. Bisa juga ditambahkan fitur: + * - Absensi langsung dari jadwal + * - Informasi tugas/quiz + * - Materi kuliah + */ \ No newline at end of file diff --git a/app/src/main/java/id/ac/ubharajaya/sistemakademik/screens/SelectMataKuliahScreen.kt b/app/src/main/java/id/ac/ubharajaya/sistemakademik/screens/SelectMataKuliahScreen.kt new file mode 100644 index 0000000..71bff03 --- /dev/null +++ b/app/src/main/java/id/ac/ubharajaya/sistemakademik/screens/SelectMataKuliahScreen.kt @@ -0,0 +1,449 @@ +package id.ac.ubharajaya.sistemakademik.screens + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import id.ac.ubharajaya.sistemakademik.data.AppDatabase +import id.ac.ubharajaya.sistemakademik.data.MataKuliah +import kotlinx.coroutines.launch +import java.text.SimpleDateFormat +import java.util.* + +/** + * FILE: SelectMatakuliahScreen.kt + * LOKASI: screens/SelectMatakuliahScreen.kt + * + * Deskripsi: + * Screen untuk memilih mata kuliah yang akan diabsen + * Menampilkan jadwal hari ini dan filter MK yang sudah diabsen + */ + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SelectMatakuliahScreen( + onNavigateBack: () -> Unit, + onMatakuliahSelected: (MataKuliah) -> Unit +) { + val context = LocalContext.current + val database = remember { AppDatabase.getDatabase(context) } + val scope = rememberCoroutineScope() + + val primaryGreen = Color(0xFF2E7D32) + val lightGreen = Color(0xFF66BB6A) + + // Get hari ini + val hariIni = remember { + SimpleDateFormat("EEEE", Locale("id", "ID")).format(Date()) + } + + val tanggalHariIni = remember { + SimpleDateFormat("dd MMM yyyy", Locale("id", "ID")).format(Date()) + } + + // Jadwal lengkap (hardcoded - bisa diganti dengan dari database) + val semuaJadwal = remember { + listOf( + MataKuliah( + kode = "INFO-3527", + nama = "Pemrograman Mobile", + dosen = "Dr. Ahmad Wijaya", + ruangan = "Lab Komputer 1", + waktu = "08:00 - 10:00", + hari = "Senin", + sks = 3 + ), + MataKuliah( + kode = "INFO-3528", + nama = "Interaksi Manusia dan Komputer", + dosen = "Dian Hartanti, S.Kom., MMSI", + ruangan = "UBJ-BKS || R. Said Soekanto || SS-409", + waktu = "10:45 - 13:15", + hari = "Senin", + sks = 3 + ), + MataKuliah( + kode = "INFO-3530", + nama = "Kecerdasan Buatan", + dosen = "Hendarman Lubis, S.Kom., M.Kom.", + ruangan = "UBJ-BKS || R. Said Soekanto || SS-419", + waktu = "13:30 - 16:00", + hari = "Senin", + sks = 3 + ), + MataKuliah( + kode = "INFO-3531", + nama = "Pembelajaran Mesin", + dosen = "Mukhlis, S.Kom, MT", + ruangan = "UBJ-BKS || R. Said Soekanto || SS-408", + waktu = "10:45 - 13:15", + hari = "Selasa", + sks = 3 + ), + MataKuliah( + kode = "INFO-3532", + nama = "Keamanan Siber", + dosen = "Asep Ramdhani Mahbub, S.Kom., M.Kom.", + ruangan = "UBJ-BKS || R. Said Soekanto || SS-412", + waktu = "08:00 - 10:30", + hari = "Rabu", + sks = 3 + ), + MataKuliah( + kode = "MKDU-2007", + nama = "Manajemen Sekuriti", + dosen = "Ratna Salkiawati, S.T., M.Kom", + ruangan = "UBJ-BKS || Grha Tanoto || W-105", + waktu = "08:00 - 09:40", + hari = "Kamis", + sks = 2 + ), + MataKuliah( + kode = "INFO-3529", + nama = "Pemrograman Perangkat Bergerak", + dosen = "Arif Rifai Dwiyanto, ST., MTI", + ruangan = "UBJ-BKS || Grha Tanoto || W-104", + waktu = "13:30 - 16:00", + hari = "Kamis", + sks = 3 + ), + MataKuliah( + kode = "INFO-3533", + nama = "Manajemen Proyek Perangkat Lunak", + dosen = "M. Hadi Prayitno, S.Kom, M.Kom.", + ruangan = "UBJ-BKS || R. Said Soekanto || SS-412", + waktu = "08:00 - 10:30", + hari = "Jumat", + sks = 3 + ) + ) + } + + // Filter jadwal hari ini + val jadwalHariIni = semuaJadwal.filter { it.hari == hariIni } + + // Get absensi hari ini dari database + val absensiHariIni by database.absensiDao() + .getAbsensiHariIni(tanggalHariIni) + .collectAsStateWithLifecycle(initialValue = emptyList()) + + // Kode MK yang sudah diabsen hari ini + val kodeMkSudahAbsen = absensiHariIni.map { it.kodeMatakuliah } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Pilih Mata Kuliah") }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon(Icons.Default.ArrowBack, "Back") + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = primaryGreen, + titleContentColor = Color.White, + navigationIconContentColor = Color.White + ) + ) + } + ) { paddingValues -> + + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + ) { + + // Header Info + Card( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + colors = CardDefaults.cardColors( + containerColor = lightGreen.copy(alpha = 0.15f) + ), + shape = RoundedCornerShape(12.dp) + ) { + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Default.CalendarToday, + contentDescription = null, + tint = primaryGreen, + modifier = Modifier.size(32.dp) + ) + Spacer(modifier = Modifier.width(12.dp)) + Column { + Text( + text = "Jadwal Kuliah Hari Ini", + fontWeight = FontWeight.Bold, + fontSize = 16.sp + ) + Text( + text = "$hariIni, $tanggalHariIni", + fontSize = 13.sp, + color = Color.Gray + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "${jadwalHariIni.size} mata kuliah tersedia", + fontSize = 12.sp, + color = primaryGreen, + fontWeight = FontWeight.Medium + ) + } + } + } + + // List Mata Kuliah + if (jadwalHariIni.isEmpty()) { + // Tidak ada jadwal hari ini + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally + ) { + Icon( + imageVector = Icons.Default.EventBusy, + contentDescription = null, + modifier = Modifier.size(80.dp), + tint = Color.Gray + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = "Tidak ada jadwal kuliah hari ini", + fontSize = 16.sp, + color = Color.Gray, + fontWeight = FontWeight.Medium + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "Nikmati hari liburmu! 🎉", + fontSize = 14.sp, + color = Color.Gray + ) + } + } + } else { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + items(jadwalHariIni) { matakuliah -> + val sudahAbsen = kodeMkSudahAbsen.contains(matakuliah.kode) + + MataKuliahCard( + mataKuliah = matakuliah, + sudahAbsen = sudahAbsen, + onClick = { + if (!sudahAbsen) { + onMatakuliahSelected(matakuliah) + } + } + ) + } + } + } + } + } +} + +@Composable +private fun MataKuliahCard( + mataKuliah: MataKuliah, + sudahAbsen: Boolean, + onClick: () -> Unit +) { + val primaryGreen = Color(0xFF2E7D32) + val lightGreen = Color(0xFF66BB6A) + + Card( + modifier = Modifier + .fillMaxWidth() + .clickable(enabled = !sudahAbsen, onClick = onClick), + shape = RoundedCornerShape(12.dp), + elevation = CardDefaults.cardElevation( + defaultElevation = if (sudahAbsen) 1.dp else 3.dp + ), + colors = CardDefaults.cardColors( + containerColor = if (sudahAbsen) + Color(0xFFF5F5F5) + else + Color.White + ) + ) { + Box { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + // Header - Waktu & Status + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Surface( + shape = RoundedCornerShape(6.dp), + color = if (sudahAbsen) + Color.Gray.copy(alpha = 0.2f) + else + primaryGreen.copy(alpha = 0.15f) + ) { + Text( + text = mataKuliah.waktu, + modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp), + fontSize = 12.sp, + fontWeight = FontWeight.Medium, + color = if (sudahAbsen) Color.Gray else primaryGreen + ) + } + + if (sudahAbsen) { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Default.CheckCircle, + contentDescription = "Sudah Absen", + tint = primaryGreen, + modifier = Modifier.size(18.dp) + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = "Sudah Absen", + fontSize = 11.sp, + color = primaryGreen, + fontWeight = FontWeight.Bold + ) + } + } + } + + Spacer(modifier = Modifier.height(12.dp)) + + // Nama Mata Kuliah + Text( + text = mataKuliah.nama, + fontSize = 16.sp, + fontWeight = FontWeight.Bold, + color = if (sudahAbsen) Color.Gray else Color.Black + ) + + Spacer(modifier = Modifier.height(4.dp)) + + // Kode MK + Text( + text = mataKuliah.kode, + fontSize = 12.sp, + color = Color.Gray, + fontWeight = FontWeight.Medium + ) + + Spacer(modifier = Modifier.height(12.dp)) + Divider() + Spacer(modifier = Modifier.height(12.dp)) + + // Dosen + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Default.Person, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = if (sudahAbsen) Color.Gray else Color(0xFF666666) + ) + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = mataKuliah.dosen, + fontSize = 13.sp, + color = if (sudahAbsen) Color.Gray else Color(0xFF666666) + ) + } + + Spacer(modifier = Modifier.height(6.dp)) + + // Ruangan + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Default.LocationOn, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = if (sudahAbsen) Color.Gray else Color(0xFF666666) + ) + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = mataKuliah.ruangan, + fontSize = 13.sp, + color = if (sudahAbsen) Color.Gray else Color(0xFF666666), + maxLines = 1 + ) + } + + if (!sudahAbsen) { + Spacer(modifier = Modifier.height(12.dp)) + + // Button Absen Sekarang + Button( + onClick = onClick, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.buttonColors( + containerColor = primaryGreen + ), + shape = RoundedCornerShape(8.dp) + ) { + Icon( + imageVector = Icons.Default.CameraAlt, + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text("ABSEN SEKARANG") + } + } + } + + // Overlay untuk card yang sudah diabsen + if (sudahAbsen) { + Box( + modifier = Modifier + .matchParentSize() + .background(Color.White.copy(alpha = 0.3f)) + ) + } + } + } +} + +/** + * CATATAN IMPLEMENTASI: + * + * 1. Screen ini menampilkan jadwal kuliah HARI INI saja + * 2. Filter otomatis MK yang sudah diabsen (disable & tampil checklist) + * 3. Click card → navigasi ke AbsensiScreen dengan data MK + * 4. Data jadwal hardcoded (bisa diganti dengan dari API/Database) + * 5. Validasi sudah absen berdasarkan tanggal & kode MK + */ \ No newline at end of file diff --git a/app/src/main/java/id/ac/ubharajaya/sistemakademik/screens/SuccsessScreen.kt b/app/src/main/java/id/ac/ubharajaya/sistemakademik/screens/SuccsessScreen.kt new file mode 100644 index 0000000..625e47e --- /dev/null +++ b/app/src/main/java/id/ac/ubharajaya/sistemakademik/screens/SuccsessScreen.kt @@ -0,0 +1,272 @@ +package id.ac.ubharajaya.sistemakademik.screens + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import kotlinx.coroutines.delay +import java.text.SimpleDateFormat +import java.util.* + +/** + * FILE: SuccessScreen.kt + * LOKASI: screens/SuccessScreen.kt + * + * Deskripsi: + * Screen konfirmasi absensi berhasil + * Menampilkan: + * - Icon checklist + * - Pesan sukses + * - Waktu absensi + * - Tombol navigasi + */ + +@Composable +fun SuccessScreen( + onNavigateToDashboard: () -> Unit, + onNavigateToHistory: () -> Unit +) { + val primaryGreen = Color(0xFF2E7D32) + val lightGreen = Color(0xFF66BB6A) + + // Get waktu saat ini + val currentTime = remember { + val calendar = Calendar.getInstance() + val timeFormat = SimpleDateFormat("HH:mm", Locale("id", "ID")) + val dateFormat = SimpleDateFormat("EEEE, dd MMMM yyyy", Locale("id", "ID")) + + Pair( + timeFormat.format(calendar.time), + dateFormat.format(calendar.time) + ) + } + + // Auto navigate after 3 seconds (optional) + var countdown by remember { mutableStateOf(5) } + + LaunchedEffect(Unit) { + while (countdown > 0) { + delay(1000) + countdown-- + } + // Bisa uncomment ini kalau mau auto navigate + // onNavigateToDashboard() + } + + Surface( + modifier = Modifier.fillMaxSize(), + color = Color.White + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + + // Success Icon + Card( + modifier = Modifier.size(120.dp), + shape = RoundedCornerShape(60.dp), + colors = CardDefaults.cardColors( + containerColor = lightGreen.copy(alpha = 0.2f) + ) + ) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Default.CheckCircle, + contentDescription = "Success", + modifier = Modifier.size(80.dp), + tint = primaryGreen + ) + } + } + + Spacer(modifier = Modifier.height(32.dp)) + + // Title + Text( + text = "Absensi Berhasil!", + fontSize = 28.sp, + fontWeight = FontWeight.Bold, + color = primaryGreen, + textAlign = TextAlign.Center + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = "Kehadiran Anda telah tercatat", + fontSize = 16.sp, + color = Color.Gray, + textAlign = TextAlign.Center + ) + + Spacer(modifier = Modifier.height(32.dp)) + + // Info Card + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = lightGreen.copy(alpha = 0.1f) + ), + shape = RoundedCornerShape(12.dp) + ) { + Column( + modifier = Modifier.padding(20.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + // Checklist items + CheckItem("✓ Lokasi Tervalidasi", primaryGreen) + Spacer(modifier = Modifier.height(8.dp)) + CheckItem("✓ Foto Terekam", primaryGreen) + + Spacer(modifier = Modifier.height(16.dp)) + Divider() + Spacer(modifier = Modifier.height(16.dp)) + + // Waktu + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Default.AccessTime, + contentDescription = null, + tint = Color.Gray, + modifier = Modifier.size(20.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Column { + Text( + text = "Waktu: ${currentTime.first} WIB", + fontWeight = FontWeight.Bold, + fontSize = 14.sp + ) + Text( + text = currentTime.second, + fontSize = 12.sp, + color = Color.Gray + ) + } + } + + Spacer(modifier = Modifier.height(12.dp)) + + // Status + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Default.CloudDone, + contentDescription = null, + tint = primaryGreen, + modifier = Modifier.size(20.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = "Berhasil tercatat!", + fontWeight = FontWeight.Bold, + fontSize = 14.sp, + color = primaryGreen + ) + } + } + } + + Spacer(modifier = Modifier.height(32.dp)) + + // Button Lihat Riwayat + Button( + onClick = onNavigateToHistory, + modifier = Modifier + .fillMaxWidth() + .height(50.dp), + colors = ButtonDefaults.buttonColors( + containerColor = primaryGreen + ), + shape = RoundedCornerShape(8.dp) + ) { + Icon( + imageVector = Icons.Default.History, + contentDescription = null + ) + Spacer(modifier = Modifier.width(8.dp)) + Text("LIHAT RIWAYAT") + } + + Spacer(modifier = Modifier.height(12.dp)) + + // Button Kembali ke Dashboard + OutlinedButton( + onClick = onNavigateToDashboard, + modifier = Modifier + .fillMaxWidth() + .height(50.dp), + colors = ButtonDefaults.outlinedButtonColors( + contentColor = primaryGreen + ), + shape = RoundedCornerShape(8.dp) + ) { + Icon( + imageVector = Icons.Default.Home, + contentDescription = null + ) + Spacer(modifier = Modifier.width(8.dp)) + Text("KEMBALI KE MENU") + } + + Spacer(modifier = Modifier.height(24.dp)) + + // Countdown info (optional) + if (countdown > 0) { + Text( + text = "Otomatis kembali dalam $countdown detik", + fontSize = 12.sp, + color = Color.Gray + ) + } + } + } +} + +@Composable +private fun CheckItem(text: String, color: Color) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = text, + fontSize = 14.sp, + color = color, + fontWeight = FontWeight.Medium + ) + } +} + +/** + * CATATAN IMPLEMENTASI: + * + * 1. Screen ini menampilkan konfirmasi bahwa absensi berhasil + * + * 2. Menampilkan waktu absensi yang tepat + * + * 3. Ada 2 tombol navigasi: + * - Lihat Riwayat: ke HistoryScreen + * - Kembali ke Menu: ke Dashboard + * + * 4. Optional: Auto navigate setelah beberapa detik + */ \ No newline at end of file diff --git a/app/src/main/java/id/ac/ubharajaya/sistemakademik/utils/Constants.kt b/app/src/main/java/id/ac/ubharajaya/sistemakademik/utils/Constants.kt new file mode 100644 index 0000000..4195708 --- /dev/null +++ b/app/src/main/java/id/ac/ubharajaya/sistemakademik/utils/Constants.kt @@ -0,0 +1,66 @@ +package id.ac.ubharajaya.sistemakademik.utils + +/** + * FILE: Constants.kt + * LOKASI: utils/Constants.kt + * + * Deskripsi: + * File ini berisi konstanta-konstanta yang digunakan di seluruh aplikasi + * seperti koordinat kampus, radius validasi, URL webhook, dll. + */ + +object Constants { + + // ==================== DATA MAHASISWA ==================== + const val NPM = "202310715176" + const val NAMA = "HAGA DALPINTO GINTING" + + // ==================== KOORDINAT KAMPUS ==================== + // Universitas Bhayangkara Jakarta Raya - Bekasi Utara + // Jl. Raya Perjuangan No.81, Bekasi Utara + const val KAMPUS_LATITUDE = -6.224190375334469 + const val KAMPUS_LONGITUDE = 107.00928773168418 + const val KAMPUS_NAMA = "Universitas Bhayangkara Jakarta Raya" + const val KAMPUS_ALAMAT = "Jl. Raya Perjuangan No.81, Bekasi Utara" + + // ==================== VALIDASI LOKASI ==================== + // Radius dalam METER untuk validasi absensi + // Mahasiswa harus berada dalam radius ini dari koordinat kampus + const val RADIUS_METER = 100.0 // 100 meter + + // Untuk testing, bisa diubah jadi lebih besar: + // const val RADIUS_METER = 5000.0 // 5 km untuk testing + + // ==================== WEBHOOK URLs ==================== + // URL untuk mengirim data absensi ke server N8N + const val WEBHOOK_URL_PRODUCTION = "https://n8n.lab.ubharajaya.ac.id/webhook/23c6993d-1792-48fb-ad1c-ffc78a3e6254" + const val WEBHOOK_URL_TEST = "https://n8n.lab.ubharajaya.ac.id/webhook-test/23c6993d-1792-48fb-ad1c-ffc78a3e6254" + + // Pilih URL yang aktif (ubah ke TEST untuk testing) + const val WEBHOOK_URL_ACTIVE = WEBHOOK_URL_PRODUCTION + + // ==================== PREFERENCES KEYS ==================== + // Key untuk menyimpan data di SharedPreferences/DataStore + const val PREF_NPM = "pref_npm" + const val PREF_NAMA = "pref_nama" + const val PREF_IS_LOGGED_IN = "pref_is_logged_in" + + // ==================== DATABASE ==================== + const val DATABASE_NAME = "absensi_database" + const val DATABASE_VERSION = 1 + + // ==================== MESSAGES ==================== + const val MSG_LOKASI_VALID = "✓ Lokasi Valid - Dalam Area Kampus" + const val MSG_LOKASI_INVALID = "✗ Lokasi Tidak Valid - Di Luar Area Kampus" + const val MSG_MENUNGGU_LOKASI = "⌛ Menunggu Data Lokasi..." + const val MSG_FOTO_BELUM_DIAMBIL = "Silakan ambil foto terlebih dahulu" + const val MSG_ABSENSI_BERHASIL = "Absensi Berhasil Dicatat!" + const val MSG_ABSENSI_GAGAL = "Absensi Gagal!" + + // ==================== PERMISSIONS ==================== + val REQUIRED_PERMISSIONS = arrayOf( + android.Manifest.permission.ACCESS_FINE_LOCATION, + android.Manifest.permission.ACCESS_COARSE_LOCATION, + android.Manifest.permission.CAMERA + ) +} \ No newline at end of file diff --git a/app/src/main/java/id/ac/ubharajaya/sistemakademik/utils/LocationValidator.kt b/app/src/main/java/id/ac/ubharajaya/sistemakademik/utils/LocationValidator.kt new file mode 100644 index 0000000..4a3b8db --- /dev/null +++ b/app/src/main/java/id/ac/ubharajaya/sistemakademik/utils/LocationValidator.kt @@ -0,0 +1,112 @@ +package id.ac.ubharajaya.sistemakademik.utils + +import kotlin.math.* + +/** + * FILE: LocationValidator.kt + * LOKASI: utils/LocationValidator.kt + * + * Deskripsi: + * Class untuk menghitung jarak antara 2 koordinat menggunakan Haversine Formula + * dan memvalidasi apakah lokasi mahasiswa berada dalam radius kampus + */ + +object LocationValidator { + + // Radius bumi dalam meter + private const val EARTH_RADIUS_METER = 6371000.0 + + /** + * Menghitung jarak antara 2 koordinat menggunakan Haversine Formula + * + * Formula ini digunakan untuk menghitung jarak terpendek antara 2 titik + * di permukaan bumi (great-circle distance) + * + * @param lat1 Latitude titik 1 (dalam derajat) + * @param lon1 Longitude titik 1 (dalam derajat) + * @param lat2 Latitude titik 2 (dalam derajat) + * @param lon2 Longitude titik 2 (dalam derajat) + * @return Jarak dalam METER + */ + fun calculateDistance( + lat1: Double, + lon1: Double, + lat2: Double, + lon2: Double + ): Double { + // Konversi derajat ke radian + val lat1Rad = Math.toRadians(lat1) + val lon1Rad = Math.toRadians(lon1) + val lat2Rad = Math.toRadians(lat2) + val lon2Rad = Math.toRadians(lon2) + + // Selisih koordinat + val dLat = lat2Rad - lat1Rad + val dLon = lon2Rad - lon1Rad + + // Haversine Formula + val a = sin(dLat / 2).pow(2) + + cos(lat1Rad) * cos(lat2Rad) * + sin(dLon / 2).pow(2) + + val c = 2 * atan2(sqrt(a), sqrt(1 - a)) + + // Jarak dalam meter + return EARTH_RADIUS_METER * c + } + + /** + * Validasi apakah lokasi mahasiswa berada dalam radius kampus + * + * @param userLat Latitude mahasiswa + * @param userLon Longitude mahasiswa + * @param campusLat Latitude kampus + * @param campusLon Longitude kampus + * @param radiusMeter Radius validasi dalam meter + * @return Pair(isValid, distance) + * - isValid: true jika dalam radius, false jika di luar + * - distance: jarak dalam meter + */ + fun isLocationValid( + userLat: Double, + userLon: Double, + campusLat: Double = Constants.KAMPUS_LATITUDE, + campusLon: Double = Constants.KAMPUS_LONGITUDE, + radiusMeter: Double = Constants.RADIUS_METER + ): Pair { + + val distance = calculateDistance(userLat, userLon, campusLat, campusLon) + val isValid = distance <= radiusMeter + + return Pair(isValid, distance) + } + + /** + * Format jarak ke string yang mudah dibaca + * + * @param distanceMeter Jarak dalam meter + * @return String format "X.XX m" atau "X.XX km" + */ + fun formatDistance(distanceMeter: Double): String { + return if (distanceMeter < 1000) { + "%.0f m".format(distanceMeter) + } else { + "%.2f km".format(distanceMeter / 1000) + } + } + + /** + * Get status message berdasarkan validasi lokasi + * + * @param isValid Apakah lokasi valid + * @param distance Jarak dari kampus + * @return Pesan status + */ + fun getStatusMessage(isValid: Boolean, distance: Double): String { + return if (isValid) { + "${Constants.MSG_LOKASI_VALID}\nJarak: ${formatDistance(distance)}" + } else { + "${Constants.MSG_LOKASI_INVALID}\nJarak: ${formatDistance(distance)}" + } + } +} \ No newline at end of file