@@ -1,1423 +0,0 @@
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−}