diff --git a/app/src/main/kotlin/com/wire/android/di/accountScoped/ConversationModule.kt b/app/src/main/kotlin/com/wire/android/di/accountScoped/ConversationModule.kt index f726d098dc4..244cd31a5cf 100644 --- a/app/src/main/kotlin/com/wire/android/di/accountScoped/ConversationModule.kt +++ b/app/src/main/kotlin/com/wire/android/di/accountScoped/ConversationModule.kt @@ -337,4 +337,9 @@ class ConversationModule { @Provides fun provideRemoveConversationFromFavoritesUseCase(conversationScope: ConversationScope) = conversationScope.removeConversationFromFavorites + + @ViewModelScoped + @Provides + fun provideObserveUserFoldersUseCase(conversationScope: ConversationScope) = + conversationScope.observeUserFolders } diff --git a/app/src/main/kotlin/com/wire/android/navigation/FolderNavArgs.kt b/app/src/main/kotlin/com/wire/android/navigation/FolderNavArgs.kt new file mode 100644 index 00000000000..ec7977926ae --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/navigation/FolderNavArgs.kt @@ -0,0 +1,27 @@ +/* + * Wire + * Copyright (C) 2024 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 + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +data class FolderNavArgs( + val folderId: String, + val folderName: String +) : Parcelable diff --git a/app/src/main/kotlin/com/wire/android/navigation/HomeDestination.kt b/app/src/main/kotlin/com/wire/android/navigation/HomeDestination.kt index 3db5a7e8a03..42aed03d34a 100644 --- a/app/src/main/kotlin/com/wire/android/navigation/HomeDestination.kt +++ b/app/src/main/kotlin/com/wire/android/navigation/HomeDestination.kt @@ -19,30 +19,38 @@ package com.wire.android.navigation import androidx.annotation.DrawableRes -import androidx.annotation.StringRes +import androidx.navigation.NavBackStackEntry import com.ramcosta.composedestinations.spec.Direction import com.wire.android.R import com.wire.android.ui.destinations.AllConversationsScreenDestination import com.wire.android.ui.destinations.ArchiveScreenDestination import com.wire.android.ui.destinations.FavoritesConversationsScreenDestination +import com.wire.android.ui.destinations.FolderConversationsScreenDestination import com.wire.android.ui.destinations.GroupConversationsScreenDestination import com.wire.android.ui.destinations.OneOnOneConversationsScreenDestination import com.wire.android.ui.destinations.SettingsScreenDestination import com.wire.android.ui.destinations.VaultScreenDestination import com.wire.android.ui.destinations.WhatsNewScreenDestination +import com.wire.android.util.ui.UIText import com.wire.kalium.logic.data.conversation.ConversationFilter +import kotlinx.collections.immutable.PersistentList +import kotlinx.collections.immutable.persistentListOf @Suppress("LongParameterList") sealed class HomeDestination( - @StringRes val title: Int, + val title: UIText, @DrawableRes val icon: Int, val isSearchable: Boolean = false, val withNewConversationFab: Boolean = false, val withUserAvatar: Boolean = true, val direction: Direction ) { + + internal fun NavBackStackEntry.baseRouteMatches(): Boolean = direction.route.getBaseRoute() == destination.route?.getBaseRoute() + open fun entryMatches(entry: NavBackStackEntry): Boolean = entry.baseRouteMatches() + data object Conversations : HomeDestination( - title = R.string.conversations_screen_title, + title = UIText.StringResource(R.string.conversations_screen_title), icon = R.drawable.ic_conversation, isSearchable = true, withNewConversationFab = true, @@ -50,15 +58,28 @@ sealed class HomeDestination( ) data object Favorites : HomeDestination( - title = R.string.label_filter_favorites, + title = UIText.StringResource(R.string.label_filter_favorites), icon = R.drawable.ic_conversation, isSearchable = true, withNewConversationFab = true, direction = FavoritesConversationsScreenDestination ) + data class Folder( + val folderNavArgs: FolderNavArgs + ) : HomeDestination( + title = UIText.DynamicString(folderNavArgs.folderName), + icon = R.drawable.ic_conversation, + isSearchable = true, + withNewConversationFab = true, + direction = FolderConversationsScreenDestination(folderNavArgs) + ) { + override fun entryMatches(entry: NavBackStackEntry): Boolean = + entry.baseRouteMatches() && FolderConversationsScreenDestination.argsFrom(entry).folderId == folderNavArgs.folderId + } + data object Group : HomeDestination( - title = R.string.label_filter_group, + title = UIText.StringResource(R.string.label_filter_group), icon = R.drawable.ic_conversation, isSearchable = true, withNewConversationFab = true, @@ -66,7 +87,7 @@ sealed class HomeDestination( ) data object OneOnOne : HomeDestination( - title = R.string.label_filter_one_on_one, + title = UIText.StringResource(R.string.label_filter_one_on_one), icon = R.drawable.ic_conversation, isSearchable = true, withNewConversationFab = true, @@ -74,33 +95,33 @@ sealed class HomeDestination( ) data object Settings : HomeDestination( - title = R.string.settings_screen_title, + title = UIText.StringResource(R.string.settings_screen_title), icon = R.drawable.ic_settings, withUserAvatar = false, direction = SettingsScreenDestination ) data object Vault : HomeDestination( - title = R.string.vault_screen_title, + title = UIText.StringResource(R.string.vault_screen_title), icon = R.drawable.ic_vault, direction = VaultScreenDestination ) data object Archive : HomeDestination( - title = R.string.archive_screen_title, + title = UIText.StringResource(R.string.archive_screen_title), icon = R.drawable.ic_archive, isSearchable = true, direction = ArchiveScreenDestination ) data object Support : HomeDestination( - title = R.string.support_screen_title, + title = UIText.StringResource(R.string.support_screen_title), icon = R.drawable.ic_support, direction = SupportScreenDestination ) data object WhatsNew : HomeDestination( - title = R.string.whats_new_screen_title, + title = UIText.StringResource(R.string.whats_new_screen_title), icon = R.drawable.ic_star, direction = WhatsNewScreenDestination ) @@ -109,33 +130,32 @@ sealed class HomeDestination( companion object { private const val ITEM_NAME_PREFIX = "HomeNavigationItem." - fun fromRoute(fullRoute: String): HomeDestination? = - values().find { it.direction.route.getBaseRoute() == fullRoute.getBaseRoute() } - - fun values(): Array = - arrayOf(Conversations, Favorites, Group, OneOnOne, Settings, Vault, Archive, Support, WhatsNew) + fun values(): PersistentList = + persistentListOf(Conversations, Favorites, Group, OneOnOne, Settings, Vault, Archive, Support, WhatsNew) } } fun HomeDestination.currentFilter(): ConversationFilter { return when (this) { - HomeDestination.Conversations -> ConversationFilter.ALL - HomeDestination.Favorites -> ConversationFilter.FAVORITES - HomeDestination.Group -> ConversationFilter.GROUPS - HomeDestination.OneOnOne -> ConversationFilter.ONE_ON_ONE + HomeDestination.Conversations -> ConversationFilter.All + HomeDestination.Favorites -> ConversationFilter.Favorites + HomeDestination.Group -> ConversationFilter.Groups + HomeDestination.OneOnOne -> ConversationFilter.OneOnOne + is HomeDestination.Folder -> ConversationFilter.Folder(folderName = folderNavArgs.folderName, folderId = folderNavArgs.folderId) HomeDestination.Archive, HomeDestination.Settings, HomeDestination.Support, HomeDestination.Vault, - HomeDestination.WhatsNew -> ConversationFilter.ALL + HomeDestination.WhatsNew -> ConversationFilter.All } } fun ConversationFilter.toDestination(): HomeDestination { return when (this) { - ConversationFilter.ALL -> HomeDestination.Conversations - ConversationFilter.FAVORITES -> HomeDestination.Favorites - ConversationFilter.GROUPS -> HomeDestination.Group - ConversationFilter.ONE_ON_ONE -> HomeDestination.OneOnOne + ConversationFilter.All -> HomeDestination.Conversations + ConversationFilter.Favorites -> HomeDestination.Favorites + ConversationFilter.Groups -> HomeDestination.Group + ConversationFilter.OneOnOne -> HomeDestination.OneOnOne + is ConversationFilter.Folder -> HomeDestination.Folder(FolderNavArgs(folderId, folderName)) } } diff --git a/app/src/main/kotlin/com/wire/android/ui/common/bottomsheet/RichMenuBottomSheetItem.kt b/app/src/main/kotlin/com/wire/android/ui/common/bottomsheet/RichMenuBottomSheetItem.kt index 0c7697fa20b..031ae12e567 100644 --- a/app/src/main/kotlin/com/wire/android/ui/common/bottomsheet/RichMenuBottomSheetItem.kt +++ b/app/src/main/kotlin/com/wire/android/ui/common/bottomsheet/RichMenuBottomSheetItem.kt @@ -41,6 +41,7 @@ import com.wire.android.R import com.wire.android.model.Clickable import com.wire.android.ui.common.WireCheckIcon import com.wire.android.ui.common.clickable +import com.wire.android.ui.common.colorsScheme import com.wire.android.ui.common.dimensions import com.wire.android.ui.theme.DEFAULT_WEIGHT import com.wire.android.ui.theme.wireColorScheme @@ -55,6 +56,7 @@ fun SelectableMenuBottomSheetItem( titleStyleUnselected: TextStyle = MaterialTheme.wireTypography.body02, titleStyleSelected: TextStyle = MaterialTheme.wireTypography.body02, subLine: String? = null, + description: String? = null, icon: @Composable () -> Unit = { }, onItemClick: Clickable = Clickable(enabled = false) {}, state: RichMenuItemState = RichMenuItemState.DEFAULT @@ -77,12 +79,30 @@ fun SelectableMenuBottomSheetItem( modifier = Modifier .weight(DEFAULT_WEIGHT), ) { - MenuItemHeading( - title = title, color = titleColor, - titleStyleUnselected = titleStyleUnselected, - titleStyleSelected = titleStyleSelected, - state = state - ) + Row { + MenuItemHeading( + title = title, + color = titleColor, + titleStyleUnselected = titleStyleUnselected, + titleStyleSelected = titleStyleSelected, + state = state, + modifier = if (description != null) { + Modifier + } else { + Modifier.weight(1F) + } + ) + if (description != null) { + Text( + text = description, + style = MaterialTheme.wireTypography.body01, + color = colorsScheme().secondaryText, + modifier = Modifier + .weight(1f) + .padding(start = dimensions().spacing16x) + ) + } + } if (subLine != null) { MenuItemSubLine( subLine = subLine, @@ -106,7 +126,7 @@ fun SelectableMenuBottomSheetItem( @Composable fun MenuItemHeading( title: String, - modifier: Modifier = Modifier, + modifier: Modifier = Modifier.fillMaxWidth(), titleStyleUnselected: TextStyle = MaterialTheme.wireTypography.body02, titleStyleSelected: TextStyle = MaterialTheme.wireTypography.body02, state: RichMenuItemState = RichMenuItemState.DEFAULT, @@ -116,7 +136,7 @@ fun MenuItemHeading( style = if (isSelectedItem(state)) titleStyleSelected else titleStyleUnselected, color = if (isSelectedItem(state)) MaterialTheme.wireColorScheme.primary else color ?: MaterialTheme.wireColorScheme.onBackground, text = title, - modifier = modifier.fillMaxWidth() + modifier = modifier ) } 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 d820902563c..32907047c6e 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 @@ -38,6 +38,7 @@ import androidx.compose.material3.ModalNavigationDrawer import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.ColorFilter @@ -62,11 +63,12 @@ import com.ramcosta.composedestinations.result.NavResult import com.ramcosta.composedestinations.result.ResultRecipient import com.wire.android.R import com.wire.android.appLogger +import com.wire.android.di.hiltViewModelScoped +import com.wire.android.navigation.FolderNavArgs import com.wire.android.navigation.HomeDestination import com.wire.android.navigation.NavigationCommand import com.wire.android.navigation.Navigator import com.wire.android.navigation.WireDestination -import com.wire.android.navigation.currentFilter import com.wire.android.navigation.handleNavigation import com.wire.android.navigation.toDestination import com.wire.android.ui.NavGraphs @@ -87,12 +89,19 @@ import com.wire.android.ui.destinations.SelfUserProfileScreenDestination import com.wire.android.ui.home.conversations.PermissionPermanentlyDeniedDialogState import com.wire.android.ui.home.conversations.details.GroupConversationActionType import com.wire.android.ui.home.conversations.details.GroupConversationDetailsNavBackArgs +import com.wire.android.ui.home.conversations.folder.ConversationFoldersStateArgs +import com.wire.android.ui.home.conversations.folder.ConversationFoldersVM +import com.wire.android.ui.home.conversations.folder.ConversationFoldersVMImpl import com.wire.android.ui.home.conversationslist.filter.ConversationFilterSheetContent +import com.wire.android.ui.home.conversationslist.filter.ConversationFilterSheetData +import com.wire.android.ui.home.conversationslist.filter.rememberFilterSheetState import com.wire.android.ui.home.drawer.HomeDrawer import com.wire.android.ui.home.drawer.HomeDrawerState import com.wire.android.ui.home.drawer.HomeDrawerViewModel import com.wire.android.util.permission.rememberShowNotificationsPermissionFlow -import com.wire.kalium.logic.data.conversation.ConversationFilter +import com.wire.kalium.logic.data.conversation.ConversationFolder +import kotlinx.collections.immutable.PersistentList +import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.launch @RootNavGraph @@ -105,10 +114,21 @@ fun HomeScreen( homeViewModel: HomeViewModel = hiltViewModel(), appSyncViewModel: AppSyncViewModel = hiltViewModel(), homeDrawerViewModel: HomeDrawerViewModel = hiltViewModel(), - analyticsUsageViewModel: AnalyticsUsageViewModel = hiltViewModel() + analyticsUsageViewModel: AnalyticsUsageViewModel = hiltViewModel(), + foldersViewModel: ConversationFoldersVM = + hiltViewModelScoped( + ConversationFoldersStateArgs + ) ) { homeViewModel.checkRequirements { it.navigate(navigator::navigate) } - val homeScreenState = rememberHomeScreenState(navigator) + val homeDestinations = remember(foldersViewModel.state().folders) { + HomeDestination.values() + .plus( + foldersViewModel.state().folders.map { HomeDestination.Folder(FolderNavArgs(it.id, it.name)) } + ) + } + + val homeScreenState = rememberHomeScreenState(navigator, homeDestinations = homeDestinations) val notificationsPermissionDeniedDialogState = rememberVisibilityState() val showNotificationsPermissionDeniedDialog = { notificationsPermissionDeniedDialogState.show( @@ -169,7 +189,8 @@ fun HomeScreen( onSelfUserClick = { homeViewModel.sendOpenProfileEvent() navigator.navigate(NavigationCommand(SelfUserProfileScreenDestination)) - } + }, + folders = foldersViewModel.state().folders ) BackHandler(homeScreenState.drawerState.isOpen) { @@ -240,9 +261,10 @@ fun HomeContent( onNewConversationClick: () -> Unit, onSelfUserClick: () -> Unit, modifier: Modifier = Modifier, + folders: PersistentList = persistentListOf() ) { val context = LocalContext.current - val filterSheetState = rememberWireModalSheetState() + val filterSheetState = rememberWireModalSheetState() with(homeStateHolder) { fun openHomeDestination(item: HomeDestination) { @@ -252,6 +274,7 @@ fun HomeContent( navController.navigate(direction.route) { navController.graph.startDestinationRoute?.let { route -> popUpTo(route) { + inclusive = true saveState = true } } @@ -302,7 +325,14 @@ fun HomeContent( shouldShowCreateTeamUnreadIndicator = homeState.shouldShowCreateTeamUnreadIndicator, onHamburgerMenuClick = ::openDrawer, onNavigateToSelfUserProfile = onSelfUserClick, - onOpenConversationFilter = { filterSheetState.show(it) } + onOpenConversationFilter = { + filterSheetState.show( + ConversationFilterSheetData( + currentFilter = it, + folders = folders + ) + ) + } ) } }, @@ -317,7 +347,7 @@ fun HomeContent( } }, collapsingEnabled = !searchBarState.isSearchActive, - contentLazyListState = homeStateHolder.lazyListStateFor(currentNavigationItem), + contentLazyListState = homeStateHolder.nullAbleLazyListStateFor(currentNavigationItem), content = { /** * This "if" is a workaround, otherwise it can crash because of the SubcomposeLayout's nature. @@ -372,13 +402,18 @@ fun HomeContent( ) WireModalSheetLayout( sheetState = filterSheetState, - sheetContent = { + sheetContent = { sheetData -> + val sheetContentState = rememberFilterSheetState(sheetData) ConversationFilterSheetContent( - currentFilter = currentNavigationItem.currentFilter(), onChangeFilter = { filter -> filterSheetState.hide() openHomeDestination(filter.toDestination()) - } + }, + onChangeFolder = { + filterSheetState.hide() + openHomeDestination(it.toDestination()) + }, + filterSheetState = sheetContentState ) } ) diff --git a/app/src/main/kotlin/com/wire/android/ui/home/HomeStateHolder.kt b/app/src/main/kotlin/com/wire/android/ui/home/HomeStateHolder.kt index 034c10e9250..f6e802ce309 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/HomeStateHolder.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/HomeStateHolder.kt @@ -32,7 +32,9 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.navigation.NavHostController import androidx.navigation.compose.currentBackStackEntryAsState import com.wire.android.navigation.HomeDestination +import com.wire.android.navigation.HomeDestination.Conversations import com.wire.android.navigation.Navigator +import com.wire.android.navigation.getBaseRoute import com.wire.android.navigation.rememberTrackingAnimatedNavController import com.wire.android.ui.common.topappbar.search.SearchBarState import com.wire.android.ui.common.topappbar.search.rememberSearchbarState @@ -51,8 +53,14 @@ class HomeStateHolder( ) { val currentNavigationItem get() = currentNavigationItemState.value - fun lazyListStateFor(destination: HomeDestination): LazyListState = - lazyListStates[destination] ?: error("No LazyListState found for $destination") + + fun lazyListStateFor(destination: HomeDestination): LazyListState { + return lazyListStates[destination] ?: error("No LazyListState found for $destination") + } + + fun nullAbleLazyListStateFor(destination: HomeDestination): LazyListState? { + return lazyListStates[destination] + } fun closeDrawer() { coroutineScope.launch { @@ -70,22 +78,25 @@ class HomeStateHolder( @Composable fun rememberHomeScreenState( navigator: Navigator, + homeDestinations: List, coroutineScope: CoroutineScope = rememberCoroutineScope(), - navController: NavHostController = rememberTrackingAnimatedNavController { - HomeDestination.fromRoute(it)?.itemName + navController: NavHostController = rememberTrackingAnimatedNavController { route -> + homeDestinations.find { it.direction.route.getBaseRoute() == route }?.itemName }, - drawerState: DrawerState = rememberDrawerState(DrawerValue.Closed), + drawerState: DrawerState = rememberDrawerState(DrawerValue.Closed) ): HomeStateHolder { + val searchBarState = rememberSearchbarState() val navBackStackEntry by navController.currentBackStackEntryAsState() - val currentNavigationItemState = remember { + + val currentNavigationItemState = remember(homeDestinations) { derivedStateOf { - navBackStackEntry?.destination?.route?.let { HomeDestination.fromRoute(it) } ?: HomeDestination.Conversations + navBackStackEntry?.let { entry -> homeDestinations.find { it.entryMatches(entry) } } ?: Conversations } } - val lazyListStates = HomeDestination.values().associateWith { rememberLazyListState() } + val lazyListStates = homeDestinations.associateWith { rememberLazyListState() } - return remember { + return remember(homeDestinations) { HomeStateHolder( coroutineScope = coroutineScope, navController = navController, diff --git a/app/src/main/kotlin/com/wire/android/ui/home/HomeTopBar.kt b/app/src/main/kotlin/com/wire/android/ui/home/HomeTopBar.kt index 9f34130884c..28039c3ea12 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/HomeTopBar.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/HomeTopBar.kt @@ -52,7 +52,7 @@ fun HomeTopBar( onOpenConversationFilter: (filter: ConversationFilter) -> Unit ) { WireCenterAlignedTopAppBar( - title = stringResource(navigationItem.title), + title = navigationItem.title.asString(), onNavigationPressed = onHamburgerMenuClick, navigationIconType = NavigationIconType.Menu, actions = { @@ -60,7 +60,7 @@ fun HomeTopBar( WireTertiaryIconButton( iconResource = R.drawable.ic_filter, contentDescription = R.string.label_filter_conversations, - state = if (navigationItem.currentFilter() == ConversationFilter.ALL) { + state = if (navigationItem.currentFilter() == ConversationFilter.All) { WireButtonState.Default } else { WireButtonState.Selected diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/folder/ConversationFoldersVM.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/folder/ConversationFoldersVM.kt new file mode 100644 index 00000000000..b15fc52eef6 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/folder/ConversationFoldersVM.kt @@ -0,0 +1,68 @@ +/* + * Wire + * Copyright (C) 2024 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.ui.home.conversations.folder + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.wire.android.di.ScopedArgs +import com.wire.android.di.ViewModelScopedPreview +import com.wire.kalium.logic.data.conversation.ConversationFolder +import com.wire.kalium.logic.feature.conversation.folder.ObserveUserFoldersUseCase +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.collections.immutable.PersistentList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toPersistentList +import kotlinx.coroutines.launch +import kotlinx.serialization.Serializable +import javax.inject.Inject + +@ViewModelScopedPreview +interface ConversationFoldersVM { + fun state(): ConversationFoldersState = ConversationFoldersState(persistentListOf()) +} + +@HiltViewModel +class ConversationFoldersVMImpl @Inject constructor( + private val observeUserFoldersUseCase: ObserveUserFoldersUseCase, +) : ConversationFoldersVM, ViewModel() { + + private var state by mutableStateOf(ConversationFoldersState(persistentListOf())) + + override fun state(): ConversationFoldersState = state + + init { + observeUserFolders() + } + + private fun observeUserFolders() = viewModelScope.launch { + observeUserFoldersUseCase() + .collect { folders -> + state = ConversationFoldersState(folders.toPersistentList()) + } + } +} + +data class ConversationFoldersState(val folders: PersistentList) + +@Serializable +object ConversationFoldersStateArgs : ScopedArgs { + override val key = "ConversationFoldersStateArgsKey" +} diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/usecase/GetConversationsFromSearchUseCase.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/usecase/GetConversationsFromSearchUseCase.kt index a7d9584055b..63b0a150f9b 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/usecase/GetConversationsFromSearchUseCase.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/usecase/GetConversationsFromSearchUseCase.kt @@ -27,6 +27,7 @@ import com.wire.android.mapper.UserTypeMapper import com.wire.android.mapper.toConversationItem import com.wire.android.ui.home.conversationslist.model.ConversationItem import com.wire.android.util.dispatchers.DispatcherProvider +import com.wire.kalium.logic.data.conversation.ConversationDetailsWithEvents import com.wire.kalium.logic.data.conversation.ConversationFilter import com.wire.kalium.logic.data.conversation.ConversationQueryConfig import com.wire.kalium.logic.feature.conversation.GetPaginatedFlowOfConversationDetailsWithEventsBySearchQueryUseCase @@ -53,7 +54,7 @@ class GetConversationsFromSearchUseCase @Inject constructor( fromArchive: Boolean = false, newActivitiesOnTop: Boolean = false, onlyInteractionEnabled: Boolean = false, - conversationFilter: ConversationFilter = ConversationFilter.ALL + conversationFilter: ConversationFilter = ConversationFilter.All ): Flow> { val pagingConfig = PagingConfig( pageSize = PAGE_SIZE, @@ -62,9 +63,9 @@ class GetConversationsFromSearchUseCase @Inject constructor( enablePlaceholders = true, ) return when (conversationFilter) { - ConversationFilter.ALL, - ConversationFilter.GROUPS, - ConversationFilter.ONE_ON_ONE -> useCase( + ConversationFilter.All, + ConversationFilter.Groups, + ConversationFilter.OneOnOne -> useCase( queryConfig = ConversationQueryConfig( searchQuery = searchQuery, fromArchive = fromArchive, @@ -76,22 +77,18 @@ class GetConversationsFromSearchUseCase @Inject constructor( startingOffset = 0L, ) - ConversationFilter.FAVORITES -> { + ConversationFilter.Favorites -> { when (val result = getFavoriteFolderUseCase.invoke()) { GetFavoriteFolderUseCase.Result.Failure -> flowOf(emptyList()) is GetFavoriteFolderUseCase.Result.Success -> observeConversationsFromFromFolder(result.folder.id) } - .map { - PagingData.from( - it, - sourceLoadStates = LoadStates( - prepend = LoadState.NotLoading(true), - append = LoadState.NotLoading(true), - refresh = LoadState.NotLoading(true), - ) - ) - } + .map { staticPagingItems(it) } + } + + is ConversationFilter.Folder -> { + observeConversationsFromFromFolder(conversationFilter.folderId) + .map { staticPagingItems(it) } } } .map { pagingData -> @@ -105,6 +102,17 @@ class GetConversationsFromSearchUseCase @Inject constructor( }.flowOn(dispatchers.io()) } + private fun staticPagingItems(conversations: List): PagingData { + return PagingData.from( + conversations, + sourceLoadStates = LoadStates( + prepend = LoadState.NotLoading(true), + append = LoadState.NotLoading(true), + refresh = LoadState.NotLoading(true), + ) + ) + } + private companion object { const val PAGE_SIZE = 20 const val INITIAL_LOAD_SIZE = 60 diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/ConversationListViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/ConversationListViewModel.kt index 3ec4d460b94..466664122da 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/ConversationListViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/ConversationListViewModel.kt @@ -165,6 +165,7 @@ class ConversationListViewModelImpl @AssistedInject constructor( private val containsNewActivitiesSection = when (conversationsSource) { ConversationsSource.MAIN, ConversationsSource.FAVORITES, + is ConversationsSource.FOLDER, ConversationsSource.GROUPS, ConversationsSource.ONE_ON_ONE -> true @@ -439,11 +440,12 @@ class ConversationListViewModelImpl @AssistedInject constructor( fun Conversation.LegalHoldStatus.showLegalHoldIndicator() = this == Conversation.LegalHoldStatus.ENABLED private fun ConversationsSource.toFilter(): ConversationFilter = when (this) { - ConversationsSource.MAIN -> ConversationFilter.ALL - ConversationsSource.ARCHIVE -> ConversationFilter.ALL - ConversationsSource.GROUPS -> ConversationFilter.GROUPS - ConversationsSource.FAVORITES -> ConversationFilter.FAVORITES - ConversationsSource.ONE_ON_ONE -> ConversationFilter.ONE_ON_ONE + ConversationsSource.MAIN -> ConversationFilter.All + ConversationsSource.ARCHIVE -> ConversationFilter.All + ConversationsSource.GROUPS -> ConversationFilter.Groups + ConversationsSource.FAVORITES -> ConversationFilter.Favorites + ConversationsSource.ONE_ON_ONE -> ConversationFilter.OneOnOne + is ConversationsSource.FOLDER -> ConversationFilter.Folder(folderId = folderId, folderName = folderName) } private fun ConversationItem.hideIndicatorForSelfUserUnderLegalHold(selfUserLegalHoldStatus: LegalHoldStateForSelfUser) = @@ -473,6 +475,7 @@ private fun List.withFolders(source: ConversationsSource): Map ConversationsSource.FAVORITES, ConversationsSource.GROUPS, ConversationsSource.ONE_ON_ONE, + is ConversationsSource.FOLDER, ConversationsSource.MAIN -> { val unreadConversations = filter { when (it.mutedStatus) { diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/ConversationsScreenContent.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/ConversationsScreenContent.kt index 244fc837067..3a180efd9a7 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/ConversationsScreenContent.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/ConversationsScreenContent.kt @@ -92,7 +92,7 @@ fun ConversationsScreenContent( conversationListViewModel: ConversationListViewModel = when { LocalInspectionMode.current -> ConversationListViewModelPreview() else -> hiltViewModel( - key = "list_${conversationsSource.name}", + key = "list_$conversationsSource", creationCallback = { factory -> factory.create(conversationsSource = conversationsSource) } @@ -100,7 +100,7 @@ fun ConversationsScreenContent( }, conversationCallListViewModel: ConversationCallListViewModel = when { LocalInspectionMode.current -> ConversationCallListViewModelPreview - else -> hiltViewModel(key = "call_${conversationsSource.name}") + else -> hiltViewModel(key = "call_$conversationsSource") }, changeConversationFavoriteStateViewModel: ChangeConversationFavoriteVM = hiltViewModelScoped( @@ -189,7 +189,7 @@ fun ConversationsScreenContent( when (val state = conversationListViewModel.conversationListState) { is ConversationListState.Paginated -> { val lazyPagingItems = state.conversations.collectAsLazyPagingItems() - var showLoading by remember { mutableStateOf(!initiallyLoaded) } + var showLoading by remember(conversationsSource) { mutableStateOf(!initiallyLoaded) } if (lazyPagingItems.loadState.refresh != LoadState.Loading && showLoading) { showLoading = false } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/all/AllConversationsScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/all/AllConversationsScreen.kt index 3c7da0a4825..03318926dc3 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/all/AllConversationsScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/all/AllConversationsScreen.kt @@ -20,6 +20,7 @@ package com.wire.android.ui.home.conversationslist.all import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.runtime.Composable +import com.wire.android.navigation.FolderNavArgs import com.wire.android.navigation.HomeDestination import com.wire.android.navigation.HomeNavGraph import com.wire.android.navigation.WireDestination @@ -45,7 +46,7 @@ fun AllConversationsScreen(homeStateHolder: HomeStateHolder) { searchBarState = searchBarState, conversationsSource = ConversationsSource.MAIN, lazyListState = lazyListStateFor(HomeDestination.Conversations), - emptyListContent = { ConversationsEmptyContent(filter = ConversationFilter.ALL) } + emptyListContent = { ConversationsEmptyContent(filter = ConversationFilter.All) } ) } } @@ -60,7 +61,21 @@ fun FavoritesConversationsScreen(homeStateHolder: HomeStateHolder) { searchBarState = searchBarState, conversationsSource = ConversationsSource.FAVORITES, lazyListState = lazyListStateFor(HomeDestination.Favorites), - emptyListContent = { ConversationsEmptyContent(filter = ConversationFilter.FAVORITES) } + emptyListContent = { ConversationsEmptyContent(filter = ConversationFilter.Favorites) } + ) + } +} + +@HomeNavGraph +@WireDestination(navArgsDelegate = FolderNavArgs::class) +@Composable +fun FolderConversationsScreen(homeStateHolder: HomeStateHolder, args: FolderNavArgs) { + with(homeStateHolder) { + ConversationsScreenContent( + navigator = navigator, + searchBarState = searchBarState, + conversationsSource = ConversationsSource.FOLDER(args.folderId, args.folderName), + emptyListContent = { ConversationsEmptyContent(filter = ConversationFilter.Folder(args.folderId, args.folderName)) } ) } } @@ -75,7 +90,7 @@ fun GroupConversationsScreen(homeStateHolder: HomeStateHolder) { searchBarState = searchBarState, conversationsSource = ConversationsSource.GROUPS, lazyListState = lazyListStateFor(HomeDestination.Group), - emptyListContent = { ConversationsEmptyContent(filter = ConversationFilter.GROUPS) } + emptyListContent = { ConversationsEmptyContent(filter = ConversationFilter.Groups) } ) } } @@ -90,7 +105,7 @@ fun OneOnOneConversationsScreen(homeStateHolder: HomeStateHolder) { searchBarState = searchBarState, conversationsSource = ConversationsSource.ONE_ON_ONE, lazyListState = lazyListStateFor(HomeDestination.OneOnOne), - emptyListContent = { ConversationsEmptyContent(filter = ConversationFilter.ONE_ON_ONE, domain = it) } + emptyListContent = { ConversationsEmptyContent(filter = ConversationFilter.OneOnOne, domain = it) } ) } } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/all/ConversationsEmptyContent.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/all/ConversationsEmptyContent.kt index da2f82fe5f7..c51e6961e41 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/all/ConversationsEmptyContent.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/all/ConversationsEmptyContent.kt @@ -45,7 +45,7 @@ import com.wire.kalium.logic.data.conversation.ConversationFilter @Composable fun ConversationsEmptyContent( modifier: Modifier = Modifier, - filter: ConversationFilter = ConversationFilter.ALL, + filter: ConversationFilter = ConversationFilter.All, domain: String = "wire.com" ) { val context = LocalContext.current @@ -58,7 +58,7 @@ fun ConversationsEmptyContent( verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally ) { - if (filter == ConversationFilter.ALL) { + if (filter == ConversationFilter.All) { Text( modifier = Modifier.padding( bottom = dimensions().spacing24x, @@ -76,7 +76,7 @@ fun ConversationsEmptyContent( textAlign = TextAlign.Center, color = MaterialTheme.wireColorScheme.onSurface, ) - if (filter == ConversationFilter.FAVORITES) { + if (filter == ConversationFilter.Favorites) { val supportUrl = stringResource(id = R.string.url_how_to_add_favorites) Text( text = stringResource(R.string.favorites_empty_list_how_to_label), @@ -102,32 +102,34 @@ fun ConversationsEmptyContent( @Composable private fun ConversationFilter.emptyDescription(backendName: String): String = when (this) { - ConversationFilter.ALL -> stringResource(R.string.conversation_empty_list_description) - ConversationFilter.FAVORITES -> stringResource(R.string.favorites_empty_list_description) - ConversationFilter.GROUPS -> stringResource(R.string.group_empty_list_description) - ConversationFilter.ONE_ON_ONE -> stringResource(R.string.one_on_one_empty_list_description, backendName) + ConversationFilter.All -> stringResource(R.string.conversation_empty_list_description) + ConversationFilter.Favorites -> stringResource(R.string.favorites_empty_list_description) + ConversationFilter.Groups -> stringResource(R.string.group_empty_list_description) + ConversationFilter.OneOnOne -> stringResource(R.string.one_on_one_empty_list_description, backendName) + // currently not used, because empty folders are removed from filters + is ConversationFilter.Folder -> "" } @PreviewMultipleThemes @Composable fun PreviewAllConversationsEmptyContent() = WireTheme { - ConversationsEmptyContent(filter = ConversationFilter.ALL) + ConversationsEmptyContent(filter = ConversationFilter.All) } @PreviewMultipleThemes @Composable fun PreviewFavoritesConversationsEmptyContent() = WireTheme { - ConversationsEmptyContent(filter = ConversationFilter.FAVORITES) + ConversationsEmptyContent(filter = ConversationFilter.Favorites) } @PreviewMultipleThemes @Composable fun PreviewGroupConversationsEmptyContent() = WireTheme { - ConversationsEmptyContent(filter = ConversationFilter.GROUPS) + ConversationsEmptyContent(filter = ConversationFilter.Groups) } @PreviewMultipleThemes @Composable fun PreviewOneOnOneConversationsEmptyContent() = WireTheme { - ConversationsEmptyContent(filter = ConversationFilter.ONE_ON_ONE, domain = "wire.com") + ConversationsEmptyContent(filter = ConversationFilter.OneOnOne, domain = "wire.com") } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/filter/ConversationFilterSheetContent.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/filter/ConversationFilterSheetContent.kt index 4d7f58064cc..0dcb4da607f 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/filter/ConversationFilterSheetContent.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/filter/ConversationFilterSheetContent.kt @@ -17,54 +17,65 @@ */ package com.wire.android.ui.home.conversationslist.filter -import androidx.compose.material3.MaterialTheme +import androidx.activity.compose.BackHandler import androidx.compose.runtime.Composable -import androidx.compose.ui.res.stringResource +import androidx.compose.runtime.remember import com.wire.android.R -import com.wire.android.ui.common.bottomsheet.MenuBottomSheetItem -import com.wire.android.ui.common.bottomsheet.MenuItemIcon -import com.wire.android.ui.common.bottomsheet.MenuModalSheetHeader -import com.wire.android.ui.common.bottomsheet.WireMenuModalSheetContent -import com.wire.android.ui.common.dimensions -import com.wire.android.ui.theme.wireColorScheme +import com.wire.android.util.ui.UIText import com.wire.kalium.logic.data.conversation.ConversationFilter @Composable fun ConversationFilterSheetContent( - currentFilter: ConversationFilter, - onChangeFilter: (ConversationFilter) -> Unit + filterSheetState: ConversationFilterSheetState, + onChangeFilter: (ConversationFilter) -> Unit, + onChangeFolder: (ConversationFilter.Folder) -> Unit, + isBottomSheetVisible: () -> Boolean = { true } ) { - WireMenuModalSheetContent( - header = MenuModalSheetHeader.Visible( - title = stringResource(R.string.label_filter_conversations), - customVerticalPadding = dimensions().spacing8x - ), - menuItems = buildList<@Composable () -> Unit> { - ConversationFilter.entries.forEach { filter -> - add { - MenuBottomSheetItem( - title = stringResource(filter.getResource()), - trailing = { - if (filter == currentFilter) { - MenuItemIcon( - id = R.drawable.ic_check_circle, - contentDescription = stringResource(R.string.label_selected), - tint = MaterialTheme.wireColorScheme.positive, - ) - } - }, - onItemClick = { onChangeFilter(filter) }, - onItemClickDescription = stringResource(R.string.content_description_select_label) - ) + when (filterSheetState.currentData.tab) { + FilterTab.FILTERS -> { + ConversationFiltersSheetContent( + sheetData = filterSheetState.currentData, + onChangeFilter = onChangeFilter, + showFoldersBottomSheet = { + filterSheetState.toFolders() } - } + ) } - ) + + FilterTab.FOLDERS -> { + ConversationFoldersSheetContent( + sheetData = filterSheetState.currentData, + onChangeFolder = onChangeFolder, + onBackClick = { + filterSheetState.toFilters() + } + ) + } + } + + BackHandler( + filterSheetState.currentData.tab == FilterTab.FOLDERS + && isBottomSheetVisible() + ) { + filterSheetState.toFilters() + } +} + +@Composable +fun rememberFilterSheetState( + filterSheetData: ConversationFilterSheetData, +): ConversationFilterSheetState { + return remember(filterSheetData) { + ConversationFilterSheetState( + conversationFilterSheetData = filterSheetData + ) + } } -private fun ConversationFilter.getResource(): Int = when (this) { - ConversationFilter.ALL -> R.string.label_filter_all - ConversationFilter.FAVORITES -> R.string.label_filter_favorites - ConversationFilter.GROUPS -> R.string.label_filter_group - ConversationFilter.ONE_ON_ONE -> R.string.label_filter_one_on_one +fun ConversationFilter.uiText(): UIText = when (this) { + ConversationFilter.All -> UIText.StringResource(R.string.label_filter_all) + ConversationFilter.Favorites -> UIText.StringResource(R.string.label_filter_favorites) + ConversationFilter.Groups -> UIText.StringResource(R.string.label_filter_group) + ConversationFilter.OneOnOne -> UIText.StringResource(R.string.label_filter_one_on_one) + is ConversationFilter.Folder -> UIText.StringResource(R.string.label_filter_folders, this.folderName) } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/filter/ConversationFilterSheetState.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/filter/ConversationFilterSheetState.kt new file mode 100644 index 00000000000..28d98538142 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/filter/ConversationFilterSheetState.kt @@ -0,0 +1,56 @@ +/* + * Wire + * Copyright (C) 2024 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.ui.home.conversationslist.filter + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import com.wire.kalium.logic.data.conversation.ConversationFilter +import com.wire.kalium.logic.data.conversation.ConversationFolder +import kotlinx.collections.immutable.PersistentList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.serialization.Serializable + +class ConversationFilterSheetState( + conversationFilterSheetData: ConversationFilterSheetData = ConversationFilterSheetData( + currentFilter = ConversationFilter.All, + folders = persistentListOf() + ) +) { + var currentData: ConversationFilterSheetData by mutableStateOf(conversationFilterSheetData) + + fun toFolders() { + currentData = currentData.copy(tab = FilterTab.FOLDERS) + } + + fun toFilters() { + currentData = currentData.copy(tab = FilterTab.FILTERS) + } +} + +@Serializable +data class ConversationFilterSheetData( + val tab: FilterTab = FilterTab.FILTERS, + val currentFilter: ConversationFilter, + val folders: PersistentList +) + +enum class FilterTab { + FILTERS, + FOLDERS +} diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/filter/ConversationFiltersSheetContent.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/filter/ConversationFiltersSheetContent.kt new file mode 100644 index 00000000000..1345db26b94 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/filter/ConversationFiltersSheetContent.kt @@ -0,0 +1,126 @@ +/* + * Wire + * Copyright (C) 2024 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.ui.home.conversationslist.filter + +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import com.wire.android.R +import com.wire.android.model.Clickable +import com.wire.android.ui.common.bottomsheet.MenuModalSheetHeader +import com.wire.android.ui.common.bottomsheet.RichMenuItemState +import com.wire.android.ui.common.bottomsheet.SelectableMenuBottomSheetItem +import com.wire.android.ui.common.bottomsheet.WireMenuModalSheetContent +import com.wire.android.ui.common.dimensions +import com.wire.kalium.logic.data.conversation.ConversationFilter + +@Composable +fun ConversationFiltersSheetContent( + sheetData: ConversationFilterSheetData, + onChangeFilter: (ConversationFilter) -> Unit, + showFoldersBottomSheet: (selectedFolderId: String?) -> Unit +) { + WireMenuModalSheetContent( + header = MenuModalSheetHeader.Visible( + title = stringResource(R.string.label_filter_conversations), + customVerticalPadding = dimensions().spacing8x + ), + menuItems = buildList<@Composable () -> Unit> { + add { + val state = if (ConversationFilter.All == sheetData.currentFilter) { + RichMenuItemState.SELECTED + } else { + RichMenuItemState.DEFAULT + } + SelectableMenuBottomSheetItem( + title = ConversationFilter.All.uiText().asString(), + onItemClick = Clickable( + enabled = state == RichMenuItemState.DEFAULT, + onClickDescription = stringResource(id = R.string.content_description_select_label), + onClick = { onChangeFilter(ConversationFilter.All) }, + ), + state = state + ) + } + add { + val state = if (ConversationFilter.Favorites == sheetData.currentFilter) { + RichMenuItemState.SELECTED + } else { + RichMenuItemState.DEFAULT + } + SelectableMenuBottomSheetItem( + title = ConversationFilter.Favorites.uiText().asString(), + onItemClick = Clickable( + enabled = state == RichMenuItemState.DEFAULT, + onClickDescription = stringResource(id = R.string.content_description_select_label), + onClick = { onChangeFilter(ConversationFilter.Favorites) }, + ), + state = state + ) + } + add { + val state = if (ConversationFilter.Groups == sheetData.currentFilter) { + RichMenuItemState.SELECTED + } else { + RichMenuItemState.DEFAULT + } + SelectableMenuBottomSheetItem( + title = ConversationFilter.Groups.uiText().asString(), + onItemClick = Clickable( + enabled = state == RichMenuItemState.DEFAULT, + onClickDescription = stringResource(id = R.string.content_description_select_label), + onClick = { onChangeFilter(ConversationFilter.Groups) }, + ), + state = state + ) + } + add { + val state = if (ConversationFilter.OneOnOne == sheetData.currentFilter) { + RichMenuItemState.SELECTED + } else { + RichMenuItemState.DEFAULT + } + SelectableMenuBottomSheetItem( + title = ConversationFilter.OneOnOne.uiText().asString(), + onItemClick = Clickable( + enabled = state == RichMenuItemState.DEFAULT, + onClickDescription = stringResource(id = R.string.content_description_select_label), + onClick = { onChangeFilter(ConversationFilter.OneOnOne) }, + ), + state = state + ) + } + add { + val state = if (sheetData.currentFilter is ConversationFilter.Folder) { + RichMenuItemState.SELECTED + } else { + RichMenuItemState.DEFAULT + } + SelectableMenuBottomSheetItem( + title = stringResource(R.string.label_filter_folders), + description = (sheetData.currentFilter as? ConversationFilter.Folder)?.folderName, + onItemClick = Clickable( + enabled = true, + onClickDescription = stringResource(id = R.string.content_description_select_label), + onClick = { showFoldersBottomSheet((sheetData.currentFilter as? ConversationFilter.Folder)?.folderId) }, + ), + state = state + ) + } + } + ) +} diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/filter/ConversationFoldersSheetContent.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/filter/ConversationFoldersSheetContent.kt new file mode 100644 index 00000000000..e65797cbe8e --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/filter/ConversationFoldersSheetContent.kt @@ -0,0 +1,142 @@ +/* + * Wire + * Copyright (C) 2024 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.ui.home.conversationslist.filter + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextDecoration +import com.wire.android.R +import com.wire.android.model.Clickable +import com.wire.android.ui.common.ArrowLeftIcon +import com.wire.android.ui.common.bottomsheet.MenuModalSheetHeader +import com.wire.android.ui.common.bottomsheet.RichMenuItemState +import com.wire.android.ui.common.bottomsheet.SelectableMenuBottomSheetItem +import com.wire.android.ui.common.bottomsheet.WireMenuModalSheetContent +import com.wire.android.ui.common.colorsScheme +import com.wire.android.ui.common.dimensions +import com.wire.android.ui.common.spacers.VerticalSpace +import com.wire.android.ui.common.typography +import com.wire.android.ui.theme.wireDimensions +import com.wire.android.ui.theme.wireTypography +import com.wire.android.util.CustomTabsHelper +import com.wire.kalium.logic.data.conversation.ConversationFilter + +@Composable +fun ConversationFoldersSheetContent( + sheetData: ConversationFilterSheetData, + onChangeFolder: (ConversationFilter.Folder) -> Unit, + onBackClick: () -> Unit, + modifier: Modifier = Modifier +) { + WireMenuModalSheetContent( + modifier = modifier, + header = MenuModalSheetHeader.Visible( + title = stringResource(R.string.label_folders), + customVerticalPadding = dimensions().spacing8x, + leadingIcon = { + ArrowLeftIcon(modifier = Modifier.clickable { onBackClick() }) + Spacer(modifier = Modifier.width(dimensions().spacing8x)) + }, + includeDivider = sheetData.folders.isNotEmpty() + ), + menuItems = buildList<@Composable () -> Unit> { + if (sheetData.folders.isEmpty()) { + add { + EmptyFolders() + } + } else { + sheetData.folders.forEach { folder -> + add { + val state = if (sheetData.currentFilter is ConversationFilter.Folder) { + val currentFolder = sheetData.currentFilter + if (currentFolder.folderId == folder.id) { + RichMenuItemState.SELECTED + } else { + RichMenuItemState.DEFAULT + } + } else { + RichMenuItemState.DEFAULT + } + SelectableMenuBottomSheetItem( + title = folder.name, + onItemClick = Clickable( + enabled = state == RichMenuItemState.DEFAULT, + onClickDescription = stringResource(id = R.string.content_description_select_label), + onClick = { onChangeFolder(ConversationFilter.Folder(folder.name, folder.id)) } + ), + state = state + ) + } + } + } + } + ) +} + +@Composable +private fun EmptyFolders() { + val context = LocalContext.current + Box( + modifier = Modifier + .height(dimensions().spacing300x) + .fillMaxWidth(), + ) { + Column(Modifier.align(Alignment.Center), horizontalAlignment = Alignment.CenterHorizontally) { + Icon( + painter = painterResource(id = R.drawable.ic_folders_outline), + contentDescription = "", + tint = colorsScheme().secondaryText, + modifier = Modifier + .size(MaterialTheme.wireDimensions.spacing56x) + ) + VerticalSpace.x16() + Text( + text = stringResource(R.string.folders_empty_list_description), + style = typography().body01, + ) + VerticalSpace.x16() + val supportUrl = stringResource(id = R.string.url_how_to_add_folders) + Text( + text = stringResource(R.string.folders_empty_list_how_to_add), + style = MaterialTheme.wireTypography.body02.copy( + textDecoration = TextDecoration.Underline, + color = MaterialTheme.colorScheme.onBackground + ), + modifier = Modifier.clickable { + CustomTabsHelper.launchUrl(context, supportUrl) + } + ) + VerticalSpace.x16() + } + } +} diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/model/ConversationsSource.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/model/ConversationsSource.kt index c242f0e3b81..45a7aad4391 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/model/ConversationsSource.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/model/ConversationsSource.kt @@ -17,4 +17,16 @@ */ package com.wire.android.ui.home.conversationslist.model -enum class ConversationsSource { MAIN, ARCHIVE, FAVORITES, GROUPS, ONE_ON_ONE } +import kotlinx.serialization.Serializable + +@Serializable +sealed class ConversationsSource { + + data object MAIN : ConversationsSource() + data object ARCHIVE : ConversationsSource() + data object FAVORITES : ConversationsSource() + data object GROUPS : ConversationsSource() + data object ONE_ON_ONE : ConversationsSource() + + data class FOLDER(val folderId: String, val folderName: String) : ConversationsSource() +} diff --git a/app/src/main/kotlin/com/wire/android/ui/home/drawer/HomeDrawer.kt b/app/src/main/kotlin/com/wire/android/ui/home/drawer/HomeDrawer.kt index b0316695bc3..c3c9ac03c89 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/drawer/HomeDrawer.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/drawer/HomeDrawer.kt @@ -141,7 +141,7 @@ fun DrawerItem( ) Text( style = MaterialTheme.wireTypography.button02, - text = stringResource(id = destination.title), + text = destination.title.asString(), textAlign = TextAlign.Start, color = contentColor, modifier = Modifier diff --git a/app/src/main/res/drawable/ic_folders_outline.xml b/app/src/main/res/drawable/ic_folders_outline.xml new file mode 100644 index 00000000000..c35fc71a253 --- /dev/null +++ b/app/src/main/res/drawable/ic_folders_outline.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index de630702afe..060d2d7a6c2 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -272,6 +272,7 @@ https://support.wire.com/hc/articles/6655706999581 https://support.wire.com/hc/articles/207859815 https://support.wire.com/hc/articles/360002855557 + https://support.wire.com/hc/articles/360002855817 https://wire.com/pricing https://teams.wire.com/ @@ -658,8 +659,10 @@ Filter Conversations All Conversations Favorites + Folders Groups 1:1 Conversations + Folders Everything @@ -1198,6 +1201,8 @@ In group conversations, the group admin can overwrite this setting. You are not part of any group conversation yet.\nStart a new conversation! You have no contacts yet.\nSearch for people on %1$s and get connected. How to label conversations as favorites + Add your conversations to folders to stay organized. + How to add a conversation to a folder Welcome 👋 No conversations could be found. Connect with new users or start a new conversation diff --git a/app/src/test/kotlin/com/wire/android/ui/home/conversations/usecase/GetConversationsFromSearchUseCaseTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/conversations/usecase/GetConversationsFromSearchUseCaseTest.kt index d60659aa43a..7d954ff464d 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/conversations/usecase/GetConversationsFromSearchUseCaseTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/conversations/usecase/GetConversationsFromSearchUseCaseTest.kt @@ -112,7 +112,7 @@ class GetConversationsFromSearchUseCaseTest { fromArchive = false, newActivitiesOnTop = false, onlyInteractionEnabled = false, - conversationFilter = ConversationFilter.FAVORITES + conversationFilter = ConversationFilter.Favorites ).asSnapshot() // Then diff --git a/app/src/test/kotlin/com/wire/android/ui/home/conversationslist/ConversationListViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/conversationslist/ConversationListViewModelTest.kt index a1a0fc548fa..56bc7e134b5 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/conversationslist/ConversationListViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/conversationslist/ConversationListViewModelTest.kt @@ -278,7 +278,7 @@ class ConversationListViewModelTest { MockKAnnotations.init(this, relaxUnitFun = true) withConversationsPaginated(listOf(TestConversationItem.CONNECTION, TestConversationItem.PRIVATE, TestConversationItem.GROUP)) withSelfUserLegalHoldState(LegalHoldStateForSelfUser.Disabled) - coEvery { observeConversationListDetailsWithEventsUseCase.invoke(false, ConversationFilter.ALL) } returns flowOf( + coEvery { observeConversationListDetailsWithEventsUseCase.invoke(false, ConversationFilter.All) } returns flowOf( listOf( TestConversationDetails.CONNECTION, TestConversationDetails.CONVERSATION_ONE_ONE, diff --git a/core/ui-common/src/main/kotlin/com/wire/android/ui/common/bottomsheet/ModalSheetHeaderItem.kt b/core/ui-common/src/main/kotlin/com/wire/android/ui/common/bottomsheet/ModalSheetHeaderItem.kt index 0ec6ea2b2db..fa793613b62 100644 --- a/core/ui-common/src/main/kotlin/com/wire/android/ui/common/bottomsheet/ModalSheetHeaderItem.kt +++ b/core/ui-common/src/main/kotlin/com/wire/android/ui/common/bottomsheet/ModalSheetHeaderItem.kt @@ -38,17 +38,20 @@ import com.wire.android.ui.common.divider.WireDivider import com.wire.android.ui.theme.wireTypography @Composable -fun ModalSheetHeaderItem(header: MenuModalSheetHeader = MenuModalSheetHeader.Gone) { +fun ModalSheetHeaderItem( + modifier: Modifier = Modifier, + header: MenuModalSheetHeader = MenuModalSheetHeader.Gone, +) { when (header) { MenuModalSheetHeader.Gone -> { - Spacer(modifier = Modifier.height(dimensions().modalBottomSheetNoHeaderVerticalPadding)) + Spacer(modifier = modifier.height(dimensions().modalBottomSheetNoHeaderVerticalPadding)) } is MenuModalSheetHeader.Visible -> { CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onSurface) { Row( verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding( + modifier = modifier.padding( start = dimensions().modalBottomSheetHeaderHorizontalPadding, end = dimensions().modalBottomSheetHeaderHorizontalPadding, top = header.customVerticalPadding ?: dimensions().modalBottomSheetHeaderVerticalPadding, @@ -63,7 +66,9 @@ fun ModalSheetHeaderItem(header: MenuModalSheetHeader = MenuModalSheetHeader.Gon modifier = Modifier.semantics { heading() } ) } - WireDivider() + if (header.includeDivider) { + WireDivider() + } } } } @@ -74,7 +79,8 @@ sealed class MenuModalSheetHeader { data class Visible( val title: String, val leadingIcon: @Composable () -> Unit = {}, - val customVerticalPadding: Dp? = null + val customVerticalPadding: Dp? = null, + val includeDivider: Boolean = true ) : MenuModalSheetHeader() object Gone : MenuModalSheetHeader() diff --git a/kalium b/kalium index d8b69f1202e..26b7d4b4dcf 160000 --- a/kalium +++ b/kalium @@ -1 +1 @@ -Subproject commit d8b69f1202e0ea88889c98bf1e2f9cd5016d197c +Subproject commit 26b7d4b4dcf2b50b64a3979e6211094a7a5d63d4