From 8488289df9feabe7ae000770025a8e0e9807dad4 Mon Sep 17 00:00:00 2001 From: Sasikanth Miriyampalli Date: Fri, 26 Apr 2024 08:59:28 +0530 Subject: [PATCH 1/6] Show edit option when a single source is selected fixes TWN-50 --- .../resources/strings/DeTwineStrings.kt | 1 + .../resources/strings/EnTwineStrings.kt | 1 + .../resources/strings/TrTwineStrings.kt | 1 + .../reader/resources/strings/TwineStrings.kt | 1 + .../sasikanth/rss/reader/feeds/FeedsEvent.kt | 2 ++ .../rss/reader/feeds/FeedsPresenter.kt | 20 ++++++++++++++++--- .../feeds/ui/BottomSheetExpandedContent.kt | 11 ++++++++++ .../rss/reader/feeds/ui/FeedsBottomSheet.kt | 1 + 8 files changed, 35 insertions(+), 3 deletions(-) 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 aa6e161c0..fcbf1c28e 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 @@ -154,4 +154,5 @@ val DeTwineStrings = actionGroupsTooltip = "Gruppen können nicht innerhalb anderer Gruppen sein.", groupAddNew = "Add new", appBarAllFeeds = "All feeds", + edit = "Edit", ) 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 d1987b066..a8dbe7614 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 @@ -161,4 +161,5 @@ val EnTwineStrings = actionGroupsTooltip = "Groups cannot be inside other groups.", groupAddNew = "Add new", appBarAllFeeds = "All feeds", + edit = "Edit", ) 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 ed46dcd26..eceffe607 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 @@ -150,4 +150,5 @@ val TrTwineStrings = actionGroupsTooltip = "Gruplar başka grupların içinde olamaz.", groupAddNew = "Add new", appBarAllFeeds = "All feeds", + edit = "Edit", ) 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 7a29d4676..e0a42a80f 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 @@ -141,6 +141,7 @@ data class TwineStrings( val actionGroupsTooltip: String, val groupAddNew: String, val appBarAllFeeds: String, + val edit: String ) object Locales { 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 8f5388251..f9e4a6c22 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 @@ -60,5 +60,7 @@ sealed interface FeedsEvent { data class OnGroupsSelected(val groupIds: Set) : FeedsEvent + data class OnEditSourceClicked(val source: Source) : FeedsEvent + data object OnAddToGroupClicked : 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 bac322dcb..1d055b152 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 @@ -31,6 +31,7 @@ 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.core.model.local.FeedGroup import dev.sasikanth.rss.reader.core.model.local.Source import dev.sasikanth.rss.reader.core.model.local.SourceType import dev.sasikanth.rss.reader.feeds.ui.FeedsViewMode @@ -100,12 +101,22 @@ class FeedsPresenter( fun dispatch(event: FeedsEvent) { when (event) { - is FeedsEvent.OnFeedClick -> { - // TODO: Open source screen with posts - } is FeedsEvent.OnAddToGroupClicked -> { openGroupSelectionSheet() } + is FeedsEvent.OnEditSourceClicked -> { + when (val source = event.source) { + is Feed -> { + // TODO: Open feed info sheet + } + is FeedGroup -> { + // TODO: Open edit feed group screen + } + else -> { + throw IllegalArgumentException("Unknown source: $source") + } + } + } else -> { // no-op } @@ -163,6 +174,9 @@ class FeedsPresenter( FeedsEvent.OnAddToGroupClicked -> { // no-op } + is FeedsEvent.OnEditSourceClicked -> { + // no-op + } } } diff --git a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/feeds/ui/BottomSheetExpandedContent.kt b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/feeds/ui/BottomSheetExpandedContent.kt index f572aad55..049a6b116 100644 --- a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/feeds/ui/BottomSheetExpandedContent.kt +++ b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/feeds/ui/BottomSheetExpandedContent.kt @@ -47,6 +47,7 @@ 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.filled.Edit import androidx.compose.material.icons.filled.ExpandLess import androidx.compose.material.icons.filled.ExpandMore import androidx.compose.material.icons.filled.GridView @@ -126,6 +127,7 @@ internal fun BottomSheetExpandedContent( onDeleteSelectedFeeds: () -> Unit, onCreateGroup: (String) -> Unit, onAddToGroupClicked: () -> Unit, + onEditSource: (source: Source) -> Unit, modifier: Modifier = Modifier ) { var showNewGroupDialog by remember { mutableStateOf(false) } @@ -203,6 +205,15 @@ internal fun BottomSheetExpandedContent( label = LocalStrings.current.actionDelete, onClick = onDeleteSelectedFeeds ) + + if (selectedSources.size == 1) { + ContextActionItem( + modifier = Modifier.weight(1f), + icon = Icons.Filled.Edit, + label = LocalStrings.current.edit, + onClick = { onEditSource(selectedSources.first()) } + ) + } } } } 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 aced65f17..fdb749ee0 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 @@ -95,6 +95,7 @@ internal fun FeedsBottomSheet( onDeleteSelectedFeeds = { feedsPresenter.dispatch(FeedsEvent.DeleteSelectedSources) }, onCreateGroup = { feedsPresenter.dispatch(FeedsEvent.OnCreateGroup(it)) }, onAddToGroupClicked = { feedsPresenter.dispatch(FeedsEvent.OnAddToGroupClicked) }, + onEditSource = { feedsPresenter.dispatch(FeedsEvent.OnEditSourceClicked(it)) }, modifier = Modifier.graphicsLayer { val threshold = 0.3 From 163cb6bff3d9d9bb151e227f379d8041c780d214 Mon Sep 17 00:00:00 2001 From: Sasikanth Miriyampalli Date: Fri, 26 Apr 2024 09:00:49 +0530 Subject: [PATCH 2/6] When edit is clicked and selected source is feed, open feed info sheet fixes TWN-51 --- .../dev/sasikanth/rss/reader/app/AppPresenter.kt | 3 ++- .../dev/sasikanth/rss/reader/feeds/FeedsPresenter.kt | 10 +++------- .../dev/sasikanth/rss/reader/home/HomePresenter.kt | 10 +++++++++- 3 files changed, 14 insertions(+), 9 deletions(-) diff --git a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/app/AppPresenter.kt b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/app/AppPresenter.kt index 88089eb1a..8c86feb7a 100644 --- a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/app/AppPresenter.kt +++ b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/app/AppPresenter.kt @@ -153,7 +153,8 @@ class AppPresenter( { navigation.push(Config.Bookmarks) }, { navigation.push(Config.Settings) }, { openPost(it) }, - { modalNavigation.activate(ModalConfig.GroupSelection) } + { modalNavigation.activate(ModalConfig.GroupSelection) }, + { modalNavigation.activate(ModalConfig.FeedInfo(it)) } ) ) } 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 1d055b152..adea47dca 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 @@ -78,6 +78,7 @@ class FeedsPresenter( private val observableActiveSource: ObservableActiveSource, @Assisted componentContext: ComponentContext, @Assisted private val openGroupSelectionSheet: () -> Unit, + @Assisted private val openFeedInfoSheet: (feedId: String) -> Unit, ) : ComponentContext by componentContext { private val presenterInstance = @@ -106,9 +107,7 @@ class FeedsPresenter( } is FeedsEvent.OnEditSourceClicked -> { when (val source = event.source) { - is Feed -> { - // TODO: Open feed info sheet - } + is Feed -> openFeedInfoSheet(source.id) is FeedGroup -> { // TODO: Open edit feed group screen } @@ -157,10 +156,7 @@ class FeedsPresenter( is FeedsEvent.OnFeedPinClicked -> onFeedPinClicked(event.feed) FeedsEvent.ClearSearchQuery -> clearSearchQuery() is FeedsEvent.SearchQueryChanged -> onSearchQueryChanged(event.searchQuery) - is FeedsEvent.OnFeedClick -> { - // TODO: Remove once source page with posts is implemented - onSourceClicked(event.source) - } + is FeedsEvent.OnFeedClick -> onSourceClicked(event.source) FeedsEvent.TogglePinnedSection -> onTogglePinnedSection() is FeedsEvent.OnFeedSortOrderChanged -> onFeedSortOrderChanged(event.feedsOrderBy) FeedsEvent.OnChangeFeedsViewModeClick -> onChangeFeedsViewModeClick() 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 5e7567259..43088901f 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 @@ -78,13 +78,19 @@ internal typealias HomePresenterFactory = openSettings: () -> Unit, openPost: (PostWithMetadata) -> Unit, openGroupSelectionSheet: () -> Unit, + openFeedInfoSheet: (feedId: String) -> Unit, ) -> HomePresenter @Inject @OptIn(ExperimentalCoroutinesApi::class) class HomePresenter( dispatchersProvider: DispatchersProvider, - feedsPresenterFactory: (ComponentContext, openGroupSelectionSheet: () -> Unit) -> FeedsPresenter, + feedsPresenterFactory: + ( + ComponentContext, + openGroupSelectionSheet: () -> Unit, + openFeedInfoSheet: (feedId: String) -> Unit + ) -> FeedsPresenter, private val rssRepository: RssRepository, private val observableActiveSource: ObservableActiveSource, private val settingsRepository: SettingsRepository, @@ -94,12 +100,14 @@ class HomePresenter( @Assisted private val openSettings: () -> Unit, @Assisted private val openPost: (post: PostWithMetadata) -> Unit, @Assisted private val openGroupSelectionSheet: () -> Unit, + @Assisted private val openFeedInfoSheet: (feedId: String) -> Unit, ) : ComponentContext by componentContext { internal val feedsPresenter = feedsPresenterFactory( childContext("feeds_presenter"), openGroupSelectionSheet, + openFeedInfoSheet ) private val backCallback = BackCallback { From f8cfb2c9a95831b5014f50c4510fbe303c1c4e2e Mon Sep 17 00:00:00 2001 From: Sasikanth Miriyampalli Date: Sat, 27 Apr 2024 08:54:33 +0530 Subject: [PATCH 3/6] Use ubuntu machine for running Android tests in CI --- .github/workflows/ci_checks.yml | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci_checks.yml b/.github/workflows/ci_checks.yml index 99ec36fa2..b741c20cd 100644 --- a/.github/workflows/ci_checks.yml +++ b/.github/workflows/ci_checks.yml @@ -13,7 +13,7 @@ concurrency: jobs: code-formatting: - runs-on: macos-latest + runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -60,11 +60,17 @@ jobs: run: ./gradlew cleanAllTests allTests android-tests: - runs-on: macos-latest + runs-on: ubuntu-latest timeout-minutes: 30 steps: - uses: actions/checkout@v4 + - name: Enable KVM group perms + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + - name: Setup JDK 20 uses: actions/setup-java@v4 with: From 3d1d30698b5da9c4f0c43a3cb3996a21e8611be0 Mon Sep 17 00:00:00 2001 From: Sasikanth Miriyampalli Date: Sat, 27 Apr 2024 09:09:29 +0530 Subject: [PATCH 4/6] Refactor feeds bottom sheet expanded content (#484) Using slots API pattern instead of providing multiple callbacks as params --- .../sasikanth/rss/reader/feeds/FeedsEvent.kt | 2 +- .../rss/reader/feeds/FeedsPresenter.kt | 2 +- .../feeds/ui/BottomSheetExpandedContent.kt | 809 ------------------ .../rss/reader/feeds/ui/FeedsBottomSheet.kt | 33 +- .../ui/expanded/BottomSheetExpandedContent.kt | 362 ++++++++ .../reader/feeds/ui/expanded/SourcesAll.kt | 292 +++++++ .../reader/feeds/ui/expanded/SourcesGrid.kt | 91 ++ .../reader/feeds/ui/expanded/SourcesPinned.kt | 167 ++++ .../feeds/ui/expanded/SourcesSearchResults.kt | 68 ++ 9 files changed, 986 insertions(+), 840 deletions(-) delete mode 100644 shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/feeds/ui/BottomSheetExpandedContent.kt create mode 100644 shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/feeds/ui/expanded/BottomSheetExpandedContent.kt create mode 100644 shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/feeds/ui/expanded/SourcesAll.kt create mode 100644 shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/feeds/ui/expanded/SourcesGrid.kt create mode 100644 shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/feeds/ui/expanded/SourcesPinned.kt create mode 100644 shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/feeds/ui/expanded/SourcesSearchResults.kt 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 f9e4a6c22..5be73624c 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 @@ -38,7 +38,7 @@ sealed interface FeedsEvent { data object ClearSearchQuery : FeedsEvent - data class OnFeedClick(val source: Source) : FeedsEvent + data class OnSourceClick(val source: Source) : FeedsEvent data class OnFeedSortOrderChanged(val feedsOrderBy: FeedsOrderBy) : 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 adea47dca..1e21ac750 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 @@ -156,7 +156,7 @@ class FeedsPresenter( is FeedsEvent.OnFeedPinClicked -> onFeedPinClicked(event.feed) FeedsEvent.ClearSearchQuery -> clearSearchQuery() is FeedsEvent.SearchQueryChanged -> onSearchQueryChanged(event.searchQuery) - is FeedsEvent.OnFeedClick -> onSourceClicked(event.source) + is FeedsEvent.OnSourceClick -> onSourceClicked(event.source) FeedsEvent.TogglePinnedSection -> onTogglePinnedSection() is FeedsEvent.OnFeedSortOrderChanged -> onFeedSortOrderChanged(event.feedsOrderBy) FeedsEvent.OnChangeFeedsViewModeClick -> onChangeFeedsViewModeClick() diff --git a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/feeds/ui/BottomSheetExpandedContent.kt b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/feeds/ui/BottomSheetExpandedContent.kt deleted file mode 100644 index 049a6b116..000000000 --- a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/feeds/ui/BottomSheetExpandedContent.kt +++ /dev/null @@ -1,809 +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.feeds.ui - -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.slideInVertically -import androidx.compose.animation.slideOutVertically -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -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 -import androidx.compose.foundation.layout.asPaddingValues -import androidx.compose.foundation.layout.calculateEndPadding -import androidx.compose.foundation.layout.calculateStartPadding -import androidx.compose.foundation.layout.consumeWindowInsets -import androidx.compose.foundation.layout.fillMaxSize -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.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.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.filled.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.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.darkColorScheme -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -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.graphics.Color -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 app.cash.paging.compose.LazyPagingItems -import app.cash.paging.compose.itemKey -import dev.sasikanth.rss.reader.components.ContextActionItem -import dev.sasikanth.rss.reader.components.ContextActionsBottomBar -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.core.model.local.FeedGroup -import dev.sasikanth.rss.reader.core.model.local.Source -import dev.sasikanth.rss.reader.core.model.local.SourceType -import dev.sasikanth.rss.reader.feeds.SourceListItem -import dev.sasikanth.rss.reader.repository.FeedsOrderBy -import dev.sasikanth.rss.reader.resources.icons.Delete -import dev.sasikanth.rss.reader.resources.icons.NewGroup -import dev.sasikanth.rss.reader.resources.icons.Pin -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.utils.Constants -import dev.sasikanth.rss.reader.utils.KeyboardState -import dev.sasikanth.rss.reader.utils.keyboardVisibilityAsState - -@Composable -internal fun BottomSheetExpandedContent( - numberOfFeeds: Int, - numberOfFeedGroups: Int, - pinnedSources: LazyPagingItems, - sources: LazyPagingItems, - searchResults: LazyPagingItems, - selectedSources: Set, - searchQuery: TextFieldValue, - feedsSortOrder: FeedsOrderBy, - feedsViewMode: FeedsViewMode, - isPinnedSectionExpanded: Boolean, - canShowUnreadPostsCount: Boolean, - isInMultiSelectMode: Boolean, - onSearchQueryChanged: (TextFieldValue) -> Unit, - onClearSearchQuery: () -> Unit, - onFeedClick: (Source) -> Unit, - onToggleSourceSelection: (Source) -> Unit, - onTogglePinnedSection: () -> Unit, - onFeedsSortChanged: (FeedsOrderBy) -> Unit, - onChangeFeedsViewModeClick: () -> Unit, - onCancelFeedsSelection: () -> Unit, - onPinSelectedFeeds: () -> Unit, - onUnPinSelectedFeeds: () -> Unit, - onDeleteSelectedFeeds: () -> Unit, - onCreateGroup: (String) -> Unit, - onAddToGroupClicked: () -> Unit, - onEditSource: (source: Source) -> Unit, - modifier: Modifier = Modifier -) { - var showNewGroupDialog by remember { mutableStateOf(false) } - - Scaffold( - modifier = Modifier.fillMaxSize().consumeWindowInsets(WindowInsets.statusBars).then(modifier), - topBar = { - SearchBar( - query = searchQuery, - feedsViewMode = feedsViewMode, - onQueryChange = { onSearchQueryChanged(it) }, - onClearClick = onClearSearchQuery, - onChangeFeedsViewModeClick = onChangeFeedsViewModeClick - ) - }, - bottomBar = { - Box(contentAlignment = Alignment.BottomCenter) { - AnimatedVisibility( - visible = !isInMultiSelectMode, - enter = slideInVertically { it }, - exit = slideOutVertically { it } - ) { - BottomSheetExpandedBottomBar( - onNewGroupClick = { showNewGroupDialog = true }, - onNewFeedClick = { - // TODO: Open feed creation dialog/sheet/screen - } - ) - } - - AnimatedVisibility( - visible = isInMultiSelectMode, - enter = slideInVertically { it }, - exit = slideOutVertically { it } - ) { - val areGroupsSelected = selectedSources.any { it is FeedGroup } - val tooltip: @Composable (() -> Unit)? = - if (areGroupsSelected) { - { Text(text = LocalStrings.current.actionGroupsTooltip) } - } else { - null - } - - ContextActionsBottomBar(tooltip = tooltip, onCancel = onCancelFeedsSelection) { - val areSelectedFeedsPinned = selectedSources.all { it.pinnedAt != null } - - val label = - if (areSelectedFeedsPinned) LocalStrings.current.actionUnpin - else LocalStrings.current.actionPin - - ContextActionItem( - modifier = Modifier.weight(1f), - icon = TwineIcons.Pin, - label = label, - onClick = { - if (areSelectedFeedsPinned) { - onUnPinSelectedFeeds() - } else { - onPinSelectedFeeds() - } - } - ) - - ContextActionItem( - modifier = Modifier.weight(1f), - icon = TwineIcons.NewGroup, - label = LocalStrings.current.actionAddTo, - enabled = !areGroupsSelected, - onClick = { onAddToGroupClicked() } - ) - - ContextActionItem( - modifier = Modifier.weight(1f), - icon = TwineIcons.Delete, - label = LocalStrings.current.actionDelete, - onClick = onDeleteSelectedFeeds - ) - - if (selectedSources.size == 1) { - ContextActionItem( - modifier = Modifier.weight(1f), - icon = Icons.Filled.Edit, - label = LocalStrings.current.edit, - onClick = { onEditSource(selectedSources.first()) } - ) - } - } - } - } - }, - containerColor = AppTheme.colorScheme.tintedBackground - ) { padding -> - val layoutDirection = LocalLayoutDirection.current - val imeBottomPadding = WindowInsets.ime.asPaddingValues().calculateBottomPadding() - - 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 < Constants.MINIMUM_REQUIRED_SEARCH_CHARACTERS - ) { - pinnedSources( - pinnedSources = pinnedSources, - selectedSources = selectedSources, - isPinnedSectionExpanded = isPinnedSectionExpanded, - canShowUnreadPostsCount = canShowUnreadPostsCount, - isInMultiSelectMode = isInMultiSelectMode, - gridItemSpan = gridItemSpan, - onTogglePinnedSection = onTogglePinnedSection, - onSourceClick = onFeedClick, - onToggleSourceSelection = onToggleSourceSelection, - ) - - allSources( - numberOfFeeds = numberOfFeeds, - numberOfFeedGroups = numberOfFeedGroups, - sources = sources, - selectedSources = selectedSources, - feedsSortOrder = feedsSortOrder, - canShowUnreadPostsCount = canShowUnreadPostsCount, - isInMultiSelectMode = isInMultiSelectMode, - gridItemSpan = gridItemSpan, - onFeedsSortChanged = onFeedsSortChanged, - onSourceClick = onFeedClick, - onToggleSourceSelection = onToggleSourceSelection - ) - } else { - feedSearchResults( - searchResults = searchResults, - selectedSources = selectedSources, - canShowUnreadPostsCount = canShowUnreadPostsCount, - isInMultiSelectMode = isInMultiSelectMode, - gridItemSpan = gridItemSpan, - onSourceClick = onFeedClick, - onToggleSourceSelection = onToggleSourceSelection - ) - } - } - } - - if (showNewGroupDialog) { - CreateGroupDialog(onCreateGroup = onCreateGroup, onDismiss = { showNewGroupDialog = false }) - } -} - -@Composable -private fun SearchBar( - query: TextFieldValue, - feedsViewMode: FeedsViewMode, - onQueryChange: (TextFieldValue) -> Unit, - onClearClick: () -> Unit, - onChangeFeedsViewModeClick: () -> Unit, -) { - val keyboardState by keyboardVisibilityAsState() - val focusManager = LocalFocusManager.current - - LaunchedEffect(keyboardState) { - if (keyboardState == KeyboardState.Closed) { - focusManager.clearFocus() - } - } - - 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()) { - IconButton(onClick = onClearClick) { - Icon( - Icons.Rounded.Close, - contentDescription = null, - tint = AppTheme.colorScheme.tintedForeground - ) - } - } - }, - 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)) - } -} - -private fun LazyGridScope.feedSearchResults( - searchResults: LazyPagingItems, - selectedSources: Set, - canShowUnreadPostsCount: Boolean, - isInMultiSelectMode: Boolean, - gridItemSpan: GridItemSpan, - onSourceClick: (Source) -> Unit, - onToggleSourceSelection: (Source) -> Unit, -) { - items( - count = searchResults.itemCount, - key = searchResults.itemKey { "SearchResult:${it.id}" }, - contentType = { "FeedListItem" }, - span = { gridItemSpan } - ) { index -> - val feed = searchResults[index] - val startPadding = startPaddingOfFeedListItem(gridItemSpan, index) - val endPadding = endPaddingOfFeedListItem(gridItemSpan, index) - val topPadding = topPaddingOfFeedListItem(gridItemSpan, index) - val bottomPadding = bottomPaddingOfFeedListItem(index, searchResults.itemCount) - - if (feed != null) { - FeedListItem( - feed = feed, - canShowUnreadPostsCount = canShowUnreadPostsCount, - isInMultiSelectMode = isInMultiSelectMode, - isFeedSelected = selectedSources.any { it.id == feed.id }, - onFeedClick = onSourceClick, - onFeedSelected = onToggleSourceSelection, - modifier = - Modifier.padding( - start = startPadding, - top = topPadding, - end = endPadding, - bottom = bottomPadding - ) - ) - } - } -} - -private fun LazyGridScope.allSources( - numberOfFeeds: Int, - numberOfFeedGroups: Int, - sources: LazyPagingItems, - selectedSources: Set, - feedsSortOrder: FeedsOrderBy, - canShowUnreadPostsCount: Boolean, - isInMultiSelectMode: Boolean, - gridItemSpan: GridItemSpan, - onFeedsSortChanged: (FeedsOrderBy) -> Unit, - onSourceClick: (Source) -> Unit, - onToggleSourceSelection: (Source) -> Unit -) { - if (sources.itemCount > 0) { - item(key = "AllFeedsHeader", span = { GridItemSpan(2) }) { - AllFeedsHeader( - feedsCount = numberOfFeeds, - feedsSortOrder = feedsSortOrder, - onFeedsSortChanged = onFeedsSortChanged - ) - } - - val feedGroupGridItemSpan = GridItemSpan(1) - items( - count = sources.itemCount, - key = - sources.itemKey { - when (it) { - SourceListItem.Separator -> "Separator" - is SourceListItem.SourceItem -> it.source.id - } - }, - contentType = { - when (val sourceItem = sources[it]) { - SourceListItem.Separator -> "Separator" - is SourceListItem.SourceItem -> { - if (sourceItem.source.sourceType == SourceType.FeedGroup) { - "FeedGroupItem" - } else { - "FeedListItem" - } - } - null -> null - } - }, - span = { - when (val sourceItem = sources[it]) { - SourceListItem.Separator -> GridItemSpan(2) - is SourceListItem.SourceItem -> { - when (sourceItem.source) { - is FeedGroup -> feedGroupGridItemSpan - is Feed -> gridItemSpan - else -> GridItemSpan(2) - } - } - null -> GridItemSpan(2) - } - } - ) { index -> - when (val sourceItem = sources[index]) { - SourceListItem.Separator -> { - Spacer(Modifier.requiredHeight(40.dp)) - } - is SourceListItem.SourceItem -> { - when (val source = sourceItem.source) { - is FeedGroup -> { - val startPadding = startPaddingOfFeedListItem(feedGroupGridItemSpan, index) - val endPadding = endPaddingOfFeedListItem(feedGroupGridItemSpan, index) - val topPadding = topPaddingOfFeedListItem(feedGroupGridItemSpan, index) - val bottomPadding = bottomPaddingOfFeedListItem(index, sources.itemCount) - - FeedGroupItem( - feedGroup = source, - canShowUnreadPostsCount = canShowUnreadPostsCount, - isInMultiSelectMode = isInMultiSelectMode, - selected = selectedSources.any { it.id == source.id }, - onFeedGroupSelected = onToggleSourceSelection, - onFeedGroupClick = onSourceClick, - modifier = - Modifier.padding( - start = startPadding, - top = topPadding, - end = endPadding, - bottom = bottomPadding - ), - ) - } - is Feed -> { - // When there are even number of feed groups, we are offsetting the index - // to make sure the spacing is properly applied to feed list items after the - // separator. - val transformedIndex = - if (numberOfFeedGroups % 2 == 0) { - index - 1 - } else { - index - } - val startPadding = startPaddingOfFeedListItem(gridItemSpan, transformedIndex) - val endPadding = endPaddingOfFeedListItem(gridItemSpan, transformedIndex) - val topPadding = topPaddingOfFeedListItem(gridItemSpan, transformedIndex) - val bottomPadding = bottomPaddingOfFeedListItem(transformedIndex, sources.itemCount) - - FeedListItem( - feed = source, - canShowUnreadPostsCount = canShowUnreadPostsCount, - isInMultiSelectMode = isInMultiSelectMode, - isFeedSelected = selectedSources.any { it.id == source.id }, - onFeedClick = onSourceClick, - onFeedSelected = onToggleSourceSelection, - modifier = - Modifier.padding( - start = startPadding, - top = topPadding, - end = endPadding, - bottom = bottomPadding - ) - ) - } - } - } - null -> { - // no-op - } - } - } - } -} - -private fun LazyGridScope.pinnedSources( - pinnedSources: LazyPagingItems, - selectedSources: Set, - isPinnedSectionExpanded: Boolean, - canShowUnreadPostsCount: Boolean, - isInMultiSelectMode: Boolean, - gridItemSpan: GridItemSpan, - onTogglePinnedSection: () -> Unit, - onSourceClick: (Source) -> Unit, - onToggleSourceSelection: (Source) -> Unit, -) { - if (pinnedSources.itemCount > 0) { - item(key = "PinnedFeedsHeader", span = { GridItemSpan(2) }) { - PinnedFeedsHeader( - isPinnedSectionExpanded = isPinnedSectionExpanded, - onToggleSection = onTogglePinnedSection - ) - } - - if (isPinnedSectionExpanded) { - - items( - count = pinnedSources.itemCount, - key = pinnedSources.itemKey { "PinnedSource:${it.id}" }, - contentType = { - if (pinnedSources[it]?.sourceType == SourceType.FeedGroup) { - "FeedGroupItem" - } else { - "FeedListItem" - } - }, - span = { gridItemSpan } - ) { index -> - val source = pinnedSources[index] - if (source != null) { - val startPadding = startPaddingOfFeedListItem(gridItemSpan, index) - val endPadding = endPaddingOfFeedListItem(gridItemSpan, index) - val topPadding = topPaddingOfFeedListItem(gridItemSpan, index) - val bottomPadding = bottomPaddingOfFeedListItem(index, pinnedSources.itemCount) - - when (source) { - is FeedGroup -> { - FeedGroupItem( - feedGroup = source, - canShowUnreadPostsCount = canShowUnreadPostsCount, - isInMultiSelectMode = isInMultiSelectMode, - selected = selectedSources.any { it.id == source.id }, - onFeedGroupSelected = onToggleSourceSelection, - onFeedGroupClick = onSourceClick, - modifier = - Modifier.padding( - start = startPadding, - top = topPadding, - end = endPadding, - bottom = bottomPadding - ), - ) - } - is Feed -> { - FeedListItem( - feed = source, - canShowUnreadPostsCount = canShowUnreadPostsCount, - isInMultiSelectMode = isInMultiSelectMode, - isFeedSelected = selectedSources.any { it.id == source.id }, - onFeedClick = onSourceClick, - onFeedSelected = onToggleSourceSelection, - modifier = - Modifier.padding( - start = startPadding, - top = topPadding, - end = endPadding, - bottom = bottomPadding - ), - ) - } - } - } - } - } - - item(span = { GridItemSpan(2) }) { - HorizontalDivider( - modifier = Modifier.padding(top = 24.dp), - color = AppTheme.colorScheme.tintedSurface - ) - } - } -} - -private fun bottomPaddingOfFeedListItem(index: Int, itemCount: Int) = - when { - index < itemCount -> 8.dp - else -> 0.dp - } - -private fun topPaddingOfFeedListItem(gridItemSpan: GridItemSpan, index: Int) = - when { - gridItemSpan.currentLineSpan == 2 && index > 0 -> 8.dp - gridItemSpan.currentLineSpan == 1 && index > 1 -> 8.dp - else -> 0.dp - } - -private fun endPaddingOfFeedListItem(gridItemSpan: GridItemSpan, index: Int) = - when { - gridItemSpan.currentLineSpan == 2 || (gridItemSpan.currentLineSpan == 1 && index % 2 == 1) -> - 24.dp - else -> 8.dp - } - -private fun startPaddingOfFeedListItem(gridItemSpan: GridItemSpan, index: Int) = - when { - gridItemSpan.currentLineSpan == 2 || (gridItemSpan.currentLineSpan == 1 && index % 2 == 0) -> - 24.dp - else -> 8.dp - } - -@Composable -private fun AllFeedsHeader( - feedsCount: Int, - feedsSortOrder: FeedsOrderBy, - onFeedsSortChanged: (FeedsOrderBy) -> Unit, - modifier: Modifier = Modifier -) { - 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) } - - Text( - text = LocalStrings.current.allFeeds, - style = MaterialTheme.typography.titleMedium, - color = AppTheme.colorScheme.textEmphasisHigh, - ) - - 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 - FeedsOrderBy.Pinned -> { - throw IllegalStateException( - "Cannot use the following feed sort order here: $feedsSortOrder" - ) - } - } - - 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 - ) - } - - DropdownMenu( - modifier = Modifier.requiredWidth(132.dp), - expanded = showSortDropdown, - onDismissRequest = { showSortDropdown = false } - ) { - FeedsOrderBy.entries - .filter { it != FeedsOrderBy.Pinned } - .forEach { sortOrder -> - val label = - when (sortOrder) { - FeedsOrderBy.Latest -> LocalStrings.current.feedsSortLatest - FeedsOrderBy.Oldest -> LocalStrings.current.feedsSortOldest - FeedsOrderBy.Alphabetical -> LocalStrings.current.feedsSortAlphabetical - FeedsOrderBy.Pinned -> { - throw IllegalStateException( - "Cannot use the following feed sort order here: $feedsSortOrder" - ) - } - } - - val color = - if (feedsSortOrder == sortOrder) { - AppTheme.colorScheme.tintedSurface - } else { - Color.Unspecified - } - 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) } - ) - } - } - } - } -} - -@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/FeedsBottomSheet.kt b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/feeds/ui/FeedsBottomSheet.kt index fdb749ee0..3863a7639 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 @@ -28,6 +28,8 @@ import app.cash.paging.compose.collectAsLazyPagingItems import dev.sasikanth.rss.reader.feeds.FeedsEffect import dev.sasikanth.rss.reader.feeds.FeedsEvent import dev.sasikanth.rss.reader.feeds.FeedsPresenter +import dev.sasikanth.rss.reader.feeds.ui.expanded.BottomSheetExpandedContent +import dev.sasikanth.rss.reader.feeds.ui.expanded.pinnedSources import dev.sasikanth.rss.reader.utils.inverse @Composable @@ -63,39 +65,12 @@ internal fun FeedsBottomSheet( pinnedSources = state.pinnedSources.collectAsLazyPagingItems(), activeSource = state.activeSource, canShowUnreadPostsCount = state.canShowUnreadPostsCount, - onSourceClick = { feed -> feedsPresenter.dispatch(FeedsEvent.OnFeedClick(feed)) }, + onSourceClick = { feed -> feedsPresenter.dispatch(FeedsEvent.OnSourceClick(feed)) }, onHomeSelected = { feedsPresenter.dispatch(FeedsEvent.OnHomeSelected) } ) } else { BottomSheetExpandedContent( - numberOfFeeds = state.numberOfFeeds, - numberOfFeedGroups = state.numberOfFeedGroups, - pinnedSources = state.pinnedSources.collectAsLazyPagingItems(), - sources = state.sources.collectAsLazyPagingItems(), - searchResults = state.feedsSearchResults.collectAsLazyPagingItems(), - selectedSources = state.selectedSources, - searchQuery = feedsPresenter.searchQuery, - feedsSortOrder = state.feedsSortOrder, - feedsViewMode = state.feedsViewMode, - isPinnedSectionExpanded = state.isPinnedSectionExpanded, - isInMultiSelectMode = state.isInMultiSelectMode, - canShowUnreadPostsCount = state.canShowUnreadPostsCount, - onSearchQueryChanged = { feedsPresenter.dispatch(FeedsEvent.SearchQueryChanged(it)) }, - onClearSearchQuery = { feedsPresenter.dispatch(FeedsEvent.ClearSearchQuery) }, - onFeedClick = { feedsPresenter.dispatch(FeedsEvent.OnFeedClick(it)) }, - onToggleSourceSelection = { feedsPresenter.dispatch(FeedsEvent.OnToggleFeedSelection(it)) }, - onTogglePinnedSection = { feedsPresenter.dispatch(FeedsEvent.TogglePinnedSection) }, - onFeedsSortChanged = { feedsPresenter.dispatch(FeedsEvent.OnFeedSortOrderChanged(it)) }, - onChangeFeedsViewModeClick = { - feedsPresenter.dispatch(FeedsEvent.OnChangeFeedsViewModeClick) - }, - onCancelFeedsSelection = { feedsPresenter.dispatch(FeedsEvent.CancelSourcesSelection) }, - onPinSelectedFeeds = { feedsPresenter.dispatch(FeedsEvent.PinSelectedSources) }, - onUnPinSelectedFeeds = { feedsPresenter.dispatch(FeedsEvent.UnPinSelectedSources) }, - onDeleteSelectedFeeds = { feedsPresenter.dispatch(FeedsEvent.DeleteSelectedSources) }, - onCreateGroup = { feedsPresenter.dispatch(FeedsEvent.OnCreateGroup(it)) }, - onAddToGroupClicked = { feedsPresenter.dispatch(FeedsEvent.OnAddToGroupClicked) }, - onEditSource = { feedsPresenter.dispatch(FeedsEvent.OnEditSourceClicked(it)) }, + feedsPresenter = feedsPresenter, modifier = Modifier.graphicsLayer { val threshold = 0.3 diff --git a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/feeds/ui/expanded/BottomSheetExpandedContent.kt b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/feeds/ui/expanded/BottomSheetExpandedContent.kt new file mode 100644 index 000000000..0471814f6 --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/feeds/ui/expanded/BottomSheetExpandedContent.kt @@ -0,0 +1,362 @@ +/* + * 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.expanded + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxSize +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.requiredWidth +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.lazy.grid.GridItemSpan +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Edit +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.Search +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.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.graphics.Color +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.unit.dp +import app.cash.paging.compose.collectAsLazyPagingItems +import dev.sasikanth.rss.reader.components.ContextActionItem +import dev.sasikanth.rss.reader.components.ContextActionsBottomBar +import dev.sasikanth.rss.reader.core.model.local.FeedGroup +import dev.sasikanth.rss.reader.feeds.FeedsEvent +import dev.sasikanth.rss.reader.feeds.FeedsPresenter +import dev.sasikanth.rss.reader.feeds.ui.BottomSheetExpandedBottomBar +import dev.sasikanth.rss.reader.feeds.ui.CreateGroupDialog +import dev.sasikanth.rss.reader.feeds.ui.FeedsViewMode +import dev.sasikanth.rss.reader.resources.icons.Delete +import dev.sasikanth.rss.reader.resources.icons.NewGroup +import dev.sasikanth.rss.reader.resources.icons.Pin +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.utils.Constants +import dev.sasikanth.rss.reader.utils.KeyboardState +import dev.sasikanth.rss.reader.utils.keyboardVisibilityAsState + +@Composable +internal fun BottomSheetExpandedContent( + feedsPresenter: FeedsPresenter, + modifier: Modifier = Modifier +) { + val state by feedsPresenter.state.collectAsState() + val searchQuery = feedsPresenter.searchQuery + val feedsViewMode = state.feedsViewMode + + var showNewGroupDialog by remember { mutableStateOf(false) } + + Scaffold( + modifier = Modifier.fillMaxSize().consumeWindowInsets(WindowInsets.statusBars).then(modifier), + topBar = { + SearchBar( + query = searchQuery, + feedsViewMode = feedsViewMode, + onQueryChange = { feedsPresenter.dispatch(FeedsEvent.SearchQueryChanged(it)) }, + onClearClick = { feedsPresenter.dispatch(FeedsEvent.ClearSearchQuery) }, + onChangeFeedsViewModeClick = { + feedsPresenter.dispatch(FeedsEvent.OnChangeFeedsViewModeClick) + } + ) + }, + bottomBar = { + Box(contentAlignment = Alignment.BottomCenter) { + AnimatedVisibility( + visible = !state.isInMultiSelectMode, + enter = slideInVertically { it }, + exit = slideOutVertically { it } + ) { + BottomSheetExpandedBottomBar( + onNewGroupClick = { showNewGroupDialog = true }, + onNewFeedClick = { + // TODO: Open feed creation dialog/sheet/screen + } + ) + } + + AnimatedVisibility( + visible = state.isInMultiSelectMode, + enter = slideInVertically { it }, + exit = slideOutVertically { it } + ) { + val areGroupsSelected = state.selectedSources.any { it is FeedGroup } + val tooltip: @Composable (() -> Unit)? = + if (areGroupsSelected) { + { Text(text = LocalStrings.current.actionGroupsTooltip) } + } else { + null + } + + ContextActionsBottomBar( + tooltip = tooltip, + onCancel = { feedsPresenter.dispatch(FeedsEvent.CancelSourcesSelection) } + ) { + val areSelectedFeedsPinned = state.selectedSources.all { it.pinnedAt != null } + + val label = + if (areSelectedFeedsPinned) LocalStrings.current.actionUnpin + else LocalStrings.current.actionPin + + ContextActionItem( + modifier = Modifier.weight(1f), + icon = TwineIcons.Pin, + label = label, + onClick = { + if (areSelectedFeedsPinned) { + feedsPresenter.dispatch(FeedsEvent.UnPinSelectedSources) + } else { + feedsPresenter.dispatch(FeedsEvent.PinSelectedSources) + } + } + ) + + ContextActionItem( + modifier = Modifier.weight(1f), + icon = TwineIcons.NewGroup, + label = LocalStrings.current.actionAddTo, + enabled = !areGroupsSelected, + onClick = { feedsPresenter.dispatch(FeedsEvent.OnAddToGroupClicked) } + ) + + ContextActionItem( + modifier = Modifier.weight(1f), + icon = TwineIcons.Delete, + label = LocalStrings.current.actionDelete, + onClick = { feedsPresenter.dispatch(FeedsEvent.DeleteSelectedSources) } + ) + + if (state.selectedSources.size == 1) { + ContextActionItem( + modifier = Modifier.weight(1f), + icon = Icons.Filled.Edit, + label = LocalStrings.current.edit, + onClick = { + feedsPresenter.dispatch( + FeedsEvent.OnEditSourceClicked(state.selectedSources.first()) + ) + } + ) + } + } + } + } + }, + containerColor = AppTheme.colorScheme.tintedBackground + ) { padding -> + val pinnedSources = state.pinnedSources.collectAsLazyPagingItems() + val allSources = state.sources.collectAsLazyPagingItems() + val searchResults = state.feedsSearchResults.collectAsLazyPagingItems() + + val isInSearchMode = + searchResults.itemCount == 0 && + searchQuery.text.length < Constants.MINIMUM_REQUIRED_SEARCH_CHARACTERS + + val imeBottomPadding = WindowInsets.ime.asPaddingValues().calculateBottomPadding() + val gridItemSpan = + when (state.feedsViewMode) { + FeedsViewMode.Grid -> GridItemSpan(1) + FeedsViewMode.List -> GridItemSpan(2) + } + + SourcesGrid( + modifier = + Modifier.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 + ), + pinnedSources = { + pinnedSources( + pinnedSources = pinnedSources, + selectedSources = state.selectedSources, + isPinnedSectionExpanded = state.isPinnedSectionExpanded, + canShowUnreadPostsCount = state.canShowUnreadPostsCount, + gridItemSpan = gridItemSpan, + isInMultiSelectMode = state.isInMultiSelectMode, + onTogglePinnedSection = { feedsPresenter.dispatch(FeedsEvent.TogglePinnedSection) }, + onSourceClick = { feedsPresenter.dispatch(FeedsEvent.OnSourceClick(it)) }, + onToggleSourceSelection = { + feedsPresenter.dispatch(FeedsEvent.OnToggleFeedSelection(it)) + } + ) + }, + allSources = { + allSources( + numberOfFeeds = state.numberOfFeeds, + numberOfFeedGroups = state.numberOfFeedGroups, + sources = allSources, + selectedSources = state.selectedSources, + feedsSortOrder = state.feedsSortOrder, + canShowUnreadPostsCount = state.canShowUnreadPostsCount, + isInMultiSelectMode = state.isInMultiSelectMode, + gridItemSpan = gridItemSpan, + onFeedsSortChanged = { feedsPresenter.dispatch(FeedsEvent.OnFeedSortOrderChanged(it)) }, + onSourceClick = { feedsPresenter.dispatch(FeedsEvent.OnSourceClick(it)) }, + onToggleSourceSelection = { + feedsPresenter.dispatch(FeedsEvent.OnToggleFeedSelection(it)) + } + ) + }, + searchResults = { + sourcesSearchResults( + searchResults = searchResults, + selectedSources = state.selectedSources, + canShowUnreadPostsCount = state.canShowUnreadPostsCount, + isInMultiSelectMode = state.isInMultiSelectMode, + gridItemSpan = gridItemSpan, + onSourceClick = { feedsPresenter.dispatch(FeedsEvent.OnSourceClick(it)) }, + onToggleSourceSelection = { + feedsPresenter.dispatch(FeedsEvent.OnToggleFeedSelection(it)) + } + ) + }, + isInSearchMode = isInSearchMode, + padding = padding + ) + + if (showNewGroupDialog) { + CreateGroupDialog( + onCreateGroup = { feedsPresenter.dispatch(FeedsEvent.OnCreateGroup(it)) }, + onDismiss = { showNewGroupDialog = false } + ) + } + } +} + +@Composable +private fun SearchBar( + query: TextFieldValue, + feedsViewMode: FeedsViewMode, + onQueryChange: (TextFieldValue) -> Unit, + onClearClick: () -> Unit, + onChangeFeedsViewModeClick: () -> Unit, +) { + val keyboardState by keyboardVisibilityAsState() + val focusManager = LocalFocusManager.current + + LaunchedEffect(keyboardState) { + if (keyboardState == KeyboardState.Closed) { + focusManager.clearFocus() + } + } + + 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()) { + IconButton(onClick = onClearClick) { + Icon( + Icons.Rounded.Close, + contentDescription = null, + tint = AppTheme.colorScheme.tintedForeground + ) + } + } + }, + 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)) + } +} diff --git a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/feeds/ui/expanded/SourcesAll.kt b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/feeds/ui/expanded/SourcesAll.kt new file mode 100644 index 000000000..58735795a --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/feeds/ui/expanded/SourcesAll.kt @@ -0,0 +1,292 @@ +/* + * 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.expanded + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredHeight +import androidx.compose.foundation.layout.requiredWidth +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.grid.GridItemSpan +import androidx.compose.foundation.lazy.grid.LazyGridScope +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ExpandMore +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +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.graphics.Color +import androidx.compose.ui.unit.dp +import app.cash.paging.compose.LazyPagingItems +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.core.model.local.FeedGroup +import dev.sasikanth.rss.reader.core.model.local.Source +import dev.sasikanth.rss.reader.core.model.local.SourceType +import dev.sasikanth.rss.reader.feeds.SourceListItem +import dev.sasikanth.rss.reader.feeds.ui.FeedGroupItem +import dev.sasikanth.rss.reader.feeds.ui.FeedListItem +import dev.sasikanth.rss.reader.repository.FeedsOrderBy +import dev.sasikanth.rss.reader.resources.strings.LocalStrings +import dev.sasikanth.rss.reader.ui.AppTheme + +internal fun LazyGridScope.allSources( + numberOfFeeds: Int, + numberOfFeedGroups: Int, + sources: LazyPagingItems, + selectedSources: Set, + feedsSortOrder: FeedsOrderBy, + canShowUnreadPostsCount: Boolean, + isInMultiSelectMode: Boolean, + gridItemSpan: GridItemSpan, + onFeedsSortChanged: (FeedsOrderBy) -> Unit, + onSourceClick: (Source) -> Unit, + onToggleSourceSelection: (Source) -> Unit +) { + if (sources.itemCount > 0) { + item(key = "AllFeedsHeader", span = { GridItemSpan(2) }) { + AllFeedsHeader( + feedsCount = numberOfFeeds, + feedsSortOrder = feedsSortOrder, + onFeedsSortChanged = onFeedsSortChanged + ) + } + + val feedGroupGridItemSpan = GridItemSpan(1) + items( + count = sources.itemCount, + key = + sources.itemKey { + when (it) { + SourceListItem.Separator -> "Separator" + is SourceListItem.SourceItem -> it.source.id + } + }, + contentType = { + when (val sourceItem = sources[it]) { + SourceListItem.Separator -> "Separator" + is SourceListItem.SourceItem -> { + if (sourceItem.source.sourceType == SourceType.FeedGroup) { + "FeedGroupItem" + } else { + "FeedListItem" + } + } + null -> null + } + }, + span = { + when (val sourceItem = sources[it]) { + SourceListItem.Separator -> GridItemSpan(2) + is SourceListItem.SourceItem -> { + when (sourceItem.source) { + is FeedGroup -> feedGroupGridItemSpan + is Feed -> gridItemSpan + else -> GridItemSpan(2) + } + } + null -> GridItemSpan(2) + } + } + ) { index -> + when (val sourceItem = sources[index]) { + SourceListItem.Separator -> { + Spacer(Modifier.requiredHeight(40.dp)) + } + is SourceListItem.SourceItem -> { + when (val source = sourceItem.source) { + is FeedGroup -> { + val startPadding = startPaddingOfSourceItem(feedGroupGridItemSpan, index) + val endPadding = endPaddingOfSourceItem(feedGroupGridItemSpan, index) + val topPadding = topPaddingOfSourceItem(feedGroupGridItemSpan, index) + val bottomPadding = bottomPaddingOfSourceItem(index, sources.itemCount) + + FeedGroupItem( + feedGroup = source, + canShowUnreadPostsCount = canShowUnreadPostsCount, + isInMultiSelectMode = isInMultiSelectMode, + selected = selectedSources.any { it.id == source.id }, + onFeedGroupSelected = onToggleSourceSelection, + onFeedGroupClick = onSourceClick, + modifier = + Modifier.padding( + start = startPadding, + top = topPadding, + end = endPadding, + bottom = bottomPadding + ), + ) + } + is Feed -> { + // When there are even number of feed groups, we are offsetting the index + // to make sure the spacing is properly applied to feed list items after the + // separator. + val transformedIndex = + if (numberOfFeedGroups % 2 == 0) { + index - 1 + } else { + index + } + val startPadding = startPaddingOfSourceItem(gridItemSpan, transformedIndex) + val endPadding = endPaddingOfSourceItem(gridItemSpan, transformedIndex) + val topPadding = topPaddingOfSourceItem(gridItemSpan, transformedIndex) + val bottomPadding = bottomPaddingOfSourceItem(transformedIndex, sources.itemCount) + + FeedListItem( + feed = source, + canShowUnreadPostsCount = canShowUnreadPostsCount, + isInMultiSelectMode = isInMultiSelectMode, + isFeedSelected = selectedSources.any { it.id == source.id }, + onFeedClick = onSourceClick, + onFeedSelected = onToggleSourceSelection, + modifier = + Modifier.padding( + start = startPadding, + top = topPadding, + end = endPadding, + bottom = bottomPadding + ) + ) + } + } + } + null -> { + // no-op + } + } + } + } +} + +@Composable +private fun AllFeedsHeader( + feedsCount: Int, + feedsSortOrder: FeedsOrderBy, + onFeedsSortChanged: (FeedsOrderBy) -> Unit, + modifier: Modifier = Modifier +) { + 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) } + + Text( + text = LocalStrings.current.allFeeds, + style = MaterialTheme.typography.titleMedium, + color = AppTheme.colorScheme.textEmphasisHigh, + ) + + 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 + FeedsOrderBy.Pinned -> { + throw IllegalStateException( + "Cannot use the following feed sort order here: $feedsSortOrder" + ) + } + } + + 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 + ) + } + + DropdownMenu( + modifier = Modifier.requiredWidth(132.dp), + expanded = showSortDropdown, + onDismissRequest = { showSortDropdown = false } + ) { + FeedsOrderBy.entries + .filter { it != FeedsOrderBy.Pinned } + .forEach { sortOrder -> + val label = + when (sortOrder) { + FeedsOrderBy.Latest -> LocalStrings.current.feedsSortLatest + FeedsOrderBy.Oldest -> LocalStrings.current.feedsSortOldest + FeedsOrderBy.Alphabetical -> LocalStrings.current.feedsSortAlphabetical + FeedsOrderBy.Pinned -> { + throw IllegalStateException( + "Cannot use the following feed sort order here: $feedsSortOrder" + ) + } + } + + val color = + if (feedsSortOrder == sortOrder) { + AppTheme.colorScheme.tintedSurface + } else { + Color.Unspecified + } + 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) } + ) + } + } + } + } +} diff --git a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/feeds/ui/expanded/SourcesGrid.kt b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/feeds/ui/expanded/SourcesGrid.kt new file mode 100644 index 000000000..280d35e93 --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/feeds/ui/expanded/SourcesGrid.kt @@ -0,0 +1,91 @@ +/* + * 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.expanded + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.calculateEndPadding +import androidx.compose.foundation.layout.calculateStartPadding +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +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.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.unit.dp + +@Composable +internal fun SourcesGrid( + pinnedSources: LazyGridScope.() -> Unit, + allSources: LazyGridScope.() -> Unit, + searchResults: LazyGridScope.() -> Unit, + isInSearchMode: Boolean, + padding: PaddingValues, + modifier: Modifier = Modifier +) { + val layoutDirection = LocalLayoutDirection.current + + LazyVerticalGrid( + modifier = Modifier.fillMaxSize().then(modifier), + columns = GridCells.Fixed(2), + contentPadding = + PaddingValues( + start = padding.calculateStartPadding(layoutDirection), + end = padding.calculateEndPadding(layoutDirection), + bottom = padding.calculateBottomPadding() + 64.dp, + top = 8.dp + ), + ) { + if (isInSearchMode) { + pinnedSources() + allSources() + } else { + searchResults() + } + } +} + +internal fun bottomPaddingOfSourceItem(index: Int, itemCount: Int) = + when { + index < itemCount -> 8.dp + else -> 0.dp + } + +internal fun topPaddingOfSourceItem(gridItemSpan: GridItemSpan, index: Int) = + when { + gridItemSpan.currentLineSpan == 2 && index > 0 -> 8.dp + gridItemSpan.currentLineSpan == 1 && index > 1 -> 8.dp + else -> 0.dp + } + +internal fun endPaddingOfSourceItem(gridItemSpan: GridItemSpan, index: Int) = + when { + gridItemSpan.currentLineSpan == 2 || (gridItemSpan.currentLineSpan == 1 && index % 2 == 1) -> + 24.dp + else -> 8.dp + } + +internal fun startPaddingOfSourceItem(gridItemSpan: GridItemSpan, index: Int) = + when { + gridItemSpan.currentLineSpan == 2 || (gridItemSpan.currentLineSpan == 1 && index % 2 == 0) -> + 24.dp + else -> 8.dp + } diff --git a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/feeds/ui/expanded/SourcesPinned.kt b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/feeds/ui/expanded/SourcesPinned.kt new file mode 100644 index 000000000..f4d6115b1 --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/feeds/ui/expanded/SourcesPinned.kt @@ -0,0 +1,167 @@ +/* + * 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.expanded + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredWidth +import androidx.compose.foundation.lazy.grid.GridItemSpan +import androidx.compose.foundation.lazy.grid.LazyGridScope +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ExpandLess +import androidx.compose.material.icons.filled.ExpandMore +import androidx.compose.material3.HorizontalDivider +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.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import app.cash.paging.compose.LazyPagingItems +import app.cash.paging.compose.itemKey +import dev.sasikanth.rss.reader.core.model.local.Feed +import dev.sasikanth.rss.reader.core.model.local.FeedGroup +import dev.sasikanth.rss.reader.core.model.local.Source +import dev.sasikanth.rss.reader.core.model.local.SourceType +import dev.sasikanth.rss.reader.feeds.ui.FeedGroupItem +import dev.sasikanth.rss.reader.feeds.ui.FeedListItem +import dev.sasikanth.rss.reader.resources.strings.LocalStrings +import dev.sasikanth.rss.reader.ui.AppTheme + +internal fun LazyGridScope.pinnedSources( + pinnedSources: LazyPagingItems, + selectedSources: Set, + isPinnedSectionExpanded: Boolean, + canShowUnreadPostsCount: Boolean, + isInMultiSelectMode: Boolean, + gridItemSpan: GridItemSpan, + onTogglePinnedSection: () -> Unit, + onSourceClick: (Source) -> Unit, + onToggleSourceSelection: (Source) -> Unit, +) { + if (pinnedSources.itemCount > 0) { + item(key = "PinnedFeedsHeader", span = { GridItemSpan(2) }) { + PinnedFeedsHeader( + isPinnedSectionExpanded = isPinnedSectionExpanded, + onToggleSection = onTogglePinnedSection + ) + } + + if (isPinnedSectionExpanded) { + items( + count = pinnedSources.itemCount, + key = pinnedSources.itemKey { "PinnedSource:${it.id}" }, + contentType = { + if (pinnedSources[it]?.sourceType == SourceType.FeedGroup) { + "FeedGroupItem" + } else { + "FeedListItem" + } + }, + span = { gridItemSpan } + ) { index -> + val source = pinnedSources[index] + if (source != null) { + val startPadding = startPaddingOfSourceItem(gridItemSpan, index) + val endPadding = endPaddingOfSourceItem(gridItemSpan, index) + val topPadding = topPaddingOfSourceItem(gridItemSpan, index) + val bottomPadding = bottomPaddingOfSourceItem(index, pinnedSources.itemCount) + + when (source) { + is FeedGroup -> { + FeedGroupItem( + feedGroup = source, + canShowUnreadPostsCount = canShowUnreadPostsCount, + isInMultiSelectMode = isInMultiSelectMode, + selected = selectedSources.any { it.id == source.id }, + onFeedGroupSelected = onToggleSourceSelection, + onFeedGroupClick = onSourceClick, + modifier = + Modifier.padding( + start = startPadding, + top = topPadding, + end = endPadding, + bottom = bottomPadding + ), + ) + } + is Feed -> { + FeedListItem( + feed = source, + canShowUnreadPostsCount = canShowUnreadPostsCount, + isInMultiSelectMode = isInMultiSelectMode, + isFeedSelected = selectedSources.any { it.id == source.id }, + onFeedClick = onSourceClick, + onFeedSelected = onToggleSourceSelection, + modifier = + Modifier.padding( + start = startPadding, + top = topPadding, + end = endPadding, + bottom = bottomPadding + ), + ) + } + } + } + } + } + + item(span = { GridItemSpan(2) }) { + HorizontalDivider( + modifier = Modifier.padding(top = 24.dp), + 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/expanded/SourcesSearchResults.kt b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/feeds/ui/expanded/SourcesSearchResults.kt new file mode 100644 index 000000000..96e6b3def --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/feeds/ui/expanded/SourcesSearchResults.kt @@ -0,0 +1,68 @@ +/* + * 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.expanded + +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.grid.GridItemSpan +import androidx.compose.foundation.lazy.grid.LazyGridScope +import androidx.compose.ui.Modifier +import app.cash.paging.compose.LazyPagingItems +import app.cash.paging.compose.itemKey +import dev.sasikanth.rss.reader.core.model.local.Feed +import dev.sasikanth.rss.reader.core.model.local.Source +import dev.sasikanth.rss.reader.feeds.ui.FeedListItem + +internal fun LazyGridScope.sourcesSearchResults( + searchResults: LazyPagingItems, + selectedSources: Set, + canShowUnreadPostsCount: Boolean, + isInMultiSelectMode: Boolean, + gridItemSpan: GridItemSpan, + onSourceClick: (Source) -> Unit, + onToggleSourceSelection: (Source) -> Unit, +) { + items( + count = searchResults.itemCount, + key = searchResults.itemKey { "SearchResult:${it.id}" }, + contentType = { "FeedListItem" }, + span = { gridItemSpan } + ) { index -> + val feed = searchResults[index] + val startPadding = startPaddingOfSourceItem(gridItemSpan, index) + val endPadding = endPaddingOfSourceItem(gridItemSpan, index) + val topPadding = topPaddingOfSourceItem(gridItemSpan, index) + val bottomPadding = bottomPaddingOfSourceItem(index, searchResults.itemCount) + + if (feed != null) { + FeedListItem( + feed = feed, + canShowUnreadPostsCount = canShowUnreadPostsCount, + isInMultiSelectMode = isInMultiSelectMode, + isFeedSelected = selectedSources.any { it.id == feed.id }, + onFeedClick = onSourceClick, + onFeedSelected = onToggleSourceSelection, + modifier = + Modifier.padding( + start = startPadding, + top = topPadding, + end = endPadding, + bottom = bottomPadding + ) + ) + } + } +} From 752fa04e099a9222b173a328303a5a1981efe503 Mon Sep 17 00:00:00 2001 From: Sasikanth Miriyampalli Date: Sat, 27 Apr 2024 13:10:48 +0530 Subject: [PATCH 5/6] Bump Compose to v1.6.10-beta02 (#485) --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 516ede6f3..9cd01649c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,7 +1,7 @@ [versions] kotlin = "1.9.23" android_gradle_plugin = "8.3.2" -compose = "1.6.2" +compose = "1.6.10-beta02" android_sdk_compile = "34" android_sdk_target = "34" From 703bcb2003b8d927b518364410d71f18a5e79f55 Mon Sep 17 00:00:00 2001 From: Sasikanth Miriyampalli Date: Sat, 27 Apr 2024 21:06:05 +0530 Subject: [PATCH 6/6] Clear active source if deleted sources contains active source --- .../dev/sasikanth/rss/reader/feeds/FeedsPresenter.kt | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) 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 1e21ac750..4b959019a 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 @@ -216,7 +216,12 @@ class FeedsPresenter( private fun onDeleteSelectedSources() { coroutineScope .launch { rssRepository.deleteSources(_state.value.selectedSources) } - .invokeOnCompletion { dispatch(FeedsEvent.CancelSourcesSelection) } + .invokeOnCompletion { + if (_state.value.selectedSources.any { it.id == _state.value.activeSource?.id }) { + _state.update { it.copy(activeSource = null) } + } + dispatch(FeedsEvent.CancelSourcesSelection) + } } private fun onCancelSourcesSelection() {