Skip to content

Commit a954a89

Browse files
authored
Rework mark read on scroll (#2026)
- Track scroll position with high water mark to distinguish user scrolling from refresh resets - Mark articles as read when they leave the viewport using firstVisibleItemIndex boundary detection - Add SQL tiebreaker on articles.id for consistent ordering when published_at values match - Add setting toggle in General Settings - Skip marking during pull-to-refresh and refresh-all - Scroll to top on refresh when enabled - Gate debug logging behind BuildConfig.DEBUG
1 parent 5411a23 commit a954a89

10 files changed

Lines changed: 253 additions & 33 deletions

File tree

app/src/main/java/com/capyreader/app/common/AndroidLogging.kt

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
package com.capyreader.app.common
22

33
import android.util.Log
4+
import com.capyreader.app.BuildConfig
45
import com.jocmp.capy.logging.Logging
56

67
class AndroidLogging : Logging {
78
override fun debug(event: String, data: Map<String, Any?>) {
9+
if (!BuildConfig.DEBUG) return
10+
811
Log.d(TAG, serializeData(event, data))
912
}
1013

@@ -25,7 +28,7 @@ class AndroidLogging : Logging {
2528
}
2629

2730
private fun serializeData(event: String, data: Map<String, Any?>): String {
28-
return "event=${event.padEnd(15, ' ')}" + data.map { (key, value) -> "$key=$value" }.joinToString(" ")
31+
return "event=${event.padEnd(15, ' ')} " + data.map { (key, value) -> "$key=$value" }.joinToString(" ")
2932
}
3033

3134
companion object {

app/src/main/java/com/capyreader/app/preferences/AppPreferences.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,5 +191,8 @@ class AppPreferences(context: Context) {
191191
AfterReadAllBehavior.default
192192
)
193193

194+
val markReadOnScroll: Preference<Boolean>
195+
get() = preferenceStore.getBoolean("article_list_mark_read_on_scroll", false)
196+
194197
}
195198
}

app/src/main/java/com/capyreader/app/ui/articles/ArticleScreen.kt

Lines changed: 72 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import androidx.compose.animation.fadeIn
66
import androidx.compose.animation.fadeOut
77
import androidx.compose.foundation.layout.Box
88
import androidx.compose.foundation.layout.fillMaxSize
9+
import androidx.compose.foundation.lazy.LazyListState
910
import androidx.compose.material3.DrawerValue
1011
import androidx.compose.material3.ExperimentalMaterial3Api
1112
import androidx.compose.material3.Scaffold
@@ -28,6 +29,7 @@ import androidx.compose.runtime.remember
2829
import androidx.compose.runtime.rememberCoroutineScope
2930
import androidx.compose.runtime.saveable.rememberSaveable
3031
import androidx.compose.runtime.setValue
32+
import androidx.compose.runtime.snapshotFlow
3133
import androidx.compose.ui.Alignment
3234
import androidx.compose.ui.Modifier
3335
import androidx.compose.ui.geometry.Offset
@@ -41,6 +43,7 @@ import androidx.compose.ui.res.stringResource
4143
import androidx.core.net.toUri
4244
import androidx.lifecycle.compose.collectAsStateWithLifecycle
4345
import androidx.paging.LoadState
46+
import androidx.paging.compose.LazyPagingItems
4447
import androidx.paging.compose.collectAsLazyPagingItems
4548
import com.capyreader.app.R
4649
import com.capyreader.app.common.Media
@@ -92,12 +95,16 @@ import com.jocmp.capy.MarkRead
9295
import com.jocmp.capy.SavedSearch
9396
import com.jocmp.capy.common.launchIO
9497
import com.jocmp.capy.common.launchUI
98+
import com.jocmp.capy.logging.CapyLog
99+
import kotlinx.coroutines.FlowPreview
95100
import kotlinx.coroutines.delay
101+
import kotlinx.coroutines.flow.debounce
102+
import kotlinx.coroutines.flow.distinctUntilChanged
96103
import kotlinx.coroutines.launch
97104
import org.koin.androidx.compose.koinViewModel
98105
import org.koin.compose.koinInject
99106

100-
@OptIn(ExperimentalMaterial3AdaptiveApi::class, ExperimentalMaterial3Api::class)
107+
@OptIn(ExperimentalMaterial3AdaptiveApi::class, ExperimentalMaterial3Api::class, FlowPreview::class)
101108
@Composable
102109
fun ArticleScreen(
103110
viewModel: ArticleScreenViewModel = koinViewModel(),
@@ -204,7 +211,7 @@ fun ArticleScreen(
204211
val scaffoldNavigator = rememberListDetailPaneScaffoldNavigator()
205212
val showMultipleColumns = scaffoldNavigator.scaffoldDirective.maxHorizontalPartitions > 1
206213
val paneExpansion = rememberArticlePaneExpansion()
207-
var isPullToRefreshing by remember { mutableStateOf(false) }
214+
val isPullToRefreshing = viewModel.isPullToRefreshing
208215
val addFeedSuccessMessage = stringResource(R.string.add_feed_success)
209216
val scrollBehavior = pinnedScrollBehavior()
210217
var media by rememberSaveable(saver = Media.Saver) { mutableStateOf(null) }
@@ -262,6 +269,14 @@ fun ArticleScreen(
262269
}
263270
}
264271

272+
MarkReadOnScroll(
273+
listState = listState,
274+
articles = articles,
275+
scrollHighWaterMark = viewModel.scrollHighWaterMark,
276+
updateScrollHighWaterMark = viewModel::updateScrollHighWaterMark,
277+
markReadOnScroll = viewModel::markReadOnScroll,
278+
)
279+
265280
suspend fun openNextStatus(action: suspend () -> Unit) {
266281
scope.launchIO { action() }
267282
scaffoldNavigator.navigateTo(ListDetailPaneScaffoldRole.List)
@@ -306,24 +321,19 @@ fun ArticleScreen(
306321
}
307322
}
308323

309-
val refreshPagination = {
310-
coroutineScope.launch {
311-
resetScrollBehaviorOffset()
312-
}
313-
}
314-
315324
fun refreshAll() {
316325
viewModel.refreshAll {
317-
refreshPagination()
326+
if (viewModel.markReadOnScrollEnabled) {
327+
scrollToTop()
328+
}
318329
}
319330
}
320331

321332
fun refreshFeeds() {
322-
isPullToRefreshing = true
323-
324333
viewModel.refresh(filter) {
325-
isPullToRefreshing = false
326-
refreshPagination()
334+
if (viewModel.markReadOnScrollEnabled) {
335+
scrollToTop()
336+
}
327337
}
328338
}
329339

@@ -860,3 +870,52 @@ fun isFeedActive(
860870
article == null &&
861871
!search.isActive
862872
}
873+
874+
@OptIn(FlowPreview::class)
875+
@Composable
876+
private fun MarkReadOnScroll(
877+
listState: LazyListState,
878+
articles: LazyPagingItems<Article>,
879+
scrollHighWaterMark: Int,
880+
updateScrollHighWaterMark: (Int) -> Unit,
881+
markReadOnScroll: (String) -> Unit,
882+
) {
883+
val appPreferences = koinInject<AppPreferences>()
884+
885+
val enabled by appPreferences
886+
.articleListOptions
887+
.markReadOnScroll
888+
.collectChangesWithCurrent()
889+
890+
if (enabled) {
891+
LaunchedEffect(listState) {
892+
snapshotFlow {
893+
listState.firstVisibleItemIndex - 1
894+
}
895+
.distinctUntilChanged()
896+
.debounce(500)
897+
.collect { scrolledPastIndex ->
898+
CapyLog.debug(
899+
"mark_read_on_scroll:collect", mapOf(
900+
"scrolledPastIndex" to scrolledPastIndex,
901+
"highWaterMark" to scrollHighWaterMark,
902+
"itemCount" to articles.itemCount,
903+
)
904+
)
905+
if (scrolledPastIndex > scrollHighWaterMark && scrolledPastIndex < articles.itemCount) {
906+
updateScrollHighWaterMark(scrolledPastIndex)
907+
val boundaryArticle = articles[scrolledPastIndex]
908+
if (boundaryArticle != null) {
909+
CapyLog.debug(
910+
"mark_read_on_scroll:boundary", mapOf(
911+
"articleID" to boundaryArticle.id,
912+
"index" to scrolledPastIndex,
913+
)
914+
)
915+
markReadOnScroll(boundaryArticle.id)
916+
}
917+
}
918+
}
919+
}
920+
}
921+
}

app/src/main/java/com/capyreader/app/ui/articles/ArticleScreenViewModel.kt

Lines changed: 85 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package com.capyreader.app.ui.articles
33
import android.app.Application
44
import android.content.Context
55
import androidx.compose.runtime.getValue
6+
import androidx.compose.runtime.mutableIntStateOf
67
import androidx.compose.runtime.mutableStateOf
78
import androidx.compose.runtime.setValue
89
import androidx.lifecycle.AndroidViewModel
@@ -30,6 +31,7 @@ import com.jocmp.capy.MarkRead
3031
import com.jocmp.capy.SavedSearch
3132
import com.jocmp.capy.articles.ArticleContent
3233
import com.jocmp.capy.articles.SidebarItem
34+
import com.jocmp.capy.articles.SortOrder
3335
import com.jocmp.capy.common.UnauthorizedError
3436
import com.jocmp.capy.common.launchIO
3537
import kotlinx.coroutines.CoroutineDispatcher
@@ -80,9 +82,20 @@ class ArticleScreenViewModel(
8082
var refreshingAll by mutableStateOf(false)
8183
private set
8284

85+
var isPullToRefreshing by mutableStateOf(false)
86+
private set
87+
8388
var refreshInitialized by mutableStateOf(false)
8489
private set
8590

91+
private var _scrollHighWaterMark by mutableIntStateOf(-1)
92+
93+
val scrollHighWaterMark: Int
94+
get() = _scrollHighWaterMark
95+
96+
val markReadOnScrollEnabled: Boolean
97+
get() = appPreferences.articleListOptions.markReadOnScroll.get()
98+
8699
val articlesSince = MutableStateFlow<OffsetDateTime>(OffsetDateTime.now())
87100

88101
private var _showUnauthorizedMessage by mutableStateOf(UnauthorizedMessageState.HIDE)
@@ -383,11 +396,14 @@ class ArticleScreenViewModel(
383396
}
384397
}
385398

386-
fun refresh(filter: ArticleFilter, onComplete: () -> Unit) {
399+
fun refresh(filter: ArticleFilter, onComplete: () -> Unit = {}) {
400+
isPullToRefreshing = true
387401
updateArticlesSince()
388402

389403
refreshFilter(filter) {
390404
updateArticlesSince()
405+
isPullToRefreshing = false
406+
resetScrollHighWaterMark()
391407
onComplete()
392408
}
393409
}
@@ -403,6 +419,7 @@ class ArticleScreenViewModel(
403419
refresh(ArticleFilter.default()) {
404420
_refreshAllState.value = AngleRefreshState.SETTLING
405421
refreshInitialized = true
422+
resetScrollHighWaterMark()
406423
onComplete()
407424

408425
refreshJob?.invokeOnCompletion {
@@ -411,6 +428,70 @@ class ArticleScreenViewModel(
411428
}
412429
}
413430

431+
fun updateScrollHighWaterMark(index: Int) {
432+
if (index > _scrollHighWaterMark) {
433+
CapyLog.debug(
434+
"scroll_high_water_mark:update",
435+
mapOf("previous" to _scrollHighWaterMark, "new" to index)
436+
)
437+
_scrollHighWaterMark = index
438+
}
439+
}
440+
441+
fun resetScrollHighWaterMark() {
442+
CapyLog.debug(
443+
"scroll_high_water_mark:reset",
444+
mapOf("previous" to _scrollHighWaterMark)
445+
)
446+
_scrollHighWaterMark = -1
447+
}
448+
449+
fun markReadOnScroll(articleID: String) {
450+
if (isPullToRefreshing) {
451+
CapyLog.debug("mark_read_on_scroll:skip", mapOf("reason" to "pull_to_refresh"))
452+
return
453+
}
454+
455+
if (_refreshAllState.value == AngleRefreshState.RUNNING) {
456+
CapyLog.debug("mark_read_on_scroll:skip", mapOf("reason" to "refresh_all_running"))
457+
return
458+
}
459+
460+
val range = MarkRead.After(articleID)
461+
462+
CapyLog.debug(
463+
"mark_read_on_scroll",
464+
mapOf(
465+
"articleID" to articleID,
466+
"range" to range.toString(),
467+
"sortOrder" to sortOrder.value.toString(),
468+
"highWaterMark" to _scrollHighWaterMark,
469+
)
470+
)
471+
472+
viewModelScope.launchIO {
473+
val articleIDs = account.unreadArticleIDs(
474+
filter = latestFilter,
475+
range = range,
476+
sortOrder = sortOrder.value,
477+
query = _searchQuery.value,
478+
)
479+
480+
CapyLog.debug(
481+
"mark_read_on_scroll:marking",
482+
mapOf("count" to articleIDs.size)
483+
)
484+
485+
account.markAllRead(articleIDs).onFailure {
486+
Sync.markReadAsync(articleIDs, context)
487+
}
488+
489+
launchIO {
490+
notificationHelper.dismissNotifications(articleIDs)
491+
}
492+
}
493+
}
494+
414495
fun selectArticle(articleID: String, onComplete: (article: Article) -> Unit = {}) {
415496
if (_article?.id == articleID) {
416497
return
@@ -482,11 +563,13 @@ class ArticleScreenViewModel(
482563
}
483564
_searchQuery.value = ""
484565
_searchState.value = SearchState.INACTIVE
566+
resetScrollHighWaterMark()
485567
}
486568

487569
fun updateSearch(query: String) {
488570
clearArticle()
489571
_searchQuery.value = query
572+
resetScrollHighWaterMark()
490573
}
491574

492575
fun addStarAsync(articleID: String) {
@@ -574,6 +657,7 @@ class ArticleScreenViewModel(
574657
appPreferences.filter.set(filter)
575658

576659
clearArticle()
660+
resetScrollHighWaterMark()
577661

578662
updateArticlesSince()
579663
}

app/src/main/java/com/capyreader/app/ui/settings/panels/GeneralSettingsPanel.kt

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,8 @@ fun GeneralSettingsPanel(
9595
updateAfterReadAll = viewModel::updateAfterReadAll,
9696
updateStickyFullContent = viewModel::updateStickyFullContent,
9797
enableStickyFullContent = viewModel.enableStickyFullContent,
98+
markReadOnScroll = viewModel.markReadOnScroll,
99+
updateMarkReadOnScroll = viewModel::updateMarkReadOnScroll,
98100
)
99101
}
100102
}
@@ -118,6 +120,8 @@ fun GeneralSettingsPanelView(
118120
afterReadAll: AfterReadAllBehavior,
119121
updateAfterReadAll: (behavior: AfterReadAllBehavior) -> Unit,
120122
confirmMarkAllRead: Boolean,
123+
markReadOnScroll: Boolean,
124+
updateMarkReadOnScroll: (enable: Boolean) -> Unit,
121125
) {
122126
val (isClearArticlesDialogOpen, setClearArticlesDialogOpen) = remember { mutableStateOf(false) }
123127

@@ -188,6 +192,13 @@ fun GeneralSettingsPanelView(
188192
title = stringResource(R.string.settings_section_mark_all_as_read),
189193
) {
190194
Column {
195+
RowItem {
196+
TextSwitch(
197+
onCheckedChange = updateMarkReadOnScroll,
198+
checked = markReadOnScroll,
199+
title = stringResource(R.string.settings_mark_read_on_scroll),
200+
)
201+
}
191202
RowItem {
192203
TextSwitch(
193204
onCheckedChange = updateConfirmMarkAllRead,
@@ -346,6 +357,8 @@ private fun GeneralSettingsPanelPreview() {
346357
enableStickyFullContent = true,
347358
afterReadAll = AfterReadAllBehavior.NOTHING,
348359
updateAfterReadAll = {},
360+
markReadOnScroll = false,
361+
updateMarkReadOnScroll = {},
349362
)
350363
}
351364
}

app/src/main/java/com/capyreader/app/ui/settings/panels/GeneralSettingsViewModel.kt

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,9 @@ class GeneralSettingsViewModel(
4545
var enableStickyFullContent by mutableStateOf(appPreferences.enableStickyFullContent.get())
4646
private set
4747

48+
var markReadOnScroll by mutableStateOf(appPreferences.articleListOptions.markReadOnScroll.get())
49+
private set
50+
4851
val filterKeywords = account
4952
.preferences
5053
.filterKeywords
@@ -86,6 +89,12 @@ class GeneralSettingsViewModel(
8689
afterReadAll = behavior
8790
}
8891

92+
fun updateMarkReadOnScroll(enabled: Boolean) {
93+
appPreferences.articleListOptions.markReadOnScroll.set(enabled)
94+
95+
markReadOnScroll = enabled
96+
}
97+
8998
fun updateStickyFullContent(enable: Boolean) {
9099
appPreferences.enableStickyFullContent.set(enable)
91100

0 commit comments

Comments
 (0)