diff --git a/app/src/main/kotlin/com/wire/android/di/accountScoped/CellsModule.kt b/app/src/main/kotlin/com/wire/android/di/accountScoped/CellsModule.kt index f7be4f575d5..d9b623cfe11 100644 --- a/app/src/main/kotlin/com/wire/android/di/accountScoped/CellsModule.kt +++ b/app/src/main/kotlin/com/wire/android/di/accountScoped/CellsModule.kt @@ -30,6 +30,7 @@ import com.wire.kalium.cells.domain.usecase.GetCellFileUseCase import com.wire.kalium.cells.domain.usecase.GetEditorUrlUseCase import com.wire.kalium.cells.domain.usecase.GetFoldersUseCase import com.wire.kalium.cells.domain.usecase.GetMessageAttachmentUseCase +import com.wire.kalium.cells.domain.usecase.GetOwnersUseCase import com.wire.kalium.cells.domain.usecase.GetPaginatedFilesFlowUseCase import com.wire.kalium.cells.domain.usecase.GetPaginatedNodesUseCase import com.wire.kalium.cells.domain.usecase.GetWireCellConfigurationUseCase @@ -197,6 +198,10 @@ class CellsModule { @Provides fun provideGetAttachmentUseCase(cellsScope: CellsScope): GetMessageAttachmentUseCase = cellsScope.getMessageAttachmentUseCase + @ViewModelScoped + @Provides + fun provideGetOwnersUseCase(cellsScope: CellsScope): GetOwnersUseCase = cellsScope.getOwnersUseCase + @Provides fun provideFileNameResolver(): FileNameResolver = FileNameResolver() diff --git a/app/src/main/kotlin/com/wire/android/navigation/MainNavHost.kt b/app/src/main/kotlin/com/wire/android/navigation/MainNavHost.kt index 165c11c468d..94ffaf6d45b 100644 --- a/app/src/main/kotlin/com/wire/android/navigation/MainNavHost.kt +++ b/app/src/main/kotlin/com/wire/android/navigation/MainNavHost.kt @@ -19,7 +19,10 @@ package com.wire.android.navigation import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.SharedTransitionLayout import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -48,6 +51,7 @@ import com.ramcosta.composedestinations.scope.resultBackNavigator import com.ramcosta.composedestinations.scope.resultRecipient import com.ramcosta.composedestinations.spec.Direction import com.wire.android.feature.sketch.model.DrawingCanvasNavBackArgs +import com.wire.android.navigation.transition.LocalSharedTransitionScope import com.wire.android.ui.authentication.login.email.LoginEmailViewModel import com.wire.android.ui.authentication.login.sso.SSOUrlConfigHolder import com.wire.android.ui.authentication.login.sso.SSOUrlConfigHolderImpl @@ -55,7 +59,7 @@ import com.wire.android.ui.home.conversations.ConversationScreen import com.wire.android.ui.home.newconversation.NewConversationViewModel import com.wire.android.ui.userprofile.teammigration.TeamMigrationViewModel -@OptIn(ExperimentalAnimationApi::class) +@OptIn(ExperimentalAnimationApi::class, ExperimentalSharedTransitionApi::class) @Composable fun MainNavHost( navigator: Navigator, @@ -64,98 +68,103 @@ fun MainNavHost( modifier: Modifier = Modifier, ) { val navHostEngine = rememberWireNavHostEngine(Alignment.Center) - DestinationsNavHost( - modifier = modifier, - navGraph = WireRootGraph, - defaultTransitions = WireRootGraph.defaultTransitions, - engine = navHostEngine, - start = startDestination, - navController = navigator.navController, - dependenciesContainerBuilder = { - // 👇 To make Navigator available to all destinations as a non-navigation parameter - dependency(navigator) + SharedTransitionLayout(modifier = modifier) { + CompositionLocalProvider(LocalSharedTransitionScope provides this) { + DestinationsNavHost( + modifier = modifier, + navGraph = WireRootGraph, + defaultTransitions = WireRootGraph.defaultTransitions, + engine = navHostEngine, + start = startDestination, + navController = navigator.navController, + dependenciesContainerBuilder = { + // 👇 To make Navigator available to all destinations as a non-navigation parameter + dependency(navigator) - // Always provide a default SSO holder at root scope so destinations can resolve it - // even when navigated directly without going through the expected nested graph route. - val rootEntry = remember(navBackStackEntry) { - navController.getBackStackEntry(WireRootGraph.route) - } - val rootSSOHolder: SSOUrlConfigHolder = SSOUrlConfigHolderImpl(rootEntry.savedStateHandle) - dependency(rootSSOHolder) + // Always provide a default SSO holder at root scope so destinations can resolve it + // even when navigated directly without going through the expected nested graph route. + val rootEntry = remember(navBackStackEntry) { + navController.getBackStackEntry(WireRootGraph.route) + } + val rootSSOHolder: SSOUrlConfigHolder = SSOUrlConfigHolderImpl(rootEntry.savedStateHandle) + dependency(rootSSOHolder) - // 👇 To make LoginTypeSelector available to all destinations as a non-navigation parameter if provided - if (loginTypeSelector != null) dependency(loginTypeSelector) + // 👇 To make LoginTypeSelector available to all destinations as a non-navigation parameter if provided + if (loginTypeSelector != null) dependency(loginTypeSelector) - // 👇 To tie NewConversationViewModel to nested NewConversationNavGraph, making it shared between all screens that belong to it - navGraph(NewConversationGraph) { - val parentEntry = remember(navBackStackEntry) { - navController.getBackStackEntry(NewConversationGraph.route) - } - dependency(hiltViewModel(parentEntry)) - } + // 👇 To tie NewConversationViewModel to nested NewConversationNavGraph, making it shared between all screens that belong to it + navGraph(NewConversationGraph) { + val parentEntry = remember(navBackStackEntry) { + navController.getBackStackEntry(NewConversationGraph.route) + } + dependency(hiltViewModel(parentEntry)) + } - // 👇 To reuse LoginEmailViewModel from NewLoginPasswordScreen on NewLoginVerificationCodeScreen - destination(NewLoginVerificationCodeScreenDestination) { - val loginPasswordEntry = remember(navBackStackEntry) { - navController.getBackStackEntry(NewLoginPasswordScreenDestination.route) - } - dependency(hiltViewModel(loginPasswordEntry)) - } + // 👇 To reuse LoginEmailViewModel from NewLoginPasswordScreen on NewLoginVerificationCodeScreen + destination(NewLoginVerificationCodeScreenDestination) { + val loginPasswordEntry = remember(navBackStackEntry) { + navController.getBackStackEntry(NewLoginPasswordScreenDestination.route) + } + dependency(hiltViewModel(loginPasswordEntry)) + } - // 👇 To tie SSOUrlConfigHolder to nested LoginNavGraph, making it shared between all screens that belong to it - navGraph(LoginGraph) { - val parentEntry = remember(navBackStackEntry) { - navController.getBackStackEntry(LoginGraph.route) - } - val holder: SSOUrlConfigHolder = SSOUrlConfigHolderImpl(parentEntry.savedStateHandle) - dependency(holder) - } + // 👇 To tie SSOUrlConfigHolder to nested LoginNavGraph, making it shared between all screens that belong to it + navGraph(LoginGraph) { + val parentEntry = remember(navBackStackEntry) { + navController.getBackStackEntry(LoginGraph.route) + } + val holder: SSOUrlConfigHolder = SSOUrlConfigHolderImpl(parentEntry.savedStateHandle) + dependency(holder) + } - // 👇 To tie SSOUrlConfigHolder to nested NewLoginNavGraph, making it shared between all screens that belong to it - navGraph(NewLoginGraph) { - val parentEntry = remember(navBackStackEntry) { - navController.getBackStackEntry(NewLoginGraph.route) - } - val holder: SSOUrlConfigHolder = SSOUrlConfigHolderImpl(parentEntry.savedStateHandle) - dependency(holder) - } + // 👇 To tie SSOUrlConfigHolder to nested NewLoginNavGraph, making it shared between all screens that belong to it + navGraph(NewLoginGraph) { + val parentEntry = remember(navBackStackEntry) { + navController.getBackStackEntry(NewLoginGraph.route) + } + val holder: SSOUrlConfigHolder = SSOUrlConfigHolderImpl(parentEntry.savedStateHandle) + dependency(holder) + } - // Some flows navigate directly to screen destinations instead of the nav graph route. - // Provide the dependency at destination scope as a safe fallback. - destination(LoginScreenDestination) { - val holder: SSOUrlConfigHolder = SSOUrlConfigHolderImpl(navBackStackEntry.savedStateHandle) - dependency(holder) - } - destination(NewLoginScreenDestination) { - val holder: SSOUrlConfigHolder = SSOUrlConfigHolderImpl(navBackStackEntry.savedStateHandle) - dependency(holder) - } + // Some flows navigate directly to screen destinations instead of the nav graph route. + // Provide the dependency at destination scope as a safe fallback. + destination(LoginScreenDestination) { + val holder: SSOUrlConfigHolder = SSOUrlConfigHolderImpl(navBackStackEntry.savedStateHandle) + dependency(holder) + } + destination(NewLoginScreenDestination) { + val holder: SSOUrlConfigHolder = SSOUrlConfigHolderImpl(navBackStackEntry.savedStateHandle) + dependency(holder) + } - // 👇 To tie TeamMigrationViewModel to PersonalToTeamMigrationNavGraph, making it shared between all screens that belong to it - navGraph(PersonalToTeamMigrationGraph) { - val parentEntry = remember(navBackStackEntry) { - navController.getBackStackEntry(PersonalToTeamMigrationGraph.route) + // 👇 To tie TeamMigrationViewModel to PersonalToTeamMigrationNavGraph, + // making it shared between all screens that belong to it + navGraph(PersonalToTeamMigrationGraph) { + val parentEntry = remember(navBackStackEntry) { + navController.getBackStackEntry(PersonalToTeamMigrationGraph.route) + } + dependency(hiltViewModel(parentEntry)) + } + }, + manualComposableCallsBuilder = { + /** + * Keep manual composable calls for cross-module result wiring until we refactor + * those destinations to rely on generated dependencies directly. + */ + composable(ConversationScreenDestination) { + ConversationScreen( + navigator = navigator, + groupDetailsScreenResultRecipient = resultRecipient(groupConversationDetailsNavBackArgsNavType), + mediaGalleryScreenResultRecipient = resultRecipient(mediaGalleryNavBackArgsNavType), + imagePreviewScreenResultRecipient = resultRecipient(imagesPreviewNavBackArgsNavType), + drawingCanvasScreenResultRecipient = resultRecipient( + drawingCanvasNavBackArgsNavType + ), + resultNavigator = resultBackNavigator(groupConversationDetailsNavBackArgsNavType), + ) + } } - dependency(hiltViewModel(parentEntry)) - } - }, - manualComposableCallsBuilder = { - /** - * Keep manual composable calls for cross-module result wiring until we refactor - * those destinations to rely on generated dependencies directly. - */ - composable(ConversationScreenDestination) { - ConversationScreen( - navigator = navigator, - groupDetailsScreenResultRecipient = resultRecipient(groupConversationDetailsNavBackArgsNavType), - mediaGalleryScreenResultRecipient = resultRecipient(mediaGalleryNavBackArgsNavType), - imagePreviewScreenResultRecipient = resultRecipient(imagesPreviewNavBackArgsNavType), - drawingCanvasScreenResultRecipient = resultRecipient( - drawingCanvasNavBackArgsNavType - ), - resultNavigator = resultBackNavigator(groupConversationDetailsNavBackArgsNavType), - ) - } + ) } - ) + } } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/HomeScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/HomeScreen.kt index c9d59ec6abc..74658b48c33 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/HomeScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/HomeScreen.kt @@ -50,6 +50,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.layout.ContentScale @@ -345,6 +346,7 @@ fun HomeContent( searchBarHint = stringResource(searchBar.hint), searchQueryTextState = searchBarState.searchQueryTextState, onActiveChanged = searchBarState::searchActiveChanged, + focusRequester = remember { FocusRequester() } ) } } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/channels/BrowseChannelsScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/channels/BrowseChannelsScreen.kt index a977a222514..37ecb244a88 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/channels/BrowseChannelsScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/channels/BrowseChannelsScreen.kt @@ -23,7 +23,9 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.res.stringResource import com.wire.android.R import com.wire.android.navigation.Navigator @@ -56,6 +58,7 @@ private fun Content( modifier: Modifier = Modifier ) { val lazyListState = rememberLazyListState() + val focusRequester = remember { FocusRequester() } WireScaffold( modifier = modifier, topBar = { @@ -69,7 +72,8 @@ private fun Content( isSearchActive = true, searchBarHint = stringResource(id = R.string.label_search_public_channels), searchQueryTextState = searchQueryTextState, - isLoading = false + isLoading = false, + focusRequester = focusRequester, ) } }, diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/SearchUsersAndAppsScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/SearchUsersAndAppsScreen.kt index 6e46835fba7..4c93f992f76 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/SearchUsersAndAppsScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/SearchUsersAndAppsScreen.kt @@ -44,6 +44,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.res.stringResource import androidx.hilt.navigation.compose.hiltViewModel import com.wire.android.R @@ -140,6 +141,7 @@ fun SearchUsersAndAppsScreen( } }, topBarCollapsing = { + val focusRequester = remember { FocusRequester() } SearchTopBar( isSearchActive = searchBarState.isSearchActive, searchBarHint = searchBarTitle, @@ -147,6 +149,7 @@ fun SearchUsersAndAppsScreen( searchBarDescription = stringResource(R.string.content_description_add_participants_search_field), searchQueryTextState = searchBarState.searchQueryTextState, onActiveChanged = searchBarState::searchActiveChanged, + focusRequester = focusRequester, ) }, topBarFooter = { diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/messages/SearchConversationMessagesScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/messages/SearchConversationMessagesScreen.kt index ceca148da1d..b6b4cb350df 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/messages/SearchConversationMessagesScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/messages/SearchConversationMessagesScreen.kt @@ -26,7 +26,9 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.hilt.navigation.compose.hiltViewModel @@ -117,7 +119,8 @@ fun SearchConversationMessagesResultContent( searchBarHint = stringResource(id = R.string.label_search_messages), searchQueryTextState = searchQueryTextState, onCloseSearchClicked = onCloseSearchClicked, - isLoading = state.isLoading + isLoading = state.isLoading, + focusRequester = remember { FocusRequester() }, ) } if (isCellsConversation) { diff --git a/app/src/main/kotlin/com/wire/android/ui/sharing/ImportMediaScreen.kt b/app/src/main/kotlin/com/wire/android/ui/sharing/ImportMediaScreen.kt index 868ae92ae2c..c341c170684 100644 --- a/app/src/main/kotlin/com/wire/android/ui/sharing/ImportMediaScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/sharing/ImportMediaScreen.kt @@ -51,6 +51,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign @@ -526,6 +527,7 @@ fun ImportMediaTopBarContent( thickness = 1.dp, modifier = Modifier.padding(top = dimensions().spacing12x) ) + val focusRequester = remember { FocusRequester() } SearchTopBar( isSearchActive = searchBarState.isSearchActive, searchBarHint = stringResource( @@ -534,6 +536,7 @@ fun ImportMediaTopBarContent( ), searchQueryTextState = searchQueryTextState, onActiveChanged = searchBarState::searchActiveChanged, + focusRequester = focusRequester, ) } } diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index 954d7e2b5d0..467ed16f5ca 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -830,6 +830,7 @@ Média Képek Ebben a beszélgetésben még senki nem osztott meg képet 🥲 + Fájlok Ebben a beszégetésben még senki nem osztott meg fájlt 🙀 Névjegyek diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index f80611c8a90..cff42653149 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -618,6 +618,7 @@ Un messaggio eliminato non può essere ripristinato. Multimedia Immagini Nessuno ha ancora condiviso immagini in questa conversazione 🥲 + File Nessuno ha ancora condiviso dei file in questa conversazione 🙀 Nuovo Gruppo diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 0e2331ea25e..323feb3b6c8 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -790,6 +790,7 @@ Uma mensagem excluída não pode ser restaurada. Mídia Imagens Ninguém compartilhou fotos nesta conversa ainda 🥲 + Arquivos Ninguém compartilhou arquivos nesta conversa ainda 🙀 Contatos diff --git a/app/src/main/res/values-si/strings.xml b/app/src/main/res/values-si/strings.xml index db219a34ab8..bc7026bc751 100644 --- a/app/src/main/res/values-si/strings.xml +++ b/app/src/main/res/values-si/strings.xml @@ -846,6 +846,7 @@ මාධ්‍ය ඡායාරූප කිසිවෙක් මෙම සංවාදයෙහි ඡායාරූප බෙදාගෙන නැත 🥲 + ගොනු කිසිවෙක් මෙම සංවාදයෙහි ගොනු බෙදාගෙන නැත 🙀 සම්බන්ධතා diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index 4c8419fb6bc..b2c1394d08a 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -642,4 +642,5 @@ + Filer diff --git a/core/navigation/src/main/kotlin/com/wire/android/navigation/transition/LocalSharedTransitionScope.kt b/core/navigation/src/main/kotlin/com/wire/android/navigation/transition/LocalSharedTransitionScope.kt new file mode 100644 index 00000000000..463fb31fe6b --- /dev/null +++ b/core/navigation/src/main/kotlin/com/wire/android/navigation/transition/LocalSharedTransitionScope.kt @@ -0,0 +1,30 @@ +/* + * 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.navigation.transition + +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.SharedTransitionScope +import androidx.compose.runtime.staticCompositionLocalOf + +@OptIn(ExperimentalSharedTransitionApi::class) +val LocalSharedTransitionScope = + staticCompositionLocalOf { + error("SharedTransitionScope not provided. Wrap NavHost in SharedTransitionLayout.") + } + +const val SHARED_ELEMENT_SEARCH_INPUT_KEY = "search_bar" diff --git a/core/ui-common/src/main/kotlin/com/wire/android/ui/common/SearchBar.kt b/core/ui-common/src/main/kotlin/com/wire/android/ui/common/SearchBar.kt index 61c08bf7adc..c4b3fdbd577 100644 --- a/core/ui-common/src/main/kotlin/com/wire/android/ui/common/SearchBar.kt +++ b/core/ui-common/src/main/kotlin/com/wire/android/ui/common/SearchBar.kt @@ -59,7 +59,8 @@ fun SearchBarInput( interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, textStyle: TextStyle = LocalTextStyle.current, isLoading: Boolean = false, - semanticDescription: String? = null + semanticDescription: String? = null, + onTap: (() -> Unit)? = null ) { WireTextField( @@ -108,7 +109,8 @@ fun SearchBarInput( placeholderAlignment = placeholderAlignment, placeholderText = placeholderText, lineLimits = TextFieldLineLimits.SingleLine, - semanticDescription = semanticDescription + semanticDescription = semanticDescription, + onTap = onTap, ) } diff --git a/core/ui-common/src/main/kotlin/com/wire/android/ui/common/chip/WireFilterChip.kt b/core/ui-common/src/main/kotlin/com/wire/android/ui/common/chip/WireFilterChip.kt index 07875e39f7d..967bb0df709 100644 --- a/core/ui-common/src/main/kotlin/com/wire/android/ui/common/chip/WireFilterChip.kt +++ b/core/ui-common/src/main/kotlin/com/wire/android/ui/common/chip/WireFilterChip.kt @@ -17,20 +17,25 @@ */ package com.wire.android.ui.common.chip -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.foundation.layout.size +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.FilterChip +import androidx.compose.material3.FilterChipDefaults import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.rotate import androidx.compose.ui.res.painterResource -import com.wire.android.ui.common.R import com.wire.android.ui.common.button.wireChipColors +import com.wire.android.ui.common.colorsScheme import com.wire.android.ui.common.dimensions import com.wire.android.ui.common.preview.MultipleThemePreviews import com.wire.android.ui.theme.WireTheme @@ -41,39 +46,74 @@ fun WireFilterChip( label: String, isSelected: Boolean, modifier: Modifier = Modifier, + count: Int? = null, isEnabled: Boolean = true, - onSelectChip: (String) -> Unit = {} + onClick: (String) -> Unit = {}, + trailingIconResource: Int? = null ) { - val rotationAngle by animateFloatAsState( - targetValue = if (isSelected) 0f else 45f, - ) - FilterChip( modifier = modifier.wrapContentSize(), - onClick = { onSelectChip(label) }, + onClick = { onClick(label) }, label = { - Text( - text = label, - style = MaterialTheme.wireTypography.button02, - maxLines = 1 - ) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(dimensions().spacing8x) + ) { + Text( + text = label, + style = MaterialTheme.wireTypography.button02, + maxLines = 1 + ) + + if (count != null && count > 0) { + CountBadge(count = count) + } + } }, enabled = isEnabled, selected = isSelected, colors = wireChipColors(), trailingIcon = { - Icon( - modifier = Modifier - .size(dimensions().spacing12x) - .rotate(rotationAngle), - painter = painterResource(id = R.drawable.ic_close), - contentDescription = null, - ) + trailingIconResource ?.let { + Icon( + modifier = Modifier.width(dimensions().spacing14x), + painter = painterResource(id = it), + contentDescription = null, + ) + } }, + border = FilterChipDefaults.filterChipBorder( + enabled = isEnabled, + selected = isSelected, + borderColor = colorsScheme().outline, + selectedBorderColor = colorsScheme().primary, + ) ) } +@Composable +fun CountBadge( + count: Int, + modifier: Modifier = Modifier +) { + Box( + modifier = modifier + .background( + color = MaterialTheme.colorScheme.primary, + shape = RoundedCornerShape(36) + ) + .padding(horizontal = dimensions().spacing4x, vertical = dimensions().spacing1x), + contentAlignment = Alignment.Center + ) { + Text( + text = count.toString(), + style = MaterialTheme.wireTypography.label02, + color = colorsScheme().onPrimary + ) + } +} + @MultipleThemePreviews @Composable fun PreviewFilterChip() { @@ -86,7 +126,7 @@ fun PreviewFilterChip() { @Composable fun PreviewSelectedFilterChip() { WireTheme { - WireFilterChip(label = "Selected", isSelected = true) + WireFilterChip(label = "Selected", count = 4, isSelected = true) } } diff --git a/core/ui-common/src/main/kotlin/com/wire/android/ui/common/textfield/WirePasswordTextField.kt b/core/ui-common/src/main/kotlin/com/wire/android/ui/common/textfield/WirePasswordTextField.kt index ccf1a450c4a..0753949f15d 100644 --- a/core/ui-common/src/main/kotlin/com/wire/android/ui/common/textfield/WirePasswordTextField.kt +++ b/core/ui-common/src/main/kotlin/com/wire/android/ui/common/textfield/WirePasswordTextField.kt @@ -42,7 +42,6 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.platform.testTag @@ -86,7 +85,7 @@ fun WirePasswordTextField( inputMinHeight: Dp = MaterialTheme.wireDimensions.textFieldMinHeight, shape: Shape = RoundedCornerShape(MaterialTheme.wireDimensions.textFieldCornerSize), colors: WireTextFieldColors = wireTextFieldColors(), - onTap: ((Offset) -> Unit)? = null, + onTap: (() -> Unit)? = null, testTag: String = String.EMPTY ) { val autoFillType = if (autoFill) WireAutoFillType.Password else WireAutoFillType.None diff --git a/core/ui-common/src/main/kotlin/com/wire/android/ui/common/textfield/WireTextField.kt b/core/ui-common/src/main/kotlin/com/wire/android/ui/common/textfield/WireTextField.kt index e5eded949cc..97256d7b64b 100644 --- a/core/ui-common/src/main/kotlin/com/wire/android/ui/common/textfield/WireTextField.kt +++ b/core/ui-common/src/main/kotlin/com/wire/android/ui/common/textfield/WireTextField.kt @@ -42,7 +42,6 @@ import androidx.compose.runtime.Stable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.res.painterResource @@ -90,7 +89,7 @@ fun WireTextField( colors: WireTextFieldColors = wireTextFieldColors(), onSelectedLineIndexChanged: (Int) -> Unit = { }, onLineBottomYCoordinateChanged: (Float) -> Unit = { }, - onTap: ((Offset) -> Unit)? = null, + onTap: (() -> Unit)? = null, testTag: String = String.EMPTY, validateKeyboardOptions: Boolean = true, ) { diff --git a/core/ui-common/src/main/kotlin/com/wire/android/ui/common/textfield/WireTextFieldLayout.kt b/core/ui-common/src/main/kotlin/com/wire/android/ui/common/textfield/WireTextFieldLayout.kt index ea36102b2c9..af4f7485a4b 100644 --- a/core/ui-common/src/main/kotlin/com/wire/android/ui/common/textfield/WireTextFieldLayout.kt +++ b/core/ui-common/src/main/kotlin/com/wire/android/ui/common/textfield/WireTextFieldLayout.kt @@ -21,7 +21,7 @@ package com.wire.android.ui.common.textfield import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.background import androidx.compose.foundation.border -import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -38,9 +38,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Shape -import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.semantics.clearAndSetSemantics @@ -83,7 +81,7 @@ internal fun WireTextFieldLayout( inputMinHeight: Dp = MaterialTheme.wireDimensions.textFieldMinHeight, shape: Shape = RoundedCornerShape(MaterialTheme.wireDimensions.textFieldCornerSize), colors: WireTextFieldColors = wireTextFieldColors(), - onTap: ((Offset) -> Unit)? = null, + onTap: (() -> Unit)? = null, testTag: String = String.EMPTY ) { Column(modifier = modifier) { @@ -158,20 +156,17 @@ private fun InnerTextLayout( placeholderAlignment: Alignment.Horizontal = Alignment.Start, inputMinHeight: Dp = dimensions().spacing48x, colors: WireTextFieldColors = wireTextFieldColors(), - onTap: ((Offset) -> Unit)? = null + onTap: (() -> Unit)? = null ) { - val modifier: Modifier = Modifier.apply { - if (onTap != null) { - pointerInput(Unit) { - detectTapGestures(onTap = onTap) - } - } - } - Row( verticalAlignment = Alignment.CenterVertically, - modifier = modifier + modifier = Modifier .heightIn(min = inputMinHeight) + .then( + onTap?.let { + Modifier.clickable { onTap() } + } ?: Modifier + ) ) { val trailingOrStateIcon: @Composable (() -> Unit)? = when { trailingIcon != null -> trailingIcon diff --git a/core/ui-common/src/main/kotlin/com/wire/android/ui/common/topappbar/search/SearchTopBar.kt b/core/ui-common/src/main/kotlin/com/wire/android/ui/common/topappbar/search/SearchTopBar.kt index 7b01b123c91..fc25af14eb2 100644 --- a/core/ui-common/src/main/kotlin/com/wire/android/ui/common/topappbar/search/SearchTopBar.kt +++ b/core/ui-common/src/main/kotlin/com/wire/android/ui/common/topappbar/search/SearchTopBar.kt @@ -46,11 +46,11 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.BiasAlignment import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusManager import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusEvent import androidx.compose.ui.platform.LocalFocusManager -import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.style.TextAlign import com.wire.android.ui.common.R @@ -65,52 +65,50 @@ fun SearchTopBar( isSearchActive: Boolean, searchBarHint: String, searchQueryTextState: TextFieldState, + focusRequester: FocusRequester, modifier: Modifier = Modifier, isLoading: Boolean = false, backIconContentDescription: String? = null, searchBarDescription: String? = null, onCloseSearchClicked: (() -> Unit)? = null, onActiveChanged: (isActive: Boolean) -> Unit = {}, - bottomContent: @Composable ColumnScope.() -> Unit = {} + bottomContent: @Composable ColumnScope.() -> Unit = {}, + onTap: (() -> Unit)? = null, + focusManager: FocusManager = LocalFocusManager.current, ) { + val interactionSource = remember { MutableInteractionSource() } + + fun setActive(isActive: Boolean) { + if (isActive) { + focusRequester.requestFocus() + } else { + focusManager.clearFocus() + searchQueryTextState.clearText() + } + } + + LaunchedEffect(isSearchActive) { + setActive(isSearchActive) + } + + val placeholderAlignment by animateHorizontalAlignmentAsState( + targetAlignment = if (isSearchActive) Alignment.CenterStart else Alignment.Center + ) + Column( modifier = modifier .wrapContentHeight() .fillMaxWidth() .background(MaterialTheme.wireColorScheme.background) ) { - val interactionSource = remember { MutableInteractionSource() } - val focusRequester = remember { FocusRequester() } - val keyboardController = LocalSoftwareKeyboardController.current - val focusManager = LocalFocusManager.current - - fun setActive(isActive: Boolean) { - if (isActive) { - focusRequester.requestFocus() - keyboardController?.show() - } else { - focusManager.clearFocus() - keyboardController?.hide() - searchQueryTextState.clearText() - } - } - - LaunchedEffect(isSearchActive) { - setActive(isSearchActive) - } - - val placeholderAlignment by animateHorizontalAlignmentAsState( - targetAlignment = if (isSearchActive) Alignment.CenterStart else Alignment.Center - ) - SearchBarInput( placeholderText = searchBarHint, semanticDescription = searchBarDescription, textState = searchQueryTextState, isLoading = isLoading, leadingIcon = { - AnimatedContent(!isSearchActive, label = "") { isVisible -> - if (isVisible) { + AnimatedContent(!isSearchActive, label = "") { showSearchIcon -> + if (showSearchIcon) { Box( contentAlignment = Alignment.Center, modifier = Modifier.size(dimensions().buttonCircleMinSize) @@ -135,13 +133,16 @@ fun SearchTopBar( } } }, - placeholderTextStyle = LocalTextStyle.current.copy(textAlign = if (!isSearchActive) TextAlign.Center else TextAlign.Start), + placeholderTextStyle = LocalTextStyle.current.copy( + textAlign = if (!isSearchActive) TextAlign.Center else TextAlign.Start + ), placeholderAlignment = placeholderAlignment, textStyle = LocalTextStyle.current.copy(textAlign = TextAlign.Start), interactionSource = interactionSource, + onTap = onTap, modifier = Modifier .padding(dimensions().spacing8x) - .focusable(true) + .focusable(enabled = isSearchActive) .focusRequester(focusRequester) .onFocusEvent { onActiveChanged(it.isFocused) } ) @@ -167,6 +168,7 @@ fun PreviewSearchTopBarActive() { searchBarHint = "Search", searchQueryTextState = rememberTextFieldState(), onActiveChanged = {}, + focusRequester = remember { FocusRequester() } ) } } @@ -180,6 +182,7 @@ fun PreviewSearchTopBarInactive() { searchBarHint = "Search", searchQueryTextState = rememberTextFieldState(), onActiveChanged = {}, + focusRequester = remember { FocusRequester() } ) } } diff --git a/core/ui-common/src/main/kotlin/com/wire/android/ui/theme/WireDimensions.kt b/core/ui-common/src/main/kotlin/com/wire/android/ui/theme/WireDimensions.kt index 5dd75639ea1..5a8487b47ea 100644 --- a/core/ui-common/src/main/kotlin/com/wire/android/ui/theme/WireDimensions.kt +++ b/core/ui-common/src/main/kotlin/com/wire/android/ui/theme/WireDimensions.kt @@ -171,6 +171,7 @@ data class WireDimensions( val spacing200x: Dp, val spacing270x: Dp, val spacing300x: Dp, + val spacing700x: Dp, // Corners val corner2x: Dp, val corner3x: Dp, @@ -182,6 +183,7 @@ data class WireDimensions( val corner12x: Dp, val corner14x: Dp, val corner16x: Dp, + val corner36x: Dp, val corner100x: Dp, // Notifications val notificationBadgeHeight: Dp, @@ -354,6 +356,7 @@ private val DefaultPhonePortraitWireDimensions: WireDimensions = WireDimensions( spacing200x = 200.dp, spacing270x = 270.dp, spacing300x = 300.dp, + spacing700x = 700.dp, corner2x = 2.dp, corner3x = 3.dp, corner4x = 4.dp, @@ -364,6 +367,7 @@ private val DefaultPhonePortraitWireDimensions: WireDimensions = WireDimensions( corner12x = 12.dp, corner14x = 14.dp, corner16x = 16.dp, + corner36x = 36.dp, corner100x = 100.dp, notificationBadgeHeight = 18.dp, notificationBadgeRadius = 6.dp, diff --git a/core/ui-common/src/main/res/drawable/ic_search.xml b/core/ui-common/src/main/res/drawable/ic_search.xml index f2ac86bd23a..03cc4aa65bc 100644 --- a/core/ui-common/src/main/res/drawable/ic_search.xml +++ b/core/ui-common/src/main/res/drawable/ic_search.xml @@ -1,6 +1,6 @@ - - - - + + diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellFilesScreen.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellFilesScreen.kt index 76ad2f8c268..a0375735c22 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellFilesScreen.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellFilesScreen.kt @@ -61,9 +61,11 @@ internal fun CellFilesScreen( isRefreshing: State, onRefresh: () -> Unit, onItemClick: (CellNodeUi) -> Unit, - onItemMenuClick: (CellNodeUi) -> Unit, + modifier: Modifier = Modifier, + onItemMenuClick: (CellNodeUi) -> Unit ) { PullToRefreshBox( + modifier = modifier, isRefreshing = isRefreshing.value, onRefresh = onRefresh, ) { diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellListItem.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellListItem.kt index 7e55e3e2ad9..748a59578db 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellListItem.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellListItem.kt @@ -285,6 +285,8 @@ private fun PreviewCellListItem() { mimeType = "image/jpg", publicLinkId = "", userName = "Test User", + userHandle = "userId", + ownerUserId = "userId", conversationName = "Test Conversation", modifiedTime = null, remotePath = null, diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellScreenContent.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellScreenContent.kt index 98d3f37b23c..0b085b1d089 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellScreenContent.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellScreenContent.kt @@ -88,8 +88,9 @@ internal fun CellScreenContent( onRefresh: () -> Unit, isRestoreInProgress: Boolean, isDeleteInProgress: Boolean, - isAllFiles: Boolean, - isRecycleBin: Boolean, + modifier: Modifier = Modifier, + isRecycleBin: Boolean = false, + isAllFiles: Boolean = false, isSearchResult: Boolean = false, isFiltering: Boolean = false, retryEditNodeError: (String) -> Unit = {}, @@ -113,12 +114,14 @@ internal fun CellScreenContent( pagingListItems.isError() -> { val error = (pagingListItems.loadState.refresh as? LoadState.Error)?.error ErrorScreen( + modifier = modifier, isConnectionError = (error as? FileListLoadError)?.isConnectionError ?: false, onRetry = { pagingListItems.retry() } ) } pagingListItems.itemCount == 0 -> EmptyScreen( + modifier = modifier, isSearchResult = isSearchResult, isAllFiles = isAllFiles, isRecycleBin = isRecycleBin, @@ -127,6 +130,7 @@ internal fun CellScreenContent( else -> CellFilesScreen( + modifier = modifier, cellNodes = pagingListItems, onItemClick = { sendIntent(CellViewIntent.OnItemClick(it)) }, onItemMenuClick = { sendIntent(CellViewIntent.OnItemMenuClick(it)) }, @@ -257,13 +261,14 @@ internal fun CellScreenContent( @Composable private fun EmptyScreen( + modifier: Modifier = Modifier, isSearchResult: Boolean = false, isAllFiles: Boolean = true, isRecycleBin: Boolean = false, isFiltering: Boolean = false, ) { Column( - modifier = Modifier + modifier = modifier .fillMaxSize() .padding(dimensions().spacing16x), horizontalAlignment = Alignment.CenterHorizontally, diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellViewModel.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellViewModel.kt index cd6d3d0139a..708926b6913 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellViewModel.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellViewModel.kt @@ -38,15 +38,16 @@ import com.wire.android.feature.cells.util.FileHelper import com.wire.android.feature.cells.util.FileNameResolver import com.wire.android.ui.common.ActionsViewModel import com.wire.android.ui.common.DEFAULT_SEARCH_QUERY_DEBOUNCE +import com.wire.kalium.cells.data.FileFilters import com.wire.kalium.cells.domain.model.Node import com.wire.kalium.cells.domain.usecase.DeleteCellAssetUseCase -import com.wire.kalium.cells.domain.usecase.download.DownloadCellFileUseCase import com.wire.kalium.cells.domain.usecase.GetAllTagsUseCase import com.wire.kalium.cells.domain.usecase.GetEditorUrlUseCase import com.wire.kalium.cells.domain.usecase.GetPaginatedFilesFlowUseCase import com.wire.kalium.cells.domain.usecase.GetWireCellConfigurationUseCase import com.wire.kalium.cells.domain.usecase.IsAtLeastOneCellAvailableUseCase import com.wire.kalium.cells.domain.usecase.RestoreNodeFromRecycleBinUseCase +import com.wire.kalium.cells.domain.usecase.download.DownloadCellFileUseCase import com.wire.kalium.common.functional.fold import com.wire.kalium.common.functional.onFailure import com.wire.kalium.common.functional.onSuccess @@ -80,6 +81,7 @@ import okio.Path.Companion.toPath import javax.inject.Inject import kotlin.time.Duration.Companion.seconds +// TODO: to cleanup this viewModel as search has been moved to a separate screen in upcoming PRs @Suppress("TooManyFunctions", "LongParameterList") @HiltViewModel class CellViewModel @Inject constructor( @@ -183,8 +185,10 @@ class CellViewModel @Inject constructor( getCellFilesPaged( conversationId = navArgs.conversationId, query = query, - onlyDeleted = navArgs.isRecycleBin ?: false, - tags = currentTags.toList(), + fileFilters = FileFilters( + tags = currentTags.toList(), + onlyDeleted = navArgs.isRecycleBin ?: false, + ), ).cachedIn(viewModelScope), removedItemsFlow, downloadDataFlow diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/ConversationFilesScreen.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/ConversationFilesScreen.kt index 999038e1053..38b9e59dff5 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/ConversationFilesScreen.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/ConversationFilesScreen.kt @@ -19,18 +19,14 @@ package com.wire.android.feature.cells.ui import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.expandVertically +import androidx.compose.animation.AnimatedVisibilityScope +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.SharedTransitionLayout import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut -import androidx.compose.animation.shrinkVertically -import androidx.compose.animation.slideInVertically -import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.Image -import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.material3.MaterialTheme @@ -41,6 +37,7 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.painterResource @@ -49,11 +46,6 @@ import androidx.hilt.navigation.compose.hiltViewModel import androidx.paging.PagingData import androidx.paging.compose.LazyPagingItems import androidx.paging.compose.collectAsLazyPagingItems -import com.wire.android.feature.cells.R -import com.wire.android.feature.cells.domain.model.AttachmentFileType -import com.wire.android.feature.cells.ui.common.Breadcrumbs -import com.wire.android.feature.cells.ui.create.FileTypeBottomSheetDialog -import com.wire.android.feature.cells.ui.create.file.CreateFileScreenNavArgs import com.ramcosta.composedestinations.generated.cells.destinations.AddRemoveTagsScreenDestination import com.ramcosta.composedestinations.generated.cells.destinations.ConversationFilesWithSlideInTransitionScreenDestination import com.ramcosta.composedestinations.generated.cells.destinations.CreateFileScreenDestination @@ -62,7 +54,12 @@ import com.ramcosta.composedestinations.generated.cells.destinations.MoveToFolde import com.ramcosta.composedestinations.generated.cells.destinations.PublicLinkScreenDestination import com.ramcosta.composedestinations.generated.cells.destinations.RecycleBinScreenDestination import com.ramcosta.composedestinations.generated.cells.destinations.RenameNodeScreenDestination +import com.ramcosta.composedestinations.generated.cells.destinations.SearchScreenDestination import com.ramcosta.composedestinations.generated.cells.destinations.VersionHistoryScreenDestination +import com.wire.android.feature.cells.R +import com.wire.android.feature.cells.domain.model.AttachmentFileType +import com.wire.android.feature.cells.ui.create.FileTypeBottomSheetDialog +import com.wire.android.feature.cells.ui.create.file.CreateFileScreenNavArgs import com.wire.android.feature.cells.ui.dialog.CellsNewActionBottomSheet import com.wire.android.feature.cells.ui.dialog.CellsOptionsBottomSheet import com.wire.android.feature.cells.ui.model.CellNodeUi @@ -72,13 +69,15 @@ import com.wire.android.navigation.PreviewNavigator import com.wire.android.navigation.WireNavigator import com.wire.android.navigation.annotation.features.cells.WireCellsDestination import com.wire.android.navigation.style.PopUpNavigationAnimation -import com.wire.android.ui.common.CollapsingTopBarScaffold +import com.wire.android.navigation.transition.LocalSharedTransitionScope +import com.wire.android.navigation.transition.SHARED_ELEMENT_SEARCH_INPUT_KEY import com.wire.android.ui.common.MoreOptionIcon import com.wire.android.ui.common.bottomsheet.rememberWireModalSheetState import com.wire.android.ui.common.bottomsheet.show import com.wire.android.ui.common.button.FloatingActionButton import com.wire.android.ui.common.dimensions import com.wire.android.ui.common.preview.MultipleThemePreviews +import com.wire.android.ui.common.scaffold.WireScaffold import com.wire.android.ui.common.search.SearchBarState import com.wire.android.ui.common.search.rememberSearchbarState import com.wire.android.ui.common.topappbar.NavigationIconType @@ -104,6 +103,7 @@ import kotlinx.coroutines.flow.flowOf @Composable fun ConversationFilesScreen( navigator: WireNavigator, + animatedVisibilityScope: AnimatedVisibilityScope, viewModel: CellViewModel = hiltViewModel(), ) { val conversationSearchBarState = rememberSearchbarState(viewModel.isSearchByDefaultActive) @@ -117,6 +117,7 @@ fun ConversationFilesScreen( } ConversationFilesScreenContent( + animatedVisibilityScope = animatedVisibilityScope, navigator = navigator, currentNodeUuid = viewModel.currentNodeUuid(), conversationSearchBarState = conversationSearchBarState, @@ -140,8 +141,10 @@ fun ConversationFilesScreen( } } +@OptIn(ExperimentalSharedTransitionApi::class) @Composable fun ConversationFilesScreenContent( + animatedVisibilityScope: AnimatedVisibilityScope, navigator: WireNavigator, currentNodeUuid: String?, conversationSearchBarState: SearchBarState, @@ -155,7 +158,6 @@ fun ConversationFilesScreenContent( onRefresh: () -> Unit, retryEditNodeError: (String) -> Unit, modifier: Modifier = Modifier, - onBreadcrumbsFolderClick: (index: Int) -> Unit = {}, isDeleteInProgress: Boolean = false, screenTitle: String? = null, isRecycleBin: Boolean = false, @@ -220,57 +222,53 @@ fun ConversationFilesScreenContent( }, ) - CollapsingTopBarScaffold( + WireScaffold( modifier = modifier, - topBarHeader = { - AnimatedVisibility( - modifier = Modifier.background(MaterialTheme.colorScheme.background), - visible = !conversationSearchBarState.isSearchActive, - enter = fadeIn() + expandVertically(), - exit = shrinkVertically() + fadeOut(), - ) { - Column { - WireCenterAlignedTopAppBar( - onNavigationPressed = { navigator.navigateBack() }, - title = screenTitle ?: stringResource(R.string.conversation_files_title), - navigationIconType = NavigationIconType.Back(), - elevation = dimensions().spacing0x, - actions = { - if (!isRecycleBin) { - MoreOptionIcon( - contentDescription = R.string.content_description_conversation_files_more_button, - onButtonClicked = { optionsBottomSheetState.show() } + topBar = { + Column { + WireCenterAlignedTopAppBar( + onNavigationPressed = { navigator.navigateBack() }, + title = screenTitle ?: stringResource(R.string.conversation_files_title), + navigationIconType = NavigationIconType.Back(), + elevation = dimensions().spacing0x, + actions = { + if (!isRecycleBin) { + MoreOptionIcon( + contentDescription = R.string.content_description_conversation_files_more_button, + onButtonClicked = { optionsBottomSheetState.show() } + ) + } + } + ) + + val sharedScope = LocalSharedTransitionScope.current + val focusRequester = remember { FocusRequester() } + + with(sharedScope) { + SearchTopBar( + modifier = Modifier + .sharedElement( + sharedContentState = rememberSharedContentState(key = SHARED_ELEMENT_SEARCH_INPUT_KEY), + animatedVisibilityScope = animatedVisibilityScope + ), + isSearchActive = conversationSearchBarState.isSearchActive, + searchBarHint = stringResource(R.string.search_shared_drive_text_input_hint), + searchQueryTextState = conversationSearchBarState.searchQueryTextState, + onActiveChanged = conversationSearchBarState::searchActiveChanged, + onTap = { + currentNodeUuid?.let { + navigator.navigate( + NavigationCommand( + SearchScreenDestination(conversationId = it) + ) ) } - } + }, + focusRequester = focusRequester, ) - breadcrumbs?.let { - Breadcrumbs( - modifier = Modifier - .height(dimensions().spacing32x) - .fillMaxWidth(), - isRecycleBin = isRecycleBin, - pathSegments = it, - onBreadcrumbsFolderClick = onBreadcrumbsFolderClick - ) - } } } }, - topBarCollapsing = { - AnimatedVisibility( - visible = conversationSearchBarState.isSearchVisible, - enter = fadeIn() + slideInVertically(), - exit = fadeOut() + slideOutVertically() - ) { - SearchTopBar( - isSearchActive = conversationSearchBarState.isSearchActive, - searchBarHint = stringResource(R.string.search_text_input_hint_for_files_folders_in_conversation), - searchQueryTextState = conversationSearchBarState.searchQueryTextState, - onActiveChanged = conversationSearchBarState::searchActiveChanged, - ) - } - }, floatingActionButton = { if (isFabVisible) { AnimatedVisibility( @@ -299,8 +297,8 @@ fun ConversationFilesScreenContent( } } }, - ) { - Box { + ) { innerPadding -> + Box(modifier = Modifier.padding(innerPadding)) { CellScreenContent( actionsFlow = actions, pagingListItems = pagingListItems, @@ -308,7 +306,6 @@ fun ConversationFilesScreenContent( downloadFileState = downloadFileSheet, menuState = menu, isSearchResult = isSearchResult, - isAllFiles = false, isRestoreInProgress = isRestoreInProgress, isDeleteInProgress = isDeleteInProgress, isRecycleBin = isRecycleBin, @@ -380,58 +377,67 @@ fun ConversationFilesScreenContent( } } +@OptIn(ExperimentalSharedTransitionApi::class) @Composable @MultipleThemePreviews fun PreviewConversationFilesScreen() { WireTheme { - ConversationFilesScreenContent( - navigator = PreviewNavigator, - currentNodeUuid = "conversationId", - conversationSearchBarState = rememberSearchbarState(), - isSearchResult = false, - actions = flowOf(), - pagingListItems = MutableStateFlow( - PagingData.from( - listOf( - CellNodeUi.File( - uuid = "file1", - name = "File 1", - downloadProgress = 0.5f, - assetType = AttachmentFileType.IMAGE, - size = 123456, - localPath = null, - mimeType = "image/png", - publicLinkId = "link1", - userName = "User A", - conversationName = "Conversation A", - modifiedTime = "2023-10-01T12:00:00Z", - remotePath = "/path/to/file1.png", - contentHash = null, - contentUrl = null, - previewUrl = null - ), - CellNodeUi.Folder( - uuid = "folder1", - name = "Folder 1", - remotePath = "/path/to/folder1", - userName = "User B", - conversationName = "Conversation B", - modifiedTime = "2023-10-01T12:00:00Z", - size = 123456, + SharedTransitionLayout { + AnimatedVisibility(visible = true) { + ConversationFilesScreenContent( + animatedVisibilityScope = this, + navigator = PreviewNavigator, + currentNodeUuid = "conversationId", + conversationSearchBarState = rememberSearchbarState(), + isSearchResult = false, + actions = flowOf(), + pagingListItems = MutableStateFlow( + PagingData.from( + listOf( + CellNodeUi.File( + uuid = "file1", + name = "File 1", + downloadProgress = 0.5f, + assetType = AttachmentFileType.IMAGE, + size = 123456, + localPath = null, + mimeType = "image/png", + publicLinkId = "link1", + userName = "User A", + userHandle = "userHandle", + ownerUserId = "userA", + conversationName = "Conversation A", + modifiedTime = "2023-10-01T12:00:00Z", + remotePath = "/path/to/file1.png", + contentHash = null, + contentUrl = null, + previewUrl = null + ), + CellNodeUi.Folder( + uuid = "folder1", + name = "Folder 1", + remotePath = "/path/to/folder1", + userName = "User B", + userHandle = "userHandle", + ownerUserId = "userB", + conversationName = "Conversation B", + modifiedTime = "2023-10-01T12:00:00Z", + size = 123456, + ) + ) ) - ) + ).collectAsLazyPagingItems(), + downloadFileSheet = MutableStateFlow(null), + menu = MutableSharedFlow(replay = 0), + sendIntent = {}, + screenTitle = "Android", + isRecycleBin = false, + breadcrumbs = arrayOf("Engineering", "Android"), + isRefreshing = remember { mutableStateOf(false) }, + onRefresh = {}, + retryEditNodeError = {}, ) - ).collectAsLazyPagingItems(), - downloadFileSheet = MutableStateFlow(null), - menu = MutableSharedFlow(replay = 0), - sendIntent = {}, - onBreadcrumbsFolderClick = {}, - screenTitle = "Android", - isRecycleBin = false, - breadcrumbs = arrayOf("Engineering", "Android"), - isRefreshing = remember { mutableStateOf(false) }, - onRefresh = {}, - retryEditNodeError = {}, - ) + } + } } } diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/ConversationFilesWithSlideInTransitionScreen.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/ConversationFilesWithSlideInTransitionScreen.kt index 1fdfd799418..23a16533934 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/ConversationFilesWithSlideInTransitionScreen.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/ConversationFilesWithSlideInTransitionScreen.kt @@ -18,6 +18,7 @@ package com.wire.android.feature.cells.ui import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedVisibilityScope import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState @@ -25,7 +26,6 @@ import androidx.compose.ui.res.stringResource import androidx.hilt.navigation.compose.hiltViewModel import androidx.paging.compose.collectAsLazyPagingItems import com.wire.android.feature.cells.R -import com.ramcosta.composedestinations.generated.cells.destinations.ConversationFilesWithSlideInTransitionScreenDestination import com.ramcosta.composedestinations.generated.cells.destinations.RecycleBinScreenDestination import com.wire.android.navigation.BackStackMode import com.wire.android.navigation.NavigationCommand @@ -42,6 +42,7 @@ import com.wire.android.ui.common.search.rememberSearchbarState fun ConversationFilesWithSlideInTransitionScreen( navigator: WireNavigator, cellFilesNavArgs: CellFilesNavArgs, + animatedVisibilityScope: AnimatedVisibilityScope, viewModel: CellViewModel = hiltViewModel(), ) { val conversationSearchBarState = rememberSearchbarState() @@ -69,6 +70,7 @@ fun ConversationFilesWithSlideInTransitionScreen( } ConversationFilesScreenContent( + animatedVisibilityScope = animatedVisibilityScope, navigator = navigator, currentNodeUuid = viewModel.currentNodeUuid(), conversationSearchBarState = conversationSearchBarState, @@ -83,10 +85,6 @@ fun ConversationFilesWithSlideInTransitionScreen( isDeleteInProgress = viewModel.isDeleteInProgress.collectAsState().value, isRefreshing = viewModel.isPullToRefresh.collectAsState(), breadcrumbs = cellFilesNavArgs.breadcrumbs, - onBreadcrumbsFolderClick = { - val stepsBack = viewModel.breadcrumbs()?.size!! - it - 1 - navigator.navigateBackAndRemoveAllConsecutiveXTimes(ConversationFilesWithSlideInTransitionScreenDestination.route, stepsBack) - }, sendIntent = viewModel::sendIntent, onRefresh = viewModel::onPullToRefresh, retryEditNodeError = viewModel::editNode diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/dialog/NodeActionsBottomSheet.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/dialog/NodeActionsBottomSheet.kt index 20f14924abc..2d6bf464041 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/dialog/NodeActionsBottomSheet.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/dialog/NodeActionsBottomSheet.kt @@ -142,6 +142,8 @@ private fun PreviewFileActionsBottomSheet() { size = 2342342, localPath = "", userName = null, + userHandle = "userHandle", + ownerUserId = "userId", conversationName = null, modifiedTime = null ), diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/download/DownloadFileBottomSheet.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/download/DownloadFileBottomSheet.kt index eab4a077b5a..a0a13fec6f5 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/download/DownloadFileBottomSheet.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/download/DownloadFileBottomSheet.kt @@ -174,6 +174,8 @@ private fun DownloadFileBottomSheetPreview() { size = 23432532532, localPath = null, userName = null, + ownerUserId = "userId", + userHandle = "userHandle", modifiedTime = null, remotePath = null, contentHash = null, @@ -202,6 +204,8 @@ private fun DownloadFileBottomSheetDownloadingPreview() { size = 23432532532, localPath = null, userName = null, + ownerUserId = "userId", + userHandle = "userHandle", modifiedTime = null, remotePath = null, contentHash = null, diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/filter/FilterBottomSheet.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/filter/FilterBottomSheet.kt index e7ac685b768..bdb718a32a3 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/filter/FilterBottomSheet.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/filter/FilterBottomSheet.kt @@ -60,6 +60,7 @@ import com.wire.android.ui.common.preview.MultipleThemePreviews import com.wire.android.ui.theme.WireTheme import com.wire.android.ui.theme.wireTypography +// TODO: To be removed in upcoming PRs when filter feature is fully implemented @Composable fun FilterBottomSheet( selectableTags: List, @@ -177,7 +178,7 @@ private fun SheetContent( label = tag, isSelected = isSelected, modifier = Modifier.padding(end = dimensions().spacing16x), - onSelectChip = { label -> + onClick = { label -> selectedChips = if (isSelected) { selectedChips - label } else { diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/model/CellNodeUi.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/model/CellNodeUi.kt index cc49d2870f2..e661653b16d 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/model/CellNodeUi.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/model/CellNodeUi.kt @@ -29,6 +29,8 @@ sealed class CellNodeUi { abstract val name: String? abstract val uuid: String abstract val userName: String? + abstract val userHandle: String? + abstract val ownerUserId: String? abstract val conversationName: String? abstract val modifiedTime: String? abstract val publicLinkId: String? @@ -41,6 +43,8 @@ sealed class CellNodeUi { override val name: String?, override val uuid: String, override val userName: String?, + override val userHandle: String?, + override val ownerUserId: String?, override val conversationName: String?, override val modifiedTime: String?, override val publicLinkId: String? = null, @@ -54,6 +58,8 @@ sealed class CellNodeUi { override val name: String?, override val uuid: String, override val userName: String?, + override val userHandle: String?, + override val ownerUserId: String?, override val conversationName: String?, override val modifiedTime: String?, override val publicLinkId: String? = null, @@ -83,6 +89,8 @@ internal fun Node.File.toUiModel() = CellNodeUi.File( contentUrl = contentUrl, previewUrl = previewUrl, userName = userName, + userHandle = userHandle, + ownerUserId = ownerUserId, conversationName = conversationName, publicLinkId = publicLinkId, modifiedTime = formattedModifiedTime(), @@ -94,6 +102,8 @@ internal fun Node.Folder.toUiModel() = CellNodeUi.Folder( uuid = uuid, name = name, userName = userName, + userHandle = userHandle, + ownerUserId = ownerUserId, conversationName = conversationName, modifiedTime = formattedModifiedTime(), remotePath = remotePath, diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/movetofolder/MoveToFolderScreenContent.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/movetofolder/MoveToFolderScreenContent.kt index b00dc8cf491..5c7cdbb6ec3 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/movetofolder/MoveToFolderScreenContent.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/movetofolder/MoveToFolderScreenContent.kt @@ -124,6 +124,8 @@ fun PreviewMoveToFolderItem() { uuid = "243567990900989897", name = "some folder.pdf", userName = "User", + ownerUserId = "userId", + userHandle = "userHandle", conversationName = "Conversation", modifiedTime = null, size = 1234, diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/recyclebin/RecycleBinScreen.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/recyclebin/RecycleBinScreen.kt index c719671b692..d7ab97de41c 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/recyclebin/RecycleBinScreen.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/recyclebin/RecycleBinScreen.kt @@ -97,7 +97,6 @@ fun RecycleBinScreen( sendIntent = { cellViewModel.sendIntent(it) }, downloadFileState = cellViewModel.downloadFileSheet, menuState = cellViewModel.menu, - isAllFiles = false, isRecycleBin = true, isRestoreInProgress = cellViewModel.isRestoreInProgress.collectAsState().value, isDeleteInProgress = cellViewModel.isDeleteInProgress.collectAsState().value, diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/search/SearchNavArgs.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/search/SearchNavArgs.kt new file mode 100644 index 00000000000..f63531226e9 --- /dev/null +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/search/SearchNavArgs.kt @@ -0,0 +1,22 @@ +/* + * 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.feature.cells.ui.search + +data class SearchNavArgs( + val conversationId: String, +) diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/search/SearchScreen.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/search/SearchScreen.kt new file mode 100644 index 00000000000..835ed5dd532 --- /dev/null +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/search/SearchScreen.kt @@ -0,0 +1,301 @@ +/* + * 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.feature.cells.ui.search + +import androidx.compose.animation.AnimatedVisibilityScope +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.isImeVisible +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.SheetState +import androidx.compose.material3.rememberModalBottomSheetState +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.rememberCoroutineScope +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.stringResource +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.paging.compose.collectAsLazyPagingItems +import com.ramcosta.composedestinations.generated.cells.destinations.AddRemoveTagsScreenDestination +import com.ramcosta.composedestinations.generated.cells.destinations.MoveToFolderScreenDestination +import com.ramcosta.composedestinations.generated.cells.destinations.PublicLinkScreenDestination +import com.ramcosta.composedestinations.generated.cells.destinations.RenameNodeScreenDestination +import com.ramcosta.composedestinations.generated.cells.destinations.VersionHistoryScreenDestination +import com.wire.android.feature.cells.R +import com.wire.android.feature.cells.ui.CellScreenContent +import com.wire.android.feature.cells.ui.CellViewModel +import com.wire.android.feature.cells.ui.model.CellNodeUi +import com.wire.android.feature.cells.ui.search.filter.FilterChipsRow +import com.wire.android.feature.cells.ui.search.filter.bottomsheet.FilterByTypeBottomSheet +import com.wire.android.feature.cells.ui.search.filter.bottomsheet.owner.FilterByOwnerBottomSheet +import com.wire.android.feature.cells.ui.search.filter.bottomsheet.tags.FilterByTagsBottomSheet +import com.wire.android.navigation.NavigationCommand +import com.wire.android.navigation.WireNavigator +import com.wire.android.navigation.annotation.features.cells.WireCellsDestination +import com.wire.android.navigation.style.PopUpNavigationAnimation +import com.wire.android.navigation.transition.LocalSharedTransitionScope +import com.wire.android.navigation.transition.SHARED_ELEMENT_SEARCH_INPUT_KEY +import com.wire.android.ui.common.scaffold.WireScaffold +import com.wire.android.ui.common.topappbar.search.SearchTopBar +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +@OptIn(ExperimentalSharedTransitionApi::class, ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) +@WireCellsDestination( + style = PopUpNavigationAnimation::class, + navArgs = SearchNavArgs::class, +) +@Composable +fun SearchScreen( + navigator: WireNavigator, + animatedVisibilityScope: AnimatedVisibilityScope, + modifier: Modifier = Modifier, + searchScreenViewModel: SearchScreenViewModel = hiltViewModel(), + cellViewModel: CellViewModel = hiltViewModel(), +) { + val scope = rememberCoroutineScope() + val focusRequester = remember { FocusRequester() } + val focusManager = LocalFocusManager.current + + val uiState by searchScreenViewModel.uiState.collectAsStateWithLifecycle() + + val filterTypeSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val filterTagsSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val filterOwnerSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val keyboardController = LocalSoftwareKeyboardController.current + val isImeVisible = WindowInsets.isImeVisible + + + fun closeSheet(sheetState: SheetState, onCloseFlag: () -> Unit) { + scope.launch { + sheetState.hide() + onCloseFlag() + } + } + + fun openSheet(onOpenFlag: () -> Unit = { }) { + scope.launch { + focusManager.clearFocus(force = true) + if (isImeVisible) { + keyboardController?.hide() + delay(300) + } + onOpenFlag() + } + } + + val sharedScope = LocalSharedTransitionScope.current + + val searchState = remember { TextFieldState() } + + LaunchedEffect(searchState) { + snapshotFlow { searchState.text.toString() } + .collect { searchScreenViewModel.onSearchQueryChanged(it) } + } + + with(sharedScope) { + + WireScaffold( + modifier = modifier, + topBar = { + Column { + SearchTopBar( + modifier = Modifier.sharedElement( + sharedContentState = rememberSharedContentState(key = SHARED_ELEMENT_SEARCH_INPUT_KEY), + animatedVisibilityScope = animatedVisibilityScope + ), + isSearchActive = uiState.isSearchActive, + searchBarHint = stringResource(R.string.search_shared_drive_text_input_hint), + searchQueryTextState = searchState, + onCloseSearchClicked = { navigator.navigateBack() }, + onActiveChanged = { }, + focusRequester = focusRequester, + focusManager = focusManager + ) + FilterChipsRow( + isSharedByLinkSelected = uiState.filesWithPublicLink, + tagsCount = uiState.tagsCount, + typeCount = uiState.typeCount, + ownerCount = uiState.ownerCount, + hasAnyFilter = uiState.hasAnyFilter, + onFilterByTagsClicked = { + openSheet { searchScreenViewModel.onFilterByTagsClicked() } + }, + onFilterByTypeClicked = { + openSheet { searchScreenViewModel.onFilterByTypeClicked() } + }, + onFilterByOwnerClicked = { + openSheet { searchScreenViewModel.onFilterByOwnerClicked() } + }, + onFilterBySharedByLinkClicked = { + searchScreenViewModel.onSharedByMeClicked() + }, + onRemoveAllFiltersClicked = { + searchScreenViewModel.onRemoveAllFilters() + } + ) + } + } + ) { innerPadding -> + with(searchScreenViewModel.cellNodesFlow.collectAsLazyPagingItems()) { + CellScreenContent( + modifier = Modifier.padding(innerPadding), + actionsFlow = cellViewModel.actions, + pagingListItems = this, + sendIntent = { cellViewModel.sendIntent(it) }, + downloadFileState = cellViewModel.downloadFileSheet, + menuState = cellViewModel.menu, + isSearchResult = true, + isRestoreInProgress = cellViewModel.isRestoreInProgress.collectAsState().value, + isDeleteInProgress = cellViewModel.isDeleteInProgress.collectAsState().value, + openFolder = { _, _, _ -> }, + showPublicLinkScreen = { publicLinkScreenData -> + navigator.navigate( + NavigationCommand( + PublicLinkScreenDestination( + assetId = publicLinkScreenData.assetId, + fileName = publicLinkScreenData.fileName, + publicLinkId = publicLinkScreenData.linkId, + isFolder = publicLinkScreenData.isFolder + ) + ) + ) + }, + showMoveToFolderScreen = { currentPath, nodePath, uuid -> + navigator.navigate( + NavigationCommand( + MoveToFolderScreenDestination( + currentPath = currentPath, + nodeToMovePath = nodePath, + uuid = uuid + ) + ) + ) + }, + showRenameScreen = { cellNodeUi -> + navigator.navigate( + NavigationCommand( + RenameNodeScreenDestination( + uuid = cellNodeUi.uuid, + currentPath = cellNodeUi.remotePath, + isFolder = cellNodeUi is CellNodeUi.Folder, + nodeName = cellNodeUi.name, + ) + ) + ) + }, + showAddRemoveTagsScreen = { node -> + navigator.navigate( + NavigationCommand( + AddRemoveTagsScreenDestination(node.uuid, node.tags.toCollection(ArrayList())) + ) + ) + }, + showVersionHistoryScreen = { uuid, fileName -> + navigator.navigate(NavigationCommand(VersionHistoryScreenDestination(uuid, fileName))) + }, + retryEditNodeError = { cellViewModel.editNode(it) }, + isRefreshing = remember { mutableStateOf(false) }, + onRefresh = { } + ) + } + + if (uiState.showFilterByTagsBottomSheet) { + FilterByTagsBottomSheet( + items = uiState.availableTags, + sheetState = filterTagsSheetState, + onDismiss = { + closeSheet( + sheetState = filterTagsSheetState, + onCloseFlag = { searchScreenViewModel.onCloseTagsSheet() } + ) + }, + onSave = { selectedItems -> + searchScreenViewModel.onSaveTags(selectedItems) + closeSheet( + sheetState = filterTagsSheetState, + onCloseFlag = { searchScreenViewModel.onCloseTagsSheet() } + ) + }, + onRemoveAll = { + searchScreenViewModel.onRemoveAllTags() + } + ) + } + + if (uiState.showFilterByTypeBottomSheet) { + FilterByTypeBottomSheet( + items = uiState.availableTypes, + sheetState = filterTypeSheetState, + onDismiss = { + closeSheet( + sheetState = filterTypeSheetState, + onCloseFlag = { searchScreenViewModel.onCloseTypeSheet() } + ) + }, + onSave = { selectedItems -> + + searchScreenViewModel.onSaveTypes(selectedItems) + closeSheet( + sheetState = filterTypeSheetState, + onCloseFlag = { searchScreenViewModel.onCloseTypeSheet() } + ) + }, + onRemoveFilter = { + searchScreenViewModel.onRemoveTypeFilter() + } + ) + } + + if (uiState.showFilterByOwnerBottomSheet) { + FilterByOwnerBottomSheet( + items = uiState.availableOwners, + sheetState = filterOwnerSheetState, + onDismiss = { + closeSheet( + sheetState = filterOwnerSheetState, + onCloseFlag = { searchScreenViewModel.onCloseOwnerSheet() } + ) + }, + onSave = { selectedItems -> + + searchScreenViewModel.onSaveOwners(selectedItems) + closeSheet( + sheetState = filterOwnerSheetState, + onCloseFlag = { searchScreenViewModel.onCloseOwnerSheet() } + ) + }, + onRemoveAll = { searchScreenViewModel.onRemoveOwners() } + ) + } + } + } +} diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/search/SearchScreenViewModel.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/search/SearchScreenViewModel.kt new file mode 100644 index 00000000000..a26de7cc1ac --- /dev/null +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/search/SearchScreenViewModel.kt @@ -0,0 +1,280 @@ +/* + * 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.feature.cells.ui.search + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.paging.PagingData +import androidx.paging.cachedIn +import androidx.paging.map +import com.ramcosta.composedestinations.generated.cells.destinations.SearchScreenDestination +import com.wire.android.feature.cells.ui.model.CellNodeUi +import com.wire.android.feature.cells.ui.model.toUiModel +import com.wire.android.feature.cells.ui.search.filter.data.FilterOwnerUi +import com.wire.android.feature.cells.ui.search.filter.data.FilterTagUi +import com.wire.android.feature.cells.ui.search.filter.data.FilterTypeUi +import com.wire.android.model.ImageAsset +import com.wire.kalium.cells.data.FileFilters +import com.wire.kalium.cells.data.MIMEType +import com.wire.kalium.cells.domain.model.Node +import com.wire.kalium.cells.domain.usecase.GetAllTagsUseCase +import com.wire.kalium.cells.domain.usecase.GetOwnersUseCase +import com.wire.kalium.cells.domain.usecase.GetOwnersUseCaseResult +import com.wire.kalium.cells.domain.usecase.GetPaginatedFilesFlowUseCase +import com.wire.kalium.common.functional.onSuccess +import com.wire.kalium.logic.data.user.UserAssetId +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +// TODO: to cover it with unit test in upcoming PR +@Suppress("TooManyFunctions") +@HiltViewModel +class SearchScreenViewModel @Inject constructor( + val savedStateHandle: SavedStateHandle, + private val getAllTagsUseCase: GetAllTagsUseCase, + private val getCellFilesPaged: GetPaginatedFilesFlowUseCase, + private val getOwners: GetOwnersUseCase, +) : ViewModel() { + + private data class SearchParams( + val query: String, + val tagIds: List, + val ownerIds: List, + val mimeTypes: List, + val filesWithPublicLink: Boolean?, + ) + + private val navArgs: SearchNavArgs = SearchScreenDestination.argsFrom(savedStateHandle) + + private val _uiState = MutableStateFlow(SearchUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private val queryFlow = MutableStateFlow("") + + private val searchParamsFlow: Flow = + combine( + queryFlow, + uiState + ) { query, state -> + SearchParams( + query = query, + tagIds = state.availableTags.filter { it.selected }.map { it.id }, + ownerIds = state.availableOwners.filter { it.selected }.map { it.id }, + mimeTypes = state.availableTypes.filter { it.selected }.map { it.mimeType }, + filesWithPublicLink = state.filesWithPublicLink + ) + }.distinctUntilChanged() + + val cellNodesFlow: Flow> = + searchParamsFlow.flatMapLatest> { params: SearchParams -> + getCellFilesPaged( + conversationId = navArgs.conversationId, + query = params.query, + fileFilters = FileFilters( + tags = params.tagIds, + owners = params.ownerIds, + mimeTypes = params.mimeTypes, + hasPublicLink = params.filesWithPublicLink + ), + ).map { pagingData: PagingData -> + pagingData.map { node: Node -> + when (node) { + is Node.Folder -> node.toUiModel() + is Node.File -> node.toUiModel() + } + } + } + }.cachedIn(viewModelScope) + + init { + loadTags() + loadOwners() + } + + internal fun loadTags() = viewModelScope.launch { + getAllTagsUseCase().onSuccess { updated -> + _uiState.update { + it.copy( + availableTags = updated.map { tag -> + FilterTagUi( + id = tag, + name = tag, + ) + } + ) + } + } + } + + fun loadOwners(conversationId: String? = navArgs.conversationId) { + viewModelScope.launch { + when (val result = getOwners(conversationId = conversationId)) { + is GetOwnersUseCaseResult.Success -> { + val ownersUi = result.owners.mapNotNull { owner -> + val name = owner.name?.takeIf { it.isNotBlank() } + val handle = owner.handle?.takeIf { it.isNotBlank() } + if (name == null || handle == null) return@mapNotNull null + + val picture = owner.completePicture ?: owner.previewPicture + val avatarAsset = picture?.let { pic -> + ImageAsset.UserAvatarAsset( + UserAssetId( + value = pic.value, + domain = pic.domain + ) + ) + } + + FilterOwnerUi( + id = owner.id.value, + displayName = name, + handle = handle, + userAvatarAsset = avatarAsset, + selected = false + ) + } + .sortedBy { it.displayName.uppercase() } + + _uiState.update { state -> + state.copy( + availableOwners = ownersUi + ) + } + } + + is GetOwnersUseCaseResult.Failure -> { + // no need to show error, just keep the owners list empty + } + } + } + } + + fun onSearchQueryChanged(query: String) { + queryFlow.value = query + } + + fun onFilterByTypeClicked() { + _uiState.update { it.copy(showFilterByTypeBottomSheet = true) } + } + + fun onCloseTypeSheet() { + _uiState.update { it.copy(showFilterByTypeBottomSheet = false) } + } + + fun onFilterByTagsClicked() { + _uiState.update { it.copy(showFilterByTagsBottomSheet = true) } + } + + fun onCloseTagsSheet() { + _uiState.update { it.copy(showFilterByTagsBottomSheet = false) } + } + + fun onFilterByOwnerClicked() { + _uiState.update { it.copy(showFilterByOwnerBottomSheet = true) } + } + + fun onCloseOwnerSheet() { + _uiState.update { it.copy(showFilterByOwnerBottomSheet = false) } + } + + fun onSetSearchActive(active: Boolean) { + _uiState.update { it.copy(isSearchActive = active) } + } + + private fun applySelectedTags(selectedIds: Set) { + _uiState.update { state -> + state.copy( + availableTags = state.availableTags.map { tag -> + tag.copy(selected = tag.id in selectedIds) + } + ) + } + } + + fun onSaveTags(selectedTags: List) { + applySelectedTags(selectedTags.filter { it.selected }.map { it.id }.toSet()) + } + + fun onRemoveAllTags() { + _uiState.update { state -> + state.copy(availableTags = state.availableTags.map { it.copy(selected = false) }) + } + } + + private fun applySelectedTypes(selectedIds: Set) { + _uiState.update { state -> + state.copy( + availableTypes = state.availableTypes.map { tag -> + tag.copy(selected = tag.id in selectedIds) + } + ) + } + } + + fun onSaveTypes(selectedOwners: List) { + applySelectedTypes(selectedOwners.filter { it.selected }.map { it.id }.toSet()) + } + + fun onRemoveTypeFilter() { + _uiState.update { state -> + state.copy(availableTypes = state.availableTypes.map { it.copy(selected = false) }) + } + } + + fun onSharedByMeClicked() { + _uiState.update { it.copy(filesWithPublicLink = !it.filesWithPublicLink) } + } + + private fun applySelectedOwners(selectedIds: Set) { + _uiState.update { state -> + state.copy( + availableOwners = state.availableOwners.map { tag -> + tag.copy(selected = tag.id in selectedIds) + } + ) + } + } + + fun onSaveOwners(selectedOwners: List) { + applySelectedOwners(selectedOwners.filter { it.selected }.map { it.id }.toSet()) + } + + fun onRemoveOwners() { + _uiState.update { state -> + state.copy(availableOwners = state.availableOwners.map { it.copy(selected = false) }) + } + } + + fun onRemoveAllFilters() = _uiState.update { + onRemoveAllTags() + onRemoveOwners() + onRemoveTypeFilter() + it.copy(filesWithPublicLink = false) + } +} diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/search/SearchUiState.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/search/SearchUiState.kt new file mode 100644 index 00000000000..9266ff73851 --- /dev/null +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/search/SearchUiState.kt @@ -0,0 +1,44 @@ +/* + * 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.feature.cells.ui.search + +import com.wire.android.feature.cells.ui.search.filter.data.FilterOwnerUi +import com.wire.android.feature.cells.ui.search.filter.data.FilterTagUi +import com.wire.android.feature.cells.ui.search.filter.data.FilterTypeUi +import com.wire.android.feature.cells.ui.search.filter.data.TypeFilter + +data class SearchUiState( + val availableTags: List = emptyList(), + val availableOwners: List = emptyList(), + val availableTypes: List = TypeFilter.typeItems, + + val showFilterByTypeBottomSheet: Boolean = false, + val showFilterByTagsBottomSheet: Boolean = false, + val showFilterByOwnerBottomSheet: Boolean = false, + + val filesWithPublicLink: Boolean = false, + + val isSearchActive: Boolean = true, +) { + val tagsCount: Int get() = availableTags.count { it.selected } + val typeCount: Int get() = availableTypes.count { it.selected } + val ownerCount: Int get() = availableOwners.count { it.selected } + + val hasAnyFilter: Boolean + get() = tagsCount > 0 || typeCount > 0 || ownerCount > 0 || filesWithPublicLink +} diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/search/filter/FilterChipsRow.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/search/filter/FilterChipsRow.kt new file mode 100644 index 00000000000..35604840592 --- /dev/null +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/search/filter/FilterChipsRow.kt @@ -0,0 +1,112 @@ +/* + * 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.feature.cells.ui.search.filter + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import com.wire.android.feature.cells.R +import com.wire.android.ui.common.chip.WireFilterChip +import com.wire.android.ui.common.colorsScheme +import com.wire.android.ui.common.dimensions +import com.wire.android.ui.common.preview.MultipleThemePreviews +import com.wire.android.ui.common.typography +import com.wire.android.ui.theme.WireTheme + +@Composable +fun FilterChipsRow( + isSharedByLinkSelected: Boolean, + tagsCount: Int, + typeCount: Int, + ownerCount: Int, + hasAnyFilter: Boolean, + modifier: Modifier = Modifier, + onFilterByTagsClicked: () -> Unit = { }, + onFilterByTypeClicked: () -> Unit = { }, + onFilterByOwnerClicked: () -> Unit = { }, + onRemoveAllFiltersClicked: () -> Unit = { }, + onFilterBySharedByLinkClicked: () -> Unit = { } +) { + val scrollState = rememberScrollState() + + @Composable + fun DropdownChip(labelRes: Int, count: Int, onClick: () -> Unit) { + WireFilterChip( + label = stringResource(labelRes), + count = count.takeIf { it > 0 }, + isSelected = count > 0, + trailingIconResource = R.drawable.ic_dropdown_chevron, + onClick = { onClick() } + ) + } + + Row( + modifier = modifier + .fillMaxWidth() + .horizontalScroll(scrollState) + .background(colorsScheme().background) + .padding(horizontal = dimensions().spacing12x), + horizontalArrangement = Arrangement.spacedBy(dimensions().spacing8x) + ) { + DropdownChip(R.string.filter_chip_tags, tagsCount, onFilterByTagsClicked) + DropdownChip(R.string.filter_chip_type, typeCount, onFilterByTypeClicked) + DropdownChip(R.string.filter_chip_owner, ownerCount, onFilterByOwnerClicked) + + WireFilterChip( + label = stringResource(R.string.filter_chip_link_sharing), + isSelected = isSharedByLinkSelected, + onClick = { + onFilterBySharedByLinkClicked() + } + ) + if (hasAnyFilter) { + Text( + modifier = Modifier + .align(alignment = Alignment.CenterVertically) + .clickable { onRemoveAllFiltersClicked() }, + text = stringResource(R.string.filter_chip_remove_all_filters), + style = typography().button02, + color = colorsScheme().primary, + ) + } + } +} + +@MultipleThemePreviews +@Composable +fun PreviewFilterChipsRow() { + WireTheme { + FilterChipsRow( + isSharedByLinkSelected = true, + tagsCount = 2, + typeCount = 1, + ownerCount = 0, + hasAnyFilter = true, + ) + } +} diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/search/filter/bottomsheet/FilterByTypeBottomSheet.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/search/filter/bottomsheet/FilterByTypeBottomSheet.kt new file mode 100644 index 00000000000..c6eff9e1655 --- /dev/null +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/search/filter/bottomsheet/FilterByTypeBottomSheet.kt @@ -0,0 +1,224 @@ +/* + * 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.feature.cells.ui.search.filter.bottomsheet + +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Checkbox +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.SheetState +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +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.res.painterResource +import androidx.compose.ui.res.stringResource +import com.wire.android.feature.cells.R +import com.wire.android.feature.cells.ui.search.filter.data.FilterTypeUi +import com.wire.android.ui.common.dimensions +import com.wire.android.ui.common.preview.MultipleThemePreviews +import com.wire.android.ui.common.typography +import com.wire.android.ui.theme.WireTheme +import com.wire.kalium.cells.data.MIMEType + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun FilterByTypeBottomSheet( + sheetState: SheetState, + items: List, + onDismiss: () -> Unit, + onSave: (List) -> Unit, + onRemoveFilter: () -> Unit, + modifier: Modifier = Modifier +) { + var itemsState by remember(items) { mutableStateOf(items) } + + val hasChanges = itemsState.any { tag -> + val initial = items.first { it.id == tag.id } + tag.selected != initial.selected + } + + ModalBottomSheet( + modifier = modifier, + onDismissRequest = onDismiss, + sheetState = sheetState, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight(0.8f) + .padding(bottom = dimensions().spacing16x) + ) { + Text( + text = stringResource(R.string.bottom_sheet_title_filter_by_type), + style = typography().title02, + modifier = Modifier.padding( + horizontal = dimensions().spacing16x, + vertical = dimensions().spacing16x + ) + ) + + HorizontalDivider() + + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .weight(1f), + contentPadding = PaddingValues(vertical = dimensions().spacing4x) + ) { + items(itemsState, key = { it.id }) { item -> + FilterRow( + label = stringResource(item.label), + iconRes = item.iconRes, + checked = item.selected, + onCheckedChange = { checked -> + itemsState = itemsState.map { + if (it.id == item.id) it.copy(selected = checked) else it + } + } + ) + HorizontalDivider() + } + } + FooterButtons( + modifier = Modifier.padding(horizontal = dimensions().spacing16x), + onRemoveAll = { + itemsState = itemsState.map { it.copy(selected = false) } + onRemoveFilter() + }, + onSave = { onSave(itemsState) }, + hasChanges = hasChanges + ) + } + } +} + +@Composable +private fun FilterRow( + label: String, + iconRes: Int, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { onCheckedChange(!checked) } + .padding( + horizontal = dimensions().spacing16x, + vertical = dimensions().spacing8x + ), + verticalAlignment = Alignment.CenterVertically + ) { + Image( + painter = painterResource(iconRes), + contentDescription = null, + modifier = Modifier.size(dimensions().spacing20x) + ) + + Spacer(Modifier.width(dimensions().spacing8x)) + + Text( + text = label, + style = typography().body01, + modifier = Modifier.weight(1f) + ) + + Checkbox( + checked = checked, + onCheckedChange = onCheckedChange + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@MultipleThemePreviews +@Composable +fun PreviewFilterByTypeBottomSheet() { + val sampleItems = listOf( + FilterTypeUi( + id = "1", + label = R.string.filter_images_type, + iconRes = android.R.drawable.ic_menu_gallery, + selected = true, + mimeType = MIMEType.IMAGE + ), + FilterTypeUi( + id = "2", + label = R.string.filter_videos_type, + iconRes = android.R.drawable.ic_menu_slideshow, + selected = false, + mimeType = MIMEType.VIDEO + ), + FilterTypeUi( + id = "3", + label = R.string.filter_documents_type, + iconRes = android.R.drawable.ic_menu_edit, + selected = true, + mimeType = MIMEType.DOCUMENT + ), + FilterTypeUi( + id = "4", + label = R.string.filter_audio_type, + iconRes = android.R.drawable.ic_media_play, + selected = false, + mimeType = MIMEType.AUDIO + ), + FilterTypeUi( + id = "6", + label = R.string.filter_spreadsheets_type, + iconRes = android.R.drawable.ic_menu_edit, + selected = false, + mimeType = MIMEType.EXCEL + ), + FilterTypeUi( + id = "7", + label = R.string.filter_pdf_type, + iconRes = android.R.drawable.ic_menu_view, + selected = false, + mimeType = MIMEType.PDF + ), + ) + WireTheme { + FilterByTypeBottomSheet( + items = sampleItems, + onDismiss = {}, + onSave = {}, + onRemoveFilter = {}, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + ) + } +} diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/search/filter/bottomsheet/FooterButtons.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/search/filter/bottomsheet/FooterButtons.kt new file mode 100644 index 00000000000..744d9eaa2a4 --- /dev/null +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/search/filter/bottomsheet/FooterButtons.kt @@ -0,0 +1,80 @@ +/* + * 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.feature.cells.ui.search.filter.bottomsheet + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import com.wire.android.feature.cells.R +import com.wire.android.ui.common.button.WireButtonState +import com.wire.android.ui.common.button.WirePrimaryButton +import com.wire.android.ui.common.button.WireSecondaryButton +import com.wire.android.ui.common.dimensions +import com.wire.android.ui.common.preview.MultipleThemePreviews + +@Composable +fun FooterButtons( + onRemoveAll: () -> Unit, + onSave: () -> Unit, + modifier: Modifier = Modifier, + hasChanges: Boolean = false +) { + Column { + Spacer(modifier.height(dimensions().spacing12x)) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = dimensions().spacing12x), + horizontalArrangement = Arrangement.spacedBy(dimensions().spacing12x) + ) { + WireSecondaryButton( + text = stringResource(R.string.button_remove_all_label), + onClick = onRemoveAll, + modifier = Modifier.weight(1f), + contentPadding = PaddingValues(vertical = dimensions().spacing14x) + ) + + WirePrimaryButton( + text = stringResource(R.string.save_label), + onClick = onSave, + modifier = Modifier.weight(1f), + state = if (hasChanges) WireButtonState.Default else WireButtonState.Disabled, + contentPadding = PaddingValues(vertical = dimensions().spacing14x) + ) + } + Spacer(Modifier.height(dimensions().spacing8x)) + } +} + +@MultipleThemePreviews +@Composable +fun FooterButtonsPreview() { + FooterButtons( + onRemoveAll = {}, + onSave = {}, + hasChanges = true + ) +} diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/search/filter/bottomsheet/owner/FilterByOwnerBottomSheet.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/search/filter/bottomsheet/owner/FilterByOwnerBottomSheet.kt new file mode 100644 index 00000000000..54cac142578 --- /dev/null +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/search/filter/bottomsheet/owner/FilterByOwnerBottomSheet.kt @@ -0,0 +1,231 @@ +/* + * 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.feature.cells.ui.search.filter.bottomsheet.owner + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.material3.Checkbox +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.SheetState +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import com.wire.android.feature.cells.R +import com.wire.android.feature.cells.ui.search.filter.bottomsheet.FooterButtons +import com.wire.android.feature.cells.ui.search.filter.data.FilterOwnerUi +import com.wire.android.model.UserAvatarData +import com.wire.android.ui.common.SearchBarInput +import com.wire.android.ui.common.avatar.UserProfileAvatar +import com.wire.android.ui.common.dimensions +import com.wire.android.ui.common.preview.MultipleThemePreviews +import com.wire.android.ui.common.typography +import com.wire.android.ui.theme.WireTheme +import com.wire.android.ui.theme.wireColorScheme +import kotlinx.coroutines.launch +import com.wire.android.ui.common.R as CommonR + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun FilterByOwnerBottomSheet( + sheetState: SheetState, + items: List, + onDismiss: () -> Unit, + onSave: (List) -> Unit, + onRemoveAll: () -> Unit, + modifier: Modifier = Modifier, +) { + + val scope = rememberCoroutineScope() + val state = rememberOwnersFilterSheetState(items) + + val searchState = remember { TextFieldState() } + LaunchedEffect(searchState) { + snapshotFlow { searchState.text.toString() } + .collect(state::onQueryChange) + } + + fun dismiss() { + scope.launch { sheetState.hide() } + .invokeOnCompletion { onDismiss() } + } + + ModalBottomSheet( + onDismissRequest = ::dismiss, + sheetState = sheetState, + modifier = modifier + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight(0.8f), + ) { + Text( + text = stringResource(R.string.bottom_sheet_title_filter_by_owner), + style = typography().title02, + modifier = Modifier.padding( + horizontal = dimensions().spacing16x, + vertical = dimensions().spacing12x + ) + ) + + SearchBarInput( + modifier = Modifier.padding(start = dimensions().spacing16x, end = dimensions().spacing16x), + placeholderText = stringResource(R.string.search_owners_text_input_hint), + textState = searchState, + leadingIcon = { + Icon( + modifier = Modifier.padding( + start = dimensions().spacing12x, + end = dimensions().spacing12x + ), + painter = painterResource(CommonR.drawable.ic_search), + contentDescription = null, + tint = MaterialTheme.wireColorScheme.onBackground, + ) + }, + ) + + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .weight(1f, fill = true), + contentPadding = PaddingValues(top = dimensions().spacing8x, bottom = dimensions().spacing8x) + ) { + items( + items = state.filteredOwners, + key = { it.id } + ) { owner -> + OwnerRow( + owner = owner, + onToggle = { state.toggleOwner(owner.id) }, + ) + HorizontalDivider() + } + } + + FooterButtons( + modifier = Modifier.padding(horizontal = dimensions().spacing16x), + onRemoveAll = { + state.removeAll() + onRemoveAll() + }, + onSave = { onSave(state.selectedOwners()) }, + hasChanges = state.hasChanges + ) + } + } +} + +@Composable +private fun OwnerRow( + owner: FilterOwnerUi, + onToggle: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { onToggle() } + .padding( + start = dimensions().spacing12x, + end = dimensions().spacing12x, + top = dimensions().spacing8x, + bottom = dimensions().spacing8x + ), + verticalAlignment = Alignment.CenterVertically + ) { + UserProfileAvatar(avatarData = UserAvatarData(owner.userAvatarAsset)) + + Column( + modifier = Modifier.weight(1f) + ) { + Text( + text = owner.displayName, + style = typography().body01, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = MaterialTheme.wireColorScheme.onSurface + ) + Text( + text = owner.handle, + style = typography().label04, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = MaterialTheme.wireColorScheme.secondaryText + ) + } + Checkbox( + checked = owner.selected, + onCheckedChange = { onToggle() } + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@MultipleThemePreviews +@Composable +fun PreviewFilterByOwnerBottomSheet() { + WireTheme { + FilterByOwnerBottomSheet( + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + items = listOf( + FilterOwnerUi( + id = "1", + displayName = "John Doe", + handle = "@johndoe", + selected = true + ), + FilterOwnerUi( + id = "2", + displayName = "Jane Smith with very long name that should be truncated", + handle = "@janesmith", + selected = false + ), + FilterOwnerUi( + id = "3", + displayName = "Alice Johnson", + handle = "@alicejohnson", + selected = false + ) + ), + onDismiss = {}, + onSave = {}, + onRemoveAll = {} + ) + } +} diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/search/filter/bottomsheet/owner/OwnersFilterSheetState.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/search/filter/bottomsheet/owner/OwnersFilterSheetState.kt new file mode 100644 index 00000000000..16f33da248c --- /dev/null +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/search/filter/bottomsheet/owner/OwnersFilterSheetState.kt @@ -0,0 +1,74 @@ +/* + * 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.feature.cells.ui.search.filter.bottomsheet.owner + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import com.wire.android.feature.cells.ui.search.filter.data.FilterOwnerUi + +@Stable +class OwnersFilterSheetState( + initialItems: List +) { + private val initialById = initialItems.associateBy { it.id } + + var owners by mutableStateOf(initialItems) + private set + + var query by mutableStateOf("") + private set + + val hasChanges: Boolean + get() = owners.any { o -> initialById[o.id]?.selected != o.selected } + + val filteredOwners: List + get() { + val q = query.trim() + return if (q.isBlank()) { + owners + } else { + owners.filter { + it.displayName.contains(q, ignoreCase = true) || + it.handle.contains(q, ignoreCase = true) + } + } + } + + fun onQueryChange(text: String) { + query = text + } + + fun toggleOwner(id: String) { + owners = owners.map { if (it.id == id) it.copy(selected = !it.selected) else it } + } + + fun removeAll() { + owners = owners.map { it.copy(selected = false) } + } + + fun selectedOwners(): List = owners.filter { it.selected } +} + +@Composable +fun rememberOwnersFilterSheetState(items: List): OwnersFilterSheetState { + return remember(items) { OwnersFilterSheetState(items) } +} diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/search/filter/bottomsheet/tags/FilterByTagsBottomSheet.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/search/filter/bottomsheet/tags/FilterByTagsBottomSheet.kt new file mode 100644 index 00000000000..f75300a9e89 --- /dev/null +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/search/filter/bottomsheet/tags/FilterByTagsBottomSheet.kt @@ -0,0 +1,186 @@ +/* + * 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.feature.cells.ui.search.filter.bottomsheet.tags + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.SheetState +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import com.wire.android.feature.cells.R +import com.wire.android.feature.cells.ui.search.filter.bottomsheet.FooterButtons +import com.wire.android.feature.cells.ui.search.filter.data.FilterTagUi +import com.wire.android.ui.common.SearchBarInput +import com.wire.android.ui.common.chip.WireFilterChip +import com.wire.android.ui.common.dimensions +import com.wire.android.ui.common.preview.MultipleThemePreviews +import com.wire.android.ui.common.typography +import com.wire.android.ui.theme.WireTheme +import com.wire.android.ui.theme.wireColorScheme +import kotlinx.coroutines.launch +import com.wire.android.ui.common.R as CommonR + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun FilterByTagsBottomSheet( + sheetState: SheetState, + items: List, + onDismiss: () -> Unit, + onSave: (List) -> Unit, + onRemoveAll: () -> Unit, + modifier: Modifier = Modifier +) { + + val scope = rememberCoroutineScope() + + val state = rememberTagsFilterSheetState(items) + + val searchState = remember { TextFieldState() } + LaunchedEffect(searchState) { + snapshotFlow { searchState.text.toString() } + .collect(state::onQueryChange) + } + + fun dismiss() { + scope.launch { sheetState.hide() } + .invokeOnCompletion { onDismiss() } + } + ModalBottomSheet( + modifier = modifier, + onDismissRequest = ::dismiss, + sheetState = sheetState, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight(0.8f) + .padding(horizontal = dimensions().spacing16x) + ) { + Text( + text = stringResource(R.string.bottom_sheet_title_filter_by_tags), + style = typography().title02, + modifier = Modifier.padding( + horizontal = dimensions().spacing4x, + vertical = dimensions().spacing12x + ) + ) + + SearchBarInput( + placeholderText = stringResource(R.string.bottom_sheet_title_search_tags), + textState = searchState, + leadingIcon = { + Icon( + modifier = Modifier.padding( + start = dimensions().spacing12x, + end = dimensions().spacing12x + ), + painter = painterResource(CommonR.drawable.ic_search), + contentDescription = null, + tint = MaterialTheme.wireColorScheme.onBackground, + ) + }, + ) + + Spacer(Modifier.height(dimensions().spacing12x)) + + Column( + modifier = Modifier + .fillMaxWidth() + .weight(1f, fill = true) + .verticalScroll(rememberScrollState()) + ) { + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(dimensions().spacing8x), + ) { + state.filteredTags.forEach { tag -> + WireFilterChip( + label = tag.name, + isSelected = tag.selected, + onClick = { state.toggle(tag.id) } + ) + } + } + + Spacer(Modifier.height(dimensions().spacing12x)) + } + + FooterButtons( + onRemoveAll = { + state.removeAll() + onRemoveAll() + }, + onSave = { onSave(state.selectedTags()) }, + hasChanges = state.hasChanges + ) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@MultipleThemePreviews +@Composable +fun PreviewFilterByTagsBottomSheet() { + WireTheme { + FilterByTagsBottomSheet( + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + items = listOf( + FilterTagUi("1", "Work", true), + FilterTagUi("2", "Personal", true), + FilterTagUi("3", "Important", true), + FilterTagUi("4", "Later"), + FilterTagUi("5", "Travel"), + FilterTagUi("6", "Shopping"), + FilterTagUi("7", "Fitness"), + FilterTagUi("8", "Health"), + FilterTagUi("11", "Work"), + FilterTagUi("21", "Personal"), + FilterTagUi("31", "Important"), + FilterTagUi("41", "Later"), + FilterTagUi("51", "Travel"), + FilterTagUi("61", "Shopping"), + FilterTagUi("71", "Fitness"), + FilterTagUi("81", "Health"), + ), + onDismiss = {}, + onSave = {}, + onRemoveAll = {} + ) + } +} diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/search/filter/bottomsheet/tags/TagsFilterSheetState.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/search/filter/bottomsheet/tags/TagsFilterSheetState.kt new file mode 100644 index 00000000000..25234e37eed --- /dev/null +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/search/filter/bottomsheet/tags/TagsFilterSheetState.kt @@ -0,0 +1,81 @@ +/* + * 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.feature.cells.ui.search.filter.bottomsheet.tags + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import com.wire.android.feature.cells.ui.search.filter.data.FilterTagUi + +@Stable +class TagsFilterSheetState( + initialItems: List +) { + private val initialById = initialItems.associateBy { it.id } + + var tags by mutableStateOf(initialItems) + private set + + var query by mutableStateOf("") + private set + + val hasChanges: Boolean + get() = tags.any { t -> initialById[t.id]?.selected != t.selected } + + val filteredTags: List + get() { + val q = query.trim() + val base = if (q.isBlank()) { + tags + } else { + tags.filter { + it.name.contains(q, ignoreCase = true) + } + } + return base.sortedWith( + compareByDescending { it.selected } + .thenBy { it.name.lowercase() } + ) + } + + fun updateItems(newItems: List) { + tags = newItems + } + + fun onQueryChange(text: String) { + query = text + } + + fun toggle(id: String) { + tags = tags.map { if (it.id == id) it.copy(selected = !it.selected) else it } + } + + fun removeAll() { + tags = tags.map { it.copy(selected = false) } + } + + fun selectedTags(): List = tags.filter { it.selected } +} + +@Composable +fun rememberTagsFilterSheetState(items: List): TagsFilterSheetState { + return remember(items) { TagsFilterSheetState(items) } +} diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/search/filter/data/FilterOwnerUi.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/search/filter/data/FilterOwnerUi.kt new file mode 100644 index 00000000000..225782be832 --- /dev/null +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/search/filter/data/FilterOwnerUi.kt @@ -0,0 +1,28 @@ +/* + * 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.feature.cells.ui.search.filter.data + +import com.wire.android.model.ImageAsset + +data class FilterOwnerUi( + val id: String, + val displayName: String, + val handle: String, + val userAvatarAsset: ImageAsset.UserAvatarAsset? = null, + val selected: Boolean = false +) diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/search/filter/data/FilterTagUi.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/search/filter/data/FilterTagUi.kt new file mode 100644 index 00000000000..59635f9b8c3 --- /dev/null +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/search/filter/data/FilterTagUi.kt @@ -0,0 +1,24 @@ +/* + * 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.feature.cells.ui.search.filter.data + +data class FilterTagUi( + val id: String, + val name: String, + val selected: Boolean = false +) diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/search/filter/data/FilterTypeUi.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/search/filter/data/FilterTypeUi.kt new file mode 100644 index 00000000000..5dafa726a1d --- /dev/null +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/search/filter/data/FilterTypeUi.kt @@ -0,0 +1,28 @@ +/* + * 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.feature.cells.ui.search.filter.data + +import com.wire.kalium.cells.data.MIMEType + +data class FilterTypeUi( + val id: String, + val label: Int, + val iconRes: Int, + val selected: Boolean = false, + val mimeType: MIMEType +) diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/search/filter/data/TypeFilter.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/search/filter/data/TypeFilter.kt new file mode 100644 index 00000000000..20950086b73 --- /dev/null +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/search/filter/data/TypeFilter.kt @@ -0,0 +1,93 @@ +/* + * 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.feature.cells.ui.search.filter.data + +import com.wire.android.feature.cells.R +import com.wire.kalium.cells.data.MIMEType + +object TypeFilter { + val typeItems: List by lazy { + MIMEType.entries.map { it.toFilterTypeUi() } + } +} + +fun MIMEType.toFilterTypeUi(): FilterTypeUi = + when (this) { + MIMEType.PDF -> FilterTypeUi( + id = name, + label = R.string.filter_pdf_type, + iconRes = R.drawable.ic_file_type_pdf, + mimeType = this + ) + + MIMEType.DOCUMENT -> FilterTypeUi( + id = name, + label = R.string.filter_documents_type, + iconRes = R.drawable.ic_file_type_doc, + mimeType = this + ) + + MIMEType.IMAGE -> FilterTypeUi( + id = name, + label = R.string.filter_images_type, + iconRes = R.drawable.ic_file_type_image, + mimeType = this + ) + + MIMEType.EXCEL -> FilterTypeUi( + id = name, + label = R.string.filter_spreadsheets_type, + iconRes = R.drawable.ic_file_type_spreadsheet, + mimeType = this + ) + + MIMEType.PRESENTATION -> FilterTypeUi( + id = name, + label = R.string.filter_presentations_type, + iconRes = R.drawable.ic_file_type_presentation, + mimeType = this + ) + + MIMEType.VIDEO -> FilterTypeUi( + id = name, + label = R.string.filter_videos_type, + iconRes = R.drawable.ic_file_type_video, + mimeType = this + ) + + MIMEType.AUDIO -> FilterTypeUi( + id = name, + label = R.string.filter_audio_type, + iconRes = R.drawable.ic_file_type_audio, + mimeType = this + ) + + MIMEType.ARCHIVE -> FilterTypeUi( + id = name, + label = R.string.filter_archives_type, + iconRes = R.drawable.ic_file_type_archive, + mimeType = this + ) + + MIMEType.TEXT -> FilterTypeUi( + id = name, + label = R.string.filter_text_files_type, + iconRes = R.drawable.ic_file_type_text, + mimeType = this + ) + } diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/tags/AddRemoveTagsScreen.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/tags/AddRemoveTagsScreen.kt index 9048313439c..7b5c4710cdc 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/tags/AddRemoveTagsScreen.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/tags/AddRemoveTagsScreen.kt @@ -116,7 +116,7 @@ fun AddRemoveTagsScreen( ), label = tag, isSelected = false, - onSelectChip = { addRemoveTagsViewModel.addTag(tag) } + onClick = { addRemoveTagsViewModel.addTag(tag) } ) } } @@ -228,7 +228,7 @@ fun AddRemoveTagsScreenContent( modifier = Modifier.align(Alignment.CenterVertically), label = item, isSelected = true, - onSelectChip = onRemoveTag + onClick = onRemoveTag ) } } diff --git a/features/cells/src/main/res/drawable/ic_dropdown_chevron.xml b/features/cells/src/main/res/drawable/ic_dropdown_chevron.xml new file mode 100644 index 00000000000..79e94cc8c3a --- /dev/null +++ b/features/cells/src/main/res/drawable/ic_dropdown_chevron.xml @@ -0,0 +1,10 @@ + + + diff --git a/features/cells/src/main/res/drawable/ic_file_type_text.xml b/features/cells/src/main/res/drawable/ic_file_type_text.xml new file mode 100644 index 00000000000..f6883ae0529 --- /dev/null +++ b/features/cells/src/main/res/drawable/ic_file_type_text.xml @@ -0,0 +1,64 @@ + + + + + + + + + + + + diff --git a/features/cells/src/main/res/values-de/strings.xml b/features/cells/src/main/res/values-de/strings.xml index e19d1022722..23622e0022a 100644 --- a/features/cells/src/main/res/values-de/strings.xml +++ b/features/cells/src/main/res/values-de/strings.xml @@ -184,7 +184,6 @@ Beim Laden der Versionsliste ist ein Fehler aufgetreten. Bitte versuchen Sie es erneut. Anzeigen Herunterladen… - Dateien oder Ordner suchen Datei erstellen Datei kann nicht erstellt werden. Bitte versuchen Sie es erneut Dateiname diff --git a/features/cells/src/main/res/values-ru/strings.xml b/features/cells/src/main/res/values-ru/strings.xml index b9254d953a8..13873f9f86d 100644 --- a/features/cells/src/main/res/values-ru/strings.xml +++ b/features/cells/src/main/res/values-ru/strings.xml @@ -197,7 +197,6 @@ сохранено в Загрузках Показать Загрузка… - Поиск файлов или папок Создать файл Не удается создать файл. Пожалуйста, попробуйте снова Название файла diff --git a/features/cells/src/main/res/values/strings.xml b/features/cells/src/main/res/values/strings.xml index 99c20f57613..3d4628d5945 100644 --- a/features/cells/src/main/res/values/strings.xml +++ b/features/cells/src/main/res/values/strings.xml @@ -199,7 +199,6 @@ saved to Downloads Show Downloading… - Search files or folders Create File Unable to create file. Please try again File Name @@ -210,4 +209,26 @@ Create %1$s file Learn more about Shared Drive Learn more + Search tags + Filter by owner + Filter by type + Filter by tags + Remove all + Remove filter + Search Shared Drive + Search Created by + Tags + File type + Created by + Link Sharing + Remove all filters + PDFs + Documents + Images + Spreadsheets + Presentations + Videos + Audio files + Archives + Text files diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index cc8211cfb63..facd5bd870a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -66,7 +66,7 @@ hilt-work = "1.3.0" # Android UI accompanist = "0.32.0" material = "1.12.0" -material3 = "1.3.2" +material3 = "1.4.0" coil = "3.3.0" commonmark = "0.25.1" @@ -155,10 +155,10 @@ googleGms-gradlePlugin = { module = "com.google.gms:google-services", version.re googleGms-location = { module = "com.google.android.gms:play-services-location", version.ref = "gms-location" } aboutLibraries-gradlePlugin = { module = "com.mikepenz.aboutlibraries.plugin:aboutlibraries-plugin", version.ref = "aboutLibraries" } kover-gradlePlugin = { module = "org.jetbrains.kotlinx:kover-gradle-plugin", version.ref = "kover" } - ktx-serialization = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "ktx-serialization" } ktx-dateTime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "ktx-dateTime" } ktx-immutableCollections = { module = "org.jetbrains.kotlinx:kotlinx-collections-immutable", version.ref = "ktx-immutableCollections" } +androidx-compose-animation = { group = "androidx.compose.animation", name = "animation" } ksp-symbol-processing-api = { module = "com.google.devtools.ksp:symbol-processing-api", version.ref = "ksp" } ksp-symbol-processing-plugin = { module = "com.google.devtools.ksp:symbol-processing-gradle-plugin", version.ref = "ksp" } diff --git a/kalium b/kalium index 3e3c20c582c..aca1b206de4 160000 --- a/kalium +++ b/kalium @@ -1 +1 @@ -Subproject commit 3e3c20c582c9eb1e0a28cce022a6f9dedbb13655 +Subproject commit aca1b206de42d161679e976328b9f42e126d4662