summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.coverage-baseline2
-rwxr-xr-x.githooks/pre-commit2
-rw-r--r--app/src/main/java/org/terst/feedreader/ui/SettingsScreen.kt50
-rw-r--r--app/src/test/java/org/terst/feedreader/data/FilterRuleEngineTest.kt83
4 files changed, 134 insertions, 3 deletions
diff --git a/.coverage-baseline b/.coverage-baseline
index fb1088c..3125d73 100644
--- a/.coverage-baseline
+++ b/.coverage-baseline
@@ -1 +1 @@
-0.00
+2.31
diff --git a/.githooks/pre-commit b/.githooks/pre-commit
index 389280a..32b492a 100755
--- a/.githooks/pre-commit
+++ b/.githooks/pre-commit
@@ -27,7 +27,7 @@ fi
echo "== [5/8] Coverage must not decrease =="
./gradlew :app:jacocoTestReport -q
-REPORT="app/build/reports/jacoco/report.xml"
+REPORT="app/build/reports/jacoco/jacocoTestReport/jacocoTestReport.xml"
BASELINE_FILE=".coverage-baseline"
if [ -f "$REPORT" ]; then
CURRENT=$(python3 - "$REPORT" <<'PY'
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))
+ }
+}