diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c8e48c8..15d73db 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -6,9 +6,7 @@ plugins { android { namespace = "id.ac.ubharajaya.panicbutton" - compileSdk { - version = release(36) - } + compileSdk = 36 defaultConfig { applicationId = "id.ac.ubharajaya.panicbutton" @@ -42,11 +40,15 @@ android { } dependencies { -// OkHttp untuk HTTP request + // OkHttp untuk HTTP request implementation("com.squareup.okhttp3:okhttp:4.11.0") - // Coroutines untuk manajemen thread, penting untuk OkHttp + // Coroutines untuk manajemen thread implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.7.3") + + // Google Play Services Location - UNTUK GPS & GEOFENCING + implementation("com.google.android.gms:play-services-location:21.0.1") // Core AndroidX dan Compose BOM implementation(libs.androidx.core.ktx) @@ -57,9 +59,12 @@ dependencies { // Compose UI & Material3 implementation(libs.androidx.compose.ui) implementation(libs.androidx.compose.ui.graphics) - implementation(libs.androidx.compose.material3) // Diambil dari BOM + implementation(libs.androidx.compose.material3) implementation(libs.androidx.compose.foundation) + // Material Icons Extended - untuk icon LocationOn + implementation("androidx.compose.material:material-icons-extended:1.5.4") + // Tools dan Testing implementation(libs.androidx.compose.ui.tooling.preview) testImplementation(libs.junit) @@ -69,5 +74,4 @@ dependencies { androidTestImplementation(libs.androidx.compose.ui.test.junit4) debugImplementation(libs.androidx.compose.ui.tooling) debugImplementation(libs.androidx.compose.ui.test.manifest) - } \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 3d1a108..36e2abf 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,8 +2,13 @@ + + + + + Unit) { + if (!hasLocationPermission()) { + onResult(null) + return + } + + try { + fusedLocationClient.lastLocation.addOnSuccessListener { location: Location? -> + if (location != null) { + val locationData = processLocation(location) + onResult(locationData) + } else { + // Jika lastLocation null, request location update sekali + requestSingleLocationUpdate(onResult) + } + }.addOnFailureListener { + onResult(null) + } + } catch (e: SecurityException) { + onResult(null) + } + } + + /** + * Request location update sekali jika lastLocation tidak tersedia + */ + private fun requestSingleLocationUpdate(onResult: (LocationData?) -> Unit) { + if (!hasLocationPermission()) { + onResult(null) + return + } + + val locationRequest = LocationRequest.Builder( + Priority.PRIORITY_HIGH_ACCURACY, + 5000L // 5 detik + ).apply { + setMaxUpdates(1) // Hanya 1 kali update + }.build() + + val locationCallback = object : LocationCallback() { + override fun onLocationResult(result: LocationResult) { + val location = result.lastLocation + if (location != null) { + val locationData = processLocation(location) + onResult(locationData) + } else { + onResult(null) + } + fusedLocationClient.removeLocationUpdates(this) + } + } + + try { + fusedLocationClient.requestLocationUpdates( + locationRequest, + locationCallback, + Looper.getMainLooper() + ) + } catch (e: SecurityException) { + onResult(null) + } + } + + /** + * Real-time location monitoring menggunakan Flow + * Untuk digunakan saat app aktif + */ + fun getLocationUpdates(): Flow = callbackFlow { + if (!hasLocationPermission()) { + trySend(null) + close() + return@callbackFlow + } + + val locationRequest = LocationRequest.Builder( + Priority.PRIORITY_HIGH_ACCURACY, + 10000L // Update setiap 10 detik + ).apply { + setMinUpdateIntervalMillis(5000L) // Minimal 5 detik + setMaxUpdateDelayMillis(15000L) // Maksimal delay 15 detik + }.build() + + val locationCallback = object : LocationCallback() { + override fun onLocationResult(result: LocationResult) { + result.lastLocation?.let { location -> + val locationData = processLocation(location) + trySend(locationData) + } + } + } + + try { + fusedLocationClient.requestLocationUpdates( + locationRequest, + locationCallback, + Looper.getMainLooper() + ) + } catch (e: SecurityException) { + trySend(null) + } + + awaitClose { + fusedLocationClient.removeLocationUpdates(locationCallback) + } + } + + /** + * Process location dan cek apakah di dalam kampus + */ + private fun processLocation(location: Location): LocationData { + val distance = calculateDistance( + location.latitude, + location.longitude, + CAMPUS_LAT, + CAMPUS_LNG + ) + + val isInsideCampus = distance <= CAMPUS_RADIUS_METERS + + return LocationData( + latitude = location.latitude, + longitude = location.longitude, + isInsideCampus = isInsideCampus, + distanceFromCampus = distance + ) + } + + /** + * Hitung jarak antara 2 koordinat menggunakan Haversine formula + * Return: jarak dalam meter + */ + private fun calculateDistance( + lat1: Double, + lon1: Double, + lat2: Double, + lon2: Double + ): Double { + val earthRadius = 6371000.0 // Radius bumi dalam meter + + val dLat = Math.toRadians(lat2 - lat1) + val dLon = Math.toRadians(lon2 - lon1) + + val a = sin(dLat / 2) * sin(dLat / 2) + + cos(Math.toRadians(lat1)) * cos(Math.toRadians(lat2)) * + sin(dLon / 2) * sin(dLon / 2) + + val c = 2 * atan2(sqrt(a), sqrt(1 - a)) + + return earthRadius * c + } + + /** + * Stop location updates + */ + fun stopLocationUpdates(callback: LocationCallback) { + fusedLocationClient.removeLocationUpdates(callback) + } +} \ No newline at end of file diff --git a/app/src/main/java/id/ac/ubharajaya/panicbutton/MainActivity.kt b/app/src/main/java/id/ac/ubharajaya/panicbutton/MainActivity.kt index 32b8495..a162d0b 100644 --- a/app/src/main/java/id/ac/ubharajaya/panicbutton/MainActivity.kt +++ b/app/src/main/java/id/ac/ubharajaya/panicbutton/MainActivity.kt @@ -1,18 +1,24 @@ package id.ac.ubharajaya.panicbutton +import android.Manifest import android.content.Context import android.content.Intent import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent -import androidx.activity.compose.BackHandler // ⬅️ Tambahkan impor ini +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.icons.filled.LocationOn import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment @@ -20,28 +26,42 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalFocusManager -import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import kotlinx.coroutines.launch import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.RequestBody -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Settings -import androidx.compose.material.icons.filled.ArrowBack -import androidx.compose.ui.text.TextStyle // ---------------------------------------------------------------------- // MAIN ACTIVITY CLASS // ---------------------------------------------------------------------- class MainActivity : ComponentActivity() { + private lateinit var locationManager: LocationManager + + // Permission launcher + private val requestPermissionLauncher = registerForActivityResult( + ActivityResultContracts.RequestPermission() + ) { isGranted -> + // Permission result akan di-handle di composable + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + locationManager = LocationManager(this) + setContent { - MyApp() + MyApp( + locationManager = locationManager, + onRequestPermission = { + requestPermissionLauncher.launch(Manifest.permission.ACCESS_FINE_LOCATION) + } + ) } } } @@ -57,7 +77,10 @@ sealed class ScreenState { @OptIn(ExperimentalMaterial3Api::class) @Composable -fun MyApp() { +fun MyApp( + locationManager: LocationManager, + onRequestPermission: () -> Unit +) { var currentScreen by remember { mutableStateOf(ScreenState.Main) } val context = LocalContext.current @@ -65,8 +88,6 @@ fun MyApp() { topBar = { TopAppBar( title = { Text("Panic Button") }, - // Logika TopBar hanya muncul jika di MainScreen, - // SettingsScreen memiliki TopBar sendiri actions = { if (currentScreen is ScreenState.Main) { IconButton(onClick = { @@ -82,6 +103,8 @@ fun MyApp() { when (currentScreen) { is ScreenState.Main -> MainScreen( paddingValues = paddingValues, + locationManager = locationManager, + onRequestPermission = onRequestPermission, onSendNotification = { conditions, report, onResult -> sendNotification(context, conditions, report, onResult) }, @@ -91,14 +114,12 @@ fun MyApp() { } ) is ScreenState.Settings -> SettingsScreen( - // Melewatkan fungsi untuk kembali onBack = { currentScreen = ScreenState.Main } ) } } } - // ---------------------------------------------------------------------- // MAIN SCREEN // ---------------------------------------------------------------------- @@ -106,6 +127,8 @@ fun MyApp() { @Composable fun MainScreen( paddingValues: PaddingValues, + locationManager: LocationManager, + onRequestPermission: () -> Unit, onSendNotification: (String, String, (String) -> Unit) -> Unit, onNavigateEvakuasi: () -> Unit ) { @@ -114,8 +137,35 @@ fun MainScreen( var selectedConditions by remember { mutableStateOf(mutableSetOf()) } var additionalNotes by remember { mutableStateOf(TextFieldValue("")) } + // Location states + var locationData by remember { mutableStateOf(null) } + var locationStatus by remember { mutableStateOf("Mengecek lokasi...") } + val scope = rememberCoroutineScope() + val scrollState = rememberScrollState() + // Monitor location real-time + LaunchedEffect(Unit) { + if (locationManager.hasLocationPermission()) { + scope.launch { + locationManager.getLocationUpdates().collect { data -> + locationData = data + locationStatus = if (data != null) { + if (data.isInsideCampus) { + "πŸ“ Anda berada di DALAM kampus Ubhara Jaya" + } else { + "πŸ“ Anda berada di LUAR kampus Ubhara Jaya (${String.format("%.0f", data.distanceFromCampus)}m dari kampus)" + } + } else { + "❌ Tidak dapat mendapatkan lokasi" + } + } + } + } else { + locationStatus = "⚠️ Izin lokasi belum diberikan" + } + } + Column( modifier = Modifier .fillMaxSize() @@ -125,6 +175,75 @@ fun MainScreen( verticalArrangement = Arrangement.Top, horizontalAlignment = Alignment.Start ) { + // LOCATION STATUS CARD + Card( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 16.dp), + colors = CardDefaults.cardColors( + containerColor = if (locationData?.isInsideCampus == true) + Color(0xFF4CAF50).copy(alpha = 0.1f) + else + Color(0xFFFF9800).copy(alpha = 0.1f) + ), + shape = RoundedCornerShape(8.dp) + ) { + Column( + modifier = Modifier.padding(12.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(bottom = 4.dp) + ) { + Icon( + imageVector = Icons.Filled.LocationOn, + contentDescription = "Location", + tint = if (locationData?.isInsideCampus == true) + Color(0xFF4CAF50) + else + Color(0xFFFF9800), + modifier = Modifier.size(20.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = "Status Lokasi", + fontSize = 14.sp, + fontWeight = FontWeight.Bold + ) + } + + Text( + text = locationStatus, + fontSize = 13.sp, + color = Color.DarkGray + ) + + if (locationData != null) { + Text( + text = "Koordinat: ${String.format("%.6f", locationData!!.latitude)}, ${String.format("%.6f", locationData!!.longitude)}", + fontSize = 11.sp, + color = Color.Gray, + modifier = Modifier.padding(top = 4.dp) + ) + } + + // Tombol request permission jika belum diberikan + if (!locationManager.hasLocationPermission()) { + Spacer(modifier = Modifier.height(8.dp)) + Button( + onClick = onRequestPermission, + colors = ButtonDefaults.buttonColors( + containerColor = Color(0xFF2196F3) + ), + modifier = Modifier.fillMaxWidth() + ) { + Text("Berikan Izin Lokasi", fontSize = 12.sp) + } + } + } + } + + // EMERGENCY CONDITIONS SECTION Text( text = "Terjadi Kondisi Darurat", fontSize = 20.sp, @@ -133,9 +252,9 @@ fun MainScreen( ) listOf( - "\uD83D\uDD25 Kebakaran", "β›ˆ\uFE0F Banjir", "\uD83C\uDF0A Tsunami", "\uD83C\uDF0B Gunung Meletus", - "\uD83C\uDF0F Gempa Bumi", "\uD83D\uDC7F Huru hara", "\uD83D\uDC0D Binatang Buas", - "☒\uFE0F Radiasi Nuklir", "☣\uFE0F Biohazard" + "πŸ”₯ Kebakaran", "β›ˆοΈ Banjir", "🌊 Tsunami", "πŸŒ‹ Gunung Meletus", + "🌏 Gempa Bumi", "πŸ‘Ώ Huru hara", "🐍 Binatang Buas", + "☒️ Radiasi Nuklir", "☣️ Biohazard" ).forEach { condition -> Row( modifier = Modifier @@ -183,19 +302,24 @@ fun MainScreen( onClick = { val notes = additionalNotes.text val conditions = selectedConditions.joinToString(", ") - val report = "Kondisi: $conditions\nCatatan: $notes" + val locationInfo = locationData?.let { + "\nLokasi: ${if (it.isInsideCampus) "Di dalam kampus" else "Di luar kampus"} (${String.format("%.6f", it.latitude)}, ${String.format("%.6f", it.longitude)})" + } ?: "\nLokasi: Tidak tersedia" + + val report = "Kondisi: $conditions\nCatatan: $notes$locationInfo" onSendNotification(conditions, report) { response -> message = response } }, - colors = ButtonDefaults.buttonColors(containerColor = Color.Red) + colors = ButtonDefaults.buttonColors(containerColor = Color.Red), + modifier = Modifier.fillMaxWidth() ) { Text(text = "Kirim Laporan", color = Color.White) } Spacer(modifier = Modifier.height(16.dp)) Text( - text = "β€œJANGAN PANIK! SEGERA EVAKUASI\nDIRI ANDA KE TITIK KUMPUL”", + text = "JANGAN PANIK! SEGERA EVAKUASI\nDIRI ANDA KE TITIK KUMPUL", color = Color.Red, fontSize = 15.sp ) @@ -204,14 +328,14 @@ fun MainScreen( Text(text = message, Modifier.padding(top = 16.dp)) Button( onClick = onNavigateEvakuasi, - colors = ButtonDefaults.buttonColors(containerColor = Color.Green) + colors = ButtonDefaults.buttonColors(containerColor = Color.Green), + modifier = Modifier.fillMaxWidth() ) { Text(text = "Lihat Jalur Evakuasi", color = Color.White) } } } - // ---------------------------------------------------------------------- // SEND NOTIFICATION FUNCTION // ---------------------------------------------------------------------- @@ -221,15 +345,15 @@ fun sendNotification(context: Context, condition: String, report: String, onResu val url = getNtfyUrl(context) val tagMapping = mapOf( - "\uD83D\uDD25 Kebakaran" to "fire", - "β›ˆ\uFE0F Banjir" to "cloud_with_lightning_and_rain", - "\uD83C\uDF0A Tsunami" to "ocean", - "\uD83C\uDF0B Gunung Meletus" to "volcano", - "\uD83C\uDF0F Gempa Bumi" to "earth_asia", - "\uD83D\uDC7F Huru hara" to "imp", - "\uD83D\uDC0D Binatang Buas" to "snake", - "☒\uFE0F Radiasi Nuklir" to "radioactive", - "☣\uFE0F Biohazard" to "biohazard" + "πŸ”₯ Kebakaran" to "fire", + "β›ˆοΈ Banjir" to "cloud_with_lightning_and_rain", + "🌊 Tsunami" to "ocean", + "πŸŒ‹ Gunung Meletus" to "volcano", + "🌏 Gempa Bumi" to "earth_asia", + "πŸ‘Ώ Huru hara" to "imp", + "🐍 Binatang Buas" to "snake", + "☒️ Radiasi Nuklir" to "radioactive", + "☣️ Biohazard" to "biohazard" ) val selectedList = condition @@ -241,7 +365,7 @@ fun sendNotification(context: Context, condition: String, report: String, onResu val emojiTags = selectedList.mapNotNull { tagMapping[it] } val finalTags = listOf("Alert") + emojiTags - val finalReport = "Kondisi: $cleanConditionText\nCatatan: ${report.substringAfter("Catatan:")}" + val finalReport = report val requestBody = RequestBody.create( "text/plain".toMediaType(),