git.iko.soy/parseable-android
on master help sign in
Sync saved views with Parseable server's /api/v1/filters API (#18)

Add full CRUD support for Parseable's saved filters feature, allowing users to save, load, and delete filter configurations that persist server-side. - Add SavedFilter data models (SavedFilter, FilterBuilder, FilterRule, etc.) - Add filter CRUD endpoints to ParseableApiClient (list, get, create, update, delete) - Add repository methods with auth checking - Add SavedFiltersState to LogViewerViewModel with load/save/apply/delete operations - Add Saved Filters bottom sheet UI with save dialog in LogViewerScreen - Filters are scoped per-stream and synced across devices via the server https://claude.ai/code/session_01WdEQbLS2zk994fkHyaXNjj Co-authored-by: Claude <noreply@anthropic.com>

commit9596377a54e5d6155a5d8715b674caffe453ea50 authoriko <mail@iko.soy> dateMon 16 Feb 2026, 00:46 (8 months ago) parente7bbc3d
5 files changed+605−1
app/src/main/java/com/parseable/android/data/api/ParseableApiClient.kt+71 app/src/main/java/com/parseable/android/data/model/SavedFilter.kt+61 app/src/main/java/com/parseable/android/data/repository/ParseableRepository.kt+14 app/src/main/java/com/parseable/android/ui/screens/logviewer/LogViewerScreen.kt+278 app/src/main/java/com/parseable/android/ui/screens/logviewer/LogViewerViewModel.kt+181−1
app/src/main/java/com/parseable/android/data/api/ParseableApiClient.kt+71
@@ -377,4 +377,75 @@ class ParseableApiClient @Inject constructor() {
377377 is ApiResult.Error -> result
378378 }
379379 }
380+
381+ /**
382+ * GET /api/v1/filters - list saved filters for the authenticated user
383+ */
384+ suspend fun listFilters(): ApiResult<List<SavedFilter>> {
385+ val request = buildRequest("/api/v1/filters").get().build()
386+ return when (val result = executeRequest(request)) {
387+ is ApiResult.Success -> parseResponse(result.data, "filters") {
388+ json.decodeFromString<List<SavedFilter>>(result.data)
389+ }
390+ is ApiResult.Error -> result
391+ }
392+ }
393+
394+ /**
395+ * GET /api/v1/filters/{filterId} - get a specific saved filter
396+ */
397+ suspend fun getFilter(filterId: String): ApiResult<SavedFilter> {
398+ val encoded = encodePathSegment(filterId)
399+ val request = buildRequest("/api/v1/filters/$encoded").get().build()
400+ return when (val result = executeRequest(request)) {
401+ is ApiResult.Success -> parseResponse(result.data, "filter") {
402+ json.decodeFromString<SavedFilter>(result.data)
403+ }
404+ is ApiResult.Error -> result
405+ }
406+ }
407+
408+ /**
409+ * POST /api/v1/filters - create a new saved filter
410+ */
411+ suspend fun createFilter(filter: SavedFilter): ApiResult<SavedFilter> {
412+ val requestBody = json.encodeToString(SavedFilter.serializer(), filter)
413+ .toRequestBody("application/json".toMediaType())
414+ val request = buildRequest("/api/v1/filters")
415+ .post(requestBody)
416+ .build()
417+ return when (val result = executeRequest(request)) {
418+ is ApiResult.Success -> parseResponse(result.data, "filter") {
419+ json.decodeFromString<SavedFilter>(result.data)
420+ }
421+ is ApiResult.Error -> result
422+ }
423+ }
424+
425+ /**
426+ * PUT /api/v1/filters/{filterId} - update an existing saved filter
427+ */
428+ suspend fun updateFilter(filterId: String, filter: SavedFilter): ApiResult<SavedFilter> {
429+ val encoded = encodePathSegment(filterId)
430+ val requestBody = json.encodeToString(SavedFilter.serializer(), filter)
431+ .toRequestBody("application/json".toMediaType())
432+ val request = buildRequest("/api/v1/filters/$encoded")
433+ .put(requestBody)
434+ .build()
435+ return when (val result = executeRequest(request)) {
436+ is ApiResult.Success -> parseResponse(result.data, "filter") {
437+ json.decodeFromString<SavedFilter>(result.data)
438+ }
439+ is ApiResult.Error -> result
440+ }
441+ }
442+
443+ /**
444+ * DELETE /api/v1/filters/{filterId} - delete a saved filter
445+ */
446+ suspend fun deleteFilter(filterId: String): ApiResult<String> {
447+ val encoded = encodePathSegment(filterId)
448+ val request = buildRequest("/api/v1/filters/$encoded").delete().build()
449+ return executeRequest(request)
450+ }
380451}
app/src/main/java/com/parseable/android/data/model/SavedFilter.ktnew file+61
@@ -0,0 +1,61 @@
1+package com.parseable.android.data.model
2+
3+import kotlinx.serialization.SerialName
4+import kotlinx.serialization.Serializable
5+
6+/**
7+ * Saved filter synced with Parseable server via /api/v1/filters.
8+ */
9+@Serializable
10+data class SavedFilter(
11+ @SerialName("filter_id")
12+ val filterId: String? = null,
13+ @SerialName("filter_name")
14+ val filterName: String = "",
15+ @SerialName("stream_name")
16+ val streamName: String = "",
17+ @SerialName("user_id")
18+ val userId: String? = null,
19+ val version: String? = null,
20+ val query: SavedFilterQuery = SavedFilterQuery(),
21+ @SerialName("time_filter")
22+ val timeFilter: SavedFilterTimeFilter? = null,
23+)
24+
25+@Serializable
26+data class SavedFilterQuery(
27+ @SerialName("filter_type")
28+ val filterType: String = "filter",
29+ @SerialName("filter_query")
30+ val filterQuery: String? = null,
31+ @SerialName("filter_builder")
32+ val filterBuilder: FilterBuilder? = null,
33+)
34+
35+@Serializable
36+data class FilterBuilder(
37+ val id: String = "root",
38+ val combinator: String = "and",
39+ val rules: List<FilterRuleGroup> = emptyList(),
40+)
41+
42+@Serializable
43+data class FilterRuleGroup(
44+ val id: String = "",
45+ val combinator: String = "and",
46+ val rules: List<FilterRule> = emptyList(),
47+)
48+
49+@Serializable
50+data class FilterRule(
51+ val id: String = "",
52+ val field: String = "",
53+ val value: String = "",
54+ val operator: String = "=",
55+)
56+
57+@Serializable
58+data class SavedFilterTimeFilter(
59+ val from: String? = null,
60+ val to: String? = null,
61+)
app/src/main/java/com/parseable/android/data/repository/ParseableRepository.kt+14
@@ -162,4 +162,18 @@ class ParseableRepository @Inject constructor(
162162 checkAuth(apiClient.createAlert(alert))
163163
164164 suspend fun listUsers(): ApiResult<List<JsonObject>> = checkAuth(apiClient.listUsers())
165+
166+ suspend fun listFilters(): ApiResult<List<SavedFilter>> = checkAuth(apiClient.listFilters())
167+
168+ suspend fun getFilter(filterId: String): ApiResult<SavedFilter> =
169+ checkAuth(apiClient.getFilter(filterId))
170+
171+ suspend fun createFilter(filter: SavedFilter): ApiResult<SavedFilter> =
172+ checkAuth(apiClient.createFilter(filter))
173+
174+ suspend fun updateFilter(filterId: String, filter: SavedFilter): ApiResult<SavedFilter> =
175+ checkAuth(apiClient.updateFilter(filterId, filter))
176+
177+ suspend fun deleteFilter(filterId: String): ApiResult<String> =
178+ checkAuth(apiClient.deleteFilter(filterId))
165179}
app/src/main/java/com/parseable/android/ui/screens/logviewer/LogViewerScreen.kt+278
@@ -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+}
app/src/main/java/com/parseable/android/ui/screens/logviewer/LogViewerViewModel.kt+181−1
@@ -4,7 +4,7 @@ import androidx.lifecycle.ViewModel
44import androidx.lifecycle.viewModelScope
55import com.parseable.android.data.escapeIdentifier
66import com.parseable.android.data.escapeSql
7−import com.parseable.android.data.model.ApiResult
7+import com.parseable.android.data.model.*
88import com.parseable.android.data.repository.ParseableRepository
99import dagger.hilt.android.lifecycle.HiltViewModel
1010import kotlinx.coroutines.Job
@@ -49,6 +49,13 @@ data class StreamingState(
4949 val currentIntervalMs: Long = 3000L,
5050)
5151
52+data class SavedFiltersState(
53+ val filters: List<SavedFilter> = emptyList(),
54+ val isLoading: Boolean = false,
55+ val isSaving: Boolean = false,
56+ val error: String? = null,
57+)
58+
5259data class LogViewerState(
5360 val streamName: String = "",
5461 val logs: List<JsonObject> = emptyList(),
@@ -58,6 +65,7 @@ data class LogViewerState(
5865 val selectedTimeRange: TimeRange = TimeRange.LAST_1H,
5966 val filters: FilterState = FilterState(),
6067 val streaming: StreamingState = StreamingState(),
68+ val savedFilters: SavedFiltersState = SavedFiltersState(),
6169 val currentLimit: Int = 500,
6270 val hasMore: Boolean = false,
6371 val customStartTime: Long? = null,
@@ -471,6 +479,178 @@ class LogViewerViewModel @Inject constructor(
471479 }
472480 }
473481
482+ // --- Saved Filters (synced with Parseable server) ---
483+
484+ fun loadSavedFilters() {
485+ viewModelScope.launch {
486+ _state.update { it.copy(savedFilters = it.savedFilters.copy(isLoading = true, error = null)) }
487+ when (val result = repository.listFilters()) {
488+ is ApiResult.Success -> {
489+ val streamFilters = result.data.filter { it.streamName == _state.value.streamName }
490+ _state.update {
491+ it.copy(savedFilters = it.savedFilters.copy(filters = streamFilters, isLoading = false))
492+ }
493+ }
494+ is ApiResult.Error -> {
495+ _state.update {
496+ it.copy(savedFilters = it.savedFilters.copy(isLoading = false, error = result.userMessage))
497+ }
498+ }
499+ }
500+ }
501+ }
502+
503+ fun saveCurrentFilter(name: String) {
504+ val current = _state.value
505+ if (current.streamName.isEmpty()) return
506+
507+ viewModelScope.launch {
508+ _state.update { it.copy(savedFilters = it.savedFilters.copy(isSaving = true, error = null)) }
509+
510+ val query = if (current.filters.customSql.isNotBlank()) {
511+ SavedFilterQuery(
512+ filterType = "sql",
513+ filterQuery = current.filters.customSql,
514+ )
515+ } else {
516+ val ruleGroups = current.filters.activeFilters.zip(current.filters.filterClauses)
517+ .mapIndexed { index, (display, _) ->
518+ // Parse display back to field/operator/value
519+ val parts = parseFilterDisplay(display)
520+ FilterRule(
521+ id = "rule_$index",
522+ field = parts.first,
523+ value = parts.third,
524+ operator = parts.second,
525+ )
526+ }
527+ val builder = if (ruleGroups.isNotEmpty()) {
528+ FilterBuilder(
529+ id = "root",
530+ combinator = "and",
531+ rules = listOf(
532+ FilterRuleGroup(
533+ id = "group_0",
534+ combinator = "and",
535+ rules = ruleGroups,
536+ ),
537+ ),
538+ )
539+ } else null
540+
541+ SavedFilterQuery(
542+ filterType = if (current.filters.searchQuery.isNotBlank()) "search" else "filter",
543+ filterQuery = current.filters.searchQuery.ifBlank { null },
544+ filterBuilder = builder,
545+ )
546+ }
547+
548+ val filter = SavedFilter(
549+ filterName = name,
550+ streamName = current.streamName,
551+ query = query,
552+ )
553+
554+ when (val result = repository.createFilter(filter)) {
555+ is ApiResult.Success -> {
556+ _state.update {
557+ it.copy(
558+ savedFilters = it.savedFilters.copy(
559+ isSaving = false,
560+ filters = it.savedFilters.filters + result.data,
561+ ),
562+ )
563+ }
564+ }
565+ is ApiResult.Error -> {
566+ _state.update {
567+ it.copy(savedFilters = it.savedFilters.copy(isSaving = false, error = result.userMessage))
568+ }
569+ }
570+ }
571+ }
572+ }
573+
574+ fun applySavedFilter(filter: SavedFilter) {
575+ stopStreaming()
576+
577+ when (filter.query.filterType) {
578+ "sql" -> {
579+ val sql = filter.query.filterQuery ?: return
580+ _state.update {
581+ it.copy(
582+ filters = FilterState(customSql = sql),
583+ currentLimit = 500,
584+ )
585+ }
586+ executeCustomSql(sql)
587+ }
588+ "search" -> {
589+ val searchQuery = filter.query.filterQuery ?: ""
590+ _state.update {
591+ it.copy(
592+ filters = FilterState(searchQuery = searchQuery),
593+ currentLimit = 500,
594+ )
595+ }
596+ // Also apply builder rules if present
597+ applyBuilderRules(filter.query.filterBuilder)
598+ refresh()
599+ }
600+ else -> {
601+ _state.update {
602+ it.copy(
603+ filters = FilterState(),
604+ currentLimit = 500,
605+ )
606+ }
607+ applyBuilderRules(filter.query.filterBuilder)
608+ refresh()
609+ }
610+ }
611+ }
612+
613+ private fun applyBuilderRules(builder: FilterBuilder?) {
614+ if (builder == null) return
615+ for (group in builder.rules) {
616+ for (rule in group.rules) {
617+ if (rule.field.isNotBlank()) {
618+ addFilter(rule.field, rule.operator, rule.value)
619+ }
620+ }
621+ }
622+ }
623+
624+ fun deleteSavedFilter(filterId: String) {
625+ viewModelScope.launch {
626+ when (repository.deleteFilter(filterId)) {
627+ is ApiResult.Success -> {
628+ _state.update {
629+ it.copy(
630+ savedFilters = it.savedFilters.copy(
631+ filters = it.savedFilters.filters.filter { f -> f.filterId != filterId },
632+ ),
633+ )
634+ }
635+ }
636+ is ApiResult.Error -> { /* Silently ignore — will show on next reload */ }
637+ }
638+ }
639+ }
640+
641+ /** Parse a display string like "column = value" back into (field, operator, value) */
642+ private fun parseFilterDisplay(display: String): Triple<String, String, String> {
643+ for (op in listOf("IS NOT NULL", "IS NULL", "ILIKE", "LIKE", "!=", ">=", "<=", "=", ">", "<")) {
644+ val idx = display.indexOf(" $op")
645+ if (idx >= 0) {
646+ val field = display.substring(0, idx)
647+ val value = display.substring(idx + op.length + 1).trim()
648+ return Triple(field, op, value)
649+ }
650+ }
651+ return Triple(display, "=", "")
652+ }
653+
474654 override fun onCleared() {
475655 super.onCleared()
476656 schemaJob?.cancel()