Skip to content

Commit 996d8ae

Browse files
authored
[FEATURE] Add Pull to Next at the bottom of Feed (#1007)
* Add GetNextFeedFilterOrNullUseCase.kt * Add logic * Add UI components # Conflicts: # sharedUI/src/commonMain/kotlin/com/prof18/feedflow/shared/ui/home/components/HomeScreenContent.kt * Add tests * Apply review suggestions
1 parent eae1924 commit 996d8ae

15 files changed

Lines changed: 708 additions & 63 deletions

File tree

androidApp/src/main/kotlin/com/prof18/feedflow/android/MainActivity.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,7 @@ class MainActivity : BaseThemeActivity() {
289289
backStack.add(feedSource.toEditFeed())
290290
},
291291
onFeedSuggestionsClick = { backStack.add(FeedSuggestions) },
292+
onNavigateToNextFeed = { homeViewModel.onNavigateToNextFeed() },
292293
)
293294
}
294295

androidApp/src/main/kotlin/com/prof18/feedflow/android/home/HomeScreen.android.kt

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import androidx.compose.runtime.setValue
1818
import androidx.compose.ui.Modifier
1919
import androidx.compose.ui.platform.LocalContext
2020
import androidx.lifecycle.compose.collectAsStateWithLifecycle
21+
import androidx.window.core.layout.WindowWidthSizeClass
2122
import com.prof18.feedflow.android.BrowserManager
2223
import com.prof18.feedflow.android.categoryselection.EditCategorySheet
2324
import com.prof18.feedflow.android.openShareSheet
@@ -29,11 +30,15 @@ import com.prof18.feedflow.core.model.LinkOpeningPreference
2930
import com.prof18.feedflow.core.model.shouldOpenInBrowser
3031
import com.prof18.feedflow.shared.presentation.ChangeFeedCategoryViewModel
3132
import com.prof18.feedflow.shared.presentation.HomeViewModel
33+
import com.prof18.feedflow.shared.presentation.model.NextFeedPreviewState
34+
import com.prof18.feedflow.shared.presentation.model.NextFeedPreviewState.NextFeedPreviewDisabledState
35+
import com.prof18.feedflow.shared.presentation.model.NextFeedPreviewState.NextFeedPreviewEnabledState
3236
import com.prof18.feedflow.shared.presentation.model.UIErrorState
3337
import com.prof18.feedflow.shared.ui.home.AdaptiveHomeView
3438
import com.prof18.feedflow.shared.ui.home.FeedListActions
3539
import com.prof18.feedflow.shared.ui.home.FeedManagementActions
3640
import com.prof18.feedflow.shared.ui.home.HomeDisplayState
41+
import com.prof18.feedflow.shared.ui.home.NextFeedDisplayState
3742
import com.prof18.feedflow.shared.ui.home.ShareBehavior
3843
import com.prof18.feedflow.shared.ui.home.WindowSizeClass
3944
import com.prof18.feedflow.shared.ui.home.components.LoadingOperationDialog
@@ -49,8 +54,9 @@ internal fun HomeScreen(
4954
onSearchClick: () -> Unit,
5055
onAccountsClick: () -> Unit,
5156
onEditFeedClick: (FeedSource) -> Unit,
52-
onImportExportClick: () -> Unit = {},
5357
onFeedSuggestionsClick: () -> Unit,
58+
onNavigateToNextFeed: () -> Unit,
59+
onImportExportClick: () -> Unit = {},
5460
) {
5561
val browserManager = koinInject<BrowserManager>()
5662
val changeFeedCategoryViewModel: ChangeFeedCategoryViewModel = koinInject()
@@ -59,6 +65,7 @@ internal fun HomeScreen(
5965
val feedState by homeViewModel.feedState.collectAsStateWithLifecycle()
6066
val navDrawerState by homeViewModel.navDrawerState.collectAsStateWithLifecycle()
6167
val currentFeedFilter by homeViewModel.currentFeedFilter.collectAsStateWithLifecycle()
68+
val nextFeedPreviewState: NextFeedPreviewState by homeViewModel.nextFeedPreviewState.collectAsStateWithLifecycle()
6269
val unReadCount by homeViewModel.unreadCountFlow.collectAsStateWithLifecycle(initialValue = 0)
6370
val feedFontSizes by homeViewModel.feedFontSizeState.collectAsStateWithLifecycle()
6471
val swipeActions by homeViewModel.swipeActions.collectAsStateWithLifecycle()
@@ -130,6 +137,7 @@ internal fun HomeScreen(
130137
swipeActions = swipeActions,
131138
feedLayout = feedLayout,
132139
isSyncUploadRequired = isSyncUploadRequired,
140+
nextFeedDisplayState = nextFeedPreviewState.asDisplayState(),
133141
)
134142

135143
val feedListActions = FeedListActions(
@@ -168,8 +176,8 @@ internal fun HomeScreen(
168176

169177
val windowSizeClass = currentWindowAdaptiveInfo().windowSizeClass
170178
val adaptiveWindowSizeClass = when (windowSizeClass.windowWidthSizeClass) {
171-
androidx.window.core.layout.WindowWidthSizeClass.COMPACT -> WindowSizeClass.Compact
172-
androidx.window.core.layout.WindowWidthSizeClass.MEDIUM -> WindowSizeClass.Medium
179+
WindowWidthSizeClass.COMPACT -> WindowSizeClass.Compact
180+
WindowWidthSizeClass.MEDIUM -> WindowSizeClass.Medium
173181
else -> WindowSizeClass.Expanded
174182
}
175183

@@ -208,6 +216,7 @@ internal fun HomeScreen(
208216
onEmptyStateClick = {
209217
showNoFeedsBottomSheet = true
210218
},
219+
onNavigateToNextFeed = onNavigateToNextFeed,
211220
)
212221

213222
if (showChangeCategorySheet) {
@@ -278,3 +287,8 @@ private fun openUrl(
278287
}
279288
}
280289
}
290+
291+
fun NextFeedPreviewState.asDisplayState(): NextFeedDisplayState = when (this) {
292+
is NextFeedPreviewDisabledState -> NextFeedDisplayState.NextFeedDisplayDisabledState
293+
is NextFeedPreviewEnabledState -> NextFeedDisplayState.NextFeedDisplayEnabledState(this.title)
294+
}

desktopApp/src/jvmMain/kotlin/com/prof18/feedflow/desktop/home/HomeScreen.desktop.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import com.prof18.feedflow.shared.presentation.model.UIErrorState
3232
import com.prof18.feedflow.shared.ui.home.FeedListActions
3333
import com.prof18.feedflow.shared.ui.home.FeedManagementActions
3434
import com.prof18.feedflow.shared.ui.home.HomeDisplayState
35+
import com.prof18.feedflow.shared.ui.home.NextFeedDisplayState.NextFeedDisplayDisabledState
3536
import com.prof18.feedflow.shared.ui.home.ShareBehavior
3637
import com.prof18.feedflow.shared.ui.home.components.LoadingOperationDialog
3738
import com.prof18.feedflow.shared.ui.utils.LocalFeedFlowStrings
@@ -132,6 +133,7 @@ internal fun HomeScreen(
132133
currentFeedFilter = currentFeedFilter,
133134
swipeActions = swipeActions,
134135
feedLayout = feedLayout,
136+
nextFeedDisplayState = NextFeedDisplayDisabledState,
135137
)
136138

137139
val openReaderArticle: (FeedItemUrlInfo) -> Unit = { article ->

i18n/src/commonMain/resources/locale/values/strings.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -310,6 +310,7 @@
310310
<string name="scroll_to_top_button_content_description">Scroll to top</string>
311311
<string name="previous_article">Previous article</string>
312312
<string name="next_article">Next article</string>
313+
<string name="next">Next</string>
313314
<string name="settings_theme">Theme</string>
314315
<string name="settings_theme_system">System</string>
315316
<string name="settings_theme_light">Light</string>

shared/src/commonMain/kotlin/com/prof18/feedflow/shared/di/Koin.kt

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ import com.prof18.feedflow.shared.presentation.FeedSourceListViewModel
5050
import com.prof18.feedflow.shared.presentation.FeedSuggestionsViewModel
5151
import com.prof18.feedflow.shared.presentation.FeedbinSyncViewModel
5252
import com.prof18.feedflow.shared.presentation.FreshRssSyncViewModel
53+
import com.prof18.feedflow.shared.presentation.GetNextFeedFilterOrNullUseCase
5354
import com.prof18.feedflow.shared.presentation.HomeViewModel
5455
import com.prof18.feedflow.shared.presentation.ImportExportViewModel
5556
import com.prof18.feedflow.shared.presentation.MainSettingsViewModel
@@ -197,6 +198,7 @@ private fun getCoreModule(appConfig: AppConfig) = module {
197198
feedCategoryRepository = get(),
198199
feedStateRepository = get(),
199200
feedFetcherRepository = get(),
201+
getNextFeedFilterOrNullUseCase = get(),
200202
)
201203
}
202204

@@ -577,6 +579,12 @@ private fun getCoreModule(appConfig: AppConfig) = module {
577579
backgroundSyncScheduler = get(),
578580
)
579581
}
582+
583+
single {
584+
GetNextFeedFilterOrNullUseCase(
585+
feedSourcesRepository = get(),
586+
)
587+
}
580588
}
581589

582590
internal expect fun platformLogWriters(): List<LogWriter>

shared/src/commonMain/kotlin/com/prof18/feedflow/shared/domain/feed/FeedSourcesRepository.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,9 @@ internal class FeedSourcesRepository(
7272
fun getFeedSources(): Flow<List<FeedSource>> =
7373
databaseHelper.getFeedSourcesFlow()
7474

75+
fun getFeedSourcesWithUnreadCount(): Flow<List<FeedSourceWithUnreadCount>> =
76+
databaseHelper.getFeedSourcesWithUnreadCountFlow()
77+
7578
suspend fun deleteFeed(feedSource: FeedSource) {
7679
when (accountsRepository.getCurrentSyncAccount()) {
7780
SyncAccounts.FRESH_RSS, SyncAccounts.MINIFLUX, SyncAccounts.BAZQUX -> {
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
package com.prof18.feedflow.shared.presentation
2+
3+
import com.prof18.feedflow.core.model.FeedFilter
4+
import com.prof18.feedflow.shared.domain.feed.FeedSourcesRepository
5+
import kotlinx.coroutines.flow.first
6+
7+
class GetNextFeedFilterOrNullUseCase internal constructor(
8+
private val feedSourcesRepository: FeedSourcesRepository,
9+
) {
10+
11+
suspend operator fun invoke(
12+
currentFeedFilter: FeedFilter,
13+
): FeedFilter? {
14+
when (currentFeedFilter) {
15+
is FeedFilter.Category -> {
16+
val unreadCategoryList = feedSourcesRepository
17+
.observeFeedSourcesByCategoryWithUnreadCount()
18+
.first()
19+
.mapValues { source -> source.value.sumOf { it.unreadCount } }
20+
.filter { it.value > 0 }
21+
.keys
22+
.filterNotNull()
23+
24+
val currentCategoryIndex = unreadCategoryList
25+
.indexOfFirst { it.id == currentFeedFilter.feedCategory.id }
26+
27+
return FeedFilter.Category(
28+
feedCategory = unreadCategoryList
29+
.drop(currentCategoryIndex + 1)
30+
.firstOrNull()
31+
?: return null,
32+
)
33+
}
34+
is FeedFilter.Source -> {
35+
val feedSources = feedSourcesRepository.getFeedSourcesWithUnreadCount().first()
36+
.filter {
37+
it.feedSource.category?.id == currentFeedFilter.feedSource.category?.id
38+
}
39+
40+
val currentSourceIndex = feedSources.indexOfFirst {
41+
it.feedSource.id == currentFeedFilter.feedSource.id
42+
}
43+
44+
if (currentSourceIndex == -1) return null
45+
46+
val nextUnreadSource = feedSources
47+
.drop(currentSourceIndex + 1)
48+
.firstOrNull { it.unreadCount > 0 }
49+
50+
return FeedFilter.Source(
51+
feedSource = nextUnreadSource?.feedSource ?: return null,
52+
)
53+
}
54+
else -> return null
55+
}
56+
}
57+
}

shared/src/commonMain/kotlin/com/prof18/feedflow/shared/presentation/HomeViewModel.kt

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import com.prof18.feedflow.shared.domain.feedsync.FeedSyncRepository
2929
import com.prof18.feedflow.shared.presentation.model.DatabaseError
3030
import com.prof18.feedflow.shared.presentation.model.DeleteFeedSourceError
3131
import com.prof18.feedflow.shared.presentation.model.FeedErrorState
32+
import com.prof18.feedflow.shared.presentation.model.NextFeedPreviewState
3233
import com.prof18.feedflow.shared.presentation.model.SyncError
3334
import com.prof18.feedflow.shared.presentation.model.UIErrorState
3435
import kotlinx.collections.immutable.ImmutableList
@@ -57,6 +58,7 @@ class HomeViewModel internal constructor(
5758
private val feedCategoryRepository: FeedCategoryRepository,
5859
private val feedStateRepository: FeedStateRepository,
5960
private val feedFetcherRepository: FeedFetcherRepository,
61+
private val getNextFeedFilterOrNullUseCase: GetNextFeedFilterOrNullUseCase,
6062
) : ViewModel() {
6163

6264
// Loading
@@ -80,6 +82,11 @@ class HomeViewModel internal constructor(
8082
private val refreshTriggerMutableState = MutableStateFlow(0)
8183
val refreshTriggerState: StateFlow<Int> = refreshTriggerMutableState.asStateFlow()
8284

85+
private val nextFeedPreviewMutableState: MutableStateFlow<NextFeedPreviewState> = MutableStateFlow(
86+
NextFeedPreviewState.NextFeedPreviewDisabledState,
87+
)
88+
val nextFeedPreviewState: StateFlow<NextFeedPreviewState> = nextFeedPreviewMutableState.asStateFlow()
89+
8390
private var lastUpdateIndex = 0
8491
private var hasTriggeredAppLaunch = false
8592

@@ -332,6 +339,28 @@ class HomeViewModel internal constructor(
332339
feedStateRepository.updateFeedFilter(selectedFeedFilter)
333340
lastUpdateIndex = 0
334341
}
342+
343+
updateNextFeedPreview(selectedFeedFilter)
344+
}
345+
346+
fun updateNextFeedPreview(currentFeedFilter: FeedFilter) {
347+
viewModelScope.launch {
348+
nextFeedPreviewMutableState.update {
349+
val nextFeed = getNextFeedFilterOrNullUseCase(currentFeedFilter)
350+
351+
when (nextFeed) {
352+
is FeedFilter.Category -> NextFeedPreviewState.NextFeedPreviewEnabledState(
353+
feedFilter = nextFeed,
354+
title = nextFeed.feedCategory.title,
355+
)
356+
is FeedFilter.Source -> NextFeedPreviewState.NextFeedPreviewEnabledState(
357+
feedFilter = nextFeed,
358+
title = nextFeed.feedSource.title,
359+
)
360+
else -> NextFeedPreviewState.NextFeedPreviewDisabledState
361+
}
362+
}
363+
}
335364
}
336365

337366
fun updateReadStatus(feedItemId: FeedItemId, read: Boolean) {
@@ -385,5 +414,14 @@ class HomeViewModel internal constructor(
385414
}
386415
}
387416

417+
fun onNavigateToNextFeed() {
418+
when (val nextFeed = nextFeedPreviewState.value) {
419+
is NextFeedPreviewState.NextFeedPreviewEnabledState -> {
420+
onFeedFilterSelected(selectedFeedFilter = nextFeed.feedFilter)
421+
}
422+
is NextFeedPreviewState.NextFeedPreviewDisabledState -> {}
423+
}
424+
}
425+
388426
fun getCurrentThemeMode() = settingsRepository.getThemeMode()
389427
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
package com.prof18.feedflow.shared.presentation.model
2+
3+
import com.prof18.feedflow.core.model.FeedFilter
4+
5+
sealed class NextFeedPreviewState {
6+
data class NextFeedPreviewEnabledState(
7+
val title: String,
8+
val feedFilter: FeedFilter,
9+
) : NextFeedPreviewState()
10+
11+
data object NextFeedPreviewDisabledState : NextFeedPreviewState()
12+
}

0 commit comments

Comments
 (0)