Skip to content

Commit a53b397

Browse files
authored
Fix next after all read (#2041)
* Hold onto ref current feed for next page Fixes a bug where the Unread feed would lose context of the next filter since the current filter was removed from the sidebar * Skip emptied descendants when advancing feed When the current sidebar item vanishes after mark-read, walk the stale .next chain to find the first successor still present in the rebuilt sidebar. Handles marking a folder read with expanded children.
1 parent 182a8dd commit a53b397

2 files changed

Lines changed: 131 additions & 34 deletions

File tree

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

Lines changed: 39 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -195,9 +195,9 @@ class ArticleScreenViewModel(
195195
)
196196
}
197197

198-
private val _nextItem = MutableStateFlow<SidebarItem?>(null)
198+
private val _sidebarItem = MutableStateFlow<SidebarItem?>(null)
199199

200-
private val nextItemListener: Flow<SidebarItem?> =
200+
private val sidebarListener: Flow<SidebarItem?> =
201201
combine(
202202
listSwipeBottom,
203203
sidebar,
@@ -207,9 +207,38 @@ class ArticleScreenViewModel(
207207
return@combine null
208208
}
209209

210-
sidebar.find { it.isSelected(filter) }?.next
210+
val found = sidebar.find { it.isSelected(filter) }
211+
if (found != null) return@combine found
212+
213+
val stale = _sidebarItem.value?.takeIf { it.isSelected(filter) }
214+
?: return@combine null
215+
216+
SidebarItem(
217+
toFilter = stale.toFilter,
218+
isSelected = stale.isSelected,
219+
next = findFreshItem(stale, sidebar, filter.status),
220+
)
211221
}
212222

