From 9da3fbcdef71e90d52045531c7c18faff02f2ff6 Mon Sep 17 00:00:00 2001 From: Peter Stone Date: Wed, 22 Jul 2026 17:56:50 +0000 Subject: 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. --- .../java/org/terst/feedreader/ui/ReaderScreen.kt | 58 +++++++++++++++++++--- 1 file changed, 50 insertions(+), 8 deletions(-) (limited to 'app/src/main/java/org/terst/feedreader/ui/ReaderScreen.kt') 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(null) } + var feed by remember(articleId) { mutableStateOf(null) } var feedContent by remember(articleId) { mutableStateOf(null) } var readerContent by remember(articleId) { mutableStateOf(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(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 = """ - + $bodyContent """.trimIndent() -- cgit v1.2.3