summaryrefslogtreecommitdiff
path: root/app/src/main/java/org/terst/feedreader/ui/ReaderScreen.kt
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-07-22 17:56:50 +0000
committerPeter Stone <thepeterstone@gmail.com>2026-07-22 17:56:50 +0000
commit9da3fbcdef71e90d52045531c7c18faff02f2ff6 (patch)
treee4c9d3444ca5fa4d305b6868d0938b0b44c146c2 /app/src/main/java/org/terst/feedreader/ui/ReaderScreen.kt
parent6d9c200dbd85a3be177d6bb7cac33fba5662ac95 (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/terst/feedreader/ui/ReaderScreen.kt')
-rw-r--r--app/src/main/java/org/terst/feedreader/ui/ReaderScreen.kt58
1 files changed, 50 insertions, 8 deletions
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()