diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/ConversationListViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/ConversationListViewModel.kt index f880049a59f..dca1e6b1886 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/ConversationListViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/ConversationListViewModel.kt @@ -65,6 +65,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.debounce @@ -74,6 +75,7 @@ import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.shareIn import kotlinx.coroutines.launch @Suppress("TooManyFunctions") @@ -184,6 +186,7 @@ class ConversationListViewModelImpl @AssistedInject constructor( } .flowOn(dispatcher.io()) .cachedIn(viewModelScope) + .shareIn(scope = viewModelScope, started = SharingStarted.WhileSubscribed(), replay = 1) override var conversationListState by mutableStateOf( when (usePagination) { diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/ConversationsScreenContent.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/ConversationsScreenContent.kt index 1f75bca5bf1..78a699c3aea 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/ConversationsScreenContent.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/ConversationsScreenContent.kt @@ -83,7 +83,7 @@ fun ConversationsScreenContent( searchBarState: SearchBarState, emptyListContent: @Composable (domain: String) -> Unit = {}, lazyListState: LazyListState = rememberLazyListState(), - loadingListContent: @Composable (LazyListState) -> Unit = { LoadingListContent(it) }, + loadingListContent: @Composable () -> Unit = { LoadingListContent() }, conversationsSource: ConversationsSource = ConversationsSource.MAIN, conversationListViewModel: ConversationListViewModel = when { LocalInspectionMode.current -> ConversationListViewModelPreview() @@ -170,7 +170,7 @@ fun ConversationsScreenContent( searchBarState.searchVisibleChanged(lazyPagingItems.itemCount > 0 || searchBarState.isSearchActive) when { // when conversation list is not yet fetched, show loading indicator - lazyPagingItems.isLoading() -> loadingListContent(lazyListState) + lazyPagingItems.isLoading() -> loadingListContent() // when there is at least one conversation lazyPagingItems.itemCount > 0 -> ConversationList( lazyPagingConversations = lazyPagingItems, @@ -204,7 +204,7 @@ fun ConversationsScreenContent( searchBarState.searchVisibleChanged(isSearchVisible = hasConversations || searchBarState.isSearchActive) when { // when conversation list is not yet fetched, show loading indicator - state.isLoading -> loadingListContent(lazyListState) + state.isLoading -> loadingListContent() // when there is at least one conversation in any folder hasConversations -> ConversationList( lazyListState = lazyListState, diff --git a/app/src/test/kotlin/com/wire/android/ui/common/search/SearchBarStateTest.kt b/app/src/test/kotlin/com/wire/android/ui/common/search/SearchBarStateTest.kt new file mode 100644 index 00000000000..458954f1035 --- /dev/null +++ b/app/src/test/kotlin/com/wire/android/ui/common/search/SearchBarStateTest.kt @@ -0,0 +1,60 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +package com.wire.android.ui.common.search + +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.runtime.saveable.SaverScope +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class SearchBarStateTest { + + @Test + fun `given search visibility changed, when state is restored, then visibility is preserved`() { + val state = SearchBarState( + isSearchActive = true, + searchQueryTextState = TextFieldState(initialText = "query") + ) + state.searchVisibleChanged(false) + + val restored = with(SearchBarState.saver()) { + val saved = SaverScope { true }.save(state) + restore(saved!!) + }!! + + assertTrue(restored.isSearchActive) + assertFalse(restored.isSearchVisible) + assertEquals("query", restored.searchQueryTextState.text.toString()) + } + + @Test + fun `given old saved search state, when state is restored, then query is preserved`() { + val savedQuery = with(TextFieldState.Saver) { + SaverScope { true }.save(TextFieldState(initialText = "query")) + } + + val restored = SearchBarState.saver().restore(listOf(true, savedQuery))!! + + assertTrue(restored.isSearchActive) + assertTrue(restored.isSearchVisible) + assertEquals("query", restored.searchQueryTextState.text.toString()) + } +} diff --git a/core/ui-common/src/main/kotlin/com/wire/android/ui/common/CollapsingTopBarScaffold.kt b/core/ui-common/src/main/kotlin/com/wire/android/ui/common/CollapsingTopBarScaffold.kt index f6f0f820ff9..ad6e8828ae9 100644 --- a/core/ui-common/src/main/kotlin/com/wire/android/ui/common/CollapsingTopBarScaffold.kt +++ b/core/ui-common/src/main/kotlin/com/wire/android/ui/common/CollapsingTopBarScaffold.kt @@ -98,7 +98,12 @@ fun CollapsingTopBarScaffold( val maxBarElevationPx = with(LocalDensity.current) { maxBarElevation.toPx() } val anchoredDraggableState = remember { AnchoredDraggableState( - initialValue = State.EXPANDED, + initialValue = when { + !collapsingEnabled -> State.EXPANDED + contentLazyListState == null -> State.EXPANDED + contentLazyListState.firstVisibleItemIndex > 0 || contentLazyListState.firstVisibleItemScrollOffset > 0 -> State.COLLAPSED + else -> State.EXPANDED + }, anchors = calculateAnchors(collapsingEnabled, 0), positionalThreshold = { totalDistance: Float -> totalDistance * 0.5f }, velocityThreshold = { with(density) { 125.dp.toPx() } }, @@ -240,7 +245,10 @@ fun CollapsingTopBarScaffold( ) hasCollapsingSegment = collapsingPlaceable.height > 0 hasFooterSegment = footerPlaceable.height > 0 - anchoredDraggableState.updateAnchors(calculateAnchors(collapsingEnabled, collapsingPlaceable.height)) + anchoredDraggableState.updateAnchors( + newAnchors = calculateAnchors(collapsingEnabled, collapsingPlaceable.height), + newTarget = contentLazyListState.targetTopBarState(collapsingEnabled, collapsingPlaceable.height) + ) layout(constraints.maxWidth, constraints.maxHeight) { val swipeOffset = anchoredDraggableState.offset.roundToInt() contentPlaceable.placeRelative(0, collapsingPlaceable.height + footerPlaceable.height + swipeOffset) @@ -260,6 +268,12 @@ private fun LazyListState?.calculateContentOffset(maxValue: Float) = when { else -> maxValue } +private fun LazyListState?.targetTopBarState(collapsingEnabled: Boolean, collapsingHeight: Int): State = when { + !collapsingEnabled || collapsingHeight == 0 || this == null -> State.EXPANDED + firstVisibleItemIndex > 0 || firstVisibleItemScrollOffset > 0 -> State.COLLAPSED + else -> State.EXPANDED +} + @OptIn(ExperimentalFoundationApi::class) private fun AnchoredDraggableState.calculateCollapsingHeight() = anchors.positionOf(State.COLLAPSED).let { if (it.isNaN()) 0f else -it diff --git a/core/ui-common/src/main/kotlin/com/wire/android/ui/common/rowitem/LoadingListContent.kt b/core/ui-common/src/main/kotlin/com/wire/android/ui/common/rowitem/LoadingListContent.kt index e0a18e3aac0..360059dd84b 100644 --- a/core/ui-common/src/main/kotlin/com/wire/android/ui/common/rowitem/LoadingListContent.kt +++ b/core/ui-common/src/main/kotlin/com/wire/android/ui/common/rowitem/LoadingListContent.kt @@ -30,11 +30,13 @@ import com.wire.android.util.PreviewMultipleThemes @Composable fun LoadingListContent( - lazyListState: LazyListState, modifier: Modifier = Modifier, + userScrollEnabled: Boolean = false, + lazyListState: LazyListState = rememberLazyListState(), ) { LazyColumn( state = lazyListState, + userScrollEnabled = userScrollEnabled, modifier = modifier.fillMaxSize() ) { items(count = LOADING_PLACEHOLDER_ITEMS_COUNT) { index -> diff --git a/core/ui-common/src/main/kotlin/com/wire/android/ui/common/search/SearchBarState.kt b/core/ui-common/src/main/kotlin/com/wire/android/ui/common/search/SearchBarState.kt index cdd74bcdc8d..01d20e08f9d 100644 --- a/core/ui-common/src/main/kotlin/com/wire/android/ui/common/search/SearchBarState.kt +++ b/core/ui-common/src/main/kotlin/com/wire/android/ui/common/search/SearchBarState.kt @@ -66,13 +66,14 @@ class SearchBarState( } companion object { - fun saver(): Saver = Saver( + fun saver(): Saver> = Saver( save = { listOf( it.isSearchActive, with(TextFieldState.Saver) { save(it.searchQueryTextState) - } + }, + it.isSearchVisible, ) }, restore = { @@ -82,7 +83,8 @@ class SearchBarState( with(TextFieldState.Saver) { restore(it) } - } ?: TextFieldState() + } ?: TextFieldState(), + isSearchVisible = (it.getOrNull(2) as? Boolean) ?: true ) } ) diff --git a/core/ui-common/src/main/kotlin/com/wire/android/util/ui/LazyPagingItemsUtil.kt b/core/ui-common/src/main/kotlin/com/wire/android/util/ui/LazyPagingItemsUtil.kt index 819884e37d5..300d7cffe6d 100644 --- a/core/ui-common/src/main/kotlin/com/wire/android/util/ui/LazyPagingItemsUtil.kt +++ b/core/ui-common/src/main/kotlin/com/wire/android/util/ui/LazyPagingItemsUtil.kt @@ -43,7 +43,7 @@ import kotlin.coroutines.EmptyCoroutineContext */ @Composable fun Flow>.collectAsLazyPagingItemsWithLifecycle( - minActiveState: Lifecycle.State = Lifecycle.State.RESUMED, + minActiveState: Lifecycle.State = Lifecycle.State.STARTED, context: CoroutineContext = EmptyCoroutineContext, lifecycle: Lifecycle = LocalLifecycleOwner.current.lifecycle ): LazyPagingItems {