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 {
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)
}

View File

@ -2,8 +2,13 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!-- Permission untuk 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
android:allowBackup="true"
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
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>(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<String>()) }
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()
// 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(),