feat(ui): split MainActivity into ViewModel/UI/network modules; add checklist UI & 3D PanicButton

Split MainActivity into MainViewModel, MainScreen, PanicButton, NotificationSender, and ReportOption.\n\nReplaced dropdown with checklist, added emoji icons, notes field, and validation (catatan wajib when 'Lainnya' selected). Improves maintainability and testability. Ran editor static checks; no errors reported.
This commit is contained in:
Rakha adi 2025-11-19 22:10:32 +07:00
parent f0d6074b55
commit b3c44df833
8 changed files with 274 additions and 376 deletions

View File

@ -16,6 +16,12 @@
<option value="fe272d92-3acf-42e8-bd88-66dc00800c3f" />
<option value="ea6afe5a-c296-461b-a4a7-49209b3d7937" />
<option value="2b41e5bf-4d90-46d8-8fc8-7b41c3f788e1" />
<option value="34b3c4dc-eb50-43b4-98d0-2b9771161619" />
<option value="b32841ec-424b-46e4-af25-b31a5ca4ae51" />
<option value="8589889d-a532-45c6-9ffe-0671c2d32583" />
<option value="4f53b359-f3df-449a-ab3e-d112d5df446a" />
<option value="e2907e39-fda1-4832-99ff-fb7407d64db3" />
<option value="0e6a533b-db2a-4977-8228-bfeaa867eef9" />
</set>
</value>
</entry>
@ -25,6 +31,11 @@
<entry key="bf5061b4-a1e9-4600-a273-ea8e51b2ce89">
<set>
<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/ReportOption.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/PanicButton.kt" />
<option value="file://$PROJECT_DIR$/app/src/main/java/id/ac/ubharajaya/panicbutton/MainViewModel.kt" />
<option value="file://$PROJECT_DIR$/app/src/main/java/id/ac/ubharajaya/panicbutton/NotificationSender.kt" />
</set>
</entry>
</pendingWorkingSetItems>

File diff suppressed because one or more lines are too long

View File

