summaryrefslogtreecommitdiff
path: root/app/src/main/java/org/terst/feedreader/ui/ArticleListScreen.kt
blob: d87620d9eeb320ce2eed3e90bff7e185adfa7534 (plain)
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
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
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<List<String>>(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<String?>(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<ArticleListItem?>(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)
    )
}