summaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
Diffstat (limited to 'app')
-rw-r--r--app/src/main/java/org/terst/feedreader/data/AppDatabase.kt11
-rw-r--r--app/src/main/java/org/terst/feedreader/data/Daos.kt20
-rw-r--r--app/src/main/java/org/terst/feedreader/data/Entities.kt3
-rw-r--r--app/src/main/java/org/terst/feedreader/ui/ArticleListScreen.kt47
-rw-r--r--app/src/main/java/org/terst/feedreader/ui/FeedListScreen.kt24
-rw-r--r--app/src/main/java/org/terst/feedreader/ui/ReaderScreen.kt58
6 files changed, 141 insertions, 22 deletions
diff --git a/app/src/main/java/org/terst/feedreader/data/AppDatabase.kt b/app/src/main/java/org/terst/feedreader/data/AppDatabase.kt
index e2f6c1a..4cf0ecd 100644
--- a/app/src/main/java/org/terst/feedreader/data/AppDatabase.kt
+++ b/app/src/main/java/org/terst/feedreader/data/AppDatabase.kt
@@ -9,7 +9,7 @@ import androidx.room.migration.Migration
@Database(
entities = [FeedEntity::class, ArticleEntity::class, FilterRuleEntity::class],
- version = 2,
+ version = 3,
exportSchema = false
)
abstract class AppDatabase : RoomDatabase() {
@@ -31,12 +31,19 @@ abstract class AppDatabase : RoomDatabase() {
}
}
+ // v3: added FeedEntity.readerModeDisabled (per-feed reader-mode opt-out).
+ private val MIGRATION_2_3 = object : Migration(2, 3) {
+ override fun migrate(db: SupportSQLiteDatabase) {
+ db.execSQL("ALTER TABLE feeds ADD COLUMN readerModeDisabled INTEGER NOT NULL DEFAULT 0")
+ }
+ }
+
fun get(context: Context): AppDatabase = instance ?: synchronized(this) {
instance ?: Room.databaseBuilder(
context.applicationContext,
AppDatabase::class.java,
"feedreader.db"
- ).addMigrations(MIGRATION_1_2).build().also { instance = it }
+ ).addMigrations(MIGRATION_1_2, MIGRATION_2_3).build().also { instance = it }
}
}
}
diff --git a/app/src/main/java/org/terst/feedreader/data/Daos.kt b/app/src/main/java/org/terst/feedreader/data/Daos.kt
index f80cce3..8fe2ec2 100644
--- a/app/src/main/java/org/terst/feedreader/data/Daos.kt
+++ b/app/src/main/java/org/terst/feedreader/data/Daos.kt
@@ -48,25 +48,30 @@ data class ArticleListItem(
val isRead: Boolean,
val isSaved: Boolean,
val stashArtifactId: String?,
- val createdAt: Long
+ val createdAt: Long,
+ val feedTitle: String? // via LEFT JOIN feeds -- null for standalone share-sheet saves with no feedId
) : Titled
private const val ARTICLE_LIST_COLUMNS =
- "id, feedId, title, link, author, publishedAt, imageUrl, isRead, isSaved, stashArtifactId, createdAt"
+ "articles.id, articles.feedId, articles.title, articles.link, articles.author, articles.publishedAt, " +
+ "articles.imageUrl, articles.isRead, articles.isSaved, articles.stashArtifactId, articles.createdAt, " +
+ "feeds.title AS feedTitle"
+
+private const val ARTICLE_LIST_FROM = "articles LEFT JOIN feeds ON articles.feedId = feeds.id"
@Dao
interface ArticleDao {
// Unread-first, newest-first -- the default "what's new" view across every feed.
- @Query("SELECT $ARTICLE_LIST_COLUMNS FROM articles WHERE isRead = 0 ORDER BY publishedAt DESC")
+ @Query("SELECT $ARTICLE_LIST_COLUMNS FROM $ARTICLE_LIST_FROM WHERE articles.isRead = 0 ORDER BY articles.publishedAt DESC")
fun observeUnread(): Flow<List<ArticleListItem>>
@Query("SELECT feedId, COUNT(*) as count FROM articles WHERE isRead = 0 AND feedId IS NOT NULL GROUP BY feedId")
fun observeUnreadCounts(): Flow<List<FeedUnreadCount>>
- @Query("SELECT $ARTICLE_LIST_COLUMNS FROM articles WHERE feedId = :feedId ORDER BY publishedAt DESC")
+ @Query("SELECT $ARTICLE_LIST_COLUMNS FROM $ARTICLE_LIST_FROM WHERE articles.feedId = :feedId ORDER BY articles.publishedAt DESC")
fun observeByFeed(feedId: String): Flow<List<ArticleListItem>>
- @Query("SELECT $ARTICLE_LIST_COLUMNS FROM articles WHERE isSaved = 1 ORDER BY createdAt DESC")
+ @Query("SELECT $ARTICLE_LIST_COLUMNS FROM $ARTICLE_LIST_FROM WHERE articles.isSaved = 1 ORDER BY articles.createdAt DESC")
fun observeSaved(): Flow<List<ArticleListItem>>
@Query("SELECT * FROM articles WHERE id = :id")
@@ -84,6 +89,11 @@ interface ArticleDao {
@Query("UPDATE articles SET isRead = :read WHERE id = :id")
suspend fun setRead(id: String, read: Boolean)
+ // Used for mark-as-read-on-scroll in the article list -- batched per scroll-settle
+ // rather than one query per row.
+ @Query("UPDATE articles SET isRead = 1 WHERE id IN (:ids)")
+ suspend fun markReadBatch(ids: List<String>)
+
@Query("UPDATE articles SET isRead = 1 WHERE feedId = :feedId")
suspend fun markAllReadForFeed(feedId: String)
diff --git a/app/src/main/java/org/terst/feedreader/data/Entities.kt b/app/src/main/java/org/terst/feedreader/data/Entities.kt
index 29d4fe4..f943f78 100644
--- a/app/src/main/java/org/terst/feedreader/data/Entities.kt
+++ b/app/src/main/java/org/terst/feedreader/data/Entities.kt
@@ -12,7 +12,8 @@ data class FeedEntity(
val lastFetchedAt: Long? = null,
val etag: String? = null, // conditional-GET caching (If-None-Match) to avoid re-downloading unchanged feeds
val lastModified: String? = null, // conditional-GET caching (If-Modified-Since)
- val lastError: String? = null // set when a fetch fails; cleared on the next successful fetch
+ val lastError: String? = null, // set when a fetch fails; cleared on the next successful fetch
+ val readerModeDisabled: Boolean = false // per-feed opt-out: never auto/manually switch to Readability for this feed's articles (e.g. a feed whose own formatting is better than what extraction produces)
)
@Entity(tableName = "articles")
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 62b90c9..0beb523 100644
--- a/app/src/main/java/org/terst/feedreader/ui/ArticleListScreen.kt
+++ b/app/src/main/java/org/terst/feedreader/ui/ArticleListScreen.kt
@@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
@@ -22,9 +23,13 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
@@ -65,6 +70,31 @@ fun ArticleListScreen(
}
}.collectAsState(initial = emptyList())
+ // Unread/Saved mix articles from every feed, so each row names its source; a
+ // 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 == SAVED_FEED_ID
+
+ var specificFeedTitle by remember(feedId) { mutableStateOf<String?>(null) }
+ LaunchedEffect(feedId) {
+ if (!showFeedName) {
+ specificFeedTitle = db.feedDao().getById(feedId)?.title
+ }
+ }
+
+ val listState = rememberLazyListState()
+ // Mark-as-read-on-scroll: once an article has scrolled fully above the viewport
+ // (the user has moved past it), treat it as read -- the standard feed-reader
+ // behavior, rather than requiring every article to be opened individually.
+ LaunchedEffect(listState, visibleArticles) {
+ snapshotFlow { listState.firstVisibleItemIndex }.collect { firstVisible ->
+ val toMark = visibleArticles.take(firstVisible)
+ .map { it.first }
+ .filter { !it.isRead }
+ .map { it.id }
+ if (toMark.isNotEmpty()) db.articleDao().markReadBatch(toMark)
+ }
+ }
+
Scaffold(
topBar = {
TopAppBar(
@@ -73,7 +103,7 @@ fun ArticleListScreen(
when (feedId) {
UNREAD_FEED_ID -> "Unread"
SAVED_FEED_ID -> "Saved"
- else -> "Articles"
+ else -> specificFeedTitle ?: "Articles"
}
)
},
@@ -93,11 +123,12 @@ fun ArticleListScreen(
)
}
) { padding ->
- LazyColumn(modifier = Modifier.fillMaxSize().padding(padding)) {
+ LazyColumn(state = listState, modifier = Modifier.fillMaxSize().padding(padding)) {
items(visibleArticles, key = { it.first.id }) { (article, result) ->
ArticleRow(
article = article,
highlighted = result == FilterResult.HIGHLIGHTED,
+ showFeedName = showFeedName,
onClick = { onOpenArticle(article.id) }
)
HorizontalDivider()
@@ -107,8 +138,16 @@ fun ArticleListScreen(
}
@Composable
-private fun ArticleRow(article: ArticleListItem, highlighted: Boolean, onClick: () -> Unit) {
+private fun ArticleRow(
+ article: ArticleListItem,
+ highlighted: Boolean,
+ showFeedName: Boolean,
+ onClick: () -> Unit
+) {
val dateText = article.publishedAt?.let { DateFormat.getDateInstance(DateFormat.MEDIUM).format(Date(it)) }
+ val metaText = listOfNotNull(if (showFeedName) article.feedTitle else null, dateText)
+ .joinToString(" · ")
+ .ifEmpty { null }
ListItem(
// Passed as null (not an empty/conditional lambda) when there's no image, so
// ListItem's own layout collapses the leading slot entirely for text-only rows
@@ -129,7 +168,7 @@ private fun ArticleRow(article: ArticleListItem, highlighted: Boolean, onClick:
textDecoration = if (article.isRead) TextDecoration.LineThrough else null
)
},
- supportingContent = { if (dateText != null) Text(dateText) },
+ supportingContent = { if (metaText != null) Text(metaText) },
modifier = Modifier
.fillMaxWidth()
.background(if (highlighted) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surface)
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 a4852c6..5ed5855 100644
--- a/app/src/main/java/org/terst/feedreader/ui/FeedListScreen.kt
+++ b/app/src/main/java/org/terst/feedreader/ui/FeedListScreen.kt
@@ -11,10 +11,12 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
+import androidx.compose.material.icons.filled.Article
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Settings
+import androidx.compose.material.icons.outlined.Article
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
@@ -137,7 +139,10 @@ fun FeedListScreen(
feed = feed,
unreadCount = unreadCount,
onClick = { onOpenFeed(feed.id) },
- onDelete = { pendingDelete = feed }
+ onDelete = { pendingDelete = feed },
+ onToggleReaderMode = {
+ scope.launch { db.feedDao().update(feed.copy(readerModeDisabled = !feed.readerModeDisabled)) }
+ }
)
HorizontalDivider()
}
@@ -146,7 +151,13 @@ fun FeedListScreen(
}
@Composable
-private fun FeedRow(feed: FeedEntity, unreadCount: Int, onClick: () -> Unit, onDelete: () -> Unit) {
+private fun FeedRow(
+ feed: FeedEntity,
+ unreadCount: Int,
+ onClick: () -> Unit,
+ onDelete: () -> Unit,
+ onToggleReaderMode: () -> Unit
+) {
ListItem(
headlineContent = { Text(feed.title.ifBlank { feed.id }) },
supportingContent = {
@@ -160,6 +171,15 @@ private fun FeedRow(feed: FeedEntity, unreadCount: Int, onClick: () -> Unit, onD
Text(unreadCount.toString())
Spacer(modifier = Modifier.width(8.dp))
}
+ IconButton(onClick = onToggleReaderMode) {
+ Icon(
+ if (feed.readerModeDisabled) Icons.Outlined.Article else Icons.Filled.Article,
+ contentDescription = if (feed.readerModeDisabled)
+ "Reader mode disabled for this feed -- tap to re-enable"
+ else
+ "Reader mode enabled for this feed -- tap to disable"
+ )
+ }
IconButton(onClick = onDelete) {
Icon(Icons.Default.Delete, contentDescription = "Unsubscribe")
}
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 cef17c5..2177d72 100644
--- a/app/src/main/java/org/terst/feedreader/ui/ReaderScreen.kt
+++ b/app/src/main/java/org/terst/feedreader/ui/ReaderScreen.kt
@@ -5,6 +5,7 @@ import android.net.Uri
import android.webkit.WebView
import androidx.compose.foundation.ExperimentalFoundationApi
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
@@ -20,6 +21,7 @@ import androidx.compose.material3.CircularProgressIndicator
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
@@ -35,11 +37,13 @@ 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.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
@@ -100,6 +104,7 @@ private fun ReaderContent(articleId: String, onBack: () -> Unit) {
val snackbarHostState = remember { SnackbarHostState() }
var article by remember(articleId) { mutableStateOf<ArticleEntity?>(null) }
+ var feed by remember(articleId) { mutableStateOf<FeedEntity?>(null) }
var feedContent by remember(articleId) { mutableStateOf<String?>(null) }
var readerContent by remember(articleId) { mutableStateOf<String?>(null) }
var isReaderMode by remember(articleId) { mutableStateOf(false) }
@@ -110,12 +115,14 @@ private fun ReaderContent(articleId: String, onBack: () -> Unit) {
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 (readerContent == null && plainLength < THIN_CONTENT_THRESHOLD) {
+ 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
@@ -131,6 +138,7 @@ private fun ReaderContent(articleId: String, onBack: () -> Unit) {
fun toggleReaderMode() {
val a = article ?: return
+ if (feed?.readerModeDisabled == true) return
if (isReaderMode) {
isReaderMode = false
return
@@ -157,7 +165,20 @@ private fun ReaderContent(articleId: String, onBack: () -> Unit) {
Scaffold(
topBar = {
TopAppBar(
- title = { Text(article?.title.orEmpty(), maxLines = 1) },
+ 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")
@@ -166,9 +187,9 @@ private fun ReaderContent(articleId: String, onBack: () -> Unit) {
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 (thin feed content),
- // reader mode IS the only content, so there's nothing to toggle between.
- if (feedContent != null) {
+ // 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,
@@ -223,15 +244,28 @@ private fun ReaderContent(articleId: String, onBack: () -> Unit) {
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<String?>(null) }
AndroidView(
modifier = Modifier.fillMaxSize(),
factory = { ctx ->
WebView(ctx).apply {
settings.javaScriptEnabled = false
loadDataWithBaseURL(baseUrl, wrapHtml(content), "text/html", "UTF-8", null)
+ loadedContent.value = content
+ }
+ },
+ update = { webView ->
+ if (loadedContent.value != content) {
+ webView.loadDataWithBaseURL(baseUrl, wrapHtml(content), "text/html", "UTF-8", null)
+ loadedContent.value = content
}
},
- update = { webView -> webView.loadDataWithBaseURL(baseUrl, wrapHtml(content), "text/html", "UTF-8", null) },
// 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
@@ -245,9 +279,17 @@ private fun ReaderContent(articleId: String, onBack: () -> Unit) {
}
}
+// Hardcoded dark palette, not @media (prefers-color-scheme) -- the app itself always
+// forces dark colorScheme() regardless of system setting (see MainActivity), so the
+// reader content should match rather than following a system light/dark that the rest
+// of the app ignores.
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>
+ <style>
+ body { font-family: sans-serif; font-size: 18px; line-height: 1.5; padding: 16px;
+ background-color: #121212; color: #e1e1e1; }
+ a { color: #8ab4f8; }
+ img { max-width: 100%; height: auto; }
+ </style></head>
<body>$bodyContent</body></html>
""".trimIndent()