Fitur Koordinat Lokasi dengan GPS dan Geofencing

This commit is contained in:
202310715297 RAIHAN ARIQ MUZAKKI 2025-12-03 11:48:58 +07:00
parent 835b856792
commit 30e5a80e4a
4 changed files with 384 additions and 37 deletions

View File

@ -6,9 +6,7 @@ plugins {
android { android {
namespace = "id.ac.ubharajaya.panicbutton" namespace = "id.ac.ubharajaya.panicbutton"
compileSdk { compileSdk = 36
version = release(36)
}
defaultConfig { defaultConfig {
applicationId = "id.ac.ubharajaya.panicbutton" applicationId = "id.ac.ubharajaya.panicbutton"
@ -42,11 +40,15 @@ android {
} }
dependencies { dependencies {
// OkHttp untuk HTTP request // OkHttp untuk HTTP request
implementation("com.squareup.okhttp3:okhttp:4.11.0") 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-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 // Core AndroidX dan Compose BOM
implementation(libs.androidx.core.ktx) implementation(libs.androidx.core.ktx)
@ -57,9 +59,12 @@ dependencies {
// Compose UI & Material3 // Compose UI & Material3
implementation(libs.androidx.compose.ui) implementation(libs.androidx.compose.ui)
implementation(libs.androidx.compose.ui.graphics) implementation(libs.androidx.compose.ui.graphics)
implementation(libs.androidx.compose.material3) // Diambil dari BOM implementation(libs.androidx.compose.material3)
implementation(libs.androidx.compose.foundation) implementation(libs.androidx.compose.foundation)
// Material Icons Extended - untuk icon LocationOn
implementation("androidx.compose.material:material-icons-extended:1.5.4")
// Tools dan Testing // Tools dan Testing
implementation(libs.androidx.compose.ui.tooling.preview) implementation(libs.androidx.compose.ui.tooling.preview)
testImplementation(libs.junit) testImplementation(libs.junit)
@ -69,5 +74,4 @@ dependencies {
androidTestImplementation(libs.androidx.compose.ui.test.junit4) androidTestImplementation(libs.androidx.compose.ui.test.junit4)
debugImplementation(libs.androidx.compose.ui.tooling) debugImplementation(libs.androidx.compose.ui.tooling)
debugImplementation(libs.androidx.compose.ui.test.manifest) debugImplementation(libs.androidx.compose.ui.test.manifest)
} }

View File

@ -2,8 +2,13 @@
<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">
<!-- Permission untuk Internet -->
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<!-- Permission untuk Lokasi -->
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<application <application
android:allowBackup="true" android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules" android:dataExtractionRules="@xml/data_extraction_rules"

View File

@ -0,0 +1,214 @@
package id.ac.ubharajaya.panicbutton
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.location.Location
import android.os.Looper
import androidx.core.app.ActivityCompat
import com.google.android.gms.location.*
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlin.math.atan2
import kotlin.math.cos
import kotlin.math.sin
import kotlin.math.sqrt
/**
* LocationManager untuk handle GPS tracking dan Geofencing
* Kampus Ubhara Jaya: -6.223276, 107.009273 (Radius: 500m)
*/
class LocationManager(private val context: Context) {
private val fusedLocationClient: FusedLocationProviderClient =
LocationServices.getFusedLocationProviderClient(context)
// Koordinat Kampus Ubhara Jaya
companion object {
private const val CAMPUS_LAT = -6.223276
private const val CAMPUS_LNG = 107.009273
private const val CAMPUS_RADIUS_METERS = 500.0
}
/**
* Data class untuk menyimpan informasi lokasi
*/
data class LocationData(
val latitude: Double,
val longitude: Double,
val isInsideCampus: Boolean,
val distanceFromCampus: Double // dalam meter
)
/**
* Cek apakah permission lokasi sudah diberikan
*/
fun hasLocationPermission(): Boolean {
return ActivityCompat.checkSelfPermission(
context,
Manifest.permission.ACCESS_FINE_LOCATION
) == PackageManager.PERMISSION_GRANTED
}
/**
* Get lokasi sekali (one-time location)
*/
fun getCurrentLocation(onResult: (LocationData?) -> 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<LocationData?> = 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)
}
}

View File

@ -1,18 +1,24 @@
package id.ac.ubharajaya.panicbutton package id.ac.ubharajaya.panicbutton
import android.Manifest
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.os.Bundle import android.os.Bundle
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent 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.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll 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.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@ -20,28 +26,42 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager 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.ImeAction
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import kotlinx.coroutines.launch
import okhttp3.MediaType.Companion.toMediaType import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.RequestBody 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 // MAIN ACTIVITY CLASS
// ---------------------------------------------------------------------- // ----------------------------------------------------------------------
class MainActivity : ComponentActivity() { 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?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
locationManager = LocationManager(this)
setContent { setContent {
MyApp() MyApp(
locationManager = locationManager,
onRequestPermission = {
requestPermissionLauncher.launch(Manifest.permission.ACCESS_FINE_LOCATION)
}
)
} }
} }
} }
@ -57,7 +77,10 @@ sealed class ScreenState {
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun MyApp() { fun MyApp(
locationManager: LocationManager,
onRequestPermission: () -> Unit
) {
var currentScreen by remember { mutableStateOf<ScreenState>(ScreenState.Main) } var currentScreen by remember { mutableStateOf<ScreenState>(ScreenState.Main) }
val context = LocalContext.current val context = LocalContext.current
@ -65,8 +88,6 @@ fun MyApp() {
topBar = { topBar = {
TopAppBar( TopAppBar(
title = { Text("Panic Button") }, title = { Text("Panic Button") },
// Logika TopBar hanya muncul jika di MainScreen,
// SettingsScreen memiliki TopBar sendiri
actions = { actions = {
if (currentScreen is ScreenState.Main) { if (currentScreen is ScreenState.Main) {
IconButton(onClick = { IconButton(onClick = {
@ -82,6 +103,8 @@ fun MyApp() {
when (currentScreen) { when (currentScreen) {
is ScreenState.Main -> MainScreen( is ScreenState.Main -> MainScreen(
paddingValues = paddingValues, paddingValues = paddingValues,
locationManager = locationManager,
onRequestPermission = onRequestPermission,
onSendNotification = { conditions, report, onResult -> onSendNotification = { conditions, report, onResult ->
sendNotification(context, conditions, report, onResult) sendNotification(context, conditions, report, onResult)
}, },
@ -91,14 +114,12 @@ fun MyApp() {
} }
) )
is ScreenState.Settings -> SettingsScreen( is ScreenState.Settings -> SettingsScreen(
// Melewatkan fungsi untuk kembali
onBack = { currentScreen = ScreenState.Main } onBack = { currentScreen = ScreenState.Main }
) )
} }
} }
} }
// ---------------------------------------------------------------------- // ----------------------------------------------------------------------
// MAIN SCREEN // MAIN SCREEN
// ---------------------------------------------------------------------- // ----------------------------------------------------------------------
@ -106,6 +127,8 @@ fun MyApp() {
@Composable @Composable
fun MainScreen( fun MainScreen(
paddingValues: PaddingValues, paddingValues: PaddingValues,
locationManager: LocationManager,
onRequestPermission: () -> Unit,
onSendNotification: (String, String, (String) -> Unit) -> Unit, onSendNotification: (String, String, (String) -> Unit) -> Unit,
onNavigateEvakuasi: () -> Unit onNavigateEvakuasi: () -> Unit
) { ) {
@ -114,8 +137,35 @@ fun MainScreen(
var selectedConditions by remember { mutableStateOf(mutableSetOf<String>()) } var selectedConditions by remember { mutableStateOf(mutableSetOf<String>()) }
var additionalNotes by remember { mutableStateOf(TextFieldValue("")) } var additionalNotes by remember { mutableStateOf(TextFieldValue("")) }
// Location states
var locationData by remember { mutableStateOf<LocationManager.LocationData?>(null) }
var locationStatus by remember { mutableStateOf("Mengecek lokasi...") }
val scope = rememberCoroutineScope()
val scrollState = rememberScrollState() 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( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@ -125,6 +175,75 @@ fun MainScreen(
verticalArrangement = Arrangement.Top, verticalArrangement = Arrangement.Top,
horizontalAlignment = Alignment.Start 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(
text = "Terjadi Kondisi Darurat", text = "Terjadi Kondisi Darurat",
fontSize = 20.sp, fontSize = 20.sp,
@ -133,9 +252,9 @@ fun MainScreen(
) )
listOf( listOf(
"\uD83D\uDD25 Kebakaran", "\uFE0F Banjir", "\uD83C\uDF0A Tsunami", "\uD83C\uDF0B Gunung Meletus", "🔥 Kebakaran", " Banjir", "🌊 Tsunami", "🌋 Gunung Meletus",
"\uD83C\uDF0F Gempa Bumi", "\uD83D\uDC7F Huru hara", "\uD83D\uDC0D Binatang Buas", "🌏 Gempa Bumi", "👿 Huru hara", "🐍 Binatang Buas",
"\uFE0F Radiasi Nuklir", "\uFE0F Biohazard" " Radiasi Nuklir", "☣️ Biohazard"
).forEach { condition -> ).forEach { condition ->
Row( Row(
modifier = Modifier modifier = Modifier
@ -183,19 +302,24 @@ fun MainScreen(
onClick = { onClick = {
val notes = additionalNotes.text val notes = additionalNotes.text
val conditions = selectedConditions.joinToString(", ") 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 -> onSendNotification(conditions, report) { response ->
message = 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) Text(text = "Kirim Laporan", color = Color.White)
} }
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
Text( Text(
text = "JANGAN PANIK! SEGERA EVAKUASI\nDIRI ANDA KE TITIK KUMPUL", text = "JANGAN PANIK! SEGERA EVAKUASI\nDIRI ANDA KE TITIK KUMPUL",
color = Color.Red, color = Color.Red,
fontSize = 15.sp fontSize = 15.sp
) )
@ -204,14 +328,14 @@ fun MainScreen(
Text(text = message, Modifier.padding(top = 16.dp)) Text(text = message, Modifier.padding(top = 16.dp))
Button( Button(
onClick = onNavigateEvakuasi, 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) Text(text = "Lihat Jalur Evakuasi", color = Color.White)
} }
} }
} }
// ---------------------------------------------------------------------- // ----------------------------------------------------------------------
// SEND NOTIFICATION FUNCTION // SEND NOTIFICATION FUNCTION
// ---------------------------------------------------------------------- // ----------------------------------------------------------------------
@ -221,15 +345,15 @@ fun sendNotification(context: Context, condition: String, report: String, onResu
val url = getNtfyUrl(context) val url = getNtfyUrl(context)
val tagMapping = mapOf( val tagMapping = mapOf(
"\uD83D\uDD25 Kebakaran" to "fire", "🔥 Kebakaran" to "fire",
"\uFE0F Banjir" to "cloud_with_lightning_and_rain", " Banjir" to "cloud_with_lightning_and_rain",
"\uD83C\uDF0A Tsunami" to "ocean", "🌊 Tsunami" to "ocean",
"\uD83C\uDF0B Gunung Meletus" to "volcano", "🌋 Gunung Meletus" to "volcano",
"\uD83C\uDF0F Gempa Bumi" to "earth_asia", "🌏 Gempa Bumi" to "earth_asia",
"\uD83D\uDC7F Huru hara" to "imp", "👿 Huru hara" to "imp",
"\uD83D\uDC0D Binatang Buas" to "snake", "🐍 Binatang Buas" to "snake",
"\uFE0F Radiasi Nuklir" to "radioactive", " Radiasi Nuklir" to "radioactive",
"\uFE0F Biohazard" to "biohazard" " Biohazard" to "biohazard"
) )
val selectedList = condition val selectedList = condition
@ -241,7 +365,7 @@ fun sendNotification(context: Context, condition: String, report: String, onResu
val emojiTags = selectedList.mapNotNull { tagMapping[it] } val emojiTags = selectedList.mapNotNull { tagMapping[it] }
val finalTags = listOf("Alert") + emojiTags val finalTags = listOf("Alert") + emojiTags
val finalReport = "Kondisi: $cleanConditionText\nCatatan: ${report.substringAfter("Catatan:")}" val finalReport = report
val requestBody = RequestBody.create( val requestBody = RequestBody.create(
"text/plain".toMediaType(), "text/plain".toMediaType(),