@ -3,370 +3,18 @@ package id.ac.ubharajaya.panicbutton
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.animation.core.tween
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.selection.toggleable
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.scale
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Brush
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
import androidx.compose.ui.window.Dialog
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import androidx.activity.viewModels
import androidx.compose.material3.MaterialTheme
class MainActivity : ComponentActivity() {
private val viewModel: MainViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
MyApp()
}
}
}
@Composable
fun MyApp() {
// dialogMessage akan menampung hasil callback dari sendNotification; null = tidak tampil
var dialogMessage by remember { mutableStateOf<String?>(null) }
// theme color for panic
val panicColor = Color(0xFFB71C1C)
// Report options (checkbox-style) with icons (emoji from ntfy emoji list)
val reportOptions = listOf(
"Kebakaran" to "🔥",
"Banjir" to "🌊",
"Gempa Bumi" to "🌍",
"Huru Hara/Demostrasi" to "📣",
"Lainnya" to "📝"
)
val checkedMap = remember { mutableStateMapOf<String, Boolean>().apply { reportOptions.forEach { put(it.first, false) } } }
var otherNote by remember { mutableStateOf("") }
// UI
Column(
modifier = Modifier
.fillMaxSize()
.padding(18.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
// Checklist area styled as card
Surface(
color = Color(0xFFFFEBEE), // soft red/pink background to match panic theme
tonalElevation = 4.dp,
shape = RoundedCornerShape(10.dp),
modifier = Modifier
.fillMaxWidth(0.92f)
.padding(horizontal = 12.dp)
) {
Column(modifier = Modifier.padding(14.dp)) {
Text(
text = "Terjadi Kondisi Darurat",
fontWeight = FontWeight.SemiBold,
fontSize = 18.sp,
color = panicColor
)
Spacer(modifier = Modifier.height(10.dp))
// Each option as a row with icon, label and checkbox aligned nicely
reportOptions.forEach { (label, icon) ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 6.dp)
.toggleable(
value = checkedMap[label] ?: false,
onValueChange = { checked -> checkedMap[label] = checked }
)
) {
// icon circle
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.size(34.dp)
.background(color = Color.White.copy(alpha = 0.9f), shape = CircleShape)
.shadow(1.dp, shape = CircleShape)
) {
Text(text = icon, fontSize = 18.sp)
}
Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(text = label, fontSize = 16.sp)
}
Checkbox(
checked = checkedMap[label] ?: false,
onCheckedChange = { checked -> checkedMap[label] = checked },
colors = CheckboxDefaults.colors(
checkedColor = panicColor,
uncheckedColor = Color.DarkGray
)
)
}
}
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = otherNote,
onValueChange = { otherNote = it },
label = { Text("Catatan tambahan (opsional)") },
placeholder = { Text("Catatan tambahan ...") },
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 56.dp, max = 140.dp),
maxLines = 4
)
Spacer(modifier = Modifier.height(8.dp))
// small hint when Lainnya is checked but note empty
if ((checkedMap["Lainnya"] == true) && otherNote.isBlank()) {
Text(
text = "Catatan wajib jika Anda memilih 'Lainnya'",
color = panicColor,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(top = 6.dp)
)
}
}
}
Spacer(modifier = Modifier.height(20.dp))
// Keep the existing nice PanicButton unchanged (slightly smaller spacing)
PanicButton(onClick = {
// Validation: at least one option selected
val selected = reportOptions.map { it.first }.filter { checkedMap[it] == true }
if (selected.isEmpty()) {
dialogMessage = "Pilih minimal satu jenis laporan sebelum mengirim."
return@PanicButton
}
if (selected.contains("Lainnya") && otherNote.isBlank()) {
dialogMessage = "Untuk opsi 'Lainnya', harap tambahkan catatan yang menjelaskan keadaan."
return@PanicButton
}
// build message: list selected options and the note
val message = buildString {
append("Jenis Laporan: ")
append(selected.joinToString(", "))
append("\n")
append("Keterangan: ")
if (otherNote.isNotBlank()) append(otherNote.trim()) else append("-")
append("\n")
append("Pengirim: Rakha adi saputro 202310715083")
}
sendNotification(message) { response -> dialogMessage = response }
})
Spacer(modifier = Modifier.height(14.dp))
// dialog hasil
if (dialogMessage != null) {
Dialog(onDismissRequest = { dialogMessage = null }) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(24.dp),
contentAlignment = Alignment.Center
) {
Surface(
shape = RoundedCornerShape(12.dp),
color = Color.White,
tonalElevation = 8.dp,
modifier = Modifier.wrapContentWidth()
) {
Column(
modifier = Modifier
.padding(0.dp)
.widthIn(min = 280.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
// header
Box(
modifier = Modifier
.fillMaxWidth()
.background(color = panicColor)
.padding(vertical = 12.dp),
contentAlignment = Alignment.Center
) {
Text(
text = "Notifikasi",
color = Color.White,
fontWeight = FontWeight.Bold,
fontSize = 18.sp
)
}
Spacer(modifier = Modifier.height(12.dp))
// message
Text(
text = dialogMessage ?: "",
color = Color.Black,
modifier = Modifier.padding(horizontal = 16.dp)
)
Spacer(modifier = Modifier.height(20.dp))
// action
Box(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp),
contentAlignment = Alignment.Center
) {
Button(
onClick = { dialogMessage = null },
colors = ButtonDefaults.buttonColors(containerColor = panicColor)
) {
Text(text = "OK", color = Color.White)
MaterialTheme {
MainScreen(viewModel)
}
}
}
}
}
}
}
}
}
// New: 3D-styled PanicButton with press animation and glossy highlight
@Composable
fun PanicButton(
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
// theme colors
val panicColor = Color(0xFFB71C1C)
val darkShade = Color(0xFF7F0F0F)
val lightAccent = Color(0xFFFF8A80)
// interaction for press-state
val interactionSource = remember { MutableInteractionSource() }
val isPressed by interactionSource.collectIsPressedAsState()
// animations: scale down and lower elevation when pressed
val scaleAnim by animateFloatAsState(targetValue = if (isPressed) 0.96f else 1f, animationSpec = tween(120))
val elevationAnim by animateDpAsState(targetValue = if (isPressed) 6.dp else 18.dp, animationSpec = tween(120))
// vertical gradient to simulate 3D lighting
val gradient = Brush.verticalGradient(listOf(lightAccent, panicColor, darkShade))
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
modifier = modifier
) {
Box(contentAlignment = Alignment.Center) {
// soft outer shadow (ground shadow)
Box(
modifier = Modifier
.size(210.dp)
.shadow(elevation = elevationAnim, shape = CircleShape)
.background(color = Color.Black.copy(alpha = 0.12f), shape = CircleShape)
)
// main 3D button
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.size(170.dp)
.scale(scaleAnim)
.shadow(elevation = elevationAnim, shape = CircleShape)
.background(brush = gradient, shape = CircleShape)
.clickable(indication = null, interactionSource = interactionSource) {
onClick()
}
) {
// glossy highlight (small white circle offset to top-left)
Box(
modifier = Modifier
.size(70.dp)
.offset(x = (-24).dp, y = (-28).dp)
.background(color = Color.White.copy(alpha = 0.16f), shape = CircleShape)
)
// central exclamation icon
Text(
text = "!",
color = Color.White,
fontSize = 72.sp,
fontWeight = FontWeight.ExtraBold
)
// subtle inner rim to enhance 3D edge
Box(
modifier = Modifier
.matchParentSize()
.padding(6.dp)
.background(color = Color.Transparent, shape = CircleShape)
)
}
}
}
}
// Fungsi untuk mengirimkan HTTP request (mengikuti format pesan yang lebih rapi)
fun sendNotification(message: String, onResult: (String) -> Unit) {
val client = OkHttpClient()
val url = "https://ntfy.ubharajaya.ac.id/panic-button" // Ganti <your-topic> dengan topik Anda
val requestBody = message.toRequestBody(
"text/plain".toMediaType()
)
val request = Request.Builder()
.url(url)
.addHeader("Title", "Alert")
.addHeader("Priority", "urgent")
.addHeader("Tags", "alert warning,rotating_light")
.post(requestBody)
.build()
// Eksekusi request di thread terpisah
Thread {
try {
val response = client.newCall(request).execute()
if (response.isSuccessful) {
onResult("Notifikasi berhasil dikirim!")
} else {
onResult("Gagal mengirim notifikasi: ${response.code}")
}
} catch (e: Exception) {
onResult("Error: ${e.message}")
}
}.start()
}

