ADDING SCREENS SETIAP FITUR DAN MATKUL SAAT ABSENSI
This commit is contained in:
parent
926d3e0a14
commit
ce05e1a617
13
.idea/deviceManager.xml
generated
Normal file
13
.idea/deviceManager.xml
generated
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="DeviceTable">
|
||||||
|
<option name="columnSorters">
|
||||||
|
<list>
|
||||||
|
<ColumnSorterState>
|
||||||
|
<option name="column" value="Name" />
|
||||||
|
<option name="order" value="ASCENDING" />
|
||||||
|
</ColumnSorterState>
|
||||||
|
</list>
|
||||||
|
</option>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
8
.idea/markdown.xml
generated
Normal file
8
.idea/markdown.xml
generated
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="MarkdownSettings">
|
||||||
|
<option name="previewPanelProviderInfo">
|
||||||
|
<ProviderInfo name="Compose (experimental)" className="com.intellij.markdown.compose.preview.ComposePanelProvider" />
|
||||||
|
</option>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
@ -8,6 +8,8 @@ Aplikasi ini dirancang untuk meningkatkan **validitas kehadiran mahasiswa**, den
|
|||||||
2. Melakukan **pengambilan foto (selfie) secara langsung saat absensi**
|
2. Melakukan **pengambilan foto (selfie) secara langsung saat absensi**
|
||||||
|
|
||||||
---
|
---
|
||||||
|
Nama : HAGA DALPINTO GINTING
|
||||||
|
NPM : 202310715176
|
||||||
|
|
||||||
## 🎯 Tujuan Proyek
|
## 🎯 Tujuan Proyek
|
||||||
- Mengimplementasikan **Location-Based Service (LBS)** pada aplikasi mobile
|
- Mengimplementasikan **Location-Based Service (LBS)** pada aplikasi mobile
|
||||||
|
|||||||
@ -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 {
|
plugins {
|
||||||
alias(libs.plugins.android.application)
|
alias(libs.plugins.android.application)
|
||||||
alias(libs.plugins.kotlin.android)
|
alias(libs.plugins.kotlin.android)
|
||||||
alias(libs.plugins.kotlin.compose)
|
alias(libs.plugins.kotlin.compose)
|
||||||
|
id("kotlin-kapt")
|
||||||
|
id("kotlin-parcelize")
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "id.ac.ubharajaya.sistemakademik"
|
namespace = "id.ac.ubharajaya.sistemakademik"
|
||||||
compileSdk {
|
compileSdk = 35
|
||||||
version = release(36)
|
|
||||||
}
|
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "id.ac.ubharajaya.sistemakademik"
|
applicationId = "id.ac.ubharajaya.sistemakademik"
|
||||||
minSdk = 28
|
minSdk = 26
|
||||||
targetSdk = 36
|
targetSdk = 35
|
||||||
versionCode = 1
|
versionCode = 1
|
||||||
versionName = "1.0"
|
versionName = "1.0"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
|
||||||
|
vectorDrawables {
|
||||||
|
useSupportLibrary = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
@ -29,36 +44,114 @@ android {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
compileOptions {
|
compileOptions {
|
||||||
sourceCompatibility = JavaVersion.VERSION_11
|
sourceCompatibility = JavaVersion.VERSION_11
|
||||||
targetCompatibility = JavaVersion.VERSION_11
|
targetCompatibility = JavaVersion.VERSION_11
|
||||||
}
|
}
|
||||||
|
|
||||||
kotlinOptions {
|
kotlinOptions {
|
||||||
jvmTarget = "11"
|
jvmTarget = "11"
|
||||||
}
|
}
|
||||||
|
|
||||||
buildFeatures {
|
buildFeatures {
|
||||||
compose = true
|
compose = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
composeOptions {
|
||||||
|
kotlinCompilerExtensionVersion = "1.5.8"
|
||||||
|
}
|
||||||
|
|
||||||
|
packaging {
|
||||||
|
resources {
|
||||||
|
excludes += "/META-INF/{AL2.0,LGPL2.1}"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
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)
|
// ==================== CORE ANDROID ====================
|
||||||
androidTestImplementation(libs.androidx.junit)
|
implementation("androidx.core:core-ktx:1.12.0")
|
||||||
androidTestImplementation(libs.androidx.espresso.core)
|
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0")
|
||||||
androidTestImplementation(platform(libs.androidx.compose.bom))
|
implementation("androidx.activity:activity-compose:1.8.2")
|
||||||
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
|
|
||||||
debugImplementation(libs.androidx.compose.ui.tooling)
|
// ==================== COMPOSE ====================
|
||||||
debugImplementation(libs.androidx.compose.ui.test.manifest)
|
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
|
||||||
|
*/
|
||||||
@ -1,14 +1,52 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!--
|
||||||
|
FILE: AndroidManifest.xml
|
||||||
|
LOKASI: app/src/main/AndroidManifest.xml
|
||||||
|
|
||||||
|
CARA IMPLEMENTASI:
|
||||||
|
1. Buka file AndroidManifest.xml di Android Studio
|
||||||
|
2. REPLACE semua isinya dengan kode ini
|
||||||
|
3. Save file
|
||||||
|
-->
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:tools="http://schemas.android.com/tools">
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
|
||||||
<uses-permission android:name="android.permission.INTERNET"/>
|
<!-- ==================== PERMISSIONS ==================== -->
|
||||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
|
|
||||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
|
<!-- Permission untuk akses lokasi GPS -->
|
||||||
<uses-permission android:name="android.permission.CAMERA"/>
|
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||||
|
|
||||||
|
<!-- Permission untuk akses kamera -->
|
||||||
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
|
|
||||||
|
<!-- Permission untuk akses internet (kirim data ke webhook) -->
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
|
||||||
|
<!-- Permission untuk akses network state -->
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
|
|
||||||
|
<!-- Optional: Untuk menyimpan foto (jika dibutuhkan) -->
|
||||||
|
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||||
|
android:maxSdkVersion="28"
|
||||||
|
tools:ignore="ScopedStorage" />
|
||||||
|
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
|
||||||
|
android:maxSdkVersion="32" />
|
||||||
|
|
||||||
|
<!-- Feature camera (optional, tapi bagus untuk filter di Play Store) -->
|
||||||
<uses-feature
|
<uses-feature
|
||||||
android:name="android.hardware.camera"
|
android:name="android.hardware.camera"
|
||||||
android:required="false" />
|
android:required="false" />
|
||||||
|
<uses-feature
|
||||||
|
android:name="android.hardware.camera.autofocus"
|
||||||
|
android:required="false" />
|
||||||
|
|
||||||
|
<!-- Feature location -->
|
||||||
|
<uses-feature
|
||||||
|
android:name="android.hardware.location.gps"
|
||||||
|
android:required="true" />
|
||||||
|
|
||||||
|
<!-- ==================== APPLICATION ==================== -->
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
@ -18,18 +56,63 @@
|
|||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:roundIcon="@mipmap/ic_launcher_round"
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@style/Theme.SistemAkademik">
|
android:theme="@style/Theme.SistemAkademik"
|
||||||
|
android:usesCleartextTraffic="true"
|
||||||
|
tools:targetApi="31">
|
||||||
|
|
||||||
|
<!-- Main Activity -->
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:label="@string/app_name"
|
android:theme="@style/Theme.SistemAkademik"
|
||||||
android:theme="@style/Theme.SistemAkademik">
|
android:screenOrientation="portrait"
|
||||||
|
android:configChanges="orientation|screenSize">
|
||||||
|
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
|
<!-- Google Play Services Location -->
|
||||||
|
<meta-data
|
||||||
|
android:name="com.google.android.gms.version"
|
||||||
|
android:value="@integer/google_play_services_version" />
|
||||||
|
|
||||||
</application>
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
PENJELASAN PERMISSIONS:
|
||||||
|
|
||||||
|
1. ACCESS_FINE_LOCATION & ACCESS_COARSE_LOCATION
|
||||||
|
- Untuk mendapatkan koordinat GPS
|
||||||
|
- FINE = Akurasi tinggi (GPS)
|
||||||
|
- COARSE = Akurasi rendah (Network)
|
||||||
|
|
||||||
|
2. CAMERA
|
||||||
|
- Untuk mengambil foto selfie
|
||||||
|
|
||||||
|
3. INTERNET & ACCESS_NETWORK_STATE
|
||||||
|
- Untuk kirim data ke webhook
|
||||||
|
- Cek status koneksi
|
||||||
|
|
||||||
|
4. WRITE/READ_EXTERNAL_STORAGE
|
||||||
|
- Optional, untuk simpan foto ke storage
|
||||||
|
- Hanya untuk Android < 10
|
||||||
|
|
||||||
|
5. usesCleartextTraffic="true"
|
||||||
|
- Mengizinkan HTTP (bukan HTTPS)
|
||||||
|
- Diperlukan jika webhook menggunakan HTTP
|
||||||
|
- Untuk production, sebaiknya gunakan HTTPS
|
||||||
|
|
||||||
|
6. screenOrientation="portrait"
|
||||||
|
- Mengunci orientasi portrait
|
||||||
|
- Bisa dihapus jika mau support landscape
|
||||||
|
|
||||||
|
CATATAN PENTING:
|
||||||
|
- Semua permission di atas sudah di-request secara runtime di code
|
||||||
|
- User harus approve permission saat pertama kali buka aplikasi
|
||||||
|
- Jika permission ditolak, fitur terkait tidak akan berfungsi
|
||||||
|
-->
|
||||||
@ -1,274 +1,207 @@
|
|||||||
package id.ac.ubharajaya.sistemakademik
|
package id.ac.ubharajaya.sistemakademik
|
||||||
|
|
||||||
import android.Manifest
|
|
||||||
import android.app.Activity
|
|
||||||
import android.content.Intent
|
|
||||||
import android.content.pm.PackageManager
|
|
||||||
import android.graphics.Bitmap
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.provider.MediaStore
|
|
||||||
import android.util.Base64
|
|
||||||
import android.widget.Toast
|
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.navigation.NavType
|
||||||
import androidx.compose.material3.*
|
import androidx.navigation.compose.NavHost
|
||||||
import androidx.compose.runtime.*
|
import androidx.navigation.compose.composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.navigation.compose.rememberNavController
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.navigation.navArgument
|
||||||
import androidx.compose.ui.unit.dp
|
import com.google.gson.Gson
|
||||||
import androidx.core.content.ContextCompat
|
import id.ac.ubharajaya.sistemakademik.data.MataKuliah
|
||||||
import com.google.android.gms.location.LocationServices
|
import id.ac.ubharajaya.sistemakademik.screens.*
|
||||||
import id.ac.ubharajaya.sistemakademik.ui.theme.SistemAkademikTheme
|
import id.ac.ubharajaya.sistemakademik.ui.theme.SistemAkademikTheme
|
||||||
import org.json.JSONObject
|
import java.net.URLDecoder
|
||||||
import java.io.ByteArrayOutputStream
|
import java.net.URLEncoder
|
||||||
import java.net.HttpURLConnection
|
import java.nio.charset.StandardCharsets
|
||||||
import java.net.URL
|
|
||||||
import kotlin.concurrent.thread
|
|
||||||
|
|
||||||
/* ================= UTIL ================= */
|
/**
|
||||||
|
* FILE: MainActivity.kt (UPDATED dengan SelectMatakuliah)
|
||||||
fun bitmapToBase64(bitmap: Bitmap): String {
|
*
|
||||||
val outputStream = ByteArrayOutputStream()
|
* Deskripsi:
|
||||||
bitmap.compress(Bitmap.CompressFormat.JPEG, 80, outputStream)
|
* Main Activity dengan Navigation Compose
|
||||||
return Base64.encodeToString(outputStream.toByteArray(), Base64.NO_WRAP)
|
* FITUR BARU: Pilih mata kuliah sebelum absen
|
||||||
}
|
*
|
||||||
|
* Flow Baru:
|
||||||
fun kirimKeN8n(
|
* Dashboard → SelectMatakuliah → Absensi → Success
|
||||||
context: ComponentActivity,
|
*/
|
||||||
latitude: Double,
|
|
||||||
longitude: Double,
|
|
||||||
foto: Bitmap
|
|
||||||
) {
|
|
||||||
thread {
|
|
||||||
try {
|
|
||||||
val url = URL("https://n8n.lab.ubharajaya.ac.id/webhook/23c6993d-1792-48fb-ad1c-ffc78a3e6254")
|
|
||||||
// test URL val url = URL("https://n8n.lab.ubharajaya.ac.id/webhook-test/23c6993d-1792-48fb-ad1c-ffc78a3e6254")
|
|
||||||
val conn = url.openConnection() as HttpURLConnection
|
|
||||||
|
|
||||||
conn.requestMethod = "POST"
|
|
||||||
conn.setRequestProperty("Content-Type", "application/json")
|
|
||||||
conn.doOutput = true
|
|
||||||
|
|
||||||
val json = JSONObject().apply {
|
|
||||||
put("npm", "12345")
|
|
||||||
put("nama","Arif R D")
|
|
||||||
put("latitude", latitude)
|
|
||||||
put("longitude", longitude)
|
|
||||||
put("timestamp", System.currentTimeMillis())
|
|
||||||
put("foto_base64", bitmapToBase64(foto))
|
|
||||||
}
|
|
||||||
|
|
||||||
conn.outputStream.use {
|
|
||||||
it.write(json.toString().toByteArray())
|
|
||||||
}
|
|
||||||
|
|
||||||
val responseCode = conn.responseCode
|
|
||||||
|
|
||||||
context.runOnUiThread {
|
|
||||||
Toast.makeText(
|
|
||||||
context,
|
|
||||||
if (responseCode == 200)
|
|
||||||
"Absensi diterima server"
|
|
||||||
else
|
|
||||||
"Absensi ditolak server",
|
|
||||||
Toast.LENGTH_SHORT
|
|
||||||
).show()
|
|
||||||
}
|
|
||||||
|
|
||||||
conn.disconnect()
|
|
||||||
|
|
||||||
} catch (_: Exception) {
|
|
||||||
context.runOnUiThread {
|
|
||||||
Toast.makeText(
|
|
||||||
context,
|
|
||||||
"Gagal kirim ke server",
|
|
||||||
Toast.LENGTH_SHORT
|
|
||||||
).show()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ================= ACTIVITY ================= */
|
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
|
|
||||||
setContent {
|
setContent {
|
||||||
SistemAkademikTheme {
|
SistemAkademikTheme {
|
||||||
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
|
AppNavigation()
|
||||||
AbsensiScreen(
|
|
||||||
modifier = Modifier.padding(innerPadding),
|
|
||||||
activity = this
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ================= 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"
|
||||||
|
|
||||||
|
fun createAbsensiRoute(mataKuliah: MataKuliah): String {
|
||||||
|
val json = Gson().toJson(mataKuliah)
|
||||||
|
val encodedJson = URLEncoder.encode(json, StandardCharsets.UTF_8.toString())
|
||||||
|
return "absensi/$encodedJson"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* App Navigation
|
||||||
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun AbsensiScreen(
|
fun AppNavigation() {
|
||||||
modifier: Modifier = Modifier,
|
val navController = rememberNavController()
|
||||||
activity: ComponentActivity
|
val gson = Gson()
|
||||||
) {
|
|
||||||
val context = LocalContext.current
|
|
||||||
|
|
||||||
var lokasi by remember { mutableStateOf("Koordinat: -") }
|
NavHost(
|
||||||
var latitude by remember { mutableStateOf<Double?>(null) }
|
navController = navController,
|
||||||
var longitude by remember { mutableStateOf<Double?>(null) }
|
startDestination = Routes.LOGIN
|
||||||
var foto by remember { mutableStateOf<Bitmap?>(null) }
|
|
||||||
|
|
||||||
val fusedLocationClient =
|
|
||||||
LocationServices.getFusedLocationProviderClient(context)
|
|
||||||
|
|
||||||
/* ===== Permission Lokasi ===== */
|
|
||||||
|
|
||||||
val locationPermissionLauncher =
|
|
||||||
rememberLauncherForActivityResult(
|
|
||||||
ActivityResultContracts.RequestPermission()
|
|
||||||
) { granted ->
|
|
||||||
if (granted) {
|
|
||||||
|
|
||||||
if (
|
|
||||||
ContextCompat.checkSelfPermission(
|
|
||||||
context,
|
|
||||||
Manifest.permission.ACCESS_FINE_LOCATION
|
|
||||||
) == PackageManager.PERMISSION_GRANTED
|
|
||||||
) {
|
) {
|
||||||
|
|
||||||
fusedLocationClient.lastLocation
|
// Login Screen
|
||||||
.addOnSuccessListener { location ->
|
composable(Routes.LOGIN) {
|
||||||
if (location != null) {
|
LoginScreen(
|
||||||
latitude = location.latitude
|
onLoginSuccess = {
|
||||||
longitude = location.longitude
|
navController.navigate(Routes.DASHBOARD) {
|
||||||
lokasi =
|
popUpTo(Routes.LOGIN) { inclusive = true }
|
||||||
"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
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===== UI ===== */
|
// Dashboard Screen
|
||||||
|
composable(Routes.DASHBOARD) {
|
||||||
Column(
|
DashboardScreen(
|
||||||
modifier = modifier
|
onNavigateToAbsensi = {
|
||||||
.fillMaxSize()
|
// Navigasi ke SelectMatakuliah dulu (BARU!)
|
||||||
.padding(24.dp),
|
navController.navigate(Routes.SELECT_MATAKULIAH)
|
||||||
verticalArrangement = Arrangement.Center
|
|
||||||
) {
|
|
||||||
|
|
||||||
Text(
|
|
||||||
text = "Absensi Akademik",
|
|
||||||
style = MaterialTheme.typography.titleLarge
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
|
||||||
|
|
||||||
Text(text = lokasi)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
|
||||||
|
|
||||||
Button(
|
|
||||||
onClick = {
|
|
||||||
cameraPermissionLauncher.launch(
|
|
||||||
Manifest.permission.CAMERA
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
modifier = Modifier.fillMaxWidth()
|
onNavigateToHistory = {
|
||||||
) {
|
navController.navigate(Routes.HISTORY)
|
||||||
Text("Ambil Foto")
|
},
|
||||||
|
onNavigateToSchedule = {
|
||||||
|
navController.navigate(Routes.SCHEDULE)
|
||||||
|
},
|
||||||
|
onLogout = {
|
||||||
|
navController.navigate(Routes.LOGIN) {
|
||||||
|
popUpTo(0) { inclusive = true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
// Select Mata Kuliah Screen (BARU!)
|
||||||
|
composable(Routes.SELECT_MATAKULIAH) {
|
||||||
Button(
|
SelectMatakuliahScreen(
|
||||||
onClick = {
|
onNavigateBack = {
|
||||||
if (latitude != null && longitude != null && foto != null) {
|
navController.popBackStack()
|
||||||
kirimKeN8n(
|
},
|
||||||
activity,
|
onMatakuliahSelected = { mataKuliah ->
|
||||||
latitude!!,
|
// Navigasi ke Absensi dengan data MK
|
||||||
longitude!!,
|
val route = Routes.createAbsensiRoute(mataKuliah)
|
||||||
foto!!
|
navController.navigate(route)
|
||||||
|
}
|
||||||
)
|
)
|
||||||
} else {
|
}
|
||||||
Toast.makeText(
|
|
||||||
context,
|
// Absensi Screen (UPDATED - terima parameter MK)
|
||||||
"Lokasi atau foto belum lengkap",
|
composable(
|
||||||
Toast.LENGTH_SHORT
|
route = Routes.ABSENSI,
|
||||||
).show()
|
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)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
modifier = Modifier.fillMaxWidth()
|
onNavigateBack = {
|
||||||
) {
|
navController.popBackStack()
|
||||||
Text("Kirim Absensi")
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
*/
|
||||||
@ -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<List<AbsensiEntity>>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get data absensi berdasarkan NPM
|
||||||
|
*/
|
||||||
|
@Query("SELECT * FROM absensi WHERE npm = :npm ORDER BY timestamp DESC")
|
||||||
|
fun getAbsensiByNpm(npm: String): Flow<List<AbsensiEntity>>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<List<AbsensiEntity>>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get absensi yang belum terkirim ke server
|
||||||
|
*/
|
||||||
|
@Query("SELECT * FROM absensi WHERE statusKirim = 0 ORDER BY timestamp ASC")
|
||||||
|
suspend fun getAbsensiPending(): List<AbsensiEntity>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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()
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
)
|
||||||
@ -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())
|
||||||
|
*/
|
||||||
@ -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
|
||||||
|
*/
|
||||||
@ -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<Preferences> 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<String> = context.dataStore.data
|
||||||
|
.map { preferences ->
|
||||||
|
preferences[PreferenceKeys.NPM] ?: ""
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get nama user
|
||||||
|
*/
|
||||||
|
val nama: Flow<String> = context.dataStore.data
|
||||||
|
.map { preferences ->
|
||||||
|
preferences[PreferenceKeys.NAMA] ?: ""
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get status login
|
||||||
|
*/
|
||||||
|
val isLoggedIn: Flow<Boolean> = 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<UserData> = 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()
|
||||||
|
* }
|
||||||
|
*/
|
||||||
@ -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<Double?>(null) }
|
||||||
|
var longitude by remember { mutableStateOf<Double?>(null) }
|
||||||
|
var isLocationValid by remember { mutableStateOf(false) }
|
||||||
|
var distance by remember { mutableStateOf(0.0) }
|
||||||
|
var foto by remember { mutableStateOf<Bitmap?>(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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
*/
|
||||||
@ -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<AbsensiEntity?>(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
|
||||||
|
*/
|
||||||
@ -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
|
||||||
|
*/
|
||||||
@ -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
|
||||||
|
*/
|
||||||
@ -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
|
||||||
|
*/
|
||||||
@ -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
|
||||||
|
*/
|
||||||
@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -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<Boolean, Double> {
|
||||||
|
|
||||||
|
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)}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user