@@ -0,0 +1,994 @@
1+package com.parseable.android.ui.screens.logviewer
2+
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.*
41+
42+private val prettyJson = Json { prettyPrint = true }
43+
44+@OptIn(ExperimentalMaterial3Api::class)
45+@Composable
46+fun LogViewerScreen(
47+ streamName: String,
48+ onBack: () -> Unit,
49+ onStreamInfo: (String) -> Unit,
50+ viewModel: LogViewerViewModel = hiltViewModel(),
51+) {
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) }
62+
63+ LaunchedEffect(streamName) {
64+ viewModel.initialize(streamName)
65+ }
66+
67+ Scaffold(
68+ snackbarHost = { SnackbarHost(snackbarHostState) },
69+ topBar = {
70+ TopAppBar(
71+ title = {
72+ Column {
73+ Text(
74+ text = streamName,
75+ fontWeight = FontWeight.Bold,
76+ maxLines = 1,
77+ overflow = TextOverflow.Ellipsis,
78+ )
79+ Text(
80+ text = if (state.isStreaming) {
81+ "Live - ${state.logs.size} logs (+${state.streamingNewCount} new)"
82+ } else {
83+ "${state.logs.size} results"
84+ },
85+ style = MaterialTheme.typography.bodySmall,
86+ color = if (state.isStreaming) {
87+ MaterialTheme.colorScheme.primary
88+ } else {
89+ MaterialTheme.colorScheme.onSurfaceVariant
90+ },
91+ )
92+ }
93+ },
94+ navigationIcon = {
95+ IconButton(onClick = onBack) {
96+ Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
97+ }
98+ },
99+ actions = {
100+ StreamingToggleButton(
101+ isStreaming = state.isStreaming,
102+ onClick = viewModel::toggleStreaming,
103+ )
104+ IconButton(onClick = { onStreamInfo(streamName) }) {
105+ Icon(Icons.Filled.Info, contentDescription = "Stream Info")
106+ }
107+ IconButton(onClick = { showFilterSheet = true }) {
108+ Icon(Icons.Filled.FilterList, contentDescription = "Filters")
109+ }
110+ IconButton(onClick = { showSqlSheet = true }) {
111+ Icon(Icons.Filled.Code, contentDescription = "SQL Query")
112+ }
113+ IconButton(
114+ onClick = { showShareWarning = true },
115+ enabled = state.logs.isNotEmpty(),
116+ ) {
117+ Icon(Icons.Filled.Share, contentDescription = "Share logs")
118+ }
119+ },
120+ )
121+ },
122+ ) { padding ->
123+ Column(
124+ modifier = Modifier
125+ .fillMaxSize()
126+ .padding(padding),
127+ ) {
128+ // Time range chips
129+ TimeRangeBar(
130+ selectedRange = state.selectedTimeRange,
131+ onRangeSelected = { range ->
132+ if (range == TimeRange.CUSTOM) {
133+ showDateRangePicker = true
134+ } else {
135+ viewModel.onTimeRangeChange(range)
136+ }
137+ },
138+ )
139+
140+ // Quick search bar
141+ OutlinedTextField(
142+ value = state.searchQuery,
143+ onValueChange = viewModel::onSearchQueryChange,
144+ placeholder = { Text("Search logs...") },
145+ leadingIcon = { Icon(Icons.Filled.Search, contentDescription = "Search") },
146+ trailingIcon = {
147+ if (state.isSearching) {
148+ CircularProgressIndicator(
149+ modifier = Modifier.size(20.dp),
150+ strokeWidth = 2.dp,
151+ )
152+ } else if (state.searchQuery.isNotEmpty()) {
153+ IconButton(onClick = { viewModel.onSearchQueryChange("") }) {
154+ Icon(Icons.Filled.Clear, contentDescription = "Clear")
155+ }
156+ }
157+ },
158+ singleLine = true,
159+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
160+ keyboardActions = KeyboardActions(onSearch = {
161+ keyboardController?.hide()
162+ viewModel.refresh()
163+ }),
164+ modifier = Modifier
165+ .fillMaxWidth()
166+ .padding(horizontal = 16.dp, vertical = 4.dp),
167+ textStyle = MaterialTheme.typography.bodyMedium,
168+ )
169+
170+ // Active filters indicator
171+ if (state.activeFilters.isNotEmpty()) {
172+ Row(
173+ modifier = Modifier
174+ .horizontalScroll(rememberScrollState())
175+ .padding(horizontal = 16.dp, vertical = 4.dp)
176+ .animateContentSize(),
177+ horizontalArrangement = Arrangement.spacedBy(8.dp),
178+ ) {
179+ state.activeFilters.forEach { filter ->
180+ FilterChip(
181+ selected = true,
182+ onClick = { viewModel.removeFilter(filter) },
183+ label = { Text(filter, maxLines = 1) },
184+ trailingIcon = {
185+ Icon(
186+ Icons.Filled.Close,
187+ contentDescription = "Remove filter",
188+ modifier = Modifier.size(16.dp),
189+ )
190+ },
191+ )
192+ }
193+ TextButton(onClick = viewModel::clearFilters) {
194+ Text("Clear all")
195+ }
196+ }
197+ }
198+
199+ // Streaming indicator banner
200+ AnimatedVisibility(visible = state.isStreaming) {
201+ Surface(
202+ color = MaterialTheme.colorScheme.primaryContainer,
203+ modifier = Modifier.fillMaxWidth(),
204+ ) {
205+ Row(
206+ modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
207+ verticalAlignment = Alignment.CenterVertically,
208+ ) {
209+ val infiniteTransition = rememberInfiniteTransition(label = "pulse")
210+ val alpha by infiniteTransition.animateFloat(
211+ initialValue = 1f,
212+ targetValue = 0.3f,
213+ animationSpec = infiniteRepeatable(
214+ animation = tween(800, easing = EaseInOut),
215+ repeatMode = RepeatMode.Reverse,
216+ ),
217+ label = "pulse_alpha",
218+ )
219+ Icon(
220+ Icons.Filled.FiberManualRecord,
221+ contentDescription = "Live streaming active",
222+ tint = MaterialTheme.colorScheme.error,
223+ modifier = Modifier
224+ .size(12.dp)
225+ .alpha(alpha),
226+ )
227+ Spacer(modifier = Modifier.width(8.dp))
228+ Text(
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),
233+ )
234+ TextButton(onClick = viewModel::stopStreaming) {
235+ Text("Stop")
236+ }
237+ }
238+ }
239+ }
240+
241+ // Streaming error banner
242+ AnimatedVisibility(visible = state.streamingError != null) {
243+ Surface(
244+ color = MaterialTheme.colorScheme.errorContainer,
245+ modifier = Modifier.fillMaxWidth(),
246+ ) {
247+ Row(
248+ modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
249+ verticalAlignment = Alignment.CenterVertically,
250+ ) {
251+ Icon(
252+ Icons.Filled.ErrorOutline,
253+ contentDescription = "Streaming error",
254+ tint = MaterialTheme.colorScheme.onErrorContainer,
255+ modifier = Modifier.size(16.dp),
256+ )
257+ Spacer(modifier = Modifier.width(8.dp))
258+ Text(
259+ text = state.streamingError ?: "",
260+ style = MaterialTheme.typography.labelMedium,
261+ color = MaterialTheme.colorScheme.onErrorContainer,
262+ modifier = Modifier.weight(1f),
263+ maxLines = 1,
264+ overflow = TextOverflow.Ellipsis,
265+ )
266+ TextButton(onClick = viewModel::dismissStreamingError) {
267+ Text("Dismiss")
268+ }
269+ }
270+ }
271+ }
272+
273+ // Log entries
274+ PullToRefreshBox(
275+ isRefreshing = state.isLoading,
276+ onRefresh = viewModel::refresh,
277+ modifier = Modifier.fillMaxSize(),
278+ ) {
279+ if (state.error != null && state.logs.isEmpty()) {
280+ Box(
281+ modifier = Modifier.fillMaxSize(),
282+ contentAlignment = Alignment.Center,
283+ ) {
284+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
285+ Icon(
286+ Icons.Filled.ErrorOutline,
287+ contentDescription = "Error loading logs",
288+ modifier = Modifier.size(48.dp),
289+ tint = MaterialTheme.colorScheme.error,
290+ )
291+ Spacer(modifier = Modifier.height(16.dp))
292+ Text(
293+ text = state.error.orEmpty(),
294+ style = MaterialTheme.typography.bodyMedium,
295+ color = MaterialTheme.colorScheme.error,
296+ modifier = Modifier.padding(horizontal = 32.dp),
297+ )
298+ Spacer(modifier = Modifier.height(16.dp))
299+ OutlinedButton(onClick = viewModel::refresh) {
300+ Text("Retry")
301+ }
302+ }
303+ }
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"
314+ else ->
315+ "No logs found for the selected time range"
316+ }
317+ Box(
318+ modifier = Modifier.fillMaxSize(),
319+ contentAlignment = Alignment.Center,
320+ ) {
321+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
322+ Icon(
323+ Icons.Filled.SearchOff,
324+ contentDescription = "No logs found",
325+ modifier = Modifier.size(48.dp),
326+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
327+ )
328+ Spacer(modifier = Modifier.height(16.dp))
329+ Text(
330+ text = emptyMessage,
331+ style = MaterialTheme.typography.bodyLarge,
332+ color = MaterialTheme.colorScheme.onSurfaceVariant,
333+ modifier = Modifier.padding(horizontal = 32.dp),
334+ )
335+ }
336+ }
337+ } else {
338+ val listState = rememberLazyListState()
339+ val clipboard = remember {
340+ context.getSystemService(android.content.Context.CLIPBOARD_SERVICE) as android.content.ClipboardManager
341+ }
342+
343+ // Auto-load more when scrolling near the bottom
344+ if (state.hasMore && !state.isLoading) {
345+ val shouldLoadMore by remember {
346+ derivedStateOf {
347+ val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0
348+ lastVisible >= listState.layoutInfo.totalItemsCount - 5
349+ }
350+ }
351+ LaunchedEffect(shouldLoadMore) {
352+ if (shouldLoadMore) viewModel.loadMore()
353+ }
354+ }
355+
356+ // Pre-compute stable keys so each log is assigned exactly one key
357+ val logKeys = remember(state.logs) {
358+ stableLogKeys(state.logs)
359+ }
360+
361+ LazyColumn(
362+ state = listState,
363+ contentPadding = PaddingValues(8.dp),
364+ verticalArrangement = Arrangement.spacedBy(4.dp),
365+ ) {
366+ itemsIndexed(
367+ state.logs,
368+ key = { index, _ -> logKeys[index] },
369+ ) { index, logEntry ->
370+ val key = logKeys[index]
371+ LogEntryCard(
372+ logEntry = logEntry,
373+ isExpanded = expandedLogKey == key,
374+ onClick = {
375+ expandedLogKey = if (expandedLogKey == key) null else key
376+ },
377+ clipboardManager = clipboard,
378+ onCopied = {
379+ scope.launch {
380+ snackbarHostState.showSnackbar(
381+ message = "Log entry copied to clipboard",
382+ duration = SnackbarDuration.Short,
383+ )
384+ }
385+ },
386+ )
387+ }
388+
389+ if (state.hasMore) {
390+ item {
391+ Box(
392+ modifier = Modifier
393+ .fillMaxWidth()
394+ .padding(16.dp),
395+ contentAlignment = Alignment.Center,
396+ ) {
397+ CircularProgressIndicator(
398+ modifier = Modifier.size(24.dp),
399+ strokeWidth = 2.dp,
400+ )
401+ }
402+ }
403+ } else if (state.logs.isNotEmpty()) {
404+ item {
405+ Box(
406+ modifier = Modifier
407+ .fillMaxWidth()
408+ .padding(16.dp),
409+ contentAlignment = Alignment.Center,
410+ ) {
411+ val atCap = state.currentLimit >= 5000 && state.logs.size >= 5000
412+ Text(
413+ text = if (atCap) {
414+ "Showing max 5,000 results. Refine your query to see more."
415+ } else {
416+ "End of results"
417+ },
418+ style = MaterialTheme.typography.labelMedium,
419+ color = if (atCap) {
420+ MaterialTheme.colorScheme.primary
421+ } else {
422+ MaterialTheme.colorScheme.onSurfaceVariant
423+ },
424+ )
425+ }
426+ }
427+ }
428+ }
429+ }
430+ }
431+ }
432+ }
433+
434+ // Filter bottom sheet
435+ if (showFilterSheet) {
436+ FilterBottomSheet(
437+ columns = state.columns,
438+ onDismiss = { showFilterSheet = false },
439+ onApplyFilter = { column, operator, value ->
440+ viewModel.addFilter(column, operator, value)
441+ showFilterSheet = false
442+ },
443+ )
444+ }
445+
446+ // SQL query bottom sheet
447+ if (showSqlSheet) {
448+ SqlQueryBottomSheet(
449+ streamName = streamName,
450+ currentSql = state.customSql,
451+ onDismiss = { showSqlSheet = false },
452+ onExecute = { sql ->
453+ viewModel.executeCustomSql(sql)
454+ showSqlSheet = false
455+ },
456+ )
457+ }
458+
459+ // Share confirmation dialog (logs may contain sensitive data)
460+ if (showShareWarning) {
461+ AlertDialog(
462+ onDismissRequest = { showShareWarning = false },
463+ title = { Text("Share Logs") },
464+ text = {
465+ Text("Logs may contain sensitive information (PII, secrets, tokens). " +
466+ "They will be exported as unencrypted JSON. Continue?")
467+ },
468+ confirmButton = {
469+ TextButton(onClick = {
470+ showShareWarning = false
471+ val logs = state.logs
472+ if (logs.isNotEmpty()) {
473+ scope.launch {
474+ try {
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")
479+ }
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
487+ writer.write("[")
488+ logs.forEachIndexed { index, log ->
489+ if (index > 0) writer.write(",")
490+ writer.newLine()
491+ writer.write(encoder.encodeToString(JsonObject.serializer(), log))
492+ }
493+ writer.newLine()
494+ writer.write("]")
495+ }
496+ file
497+ }
498+ val uri = FileProvider.getUriForFile(
499+ context,
500+ "${context.packageName}.fileprovider",
501+ file,
502+ )
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)
509+ }
510+ context.startActivity(
511+ Intent.createChooser(sendIntent, "Share logs")
512+ )
513+ } catch (e: Exception) {
514+ snackbarHostState.showSnackbar(
515+ message = "Failed to share logs: ${e.message}",
516+ duration = SnackbarDuration.Short,
517+ )
518+ }
519+ }
520+ }
521+ }) {
522+ Text("Share")
523+ }
524+ },
525+ dismissButton = {
526+ TextButton(onClick = { showShareWarning = false }) {
527+ Text("Cancel")
528+ }
529+ },
530+ )
531+ }
532+
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()
537+ DatePickerDialog(
538+ onDismissRequest = { showDateRangePicker = false },
539+ confirmButton = {
540+ TextButton(
541+ onClick = {
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)
560+ }
561+ showDateRangePicker = false
562+ },
563+ enabled = dateRangePickerState.selectedStartDateMillis != null &&
564+ dateRangePickerState.selectedEndDateMillis != null,
565+ ) {
566+ Text("Apply")
567+ }
568+ },
569+ dismissButton = {
570+ TextButton(onClick = { showDateRangePicker = false }) {
571+ Text("Cancel")
572+ }
573+ },
574+ ) {
575+ DateRangePicker(
576+ state = dateRangePickerState,
577+ modifier = Modifier.weight(1f),
578+ )
579+ }
580+ }
581+}
582+
583+@Composable
584+private fun TimeRangeBar(
585+ selectedRange: TimeRange,
586+ onRangeSelected: (TimeRange) -> Unit,
587+) {
588+ Row(
589+ modifier = Modifier
590+ .horizontalScroll(rememberScrollState())
591+ .padding(horizontal = 16.dp, vertical = 4.dp),
592+ horizontalArrangement = Arrangement.spacedBy(8.dp),
593+ ) {
594+ TimeRange.entries.forEach { range ->
595+ FilterChip(
596+ selected = selectedRange == range,
597+ onClick = { onRangeSelected(range) },
598+ label = { Text(range.label) },
599+ )
600+ }
601+ }
602+}
603+
604+@Composable
605+private fun StreamingToggleButton(
606+ isStreaming: Boolean,
607+ onClick: () -> Unit,
608+) {
609+ IconButton(onClick = onClick) {
610+ if (isStreaming) {
611+ val infiniteTransition = rememberInfiniteTransition(label = "stream_pulse")
612+ val alpha by infiniteTransition.animateFloat(
613+ initialValue = 1f,
614+ targetValue = 0.3f,
615+ animationSpec = infiniteRepeatable(
616+ animation = tween(600, easing = EaseInOut),
617+ repeatMode = RepeatMode.Reverse,
618+ ),
619+ label = "stream_btn_alpha",
620+ )
621+ Icon(
622+ Icons.Filled.Stop,
623+ contentDescription = "Stop streaming",
624+ tint = MaterialTheme.colorScheme.error,
625+ modifier = Modifier.alpha(alpha),
626+ )
627+ } else {
628+ Icon(
629+ Icons.Filled.PlayArrow,
630+ contentDescription = "Start live tail",
631+ tint = MaterialTheme.colorScheme.primary,
632+ )
633+ }
634+ }
635+}
636+
637+@Composable
638+fun LogEntryCard(
639+ logEntry: JsonObject,
640+ isExpanded: Boolean,
641+ onClick: () -> Unit,
642+ onCopied: () -> Unit = {},
643+ clipboardManager: android.content.ClipboardManager,
644+) {
645+ val containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
646+ val cardColors = CardDefaults.cardColors(containerColor = containerColor)
647+ Card(
648+ onClick = onClick,
649+ modifier = Modifier.fillMaxWidth(),
650+ colors = cardColors,
651+ ) {
652+ Column(modifier = Modifier.padding(12.dp)) {
653+ // Timestamp row
654+ val rawTimestamp = remember(logEntry) {
655+ try {
656+ logEntry["p_timestamp"]?.jsonPrimitive?.content
657+ ?: logEntry["datetime"]?.jsonPrimitive?.content
658+ ?: ""
659+ } catch (_: Exception) {
660+ ""
661+ }
662+ }
663+ val displayTimestamp = remember(rawTimestamp) {
664+ if (rawTimestamp.isNotEmpty()) formatTimestamp(rawTimestamp) else ""
665+ }
666+
667+ if (displayTimestamp.isNotEmpty()) {
668+ Text(
669+ text = displayTimestamp,
670+ style = MaterialTheme.typography.labelSmall,
671+ color = MaterialTheme.colorScheme.primary,
672+ fontFamily = FontFamily.Monospace,
673+ )
674+ Spacer(modifier = Modifier.height(4.dp))
675+ }
676+
677+ if (!isExpanded) {
678+ // Compact view: show first meaningful fields
679+ val preview = remember(logEntry) {
680+ logEntry.entries
681+ .filter { !it.key.startsWith("p_") || it.key == "p_timestamp" }
682+ .take(3)
683+ .joinToString(" | ") { "${it.key}: ${formatJsonValue(it.value)}" }
684+ }
685+ Text(
686+ text = preview.ifEmpty { logEntry.toString() },
687+ style = MaterialTheme.typography.bodySmall,
688+ fontFamily = FontFamily.Monospace,
689+ maxLines = 2,
690+ overflow = TextOverflow.Ellipsis,
691+ fontSize = 12.sp,
692+ )
693+ } else {
694+ // Expanded view: show all fields + copy button
695+ Row(
696+ modifier = Modifier.fillMaxWidth(),
697+ horizontalArrangement = Arrangement.End,
698+ ) {
699+ IconButton(
700+ onClick = {
701+ val text = prettyJson.encodeToString(JsonObject.serializer(), logEntry)
702+ try {
703+ clipboardManager.setPrimaryClip(
704+ android.content.ClipData.newPlainText("Log entry", text)
705+ )
706+ onCopied()
707+ } catch (_: SecurityException) {
708+ // Some devices/custom ROMs restrict clipboard access
709+ }
710+ },
711+ ) {
712+ Icon(
713+ Icons.Filled.ContentCopy,
714+ contentDescription = "Copy log entry to clipboard",
715+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
716+ )
717+ }
718+ }
719+ HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
720+ logEntry.entries.forEach { (key, value) ->
721+ Row(
722+ modifier = Modifier
723+ .fillMaxWidth()
724+ .padding(vertical = 2.dp),
725+ ) {
726+ Text(
727+ text = "$key: ",
728+ style = MaterialTheme.typography.bodySmall,
729+ fontWeight = FontWeight.Bold,
730+ fontFamily = FontFamily.Monospace,
731+ color = MaterialTheme.colorScheme.primary,
732+ fontSize = 12.sp,
733+ )
734+ Text(
735+ text = formatJsonValue(value),
736+ style = MaterialTheme.typography.bodySmall,
737+ fontFamily = FontFamily.Monospace,
738+ fontSize = 12.sp,
739+ )
740+ }
741+ }
742+ }
743+ }
744+ }
745+}
746+
747+/**
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.
751+ */
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"
762+ }
763+}
764+
765+private fun formatJsonValue(element: JsonElement): String {
766+ return when (element) {
767+ is JsonPrimitive -> element.content
768+ is JsonNull -> "null"
769+ else -> element.toString()
770+ }
771+}
772+
773+@OptIn(ExperimentalMaterial3Api::class)
774+@Composable
775+fun FilterBottomSheet(
776+ columns: List<String>,
777+ onDismiss: () -> Unit,
778+ onApplyFilter: (column: String, operator: String, value: String) -> Unit,
779+) {
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) }
785+
786+ val operators = listOf("=", "!=", "LIKE", "ILIKE", ">", "<", ">=", "<=", "IS NULL", "IS NOT NULL")
787+
788+ ModalBottomSheet(onDismissRequest = onDismiss) {
789+ Column(
790+ modifier = Modifier.padding(16.dp),
791+ verticalArrangement = Arrangement.spacedBy(12.dp),
792+ ) {
793+ Text(
794+ text = "Add Filter",
795+ style = MaterialTheme.typography.titleLarge,
796+ fontWeight = FontWeight.Bold,
797+ )
798+
799+ // Column selector
800+ ExposedDropdownMenuBox(
801+ expanded = columnDropdownExpanded,
802+ onExpandedChange = { columnDropdownExpanded = it },
803+ ) {
804+ OutlinedTextField(
805+ value = selectedColumn,
806+ onValueChange = {},
807+ readOnly = true,
808+ label = { Text("Column") },
809+ trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = columnDropdownExpanded) },
810+ modifier = Modifier
811+ .fillMaxWidth()
812+ .menuAnchor(MenuAnchorType.PrimaryNotEditable),
813+ )
814+ ExposedDropdownMenu(
815+ expanded = columnDropdownExpanded,
816+ onDismissRequest = { columnDropdownExpanded = false },
817+ ) {
818+ columns.forEach { column ->
819+ DropdownMenuItem(
820+ text = { Text(column) },
821+ onClick = {
822+ selectedColumn = column
823+ columnDropdownExpanded = false
824+ },
825+ )
826+ }
827+ }
828+ }
829+
830+ // Operator selector
831+ ExposedDropdownMenuBox(
832+ expanded = operatorDropdownExpanded,
833+ onExpandedChange = { operatorDropdownExpanded = it },
834+ ) {
835+ OutlinedTextField(
836+ value = selectedOperator,
837+ onValueChange = {},
838+ readOnly = true,
839+ label = { Text("Operator") },
840+ trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = operatorDropdownExpanded) },
841+ modifier = Modifier
842+ .fillMaxWidth()
843+ .menuAnchor(MenuAnchorType.PrimaryNotEditable),
844+ )
845+ ExposedDropdownMenu(
846+ expanded = operatorDropdownExpanded,
847+ onDismissRequest = { operatorDropdownExpanded = false },
848+ ) {
849+ operators.forEach { op ->
850+ DropdownMenuItem(
851+ text = { Text(op) },
852+ onClick = {
853+ selectedOperator = op
854+ operatorDropdownExpanded = false
855+ },
856+ )
857+ }
858+ }
859+ }
860+
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"
867+ }
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"
873+ else -> null
874+ }
875+ OutlinedTextField(
876+ value = filterValue,
877+ onValueChange = { filterValue = it },
878+ label = { Text("Value") },
879+ placeholder = { Text(valueHint) },
880+ modifier = Modifier.fillMaxWidth(),
881+ singleLine = true,
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)
888+ }
889+ }),
890+ )
891+ }
892+
893+ val hasValueError = selectedOperator in listOf(">", "<", ">=", "<=") &&
894+ filterValue.isNotEmpty() &&
895+ filterValue.toDoubleOrNull() == null &&
896+ !filterValue.matches(Regex("\\d{4}-\\d{2}-\\d{2}.*"))
897+
898+ Row(
899+ modifier = Modifier.fillMaxWidth(),
900+ horizontalArrangement = Arrangement.End,
901+ verticalAlignment = Alignment.CenterVertically,
902+ ) {
903+ TextButton(onClick = onDismiss) {
904+ Text("Cancel")
905+ }
906+ Spacer(modifier = Modifier.width(8.dp))
907+ Button(
908+ onClick = {
909+ if (selectedColumn.isNotBlank()) {
910+ onApplyFilter(selectedColumn, selectedOperator, filterValue)
911+ }
912+ },
913+ enabled = selectedColumn.isNotBlank() &&
914+ !hasValueError &&
915+ (selectedOperator in listOf("IS NULL", "IS NOT NULL") || filterValue.isNotEmpty()),
916+ ) {
917+ Text("Apply")
918+ }
919+ }
920+
921+ Spacer(modifier = Modifier.height(32.dp))
922+ }
923+ }
924+}
925+
926+@OptIn(ExperimentalMaterial3Api::class)
927+@Composable
928+fun SqlQueryBottomSheet(
929+ streamName: String,
930+ currentSql: String,
931+ onDismiss: () -> Unit,
932+ onExecute: (String) -> Unit,
933+) {
934+ val safeName = remember(streamName) { escapeIdentifier(streamName) }
935+ var sql by remember {
936+ mutableStateOf(
937+ currentSql.ifEmpty {
938+ "SELECT * FROM \"$safeName\" ORDER BY p_timestamp DESC LIMIT 100"
939+ }
940+ )
941+ }
942+
943+ ModalBottomSheet(onDismissRequest = onDismiss) {
944+ Column(
945+ modifier = Modifier.padding(16.dp),
946+ verticalArrangement = Arrangement.spacedBy(12.dp),
947+ ) {
948+ Text(
949+ text = "SQL Query",
950+ style = MaterialTheme.typography.titleLarge,
951+ fontWeight = FontWeight.Bold,
952+ )
953+
954+ Text(
955+ text = "Use PostgreSQL-compatible SQL to query your logs.",
956+ style = MaterialTheme.typography.bodySmall,
957+ color = MaterialTheme.colorScheme.onSurfaceVariant,
958+ )
959+
960+ OutlinedTextField(
961+ value = sql,
962+ onValueChange = { sql = it },
963+ label = { Text("SQL") },
964+ modifier = Modifier
965+ .fillMaxWidth()
966+ .heightIn(min = 120.dp),
967+ textStyle = MaterialTheme.typography.bodyMedium.copy(
968+ fontFamily = FontFamily.Monospace,
969+ ),
970+ maxLines = 8,
971+ )
972+
973+ Row(
974+ modifier = Modifier.fillMaxWidth(),
975+ horizontalArrangement = Arrangement.End,
976+ ) {
977+ TextButton(onClick = onDismiss) {
978+ Text("Cancel")
979+ }
980+ Spacer(modifier = Modifier.width(8.dp))
981+ Button(
982+ onClick = { onExecute(sql) },
983+ enabled = sql.isNotBlank(),
984+ ) {
985+ Icon(Icons.Filled.PlayArrow, contentDescription = "Execute query")
986+ Spacer(modifier = Modifier.width(4.dp))
987+ Text("Execute")
988+ }
989+ }
990+
991+ Spacer(modifier = Modifier.height(32.dp))
992+ }
993+ }
994+}