Compare commits

...

3 Commits

10 changed files with 329 additions and 109 deletions

View File

@ -4,26 +4,61 @@
<option name="migrationStatus" value="IN_PROGRESS" />
<option name="pendingSessionIds">
<option value="05f02dcc-4a62-4a3b-a6bb-d4764cbd1bab" />
<option value="1aa6c162-aaff-428f-bfae-3b53f8ce3f76" />
<option value="1e2fee64-523c-4038-ad08-03d8f8196cb1" />
<option value="46f36349-a2e4-46bf-b85e-8ee46252660a" />
<option value="4e8befa7-c7cb-4874-9ac5-4b69e81d19b8" />
<option value="63e1cd83-39a1-4de9-83e4-a7d978cb9048" />
<option value="82cd9415-28a8-49b5-841d-6178af5419de" />
<option value="9e78818c-2d23-4dc2-8dd8-c82d3ade6f9d" />
<option value="a1f1bc1b-b1d4-4655-9677-4653b213cc14" />
<option value="ad5bcc95-675a-45d4-8896-b1645cb788b9" />
<option value="b944cd09-1573-42de-840e-06d91ce8729c" />
<option value="bf5061b4-a1e9-4600-a273-ea8e51b2ce89" />
<option value="d23e68d4-6558-4c12-a57e-d937ea639547" />
<option value="dd38c49b-b13d-4206-8c24-bcf78317eec5" />
<option value="e6a32dc0-5413-49cf-ad92-9fb87982ed75" />
</option>
<option name="pendingTurns">
<map>
<entry key="1aa6c162-aaff-428f-bfae-3b53f8ce3f76">
<value>
<set>
<option value="cbcc2ea0-3963-436d-be88-f495313b8d20" />
<option value="c00b235a-a5a8-4158-baf4-7a3ca74345b0" />
<option value="16815d5b-8699-467d-9929-397e12de1084" />
<option value="f6f34f7d-39e2-4634-a01d-82dae28320aa" />
<option value="43c8a7ee-b78d-4cf3-824e-8d0c98692b00" />
<option value="eab6d8f0-3fdf-46bc-ab06-c8eb1243fe00" />
<option value="207107b2-1903-41f3-a6d3-30ee3505d45b" />
<option value="73b11a61-7794-4de6-bbdf-5f37c67fd8f0" />
</set>
</value>
</entry>
<entry key="63e1cd83-39a1-4de9-83e4-a7d978cb9048">
<value>
<set>
<option value="f099f72f-0866-4d16-85e4-8323be39e45f" />
<option value="44c8c4f4-3bbe-4fb8-88e4-58bdb184dad0" />
<option value="a20448f7-57cd-4a40-89b1-460451412588" />
<option value="484561a1-1c08-44cd-bcd3-a53895fc3851" />
<option value="17ec376a-38fd-48ac-a4e1-52e9f7df3100" />
<option value="3ac3800e-363e-4148-86b3-0d4f3b0a8b62" />
<option value="44c8c4f4-3bbe-4fb8-88e4-58bdb184dad0" />
<option value="484561a1-1c08-44cd-bcd3-a53895fc3851" />
<option value="5460190d-c8ff-4194-9316-a6298c7cb54b" />
<option value="a0c23b11-ba1e-4275-a3e4-38b3c8fdbe00" />
<option value="a20448f7-57cd-4a40-89b1-460451412588" />
<option value="c640b33a-4bc6-4ee0-98ed-6a71e7270331" />
<option value="f099f72f-0866-4d16-85e4-8323be39e45f" />
</set>
</value>
</entry>
<entry key="9e78818c-2d23-4dc2-8dd8-c82d3ade6f9d">
<value>
<set>
<option value="3a14eb41-53f1-4190-a7f8-24c23cb882e8" />
<option value="4a2475a2-98ab-4ae0-b689-cbc2da702b83" />
<option value="72c7c9c6-ecb5-444b-86de-559ea9d893e5" />
<option value="a58fb4e2-20bd-480e-9eff-a5b9dc57c1b4" />
<option value="f43c2e1c-5a6c-4deb-9fbf-5e7694c69f6d" />
<option value="faff10ea-f875-4de1-adc0-6095c7c3e718" />
</set>
</value>
</entry>
@ -77,15 +112,14 @@
</map>
</option>
<pendingWorkingSetItems>
<entry key="63e1cd83-39a1-4de9-83e4-a7d978cb9048">
<entry key="1aa6c162-aaff-428f-bfae-3b53f8ce3f76">
<set>
<option value="file://$PROJECT_DIR$/app/src/main/res/mipmap-anydpi/ic_launcher.xml" />
<option value="file://$PROJECT_DIR$/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml" />
<option value="file://$PROJECT_DIR$/app/build.gradle.kts" />
<option value="file://$PROJECT_DIR$/app/src/main/java/id/ac/ubharajaya/panicbutton/MainActivity.kt" />
<option value="file://$PROJECT_DIR$/app/src/main/java/id/ac/ubharajaya/panicbutton/NotificationSender.kt" />
<option value="file://$PROJECT_DIR$/app/src/main/java/id/ac/ubharajaya/panicbutton/MainScreen.kt" />
<option value="file://$PROJECT_DIR$/app/src/main/java/id/ac/ubharajaya/panicbutton/EvacuationMaps.kt" />
</set>
</entry>
<entry key="63e1cd83-39a1-4de9-83e4-a7d978cb9048" />
<entry key="9e78818c-2d23-4dc2-8dd8-c82d3ade6f9d" />
<entry key="bf5061b4-a1e9-4600-a273-ea8e51b2ce89" />
</pendingWorkingSetItems>
</component>

