package org.terst.feedreader.ui import android.content.ClipData import android.content.ClipboardManager import android.content.Context import android.content.Intent import android.net.Uri import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height 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.CloudDone import androidx.compose.material.icons.filled.CloudUpload import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.NavigateBefore import androidx.compose.material.icons.filled.NavigateNext import androidx.compose.material.icons.filled.OpenInBrowser import androidx.compose.material.icons.outlined.Article import androidx.compose.material3.AlertDialog import androidx.compose.material3.BottomAppBar import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope 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.unit.dp 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 import org.terst.feedreader.stash.StashClient import org.terst.feedreader.stash.StashSaveResult import java.net.URLEncoder /** * 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, onOpenLink: (String) -> Unit, onOpenBrowser: (String) -> Unit ) { val context = LocalContext.current val db = remember { AppDatabase.get(context) } var articleIds by remember(feedId) { mutableStateOf?>(null) } LaunchedEffect(feedId) { val cutoff = System.currentTimeMillis() - RECENT_WINDOW_MS val flow = when (feedId) { UNREAD_FEED_ID -> db.articleDao().observeUnread(cutoff) OLDER_FEED_ID -> db.articleDao().observeOlderUnread(cutoff) 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 } val scope = rememberCoroutineScope() // Swipe-to-next-article is gone -- two different approaches to keeping it from // interfering with WebView scrolling (an orientation-aware Compose gesture detector, // then a passive native touch listener) were tried and neither actually resolved the // reported jerkiness, and the second one broke swipe outright. Explicit Previous/Next // buttons in the bottom bar replace it: no ambiguity with scrolling at all, since // there's no gesture to disambiguate in the first place. val currentPage = pagerState.currentPage val hasPrevious = currentPage > 0 val hasNext = currentPage < safeIds.size - 1 fun goToPreviousArticle() { scope.launch { pagerState.animateScrollToPage((pagerState.currentPage - 1).coerceAtLeast(0)) } } fun goToNextArticle() { scope.launch { pagerState.animateScrollToPage((pagerState.currentPage + 1).coerceAtMost(safeIds.size - 1)) } } HorizontalPager(state = pagerState, userScrollEnabled = false, modifier = Modifier.fillMaxSize()) { page -> ReaderContent( articleId = safeIds[page], onBack = onBack, onOpenLink = onOpenLink, onOpenBrowser = onOpenBrowser, hasPrevious = hasPrevious, hasNext = hasNext, onPrevious = ::goToPreviousArticle, onNext = ::goToNextArticle ) } } @OptIn(ExperimentalMaterial3Api::class) @Composable private fun ReaderContent( articleId: String, onBack: () -> Unit, onOpenLink: (String) -> Unit, onOpenBrowser: (String) -> Unit, hasPrevious: Boolean, hasNext: Boolean, onPrevious: () -> Unit, onNext: () -> Unit ) { val context = LocalContext.current val db = remember { AppDatabase.get(context) } val scope = rememberCoroutineScope() 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) } var isExtracting by remember(articleId) { mutableStateOf(false) } var isSaving by remember(articleId) { mutableStateOf(false) } var moreMenuExpanded by remember(articleId) { mutableStateOf(false) } var showBugReportDialog by remember(articleId) { mutableStateOf(false) } var bugReportDescription by remember(articleId) { mutableStateOf("") } LaunchedEffect(articleId) { 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 // Reader mode is NEVER heuristic/automatic (no more "content looks thin, switch // for you") -- it strictly follows the feed's persisted, explicitly-set opt-in. // Once a feed has it on, articles from it default to the extracted view and // auto-extract on open (that's the point of persisting it per-feed: no re-tapping // per article), but nothing here ever turns it on by itself. if (loadedFeed?.readerModeEnabled == true) { isReaderMode = true if (readerContent == null) { isExtracting = true val extracted = ReaderExtractor.extract(context, loaded.link) isExtracting = false extracted?.content?.let { readerContent = it db.articleDao().setExtractedContent(articleId, it) } } } } // The toggle now sets reader mode for the ARTICLE'S FEED, persisted immediately, with // a confirmation snackbar -- not a per-article, this-session-only view flip. Tapping it // is the one and only way reader mode ever turns on. fun toggleFeedReaderMode() { val f = feed ?: return val a = article ?: return val newEnabled = !f.readerModeEnabled feed = f.copy(readerModeEnabled = newEnabled) scope.launch { db.feedDao().update(f.copy(readerModeEnabled = newEnabled)) val feedLabel = f.title.ifBlank { "this feed" } if (newEnabled) { if (readerContent == null) { 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 snackbarHostState.showSnackbar("Reader mode enabled for $feedLabel") } else { isReaderMode = false snackbarHostState.showSnackbar("Reader mode disabled for $feedLabel") } } } Scaffold( topBar = { TopAppBar( title = { Column { Text( article?.title.orEmpty(), maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.titleMedium ) 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") } }, actions = { val currentArticle = article // Sets reader mode for the whole feed (persisted), not just this article -- // shown whenever the article belongs to an actual subscribed feed. if (feed != null) { IconButton(enabled = !isExtracting, onClick = { toggleFeedReaderMode() }) { Icon( if (feed?.readerModeEnabled == true) Icons.Filled.Article else Icons.Outlined.Article, contentDescription = if (feed?.readerModeEnabled == true) "Reader mode enabled for this feed -- tap to disable" else "Reader mode disabled for this feed -- tap to enable" ) } } 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. IconButton(onClick = { val a = currentArticle ?: return@IconButton val newSaved = !a.isSaved article = a.copy(isSaved = newSaved) scope.launch { db.articleDao().setSaved(a.id, newSaved) } }) { Icon( if (currentArticle?.isSaved == true) Icons.Default.Bookmark else Icons.Default.BookmarkBorder, 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 }) { // A real (JS-on) browser, not the extraction pipeline -- search results // and login pages need actual interaction, not a Readability pass. DropdownMenuItem( text = { Text("Find another source") }, onClick = { moreMenuExpanded = false val a = currentArticle ?: return@DropdownMenuItem val query = URLEncoder.encode(a.title, "UTF-8") onOpenBrowser("https://www.google.com/search?q=$query") } ) DropdownMenuItem( text = { Text("Log in to this site") }, onClick = { moreMenuExpanded = false val a = currentArticle ?: return@DropdownMenuItem onOpenBrowser(a.link) } ) DropdownMenuItem( text = { Text("Report bug") }, onClick = { moreMenuExpanded = false showBugReportDialog = true } ) } } ) }, bottomBar = { BottomAppBar { TextButton( onClick = onPrevious, enabled = hasPrevious, modifier = Modifier.weight(1f) ) { Icon(Icons.Default.NavigateBefore, contentDescription = null) Text("Previous") } TextButton( onClick = onNext, enabled = hasNext, modifier = Modifier.weight(1f) ) { Text("Next") Icon(Icons.Default.NavigateNext, contentDescription = null) } } }, snackbarHost = { SnackbarHost(snackbarHostState) } ) { padding -> if (showBugReportDialog) { val a = article AlertDialog( onDismissRequest = { showBugReportDialog = false }, title = { Text("Report bug") }, text = { Column { Text("Describe what happened. Copies article/feed state along with it so it can be reproduced.") Spacer(modifier = Modifier.height(12.dp)) OutlinedTextField( value = bugReportDescription, onValueChange = { bugReportDescription = it }, label = { Text("Description") }, modifier = Modifier.fillMaxWidth() ) } }, confirmButton = { TextButton(onClick = { val report = buildString { appendLine("Feeds bug report") appendLine("Description: ${bugReportDescription.ifBlank { "(none provided)" }}") appendLine("Article title: ${a?.title}") appendLine("Article URL: ${a?.link}") appendLine("Article id: ${a?.id}") appendLine("Feed: ${feed?.title} (${feed?.id})") appendLine("Feed readerModeEnabled: ${feed?.readerModeEnabled}") appendLine("Session isReaderMode: $isReaderMode") appendLine("isSaved: ${a?.isSaved}, stashArtifactId: ${a?.stashArtifactId}") appendLine("extractedContent cached: ${readerContent != null}") } val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager clipboard.setPrimaryClip(ClipData.newPlainText("Bug report", report)) showBugReportDialog = false scope.launch { snackbarHostState.showSnackbar("Copied to clipboard") } }) { Text("Copy") } }, dismissButton = { TextButton(onClick = { showBugReportDialog = false }) { Text("Cancel") } } ) } Box(modifier = Modifier.fillMaxSize().padding(padding)) { 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 -> newReaderWebView(ctx, onOpenLink).apply { loadDataWithBaseURL(baseUrl, wrapReaderHtml(content), "text/html", "UTF-8", null) loadedContent.value = content } }, update = { webView -> if (loadedContent.value != content) { webView.loadDataWithBaseURL(baseUrl, wrapReaderHtml(content), "text/html", "UTF-8", null) loadedContent.value = content } }, // 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 // exhausts memory over a long reading session. onRelease = { webView -> webView.destroy() } ) } else if (isExtracting) { CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) } } } }