diff options
| author | Peter Stone <thepeterstone@gmail.com> | 2026-07-23 07:38:28 +0000 |
|---|---|---|
| committer | Peter Stone <thepeterstone@gmail.com> | 2026-07-23 07:38:28 +0000 |
| commit | ecdb45778e274126737aa87e27ed1b1c00cda01c (patch) | |
| tree | b632e53bc91475ba49094539e04b51e7a5eaa41c /app/src/main/java/org/terst/feedreader/ui | |
| parent | 3bec692d9613b9b3e688441e5945424bd0fe20f0 (diff) | |
Rebrand to FeeDreader; overflow-menu cleanup; deferred read-marking; folder-organized feed list
- Rename app to FeeDreader with an owl launcher icon and a paywall-avoidance
note in Settings
- Move Open original/Save to Stash into the reader's overflow menu; add
Mark unread there too
- Only mark an article read when navigating away via Previous/Next, not on
open or on back
- Saved list no longer crosses out read items; its count includes them
- Back from Unread (the root screen) now opens the feed list instead of
exiting the app
- Feed list: clearer separator above the per-feed list, folder grouping
(from OPML categories) with case-insensitive sort, and a virtual "Last
fetch failed" section at the top
- Reader extraction: cap unsized decorative SVG icons (fixes Ars
Technica's giant section-badge icon), force native loading="lazy"
images to load eagerly in the hidden extraction WebView, and drop a
lightbox's duplicate caption only when a real figcaption sibling exists
- Backlog notes on swipe-to-next research, extraction test coverage, and
an async-rendered-gallery race confirmed live on Ars Technica
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EZ7ikw2ukGJFTHE3bJS7zL
Diffstat (limited to 'app/src/main/java/org/terst/feedreader/ui')
6 files changed, 167 insertions, 64 deletions
diff --git a/app/src/main/java/org/terst/feedreader/ui/AddFeedScreen.kt b/app/src/main/java/org/terst/feedreader/ui/AddFeedScreen.kt index 066d8d9..e531fb7 100644 --- a/app/src/main/java/org/terst/feedreader/ui/AddFeedScreen.kt +++ b/app/src/main/java/org/terst/feedreader/ui/AddFeedScreen.kt @@ -68,8 +68,11 @@ fun AddFeedScreen(onDone: () -> Unit) { // (and etag/lastModified/lastError) back to defaults on every re-import. // Preserve the existing row's other fields; only title/siteUrl come from OPML. val existing = db.feedDao().getById(feed.xmlUrl) - val entity = existing?.copy(title = feed.title, siteUrl = feed.htmlUrl ?: existing.siteUrl) - ?: FeedEntity(id = feed.xmlUrl, title = feed.title, siteUrl = feed.htmlUrl) + val entity = existing?.copy( + title = feed.title, + siteUrl = feed.htmlUrl ?: existing.siteUrl, + folder = feed.folder ?: existing.folder + ) ?: FeedEntity(id = feed.xmlUrl, title = feed.title, siteUrl = feed.htmlUrl, folder = feed.folder) db.feedDao().upsert(entity) } FeedFetchWorker.fetchNow(context) diff --git a/app/src/main/java/org/terst/feedreader/ui/ArticleListScreen.kt b/app/src/main/java/org/terst/feedreader/ui/ArticleListScreen.kt index c3a398b..a852288 100644 --- a/app/src/main/java/org/terst/feedreader/ui/ArticleListScreen.kt +++ b/app/src/main/java/org/terst/feedreader/ui/ArticleListScreen.kt @@ -1,5 +1,6 @@ package org.terst.feedreader.ui +import androidx.activity.compose.BackHandler import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.combinedClickable @@ -80,6 +81,11 @@ fun ArticleListScreen( // single feed's own list already says so in the top bar, so rows there don't repeat it. val showFeedName = feedId == UNREAD_FEED_ID || feedId == OLDER_FEED_ID || feedId == SAVED_FEED_ID + // Unread is the app's landing screen (root of the back stack) -- the system back + // gesture/button here would otherwise exit the app entirely. Route it to the feed + // list instead, matching the same forward affordance already in the top bar. + BackHandler(enabled = feedId == UNREAD_FEED_ID) { onOpenFeedList() } + var specificFeedTitle by remember(feedId) { mutableStateOf<String?>(null) } LaunchedEffect(feedId) { if (!showFeedName) { @@ -146,6 +152,9 @@ fun ArticleListScreen( article = article, highlighted = result == FilterResult.HIGHLIGHTED, showFeedName = showFeedName, + // Saved is a "read later" shelf, not a read/unread queue -- articles + // don't get crossed out there just because they were opened elsewhere. + showReadState = feedId != SAVED_FEED_ID, onClick = { onOpenArticle(article.id) }, onMarkReadFrom = { pendingMarkReadFrom = article } ) @@ -161,6 +170,7 @@ private fun ArticleRow( article: ArticleListItem, highlighted: Boolean, showFeedName: Boolean, + showReadState: Boolean, onClick: () -> Unit, onMarkReadFrom: () -> Unit ) { @@ -185,7 +195,7 @@ private fun ArticleRow( headlineContent = { Text( article.title, - textDecoration = if (article.isRead) TextDecoration.LineThrough else null, + textDecoration = if (showReadState && article.isRead) TextDecoration.LineThrough else null, style = MaterialTheme.typography.bodyMedium ) }, diff --git a/app/src/main/java/org/terst/feedreader/ui/FeedListScreen.kt b/app/src/main/java/org/terst/feedreader/ui/FeedListScreen.kt index 47f51fc..ee2130c 100644 --- a/app/src/main/java/org/terst/feedreader/ui/FeedListScreen.kt +++ b/app/src/main/java/org/terst/feedreader/ui/FeedListScreen.kt @@ -26,6 +26,7 @@ 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.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState @@ -73,6 +74,7 @@ fun FeedListScreen( val recentUnreadCount by remember { db.articleDao().observeUnreadCount(cutoff) }.collectAsState(initial = 0) val olderUnreadCount by remember { db.articleDao().observeOlderUnreadCount(cutoff) }.collectAsState(initial = 0) + val savedCount by remember { db.articleDao().observeSavedCount() }.collectAsState(initial = 0) var menuExpanded by remember { mutableStateOf(false) } var pendingDelete by remember { mutableStateOf<FeedEntity?>(null) } val snackbarHostState = remember { SnackbarHostState() } @@ -145,34 +147,67 @@ fun FeedListScreen( HorizontalDivider() ListItem( headlineContent = { Text("Saved") }, + trailingContent = { Text(savedCount.toString()) }, modifier = Modifier.fillMaxWidth().clickable { onOpenSaved() } ) - HorizontalDivider() + SectionHeader("Feeds") } - items(feeds, key = { it.first.id }) { (feed, unreadCount) -> - FeedRow( - feed = feed, - unreadCount = unreadCount, - onClick = { onOpenFeed(feed.id) }, - onDelete = { pendingDelete = feed }, - onToggleReaderMode = { - val newEnabled = !feed.readerModeEnabled - scope.launch { - db.feedDao().update(feed.copy(readerModeEnabled = newEnabled)) - val label = feed.title.ifBlank { "this feed" } - snackbarHostState.showSnackbar( - if (newEnabled) "Reader mode enabled for $label" else "Reader mode disabled for $label" - ) + + val failedFeeds = feeds.filter { it.first.lastError != null } + val okFeeds = feeds.filter { it.first.lastError == null } + val groupedByFolder = okFeeds.groupBy { it.first.folder } + val folderNames = groupedByFolder.keys.filterNotNull().sortedBy { it.lowercase() } + val ungrouped = groupedByFolder[null].orEmpty() + + fun feedRowItems(rows: List<Pair<FeedEntity, Int>>) { + items(rows, key = { it.first.id }) { (feed, unreadCount) -> + FeedRow( + feed = feed, + unreadCount = unreadCount, + onClick = { onOpenFeed(feed.id) }, + onDelete = { pendingDelete = feed }, + onToggleReaderMode = { + val newEnabled = !feed.readerModeEnabled + scope.launch { + db.feedDao().update(feed.copy(readerModeEnabled = newEnabled)) + val label = feed.title.ifBlank { "this feed" } + snackbarHostState.showSnackbar( + if (newEnabled) "Reader mode enabled for $label" else "Reader mode disabled for $label" + ) + } } - } - ) - HorizontalDivider() + ) + HorizontalDivider() + } + } + + if (failedFeeds.isNotEmpty()) { + item { SectionHeader("Last fetch failed") } + feedRowItems(failedFeeds) + } + for (folder in folderNames) { + item { SectionHeader(folder) } + feedRowItems(groupedByFolder.getValue(folder)) + } + if (ungrouped.isNotEmpty()) { + if (folderNames.isNotEmpty() || failedFeeds.isNotEmpty()) item { SectionHeader("Other") } + feedRowItems(ungrouped) } } } } @Composable +private fun SectionHeader(title: String) { + Text( + title, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp) + ) +} + +@Composable private fun FeedRow( feed: FeedEntity, unreadCount: Int, diff --git a/app/src/main/java/org/terst/feedreader/ui/ReaderScreen.kt b/app/src/main/java/org/terst/feedreader/ui/ReaderScreen.kt index a46d1aa..6e1f939 100644 --- a/app/src/main/java/org/terst/feedreader/ui/ReaderScreen.kt +++ b/app/src/main/java/org/terst/feedreader/ui/ReaderScreen.kt @@ -123,11 +123,22 @@ fun ReaderScreen( val currentPage = pagerState.currentPage val hasPrevious = currentPage > 0 val hasNext = currentPage < safeIds.size - 1 + // Marking read happens HERE (moving to another article), not on open -- backing out + // to the list without paging past an article leaves it unread. Only Next/Previous + // count as "moved away from it"; exiting via the back arrow deliberately doesn't. fun goToPreviousArticle() { - scope.launch { pagerState.animateScrollToPage((pagerState.currentPage - 1).coerceAtLeast(0)) } + val leavingId = safeIds[pagerState.currentPage] + scope.launch { + db.articleDao().setRead(leavingId, true) + pagerState.animateScrollToPage((pagerState.currentPage - 1).coerceAtLeast(0)) + } } fun goToNextArticle() { - scope.launch { pagerState.animateScrollToPage((pagerState.currentPage + 1).coerceAtMost(safeIds.size - 1)) } + val leavingId = safeIds[pagerState.currentPage] + scope.launch { + db.articleDao().setRead(leavingId, true) + pagerState.animateScrollToPage((pagerState.currentPage + 1).coerceAtMost(safeIds.size - 1)) + } } HorizontalPager(state = pagerState, userScrollEnabled = false, modifier = Modifier.fillMaxSize()) { page -> @@ -175,7 +186,8 @@ private fun ReaderContent( LaunchedEffect(articleId) { val loaded = db.articleDao().getById(articleId) ?: return@LaunchedEffect article = loaded - db.articleDao().setRead(articleId, true) + // Deliberately NOT marking read here -- see ReaderScreen.goToNextArticle/ + // goToPreviousArticle, which is where "read" actually gets set now. val loadedFeed = loaded.feedId?.let { db.feedDao().getById(it) } feed = loadedFeed @@ -274,16 +286,10 @@ private fun ReaderContent( ) } } - IconButton(onClick = { - val a = currentArticle ?: return@IconButton - val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse(a.link)) - context.startActivity(browserIntent) - }) { - Icon(Icons.Default.OpenInBrowser, contentDescription = "Open in browser") - } // Purely local: just flips isSaved (shows up in the Saved list). Does NOT - // touch Stash -- that's a separate, explicit action below, since archiving - // to Stash is a heavier/networked action the user should trigger on purpose. + // touch Stash -- that's a separate, explicit action in the overflow menu, + // since archiving to Stash is a heavier/networked action the user should + // trigger on purpose. IconButton(onClick = { val a = currentArticle ?: return@IconButton val newSaved = !a.isSaved @@ -295,41 +301,65 @@ private fun ReaderContent( contentDescription = "Save locally" ) } - IconButton( - enabled = !isSaving, - onClick = { - val a = currentArticle ?: return@IconButton - isSaving = true - scope.launch { - val settings = Settings(context) - val client = StashClient(Net.client, settings.stashBaseUrlNow(), settings.stashApiKeyNow()) - when (val result = client.saveArticle(a.link, a.title)) { - is StashSaveResult.Saved -> { - db.articleDao().setStashArtifactId(a.id, result.artifact.id) - article = a.copy(stashArtifactId = result.artifact.id) - snackbarHostState.showSnackbar("Saved to Stash") - } - is StashSaveResult.AlreadySaved -> { - db.articleDao().setStashArtifactId(a.id, result.existingId) - article = a.copy(stashArtifactId = result.existingId) - snackbarHostState.showSnackbar("Already in Stash") - } - is StashSaveResult.Failed -> - snackbarHostState.showSnackbar("Save failed: ${result.message}") - } - isSaving = false - } - } - ) { - Icon( - if (currentArticle?.stashArtifactId != null) Icons.Default.CloudDone else Icons.Default.CloudUpload, - contentDescription = "Save to Stash" - ) - } IconButton(onClick = { moreMenuExpanded = true }) { Icon(Icons.Default.MoreVert, contentDescription = "More") } DropdownMenu(expanded = moreMenuExpanded, onDismissRequest = { moreMenuExpanded = false }) { + DropdownMenuItem( + leadingIcon = { Icon(Icons.Default.OpenInBrowser, contentDescription = null) }, + text = { Text("Open original") }, + onClick = { + moreMenuExpanded = false + val a = currentArticle ?: return@DropdownMenuItem + context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(a.link))) + } + ) + DropdownMenuItem( + enabled = !isSaving, + leadingIcon = { + Icon( + if (currentArticle?.stashArtifactId != null) Icons.Default.CloudDone else Icons.Default.CloudUpload, + contentDescription = null + ) + }, + text = { Text(if (currentArticle?.stashArtifactId != null) "Saved to Stash" else "Save to Stash") }, + onClick = { + moreMenuExpanded = false + val a = currentArticle ?: return@DropdownMenuItem + isSaving = true + scope.launch { + val settings = Settings(context) + val client = StashClient(Net.client, settings.stashBaseUrlNow(), settings.stashApiKeyNow()) + when (val result = client.saveArticle(a.link, a.title)) { + is StashSaveResult.Saved -> { + db.articleDao().setStashArtifactId(a.id, result.artifact.id) + article = a.copy(stashArtifactId = result.artifact.id) + snackbarHostState.showSnackbar("Saved to Stash") + } + is StashSaveResult.AlreadySaved -> { + db.articleDao().setStashArtifactId(a.id, result.existingId) + article = a.copy(stashArtifactId = result.existingId) + snackbarHostState.showSnackbar("Already in Stash") + } + is StashSaveResult.Failed -> + snackbarHostState.showSnackbar("Save failed: ${result.message}") + } + isSaving = false + } + } + ) + DropdownMenuItem( + text = { Text("Mark unread") }, + onClick = { + moreMenuExpanded = false + val a = currentArticle ?: return@DropdownMenuItem + article = a.copy(isRead = false) + scope.launch { + db.articleDao().setRead(a.id, false) + snackbarHostState.showSnackbar("Marked unread") + } + } + ) // A real (JS-on) browser, not the extraction pipeline -- search results // and login pages need actual interaction, not a Readability pass. DropdownMenuItem( diff --git a/app/src/main/java/org/terst/feedreader/ui/ReaderWebView.kt b/app/src/main/java/org/terst/feedreader/ui/ReaderWebView.kt index 42a15f0..503f71e 100644 --- a/app/src/main/java/org/terst/feedreader/ui/ReaderWebView.kt +++ b/app/src/main/java/org/terst/feedreader/ui/ReaderWebView.kt @@ -38,6 +38,16 @@ fun wrapReaderHtml(bodyContent: String): String = """ background-color: #121212; color: #e1e1e1; } a { color: #8ab4f8; } img { max-width: 100%; height: auto; } + svg { max-width: 100%; } + /* A decorative icon (section badge, share glyph, etc.) that slipped through + extraction relies on the source site's own stylesheet for its real display + size (commonly Tailwind classes like "h-5 w-5") -- which we don't load here. + An <svg> with no explicit width/height falls back to the browser's default + (much larger than any inline icon is meant to be, confirmed live: Ars + Technica's per-article section-badge icon rendering "giant"). One WITH + explicit width/height was deliberately sized by the page itself, so leave + those alone -- just cap the ones that would otherwise size themselves. */ + svg:not([width]):not([height]) { max-width: 32px; max-height: 32px; } </style></head> <body>$bodyContent</body></html> """.trimIndent() 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 a5d3f9c..b7897f4 100644 --- a/app/src/main/java/org/terst/feedreader/ui/SettingsScreen.kt +++ b/app/src/main/java/org/terst/feedreader/ui/SettingsScreen.kt @@ -78,6 +78,21 @@ fun SettingsScreen(onBack: () -> Unit) { LazyColumn(modifier = Modifier.fillMaxWidth().padding(padding)) { item { Column(modifier = Modifier.padding(16.dp)) { + Text("FeeDreader", style = MaterialTheme.typography.titleMedium) + Spacer(modifier = Modifier.height(4.dp)) + Text( + "If a site paywalls its own feed, that's the site's call. But this reader " + + "won't go out of its way to help a soft paywall block you from reading " + + "something you already subscribed to get -- reader mode extracts the " + + "article you were sent, nothing more, nothing sneaky.", + style = MaterialTheme.typography.bodyMedium + ) + } + HorizontalDivider() + } + + item { + Column(modifier = Modifier.padding(16.dp)) { Text("Stash", style = MaterialTheme.typography.titleMedium) Spacer(modifier = Modifier.height(8.dp)) OutlinedTextField( |