View File

@ -69,4 +69,7 @@ dependencies {
// AndroidX SplashScreen (required for installSplashScreen API)
implementation("androidx.core:core-splashscreen:1.0.1")
// Play Services Location for GPS (FusedLocationProviderClient)
implementation("com.google.android.gms:play-services-location:21.0.1")
}

View File

@ -2,19 +2,22 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<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"
android:fullBackupContent="@xml/backup_rules"
android:icon="@drawable/logo"
android:label="@string/app_name"
android:roundIcon="@drawable/logo"
android:supportsRtl="true"
android:theme="@style/Theme.PanicButton">
android:theme="@style/Theme.PanicButton"
android:label="@string/app_name">
<activity
android:name=".MainActivity"
android:exported="true"
android:label="@string/app_name"
android:theme="@style/Theme.App.Starting">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
@ -34,5 +37,4 @@
android:label="Detail Peta" />
</application>
<uses-permission android:name="android.permission.INTERNET" />
</manifest>

View File

@ -1,58 +1,170 @@
package id.ac.ubharajaya.panicbutton
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
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.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
// --- Constants for better readability ---
val CardElevation = 8.dp
val CornerRadius = 16.dp
val PaddingDefault = 16.dp
val ImageSize = 100.dp
// ====================================================================
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun EvacuationMapsScreen(navController: NavController, onBack: () -> Unit) {
Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
Text(text = "Peta Jalur Evakuasi", style = MaterialTheme.typography.titleLarge, modifier = Modifier.padding(bottom = 12.dp))
Scaffold(
topBar = {
TopAppBar(
title = { Text("Peta Jalur Evakuasi") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Kembali")
}
}
)
}
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.padding(horizontal = PaddingDefault)
) {
// Deskripsi Singkat
Text(
text = "Pilih area peta jalur evakuasi yang ingin Anda lihat.",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = PaddingDefault)
)
Spacer(modifier = Modifier.height(8.dp))
// Card 1 - Selatan
Card(modifier = Modifier.fillMaxWidth().padding(bottom = 12.dp).clickable { navController.navigate("evac_map/selatan") }, shape = RoundedCornerShape(12.dp), elevation = CardDefaults.cardElevation(defaultElevation = 6.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(12.dp)) {
Image(painter = painterResource(id = R.drawable.lantai_1_selatan), contentDescription = "Selatan", modifier = Modifier.size(120.dp))
Spacer(modifier = Modifier.width(12.dp))
Column {
Text(text = "Jalur Evakuasi Selatan", fontSize = 18.sp)
Spacer(modifier = Modifier.height(6.dp))
Text(text = "Lihat detail jalur evakuasi lantai selatan.")
}
}
}
EvacuationMapCard(
title = "Jalur Evakuasi Selatan",
description = "Lihat detail jalur evakuasi lantai selatan.",
drawableId = R.drawable.lantai_1_selatan,
onClick = { navController.navigate("evac_map/selatan") }
)
// Card 2 - Utara
Card(modifier = Modifier.fillMaxWidth().padding(bottom = 12.dp).clickable { navController.navigate("evac_map/utara") }, shape = RoundedCornerShape(12.dp), elevation = CardDefaults.cardElevation(defaultElevation = 6.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(12.dp)) {
Image(painter = painterResource(id = R.drawable.lantai_1_utara), contentDescription = "Utara", modifier = Modifier.size(120.dp))
Spacer(modifier = Modifier.width(12.dp))
Column {
Text(text = "Jalur Evakuasi Utara", fontSize = 18.sp)
Spacer(modifier = Modifier.height(6.dp))
Text(text = "Lihat detail jalur evakuasi lantai utara.")
}
}
EvacuationMapCard(
title = "Jalur Evakuasi Utara",
description = "Lihat detail jalur evakuasi lantai utara.",
drawableId = R.drawable.lantai_1_utara,
onClick = { navController.navigate("evac_map/utara") }
)
}
}
}
@Composable
fun EvacuationMapDetailScreen(drawableResId: Int, onBack: () -> Unit) {
Column(modifier = Modifier.fillMaxSize().padding(12.dp)) {
Text(text = "Detail Peta", style = MaterialTheme.typography.titleLarge, modifier = Modifier.padding(bottom = 12.dp))
Image(painter = painterResource(id = drawableResId), contentDescription = "Map Detail", modifier = Modifier.fillMaxWidth().heightIn(max = 600.dp))
fun EvacuationMapCard(
title: String,
description: String,
drawableId: Int,
onClick: () -> Unit
) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 12.dp)
.clickable(onClick = onClick),
shape = RoundedCornerShape(CornerRadius),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh
),
elevation = CardDefaults.cardElevation(defaultElevation = CardElevation)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(PaddingDefault)
) {
// Gambar dengan shape dan padding
Image(
painter = painterResource(id = drawableId),
contentDescription = title,
contentScale = ContentScale.Crop,
modifier = Modifier
.size(ImageSize)
.clip(RoundedCornerShape(12.dp))
.background(MaterialTheme.colorScheme.primaryContainer)
)
Spacer(modifier = Modifier.width(PaddingDefault))
// Kolom Teks
Column(modifier = Modifier.weight(1f)) {
Text(
text = title,
fontSize = 18.sp,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = description,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
// ====================================================================
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun EvacuationMapDetailScreen(drawableResId: Int, onBack: () -> Unit) {
Scaffold(
topBar = {
TopAppBar(
title = { Text("Detail Peta") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Kembali")
}
}
)
}
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
) {
// Gambar Detail Peta
Image(
painter = painterResource(id = drawableResId),
contentDescription = "Map Detail",
contentScale = ContentScale.Fit, // Gunakan Fit untuk memastikan peta terlihat keseluruhan
modifier = Modifier
.fillMaxWidth()
.padding(PaddingDefault)
.clip(RoundedCornerShape(CornerRadius))
.background(Color.LightGray) // Warna latar belakang untuk gambar
)
}
}
}

View File

@ -1,24 +1,68 @@
package id.ac.ubharajaya.panicbutton
import android.Manifest
import android.annotation.SuppressLint
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.ContextCompat
import androidx.lifecycle.ViewModelProvider
import androidx.compose.material3.MaterialTheme
import com.google.android.gms.location.LocationServices
class MainActivity : ComponentActivity() {
private lateinit var viewModel: MainViewModel
private val requestPermissionLauncher =
registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted: Boolean ->
if (isGranted) {
if (::viewModel.isInitialized) viewModel.dialogMessage = "Izin lokasi diberikan. Tekan kembali untuk menyertakan koordinat."
} else {
if (::viewModel.isInitialized) viewModel.dialogMessage = "Izin lokasi ditolak. Laporan akan dikirim tanpa koordinat."
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
viewModel = ViewModelProvider(this).get(MainViewModel::class.java)
val fusedClient = LocationServices.getFusedLocationProviderClient(this)
val openEvacMaps = {
startActivity(Intent(this, EvacuationMapsActivity::class.java))
}
@SuppressLint("MissingPermission")
fun handleSendAlert() {
// Check permission
val fineGranted = ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED
val coarseGranted = ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED
if (!fineGranted && !coarseGranted) {
requestPermissionLauncher.launch(Manifest.permission.ACCESS_FINE_LOCATION)
viewModel.dialogMessage = "Izin lokasi dibutuhkan untuk menyertakan koordinat. Silakan tekan lagi setelah mengizinkan lokasi."
return
}
fusedClient.lastLocation
.addOnSuccessListener { loc ->
if (loc != null) {
viewModel.sendAlert(loc.latitude, loc.longitude)
} else {
viewModel.sendAlert()
}
}
.addOnFailureListener {
viewModel.sendAlert()
}
}
setContent {
MaterialTheme {
MainScreen(viewModel = viewModel, onOpenEvacMaps = openEvacMaps)
MainScreen(viewModel = viewModel, onOpenEvacMaps = openEvacMaps, onSendAlert = ::handleSendAlert)
}
}
}

View File

@ -22,44 +22,28 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.foundation.layout.IntrinsicSize
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MainScreen(viewModel: MainViewModel, onOpenEvacMaps: () -> Unit) {
fun MainScreen(viewModel: MainViewModel, onOpenEvacMaps: () -> Unit, onSendAlert: () -> Unit) {
Scaffold(
modifier = Modifier.fillMaxSize(),
bottomBar = {
topBar = {
// Put the emergency box in the topBar so Scaffold will position it below the status bar automatically
Box(
modifier = Modifier
.fillMaxWidth()
.height(120.dp),
.padding(horizontal = 20.dp, vertical = 8.dp),
contentAlignment = Alignment.TopCenter
) {
PanicButton(onClick = { viewModel.sendAlert() }, buttonSize = 130.dp, shadowSize = 160.dp)
}
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(20.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
// Emergency instruction
Text(
text = "JANGAN PANIK, SEGERA EVAKUASI DIRI ANDA KE TITIK KUMPUL!!",
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyLarge.copy(
fontWeight = FontWeight.Bold
),
style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.Bold),
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 12.dp)
.background(
MaterialTheme.colorScheme.error.copy(alpha = 0.2f),
shape = RoundedCornerShape(8.dp)
@ -70,9 +54,27 @@ fun MainScreen(viewModel: MainViewModel, onOpenEvacMaps: () -> Unit) {
shape = RoundedCornerShape(8.dp)
)
.padding(12.dp),
maxLines = 2,
maxLines = 2
)
}
}
) { padding -> // Inset padding yang disediakan oleh Scaffold
// Use the Scaffold padding for content so it's laid out under the topBar correctly
val contentPaddingModifier = Modifier
.fillMaxSize()
.padding(padding)
Column(
modifier = contentPaddingModifier
.verticalScroll(rememberScrollState())
.padding(horizontal = 20.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.height(12.dp))
// Main card and other content
EmergencyConditionCard(viewModel)
Spacer(modifier = Modifier.height(12.dp))
@ -88,23 +90,42 @@ fun MainScreen(viewModel: MainViewModel, onOpenEvacMaps: () -> Unit) {
}
Spacer(modifier = Modifier.height(20.dp))
// Move PanicButton into scrollable content so it scrolls with the page
Box(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 24.dp),
contentAlignment = Alignment.Center
) {
// keep same size and shadow as before
PanicButton(onClick = { onSendAlert() }, buttonSize = 130.dp, shadowSize = 160.dp)
}
// Dialog
val dialogMessage = viewModel.dialogMessage
if (!dialogMessage.isNullOrBlank()) {
// create some extra space below button for comfortable scrolling
Spacer(modifier = Modifier.height(40.dp))
}
// Dialog notifikasi
viewModel.dialogMessage
?.takeIf { it.isNotBlank() }
?.let { msg ->
AlertDialog(
onDismissRequest = { viewModel.clearDialog() },
confirmButton = {
TextButton(onClick = { viewModel.clearDialog() }) { Text("OK") }
},
title = { Text("🚨 Notifikasi", style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold)) },
text = { Text(dialogMessage ?: "") }
text = { Text(msg) }
)
}
}
}
// =======================================================
// EmergencyConditionCard
// =======================================================
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun EmergencyConditionCard(viewModel: MainViewModel) {
@ -242,7 +263,6 @@ fun EmergencyConditionCard(viewModel: MainViewModel) {
expanded = expanded,
onDismissRequest = { expanded = false },
modifier = Modifier
.fillMaxWidth()
.background(
color = MaterialTheme.colorScheme.surface,
shape = RoundedCornerShape(16.dp)
@ -341,6 +361,10 @@ fun EmergencyConditionCard(viewModel: MainViewModel) {
}
}
// =======================================================
// EmergencyOptionItem
// =======================================================
@Composable
fun EmergencyOptionItem(
option: EmergencyOption,
@ -420,6 +444,10 @@ fun EmergencyOptionItem(
}
}
// =======================================================
// SelectedChip
// =======================================================
@Composable
fun SelectedChip(option: EmergencyOption, viewModel: MainViewModel) {
Box(

View File

@ -7,7 +7,6 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.launch
// Ganti ReportOption dengan EmergencyOption
data class EmergencyOption(
val label: String,
val icon: String
@ -34,13 +33,12 @@ class MainViewModel : ViewModel() {
fun setChecked(label: String, isChecked: Boolean) {
_checkedState[label] = isChecked
// Clear otherNote jika "Lainnya" di-uncheck
if (label == "Lainnya" && !isChecked) {
otherNote = ""
}
}
fun sendAlert() {
fun sendAlert(latitude: Double? = null, longitude: Double? = null) {
val selectedOptions = options.filter { isChecked(it.label) }.map { it.label }
if (selectedOptions.isEmpty()) {
@ -53,15 +51,18 @@ class MainViewModel : ViewModel() {
return
}
// Build message payload
val bodyBuilder = StringBuilder()
bodyBuilder.append("Kondisi: ${selectedOptions.joinToString(", ")}")
if (otherNote.isNotBlank()) {
bodyBuilder.append("\nCatatan: ${otherNote.trim()}")
}
if (latitude != null && longitude != null) {
bodyBuilder.append("\nLokasi: https://maps.google.com/?q=$latitude,$longitude")
}
val payload = bodyBuilder.toString()
// Use NotificationSender utility to send the payload
viewModelScope.launch {
try {
val resultMessage = NotificationSender.sendNotification(payload)

View File

@ -30,11 +30,7 @@ object NotificationSender {
try {
val url = "$server/$topic"
Log.d(TAG, "Preparing notification to $url")
// Title ONLY has emoji
val titleSafe = sanitizeHeaderValue("🚨 Alert 🚨")
// Body is clean text, no emoji auto-append
val cleanMessage = message.trim()
val body = cleanMessage.toRequestBody("text/plain".toMediaType())

View File

@ -2,8 +2,8 @@
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:drawable="@drawable/ubhara"
android:top="30dp"
android:bottom="30dp"
android:left="30dp"
android:right="30dp"/>
android:top="50dp"
android:bottom="50dp"
android:left="50dp"
android:right="50dp"/>
</layer-list>

View File

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.App.Starting" parent="Theme.SplashScreen">
<style name="Theme.App.Starting" parent="Theme.SplashScreen.IconBackground">
<item name="postSplashScreenTheme">@style/Theme.PanicButton</item>
<item name="windowSplashScreenAnimatedIcon">@drawable/splash_icon_padded</item>