package org.terst.feedreader.ui import android.util.Log import androidx.activity.compose.BackHandler import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth 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 import androidx.compose.material.icons.filled.List import androidx.compose.material.icons.filled.Refresh import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.ListItem import androidx.compose.material3.LocalContentColor 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.collectAsState 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.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.unit.dp import androidx.work.WorkInfo import androidx.work.WorkManager import coil.compose.AsyncImage import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.launch import org.terst.feedreader.data.AppDatabase import org.terst.feedreader.data.ArticleListItem import org.terst.feedreader.data.FilterResult import org.terst.feedreader.data.FilterRuleEngine import org.terst.feedreader.data.Settings import org.terst.feedreader.data.markReadBatchChunked import org.terst.feedreader.work.FeedFetchWorker import java.text.DateFormat import java.util.Date private const val TAG = "ArticleListScreen" @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class, ExperimentalCoroutinesApi::class) @Composable fun ArticleListScreen( feedId: String, onOpenArticle: (String) -> Unit, onBack: () -> Unit, onOpenFeedList: () -> Unit ) { val context = LocalContext.current val db = remember { AppDatabase.get(context) } val scope = rememberCoroutineScope() val cutoff = remember(feedId) { System.currentTimeMillis() - RECENT_WINDOW_MS } val settings = remember { Settings(context) } // Unread/Older re-derive their query every time lastFetchAt changes (flatMapLatest), // not just once at screen entry -- a fetch completing while this screen is open should // immediately drop whatever was read-but-lingering, same as if you'd navigated back in. // See observeUnread's comment for why sinceFetch is here at all. val articleFlow = remember(feedId) { when (feedId) { UNREAD_FEED_ID -> settings.lastFetchAt.flatMapLatest { since -> db.articleDao().observeUnread(cutoff, since) } OLDER_FEED_ID -> settings.lastFetchAt.flatMapLatest { since -> db.articleDao().observeOlderUnread(cutoff, since) } SAVED_FEED_ID -> db.articleDao().observeSaved() else -> db.articleDao().observeByFeed(feedId) } } val visibleArticles by remember(feedId) { combine(articleFlow, db.filterRuleDao().observeAll()) { articles, rules -> val compiledRules = FilterRuleEngine.compile(rules) articles.map { it to FilterRuleEngine.evaluate(it, compiledRules) } .filter { (_, result) -> result != FilterResult.MUTED } } }.collectAsState(initial = emptyList()) // Hoisted (not LazyColumn's own default state) so the effect below can read/adjust it. val listState = rememberLazyListState() var previousArticleIds by remember(feedId) { mutableStateOf>(emptyList()) } // A background fetch (the 5am alarm, or the explicit refresh button) can insert new // articles above whatever's currently on screen. LazyColumn doesn't move // firstVisibleItemIndex just because the backing list changed -- that index is only // ever moved by an actual scroll or an explicit scrollToItem -- so after an insert, the // SAME index now points at a DIFFERENT (newer) article, and the visible content jumps // out from under you instead of staying where you were reading. Re-anchor to the same // article, by id, at whatever index it now sits at, preserving the on-screen offset too // so this doesn't even show as a snap -- just an uninterrupted read. LaunchedEffect(visibleArticles) { val anchorId = previousArticleIds.getOrNull(listState.firstVisibleItemIndex) if (anchorId != null) { val newIndex = visibleArticles.indexOfFirst { it.first.id == anchorId } if (newIndex >= 0 && newIndex != listState.firstVisibleItemIndex) { listState.scrollToItem(newIndex, listState.firstVisibleItemScrollOffset) } } previousArticleIds = visibleArticles.map { it.first.id } } // Unread/Older/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 == OLDER_FEED_ID || feedId == SAVED_FEED_ID // A feed fetch always touches every subscription at once -- there's no such thing as // "fetching just this one feed" or "fetching Saved." Showing "checking feed 7 of 18" // or a new-articles snackbar on Saved (not feed-driven at all) or a single specific // feed's screen would misleadingly imply the fetch is about whatever's being viewed. // Scoped to the two views that actually aggregate across every subscription. val showFetchStatus = feedId == UNREAD_FEED_ID || feedId == OLDER_FEED_ID val snackbarHostState = remember { SnackbarHostState() } val fetchWorkInfos by remember { WorkManager.getInstance(context).getWorkInfosByTagFlow(FeedFetchWorker.FETCH_WORK_TAG) // A pure status readout, not load-bearing for the article list itself -- an // exception collected via collectAsState would otherwise propagate straight // through Compose and crash the whole screen. Worst case here is just falling // back to the plain "nothing's fetching" state instead of taking the app down. .catch { e -> Log.e(TAG, "Fetch status flow failed", e); emit(emptyList()) } }.collectAsState(initial = emptyList()) // RUNNING only, deliberately not ENQUEUED: a periodic WorkRequest's state cycles // straight back to ENQUEUED after every successful run rather than settling anywhere // else, so it's ENQUEUED almost all the time between hourly runs -- treating that as // "currently fetching" meant this permanently showed "Checking feeds…" instead of // ever actually reaching "Up to date". val runningFetch = fetchWorkInfos.firstOrNull { it.state == WorkInfo.State.RUNNING } val isFetching = runningFetch != null val fetchStatusText = runningFetch?.progress?.let { progress -> val current = progress.getInt(FeedFetchWorker.KEY_CURRENT, -1) val total = progress.getInt(FeedFetchWorker.KEY_TOTAL, -1) if (current >= 0 && total > 0) "Checking feed ${current + 1} of $total…" else "Checking feeds…" } ?: "Checking feeds…" // Driven by Settings' durable lastFetchAt/lastFetchNewArticles, NOT WorkInfo.SUCCEEDED // -- a periodic fetch's WorkInfo never reaches SUCCEEDED (see above), so watching for // that only ever caught manual/alarm-triggered one-time fetches, never the actual // hourly background one. lastAcknowledgedFetchAt is persisted too, not just in-memory // Compose state, so leaving and returning to this screen (a fresh composable instance) // doesn't re-show a snackbar for a fetch that was already acknowledged elsewhere. if (showFetchStatus) { val lastFetchAt by settings.lastFetchAt.collectAsState(initial = 0L) val lastFetchNewArticles by settings.lastFetchNewArticles.collectAsState(initial = 0) val lastAcknowledgedFetchAt by settings.lastAcknowledgedFetchAt.collectAsState(initial = 0L) LaunchedEffect(lastFetchAt) { if (lastFetchAt > lastAcknowledgedFetchAt) { if (lastFetchNewArticles > 0) { snackbarHostState.showSnackbar( if (lastFetchNewArticles == 1) "1 new article" else "$lastFetchNewArticles new articles" ) } settings.acknowledgeFetch(lastFetchAt) } } } // 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(null) } LaunchedEffect(feedId) { if (!showFeedName) { specificFeedTitle = db.feedDao().getById(feedId)?.title } } // "Mark read from here": long-press an article to mark it and everything older // (further down this same filtered/sorted list) as read in one batch, rather than // opening each one individually. Confirmed rather than instant, since it can affect // a lot of rows at once and there's no undo. var pendingMarkReadFrom by remember(feedId) { mutableStateOf(null) } pendingMarkReadFrom?.let { from -> val index = visibleArticles.indexOfFirst { it.first.id == from.id } val toMark = if (index >= 0) visibleArticles.drop(index).map { it.first }.filter { !it.isRead } else emptyList() ConfirmDialog( title = "Mark read from here?", text = "Marks ${toMark.size} article(s) as read.", confirmLabel = "Mark read", onConfirm = { scope.launch { db.articleDao().markReadBatchChunked(toMark.map { it.id }) } pendingMarkReadFrom = null }, onDismiss = { pendingMarkReadFrom = null } ) } Scaffold( topBar = { TopAppBar( title = { Column { Text( when (feedId) { UNREAD_FEED_ID -> "Unread" OLDER_FEED_ID -> "Older" SAVED_FEED_ID -> "Saved" else -> specificFeedTitle ?: "Articles" } ) if (showFetchStatus && isFetching) { Text(fetchStatusText, style = MaterialTheme.typography.labelSmall) } } }, navigationIcon = { // Unread is the app's landing screen (root of the back stack), so there's // nothing to pop back to -- show a way forward to the feed list instead. if (feedId == UNREAD_FEED_ID) { IconButton(onClick = onOpenFeedList) { Icon(Icons.Default.List, contentDescription = "Feeds") } } else { IconButton(onClick = onBack) { Icon(Icons.Default.ArrowBack, contentDescription = "Back") } } }, // Only two things ever trigger a fetch now: a fresh cold launch and this // button -- no periodic or scheduled background refresh any more. Scoped to // the two aggregate views (same showFetchStatus condition the snackbar/status // text above already uses) since a fetch always touches every subscription at // once, not just whatever single feed screen happens to be open. actions = { if (showFetchStatus) { IconButton( onClick = { FeedFetchWorker.fetchNow(context) }, enabled = !isFetching ) { if (isFetching) { CircularProgressIndicator( modifier = Modifier.size(20.dp), strokeWidth = 2.dp, color = LocalContentColor.current ) } else { Icon(Icons.Default.Refresh, contentDescription = "Refresh") } } } } ) }, snackbarHost = { SnackbarHost(snackbarHostState) } ) { padding -> LazyColumn(modifier = Modifier.fillMaxSize().padding(padding), state = listState) { items(visibleArticles, key = { it.first.id }) { (article, result) -> ArticleRow( 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 } ) HorizontalDivider() } } } } // Shared with ReaderWebView's wrapReaderHtml (the reader's own date header) so both // places format a publishedAt timestamp identically. fun absoluteDateText(timestamp: Long): String = DateFormat.getDateInstance(DateFormat.MEDIUM).format(Date(timestamp)) @OptIn(ExperimentalFoundationApi::class) @Composable private fun ArticleRow( article: ArticleListItem, highlighted: Boolean, showFeedName: Boolean, showReadState: Boolean, onClick: () -> Unit, onMarkReadFrom: () -> Unit ) { val dateText = article.publishedAt?.let { absoluteDateText(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 // instead of reserving blank space for every row regardless of content. leadingContent = article.imageUrl?.let { url -> { AsyncImage( model = url, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.size(56.dp).clip(RoundedCornerShape(6.dp)) ) } }, headlineContent = { Text( article.title, textDecoration = if (showReadState && article.isRead) TextDecoration.LineThrough else null, style = MaterialTheme.typography.bodyMedium ) }, supportingContent = { if (metaText != null) Text(metaText) }, modifier = Modifier .fillMaxWidth() .background(if (highlighted) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surface) .combinedClickable(onClick = onClick, onLongClick = onMarkReadFrom) ) }