View File

@ -0,0 +1,100 @@
package id.ac.ubharajaya.panicbutton
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@Composable
fun MainScreen(viewModel: MainViewModel) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(18.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Surface(
color = MaterialTheme.colorScheme.surfaceVariant,
tonalElevation = 4.dp,
shape = RoundedCornerShape(10.dp),
modifier = Modifier
.fillMaxWidth(0.92f)
.padding(horizontal = 12.dp)
) {
Column(modifier = Modifier.padding(14.dp)) {
Text(text = "Jenis Kondisi Darurat", fontSize = 18.sp, color = MaterialTheme.colorScheme.error)
Spacer(modifier = Modifier.height(10.dp))
// list options directly from viewModel
for (opt in viewModel.options) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 6.dp)
) {
Box(
modifier = Modifier
.size(34.dp)
.background(color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.06f), shape = CircleShape),
contentAlignment = Alignment.Center
) {
Text(text = opt.icon)
}
Spacer(modifier = Modifier.width(12.dp))
Text(text = opt.label, modifier = Modifier.weight(1f))
val checked = viewModel.isChecked(opt.label)
Checkbox(
checked = checked,
onCheckedChange = { checkedValue -> viewModel.setChecked(opt.label, checkedValue) }
)
}
}
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = viewModel.otherNote,
onValueChange = { newValue -> viewModel.otherNote = newValue },
label = { Text("Catatan tambahan (opsional)") },
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 56.dp, max = 140.dp)
)
Spacer(modifier = Modifier.height(8.dp))
if (viewModel.isChecked("Lainnya") && viewModel.otherNote.isBlank()) {
Text("Catatan wajib jika Anda memilih 'Lainnya'", color = MaterialTheme.colorScheme.error)
}
}
}
Spacer(modifier = Modifier.height(20.dp))
PanicButton(onClick = { viewModel.sendAlert() })
Spacer(modifier = Modifier.height(14.dp))
val dialog = viewModel.dialogMessage
if (!dialog.isNullOrBlank()) {
AlertDialog(
onDismissRequest = { viewModel.clearDialog() },
confirmButton = { TextButton(onClick = { viewModel.clearDialog() }) { Text("OK") } },
title = { Text("Notifikasi") },
text = { Text(dialog) }
)
}
}
}

View File

