@@ -56,6 +56,8 @@ fun LogViewerScreen(
56 val keyboardController = LocalSoftwareKeyboardController.current
56 val keyboardController = LocalSoftwareKeyboardController.current
57 var showFilterSheet by remember { mutableStateOf(false) }
57 var showFilterSheet by remember { mutableStateOf(false) }
58 var showSqlSheet 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) }
59 var expandedLogKey by remember { mutableStateOf<String?>(null) }
61 var expandedLogKey by remember { mutableStateOf<String?>(null) }
60 var showDateRangePicker by remember { mutableStateOf(false) }
62 var showDateRangePicker by remember { mutableStateOf(false) }
61 var showShareWarning by remember { mutableStateOf(false) }
63 var showShareWarning by remember { mutableStateOf(false) }
@@ -110,6 +112,12 @@ fun LogViewerScreen(
110 IconButton(onClick = { showSqlSheet = true }) {
112 IconButton(onClick = { showSqlSheet = true }) {
111 Icon(Icons.Filled.Code, contentDescription = "SQL Query")
113 Icon(Icons.Filled.Code, contentDescription = "SQL Query")
115+ IconButton(onClick = {
116+ viewModel.loadSavedFilters()
117+ showSavedFiltersSheet = true
119+ Icon(Icons.Filled.BookmarkBorder, contentDescription = "Saved Filters")
113 IconButton(
121 IconButton(
114 onClick = { showShareWarning = true },
122 onClick = { showShareWarning = true },
115 enabled = state.logs.isNotEmpty(),
123 enabled = state.logs.isNotEmpty(),
@@ -585,6 +593,37 @@ fun LogViewerScreen(
597+ // Saved filters bottom sheet
598+ if (showSavedFiltersSheet) {
599+ SavedFiltersBottomSheet(
600+ savedFiltersState = state.savedFilters,
601+ hasActiveFilters = state.activeFilters.isNotEmpty() ||
602+ state.searchQuery.isNotBlank() ||
603+ state.customSql.isNotBlank(),
604+ onDismiss = { showSavedFiltersSheet = false },
605+ onSaveCurrentFilter = { showSaveFilterDialog = true },
606+ onApplyFilter = { filter ->
607+ viewModel.applySavedFilter(filter)
608+ showSavedFiltersSheet = false
610+ onDeleteFilter = { filterId -> viewModel.deleteSavedFilter(filterId) },
611+ onRetry = { viewModel.loadSavedFilters() },
615+ // Save filter name dialog
616+ if (showSaveFilterDialog) {
618+ isSaving = state.savedFilters.isSaving,
619+ onDismiss = { showSaveFilterDialog = false },
621+ viewModel.saveCurrentFilter(name)
622+ showSaveFilterDialog = false
623+ showSavedFiltersSheet = false
590@Composable
629@Composable
@@ -1001,3 +1040,242 @@ fun SqlQueryBottomSheet(
1044+@OptIn(ExperimentalMaterial3Api::class)
1046+fun SavedFiltersBottomSheet(
1047+ savedFiltersState: SavedFiltersState,
1048+ hasActiveFilters: Boolean,
1049+ onDismiss: () -> Unit,
1050+ onSaveCurrentFilter: () -> Unit,
1051+ onApplyFilter: (com.parseable.android.data.model.SavedFilter) -> Unit,
1052+ onDeleteFilter: (String) -> Unit,
1053+ onRetry: () -> Unit,
1055+ var filterToDelete by remember { mutableStateOf<String?>(null) }
1057+ ModalBottomSheet(onDismissRequest = onDismiss) {
1059+ modifier = Modifier.padding(16.dp),
1060+ verticalArrangement = Arrangement.spacedBy(12.dp),
1063+ modifier = Modifier.fillMaxWidth(),
1064+ horizontalArrangement = Arrangement.SpaceBetween,
1065+ verticalAlignment = Alignment.CenterVertically,
1068+ text = "Saved Filters",
1069+ style = MaterialTheme.typography.titleLarge,
1070+ fontWeight = FontWeight.Bold,
1072+ if (hasActiveFilters) {
1073+ FilledTonalButton(onClick = onSaveCurrentFilter) {
1076+ contentDescription = null,
1077+ modifier = Modifier.size(18.dp),
1079+ Spacer(modifier = Modifier.width(4.dp))
1080+ Text("Save Current")
1086+ savedFiltersState.isLoading -> {
1088+ modifier = Modifier
1091+ contentAlignment = Alignment.Center,
1093+ CircularProgressIndicator()
1096+ savedFiltersState.error != null -> {
1098+ modifier = Modifier
1101+ horizontalAlignment = Alignment.CenterHorizontally,
1104+ text = savedFiltersState.error,
1105+ style = MaterialTheme.typography.bodyMedium,
1106+ color = MaterialTheme.colorScheme.error,
1108+ Spacer(modifier = Modifier.height(8.dp))
1109+ OutlinedButton(onClick = onRetry) {
1114+ savedFiltersState.filters.isEmpty() -> {
1116+ modifier = Modifier
1119+ contentAlignment = Alignment.Center,
1121+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
1123+ Icons.Filled.BookmarkBorder,
1124+ contentDescription = null,
1125+ modifier = Modifier.size(40.dp),
1126+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
1128+ Spacer(modifier = Modifier.height(8.dp))
1130+ text = "No saved filters for this stream",
1131+ style = MaterialTheme.typography.bodyMedium,
1132+ color = MaterialTheme.colorScheme.onSurfaceVariant,
1134+ if (hasActiveFilters) {
1135+ Spacer(modifier = Modifier.height(4.dp))
1137+ text = "Use \"Save Current\" to save your active filters",
1138+ style = MaterialTheme.typography.bodySmall,
1139+ color = MaterialTheme.colorScheme.onSurfaceVariant,
1146+ savedFiltersState.filters.forEach { filter ->
1148+ onClick = { onApplyFilter(filter) },
1149+ modifier = Modifier.fillMaxWidth(),
1152+ modifier = Modifier
1155+ verticalAlignment = Alignment.CenterVertically,
1158+ Icons.Filled.Bookmark,
1159+ contentDescription = null,
1160+ tint = MaterialTheme.colorScheme.primary,
1161+ modifier = Modifier.size(20.dp),
1163+ Spacer(modifier = Modifier.width(12.dp))
1164+ Column(modifier = Modifier.weight(1f)) {
1166+ text = filter.filterName,
1167+ style = MaterialTheme.typography.titleSmall,
1168+ fontWeight = FontWeight.Medium,
1170+ val description = when (filter.query.filterType) {
1171+ "sql" -> filter.query.filterQuery?.take(60) ?: "SQL query"
1172+ "search" -> "Search: ${filter.query.filterQuery ?: ""}"
1174+ val ruleCount = filter.query.filterBuilder?.rules
1175+ ?.sumOf { it.rules.size } ?: 0
1176+ if (ruleCount > 0) "$ruleCount filter rule(s)" else "No rules"
1180+ text = description,
1181+ style = MaterialTheme.typography.bodySmall,
1182+ color = MaterialTheme.colorScheme.onSurfaceVariant,
1184+ overflow = TextOverflow.Ellipsis,
1187+ IconButton(onClick = {
1188+ filterToDelete = filter.filterId
1191+ Icons.Filled.Delete,
1192+ contentDescription = "Delete filter",
1193+ tint = MaterialTheme.colorScheme.error,
1202+ Spacer(modifier = Modifier.height(32.dp))
1206+ // Delete confirmation dialog
1207+ if (filterToDelete != null) {
1209+ onDismissRequest = { filterToDelete = null },
1210+ title = { Text("Delete Filter") },
1211+ text = { Text("Are you sure you want to delete this saved filter? This will remove it from the server.") },
1213+ TextButton(onClick = {
1214+ filterToDelete?.let { onDeleteFilter(it) }
1215+ filterToDelete = null
1217+ Text("Delete", color = MaterialTheme.colorScheme.error)
1221+ TextButton(onClick = { filterToDelete = null }) {
1230+fun SaveFilterDialog(
1232+ onDismiss: () -> Unit,
1233+ onSave: (String) -> Unit,
1235+ var filterName by remember { mutableStateOf("") }
1238+ onDismissRequest = onDismiss,
1239+ title = { Text("Save Filter") },
1243+ text = "Save your current filters to the Parseable server. You can load them later on any device.",
1244+ style = MaterialTheme.typography.bodySmall,
1245+ color = MaterialTheme.colorScheme.onSurfaceVariant,
1247+ Spacer(modifier = Modifier.height(12.dp))
1249+ value = filterName,
1250+ onValueChange = { filterName = it },
1251+ label = { Text("Filter name") },
1253+ modifier = Modifier.fillMaxWidth(),
1254+ enabled = !isSaving,
1260+ CircularProgressIndicator(
1261+ modifier = Modifier
1263+ .padding(end = 8.dp),
1264+ strokeWidth = 2.dp,
1268+ onClick = { onSave(filterName.trim()) },
1269+ enabled = filterName.isNotBlank(),
1276+ TextButton(onClick = onDismiss, enabled = !isSaving) {