package org.terst.feedreader.ui import android.content.Intent import android.net.Uri import android.webkit.WebView import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons 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.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton 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.platform.LocalContext import androidx.compose.ui.viewinterop.AndroidView import kotlinx.coroutines.launch import org.terst.feedreader.data.AppDatabase import org.terst.feedreader.data.ArticleEntity 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 // 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. private const val THIN_CONTENT_THRESHOLD = 500 @OptIn(ExperimentalMaterial3Api::class) @Composable fun ReaderScreen(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(null) } var displayContent by remember { mutableStateOf(null) } var isExtracting by remember { mutableStateOf(false) } var isSaving by remember { mutableStateOf(false) } LaunchedEffect(articleId) { val loaded = db.articleDao().getById(articleId) ?: return@LaunchedEffect article = loaded db.articleDao().setRead(articleId, true) val feedContent = loaded.feedContentHtml 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 ?: "

Couldn't load article content. Try opening in browser.

" } } } } Scaffold( topBar = { TopAppBar( title = { Text(article?.title.orEmpty(), maxLines = 1) }, navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.Default.ArrowBack, contentDescription = "Back") } }, actions = { val currentArticle = article 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") } 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().setSaved(a.id, true) db.articleDao().setStashArtifactId(a.id, result.artifact.id) snackbarHostState.showSnackbar("Saved to Stash") } is StashSaveResult.AlreadySaved -> { db.articleDao().setSaved(a.id, true) db.articleDao().setStashArtifactId(a.id, result.existingId) snackbarHostState.showSnackbar("Already in Stash") } is StashSaveResult.Failed -> snackbarHostState.showSnackbar("Save failed: ${result.message}") } isSaving = false } } ) { Icon( if (currentArticle?.isSaved == true) Icons.Default.Bookmark else Icons.Default.BookmarkBorder, contentDescription = "Save to Stash" ) } } ) }, snackbarHost = { SnackbarHost(snackbarHostState) } ) { padding -> Box(modifier = Modifier.fillMaxSize().padding(padding)) { val content = displayContent val baseUrl = article?.link if (content != null) { AndroidView( modifier = Modifier.fillMaxSize(), factory = { ctx -> WebView(ctx).apply { settings.javaScriptEnabled = false loadDataWithBaseURL(baseUrl, wrapHtml(content), "text/html", "UTF-8", null) } }, update = { webView -> webView.loadDataWithBaseURL(baseUrl, wrapHtml(content), "text/html", "UTF-8", null) } ) } else if (isExtracting) { CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) } } } } private fun wrapHtml(bodyContent: String): String = """ $bodyContent """.trimIndent()