@@ -0,0 +1,1423 @@
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) {