223+
private fun findFreshItem(
224+
stale: SidebarItem,
225+
sidebar: List<SidebarItem>,
226+
status: ArticleStatus,
227+
): SidebarItem? {
228+
var search: SidebarItem? = stale.next
229+
230+
while (search != null) {
231+
val searchFilter = search.toFilter(status)
232+
val match = sidebar.find { it.isSelected(searchFilter) }
233+
if (match != null) {
234+
return match
235+
}
236+
search = search.next
237+
}
238+
239+
return null
240+
}
241+
213242
val statusCount: Flow<Long> = filter.flatMapLatest { latestFilter ->
214243
account.countAllByStatus(countableStatus(latestFilter))
215244
}
@@ -240,17 +269,17 @@ class ArticleScreenViewModel(
240269
val searchState: Flow<SearchState>
241270
get() = _searchState
242271

243-
val nextFilter: Flow<SidebarItem?>
244-
get() = _nextItem
272+
val nextFilter: Flow<SidebarItem?> = _sidebarItem.map { it?.next }
245273

246274
init {
247275
viewModelScope.launch {
248-
nextItemListener.collect {
249-
_nextItem.value = it
276+
sidebarListener.collect {
277+
_sidebarItem.value = it
250278
}
251279
}
252280

253-
val skipInitialRefresh = appPreferences.refreshInterval.get() == RefreshInterval.MANUALLY_ONLY
281+
val skipInitialRefresh =
282+
appPreferences.refreshInterval.get() == RefreshInterval.MANUALLY_ONLY
254283

255284
if (skipInitialRefresh) {
256285
refreshInitialized = true
@@ -604,7 +633,7 @@ class ArticleScreenViewModel(
604633
}
605634

606635
fun requestNextFeed() {
607-
_nextItem.value?.let(::selectSidebarItem)
636+
_sidebarItem.value?.next?.let(::selectSidebarItem)
608637
}
609638

610639
private fun selectSidebarItem(item: SidebarItem) {
@@ -789,7 +818,7 @@ class ArticleScreenViewModel(
789818
private fun openNextFeedOnAllRead(
790819
onArticlesCleared: () -> Unit,
791820
) {
792-
val nextItem = _nextItem.value
821+
val nextItem = _sidebarItem.value?.next
793822

794823
if (nextItem != null) {
795824
selectSidebarItem(nextItem)

app/src/test/java/com/capyreader/app/ui/articles/ArticleScreenViewModelTest.kt

Lines changed: 92 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,16 @@ package com.capyreader.app.ui.articles
33
import android.app.Application
44
import app.cash.turbine.test
55
import com.capyreader.app.notifications.NotificationHelper
6-
import com.capyreader.app.preferences.AfterReadAllBehavior
76
import com.capyreader.app.preferences.AppPreferences
87
import com.capyreader.app.preferences.ArticleListVerticalSwipe
98
import com.capyreader.app.refresher.RefreshInterval
109
import com.capyreader.app.ui.articles.feeds.AngleRefreshState
1110
import com.jocmp.capy.Account
1211
import com.jocmp.capy.ArticleFilter
12+
import com.jocmp.capy.ArticleStatus
13+
import com.jocmp.capy.Feed
14+
import com.jocmp.capy.Folder
1315
import com.jocmp.capy.accounts.Source
14-
import com.jocmp.capy.articles.SortOrder
15-
import com.jocmp.capy.preferences.Preference
1616
import io.mockk.coEvery
1717
import io.mockk.every
1818
import io.mockk.mockk
@@ -54,20 +54,20 @@ class ArticleScreenViewModelTest {
5454
every { feeds } returns flowOf(emptyList())
5555
every { taggedFeeds } returns flowOf(emptyList())
5656
every { savedSearches } returns flowOf(emptyList())
57-
every { canSaveArticleExternally } returns mockPreference(false)
57+
every { canSaveArticleExternally } returns mockk(relaxed = true) {
58+
every { get() } returns false
59+
every { stateIn(any()) } returns MutableStateFlow(false)
60+
every { changes() } returns flowOf(false)
61+
}
62+
every { countAll(any()) } returns flowOf(emptyMap())
63+
every { countAllBySavedSearch(any()) } returns flowOf(emptyMap())
5864
every { source } returns Source.LOCAL
5965
coEvery { refresh(any()) } returns Result.success(Unit)
6066
}
6167

62-
appPreferences = mockk(relaxed = true) {
63-
every { filter } returns mockPreference(ArticleFilter.default())
64-
every { refreshInterval } returns mockPreference(RefreshInterval.EVERY_TWO_HOURS)
65-
every { enableStickyFullContent } returns mockPreference(false)
66-
every { articleListOptions } returns mockk(relaxed = true) {
67-
every { swipeBottom } returns mockPreference(ArticleListVerticalSwipe.DISABLED)
68-
every { sortOrder } returns mockPreference(SortOrder.default)
69-
every { afterReadAllBehavior } returns mockPreference(AfterReadAllBehavior.default)
70-
}
68+
appPreferences = AppPreferences(RuntimeEnvironment.getApplication()).also {
69+
it.clearAll()
70+
it.refreshInterval.set(RefreshInterval.EVERY_TWO_HOURS)
7171
}
7272

7373
notificationHelper = mockk(relaxed = true)
@@ -92,7 +92,7 @@ class ArticleScreenViewModelTest {
9292

9393
@Test
9494
fun `skips initial refresh when refresh interval is manual only`() = runTest {
95-
every { appPreferences.refreshInterval } returns mockPreference(RefreshInterval.MANUALLY_ONLY)
95+
appPreferences.refreshInterval.set(RefreshInterval.MANUALLY_ONLY)
9696

9797
val viewModel = buildViewModel()
9898

@@ -102,7 +102,7 @@ class ArticleScreenViewModelTest {
102102

103103
@Test
104104
fun `refreshAll transitions from stopped, running, to settling`() = runTest {
105-
every { appPreferences.refreshInterval } returns mockPreference(RefreshInterval.MANUALLY_ONLY)
105+
appPreferences.refreshInterval.set(RefreshInterval.MANUALLY_ONLY)
106106

107107
val viewModel = buildViewModel()
108108

@@ -120,7 +120,7 @@ class ArticleScreenViewModelTest {
120120

121121
@Test
122122
fun `refreshAll guards against double calls while running`() = runTest {
123-
every { appPreferences.refreshInterval } returns mockPreference(RefreshInterval.MANUALLY_ONLY)
123+
appPreferences.refreshInterval.set(RefreshInterval.MANUALLY_ONLY)
124124

125125
val viewModel = buildViewModel()
126126

@@ -138,6 +138,82 @@ class ArticleScreenViewModelTest {
138138
}
139139
}
140140

141+
@Test
142+
fun `requestNextFeed opens next feed after current feed's unread count drops to zero`() = runTest {
143+
val initialFilter = ArticleFilter.Feeds(
144+
feedID = "1",
145+
folderTitle = null,
146+
feedStatus = ArticleStatus.UNREAD
147+
)
148+
appPreferences.filter.set(initialFilter)
149+
appPreferences.articleListOptions.swipeBottom.set(ArticleListVerticalSwipe.NEXT_FEED)
150+
151+
val feedA = Feed(id = "1", subscriptionID = "1", title = "A", feedURL = "a", count = 3)
152+
val feedB = Feed(id = "2", subscriptionID = "2", title = "B", feedURL = "b", count = 3)
153+
154+
val feeds = MutableStateFlow(listOf(feedA, feedB))
155+
val counts = MutableStateFlow<Map<String, Long>>(mapOf("1" to 3, "2" to 3))
156+
157+
every { account.feeds } returns feeds
158+
every { account.countAll(any()) } returns counts
159+
160+
val viewModel = buildViewModel()
161+
advanceUntilIdle()
162+
163+
counts.value = mapOf("1" to 0, "2" to 3)
164+
advanceUntilIdle()
165+
166+
viewModel.requestNextFeed()
167+
advanceUntilIdle()
168+
169+
val expectedNext = ArticleFilter.Feeds(
170+
feedID = "2",
171+
folderTitle = null,
172+
feedStatus = ArticleStatus.UNREAD
173+
)
174+
assertEquals(expectedNext, appPreferences.filter.get())
175+
}
176+
177+
@Test
178+
fun `requestNextFeed skips empty children when current folder is marked read`() = runTest {
179+
val initialFilter = ArticleFilter.Folders(
180+
folderTitle = "X",
181+
folderStatus = ArticleStatus.UNREAD,
182+
)
183+
appPreferences.filter.set(initialFilter)
184+
appPreferences.articleListOptions.swipeBottom.set(ArticleListVerticalSwipe.NEXT_FEED)
185+
186+
val x1 = Feed(id = "x1", subscriptionID = "x1", title = "X1", feedURL = "x1", folderName = "X")
187+
val x2 = Feed(id = "x2", subscriptionID = "x2", title = "X2", feedURL = "x2", folderName = "X")
188+
val y1 = Feed(id = "y1", subscriptionID = "y1", title = "Y1", feedURL = "y1", folderName = "Y")
189+
190+
val folderX = Folder(title = "X", feeds = listOf(x1, x2), expanded = true)
191+
val folderY = Folder(title = "Y", feeds = listOf(y1), expanded = true)
192+
193+
val folders = MutableStateFlow(listOf(folderX, folderY))
194+
val counts = MutableStateFlow<Map<String, Long>>(
195+
mapOf("x1" to 2L, "x2" to 2L, "y1" to 3L)
196+
)
197+
198+
every { account.folders } returns folders
199+
every { account.feeds } returns flowOf(emptyList())
200+
every { account.countAll(any()) } returns counts
201+
202+
val viewModel = buildViewModel()
203+
advanceUntilIdle()
204+
205+
counts.value = mapOf("x1" to 0L, "x2" to 0L, "y1" to 3L)
206+
advanceUntilIdle()
207+
208+
viewModel.requestNextFeed()
209+
advanceUntilIdle()
210+
211+
assertEquals(
212+
ArticleFilter.Folders(folderTitle = "Y", folderStatus = ArticleStatus.UNREAD),
213+
appPreferences.filter.get()
214+
)
215+
}
216+
141217
private fun buildViewModel(): ArticleScreenViewModel {
142218
val application = RuntimeEnvironment.getApplication() as Application
143219

@@ -149,12 +225,4 @@ class ArticleScreenViewModelTest {
149225
ioDispatcher = testDispatcher,
150226
)
151227
}
152-
153-
private inline fun <reified T> mockPreference(value: T): Preference<T> {
154-
return mockk {
155-
every { get() } returns value
156-
every { stateIn(any()) } returns MutableStateFlow(value)
157-
every { changes() } returns flowOf(value)
158-
}
159-
}
160228
}

0 commit comments

Comments
 (0)