@@ -1,1423 +0,0 @@
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 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− // Live-tail re-derives its own default SELECT * query in the poller, so
105− // it can't honor a custom SQL projection/filter. Disable the toggle while
106− // a custom query is active to avoid prepending mismatched rows.
107− enabled = state.customSql.isBlank(),
108− onClick = viewModel::toggleStreaming,
110− IconButton(onClick = { onStreamInfo(streamName) }) {
111− Icon(Icons.Filled.Info, contentDescription = "Stream Info")
113− IconButton(onClick = { showFilterSheet = true }) {
114− Icon(Icons.Filled.FilterList, contentDescription = "Filters")
116− IconButton(onClick = { showSqlSheet = true }) {
117− Icon(Icons.Filled.Code, contentDescription = "SQL Query")
119− IconButton(onClick = {
120− viewModel.loadSavedFilters()
121− showSavedFiltersSheet = true
123− Icon(Icons.Filled.BookmarkBorder, contentDescription = "Saved Filters")
126− onClick = { showShareWarning = true },
127− enabled = state.logs.isNotEmpty(),
129− Icon(Icons.Filled.Share, contentDescription = "Share logs")
142− selectedRange = state.selectedTimeRange,
143− onRangeSelected = { range ->
144− if (range == TimeRange.CUSTOM) {
145− showDateRangePicker = true
147− viewModel.onTimeRangeChange(range)
154− value = state.searchQuery,
155− onValueChange = viewModel::onSearchQueryChange,
156− placeholder = { Text("Search logs...") },
157− leadingIcon = { Icon(Icons.Filled.Search, contentDescription = "Search") },
159− if (state.isSearching) {
160− CircularProgressIndicator(
161− modifier = Modifier.size(20.dp),
164− } else if (state.searchQuery.isNotEmpty()) {
165− IconButton(onClick = { viewModel.onSearchQueryChange("") }) {
166− Icon(Icons.Filled.Clear, contentDescription = "Clear")
171− keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
172− keyboardActions = KeyboardActions(onSearch = {
173− keyboardController?.hide()
174− // Runs the search now and cancels the pending debounce (which stops live-tail
175− // and refreshes) so we don't fire a second, redundant refresh ~300ms later.
176− viewModel.submitSearch()
180− .padding(horizontal = 16.dp, vertical = 4.dp),
181− textStyle = MaterialTheme.typography.bodyMedium,
184− // Active filters indicator
185− if (state.activeFilters.isNotEmpty()) {
188− .horizontalScroll(rememberScrollState())
189− .padding(horizontal = 16.dp, vertical = 4.dp)
190− .animateContentSize(),
191− horizontalArrangement = Arrangement.spacedBy(8.dp),
193− state.activeFilters.forEach { filter ->
196− onClick = { viewModel.removeFilter(filter) },
197− label = { Text(filter, maxLines = 1) },
201− contentDescription = "Remove filter",
202− modifier = Modifier.size(16.dp),
207− TextButton(onClick = viewModel::clearFilters) {
213− // Streaming indicator banner
214− AnimatedVisibility(visible = state.isStreaming) {
216− color = MaterialTheme.colorScheme.primaryContainer,
217− modifier = Modifier.fillMaxWidth(),
220− modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
221− verticalAlignment = Alignment.CenterVertically,
223− val infiniteTransition = rememberInfiniteTransition(label = "pulse")
224− val alpha by infiniteTransition.animateFloat(
227− animationSpec = infiniteRepeatable(
228− animation = tween(800, easing = EaseInOut),
229− repeatMode = RepeatMode.Reverse,
231− label = "pulse_alpha",
234− Icons.Filled.FiberManualRecord,
235− contentDescription = "Live streaming active",
236− tint = MaterialTheme.colorScheme.error,
241− Spacer(modifier = Modifier.width(8.dp))
243− text = "Live tail active - polling every ${state.currentIntervalMs / 1000}s",
244− style = MaterialTheme.typography.labelMedium,
245− color = MaterialTheme.colorScheme.onPrimaryContainer,
246− modifier = Modifier.weight(1f),
248− TextButton(onClick = viewModel::stopStreaming) {
255− // Streaming error banner
256− AnimatedVisibility(visible = state.streamingError != null) {
258− color = MaterialTheme.colorScheme.errorContainer,
259− modifier = Modifier.fillMaxWidth(),
262− modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
263− verticalAlignment = Alignment.CenterVertically,
266− Icons.Filled.ErrorOutline,
267− contentDescription = "Streaming error",
268− tint = MaterialTheme.colorScheme.onErrorContainer,
269− modifier = Modifier.size(16.dp),
271− Spacer(modifier = Modifier.width(8.dp))
273− text = state.streamingError ?: "",
274− style = MaterialTheme.typography.labelMedium,
275− color = MaterialTheme.colorScheme.onErrorContainer,
276− modifier = Modifier.weight(1f),
278− overflow = TextOverflow.Ellipsis,
280− TextButton(onClick = viewModel::dismissStreamingError) {
289− isRefreshing = state.isLoading,
290− onRefresh = viewModel::pullRefresh,
291− modifier = Modifier.fillMaxSize(),
293− // Hoisted above the error/loading/empty/list branches so the scroll position,
294− // the clipboard handle, and the viewing-top / load-more effects survive
295− // transitions through those states. If they lived inside the list `else` branch
296− // they would be disposed and recreated on every empty/loading/error transition,
297− // resetting scroll to the top and re-arming loadMore mid-load.
298− val listState = rememberLazyListState()
299− val clipboard = remember {
300− context.getSystemService(android.content.Context.CLIPBOARD_SERVICE) as android.content.ClipboardManager
303− // Tell the ViewModel when the list is at the top so it can clear the live-tail
304− // "+N new" badge — new rows prepend at the top and are seen immediately there.
305− val atTop by remember {
307− listState.firstVisibleItemIndex == 0 && listState.firstVisibleItemScrollOffset == 0
310− LaunchedEffect(atTop) { viewModel.setViewingTop(atTop) }
312− // Auto-load more when scrolling near the bottom.
313− val shouldLoadMore by remember {
315− val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0
316− lastVisible >= listState.layoutInfo.totalItemsCount - 5
319− LaunchedEffect(shouldLoadMore, state.hasMore, state.isLoading) {
320− if (shouldLoadMore && state.hasMore && !state.isLoading) {
321− viewModel.loadMore()
325− if (state.error != null && state.logs.isEmpty()) {
327− modifier = Modifier.fillMaxSize(),
328− contentAlignment = Alignment.Center,
330− Column(horizontalAlignment = Alignment.CenterHorizontally) {
332− Icons.Filled.ErrorOutline,
333− contentDescription = "Error loading logs",
334− modifier = Modifier.size(48.dp),
335− tint = MaterialTheme.colorScheme.error,
337− Spacer(modifier = Modifier.height(16.dp))
339− text = state.error.orEmpty(),
340− style = MaterialTheme.typography.bodyMedium,
341− color = MaterialTheme.colorScheme.error,
342− modifier = Modifier.padding(horizontal = 32.dp),
344− Spacer(modifier = Modifier.height(16.dp))
345− OutlinedButton(onClick = viewModel::refresh) {
350− } else if (state.logs.isEmpty() && state.isLoading) {
352− modifier = Modifier.fillMaxSize(),
353− contentAlignment = Alignment.Center,
355− CircularProgressIndicator()
357− } else if (state.logs.isEmpty() && !state.isLoading) {
358− val emptyMessage = when {
359− state.isStreaming ->
360− "Waiting for new logs\u2026"
361− state.searchQuery.isNotBlank() && state.activeFilters.isNotEmpty() ->
362− "No logs match \"${state.searchQuery}\" with the active filters"
363− state.searchQuery.isNotBlank() ->
364− "No logs match \"${state.searchQuery}\""
365− state.activeFilters.isNotEmpty() ->
366− "No logs match the active filters"
368− "No logs found for the selected time range"
371− modifier = Modifier.fillMaxSize(),
372− contentAlignment = Alignment.Center,
374− Column(horizontalAlignment = Alignment.CenterHorizontally) {
376− Icons.Filled.SearchOff,
377− contentDescription = "No logs found",
378− modifier = Modifier.size(48.dp),
379− tint = MaterialTheme.colorScheme.onSurfaceVariant,
381− Spacer(modifier = Modifier.height(16.dp))
383− text = emptyMessage,
384− style = MaterialTheme.typography.bodyLarge,
385− color = MaterialTheme.colorScheme.onSurfaceVariant,
386− modifier = Modifier.padding(horizontal = 32.dp),
393− contentPadding = PaddingValues(8.dp),
394− verticalArrangement = Arrangement.spacedBy(4.dp),
398− key = { index, _ -> state.logKeys.getOrElse(index) { "log_$index" } },
399− ) { index, logEntry ->
400− val key = state.logKeys.getOrElse(index) { "log_$index" }
402− logEntry = logEntry,
404− isExpanded = expandedLogKey == key,
406− if (expandedLogKey == key) {
407− // Collapsing: if the top of the entry is scrolled
408− // above the viewport, jump to it so the user sees
409− // the collapsed card instead of a random position.
410− // Resolve the row by its stable key, not the captured
411− // index — live-tail prepends shift every row's index,
412− // so the closed-over index can point at the wrong row.
413− val itemInfo = listState.layoutInfo.visibleItemsInfo
414− .firstOrNull { it.key == key }
415− val needsScroll = itemInfo == null || itemInfo.offset < 0
416− expandedLogKey = null
418− val target = state.logKeys.indexOf(key).takeIf { it >= 0 } ?: index
420− listState.animateScrollToItem(target)
424− expandedLogKey = key
427− clipboardManager = clipboard,
430− snackbarHostState.showSnackbar(
431− message = "Log entry copied to clipboard",
432− duration = SnackbarDuration.Short,
436− onFilterInclude = { column, value ->
437− viewModel.addFilter(column, "=", value)
439− onFilterExclude = { column, value ->
440− viewModel.addFilter(column, "!=", value)
445− if (state.hasMore) {
451− contentAlignment = Alignment.Center,
453− CircularProgressIndicator(
454− modifier = Modifier.size(24.dp),
459− } else if (state.logs.isNotEmpty()) {
465− contentAlignment = Alignment.Center,
467− val atCap = state.currentLimit >= 5000 && state.logs.size >= 5000
470− "Showing max 5,000 results. Refine your query to see more."
474− style = MaterialTheme.typography.labelMedium,
475− color = if (atCap) {
476− MaterialTheme.colorScheme.primary
478− MaterialTheme.colorScheme.onSurfaceVariant
490− // Filter bottom sheet
491− if (showFilterSheet) {
493− columns = state.columns,
495− onDismiss = { showFilterSheet = false },
496− onApplyFilter = { column, operator, value ->
497− viewModel.addFilter(column, operator, value)
498− showFilterSheet = false
503− // SQL query bottom sheet
505− SqlQueryBottomSheet(
506− streamName = streamName,
507− currentSql = state.customSql,
508− onDismiss = { showSqlSheet = false },
509− onExecute = { sql ->
510− viewModel.executeCustomSql(sql)
511− showSqlSheet = false
516− // Share confirmation dialog (logs may contain sensitive data)
517− if (showShareWarning) {
519− onDismissRequest = { showShareWarning = false },
520− title = { Text("Share Logs") },
522− Text("Logs may contain sensitive information (PII, secrets, tokens). " +
523− "They will be exported as unencrypted JSON. Continue?")
526− TextButton(onClick = {
527− showShareWarning = false
528− val logs = state.logs
529− if (logs.isNotEmpty()) {
532− val file = withContext(kotlinx.coroutines.Dispatchers.IO) {
533− val dir = java.io.File(context.cacheDir, "shared_logs")
534− if (!dir.mkdirs() && !dir.isDirectory) {
535− throw java.io.IOException("Failed to create shared_logs directory")
537− // Clean up STALE exports only. A share chooser opened moments
538− // ago may still be reading a recent file via its FileProvider
539− // URI; deleting every file (as before) could pull one out from
540− // under an in-flight share. Keep anything written in the last
541− // hour and give each export a unique name so concurrent shares
543− val cutoff = System.currentTimeMillis() - 60 * 60 * 1000L
544− dir.listFiles()?.forEach { old ->
545− if (old.lastModified() < cutoff) old.delete()
547− val safeFileName = streamName.replace(Regex("[^a-zA-Z0-9._-]"), "_")
548− val file = java.io.File(dir, "logs_${safeFileName}_${System.currentTimeMillis()}.json")
549− file.bufferedWriter().use { writer ->
550− // Use compact JSON for large exports to reduce memory/file size
551− val encoder = if (logs.size > 1000) Json else prettyJson
553− logs.forEachIndexed { index, log ->
554− if (index > 0) writer.write(",")
556− writer.write(encoder.encodeToString(JsonObject.serializer(), log))
563− val uri = FileProvider.getUriForFile(
565− "${context.packageName}.fileprovider",
568− val sendIntent = Intent().apply {
569− action = Intent.ACTION_SEND
570− putExtra(Intent.EXTRA_STREAM, uri)
571− putExtra(Intent.EXTRA_SUBJECT, "Logs: $streamName")
572− type = "application/json"
573− addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
575− context.startActivity(
576− Intent.createChooser(sendIntent, "Share logs")
578− } catch (e: Exception) {
579− snackbarHostState.showSnackbar(
580− message = "Failed to share logs: ${e.message}",
581− duration = SnackbarDuration.Short,
591− TextButton(onClick = { showShareWarning = false }) {
598− // Custom date range picker – state is created inside the block so it
599− // resets every time the dialog reopens (no stale selection carried over).
600− if (showDateRangePicker) {
601− val dateRangePickerState = rememberDateRangePickerState()
603− onDismissRequest = { showDateRangePicker = false },
607− val start = dateRangePickerState.selectedStartDateMillis
608− val end = dateRangePickerState.selectedEndDateMillis
609− if (start != null && end != null) {
610− // DateRangePicker returns midnight UTC for the selected date, so the
611− // calendar date must be read back in UTC — reading it in the local zone
612− // would roll west-of-UTC users to the previous day. Once we have the
613− // tapped date, build the boundaries to cover the full day locally.
614− val localZone = java.time.ZoneId.systemDefault()
615− val endLocalDate = java.time.Instant.ofEpochMilli(end)
616− .atZone(java.time.ZoneOffset.UTC).toLocalDate()
617− val endOfDayUtc = endLocalDate.plusDays(1)
618− .atStartOfDay(localZone)
619− .toInstant().toEpochMilli() - 1
620− val startLocalDate = java.time.Instant.ofEpochMilli(start)
621− .atZone(java.time.ZoneOffset.UTC).toLocalDate()
622− val startUtc = startLocalDate
623− .atStartOfDay(localZone)
624− .toInstant().toEpochMilli()
625− viewModel.setCustomTimeRange(startUtc, endOfDayUtc)
627− showDateRangePicker = false
629− enabled = dateRangePickerState.selectedStartDateMillis != null &&
630− dateRangePickerState.selectedEndDateMillis != null,
636− TextButton(onClick = { showDateRangePicker = false }) {
642− state = dateRangePickerState,
643− modifier = Modifier.weight(1f),
648− // Saved filters bottom sheet
649− if (showSavedFiltersSheet) {
650− SavedFiltersBottomSheet(
651− savedFiltersState = state.savedFilters,
652− hasActiveFilters = state.activeFilters.isNotEmpty() ||
653− state.searchQuery.isNotBlank() ||
654− state.customSql.isNotBlank(),
655− onDismiss = { showSavedFiltersSheet = false },
656− onSaveCurrentFilter = { showSaveFilterDialog = true },
657− onApplyFilter = { filter ->
658− viewModel.applySavedFilter(filter)
659− showSavedFiltersSheet = false
661− onDeleteFilter = { filterId -> viewModel.deleteSavedFilter(filterId) },
662− onRetry = { viewModel.loadSavedFilters() },
666− // Save filter name dialog
667− if (showSaveFilterDialog) {
669− isSaving = state.savedFilters.isSaving,
670− onDismiss = { showSaveFilterDialog = false },
672− viewModel.saveCurrentFilter(name)
673− showSaveFilterDialog = false
674− showSavedFiltersSheet = false
681−private fun TimeRangeBar(
682− selectedRange: TimeRange,
683− onRangeSelected: (TimeRange) -> Unit,
687− .horizontalScroll(rememberScrollState())
688− .padding(horizontal = 16.dp, vertical = 4.dp),
689− horizontalArrangement = Arrangement.spacedBy(8.dp),
691− TimeRange.entries.forEach { range ->
693− selected = selectedRange == range,
694− onClick = { onRangeSelected(range) },
695− label = { Text(range.label) },
702−private fun StreamingToggleButton(
703− isStreaming: Boolean,
705− onClick: () -> Unit,
707− IconButton(onClick = onClick, enabled = enabled || isStreaming) {
709− val infiniteTransition = rememberInfiniteTransition(label = "stream_pulse")
710− val alpha by infiniteTransition.animateFloat(
713− animationSpec = infiniteRepeatable(
714− animation = tween(600, easing = EaseInOut),
715− repeatMode = RepeatMode.Reverse,
717− label = "stream_btn_alpha",
721− contentDescription = "Stop streaming",
722− tint = MaterialTheme.colorScheme.error,
723− modifier = Modifier.alpha(alpha),
727− Icons.Filled.PlayArrow,
728− contentDescription = "Start live tail",
729− tint = MaterialTheme.colorScheme.primary,
737− logEntry: JsonObject,
739− isExpanded: Boolean,
740− onClick: () -> Unit,
741− onCopied: () -> Unit = {},
742− onFilterInclude: (column: String, value: String) -> Unit = { _, _ -> },
743− onFilterExclude: (column: String, value: String) -> Unit = { _, _ -> },
744− clipboardManager: android.content.ClipboardManager,
746− val severity = remember(logEntry) { detectSeverity(logEntry) }
747− val severityTint = severityBackgroundColor(severity)
748− val containerColor = severityTint ?: MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
749− val cardColors = CardDefaults.cardColors(containerColor = containerColor)
752− modifier = Modifier.fillMaxWidth(),
753− colors = cardColors,
755− Column(modifier = Modifier.padding(12.dp)) {
757− // Key the derived values on the content-stable log key (not the JsonObject
758− // instance), so live-tail re-emissions of unchanged rows don't re-parse them.
759− val rawTimestamp = remember(stableKey) {
761− logEntry["p_timestamp"]?.jsonPrimitive?.content
762− ?: logEntry["datetime"]?.jsonPrimitive?.content
764− } catch (_: Exception) {
768− val displayTimestamp = remember(rawTimestamp) {
769− if (rawTimestamp.isNotEmpty()) formatTimestamp(rawTimestamp) else ""
772− if (displayTimestamp.isNotEmpty()) {
774− text = displayTimestamp,
775− style = MaterialTheme.typography.labelSmall,
776− color = MaterialTheme.colorScheme.primary,
777− fontFamily = FontFamily.Monospace,
779− Spacer(modifier = Modifier.height(4.dp))
783− // Compact view: show first meaningful fields
784− val preview = remember(stableKey) {
786− .filter { !it.key.startsWith("p_") || it.key == "p_timestamp" }
788− .joinToString(" | ") { "${it.key}: ${formatJsonValue(it.value)}" }
791− text = preview.ifEmpty { logEntry.toString() },
792− style = MaterialTheme.typography.bodySmall,
793− fontFamily = FontFamily.Monospace,
795− overflow = TextOverflow.Ellipsis,
799− // Expanded view: show all fields + copy button
801− modifier = Modifier.fillMaxWidth(),
802− horizontalArrangement = Arrangement.End,
806− val text = prettyJson.encodeToString(JsonObject.serializer(), logEntry)
808− clipboardManager.setPrimaryClip(
809− android.content.ClipData.newPlainText("Log entry", text)
812− } catch (_: SecurityException) {
813− // Some devices/custom ROMs restrict clipboard access
818− Icons.Filled.ContentCopy,
819− contentDescription = "Copy log entry to clipboard",
820− tint = MaterialTheme.colorScheme.onSurfaceVariant,
824− HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
825− logEntry.entries.forEach { (key, value) ->
826− val displayValue = formatJsonValue(value)
830− .padding(vertical = 2.dp),
831− verticalAlignment = Alignment.CenterVertically,
833− Column(modifier = Modifier.weight(1f)) {
837− style = MaterialTheme.typography.bodySmall,
838− fontWeight = FontWeight.Bold,
839− fontFamily = FontFamily.Monospace,
840− color = MaterialTheme.colorScheme.primary,
844− text = displayValue,
845− style = MaterialTheme.typography.bodySmall,
846− fontFamily = FontFamily.Monospace,
851− // Filter include (=) button
853− onClick = { onFilterInclude(key, displayValue) },
854− modifier = Modifier.size(28.dp),
857− Icons.Filled.AddCircle,
858− contentDescription = "Filter for $key = $displayValue",
859− modifier = Modifier.size(16.dp),
860− tint = MaterialTheme.colorScheme.primary,
863− // Filter exclude (!=) button
865− onClick = { onFilterExclude(key, displayValue) },
866− modifier = Modifier.size(28.dp),
869− Icons.Filled.RemoveCircle,
870− contentDescription = "Filter out $key = $displayValue",
871− modifier = Modifier.size(16.dp),
872− tint = MaterialTheme.colorScheme.error,
882−private fun formatJsonValue(element: JsonElement): String {
883− return when (element) {
884− is JsonPrimitive -> element.content
885− is JsonNull -> "null"
886− else -> element.toString()
890−@OptIn(ExperimentalMaterial3Api::class)
892−fun FilterBottomSheet(
893− columns: List<String>,
894− logs: List<JsonObject> = emptyList(),
895− onDismiss: () -> Unit,
896− onApplyFilter: (column: String, operator: String, value: String) -> Unit,
898− var selectedColumn by remember { mutableStateOf(columns.firstOrNull() ?: "") }
899− // The filter icon is always enabled, so the sheet can open before the schema (and thus
900− // `columns`) has loaded. In that case `selectedColumn` initializes to "" and — because the
901− // remember above isn't keyed on `columns` — would stay blank even after columns arrive,
902− // leaving the Column field empty and the Apply button permanently disabled. Backfill the
903− // default once columns are available, but only while the selection is still blank so a
904− // user's manual pick isn't clobbered by a late schema refresh.
905− LaunchedEffect(columns) {
906− if (selectedColumn.isBlank()) {
907− selectedColumn = columns.firstOrNull() ?: ""
910− var selectedOperator by remember { mutableStateOf("=") }
911− var filterValue by remember { mutableStateOf("") }
912− var columnDropdownExpanded by remember { mutableStateOf(false) }
913− var operatorDropdownExpanded by remember { mutableStateOf(false) }
915− val operators = listOf("=", "!=", "LIKE", "ILIKE", ">", "<", ">=", "<=", "IS NULL", "IS NOT NULL")
917− // Extract distinct values for the selected column from current logs
918− val columnValues = remember(selectedColumn, logs) {
919− if (selectedColumn.isBlank() || logs.isEmpty()) {
923− .mapNotNull { entry ->
925− entry[selectedColumn]?.let { element ->
926− formatJsonValue(element).takeIf { it != "null" && it.isNotBlank() }
928− } catch (_: Exception) {
939− ModalBottomSheet(onDismissRequest = onDismiss) {
941− modifier = Modifier.padding(16.dp),
942− verticalArrangement = Arrangement.spacedBy(12.dp),
945− text = "Add Filter",
946− style = MaterialTheme.typography.titleLarge,
947− fontWeight = FontWeight.Bold,
951− ExposedDropdownMenuBox(
952− expanded = columnDropdownExpanded,
953− onExpandedChange = { columnDropdownExpanded = it },
956− value = selectedColumn,
959− label = { Text("Column") },
960− trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = columnDropdownExpanded) },
963− .menuAnchor(MenuAnchorType.PrimaryNotEditable),
965− ExposedDropdownMenu(
966− expanded = columnDropdownExpanded,
967− onDismissRequest = { columnDropdownExpanded = false },
969− columns.forEach { column ->
971− text = { Text(column) },
973− selectedColumn = column
975− columnDropdownExpanded = false
982− // Operator selector
983− ExposedDropdownMenuBox(
984− expanded = operatorDropdownExpanded,
985− onExpandedChange = { operatorDropdownExpanded = it },
988− value = selectedOperator,
991− label = { Text("Operator") },
992− trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = operatorDropdownExpanded) },
995− .menuAnchor(MenuAnchorType.PrimaryNotEditable),
997− ExposedDropdownMenu(
998− expanded = operatorDropdownExpanded,
999− onDismissRequest = { operatorDropdownExpanded = false },
1001− operators.forEach { op ->
1003− text = { Text(op) },
1005− selectedOperator = op
1006− operatorDropdownExpanded = false
1013− // Value input (hidden for IS NULL / IS NOT NULL)
1014− if (selectedOperator !in listOf("IS NULL", "IS NOT NULL")) {
1015− val valueHint = when (selectedOperator) {
1016− "LIKE", "ILIKE" -> "Pattern (% added automatically)"
1017− ">", "<", ">=", "<=" -> "Numeric or date value"
1018− else -> "Exact value"
1020− val valueError = when {
1021− filterValue.isNotEmpty() && selectedOperator in listOf(">", "<", ">=", "<=") &&
1022− filterValue.toDoubleOrNull() == null &&
1023− !filterValue.matches(Regex("\\d{4}-\\d{2}-\\d{2}.*")) ->
1024− "Expected a number or date"
1028− value = filterValue,
1029− onValueChange = { filterValue = it },
1030− label = { Text("Value") },
1031− placeholder = { Text(valueHint) },
1032− modifier = Modifier.fillMaxWidth(),
1034− isError = valueError != null,
1035− supportingText = valueError?.let { { Text(it) } },
1036− keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
1037− keyboardActions = KeyboardActions(onDone = {
1038− if (selectedColumn.isNotBlank() && filterValue.isNotEmpty() && valueError == null) {
1039− onApplyFilter(selectedColumn, selectedOperator, filterValue)
1044− // Show existing values as selectable suggestions
1045− if (columnValues.isNotEmpty() && selectedOperator in listOf("=", "!=")) {
1047− text = "Existing values:",
1048− style = MaterialTheme.typography.labelMedium,
1049− color = MaterialTheme.colorScheme.onSurfaceVariant,
1051− // Filter suggestions by typed text
1052− val filteredValues = remember(columnValues, filterValue) {
1053− if (filterValue.isBlank()) columnValues
1054− else columnValues.filter {
1055− it.contains(filterValue, ignoreCase = true)
1059− modifier = Modifier
1061− .horizontalScroll(rememberScrollState()),
1062− horizontalArrangement = Arrangement.spacedBy(6.dp),
1064− filteredValues.forEach { value ->
1067− onApplyFilter(selectedColumn, selectedOperator, value)
1071− text = if (value.length > 40) value.take(40) + "\u2026" else value,
1073− overflow = TextOverflow.Ellipsis,
1083− val hasValueError = selectedOperator in listOf(">", "<", ">=", "<=") &&
1084− filterValue.isNotEmpty() &&
1085− filterValue.toDoubleOrNull() == null &&
1086− !filterValue.matches(Regex("\\d{4}-\\d{2}-\\d{2}.*"))
1089− modifier = Modifier.fillMaxWidth(),
1090− horizontalArrangement = Arrangement.End,
1091− verticalAlignment = Alignment.CenterVertically,
1093− TextButton(onClick = onDismiss) {
1096− Spacer(modifier = Modifier.width(8.dp))
1099− if (selectedColumn.isNotBlank()) {
1100− onApplyFilter(selectedColumn, selectedOperator, filterValue)
1103− enabled = selectedColumn.isNotBlank() &&
1105− (selectedOperator in listOf("IS NULL", "IS NOT NULL") || filterValue.isNotEmpty()),
1111− Spacer(modifier = Modifier.height(32.dp))
1116−@OptIn(ExperimentalMaterial3Api::class)
1118−fun SqlQueryBottomSheet(
1119− streamName: String,
1120− currentSql: String,
1121− onDismiss: () -> Unit,
1122− onExecute: (String) -> Unit,
1124− val safeName = remember(streamName) { escapeIdentifier(streamName) }
1125− var sql by remember {
1127− currentSql.ifEmpty {
1128− "SELECT * FROM \"$safeName\" ORDER BY p_timestamp DESC LIMIT 100"
1133− ModalBottomSheet(onDismissRequest = onDismiss) {
1135− modifier = Modifier.padding(16.dp),
1136− verticalArrangement = Arrangement.spacedBy(12.dp),
1139− text = "SQL Query",
1140− style = MaterialTheme.typography.titleLarge,
1141− fontWeight = FontWeight.Bold,
1145− text = "Use PostgreSQL-compatible SQL to query your logs.",
1146− style = MaterialTheme.typography.bodySmall,
1147− color = MaterialTheme.colorScheme.onSurfaceVariant,
1152− onValueChange = { sql = it },
1153− label = { Text("SQL") },
1154− modifier = Modifier
1156− .heightIn(min = 120.dp),
1157− textStyle = MaterialTheme.typography.bodyMedium.copy(
1158− fontFamily = FontFamily.Monospace,
1164− modifier = Modifier.fillMaxWidth(),
1165− horizontalArrangement = Arrangement.End,
1167− TextButton(onClick = onDismiss) {
1170− Spacer(modifier = Modifier.width(8.dp))
1172− onClick = { onExecute(sql) },
1173− enabled = sql.isNotBlank(),
1175− Icon(Icons.Filled.PlayArrow, contentDescription = "Execute query")
1176− Spacer(modifier = Modifier.width(4.dp))
1181− Spacer(modifier = Modifier.height(32.dp))
1186−@OptIn(ExperimentalMaterial3Api::class)
1188−fun SavedFiltersBottomSheet(
1189− savedFiltersState: SavedFiltersState,
1190− hasActiveFilters: Boolean,
1191− onDismiss: () -> Unit,
1192− onSaveCurrentFilter: () -> Unit,
1193− onApplyFilter: (com.parseable.android.data.model.SavedFilter) -> Unit,
1194− onDeleteFilter: (String) -> Unit,
1195− onRetry: () -> Unit,
1197− var filterToDelete by remember { mutableStateOf<String?>(null) }
1199− ModalBottomSheet(onDismissRequest = onDismiss) {
1201− modifier = Modifier.padding(16.dp),
1202− verticalArrangement = Arrangement.spacedBy(12.dp),
1205− modifier = Modifier.fillMaxWidth(),
1206− horizontalArrangement = Arrangement.SpaceBetween,
1207− verticalAlignment = Alignment.CenterVertically,
1210− text = "Saved Filters",
1211− style = MaterialTheme.typography.titleLarge,
1212− fontWeight = FontWeight.Bold,
1214− if (hasActiveFilters) {
1215− FilledTonalButton(onClick = onSaveCurrentFilter) {
1218− contentDescription = null,
1219− modifier = Modifier.size(18.dp),
1221− Spacer(modifier = Modifier.width(4.dp))
1222− Text("Save Current")
1228− savedFiltersState.isLoading -> {
1230− modifier = Modifier
1233− contentAlignment = Alignment.Center,
1235− CircularProgressIndicator()
1238− savedFiltersState.error != null -> {
1240− modifier = Modifier
1243− horizontalAlignment = Alignment.CenterHorizontally,
1246− text = savedFiltersState.error,
1247− style = MaterialTheme.typography.bodyMedium,
1248− color = MaterialTheme.colorScheme.error,
1250− Spacer(modifier = Modifier.height(8.dp))
1251− OutlinedButton(onClick = onRetry) {
1256− savedFiltersState.filters.isEmpty() -> {
1258− modifier = Modifier
1261− contentAlignment = Alignment.Center,
1263− Column(horizontalAlignment = Alignment.CenterHorizontally) {
1265− Icons.Filled.BookmarkBorder,
1266− contentDescription = null,
1267− modifier = Modifier.size(40.dp),
1268− tint = MaterialTheme.colorScheme.onSurfaceVariant,
1270− Spacer(modifier = Modifier.height(8.dp))
1272− text = "No saved filters for this stream",
1273− style = MaterialTheme.typography.bodyMedium,
1274− color = MaterialTheme.colorScheme.onSurfaceVariant,
1276− if (hasActiveFilters) {
1277− Spacer(modifier = Modifier.height(4.dp))
1279− text = "Use \"Save Current\" to save your active filters",
1280− style = MaterialTheme.typography.bodySmall,
1281− color = MaterialTheme.colorScheme.onSurfaceVariant,
1288− savedFiltersState.filters.forEach { filter ->
1290− onClick = { onApplyFilter(filter) },
1291− modifier = Modifier.fillMaxWidth(),
1294− modifier = Modifier
1297− verticalAlignment = Alignment.CenterVertically,
1300− Icons.Filled.Bookmark,
1301− contentDescription = null,
1302− tint = MaterialTheme.colorScheme.primary,
1303− modifier = Modifier.size(20.dp),
1305− Spacer(modifier = Modifier.width(12.dp))
1306− Column(modifier = Modifier.weight(1f)) {
1308− text = filter.filterName,
1309− style = MaterialTheme.typography.titleSmall,
1310− fontWeight = FontWeight.Medium,
1312− val description = when (filter.query.filterType) {
1313− "sql" -> filter.query.filterQuery?.take(60) ?: "SQL query"
1314− "search" -> "Search: ${filter.query.filterQuery ?: ""}"
1316− val ruleCount = filter.query.filterBuilder?.rules
1317− ?.sumOf { it.rules.size } ?: 0
1318− if (ruleCount > 0) "$ruleCount filter rule(s)" else "No rules"
1322− text = description,
1323− style = MaterialTheme.typography.bodySmall,
1324− color = MaterialTheme.colorScheme.onSurfaceVariant,
1326− overflow = TextOverflow.Ellipsis,
1329− IconButton(onClick = {
1330− filterToDelete = filter.filterId
1333− Icons.Filled.Delete,
1334− contentDescription = "Delete filter",
1335− tint = MaterialTheme.colorScheme.error,
1344− Spacer(modifier = Modifier.height(32.dp))
1348− // Delete confirmation dialog
1349− if (filterToDelete != null) {
1351− onDismissRequest = { filterToDelete = null },
1352− title = { Text("Delete Filter") },
1353− text = { Text("Are you sure you want to delete this saved filter? This will remove it from the server.") },
1355− TextButton(onClick = {
1356− filterToDelete?.let { onDeleteFilter(it) }
1357− filterToDelete = null
1359− Text("Delete", color = MaterialTheme.colorScheme.error)
1363− TextButton(onClick = { filterToDelete = null }) {
1372−fun SaveFilterDialog(
1374− onDismiss: () -> Unit,
1375− onSave: (String) -> Unit,
1377− var filterName by remember { mutableStateOf("") }
1380− onDismissRequest = onDismiss,
1381− title = { Text("Save Filter") },
1385− text = "Save your current filters to the Parseable server. You can load them later on any device.",
1386− style = MaterialTheme.typography.bodySmall,
1387− color = MaterialTheme.colorScheme.onSurfaceVariant,
1389− Spacer(modifier = Modifier.height(12.dp))
1391− value = filterName,
1392− onValueChange = { filterName = it },
1393− label = { Text("Filter name") },
1395− modifier = Modifier.fillMaxWidth(),
1396− enabled = !isSaving,
1402− CircularProgressIndicator(
1403− modifier = Modifier
1405− .padding(end = 8.dp),
1406− strokeWidth = 2.dp,
1410− onClick = { onSave(filterName.trim()) },
1411− enabled = filterName.isNotBlank(),
1418− TextButton(onClick = onDismiss, enabled = !isSaving) {