@@ -0,0 +1,1423 @@
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 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) }
64+
65+ LaunchedEffect(streamName) {
66+ viewModel.initialize(streamName)
67+ }
68+
69+ Scaffold(
70+ snackbarHost = { SnackbarHost(snackbarHostState) },
71+ topBar = {
72+ TopAppBar(
73+ title = {
74+ Column {
75+ Text(
76+ text = streamName,
77+ fontWeight = FontWeight.Bold,
78+ maxLines = 1,
79+ overflow = TextOverflow.Ellipsis,
80+ )
81+ Text(
82+ text = if (state.isStreaming) {
83+ "Live - ${state.logs.size} logs (+${state.streamingNewCount} new)"
84+ } else {
85+ "${state.logs.size} results"
86+ },
87+ style = MaterialTheme.typography.bodySmall,
88+ color = if (state.isStreaming) {
89+ MaterialTheme.colorScheme.primary
90+ } else {
91+ MaterialTheme.colorScheme.onSurfaceVariant
92+ },
93+ )
94+ }
95+ },
96+ navigationIcon = {
97+ IconButton(onClick = onBack) {
98+ Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
99+ }
100+ },
101+ actions = {
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,
109+ )
110+ IconButton(onClick = { onStreamInfo(streamName) }) {
111+ Icon(Icons.Filled.Info, contentDescription = "Stream Info")
112+ }
113+ IconButton(onClick = { showFilterSheet = true }) {
114+ Icon(Icons.Filled.FilterList, contentDescription = "Filters")
115+ }
116+ IconButton(onClick = { showSqlSheet = true }) {
117+ Icon(Icons.Filled.Code, contentDescription = "SQL Query")
118+ }
119+ IconButton(onClick = {
120+ viewModel.loadSavedFilters()
121+ showSavedFiltersSheet = true
122+ }) {
123+ Icon(Icons.Filled.BookmarkBorder, contentDescription = "Saved Filters")
124+ }
125+ IconButton(
126+ onClick = { showShareWarning = true },
127+ enabled = state.logs.isNotEmpty(),
128+ ) {
129+ Icon(Icons.Filled.Share, contentDescription = "Share logs")
130+ }
131+ },
132+ )
133+ },
134+ ) { padding ->
135+ Column(
136+ modifier = Modifier
137+ .fillMaxSize()
138+ .padding(padding),
139+ ) {
140+ // Time range chips
141+ TimeRangeBar(
142+ selectedRange = state.selectedTimeRange,
143+ onRangeSelected = { range ->
144+ if (range == TimeRange.CUSTOM) {
145+ showDateRangePicker = true
146+ } else {
147+ viewModel.onTimeRangeChange(range)
148+ }
149+ },
150+ )
151+
152+ // Quick search bar
153+ OutlinedTextField(
154+ value = state.searchQuery,
155+ onValueChange = viewModel::onSearchQueryChange,
156+ placeholder = { Text("Search logs...") },
157+ leadingIcon = { Icon(Icons.Filled.Search, contentDescription = "Search") },
158+ trailingIcon = {
159+ if (state.isSearching) {
160+ CircularProgressIndicator(
161+ modifier = Modifier.size(20.dp),
162+ strokeWidth = 2.dp,
163+ )
164+ } else if (state.searchQuery.isNotEmpty()) {
165+ IconButton(onClick = { viewModel.onSearchQueryChange("") }) {
166+ Icon(Icons.Filled.Clear, contentDescription = "Clear")
167+ }
168+ }
169+ },
170+ singleLine = true,
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()
177+ }),
178+ modifier = Modifier
179+ .fillMaxWidth()
180+ .padding(horizontal = 16.dp, vertical = 4.dp),
181+ textStyle = MaterialTheme.typography.bodyMedium,
182+ )
183+
184+ // Active filters indicator
185+ if (state.activeFilters.isNotEmpty()) {
186+ Row(
187+ modifier = Modifier
188+ .horizontalScroll(rememberScrollState())
189+ .padding(horizontal = 16.dp, vertical = 4.dp)
190+ .animateContentSize(),
191+ horizontalArrangement = Arrangement.spacedBy(8.dp),
192+ ) {
193+ state.activeFilters.forEach { filter ->
194+ FilterChip(
195+ selected = true,
196+ onClick = { viewModel.removeFilter(filter) },
197+ label = { Text(filter, maxLines = 1) },
198+ trailingIcon = {
199+ Icon(
200+ Icons.Filled.Close,
201+ contentDescription = "Remove filter",
202+ modifier = Modifier.size(16.dp),
203+ )
204+ },
205+ )
206+ }
207+ TextButton(onClick = viewModel::clearFilters) {
208+ Text("Clear all")
209+ }
210+ }
211+ }
212+
213+ // Streaming indicator banner
214+ AnimatedVisibility(visible = state.isStreaming) {
215+ Surface(
216+ color = MaterialTheme.colorScheme.primaryContainer,
217+ modifier = Modifier.fillMaxWidth(),
218+ ) {
219+ Row(
220+ modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
221+ verticalAlignment = Alignment.CenterVertically,
222+ ) {
223+ val infiniteTransition = rememberInfiniteTransition(label = "pulse")
224+ val alpha by infiniteTransition.animateFloat(
225+ initialValue = 1f,
226+ targetValue = 0.3f,
227+ animationSpec = infiniteRepeatable(
228+ animation = tween(800, easing = EaseInOut),
229+ repeatMode = RepeatMode.Reverse,
230+ ),
231+ label = "pulse_alpha",
232+ )
233+ Icon(
234+ Icons.Filled.FiberManualRecord,
235+ contentDescription = "Live streaming active",
236+ tint = MaterialTheme.colorScheme.error,
237+ modifier = Modifier
238+ .size(12.dp)
239+ .alpha(alpha),
240+ )
241+ Spacer(modifier = Modifier.width(8.dp))
242+ Text(
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),
247+ )
248+ TextButton(onClick = viewModel::stopStreaming) {
249+ Text("Stop")
250+ }
251+ }
252+ }
253+ }
254+
255+ // Streaming error banner
256+ AnimatedVisibility(visible = state.streamingError != null) {
257+ Surface(
258+ color = MaterialTheme.colorScheme.errorContainer,
259+ modifier = Modifier.fillMaxWidth(),
260+ ) {
261+ Row(
262+ modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
263+ verticalAlignment = Alignment.CenterVertically,
264+ ) {
265+ Icon(
266+ Icons.Filled.ErrorOutline,
267+ contentDescription = "Streaming error",
268+ tint = MaterialTheme.colorScheme.onErrorContainer,
269+ modifier = Modifier.size(16.dp),
270+ )
271+ Spacer(modifier = Modifier.width(8.dp))
272+ Text(
273+ text = state.streamingError ?: "",
274+ style = MaterialTheme.typography.labelMedium,
275+ color = MaterialTheme.colorScheme.onErrorContainer,
276+ modifier = Modifier.weight(1f),
277+ maxLines = 1,
278+ overflow = TextOverflow.Ellipsis,
279+ )
280+ TextButton(onClick = viewModel::dismissStreamingError) {
281+ Text("Dismiss")
282+ }
283+ }
284+ }
285+ }
286+
287+ // Log entries
288+ PullToRefreshBox(
289+ isRefreshing = state.isLoading,
290+ onRefresh = viewModel::pullRefresh,
291+ modifier = Modifier.fillMaxSize(),
292+ ) {
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
301+ }
302+
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 {
306+ derivedStateOf {
307+ listState.firstVisibleItemIndex == 0 && listState.firstVisibleItemScrollOffset == 0
308+ }
309+ }
310+ LaunchedEffect(atTop) { viewModel.setViewingTop(atTop) }
311+
312+ // Auto-load more when scrolling near the bottom.
313+ val shouldLoadMore by remember {
314+ derivedStateOf {
315+ val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0
316+ lastVisible >= listState.layoutInfo.totalItemsCount - 5
317+ }
318+ }
319+ LaunchedEffect(shouldLoadMore, state.hasMore, state.isLoading) {
320+ if (shouldLoadMore && state.hasMore && !state.isLoading) {
321+ viewModel.loadMore()
322+ }
323+ }
324+
325+ if (state.error != null && state.logs.isEmpty()) {
326+ Box(
327+ modifier = Modifier.fillMaxSize(),
328+ contentAlignment = Alignment.Center,
329+ ) {
330+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
331+ Icon(
332+ Icons.Filled.ErrorOutline,
333+ contentDescription = "Error loading logs",
334+ modifier = Modifier.size(48.dp),
335+ tint = MaterialTheme.colorScheme.error,
336+ )
337+ Spacer(modifier = Modifier.height(16.dp))
338+ Text(
339+ text = state.error.orEmpty(),
340+ style = MaterialTheme.typography.bodyMedium,
341+ color = MaterialTheme.colorScheme.error,
342+ modifier = Modifier.padding(horizontal = 32.dp),
343+ )
344+ Spacer(modifier = Modifier.height(16.dp))
345+ OutlinedButton(onClick = viewModel::refresh) {
346+ Text("Retry")
347+ }
348+ }
349+ }
350+ } else if (state.logs.isEmpty() && state.isLoading) {
351+ Box(
352+ modifier = Modifier.fillMaxSize(),
353+ contentAlignment = Alignment.Center,
354+ ) {
355+ CircularProgressIndicator()
356+ }
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"
367+ else ->
368+ "No logs found for the selected time range"
369+ }
370+ Box(
371+ modifier = Modifier.fillMaxSize(),
372+ contentAlignment = Alignment.Center,
373+ ) {
374+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
375+ Icon(
376+ Icons.Filled.SearchOff,
377+ contentDescription = "No logs found",
378+ modifier = Modifier.size(48.dp),
379+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
380+ )
381+ Spacer(modifier = Modifier.height(16.dp))
382+ Text(
383+ text = emptyMessage,
384+ style = MaterialTheme.typography.bodyLarge,
385+ color = MaterialTheme.colorScheme.onSurfaceVariant,
386+ modifier = Modifier.padding(horizontal = 32.dp),
387+ )
388+ }
389+ }
390+ } else {
391+ LazyColumn(
392+ state = listState,
393+ contentPadding = PaddingValues(8.dp),
394+ verticalArrangement = Arrangement.spacedBy(4.dp),
395+ ) {
396+ itemsIndexed(
397+ state.logs,
398+ key = { index, _ -> state.logKeys.getOrElse(index) { "log_$index" } },
399+ ) { index, logEntry ->
400+ val key = state.logKeys.getOrElse(index) { "log_$index" }
401+ LogEntryCard(
402+ logEntry = logEntry,
403+ stableKey = key,
404+ isExpanded = expandedLogKey == key,
405+ onClick = {
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
417+ if (needsScroll) {
418+ val target = state.logKeys.indexOf(key).takeIf { it >= 0 } ?: index
419+ scope.launch {
420+ listState.animateScrollToItem(target)
421+ }
422+ }
423+ } else {
424+ expandedLogKey = key
425+ }
426+ },
427+ clipboardManager = clipboard,
428+ onCopied = {
429+ scope.launch {
430+ snackbarHostState.showSnackbar(
431+ message = "Log entry copied to clipboard",
432+ duration = SnackbarDuration.Short,
433+ )
434+ }
435+ },
436+ onFilterInclude = { column, value ->
437+ viewModel.addFilter(column, "=", value)
438+ },
439+ onFilterExclude = { column, value ->
440+ viewModel.addFilter(column, "!=", value)
441+ },
442+ )
443+ }
444+
445+ if (state.hasMore) {
446+ item {
447+ Box(
448+ modifier = Modifier
449+ .fillMaxWidth()
450+ .padding(16.dp),
451+ contentAlignment = Alignment.Center,
452+ ) {
453+ CircularProgressIndicator(
454+ modifier = Modifier.size(24.dp),
455+ strokeWidth = 2.dp,
456+ )
457+ }
458+ }
459+ } else if (state.logs.isNotEmpty()) {
460+ item {
461+ Box(
462+ modifier = Modifier
463+ .fillMaxWidth()
464+ .padding(16.dp),
465+ contentAlignment = Alignment.Center,
466+ ) {
467+ val atCap = state.currentLimit >= 5000 && state.logs.size >= 5000
468+ Text(
469+ text = if (atCap) {
470+ "Showing max 5,000 results. Refine your query to see more."
471+ } else {
472+ "End of results"
473+ },
474+ style = MaterialTheme.typography.labelMedium,
475+ color = if (atCap) {
476+ MaterialTheme.colorScheme.primary
477+ } else {
478+ MaterialTheme.colorScheme.onSurfaceVariant
479+ },
480+ )
481+ }
482+ }
483+ }
484+ }
485+ }
486+ }
487+ }
488+ }
489+
490+ // Filter bottom sheet
491+ if (showFilterSheet) {
492+ FilterBottomSheet(
493+ columns = state.columns,
494+ logs = state.logs,
495+ onDismiss = { showFilterSheet = false },
496+ onApplyFilter = { column, operator, value ->
497+ viewModel.addFilter(column, operator, value)
498+ showFilterSheet = false
499+ },
500+ )
501+ }
502+
503+ // SQL query bottom sheet
504+ if (showSqlSheet) {
505+ SqlQueryBottomSheet(
506+ streamName = streamName,
507+ currentSql = state.customSql,
508+ onDismiss = { showSqlSheet = false },
509+ onExecute = { sql ->
510+ viewModel.executeCustomSql(sql)
511+ showSqlSheet = false
512+ },
513+ )
514+ }
515+
516+ // Share confirmation dialog (logs may contain sensitive data)
517+ if (showShareWarning) {
518+ AlertDialog(
519+ onDismissRequest = { showShareWarning = false },
520+ title = { Text("Share Logs") },
521+ text = {
522+ Text("Logs may contain sensitive information (PII, secrets, tokens). " +
523+ "They will be exported as unencrypted JSON. Continue?")
524+ },
525+ confirmButton = {
526+ TextButton(onClick = {
527+ showShareWarning = false
528+ val logs = state.logs
529+ if (logs.isNotEmpty()) {
530+ scope.launch {
531+ try {
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")
536+ }
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
542+ // never collide.
543+ val cutoff = System.currentTimeMillis() - 60 * 60 * 1000L
544+ dir.listFiles()?.forEach { old ->
545+ if (old.lastModified() < cutoff) old.delete()
546+ }
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
552+ writer.write("[")
553+ logs.forEachIndexed { index, log ->
554+ if (index > 0) writer.write(",")
555+ writer.newLine()
556+ writer.write(encoder.encodeToString(JsonObject.serializer(), log))
557+ }
558+ writer.newLine()
559+ writer.write("]")
560+ }
561+ file
562+ }
563+ val uri = FileProvider.getUriForFile(
564+ context,
565+ "${context.packageName}.fileprovider",
566+ file,
567+ )
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)
574+ }
575+ context.startActivity(
576+ Intent.createChooser(sendIntent, "Share logs")
577+ )
578+ } catch (e: Exception) {
579+ snackbarHostState.showSnackbar(
580+ message = "Failed to share logs: ${e.message}",
581+ duration = SnackbarDuration.Short,
582+ )
583+ }
584+ }
585+ }
586+ }) {
587+ Text("Share")
588+ }
589+ },
590+ dismissButton = {
591+ TextButton(onClick = { showShareWarning = false }) {
592+ Text("Cancel")
593+ }
594+ },
595+ )
596+ }
597+
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()
602+ DatePickerDialog(
603+ onDismissRequest = { showDateRangePicker = false },
604+ confirmButton = {
605+ TextButton(
606+ onClick = {
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)
626+ }
627+ showDateRangePicker = false
628+ },
629+ enabled = dateRangePickerState.selectedStartDateMillis != null &&
630+ dateRangePickerState.selectedEndDateMillis != null,
631+ ) {
632+ Text("Apply")
633+ }
634+ },
635+ dismissButton = {
636+ TextButton(onClick = { showDateRangePicker = false }) {
637+ Text("Cancel")
638+ }
639+ },
640+ ) {
641+ DateRangePicker(
642+ state = dateRangePickerState,
643+ modifier = Modifier.weight(1f),
644+ )
645+ }
646+ }
647+
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
660+ },
661+ onDeleteFilter = { filterId -> viewModel.deleteSavedFilter(filterId) },
662+ onRetry = { viewModel.loadSavedFilters() },
663+ )
664+ }
665+
666+ // Save filter name dialog
667+ if (showSaveFilterDialog) {
668+ SaveFilterDialog(
669+ isSaving = state.savedFilters.isSaving,
670+ onDismiss = { showSaveFilterDialog = false },
671+ onSave = { name ->
672+ viewModel.saveCurrentFilter(name)
673+ showSaveFilterDialog = false
674+ showSavedFiltersSheet = false
675+ },
676+ )
677+ }
678+}
679+
680+@Composable
681+private fun TimeRangeBar(
682+ selectedRange: TimeRange,
683+ onRangeSelected: (TimeRange) -> Unit,
684+) {
685+ Row(
686+ modifier = Modifier
687+ .horizontalScroll(rememberScrollState())
688+ .padding(horizontal = 16.dp, vertical = 4.dp),
689+ horizontalArrangement = Arrangement.spacedBy(8.dp),
690+ ) {
691+ TimeRange.entries.forEach { range ->
692+ FilterChip(
693+ selected = selectedRange == range,
694+ onClick = { onRangeSelected(range) },
695+ label = { Text(range.label) },
696+ )
697+ }
698+ }
699+}
700+
701+@Composable
702+private fun StreamingToggleButton(
703+ isStreaming: Boolean,
704+ enabled: Boolean,
705+ onClick: () -> Unit,
706+) {
707+ IconButton(onClick = onClick, enabled = enabled || isStreaming) {
708+ if (isStreaming) {
709+ val infiniteTransition = rememberInfiniteTransition(label = "stream_pulse")
710+ val alpha by infiniteTransition.animateFloat(
711+ initialValue = 1f,
712+ targetValue = 0.3f,
713+ animationSpec = infiniteRepeatable(
714+ animation = tween(600, easing = EaseInOut),
715+ repeatMode = RepeatMode.Reverse,
716+ ),
717+ label = "stream_btn_alpha",
718+ )
719+ Icon(
720+ Icons.Filled.Stop,
721+ contentDescription = "Stop streaming",
722+ tint = MaterialTheme.colorScheme.error,
723+ modifier = Modifier.alpha(alpha),
724+ )
725+ } else {
726+ Icon(
727+ Icons.Filled.PlayArrow,
728+ contentDescription = "Start live tail",
729+ tint = MaterialTheme.colorScheme.primary,
730+ )
731+ }
732+ }
733+}
734+
735+@Composable
736+fun LogEntryCard(
737+ logEntry: JsonObject,
738+ stableKey: String,
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,
745+) {
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)
750+ Card(
751+ onClick = onClick,
752+ modifier = Modifier.fillMaxWidth(),
753+ colors = cardColors,
754+ ) {
755+ Column(modifier = Modifier.padding(12.dp)) {
756+ // Timestamp row
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) {
760+ try {
761+ logEntry["p_timestamp"]?.jsonPrimitive?.content
762+ ?: logEntry["datetime"]?.jsonPrimitive?.content
763+ ?: ""
764+ } catch (_: Exception) {
765+ ""
766+ }
767+ }
768+ val displayTimestamp = remember(rawTimestamp) {
769+ if (rawTimestamp.isNotEmpty()) formatTimestamp(rawTimestamp) else ""
770+ }
771+
772+ if (displayTimestamp.isNotEmpty()) {
773+ Text(
774+ text = displayTimestamp,
775+ style = MaterialTheme.typography.labelSmall,
776+ color = MaterialTheme.colorScheme.primary,
777+ fontFamily = FontFamily.Monospace,
778+ )
779+ Spacer(modifier = Modifier.height(4.dp))
780+ }
781+
782+ if (!isExpanded) {
783+ // Compact view: show first meaningful fields
784+ val preview = remember(stableKey) {
785+ logEntry.entries
786+ .filter { !it.key.startsWith("p_") || it.key == "p_timestamp" }
787+ .take(3)
788+ .joinToString(" | ") { "${it.key}: ${formatJsonValue(it.value)}" }
789+ }
790+ Text(
791+ text = preview.ifEmpty { logEntry.toString() },
792+ style = MaterialTheme.typography.bodySmall,
793+ fontFamily = FontFamily.Monospace,
794+ maxLines = 2,
795+ overflow = TextOverflow.Ellipsis,
796+ fontSize = 12.sp,
797+ )
798+ } else {
799+ // Expanded view: show all fields + copy button
800+ Row(
801+ modifier = Modifier.fillMaxWidth(),
802+ horizontalArrangement = Arrangement.End,
803+ ) {
804+ IconButton(
805+ onClick = {
806+ val text = prettyJson.encodeToString(JsonObject.serializer(), logEntry)
807+ try {
808+ clipboardManager.setPrimaryClip(
809+ android.content.ClipData.newPlainText("Log entry", text)
810+ )
811+ onCopied()
812+ } catch (_: SecurityException) {
813+ // Some devices/custom ROMs restrict clipboard access
814+ }
815+ },
816+ ) {
817+ Icon(
818+ Icons.Filled.ContentCopy,
819+ contentDescription = "Copy log entry to clipboard",
820+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
821+ )
822+ }
823+ }
824+ HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
825+ logEntry.entries.forEach { (key, value) ->
826+ val displayValue = formatJsonValue(value)
827+ Row(
828+ modifier = Modifier
829+ .fillMaxWidth()
830+ .padding(vertical = 2.dp),
831+ verticalAlignment = Alignment.CenterVertically,
832+ ) {
833+ Column(modifier = Modifier.weight(1f)) {
834+ Row {
835+ Text(
836+ text = "$key: ",
837+ style = MaterialTheme.typography.bodySmall,
838+ fontWeight = FontWeight.Bold,
839+ fontFamily = FontFamily.Monospace,
840+ color = MaterialTheme.colorScheme.primary,
841+ fontSize = 12.sp,
842+ )
843+ Text(
844+ text = displayValue,
845+ style = MaterialTheme.typography.bodySmall,
846+ fontFamily = FontFamily.Monospace,
847+ fontSize = 12.sp,
848+ )
849+ }
850+ }
851+ // Filter include (=) button
852+ IconButton(
853+ onClick = { onFilterInclude(key, displayValue) },
854+ modifier = Modifier.size(28.dp),
855+ ) {
856+ Icon(
857+ Icons.Filled.AddCircle,
858+ contentDescription = "Filter for $key = $displayValue",
859+ modifier = Modifier.size(16.dp),
860+ tint = MaterialTheme.colorScheme.primary,
861+ )
862+ }
863+ // Filter exclude (!=) button
864+ IconButton(
865+ onClick = { onFilterExclude(key, displayValue) },
866+ modifier = Modifier.size(28.dp),
867+ ) {
868+ Icon(
869+ Icons.Filled.RemoveCircle,
870+ contentDescription = "Filter out $key = $displayValue",
871+ modifier = Modifier.size(16.dp),
872+ tint = MaterialTheme.colorScheme.error,
873+ )
874+ }
875+ }
876+ }
877+ }
878+ }
879+ }
880+}
881+
882+private fun formatJsonValue(element: JsonElement): String {
883+ return when (element) {
884+ is JsonPrimitive -> element.content
885+ is JsonNull -> "null"
886+ else -> element.toString()
887+ }
888+}
889+
890+@OptIn(ExperimentalMaterial3Api::class)
891+@Composable
892+fun FilterBottomSheet(
893+ columns: List<String>,
894+ logs: List<JsonObject> = emptyList(),
895+ onDismiss: () -> Unit,
896+ onApplyFilter: (column: String, operator: String, value: String) -> Unit,
897+) {
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() ?: ""
908+ }
909+ }
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) }
914+
915+ val operators = listOf("=", "!=", "LIKE", "ILIKE", ">", "<", ">=", "<=", "IS NULL", "IS NOT NULL")
916+
917+ // Extract distinct values for the selected column from current logs
918+ val columnValues = remember(selectedColumn, logs) {
919+ if (selectedColumn.isBlank() || logs.isEmpty()) {
920+ emptyList()
921+ } else {
922+ logs.asSequence()
923+ .mapNotNull { entry ->
924+ try {
925+ entry[selectedColumn]?.let { element ->
926+ formatJsonValue(element).takeIf { it != "null" && it.isNotBlank() }
927+ }
928+ } catch (_: Exception) {
929+ null
930+ }
931+ }
932+ .distinct()
933+ .take(50)
934+ .sorted()
935+ .toList()
936+ }
937+ }
938+
939+ ModalBottomSheet(onDismissRequest = onDismiss) {
940+ Column(
941+ modifier = Modifier.padding(16.dp),
942+ verticalArrangement = Arrangement.spacedBy(12.dp),
943+ ) {
944+ Text(
945+ text = "Add Filter",
946+ style = MaterialTheme.typography.titleLarge,
947+ fontWeight = FontWeight.Bold,
948+ )
949+
950+ // Column selector
951+ ExposedDropdownMenuBox(
952+ expanded = columnDropdownExpanded,
953+ onExpandedChange = { columnDropdownExpanded = it },
954+ ) {
955+ OutlinedTextField(
956+ value = selectedColumn,
957+ onValueChange = {},
958+ readOnly = true,
959+ label = { Text("Column") },
960+ trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = columnDropdownExpanded) },
961+ modifier = Modifier
962+ .fillMaxWidth()
963+ .menuAnchor(MenuAnchorType.PrimaryNotEditable),
964+ )
965+ ExposedDropdownMenu(
966+ expanded = columnDropdownExpanded,
967+ onDismissRequest = { columnDropdownExpanded = false },
968+ ) {
969+ columns.forEach { column ->
970+ DropdownMenuItem(
971+ text = { Text(column) },
972+ onClick = {
973+ selectedColumn = column
974+ filterValue = ""
975+ columnDropdownExpanded = false
976+ },
977+ )
978+ }
979+ }
980+ }
981+
982+ // Operator selector
983+ ExposedDropdownMenuBox(
984+ expanded = operatorDropdownExpanded,
985+ onExpandedChange = { operatorDropdownExpanded = it },
986+ ) {
987+ OutlinedTextField(
988+ value = selectedOperator,
989+ onValueChange = {},
990+ readOnly = true,
991+ label = { Text("Operator") },
992+ trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = operatorDropdownExpanded) },
993+ modifier = Modifier
994+ .fillMaxWidth()
995+ .menuAnchor(MenuAnchorType.PrimaryNotEditable),
996+ )
997+ ExposedDropdownMenu(
998+ expanded = operatorDropdownExpanded,
999+ onDismissRequest = { operatorDropdownExpanded = false },
1000+ ) {
1001+ operators.forEach { op ->
1002+ DropdownMenuItem(
1003+ text = { Text(op) },
1004+ onClick = {
1005+ selectedOperator = op
1006+ operatorDropdownExpanded = false
1007+ },
1008+ )
1009+ }
1010+ }
1011+ }
1012+
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"
1019+ }
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"
1025+ else -> null
1026+ }
1027+ OutlinedTextField(
1028+ value = filterValue,
1029+ onValueChange = { filterValue = it },
1030+ label = { Text("Value") },
1031+ placeholder = { Text(valueHint) },
1032+ modifier = Modifier.fillMaxWidth(),
1033+ singleLine = true,
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)
1040+ }
1041+ }),
1042+ )
1043+
1044+ // Show existing values as selectable suggestions
1045+ if (columnValues.isNotEmpty() && selectedOperator in listOf("=", "!=")) {
1046+ Text(
1047+ text = "Existing values:",
1048+ style = MaterialTheme.typography.labelMedium,
1049+ color = MaterialTheme.colorScheme.onSurfaceVariant,
1050+ )
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)
1056+ }
1057+ }
1058+ Row(
1059+ modifier = Modifier
1060+ .fillMaxWidth()
1061+ .horizontalScroll(rememberScrollState()),
1062+ horizontalArrangement = Arrangement.spacedBy(6.dp),
1063+ ) {
1064+ filteredValues.forEach { value ->
1065+ SuggestionChip(
1066+ onClick = {
1067+ onApplyFilter(selectedColumn, selectedOperator, value)
1068+ },
1069+ label = {
1070+ Text(
1071+ text = if (value.length > 40) value.take(40) + "\u2026" else value,
1072+ maxLines = 1,
1073+ overflow = TextOverflow.Ellipsis,
1074+ fontSize = 12.sp,
1075+ )
1076+ },
1077+ )
1078+ }
1079+ }
1080+ }
1081+ }
1082+
1083+ val hasValueError = selectedOperator in listOf(">", "<", ">=", "<=") &&
1084+ filterValue.isNotEmpty() &&
1085+ filterValue.toDoubleOrNull() == null &&
1086+ !filterValue.matches(Regex("\\d{4}-\\d{2}-\\d{2}.*"))
1087+
1088+ Row(
1089+ modifier = Modifier.fillMaxWidth(),
1090+ horizontalArrangement = Arrangement.End,
1091+ verticalAlignment = Alignment.CenterVertically,
1092+ ) {
1093+ TextButton(onClick = onDismiss) {
1094+ Text("Cancel")
1095+ }
1096+ Spacer(modifier = Modifier.width(8.dp))
1097+ Button(
1098+ onClick = {
1099+ if (selectedColumn.isNotBlank()) {
1100+ onApplyFilter(selectedColumn, selectedOperator, filterValue)
1101+ }
1102+ },
1103+ enabled = selectedColumn.isNotBlank() &&
1104+ !hasValueError &&
1105+ (selectedOperator in listOf("IS NULL", "IS NOT NULL") || filterValue.isNotEmpty()),
1106+ ) {
1107+ Text("Apply")
1108+ }
1109+ }
1110+
1111+ Spacer(modifier = Modifier.height(32.dp))
1112+ }
1113+ }
1114+}
1115+
1116+@OptIn(ExperimentalMaterial3Api::class)
1117+@Composable
1118+fun SqlQueryBottomSheet(
1119+ streamName: String,
1120+ currentSql: String,
1121+ onDismiss: () -> Unit,
1122+ onExecute: (String) -> Unit,
1123+) {
1124+ val safeName = remember(streamName) { escapeIdentifier(streamName) }
1125+ var sql by remember {
1126+ mutableStateOf(
1127+ currentSql.ifEmpty {
1128+ "SELECT * FROM \"$safeName\" ORDER BY p_timestamp DESC LIMIT 100"
1129+ }
1130+ )
1131+ }
1132+
1133+ ModalBottomSheet(onDismissRequest = onDismiss) {
1134+ Column(
1135+ modifier = Modifier.padding(16.dp),
1136+ verticalArrangement = Arrangement.spacedBy(12.dp),
1137+ ) {
1138+ Text(
1139+ text = "SQL Query",
1140+ style = MaterialTheme.typography.titleLarge,
1141+ fontWeight = FontWeight.Bold,
1142+ )
1143+
1144+ Text(
1145+ text = "Use PostgreSQL-compatible SQL to query your logs.",
1146+ style = MaterialTheme.typography.bodySmall,
1147+ color = MaterialTheme.colorScheme.onSurfaceVariant,
1148+ )
1149+
1150+ OutlinedTextField(
1151+ value = sql,
1152+ onValueChange = { sql = it },
1153+ label = { Text("SQL") },
1154+ modifier = Modifier
1155+ .fillMaxWidth()
1156+ .heightIn(min = 120.dp),
1157+ textStyle = MaterialTheme.typography.bodyMedium.copy(
1158+ fontFamily = FontFamily.Monospace,
1159+ ),
1160+ maxLines = 8,
1161+ )
1162+
1163+ Row(
1164+ modifier = Modifier.fillMaxWidth(),
1165+ horizontalArrangement = Arrangement.End,
1166+ ) {
1167+ TextButton(onClick = onDismiss) {
1168+ Text("Cancel")
1169+ }
1170+ Spacer(modifier = Modifier.width(8.dp))
1171+ Button(
1172+ onClick = { onExecute(sql) },
1173+ enabled = sql.isNotBlank(),
1174+ ) {
1175+ Icon(Icons.Filled.PlayArrow, contentDescription = "Execute query")
1176+ Spacer(modifier = Modifier.width(4.dp))
1177+ Text("Execute")
1178+ }
1179+ }
1180+
1181+ Spacer(modifier = Modifier.height(32.dp))
1182+ }
1183+ }
1184+}
1185+
1186+@OptIn(ExperimentalMaterial3Api::class)
1187+@Composable
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,
1196+) {
1197+ var filterToDelete by remember { mutableStateOf<String?>(null) }
1198+
1199+ ModalBottomSheet(onDismissRequest = onDismiss) {
1200+ Column(
1201+ modifier = Modifier.padding(16.dp),
1202+ verticalArrangement = Arrangement.spacedBy(12.dp),
1203+ ) {
1204+ Row(
1205+ modifier = Modifier.fillMaxWidth(),
1206+ horizontalArrangement = Arrangement.SpaceBetween,
1207+ verticalAlignment = Alignment.CenterVertically,
1208+ ) {
1209+ Text(
1210+ text = "Saved Filters",
1211+ style = MaterialTheme.typography.titleLarge,
1212+ fontWeight = FontWeight.Bold,
1213+ )
1214+ if (hasActiveFilters) {
1215+ FilledTonalButton(onClick = onSaveCurrentFilter) {
1216+ Icon(
1217+ Icons.Filled.Add,
1218+ contentDescription = null,
1219+ modifier = Modifier.size(18.dp),
1220+ )
1221+ Spacer(modifier = Modifier.width(4.dp))
1222+ Text("Save Current")
1223+ }
1224+ }
1225+ }
1226+
1227+ when {
1228+ savedFiltersState.isLoading -> {
1229+ Box(
1230+ modifier = Modifier
1231+ .fillMaxWidth()
1232+ .padding(32.dp),
1233+ contentAlignment = Alignment.Center,
1234+ ) {
1235+ CircularProgressIndicator()
1236+ }
1237+ }
1238+ savedFiltersState.error != null -> {
1239+ Column(
1240+ modifier = Modifier
1241+ .fillMaxWidth()
1242+ .padding(16.dp),
1243+ horizontalAlignment = Alignment.CenterHorizontally,
1244+ ) {
1245+ Text(
1246+ text = savedFiltersState.error,
1247+ style = MaterialTheme.typography.bodyMedium,
1248+ color = MaterialTheme.colorScheme.error,
1249+ )
1250+ Spacer(modifier = Modifier.height(8.dp))
1251+ OutlinedButton(onClick = onRetry) {
1252+ Text("Retry")
1253+ }
1254+ }
1255+ }
1256+ savedFiltersState.filters.isEmpty() -> {
1257+ Box(
1258+ modifier = Modifier
1259+ .fillMaxWidth()
1260+ .padding(32.dp),
1261+ contentAlignment = Alignment.Center,
1262+ ) {
1263+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
1264+ Icon(
1265+ Icons.Filled.BookmarkBorder,
1266+ contentDescription = null,
1267+ modifier = Modifier.size(40.dp),
1268+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
1269+ )
1270+ Spacer(modifier = Modifier.height(8.dp))
1271+ Text(
1272+ text = "No saved filters for this stream",
1273+ style = MaterialTheme.typography.bodyMedium,
1274+ color = MaterialTheme.colorScheme.onSurfaceVariant,
1275+ )
1276+ if (hasActiveFilters) {
1277+ Spacer(modifier = Modifier.height(4.dp))
1278+ Text(
1279+ text = "Use \"Save Current\" to save your active filters",
1280+ style = MaterialTheme.typography.bodySmall,
1281+ color = MaterialTheme.colorScheme.onSurfaceVariant,
1282+ )
1283+ }
1284+ }
1285+ }
1286+ }
1287+ else -> {
1288+ savedFiltersState.filters.forEach { filter ->
1289+ Card(
1290+ onClick = { onApplyFilter(filter) },
1291+ modifier = Modifier.fillMaxWidth(),
1292+ ) {
1293+ Row(
1294+ modifier = Modifier
1295+ .fillMaxWidth()
1296+ .padding(12.dp),
1297+ verticalAlignment = Alignment.CenterVertically,
1298+ ) {
1299+ Icon(
1300+ Icons.Filled.Bookmark,
1301+ contentDescription = null,
1302+ tint = MaterialTheme.colorScheme.primary,
1303+ modifier = Modifier.size(20.dp),
1304+ )
1305+ Spacer(modifier = Modifier.width(12.dp))
1306+ Column(modifier = Modifier.weight(1f)) {
1307+ Text(
1308+ text = filter.filterName,
1309+ style = MaterialTheme.typography.titleSmall,
1310+ fontWeight = FontWeight.Medium,
1311+ )
1312+ val description = when (filter.query.filterType) {
1313+ "sql" -> filter.query.filterQuery?.take(60) ?: "SQL query"
1314+ "search" -> "Search: ${filter.query.filterQuery ?: ""}"
1315+ else -> {
1316+ val ruleCount = filter.query.filterBuilder?.rules
1317+ ?.sumOf { it.rules.size } ?: 0
1318+ if (ruleCount > 0) "$ruleCount filter rule(s)" else "No rules"
1319+ }
1320+ }
1321+ Text(
1322+ text = description,
1323+ style = MaterialTheme.typography.bodySmall,
1324+ color = MaterialTheme.colorScheme.onSurfaceVariant,
1325+ maxLines = 1,
1326+ overflow = TextOverflow.Ellipsis,
1327+ )
1328+ }
1329+ IconButton(onClick = {
1330+ filterToDelete = filter.filterId
1331+ }) {
1332+ Icon(
1333+ Icons.Filled.Delete,
1334+ contentDescription = "Delete filter",
1335+ tint = MaterialTheme.colorScheme.error,
1336+ )
1337+ }
1338+ }
1339+ }
1340+ }
1341+ }
1342+ }
1343+
1344+ Spacer(modifier = Modifier.height(32.dp))
1345+ }
1346+ }
1347+
1348+ // Delete confirmation dialog
1349+ if (filterToDelete != null) {
1350+ AlertDialog(
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.") },
1354+ confirmButton = {
1355+ TextButton(onClick = {
1356+ filterToDelete?.let { onDeleteFilter(it) }
1357+ filterToDelete = null
1358+ }) {
1359+ Text("Delete", color = MaterialTheme.colorScheme.error)
1360+ }
1361+ },
1362+ dismissButton = {
1363+ TextButton(onClick = { filterToDelete = null }) {
1364+ Text("Cancel")
1365+ }
1366+ },
1367+ )
1368+ }
1369+}
1370+
1371+@Composable
1372+fun SaveFilterDialog(
1373+ isSaving: Boolean,
1374+ onDismiss: () -> Unit,
1375+ onSave: (String) -> Unit,
1376+) {
1377+ var filterName by remember { mutableStateOf("") }
1378+
1379+ AlertDialog(
1380+ onDismissRequest = onDismiss,
1381+ title = { Text("Save Filter") },
1382+ text = {
1383+ Column {
1384+ Text(
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,
1388+ )
1389+ Spacer(modifier = Modifier.height(12.dp))
1390+ OutlinedTextField(
1391+ value = filterName,
1392+ onValueChange = { filterName = it },
1393+ label = { Text("Filter name") },
1394+ singleLine = true,
1395+ modifier = Modifier.fillMaxWidth(),
1396+ enabled = !isSaving,
1397+ )
1398+ }
1399+ },
1400+ confirmButton = {
1401+ if (isSaving) {
1402+ CircularProgressIndicator(
1403+ modifier = Modifier
1404+ .size(24.dp)
1405+ .padding(end = 8.dp),
1406+ strokeWidth = 2.dp,
1407+ )
1408+ } else {
1409+ TextButton(
1410+ onClick = { onSave(filterName.trim()) },
1411+ enabled = filterName.isNotBlank(),
1412+ ) {
1413+ Text("Save")
1414+ }
1415+ }
1416+ },
1417+ dismissButton = {
1418+ TextButton(onClick = onDismiss, enabled = !isSaving) {
1419+ Text("Cancel")
1420+ }
1421+ },
1422+ )
1423+}