package org.terst.feedreader.ui import android.content.Intent import android.net.Uri import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.gestures.detectDragGestures 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 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.OpenInBrowser import androidx.compose.material.icons.outlined.Article 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.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text 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.input.pointer.pointerInput import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity 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 import kotlin.math.abs // Below this length (of the plain feed-provided HTML), assume it's a teaser/summary, // 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, 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 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 } val scope = rememberCoroutineScope() val directionLockPx = with(LocalDensity.current) { 16.dp.toPx() } val swipeThresholdPx = with(LocalDensity.current) { 80.dp.toPx() } // HorizontalPager's own full-surface drag detection was grabbing ordinary vertical // scrolling inside the embedded WebView (a native View, which doesn't arbitrate with // Compose's gesture system the way a pure-Compose scrollable child would) as an // attempted horizontal page-turn on anything less than a perfectly vertical swipe -- // which is most real thumb scrolling. An edge-zone-gated version of this was tried // first, but on gesture-nav Android the screen edges belong to the OS's own back // gesture, which wins before the app ever sees the touch (confirmed live: an edge // swipe triggered system back, not the pager). So instead: disable the Pager's own // gesture and drive it from a detector that works anywhere on screen but only commits // to a page turn once the drag is clearly horizontal-dominant (>2x the vertical // movement) -- ordinary scrolling, even sloppy/diagonal, stays vertical-dominant and // is left completely unconsumed for the WebView to handle normally. Box( modifier = Modifier .fillMaxSize() .pointerInput(safeIds.size) { var totalDx = 0f var totalDy = 0f var committedHorizontal = false detectDragGestures( onDragStart = { totalDx = 0f totalDy = 0f committedHorizontal = false }, onDragCancel = { committedHorizontal = false }, onDragEnd = { if (committedHorizontal) { val direction = when { totalDx <= -swipeThresholdPx -> 1 totalDx >= swipeThresholdPx -> -1 else -> 0 } if (direction != 0) { val target = (pagerState.currentPage + direction).coerceIn(0, safeIds.size - 1) scope.launch { pagerState.animateScrollToPage(target) } } } committedHorizontal = false }, onDrag = { change, dragAmount -> totalDx += dragAmount.x totalDy += dragAmount.y if (!committedHorizontal && abs(totalDx) > directionLockPx && abs(totalDx) > abs(totalDy) * 2f ) { committedHorizontal = true } if (committedHorizontal) change.consume() } ) } ) { HorizontalPager(state = pagerState, userScrollEnabled = false, modifier = Modifier.fillMaxSize()) { page -> ReaderContent( articleId = safeIds[page], onBack = onBack, onOpenLink = onOpenLink, onOpenBrowser = onOpenBrowser ) } } } @OptIn(ExperimentalMaterial3Api::class) @Composable private fun ReaderContent( articleId: String, onBack: () -> Unit, onOpenLink: (String) -> Unit, onOpenBrowser: (String) -> 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) } 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 val plainLength = feedContent?.replace(Regex("<[^>]*>"), "")?.trim()?.length ?: 0 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 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 (feed?.readerModeDisabled == true) 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") } } } Scaffold( topBar = { TopAppBar( 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") } }, 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 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, contentDescription = "Toggle reader view" ) } } 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) } ) } } ) }, snackbarHost = { SnackbarHost(snackbarHostState) } ) { padding -> 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)) } } } }