From fa4bebb4adf9fd11e524409a6c805f7f43264121 Mon Sep 17 00:00:00 2001 From: Konstantin Ivanov <54908981+konstantiniiv@users.noreply.github.com> Date: Wed, 2 Oct 2024 23:34:19 +0200 Subject: [PATCH] DROID-2886 All content | Enhancement | Paging (#1621) --- .../anytype/di/feature/AllContentDI.kt | 3 - .../ui/allcontent/AllContentFragment.kt | 12 +- .../models/AllContentModels.kt | 80 +---- .../models/AllContentSearchParams.kt | 7 +- .../presentation/AllContentViewModel.kt | 318 +++++++++--------- .../AllContentViewModelFactory.kt | 4 - .../feature_allcontent/ui/AllContentMenu.kt | 4 +- .../feature_allcontent/ui/AllContentScreen.kt | 167 ++++++--- .../ui/AllContentTopToolbar.kt | 73 ++-- 9 files changed, 341 insertions(+), 327 deletions(-) diff --git a/app/src/main/java/com/anytypeio/anytype/di/feature/AllContentDI.kt b/app/src/main/java/com/anytypeio/anytype/di/feature/AllContentDI.kt index 21675932e1..09a3bfc718 100644 --- a/app/src/main/java/com/anytypeio/anytype/di/feature/AllContentDI.kt +++ b/app/src/main/java/com/anytypeio/anytype/di/feature/AllContentDI.kt @@ -14,7 +14,6 @@ import com.anytypeio.anytype.domain.library.StorelessSubscriptionContainer import com.anytypeio.anytype.domain.misc.LocaleProvider import com.anytypeio.anytype.domain.misc.UrlBuilder import com.anytypeio.anytype.domain.objects.StoreOfObjectTypes -import com.anytypeio.anytype.domain.objects.StoreOfRelations import com.anytypeio.anytype.domain.search.SearchObjects import com.anytypeio.anytype.domain.search.SubscriptionEventChannel import com.anytypeio.anytype.feature_allcontent.presentation.AllContentViewModel @@ -26,7 +25,6 @@ import dagger.BindsInstance import dagger.Component import dagger.Module import dagger.Provides -import javax.inject.Named @Component( dependencies = [AllContentDependencies::class], @@ -112,7 +110,6 @@ interface AllContentDependencies : ComponentDependencies { fun urlBuilder(): UrlBuilder fun dispatchers(): AppCoroutineDispatchers fun storeOfObjectTypes(): StoreOfObjectTypes - fun storeOfRelations(): StoreOfRelations fun analyticsHelper(): AnalyticSpaceHelperDelegate fun userSettingsRepository(): UserSettingsRepository fun subEventChannel(): SubscriptionEventChannel diff --git a/app/src/main/java/com/anytypeio/anytype/ui/allcontent/AllContentFragment.kt b/app/src/main/java/com/anytypeio/anytype/ui/allcontent/AllContentFragment.kt index 27e5ed5b82..8a049ef872 100644 --- a/app/src/main/java/com/anytypeio/anytype/ui/allcontent/AllContentFragment.kt +++ b/app/src/main/java/com/anytypeio/anytype/ui/allcontent/AllContentFragment.kt @@ -25,8 +25,8 @@ import com.anytypeio.anytype.core_utils.ui.BaseComposeFragment import com.anytypeio.anytype.di.common.componentManager import com.anytypeio.anytype.feature_allcontent.presentation.AllContentViewModel import com.anytypeio.anytype.feature_allcontent.presentation.AllContentViewModelFactory -import com.anytypeio.anytype.feature_allcontent.ui.AllContentWrapperScreen import com.anytypeio.anytype.feature_allcontent.ui.AllContentNavigation.ALL_CONTENT_MAIN +import com.anytypeio.anytype.feature_allcontent.ui.AllContentWrapperScreen import com.anytypeio.anytype.presentation.widgets.collection.Subscription import com.anytypeio.anytype.ui.base.navigation import com.anytypeio.anytype.ui.settings.typography @@ -106,17 +106,19 @@ class AllContentFragment : BaseComposeFragment() { ) { composable(route = ALL_CONTENT_MAIN) { AllContentWrapperScreen( - uiState = vm.uiState.collectAsStateWithLifecycle().value, + uiItemsState = vm.uiItemsState.collectAsStateWithLifecycle().value, onTabClick = vm::onTabClicked, onQueryChanged = vm::onFilterChanged, uiTabsState = vm.uiTabsState.collectAsStateWithLifecycle().value, uiTitleState = vm.uiTitleState.collectAsStateWithLifecycle().value, - uiMenuButtonViewState = vm.uiMenuButtonState.collectAsStateWithLifecycle().value, - uiMenuState = vm.uiMenu.collectAsStateWithLifecycle().value, + uiMenuState = vm.uiMenuState.collectAsStateWithLifecycle().value, onSortClick = vm::onSortClicked, onModeClick = vm::onAllContentModeClicked, onItemClicked = vm::onItemClicked, - onBinClick = vm::onViewBinClicked + onBinClick = vm::onViewBinClicked, + canPaginate = vm.canPaginate.collectAsStateWithLifecycle().value, + onUpdateLimitSearch = vm::updateLimit, + uiContentState = vm.uiContentState.collectAsStateWithLifecycle().value, ) } } diff --git a/feature-all-content/src/main/java/com/anytypeio/anytype/feature_allcontent/models/AllContentModels.kt b/feature-all-content/src/main/java/com/anytypeio/anytype/feature_allcontent/models/AllContentModels.kt index 885080c361..b9306fbd26 100644 --- a/feature-all-content/src/main/java/com/anytypeio/anytype/feature_allcontent/models/AllContentModels.kt +++ b/feature-all-content/src/main/java/com/anytypeio/anytype/feature_allcontent/models/AllContentModels.kt @@ -18,27 +18,11 @@ import com.anytypeio.anytype.presentation.objects.getProperName import com.anytypeio.anytype.presentation.objects.getProperType //region STATE -sealed class AllContentState { - data object Init : AllContentState() - data class Default( - val activeTab: AllContentTab, - val activeMode: AllContentMode, - val activeSort: AllContentSort, - val filter: String, - val limit: Int - ) : AllContentState() -} - @Immutable enum class AllContentTab { PAGES, LISTS, MEDIA, BOOKMARKS, FILES, TYPES } -sealed class AllContentMode { - data object AllContent : AllContentMode() - data object Unlinked : AllContentMode() -} - sealed class AllContentMenuMode { abstract val isSelected: Boolean @@ -89,12 +73,6 @@ sealed class UiTitleState { data object OnlyUnlinked : UiTitleState() } -//MENU BUTTON -sealed class MenuButtonViewState { - data object Hidden : MenuButtonViewState() - data object Visible : MenuButtonViewState() -} - // TABS @Immutable sealed class UiTabsState { @@ -109,19 +87,13 @@ sealed class UiTabsState { // CONTENT sealed class UiContentState { - - data object Hidden : UiContentState() - - data object Loading : UiContentState() - + data class Idle(val scrollToTop: Boolean = false) : UiContentState() + data object InitLoading : UiContentState() + data object Paging : UiContentState() + data object Empty : UiContentState() data class Error( val message: String, ) : UiContentState() - - @Immutable - data class Content( - val items: List, - ) : UiContentState() } // ITEMS @@ -160,25 +132,21 @@ sealed class UiContentItem { // MENU @Immutable -data class UiMenuState( - val mode: List, - val container: MenuSortsItem.Container, - val sorts: List, - val types: List, - val showBin: Boolean = true -) { - companion object { - fun empty(): UiMenuState { - return UiMenuState( - mode = emptyList(), - container = MenuSortsItem.Container(AllContentSort.ByName()), - sorts = emptyList(), - types = emptyList() - ) - } - } +sealed class UiMenuState{ + + data object Hidden : UiMenuState() + + @Immutable + data class Visible( + val mode: List, + val container: MenuSortsItem.Container, + val sorts: List, + val types: List, + val showBin: Boolean = true + ) : UiMenuState() } + sealed class MenuSortsItem { data class Container(val sort: AllContentSort) : MenuSortsItem() data class Sort(val sort: AllContentSort) : MenuSortsItem() @@ -192,20 +160,6 @@ sealed class MenuSortsItem { //endregion //region MAPPING -fun AllContentState.Default.toMenuMode(): AllContentMenuMode { - return when (activeMode) { - AllContentMode.AllContent -> AllContentMenuMode.AllContent(isSelected = true) - AllContentMode.Unlinked -> AllContentMenuMode.Unlinked(isSelected = true) - } -} - -fun AllContentMode.view(): UiTitleState { - return when (this) { - AllContentMode.AllContent -> UiTitleState.AllContent - AllContentMode.Unlinked -> UiTitleState.OnlyUnlinked - } -} - fun Key?.mapRelationKeyToSort(): AllContentSort { return when (this) { Relations.CREATED_DATE -> AllContentSort.ByDateCreated() diff --git a/feature-all-content/src/main/java/com/anytypeio/anytype/feature_allcontent/models/AllContentSearchParams.kt b/feature-all-content/src/main/java/com/anytypeio/anytype/feature_allcontent/models/AllContentSearchParams.kt index ce880473e5..cb0e75df84 100644 --- a/feature-all-content/src/main/java/com/anytypeio/anytype/feature_allcontent/models/AllContentSearchParams.kt +++ b/feature-all-content/src/main/java/com/anytypeio/anytype/feature_allcontent/models/AllContentSearchParams.kt @@ -38,7 +38,7 @@ val allContentTabLayouts = mapOf( // Function to create subscription params fun createSubscriptionParams( spaceId: Id, - activeMode: AllContentMode, + activeMode: UiTitleState, activeTab: AllContentTab, activeSort: AllContentSort, limitedObjectIds: List, @@ -90,7 +90,7 @@ fun AllContentTab.filtersForSubscribe( spaces: List, activeSort: AllContentSort, limitedObjectIds: List, - activeMode: AllContentMode + activeMode: UiTitleState ): Pair, List> { val tab = this when (this) { @@ -109,7 +109,7 @@ fun AllContentTab.filtersForSubscribe( if (limitedObjectIds.isNotEmpty()) { add(buildLimitedObjectIdsFilter(limitedObjectIds = limitedObjectIds)) } - if (activeMode == AllContentMode.Unlinked) { + if (activeMode == UiTitleState.OnlyUnlinked) { addAll(buildUnlinkedObjectFilter()) } } @@ -133,7 +133,6 @@ fun AllContentTab.filtersForSearch( AllContentTab.BOOKMARKS -> { val filters = buildList { addAll(buildDeletedFilter()) - add(buildLayoutFilter(layouts = allContentTabLayouts.getValue(tab))) add(buildSpaceIdFilter(spaces)) if (tab == AllContentTab.PAGES) { add(buildTemplateFilter()) diff --git a/feature-all-content/src/main/java/com/anytypeio/anytype/feature_allcontent/presentation/AllContentViewModel.kt b/feature-all-content/src/main/java/com/anytypeio/anytype/feature_allcontent/presentation/AllContentViewModel.kt index 87cd30f7a2..fa07b47701 100644 --- a/feature-all-content/src/main/java/com/anytypeio/anytype/feature_allcontent/presentation/AllContentViewModel.kt +++ b/feature-all-content/src/main/java/com/anytypeio/anytype/feature_allcontent/presentation/AllContentViewModel.kt @@ -15,24 +15,20 @@ import com.anytypeio.anytype.domain.library.StorelessSubscriptionContainer import com.anytypeio.anytype.domain.misc.LocaleProvider import com.anytypeio.anytype.domain.misc.UrlBuilder import com.anytypeio.anytype.domain.objects.StoreOfObjectTypes -import com.anytypeio.anytype.domain.objects.StoreOfRelations import com.anytypeio.anytype.domain.search.SearchObjects -import com.anytypeio.anytype.feature_allcontent.models.UiContentItem import com.anytypeio.anytype.feature_allcontent.models.AllContentMenuMode -import com.anytypeio.anytype.feature_allcontent.models.AllContentMode import com.anytypeio.anytype.feature_allcontent.models.AllContentSort import com.anytypeio.anytype.feature_allcontent.models.AllContentTab -import com.anytypeio.anytype.feature_allcontent.models.UiTitleState -import com.anytypeio.anytype.feature_allcontent.models.UiContentState -import com.anytypeio.anytype.feature_allcontent.models.MenuButtonViewState import com.anytypeio.anytype.feature_allcontent.models.MenuSortsItem +import com.anytypeio.anytype.feature_allcontent.models.UiContentItem +import com.anytypeio.anytype.feature_allcontent.models.UiContentState import com.anytypeio.anytype.feature_allcontent.models.UiMenuState import com.anytypeio.anytype.feature_allcontent.models.UiTabsState +import com.anytypeio.anytype.feature_allcontent.models.UiTitleState import com.anytypeio.anytype.feature_allcontent.models.createSubscriptionParams import com.anytypeio.anytype.feature_allcontent.models.filtersForSearch import com.anytypeio.anytype.feature_allcontent.models.mapRelationKeyToSort import com.anytypeio.anytype.feature_allcontent.models.toUiContentItems -import com.anytypeio.anytype.feature_allcontent.models.view import com.anytypeio.anytype.presentation.analytics.AnalyticSpaceHelperDelegate import com.anytypeio.anytype.presentation.home.OpenObjectNavigation import com.anytypeio.anytype.presentation.home.navigation @@ -43,13 +39,9 @@ import java.time.format.TextStyle import java.time.temporal.ChronoUnit import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharedFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine @@ -57,11 +49,11 @@ import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onCompletion -import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.take import kotlinx.coroutines.launch import timber.log.Timber @@ -75,7 +67,6 @@ import timber.log.Timber class AllContentViewModel( private val vmParams: VmParams, private val storeOfObjectTypes: StoreOfObjectTypes, - private val storeOfRelations: StoreOfRelations, private val urlBuilder: UrlBuilder, private val analytics: Analytics, private val analyticSpaceHelperDelegate: AnalyticSpaceHelperDelegate, @@ -86,12 +77,20 @@ class AllContentViewModel( private val localeProvider: LocaleProvider ) : ViewModel() { - private val _limitedObjectIds: MutableStateFlow> = MutableStateFlow(emptyList()) + private val searchResultIds = MutableStateFlow>(emptyList()) + private val sortState = MutableStateFlow(DEFAULT_INITIAL_SORT) - private val _tabsState = MutableStateFlow(DEFAULT_INITIAL_TAB) - private val _modeState = MutableStateFlow(DEFAULT_INITIAL_MODE) - private val _sortState = MutableStateFlow(DEFAULT_INITIAL_SORT) - private val _limitState = MutableStateFlow(DEFAULT_SEARCH_LIMIT) + val uiTitleState = MutableStateFlow(UiTitleState.Hidden) + val uiTabsState = MutableStateFlow(UiTabsState.Hidden) + val uiMenuState = MutableStateFlow(UiMenuState.Hidden) + val uiItemsState = MutableStateFlow>(emptyList()) + val uiContentState = MutableStateFlow(UiContentState.Idle()) + + val commands = MutableSharedFlow() + + /** + * Search query + */ private val userInput = MutableStateFlow(DEFAULT_QUERY) @OptIn(FlowPreview::class) @@ -101,58 +100,30 @@ class AllContentViewModel( emitAll(userInput.drop(1).debounce(DEFAULT_DEBOUNCE_DURATION).distinctUntilChanged()) } - private val _uiTitleState = MutableStateFlow(UiTitleState.Hidden) - val uiTitleState: StateFlow = _uiTitleState.asStateFlow() + /** + * Paging and subscription limit. If true, we can paginate after reaching bottom items. + * Could be true only after the first subscription results (if results size == limit) + */ + val canPaginate = MutableStateFlow(false) + private var itemsLimit = DEFAULT_SEARCH_LIMIT + private val limitUpdateTrigger = MutableStateFlow(0) - private val _uiMenuButtonState = - MutableStateFlow(MenuButtonViewState.Hidden) - val uiMenuButtonState: StateFlow = _uiMenuButtonState.asStateFlow() - - private val _uiTabsState = MutableStateFlow(UiTabsState.Hidden) - val uiTabsState: StateFlow = _uiTabsState.asStateFlow() - - private val _uiState = MutableStateFlow(UiContentState.Hidden) - val uiState: StateFlow = _uiState.asStateFlow() - - private val _uiMenu = MutableStateFlow(UiMenuState.empty()) - val uiMenu: StateFlow = _uiMenu.asStateFlow() - - private val _commands = MutableSharedFlow() - val commands: SharedFlow = _commands + private var shouldScrollToTopItems = false init { Timber.d("AllContentViewModel init, spaceId:[${vmParams.spaceId.id}]") setupInitialStateParams() - proceedWithUiTitleStateSetup() - proceedWithUiTabsStateSetup() - proceedWithUiStateSetup() - proceedWithSearchStateSetup() - proceedWithMenuSetup() - } - - private fun proceedWithUiTitleStateSetup() { - viewModelScope.launch { - _modeState.collectLatest { result -> - Timber.d("New mode: [$result]") - _uiTitleState.value = result.view() - _uiMenuButtonState.value = MenuButtonViewState.Visible - } - } - } - - private fun proceedWithUiTabsStateSetup() { - viewModelScope.launch { - _tabsState.collectLatest { result -> - Timber.d("New tab: [$result]") - _uiTabsState.value = UiTabsState.Default( - tabs = AllContentTab.entries, - selectedTab = result - ) - } - } + setupUiStateFlow() + setupSearchStateFlow() + setupMenuFlow() } private fun setupInitialStateParams() { + uiTitleState.value = UiTitleState.AllContent + uiTabsState.value = UiTabsState.Default( + tabs = AllContentTab.entries, + selectedTab = DEFAULT_INITIAL_TAB + ) viewModelScope.launch { if (vmParams.useHistory) { runCatching { @@ -160,7 +131,7 @@ class AllContentViewModel( RestoreAllContentState.Params(vmParams.spaceId) ) if (!initialParams.activeSort.isNullOrEmpty()) { - _sortState.value = initialParams.activeSort.mapRelationKeyToSort() + sortState.value = initialParams.activeSort.mapRelationKeyToSort() } }.onFailure { e -> Timber.e(e, "Error restoring state") @@ -169,98 +140,101 @@ class AllContentViewModel( } } - private fun proceedWithSearchStateSetup() { + private fun setupSearchStateFlow() { viewModelScope.launch { searchQuery.collectLatest { query -> Timber.d("New query: [$query]") if (query.isBlank()) { - _limitedObjectIds.value = emptyList() + searchResultIds.value = emptyList() } else { - val searchParams = createSearchParams( - activeTab = _tabsState.value, - activeQuery = query - ) - searchObjects(searchParams).process( - success = { searchResults -> - Timber.d("Search objects by query:[$query], size: : ${searchResults.size}") - _limitedObjectIds.value = searchResults.map { it.id } - }, - failure = { - Timber.e(it, "Error searching objects by query") - } - ) + val activeTab = uiTabsState.value + if (activeTab is UiTabsState.Default) { + resetLimit() + val searchParams = createSearchParams( + activeTab = activeTab.selectedTab, + activeQuery = query + ) + searchObjects(searchParams).process( + success = { searchResults -> + Timber.d("Search objects by query:[$query], size: : ${searchResults.size}") + searchResultIds.value = searchResults.map { it.id } + }, + failure = { + Timber.e(it, "Error searching objects by query") + } + ) + } else { + Timber.w("Unexpected tabs state: $activeTab") + } } } } } @OptIn(ExperimentalCoroutinesApi::class) - private fun proceedWithUiStateSetup() { + private fun setupUiStateFlow() { viewModelScope.launch { combine( - _modeState, - _tabsState, - _sortState, - _limitedObjectIds, - _limitState - ) { mode, tab, sort, limitedObjectIds, limit -> - Result(mode, tab, sort, limitedObjectIds, limit) + uiTitleState, + uiTabsState.filterIsInstance(), + sortState, + searchResultIds, + limitUpdateTrigger + ) { mode, tab, sort, limitedObjectIds, _ -> + Result(mode, tab, sort, limitedObjectIds) } .flatMapLatest { currentState -> - Timber.d("AllContentNewState:$currentState, restart subscription") + Timber.d("New params:$currentState, restart subscription") loadData(currentState) }.collect { - _uiState.value = it + uiItemsState.value = it } } } - fun subscriptionId() = "all_content_subscription_${vmParams.spaceId.id}" + private fun subscriptionId() = "all_content_subscription_${vmParams.spaceId.id}" private fun loadData( result: Result - ): Flow = flow { - val loadingStartTime = System.currentTimeMillis() + ): Flow> = flow { - emit(UiContentState.Loading) + if (itemsLimit == DEFAULT_SEARCH_LIMIT) { + uiContentState.value = UiContentState.InitLoading + } else { + uiContentState.value = UiContentState.Paging + } val searchParams = createSubscriptionParams( - activeTab = result.tab, + activeTab = result.tab.selectedTab, activeSort = result.sort, limitedObjectIds = result.limitedObjectIds, - limit = result.limit, + limit = itemsLimit, subscriptionId = subscriptionId(), spaceId = vmParams.spaceId.id, activeMode = result.mode ) val dataFlow = storelessSubscriptionContainer.subscribe(searchParams) - .map { objWrappers -> + emitAll( + dataFlow.map { objWrappers -> + canPaginate.value = objWrappers.size == itemsLimit val items = mapToUiContentItems( objectWrappers = objWrappers, activeSort = result.sort ) - UiContentState.Content(items = items) - } - .catch { e -> - emit( - UiContentState.Error( - message = e.message ?: "Error loading objects by subscription" - ) - ) - } - - var isFirstEmission = true - - emitAll( - dataFlow.onEach { - if (isFirstEmission) { - val elapsedTime = System.currentTimeMillis() - loadingStartTime - if (elapsedTime < DEFAULT_LOADING_DELAY) { - delay(DEFAULT_LOADING_DELAY - elapsedTime) + uiContentState.value = if (items.isEmpty()) { + UiContentState.Empty + } else { + UiContentState.Idle(scrollToTop = shouldScrollToTopItems).also { + shouldScrollToTopItems = false } - isFirstEmission = false } + items + }.catch { e -> + uiContentState.value = UiContentState.Error( + message = e.message ?: "An error occurred while loading data." + ) + emit(emptyList()) } ) } @@ -274,28 +248,31 @@ class AllContentViewModel( urlBuilder = urlBuilder, objectTypes = storeOfObjectTypes.getAll() ) - return if (activeSort.canGroupByDate) { - groupItemsByDate( - items = items, - activeSort = activeSort - ) - } else { - items + return when (activeSort) { + is AllContentSort.ByDateCreated -> { + groupItemsByDate(items = items, isSortByDateCreated = true) + } + is AllContentSort.ByDateUpdated -> { + groupItemsByDate(items = items, isSortByDateCreated = false) + } + is AllContentSort.ByName -> { + items + } } } private fun groupItemsByDate( items: List, - activeSort: AllContentSort + isSortByDateCreated: Boolean ): List { val groupedItems = mutableListOf() var currentGroupKey: String? = null for (item in items) { - val timestamp = when (activeSort) { - is AllContentSort.ByDateCreated -> item.createdDate - is AllContentSort.ByDateUpdated -> item.lastModifiedDate - is AllContentSort.ByName -> 0L + val timestamp = if (isSortByDateCreated) { + item.createdDate + } else { + item.lastModifiedDate } val (groupKey, group) = getDateGroup(timestamp) @@ -347,7 +324,6 @@ class AllContentViewModel( } } - // Function to create search parameters private fun createSearchParams( activeTab: AllContentTab, activeQuery: String, @@ -362,25 +338,17 @@ class AllContentViewModel( ) } - // Function to get the menu mode based on the active mode - private fun getMenuMode(mode: AllContentMode): AllContentMenuMode { - return when (mode) { - AllContentMode.AllContent -> AllContentMenuMode.AllContent(isSelected = true) - AllContentMode.Unlinked -> AllContentMenuMode.Unlinked(isSelected = true) - } - } - - private fun proceedWithMenuSetup() { + private fun setupMenuFlow() { viewModelScope.launch { combine( - _modeState, - _sortState + uiTitleState, + sortState ) { mode, sort -> mode to sort }.collectLatest { (mode, sort) -> val uiMode = listOf( - AllContentMenuMode.AllContent(isSelected = mode == AllContentMode.AllContent), - AllContentMenuMode.Unlinked(isSelected = mode == AllContentMode.Unlinked) + AllContentMenuMode.AllContent(isSelected = mode == UiTitleState.AllContent), + AllContentMenuMode.Unlinked(isSelected = mode == UiTitleState.OnlyUnlinked) ) val container = MenuSortsItem.Container(sort = sort) val uiSorts = listOf( @@ -406,7 +374,7 @@ class AllContentViewModel( isSelected = sort.sortType == DVSortType.DESC ) ) - _uiMenu.value = UiMenuState( + uiMenuState.value = UiMenuState.Visible( mode = uiMode, container = container, sorts = uiSorts, @@ -420,24 +388,34 @@ class AllContentViewModel( Timber.d("onTabClicked: $tab") if (tab == AllContentTab.TYPES) { viewModelScope.launch { - _commands.emit(Command.SendToast("Not implemented yet")) + commands.emit(Command.SendToast("Not implemented yet")) } return } - _tabsState.value = tab + shouldScrollToTopItems = true + resetLimit() + uiItemsState.value = emptyList() + uiTabsState.value = UiTabsState.Default( + tabs = AllContentTab.entries, + selectedTab = tab + ) } fun onAllContentModeClicked(mode: AllContentMenuMode) { Timber.d("onAllContentModeClicked: $mode") - _modeState.value = when (mode) { - is AllContentMenuMode.AllContent -> AllContentMode.AllContent - is AllContentMenuMode.Unlinked -> AllContentMode.Unlinked + shouldScrollToTopItems = true + uiItemsState.value = emptyList() + uiTitleState.value = when (mode) { + is AllContentMenuMode.AllContent -> UiTitleState.AllContent + is AllContentMenuMode.Unlinked -> UiTitleState.OnlyUnlinked } } fun onSortClicked(sort: AllContentSort) { Timber.d("onSortClicked: $sort") - _sortState.value = sort + shouldScrollToTopItems = true + uiItemsState.value = emptyList() + sortState.value = sort proceedWithSortSaving(sort) } @@ -463,14 +441,9 @@ class AllContentViewModel( userInput.value = filter } - fun onLimitUpdated(limit: Int) { - Timber.d("onLimitUpdated: $limit") - _limitState.value = limit - } - fun onViewBinClicked() { viewModelScope.launch { - _commands.emit(Command.NavigateToBin(vmParams.spaceId.id)) + commands.emit(Command.NavigateToBin(vmParams.spaceId.id)) } } @@ -483,7 +456,7 @@ class AllContentViewModel( space = vmParams.spaceId.id )) { is OpenObjectNavigation.OpenDataView -> { - _commands.emit( + commands.emit( Command.NavigateToSetOrCollection( id = navigation.target, space = navigation.space @@ -492,7 +465,7 @@ class AllContentViewModel( } is OpenObjectNavigation.OpenEditor -> { - _commands.emit( + commands.emit( Command.NavigateToEditor( id = navigation.target, space = navigation.space @@ -501,7 +474,7 @@ class AllContentViewModel( } is OpenObjectNavigation.UnexpectedLayoutError -> { - _commands.emit(Command.SendToast("Unexpected layout: ${navigation.layout}")) + commands.emit(Command.SendToast("Unexpected layout: ${navigation.layout}")) } } } @@ -514,17 +487,38 @@ class AllContentViewModel( } } + /** + * Updates the limit for the number of items fetched and triggers data reload. + */ + fun updateLimit() { + Timber.d("Update limit, canPaginate: ${canPaginate.value} uiContentState: ${uiContentState.value}") + if (canPaginate.value && uiContentState.value is UiContentState.Idle) { + itemsLimit += DEFAULT_SEARCH_LIMIT + limitUpdateTrigger.value++ + } + } + + override fun onCleared() { + super.onCleared() + uiItemsState.value = emptyList() + resetLimit() + } + + private fun resetLimit() { + Timber.d("Reset limit") + itemsLimit = DEFAULT_SEARCH_LIMIT + } + data class VmParams( val spaceId: SpaceId, val useHistory: Boolean = true ) internal data class Result( - val mode: AllContentMode, - val tab: AllContentTab, + val mode: UiTitleState, + val tab: UiTabsState.Default, val sort: AllContentSort, - val limitedObjectIds: List, - val limit: Int + val limitedObjectIds: List ) sealed class Command { @@ -536,13 +530,11 @@ class AllContentViewModel( companion object { const val DEFAULT_DEBOUNCE_DURATION = 300L - const val DEFAULT_LOADING_DELAY = 250L //INITIAL STATE - const val DEFAULT_SEARCH_LIMIT = 50 + const val DEFAULT_SEARCH_LIMIT = 100 val DEFAULT_INITIAL_TAB = AllContentTab.PAGES val DEFAULT_INITIAL_SORT = AllContentSort.ByName() - val DEFAULT_INITIAL_MODE = AllContentMode.AllContent val DEFAULT_QUERY = "" } } diff --git a/feature-all-content/src/main/java/com/anytypeio/anytype/feature_allcontent/presentation/AllContentViewModelFactory.kt b/feature-all-content/src/main/java/com/anytypeio/anytype/feature_allcontent/presentation/AllContentViewModelFactory.kt index 5d7bf6723a..cefa74902b 100644 --- a/feature-all-content/src/main/java/com/anytypeio/anytype/feature_allcontent/presentation/AllContentViewModelFactory.kt +++ b/feature-all-content/src/main/java/com/anytypeio/anytype/feature_allcontent/presentation/AllContentViewModelFactory.kt @@ -9,17 +9,14 @@ import com.anytypeio.anytype.domain.library.StorelessSubscriptionContainer import com.anytypeio.anytype.domain.misc.LocaleProvider import com.anytypeio.anytype.domain.misc.UrlBuilder import com.anytypeio.anytype.domain.objects.StoreOfObjectTypes -import com.anytypeio.anytype.domain.objects.StoreOfRelations import com.anytypeio.anytype.domain.search.SearchObjects import com.anytypeio.anytype.feature_allcontent.presentation.AllContentViewModel.VmParams import com.anytypeio.anytype.presentation.analytics.AnalyticSpaceHelperDelegate import javax.inject.Inject -import javax.inject.Named class AllContentViewModelFactory @Inject constructor( private val vmParams: VmParams, private val storeOfObjectTypes: StoreOfObjectTypes, - private val storeOfRelations: StoreOfRelations, private val urlBuilder: UrlBuilder, private val analytics: Analytics, private val analyticSpaceHelperDelegate: AnalyticSpaceHelperDelegate, @@ -34,7 +31,6 @@ class AllContentViewModelFactory @Inject constructor( AllContentViewModel( vmParams = vmParams, storeOfObjectTypes = storeOfObjectTypes, - storeOfRelations = storeOfRelations, urlBuilder = urlBuilder, analytics = analytics, analyticSpaceHelperDelegate = analyticSpaceHelperDelegate, diff --git a/feature-all-content/src/main/java/com/anytypeio/anytype/feature_allcontent/ui/AllContentMenu.kt b/feature-all-content/src/main/java/com/anytypeio/anytype/feature_allcontent/ui/AllContentMenu.kt index 0e5fe57297..10b6c577ed 100644 --- a/feature-all-content/src/main/java/com/anytypeio/anytype/feature_allcontent/ui/AllContentMenu.kt +++ b/feature-all-content/src/main/java/com/anytypeio/anytype/feature_allcontent/ui/AllContentMenu.kt @@ -39,7 +39,7 @@ import com.anytypeio.anytype.feature_allcontent.models.UiMenuState @Composable fun AllContentMenu( - uiMenuState: UiMenuState, + uiMenuState: UiMenuState.Visible, onModeClick: (AllContentMenuMode) -> Unit, onSortClick: (AllContentSort) -> Unit, onBinClick: () -> Unit @@ -233,7 +233,7 @@ private fun DVSortType.title(sort: AllContentSort): String = when (this) { @Composable fun AllContentMenuPreview() { AllContentMenu( - uiMenuState = UiMenuState( + uiMenuState = UiMenuState.Visible( mode = listOf( AllContentMenuMode.AllContent(isSelected = true), AllContentMenuMode.Unlinked(isSelected = false) diff --git a/feature-all-content/src/main/java/com/anytypeio/anytype/feature_allcontent/ui/AllContentScreen.kt b/feature-all-content/src/main/java/com/anytypeio/anytype/feature_allcontent/ui/AllContentScreen.kt index 41be83cba9..52b11dcae7 100644 --- a/feature-all-content/src/main/java/com/anytypeio/anytype/feature_allcontent/ui/AllContentScreen.kt +++ b/feature-all-content/src/main/java/com/anytypeio/anytype/feature_allcontent/ui/AllContentScreen.kt @@ -20,12 +20,17 @@ import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.Scaffold import androidx.compose.material3.Text 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.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment.Companion.CenterVertically @@ -69,32 +74,54 @@ import com.anytypeio.anytype.feature_allcontent.R import com.anytypeio.anytype.feature_allcontent.models.AllContentMenuMode import com.anytypeio.anytype.feature_allcontent.models.AllContentSort import com.anytypeio.anytype.feature_allcontent.models.AllContentTab -import com.anytypeio.anytype.feature_allcontent.models.UiContentState -import com.anytypeio.anytype.feature_allcontent.models.MenuButtonViewState import com.anytypeio.anytype.feature_allcontent.models.UiContentItem +import com.anytypeio.anytype.feature_allcontent.models.UiContentState import com.anytypeio.anytype.feature_allcontent.models.UiMenuState import com.anytypeio.anytype.feature_allcontent.models.UiTabsState import com.anytypeio.anytype.feature_allcontent.models.UiTitleState import com.anytypeio.anytype.presentation.objects.ObjectIcon +import kotlinx.coroutines.launch + @Composable fun AllContentWrapperScreen( uiTitleState: UiTitleState, uiTabsState: UiTabsState, - uiMenuButtonViewState: MenuButtonViewState, uiMenuState: UiMenuState, - uiState: UiContentState, + uiItemsState: List, onTabClick: (AllContentTab) -> Unit, onQueryChanged: (String) -> Unit, onModeClick: (AllContentMenuMode) -> Unit, onSortClick: (AllContentSort) -> Unit, onItemClicked: (UiContentItem.Item) -> Unit, - onBinClick: () -> Unit + onBinClick: () -> Unit, + canPaginate: Boolean, + onUpdateLimitSearch: () -> Unit, + uiContentState: UiContentState ) { + val lazyListState = rememberLazyListState() + + val canPaginateState = remember { mutableStateOf(false) } + LaunchedEffect(key1 = canPaginate) { + canPaginateState.value = canPaginate + } + + val shouldStartPaging = remember { + derivedStateOf { + canPaginateState.value && (lazyListState.layoutInfo.visibleItemsInfo.lastOrNull()?.index + ?: -9) >= (lazyListState.layoutInfo.totalItemsCount - 2) + } + } + + LaunchedEffect(key1 = shouldStartPaging.value) { + if (shouldStartPaging.value && uiContentState is UiContentState.Idle) { + onUpdateLimitSearch() + } + } + AllContentMainScreen( uiTitleState = uiTitleState, uiTabsState = uiTabsState, - uiMenuButtonViewState = uiMenuButtonViewState, onTabClick = onTabClick, onQueryChanged = onQueryChanged, uiMenuState = uiMenuState, @@ -102,23 +129,27 @@ fun AllContentWrapperScreen( onSortClick = onSortClick, onItemClicked = onItemClicked, onBinClick = onBinClick, - uiState = uiState + uiItemsState = uiItemsState, + lazyListState = lazyListState, + uiContentState = uiContentState ) } + @Composable fun AllContentMainScreen( - uiState: UiContentState, + uiItemsState: List, uiTitleState: UiTitleState, uiTabsState: UiTabsState, - uiMenuButtonViewState: MenuButtonViewState, uiMenuState: UiMenuState, onTabClick: (AllContentTab) -> Unit, onQueryChanged: (String) -> Unit, onModeClick: (AllContentMenuMode) -> Unit, onSortClick: (AllContentSort) -> Unit, onItemClicked: (UiContentItem.Item) -> Unit, - onBinClick: () -> Unit + onBinClick: () -> Unit, + lazyListState: LazyListState, + uiContentState: UiContentState ) { val modifier = Modifier .background(color = colorResource(id = R.color.background_primary)) @@ -141,7 +172,6 @@ fun AllContentMainScreen( if (uiTitleState !is UiTitleState.Hidden) { AllContentTopBarContainer( titleState = uiTitleState, - menuButtonState = uiMenuButtonViewState, uiMenuState = uiMenuState, onSortClick = onSortClick, onModeClick = onModeClick, @@ -175,34 +205,37 @@ fun AllContentMainScreen( .fillMaxSize() .padding(paddingValues) - when (uiState) { - is UiContentState.Content -> { - if (uiState.items.isEmpty()) { - Box(modifier = contentModifier, contentAlignment = Alignment.Center) { - EmptyState(isSearchEmpty = isSearchEmpty) + Box( + modifier = contentModifier, + contentAlignment = Alignment.Center + ) { + when { + uiItemsState.isEmpty() -> { + when (uiContentState) { + is UiContentState.Error -> { + ErrorState(uiContentState.message) + } + is UiContentState.Idle -> { + // Do nothing. + } + UiContentState.InitLoading -> { + LoadingState() + } + UiContentState.Paging -> {} + UiContentState.Empty -> { + EmptyState(isSearchEmpty = isSearchEmpty) + } } - } else { + } + else -> { ContentItems( - modifier = contentModifier, - items = uiState.items, - onItemClicked = onItemClicked + uiItemsState = uiItemsState, + onItemClicked = onItemClicked, + uiContentState = uiContentState, + lazyListState = lazyListState ) } } - is UiContentState.Error -> { - Box( - modifier = contentModifier, - contentAlignment = Alignment.Center - ) { - ErrorState(uiState.message) - } - } - UiContentState.Hidden -> {} - UiContentState.Loading -> { - Box(modifier = contentModifier) { - LoadingState() - } - } } } ) @@ -210,27 +243,34 @@ fun AllContentMainScreen( @Composable private fun ContentItems( - modifier: Modifier, - items: List, - onItemClicked: (UiContentItem.Item) -> Unit + uiItemsState: List, + onItemClicked: (UiContentItem.Item) -> Unit, + uiContentState: UiContentState, + lazyListState: LazyListState ) { - LazyColumn(modifier = modifier) { + val scope = rememberCoroutineScope() + + LazyColumn( + modifier = Modifier.fillMaxSize(), + state = lazyListState + ) { items( - count = items.size, - key = { index -> items[index].id }, + count = uiItemsState.size, + key = { index -> uiItemsState[index].id }, contentType = { index -> - when (items[index]) { + when (uiItemsState[index]) { is UiContentItem.Group -> "group" is UiContentItem.Item -> "item" } } ) { index -> - when (val item = items[index]) { + when (val item = uiItemsState[index]) { is UiContentItem.Group -> { Box( modifier = Modifier .fillMaxWidth() - .height(52.dp), + .height(52.dp) + .animateItem(), contentAlignment = Alignment.BottomStart ) { Text( @@ -258,6 +298,28 @@ private fun ContentItems( } } } + if (uiContentState is UiContentState.Paging) { + item { + Box( + modifier = Modifier + .fillParentMaxWidth() + .height(52.dp), + contentAlignment = Alignment.Center + ) { + LoadingState() + } + } + } + } + + LaunchedEffect(key1 = uiContentState) { + if (uiContentState is UiContentState.Idle) { + if (uiContentState.scrollToTop) { + scope.launch { + lazyListState.scrollToItem(0) + } + } + } } } @@ -283,6 +345,25 @@ fun PreviewLoadingState() { } } +@DefaultPreviews +@Composable +fun PreviewMainScreen() { + AllContentMainScreen( + uiItemsState = emptyList(), + uiTitleState = UiTitleState.AllContent, + uiTabsState = UiTabsState.Default(tabs = listOf(AllContentTab.PAGES, AllContentTab.TYPES, AllContentTab.LISTS), selectedTab = AllContentTab.LISTS), + uiMenuState = UiMenuState.Hidden, + onTabClick = {}, + onQueryChanged = {}, + onModeClick = {}, + onSortClick = {}, + onItemClicked = {}, + onBinClick = {}, + lazyListState = rememberLazyListState(), + uiContentState = UiContentState.Error("Error message") + ) +} + @Composable private fun Item( modifier: Modifier, diff --git a/feature-all-content/src/main/java/com/anytypeio/anytype/feature_allcontent/ui/AllContentTopToolbar.kt b/feature-all-content/src/main/java/com/anytypeio/anytype/feature_allcontent/ui/AllContentTopToolbar.kt index 24b9cf5952..e72e69cbda 100644 --- a/feature-all-content/src/main/java/com/anytypeio/anytype/feature_allcontent/ui/AllContentTopToolbar.kt +++ b/feature-all-content/src/main/java/com/anytypeio/anytype/feature_allcontent/ui/AllContentTopToolbar.kt @@ -63,7 +63,6 @@ import com.anytypeio.anytype.feature_allcontent.R import com.anytypeio.anytype.feature_allcontent.models.AllContentMenuMode import com.anytypeio.anytype.feature_allcontent.models.AllContentSort import com.anytypeio.anytype.feature_allcontent.models.AllContentTab -import com.anytypeio.anytype.feature_allcontent.models.MenuButtonViewState import com.anytypeio.anytype.feature_allcontent.models.MenuSortsItem import com.anytypeio.anytype.feature_allcontent.models.UiMenuState import com.anytypeio.anytype.feature_allcontent.models.UiTabsState @@ -74,7 +73,6 @@ import com.anytypeio.anytype.feature_allcontent.models.UiTitleState @Composable fun AllContentTopBarContainer( titleState: UiTitleState, - menuButtonState: MenuButtonViewState, uiMenuState: UiMenuState, onModeClick: (AllContentMenuMode) -> Unit, onSortClick: (AllContentSort) -> Unit, @@ -88,29 +86,30 @@ fun AllContentTopBarContainer( title = { AllContentTitle(state = titleState) }, actions = { AllContentMenuButton( - state = menuButtonState, onClick = { isMenuExpanded = true } ) - DropdownMenu( - modifier = Modifier.width(252.dp), - expanded = isMenuExpanded, - onDismissRequest = { isMenuExpanded = false }, - shape = RoundedCornerShape(size = 16.dp), - containerColor = colorResource(id = R.color.background_primary), - shadowElevation = 5.dp - ) { - AllContentMenu( - uiMenuState = uiMenuState, - onModeClick = { - onModeClick(it) - isMenuExpanded = false - }, - onSortClick = { - onSortClick(it) - isMenuExpanded = false - }, - onBinClick = onBinClick - ) + if (uiMenuState is UiMenuState.Visible) { + DropdownMenu( + modifier = Modifier.width(252.dp), + expanded = isMenuExpanded, + onDismissRequest = { isMenuExpanded = false }, + shape = RoundedCornerShape(size = 16.dp), + containerColor = colorResource(id = R.color.background_primary), + shadowElevation = 5.dp + ) { + AllContentMenu( + uiMenuState = uiMenuState, + onModeClick = { + onModeClick(it) + isMenuExpanded = false + }, + onSortClick = { + onSortClick(it) + isMenuExpanded = false + }, + onBinClick = onBinClick + ) + } } }, colors = TopAppBarDefaults.centerAlignedTopAppBarColors( @@ -124,8 +123,7 @@ fun AllContentTopBarContainer( private fun AllContentTopBarContainerPreview() { AllContentTopBarContainer( titleState = UiTitleState.OnlyUnlinked, - menuButtonState = MenuButtonViewState.Visible, - uiMenuState = UiMenuState( + uiMenuState = UiMenuState.Visible( mode = listOf( AllContentMenuMode.AllContent(isSelected = true), AllContentMenuMode.Unlinked() @@ -188,21 +186,16 @@ fun AllContentTitle(state: UiTitleState) { //region AllContentMenuButton @Composable -fun AllContentMenuButton(state: MenuButtonViewState, onClick: () -> Unit) { - when (state) { - MenuButtonViewState.Hidden -> return - MenuButtonViewState.Visible -> { - Image( - modifier = Modifier - .padding(end = 12.dp) - .size(32.dp) - .bouncingClickable { onClick() }, - painter = painterResource(id = R.drawable.ic_space_list_dots), - contentDescription = "Menu icon", - contentScale = ContentScale.Inside - ) - } - } +fun AllContentMenuButton(onClick: () -> Unit) { + Image( + modifier = Modifier + .padding(end = 12.dp) + .size(32.dp) + .bouncingClickable { onClick() }, + painter = painterResource(id = R.drawable.ic_space_list_dots), + contentDescription = "Menu icon", + contentScale = ContentScale.Inside + ) } //endregion