From d8ce4b3c0f383e844b125d2d3abd618715bb3c89 Mon Sep 17 00:00:00 2001 From: Peter Stone Date: Wed, 22 Jul 2026 08:08:58 +0000 Subject: Initial commit: Feeds, an Android RSS reader Feed subscriptions (RSS 2.0 + Atom, hand-rolled parser), OPML import/export, WorkManager periodic fetch with conditional GET, Room-backed local storage (feeds/articles/filter rules), keyword mute/highlight filtering. Reader mode: on-device extraction via Mozilla's Readability.js (vendored from github.com/mozilla/readability, pinned to release 0.6.0) run in a hidden WebView, for feed articles whose provided content is too thin to be the real article body. Save: explicit action (from a feed article, or the Android share-sheet target for any URL) archives to Stash via POST /artifacts?sync=true, while a local on-device copy (Readability-extracted) is always kept so saved articles read offline regardless of Stash's reachability. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01EZ7ikw2ukGJFTHE3bJS7zL --- .../java/org/terst/feedreader/ui/SettingsScreen.kt | 162 +++++++++++++++++++++ 1 file changed, 162 insertions(+) create mode 100644 app/src/main/java/org/terst/feedreader/ui/SettingsScreen.kt (limited to 'app/src/main/java/org/terst/feedreader/ui/SettingsScreen.kt') diff --git a/app/src/main/java/org/terst/feedreader/ui/SettingsScreen.kt b/app/src/main/java/org/terst/feedreader/ui/SettingsScreen.kt new file mode 100644 index 0000000..a5d3f9c --- /dev/null +++ b/app/src/main/java/org/terst/feedreader/ui/SettingsScreen.kt @@ -0,0 +1,162 @@ +package org.terst.feedreader.ui + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material3.Button +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.launch +import org.terst.feedreader.data.AppDatabase +import org.terst.feedreader.data.FilterAction +import org.terst.feedreader.data.FilterRuleEntity +import org.terst.feedreader.data.Settings + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SettingsScreen(onBack: () -> Unit) { + val context = LocalContext.current + val db = remember { AppDatabase.get(context) } + val settings = remember { Settings(context) } + val scope = rememberCoroutineScope() + + var stashUrl by remember { mutableStateOf("") } + var stashKey by remember { mutableStateOf("") } + LaunchedEffect(Unit) { + stashUrl = settings.stashBaseUrlNow() + stashKey = settings.stashApiKeyNow() + } + + var newRulePattern by remember { mutableStateOf("") } + var newRuleAction by remember { mutableStateOf(FilterAction.MUTE) } + var actionMenuExpanded by remember { mutableStateOf(false) } + val rules by db.filterRuleDao().observeAll().collectAsState(initial = emptyList()) + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Settings") }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.Default.ArrowBack, contentDescription = "Back") + } + } + ) + } + ) { padding -> + LazyColumn(modifier = Modifier.fillMaxWidth().padding(padding)) { + item { + Column(modifier = Modifier.padding(16.dp)) { + Text("Stash", style = MaterialTheme.typography.titleMedium) + Spacer(modifier = Modifier.height(8.dp)) + OutlinedTextField( + value = stashUrl, + onValueChange = { stashUrl = it }, + label = { Text("Base URL") }, + placeholder = { Text("https://stash.example.com") }, + modifier = Modifier.fillMaxWidth() + ) + Spacer(modifier = Modifier.height(8.dp)) + OutlinedTextField( + value = stashKey, + onValueChange = { stashKey = it }, + label = { Text("API key") }, + modifier = Modifier.fillMaxWidth() + ) + Spacer(modifier = Modifier.height(8.dp)) + Button( + onClick = { scope.launch { settings.setStash(stashUrl, stashKey) } }, + modifier = Modifier.fillMaxWidth() + ) { Text("Save") } + } + HorizontalDivider() + } + + item { + Column(modifier = Modifier.padding(16.dp)) { + Text("Filter rules", style = MaterialTheme.typography.titleMedium) + Spacer(modifier = Modifier.height(8.dp)) + Row(modifier = Modifier.fillMaxWidth()) { + OutlinedTextField( + value = newRulePattern, + onValueChange = { newRulePattern = it }, + label = { Text("Keyword") }, + modifier = Modifier.weight(1f) + ) + Spacer(modifier = Modifier.width(8.dp)) + Box { + Button(onClick = { actionMenuExpanded = true }) { + Text(if (newRuleAction == FilterAction.MUTE) "Mute" else "Highlight") + } + DropdownMenu(expanded = actionMenuExpanded, onDismissRequest = { actionMenuExpanded = false }) { + DropdownMenuItem(text = { Text("Mute") }, onClick = { newRuleAction = FilterAction.MUTE; actionMenuExpanded = false }) + DropdownMenuItem(text = { Text("Highlight") }, onClick = { newRuleAction = FilterAction.HIGHLIGHT; actionMenuExpanded = false }) + } + } + } + Spacer(modifier = Modifier.height(8.dp)) + Button( + enabled = newRulePattern.isNotBlank(), + onClick = { + scope.launch { + db.filterRuleDao().insert( + FilterRuleEntity( + pattern = newRulePattern.trim(), + action = newRuleAction, + createdAt = System.currentTimeMillis() + ) + ) + newRulePattern = "" + } + }, + modifier = Modifier.fillMaxWidth() + ) { Text("Add rule") } + } + } + + items(rules, key = { it.id }) { rule -> + ListItem( + headlineContent = { Text(rule.pattern) }, + supportingContent = { Text(if (rule.action == FilterAction.MUTE) "Mute" else "Highlight") }, + trailingContent = { + IconButton(onClick = { scope.launch { db.filterRuleDao().delete(rule.id) } }) { + Icon(Icons.Default.Delete, contentDescription = "Delete rule") + } + } + ) + HorizontalDivider() + } + } + } +} -- cgit v1.2.3