diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 208e97ada..846c09229 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -7,7 +7,7 @@ android_sdk_compile = "34" android_sdk_target = "34" android_sdk_min = "26" -sqldelight = "2.0.1" +sqldelight = "2.0.2" ktor = "2.3.9" kotlinx_coroutines = "1.8.0" kotlinx_date_time = "0.5.0" diff --git a/resources/icons/src/commonMain/kotlin/dev/sasikanth/rss/reader/resources/icons/NewGroup.kt b/resources/icons/src/commonMain/kotlin/dev/sasikanth/rss/reader/resources/icons/NewGroup.kt new file mode 100644 index 000000000..f629ab0c2 --- /dev/null +++ b/resources/icons/src/commonMain/kotlin/dev/sasikanth/rss/reader/resources/icons/NewGroup.kt @@ -0,0 +1,124 @@ +/* + * Copyright 2024 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.resources.icons + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathFillType.Companion.EvenOdd +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.StrokeCap.Companion.Butt +import androidx.compose.ui.graphics.StrokeJoin.Companion.Miter +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.ImageVector.Builder +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +val TwineIcons.NewGroup: ImageVector + get() { + if (newGroup != null) { + return newGroup!! + } + newGroup = + Builder( + name = "NewGroup", + defaultWidth = 24.0.dp, + defaultHeight = 24.0.dp, + viewportWidth = 24.0f, + viewportHeight = 24.0f + ) + .apply { + path( + fill = SolidColor(Color(0xFF171D19)), + stroke = null, + strokeLineWidth = 0.0f, + strokeLineCap = Butt, + strokeLineJoin = Miter, + strokeLineMiter = 4.0f, + pathFillType = EvenOdd + ) { + moveTo(5.0f, 5.0f) + horizontalLineTo(9.0f) + verticalLineTo(9.0f) + horizontalLineTo(5.0f) + verticalLineTo(5.0f) + close() + moveTo(3.0f, 5.0f) + curveTo(3.0f, 3.8954f, 3.8954f, 3.0f, 5.0f, 3.0f) + horizontalLineTo(9.0f) + curveTo(10.1046f, 3.0f, 11.0f, 3.8954f, 11.0f, 5.0f) + verticalLineTo(9.0f) + curveTo(11.0f, 10.1046f, 10.1046f, 11.0f, 9.0f, 11.0f) + horizontalLineTo(5.0f) + curveTo(3.8954f, 11.0f, 3.0f, 10.1046f, 3.0f, 9.0f) + verticalLineTo(5.0f) + close() + moveTo(15.0f, 5.0f) + horizontalLineTo(19.0f) + verticalLineTo(9.0f) + horizontalLineTo(15.0f) + verticalLineTo(5.0f) + close() + moveTo(13.0f, 5.0f) + curveTo(13.0f, 3.8954f, 13.8954f, 3.0f, 15.0f, 3.0f) + horizontalLineTo(19.0f) + curveTo(20.1046f, 3.0f, 21.0f, 3.8954f, 21.0f, 5.0f) + verticalLineTo(9.0f) + curveTo(21.0f, 10.1046f, 20.1046f, 11.0f, 19.0f, 11.0f) + horizontalLineTo(15.0f) + curveTo(13.8954f, 11.0f, 13.0f, 10.1046f, 13.0f, 9.0f) + verticalLineTo(5.0f) + close() + moveTo(9.0f, 15.0f) + horizontalLineTo(5.0f) + verticalLineTo(19.0f) + horizontalLineTo(9.0f) + verticalLineTo(15.0f) + close() + moveTo(5.0f, 13.0f) + curveTo(3.8954f, 13.0f, 3.0f, 13.8954f, 3.0f, 15.0f) + verticalLineTo(19.0f) + curveTo(3.0f, 20.1046f, 3.8954f, 21.0f, 5.0f, 21.0f) + horizontalLineTo(9.0f) + curveTo(10.1046f, 21.0f, 11.0f, 20.1046f, 11.0f, 19.0f) + verticalLineTo(15.0f) + curveTo(11.0f, 13.8954f, 10.1046f, 13.0f, 9.0f, 13.0f) + horizontalLineTo(5.0f) + close() + moveTo(16.0f, 14.0f) + curveTo(16.0f, 13.4477f, 16.4477f, 13.0f, 17.0f, 13.0f) + curveTo(17.5523f, 13.0f, 18.0f, 13.4477f, 18.0f, 14.0f) + verticalLineTo(16.0f) + horizontalLineTo(20.0f) + curveTo(20.5523f, 16.0f, 21.0f, 16.4477f, 21.0f, 17.0f) + curveTo(21.0f, 17.5523f, 20.5523f, 18.0f, 20.0f, 18.0f) + horizontalLineTo(18.0f) + verticalLineTo(20.0f) + curveTo(18.0f, 20.5523f, 17.5523f, 21.0f, 17.0f, 21.0f) + curveTo(16.4477f, 21.0f, 16.0f, 20.5523f, 16.0f, 20.0f) + verticalLineTo(18.0f) + horizontalLineTo(14.0f) + curveTo(13.4477f, 18.0f, 13.0f, 17.5523f, 13.0f, 17.0f) + curveTo(13.0f, 16.4477f, 13.4477f, 16.0f, 14.0f, 16.0f) + horizontalLineTo(16.0f) + verticalLineTo(14.0f) + close() + } + } + .build() + return newGroup!! + } + +private var newGroup: ImageVector? = null diff --git a/resources/strings/src/commonMain/kotlin/dev/sasikanth/rss/reader/resources/strings/DeTwineStrings.kt b/resources/strings/src/commonMain/kotlin/dev/sasikanth/rss/reader/resources/strings/DeTwineStrings.kt index 9aeebbabf..d69e4ff86 100644 --- a/resources/strings/src/commonMain/kotlin/dev/sasikanth/rss/reader/resources/strings/DeTwineStrings.kt +++ b/resources/strings/src/commonMain/kotlin/dev/sasikanth/rss/reader/resources/strings/DeTwineStrings.kt @@ -90,8 +90,8 @@ val DeTwineStrings = aboutSocialThreads = "Threads", aboutSocialGitHub = "GitHub", aboutSocialWebsite = "Webseite", - feedsSearchHint = "Suche nach Feeds", - allFeeds = "Alle Feeds", + feedsSearchHint = "Filter", + allFeeds = "Feeds", pinnedFeeds = "Angepinnt", openWebsite = "Website öffnen", markAllAsRead = "Alle als gelesen markieren", @@ -130,5 +130,10 @@ val DeTwineStrings = 1L -> "$numberOfUnreadPosts ungelesener Beitrag" else -> "$numberOfUnreadPosts ungelesene Beiträge" } - } + }, + feedsSortLatest = "Zuletzt hinzugefügt", + feedsSortOldest = "Zuerst hinzugefügt", + feedsSortAlphabetical = "A-Z", + feedsBottomBarNewGroup = "Neue Gruppe", + feedsBottomBarNewFeed = "Neuer Feed" ) diff --git a/resources/strings/src/commonMain/kotlin/dev/sasikanth/rss/reader/resources/strings/EnTwineStrings.kt b/resources/strings/src/commonMain/kotlin/dev/sasikanth/rss/reader/resources/strings/EnTwineStrings.kt index 2de05bede..d0bf5b42d 100644 --- a/resources/strings/src/commonMain/kotlin/dev/sasikanth/rss/reader/resources/strings/EnTwineStrings.kt +++ b/resources/strings/src/commonMain/kotlin/dev/sasikanth/rss/reader/resources/strings/EnTwineStrings.kt @@ -98,8 +98,8 @@ val EnTwineStrings = aboutSocialThreads = "Threads", aboutSocialGitHub = "GitHub", aboutSocialWebsite = "Website", - feedsSearchHint = "Search feeds", - allFeeds = "All Feeds", + feedsSearchHint = "Filter", + allFeeds = "Feeds", pinnedFeeds = "Pinned", openWebsite = "Open Website", markAllAsRead = "Mark All as Read", @@ -137,5 +137,10 @@ val EnTwineStrings = 1L -> "$numberOfUnreadPosts unread article" else -> "$numberOfUnreadPosts unread articles" } - } + }, + feedsSortLatest = "Last added", + feedsSortOldest = "First added", + feedsSortAlphabetical = "A-Z", + feedsBottomBarNewGroup = "New group", + feedsBottomBarNewFeed = "New feed" ) diff --git a/resources/strings/src/commonMain/kotlin/dev/sasikanth/rss/reader/resources/strings/TrTwineStrings.kt b/resources/strings/src/commonMain/kotlin/dev/sasikanth/rss/reader/resources/strings/TrTwineStrings.kt index 5c2026c0e..9ec0e7e31 100644 --- a/resources/strings/src/commonMain/kotlin/dev/sasikanth/rss/reader/resources/strings/TrTwineStrings.kt +++ b/resources/strings/src/commonMain/kotlin/dev/sasikanth/rss/reader/resources/strings/TrTwineStrings.kt @@ -86,8 +86,8 @@ val TrTwineStrings = aboutSocialThreads = "Threads", aboutSocialGitHub = "GitHub", aboutSocialWebsite = "Web sitesi", - feedsSearchHint = "İçerikleri ara", - allFeeds = "Tüm içerikler", + feedsSearchHint = "Filtre", + allFeeds = "Içerikler", pinnedFeeds = "Sabitlenmiş", openWebsite = "Web sitesini aç", markAllAsRead = "Tümünü okundu olarak işaretle", @@ -126,5 +126,10 @@ val TrTwineStrings = 1L -> "$numberOfUnreadPosts okunmamış makale" else -> "$numberOfUnreadPosts okunmamış makaleler" } - } + }, + feedsSortLatest = "En son eklenmiş", + feedsSortOldest = "İlk eklenen", + feedsSortAlphabetical = "A-Z", + feedsBottomBarNewGroup = "Yeni Grup", + feedsBottomBarNewFeed = "Yeni besleme" ) diff --git a/resources/strings/src/commonMain/kotlin/dev/sasikanth/rss/reader/resources/strings/TwineStrings.kt b/resources/strings/src/commonMain/kotlin/dev/sasikanth/rss/reader/resources/strings/TwineStrings.kt index bee81b4a4..5c412c044 100644 --- a/resources/strings/src/commonMain/kotlin/dev/sasikanth/rss/reader/resources/strings/TwineStrings.kt +++ b/resources/strings/src/commonMain/kotlin/dev/sasikanth/rss/reader/resources/strings/TwineStrings.kt @@ -122,7 +122,12 @@ data class TwineStrings( val feedOptionRemove: String, val feedTitleHint: String, val noUnreadPostsInFeed: String, - val numberOfUnreadPostsInFeed: (Long) -> String + val numberOfUnreadPostsInFeed: (Long) -> String, + val feedsSortLatest: String, + val feedsSortOldest: String, + val feedsSortAlphabetical: String, + val feedsBottomBarNewGroup: String, + val feedsBottomBarNewFeed: String, ) object Locales { diff --git a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/components/FeedLabelInput.kt b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/components/FeedLabelInput.kt deleted file mode 100644 index b790a8580..000000000 --- a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/components/FeedLabelInput.kt +++ /dev/null @@ -1,126 +0,0 @@ -/* - * Copyright 2024 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.components - -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.interaction.collectIsFocusedAsState -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.requiredHeight -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.material3.TextField -import androidx.compose.material3.TextFieldDefaults -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalFocusManager -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import dev.sasikanth.rss.reader.resources.strings.LocalStrings -import dev.sasikanth.rss.reader.ui.AppTheme -import kotlinx.coroutines.delay - -@Composable -internal fun FeedLabelInput( - value: String, - onFeedNameChanged: (String) -> Unit, - modifier: Modifier = Modifier, - enabled: Boolean = true, - textAlign: TextAlign = TextAlign.Start -) { - // Maintaining local state so that it updates the text field in the UI - // instantly and doesn't have any weird UI state issues. - // - // I probably can extract this out into the presenter, we would have to - // maintain a list of text field states that are derived from the feeds list - // but this seems like a good alternative. - // - var input by remember(value) { mutableStateOf(value) } - var inputModified by remember(value) { mutableStateOf(false) } - - val focusManager = LocalFocusManager.current - val isInputBlank by derivedStateOf { input.isBlank() } - val interactionSource = remember { MutableInteractionSource() } - val isFocused by interactionSource.collectIsFocusedAsState() - - fun onFeedNameChanged(clearFocus: Boolean = true) { - inputModified = input != value - - if (!isInputBlank && inputModified) { - onFeedNameChanged.invoke(input) - } - - if (clearFocus) { - focusManager.clearFocus() - } - } - - LaunchedEffect(isFocused) { - if (!isFocused && !inputModified) { - input = value - } - } - - LaunchedEffect(input) { - // Same as setting a debounce - delay(500) - onFeedNameChanged(clearFocus = false) - } - - TextField( - modifier = modifier.requiredHeight(56.dp).fillMaxWidth(), - value = input, - onValueChange = { input = it }, - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done, autoCorrect = false), - keyboardActions = KeyboardActions(onDone = { onFeedNameChanged() }), - singleLine = true, - textStyle = MaterialTheme.typography.titleMedium.copy(textAlign = textAlign), - shape = RoundedCornerShape(16.dp), - enabled = enabled, - interactionSource = interactionSource, - colors = - TextFieldDefaults.colors( - focusedContainerColor = AppTheme.colorScheme.tintedSurface, - unfocusedContainerColor = AppTheme.colorScheme.tintedSurface, - disabledContainerColor = AppTheme.colorScheme.tintedBackground, - focusedIndicatorColor = Color.Transparent, - unfocusedIndicatorColor = Color.Transparent, - disabledIndicatorColor = Color.Transparent, - errorIndicatorColor = Color.Transparent, - disabledTextColor = AppTheme.colorScheme.textEmphasisHigh, - focusedTextColor = AppTheme.colorScheme.textEmphasisHigh, - unfocusedTextColor = AppTheme.colorScheme.textEmphasisHigh, - ), - placeholder = { - Text( - text = LocalStrings.current.feedNameHint, - style = MaterialTheme.typography.labelLarge, - color = AppTheme.colorScheme.tintedForeground.copy(alpha = 0.4f) - ) - } - ) -} diff --git a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/feeds/FeedsEvent.kt b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/feeds/FeedsEvent.kt index 42e77831b..cab84a92e 100644 --- a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/feeds/FeedsEvent.kt +++ b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/feeds/FeedsEvent.kt @@ -17,6 +17,7 @@ package dev.sasikanth.rss.reader.feeds import androidx.compose.ui.text.input.TextFieldValue import dev.sasikanth.rss.reader.core.model.local.Feed +import dev.sasikanth.rss.reader.repository.FeedsOrderBy sealed interface FeedsEvent { @@ -37,4 +38,12 @@ sealed interface FeedsEvent { data object ClearSearchQuery : FeedsEvent data class OnFeedInfoClick(val feedLink: String) : FeedsEvent + + data class OnFeedSortOrderChanged(val feedsOrderBy: FeedsOrderBy) : FeedsEvent + + data object OnChangeFeedsViewModeClick : FeedsEvent + + data object TogglePinnedSection : FeedsEvent + + data object OnHomeSelected : FeedsEvent } 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 5a0235b35..140572b24 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 @@ -24,15 +24,14 @@ import androidx.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.core.model.local.Feed -import dev.sasikanth.rss.reader.feeds.ui.FeedsListItemType +import dev.sasikanth.rss.reader.feeds.ui.FeedsViewMode import dev.sasikanth.rss.reader.home.ui.PostsType +import dev.sasikanth.rss.reader.repository.FeedsOrderBy import dev.sasikanth.rss.reader.repository.ObservableSelectedFeed import dev.sasikanth.rss.reader.repository.RssRepository import dev.sasikanth.rss.reader.repository.SettingsRepository @@ -45,7 +44,6 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.FlowPreview 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 @@ -56,12 +54,13 @@ import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChangedBy import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import kotlinx.datetime.Instant import me.tatarka.inject.annotations.Assisted import me.tatarka.inject.annotations.Inject @@ -109,7 +108,7 @@ class FeedsPresenter( } private class PresenterInstance( - dispatchersProvider: DispatchersProvider, + private val dispatchersProvider: DispatchersProvider, private val rssRepository: RssRepository, private val settingsRepository: SettingsRepository, private val observableSelectedFeed: ObservableSelectedFeed @@ -143,9 +142,43 @@ class FeedsPresenter( is FeedsEvent.OnFeedInfoClick -> { // no-op } + FeedsEvent.TogglePinnedSection -> onTogglePinnedSection() + is FeedsEvent.OnFeedSortOrderChanged -> onFeedSortOrderChanged(event.feedsOrderBy) + FeedsEvent.OnChangeFeedsViewModeClick -> onChangeFeedsViewModeClick() + is FeedsEvent.OnHomeSelected -> onHomeSelected() } } + private fun onHomeSelected() { + coroutineScope.launch { observableSelectedFeed.clearSelection() } + } + + private fun onChangeFeedsViewModeClick() { + val newFeedsViewMode = + when (_state.value.feedsViewMode) { + FeedsViewMode.Grid -> FeedsViewMode.List + FeedsViewMode.List -> FeedsViewMode.Grid + } + + coroutineScope.launch { + withContext(dispatchersProvider.io) { + settingsRepository.updateFeedsViewMode(newFeedsViewMode) + } + } + } + + private fun onFeedSortOrderChanged(feedsOrderBy: FeedsOrderBy) { + coroutineScope.launch { + withContext(dispatchersProvider.io) { + settingsRepository.updateFeedsSortOrder(feedsOrderBy) + } + } + } + + private fun onTogglePinnedSection() { + _state.update { it.copy(isPinnedSectionExpanded = !_state.value.isPinnedSectionExpanded) } + } + private fun onSearchQueryChanged(searchQuery: TextFieldValue) { this.searchQuery = searchQuery } @@ -187,41 +220,74 @@ class FeedsPresenter( } private fun init() { - observeNumberOfPinnedFeeds() + observePreferences() observeShowUnreadCountPreference() observeFeedsForCollapsedSheet() observeFeedsForExpandedSheet() - } - - private fun observeNumberOfPinnedFeeds() { - rssRepository.numberOfPinnedFeeds().distinctUntilChanged().onEach { numberOfPinnedFeeds -> - _state.update { it.copy(numberOfPinnedFeeds = numberOfPinnedFeeds) } - } + observeSearchQuery() } @OptIn(FlowPreview::class) - private fun observeFeedsForExpandedSheet() { - val searchQueryFlow = snapshotFlow { searchQuery }.debounce(500.milliseconds) - searchQueryFlow - .distinctUntilChangedBy { it.text } - .combine(settingsRepository.postsType) { searchQuery, postsType -> - searchQuery to postsType + private fun observeSearchQuery() { + val searchQueryFlow = + snapshotFlow { searchQuery }.debounce(500.milliseconds).distinctUntilChangedBy { it.text } + + combine(searchQueryFlow, settingsRepository.postsType) { searchQuery, postsType -> + Pair(searchQuery, postsType) } .onEach { (searchQuery, postsType) -> - val searchQueryText = searchQuery.text val postsAfter = postsAfterInstantFromPostsType(postsType) - val feeds = - if (searchQueryText.length >= MINIMUM_REQUIRED_SEARCH_CHARACTERS) { + val searchResults = + if (searchQuery.text.length >= MINIMUM_REQUIRED_SEARCH_CHARACTERS) { feedsSearchResultsPager( - transformedSearchQuery = searchQueryText, + transformedSearchQuery = searchQuery.text, postsAfter = postsAfter ) } else { - feedsPager(postsAfter = postsAfter) + flowOf(PagingData.empty()) } - val feedsWithHeaders = addFeedsHeaders(feeds).cachedIn(coroutineScope) - _state.update { it.copy(feedsInExpandedMode = feedsWithHeaders) } + _state.update { it.copy(feedsSearchResults = searchResults) } + } + .launchIn(coroutineScope) + } + + private fun observePreferences() { + settingsRepository.feedsViewMode + .onEach { feedsViewMode -> _state.update { it.copy(feedsViewMode = feedsViewMode) } } + .launchIn(coroutineScope) + + settingsRepository.feedsSortOrder + .onEach { feedsSortOrder -> _state.update { it.copy(feedsSortOrder = feedsSortOrder) } } + .launchIn(coroutineScope) + } + + private fun observeFeedsForExpandedSheet() { + settingsRepository.postsType + .onEach { postsType -> + val postsAfter = postsAfterInstantFromPostsType(postsType) + val pinnedFeeds = pinnedFeedsPager(postsAfter = postsAfter).cachedIn(coroutineScope) + + _state.update { it.copy(pinnedFeeds = pinnedFeeds) } + } + .launchIn(coroutineScope) + + combine(settingsRepository.postsType, settingsRepository.feedsSortOrder) { + postsType, + feedsSortOrder -> + Pair(postsType, feedsSortOrder) + } + .onEach { (postsType, feedsSortOrder) -> + val postsAfter = postsAfterInstantFromPostsType(postsType) + + val feeds = + feedsPager( + postsAfter = postsAfter, + feedsSortOrder = feedsSortOrder, + ) + .cachedIn(coroutineScope) + + _state.update { it.copy(feedsInExpandedView = feeds) } } .launchIn(coroutineScope) } @@ -231,26 +297,32 @@ class FeedsPresenter( settingsRepository.postsType.distinctUntilChanged().flatMapLatest { postsType -> val postsAfter = postsAfterInstantFromPostsType(postsType) - feedsPager(postsAfter).cachedIn(coroutineScope) + feedsPager(postsAfter, FeedsOrderBy.Latest).cachedIn(coroutineScope) } observableSelectedFeed.selectedFeed .distinctUntilChanged() .onEach { selectedFeed -> - _state.update { it.copy(feeds = feeds, selectedFeed = selectedFeed) } + _state.update { it.copy(feedsInBottomBar = feeds, selectedFeed = selectedFeed) } } .launchIn(coroutineScope) } + private fun pinnedFeedsPager(postsAfter: Instant) = + createPager(config = createPagingConfig(pageSize = 20)) { + rssRepository.pinnedFeeds(postsAfter = postsAfter) + } + .flow + private fun feedsSearchResultsPager(transformedSearchQuery: String, postsAfter: Instant) = createPager(config = createPagingConfig(pageSize = 20)) { rssRepository.searchFeed(searchQuery = transformedSearchQuery, postsAfter = postsAfter) } .flow - private fun feedsPager(postsAfter: Instant) = + private fun feedsPager(postsAfter: Instant, feedsSortOrder: FeedsOrderBy) = createPager(config = createPagingConfig(pageSize = 20)) { - rssRepository.allFeeds(postsAfter = postsAfter) + rssRepository.allFeeds(postsAfter = postsAfter, orderBy = feedsSortOrder) } .flow @@ -272,30 +344,6 @@ class FeedsPresenter( } } - private fun addFeedsHeaders( - feeds: Flow> - ): Flow> { - return feeds.mapLatest { - it - .map { feed -> FeedsListItemType.FeedListItem(feed = feed) } - .insertSeparators { before, after -> - when { - before?.feed?.pinnedAt == null && after?.feed?.pinnedAt != null -> { - FeedsListItemType.PinnedFeedsHeader - } - (before?.feed?.pinnedAt != null || before == null) && - after != null && - after.feed.pinnedAt == null -> { - FeedsListItemType.AllFeedsHeader - } - else -> { - null - } - } - } - } - } - override fun onDestroy() { coroutineScope.cancel() } 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 a3299a192..4886983f8 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 @@ -18,31 +18,37 @@ package dev.sasikanth.rss.reader.feeds import androidx.compose.runtime.Immutable import androidx.paging.PagingData import dev.sasikanth.rss.reader.core.model.local.Feed -import dev.sasikanth.rss.reader.feeds.ui.FeedsListItemType +import dev.sasikanth.rss.reader.feeds.ui.FeedsViewMode +import dev.sasikanth.rss.reader.repository.FeedsOrderBy import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.emptyFlow @Immutable internal data class FeedsState( - val feeds: Flow>, - val feedsInExpandedMode: Flow>, + val feedsInBottomBar: Flow>, + val feedsInExpandedView: Flow>, + val pinnedFeeds: Flow>, + val feedsSearchResults: Flow>, val selectedFeed: Feed?, - val numberOfPinnedFeeds: Long, val canShowUnreadPostsCount: Boolean, + val feedsViewMode: FeedsViewMode, + val feedsSortOrder: FeedsOrderBy, + val isPinnedSectionExpanded: Boolean, ) { - val canPinFeeds: Boolean - get() = numberOfPinnedFeeds < 10L - companion object { val DEFAULT = FeedsState( - feeds = emptyFlow(), - feedsInExpandedMode = emptyFlow(), + feedsInBottomBar = emptyFlow(), + feedsInExpandedView = emptyFlow(), + pinnedFeeds = emptyFlow(), + feedsSearchResults = emptyFlow(), selectedFeed = null, - numberOfPinnedFeeds = 0, canShowUnreadPostsCount = false, + feedsViewMode = FeedsViewMode.List, + feedsSortOrder = FeedsOrderBy.Latest, + isPinnedSectionExpanded = true, ) } } diff --git a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/feeds/ui/BottomBar.kt b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/feeds/ui/BottomBar.kt new file mode 100644 index 000000000..6dc1d21d7 --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/feeds/ui/BottomBar.kt @@ -0,0 +1,138 @@ +/* + * Copyright 2024 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 androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredHeight +import androidx.compose.foundation.layout.requiredWidth +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.VerticalDivider +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.unit.dp +import dev.sasikanth.rss.reader.resources.icons.NewGroup +import dev.sasikanth.rss.reader.resources.icons.RSS +import dev.sasikanth.rss.reader.resources.icons.TwineIcons +import dev.sasikanth.rss.reader.resources.strings.LocalStrings +import dev.sasikanth.rss.reader.ui.AppTheme + +@Composable +fun BottomBar( + modifier: Modifier = Modifier, + onNewGroupClick: () -> Unit, + onNewFeedClick: () -> Unit, +) { + Box { + Box( + Modifier.fillMaxWidth() + .requiredHeight(184.dp) + .windowInsetsPadding(WindowInsets.navigationBars) + .background( + Brush.verticalGradient(listOf(Color.Transparent, AppTheme.colorScheme.tintedBackground)) + ) + ) + + Box( + modifier = + modifier + .fillMaxWidth() + .background( + color = AppTheme.colorScheme.tintedSurface, + shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp) + ) + .pointerInput(Unit) { + // Consume bottom bar taps + } + .windowInsetsPadding(WindowInsets.navigationBars) + .padding(horizontal = 8.dp, vertical = 8.dp) + .align(Alignment.BottomCenter) + ) { + Row { + BottomBarItem( + icon = TwineIcons.NewGroup, + label = LocalStrings.current.feedsBottomBarNewGroup, + modifier = Modifier.weight(1f), + onClick = onNewGroupClick + ) + + Spacer(Modifier.requiredWidth(8.dp)) + + VerticalDivider( + modifier = Modifier.requiredHeight(32.dp).align(Alignment.CenterVertically), + color = AppTheme.colorScheme.tintedHighlight, + thickness = 2.dp + ) + + Spacer(Modifier.requiredWidth(8.dp)) + + BottomBarItem( + icon = TwineIcons.RSS, + label = LocalStrings.current.feedsBottomBarNewFeed, + modifier = Modifier.weight(1f), + onClick = onNewFeedClick + ) + } + } + } +} + +@Composable +private fun BottomBarItem( + icon: ImageVector, + label: String, + modifier: Modifier = Modifier, + onClick: () -> Unit, +) { + Box( + Modifier.clip(MaterialTheme.shapes.large).clickable { onClick() }.padding(12.dp).then(modifier) + ) { + Column(modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) { + Icon( + imageVector = icon, + contentDescription = null, + tint = AppTheme.colorScheme.textEmphasisHigh + ) + + Spacer(Modifier.requiredHeight(4.dp)) + + Text( + text = label, + style = MaterialTheme.typography.labelLarge, + color = AppTheme.colorScheme.textEmphasisHigh + ) + } + } +} diff --git a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/feeds/ui/BottomSheetItem.kt b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/feeds/ui/BottomSheetItem.kt index d76e89b0e..1a558b423 100644 --- a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/feeds/ui/BottomSheetItem.kt +++ b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/feeds/ui/BottomSheetItem.kt @@ -47,7 +47,7 @@ import dev.sasikanth.rss.reader.ui.bottomSheetItemLabel private const val BADGE_COUNT_TRIM_LIMIT = 99 @Composable -internal fun BottomSheetItem( +internal fun FeedBottomBarItem( text: String, badgeCount: Long, iconUrl: String, 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 8bd83f53a..2bde9d3e3 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 @@ -15,9 +15,9 @@ */ package dev.sasikanth.rss.reader.feeds.ui -import androidx.compose.animation.AnimatedContent +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background -import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -27,195 +27,85 @@ import androidx.compose.foundation.layout.requiredSize import androidx.compose.foundation.layout.requiredWidth import androidx.compose.foundation.layout.sizeIn import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.MoreVert import androidx.compose.material3.Badge -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import dev.sasikanth.rss.reader.components.ConfirmFeedDeleteDialog -import dev.sasikanth.rss.reader.components.FeedLabelInput import dev.sasikanth.rss.reader.components.image.AsyncImage import dev.sasikanth.rss.reader.core.model.local.Feed -import dev.sasikanth.rss.reader.feeds.ui.FeedsSheetMode.Edit -import dev.sasikanth.rss.reader.resources.icons.Delete -import dev.sasikanth.rss.reader.resources.icons.Pin -import dev.sasikanth.rss.reader.resources.icons.PinFilled -import dev.sasikanth.rss.reader.resources.icons.TwineIcons -import dev.sasikanth.rss.reader.resources.strings.LocalStrings import dev.sasikanth.rss.reader.ui.AppTheme +@OptIn(ExperimentalFoundationApi::class) @Composable internal fun FeedListItem( - modifier: Modifier = Modifier, feed: Feed, - selected: Boolean, - canPinFeeds: Boolean, canShowUnreadPostsCount: Boolean, - feedsSheetMode: FeedsSheetMode, onFeedInfoClick: (Feed) -> Unit, onFeedSelected: (Feed) -> Unit, - onFeedNameChanged: (newFeedName: String, feedLink: String) -> Unit, - onFeedPinClick: (Feed) -> Unit, - onDeleteFeed: (Feed) -> Unit + modifier: Modifier = Modifier, ) { - val clickableModifier = - if (feedsSheetMode != Edit) { - modifier.clickable { onFeedSelected(feed) } - } else { - modifier - } - - Box(modifier = clickableModifier.fillMaxWidth().padding(start = 20.dp, end = 12.dp)) { - Row( - modifier = Modifier.padding(top = 16.dp, bottom = 16.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Box { - Box(contentAlignment = Alignment.Center) { - SelectionIndicator(selected = selected && feedsSheetMode != Edit, animationProgress = 1f) - - Box( - modifier = - Modifier.requiredSize(56.dp).background(Color.White, RoundedCornerShape(16.dp)), - contentAlignment = Alignment.Center - ) { - AsyncImage( - url = feed.icon, - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = - Modifier.requiredSize(48.dp) - .clip(RoundedCornerShape(12.dp)) - .align(Alignment.Center), - ) + Box( + modifier = + Modifier.fillMaxWidth() + .then(modifier) + .clip(RoundedCornerShape(16.dp)) + .background(AppTheme.colorScheme.tintedSurface) + .combinedClickable( + onClick = { onFeedSelected(feed) }, + onLongClick = { + // TODO: Add support for feeds multi selection + onFeedInfoClick(feed) } - } - - val numberOfUnreadPosts = feed.numberOfUnreadPosts - if (numberOfUnreadPosts > 0 && canShowUnreadPostsCount) { - Badge( - containerColor = AppTheme.colorScheme.tintedForeground, - contentColor = AppTheme.colorScheme.tintedBackground, - modifier = Modifier.sizeIn(minWidth = 24.dp, minHeight = 16.dp).align(Alignment.TopEnd) - ) { - Text( - text = feed.numberOfUnreadPosts.toString(), - style = MaterialTheme.typography.labelSmall, - modifier = - Modifier.align(Alignment.CenterVertically).graphicsLayer { - translationY = -2.toDp().toPx() - } - ) - } - } + ) + ) { + Row(modifier = Modifier.padding(all = 8.dp), verticalAlignment = Alignment.CenterVertically) { + Box( + modifier = Modifier.requiredSize(36.dp).background(Color.White, RoundedCornerShape(8.dp)), + contentAlignment = Alignment.Center + ) { + AsyncImage( + url = feed.icon, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = + Modifier.requiredSize(28.dp).clip(RoundedCornerShape(4.dp)).align(Alignment.Center), + ) } - Spacer(Modifier.requiredWidth(16.dp)) + Spacer(Modifier.requiredWidth(12.dp)) - FeedLabelInput( + Text( modifier = Modifier.weight(1f), - value = feed.name, - onFeedNameChanged = { newFeedName -> onFeedNameChanged(newFeedName, feed.link) }, - enabled = feedsSheetMode == Edit + text = feed.name, + style = MaterialTheme.typography.bodyMedium, + color = AppTheme.colorScheme.textEmphasisHigh, + maxLines = 1, + overflow = TextOverflow.Clip ) - Spacer(Modifier.requiredWidth(16.dp)) - - AnimatedContent(feedsSheetMode == Edit) { - ActionButtons( - feed = feed, - isInEditMode = it, - canPinFeed = canPinFeeds, - onFeedInfoClick = onFeedInfoClick, - onFeedPinClick = onFeedPinClick, - onDeleteFeed = onDeleteFeed - ) - } - } - } -} - -@Composable -private fun ActionButtons( - feed: Feed, - isInEditMode: Boolean, - canPinFeed: Boolean, - onFeedInfoClick: (Feed) -> Unit, - onFeedPinClick: (Feed) -> Unit, - onDeleteFeed: (Feed) -> Unit, -) { - Row { - if (isInEditMode) { - PinFeedIconButton(feed = feed, canPinFeed = canPinFeed, onFeedPinClick = onFeedPinClick) - - Box { - var showConfirmDialog by remember { mutableStateOf(false) } + Spacer(Modifier.requiredWidth(12.dp)) - IconButton(onClick = { showConfirmDialog = true }) { - Icon( - imageVector = TwineIcons.Delete, - contentDescription = LocalStrings.current.removeFeed, - tint = AppTheme.colorScheme.tintedForeground - ) - } - - if (showConfirmDialog) { - ConfirmFeedDeleteDialog( - feedName = feed.name, - onRemoveFeed = { onDeleteFeed(feed) }, - dismiss = { showConfirmDialog = false } + val numberOfUnreadPosts = feed.numberOfUnreadPosts + if (canShowUnreadPostsCount && numberOfUnreadPosts > 0) { + Badge( + containerColor = AppTheme.colorScheme.tintedForeground, + contentColor = AppTheme.colorScheme.tintedBackground, + modifier = Modifier.sizeIn(minWidth = 24.dp, minHeight = 16.dp) + ) { + Text( + text = feed.numberOfUnreadPosts.toString(), + style = MaterialTheme.typography.labelSmall, + modifier = Modifier.align(Alignment.CenterVertically) ) } } - } else { - IconButton(onClick = { onFeedInfoClick(feed) }) { - Icon( - imageVector = Icons.Rounded.MoreVert, - contentDescription = null, - tint = AppTheme.colorScheme.tintedForeground - ) - } } } } - -@Composable -private fun PinFeedIconButton( - feed: Feed, - canPinFeed: Boolean, - onFeedPinClick: (Feed) -> Unit, -) { - val pinnedIconColor = - if (canPinFeed) { - AppTheme.colorScheme.tintedForeground - } else { - AppTheme.colorScheme.tintedForeground.copy(alpha = 0.4f) - } - - IconButton(onClick = { onFeedPinClick(feed) }, enabled = canPinFeed) { - Icon( - imageVector = - if (feed.pinnedAt != null) { - TwineIcons.PinFilled - } else { - TwineIcons.Pin - }, - contentDescription = null, - tint = pinnedIconColor - ) - } -} 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 47799a7a8..30964b6c5 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 @@ -15,20 +15,14 @@ */ package dev.sasikanth.rss.reader.feeds.ui -import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.Transition -import androidx.compose.animation.slideInVertically -import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.gestures.detectTapGestures -import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsetsSides @@ -41,60 +35,67 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.ime import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.requiredHeight -import androidx.compose.foundation.layout.requiredSize +import androidx.compose.foundation.layout.requiredWidth import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.windowInsetsPadding -import androidx.compose.foundation.layout.wrapContentHeight -import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.GridItemSpan +import androidx.compose.foundation.lazy.grid.LazyGridScope +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.Edit +import androidx.compose.material.icons.filled.ExpandLess +import androidx.compose.material.icons.filled.ExpandMore +import androidx.compose.material.icons.filled.GridView +import androidx.compose.material.icons.outlined.ViewAgenda import androidx.compose.material.icons.rounded.Close -import androidx.compose.material.icons.rounded.KeyboardArrowDown +import androidx.compose.material.icons.rounded.Search import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TextButton -import androidx.compose.material3.TextField -import androidx.compose.material3.TextFieldDefaults import androidx.compose.material3.darkColorScheme 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.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithCache import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.dp +import androidx.paging.LoadState import app.cash.paging.compose.LazyPagingItems import app.cash.paging.compose.collectAsLazyPagingItems -import dev.sasikanth.rss.reader.components.SubHeader +import app.cash.paging.compose.itemContentType +import app.cash.paging.compose.itemKey +import dev.sasikanth.rss.reader.components.DropdownMenu +import dev.sasikanth.rss.reader.components.DropdownMenuItem import dev.sasikanth.rss.reader.core.model.local.Feed import dev.sasikanth.rss.reader.feeds.FeedsEffect import dev.sasikanth.rss.reader.feeds.FeedsEvent import dev.sasikanth.rss.reader.feeds.FeedsPresenter -import dev.sasikanth.rss.reader.feeds.ui.FeedsSheetMode.Default -import dev.sasikanth.rss.reader.feeds.ui.FeedsSheetMode.Edit -import dev.sasikanth.rss.reader.feeds.ui.FeedsSheetMode.LinkEntry -import dev.sasikanth.rss.reader.resources.icons.ArrowBack -import dev.sasikanth.rss.reader.resources.icons.TwineIcons +import dev.sasikanth.rss.reader.repository.FeedsOrderBy import dev.sasikanth.rss.reader.resources.strings.LocalStrings import dev.sasikanth.rss.reader.ui.AppTheme +import dev.sasikanth.rss.reader.utils.Constants.MINIMUM_REQUIRED_SEARCH_CHARACTERS import dev.sasikanth.rss.reader.utils.KeyboardState import dev.sasikanth.rss.reader.utils.inverse import dev.sasikanth.rss.reader.utils.keyboardVisibilityAsState @@ -103,10 +104,7 @@ import dev.sasikanth.rss.reader.utils.keyboardVisibilityAsState internal fun FeedsBottomSheet( feedsPresenter: FeedsPresenter, bottomSheetSwipeTransition: Transition, - feedsSheetMode: FeedsSheetMode, closeSheet: () -> Unit, - editFeeds: () -> Unit, - exitFeedsEdit: () -> Unit, selectedFeedChanged: () -> Unit ) { val state by feedsPresenter.state.collectAsState() @@ -133,33 +131,31 @@ internal fun FeedsBottomSheet( if (hasBottomSheetExpandedThreshold) { BottomSheetCollapsedContent( modifier = Modifier.graphicsLayer { alpha = bottomSheetExpandingProgress }, - feeds = state.feeds.collectAsLazyPagingItems(), + feeds = state.feedsInBottomBar.collectAsLazyPagingItems(), selectedFeed = selectedFeed, canShowUnreadPostsCount = state.canShowUnreadPostsCount, - onFeedSelected = { feed -> feedsPresenter.dispatch(FeedsEvent.OnFeedSelected(feed)) } + onFeedSelected = { feed -> feedsPresenter.dispatch(FeedsEvent.OnFeedSelected(feed)) }, + onHomeSelected = { feedsPresenter.dispatch(FeedsEvent.OnHomeSelected) } ) } else { BottomSheetExpandedContent( - feedsListItemTypes = state.feedsInExpandedMode.collectAsLazyPagingItems(), - selectedFeed = state.selectedFeed, - feedsSheetMode = feedsSheetMode, - canPinFeeds = state.canPinFeeds, - canShowUnreadPostsCount = state.canShowUnreadPostsCount, + feeds = state.feedsInExpandedView.collectAsLazyPagingItems(), + pinnedFeeds = state.pinnedFeeds.collectAsLazyPagingItems(), + searchResults = state.feedsSearchResults.collectAsLazyPagingItems(), searchQuery = feedsPresenter.searchQuery, + feedsSortOrder = state.feedsSortOrder, + feedsViewMode = state.feedsViewMode, + isPinnedSectionExpanded = state.isPinnedSectionExpanded, + canShowUnreadPostsCount = state.canShowUnreadPostsCount, onSearchQueryChanged = { feedsPresenter.dispatch(FeedsEvent.SearchQueryChanged(it)) }, onClearSearchQuery = { feedsPresenter.dispatch(FeedsEvent.ClearSearchQuery) }, - closeSheet = { feedsPresenter.dispatch(FeedsEvent.OnGoBackClicked) }, onFeedInfoClick = { feedsPresenter.dispatch(FeedsEvent.OnFeedInfoClick(it.link)) }, onFeedSelected = { feedsPresenter.dispatch(FeedsEvent.OnFeedSelected(it)) }, - onFeedNameChanged = { newFeedName, feedLink -> - feedsPresenter.dispatch( - FeedsEvent.OnFeedNameUpdated(newFeedName = newFeedName, feedLink = feedLink) - ) + onTogglePinnedSection = { feedsPresenter.dispatch(FeedsEvent.TogglePinnedSection) }, + onFeedsSortChanged = { feedsPresenter.dispatch(FeedsEvent.OnFeedSortOrderChanged(it)) }, + onChangeFeedsViewModeClick = { + feedsPresenter.dispatch(FeedsEvent.OnChangeFeedsViewModeClick) }, - editFeeds = editFeeds, - exitFeedsEdit = exitFeedsEdit, - onFeedPinClick = { feed -> feedsPresenter.dispatch(FeedsEvent.OnFeedPinClicked(feed)) }, - onDeleteFeed = { feed -> feedsPresenter.dispatch(FeedsEvent.OnDeleteFeed(feed)) }, modifier = Modifier.graphicsLayer { val threshold = 0.3 @@ -179,24 +175,22 @@ internal fun FeedsBottomSheet( } @Composable -@OptIn(ExperimentalFoundationApi::class) private fun BottomSheetExpandedContent( - feedsListItemTypes: LazyPagingItems, - selectedFeed: Feed?, - feedsSheetMode: FeedsSheetMode, - canPinFeeds: Boolean, - canShowUnreadPostsCount: Boolean, + feeds: LazyPagingItems, + pinnedFeeds: LazyPagingItems, + searchResults: LazyPagingItems, searchQuery: TextFieldValue, + feedsSortOrder: FeedsOrderBy, + feedsViewMode: FeedsViewMode, + isPinnedSectionExpanded: Boolean, + canShowUnreadPostsCount: Boolean, onSearchQueryChanged: (TextFieldValue) -> Unit, onClearSearchQuery: () -> Unit, - closeSheet: () -> Unit, onFeedInfoClick: (Feed) -> Unit, onFeedSelected: (Feed) -> Unit, - onFeedNameChanged: (newFeedName: String, feedLink: String) -> Unit, - editFeeds: () -> Unit, - exitFeedsEdit: () -> Unit, - onFeedPinClick: (Feed) -> Unit, - onDeleteFeed: (Feed) -> Unit, + onTogglePinnedSection: () -> Unit, + onFeedsSortChanged: (FeedsOrderBy) -> Unit, + onChangeFeedsViewModeClick: () -> Unit, modifier: Modifier = Modifier ) { Scaffold( @@ -204,117 +198,138 @@ private fun BottomSheetExpandedContent( topBar = { SearchBar( query = searchQuery, - feedsSheetMode = feedsSheetMode, + feedsViewMode = feedsViewMode, onQueryChange = { onSearchQueryChanged(it) }, - onNavigationIconClick = { - when (feedsSheetMode) { - Default, - LinkEntry -> closeSheet() - Edit -> exitFeedsEdit() - } - }, - onClearClick = onClearSearchQuery + onClearClick = onClearSearchQuery, + onChangeFeedsViewModeClick = onChangeFeedsViewModeClick ) }, bottomBar = { - FeedsSheetBottomBar( - feedsSheetMode = feedsSheetMode, - editFeeds = editFeeds, + BottomBar( + onNewGroupClick = { + // TODO: Open group creation dialog/sheet/screen + }, + onNewFeedClick = { + // TODO: Open feed creation dialog/sheet/screen + } ) }, containerColor = AppTheme.colorScheme.tintedBackground ) { padding -> val layoutDirection = LocalLayoutDirection.current - val focusManager = LocalFocusManager.current val imeBottomPadding = WindowInsets.ime.asPaddingValues().calculateBottomPadding() - val keyboardState by keyboardVisibilityAsState() - Box { - LazyColumn( - modifier = - Modifier.fillMaxSize() - .padding( - bottom = if (imeBottomPadding > 0.dp) imeBottomPadding + 16.dp else 0.dp, - // doing this so that the dividers in sticky headers can go below the search bar and - // not overlap with each other - top = padding.calculateTopPadding() - 1.dp - ), - contentPadding = - PaddingValues( - start = padding.calculateStartPadding(layoutDirection), - end = padding.calculateEndPadding(layoutDirection), - bottom = padding.calculateBottomPadding() + 64.dp - ) + val gridItemSpan = + when (feedsViewMode) { + FeedsViewMode.Grid -> GridItemSpan(1) + FeedsViewMode.List -> GridItemSpan(2) + } + + LazyVerticalGrid( + modifier = + Modifier.fillMaxSize() + .padding( + bottom = if (imeBottomPadding > 0.dp) imeBottomPadding + 16.dp else 0.dp, + // doing this so that the dividers in sticky headers can go below the search bar and + // not overlap with each other + top = padding.calculateTopPadding() - 1.dp + ), + columns = GridCells.Fixed(2), + contentPadding = + PaddingValues( + start = padding.calculateStartPadding(layoutDirection), + end = padding.calculateEndPadding(layoutDirection), + bottom = padding.calculateBottomPadding() + 64.dp, + top = 8.dp + ), + ) { + if ( + searchResults.itemCount == 0 && searchQuery.text.length < MINIMUM_REQUIRED_SEARCH_CHARACTERS ) { - for (index in 0 until feedsListItemTypes.itemCount) { - when (val feedListItemType = feedsListItemTypes[index]) { - is FeedsListItemType.FeedListItem -> { - item { - val feed = feedListItemType.feed - FeedListItem( - feed = feed, - selected = selectedFeed?.link == feed.link, - canPinFeeds = (feed.pinnedAt != null || canPinFeeds), - canShowUnreadPostsCount = canShowUnreadPostsCount, - feedsSheetMode = feedsSheetMode, - onFeedInfoClick = onFeedInfoClick, - onFeedSelected = onFeedSelected, - onFeedNameChanged = onFeedNameChanged, - onFeedPinClick = onFeedPinClick, - onDeleteFeed = onDeleteFeed - ) - } - } - FeedsListItemType.AllFeedsHeader -> { - stickyHeader(contentType = FeedsListItemType.AllFeedsHeader) { - Box(modifier = Modifier.wrapContentHeight()) { - SubHeader( - text = LocalStrings.current.allFeeds, - modifier = - Modifier.fillMaxWidth().background(AppTheme.colorScheme.tintedBackground) - ) + pinnedFeeds( + pinnedFeeds = pinnedFeeds, + isPinnedSectionExpanded = isPinnedSectionExpanded, + canShowUnreadPostsCount = canShowUnreadPostsCount, + gridItemSpan = gridItemSpan, + onTogglePinnedSection = onTogglePinnedSection, + onFeedInfoClick = onFeedInfoClick, + onFeedSelected = onFeedSelected + ) - HorizontalDivider(color = AppTheme.colorScheme.tintedSurface) + allFeeds( + feeds = feeds, + feedsSortOrder = feedsSortOrder, + canShowUnreadPostsCount = canShowUnreadPostsCount, + gridItemSpan = gridItemSpan, + onFeedsSortChanged = onFeedsSortChanged, + onFeedInfoClick = onFeedInfoClick, + onFeedSelected = onFeedSelected + ) + } else { + feedSearchResults( + searchResults = searchResults, + canShowUnreadPostsCount = canShowUnreadPostsCount, + gridItemSpan = gridItemSpan, + onFeedInfoClick = onFeedInfoClick, + onFeedSelected = onFeedSelected + ) + } + } + } +} - HorizontalDivider( - modifier = - Modifier.align(Alignment.BottomStart).graphicsLayer { translationY - 1f }, - color = AppTheme.colorScheme.tintedSurface - ) - } - } - } - FeedsListItemType.PinnedFeedsHeader -> { - stickyHeader(contentType = FeedsListItemType.PinnedFeedsHeader) { - Box(modifier = Modifier.wrapContentHeight()) { - SubHeader( - text = LocalStrings.current.pinnedFeeds, - modifier = - Modifier.fillMaxWidth().background(AppTheme.colorScheme.tintedBackground) - ) +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun BottomSheetCollapsedContent( + feeds: LazyPagingItems, + selectedFeed: Feed?, + canShowUnreadPostsCount: Boolean, + onFeedSelected: (Feed) -> Unit, + onHomeSelected: () -> Unit, + modifier: Modifier = Modifier +) { + LazyRow( + modifier = modifier.fillMaxWidth().padding(start = 20.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + contentPadding = PaddingValues(end = 24.dp) + ) { + stickyHeader { + val shadowColors = + arrayOf( + 0.85f to AppTheme.colorScheme.tintedBackground, + 0.9f to AppTheme.colorScheme.tintedBackground.copy(alpha = 0.4f), + 1f to Color.Transparent + ) - HorizontalDivider( - modifier = - Modifier.align(Alignment.BottomStart).graphicsLayer { translationY - 1f }, - color = AppTheme.colorScheme.tintedSurface + HomeBottomBarItem( + selected = selectedFeed == null, + onClick = onHomeSelected, + modifier = + Modifier.drawWithCache { + onDrawBehind { + val brush = + Brush.horizontalGradient( + colorStops = shadowColors, ) - } + drawRect( + brush = brush, + ) } } - null -> { - // no-op - } - } - } - } + .padding(end = 4.dp) + ) + } - if (keyboardState == KeyboardState.Opened && feedsSheetMode == LinkEntry) { - // Scrim when keyboard is open - Box( - Modifier.fillMaxSize() - .padding(padding) - .background(AppTheme.colorScheme.tintedBackground.copy(alpha = 0.8f)) - .pointerInput(Unit) { detectTapGestures { focusManager.clearFocus() } } + items(feeds.itemCount) { index -> + val feed = feeds[index] + if (feed != null) { + FeedBottomBarItem( + text = feed.name.uppercase(), + badgeCount = feed.numberOfUnreadPosts, + iconUrl = feed.icon, + canShowUnreadPostsCount = canShowUnreadPostsCount, + selected = selectedFeed?.link == feed.link, + onClick = { onFeedSelected(feed) } ) } } @@ -322,220 +337,421 @@ private fun BottomSheetExpandedContent( } @Composable -private fun FeedsSheetBottomBar( - feedsSheetMode: FeedsSheetMode, - modifier: Modifier = Modifier, - editFeeds: () -> Unit +private fun SearchBar( + query: TextFieldValue, + feedsViewMode: FeedsViewMode, + onQueryChange: (TextFieldValue) -> Unit, + onClearClick: () -> Unit, + onChangeFeedsViewModeClick: () -> Unit, ) { - val imeModifier = - if (feedsSheetMode == LinkEntry) { - Modifier.windowInsetsPadding(WindowInsets.ime) - } else { - Modifier + val keyboardState by keyboardVisibilityAsState() + val focusManager = LocalFocusManager.current + + LaunchedEffect(keyboardState) { + if (keyboardState == KeyboardState.Closed) { + focusManager.clearFocus() } + } - AnimatedVisibility( - visible = feedsSheetMode != Edit, - enter = slideInVertically { it }, - exit = slideOutVertically { it } - ) { - Box( - imeModifier - .background(AppTheme.colorScheme.tintedBackground) - .windowInsetsPadding(WindowInsets.systemBars.only(WindowInsetsSides.Bottom)) - .clickable(indication = null, interactionSource = remember { MutableInteractionSource() }) { - // Only to prevent clicks from passing through. Not sure what's happening - } - .then(modifier) - ) { - HorizontalDivider( - Modifier.align(Alignment.TopStart), - color = AppTheme.colorScheme.tintedSurface - ) - Box(Modifier.fillMaxWidth().padding(top = 24.dp, bottom = 20.dp)) { - // Placeholder view with similar height of primary action button and input field - // from the home screen - Box(Modifier.requiredHeight(56.dp)) - when (feedsSheetMode) { - Default, - Edit -> { - EditFeeds(editFeeds) - } - LinkEntry -> { - // no-op + Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + MaterialTheme(colorScheme = darkColorScheme(primary = AppTheme.colorScheme.tintedForeground)) { + OutlinedTextField( + modifier = + Modifier.weight(1f) + .windowInsetsPadding( + WindowInsets.systemBars.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal) + ) + .padding(vertical = 8.dp) + .padding(start = 24.dp, end = 12.dp), + value = query.copy(selection = TextRange(query.text.length)), + onValueChange = onQueryChange, + placeholder = { + Text( + text = LocalStrings.current.feedsSearchHint, + color = AppTheme.colorScheme.tintedForeground, + style = MaterialTheme.typography.bodyLarge + ) + }, + leadingIcon = { + Icon( + imageVector = Icons.Rounded.Search, + contentDescription = null, + tint = AppTheme.colorScheme.tintedForeground + ) + }, + trailingIcon = { + if (query.text.isNotBlank()) { + ClearSearchQueryButton { onClearClick() } } + }, + shape = RoundedCornerShape(16.dp), + singleLine = true, + textStyle = MaterialTheme.typography.bodyLarge, + colors = + OutlinedTextFieldDefaults.colors( + focusedBorderColor = AppTheme.colorScheme.tintedHighlight, + unfocusedBorderColor = AppTheme.colorScheme.tintedHighlight, + disabledBorderColor = AppTheme.colorScheme.tintedHighlight, + focusedTextColor = AppTheme.colorScheme.textEmphasisHigh, + disabledTextColor = Color.Transparent, + ) + ) + } + + IconButton( + onClick = onChangeFeedsViewModeClick, + ) { + val icon = + when (feedsViewMode) { + FeedsViewMode.Grid -> Icons.Outlined.ViewAgenda + FeedsViewMode.List -> Icons.Filled.GridView } - } + + Icon( + imageVector = icon, + contentDescription = null, + tint = AppTheme.colorScheme.tintedForeground + ) } + + Spacer(Modifier.requiredWidth(20.dp)) } } -@Composable -private fun BoxScope.EditFeeds(onClick: () -> Unit) { - TextButton( - modifier = Modifier.align(Alignment.CenterEnd).padding(end = 24.dp), - onClick = onClick, - contentPadding = PaddingValues(start = 16.dp, top = 16.dp, bottom = 16.dp, end = 24.dp), - shape = MaterialTheme.shapes.large - ) { - Icon( - imageVector = Icons.Outlined.Edit, - contentDescription = LocalStrings.current.editFeeds, - tint = AppTheme.colorScheme.tintedForeground - ) - Spacer(Modifier.width(12.dp)) - Text( - text = LocalStrings.current.editFeeds, - style = MaterialTheme.typography.labelLarge, - color = AppTheme.colorScheme.tintedForeground - ) +private fun LazyGridScope.feedSearchResults( + searchResults: LazyPagingItems, + canShowUnreadPostsCount: Boolean, + gridItemSpan: GridItemSpan, + onFeedInfoClick: (Feed) -> Unit, + onFeedSelected: (Feed) -> Unit +) { + items( + count = searchResults.itemCount, + key = searchResults.itemKey { it.link }, + contentType = searchResults.itemContentType { it.link }, + span = { gridItemSpan } + ) { index -> + val feed = searchResults[index] + val startPadding = + when { + gridItemSpan.currentLineSpan == 2 || + (gridItemSpan.currentLineSpan == 1 && index % 2 == 0) -> 24.dp + else -> 8.dp + } + + val endPadding = + when { + gridItemSpan.currentLineSpan == 2 || + (gridItemSpan.currentLineSpan == 1 && index % 2 == 1) -> 24.dp + else -> 8.dp + } + + val topPadding = + when { + gridItemSpan.currentLineSpan == 2 && index > 0 -> 8.dp + gridItemSpan.currentLineSpan == 1 && index > 1 -> 8.dp + else -> 0.dp + } + + val bottomPadding = + when { + index < searchResults.itemCount -> 8.dp + else -> 0.dp + } + + if (feed != null) { + FeedListItem( + modifier = + Modifier.padding( + start = startPadding, + top = topPadding, + end = endPadding, + bottom = bottomPadding + ), + feed = feed, + canShowUnreadPostsCount = canShowUnreadPostsCount, + onFeedInfoClick = onFeedInfoClick, + onFeedSelected = onFeedSelected, + ) + } } } -@Composable -private fun BottomSheetCollapsedContent( +private fun LazyGridScope.allFeeds( feeds: LazyPagingItems, - selectedFeed: Feed?, + feedsSortOrder: FeedsOrderBy, canShowUnreadPostsCount: Boolean, - onFeedSelected: (Feed) -> Unit, - modifier: Modifier = Modifier + gridItemSpan: GridItemSpan, + onFeedsSortChanged: (FeedsOrderBy) -> Unit, + onFeedInfoClick: (Feed) -> Unit, + onFeedSelected: (Feed) -> Unit ) { - Box { - LazyRow( - modifier = modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp), - contentPadding = PaddingValues(start = 100.dp, end = 24.dp) - ) { - items(feeds.itemCount) { index -> - val feed = feeds[index] + if (feeds.itemCount > 0 && feeds.loadState.refresh != LoadState.Loading) { + item(key = "AllFeedsHeader", span = { GridItemSpan(2) }) { + AllFeedsHeader( + feedsCount = feeds.itemCount, + feedsSortOrder = feedsSortOrder, + onFeedsSortChanged = onFeedsSortChanged + ) + } + + items( + count = feeds.itemCount, + key = feeds.itemKey { it.link }, + contentType = { "FeedListItem" }, + span = { gridItemSpan } + ) { index -> + val feed = feeds[index] + val startPadding = + when { + gridItemSpan.currentLineSpan == 2 || + (gridItemSpan.currentLineSpan == 1 && index % 2 == 0) -> 24.dp + else -> 8.dp + } + + val endPadding = + when { + gridItemSpan.currentLineSpan == 2 || + (gridItemSpan.currentLineSpan == 1 && index % 2 == 1) -> 24.dp + else -> 8.dp + } + + val topPadding = + when { + gridItemSpan.currentLineSpan == 2 && index > 0 -> 8.dp + gridItemSpan.currentLineSpan == 1 && index > 1 -> 8.dp + else -> 0.dp + } + + val bottomPadding = + when { + index < feeds.itemCount -> 8.dp + else -> 0.dp + } + + if (feed != null) { + FeedListItem( + modifier = + Modifier.padding( + start = startPadding, + top = topPadding, + end = endPadding, + bottom = bottomPadding + ), + feed = feed, + canShowUnreadPostsCount = canShowUnreadPostsCount, + onFeedInfoClick = onFeedInfoClick, + onFeedSelected = onFeedSelected, + ) + } + } + } +} + +private fun LazyGridScope.pinnedFeeds( + pinnedFeeds: LazyPagingItems, + isPinnedSectionExpanded: Boolean, + canShowUnreadPostsCount: Boolean, + gridItemSpan: GridItemSpan, + onTogglePinnedSection: () -> Unit, + onFeedInfoClick: (Feed) -> Unit, + onFeedSelected: (Feed) -> Unit +) { + if (pinnedFeeds.itemCount > 0 && pinnedFeeds.loadState.refresh != LoadState.Loading) { + item(key = "PinnedFeedsHeader", span = { GridItemSpan(2) }) { + PinnedFeedsHeader( + isPinnedSectionExpanded = isPinnedSectionExpanded, + onToggleSection = onTogglePinnedSection + ) + } + + if (isPinnedSectionExpanded) { + items( + count = pinnedFeeds.itemCount, + key = pinnedFeeds.itemKey { "PinnedFeed:${it.link}" }, + contentType = { "FeedListItem" }, + span = { gridItemSpan } + ) { index -> + val feed = pinnedFeeds[index] + val startPadding = + when { + gridItemSpan.currentLineSpan == 2 || + (gridItemSpan.currentLineSpan == 1 && index % 2 == 0) -> 24.dp + else -> 8.dp + } + + val endPadding = + when { + gridItemSpan.currentLineSpan == 2 || + (gridItemSpan.currentLineSpan == 1 && index % 2 == 1) -> 24.dp + else -> 8.dp + } + + val topPadding = + when { + gridItemSpan.currentLineSpan == 2 && index > 0 -> 8.dp + gridItemSpan.currentLineSpan == 1 && index > 1 -> 8.dp + else -> 0.dp + } + + val bottomPadding = + when { + index < pinnedFeeds.itemCount -> 8.dp + else -> 0.dp + } + if (feed != null) { - BottomSheetItem( - text = feed.name.uppercase(), - badgeCount = feed.numberOfUnreadPosts, - iconUrl = feed.icon, + FeedListItem( + modifier = + Modifier.padding( + start = startPadding, + top = topPadding, + end = endPadding, + bottom = bottomPadding + ), + feed = feed, canShowUnreadPostsCount = canShowUnreadPostsCount, - selected = selectedFeed?.link == feed.link, - onClick = { onFeedSelected(feed) } + onFeedInfoClick = onFeedInfoClick, + onFeedSelected = onFeedSelected, ) } } } - Box( - modifier = - Modifier.requiredSize(100.dp) - .background( - Brush.horizontalGradient( - colorStops = - arrayOf( - 0.7f to AppTheme.colorScheme.tintedBackground, - 0.8f to AppTheme.colorScheme.tintedBackground.copy(alpha = 0.4f), - 1f to Color.Transparent - ) - ) - ) - ) + item(span = { GridItemSpan(2) }) { + HorizontalDivider( + modifier = Modifier.padding(top = 24.dp), + color = AppTheme.colorScheme.tintedSurface + ) + } } } @Composable -private fun SearchBar( - query: TextFieldValue, - feedsSheetMode: FeedsSheetMode, - onQueryChange: (TextFieldValue) -> Unit, - onNavigationIconClick: () -> Unit, - onClearClick: () -> Unit, +private fun AllFeedsHeader( + feedsCount: Int, + feedsSortOrder: FeedsOrderBy, + onFeedsSortChanged: (FeedsOrderBy) -> Unit, + modifier: Modifier = Modifier ) { - val keyboardState by keyboardVisibilityAsState() - val focusManager = LocalFocusManager.current + Row( + modifier = + Modifier.padding(start = 32.dp, end = 20.dp).padding(vertical = 12.dp).then(modifier), + verticalAlignment = Alignment.CenterVertically + ) { + var showSortDropdown by remember { mutableStateOf(false) } - LaunchedEffect(keyboardState) { - if (keyboardState == KeyboardState.Closed) { - focusManager.clearFocus() - } - } + Text( + text = LocalStrings.current.allFeeds, + style = MaterialTheme.typography.titleMedium, + color = AppTheme.colorScheme.textEmphasisHigh, + ) - Box( - modifier = - Modifier.fillMaxWidth() - .background(AppTheme.colorScheme.tintedBackground) - .windowInsetsPadding( - WindowInsets.systemBars.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal) + Spacer(Modifier.requiredWidth(8.dp)) + + Text( + modifier = Modifier.weight(1f), + text = feedsCount.toString(), + style = MaterialTheme.typography.titleMedium, + color = AppTheme.colorScheme.tintedForeground, + ) + + Spacer(Modifier.requiredWidth(12.dp)) + + Box { + TextButton(onClick = { showSortDropdown = true }, shape = MaterialTheme.shapes.large) { + val orderText = + when (feedsSortOrder) { + FeedsOrderBy.Latest -> LocalStrings.current.feedsSortLatest + FeedsOrderBy.Oldest -> LocalStrings.current.feedsSortOldest + FeedsOrderBy.Alphabetical -> LocalStrings.current.feedsSortAlphabetical + } + + Text( + text = orderText, + style = MaterialTheme.typography.labelLarge, + color = AppTheme.colorScheme.tintedForeground + ) + + Spacer(Modifier.width(8.dp)) + + Icon( + imageVector = Icons.Filled.ExpandMore, + contentDescription = LocalStrings.current.editFeeds, + tint = AppTheme.colorScheme.tintedForeground ) - ) { - val background = - when (feedsSheetMode) { - Default, - Edit -> AppTheme.colorScheme.tintedSurface - LinkEntry -> AppTheme.colorScheme.tintedSurface.copy(alpha = 0.6f) } - Box( - modifier = - Modifier.padding(all = 16.dp) - .background(color = background, shape = RoundedCornerShape(16.dp)) - .padding(horizontal = 4.dp) - ) { - MaterialTheme( - colorScheme = darkColorScheme(primary = AppTheme.colorScheme.tintedForeground) + DropdownMenu( + modifier = Modifier.requiredWidth(132.dp), + expanded = showSortDropdown, + onDismissRequest = { showSortDropdown = false } ) { - TextField( - modifier = Modifier.fillMaxWidth(), - value = query.copy(selection = TextRange(query.text.length)), - onValueChange = onQueryChange, - placeholder = { - val hintColor = - when (feedsSheetMode) { - Default, - Edit -> AppTheme.colorScheme.textEmphasisHigh - LinkEntry -> AppTheme.colorScheme.textEmphasisMed - } - Text( - text = LocalStrings.current.feedsSearchHint, - color = hintColor, - style = MaterialTheme.typography.bodyLarge - ) - }, - leadingIcon = { - val icon = - when (feedsSheetMode) { - Default, - LinkEntry -> Icons.Rounded.KeyboardArrowDown - Edit -> TwineIcons.ArrowBack - } - IconButton(onClick = onNavigationIconClick) { - Icon(icon, contentDescription = null, tint = AppTheme.colorScheme.tintedForeground) + FeedsOrderBy.entries.forEach { sortOrder -> + val label = + when (sortOrder) { + FeedsOrderBy.Latest -> LocalStrings.current.feedsSortLatest + FeedsOrderBy.Oldest -> LocalStrings.current.feedsSortOldest + FeedsOrderBy.Alphabetical -> LocalStrings.current.feedsSortAlphabetical } - }, - trailingIcon = { - if (query.text.isNotBlank()) { - ClearSearchQueryButton { onClearClick() } + + val color = + if (feedsSortOrder == sortOrder) { + AppTheme.colorScheme.tintedSurface + } else { + Color.Unspecified } - }, - shape = RoundedCornerShape(16.dp), - singleLine = true, - textStyle = MaterialTheme.typography.bodyLarge, - enabled = feedsSheetMode != LinkEntry, - colors = - TextFieldDefaults.colors( - focusedContainerColor = Color.Transparent, - unfocusedContainerColor = Color.Transparent, - disabledContainerColor = Color.Transparent, - focusedTextColor = AppTheme.colorScheme.textEmphasisHigh, - disabledTextColor = Color.Transparent, - unfocusedIndicatorColor = Color.Transparent, - focusedIndicatorColor = Color.Transparent, - disabledIndicatorColor = Color.Transparent, - errorIndicatorColor = Color.Transparent - ) - ) + val labelColor = + if (feedsSortOrder == sortOrder) { + AppTheme.colorScheme.onSurface + } else { + AppTheme.colorScheme.textEmphasisHigh + } + + DropdownMenuItem( + modifier = Modifier.background(color), + onClick = { + onFeedsSortChanged(sortOrder) + showSortDropdown = false + }, + text = { Text(label, color = labelColor) } + ) + } } } + } +} - HorizontalDivider( - modifier = Modifier.fillMaxWidth().align(Alignment.BottomStart), - color = AppTheme.colorScheme.tintedSurface +@Composable +private fun PinnedFeedsHeader( + isPinnedSectionExpanded: Boolean, + modifier: Modifier = Modifier, + onToggleSection: () -> Unit +) { + Row( + modifier = + Modifier.padding(start = 32.dp, end = 20.dp).padding(vertical = 12.dp).then(modifier), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + modifier = Modifier.weight(1f), + text = LocalStrings.current.pinnedFeeds, + style = MaterialTheme.typography.titleMedium, + color = AppTheme.colorScheme.textEmphasisHigh, ) + + val icon = + if (isPinnedSectionExpanded) { + Icons.Filled.ExpandLess + } else { + Icons.Filled.ExpandMore + } + + Spacer(Modifier.requiredWidth(12.dp)) + + IconButton(onClick = onToggleSection) { + Icon(imageVector = icon, contentDescription = null, tint = AppTheme.colorScheme.onSurface) + } } } diff --git a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/feeds/ui/FeedsSheetMode.kt b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/feeds/ui/FeedsViewMode.kt similarity index 85% rename from shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/feeds/ui/FeedsSheetMode.kt rename to shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/feeds/ui/FeedsViewMode.kt index ef2d51057..f95b55688 100644 --- a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/feeds/ui/FeedsSheetMode.kt +++ b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/feeds/ui/FeedsViewMode.kt @@ -1,5 +1,5 @@ /* - * Copyright 2023 Sasikanth Miriyampalli + * Copyright 2024 Sasikanth Miriyampalli * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -13,10 +13,10 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + package dev.sasikanth.rss.reader.feeds.ui -enum class FeedsSheetMode { - Default, - LinkEntry, - Edit +enum class FeedsViewMode { + Grid, + List } diff --git a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/feeds/ui/HomeBottomBarItem.kt b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/feeds/ui/HomeBottomBarItem.kt new file mode 100644 index 000000000..507e86979 --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/feeds/ui/HomeBottomBarItem.kt @@ -0,0 +1,79 @@ +/* + * Copyright 2024 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 androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredWidth +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Icon +import androidx.compose.material.Text +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import dev.sasikanth.rss.reader.resources.icons.All +import dev.sasikanth.rss.reader.resources.icons.TwineIcons +import dev.sasikanth.rss.reader.resources.strings.LocalStrings +import dev.sasikanth.rss.reader.ui.AppTheme +import dev.sasikanth.rss.reader.ui.bottomSheetItemLabel + +@Composable +internal fun HomeBottomBarItem( + selected: Boolean = false, + modifier: Modifier = Modifier, + onClick: () -> Unit +) { + Column( + modifier = Modifier.wrapContentHeight().then(modifier), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Box { + SelectionIndicator(selected = selected, animationProgress = 1f) + Box( + modifier = + Modifier.clip(RoundedCornerShape(16.dp)) + .background(color = AppTheme.colorScheme.tintedSurface) + .clickable(onClick = onClick, role = Role.Button) + .padding(start = 16.dp, top = 16.dp, end = 16.dp, bottom = 16.dp) + .align(Alignment.Center), + ) { + Icon( + imageVector = TwineIcons.All, + contentDescription = null, + tint = AppTheme.colorScheme.tintedForeground + ) + } + } + + Text( + text = LocalStrings.current.buttonAll, + style = MaterialTheme.typography.bottomSheetItemLabel, + textAlign = TextAlign.Center, + color = AppTheme.colorScheme.textEmphasisHigh, + modifier = Modifier.wrapContentHeight().requiredWidth(56.dp) + ) + } +} diff --git a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/home/HomeEvent.kt b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/home/HomeEvent.kt index f6d843b68..59df2e1e0 100644 --- a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/home/HomeEvent.kt +++ b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/home/HomeEvent.kt @@ -36,12 +36,6 @@ sealed interface HomeEvent { data object OnHomeSelected : HomeEvent - data object OnAddFeedClicked : HomeEvent - - data object OnPrimaryActionClicked : HomeEvent - - data object OnCancelAddFeedClicked : HomeEvent - data class AddFeed(val feedLink: String) : HomeEvent data object BackClicked : HomeEvent @@ -56,9 +50,5 @@ sealed interface HomeEvent { data object SettingsClicked : HomeEvent - data object EditFeedsClicked : HomeEvent - - data object ExitFeedsEdit : HomeEvent - data class TogglePostReadStatus(val postLink: String, val postRead: Boolean) : HomeEvent } diff --git a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/home/HomePresenter.kt b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/home/HomePresenter.kt index 80297dfbc..7a411e759 100644 --- a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/home/HomePresenter.kt +++ b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/home/HomePresenter.kt @@ -33,9 +33,6 @@ import dev.sasikanth.rss.reader.core.model.local.PostWithMetadata import dev.sasikanth.rss.reader.exceptions.XmlParsingError import dev.sasikanth.rss.reader.feeds.FeedsEvent import dev.sasikanth.rss.reader.feeds.FeedsPresenter -import dev.sasikanth.rss.reader.feeds.ui.FeedsSheetMode.Default -import dev.sasikanth.rss.reader.feeds.ui.FeedsSheetMode.Edit -import dev.sasikanth.rss.reader.feeds.ui.FeedsSheetMode.LinkEntry import dev.sasikanth.rss.reader.home.ui.PostsType import dev.sasikanth.rss.reader.repository.FeedAddResult import dev.sasikanth.rss.reader.repository.ObservableSelectedFeed @@ -174,10 +171,7 @@ class HomePresenter( } is HomeEvent.FeedsSheetStateChanged -> feedsSheetStateChanged(event.feedsSheetState) HomeEvent.OnHomeSelected -> onHomeSelected() - HomeEvent.OnAddFeedClicked -> onAddFeedClicked() - HomeEvent.OnCancelAddFeedClicked -> onCancelAddFeedClicked() is HomeEvent.AddFeed -> addFeed(event.feedLink) - HomeEvent.OnPrimaryActionClicked -> onPrimaryActionClicked() HomeEvent.BackClicked -> backClicked() HomeEvent.SearchClicked -> { /* no-op */ @@ -189,8 +183,6 @@ class HomePresenter( HomeEvent.SettingsClicked -> { /* no-op */ } - HomeEvent.EditFeedsClicked -> editFeedsClicked() - HomeEvent.ExitFeedsEdit -> exitFeedsEdit() is HomeEvent.OnPostSourceClicked -> postSourceClicked(event.feedLink) is HomeEvent.OnPostsTypeChanged -> onPostsTypeChanged(event.postsType) is HomeEvent.TogglePostReadStatus -> togglePostReadStatus(event.postLink, event.postRead) @@ -214,14 +206,6 @@ class HomePresenter( } } - private fun exitFeedsEdit() { - _state.update { it.copy(feedsSheetMode = Default) } - } - - private fun editFeedsClicked() { - _state.update { it.copy(feedsSheetMode = Edit) } - } - private fun onPostBookmarkClicked(post: PostWithMetadata) { coroutineScope.launch { rssRepository.updateBookmarkStatus(bookmarked = !post.bookmarked, link = post.link) @@ -229,17 +213,7 @@ class HomePresenter( } private fun backClicked() { - coroutineScope.launch { - when (state.value.feedsSheetMode) { - Default, - LinkEntry -> { - effects.emit(HomeEffect.MinimizeSheet) - } - Edit -> { - _state.update { it.copy(feedsSheetMode = Default) } - } - } - } + coroutineScope.launch { effects.emit(HomeEffect.MinimizeSheet) } } private fun init() { @@ -307,14 +281,6 @@ class HomePresenter( .launchIn(coroutineScope) } - private fun onPrimaryActionClicked() { - if (_state.value.feedsSheetState == BottomSheetValue.Collapsed) { - dispatch(HomeEvent.OnHomeSelected) - } else { - dispatch(HomeEvent.OnAddFeedClicked) - } - } - private fun addFeed(feedLink: String) { coroutineScope.launch { _state.update { it.copy(feedFetchingState = FeedFetchingState.Loading) } @@ -335,9 +301,7 @@ class HomePresenter( BugsnagKotlin.sendHandledException(e) effects.emit(HomeEffect.ShowError(HomeErrorType.Unknown(e))) } finally { - _state.update { - it.copy(feedFetchingState = FeedFetchingState.Idle, feedsSheetMode = Default) - } + _state.update { it.copy(feedFetchingState = FeedFetchingState.Idle) } } } } @@ -407,14 +371,6 @@ class HomePresenter( } } - private fun onCancelAddFeedClicked() { - _state.update { it.copy(feedsSheetMode = Default) } - } - - private fun onAddFeedClicked() { - _state.update { it.copy(feedsSheetMode = LinkEntry) } - } - private fun onHomeSelected() { coroutineScope.launch { observableSelectedFeed.clearSelection() } } diff --git a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/home/HomeState.kt b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/home/HomeState.kt index caa5ac7ec..5b296c779 100644 --- a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/home/HomeState.kt +++ b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/home/HomeState.kt @@ -24,7 +24,6 @@ import dev.sasikanth.rss.reader.components.bottomsheet.BottomSheetValue import dev.sasikanth.rss.reader.components.bottomsheet.BottomSheetValue.Collapsed import dev.sasikanth.rss.reader.core.model.local.Feed import dev.sasikanth.rss.reader.core.model.local.PostWithMetadata -import dev.sasikanth.rss.reader.feeds.ui.FeedsSheetMode import dev.sasikanth.rss.reader.home.HomeLoadingState.Loading import dev.sasikanth.rss.reader.home.ui.PostsType import kotlinx.collections.immutable.ImmutableList @@ -37,7 +36,6 @@ internal data class HomeState( val loadingState: HomeLoadingState, val feedsSheetState: BottomSheetValue, val selectedFeed: Feed?, - val feedsSheetMode: FeedsSheetMode, val feedFetchingState: FeedFetchingState, val featuredItemBlurEnabled: Boolean, val hasFeeds: Boolean?, @@ -53,7 +51,6 @@ internal data class HomeState( loadingState = HomeLoadingState.Idle, feedsSheetState = Collapsed, selectedFeed = null, - feedsSheetMode = FeedsSheetMode.Default, feedFetchingState = FeedFetchingState.Idle, featuredItemBlurEnabled = true, hasFeeds = null, diff --git a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/home/ui/HomeScreen.kt b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/home/ui/HomeScreen.kt index b51f5fbc7..8d57c0419 100644 --- a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/home/ui/HomeScreen.kt +++ b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/home/ui/HomeScreen.kt @@ -15,12 +15,8 @@ */ package dev.sasikanth.rss.reader.home.ui -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.core.Transition import androidx.compose.animation.core.animateDp import androidx.compose.animation.core.updateTransition -import androidx.compose.animation.slideInVertically -import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.gestures.detectDragGestures import androidx.compose.foundation.layout.Arrangement @@ -31,14 +27,12 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.ime import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.requiredHeight import androidx.compose.foundation.layout.requiredSize import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.systemBars -import androidx.compose.foundation.layout.union import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.rememberLazyListState @@ -67,7 +61,6 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow @@ -77,14 +70,10 @@ import app.cash.paging.compose.collectAsLazyPagingItems import dev.sasikanth.rss.reader.components.CompactFloatingActionButton import dev.sasikanth.rss.reader.components.LocalDynamicColorState import dev.sasikanth.rss.reader.components.bottomsheet.BottomSheetScaffold -import dev.sasikanth.rss.reader.components.bottomsheet.BottomSheetState -import dev.sasikanth.rss.reader.components.bottomsheet.BottomSheetValue import dev.sasikanth.rss.reader.components.bottomsheet.rememberBottomSheetScaffoldState import dev.sasikanth.rss.reader.components.bottomsheet.rememberBottomSheetState import dev.sasikanth.rss.reader.core.model.local.PostWithMetadata -import dev.sasikanth.rss.reader.feeds.ui.BottomSheetPrimaryActionButton import dev.sasikanth.rss.reader.feeds.ui.FeedsBottomSheet -import dev.sasikanth.rss.reader.feeds.ui.FeedsSheetMode.* import dev.sasikanth.rss.reader.home.HomeEffect import dev.sasikanth.rss.reader.home.HomeErrorType import dev.sasikanth.rss.reader.home.HomeEvent @@ -150,12 +139,6 @@ internal fun HomeScreen(homePresenter: HomePresenter, modifier: Modifier = Modif } } - LaunchedEffect(bottomSheetState.targetValue) { - if (bottomSheetState.targetValue == BottomSheetValue.Collapsed) { - homePresenter.dispatch(HomeEvent.OnCancelAddFeedClicked) - } - } - Box(modifier = modifier) { BottomSheetScaffold( scaffoldState = bottomSheetScaffoldState, @@ -195,10 +178,7 @@ internal fun HomeScreen(homePresenter: HomePresenter, modifier: Modifier = Modif FeedsBottomSheet( feedsPresenter = homePresenter.feedsPresenter, bottomSheetSwipeTransition = bottomSheetSwipeTransition, - feedsSheetMode = state.feedsSheetMode, closeSheet = { coroutineScope.launch { bottomSheetState.collapse() } }, - editFeeds = { homePresenter.dispatch(HomeEvent.EditFeedsClicked) }, - exitFeedsEdit = { homePresenter.dispatch(HomeEvent.ExitFeedsEdit) }, selectedFeedChanged = { coroutineScope.launch { listState.scrollToItem(0) @@ -255,15 +235,6 @@ internal fun HomeScreen(homePresenter: HomePresenter, modifier: Modifier = Modif sheetPeekHeight = BOTTOM_SHEET_PEEK_HEIGHT, sheetShape = RoundedCornerShape(topStart = bottomSheetCornerSize, topEnd = bottomSheetCornerSize), - sheetGesturesEnabled = state.feedsSheetMode != Edit - ) - - PrimaryActionButtonContainer( - bottomSheetSwipeTransition = bottomSheetSwipeTransition, - state = state, - presenter = homePresenter, - bottomSheetState = bottomSheetState, - modifier = Modifier.align(Alignment.BottomStart) ) } } @@ -335,72 +306,6 @@ private fun HomeScreenContent( } } -/** - * Since we want the all button to not move when expanding and collapsing bottom bar and transform - * to add button. We are not placing it inside the bottom sheet content and instead place it above - * the home screen and bottom bar content essentially. - * - * We might have to replace this once bottom sheet exposes height or offset from bottom which would - * allow us to modify the offset of this item in the sheet itself instead of using workarounds. - * - * track: https://issuetracker.google.com/issues/209825720 - */ -@Composable -private fun PrimaryActionButtonContainer( - bottomSheetSwipeTransition: Transition, - state: HomeState, - presenter: HomePresenter, - bottomSheetState: BottomSheetState, - modifier: Modifier = Modifier -) { - // We want to finish the padding animation by the time - // bottom sheet is expanded 20% of the way - val bottomSheetExpansionThreshold = 0.2f - val bottomSheetExpansionProgress = - (bottomSheetSwipeTransition.currentState / bottomSheetExpansionThreshold) - val primaryActionStartPadding = (20 + (4 * bottomSheetExpansionProgress)).coerceIn(20f, 24f).dp - - val safeWindowInsets = - WindowInsets.systemBars.only(WindowInsetsSides.Bottom + WindowInsetsSides.Horizontal) - - AnimatedVisibility( - visible = state.feedsSheetMode != Edit, - enter = slideInVertically { it }, - exit = slideOutVertically { it }, - modifier = Modifier.padding(start = primaryActionStartPadding).then(modifier) - ) { - Box { - when (state.feedsSheetMode) { - Default, - Edit -> { - BottomSheetPrimaryActionButton( - modifier = - Modifier.windowInsetsPadding(safeWindowInsets).graphicsLayer { - translationY = (4 * bottomSheetSwipeTransition.currentState).dp.toPx() - }, - selected = state.isAllFeedsSelected, - bottomSheetSwipeProgress = bottomSheetExpansionProgress.inverse(), - bottomSheetCurrentState = bottomSheetState.currentValue, - bottomSheetTargetState = bottomSheetState.targetValue - ) { - presenter.dispatch(HomeEvent.OnPrimaryActionClicked) - } - } - LinkEntry -> { - FeedLinkInputField( - modifier = - Modifier.windowInsetsPadding(safeWindowInsets.union(WindowInsets.ime)) - .padding(bottom = 20.dp, end = 24.dp), - isFetchingFeed = state.isFetchingFeed, - onAddFeed = { presenter.dispatch(HomeEvent.AddFeed(it)) }, - onCancelFeedEntryClicked = { presenter.dispatch(HomeEvent.OnCancelAddFeedClicked) } - ) - } - } - } - } -} - @Composable private fun NoFeeds(onNoFeedsSwipeUp: () -> Unit) { Column( diff --git a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/feeds/ui/FeedsListItemType.kt b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/repository/FeedsOrderBy.kt similarity index 61% rename from shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/feeds/ui/FeedsListItemType.kt rename to shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/repository/FeedsOrderBy.kt index 73809811c..3ce012710 100644 --- a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/feeds/ui/FeedsListItemType.kt +++ b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/repository/FeedsOrderBy.kt @@ -1,5 +1,5 @@ /* - * Copyright 2023 Sasikanth Miriyampalli + * Copyright 2024 Sasikanth Miriyampalli * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,14 +14,10 @@ * limitations under the License. */ -package dev.sasikanth.rss.reader.feeds.ui +package dev.sasikanth.rss.reader.repository -import dev.sasikanth.rss.reader.core.model.local.Feed - -internal sealed interface FeedsListItemType { - data class FeedListItem(val feed: Feed) : FeedsListItemType - - data object PinnedFeedsHeader : FeedsListItemType - - data object AllFeedsHeader : FeedsListItemType +enum class FeedsOrderBy(val value: String) { + Latest("latest"), + Oldest("oldest"), + Alphabetical("alphabetical") } 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 9ad7ce0ee..24610ace8 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 @@ -207,13 +207,33 @@ class RssRepository( withContext(ioDispatcher) { bookmarkQueries.deleteBookmark(link) } } - fun allFeeds(postsAfter: Instant = Instant.DISTANT_PAST): PagingSource { + fun allFeeds( + postsAfter: Instant = Instant.DISTANT_PAST, + orderBy: FeedsOrderBy = FeedsOrderBy.Latest, + ): PagingSource { return QueryPagingSource( countQuery = feedQueries.count(), transacter = feedQueries, context = ioDispatcher, queryProvider = { limit, offset -> feedQueries.feedsPaginated( + postsAfter = postsAfter, + limit = limit, + orderBy = orderBy.value, + offset = offset, + mapper = ::Feed + ) + } + ) + } + + fun pinnedFeeds(postsAfter: Instant = Instant.DISTANT_PAST): PagingSource { + return QueryPagingSource( + countQuery = feedQueries.pinnedFeedsCount(), + transacter = feedQueries, + context = ioDispatcher, + queryProvider = { limit, offset -> + feedQueries.pinnedFeedsPaginated( postsAfter = postsAfter, limit = limit, offset = offset, @@ -446,6 +466,10 @@ class RssRepository( } } + suspend fun feedsCount(): Long { + return withContext(ioDispatcher) { feedQueries.count().executeAsOne() } + } + private fun sanitizeSearchQuery(searchQuery: String): String { return searchQuery.replace(Regex.fromLiteral("\""), "\"\"").run { "\"$this\"" } } diff --git a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/repository/SettingsRepository.kt b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/repository/SettingsRepository.kt index f900c33b9..43f305c56 100644 --- a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/repository/SettingsRepository.kt +++ b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/repository/SettingsRepository.kt @@ -21,6 +21,7 @@ import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.stringPreferencesKey import dev.sasikanth.rss.reader.di.scopes.AppScope +import dev.sasikanth.rss.reader.feeds.ui.FeedsViewMode import dev.sasikanth.rss.reader.home.ui.PostsType import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.first @@ -37,6 +38,8 @@ class SettingsRepository(private val dataStore: DataStore) { private val postsDeletionPeriodKey = stringPreferencesKey("posts_cleanup_frequency") private val postsTypeKey = stringPreferencesKey("posts_type") private val showReaderViewKey = booleanPreferencesKey("pref_show_reader_view") + private val feedsViewModeKey = stringPreferencesKey("pref_feeds_view_mode") + private val feedsSortOrderKey = stringPreferencesKey("pref_feeds_sort_order") val browserType: Flow = dataStore.data.map { preferences -> @@ -60,6 +63,20 @@ class SettingsRepository(private val dataStore: DataStore) { val postsType: Flow = dataStore.data.map { preferences -> mapToPostsType(preferences[postsTypeKey]) ?: PostsType.ALL } + val feedsViewMode: Flow = + dataStore.data.map { preferences -> + mapToFeedsViewMode(preferences[feedsViewModeKey]) ?: FeedsViewMode.List + } + + val feedsSortOrder: Flow = + dataStore.data.map { preferences -> + mapToFeedsOrderBy(preferences[feedsSortOrderKey]) ?: FeedsOrderBy.Latest + } + + suspend fun updateFeedsSortOrder(value: FeedsOrderBy) { + dataStore.edit { preferences -> preferences[feedsSortOrderKey] = value.name } + } + suspend fun postsDeletionPeriodImmediate(): Period { return postsDeletionPeriod.first() } @@ -88,6 +105,20 @@ class SettingsRepository(private val dataStore: DataStore) { dataStore.edit { preferences -> preferences[showReaderViewKey] = value } } + suspend fun updateFeedsViewMode(value: FeedsViewMode) { + dataStore.edit { preferences -> preferences[feedsViewModeKey] = value.name } + } + + private fun mapToFeedsOrderBy(pref: String?): FeedsOrderBy? { + if (pref.isNullOrBlank()) return null + return FeedsOrderBy.valueOf(pref) + } + + private fun mapToFeedsViewMode(pref: String?): FeedsViewMode? { + if (pref.isNullOrBlank()) return null + return FeedsViewMode.valueOf(pref) + } + private fun mapToBrowserType(pref: String?): BrowserType? { if (pref.isNullOrBlank()) return null return BrowserType.valueOf(pref) diff --git a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/utils/Tuples.kt b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/utils/Tuples.kt new file mode 100644 index 000000000..01d91e903 --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/utils/Tuples.kt @@ -0,0 +1,19 @@ +/* + * Copyright 2024 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.utils + +data class NTuple4(val t1: T1, val t2: T2, val t3: T3, val t4: T4) 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 ae6125ba1..928746e85 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 @@ -28,6 +28,9 @@ DELETE FROM feed WHERE link = :link; count: SELECT COUNT(*) FROM feed; +pinnedFeedsCount: +SELECT COUNT(*) FROM feed WHERE pinnedAt IS NOT NULL; + feeds: SELECT * FROM feed ORDER BY pinnedAt DESC, createdAt DESC; @@ -46,7 +49,29 @@ SELECT FROM feed f LEFT JOIN post p ON f.link = p.feedLink AND p.date > :postsAfter GROUP BY f.link -ORDER BY f.pinnedAt DESC, f.createdAt DESC +ORDER BY + CASE WHEN :orderBy = 'latest' THEN f.createdAt END DESC, + CASE WHEN :orderBy = 'oldest' THEN f.createdAt END ASC, + CASE WHEN :orderBy = 'alphabetical' THEN f.name END ASC, + f.createdAt DESC +LIMIT :limit OFFSET :offset; + +pinnedFeedsPaginated: +SELECT + f.name, + f.icon, + f.description, + f.homepageLink, + f.createdAt, + f.link, + f.pinnedAt, + f.lastCleanUpAt, + COUNT(CASE WHEN p.read = 0 THEN 1 ELSE NULL END) AS numberOfUnreadPosts +FROM feed f +LEFT JOIN post p ON f.link = p.feedLink AND p.date > :postsAfter +WHERE pinnedAt IS NOT NULL +GROUP BY f.link +ORDER BY f.pinnedAt DESC LIMIT :limit OFFSET :offset; feed: