1package com.parseable.android.ui.screens.logviewer
3import androidx.compose.animation.AnimatedVisibility
4import androidx.compose.animation.animateContentSize
5import androidx.compose.animation.core.*
6import androidx.compose.foundation.horizontalScroll
7import androidx.compose.foundation.layout.*
8import androidx.compose.foundation.lazy.LazyColumn
9import androidx.compose.foundation.lazy.itemsIndexed
10import androidx.compose.foundation.lazy.rememberLazyListState
11import androidx.compose.foundation.rememberScrollState
12import androidx.compose.foundation.text.KeyboardActions
13import androidx.compose.foundation.text.KeyboardOptions
14import androidx.compose.material.icons.Icons
15import androidx.compose.material.icons.automirrored.filled.ArrowBack
16import androidx.compose.material.icons.filled.*
17import androidx.compose.material3.*
18import androidx.compose.material3.MenuAnchorType
19import androidx.compose.material3.pulltorefresh.PullToRefreshBox
20import androidx.compose.runtime.*
21import androidx.compose.ui.Alignment
22import androidx.compose.ui.Modifier
23import androidx.compose.ui.draw.alpha
24import androidx.compose.ui.platform.LocalContext
25import androidx.compose.ui.platform.LocalSoftwareKeyboardController
26import androidx.compose.ui.text.font.FontFamily
27import androidx.compose.ui.text.font.FontWeight
28import androidx.compose.ui.text.input.ImeAction
29import androidx.compose.ui.text.style.TextOverflow
30import androidx.compose.ui.unit.dp
31import androidx.compose.ui.unit.sp
32import android.content.Intent
33import androidx.core.content.FileProvider
34import com.parseable.android.data.escapeIdentifier
35import com.parseable.android.data.formatTimestamp
36import androidx.hilt.navigation.compose.hiltViewModel
37import androidx.lifecycle.compose.collectAsStateWithLifecycle
38import kotlinx.coroutines.launch
39import kotlinx.coroutines.withContext
40import kotlinx.serialization.json.*
42private val prettyJson = Json { prettyPrint = true }
44@OptIn(ExperimentalMaterial3Api::class)
49 onStreamInfo: (String) -> Unit,
50 viewModel: LogViewerViewModel = hiltViewModel(),
52 val state by viewModel.state.collectAsStateWithLifecycle()
53 val context = LocalContext.current
54 val scope = rememberCoroutineScope()
55 val snackbarHostState = remember { SnackbarHostState() }
56 val keyboardController = LocalSoftwareKeyboardController.current
57 var showFilterSheet by remember { mutableStateOf(false) }
58 var showSqlSheet by remember { mutableStateOf(false) }
59 var showSavedFiltersSheet by remember { mutableStateOf(false) }
60 var showSaveFilterDialog by remember { mutableStateOf(false) }
61 var expandedLogKey by remember { mutableStateOf<String?>(null) }
62 var showDateRangePicker by remember { mutableStateOf(false) }
63 var showShareWarning by remember { mutableStateOf(false) }
65 LaunchedEffect(streamName) {
66 viewModel.initialize(streamName)
70 snackbarHost = { SnackbarHost(snackbarHostState) },
77 fontWeight = FontWeight.Bold,
79 overflow = TextOverflow.Ellipsis,
82 text = if (state.isStreaming) {
83 "Live - ${state.logs.size} logs (+${state.streamingNewCount} new)"
85 "${state.logs.size} results"
87 style = MaterialTheme.typography.bodySmall,
88 color = if (state.isStreaming) {
89 MaterialTheme.colorScheme.primary
91 MaterialTheme.colorScheme.onSurfaceVariant
97 IconButton(onClick = onBack) {
98 Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
102 StreamingToggleButton(
103 isStreaming = state.isStreaming,
104 onClick = viewModel::toggleStreaming,
106 IconButton(onClick = { onStreamInfo(streamName) }) {
107 Icon(Icons.Filled.Info, contentDescription = "Stream Info")
109 IconButton(onClick = { showFilterSheet = true }) {
110 Icon(Icons.Filled.FilterList, contentDescription = "Filters")
112 IconButton(onClick = { showSqlSheet = true }) {
113 Icon(Icons.Filled.Code, contentDescription = "SQL Query")
115 IconButton(onClick = {
116 viewModel.loadSavedFilters()
117 showSavedFiltersSheet = true
119 Icon(Icons.Filled.BookmarkBorder, contentDescription = "Saved Filters")
122 onClick = { showShareWarning = true },
123 enabled = state.logs.isNotEmpty(),
125 Icon(Icons.Filled.Share, contentDescription = "Share logs")
138 selectedRange = state.selectedTimeRange,
139 onRangeSelected = { range ->
140 if (range == TimeRange.CUSTOM) {
141 showDateRangePicker = true
143 viewModel.onTimeRangeChange(range)
150 value = state.searchQuery,
151 onValueChange = viewModel::onSearchQueryChange,
152 placeholder = { Text("Search logs...") },
153 leadingIcon = { Icon(Icons.Filled.Search, contentDescription = "Search") },
155 if (state.isSearching) {
156 CircularProgressIndicator(
157 modifier = Modifier.size(20.dp),
160 } else if (state.searchQuery.isNotEmpty()) {
161 IconButton(onClick = { viewModel.onSearchQueryChange("") }) {
162 Icon(Icons.Filled.Clear, contentDescription = "Clear")
167 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
168 keyboardActions = KeyboardActions(onSearch = {
169 keyboardController?.hide()
174 .padding(horizontal = 16.dp, vertical = 4.dp),
175 textStyle = MaterialTheme.typography.bodyMedium,
178 // Active filters indicator
179 if (state.activeFilters.isNotEmpty()) {
182 .horizontalScroll(rememberScrollState())
183 .padding(horizontal = 16.dp, vertical = 4.dp)
184 .animateContentSize(),
185 horizontalArrangement = Arrangement.spacedBy(8.dp),
187 state.activeFilters.forEach { filter ->
190 onClick = { viewModel.removeFilter(filter) },
191 label = { Text(filter, maxLines = 1) },
195 contentDescription = "Remove filter",
196 modifier = Modifier.size(16.dp),
201 TextButton(onClick = viewModel::clearFilters) {
207 // Streaming indicator banner
208 AnimatedVisibility(visible = state.isStreaming) {
210 color = MaterialTheme.colorScheme.primaryContainer,
211 modifier = Modifier.fillMaxWidth(),
214 modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
215 verticalAlignment = Alignment.CenterVertically,
217 val infiniteTransition = rememberInfiniteTransition(label = "pulse")
218 val alpha by infiniteTransition.animateFloat(
221 animationSpec = infiniteRepeatable(
222 animation = tween(800, easing = EaseInOut),
223 repeatMode = RepeatMode.Reverse,
225 label = "pulse_alpha",
228 Icons.Filled.FiberManualRecord,
229 contentDescription = "Live streaming active",
230 tint = MaterialTheme.colorScheme.error,
235 Spacer(modifier = Modifier.width(8.dp))
237 text = "Live tail active - polling every ${state.currentIntervalMs / 1000}s",
238 style = MaterialTheme.typography.labelMedium,
239 color = MaterialTheme.colorScheme.onPrimaryContainer,
240 modifier = Modifier.weight(1f),
242 TextButton(onClick = viewModel::stopStreaming) {
249 // Streaming error banner
250 AnimatedVisibility(visible = state.streamingError != null) {
252 color = MaterialTheme.colorScheme.errorContainer,
253 modifier = Modifier.fillMaxWidth(),
256 modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
257 verticalAlignment = Alignment.CenterVertically,
260 Icons.Filled.ErrorOutline,
261 contentDescription = "Streaming error",
262 tint = MaterialTheme.colorScheme.onErrorContainer,
263 modifier = Modifier.size(16.dp),
265 Spacer(modifier = Modifier.width(8.dp))
267 text = state.streamingError ?: "",
268 style = MaterialTheme.typography.labelMedium,
269 color = MaterialTheme.colorScheme.onErrorContainer,
270 modifier = Modifier.weight(1f),
272 overflow = TextOverflow.Ellipsis,
274 TextButton(onClick = viewModel::dismissStreamingError) {
283 isRefreshing = state.isLoading,
284 onRefresh = viewModel::refresh,
285 modifier = Modifier.fillMaxSize(),
287 if (state.error != null && state.logs.isEmpty()) {
289 modifier = Modifier.fillMaxSize(),
290 contentAlignment = Alignment.Center,
292 Column(horizontalAlignment = Alignment.CenterHorizontally) {
294 Icons.Filled.ErrorOutline,
295 contentDescription = "Error loading logs",
296 modifier = Modifier.size(48.dp),
297 tint = MaterialTheme.colorScheme.error,
299 Spacer(modifier = Modifier.height(16.dp))
301 text = state.error.orEmpty(),
302 style = MaterialTheme.typography.bodyMedium,
303 color = MaterialTheme.colorScheme.error,
304 modifier = Modifier.padding(horizontal = 32.dp),
306 Spacer(modifier = Modifier.height(16.dp))
307 OutlinedButton(onClick = viewModel::refresh) {
312 } else if (state.logs.isEmpty() && state.isLoading) {
314 modifier = Modifier.fillMaxSize(),
315 contentAlignment = Alignment.Center,
317 CircularProgressIndicator()
319 } else if (state.logs.isEmpty() && !state.isLoading) {
320 val emptyMessage = when {
322 "Waiting for new logs\u2026"
323 state.searchQuery.isNotBlank() && state.activeFilters.isNotEmpty() ->
324 "No logs match \"${state.searchQuery}\" with the active filters"
325 state.searchQuery.isNotBlank() ->
326 "No logs match \"${state.searchQuery}\""
327 state.activeFilters.isNotEmpty() ->
328 "No logs match the active filters"
330 "No logs found for the selected time range"
333 modifier = Modifier.fillMaxSize(),
334 contentAlignment = Alignment.Center,
336 Column(horizontalAlignment = Alignment.CenterHorizontally) {
338 Icons.Filled.SearchOff,
339 contentDescription = "No logs found",
340 modifier = Modifier.size(48.dp),
341 tint = MaterialTheme.colorScheme.onSurfaceVariant,
343 Spacer(modifier = Modifier.height(16.dp))
346 style = MaterialTheme.typography.bodyLarge,
347 color = MaterialTheme.colorScheme.onSurfaceVariant,
348 modifier = Modifier.padding(horizontal = 32.dp),
353 val listState = rememberLazyListState()
354 val clipboard = remember {
355 context.getSystemService(android.content.Context.CLIPBOARD_SERVICE) as android.content.ClipboardManager
358 // Auto-load more when scrolling near the bottom
359 if (state.hasMore && !state.isLoading) {
360 val shouldLoadMore by remember {
362 val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0
363 lastVisible >= listState.layoutInfo.totalItemsCount - 5
366 LaunchedEffect(shouldLoadMore) {
367 if (shouldLoadMore) viewModel.loadMore()
373 contentPadding = PaddingValues(8.dp),
374 verticalArrangement = Arrangement.spacedBy(4.dp),
378 key = { index, _ -> state.logKeys.getOrElse(index) { "log_$index" } },
379 ) { index, logEntry ->
380 val key = state.logKeys.getOrElse(index) { "log_$index" }
383 isExpanded = expandedLogKey == key,
385 expandedLogKey = if (expandedLogKey == key) null else key
387 clipboardManager = clipboard,
390 snackbarHostState.showSnackbar(
391 message = "Log entry copied to clipboard",
392 duration = SnackbarDuration.Short,
396 onFilterInclude = { column, value ->
397 viewModel.addFilter(column, "=", value)
399 onFilterExclude = { column, value ->
400 viewModel.addFilter(column, "!=", value)
411 contentAlignment = Alignment.Center,
413 CircularProgressIndicator(
414 modifier = Modifier.size(24.dp),
419 } else if (state.logs.isNotEmpty()) {
425 contentAlignment = Alignment.Center,
427 val atCap = state.currentLimit >= 5000 && state.logs.size >= 5000
430 "Showing max 5,000 results. Refine your query to see more."
434 style = MaterialTheme.typography.labelMedium,
436 MaterialTheme.colorScheme.primary
438 MaterialTheme.colorScheme.onSurfaceVariant
450 // Filter bottom sheet
451 if (showFilterSheet) {
453 columns = state.columns,
455 onDismiss = { showFilterSheet = false },
456 onApplyFilter = { column, operator, value ->
457 viewModel.addFilter(column, operator, value)
458 showFilterSheet = false
463 // SQL query bottom sheet
466 streamName = streamName,
467 currentSql = state.customSql,
468 onDismiss = { showSqlSheet = false },
470 viewModel.executeCustomSql(sql)
476 // Share confirmation dialog (logs may contain sensitive data)
477 if (showShareWarning) {
479 onDismissRequest = { showShareWarning = false },
480 title = { Text("Share Logs") },
482 Text("Logs may contain sensitive information (PII, secrets, tokens). " +
483 "They will be exported as unencrypted JSON. Continue?")
486 TextButton(onClick = {
487 showShareWarning = false
488 val logs = state.logs
489 if (logs.isNotEmpty()) {
492 val file = withContext(kotlinx.coroutines.Dispatchers.IO) {
493 val dir = java.io.File(context.cacheDir, "shared_logs")
494 if (!dir.mkdirs() && !dir.isDirectory) {
495 throw java.io.IOException("Failed to create shared_logs directory")
497 // Clean up previous exports
498 dir.listFiles()?.forEach { old -> old.delete() }
499 val safeFileName = streamName.replace(Regex("[^a-zA-Z0-9._-]"), "_")
500 val file = java.io.File(dir, "logs_$safeFileName.json")
501 file.bufferedWriter().use { writer ->
502 // Use compact JSON for large exports to reduce memory/file size
503 val encoder = if (logs.size > 1000) Json else prettyJson
505 logs.forEachIndexed { index, log ->
506 if (index > 0) writer.write(",")
508 writer.write(encoder.encodeToString(JsonObject.serializer(), log))
515 val uri = FileProvider.getUriForFile(
517 "${context.packageName}.fileprovider",
520 val sendIntent = Intent().apply {
521 action = Intent.ACTION_SEND
522 putExtra(Intent.EXTRA_STREAM, uri)
523 putExtra(Intent.EXTRA_SUBJECT, "Logs: $streamName")
524 type = "application/json"
525 addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
527 context.startActivity(
528 Intent.createChooser(sendIntent, "Share logs")
530 } catch (e: Exception) {
531 snackbarHostState.showSnackbar(
532 message = "Failed to share logs: ${e.message}",
533 duration = SnackbarDuration.Short,
543 TextButton(onClick = { showShareWarning = false }) {
550 // Custom date range picker – state is created inside the block so it
551 // resets every time the dialog reopens (no stale selection carried over).
552 if (showDateRangePicker) {
553 val dateRangePickerState = rememberDateRangePickerState()
555 onDismissRequest = { showDateRangePicker = false },
559 val start = dateRangePickerState.selectedStartDateMillis
560 val end = dateRangePickerState.selectedEndDateMillis
561 if (start != null && end != null) {
562 // DateRangePicker returns midnight UTC for the selected date.
563 // Adjust to cover the full end day in the user's local timezone:
564 // Convert to local date, get end-of-day, convert back to UTC millis.
565 val localZone = java.time.ZoneId.systemDefault()
566 val endLocalDate = java.time.Instant.ofEpochMilli(end)
567 .atZone(localZone).toLocalDate()
568 val endOfDayUtc = endLocalDate.plusDays(1)
569 .atStartOfDay(localZone)
570 .toInstant().toEpochMilli() - 1
571 val startLocalDate = java.time.Instant.ofEpochMilli(start)
572 .atZone(localZone).toLocalDate()
573 val startUtc = startLocalDate
574 .atStartOfDay(localZone)
575 .toInstant().toEpochMilli()
576 viewModel.setCustomTimeRange(startUtc, endOfDayUtc)
578 showDateRangePicker = false
580 enabled = dateRangePickerState.selectedStartDateMillis != null &&
581 dateRangePickerState.selectedEndDateMillis != null,
587 TextButton(onClick = { showDateRangePicker = false }) {
593 state = dateRangePickerState,
594 modifier = Modifier.weight(1f),
599 // Saved filters bottom sheet
600 if (showSavedFiltersSheet) {
601 SavedFiltersBottomSheet(
602 savedFiltersState = state.savedFilters,
603 hasActiveFilters = state.activeFilters.isNotEmpty() ||
604 state.searchQuery.isNotBlank() ||
605 state.customSql.isNotBlank(),
606 onDismiss = { showSavedFiltersSheet = false },
607 onSaveCurrentFilter = { showSaveFilterDialog = true },
608 onApplyFilter = { filter ->
609 viewModel.applySavedFilter(filter)
610 showSavedFiltersSheet = false
612 onDeleteFilter = { filterId -> viewModel.deleteSavedFilter(filterId) },
613 onRetry = { viewModel.loadSavedFilters() },
617 // Save filter name dialog
618 if (showSaveFilterDialog) {
620 isSaving = state.savedFilters.isSaving,
621 onDismiss = { showSaveFilterDialog = false },
623 viewModel.saveCurrentFilter(name)
624 showSaveFilterDialog = false
625 showSavedFiltersSheet = false
632private fun TimeRangeBar(
633 selectedRange: TimeRange,
634 onRangeSelected: (TimeRange) -> Unit,
638 .horizontalScroll(rememberScrollState())
639 .padding(horizontal = 16.dp, vertical = 4.dp),
640 horizontalArrangement = Arrangement.spacedBy(8.dp),
642 TimeRange.entries.forEach { range ->
644 selected = selectedRange == range,
645 onClick = { onRangeSelected(range) },
646 label = { Text(range.label) },
653private fun StreamingToggleButton(
654 isStreaming: Boolean,
657 IconButton(onClick = onClick) {
659 val infiniteTransition = rememberInfiniteTransition(label = "stream_pulse")
660 val alpha by infiniteTransition.animateFloat(
663 animationSpec = infiniteRepeatable(
664 animation = tween(600, easing = EaseInOut),
665 repeatMode = RepeatMode.Reverse,
667 label = "stream_btn_alpha",
671 contentDescription = "Stop streaming",
672 tint = MaterialTheme.colorScheme.error,
673 modifier = Modifier.alpha(alpha),
677 Icons.Filled.PlayArrow,
678 contentDescription = "Start live tail",
679 tint = MaterialTheme.colorScheme.primary,
687 logEntry: JsonObject,
690 onCopied: () -> Unit = {},
691 onFilterInclude: (column: String, value: String) -> Unit = { _, _ -> },
692 onFilterExclude: (column: String, value: String) -> Unit = { _, _ -> },
693 clipboardManager: android.content.ClipboardManager,
695 val severity = remember(logEntry) { detectSeverity(logEntry) }
696 val severityTint = severityBackgroundColor(severity)
697 val containerColor = severityTint ?: MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
698 val cardColors = CardDefaults.cardColors(containerColor = containerColor)
701 modifier = Modifier.fillMaxWidth(),
704 Column(modifier = Modifier.padding(12.dp)) {
706 val rawTimestamp = remember(logEntry) {
708 logEntry["p_timestamp"]?.jsonPrimitive?.content
709 ?: logEntry["datetime"]?.jsonPrimitive?.content
711 } catch (_: Exception) {
715 val displayTimestamp = remember(rawTimestamp) {
716 if (rawTimestamp.isNotEmpty()) formatTimestamp(rawTimestamp) else ""
719 if (displayTimestamp.isNotEmpty()) {
721 text = displayTimestamp,
722 style = MaterialTheme.typography.labelSmall,
723 color = MaterialTheme.colorScheme.primary,
724 fontFamily = FontFamily.Monospace,
726 Spacer(modifier = Modifier.height(4.dp))
730 // Compact view: show first meaningful fields
731 val preview = remember(logEntry) {
733 .filter { !it.key.startsWith("p_") || it.key == "p_timestamp" }
735 .joinToString(" | ") { "${it.key}: ${formatJsonValue(it.value)}" }
738 text = preview.ifEmpty { logEntry.toString() },
739 style = MaterialTheme.typography.bodySmall,
740 fontFamily = FontFamily.Monospace,
742 overflow = TextOverflow.Ellipsis,
746 // Expanded view: show all fields + copy button
748 modifier = Modifier.fillMaxWidth(),
749 horizontalArrangement = Arrangement.End,
753 val text = prettyJson.encodeToString(JsonObject.serializer(), logEntry)
755 clipboardManager.setPrimaryClip(
756 android.content.ClipData.newPlainText("Log entry", text)
759 } catch (_: SecurityException) {
760 // Some devices/custom ROMs restrict clipboard access
765 Icons.Filled.ContentCopy,
766 contentDescription = "Copy log entry to clipboard",
767 tint = MaterialTheme.colorScheme.onSurfaceVariant,
771 HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
772 logEntry.entries.forEach { (key, value) ->
773 val displayValue = formatJsonValue(value)
777 .padding(vertical = 2.dp),
778 verticalAlignment = Alignment.CenterVertically,
780 Column(modifier = Modifier.weight(1f)) {
784 style = MaterialTheme.typography.bodySmall,
785 fontWeight = FontWeight.Bold,
786 fontFamily = FontFamily.Monospace,
787 color = MaterialTheme.colorScheme.primary,
792 style = MaterialTheme.typography.bodySmall,
793 fontFamily = FontFamily.Monospace,
798 // Filter include (=) button
800 onClick = { onFilterInclude(key, displayValue) },
801 modifier = Modifier.size(28.dp),
804 Icons.Filled.AddCircle,
805 contentDescription = "Filter for $key = $displayValue",
806 modifier = Modifier.size(16.dp),
807 tint = MaterialTheme.colorScheme.primary,
810 // Filter exclude (!=) button
812 onClick = { onFilterExclude(key, displayValue) },
813 modifier = Modifier.size(28.dp),
816 Icons.Filled.RemoveCircle,
817 contentDescription = "Filter out $key = $displayValue",
818 modifier = Modifier.size(16.dp),
819 tint = MaterialTheme.colorScheme.error,
829private fun formatJsonValue(element: JsonElement): String {
830 return when (element) {
831 is JsonPrimitive -> element.content
832 is JsonNull -> "null"
833 else -> element.toString()
837@OptIn(ExperimentalMaterial3Api::class)
839fun FilterBottomSheet(
840 columns: List<String>,
841 logs: List<JsonObject> = emptyList(),
842 onDismiss: () -> Unit,
843 onApplyFilter: (column: String, operator: String, value: String) -> Unit,
845 var selectedColumn by remember { mutableStateOf(columns.firstOrNull() ?: "") }
846 var selectedOperator by remember { mutableStateOf("=") }
847 var filterValue by remember { mutableStateOf("") }
848 var columnDropdownExpanded by remember { mutableStateOf(false) }
849 var operatorDropdownExpanded by remember { mutableStateOf(false) }
851 val operators = listOf("=", "!=", "LIKE", "ILIKE", ">", "<", ">=", "<=", "IS NULL", "IS NOT NULL")
853 // Extract distinct values for the selected column from current logs
854 val columnValues = remember(selectedColumn, logs) {
855 if (selectedColumn.isBlank() || logs.isEmpty()) {
859 .mapNotNull { entry ->
861 entry[selectedColumn]?.let { element ->
862 formatJsonValue(element).takeIf { it != "null" && it.isNotBlank() }
864 } catch (_: Exception) {
875 ModalBottomSheet(onDismissRequest = onDismiss) {
877 modifier = Modifier.padding(16.dp),
878 verticalArrangement = Arrangement.spacedBy(12.dp),
882 style = MaterialTheme.typography.titleLarge,
883 fontWeight = FontWeight.Bold,
887 ExposedDropdownMenuBox(
888 expanded = columnDropdownExpanded,
889 onExpandedChange = { columnDropdownExpanded = it },
892 value = selectedColumn,
895 label = { Text("Column") },
896 trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = columnDropdownExpanded) },
899 .menuAnchor(MenuAnchorType.PrimaryNotEditable),
902 expanded = columnDropdownExpanded,
903 onDismissRequest = { columnDropdownExpanded = false },
905 columns.forEach { column ->
907 text = { Text(column) },
909 selectedColumn = column
911 columnDropdownExpanded = false
919 ExposedDropdownMenuBox(
920 expanded = operatorDropdownExpanded,
921 onExpandedChange = { operatorDropdownExpanded = it },
924 value = selectedOperator,
927 label = { Text("Operator") },
928 trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = operatorDropdownExpanded) },
931 .menuAnchor(MenuAnchorType.PrimaryNotEditable),
934 expanded = operatorDropdownExpanded,
935 onDismissRequest = { operatorDropdownExpanded = false },
937 operators.forEach { op ->
941 selectedOperator = op
942 operatorDropdownExpanded = false
949 // Value input (hidden for IS NULL / IS NOT NULL)
950 if (selectedOperator !in listOf("IS NULL", "IS NOT NULL")) {
951 val valueHint = when (selectedOperator) {
952 "LIKE", "ILIKE" -> "Pattern (% added automatically)"
953 ">", "<", ">=", "<=" -> "Numeric or date value"
954 else -> "Exact value"
956 val valueError = when {
957 filterValue.isNotEmpty() && selectedOperator in listOf(">", "<", ">=", "<=") &&
958 filterValue.toDoubleOrNull() == null &&
959 !filterValue.matches(Regex("\\d{4}-\\d{2}-\\d{2}.*")) ->
960 "Expected a number or date"
965 onValueChange = { filterValue = it },
966 label = { Text("Value") },
967 placeholder = { Text(valueHint) },
968 modifier = Modifier.fillMaxWidth(),
970 isError = valueError != null,
971 supportingText = valueError?.let { { Text(it) } },
972 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
973 keyboardActions = KeyboardActions(onDone = {
974 if (selectedColumn.isNotBlank() && filterValue.isNotEmpty() && valueError == null) {
975 onApplyFilter(selectedColumn, selectedOperator, filterValue)
980 // Show existing values as selectable suggestions
981 if (columnValues.isNotEmpty() && selectedOperator in listOf("=", "!=")) {
983 text = "Existing values:",
984 style = MaterialTheme.typography.labelMedium,
985 color = MaterialTheme.colorScheme.onSurfaceVariant,
987 // Filter suggestions by typed text
988 val filteredValues = remember(columnValues, filterValue) {
989 if (filterValue.isBlank()) columnValues
990 else columnValues.filter {
991 it.contains(filterValue, ignoreCase = true)
997 .horizontalScroll(rememberScrollState()),
998 horizontalArrangement = Arrangement.spacedBy(6.dp),
1000 filteredValues.forEach { value ->
1003 onApplyFilter(selectedColumn, selectedOperator, value)
1007 text = if (value.length > 40) value.take(40) + "\u2026" else value,
1009 overflow = TextOverflow.Ellipsis,
1019 val hasValueError = selectedOperator in listOf(">", "<", ">=", "<=") &&
1020 filterValue.isNotEmpty() &&
1021 filterValue.toDoubleOrNull() == null &&
1022 !filterValue.matches(Regex("\\d{4}-\\d{2}-\\d{2}.*"))
1025 modifier = Modifier.fillMaxWidth(),
1026 horizontalArrangement = Arrangement.End,
1027 verticalAlignment = Alignment.CenterVertically,
1029 TextButton(onClick = onDismiss) {
1032 Spacer(modifier = Modifier.width(8.dp))
1035 if (selectedColumn.isNotBlank()) {
1036 onApplyFilter(selectedColumn, selectedOperator, filterValue)
1039 enabled = selectedColumn.isNotBlank() &&
1041 (selectedOperator in listOf("IS NULL", "IS NOT NULL") || filterValue.isNotEmpty()),
1047 Spacer(modifier = Modifier.height(32.dp))
1052@OptIn(ExperimentalMaterial3Api::class)
1054fun SqlQueryBottomSheet(
1057 onDismiss: () -> Unit,
1058 onExecute: (String) -> Unit,
1060 val safeName = remember(streamName) { escapeIdentifier(streamName) }
1061 var sql by remember {
1063 currentSql.ifEmpty {
1064 "SELECT * FROM \"$safeName\" ORDER BY p_timestamp DESC LIMIT 100"
1069 ModalBottomSheet(onDismissRequest = onDismiss) {
1071 modifier = Modifier.padding(16.dp),
1072 verticalArrangement = Arrangement.spacedBy(12.dp),
1076 style = MaterialTheme.typography.titleLarge,
1077 fontWeight = FontWeight.Bold,
1081 text = "Use PostgreSQL-compatible SQL to query your logs.",
1082 style = MaterialTheme.typography.bodySmall,
1083 color = MaterialTheme.colorScheme.onSurfaceVariant,
1088 onValueChange = { sql = it },
1089 label = { Text("SQL") },
1092 .heightIn(min = 120.dp),
1093 textStyle = MaterialTheme.typography.bodyMedium.copy(
1094 fontFamily = FontFamily.Monospace,
1100 modifier = Modifier.fillMaxWidth(),
1101 horizontalArrangement = Arrangement.End,
1103 TextButton(onClick = onDismiss) {
1106 Spacer(modifier = Modifier.width(8.dp))
1108 onClick = { onExecute(sql) },
1109 enabled = sql.isNotBlank(),
1111 Icon(Icons.Filled.PlayArrow, contentDescription = "Execute query")
1112 Spacer(modifier = Modifier.width(4.dp))
1117 Spacer(modifier = Modifier.height(32.dp))
1122@OptIn(ExperimentalMaterial3Api::class)
1124fun SavedFiltersBottomSheet(
1125 savedFiltersState: SavedFiltersState,
1126 hasActiveFilters: Boolean,
1127 onDismiss: () -> Unit,
1128 onSaveCurrentFilter: () -> Unit,
1129 onApplyFilter: (com.parseable.android.data.model.SavedFilter) -> Unit,
1130 onDeleteFilter: (String) -> Unit,
1131 onRetry: () -> Unit,
1133 var filterToDelete by remember { mutableStateOf<String?>(null) }
1135 ModalBottomSheet(onDismissRequest = onDismiss) {
1137 modifier = Modifier.padding(16.dp),
1138 verticalArrangement = Arrangement.spacedBy(12.dp),
1141 modifier = Modifier.fillMaxWidth(),
1142 horizontalArrangement = Arrangement.SpaceBetween,
1143 verticalAlignment = Alignment.CenterVertically,
1146 text = "Saved Filters",
1147 style = MaterialTheme.typography.titleLarge,
1148 fontWeight = FontWeight.Bold,
1150 if (hasActiveFilters) {
1151 FilledTonalButton(onClick = onSaveCurrentFilter) {
1154 contentDescription = null,
1155 modifier = Modifier.size(18.dp),
1157 Spacer(modifier = Modifier.width(4.dp))
1158 Text("Save Current")
1164 savedFiltersState.isLoading -> {
1169 contentAlignment = Alignment.Center,
1171 CircularProgressIndicator()
1174 savedFiltersState.error != null -> {
1179 horizontalAlignment = Alignment.CenterHorizontally,
1182 text = savedFiltersState.error,
1183 style = MaterialTheme.typography.bodyMedium,
1184 color = MaterialTheme.colorScheme.error,
1186 Spacer(modifier = Modifier.height(8.dp))
1187 OutlinedButton(onClick = onRetry) {
1192 savedFiltersState.filters.isEmpty() -> {
1197 contentAlignment = Alignment.Center,
1199 Column(horizontalAlignment = Alignment.CenterHorizontally) {
1201 Icons.Filled.BookmarkBorder,
1202 contentDescription = null,
1203 modifier = Modifier.size(40.dp),
1204 tint = MaterialTheme.colorScheme.onSurfaceVariant,
1206 Spacer(modifier = Modifier.height(8.dp))
1208 text = "No saved filters for this stream",
1209 style = MaterialTheme.typography.bodyMedium,
1210 color = MaterialTheme.colorScheme.onSurfaceVariant,
1212 if (hasActiveFilters) {
1213 Spacer(modifier = Modifier.height(4.dp))
1215 text = "Use \"Save Current\" to save your active filters",
1216 style = MaterialTheme.typography.bodySmall,
1217 color = MaterialTheme.colorScheme.onSurfaceVariant,
1224 savedFiltersState.filters.forEach { filter ->
1226 onClick = { onApplyFilter(filter) },
1227 modifier = Modifier.fillMaxWidth(),
1233 verticalAlignment = Alignment.CenterVertically,
1236 Icons.Filled.Bookmark,
1237 contentDescription = null,
1238 tint = MaterialTheme.colorScheme.primary,
1239 modifier = Modifier.size(20.dp),
1241 Spacer(modifier = Modifier.width(12.dp))
1242 Column(modifier = Modifier.weight(1f)) {
1244 text = filter.filterName,
1245 style = MaterialTheme.typography.titleSmall,
1246 fontWeight = FontWeight.Medium,
1248 val description = when (filter.query.filterType) {
1249 "sql" -> filter.query.filterQuery?.take(60) ?: "SQL query"
1250 "search" -> "Search: ${filter.query.filterQuery ?: ""}"
1252 val ruleCount = filter.query.filterBuilder?.rules
1253 ?.sumOf { it.rules.size } ?: 0
1254 if (ruleCount > 0) "$ruleCount filter rule(s)" else "No rules"
1259 style = MaterialTheme.typography.bodySmall,
1260 color = MaterialTheme.colorScheme.onSurfaceVariant,
1262 overflow = TextOverflow.Ellipsis,
1265 IconButton(onClick = {
1266 filterToDelete = filter.filterId
1269 Icons.Filled.Delete,
1270 contentDescription = "Delete filter",
1271 tint = MaterialTheme.colorScheme.error,
1280 Spacer(modifier = Modifier.height(32.dp))
1284 // Delete confirmation dialog
1285 if (filterToDelete != null) {
1287 onDismissRequest = { filterToDelete = null },
1288 title = { Text("Delete Filter") },
1289 text = { Text("Are you sure you want to delete this saved filter? This will remove it from the server.") },
1291 TextButton(onClick = {
1292 filterToDelete?.let { onDeleteFilter(it) }
1293 filterToDelete = null
1295 Text("Delete", color = MaterialTheme.colorScheme.error)
1299 TextButton(onClick = { filterToDelete = null }) {
1308fun SaveFilterDialog(
1310 onDismiss: () -> Unit,
1311 onSave: (String) -> Unit,
1313 var filterName by remember { mutableStateOf("") }
1316 onDismissRequest = onDismiss,
1317 title = { Text("Save Filter") },
1321 text = "Save your current filters to the Parseable server. You can load them later on any device.",
1322 style = MaterialTheme.typography.bodySmall,
1323 color = MaterialTheme.colorScheme.onSurfaceVariant,
1325 Spacer(modifier = Modifier.height(12.dp))
1328 onValueChange = { filterName = it },
1329 label = { Text("Filter name") },
1331 modifier = Modifier.fillMaxWidth(),
1332 enabled = !isSaving,
1338 CircularProgressIndicator(
1341 .padding(end = 8.dp),
1346 onClick = { onSave(filterName.trim()) },
1347 enabled = filterName.isNotBlank(),
1354 TextButton(onClick = onDismiss, enabled = !isSaving) {
keysesc to close
- j k
- move down, up
- enter
- open
- esc
- back, or up a level
- 1 2 3 4
- readme, files, commits, refs
- /
- filter
- y
- copy the clone command
- c
- compare the chosen commit with another (or shift-click both)
- ?
- show or hide this list