diff --git a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/feeds/FeedsPresenter.kt b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/feeds/FeedsPresenter.kt index 184ce4ab7..cdfe64ec1 100644 --- a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/feeds/FeedsPresenter.kt +++ b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/feeds/FeedsPresenter.kt @@ -15,24 +15,34 @@ */ package dev.sasikanth.rss.reader.feeds +import app.cash.paging.PagingData +import app.cash.paging.cachedIn +import app.cash.paging.createPager +import app.cash.paging.createPagingConfig +import app.cash.paging.insertSeparators +import app.cash.paging.map import com.arkivanov.decompose.ComponentContext import com.arkivanov.essenty.instancekeeper.InstanceKeeper import com.arkivanov.essenty.instancekeeper.getOrCreate import com.arkivanov.essenty.lifecycle.doOnCreate +import dev.sasikanth.rss.reader.feeds.ui.FeedsListItemType import dev.sasikanth.rss.reader.models.local.Feed import dev.sasikanth.rss.reader.repository.ObservableSelectedFeed import dev.sasikanth.rss.reader.repository.RssRepository import dev.sasikanth.rss.reader.utils.DispatchersProvider -import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update @@ -121,32 +131,47 @@ class FeedsPresenter( } private fun init() { - rssRepository - .allFeeds() - .onEach { feeds -> - val feedsGroup = feeds.groupBy { it.pinnedAt != null }.entries - val pinnedFeeds = feedsGroup.firstOrNull()?.value.orEmpty().sortedBy { it.pinnedAt } - val unPinnedFeeds = feedsGroup.elementAtOrNull(1)?.value.orEmpty() - + observableSelectedFeed.selectedFeed + .flatMapLatest { selectedFeed -> + val feedListItemTypes: Flow> = + createPager(config = createPagingConfig(pageSize = 20)) { rssRepository.allFeeds() } + .flow + .cachedIn(coroutineScope) + .map { feeds -> + feeds + .map { feed -> FeedsListItemType.FeedListItem(feed = feed) } + .insertSeparators { before, after -> + when { + before?.feed?.pinnedAt != null && + after != null && + after.feed.pinnedAt == null -> { + FeedsListItemType.SectionSeparator + } + before?.feed != null && after?.feed != null -> { + FeedsListItemType.FeedSeparator + } + else -> { + null + } + } + } + } + + rssRepository.numberOfPinnedFeeds().map { numberOfPinnedFeeds -> + Triple(selectedFeed, feedListItemTypes, numberOfPinnedFeeds) + } + } + .distinctUntilChanged() + .onEach { (selectedFeed, feedListItemTypes, numberOfPinnedFeeds) -> _state.update { it.copy( - pinnedFeeds = pinnedFeeds.toImmutableList(), - feeds = unPinnedFeeds.toImmutableList() + feedsListItemTypes = feedListItemTypes, + numberOfPinnedFeeds = numberOfPinnedFeeds, + selectedFeed = selectedFeed ) } } .launchIn(coroutineScope) - - rssRepository - .numberOfPinnedFeeds() - .onEach { numberOfPinnedFeeds -> - _state.update { it.copy(numberOfPinnedFeeds = numberOfPinnedFeeds) } - } - .launchIn(coroutineScope) - - observableSelectedFeed.selectedFeed - .onEach { selectedFeed -> _state.update { it.copy(selectedFeed = selectedFeed) } } - .launchIn(coroutineScope) } override fun onDestroy() { diff --git a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/feeds/FeedsState.kt b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/feeds/FeedsState.kt index 6921b5017..c3dae3cd5 100644 --- a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/feeds/FeedsState.kt +++ b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/feeds/FeedsState.kt @@ -16,33 +16,35 @@ package dev.sasikanth.rss.reader.feeds import androidx.compose.runtime.Immutable +import androidx.paging.PagingData +import androidx.paging.filter +import dev.sasikanth.rss.reader.feeds.ui.FeedsListItemType import dev.sasikanth.rss.reader.models.local.Feed -import kotlinx.collections.immutable.ImmutableList -import kotlinx.collections.immutable.persistentListOf -import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.map @Immutable internal data class FeedsState( - val pinnedFeeds: ImmutableList, - val feeds: ImmutableList, + val feedsListItemTypes: Flow>, val selectedFeed: Feed?, val numberOfPinnedFeeds: Long ) { - val allFeeds: ImmutableList - get() = (pinnedFeeds + feeds).toImmutableList() + val feedsOnly: Flow> = + feedsListItemTypes + .map { feeds -> + feeds.filter { feedListItemType -> feedListItemType is FeedsListItemType.FeedListItem } + } + .filterIsInstance() val canPinFeeds: Boolean - get() = numberOfPinnedFeeds <= 10L + get() = numberOfPinnedFeeds < 10L companion object { val DEFAULT = - FeedsState( - pinnedFeeds = persistentListOf(), - feeds = persistentListOf(), - selectedFeed = null, - numberOfPinnedFeeds = 0 - ) + FeedsState(feedsListItemTypes = emptyFlow(), selectedFeed = null, numberOfPinnedFeeds = 0) } } diff --git a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/feeds/ui/FeedListItem.kt b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/feeds/ui/FeedListItem.kt index 951423343..16a9e3076 100644 --- a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/feeds/ui/FeedListItem.kt +++ b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/feeds/ui/FeedListItem.kt @@ -31,7 +31,6 @@ import androidx.compose.foundation.layout.requiredWidth import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material3.Divider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -71,7 +70,6 @@ internal fun FeedListItem( modifier: Modifier = Modifier, feed: Feed, selected: Boolean, - canShowDivider: Boolean, canPinFeeds: Boolean, feedsSheetMode: FeedsSheetMode, onDeleteFeed: (Feed) -> Unit, @@ -130,13 +128,6 @@ internal fun FeedListItem( ) } } - - if (canShowDivider) { - Divider( - modifier = Modifier.requiredHeight(1.dp).align(Alignment.BottomStart).padding(end = 12.dp), - color = AppTheme.colorScheme.tintedSurface - ) - } } } diff --git a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/feeds/ui/FeedsBottomSheet.kt b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/feeds/ui/FeedsBottomSheet.kt index 5f8b6ed9f..abdb56867 100644 --- a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/feeds/ui/FeedsBottomSheet.kt +++ b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/feeds/ui/FeedsBottomSheet.kt @@ -48,12 +48,9 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyRow -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.material.Icon import androidx.compose.material.IconButton import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.outlined.Edit import androidx.compose.material.icons.rounded.ArrowBack import androidx.compose.material.icons.rounded.Close @@ -78,6 +75,8 @@ import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.unit.dp +import app.cash.paging.compose.LazyPagingItems +import app.cash.paging.compose.collectAsLazyPagingItems import dev.sasikanth.rss.reader.feeds.FeedsEffect import dev.sasikanth.rss.reader.feeds.FeedsEvent import dev.sasikanth.rss.reader.feeds.FeedsPresenter @@ -88,7 +87,6 @@ import dev.sasikanth.rss.reader.ui.AppTheme import dev.sasikanth.rss.reader.utils.KeyboardState import dev.sasikanth.rss.reader.utils.inverseProgress import dev.sasikanth.rss.reader.utils.keyboardVisibilityAsState -import kotlinx.collections.immutable.ImmutableList @Composable internal fun FeedsBottomSheet( @@ -124,7 +122,7 @@ internal fun FeedsBottomSheet( if (hasBottomSheetExpandedThreshold) { BottomSheetCollapsedContent( modifier = Modifier.graphicsLayer { alpha = bottomSheetExpandingProgress }, - feeds = state.allFeeds, + feeds = state.feedsOnly.collectAsLazyPagingItems(), selectedFeed = selectedFeed, onFeedSelected = { feed -> feedsPresenter.dispatch(FeedsEvent.OnFeedSelected(feed)) } ) @@ -143,8 +141,7 @@ internal fun FeedsBottomSheet( .toFloat() alpha = targetAlpha }, - pinnedFeeds = state.pinnedFeeds, - feeds = state.feeds, + feedsListItemTypes = state.feedsListItemTypes.collectAsLazyPagingItems(), selectedFeed = state.selectedFeed, feedsSheetMode = feedsSheetMode, canPinFeeds = state.canPinFeeds, @@ -167,8 +164,7 @@ internal fun FeedsBottomSheet( @Composable private fun BottomSheetExpandedContent( - pinnedFeeds: ImmutableList, - feeds: ImmutableList, + feedsListItemTypes: LazyPagingItems, selectedFeed: Feed?, feedsSheetMode: FeedsSheetMode, canPinFeeds: Boolean, @@ -245,42 +241,42 @@ private fun BottomSheetExpandedContent( bottom = padding.calculateBottomPadding() + 64.dp ) ) { - itemsIndexed(pinnedFeeds) { index, feed -> - FeedListItem( - feed = feed, - selected = selectedFeed == feed, - canShowDivider = index != pinnedFeeds.lastIndex, - canPinFeeds = true, - feedsSheetMode = feedsSheetMode, - onDeleteFeed = onDeleteFeed, - onFeedSelected = onFeedSelected, - onFeedNameChanged = onFeedNameChanged, - onFeedPinClick = onFeedPinClick - ) - } - - if (pinnedFeeds.isNotEmpty() && feeds.isNotEmpty()) { - item { - Divider( - modifier = Modifier.fillMaxWidth().padding(vertical = 16.dp), - color = AppTheme.colorScheme.tintedSurface - ) + items(feedsListItemTypes.itemCount) { index -> + when (val feedListItemType = feedsListItemTypes[index]) { + is FeedsListItemType.FeedListItem -> { + val feed = feedListItemType.feed + FeedListItem( + feed = feed, + selected = selectedFeed == feed, + canPinFeeds = (feed.pinnedAt != null || canPinFeeds), + feedsSheetMode = feedsSheetMode, + onDeleteFeed = onDeleteFeed, + onFeedSelected = onFeedSelected, + onFeedNameChanged = onFeedNameChanged, + onFeedPinClick = onFeedPinClick + ) + } + FeedsListItemType.FeedSeparator -> { + Divider( + modifier = + Modifier.requiredHeight(1.dp) + .align(Alignment.BottomStart) + .padding(start = 24.dp, end = 12.dp) + .graphicsLayer { translationY = -1f }, + color = AppTheme.colorScheme.tintedSurface + ) + } + FeedsListItemType.SectionSeparator -> { + Divider( + modifier = Modifier.fillMaxWidth().padding(vertical = 16.dp), + color = AppTheme.colorScheme.tintedSurface + ) + } + null -> { + // no-op + } } } - - itemsIndexed(feeds) { index, feed -> - FeedListItem( - feed = feed, - selected = selectedFeed == feed, - canShowDivider = index != feeds.lastIndex, - canPinFeeds = canPinFeeds, - feedsSheetMode = feedsSheetMode, - onDeleteFeed = onDeleteFeed, - onFeedSelected = onFeedSelected, - onFeedNameChanged = onFeedNameChanged, - onFeedPinClick = onFeedPinClick - ) - } } if (keyboardState == KeyboardState.Opened && feedsSheetMode == LinkEntry) { @@ -362,7 +358,7 @@ private fun BoxScope.EditFeeds(onClick: () -> Unit) { @Composable private fun BottomSheetCollapsedContent( - feeds: ImmutableList, + feeds: LazyPagingItems, selectedFeed: Feed?, onFeedSelected: (Feed) -> Unit, modifier: Modifier = Modifier @@ -373,13 +369,16 @@ private fun BottomSheetCollapsedContent( horizontalArrangement = Arrangement.spacedBy(8.dp), contentPadding = PaddingValues(start = 100.dp, end = 24.dp) ) { - items(feeds) { feed -> - BottomSheetItem( - text = feed.name.uppercase(), - iconUrl = feed.icon, - selected = selectedFeed == feed, - onClick = { onFeedSelected(feed) } - ) + items(feeds.itemCount) { index -> + val feed = feeds[index]?.feed + if (feed != null) { + BottomSheetItem( + text = feed.name.uppercase(), + iconUrl = feed.icon, + selected = selectedFeed == feed, + onClick = { onFeedSelected(feed) } + ) + } } } diff --git a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/feeds/ui/FeedsListItemType.kt b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/feeds/ui/FeedsListItemType.kt new file mode 100644 index 000000000..349753cc8 --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/feeds/ui/FeedsListItemType.kt @@ -0,0 +1,27 @@ +/* + * Copyright 2023 Sasikanth Miriyampalli + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.sasikanth.rss.reader.feeds.ui + +import dev.sasikanth.rss.reader.models.local.Feed + +internal sealed interface FeedsListItemType { + data class FeedListItem(val feed: Feed) : FeedsListItemType + + object FeedSeparator : FeedsListItemType + + object SectionSeparator : FeedsListItemType +} diff --git a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/repository/RssRepository.kt b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/repository/RssRepository.kt index 8cd4560c9..591853fc6 100644 --- a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/repository/RssRepository.kt +++ b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/repository/RssRepository.kt @@ -165,8 +165,13 @@ class RssRepository( withContext(ioDispatcher) { bookmarkQueries.deleteBookmark(link) } } - fun allFeeds(): Flow> { - return feedQueries.feeds(mapper = ::mapToFeed).asFlow().mapToList(ioDispatcher) + fun allFeeds(): PagingSource { + return QueryPagingSource( + countQuery = feedQueries.count(), + transacter = feedQueries, + context = ioDispatcher, + queryProvider = { limit, offset -> feedQueries.feedsPaginated(limit, offset, ::mapToFeed) } + ) } fun feed(feedLink: String): Feed { diff --git a/shared/src/commonMain/sqldelight/dev/sasikanth/rss/reader/database/Feed.sq b/shared/src/commonMain/sqldelight/dev/sasikanth/rss/reader/database/Feed.sq index 66838114f..fcb658058 100644 --- a/shared/src/commonMain/sqldelight/dev/sasikanth/rss/reader/database/Feed.sq +++ b/shared/src/commonMain/sqldelight/dev/sasikanth/rss/reader/database/Feed.sq @@ -22,10 +22,18 @@ homepageLink = excluded.homepageLink; remove: DELETE FROM feed WHERE link = :link; +count: +SELECT COUNT(*) FROM feed; + feeds: SELECT * FROM feed ORDER BY pinnedAt DESC, createdAt DESC; +feedsPaginated: +SELECT * FROM feed +ORDER BY pinnedAt DESC , createdAt DESC +LIMIT :limit OFFSET :offset; + feed: SELECT * FROM feed WHERE link = :link