diff --git a/.idea/copilot.data.migration.agent.xml b/.idea/copilot.data.migration.agent.xml index 3b7d94b..1cd0eb8 100644 --- a/.idea/copilot.data.migration.agent.xml +++ b/.idea/copilot.data.migration.agent.xml @@ -1,6 +1,7 @@ + @@ -9,27 +10,37 @@ - @@ -38,13 +49,8 @@ - diff --git a/.idea/copilot.data.migration.ask.xml b/.idea/copilot.data.migration.ask.xml new file mode 100644 index 0000000..7ef04e2 --- /dev/null +++ b/.idea/copilot.data.migration.ask.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/copilot.data.migration.ask2agent.xml b/.idea/copilot.data.migration.ask2agent.xml new file mode 100644 index 0000000..1f2ea11 --- /dev/null +++ b/.idea/copilot.data.migration.ask2agent.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/copilot.data.migration.edit.xml b/.idea/copilot.data.migration.edit.xml new file mode 100644 index 0000000..8648f94 --- /dev/null +++ b/.idea/copilot.data.migration.edit.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 6921437..2fcf1e1 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -60,5 +60,5 @@ dependencies { //praktikum 1 implementation("com.squareup.okhttp3:okhttp:4.11.0") implementation("androidx.compose.material3:material3:1.2.0") - + implementation("androidx.compose.material:material-icons-extended:") } \ No newline at end of file diff --git a/app/src/main/java/id/ac/ubharajaya/panicbutton/MainScreen.kt b/app/src/main/java/id/ac/ubharajaya/panicbutton/MainScreen.kt index f7cb2c6..74be991 100644 --- a/app/src/main/java/id/ac/ubharajaya/panicbutton/MainScreen.kt +++ b/app/src/main/java/id/ac/ubharajaya/panicbutton/MainScreen.kt @@ -1,145 +1,429 @@ package id.ac.ubharajaya.panicbutton +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.filled.KeyboardArrowUp +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Close import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.CircleShape +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.material3.AlertDialog -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.Checkbox -import androidx.compose.material3.CheckboxDefaults -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.material3.TextFieldDefaults -import androidx.compose.runtime.Composable +import androidx.compose.foundation.verticalScroll +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Brush +import androidx.compose.material3.* +import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp @OptIn(ExperimentalMaterial3Api::class) @Composable fun MainScreen(viewModel: MainViewModel) { + Scaffold( modifier = Modifier.fillMaxSize(), bottomBar = { PanicButton(onClick = { viewModel.sendAlert() }) } ) { paddingValues -> + Column( modifier = Modifier .fillMaxSize() .padding(paddingValues) - .padding(16.dp), + .padding(20.dp), horizontalAlignment = Alignment.CenterHorizontally ) { - Card( - shape = RoundedCornerShape(16.dp), - elevation = CardDefaults.cardElevation(defaultElevation = 4.dp), - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), - modifier = Modifier.fillMaxWidth() - ) { - Column(modifier = Modifier.padding(16.dp)) { - Text( - text = "Jenis Kondisi Darurat", - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.primary - ) - Spacer(modifier = Modifier.height(16.dp)) + // Styled Emergency Condition Card + EmergencyConditionCard(viewModel) - viewModel.options.forEach { opt -> - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 6.dp) - ) { - Box( - modifier = Modifier - .size(40.dp) - .background( - color = MaterialTheme.colorScheme.secondary.copy(alpha = 0.1f), - shape = CircleShape - ), - contentAlignment = Alignment.Center - ) { - Text(text = opt.icon, fontSize = 22.sp) - } - - Spacer(modifier = Modifier.width(16.dp)) - - Text( - text = opt.label, - modifier = Modifier.weight(1f), - style = MaterialTheme.typography.bodyLarge - ) - - Checkbox( - checked = viewModel.isChecked(opt.label), - onCheckedChange = { isChecked -> viewModel.setChecked(opt.label, isChecked) }, - colors = CheckboxDefaults.colors(checkedColor = MaterialTheme.colorScheme.primary) - ) - } - } - - Spacer(modifier = Modifier.height(16.dp)) - - OutlinedTextField( - value = viewModel.otherNote, - onValueChange = { viewModel.otherNote = it }, - label = { Text("Catatan tambahan (opsional)") }, - modifier = Modifier - .fillMaxWidth() - .heightIn(min = 56.dp, max = 120.dp), - colors = TextFieldDefaults.colors( - focusedIndicatorColor = MaterialTheme.colorScheme.primary, - cursorColor = MaterialTheme.colorScheme.primary - ) - ) - - if (viewModel.isChecked("Lainnya") && viewModel.otherNote.isBlank()) { - Text( - "Catatan wajib diisi jika memilih 'Lainnya'", - color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.bodySmall, - modifier = Modifier.padding(start = 4.dp, top = 4.dp) - ) - } - } - } + // Tambahkan spacer di luar Card agar ada jarak dari bottom bar + Spacer(Modifier.height(20.dp)) } + // Dialog Feedback val dialogMessage = viewModel.dialogMessage if (!dialogMessage.isNullOrBlank()) { AlertDialog( onDismissRequest = { viewModel.clearDialog() }, confirmButton = { TextButton(onClick = { viewModel.clearDialog() }) { - Text("OK", color = MaterialTheme.colorScheme.primary) + Text("OK") } }, - title = { Text("Notifikasi", style = MaterialTheme.typography.titleMedium) }, - text = { Text(dialogMessage, style = MaterialTheme.typography.bodyMedium) }, + title = { + Text( + "Notifikasi", + style = MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.Bold + ) + ) + }, + text = { + Text( + dialogMessage, + style = MaterialTheme.typography.bodyMedium + ) + }, containerColor = MaterialTheme.colorScheme.surface ) } } } + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun EmergencyConditionCard(viewModel: MainViewModel) { + var expanded by remember { mutableStateOf(false) } + + Card( + shape = RoundedCornerShape(24.dp), + elevation = CardDefaults.cardElevation(defaultElevation = 8.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface + ), + modifier = Modifier + .fillMaxWidth() + .shadow( + elevation = 4.dp, + shape = RoundedCornerShape(24.dp), + clip = true + ) + ) { + + Column( + modifier = Modifier + .padding(24.dp) + ) { + + // Header dengan gradient background + Row( + modifier = Modifier + .fillMaxWidth() + .background( + brush = Brush.linearGradient( + colors = listOf( + MaterialTheme.colorScheme.primary.copy(alpha = 0.1f), + MaterialTheme.colorScheme.secondary.copy(alpha = 0.05f) + ) + ), + shape = RoundedCornerShape(16.dp) + ) + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + // Icon dengan background + Box( + modifier = Modifier + .size(40.dp) + .background( + color = MaterialTheme.colorScheme.primary.copy(alpha = 0.15f), + shape = RoundedCornerShape(12.dp) + ), + contentAlignment = Alignment.Center + ) { + Text( + text = "🚨", + style = MaterialTheme.typography.titleLarge + ) + } + + Column { + Text( + text = "Jenis Kondisi Darurat", + style = MaterialTheme.typography.titleLarge.copy( + fontWeight = FontWeight.Bold + ), + color = MaterialTheme.colorScheme.primary + ) + Text( + text = "Pilih satu atau lebih kondisi", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 2.dp) + ) + } + } + + Spacer(Modifier.height(24.dp)) + + // Custom Styled Dropdown + Box( + modifier = Modifier + .fillMaxWidth() + .background( + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f), + shape = RoundedCornerShape(16.dp) + ) + .border( + width = 1.dp, + color = if (expanded) + MaterialTheme.colorScheme.primary.copy(alpha = 0.5f) + else + MaterialTheme.colorScheme.outline.copy(alpha = 0.3f), + shape = RoundedCornerShape(16.dp) + ) + .clickable { expanded = !expanded } + .padding(horizontal = 18.dp, vertical = 16.dp) + ) { + + Column { + // Selected items preview + val selectedItems = viewModel.options.filter { viewModel.isChecked(it.label) } + + if (selectedItems.isEmpty()) { + Text( + "Pilih kondisi darurat...", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } else { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + "${selectedItems.size} kondisi terpilih", + style = MaterialTheme.typography.bodySmall.copy( + fontWeight = FontWeight.Medium + ), + color = MaterialTheme.colorScheme.primary + ) + + // Show first 2 selected items as chips + selectedItems.take(2).forEach { option -> + SelectedChip(option = option, viewModel = viewModel) + } + + if (selectedItems.size > 2) { + Text( + "+${selectedItems.size - 2} lainnya", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + + // Dropdown Menu + DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + modifier = Modifier + .width(IntrinsicSize.Max) + .background( + color = MaterialTheme.colorScheme.surface, + shape = RoundedCornerShape(16.dp) + ) + ) { + Column( + modifier = Modifier + .width(320.dp) + .padding(vertical = 8.dp) + .heightIn(max = 280.dp) + .verticalScroll(rememberScrollState()) + ) { + viewModel.options.forEach { option -> + EmergencyOptionItem( + option = option, + isChecked = viewModel.isChecked(option.label), + onCheckedChange = { checked -> + viewModel.setChecked(option.label, checked) + } + ) + } + } + } + } + + // Dropdown arrow + Icon( + imageVector = if (expanded) + Icons.Filled.KeyboardArrowUp + else + Icons.Filled.KeyboardArrowDown, + contentDescription = if (expanded) "Tutup" else "Buka", + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.align(Alignment.CenterEnd) + ) + } + + Spacer(Modifier.height(20.dp)) + + // Extra note input dengan styling yang lebih baik + Column { + Text( + "Catatan Tambahan", + style = MaterialTheme.typography.bodyMedium.copy( + fontWeight = FontWeight.Medium + ), + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(bottom = 8.dp, start = 4.dp) + ) + + OutlinedTextField( + value = viewModel.otherNote, + onValueChange = { viewModel.otherNote = it }, + placeholder = { + Text( + "Tambahkan catatan jika diperlukan...", + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + }, + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 56.dp, max = 120.dp), + shape = RoundedCornerShape(14.dp), + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = MaterialTheme.colorScheme.primary, + unfocusedBorderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.4f), + cursorColor = MaterialTheme.colorScheme.primary, + focusedTextColor = MaterialTheme.colorScheme.onSurface, + unfocusedTextColor = MaterialTheme.colorScheme.onSurface + ), + textStyle = MaterialTheme.typography.bodyMedium + ) + + // Validation jika pilih 'Lainnya' tapi kosong + if (viewModel.isChecked("Lainnya") && viewModel.otherNote.isBlank()) { + Row( + modifier = Modifier.padding(top = 6.dp, start = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Default.Warning, + contentDescription = "Warning", + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(16.dp) + ) + Spacer(Modifier.width(4.dp)) + Text( + "Catatan wajib diisi jika memilih 'Lainnya'", + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall + ) + } + } + } + } + } +} + +@Composable +fun EmergencyOptionItem( + option: EmergencyOption, + isChecked: Boolean, + onCheckedChange: (Boolean) -> Unit +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .clickable { onCheckedChange(!isChecked) } + .padding(horizontal = 12.dp, vertical = 8.dp) + ) { + // Icon dengan background + Box( + modifier = Modifier + .size(36.dp) + .background( + color = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f), + shape = RoundedCornerShape(8.dp) + ), + contentAlignment = Alignment.Center + ) { + Text( + text = option.icon, + style = MaterialTheme.typography.bodyLarge + ) + } + + Spacer(Modifier.width(12.dp)) + + // Label + Text( + text = option.label, + style = MaterialTheme.typography.bodyMedium.copy( + fontWeight = if (isChecked) FontWeight.Medium else FontWeight.Normal + ), + color = if (isChecked) + MaterialTheme.colorScheme.primary + else + MaterialTheme.colorScheme.onSurface, + modifier = Modifier.weight(1f) + ) + + Spacer(Modifier.width(8.dp)) + + // Custom Checkbox + Box( + modifier = Modifier + .size(20.dp) + .background( + color = if (isChecked) + MaterialTheme.colorScheme.primary + else + MaterialTheme.colorScheme.surface, + shape = RoundedCornerShape(4.dp) + ) + .border( + width = 1.dp, + color = if (isChecked) + MaterialTheme.colorScheme.primary + else + MaterialTheme.colorScheme.outline.copy(alpha = 0.6f), + shape = RoundedCornerShape(4.dp) + ), + contentAlignment = Alignment.Center + ) { + if (isChecked) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = "Selected", + tint = MaterialTheme.colorScheme.onPrimary, + modifier = Modifier.size(14.dp) + ) + } + } + } +} + +@Composable +fun SelectedChip(option: EmergencyOption, viewModel: MainViewModel) { + Box( + modifier = Modifier + .wrapContentWidth() + .background( + color = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f), + shape = RoundedCornerShape(8.dp) + ) + .padding(horizontal = 8.dp, vertical = 4.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "${option.icon} ${option.label}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary, + maxLines = 1 + ) + + Spacer(Modifier.width(4.dp)) + + Icon( + imageVector = Icons.Default.Close, + contentDescription = "Hapus", + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier + .size(14.dp) + .clickable { viewModel.setChecked(option.label, false) } + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/id/ac/ubharajaya/panicbutton/MainViewModel.kt b/app/src/main/java/id/ac/ubharajaya/panicbutton/MainViewModel.kt index 2ee6530..4aeebdb 100644 --- a/app/src/main/java/id/ac/ubharajaya/panicbutton/MainViewModel.kt +++ b/app/src/main/java/id/ac/ubharajaya/panicbutton/MainViewModel.kt @@ -1,18 +1,25 @@ package id.ac.ubharajaya.panicbutton -import androidx.compose.runtime.getValue +import androidx.compose.runtime.* import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue 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 +) class MainViewModel : ViewModel() { val options = listOf( - ReportOption("Kebakaran", "🔥"), - ReportOption("Banjir", "🌊"), - ReportOption("Gempa Bumi", "🌋"), - ReportOption("Huru Hara/Demonstrasi", "💥"), - ReportOption("Lainnya", "✏️") + EmergencyOption("Kebakaran", "🔥"), + EmergencyOption("Banjir", "🌊"), + EmergencyOption("Gempa Bumi", "🌍"), + EmergencyOption("Huru Hara/Demonstrasi", "💥"), + EmergencyOption("Lainnya", "✏️") ) // observable map so Compose recomposes on changes @@ -27,6 +34,10 @@ 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() { @@ -42,11 +53,27 @@ class MainViewModel : ViewModel() { return } - // Simulate sending data - dialogMessage = "Laporan terkirim!\nKondisi: ${selectedOptions.joinToString() }" + // Build message payload + val bodyBuilder = StringBuilder() + bodyBuilder.append("Kondisi: ${selectedOptions.joinToString(", ")}") + if (otherNote.isNotBlank()) { + bodyBuilder.append("\nCatatan: ${otherNote.trim()}") + } + val payload = bodyBuilder.toString() + + // Use NotificationSender utility to send the payload + viewModelScope.launch { + try { + val resultMessage = NotificationSender.sendNotification(payload) + dialogMessage = resultMessage + } catch (e: Exception) { + e.printStackTrace() + dialogMessage = "Gagal mengirim laporan. Silakan periksa koneksi Anda dan coba lagi." + } + } } fun clearDialog() { dialogMessage = null } -} +} \ No newline at end of file diff --git a/app/src/main/java/id/ac/ubharajaya/panicbutton/NotificationSender.kt b/app/src/main/java/id/ac/ubharajaya/panicbutton/NotificationSender.kt index 2c9ddee..819dc1d 100644 --- a/app/src/main/java/id/ac/ubharajaya/panicbutton/NotificationSender.kt +++ b/app/src/main/java/id/ac/ubharajaya/panicbutton/NotificationSender.kt @@ -1,29 +1,79 @@ package id.ac.ubharajaya.panicbutton +import android.util.Log import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody +import java.io.IOException object NotificationSender { - private val client = OkHttpClient() + private val client = OkHttpClient.Builder() + .build() + private const val url = "https://ntfy.ubharajaya.ac.id/panic-button" + private const val TAG = "NotificationSender" + + // Header MUST be ASCII only + private fun sanitizeHeaderValue(value: String): String { + val sb = StringBuilder() + for (ch in value) { + val code = ch.code + if (code in 32..126) sb.append(ch) + } + val out = sb.toString() + return if (out.isBlank()) "Alert" else out + } 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() + try { + Log.d(TAG, "Preparing notification to $url") - client.newCall(request).execute().use { resp -> - if (resp.isSuccessful) "Notifikasi berhasil dikirim!" else "Gagal mengirim notifikasi: ${resp.code}" + // 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()) + + val request = Request.Builder() + .url(url) + .addHeader("Title", titleSafe) + .addHeader("Priority", "urgent") + .addHeader("Tags", "warning") + .post(body) + .build() + + // Debug logging + Log.d(TAG, "Body to send: $cleanMessage") + + client.newCall(request).execute().use { resp -> + val respCode = resp.code + val respMessage = resp.message + val respBody = try { + resp.body?.string() + } catch (e: Exception) { + null + } + + Log.d(TAG, "Response: code=$respCode message=$respMessage body=$respBody") + + return@withContext if (resp.isSuccessful) { + "Notifikasi berhasil dikirim!" + } else { + "Gagal mengirim notifikasi: $respCode $respMessage ${respBody ?: ""}".trim() + } + } + + } catch (e: IOException) { + Log.e(TAG, "IO error", e) + return@withContext "Gagal mengirim notifikasi: ${e.localizedMessage}" + } catch (e: Exception) { + Log.e(TAG, "Unexpected error", e) + return@withContext "Gagal mengirim notifikasi: ${e.localizedMessage}" } } } -