1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
|
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<ArticleEntity?>(null) }
var displayContent by remember { mutableStateOf<String?>(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 ?: "<p>Couldn't load article content. Try opening in browser.</p>"
}
}
}
}
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 = """
<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>
<body>$bodyContent</body></html>
""".trimIndent()
|