diff options
| author | Peter Stone <thepeterstone@gmail.com> | 2026-07-22 17:56:50 +0000 |
|---|---|---|
| committer | Peter Stone <thepeterstone@gmail.com> | 2026-07-22 17:56:50 +0000 |
| commit | 9da3fbcdef71e90d52045531c7c18faff02f2ff6 (patch) | |
| tree | e4c9d3444ca5fa4d305b6868d0938b0b44c146c2 /app/src/main/java/org | |
| parent | 6d9c200dbd85a3be177d6bb7cac33fba5662ac95 (diff) | |
Smooth scrolling, dark reader, mark-read-on-scroll, feed names, per-feed reader-mode opt-out
- ReaderScreen: AndroidView's update() was reloading the WebView's HTML on
every recomposition (e.g. isSaving flipping mid-read), resetting scroll
position -- this is what made scrolling feel unsmooth. Now only reloads
when the content string actually changes.
- ReaderScreen: reader content now uses a hardcoded dark palette instead of
browser-default white, matching the app's always-dark theme.
- ArticleListScreen: mark-as-read-on-scroll -- once an article has scrolled
fully above the viewport it's marked read, the standard feed-reader
behavior, via a new batched ArticleDao.markReadBatch(). Verified against
the on-device DB (scrolled-past articles flipped to isRead=1, the
still-visible one stayed unread).
- ArticleListItem/queries: LEFT JOIN feeds for a feedTitle column, shown per
row in the Unread/Saved aggregated views and as a reader subtitle; the
per-feed article list's top bar now shows the real feed title instead of
a generic "Articles".
- FeedEntity.readerModeDisabled (schema v3, migration 2->3): per-feed
opt-out of the Readability fallback/toggle, toggled from a new icon in
FeedListScreen. Added for kottke.org specifically -- confirmed its thin
linkblog posts were making Readability extract the page footer instead
of the actual post; disabling reader mode for that feed now shows its
own (short but correct) content instead.
All verified live on the emulator against the real 107-feed dataset.
Diffstat (limited to 'app/src/main/java/org')
6 files changed, 141 insertions, 22 deletions
diff --git a/app/src/main/java/org/terst/feedreader/data/AppDatabase.kt b/app/src/main/java/org/terst/feedreader/data/AppDatabase.kt index e2f6c1a..4cf0ecd 100644 --- a/app/src/main/java/org/terst/feedreader/data/AppDatabase.kt +++ b/app/src/main/java/org/terst/feedreader/data/AppDatabase.kt @@ -9,7 +9,7 @@ import androidx.room.migration.Migration @Database( entities = [FeedEntity::class, ArticleEntity::class, FilterRuleEntity::class], - version = 2, + version = 3, exportSchema = false ) abstract class AppDatabase : RoomDatabase() { @@ -31,12 +31,19 @@ abstract class AppDatabase : RoomDatabase() { } } + // v3: added FeedEntity.readerModeDisabled (per-feed reader-mode opt-out). + private val MIGRATION_2_3 = object : Migration(2, 3) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE feeds ADD COLUMN readerModeDisabled INTEGER NOT NULL DEFAULT 0") + } + } + fun get(context: Context): AppDatabase = instance ?: synchronized(this) { instance ?: Room.databaseBuilder( context.applicationContext, AppDatabase::class.java, "feedreader.db" - ).addMigrations(MIGRATION_1_2).build().also { instance = it } + ).addMigrations(MIGRATION_1_2, MIGRATION_2_3).build().also { instance = it } } } } diff --git a/app/src/main/java/org/terst/feedreader/data/Daos.kt b/app/src/main/java/org/terst/feedreader/data/Daos.kt index f80cce3..8fe2ec2 100644 --- a/app/src/main/java/org/terst/feedreader/data/Daos.kt +++ b/app/src/main/java/org/terst/feedreader/data/Daos.kt @@ -48,25 +48,30 @@ data class ArticleListItem( val isRead: Boolean, val isSaved: Boolean, val stashArtifactId: String?, - val createdAt: Long + val createdAt: Long, + val feedTitle: String? // via LEFT JOIN feeds -- null for standalone share-sheet saves with no feedId ) : Titled private const val ARTICLE_LIST_COLUMNS = - "id, feedId, title, link, author, publishedAt, imageUrl, isRead, isSaved, stashArtifactId, createdAt" + "articles.id, articles.feedId, articles.title, articles.link, articles.author, articles.publishedAt, " + + "articles.imageUrl, articles.isRead, articles.isSaved, articles.stashArtifactId, articles.createdAt, " + + "feeds.title AS feedTitle" + +private const val ARTICLE_LIST_FROM = "articles LEFT JOIN feeds ON articles.feedId = feeds.id" @Dao interface ArticleDao { // Unread-first, newest-first -- the default "what's new" view across every feed. - @Query("SELECT $ARTICLE_LIST_COLUMNS FROM articles WHERE isRead = 0 ORDER BY publishedAt DESC") + @Query("SELECT $ARTICLE_LIST_COLUMNS FROM $ARTICLE_LIST_FROM WHERE articles.isRead = 0 ORDER BY articles.publishedAt DESC") fun observeUnread(): Flow<List<ArticleListItem>> @Query("SELECT feedId, COUNT(*) as count FROM articles WHERE isRead = 0 AND feedId IS NOT NULL GROUP BY feedId") fun observeUnreadCounts(): Flow<List<FeedUnreadCount>> - @Query("SELECT $ARTICLE_LIST_COLUMNS FROM articles WHERE feedId = :feedId ORDER BY publishedAt DESC") + @Query("SELECT $ARTICLE_LIST_COLUMNS FROM $ARTICLE_LIST_FROM WHERE articles.feedId = :feedId ORDER BY articles.publishedAt DESC") fun observeByFeed(feedId: String): Flow<List<ArticleListItem>> - @Query("SELECT $ARTICLE_LIST_COLUMNS FROM articles WHERE isSaved = 1 ORDER BY createdAt DESC") + @Query("SELECT $ARTICLE_LIST_COLUMNS FROM $ARTICLE_LIST_FROM WHERE articles.isSaved = 1 ORDER BY articles.createdAt DESC") fun observeSaved(): Flow<List<ArticleListItem>> @Query("SELECT * FROM articles WHERE id = :id") @@ -84,6 +89,11 @@ interface ArticleDao { @Query("UPDATE articles SET isRead = :read WHERE id = :id") suspend fun setRead(id: String, read: Boolean) + // Used for mark-as-read-on-scroll in the article list -- batched per scroll-settle + // rather than one query per row. + @Query("UPDATE articles SET isRead = 1 WHERE id IN (:ids)") + suspend fun markReadBatch(ids: List<String>) + @Query("UPDATE articles SET isRead = 1 WHERE feedId = :feedId") suspend fun markAllReadForFeed(feedId: String) diff --git a/app/src/main/java/org/terst/feedreader/data/Entities.kt b/app/src/main/java/org/terst/feedreader/data/Entities.kt index 29d4fe4..f943f78 100644 --- a/app/src/main/java/org/terst/feedreader/data/Entities.kt +++ b/app/src/main/java/org/terst/feedreader/data/Entities.kt @@ -12,7 +12,8 @@ data class FeedEntity( val lastFetchedAt: Long? = null, val etag: String? = null, // conditional-GET caching (If-None-Match) to avoid re-downloading unchanged feeds val lastModified: String? = null, // conditional-GET caching (If-Modified-Since) - val lastError: String? = null // set when a fetch fails; cleared on the next successful fetch + val lastError: String? = null, // set when a fetch fails; cleared on the next successful fetch + val readerModeDisabled: Boolean = false // per-feed opt-out: never auto/manually switch to Readability for this feed's articles (e.g. a feed whose own formatting is better than what extraction produces) ) @Entity(tableName = "articles") 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 62b90c9..0beb523 100644 --- a/app/src/main/java/org/terst/feedreader/ui/ArticleListScreen.kt +++ b/app/src/main/java/org/terst/feedreader/ui/ArticleListScreen.kt @@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowBack @@ -22,9 +23,13 @@ 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.setValue +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.layout.ContentScale @@ -65,6 +70,31 @@ fun ArticleListScreen( } }.collectAsState(initial = emptyList()) + // Unread/Saved mix articles from every feed, so each row names its source; a + // 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 == SAVED_FEED_ID + + var specificFeedTitle by remember(feedId) { mutableStateOf<String?>(null) } + LaunchedEffect(feedId) { + if (!showFeedName) { + specificFeedTitle = db.feedDao().getById(feedId)?.title + } + } + + val listState = rememberLazyListState() + // Mark-as-read-on-scroll: once an article has scrolled fully above the viewport + // (the user has moved past it), treat it as read -- the standard feed-reader + // behavior, rather than requiring every article to be opened individually. + LaunchedEffect(listState, visibleArticles) { + snapshotFlow { listState.firstVisibleItemIndex }.collect { firstVisible -> + val toMark = visibleArticles.take(firstVisible) + .map { it.first } + .filter { !it.isRead } + .map { it.id } + if (toMark.isNotEmpty()) db.articleDao().markReadBatch(toMark) + } + } + Scaffold( topBar = { TopAppBar( @@ -73,7 +103,7 @@ fun ArticleListScreen( when (feedId) { UNREAD_FEED_ID -> "Unread" SAVED_FEED_ID -> "Saved" - else -> "Articles" + else -> specificFeedTitle ?: "Articles" } ) }, @@ -93,11 +123,12 @@ fun ArticleListScreen( ) } ) { padding -> - LazyColumn(modifier = Modifier.fillMaxSize().padding(padding)) { + LazyColumn(state = listState, modifier = Modifier.fillMaxSize().padding(padding)) { items(visibleArticles, key = { it.first.id }) { (article, result) -> ArticleRow( article = article, highlighted = result == FilterResult.HIGHLIGHTED, + showFeedName = showFeedName, onClick = { onOpenArticle(article.id) } ) HorizontalDivider() @@ -107,8 +138,16 @@ fun ArticleListScreen( } @Composable -private fun ArticleRow(article: ArticleListItem, highlighted: Boolean, onClick: () -> Unit) { +private fun ArticleRow( + article: ArticleListItem, + highlighted: Boolean, + showFeedName: Boolean, + onClick: () -> Unit +) { val dateText = article.publishedAt?.let { DateFormat.getDateInstance(DateFormat.MEDIUM).format(Date(it)) } + val metaText = listOfNotNull(if (showFeedName) article.feedTitle else null, dateText) + .joinToString(" · ") + .ifEmpty { null } ListItem( // Passed as null (not an empty/conditional lambda) when there's no image, so // ListItem's own layout collapses the leading slot entirely for text-only rows @@ -129,7 +168,7 @@ private fun ArticleRow(article: ArticleListItem, highlighted: Boolean, onClick: textDecoration = if (article.isRead) TextDecoration.LineThrough else null ) }, - supportingContent = { if (dateText != null) Text(dateText) }, + supportingContent = { if (metaText != null) Text(metaText) }, modifier = Modifier .fillMaxWidth() .background(if (highlighted) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surface) 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 a4852c6..5ed5855 100644 --- a/app/src/main/java/org/terst/feedreader/ui/FeedListScreen.kt +++ b/app/src/main/java/org/terst/feedreader/ui/FeedListScreen.kt @@ -11,10 +11,12 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Article import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.icons.outlined.Article import androidx.compose.material3.AlertDialog import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem @@ -137,7 +139,10 @@ fun FeedListScreen( feed = feed, unreadCount = unreadCount, onClick = { onOpenFeed(feed.id) }, - onDelete = { pendingDelete = feed } + onDelete = { pendingDelete = feed }, + onToggleReaderMode = { + scope.launch { db.feedDao().update(feed.copy(readerModeDisabled = !feed.readerModeDisabled)) } + } ) HorizontalDivider() } @@ -146,7 +151,13 @@ fun FeedListScreen( } @Composable -private fun FeedRow(feed: FeedEntity, unreadCount: Int, onClick: () -> Unit, onDelete: () -> Unit) { +private fun FeedRow( + feed: FeedEntity, + unreadCount: Int, + onClick: () -> Unit, + onDelete: () -> Unit, + onToggleReaderMode: () -> Unit +) { ListItem( headlineContent = { Text(feed.title.ifBlank { feed.id }) }, supportingContent = { @@ -160,6 +171,15 @@ private fun FeedRow(feed: FeedEntity, unreadCount: Int, onClick: () -> Unit, onD Text(unreadCount.toString()) Spacer(modifier = Modifier.width(8.dp)) } + IconButton(onClick = onToggleReaderMode) { + Icon( + if (feed.readerModeDisabled) Icons.Outlined.Article else Icons.Filled.Article, + contentDescription = if (feed.readerModeDisabled) + "Reader mode disabled for this feed -- tap to re-enable" + else + "Reader mode enabled for this feed -- tap to disable" + ) + } IconButton(onClick = onDelete) { Icon(Icons.Default.Delete, contentDescription = "Unsubscribe") } 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 cef17c5..2177d72 100644 --- a/app/src/main/java/org/terst/feedreader/ui/ReaderScreen.kt +++ b/app/src/main/java/org/terst/feedreader/ui/ReaderScreen.kt @@ -5,6 +5,7 @@ import android.net.Uri import android.webkit.WebView import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.pager.HorizontalPager @@ -20,6 +21,7 @@ import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState @@ -35,11 +37,13 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.viewinterop.AndroidView import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import org.terst.feedreader.data.AppDatabase import org.terst.feedreader.data.ArticleEntity +import org.terst.feedreader.data.FeedEntity import org.terst.feedreader.data.Net import org.terst.feedreader.data.Settings import org.terst.feedreader.readability.ReaderExtractor @@ -100,6 +104,7 @@ private fun ReaderContent(articleId: String, onBack: () -> Unit) { val snackbarHostState = remember { SnackbarHostState() } var article by remember(articleId) { mutableStateOf<ArticleEntity?>(null) } + var feed by remember(articleId) { mutableStateOf<FeedEntity?>(null) } var feedContent by remember(articleId) { mutableStateOf<String?>(null) } var readerContent by remember(articleId) { mutableStateOf<String?>(null) } var isReaderMode by remember(articleId) { mutableStateOf(false) } @@ -110,12 +115,14 @@ private fun ReaderContent(articleId: String, onBack: () -> Unit) { val loaded = db.articleDao().getById(articleId) ?: return@LaunchedEffect article = loaded db.articleDao().setRead(articleId, true) + val loadedFeed = loaded.feedId?.let { db.feedDao().getById(it) } + feed = loadedFeed feedContent = loaded.feedContentHtml readerContent = loaded.extractedContent val plainLength = feedContent?.replace(Regex("<[^>]*>"), "")?.trim()?.length ?: 0 - if (readerContent == null && plainLength < THIN_CONTENT_THRESHOLD) { + if (loadedFeed?.readerModeDisabled != true && readerContent == null && plainLength < THIN_CONTENT_THRESHOLD) { // Feed content is too thin to be the real article -- auto-extract, since in this // case reader mode isn't really an alternative view, it's the only real content. isExtracting = true @@ -131,6 +138,7 @@ private fun ReaderContent(articleId: String, onBack: () -> Unit) { fun toggleReaderMode() { val a = article ?: return + if (feed?.readerModeDisabled == true) return if (isReaderMode) { isReaderMode = false return @@ -157,7 +165,20 @@ private fun ReaderContent(articleId: String, onBack: () -> Unit) { Scaffold( topBar = { TopAppBar( - title = { Text(article?.title.orEmpty(), maxLines = 1) }, + title = { + Column { + Text(article?.title.orEmpty(), maxLines = 1, overflow = TextOverflow.Ellipsis) + feed?.title?.let { + Text( + it, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + }, navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.Default.ArrowBack, contentDescription = "Back") @@ -166,9 +187,9 @@ private fun ReaderContent(articleId: String, onBack: () -> Unit) { actions = { val currentArticle = article // Only offer the toggle when there's an actual feed-provided alternative to - // switch back to -- if we already had to auto-extract (thin feed content), - // reader mode IS the only content, so there's nothing to toggle between. - if (feedContent != null) { + // switch back to (if we already had to auto-extract for thin feed content, + // reader mode IS the only content) and the feed hasn't opted out of it. + if (feedContent != null && feed?.readerModeDisabled != true) { IconButton(enabled = !isExtracting, onClick = { toggleReaderMode() }) { Icon( if (isReaderMode) Icons.Filled.Article else Icons.Outlined.Article, @@ -223,15 +244,28 @@ private fun ReaderContent(articleId: String, onBack: () -> Unit) { val content = if (isReaderMode) readerContent else (feedContent ?: readerContent) val baseUrl = article?.link if (content != null) { + // AndroidView's update lambda reruns on every recomposition of this + // composable -- including ones unrelated to content, like isSaving + // flipping while a Stash save is in flight. Reloading the WebView's HTML + // on those unrelated recompositions was resetting scroll position mid-swipe, + // which is what made scrolling feel unsmooth/janky. Track what's actually + // loaded and only reload when the content string itself changes. + val loadedContent = remember(articleId) { mutableStateOf<String?>(null) } AndroidView( modifier = Modifier.fillMaxSize(), factory = { ctx -> WebView(ctx).apply { settings.javaScriptEnabled = false loadDataWithBaseURL(baseUrl, wrapHtml(content), "text/html", "UTF-8", null) + loadedContent.value = content + } + }, + update = { webView -> + if (loadedContent.value != content) { + webView.loadDataWithBaseURL(baseUrl, wrapHtml(content), "text/html", "UTF-8", null) + loadedContent.value = content } }, - update = { webView -> webView.loadDataWithBaseURL(baseUrl, wrapHtml(content), "text/html", "UTF-8", null) }, // AndroidView does not destroy() the underlying WebView on its own -- // without this, swiping through many articles (each page holding its own // WebView) leaks a WebView's native resources per page and eventually @@ -245,9 +279,17 @@ private fun ReaderContent(articleId: String, onBack: () -> Unit) { } } +// Hardcoded dark palette, not @media (prefers-color-scheme) -- the app itself always +// forces dark colorScheme() regardless of system setting (see MainActivity), so the +// reader content should match rather than following a system light/dark that the rest +// of the app ignores. private fun wrapHtml(bodyContent: String): String = """ <html><head><meta name="viewport" content="width=device-width, initial-scale=1"> - <style>body { font-family: sans-serif; font-size: 18px; line-height: 1.5; padding: 16px; } - img { max-width: 100%; height: auto; }</style></head> + <style> + body { font-family: sans-serif; font-size: 18px; line-height: 1.5; padding: 16px; + background-color: #121212; color: #e1e1e1; } + a { color: #8ab4f8; } + img { max-width: 100%; height: auto; } + </style></head> <body>$bodyContent</body></html> """.trimIndent() |
