@@ -0,0 +1,994 @@
1+package com.parseable.android.ui.screens.logviewer
3+import androidx.compose.animation.AnimatedVisibility
4+import androidx.compose.animation.animateContentSize
5+import androidx.compose.animation.core.*
6+import androidx.compose.foundation.horizontalScroll
7+import androidx.compose.foundation.layout.*
8+import androidx.compose.foundation.lazy.LazyColumn
9+import androidx.compose.foundation.lazy.itemsIndexed
10+import androidx.compose.foundation.lazy.rememberLazyListState
11+import androidx.compose.foundation.rememberScrollState
12+import androidx.compose.foundation.text.KeyboardActions
13+import androidx.compose.foundation.text.KeyboardOptions
14+import androidx.compose.material.icons.Icons
15+import androidx.compose.material.icons.automirrored.filled.ArrowBack
16+import androidx.compose.material.icons.filled.*
17+import androidx.compose.material3.*
18+import androidx.compose.material3.MenuAnchorType
19+import androidx.compose.material3.pulltorefresh.PullToRefreshBox
20+import androidx.compose.runtime.*
21+import androidx.compose.ui.Alignment
22+import androidx.compose.ui.Modifier
23+import androidx.compose.ui.draw.alpha
24+import androidx.compose.ui.platform.LocalContext
25+import androidx.compose.ui.platform.LocalSoftwareKeyboardController
26+import androidx.compose.ui.text.font.FontFamily
27+import androidx.compose.ui.text.font.FontWeight
28+import androidx.compose.ui.text.input.ImeAction
29+import androidx.compose.ui.text.style.TextOverflow
30+import androidx.compose.ui.unit.dp
31+import androidx.compose.ui.unit.sp
32+import android.content.Intent
33+import androidx.core.content.FileProvider
34+import com.parseable.android.data.escapeIdentifier
35+import com.parseable.android.data.formatTimestamp
36+import androidx.hilt.navigation.compose.hiltViewModel
37+import androidx.lifecycle.compose.collectAsStateWithLifecycle
38+import kotlinx.coroutines.launch
39+import kotlinx.coroutines.withContext
40+import kotlinx.serialization.json.*
42+private 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 expandedLogKey by remember { mutableStateOf<String?>(null) }
60+ var showDateRangePicker by remember { mutableStateOf(false) }
61+ var showShareWarning by remember { mutableStateOf(false) }
63+ LaunchedEffect(streamName) {
64+ viewModel.initialize(streamName)
68+ snackbarHost = { SnackbarHost(snackbarHostState) },
75+ fontWeight = FontWeight.Bold,
77+ overflow = TextOverflow.Ellipsis,
80+ text = if (state.isStreaming) {
81+ "Live - ${state.logs.size} logs (+${state.streamingNewCount} new)"
83+ "${state.logs.size} results"
85+ style = MaterialTheme.typography.bodySmall,
86+ color = if (state.isStreaming) {
87+ MaterialTheme.colorScheme.primary
89+ MaterialTheme.colorScheme.onSurfaceVariant
95+ IconButton(onClick = onBack) {
96+ Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
100+ StreamingToggleButton(
101+ isStreaming = state.isStreaming,
102+ onClick = viewModel::toggleStreaming,
104+ IconButton(onClick = { onStreamInfo(streamName) }) {
105+ Icon(Icons.Filled.Info, contentDescription = "Stream Info")
107+ IconButton(onClick = { showFilterSheet = true }) {
108+ Icon(Icons.Filled.FilterList, contentDescription = "Filters")
110+ IconButton(onClick = { showSqlSheet = true }) {
111+ Icon(Icons.Filled.Code, contentDescription = "SQL Query")
114+ onClick = { showShareWarning = true },
115+ enabled = state.logs.isNotEmpty(),
117+ Icon(Icons.Filled.Share, contentDescription = "Share logs")
130+ selectedRange = state.selectedTimeRange,
131+ onRangeSelected = { range ->
132+ if (range == TimeRange.CUSTOM) {
133+ showDateRangePicker = true
135+ viewModel.onTimeRangeChange(range)
142+ value = state.searchQuery,
143+ onValueChange = viewModel::onSearchQueryChange,
144+ placeholder = { Text("Search logs...") },
145+ leadingIcon = { Icon(Icons.Filled.Search, contentDescription = "Search") },
147+ if (state.isSearching) {
148+ CircularProgressIndicator(
149+ modifier = Modifier.size(20.dp),
152+ } else if (state.searchQuery.isNotEmpty()) {
153+ IconButton(onClick = { viewModel.onSearchQueryChange("") }) {
154+ Icon(Icons.Filled.Clear, contentDescription = "Clear")
159+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
160+ keyboardActions = KeyboardActions(onSearch = {
161+ keyboardController?.hide()
166+ .padding(horizontal = 16.dp, vertical = 4.dp),
167+ textStyle = MaterialTheme.typography.bodyMedium,
170+ // Active filters indicator
171+ if (state.activeFilters.isNotEmpty()) {
174+ .horizontalScroll(rememberScrollState())
175+ .padding(horizontal = 16.dp, vertical = 4.dp)
176+ .animateContentSize(),
177+ horizontalArrangement = Arrangement.spacedBy(8.dp),
179+ state.activeFilters.forEach { filter ->
182+ onClick = { viewModel.removeFilter(filter) },
183+ label = { Text(filter, maxLines = 1) },
187+ contentDescription = "Remove filter",
188+ modifier = Modifier.size(16.dp),
193+ TextButton(onClick = viewModel::clearFilters) {
199+ // Streaming indicator banner
200+ AnimatedVisibility(visible = state.isStreaming) {
202+ color = MaterialTheme.colorScheme.primaryContainer,
203+ modifier = Modifier.fillMaxWidth(),
206+ modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
207+ verticalAlignment = Alignment.CenterVertically,
209+ val infiniteTransition = rememberInfiniteTransition(label = "pulse")
210+ val alpha by infiniteTransition.animateFloat(
213+ animationSpec = infiniteRepeatable(
214+ animation = tween(800, easing = EaseInOut),
215+ repeatMode = RepeatMode.Reverse,
217+ label = "pulse_alpha",
220+ Icons.Filled.FiberManualRecord,
221+ contentDescription = "Live streaming active",
222+ tint = MaterialTheme.colorScheme.error,
227+ Spacer(modifier = Modifier.width(8.dp))
229+ text = "Live tail active - polling every ${state.currentIntervalMs / 1000}s",
230+ style = MaterialTheme.typography.labelMedium,
231+ color = MaterialTheme.colorScheme.onPrimaryContainer,
232+ modifier = Modifier.weight(1f),
234+ TextButton(onClick = viewModel::stopStreaming) {
241+ // Streaming error banner
242+ AnimatedVisibility(visible = state.streamingError != null) {
244+ color = MaterialTheme.colorScheme.errorContainer,
245+ modifier = Modifier.fillMaxWidth(),
248+ modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
249+ verticalAlignment = Alignment.CenterVertically,
252+ Icons.Filled.ErrorOutline,
253+ contentDescription = "Streaming error",
254+ tint = MaterialTheme.colorScheme.onErrorContainer,
255+ modifier = Modifier.size(16.dp),
257+ Spacer(modifier = Modifier.width(8.dp))
259+ text = state.streamingError ?: "",
260+ style = MaterialTheme.typography.labelMedium,
261+ color = MaterialTheme.colorScheme.onErrorContainer,
262+ modifier = Modifier.weight(1f),
264+ overflow = TextOverflow.Ellipsis,
266+ TextButton(onClick = viewModel::dismissStreamingError) {
275+ isRefreshing = state.isLoading,
276+ onRefresh = viewModel::refresh,
277+ modifier = Modifier.fillMaxSize(),
279+ if (state.error != null && state.logs.isEmpty()) {
281+ modifier = Modifier.fillMaxSize(),
282+ contentAlignment = Alignment.Center,
284+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
286+ Icons.Filled.ErrorOutline,
287+ contentDescription = "Error loading logs",
288+ modifier = Modifier.size(48.dp),
289+ tint = MaterialTheme.colorScheme.error,
291+ Spacer(modifier = Modifier.height(16.dp))
293+ text = state.error.orEmpty(),
294+ style = MaterialTheme.typography.bodyMedium,
295+ color = MaterialTheme.colorScheme.error,
296+ modifier = Modifier.padding(horizontal = 32.dp),
298+ Spacer(modifier = Modifier.height(16.dp))
299+ OutlinedButton(onClick = viewModel::refresh) {
304+ } else if (state.logs.isEmpty() && !state.isLoading) {
305+ val emptyMessage = when {
306+ state.isStreaming ->
307+ "Waiting for new logs\u2026"
308+ state.searchQuery.isNotBlank() && state.activeFilters.isNotEmpty() ->
309+ "No logs match \"${state.searchQuery}\" with the active filters"
310+ state.searchQuery.isNotBlank() ->
311+ "No logs match \"${state.searchQuery}\""
312+ state.activeFilters.isNotEmpty() ->
313+ "No logs match the active filters"
315+ "No logs found for the selected time range"
318+ modifier = Modifier.fillMaxSize(),
319+ contentAlignment = Alignment.Center,
321+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
323+ Icons.Filled.SearchOff,
324+ contentDescription = "No logs found",
325+ modifier = Modifier.size(48.dp),
326+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
328+ Spacer(modifier = Modifier.height(16.dp))
330+ text = emptyMessage,
331+ style = MaterialTheme.typography.bodyLarge,
332+ color = MaterialTheme.colorScheme.onSurfaceVariant,
333+ modifier = Modifier.padding(horizontal = 32.dp),
338+ val listState = rememberLazyListState()
339+ val clipboard = remember {
340+ context.getSystemService(android.content.Context.CLIPBOARD_SERVICE) as android.content.ClipboardManager
343+ // Auto-load more when scrolling near the bottom
344+ if (state.hasMore && !state.isLoading) {
345+ val shouldLoadMore by remember {
347+ val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0
348+ lastVisible >= listState.layoutInfo.totalItemsCount - 5
351+ LaunchedEffect(shouldLoadMore) {
352+ if (shouldLoadMore) viewModel.loadMore()
356+ // Pre-compute stable keys so each log is assigned exactly one key
357+ val logKeys = remember(state.logs) {
358+ stableLogKeys(state.logs)
363+ contentPadding = PaddingValues(8.dp),
364+ verticalArrangement = Arrangement.spacedBy(4.dp),
368+ key = { index, _ -> logKeys[index] },
369+ ) { index, logEntry ->
370+ val key = logKeys[index]
372+ logEntry = logEntry,
373+ isExpanded = expandedLogKey == key,
375+ expandedLogKey = if (expandedLogKey == key) null else key
377+ clipboardManager = clipboard,
380+ snackbarHostState.showSnackbar(
381+ message = "Log entry copied to clipboard",
382+ duration = SnackbarDuration.Short,
389+ if (state.hasMore) {
395+ contentAlignment = Alignment.Center,
397+ CircularProgressIndicator(
398+ modifier = Modifier.size(24.dp),
403+ } else if (state.logs.isNotEmpty()) {
409+ contentAlignment = Alignment.Center,
411+ val atCap = state.currentLimit >= 5000 && state.logs.size >= 5000
414+ "Showing max 5,000 results. Refine your query to see more."
418+ style = MaterialTheme.typography.labelMedium,
419+ color = if (atCap) {
420+ MaterialTheme.colorScheme.primary
422+ MaterialTheme.colorScheme.onSurfaceVariant
434+ // Filter bottom sheet
435+ if (showFilterSheet) {
437+ columns = state.columns,
438+ onDismiss = { showFilterSheet = false },
439+ onApplyFilter = { column, operator, value ->
440+ viewModel.addFilter(column, operator, value)
441+ showFilterSheet = false
446+ // SQL query bottom sheet
448+ SqlQueryBottomSheet(
449+ streamName = streamName,
450+ currentSql = state.customSql,
451+ onDismiss = { showSqlSheet = false },
452+ onExecute = { sql ->
453+ viewModel.executeCustomSql(sql)
454+ showSqlSheet = false
459+ // Share confirmation dialog (logs may contain sensitive data)
460+ if (showShareWarning) {
462+ onDismissRequest = { showShareWarning = false },
463+ title = { Text("Share Logs") },
465+ Text("Logs may contain sensitive information (PII, secrets, tokens). " +
466+ "They will be exported as unencrypted JSON. Continue?")
469+ TextButton(onClick = {
470+ showShareWarning = false
471+ val logs = state.logs
472+ if (logs.isNotEmpty()) {
475+ val file = withContext(kotlinx.coroutines.Dispatchers.IO) {
476+ val dir = java.io.File(context.cacheDir, "shared_logs")
477+ if (!dir.mkdirs() && !dir.isDirectory) {
478+ throw java.io.IOException("Failed to create shared_logs directory")
480+ // Clean up previous exports
481+ dir.listFiles()?.forEach { old -> old.delete() }
482+ val safeFileName = streamName.replace(Regex("[^a-zA-Z0-9._-]"), "_")
483+ val file = java.io.File(dir, "logs_$safeFileName.json")
484+ file.bufferedWriter().use { writer ->
485+ // Use compact JSON for large exports to reduce memory/file size
486+ val encoder = if (logs.size > 1000) Json else prettyJson
488+ logs.forEachIndexed { index, log ->
489+ if (index > 0) writer.write(",")
491+ writer.write(encoder.encodeToString(JsonObject.serializer(), log))
498+ val uri = FileProvider.getUriForFile(
500+ "${context.packageName}.fileprovider",
503+ val sendIntent = Intent().apply {
504+ action = Intent.ACTION_SEND
505+ putExtra(Intent.EXTRA_STREAM, uri)
506+ putExtra(Intent.EXTRA_SUBJECT, "Logs: $streamName")
507+ type = "application/json"
508+ addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
510+ context.startActivity(
511+ Intent.createChooser(sendIntent, "Share logs")
513+ } catch (e: Exception) {
514+ snackbarHostState.showSnackbar(
515+ message = "Failed to share logs: ${e.message}",
516+ duration = SnackbarDuration.Short,
526+ TextButton(onClick = { showShareWarning = false }) {
533+ // Custom date range picker – state is created inside the block so it
534+ // resets every time the dialog reopens (no stale selection carried over).
535+ if (showDateRangePicker) {
536+ val dateRangePickerState = rememberDateRangePickerState()
538+ onDismissRequest = { showDateRangePicker = false },
542+ val start = dateRangePickerState.selectedStartDateMillis
543+ val end = dateRangePickerState.selectedEndDateMillis
544+ if (start != null && end != null) {
545+ // DateRangePicker returns midnight UTC for the selected date.
546+ // Adjust to cover the full end day in the user's local timezone:
547+ // Convert to local date, get end-of-day, convert back to UTC millis.
548+ val localZone = java.time.ZoneId.systemDefault()
549+ val endLocalDate = java.time.Instant.ofEpochMilli(end)
550+ .atZone(localZone).toLocalDate()
551+ val endOfDayUtc = endLocalDate.plusDays(1)
552+ .atStartOfDay(localZone)
553+ .toInstant().toEpochMilli() - 1
554+ val startLocalDate = java.time.Instant.ofEpochMilli(start)
555+ .atZone(localZone).toLocalDate()
556+ val startUtc = startLocalDate
557+ .atStartOfDay(localZone)
558+ .toInstant().toEpochMilli()
559+ viewModel.setCustomTimeRange(startUtc, endOfDayUtc)
561+ showDateRangePicker = false
563+ enabled = dateRangePickerState.selectedStartDateMillis != null &&
564+ dateRangePickerState.selectedEndDateMillis != null,
570+ TextButton(onClick = { showDateRangePicker = false }) {
576+ state = dateRangePickerState,
577+ modifier = Modifier.weight(1f),
584+private fun TimeRangeBar(
585+ selectedRange: TimeRange,
586+ onRangeSelected: (TimeRange) -> Unit,
590+ .horizontalScroll(rememberScrollState())
591+ .padding(horizontal = 16.dp, vertical = 4.dp),
592+ horizontalArrangement = Arrangement.spacedBy(8.dp),
594+ TimeRange.entries.forEach { range ->
596+ selected = selectedRange == range,
597+ onClick = { onRangeSelected(range) },
598+ label = { Text(range.label) },
605+private fun StreamingToggleButton(
606+ isStreaming: Boolean,
607+ onClick: () -> Unit,
609+ IconButton(onClick = onClick) {
611+ val infiniteTransition = rememberInfiniteTransition(label = "stream_pulse")
612+ val alpha by infiniteTransition.animateFloat(
615+ animationSpec = infiniteRepeatable(
616+ animation = tween(600, easing = EaseInOut),
617+ repeatMode = RepeatMode.Reverse,
619+ label = "stream_btn_alpha",
623+ contentDescription = "Stop streaming",
624+ tint = MaterialTheme.colorScheme.error,
625+ modifier = Modifier.alpha(alpha),
629+ Icons.Filled.PlayArrow,
630+ contentDescription = "Start live tail",
631+ tint = MaterialTheme.colorScheme.primary,
639+ logEntry: JsonObject,
640+ isExpanded: Boolean,
641+ onClick: () -> Unit,
642+ onCopied: () -> Unit = {},
643+ clipboardManager: android.content.ClipboardManager,
645+ val containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
646+ val cardColors = CardDefaults.cardColors(containerColor = containerColor)
649+ modifier = Modifier.fillMaxWidth(),
650+ colors = cardColors,
652+ Column(modifier = Modifier.padding(12.dp)) {
654+ val rawTimestamp = remember(logEntry) {
656+ logEntry["p_timestamp"]?.jsonPrimitive?.content
657+ ?: logEntry["datetime"]?.jsonPrimitive?.content
659+ } catch (_: Exception) {
663+ val displayTimestamp = remember(rawTimestamp) {
664+ if (rawTimestamp.isNotEmpty()) formatTimestamp(rawTimestamp) else ""
667+ if (displayTimestamp.isNotEmpty()) {
669+ text = displayTimestamp,
670+ style = MaterialTheme.typography.labelSmall,
671+ color = MaterialTheme.colorScheme.primary,
672+ fontFamily = FontFamily.Monospace,
674+ Spacer(modifier = Modifier.height(4.dp))
678+ // Compact view: show first meaningful fields
679+ val preview = remember(logEntry) {
681+ .filter { !it.key.startsWith("p_") || it.key == "p_timestamp" }
683+ .joinToString(" | ") { "${it.key}: ${formatJsonValue(it.value)}" }
686+ text = preview.ifEmpty { logEntry.toString() },
687+ style = MaterialTheme.typography.bodySmall,
688+ fontFamily = FontFamily.Monospace,
690+ overflow = TextOverflow.Ellipsis,
694+ // Expanded view: show all fields + copy button
696+ modifier = Modifier.fillMaxWidth(),
697+ horizontalArrangement = Arrangement.End,
701+ val text = prettyJson.encodeToString(JsonObject.serializer(), logEntry)
703+ clipboardManager.setPrimaryClip(
704+ android.content.ClipData.newPlainText("Log entry", text)
707+ } catch (_: SecurityException) {
708+ // Some devices/custom ROMs restrict clipboard access
713+ Icons.Filled.ContentCopy,
714+ contentDescription = "Copy log entry to clipboard",
715+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
719+ HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
720+ logEntry.entries.forEach { (key, value) ->
724+ .padding(vertical = 2.dp),
728+ style = MaterialTheme.typography.bodySmall,
729+ fontWeight = FontWeight.Bold,
730+ fontFamily = FontFamily.Monospace,
731+ color = MaterialTheme.colorScheme.primary,
735+ text = formatJsonValue(value),
736+ style = MaterialTheme.typography.bodySmall,
737+ fontFamily = FontFamily.Monospace,
748+ * Produces deterministic keys for a list of log entries so that expanded state survives
749+ * list mutations (e.g., new logs prepended during streaming). Keys are content-based
750+ * using p_timestamp + p_metadata + p_tags; duplicate content gets a disambiguation suffix.
752+private fun stableLogKeys(logs: List<JsonObject>): List<String> {
753+ val seen = mutableMapOf<String, Int>()
754+ return logs.map { log ->
755+ val ts = log["p_timestamp"]?.toString()
756+ val meta = log["p_metadata"]?.toString()
757+ val tag = log["p_tags"]?.toString()
758+ val base = if (ts != null) "$ts|${meta.orEmpty()}|${tag.orEmpty()}" else log.hashCode().toString()
759+ val count = seen.getOrDefault(base, 0)
760+ seen[base] = count + 1
761+ if (count == 0) base else "$base|$count"
765+private fun formatJsonValue(element: JsonElement): String {
766+ return when (element) {
767+ is JsonPrimitive -> element.content
768+ is JsonNull -> "null"
769+ else -> element.toString()
773+@OptIn(ExperimentalMaterial3Api::class)
775+fun FilterBottomSheet(
776+ columns: List<String>,
777+ onDismiss: () -> Unit,
778+ onApplyFilter: (column: String, operator: String, value: String) -> Unit,
780+ var selectedColumn by remember { mutableStateOf(columns.firstOrNull() ?: "") }
781+ var selectedOperator by remember { mutableStateOf("=") }
782+ var filterValue by remember { mutableStateOf("") }
783+ var columnDropdownExpanded by remember { mutableStateOf(false) }
784+ var operatorDropdownExpanded by remember { mutableStateOf(false) }
786+ val operators = listOf("=", "!=", "LIKE", "ILIKE", ">", "<", ">=", "<=", "IS NULL", "IS NOT NULL")
788+ ModalBottomSheet(onDismissRequest = onDismiss) {
790+ modifier = Modifier.padding(16.dp),
791+ verticalArrangement = Arrangement.spacedBy(12.dp),
794+ text = "Add Filter",
795+ style = MaterialTheme.typography.titleLarge,
796+ fontWeight = FontWeight.Bold,
800+ ExposedDropdownMenuBox(
801+ expanded = columnDropdownExpanded,
802+ onExpandedChange = { columnDropdownExpanded = it },
805+ value = selectedColumn,
808+ label = { Text("Column") },
809+ trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = columnDropdownExpanded) },
812+ .menuAnchor(MenuAnchorType.PrimaryNotEditable),
814+ ExposedDropdownMenu(
815+ expanded = columnDropdownExpanded,
816+ onDismissRequest = { columnDropdownExpanded = false },
818+ columns.forEach { column ->
820+ text = { Text(column) },
822+ selectedColumn = column
823+ columnDropdownExpanded = false
830+ // Operator selector
831+ ExposedDropdownMenuBox(
832+ expanded = operatorDropdownExpanded,
833+ onExpandedChange = { operatorDropdownExpanded = it },
836+ value = selectedOperator,
839+ label = { Text("Operator") },
840+ trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = operatorDropdownExpanded) },
843+ .menuAnchor(MenuAnchorType.PrimaryNotEditable),
845+ ExposedDropdownMenu(
846+ expanded = operatorDropdownExpanded,
847+ onDismissRequest = { operatorDropdownExpanded = false },
849+ operators.forEach { op ->
851+ text = { Text(op) },
853+ selectedOperator = op
854+ operatorDropdownExpanded = false
861+ // Value input (hidden for IS NULL / IS NOT NULL)
862+ if (selectedOperator !in listOf("IS NULL", "IS NOT NULL")) {
863+ val valueHint = when (selectedOperator) {
864+ "LIKE", "ILIKE" -> "Pattern (% added automatically)"
865+ ">", "<", ">=", "<=" -> "Numeric or date value"
866+ else -> "Exact value"
868+ val valueError = when {
869+ filterValue.isNotEmpty() && selectedOperator in listOf(">", "<", ">=", "<=") &&
870+ filterValue.toDoubleOrNull() == null &&
871+ !filterValue.matches(Regex("\\d{4}-\\d{2}-\\d{2}.*")) ->
872+ "Expected a number or date"
876+ value = filterValue,
877+ onValueChange = { filterValue = it },
878+ label = { Text("Value") },
879+ placeholder = { Text(valueHint) },
880+ modifier = Modifier.fillMaxWidth(),
882+ isError = valueError != null,
883+ supportingText = valueError?.let { { Text(it) } },
884+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
885+ keyboardActions = KeyboardActions(onDone = {
886+ if (selectedColumn.isNotBlank() && filterValue.isNotEmpty() && valueError == null) {
887+ onApplyFilter(selectedColumn, selectedOperator, filterValue)
893+ val hasValueError = selectedOperator in listOf(">", "<", ">=", "<=") &&
894+ filterValue.isNotEmpty() &&
895+ filterValue.toDoubleOrNull() == null &&
896+ !filterValue.matches(Regex("\\d{4}-\\d{2}-\\d{2}.*"))
899+ modifier = Modifier.fillMaxWidth(),
900+ horizontalArrangement = Arrangement.End,
901+ verticalAlignment = Alignment.CenterVertically,
903+ TextButton(onClick = onDismiss) {
906+ Spacer(modifier = Modifier.width(8.dp))
909+ if (selectedColumn.isNotBlank()) {
910+ onApplyFilter(selectedColumn, selectedOperator, filterValue)
913+ enabled = selectedColumn.isNotBlank() &&
915+ (selectedOperator in listOf("IS NULL", "IS NOT NULL") || filterValue.isNotEmpty()),
921+ Spacer(modifier = Modifier.height(32.dp))
926+@OptIn(ExperimentalMaterial3Api::class)
928+fun SqlQueryBottomSheet(
931+ onDismiss: () -> Unit,
932+ onExecute: (String) -> Unit,
934+ val safeName = remember(streamName) { escapeIdentifier(streamName) }
935+ var sql by remember {
937+ currentSql.ifEmpty {
938+ "SELECT * FROM \"$safeName\" ORDER BY p_timestamp DESC LIMIT 100"
943+ ModalBottomSheet(onDismissRequest = onDismiss) {
945+ modifier = Modifier.padding(16.dp),
946+ verticalArrangement = Arrangement.spacedBy(12.dp),
950+ style = MaterialTheme.typography.titleLarge,
951+ fontWeight = FontWeight.Bold,
955+ text = "Use PostgreSQL-compatible SQL to query your logs.",
956+ style = MaterialTheme.typography.bodySmall,
957+ color = MaterialTheme.colorScheme.onSurfaceVariant,
962+ onValueChange = { sql = it },
963+ label = { Text("SQL") },
966+ .heightIn(min = 120.dp),
967+ textStyle = MaterialTheme.typography.bodyMedium.copy(
968+ fontFamily = FontFamily.Monospace,
974+ modifier = Modifier.fillMaxWidth(),
975+ horizontalArrangement = Arrangement.End,
977+ TextButton(onClick = onDismiss) {
980+ Spacer(modifier = Modifier.width(8.dp))
982+ onClick = { onExecute(sql) },
983+ enabled = sql.isNotBlank(),
985+ Icon(Icons.Filled.PlayArrow, contentDescription = "Execute query")
986+ Spacer(modifier = Modifier.width(4.dp))
991+ Spacer(modifier = Modifier.height(32.dp))