diff options
Diffstat (limited to 'app/src')
| -rw-r--r-- | app/src/main/java/org/terst/feedreader/ui/SettingsScreen.kt | 50 | ||||
| -rw-r--r-- | app/src/test/java/org/terst/feedreader/data/FilterRuleEngineTest.kt | 83 |
2 files changed, 132 insertions, 1 deletions
diff --git a/app/src/main/java/org/terst/feedreader/ui/SettingsScreen.kt b/app/src/main/java/org/terst/feedreader/ui/SettingsScreen.kt index 3bd6e3e..5f8eca7 100644 --- a/app/src/main/java/org/terst/feedreader/ui/SettingsScreen.kt +++ b/app/src/main/java/org/terst/feedreader/ui/SettingsScreen.kt @@ -44,6 +44,7 @@ import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.unit.dp import kotlinx.coroutines.launch import org.terst.feedreader.data.AppDatabase +import org.terst.feedreader.data.FeedEntity import org.terst.feedreader.data.FilterAction import org.terst.feedreader.data.FilterRuleEntity import org.terst.feedreader.data.Net @@ -73,6 +74,11 @@ fun SettingsScreen(onBack: () -> Unit) { var newRuleAction by remember { mutableStateOf(FilterAction.MUTE) } var actionMenuExpanded by remember { mutableStateOf(false) } val rules by db.filterRuleDao().observeAll().collectAsState(initial = emptyList()) + val feeds by db.feedDao().observeAll().collectAsState(initial = emptyList()) + val feedTitleById = remember(feeds) { feeds.associate { it.id to it.title } } + // null = applies to every feed (the only option before this scope picker existed). + var newRuleFeedId by remember { mutableStateOf<String?>(null) } + var feedMenuExpanded by remember { mutableStateOf(false) } Scaffold( topBar = { @@ -189,6 +195,14 @@ fun SettingsScreen(onBack: () -> Unit) { } } Spacer(modifier = Modifier.height(8.dp)) + FeedScopePicker( + feeds = feeds, + selectedFeedId = newRuleFeedId, + expanded = feedMenuExpanded, + onExpandedChange = { feedMenuExpanded = it }, + onSelect = { newRuleFeedId = it } + ) + Spacer(modifier = Modifier.height(8.dp)) Button( enabled = newRulePattern.isNotBlank(), onClick = { @@ -197,10 +211,12 @@ fun SettingsScreen(onBack: () -> Unit) { FilterRuleEntity( pattern = newRulePattern.trim(), action = newRuleAction, + feedId = newRuleFeedId, createdAt = System.currentTimeMillis() ) ) newRulePattern = "" + newRuleFeedId = null } }, modifier = Modifier.fillMaxWidth() @@ -211,7 +227,11 @@ fun SettingsScreen(onBack: () -> Unit) { items(rules, key = { it.id }) { rule -> ListItem( headlineContent = { Text(rule.pattern) }, - supportingContent = { Text(if (rule.action == FilterAction.MUTE) "Mute" else "Highlight") }, + supportingContent = { + val actionLabel = if (rule.action == FilterAction.MUTE) "Mute" else "Highlight" + val scopeLabel = rule.feedId?.let { feedTitleById[it] ?: it } ?: "All feeds" + Text("$actionLabel · $scopeLabel") + }, trailingContent = { IconButton(onClick = { scope.launch { db.filterRuleDao().delete(rule.id) } }) { Icon(Icons.Default.Delete, contentDescription = "Delete rule") @@ -223,3 +243,31 @@ fun SettingsScreen(onBack: () -> Unit) { } } } + +/** Scope picker for a new filter rule: "All feeds" (feedId = null) or one specific feed. */ +@Composable +private fun FeedScopePicker( + feeds: List<FeedEntity>, + selectedFeedId: String?, + expanded: Boolean, + onExpandedChange: (Boolean) -> Unit, + onSelect: (String?) -> Unit +) { + Box { + Button(onClick = { onExpandedChange(true) }, modifier = Modifier.fillMaxWidth()) { + Text(feeds.find { it.id == selectedFeedId }?.title ?: "All feeds") + } + DropdownMenu(expanded = expanded, onDismissRequest = { onExpandedChange(false) }) { + DropdownMenuItem( + text = { Text("All feeds") }, + onClick = { onSelect(null); onExpandedChange(false) } + ) + feeds.forEach { feed -> + DropdownMenuItem( + text = { Text(feed.title) }, + onClick = { onSelect(feed.id); onExpandedChange(false) } + ) + } + } + } +} diff --git a/app/src/test/java/org/terst/feedreader/data/FilterRuleEngineTest.kt b/app/src/test/java/org/terst/feedreader/data/FilterRuleEngineTest.kt new file mode 100644 index 0000000..2f9dbd3 --- /dev/null +++ b/app/src/test/java/org/terst/feedreader/data/FilterRuleEngineTest.kt @@ -0,0 +1,83 @@ +package org.terst.feedreader.data + +import org.junit.Assert.assertEquals +import org.junit.Test + +private data class FakeArticle(override val title: String, override val feedId: String?) : Titled + +class FilterRuleEngineTest { + + private fun rule(pattern: String, action: FilterAction, feedId: String? = null) = + FilterRuleEntity(pattern = pattern, action = action, feedId = feedId, createdAt = 0L) + + @Test + fun `global rule matches an article from any feed`() { + val rules = FilterRuleEngine.compile(listOf(rule("politics", FilterAction.MUTE))) + + assertEquals(FilterResult.MUTED, FilterRuleEngine.evaluate(FakeArticle("Politics today", "feed-a"), rules)) + assertEquals(FilterResult.MUTED, FilterRuleEngine.evaluate(FakeArticle("Politics today", "feed-b"), rules)) + assertEquals(FilterResult.MUTED, FilterRuleEngine.evaluate(FakeArticle("Politics today", null), rules)) + } + + @Test + fun `feed-scoped rule only matches an article from that feed`() { + val rules = FilterRuleEngine.compile(listOf(rule("politics", FilterAction.MUTE, feedId = "feed-a"))) + + assertEquals(FilterResult.MUTED, FilterRuleEngine.evaluate(FakeArticle("Politics today", "feed-a"), rules)) + } + + @Test + fun `feed-scoped rule does not match an article from a different feed`() { + val rules = FilterRuleEngine.compile(listOf(rule("politics", FilterAction.MUTE, feedId = "feed-a"))) + + assertEquals(FilterResult.NORMAL, FilterRuleEngine.evaluate(FakeArticle("Politics today", "feed-b"), rules)) + assertEquals(FilterResult.NORMAL, FilterRuleEngine.evaluate(FakeArticle("Politics today", null), rules)) + } + + @Test + fun `mute wins over highlight when both match`() { + val rules = FilterRuleEngine.compile( + listOf( + rule("politics", FilterAction.HIGHLIGHT), + rule("politics", FilterAction.MUTE) + ) + ) + + assertEquals(FilterResult.MUTED, FilterRuleEngine.evaluate(FakeArticle("Politics today", "feed-a"), rules)) + } + + @Test + fun `highlight applies when no mute rule matches`() { + val rules = FilterRuleEngine.compile(listOf(rule("politics", FilterAction.HIGHLIGHT))) + + assertEquals(FilterResult.HIGHLIGHTED, FilterRuleEngine.evaluate(FakeArticle("Politics today", "feed-a"), rules)) + } + + @Test + fun `matching is case-insensitive`() { + val rules = FilterRuleEngine.compile(listOf(rule("POLITICS", FilterAction.MUTE))) + + assertEquals(FilterResult.MUTED, FilterRuleEngine.evaluate(FakeArticle("politics today", "feed-a"), rules)) + } + + @Test + fun `no match returns normal`() { + val rules = FilterRuleEngine.compile(listOf(rule("politics", FilterAction.MUTE))) + + assertEquals(FilterResult.NORMAL, FilterRuleEngine.evaluate(FakeArticle("Sports today", "feed-a"), rules)) + } + + @Test + fun `a feed-scoped rule for one feed and a global rule both apply independently`() { + val rules = FilterRuleEngine.compile( + listOf( + rule("politics", FilterAction.MUTE, feedId = "feed-a"), + rule("sports", FilterAction.HIGHLIGHT) + ) + ) + + assertEquals(FilterResult.MUTED, FilterRuleEngine.evaluate(FakeArticle("Politics today", "feed-a"), rules)) + assertEquals(FilterResult.NORMAL, FilterRuleEngine.evaluate(FakeArticle("Politics today", "feed-b"), rules)) + assertEquals(FilterResult.HIGHLIGHTED, FilterRuleEngine.evaluate(FakeArticle("Sports today", "feed-b"), rules)) + } +} |