@ -0,0 +1,69 @@
package id.ac.ubharajaya.panicbutton
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.launch
class MainViewModel : ViewModel() {
val options = listOf(
ReportOption("Kebakaran", "🔥"),
ReportOption("Banjir", "🌊"),
ReportOption("Gempa Bumi", "🌍"),
ReportOption("Huru Hara/Demonstrasi", "📣"),
ReportOption("Lainnya", "📝")
)
// simple state holders
var checkedMap by mutableStateOf(options.associate { it.label to false }.toMutableMap())
private set
var otherNote by mutableStateOf("")
var dialogMessage by mutableStateOf<String?>(null)
fun isChecked(label: String): Boolean = checkedMap[label] ?: false
fun setChecked(label: String, checked: Boolean) { toggleOption(label, checked) }
fun toggleOption(label: String, checked: Boolean) {
val copy = checkedMap.toMutableMap()
copy[label] = checked
checkedMap = copy
}
fun sendAlert(senderName: String = "Rakha adi saputro 202310715083") {
val selected = checkedMap.filterValues { it }.keys.toList()
if (selected.isEmpty()) {
dialogMessage = "Pilih minimal satu jenis laporan sebelum mengirim."
return
}
if (selected.contains("Lainnya") && otherNote.isBlank()) {
dialogMessage = "Untuk opsi 'Lainnya', harap tambahkan catatan yang menjelaskan keadaan."
return
}
val message = buildString {
append("Jenis Laporan: ")
append(selected.joinToString(", "))
append("\nKeterangan: ")
append(if (otherNote.isBlank()) "-" else otherNote.trim())
append("\nPengirim: ")
append(senderName)
}
viewModelScope.launch {
try {
val result = NotificationSender.sendNotification(message)
dialogMessage = result
} catch (e: Exception) {
dialogMessage = "Error: ${e.message}"
}
}
}
fun clearDialog() {
dialogMessage = null
}
}

View File

@ -0,0 +1,29 @@
package id.ac.ubharajaya.panicbutton
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
object NotificationSender {
private val client = OkHttpClient()
private const val url = "https://ntfy.ubharajaya.ac.id/panic-button"
suspend fun sendNotification(message: String): String = withContext(Dispatchers.IO) {
val body = message.toRequestBody("text/plain".toMediaType())
val request = Request.Builder()
.url(url)
.addHeader("Title", "Alert")
.addHeader("Priority", "urgent")
.addHeader("Tags", "alert warning,rotating_light")
.post(body)
.build()
client.newCall(request).execute().use { resp ->
if (resp.isSuccessful) "Notifikasi berhasil dikirim!" else "Gagal mengirim notifikasi: ${resp.code}"
}
}
}

View File

@ -0,0 +1,56 @@
package id.ac.ubharajaya.panicbutton
import androidx.compose.animation.core.tween
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.scale
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@Composable
fun PanicButton(onClick: () -> Unit) {
val panicColor = Color(0xFFB71C1C)
val darkShade = Color(0xFF7F0F0F)
val lightAccent = Color(0xFFFF8A80)
val interactionSource = remember { MutableInteractionSource() }
val isPressedState = interactionSource.collectIsPressedAsState()
val isPressed = isPressedState.value
val scaleAnim = animateFloatAsState(targetValue = if (isPressed) 0.96f else 1f, animationSpec = tween(120)).value
val elevationAnim = animateDpAsState(targetValue = if (isPressed) 6.dp else 18.dp, animationSpec = tween(120)).value
val gradient = Brush.verticalGradient(listOf(lightAccent, panicColor, darkShade))
Box(contentAlignment = Alignment.Center) {
Box(
modifier = Modifier
.size(210.dp)
.shadow(elevation = elevationAnim, shape = CircleShape)
.background(color = Color.Black.copy(alpha = 0.12f), shape = CircleShape)
)
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.size(170.dp)
.scale(scaleAnim)
.shadow(elevation = elevationAnim, shape = CircleShape)
.background(brush = gradient, shape = CircleShape)
.clickable(indication = null, interactionSource = interactionSource) { onClick() }
) {
Box(modifier = Modifier.size(70.dp).offset(x = (-24).dp, y = (-28).dp).background(color = Color.White.copy(alpha = 0.16f), shape = CircleShape))
Text("!", color = Color.White, fontSize = 72.sp)
}
}
}

View File

@ -0,0 +1,3 @@
package id.ac.ubharajaya.panicbutton
data class ReportOption(val label: String, val icon: String)