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 16:54:03 +0000
committerPeter Stone <thepeterstone@gmail.com>2026-07-22 16:54:03 +0000
commit39cca1cb3650edb066f57d0891b1873b767b1c43 (patch)
tree0a966be07f4d4440cfad0f4eeb8bffd1617f3ea0 /app/src/main/java/org/terst/feedreader/ui/ReaderScreen.kt
parent52d7877fccd60a1dae33d877ba6aa765dc098a52 (diff)
Fix Unread/startup crash; add reader-mode toggle and swipe navigation
- Root cause of the reported crashes: observeUnread/observeByFeed/ observeSaved used SELECT *, pulling feedContentHtml/extractedContent (potentially large per-article blobs) just to render a list row. With 107 feeds worth of real articles this blows past Android's CursorWindow/memory limits, scaling with item count -- and since Unread is now the landing screen, it crashed at startup too. Added an ArticleListItem projection excluding the heavy content columns; list queries now only select what a row actually renders. - FilterRuleEngine now takes a small Titled interface (title/feedId) instead of the full entity, so it works with both ArticleEntity and the new lighter projection. - Reader-mode toggle: a button in the reader to manually switch between feed-provided content and Readability-extracted full text, matching Inoreader's full-text-view toggle (previously only an automatic fallback when feed content was thin). - Swipe between articles: ReaderScreen now wraps a HorizontalPager over a snapshot of the current list's article-id order, so swiping moves to the next/previous article. Snapshotted rather than live, since marking an article read would otherwise drop it from the live Unread query mid-swipe and shift every later page underneath the user. 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/ReaderScreen.kt')
-rw-r--r--app/src/main/java/org/terst/feedreader/ui/ReaderScreen.kt126
1 files changed, 104 insertions, 22 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 8b320eb..692c198 100644
--- a/app/src/main/java/org/terst/feedreader/ui/ReaderScreen.kt
+++ b/app/src/main/java/org/terst/feedreader/ui/ReaderScreen.kt
@@ -3,14 +3,19 @@ package org.terst.feedreader.ui
import android.content.Intent
import android.net.Uri
import android.webkit.WebView
+import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.pager.HorizontalPager
+import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Article
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Bookmark
import androidx.compose.material.icons.filled.BookmarkBorder
import androidx.compose.material.icons.filled.OpenInBrowser
+import androidx.compose.material.icons.outlined.Article
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
@@ -31,6 +36,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
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
@@ -41,44 +47,109 @@ import org.terst.feedreader.stash.StashClient
import org.terst.feedreader.stash.StashSaveResult
// Below this length (of the plain feed-provided HTML), assume it's a teaser/summary,
-// not the real article body, and fall back to on-device Readability extraction.
+// not the real article body, and auto-fall-back to on-device Readability extraction.
private const val THIN_CONTENT_THRESHOLD = 500
+/**
+ * Swipeable wrapper: snapshots the article-id order of the list this reader
+ * was opened from (Unread/Saved/a specific feed) ONCE at entry, then pages
+ * across that fixed queue. Deliberately a snapshot, not a live Flow -- opening
+ * an article marks it read, which would otherwise drop it out of the live
+ * Unread query mid-swipe and shift every subsequent page underneath the user.
+ */
+@OptIn(ExperimentalFoundationApi::class)
+@Composable
+fun ReaderScreen(feedId: String, initialArticleId: String, onBack: () -> Unit) {
+ val context = LocalContext.current
+ val db = remember { AppDatabase.get(context) }
+ var articleIds by remember(feedId) { mutableStateOf<List<String>?>(null) }
+
+ LaunchedEffect(feedId) {
+ val flow = when (feedId) {
+ UNREAD_FEED_ID -> db.articleDao().observeUnread()
+ SAVED_FEED_ID -> db.articleDao().observeSaved()
+ else -> db.articleDao().observeByFeed(feedId)
+ }
+ articleIds = flow.first().map { it.id }
+ }
+
+ val ids = articleIds
+ if (ids == null) {
+ Box(modifier = Modifier.fillMaxSize()) {
+ CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
+ }
+ return
+ }
+ // The article may have been opened from somewhere not in this queue (shouldn't normally
+ // happen, but fall back to a single-item queue rather than crashing on indexOfFirst = -1).
+ val safeIds = if (ids.contains(initialArticleId)) ids else listOf(initialArticleId)
+ val initialIndex = safeIds.indexOf(initialArticleId).coerceAtLeast(0)
+ val pagerState = rememberPagerState(initialPage = initialIndex) { safeIds.size }
+
+ HorizontalPager(state = pagerState, modifier = Modifier.fillMaxSize()) { page ->
+ ReaderContent(articleId = safeIds[page], onBack = onBack)
+ }
+}
+
@OptIn(ExperimentalMaterial3Api::class)
@Composable
-fun ReaderScreen(articleId: String, onBack: () -> Unit) {
+private fun ReaderContent(articleId: String, onBack: () -> Unit) {
val context = LocalContext.current
val db = remember { AppDatabase.get(context) }
val scope = rememberCoroutineScope()
val snackbarHostState = remember { SnackbarHostState() }
- var article by remember { mutableStateOf<ArticleEntity?>(null) }
- var displayContent by remember { mutableStateOf<String?>(null) }
- var isExtracting by remember { mutableStateOf(false) }
- var isSaving by remember { mutableStateOf(false) }
+ var article by remember(articleId) { mutableStateOf<ArticleEntity?>(null) }
+ var feedContent by remember(articleId) { mutableStateOf<String?>(null) }
+ var readerContent by remember(articleId) { mutableStateOf<String?>(null) }
+ var isReaderMode by remember(articleId) { mutableStateOf(false) }
+ var isExtracting by remember(articleId) { mutableStateOf(false) }
+ var isSaving by remember(articleId) { mutableStateOf(false) }
LaunchedEffect(articleId) {
val loaded = db.articleDao().getById(articleId) ?: return@LaunchedEffect
article = loaded
db.articleDao().setRead(articleId, true)
- val feedContent = loaded.feedContentHtml
+ feedContent = loaded.feedContentHtml
+ readerContent = loaded.extractedContent
val plainLength = feedContent?.replace(Regex("<[^>]*>"), "")?.trim()?.length ?: 0
- displayContent = when {
- loaded.extractedContent != null -> loaded.extractedContent
- plainLength >= THIN_CONTENT_THRESHOLD -> feedContent
- else -> {
- isExtracting = true
- val extracted = ReaderExtractor.extract(context, loaded.link)
- isExtracting = false
- val content = extracted?.content
- if (content != null) {
- db.articleDao().setExtractedContent(articleId, content)
- content
- } else {
- feedContent ?: "<p>Couldn't load article content. Try opening in browser.</p>"
- }
+ if (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
+ val extracted = ReaderExtractor.extract(context, loaded.link)
+ isExtracting = false
+ extracted?.content?.let {
+ readerContent = it
+ db.articleDao().setExtractedContent(articleId, it)
+ }
+ isReaderMode = true
+ }
+ }
+
+ fun toggleReaderMode() {
+ val a = article ?: return
+ if (isReaderMode) {
+ isReaderMode = false
+ return
+ }
+ if (readerContent != null) {
+ isReaderMode = true
+ return
+ }
+ scope.launch {
+ isExtracting = true
+ val extracted = ReaderExtractor.extract(context, a.link)
+ isExtracting = false
+ val content = extracted?.content
+ if (content != null) {
+ readerContent = content
+ db.articleDao().setExtractedContent(articleId, content)
+ isReaderMode = true
+ } else {
+ snackbarHostState.showSnackbar("Couldn't extract a reader view for this article")
}
}
}
@@ -94,6 +165,17 @@ fun ReaderScreen(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) {
+ IconButton(enabled = !isExtracting, onClick = { toggleReaderMode() }) {
+ Icon(
+ if (isReaderMode) Icons.Filled.Article else Icons.Outlined.Article,
+ contentDescription = "Toggle reader view"
+ )
+ }
+ }
IconButton(onClick = {
val a = currentArticle ?: return@IconButton
val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse(a.link))
@@ -138,7 +220,7 @@ fun ReaderScreen(articleId: String, onBack: () -> Unit) {
snackbarHost = { SnackbarHost(snackbarHostState) }
) { padding ->
Box(modifier = Modifier.fillMaxSize().padding(padding)) {
- val content = displayContent
+ val content = if (isReaderMode) readerContent else (feedContent ?: readerContent)
val baseUrl = article?.link
if (content != null) {
AndroidView(