summaryrefslogtreecommitdiff
path: root/app/src/main/java/org/terst/feedreader/ui
diff options
context:
space:
mode:
Diffstat (limited to 'app/src/main/java/org/terst/feedreader/ui')
-rw-r--r--app/src/main/java/org/terst/feedreader/ui/AddFeedScreen.kt7
-rw-r--r--app/src/main/java/org/terst/feedreader/ui/ArticleListScreen.kt12
-rw-r--r--app/src/main/java/org/terst/feedreader/ui/FeedListScreen.kt71
-rw-r--r--app/src/main/java/org/terst/feedreader/ui/ReaderScreen.kt116
-rw-r--r--app/src/main/java/org/terst/feedreader/ui/ReaderWebView.kt10
-rw-r--r--app/src/main/java/org/terst/feedreader/ui/SettingsScreen.kt15
6 files changed, 167 insertions, 64 deletions
diff --git a/app/src/main/java/org/terst/feedreader/ui/AddFeedScreen.kt b/app/src/main/java/org/terst/feedreader/ui/AddFeedScreen.kt
index 066d8d9..e531fb7 100644
--- a/app/src/main/java/org/terst/feedreader/ui/AddFeedScreen.kt
+++ b/app/src/main/java/org/terst/feedreader/ui/AddFeedScreen.kt
@@ -68,8 +68,11 @@ fun AddFeedScreen(onDone: () -> Unit) {
// (and etag/lastModified/lastError) back to defaults on every re-import.
// Preserve the existing row's other fields; only title/siteUrl come from OPML.
val existing = db.feedDao().getById(feed.xmlUrl)
- val entity = existing?.copy(title = feed.title, siteUrl = feed.htmlUrl ?: existing.siteUrl)
- ?: FeedEntity(id = feed.xmlUrl, title = feed.title, siteUrl = feed.htmlUrl)
+ val entity = existing?.copy(
+ title = feed.title,
+ siteUrl = feed.htmlUrl ?: existing.siteUrl,
+ folder = feed.folder ?: existing.folder
+ ) ?: FeedEntity(id = feed.xmlUrl, title = feed.title, siteUrl = feed.htmlUrl, folder = feed.folder)
db.feedDao().upsert(entity)
}
FeedFetchWorker.fetchNow(context)
diff --git a/app/src/main/java/org/terst/feedreader/ui/ArticleListScreen.kt b/app/src/main/java/org/terst/feedreader/ui/ArticleListScreen.kt
index c3a398b..a852288 100644
--- a/app/src/main/java/org/terst/feedreader/ui/ArticleListScreen.kt
+++ b/app/src/main/java/org/terst/feedreader/ui/ArticleListScreen.kt
@@ -1,5 +1,6 @@
package org.terst.feedreader.ui
+import androidx.activity.compose.BackHandler
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
@@ -80,6 +81,11 @@ fun ArticleListScreen(
// single feed's own list already says so in the top bar, so rows there don't repeat it.
val showFeedName = feedId == UNREAD_FEED_ID || feedId == OLDER_FEED_ID || feedId == SAVED_FEED_ID
+ // Unread is the app's landing screen (root of the back stack) -- the system back
+ // gesture/button here would otherwise exit the app entirely. Route it to the feed
+ // list instead, matching the same forward affordance already in the top bar.
+ BackHandler(enabled = feedId == UNREAD_FEED_ID) { onOpenFeedList() }
+
var specificFeedTitle by remember(feedId) { mutableStateOf<String?>(null) }
LaunchedEffect(feedId) {
if (!showFeedName) {
@@ -146,6 +152,9 @@ fun ArticleListScreen(
article = article,
highlighted = result == FilterResult.HIGHLIGHTED,
showFeedName = showFeedName,
+ // Saved is a "read later" shelf, not a read/unread queue -- articles
+ // don't get crossed out there just because they were opened elsewhere.
+ showReadState = feedId != SAVED_FEED_ID,
onClick = { onOpenArticle(article.id) },
onMarkReadFrom = { pendingMarkReadFrom = article }
)
@@ -161,6 +170,7 @@ private fun ArticleRow(
article: ArticleListItem,
highlighted: Boolean,
showFeedName: Boolean,
+ showReadState: Boolean,
onClick: () -> Unit,
onMarkReadFrom: () -> Unit
) {
@@ -185,7 +195,7 @@ private fun ArticleRow(
headlineContent = {
Text(
article.title,
- textDecoration = if (article.isRead) TextDecoration.LineThrough else null,
+ textDecoration = if (showReadState && article.isRead) TextDecoration.LineThrough else null,
style = MaterialTheme.typography.bodyMedium
)
},
diff --git a/app/src/main/java/org/terst/feedreader/ui/FeedListScreen.kt b/app/src/main/java/org/terst/feedreader/ui/FeedListScreen.kt
index 47f51fc..ee2130c 100644
--- a/app/src/main/java/org/terst/feedreader/ui/FeedListScreen.kt
+++ b/app/src/main/java/org/terst/feedreader/ui/FeedListScreen.kt
@@ -26,6 +26,7 @@ import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
+import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
@@ -73,6 +74,7 @@ fun FeedListScreen(
val recentUnreadCount by remember { db.articleDao().observeUnreadCount(cutoff) }.collectAsState(initial = 0)
val olderUnreadCount by remember { db.articleDao().observeOlderUnreadCount(cutoff) }.collectAsState(initial = 0)
+ val savedCount by remember { db.articleDao().observeSavedCount() }.collectAsState(initial = 0)
var menuExpanded by remember { mutableStateOf(false) }
var pendingDelete by remember { mutableStateOf<FeedEntity?>(null) }
val snackbarHostState = remember { SnackbarHostState() }
@@ -145,34 +147,67 @@ fun FeedListScreen(
HorizontalDivider()
ListItem(
headlineContent = { Text("Saved") },
+ trailingContent = { Text(savedCount.toString()) },
modifier = Modifier.fillMaxWidth().clickable { onOpenSaved() }
)
- HorizontalDivider()
+ SectionHeader("Feeds")
}
- items(feeds, key = { it.first.id }) { (feed, unreadCount) ->
- FeedRow(
- feed = feed,
- unreadCount = unreadCount,
- onClick = { onOpenFeed(feed.id) },
- onDelete = { pendingDelete = feed },
- onToggleReaderMode = {
- val newEnabled = !feed.readerModeEnabled
- scope.launch {
- db.feedDao().update(feed.copy(readerModeEnabled = newEnabled))
- val label = feed.title.ifBlank { "this feed" }
- snackbarHostState.showSnackbar(
- if (newEnabled) "Reader mode enabled for $label" else "Reader mode disabled for $label"
- )
+
+ val failedFeeds = feeds.filter { it.first.lastError != null }
+ val okFeeds = feeds.filter { it.first.lastError == null }
+ val groupedByFolder = okFeeds.groupBy { it.first.folder }
+ val folderNames = groupedByFolder.keys.filterNotNull().sortedBy { it.lowercase() }
+ val ungrouped = groupedByFolder[null].orEmpty()
+
+ fun feedRowItems(rows: List<Pair<FeedEntity, Int>>) {
+ items(rows, key = { it.first.id }) { (feed, unreadCount) ->
+ FeedRow(
+ feed = feed,
+ unreadCount = unreadCount,
+ onClick = { onOpenFeed(feed.id) },
+ onDelete = { pendingDelete = feed },
+ onToggleReaderMode = {
+ val newEnabled = !feed.readerModeEnabled
+ scope.launch {
+ db.feedDao().update(feed.copy(readerModeEnabled = newEnabled))
+ val label = feed.title.ifBlank { "this feed" }
+ snackbarHostState.showSnackbar(
+ if (newEnabled) "Reader mode enabled for $label" else "Reader mode disabled for $label"
+ )
+ }
}
- }
- )
- HorizontalDivider()
+ )
+ HorizontalDivider()
+ }
+ }
+
+ if (failedFeeds.isNotEmpty()) {
+ item { SectionHeader("Last fetch failed") }
+ feedRowItems(failedFeeds)
+ }
+ for (folder in folderNames) {
+ item { SectionHeader(folder) }
+ feedRowItems(groupedByFolder.getValue(folder))
+ }
+ if (ungrouped.isNotEmpty()) {
+ if (folderNames.isNotEmpty() || failedFeeds.isNotEmpty()) item { SectionHeader("Other") }
+ feedRowItems(ungrouped)
}
}
}
}
@Composable
+private fun SectionHeader(title: String) {
+ Text(
+ title,
+ style = MaterialTheme.typography.labelLarge,
+ color = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp)
+ )
+}
+
+@Composable
private fun FeedRow(
feed: FeedEntity,
unreadCount: Int,
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 a46d1aa..6e1f939 100644
--- a/app/src/main/java/org/terst/feedreader/ui/ReaderScreen.kt
+++ b/app/src/main/java/org/terst/feedreader/ui/ReaderScreen.kt
@@ -123,11 +123,22 @@ fun ReaderScreen(
val currentPage = pagerState.currentPage
val hasPrevious = currentPage > 0
val hasNext = currentPage < safeIds.size - 1
+ // Marking read happens HERE (moving to another article), not on open -- backing out
+ // to the list without paging past an article leaves it unread. Only Next/Previous
+ // count as "moved away from it"; exiting via the back arrow deliberately doesn't.
fun goToPreviousArticle() {
- scope.launch { pagerState.animateScrollToPage((pagerState.currentPage - 1).coerceAtLeast(0)) }
+ val leavingId = safeIds[pagerState.currentPage]
+ scope.launch {
+ db.articleDao().setRead(leavingId, true)
+ pagerState.animateScrollToPage((pagerState.currentPage - 1).coerceAtLeast(0))
+ }
}
fun goToNextArticle() {
- scope.launch { pagerState.animateScrollToPage((pagerState.currentPage + 1).coerceAtMost(safeIds.size - 1)) }
+ val leavingId = safeIds[pagerState.currentPage]
+ scope.launch {
+ db.articleDao().setRead(leavingId, true)
+ pagerState.animateScrollToPage((pagerState.currentPage + 1).coerceAtMost(safeIds.size - 1))
+ }
}
HorizontalPager(state = pagerState, userScrollEnabled = false, modifier = Modifier.fillMaxSize()) { page ->
@@ -175,7 +186,8 @@ private fun ReaderContent(
LaunchedEffect(articleId) {
val loaded = db.articleDao().getById(articleId) ?: return@LaunchedEffect
article = loaded
- db.articleDao().setRead(articleId, true)
+ // Deliberately NOT marking read here -- see ReaderScreen.goToNextArticle/
+ // goToPreviousArticle, which is where "read" actually gets set now.
val loadedFeed = loaded.feedId?.let { db.feedDao().getById(it) }
feed = loadedFeed
@@ -274,16 +286,10 @@ private fun ReaderContent(
)
}
}
- 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.
+ // touch Stash -- that's a separate, explicit action in the overflow menu,
+ // 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
@@ -295,41 +301,65 @@ private fun ReaderContent(
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 }) {
+ DropdownMenuItem(
+ leadingIcon = { Icon(Icons.Default.OpenInBrowser, contentDescription = null) },
+ text = { Text("Open original") },
+ onClick = {
+ moreMenuExpanded = false
+ val a = currentArticle ?: return@DropdownMenuItem
+ context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(a.link)))
+ }
+ )
+ DropdownMenuItem(
+ enabled = !isSaving,
+ leadingIcon = {
+ Icon(
+ if (currentArticle?.stashArtifactId != null) Icons.Default.CloudDone else Icons.Default.CloudUpload,
+ contentDescription = null
+ )
+ },
+ text = { Text(if (currentArticle?.stashArtifactId != null) "Saved to Stash" else "Save to Stash") },
+ onClick = {
+ moreMenuExpanded = false
+ val a = currentArticle ?: return@DropdownMenuItem
+ 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
+ }
+ }
+ )
+ DropdownMenuItem(
+ text = { Text("Mark unread") },
+ onClick = {
+ moreMenuExpanded = false
+ val a = currentArticle ?: return@DropdownMenuItem
+ article = a.copy(isRead = false)
+ scope.launch {
+ db.articleDao().setRead(a.id, false)
+ snackbarHostState.showSnackbar("Marked unread")
+ }
+ }
+ )
// A real (JS-on) browser, not the extraction pipeline -- search results
// and login pages need actual interaction, not a Readability pass.
DropdownMenuItem(
diff --git a/app/src/main/java/org/terst/feedreader/ui/ReaderWebView.kt b/app/src/main/java/org/terst/feedreader/ui/ReaderWebView.kt
index 42a15f0..503f71e 100644
--- a/app/src/main/java/org/terst/feedreader/ui/ReaderWebView.kt
+++ b/app/src/main/java/org/terst/feedreader/ui/ReaderWebView.kt
@@ -38,6 +38,16 @@ fun wrapReaderHtml(bodyContent: String): String = """
background-color: #121212; color: #e1e1e1; }
a { color: #8ab4f8; }
img { max-width: 100%; height: auto; }
+ svg { max-width: 100%; }
+ /* A decorative icon (section badge, share glyph, etc.) that slipped through
+ extraction relies on the source site's own stylesheet for its real display
+ size (commonly Tailwind classes like "h-5 w-5") -- which we don't load here.
+ An <svg> with no explicit width/height falls back to the browser's default
+ (much larger than any inline icon is meant to be, confirmed live: Ars
+ Technica's per-article section-badge icon rendering "giant"). One WITH
+ explicit width/height was deliberately sized by the page itself, so leave
+ those alone -- just cap the ones that would otherwise size themselves. */
+ svg:not([width]):not([height]) { max-width: 32px; max-height: 32px; }
</style></head>
<body>$bodyContent</body></html>
""".trimIndent()
diff --git a/app/src/main/java/org/terst/feedreader/ui/SettingsScreen.kt b/app/src/main/java/org/terst/feedreader/ui/SettingsScreen.kt
index a5d3f9c..b7897f4 100644
--- a/app/src/main/java/org/terst/feedreader/ui/SettingsScreen.kt
+++ b/app/src/main/java/org/terst/feedreader/ui/SettingsScreen.kt
@@ -78,6 +78,21 @@ fun SettingsScreen(onBack: () -> Unit) {
LazyColumn(modifier = Modifier.fillMaxWidth().padding(padding)) {
item {
Column(modifier = Modifier.padding(16.dp)) {
+ Text("FeeDreader", style = MaterialTheme.typography.titleMedium)
+ Spacer(modifier = Modifier.height(4.dp))
+ Text(
+ "If a site paywalls its own feed, that's the site's call. But this reader " +
+ "won't go out of its way to help a soft paywall block you from reading " +
+ "something you already subscribed to get -- reader mode extracts the " +
+ "article you were sent, nothing more, nothing sneaky.",
+ style = MaterialTheme.typography.bodyMedium
+ )
+ }
+ HorizontalDivider()
+ }
+
+ item {
+ Column(modifier = Modifier.padding(16.dp)) {
Text("Stash", style = MaterialTheme.typography.titleMedium)
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(