@@ -56,6 +56,8 @@ fun LogViewerScreen(
5656 val keyboardController = LocalSoftwareKeyboardController.current
5757 var showFilterSheet by remember { mutableStateOf(false) }
5858 var showSqlSheet by remember { mutableStateOf(false) }
59+ var showSavedFiltersSheet by remember { mutableStateOf(false) }
60+ var showSaveFilterDialog by remember { mutableStateOf(false) }
5961 var expandedLogKey by remember { mutableStateOf<String?>(null) }
6062 var showDateRangePicker by remember { mutableStateOf(false) }
6163 var showShareWarning by remember { mutableStateOf(false) }
@@ -110,6 +112,12 @@ fun LogViewerScreen(
110112 IconButton(onClick = { showSqlSheet = true }) {
111113 Icon(Icons.Filled.Code, contentDescription = "SQL Query")
112114 }
115+ IconButton(onClick = {
116+ viewModel.loadSavedFilters()
117+ showSavedFiltersSheet = true
118+ }) {
119+ Icon(Icons.Filled.BookmarkBorder, contentDescription = "Saved Filters")
120+ }
113121 IconButton(
114122 onClick = { showShareWarning = true },
115123 enabled = state.logs.isNotEmpty(),
@@ -585,6 +593,37 @@ fun LogViewerScreen(
585593 )
586594 }
587595 }
596+
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
609+ },
610+ onDeleteFilter = { filterId -> viewModel.deleteSavedFilter(filterId) },
611+ onRetry = { viewModel.loadSavedFilters() },
612+ )
613+ }
614+
615+ // Save filter name dialog
616+ if (showSaveFilterDialog) {
617+ SaveFilterDialog(
618+ isSaving = state.savedFilters.isSaving,
619+ onDismiss = { showSaveFilterDialog = false },
620+ onSave = { name ->
621+ viewModel.saveCurrentFilter(name)
622+ showSaveFilterDialog = false
623+ showSavedFiltersSheet = false
624+ },
625+ )
626+ }
588627}
589628
590629@Composable
@@ -1001,3 +1040,242 @@ fun SqlQueryBottomSheet(
10011040 }
10021041 }
10031042}
1043+
1044+@OptIn(ExperimentalMaterial3Api::class)
1045+@Composable
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,
1054+) {
1055+ var filterToDelete by remember { mutableStateOf<String?>(null) }
1056+
1057+ ModalBottomSheet(onDismissRequest = onDismiss) {
1058+ Column(
1059+ modifier = Modifier.padding(16.dp),
1060+ verticalArrangement = Arrangement.spacedBy(12.dp),
1061+ ) {
1062+ Row(
1063+ modifier = Modifier.fillMaxWidth(),
1064+ horizontalArrangement = Arrangement.SpaceBetween,
1065+ verticalAlignment = Alignment.CenterVertically,
1066+ ) {
1067+ Text(
1068+ text = "Saved Filters",
1069+ style = MaterialTheme.typography.titleLarge,
1070+ fontWeight = FontWeight.Bold,
1071+ )
1072+ if (hasActiveFilters) {
1073+ FilledTonalButton(onClick = onSaveCurrentFilter) {
1074+ Icon(
1075+ Icons.Filled.Add,
1076+ contentDescription = null,
1077+ modifier = Modifier.size(18.dp),
1078+ )
1079+ Spacer(modifier = Modifier.width(4.dp))
1080+ Text("Save Current")
1081+ }
1082+ }
1083+ }
1084+
1085+ when {
1086+ savedFiltersState.isLoading -> {
1087+ Box(
1088+ modifier = Modifier
1089+ .fillMaxWidth()
1090+ .padding(32.dp),
1091+ contentAlignment = Alignment.Center,
1092+ ) {
1093+ CircularProgressIndicator()
1094+ }
1095+ }
1096+ savedFiltersState.error != null -> {
1097+ Column(
1098+ modifier = Modifier
1099+ .fillMaxWidth()
1100+ .padding(16.dp),
1101+ horizontalAlignment = Alignment.CenterHorizontally,
1102+ ) {
1103+ Text(
1104+ text = savedFiltersState.error,
1105+ style = MaterialTheme.typography.bodyMedium,
1106+ color = MaterialTheme.colorScheme.error,
1107+ )
1108+ Spacer(modifier = Modifier.height(8.dp))
1109+ OutlinedButton(onClick = onRetry) {
1110+ Text("Retry")
1111+ }
1112+ }
1113+ }
1114+ savedFiltersState.filters.isEmpty() -> {
1115+ Box(
1116+ modifier = Modifier
1117+ .fillMaxWidth()
1118+ .padding(32.dp),
1119+ contentAlignment = Alignment.Center,
1120+ ) {
1121+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
1122+ Icon(
1123+ Icons.Filled.BookmarkBorder,
1124+ contentDescription = null,
1125+ modifier = Modifier.size(40.dp),
1126+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
1127+ )
1128+ Spacer(modifier = Modifier.height(8.dp))
1129+ Text(
1130+ text = "No saved filters for this stream",
1131+ style = MaterialTheme.typography.bodyMedium,
1132+ color = MaterialTheme.colorScheme.onSurfaceVariant,
1133+ )
1134+ if (hasActiveFilters) {
1135+ Spacer(modifier = Modifier.height(4.dp))
1136+ Text(
1137+ text = "Use \"Save Current\" to save your active filters",
1138+ style = MaterialTheme.typography.bodySmall,
1139+ color = MaterialTheme.colorScheme.onSurfaceVariant,
1140+ )
1141+ }
1142+ }
1143+ }
1144+ }
1145+ else -> {
1146+ savedFiltersState.filters.forEach { filter ->
1147+ Card(
1148+ onClick = { onApplyFilter(filter) },
1149+ modifier = Modifier.fillMaxWidth(),
1150+ ) {
1151+ Row(
1152+ modifier = Modifier
1153+ .fillMaxWidth()
1154+ .padding(12.dp),
1155+ verticalAlignment = Alignment.CenterVertically,
1156+ ) {
1157+ Icon(
1158+ Icons.Filled.Bookmark,
1159+ contentDescription = null,
1160+ tint = MaterialTheme.colorScheme.primary,
1161+ modifier = Modifier.size(20.dp),
1162+ )
1163+ Spacer(modifier = Modifier.width(12.dp))
1164+ Column(modifier = Modifier.weight(1f)) {
1165+ Text(
1166+ text = filter.filterName,
1167+ style = MaterialTheme.typography.titleSmall,
1168+ fontWeight = FontWeight.Medium,
1169+ )
1170+ val description = when (filter.query.filterType) {
1171+ "sql" -> filter.query.filterQuery?.take(60) ?: "SQL query"
1172+ "search" -> "Search: ${filter.query.filterQuery ?: ""}"
1173+ else -> {
1174+ val ruleCount = filter.query.filterBuilder?.rules
1175+ ?.sumOf { it.rules.size } ?: 0
1176+ if (ruleCount > 0) "$ruleCount filter rule(s)" else "No rules"
1177+ }
1178+ }
1179+ Text(
1180+ text = description,
1181+ style = MaterialTheme.typography.bodySmall,
1182+ color = MaterialTheme.colorScheme.onSurfaceVariant,
1183+ maxLines = 1,
1184+ overflow = TextOverflow.Ellipsis,
1185+ )
1186+ }
1187+ IconButton(onClick = {
1188+ filterToDelete = filter.filterId
1189+ }) {
1190+ Icon(
1191+ Icons.Filled.Delete,
1192+ contentDescription = "Delete filter",
1193+ tint = MaterialTheme.colorScheme.error,
1194+ )
1195+ }
1196+ }
1197+ }
1198+ }
1199+ }
1200+ }
1201+
1202+ Spacer(modifier = Modifier.height(32.dp))
1203+ }
1204+ }
1205+
1206+ // Delete confirmation dialog
1207+ if (filterToDelete != null) {
1208+ AlertDialog(
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.") },
1212+ confirmButton = {
1213+ TextButton(onClick = {
1214+ filterToDelete?.let { onDeleteFilter(it) }
1215+ filterToDelete = null
1216+ }) {
1217+ Text("Delete", color = MaterialTheme.colorScheme.error)
1218+ }
1219+ },
1220+ dismissButton = {
1221+ TextButton(onClick = { filterToDelete = null }) {
1222+ Text("Cancel")
1223+ }
1224+ },
1225+ )
1226+ }
1227+}
1228+
1229+@Composable
1230+fun SaveFilterDialog(
1231+ isSaving: Boolean,
1232+ onDismiss: () -> Unit,
1233+ onSave: (String) -> Unit,
1234+) {
1235+ var filterName by remember { mutableStateOf("") }
1236+
1237+ AlertDialog(
1238+ onDismissRequest = onDismiss,
1239+ title = { Text("Save Filter") },
1240+ text = {
1241+ Column {
1242+ Text(
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,
1246+ )
1247+ Spacer(modifier = Modifier.height(12.dp))
1248+ OutlinedTextField(
1249+ value = filterName,
1250+ onValueChange = { filterName = it },
1251+ label = { Text("Filter name") },
1252+ singleLine = true,
1253+ modifier = Modifier.fillMaxWidth(),
1254+ enabled = !isSaving,
1255+ )
1256+ }
1257+ },
1258+ confirmButton = {
1259+ if (isSaving) {
1260+ CircularProgressIndicator(
1261+ modifier = Modifier
1262+ .size(24.dp)
1263+ .padding(end = 8.dp),
1264+ strokeWidth = 2.dp,
1265+ )
1266+ } else {
1267+ TextButton(
1268+ onClick = { onSave(filterName.trim()) },
1269+ enabled = filterName.isNotBlank(),
1270+ ) {
1271+ Text("Save")
1272+ }
1273+ }
1274+ },
1275+ dismissButton = {
1276+ TextButton(onClick = onDismiss, enabled = !isSaving) {
1277+ Text("Cancel")
1278+ }
1279+ },
1280+ )
1281+}