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 551d77f1c76..4baac30ed2f 100644 --- a/app/src/main/kotlin/com/wire/android/navigation/HomeDestination.kt +++ b/app/src/main/kotlin/com/wire/android/navigation/HomeDestination.kt @@ -22,11 +22,8 @@ import androidx.annotation.DrawableRes import androidx.annotation.StringRes import com.ramcosta.composedestinations.spec.Direction import com.wire.android.R -import com.wire.android.ui.common.WireBottomNavigationItemData import com.wire.android.ui.destinations.AllConversationScreenDestination import com.wire.android.ui.destinations.ArchiveScreenDestination -import com.wire.android.ui.destinations.CallsScreenDestination -import com.wire.android.ui.destinations.MentionScreenDestination import com.wire.android.ui.destinations.SettingsScreenDestination import com.wire.android.ui.destinations.VaultScreenDestination import com.wire.android.ui.destinations.WhatsNewScreenDestination @@ -49,22 +46,6 @@ sealed class HomeDestination( direction = AllConversationScreenDestination ) - data object Calls : HomeDestination( - title = R.string.conversations_calls_tab_title, - icon = R.drawable.ic_call, - isSearchable = true, - withNewConversationFab = true, - direction = CallsScreenDestination - ) - - data object Mentions : HomeDestination( - title = R.string.conversations_mentions_tab_title, - icon = R.drawable.ic_mention, - isSearchable = true, - withNewConversationFab = true, - direction = MentionScreenDestination - ) - data object Settings : HomeDestination( title = R.string.settings_screen_title, icon = R.drawable.ic_settings, @@ -96,22 +77,13 @@ sealed class HomeDestination( direction = WhatsNewScreenDestination ) - val withBottomTabs: Boolean get() = bottomTabItems.contains(this) - - fun toBottomNavigationItemData(notificationAmount: Long): WireBottomNavigationItemData = - WireBottomNavigationItemData(icon, tabName, notificationAmount, direction.route) - val itemName: String get() = ITEM_NAME_PREFIX + this companion object { - // TODO uncomment when CallsScreen and MentionScreen will be implemented -// val bottomTabItems = listOf(Conversations, Calls, Mentions) - val bottomTabItems = listOf() - 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, Calls, Mentions, Settings, Vault, Archive, Support, WhatsNew) + arrayOf(Conversations, Settings, Vault, Archive, Support, WhatsNew) } } diff --git a/app/src/main/kotlin/com/wire/android/ui/common/bottomsheet/PreviewWireModalSheetLayout.kt b/app/src/main/kotlin/com/wire/android/ui/common/bottomsheet/PreviewWireModalSheetLayout.kt index 2c39752fb6c..f86c53d8416 100644 --- a/app/src/main/kotlin/com/wire/android/ui/common/bottomsheet/PreviewWireModalSheetLayout.kt +++ b/app/src/main/kotlin/com/wire/android/ui/common/bottomsheet/PreviewWireModalSheetLayout.kt @@ -18,26 +18,30 @@ package com.wire.android.ui.common.bottomsheet import androidx.compose.runtime.Composable -import androidx.compose.ui.tooling.preview.Preview 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.edit.ReactionOption import com.wire.android.ui.home.conversationslist.common.GroupConversationAvatar -@Preview +@MultipleThemePreviews @Composable fun PreviewMenuModalSheetContentWithoutHeader() { MenuModalSheetContent( - MenuModalSheetHeader.Gone, - listOf { ReactionOption({}) } + header = MenuModalSheetHeader.Gone, + menuItems = listOf { ReactionOption({}) } ) } -@Preview +@MultipleThemePreviews @Composable fun PreviewMenuModalSheetContentWithHeader() { MenuModalSheetContent( - MenuModalSheetHeader.Visible("Title", { GroupConversationAvatar(colorsScheme().primary) }, dimensions().spacing8x), - listOf { ReactionOption({}) } + header = MenuModalSheetHeader.Visible( + "Title", + { GroupConversationAvatar(colorsScheme().primary) }, + dimensions().spacing8x + ), + menuItems = listOf { ReactionOption({}) } ) } 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 798dc5db0e0..d85144aec91 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 @@ -24,8 +24,6 @@ import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.animation.animateContentSize import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut -import androidx.compose.animation.slideInVertically -import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.Image import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column @@ -36,11 +34,11 @@ import androidx.compose.material3.DrawerDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalDrawerSheet import androidx.compose.material3.ModalNavigationDrawer -import androidx.compose.material3.SnackbarHostState 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 import androidx.compose.ui.graphics.RectangleShape @@ -72,10 +70,8 @@ import com.wire.android.navigation.handleNavigation import com.wire.android.ui.NavGraphs import com.wire.android.ui.common.CollapsingTopBarScaffold import com.wire.android.ui.common.FloatingActionButton -import com.wire.android.ui.common.WireBottomNavigationBar -import com.wire.android.ui.common.WireBottomNavigationItemData -import com.wire.android.ui.common.bottomsheet.WireModalSheetLayout import com.wire.android.ui.common.dimensions +import com.wire.android.ui.common.snackbar.LocalSnackbarHostState import com.wire.android.ui.common.topappbar.search.SearchTopBar import com.wire.android.ui.destinations.ConversationScreenDestination import com.wire.android.ui.destinations.NewConversationSearchPeopleScreenDestination @@ -83,9 +79,6 @@ import com.wire.android.ui.destinations.OtherUserProfileScreenDestination import com.wire.android.ui.destinations.SelfUserProfileScreenDestination import com.wire.android.ui.home.conversations.details.GroupConversationActionType import com.wire.android.ui.home.conversations.details.GroupConversationDetailsNavBackArgs -import com.wire.android.ui.home.conversationslist.ConversationListState -import com.wire.android.ui.home.conversationslist.ConversationListViewModel -import com.wire.android.ui.home.conversationslist.model.ConversationsSource import com.wire.android.ui.home.drawer.HomeDrawer import com.wire.android.ui.home.drawer.HomeDrawerState import com.wire.android.ui.home.drawer.HomeDrawerViewModel @@ -97,19 +90,21 @@ import kotlinx.coroutines.launch @Composable fun HomeScreen( navigator: Navigator, - homeViewModel: HomeViewModel = hiltViewModel(), - appSyncViewModel: AppSyncViewModel = hiltViewModel(), - homeDrawerViewModel: HomeDrawerViewModel = hiltViewModel(), - conversationListViewModel: ConversationListViewModel = hiltViewModel(), // TODO: move required elements from this one to HomeViewModel?, groupDetailsScreenResultRecipient: ResultRecipient, otherUserProfileScreenResultRecipient: ResultRecipient, + homeViewModel: HomeViewModel = hiltViewModel(), + appSyncViewModel: AppSyncViewModel = hiltViewModel(), + homeDrawerViewModel: HomeDrawerViewModel = hiltViewModel() ) { - homeViewModel.checkRequirements() { it.navigate(navigator::navigate) } + homeViewModel.checkRequirements { it.navigate(navigator::navigate) } val homeScreenState = rememberHomeScreenState(navigator) val showNotificationsFlow = rememberRequestPushNotificationsPermissionFlow( onPermissionDenied = { /** TODO: Show a dialog rationale explaining why the permission is needed **/ }) val lifecycleOwner = LocalLifecycleOwner.current + val context = LocalContext.current + val snackbarHostState = LocalSnackbarHostState.current + val coroutineScope = rememberCoroutineScope() DisposableEffect(lifecycleOwner) { val observer = LifecycleEventObserver { _, event -> @@ -128,20 +123,6 @@ fun HomeScreen( showNotificationsFlow.launch() } - LaunchedEffect(homeScreenState.currentNavigationItem) { - when (homeScreenState.currentNavigationItem) { - HomeDestination.Archive -> conversationListViewModel.updateConversationsSource(ConversationsSource.ARCHIVE) - HomeDestination.Conversations -> conversationListViewModel.updateConversationsSource(ConversationsSource.MAIN) - else -> {} - } - } - - handleSnackBarMessage( - snackbarHostState = homeScreenState.snackBarHostState, - conversationListSnackBarState = homeScreenState.snackbarState, - onMessageShown = homeScreenState::clearSnackbarMessage - ) - val homeState = homeViewModel.homeState if (homeViewModel.homeState.shouldDisplayWelcomeMessage) { WelcomeNewUserDialog( @@ -153,7 +134,6 @@ fun HomeScreen( homeState = homeState, homeDrawerState = homeDrawerViewModel.drawerState, homeStateHolder = homeScreenState, - conversationListState = conversationListViewModel.conversationListState, onNewConversationClick = { navigator.navigate(NavigationCommand(NewConversationSearchPeopleScreenDestination)) }, onSelfUserClick = remember(navigator) { { navigator.navigate(NavigationCommand(SelfUserProfileScreenDestination)) } } ) @@ -176,12 +156,19 @@ fun HomeScreen( is NavResult.Value -> { when (result.value.groupConversationActionType) { GroupConversationActionType.LEAVE_GROUP -> { - homeScreenState.setSnackBarState(HomeSnackbarState.LeftConversationSuccess) + coroutineScope.launch { + snackbarHostState.showSnackbar((HomeSnackBarMessage.LeftConversationSuccess.uiText.asString(context.resources))) + } } GroupConversationActionType.DELETE_GROUP -> { - val groupDeletedSnackBar = HomeSnackbarState.DeletedConversationGroupSuccess(result.value.conversationName) - homeScreenState.setSnackBarState(groupDeletedSnackBar) + coroutineScope.launch { + snackbarHostState.showSnackbar( + HomeSnackBarMessage.DeletedConversationGroupSuccess(result.value.conversationName).uiText.asString( + context.resources + ) + ) + } } } } @@ -195,7 +182,11 @@ fun HomeScreen( } is NavResult.Value -> { - homeScreenState.setSnackBarState(HomeSnackbarState.SuccessConnectionIgnoreRequest(result.value)) + coroutineScope.launch { + snackbarHostState.showSnackbar( + HomeSnackBarMessage.SuccessConnectionIgnoreRequest(result.value).uiText.asString(context.resources) + ) + } } } } @@ -207,7 +198,6 @@ fun HomeContent( homeState: HomeState, homeDrawerState: HomeDrawerState, homeStateHolder: HomeStateHolder, - conversationListState: ConversationListState, onNewConversationClick: () -> Unit, onSelfUserClick: () -> Unit, ) { @@ -330,107 +320,9 @@ fun HomeContent( onClick = onNewConversationClick ) } - }, - bottomBar = { - AnimatedVisibility( - visible = currentNavigationItem.withBottomTabs, - enter = slideInVertically(initialOffsetY = { it }), - exit = slideOutVertically(targetOffsetY = { it }), - ) { - WireBottomNavigationBar( - items = HomeDestination.bottomTabItems.toBottomNavigationItemData( - conversationListState = conversationListState - ), - selectedItemRoute = homeStateHolder.currentNavigationItem.direction.route, - onItemSelected = { HomeDestination.fromRoute(it.route)?.let { openHomeDestination(it) } } - ) - } } ) - - WireModalSheetLayout( - sheetState = bottomSheetState, - coroutineScope = coroutineScope, - // we want to render "nothing" instead of doing a if/else check - // on homeBottomSheetContent and wrap homeContent() into WireModalSheetLayout - // or render it without WireModalSheetLayout to avoid - // recomposing the homeContent() when homeBottomSheetContent - // changes from null to "something" - sheetContent = homeBottomSheetContent ?: { } - ) } ) } } - -@Suppress("ComplexMethod") -@Composable -private fun handleSnackBarMessage( - snackbarHostState: SnackbarHostState, - conversationListSnackBarState: HomeSnackbarState, - onMessageShown: () -> Unit -) { - conversationListSnackBarState.let { messageType -> - val message = when (messageType) { - is HomeSnackbarState.SuccessConnectionIgnoreRequest -> - stringResource(id = R.string.connection_request_ignored, messageType.userName) - - is HomeSnackbarState.BlockingUserOperationSuccess -> - stringResource(id = R.string.blocking_user_success, messageType.userName) - - HomeSnackbarState.MutingOperationError -> stringResource(id = R.string.error_updating_muting_setting) - HomeSnackbarState.BlockingUserOperationError -> stringResource(id = R.string.error_blocking_user) - HomeSnackbarState.UnblockingUserOperationError -> stringResource(id = R.string.error_unblocking_user) - HomeSnackbarState.None -> "" - is HomeSnackbarState.DeletedConversationGroupSuccess -> stringResource( - id = R.string.conversation_group_removed_success, - messageType.groupName - ) - - HomeSnackbarState.LeftConversationSuccess -> stringResource(id = R.string.left_conversation_group_success) - HomeSnackbarState.LeaveConversationError -> stringResource(id = R.string.leave_group_conversation_error) - HomeSnackbarState.DeleteConversationGroupError -> stringResource(id = R.string.delete_group_conversation_error) - is HomeSnackbarState.ClearConversationContentFailure -> stringResource( - if (messageType.isGroup) R.string.group_content_delete_failure - else R.string.conversation_content_delete_failure - ) - - is HomeSnackbarState.ClearConversationContentSuccess -> stringResource( - if (messageType.isGroup) R.string.group_content_deleted else R.string.conversation_content_deleted - ) - - is HomeSnackbarState.UpdateArchivingStatusSuccess -> { - stringResource( - id = if (messageType.isArchiving) R.string.success_archiving_conversation - else R.string.success_unarchiving_conversation - ) - } - - is HomeSnackbarState.UpdateArchivingStatusError -> { - stringResource( - id = if (messageType.isArchiving) R.string.error_archiving_conversation - else R.string.error_archiving_conversation - ) - } - } - - LaunchedEffect(messageType) { - if (messageType != HomeSnackbarState.None) { - snackbarHostState.showSnackbar(message) - onMessageShown() - } - } - } -} - -@Composable -private fun List.toBottomNavigationItemData( - conversationListState: ConversationListState -): List = map { - when (it) { - HomeDestination.Conversations -> it.toBottomNavigationItemData(conversationListState.newActivityCount) - HomeDestination.Calls -> it.toBottomNavigationItemData(conversationListState.missedCallsCount) - HomeDestination.Mentions -> it.toBottomNavigationItemData(conversationListState.unreadMentionsCount) - else -> it.toBottomNavigationItemData(0L) - } -} diff --git a/app/src/main/kotlin/com/wire/android/ui/home/HomeSnackBarMessage.kt b/app/src/main/kotlin/com/wire/android/ui/home/HomeSnackBarMessage.kt new file mode 100644 index 00000000000..0a8bb081457 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/home/HomeSnackBarMessage.kt @@ -0,0 +1,85 @@ +/* + * 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 + +import com.wire.android.R +import com.wire.android.model.SnackBarMessage +import com.wire.android.util.ui.UIText + +sealed class HomeSnackBarMessage(override val uiText: UIText) : SnackBarMessage { + + data class ClearConversationContentSuccess(val isGroup: Boolean) : HomeSnackBarMessage( + UIText.StringResource( + if (isGroup) { + R.string.group_content_deleted + } else { + R.string.conversation_content_deleted + } + ) + ) + + data class ClearConversationContentFailure(val isGroup: Boolean) : HomeSnackBarMessage( + UIText.StringResource( + if (isGroup) { + R.string.group_content_delete_failure + } else { + R.string.conversation_content_delete_failure + } + ) + ) + + class SuccessConnectionIgnoreRequest(val userName: String) : + HomeSnackBarMessage(UIText.StringResource(R.string.connection_request_ignored, userName)) + + data object MutingOperationError : HomeSnackBarMessage(UIText.StringResource(R.string.error_updating_muting_setting)) + data object BlockingUserOperationError : HomeSnackBarMessage(UIText.StringResource(R.string.error_blocking_user)) + data class BlockingUserOperationSuccess(val userName: String) : + HomeSnackBarMessage(UIText.StringResource(R.string.blocking_user_success, userName)) + + data object UnblockingUserOperationError : HomeSnackBarMessage(UIText.StringResource(R.string.error_unblocking_user)) + data class DeletedConversationGroupSuccess(val groupName: String) : HomeSnackBarMessage( + UIText.StringResource( + R.string.conversation_group_removed_success, + groupName + ) + ) + + data object DeleteConversationGroupError : HomeSnackBarMessage(UIText.StringResource(R.string.delete_group_conversation_error)) + data object LeftConversationSuccess : HomeSnackBarMessage(UIText.StringResource(R.string.left_conversation_group_success)) + data object LeaveConversationError : HomeSnackBarMessage(UIText.StringResource(R.string.leave_group_conversation_error)) + data class UpdateArchivingStatusSuccess(val isArchiving: Boolean) : HomeSnackBarMessage( + UIText.StringResource( + if (isArchiving) { + R.string.success_archiving_conversation + } else { + R.string.success_unarchiving_conversation + } + ) + ) + + data class UpdateArchivingStatusError(val isArchiving: Boolean) : HomeSnackBarMessage( + UIText.StringResource( + if (isArchiving) { + R.string.error_archiving_conversation + } else { + R.string.error_archiving_conversation + } + ) + ) +} diff --git a/app/src/main/kotlin/com/wire/android/ui/home/HomeSnackbarState.kt b/app/src/main/kotlin/com/wire/android/ui/home/HomeSnackbarState.kt deleted file mode 100644 index b291bc54376..00000000000 --- a/app/src/main/kotlin/com/wire/android/ui/home/HomeSnackbarState.kt +++ /dev/null @@ -1,38 +0,0 @@ -/* - * 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 - -// TODO change to extend [SnackBarMessage] -sealed class HomeSnackbarState { - object None : HomeSnackbarState() - data class ClearConversationContentSuccess(val isGroup: Boolean) : HomeSnackbarState() - data class ClearConversationContentFailure(val isGroup: Boolean) : HomeSnackbarState() - - class SuccessConnectionIgnoreRequest(val userName: String) : HomeSnackbarState() - object MutingOperationError : HomeSnackbarState() - object BlockingUserOperationError : HomeSnackbarState() - data class BlockingUserOperationSuccess(val userName: String) : HomeSnackbarState() - object UnblockingUserOperationError : HomeSnackbarState() - data class DeletedConversationGroupSuccess(val groupName: String) : HomeSnackbarState() - object DeleteConversationGroupError : HomeSnackbarState() - object LeftConversationSuccess : HomeSnackbarState() - object LeaveConversationError : HomeSnackbarState() - data class UpdateArchivingStatusSuccess(val isArchiving: Boolean) : HomeSnackbarState() - data class UpdateArchivingStatusError(val isArchiving: Boolean) : HomeSnackbarState() -} 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 f84b680a355..68616554be7 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 @@ -16,33 +16,22 @@ * along with this program. If not, see http://www.gnu.org/licenses/. */ -@file:OptIn(ExperimentalAnimationApi::class) - package com.wire.android.ui.home -import androidx.compose.animation.ExperimentalAnimationApi -import androidx.compose.foundation.layout.ColumnScope import androidx.compose.material3.DrawerState import androidx.compose.material3.DrawerValue -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.rememberDrawerState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue import androidx.navigation.NavHostController import androidx.navigation.compose.currentBackStackEntryAsState import com.wire.android.navigation.HomeDestination import com.wire.android.navigation.Navigator import com.wire.android.navigation.rememberTrackingAnimatedNavController -import com.wire.android.ui.common.bottomsheet.WireModalSheetState -import com.wire.android.ui.common.bottomsheet.rememberWireModalSheetState import com.wire.android.ui.common.topappbar.search.SearchBarState import com.wire.android.ui.common.topappbar.search.rememberSearchbarState -import com.wire.android.ui.common.snackbar.LocalSnackbarHostState import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch @@ -51,46 +40,11 @@ class HomeStateHolder( val coroutineScope: CoroutineScope, val navController: NavHostController, val drawerState: DrawerState, - val bottomSheetState: WireModalSheetState, val currentNavigationItem: HomeDestination, - val snackBarHostState: SnackbarHostState, val searchBarState: SearchBarState, val navigator: Navigator ) { - var homeBottomSheetContent: @Composable (ColumnScope.() -> Unit)? by mutableStateOf(null) - private set - - var snackbarState: HomeSnackbarState by mutableStateOf(HomeSnackbarState.None) - private set - - fun setSnackBarState(state: HomeSnackbarState) { - snackbarState = state - if (state != HomeSnackbarState.None) closeBottomSheet() - } - - fun clearSnackbarMessage() { - setSnackBarState(HomeSnackbarState.None) - } - - fun openBottomSheet() { - coroutineScope.launch { - if (!bottomSheetState.isVisible) bottomSheetState.show() - } - } - - fun closeBottomSheet() { - coroutineScope.launch { - if (bottomSheetState.isVisible) bottomSheetState.hide() - } - } - - fun isBottomSheetVisible() = bottomSheetState.isVisible - - fun changeBottomSheetContent(content: @Composable ColumnScope.() -> Unit) { - homeBottomSheetContent = content - } - fun closeDrawer() { coroutineScope.launch { drawerState.close() @@ -104,16 +58,15 @@ class HomeStateHolder( } } -@OptIn(ExperimentalMaterial3Api::class) @Composable fun rememberHomeScreenState( navigator: Navigator, coroutineScope: CoroutineScope = rememberCoroutineScope(), - navController: NavHostController = rememberTrackingAnimatedNavController() { HomeDestination.fromRoute(it)?.itemName }, + navController: NavHostController = rememberTrackingAnimatedNavController { + HomeDestination.fromRoute(it)?.itemName + }, drawerState: DrawerState = rememberDrawerState(DrawerValue.Closed), - bottomSheetState: WireModalSheetState = rememberWireModalSheetState() ): HomeStateHolder { - val snackbarHostState = LocalSnackbarHostState.current val navBackStackEntry by navController.currentBackStackEntryAsState() val currentRoute = navBackStackEntry?.destination?.route val currentNavigationItem = currentRoute?.let { HomeDestination.fromRoute(it) } ?: HomeDestination.Conversations @@ -127,9 +80,7 @@ fun rememberHomeScreenState( coroutineScope, navController, drawerState, - bottomSheetState, currentNavigationItem, - snackbarHostState, searchBarState, navigator ) diff --git a/app/src/main/kotlin/com/wire/android/ui/home/archive/ArchiveScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/archive/ArchiveScreen.kt index fb6491bee42..7c08265ef09 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/archive/ArchiveScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/archive/ArchiveScreen.kt @@ -60,12 +60,7 @@ fun ArchiveScreen(homeStateHolder: HomeStateHolder) { ConversationRouterHomeBridge( navigator = navigator, conversationItemType = ConversationItemType.ALL_CONVERSATIONS, - onHomeBottomSheetContentChanged = ::changeBottomSheetContent, - onOpenBottomSheet = ::openBottomSheet, - onCloseBottomSheet = ::closeBottomSheet, - onSnackBarStateChanged = ::setSnackBarState, searchBarState = searchBarState, - isBottomSheetVisible = ::isBottomSheetVisible, conversationsSource = ConversationsSource.ARCHIVE ) } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationScreen.kt index 3cefb63acd0..17329906d0a 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationScreen.kt @@ -120,7 +120,7 @@ import com.wire.android.ui.home.conversations.AuthorHeaderHelper.rememberShouldS import com.wire.android.ui.home.conversations.ConversationSnackbarMessages.OnFileDownloaded import com.wire.android.ui.home.conversations.banner.ConversationBanner import com.wire.android.ui.home.conversations.banner.ConversationBannerViewModel -import com.wire.android.ui.home.conversations.call.ConversationCallViewModel +import com.wire.android.ui.home.conversations.call.ConversationListCallViewModel import com.wire.android.ui.home.conversations.call.ConversationCallViewState import com.wire.android.ui.home.conversations.composer.MessageComposerViewModel import com.wire.android.ui.home.conversations.delete.DeleteMessageDialog @@ -207,7 +207,7 @@ fun ConversationScreen( resultNavigator: ResultBackNavigator, conversationInfoViewModel: ConversationInfoViewModel = hiltViewModel(), conversationBannerViewModel: ConversationBannerViewModel = hiltViewModel(), - conversationCallViewModel: ConversationCallViewModel = hiltViewModel(), + conversationListCallViewModel: ConversationListCallViewModel = hiltViewModel(), conversationMessagesViewModel: ConversationMessagesViewModel = hiltViewModel(), messageComposerViewModel: MessageComposerViewModel = hiltViewModel(), sendMessageViewModel: SendMessageViewModel = hiltViewModel(), @@ -261,7 +261,7 @@ fun ConversationScreen( ) } - with(conversationCallViewModel) { + with(conversationListCallViewModel) { if (conversationCallViewState.shouldShowJoinAnywayDialog) { appLogger.i("showing showJoinAnywayDialog..") JoinAnywayDialog( @@ -280,8 +280,8 @@ fun ConversationScreen( when (showDialog.value) { ConversationScreenDialogType.ONGOING_ACTIVE_CALL -> { OngoingActiveCallDialog(onJoinAnyways = { - conversationCallViewModel.endEstablishedCallIfAny { - getOutgoingCallIntent(activity, conversationCallViewModel.conversationId.toString()).run { + conversationListCallViewModel.endEstablishedCallIfAny { + getOutgoingCallIntent(activity, conversationListCallViewModel.conversationId.toString()).run { activity.startActivity(this) } } @@ -299,10 +299,10 @@ fun ConversationScreen( ConversationScreenDialogType.CALL_CONFIRMATION -> { ConfirmStartCallDialog( - participantsCount = conversationCallViewModel.conversationCallViewState.participantsCount - 1, + participantsCount = conversationListCallViewModel.conversationCallViewState.participantsCount - 1, onConfirm = { startCallIfPossible( - conversationCallViewModel, + conversationListCallViewModel, showDialog, coroutineScope, conversationInfoViewModel.conversationInfoViewState.conversationType, @@ -332,9 +332,9 @@ fun ConversationScreen( ConversationScreenDialogType.VERIFICATION_DEGRADED -> { SureAboutCallingInDegradedConversationDialog( callAnyway = { - conversationCallViewModel.onApplyConversationDegradation() + conversationListCallViewModel.onApplyConversationDegradation() startCallIfPossible( - conversationCallViewModel, + conversationListCallViewModel, showDialog, coroutineScope, conversationInfoViewModel.conversationInfoViewState.conversationType, @@ -359,7 +359,7 @@ fun ConversationScreen( ConversationScreen( bannerMessage = conversationBannerViewModel.bannerState, messageComposerViewState = messageComposerViewState.value, - conversationCallViewState = conversationCallViewModel.conversationCallViewState, + conversationCallViewState = conversationListCallViewModel.conversationCallViewState, conversationInfoViewState = conversationInfoViewModel.conversationInfoViewState, conversationMessagesViewState = conversationMessagesViewModel.conversationViewState, onOpenProfile = { @@ -408,7 +408,7 @@ fun ConversationScreen( }, onStartCall = { startCallIfPossible( - conversationCallViewModel, + conversationListCallViewModel, showDialog, coroutineScope, conversationInfoViewModel.conversationInfoViewState.conversationType, @@ -424,7 +424,7 @@ fun ConversationScreen( } }, onJoinCall = { - conversationCallViewModel.joinOngoingCall { + conversationListCallViewModel.joinOngoingCall { getOngoingCallIntent(activity, it.toString()).run { activity.startActivity(this) } @@ -654,7 +654,7 @@ private fun conversationScreenOnBackButtonClick( @Suppress("LongParameterList") private fun startCallIfPossible( - conversationCallViewModel: ConversationCallViewModel, + conversationListCallViewModel: ConversationListCallViewModel, showDialog: MutableState, coroutineScope: CoroutineScope, conversationType: Conversation.Type, @@ -662,28 +662,28 @@ private fun startCallIfPossible( onOpenOngoingCallScreen: (ConversationId) -> Unit ) { coroutineScope.launch { - if (!conversationCallViewModel.hasStableConnectivity()) { + if (!conversationListCallViewModel.hasStableConnectivity()) { showDialog.value = ConversationScreenDialogType.NO_CONNECTIVITY - } else if (conversationCallViewModel.shouldInformAboutVerification.value) { + } else if (conversationListCallViewModel.shouldInformAboutVerification.value) { showDialog.value = ConversationScreenDialogType.VERIFICATION_DEGRADED } else { - val dialogValue = when (conversationCallViewModel.isConferenceCallingEnabled(conversationType)) { + val dialogValue = when (conversationListCallViewModel.isConferenceCallingEnabled(conversationType)) { ConferenceCallingResult.Enabled -> { if ( showDialog.value != ConversationScreenDialogType.CALL_CONFIRMATION && - conversationCallViewModel.conversationCallViewState.participantsCount > MAX_GROUP_SIZE_FOR_CALL_WITHOUT_ALERT + conversationListCallViewModel.conversationCallViewState.participantsCount > MAX_GROUP_SIZE_FOR_CALL_WITHOUT_ALERT ) { ConversationScreenDialogType.CALL_CONFIRMATION } else { - conversationCallViewModel.endEstablishedCallIfAny { - onOpenOutgoingCallScreen(conversationCallViewModel.conversationId) + conversationListCallViewModel.endEstablishedCallIfAny { + onOpenOutgoingCallScreen(conversationListCallViewModel.conversationId) } ConversationScreenDialogType.NONE } } ConferenceCallingResult.Disabled.Established -> { - onOpenOngoingCallScreen(conversationCallViewModel.conversationId) + onOpenOngoingCallScreen(conversationListCallViewModel.conversationId) ConversationScreenDialogType.NONE } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/call/ConversationCallViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/call/ConversationListCallViewModel.kt similarity index 93% rename from app/src/main/kotlin/com/wire/android/ui/home/conversations/call/ConversationCallViewModel.kt rename to app/src/main/kotlin/com/wire/android/ui/home/conversations/call/ConversationListCallViewModel.kt index cec753332d2..2f533ac6044 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/call/ConversationCallViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/call/ConversationListCallViewModel.kt @@ -53,7 +53,7 @@ import javax.inject.Inject @HiltViewModel @Suppress("LongParameterList", "TooManyFunctions") -class ConversationCallViewModel @Inject constructor( +class ConversationListCallViewModel @Inject constructor( override val savedStateHandle: SavedStateHandle, private val observeOngoingCalls: ObserveOngoingCallsUseCase, private val observeEstablishedCalls: ObserveEstablishedCallsUseCase, @@ -101,8 +101,14 @@ class ConversationCallViewModel @Inject constructor( // valid conversation is a conversation where the user is a member and it's not deleted val validConversation = when (conversationDetailsResult) { is ObserveConversationDetailsUseCase.Result.Success -> { - !(conversationDetailsResult.conversationDetails is ConversationDetails.Group && - !(conversationDetailsResult.conversationDetails as ConversationDetails.Group).isSelfUserMember) + val conversationDetails = conversationDetailsResult.conversationDetails + val isGroup = conversationDetails is ConversationDetails.Group + val isSelfUserMember = if (isGroup) { + (conversationDetails as ConversationDetails.Group).isSelfUserMember + } else { + false + } + !(isGroup && !isSelfUserMember) } is ObserveConversationDetailsUseCase.Result.Failure -> false @@ -120,7 +126,9 @@ class ConversationCallViewModel @Inject constructor( val hasEstablishedCall = it.isNotEmpty() establishedCallConversationId = if (it.isNotEmpty()) { it.first().conversationId - } else null + } else { + null + } conversationCallViewState = conversationCallViewState.copy(hasEstablishedCall = hasEstablishedCall) } } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/GroupConversationDetailsScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/GroupConversationDetailsScreen.kt index d2ffb0e7076..fe2af7e51a5 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/GroupConversationDetailsScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/GroupConversationDetailsScreen.kt @@ -36,7 +36,6 @@ import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState -import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface @@ -58,7 +57,6 @@ import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.tooling.preview.Preview import androidx.hilt.navigation.compose.hiltViewModel import com.ramcosta.composedestinations.annotation.Destination import com.ramcosta.composedestinations.annotation.RootNavGraph @@ -79,6 +77,7 @@ import com.wire.android.ui.common.VisibilityState import com.wire.android.ui.common.WireTabRow import com.wire.android.ui.common.bottomsheet.WireModalSheetLayout import com.wire.android.ui.common.bottomsheet.conversation.ConversationSheetContent +import com.wire.android.ui.common.bottomsheet.conversation.ConversationTypeDetail import com.wire.android.ui.common.bottomsheet.conversation.rememberConversationSheetState import com.wire.android.ui.common.bottomsheet.rememberWireModalSheetState import com.wire.android.ui.common.button.WirePrimaryButton @@ -116,9 +115,14 @@ import com.wire.android.ui.theme.WireTheme import com.wire.android.ui.theme.wireColorScheme import com.wire.android.ui.theme.wireDimensions import com.wire.android.ui.theme.wireTypography +import com.wire.android.util.ui.PreviewMultipleThemes import com.wire.android.util.ui.UIText import com.wire.kalium.logic.data.conversation.Conversation +import com.wire.kalium.logic.data.conversation.MutedConversationStatus +import com.wire.kalium.logic.data.id.ConversationId +import com.wire.kalium.logic.data.id.GroupID import kotlinx.coroutines.launch +import kotlinx.datetime.Instant @RootNavGraph @Destination( @@ -128,9 +132,9 @@ import kotlinx.coroutines.launch @Composable fun GroupConversationDetailsScreen( navigator: Navigator, - viewModel: GroupConversationDetailsViewModel = hiltViewModel(), resultNavigator: ResultBackNavigator, groupConversationDetailResultRecipient: ResultRecipient, + viewModel: GroupConversationDetailsViewModel = hiltViewModel() ) { val scope = rememberCoroutineScope() val resources = LocalContext.current.resources @@ -159,7 +163,7 @@ fun GroupConversationDetailsScreen( GroupConversationDetailsContent( conversationSheetContent = viewModel.conversationSheetContent, - bottomSheetEventsHandler = viewModel, + bottomSheetEventsHandler = viewModel as GroupConversationDetailsBottomSheetEventsHandler, onBackPressed = navigator::navigateBack, onProfilePressed = { participant -> when { @@ -260,7 +264,7 @@ fun GroupConversationDetailsScreen( } } -@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class, ExperimentalMaterialApi::class) +@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @Composable private fun GroupConversationDetailsContent( conversationSheetContent: ConversationSheetContent?, @@ -561,15 +565,34 @@ private fun VerifiedLabel(text: String, color: Color, icon: @Composable RowScope enum class GroupConversationDetailsTabItem(@StringRes val titleResId: Int) : TabItem { OPTIONS(R.string.conversation_details_options_tab), PARTICIPANTS(R.string.conversation_details_participants_tab); + override val title: UIText = UIText.StringResource(titleResId) } -@Preview +@PreviewMultipleThemes @Composable fun PreviewGroupConversationDetails() { WireTheme { GroupConversationDetailsContent( - conversationSheetContent = null, + conversationSheetContent = ConversationSheetContent( + title = "title", + conversationId = ConversationId("value", "domain"), + mutingConversationState = MutedConversationStatus.AllAllowed, + conversationTypeDetail = ConversationTypeDetail.Group(ConversationId("value", "domain"), false), + selfRole = null, + isTeamConversation = true, + isArchived = false, + protocol = Conversation.ProtocolInfo.MLS( + groupId = GroupID("groupId"), + groupState = Conversation.ProtocolInfo.MLSCapable.GroupState.ESTABLISHED, + epoch = ULong.MIN_VALUE, + keyingMaterialLastUpdate = Instant.fromEpochMilliseconds(1648654560000), + cipherSuite = Conversation.CipherSuite.MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519 + ), + mlsVerificationStatus = Conversation.VerificationStatus.VERIFIED, + isUnderLegalHold = false, + proteusVerificationStatus = Conversation.VerificationStatus.VERIFIED + ), bottomSheetEventsHandler = GroupConversationDetailsBottomSheetEventsHandler.PREVIEW, onBackPressed = {}, onProfilePressed = {}, diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/media/ConversationMediaScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/media/ConversationMediaScreen.kt index 4395bfe2be1..600a2f223fa 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/media/ConversationMediaScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/media/ConversationMediaScreen.kt @@ -39,14 +39,12 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.hilt.navigation.compose.hiltViewModel import com.ramcosta.composedestinations.annotation.Destination import com.ramcosta.composedestinations.annotation.RootNavGraph import com.wire.android.R import com.wire.android.media.audiomessage.AudioState -import com.wire.android.model.SnackBarMessage import com.wire.android.navigation.NavigationCommand import com.wire.android.navigation.Navigator import com.wire.android.navigation.style.PopUpNavigationAnimation @@ -56,7 +54,6 @@ import com.wire.android.ui.common.calculateCurrentTab import com.wire.android.ui.common.colorsScheme import com.wire.android.ui.common.dialogs.PermissionPermanentlyDeniedDialog import com.wire.android.ui.common.scaffold.WireScaffold -import com.wire.android.ui.common.snackbar.LocalSnackbarHostState import com.wire.android.ui.common.topBarElevation import com.wire.android.ui.common.topappbar.NavigationIconType import com.wire.android.ui.common.topappbar.WireCenterAlignedTopAppBar @@ -68,11 +65,11 @@ import com.wire.android.ui.home.conversations.messages.ConversationMessagesViewM import com.wire.android.ui.theme.WireTheme import com.wire.android.ui.theme.wireDimensions import com.wire.android.util.ui.PreviewMultipleThemes +import com.wire.android.util.ui.SnackBarMessageHandler import com.wire.android.util.ui.UIText import com.wire.kalium.logic.data.id.ConversationId import kotlinx.collections.immutable.PersistentMap import kotlinx.collections.immutable.persistentMapOf -import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.launch @RootNavGraph @@ -131,7 +128,7 @@ fun ConversationMediaScreen( hideDialog = permissionPermanentlyDeniedDialogState::dismiss ) - SnackBarMessage(conversationMessagesViewModel.infoMessage) + SnackBarMessageHandler(conversationMessagesViewModel.infoMessage) } @OptIn(ExperimentalFoundationApi::class) @@ -206,20 +203,6 @@ private fun Content( } } -@Composable -private fun SnackBarMessage(infoMessages: SharedFlow) { - val context = LocalContext.current - val snackbarHostState = LocalSnackbarHostState.current - - LaunchedEffect(Unit) { - infoMessages.collect { - snackbarHostState.showSnackbar( - message = it.uiText.asString(context.resources) - ) - } - } -} - enum class ConversationMediaScreenTabItem(@StringRes val titleResId: Int) : TabItem { PICTURES(R.string.label_conversation_pictures), FILES(R.string.label_conversation_files); diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/ConversationCallListViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/ConversationCallListViewModel.kt new file mode 100644 index 00000000000..eadd68f7ba1 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/ConversationCallListViewModel.kt @@ -0,0 +1,115 @@ +/* + * 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 + +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.model.SnackBarMessage +import com.wire.kalium.logic.data.id.ConversationId +import com.wire.kalium.logic.data.id.QualifiedID +import com.wire.kalium.logic.feature.call.usecase.AnswerCallUseCase +import com.wire.kalium.logic.feature.call.usecase.EndCallUseCase +import com.wire.kalium.logic.feature.call.usecase.ObserveEstablishedCallsUseCase +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.launch +import javax.inject.Inject + +@Suppress("MagicNumber", "TooManyFunctions", "LongParameterList") +@HiltViewModel +class ConversationCallListViewModel @Inject constructor( + private val answerCall: AnswerCallUseCase, + private val observeEstablishedCalls: ObserveEstablishedCallsUseCase, + private val endCall: EndCallUseCase +) : ViewModel() { + + var conversationListCallState by mutableStateOf(ConversationListCallState()) + + private val _infoMessage = MutableSharedFlow() + val infoMessage = _infoMessage.asSharedFlow() + + var establishedCallConversationId: QualifiedID? = null + private var conversationId: QualifiedID? = null + + private suspend fun observeEstablishedCall() { + observeEstablishedCalls() + .distinctUntilChanged() + .collectLatest { + val hasEstablishedCall = it.isNotEmpty() + conversationListCallState = conversationListCallState.copy( + hasEstablishedCall = hasEstablishedCall + ) + establishedCallConversationId = if (it.isNotEmpty()) { + it.first().conversationId + } else { + null + } + } + } + + init { + viewModelScope.launch { + observeEstablishedCall() + } + } + + fun joinAnyway(conversationId: ConversationId, onJoined: (ConversationId) -> Unit) { + viewModelScope.launch { + establishedCallConversationId?.let { + endCall(it) + delay(DELAY_END_CALL) + } + joinOngoingCall(conversationId, onJoined) + } + } + + fun joinOngoingCall(conversationId: ConversationId, onJoined: (ConversationId) -> Unit) { + this.conversationId = conversationId + if (conversationListCallState.hasEstablishedCall) { + showJoinCallAnywayDialog() + } else { + dismissJoinCallAnywayDialog() + viewModelScope.launch { + answerCall(conversationId = conversationId) + } + onJoined(conversationId) + } + } + + private fun showJoinCallAnywayDialog() { + conversationListCallState = + conversationListCallState.copy(shouldShowJoinAnywayDialog = true) + } + + fun dismissJoinCallAnywayDialog() { + conversationListCallState = + conversationListCallState.copy(shouldShowJoinAnywayDialog = false) + } + + companion object { + const val DELAY_END_CALL = 200L + } +} 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 345b2e2a654..7f053c23143 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 @@ -28,10 +28,11 @@ import com.wire.android.appLogger import com.wire.android.mapper.UserTypeMapper import com.wire.android.mapper.toUIPreview import com.wire.android.model.ImageAsset.UserAvatarAsset +import com.wire.android.model.SnackBarMessage import com.wire.android.model.UserAvatarData import com.wire.android.ui.common.bottomsheet.conversation.ConversationTypeDetail import com.wire.android.ui.common.dialogs.BlockUserDialogState -import com.wire.android.ui.home.HomeSnackbarState +import com.wire.android.ui.home.HomeSnackBarMessage import com.wire.android.ui.home.conversations.model.UILastMessageContent import com.wire.android.ui.home.conversations.search.DEFAULT_SEARCH_QUERY_DEBOUNCE import com.wire.android.ui.home.conversationslist.model.BadgeEventType @@ -60,8 +61,6 @@ import com.wire.kalium.logic.data.message.UnreadEventType import com.wire.kalium.logic.data.user.ConnectionState import com.wire.kalium.logic.data.user.UserAvailabilityStatus import com.wire.kalium.logic.data.user.UserId -import com.wire.kalium.logic.feature.call.usecase.AnswerCallUseCase -import com.wire.kalium.logic.feature.call.usecase.EndCallUseCase import com.wire.kalium.logic.feature.call.usecase.ObserveEstablishedCallsUseCase import com.wire.kalium.logic.feature.connection.BlockUserResult import com.wire.kalium.logic.feature.connection.BlockUserUseCase @@ -82,8 +81,8 @@ import com.wire.kalium.logic.feature.team.Result import com.wire.kalium.util.DateTimeUtil import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.collections.immutable.toImmutableMap -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.distinctUntilChanged @@ -100,7 +99,6 @@ import javax.inject.Inject class ConversationListViewModel @Inject constructor( private val dispatcher: DispatcherProvider, private val updateConversationMutedStatus: UpdateConversationMutedStatusUseCase, - private val answerCall: AnswerCallUseCase, private val observeConversationListDetails: ObserveConversationListDetailsUseCase, private val leaveConversation: LeaveConversationUseCase, private val deleteTeamConversation: DeleteTeamConversationUseCase, @@ -110,7 +108,6 @@ class ConversationListViewModel @Inject constructor( private val observeEstablishedCalls: ObserveEstablishedCallsUseCase, private val wireSessionImageLoader: WireSessionImageLoader, private val userTypeMapper: UserTypeMapper, - private val endCall: EndCallUseCase, private val refreshUsersWithoutMetadata: RefreshUsersWithoutMetadataUseCase, private val refreshConversationsWithoutMetadata: RefreshConversationsWithoutMetadataUseCase, private val updateConversationArchivedStatus: UpdateConversationArchivedStatusUseCase, @@ -119,7 +116,8 @@ class ConversationListViewModel @Inject constructor( var conversationListState by mutableStateOf(ConversationListState()) var conversationListCallState by mutableStateOf(ConversationListCallState()) - val homeSnackBarState = MutableSharedFlow() + private val _infoMessage = MutableSharedFlow() + val infoMessage = _infoMessage.asSharedFlow() val closeBottomSheet = MutableSharedFlow() @@ -309,8 +307,8 @@ class ConversationListViewModel @Inject constructor( mutedConversationStatus, Date().time )) { - ConversationUpdateStatusResult.Failure -> homeSnackBarState.emit( - HomeSnackbarState.MutingOperationError + ConversationUpdateStatusResult.Failure -> _infoMessage.emit( + HomeSnackBarMessage.MutingOperationError ) ConversationUpdateStatusResult.Success -> @@ -323,46 +321,13 @@ class ConversationListViewModel @Inject constructor( } } - fun joinAnyway(conversationId: ConversationId, onJoined: (ConversationId) -> Unit) { - viewModelScope.launch { - establishedCallConversationId?.let { - endCall(it) - delay(DELAY_END_CALL) - } - joinOngoingCall(conversationId, onJoined) - } - } - - fun joinOngoingCall(conversationId: ConversationId, onJoined: (ConversationId) -> Unit) { - this.conversationId = conversationId - if (conversationListCallState.hasEstablishedCall) { - showJoinCallAnywayDialog() - } else { - dismissJoinCallAnywayDialog() - viewModelScope.launch { - answerCall(conversationId = conversationId) - } - onJoined(conversationId) - } - } - - private fun showJoinCallAnywayDialog() { - conversationListCallState = - conversationListCallState.copy(shouldShowJoinAnywayDialog = true) - } - - fun dismissJoinCallAnywayDialog() { - conversationListCallState = - conversationListCallState.copy(shouldShowJoinAnywayDialog = false) - } - fun blockUser(blockUserState: BlockUserDialogState) { viewModelScope.launch { requestInProgress = true val state = when (val result = blockUserUseCase(blockUserState.userId)) { BlockUserResult.Success -> { appLogger.d("User ${blockUserState.userId} was blocked") - HomeSnackbarState.BlockingUserOperationSuccess(blockUserState.userName) + HomeSnackBarMessage.BlockingUserOperationSuccess(blockUserState.userName) } is BlockUserResult.Failure -> { @@ -370,10 +335,10 @@ class ConversationListViewModel @Inject constructor( "Error while blocking user ${blockUserState.userId} ;" + " Error ${result.coreFailure}" ) - HomeSnackbarState.BlockingUserOperationError + HomeSnackBarMessage.BlockingUserOperationError } } - homeSnackBarState.emit(state) + _infoMessage.emit(state) requestInProgress = false } } @@ -392,7 +357,7 @@ class ConversationListViewModel @Inject constructor( "Error while unblocking user $userId ;" + " Error ${result.coreFailure}" ) - homeSnackBarState.emit(HomeSnackbarState.UnblockingUserOperationError) + _infoMessage.emit(HomeSnackBarMessage.UnblockingUserOperationError) } } requestInProgress = false @@ -405,10 +370,10 @@ class ConversationListViewModel @Inject constructor( val response = leaveConversation(leaveGroupState.conversationId) when (response) { is RemoveMemberFromConversationUseCase.Result.Failure -> - homeSnackBarState.emit(HomeSnackbarState.LeaveConversationError) + _infoMessage.emit(HomeSnackBarMessage.LeaveConversationError) RemoveMemberFromConversationUseCase.Result.Success -> { - homeSnackBarState.emit(HomeSnackbarState.LeftConversationSuccess) + _infoMessage.emit(HomeSnackBarMessage.LeftConversationSuccess) } } requestInProgress = false @@ -419,10 +384,10 @@ class ConversationListViewModel @Inject constructor( viewModelScope.launch { requestInProgress = true when (deleteTeamConversation(groupDialogState.conversationId)) { - is Result.Failure.GenericFailure -> homeSnackBarState.emit(HomeSnackbarState.DeleteConversationGroupError) - Result.Failure.NoTeamFailure -> homeSnackBarState.emit(HomeSnackbarState.DeleteConversationGroupError) - Result.Success -> homeSnackBarState.emit( - HomeSnackbarState.DeletedConversationGroupSuccess(groupDialogState.conversationName) + is Result.Failure.GenericFailure -> _infoMessage.emit(HomeSnackBarMessage.DeleteConversationGroupError) + Result.Failure.NoTeamFailure -> _infoMessage.emit(HomeSnackBarMessage.DeleteConversationGroupError) + Result.Success -> _infoMessage.emit( + HomeSnackBarMessage.DeletedConversationGroupSuccess(groupDialogState.conversationName) ) } requestInProgress = false @@ -469,16 +434,16 @@ class ConversationListViewModel @Inject constructor( requestInProgress = false when (result) { is ArchiveStatusUpdateResult.Failure -> { - homeSnackBarState.emit( - HomeSnackbarState.UpdateArchivingStatusError( + _infoMessage.emit( + HomeSnackBarMessage.UpdateArchivingStatusError( isArchiving ) ) } is ArchiveStatusUpdateResult.Success -> { - homeSnackBarState.emit( - HomeSnackbarState.UpdateArchivingStatusSuccess( + _infoMessage.emit( + HomeSnackBarMessage.UpdateArchivingStatusSuccess( isArchiving ) ) @@ -510,9 +475,9 @@ class ConversationListViewModel @Inject constructor( val isGroup = conversationTypeDetail is ConversationTypeDetail.Group if (clearContentResult is ClearConversationContentUseCase.Result.Failure) { - homeSnackBarState.emit(HomeSnackbarState.ClearConversationContentFailure(isGroup)) + _infoMessage.emit(HomeSnackBarMessage.ClearConversationContentFailure(isGroup)) } else { - homeSnackBarState.emit(HomeSnackbarState.ClearConversationContentSuccess(isGroup)) + _infoMessage.emit(HomeSnackBarMessage.ClearConversationContentSuccess(isGroup)) } } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/ConversationRouter.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/ConversationRouter.kt index b8cdf494ab8..cfedcf88d8a 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/ConversationRouter.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/ConversationRouter.kt @@ -19,12 +19,15 @@ package com.wire.android.ui.home.conversationslist import androidx.activity.compose.BackHandler -import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.SheetValue +import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.hilt.navigation.compose.hiltViewModel import com.wire.android.R @@ -32,6 +35,7 @@ import com.wire.android.navigation.NavigationCommand import com.wire.android.navigation.Navigator import com.wire.android.ui.LocalActivity import com.wire.android.ui.calling.getOngoingCallIntent +import com.wire.android.ui.common.bottomsheet.WireModalSheetLayout2 import com.wire.android.ui.common.bottomsheet.conversation.ConversationOptionNavigation import com.wire.android.ui.common.bottomsheet.conversation.ConversationSheetContent import com.wire.android.ui.common.bottomsheet.conversation.rememberConversationSheetState @@ -47,14 +51,11 @@ import com.wire.android.ui.common.visbility.rememberVisibilityState import com.wire.android.ui.destinations.ConversationScreenDestination import com.wire.android.ui.destinations.NewConversationSearchPeopleScreenDestination import com.wire.android.ui.destinations.OtherUserProfileScreenDestination -import com.wire.android.ui.home.HomeSnackbarState import com.wire.android.ui.home.conversations.PermissionPermanentlyDeniedDialogState import com.wire.android.ui.home.conversations.details.dialog.ClearConversationContentDialog import com.wire.android.ui.home.conversations.details.menu.DeleteConversationGroupDialog import com.wire.android.ui.home.conversations.details.menu.LeaveConversationGroupDialog import com.wire.android.ui.home.conversationslist.all.AllConversationScreenContent -import com.wire.android.ui.home.conversationslist.call.CallsScreenContent -import com.wire.android.ui.home.conversationslist.mention.MentionScreenContent import com.wire.android.ui.home.conversationslist.model.ConversationItem import com.wire.android.ui.home.conversationslist.model.ConversationsSource import com.wire.android.ui.home.conversationslist.model.DialogState @@ -62,50 +63,81 @@ import com.wire.android.ui.home.conversationslist.model.GroupDialogState import com.wire.android.ui.home.conversationslist.model.isArchive import com.wire.android.ui.home.conversationslist.search.SearchConversationScreen import com.wire.android.util.permission.PermissionDenialType +import com.wire.android.util.ui.SnackBarMessageHandler import com.wire.kalium.logic.data.id.ConversationId import com.wire.kalium.logic.data.user.UserId -import kotlinx.coroutines.flow.MutableSharedFlow // Since the HomeScreen is responsible for displaying the bottom sheet content, // we create a bridge that passes the content of the BottomSheet // also we expose the lambda which expands the BottomSheet from the HomeScreen +@OptIn(ExperimentalMaterial3Api::class) @Suppress("ComplexMethod") @Composable fun ConversationRouterHomeBridge( navigator: Navigator, conversationItemType: ConversationItemType, - onHomeBottomSheetContentChanged: (@Composable ColumnScope.() -> Unit) -> Unit, - onOpenBottomSheet: () -> Unit, - onCloseBottomSheet: () -> Unit, - onSnackBarStateChanged: (HomeSnackbarState) -> Unit, searchBarState: SearchBarState, - isBottomSheetVisible: () -> Boolean, - conversationsSource: ConversationsSource = ConversationsSource.MAIN + conversationsSource: ConversationsSource = ConversationsSource.MAIN, + conversationListViewModel: ConversationListViewModel = hiltViewModel(), + conversationCallListViewModel: ConversationCallListViewModel = hiltViewModel(), ) { + var currentSheetConversationItem by remember { + mutableStateOf(null) + } + var currentConversationOptionNavigation by remember { + mutableStateOf(ConversationOptionNavigation.Home) + } + + val sheetState = rememberModalBottomSheetState( + skipPartiallyExpanded = true, + confirmValueChange = { true }, + ) + val coroutineScope = rememberCoroutineScope() + val permissionPermanentlyDeniedDialogState = rememberVisibilityState() - val viewModel: ConversationListViewModel = hiltViewModel() - val activity = LocalActivity.current LaunchedEffect(conversationsSource) { - viewModel.updateConversationsSource(conversationsSource) + conversationListViewModel.updateConversationsSource(conversationsSource) + } + + LaunchedEffect(key1 = currentSheetConversationItem) { + if (currentSheetConversationItem != null) { + sheetState.show() + } else { + sheetState.hide() + } + } + + LaunchedEffect(sheetState.currentValue) { + if (sheetState.currentValue == SheetValue.Hidden) { + currentSheetConversationItem = null + } + } + + LaunchedEffect(Unit) { + conversationListViewModel.infoMessage.collect { + currentSheetConversationItem = null + } + } + + LaunchedEffect(Unit) { + conversationListViewModel.closeBottomSheet.collect { + currentSheetConversationItem = null + } } val conversationRouterHomeState = rememberConversationRouterState( initialConversationItemType = conversationItemType, - homeSnackBarState = viewModel.homeSnackBarState, - closeBottomSheetState = viewModel.closeBottomSheet, - requestInProgress = viewModel.requestInProgress, - onSnackBarStateChanged = onSnackBarStateChanged, - onCloseBottomSheet = onCloseBottomSheet, + requestInProgress = conversationListViewModel.requestInProgress ) with(searchBarState) { LaunchedEffect(isSearchActive) { if (isSearchActive) { - viewModel.refreshMissingMetadata() + conversationListViewModel.refreshMissingMetadata() conversationRouterHomeState.openSearch() } else { conversationRouterHomeState.closeSearch() @@ -113,14 +145,14 @@ fun ConversationRouterHomeBridge( } LaunchedEffect(searchQuery) { - viewModel.searchConversation(searchQuery) + conversationListViewModel.searchConversation(searchQuery) } } fun showConfirmationDialogOrUnarchive(): (DialogState) -> Unit { return { dialogState -> if (dialogState.isArchived) { - viewModel.moveConversationToArchive(dialogState) + conversationListViewModel.moveConversationToArchive(dialogState) } else { conversationRouterHomeState.archiveConversationDialogState.show(dialogState) } @@ -128,69 +160,10 @@ fun ConversationRouterHomeBridge( } with(conversationRouterHomeState) { - fun openConversationBottomSheet( - conversationItem: ConversationItem, - conversationOptionNavigation: ConversationOptionNavigation = ConversationOptionNavigation.Home - ) { - onHomeBottomSheetContentChanged { - // if we just use [conversationItem] we won't be able to observe changes in conversation details (e.g. name changing). - // So we need to find ConversationItem in the State by id and use it for BottomSheet content. - val item: ConversationItem? = viewModel.conversationListState.findConversationById(conversationItem.conversationId) - - val conversationState = rememberConversationSheetState( - conversationItem = item ?: conversationItem, - conversationOptionNavigation = conversationOptionNavigation - ) - // if we reopen the BottomSheet of the previous conversation for example: - // when the user swipes down the BottomSheet manually when having mute option open - // we want to reopen it in the "home" section, but ONLY when the user reopens the BottomSheet - // by holding the conversation item, not when the notification icon is pressed, therefore when - // conversationOptionNavigation is equal to ConversationOptionNavigation.MutingNotificationOption - conversationState.conversationId?.let { conversationId -> - if (conversationId == conversationItem.conversationId && - conversationOptionNavigation != ConversationOptionNavigation.MutingNotificationOption - ) { - conversationState.toHome() - } - } - - ConversationSheetContent( - conversationSheetState = conversationState, - onMutingConversationStatusChange = { - viewModel.muteConversation( - conversationId = conversationState.conversationId, - mutedConversationStatus = conversationState.conversationSheetContent!!.mutingConversationState - ) - }, - addConversationToFavourites = viewModel::addConversationToFavourites, - moveConversationToFolder = viewModel::moveConversationToFolder, - updateConversationArchiveStatus = showConfirmationDialogOrUnarchive(), - clearConversationContent = clearContentDialogState::show, - blockUser = blockUserDialogState::show, - unblockUser = unblockUserDialogState::show, - leaveGroup = leaveGroupDialogState::show, - deleteGroup = deleteGroupDialogState::show, - closeBottomSheet = onCloseBottomSheet, - isBottomSheetVisible = isBottomSheetVisible - ) - } - onOpenBottomSheet() - } - val onEditConversationItem: (ConversationItem) -> Unit = remember { - { conversationItem -> - openConversationBottomSheet( - conversationItem = conversationItem - ) - } - } - - val onEditNotifications: (ConversationItem) -> Unit = remember { - { conversationItem -> - openConversationBottomSheet( - conversationItem = conversationItem, - conversationOptionNavigation = ConversationOptionNavigation.MutingNotificationOption - ) + { + currentSheetConversationItem = it + currentConversationOptionNavigation = ConversationOptionNavigation.Home } } @@ -208,7 +181,7 @@ fun ConversationRouterHomeBridge( } } - with(viewModel.conversationListState) { + with(conversationListViewModel.conversationListState) { when (conversationRouterHomeState.conversationItemType) { ConversationItemType.ALL_CONVERSATIONS -> AllConversationScreenContent( @@ -228,26 +201,11 @@ fun ConversationRouterHomeBridge( ) ) } - } - ) - - ConversationItemType.CALLS -> - CallsScreenContent( - missedCalls = missedCalls, - callHistory = callHistory, - onCallItemClick = onOpenConversation, - onEditConversationItem = onEditConversationItem, - onOpenUserProfile = onOpenUserProfile - ) - - ConversationItemType.MENTIONS -> - MentionScreenContent( - unreadMentions = unreadMentions, - allMentions = allMentions, - onMentionItemClick = onOpenConversation, - onEditConversationItem = onEditConversationItem, - onOpenUserProfile = onOpenUserProfile, - openConversationNotificationsSettings = onEditNotifications + }, + conversationListCallState = conversationCallListViewModel.conversationListCallState, + dismissJoinCallAnywayDialog = conversationCallListViewModel::dismissJoinCallAnywayDialog, + joinCallAnyway = conversationCallListViewModel::joinAnyway, + joinOngoingCall = conversationCallListViewModel::joinOngoingCall ) ConversationItemType.SEARCH -> { @@ -258,7 +216,9 @@ fun ConversationRouterHomeBridge( onOpenConversation = onOpenConversation, onEditConversation = onEditConversationItem, onOpenUserProfile = onOpenUserProfile, - onJoinCall = { viewModel.joinOngoingCall(it, onJoinedCall) }, + onJoinCall = { + conversationCallListViewModel.joinOngoingCall(it, onJoinedCall) + }, onPermissionPermanentlyDenied = { } ) } @@ -273,42 +233,81 @@ fun ConversationRouterHomeBridge( BlockUserDialogContent( isLoading = requestInProgress, dialogState = blockUserDialogState, - onBlock = viewModel::blockUser + onBlock = conversationListViewModel::blockUser ) DeleteConversationGroupDialog( isLoading = requestInProgress, dialogState = deleteGroupDialogState, - onDeleteGroup = viewModel::deleteGroup + onDeleteGroup = conversationListViewModel::deleteGroup ) LeaveConversationGroupDialog( dialogState = leaveGroupDialogState, isLoading = requestInProgress, - onLeaveGroup = viewModel::leaveGroup + onLeaveGroup = conversationListViewModel::leaveGroup ) UnblockUserDialogContent( dialogState = unblockUserDialogState, - onUnblock = viewModel::unblockUser, + onUnblock = conversationListViewModel::unblockUser, isLoading = requestInProgress, ) ClearConversationContentDialog( dialogState = clearContentDialogState, isLoading = requestInProgress, - onClearConversationContent = viewModel::clearConversationContent + onClearConversationContent = conversationListViewModel::clearConversationContent ) ArchiveConversationDialog( dialogState = archiveConversationDialogState, - onArchiveButtonClicked = viewModel::moveConversationToArchive + onArchiveButtonClicked = conversationListViewModel::moveConversationToArchive ) + currentSheetConversationItem?.let { + WireModalSheetLayout2( + sheetState = sheetState, + coroutineScope = coroutineScope, + sheetContent = { + val conversationState = rememberConversationSheetState( + conversationItem = it, + conversationOptionNavigation = currentConversationOptionNavigation + ) + + ConversationSheetContent( + conversationSheetState = conversationState, + onMutingConversationStatusChange = { + conversationListViewModel.muteConversation( + conversationId = conversationState.conversationId, + mutedConversationStatus = conversationState.conversationSheetContent!!.mutingConversationState + ) + }, + addConversationToFavourites = conversationListViewModel::addConversationToFavourites, + moveConversationToFolder = conversationListViewModel::moveConversationToFolder, + updateConversationArchiveStatus = showConfirmationDialogOrUnarchive(), + clearConversationContent = clearContentDialogState::show, + blockUser = blockUserDialogState::show, + unblockUser = unblockUserDialogState::show, + leaveGroup = leaveGroupDialogState::show, + deleteGroup = deleteGroupDialogState::show, + closeBottomSheet = { + currentSheetConversationItem = null + } + ) + }, + onCloseBottomSheet = { + currentSheetConversationItem = null + } + ) + } + BackHandler(conversationItemType == ConversationItemType.SEARCH) { closeSearch() } } + + SnackBarMessageHandler(infoMessages = conversationListViewModel.infoMessage) } @Suppress("LongParameterList") @@ -339,10 +338,6 @@ class ConversationRouterState( @Composable fun rememberConversationRouterState( initialConversationItemType: ConversationItemType, - homeSnackBarState: MutableSharedFlow, - onSnackBarStateChanged: (HomeSnackbarState) -> Unit, - closeBottomSheetState: MutableSharedFlow, - onCloseBottomSheet: () -> Unit, requestInProgress: Boolean ): ConversationRouterState { @@ -353,14 +348,6 @@ fun rememberConversationRouterState( val clearContentDialogState = rememberVisibilityState() val archiveConversationDialogState = rememberVisibilityState() - LaunchedEffect(Unit) { - homeSnackBarState.collect { onSnackBarStateChanged(it) } - } - - LaunchedEffect(Unit) { - closeBottomSheetState.collect { onCloseBottomSheet() } - } - val conversationRouterState = remember(initialConversationItemType) { ConversationRouterState( initialConversationItemType, @@ -391,5 +378,5 @@ fun rememberConversationRouterState( } enum class ConversationItemType { - ALL_CONVERSATIONS, CALLS, MENTIONS, SEARCH; + ALL_CONVERSATIONS, SEARCH; } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/all/AllConversationScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/all/AllConversationScreen.kt index a2cf3dccfc4..f0de4e2658f 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/all/AllConversationScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/all/AllConversationScreen.kt @@ -34,8 +34,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.tooling.preview.Preview -import androidx.hilt.navigation.compose.hiltViewModel import com.ramcosta.composedestinations.annotation.Destination import com.wire.android.R import com.wire.android.appLogger @@ -45,14 +43,16 @@ import com.wire.android.ui.common.dimensions import com.wire.android.ui.home.HomeStateHolder import com.wire.android.ui.home.archive.ArchivedConversationsEmptyStateScreen import com.wire.android.ui.home.conversationslist.ConversationItemType -import com.wire.android.ui.home.conversationslist.ConversationListViewModel +import com.wire.android.ui.home.conversationslist.ConversationListCallState import com.wire.android.ui.home.conversationslist.ConversationRouterHomeBridge import com.wire.android.ui.home.conversationslist.common.ConversationList import com.wire.android.ui.home.conversationslist.model.ConversationFolder import com.wire.android.ui.home.conversationslist.model.ConversationItem +import com.wire.android.ui.theme.WireTheme import com.wire.android.ui.theme.wireColorScheme import com.wire.android.ui.theme.wireTypography import com.wire.android.util.permission.PermissionDenialType +import com.wire.android.util.ui.PreviewMultipleThemes import com.wire.kalium.logic.data.id.ConversationId import com.wire.kalium.logic.data.user.UserId import kotlinx.collections.immutable.ImmutableMap @@ -66,12 +66,7 @@ fun AllConversationScreen(homeStateHolder: HomeStateHolder) { ConversationRouterHomeBridge( navigator = navigator, conversationItemType = ConversationItemType.ALL_CONVERSATIONS, - onHomeBottomSheetContentChanged = ::changeBottomSheetContent, - onOpenBottomSheet = ::openBottomSheet, - onCloseBottomSheet = ::closeBottomSheet, - onSnackBarStateChanged = ::setSnackBarState, searchBarState = searchBarState, - isBottomSheetVisible = ::isBottomSheetVisible ) } } @@ -79,23 +74,26 @@ fun AllConversationScreen(homeStateHolder: HomeStateHolder) { @Composable fun AllConversationScreenContent( conversations: ImmutableMap>, + conversationListCallState: ConversationListCallState, hasNoConversations: Boolean, - isFromArchive: Boolean = false, - viewModel: ConversationListViewModel = hiltViewModel(), onEditConversation: (ConversationItem) -> Unit, onOpenConversation: (ConversationId) -> Unit, onOpenUserProfile: (UserId) -> Unit, onJoinedCall: (ConversationId) -> Unit, - onPermissionPermanentlyDenied: (type: PermissionDenialType) -> Unit + onPermissionPermanentlyDenied: (type: PermissionDenialType) -> Unit, + dismissJoinCallAnywayDialog: () -> Unit, + joinCallAnyway: (conversationId: ConversationId, onJoinedCall: (ConversationId) -> Unit) -> Unit, + isFromArchive: Boolean = false, + joinOngoingCall: (conversationId: ConversationId, onJoinedCall: (ConversationId) -> Unit) -> Unit ) { val lazyListState = rememberLazyListState() val callConversationIdToJoin = remember { mutableStateOf(ConversationId("", "")) } - if (viewModel.conversationListCallState.shouldShowJoinAnywayDialog) { + if (conversationListCallState.shouldShowJoinAnywayDialog) { appLogger.i("$TAG showing showJoinAnywayDialog..") JoinAnywayDialog( - onDismiss = viewModel::dismissJoinCallAnywayDialog, - onConfirm = { viewModel.joinAnyway(callConversationIdToJoin.value, onJoinedCall) } + onDismiss = dismissJoinCallAnywayDialog, + onConfirm = { joinCallAnyway(callConversationIdToJoin.value, onJoinedCall) } ) } if (hasNoConversations) { @@ -114,7 +112,7 @@ fun AllConversationScreenContent( onOpenUserProfile = onOpenUserProfile, onJoinCall = { callConversationIdToJoin.value = it - viewModel.joinOngoingCall(it, onJoinedCall) + joinOngoingCall(it, onJoinedCall) }, onPermissionPermanentlyDenied = onPermissionPermanentlyDenied ) @@ -158,9 +156,9 @@ fun ConversationListEmptyStateScreen() { } } -@Preview +@PreviewMultipleThemes @Composable -fun PreviewAllConversationScreen() { +fun PreviewAllConversationScreen() = WireTheme { AllConversationScreenContent( conversations = persistentMapOf(), hasNoConversations = false, @@ -168,13 +166,18 @@ fun PreviewAllConversationScreen() { onOpenConversation = {}, onOpenUserProfile = {}, onJoinedCall = {}, - onPermissionPermanentlyDenied = {} + onPermissionPermanentlyDenied = {}, + conversationListCallState = ConversationListCallState(), + isFromArchive = false, + dismissJoinCallAnywayDialog = {}, + joinCallAnyway = { _, _ -> }, + joinOngoingCall = { _, _ -> } ) } -@Preview +@PreviewMultipleThemes @Composable -fun ConversationListEmptyStateScreenPreview() { +fun ConversationListEmptyStateScreenPreview() = WireTheme { AllConversationScreenContent( conversations = persistentMapOf(), hasNoConversations = true, @@ -182,7 +185,12 @@ fun ConversationListEmptyStateScreenPreview() { onOpenConversation = {}, onOpenUserProfile = {}, onJoinedCall = {}, - onPermissionPermanentlyDenied = {} + onPermissionPermanentlyDenied = {}, + conversationListCallState = ConversationListCallState(), + isFromArchive = false, + dismissJoinCallAnywayDialog = {}, + joinCallAnyway = { _, _ -> }, + joinOngoingCall = { _, _ -> } ) } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/call/CallLabel.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/call/CallLabel.kt deleted file mode 100644 index a26b6f408b7..00000000000 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/call/CallLabel.kt +++ /dev/null @@ -1,59 +0,0 @@ -/* - * 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.call - -import androidx.compose.foundation.Image -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.width -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.res.painterResource -import androidx.compose.ui.unit.dp -import com.wire.android.ui.home.conversationslist.model.CallEvent -import com.wire.android.ui.home.conversationslist.model.CallTime -import com.wire.android.ui.home.conversationslist.model.ConversationLastEvent -import com.wire.android.ui.theme.wireColorScheme -import com.wire.android.ui.theme.wireTypography - -@Composable -fun CallLabel(callInfo: ConversationLastEvent.Call) { - Row(verticalAlignment = Alignment.CenterVertically) { - TimeLabel(callTime = callInfo.callTime) - Spacer(modifier = Modifier.width(6.dp)) - CallEventIcon(callEvent = callInfo.callEvent) - } -} - -@Composable -private fun CallEventIcon(callEvent: CallEvent, modifier: Modifier = Modifier) { - Image( - painter = painterResource(id = callEvent.drawableResourceId), - contentDescription = null, - modifier = modifier - ) -} - -@Composable -private fun TimeLabel(callTime: CallTime) { - Text(text = callTime.toLabel(), style = MaterialTheme.wireTypography.subline01, color = MaterialTheme.wireColorScheme.secondaryText) -} diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/call/CallsScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/call/CallsScreen.kt deleted file mode 100644 index d8b5f051091..00000000000 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/call/CallsScreen.kt +++ /dev/null @@ -1,122 +0,0 @@ -/* - * 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.call - -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.LazyListState -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import com.ramcosta.composedestinations.annotation.Destination -import com.wire.android.R -import com.wire.android.navigation.HomeNavGraph -import com.wire.android.ui.home.HomeStateHolder -import com.wire.android.ui.home.conversationslist.ConversationItemType -import com.wire.android.ui.home.conversationslist.ConversationRouterHomeBridge -import com.wire.android.ui.home.conversationslist.common.ConversationItemFactory -import com.wire.android.ui.home.conversationslist.model.ConversationItem -import com.wire.android.util.extension.folderWithElements -import com.wire.kalium.logic.data.id.ConversationId -import com.wire.kalium.logic.data.user.UserId - -@HomeNavGraph -@Destination -@Composable -fun CallsScreen(homeStateHolder: HomeStateHolder) { - with(homeStateHolder) { - ConversationRouterHomeBridge( - navigator = navigator, - conversationItemType = ConversationItemType.CALLS, - onHomeBottomSheetContentChanged = ::changeBottomSheetContent, - onOpenBottomSheet = ::openBottomSheet, - onCloseBottomSheet = ::closeBottomSheet, - onSnackBarStateChanged = ::setSnackBarState, - searchBarState = searchBarState, - isBottomSheetVisible = ::isBottomSheetVisible - ) - } -} - -@Composable -fun CallsScreenContent( - missedCalls: List = emptyList(), - callHistory: List = emptyList(), - onCallItemClick: (ConversationId) -> Unit, - onEditConversationItem: (ConversationItem) -> Unit, - onOpenUserProfile: (UserId) -> Unit -) { - val lazyListState = rememberLazyListState() - - CallContent( - lazyListState = lazyListState, - missedCalls = missedCalls, - callHistory = callHistory, - onCallItemClick = onCallItemClick, - onEditConversationItem = onEditConversationItem, - onOpenUserProfile = onOpenUserProfile - ) -} - -@Composable -fun CallContent( - lazyListState: LazyListState, - missedCalls: List, - callHistory: List, - onCallItemClick: (ConversationId) -> Unit, - onEditConversationItem: (ConversationItem) -> Unit, - onOpenUserProfile: (UserId) -> Unit -) { - val context = LocalContext.current - LazyColumn( - state = lazyListState, - modifier = Modifier.fillMaxSize() - ) { - folderWithElements( - header = context.getString(R.string.calls_label_missed_calls), - items = missedCalls.associateBy { it.conversationId.toString() } - ) { missedCall -> - ConversationItemFactory( - conversation = missedCall, - openConversation = onCallItemClick, - openMenu = onEditConversationItem, - openUserProfile = onOpenUserProfile, - joinCall = { }, - onPermissionPermanentlyDenied = {}, - searchQuery = "" - ) - } - - folderWithElements( - header = context.getString(R.string.calls_label_calls_history), - items = callHistory.associateBy { it.conversationId.toString() } - ) { callHistory -> - ConversationItemFactory( - conversation = callHistory, - openConversation = onCallItemClick, - openMenu = onEditConversationItem, - openUserProfile = onOpenUserProfile, - joinCall = { }, - onPermissionPermanentlyDenied = {}, - searchQuery = " " - ) - } - } -} diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/mention/MentionScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/mention/MentionScreen.kt deleted file mode 100644 index 3fd0a165d5f..00000000000 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/mention/MentionScreen.kt +++ /dev/null @@ -1,123 +0,0 @@ -/* - * 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.mention - -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.LazyListState -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import com.ramcosta.composedestinations.annotation.Destination -import com.wire.android.R -import com.wire.android.navigation.HomeNavGraph -import com.wire.android.ui.home.HomeStateHolder -import com.wire.android.ui.home.conversationslist.ConversationItemType -import com.wire.android.ui.home.conversationslist.ConversationRouterHomeBridge -import com.wire.android.ui.home.conversationslist.common.ConversationItemFactory -import com.wire.android.ui.home.conversationslist.model.ConversationItem -import com.wire.android.util.extension.folderWithElements -import com.wire.kalium.logic.data.id.ConversationId -import com.wire.kalium.logic.data.user.UserId - -@HomeNavGraph -@Destination -@Composable -fun MentionScreen(homeStateHolder: HomeStateHolder) { - with(homeStateHolder) { - ConversationRouterHomeBridge( - navigator = navigator, - conversationItemType = ConversationItemType.MENTIONS, - onHomeBottomSheetContentChanged = ::changeBottomSheetContent, - onOpenBottomSheet = ::openBottomSheet, - onCloseBottomSheet = ::closeBottomSheet, - onSnackBarStateChanged = ::setSnackBarState, - searchBarState = searchBarState, - isBottomSheetVisible = ::isBottomSheetVisible - ) - } -} - -@Composable -fun MentionScreenContent( - unreadMentions: List = emptyList(), - allMentions: List = emptyList(), - onMentionItemClick: (ConversationId) -> Unit, - onEditConversationItem: (ConversationItem) -> Unit, - onOpenUserProfile: (UserId) -> Unit, - openConversationNotificationsSettings: (ConversationItem) -> Unit -) { - val lazyListState = rememberLazyListState() - - MentionContent( - lazyListState = lazyListState, - unreadMentions = unreadMentions, - allMentions = allMentions, - onMentionItemClick = onMentionItemClick, - onEditConversationItem = onEditConversationItem, - onOpenUserProfile = onOpenUserProfile, - ) -} - -@Composable -private fun MentionContent( - lazyListState: LazyListState, - unreadMentions: List, - allMentions: List, - onMentionItemClick: (ConversationId) -> Unit, - onEditConversationItem: (ConversationItem) -> Unit, - onOpenUserProfile: (UserId) -> Unit, -) { - val context = LocalContext.current - LazyColumn( - state = lazyListState, - modifier = Modifier.fillMaxSize() - ) { - folderWithElements( - header = context.getString(R.string.mention_label_unread_mentions), - items = unreadMentions.associateBy { it.conversationId.toString() } - ) { unreadMention -> - ConversationItemFactory( - conversation = unreadMention, - openConversation = onMentionItemClick, - openMenu = onEditConversationItem, - openUserProfile = onOpenUserProfile, - joinCall = {}, - onPermissionPermanentlyDenied = {}, - searchQuery = "" - ) - } - - folderWithElements( - header = context.getString(R.string.mention_label_all_mentions), - items = allMentions.associateBy { it.conversationId.toString() } - ) { mention -> - ConversationItemFactory( - conversation = mention, - openConversation = onMentionItemClick, - openMenu = onEditConversationItem, - openUserProfile = onOpenUserProfile, - joinCall = {}, - onPermissionPermanentlyDenied = {}, - searchQuery = "" - ) - } - } -} diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/mention/MentionLabel.kt b/app/src/main/kotlin/com/wire/android/util/ui/SnackBarMessageHandler.kt similarity index 53% rename from app/src/main/kotlin/com/wire/android/ui/home/conversationslist/mention/MentionLabel.kt rename to app/src/main/kotlin/com/wire/android/util/ui/SnackBarMessageHandler.kt index e2da889af37..aebd33a4d85 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/mention/MentionLabel.kt +++ b/app/src/main/kotlin/com/wire/android/util/ui/SnackBarMessageHandler.kt @@ -15,24 +15,25 @@ * 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.util.ui -package com.wire.android.ui.home.conversationslist - -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.ui.text.style.TextOverflow -import com.wire.android.ui.home.conversationslist.model.MentionMessage -import com.wire.android.ui.theme.wireColorScheme -import com.wire.android.ui.theme.wireTypography +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.platform.LocalContext +import com.wire.android.model.SnackBarMessage +import com.wire.android.ui.common.snackbar.LocalSnackbarHostState +import kotlinx.coroutines.flow.SharedFlow @Composable -fun MentionLabel(mentionMessage: MentionMessage) { - Text( - text = mentionMessage.toQuote(), - style = MaterialTheme.wireTypography.subline01, - color = MaterialTheme.wireColorScheme.secondaryText, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) +fun SnackBarMessageHandler(infoMessages: SharedFlow) { + val context = LocalContext.current + val snackbarHostState = LocalSnackbarHostState.current + + LaunchedEffect(Unit) { + infoMessages.collect { + snackbarHostState.showSnackbar( + message = it.uiText.asString(context.resources) + ) + } + } } diff --git a/app/src/test/kotlin/com/wire/android/ui/home/conversations/call/ConversationCallViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/conversations/call/ConversationListCallViewModelTest.kt similarity index 79% rename from app/src/test/kotlin/com/wire/android/ui/home/conversations/call/ConversationCallViewModelTest.kt rename to app/src/test/kotlin/com/wire/android/ui/home/conversations/call/ConversationListCallViewModelTest.kt index 1995f2e3596..183101b7bd6 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/conversations/call/ConversationCallViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/conversations/call/ConversationListCallViewModelTest.kt @@ -47,7 +47,7 @@ import org.junit.jupiter.api.extension.ExtendWith @ExtendWith(CoroutineTestExtension::class) @ExtendWith(NavigationTestExtension::class) -class ConversationCallViewModelTest { +class ConversationListCallViewModelTest { @MockK private lateinit var savedStateHandle: SavedStateHandle @@ -85,7 +85,7 @@ class ConversationCallViewModelTest { @MockK lateinit var observeDegradedConversationNotifiedUseCase: ObserveDegradedConversationNotifiedUseCase - private lateinit var conversationCallViewModel: ConversationCallViewModel + private lateinit var conversationListCallViewModel: ConversationListCallViewModel @BeforeEach fun setUp() { @@ -99,7 +99,7 @@ class ConversationCallViewModelTest { coEvery { setUserInformedAboutVerificationUseCase(any()) } returns Unit coEvery { observeDegradedConversationNotifiedUseCase(any()) } returns flowOf(false) - conversationCallViewModel = ConversationCallViewModel( + conversationListCallViewModel = ConversationListCallViewModel( savedStateHandle = savedStateHandle, observeOngoingCalls = observeOngoingCalls, observeEstablishedCalls = observeEstablishedCalls, @@ -116,48 +116,48 @@ class ConversationCallViewModelTest { @Test fun `given join dialog displayed, when user dismiss it, then hide it`() { - conversationCallViewModel.conversationCallViewState = conversationCallViewModel.conversationCallViewState.copy( + conversationListCallViewModel.conversationCallViewState = conversationListCallViewModel.conversationCallViewState.copy( shouldShowJoinAnywayDialog = true ) - conversationCallViewModel.dismissJoinCallAnywayDialog() + conversationListCallViewModel.dismissJoinCallAnywayDialog() - assertEquals(false, conversationCallViewModel.conversationCallViewState.shouldShowJoinAnywayDialog) + assertEquals(false, conversationListCallViewModel.conversationCallViewState.shouldShowJoinAnywayDialog) } @Test fun `given no ongoing call, when user tries to join a call, then invoke answerCall call use case`() { - conversationCallViewModel.conversationCallViewState = - conversationCallViewModel.conversationCallViewState.copy(hasEstablishedCall = false) + conversationListCallViewModel.conversationCallViewState = + conversationListCallViewModel.conversationCallViewState.copy(hasEstablishedCall = false) coEvery { joinCall(conversationId = any()) } returns Unit - conversationCallViewModel.joinOngoingCall(onAnswered) + conversationListCallViewModel.joinOngoingCall(onAnswered) coVerify(exactly = 1) { joinCall(conversationId = any()) } coVerify(exactly = 1) { onAnswered(any()) } - assertEquals(false, conversationCallViewModel.conversationCallViewState.shouldShowJoinAnywayDialog) + assertEquals(false, conversationListCallViewModel.conversationCallViewState.shouldShowJoinAnywayDialog) } @Test fun `given an ongoing call, when user tries to join a call, then show JoinCallAnywayDialog`() { - conversationCallViewModel.conversationCallViewState = - conversationCallViewModel.conversationCallViewState.copy(hasEstablishedCall = true) + conversationListCallViewModel.conversationCallViewState = + conversationListCallViewModel.conversationCallViewState.copy(hasEstablishedCall = true) - conversationCallViewModel.joinOngoingCall(onAnswered) + conversationListCallViewModel.joinOngoingCall(onAnswered) - assertEquals(true, conversationCallViewModel.conversationCallViewState.shouldShowJoinAnywayDialog) + assertEquals(true, conversationListCallViewModel.conversationCallViewState.shouldShowJoinAnywayDialog) coVerify(inverse = true) { joinCall(conversationId = any()) } } @Test fun `given an ongoing call, when user confirms dialog to join a call, then end current call and join the newer one`() { - conversationCallViewModel.conversationCallViewState = - conversationCallViewModel.conversationCallViewState.copy(hasEstablishedCall = true) - conversationCallViewModel.establishedCallConversationId = ConversationId("value", "Domain") + conversationListCallViewModel.conversationCallViewState = + conversationListCallViewModel.conversationCallViewState.copy(hasEstablishedCall = true) + conversationListCallViewModel.establishedCallConversationId = ConversationId("value", "Domain") coEvery { endCall(any()) } returns Unit - conversationCallViewModel.joinAnyway(onAnswered) + conversationListCallViewModel.joinAnyway(onAnswered) coVerify(exactly = 1) { endCall(any()) } } diff --git a/app/src/test/kotlin/com/wire/android/ui/home/conversationslist/ConversationCallListViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/conversationslist/ConversationCallListViewModelTest.kt new file mode 100644 index 00000000000..5213a7360c4 --- /dev/null +++ b/app/src/test/kotlin/com/wire/android/ui/home/conversationslist/ConversationCallListViewModelTest.kt @@ -0,0 +1,149 @@ +/* + * 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/. + */ +@file:Suppress("MaxLineLength", "MaximumLineLength") + +package com.wire.android.ui.home.conversationslist + +import com.wire.android.config.CoroutineTestExtension +import com.wire.android.config.mockUri +import com.wire.android.framework.TestConversationDetails +import com.wire.kalium.logic.data.id.ConversationId +import com.wire.kalium.logic.feature.call.usecase.AnswerCallUseCase +import com.wire.kalium.logic.feature.call.usecase.EndCallUseCase +import com.wire.kalium.logic.feature.call.usecase.ObserveEstablishedCallsUseCase +import com.wire.kalium.logic.feature.conversation.ObserveConversationListDetailsUseCase +import io.mockk.MockKAnnotations +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.impl.annotations.MockK +import io.mockk.verify +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.amshove.kluent.internal.assertEquals +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith + +@OptIn(ExperimentalCoroutinesApi::class) +@ExtendWith(CoroutineTestExtension::class) +class ConversationCallListViewModelTest { + + private var conversationCallListViewModel: ConversationCallListViewModel + + @MockK + lateinit var observeConversationListDetailsUseCase: ObserveConversationListDetailsUseCase + + @MockK + lateinit var joinCall: AnswerCallUseCase + + @MockK + private lateinit var endCall: EndCallUseCase + + @MockK + private lateinit var observeEstablishedCalls: ObserveEstablishedCallsUseCase + + @MockK(relaxed = true) + private lateinit var onJoined: (ConversationId) -> Unit + + private val dispatcher = StandardTestDispatcher() + + init { + MockKAnnotations.init(this, relaxUnitFun = true) + Dispatchers.setMain(dispatcher) + + coEvery { observeEstablishedCalls.invoke() } returns emptyFlow() + coEvery { observeConversationListDetailsUseCase.invoke(false) } returns flowOf( + listOf( + TestConversationDetails.CONNECTION, + TestConversationDetails.CONVERSATION_ONE_ONE, + TestConversationDetails.GROUP + ) + ) + + mockUri() + conversationCallListViewModel = + ConversationCallListViewModel( + answerCall = joinCall, + endCall = endCall, + observeEstablishedCalls = observeEstablishedCalls + ) + } + + @Test + fun `given a conversation id, when joining an ongoing call, then verify that answer call usecase is called`() = runTest { + coEvery { joinCall(any()) } returns Unit + + conversationCallListViewModel.joinOngoingCall(conversationId = conversationId, onJoined = onJoined) + + coVerify(exactly = 1) { joinCall(conversationId = conversationId) } + verify(exactly = 1) { onJoined(conversationId) } + } + + @Test + fun `given join dialog displayed, when user dismiss it, then hide it`() { + conversationCallListViewModel.conversationListCallState = conversationCallListViewModel.conversationListCallState.copy( + shouldShowJoinAnywayDialog = true + ) + + conversationCallListViewModel.dismissJoinCallAnywayDialog() + + assertEquals(false, conversationCallListViewModel.conversationListCallState.shouldShowJoinAnywayDialog) + } + + @Test + fun `given no ongoing call, when user tries to join a call, then invoke answerCall call use case`() { + conversationCallListViewModel.conversationListCallState = conversationCallListViewModel.conversationListCallState.copy(hasEstablishedCall = false) + + coEvery { joinCall(conversationId = any()) } returns Unit + + conversationCallListViewModel.joinOngoingCall(conversationId, onJoined) + + coVerify(exactly = 1) { joinCall(conversationId = any()) } + coVerify(exactly = 1) { onJoined(any()) } + assertEquals(false, conversationCallListViewModel.conversationListCallState.shouldShowJoinAnywayDialog) + } + + @Test + fun `given an ongoing call, when user tries to join a call, then show JoinCallAnywayDialog`() { + conversationCallListViewModel.conversationListCallState = conversationCallListViewModel.conversationListCallState.copy(hasEstablishedCall = true) + + conversationCallListViewModel.joinOngoingCall(conversationId, onJoined) + + assertEquals(true, conversationCallListViewModel.conversationListCallState.shouldShowJoinAnywayDialog) + coVerify(inverse = true) { joinCall(conversationId = any()) } + } + + @Test + fun `given an ongoing call, when user confirms dialog to join a call, then end current call and join the newer one`() { + conversationCallListViewModel.conversationListCallState = conversationCallListViewModel.conversationListCallState.copy(hasEstablishedCall = true) + conversationCallListViewModel.establishedCallConversationId = ConversationId("value", "Domain") + coEvery { endCall(any()) } returns Unit + + conversationCallListViewModel.joinAnyway(conversationId, onJoined) + + coVerify(exactly = 1) { endCall(any()) } + } + + companion object { + private val conversationId = ConversationId("some_id", "some_domain") + } +} 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 a0977037769..60764441b08 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 @@ -20,39 +20,24 @@ package com.wire.android.ui.home.conversationslist import androidx.compose.ui.text.input.TextFieldValue -import app.cash.turbine.test import com.wire.android.config.CoroutineTestExtension import com.wire.android.config.TestDispatcherProvider import com.wire.android.config.mockUri import com.wire.android.framework.TestConversationDetails import com.wire.android.mapper.UserTypeMapper -import com.wire.android.model.UserAvatarData -import com.wire.android.ui.common.bottomsheet.conversation.ConversationTypeDetail import com.wire.android.ui.common.dialogs.BlockUserDialogState -import com.wire.android.ui.home.HomeSnackbarState -import com.wire.android.ui.home.conversations.model.UILastMessageContent -import com.wire.android.ui.home.conversationslist.model.BadgeEventType -import com.wire.android.ui.home.conversationslist.model.BlockingState import com.wire.android.ui.home.conversationslist.model.ConversationFolder -import com.wire.android.ui.home.conversationslist.model.ConversationInfo -import com.wire.android.ui.home.conversationslist.model.ConversationItem import com.wire.android.ui.home.conversationslist.model.ConversationsSource -import com.wire.android.ui.home.conversationslist.model.DialogState -import com.wire.android.ui.home.conversationslist.model.Membership import com.wire.android.util.orDefault import com.wire.android.util.ui.WireSessionImageLoader -import com.wire.kalium.logic.data.conversation.Conversation import com.wire.kalium.logic.data.conversation.MutedConversationStatus import com.wire.kalium.logic.data.id.ConversationId import com.wire.kalium.logic.data.user.UserId -import com.wire.kalium.logic.feature.call.usecase.AnswerCallUseCase -import com.wire.kalium.logic.feature.call.usecase.EndCallUseCase import com.wire.kalium.logic.feature.call.usecase.ObserveEstablishedCallsUseCase import com.wire.kalium.logic.feature.connection.BlockUserResult import com.wire.kalium.logic.feature.connection.BlockUserUseCase import com.wire.kalium.logic.feature.connection.UnblockUserResult import com.wire.kalium.logic.feature.connection.UnblockUserUseCase -import com.wire.kalium.logic.feature.conversation.ArchiveStatusUpdateResult import com.wire.kalium.logic.feature.conversation.ClearConversationContentUseCase import com.wire.kalium.logic.feature.conversation.ConversationUpdateStatusResult import com.wire.kalium.logic.feature.conversation.LeaveConversationUseCase @@ -66,7 +51,6 @@ import io.mockk.MockKAnnotations import io.mockk.coEvery import io.mockk.coVerify import io.mockk.impl.annotations.MockK -import io.mockk.verify import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.emptyFlow @@ -75,7 +59,6 @@ import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain import org.amshove.kluent.internal.assertEquals -import org.amshove.kluent.shouldBeEqualTo import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith @@ -98,9 +81,6 @@ class ConversationListViewModelTest { @MockK lateinit var deleteTeamConversationUseCase: DeleteTeamConversationUseCase - @MockK - lateinit var joinCall: AnswerCallUseCase - @MockK lateinit var blockUser: BlockUserUseCase @@ -113,9 +93,6 @@ class ConversationListViewModelTest { @MockK private lateinit var wireSessionImageLoader: WireSessionImageLoader - @MockK - private lateinit var endCall: EndCallUseCase - @MockK private lateinit var observeEstablishedCalls: ObserveEstablishedCallsUseCase @@ -128,9 +105,6 @@ class ConversationListViewModelTest { @MockK private lateinit var updateConversationArchivedStatus: UpdateConversationArchivedStatusUseCase - @MockK(relaxed = true) - private lateinit var onJoined: (ConversationId) -> Unit - private val dispatcher = StandardTestDispatcher() init { @@ -151,7 +125,6 @@ class ConversationListViewModelTest { ConversationListViewModel( dispatcher = TestDispatcherProvider(), updateConversationMutedStatus = updateConversationMutedStatus, - answerCall = joinCall, observeConversationListDetails = observeConversationListDetailsUseCase, leaveConversation = leaveConversation, deleteTeamConversation = deleteTeamConversationUseCase, @@ -159,7 +132,6 @@ class ConversationListViewModelTest { unblockUserUseCase = unblockUser, clearConversationContentUseCase = clearConversationContent, wireSessionImageLoader = wireSessionImageLoader, - endCall = endCall, observeEstablishedCalls = observeEstablishedCalls, refreshUsersWithoutMetadata = refreshUsersWithoutMetadata, refreshConversationsWithoutMetadata = refreshConversationsWithoutMetadata, @@ -234,16 +206,6 @@ class ConversationListViewModelTest { coVerify(exactly = 1) { updateConversationMutedStatus(conversationId, MutedConversationStatus.AllMuted, any()) } } - @Test - fun `given a conversation id, when joining an ongoing call, then verify that answer call usecase is called`() = runTest { - coEvery { joinCall(any()) } returns Unit - - conversationListViewModel.joinOngoingCall(conversationId = conversationId, onJoined = onJoined) - - coVerify(exactly = 1) { joinCall(conversationId = conversationId) } - verify(exactly = 1) { onJoined(conversationId) } - } - @Test fun `given a valid conversation muting state, when calling block user, then should call BlockUserUseCase`() = runTest { coEvery { blockUser(any()) } returns BlockUserResult.Success @@ -265,130 +227,8 @@ class ConversationListViewModelTest { coVerify(exactly = 1) { unblockUser(userId) } } - @Test - fun `given join dialog displayed, when user dismiss it, then hide it`() { - conversationListViewModel.conversationListCallState = conversationListViewModel.conversationListCallState.copy( - shouldShowJoinAnywayDialog = true - ) - - conversationListViewModel.dismissJoinCallAnywayDialog() - - assertEquals(false, conversationListViewModel.conversationListCallState.shouldShowJoinAnywayDialog) - } - - @Test - fun `given no ongoing call, when user tries to join a call, then invoke answerCall call use case`() { - conversationListViewModel.conversationListCallState = conversationListViewModel.conversationListCallState.copy(hasEstablishedCall = false) - - coEvery { joinCall(conversationId = any()) } returns Unit - - conversationListViewModel.joinOngoingCall(conversationId, onJoined) - - coVerify(exactly = 1) { joinCall(conversationId = any()) } - coVerify(exactly = 1) { onJoined(any()) } - assertEquals(false, conversationListViewModel.conversationListCallState.shouldShowJoinAnywayDialog) - } - - @Test - fun `given an ongoing call, when user tries to join a call, then show JoinCallAnywayDialog`() { - conversationListViewModel.conversationListCallState = conversationListViewModel.conversationListCallState.copy(hasEstablishedCall = true) - - conversationListViewModel.joinOngoingCall(conversationId, onJoined) - - assertEquals(true, conversationListViewModel.conversationListCallState.shouldShowJoinAnywayDialog) - coVerify(inverse = true) { joinCall(conversationId = any()) } - } - - @Test - fun `given an ongoing call, when user confirms dialog to join a call, then end current call and join the newer one`() { - conversationListViewModel.conversationListCallState = conversationListViewModel.conversationListCallState.copy(hasEstablishedCall = true) - conversationListViewModel.establishedCallConversationId = ConversationId("value", "Domain") - coEvery { endCall(any()) } returns Unit - - conversationListViewModel.joinAnyway(conversationId, onJoined) - - coVerify(exactly = 1) { endCall(any()) } - } - - @Test - fun `given a valid conversation state, when archiving it correctly, then the right success message is shown`() = runTest { - val isArchiving = true - val dialogState = DialogState( - conversationItem.conversationId, - conversationItem.conversationInfo.name, - ConversationTypeDetail.Private(null, conversationItem.userId, BlockingState.NOT_BLOCKED), - !isArchiving, - true - ) - val archivingTimestamp = 123456789L - - coEvery { updateConversationArchivedStatus(any(), any(), any(), any()) } returns ArchiveStatusUpdateResult.Success - - conversationListViewModel.homeSnackBarState.test { - conversationListViewModel.moveConversationToArchive(dialogState, archivingTimestamp) - expectMostRecentItem() shouldBeEqualTo HomeSnackbarState.UpdateArchivingStatusSuccess(isArchiving = isArchiving) - } - coVerify(exactly = 1) { - updateConversationArchivedStatus.invoke( - dialogState.conversationId, - !dialogState.isArchived, - onlyLocally = false, - archivingTimestamp - ) - } - } - - @Test - fun `given a valid conversation state, when un-archiving it with an error, then the right failure message is shown`() = runTest { - val isArchiving = false - val dialogState = DialogState( - conversationItem.conversationId, - conversationItem.conversationInfo.name, - ConversationTypeDetail.Private(null, conversationItem.userId, BlockingState.NOT_BLOCKED), - !isArchiving, - isMember = true - ) - val archivingTimestamp = 123456789L - - coEvery { updateConversationArchivedStatus(any(), any(), any(), any()) } returns ArchiveStatusUpdateResult.Failure - - conversationListViewModel.homeSnackBarState.test { - conversationListViewModel.moveConversationToArchive(dialogState, archivingTimestamp) - expectMostRecentItem() shouldBeEqualTo HomeSnackbarState.UpdateArchivingStatusError(isArchiving = isArchiving) - } - coVerify(exactly = 1) { - updateConversationArchivedStatus.invoke( - dialogState.conversationId, - !dialogState.isArchived, - false, - archivingTimestamp, - ) - } - } - companion object { private val conversationId = ConversationId("some_id", "some_domain") private val userId: UserId = UserId("someUser", "some_domain") - - private val testConversations = TestConversationDetails.CONVERSATION_ONE_ONE - - private val conversationItem = ConversationItem.PrivateConversation( - userAvatarData = UserAvatarData(), - conversationInfo = ConversationInfo( - name = "Some dummy name", - membership = Membership.None - ), - conversationId = conversationId, - mutedStatus = MutedConversationStatus.AllAllowed, - isLegalHold = false, - lastMessageContent = UILastMessageContent.None, - badgeEventType = BadgeEventType.None, - userId = userId, - blockingState = BlockingState.CAN_NOT_BE_BLOCKED, - teamId = null, - isArchived = false, - mlsVerificationStatus = Conversation.VerificationStatus.NOT_VERIFIED, - proteusVerificationStatus = Conversation.VerificationStatus.NOT_VERIFIED - ) } } diff --git a/core/ui-common/src/main/kotlin/com/wire/android/ui/common/bottomsheet/WireModalSheetLayout.kt b/core/ui-common/src/main/kotlin/com/wire/android/ui/common/bottomsheet/WireModalSheetLayout.kt index e8de411621f..9861c05327d 100644 --- a/core/ui-common/src/main/kotlin/com/wire/android/ui/common/bottomsheet/WireModalSheetLayout.kt +++ b/core/ui-common/src/main/kotlin/com/wire/android/ui/common/bottomsheet/WireModalSheetLayout.kt @@ -25,6 +25,7 @@ import androidx.compose.foundation.layout.absoluteOffset import androidx.compose.material3.BottomSheetDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.SheetState import androidx.compose.material3.SheetValue import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier @@ -37,6 +38,7 @@ import kotlinx.coroutines.launch @OptIn(ExperimentalMaterial3Api::class) @Composable +@Deprecated("Use WireModalSheetLayout2") fun WireModalSheetLayout( sheetState: WireModalSheetState, coroutineScope: CoroutineScope, @@ -73,23 +75,70 @@ fun WireModalSheetLayout( fun MenuModalSheetLayout( sheetState: WireModalSheetState, coroutineScope: CoroutineScope, - header: MenuModalSheetHeader = MenuModalSheetHeader.Gone, - menuItems: List<@Composable () -> Unit> + menuItems: List<@Composable () -> Unit>, + header: MenuModalSheetHeader = MenuModalSheetHeader.Gone ) { WireModalSheetLayout( sheetState = sheetState, coroutineScope = coroutineScope, - sheetContent = { MenuModalSheetContent(header, menuItems) } + sheetContent = { + MenuModalSheetContent( + menuItems = menuItems, + header = header + ) + } ) } @Composable fun MenuModalSheetContent( - header: MenuModalSheetHeader = MenuModalSheetHeader.Gone, - menuItems: List<@Composable () -> Unit> + menuItems: List<@Composable () -> Unit>, + modifier: Modifier = Modifier, + header: MenuModalSheetHeader = MenuModalSheetHeader.Gone ) { - Column { + Column(modifier = modifier) { ModalSheetHeaderItem(header = header) buildMenuSheetItems(items = menuItems) } } + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun WireModalSheetLayout2( + sheetState: SheetState, + coroutineScope: CoroutineScope, + sheetContent: @Composable ColumnScope.() -> Unit, + modifier: Modifier = Modifier, + sheetShape: Shape = WireBottomSheetDefaults.WireBottomSheetShape, + containerColor: Color = WireBottomSheetDefaults.WireSheetContainerColor, + contentColor: Color = WireBottomSheetDefaults.WireSheetContentColor, + tonalElevation: Dp = WireBottomSheetDefaults.WireSheetTonalElevation, + scrimColor: Color = BottomSheetDefaults.ScrimColor, + dragHandle: @Composable (() -> Unit)? = { WireBottomSheetDefaults.WireDragHandle() }, + onCloseBottomSheet: () -> Unit +) { + ModalBottomSheet( + sheetState = sheetState, + shape = sheetShape, + content = sheetContent, + containerColor = containerColor, + contentColor = contentColor, + scrimColor = scrimColor, + tonalElevation = tonalElevation, + onDismissRequest = { + coroutineScope.launch { + sheetState.hide() + } + }, + dragHandle = dragHandle, + modifier = modifier.absoluteOffset(y = 1.dp) + ) + + BackHandler(enabled = sheetState.isVisible) { + coroutineScope.launch { sheetState.hide() }.invokeOnCompletion { + if (!sheetState.isVisible) { + onCloseBottomSheet() + } + } + } +}