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 ab739bdc84a..495c23514ec 100644 --- a/app/src/main/kotlin/com/wire/android/navigation/HomeDestination.kt +++ b/app/src/main/kotlin/com/wire/android/navigation/HomeDestination.kt @@ -79,6 +79,12 @@ sealed class HomeDestination( direction = WhatsNewScreenDestination ) + data object TeamManagement : HomeDestination( + title = UIText.StringResource(R.string.team_management_screen_title), + icon = R.drawable.ic_team_management, + direction = TeamManagementScreenDestination + ) + data object Cells : HomeDestination( title = UIText.StringResource(R.string.cells_screen_title), icon = R.drawable.ic_files, @@ -100,6 +106,6 @@ sealed class HomeDestination( values().find { it.direction.route.getBaseRoute() == fullRoute.getBaseRoute() } fun values(): Array = - arrayOf(Conversations, Settings, Vault, Archive, Support, WhatsNew, Cells) + arrayOf(Conversations, Settings, Vault, Archive, Support, TeamManagement, WhatsNew, Cells) } } diff --git a/app/src/main/kotlin/com/wire/android/navigation/OtherDestinations.kt b/app/src/main/kotlin/com/wire/android/navigation/OtherDestinations.kt index 1ed00dd158e..d34644513a8 100644 --- a/app/src/main/kotlin/com/wire/android/navigation/OtherDestinations.kt +++ b/app/src/main/kotlin/com/wire/android/navigation/OtherDestinations.kt @@ -33,6 +33,14 @@ import com.wire.android.util.getUrisOfFilesInDirectory import com.wire.android.util.multipleFileSharingIntent import com.wire.android.util.sha256 +/** + * The route is not that relevant as won't be used for navigation, it will be overridden by a direct custom tab launch. + */ +interface ExternalDirectionLess : Direction { + override val route: String + get() = this::class.qualifiedName.orEmpty() +} + interface ExternalUriDirection : Direction { val uri: Uri override val route: String @@ -57,6 +65,8 @@ object SupportScreenDestination : ExternalUriStringResDirection { get() = R.string.url_support } +data object TeamManagementScreenDestination : ExternalDirectionLess + object PrivacyPolicyScreenDestination : ExternalUriStringResDirection { override val uriStringRes: Int get() = R.string.url_privacy_policy diff --git a/app/src/main/kotlin/com/wire/android/ui/home/drawer/HomeDrawer.kt b/app/src/main/kotlin/com/wire/android/ui/home/drawer/HomeDrawer.kt index a88d34ee64f..48f5ebd7f2e 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/drawer/HomeDrawer.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/drawer/HomeDrawer.kt @@ -27,8 +27,12 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.OpenInNew +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -39,13 +43,17 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp import com.wire.android.R +import com.wire.android.navigation.ExternalDirectionLess +import com.wire.android.navigation.ExternalUriDirection +import com.wire.android.navigation.ExternalUriStringResDirection import com.wire.android.navigation.HomeDestination import com.wire.android.ui.common.Logo +import com.wire.android.ui.common.colorsScheme import com.wire.android.ui.common.dimensions import com.wire.android.ui.common.selectableBackground import com.wire.android.ui.common.spacers.HorizontalSpace @@ -81,40 +89,56 @@ fun HomeDrawer( .height(MaterialTheme.wireDimensions.homeDrawerLogoHeight) ) - fun navigateAndCloseDrawer(item: HomeDestination) { - navigateToHomeItem(item) - onCloseDrawer() + val (topItems, bottomItems) = homeDrawerState.items + topItems.forEach { item -> + MapToDrawerItem(navigateToHomeItem, onCloseDrawer, currentRoute, item) } - DrawerItem( - destination = HomeDestination.Conversations, - selected = currentRoute == HomeDestination.Conversations.direction.route, - onItemClick = remember { { navigateAndCloseDrawer(HomeDestination.Conversations) } } - ) + Spacer(modifier = Modifier.weight(1f)) - if (homeDrawerState.showFilesOption) { - DrawerItem( - destination = HomeDestination.Cells, - selected = currentRoute == HomeDestination.Cells.direction.route, - onItemClick = remember { { navigateAndCloseDrawer(HomeDestination.Cells) } } - ) + bottomItems.forEach { item -> + MapToDrawerItem(navigateToHomeItem, onCloseDrawer, currentRoute, item) } + } +} - DrawerItem( - destination = HomeDestination.Archive, - unreadCount = homeDrawerState.unreadArchiveConversationsCount, - selected = currentRoute == HomeDestination.Archive.direction.route, - onItemClick = remember { { navigateAndCloseDrawer(HomeDestination.Archive) } } - ) +@Composable +fun MapToDrawerItem( + navigateToHomeItem: (HomeDestination) -> Unit, + onCloseDrawer: () -> Unit, + currentRoute: String?, + drawerUiItem: DrawerUiItem +) { + val context = LocalContext.current + fun navigateAndCloseDrawer(item: HomeDestination) { + navigateToHomeItem(item) + onCloseDrawer() + } - Spacer(modifier = Modifier.weight(1f)) + with(drawerUiItem) { + when (this) { + is DrawerUiItem.DynamicExternalNavigationItem -> DrawerItem( + destination = destination, + selected = currentRoute == destination.direction.route, + onItemClick = remember { + { + com.wire.android.util.CustomTabsHelper.launchUrl(context, url) + onCloseDrawer() + } + } + ) - val bottomItems = listOf(HomeDestination.WhatsNew, HomeDestination.Settings, HomeDestination.Support) - bottomItems.forEach { item -> - DrawerItem( - destination = item, - selected = currentRoute == item.direction.route, - onItemClick = remember { { navigateAndCloseDrawer(item) } } + is DrawerUiItem.RegularItem -> DrawerItem( + destination = destination, + selected = currentRoute == destination.direction.route, + onItemClick = remember { { navigateAndCloseDrawer(destination) } } + ) + + is DrawerUiItem.UnreadCounterItem -> DrawerItem( + destination = destination, + unreadCount = this.unreadCount.toInt(), + selected = currentRoute == destination.direction.route, + onItemClick = remember { { navigateAndCloseDrawer(destination) } } ) } } @@ -133,10 +157,10 @@ fun DrawerItem( Row( verticalAlignment = Alignment.CenterVertically, modifier = modifier - .padding(bottom = 8.dp) - .clip(RoundedCornerShape(12.dp)) + .padding(bottom = dimensions().spacing8x) + .clip(RoundedCornerShape(dimensions().spacing12x)) .fillMaxWidth() - .height(40.dp) + .height(dimensions().spacing40x) .background(backgroundColor) .selectableBackground(selected, stringResource(R.string.content_description_open_label), onItemClick), ) { @@ -157,6 +181,17 @@ fun DrawerItem( .weight(1F) ) UnreadMessageEventBadge(unreadMessageCount = unreadCount) + with(destination) { + if (direction is ExternalUriDirection || direction is ExternalUriStringResDirection || direction is ExternalDirectionLess) { + HorizontalSpace.x8() + Icon( + imageVector = Icons.AutoMirrored.Filled.OpenInNew, + contentDescription = null, + tint = colorsScheme().secondaryText, + modifier = Modifier.size(dimensions().spacing16x) + ) + } + } HorizontalSpace.x12() } } @@ -186,3 +221,15 @@ fun PreviewUnSelectedArchivedItemWithUnreadCount() { ) } } + +@PreviewMultipleThemes +@Composable +fun PreviewItemWithExternalDestination() { + Box(modifier = Modifier.background(color = MaterialTheme.colorScheme.surface)) { + DrawerItem( + destination = HomeDestination.Support, + selected = false, + onItemClick = {}, + ) + } +} diff --git a/app/src/main/kotlin/com/wire/android/ui/home/drawer/HomeDrawerState.kt b/app/src/main/kotlin/com/wire/android/ui/home/drawer/HomeDrawerState.kt index 56d17e9b60d..60b8c977fd1 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/drawer/HomeDrawerState.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/drawer/HomeDrawerState.kt @@ -19,6 +19,8 @@ package com.wire.android.ui.home.drawer data class HomeDrawerState( - val unreadArchiveConversationsCount: Int, - val showFilesOption: Boolean, + /** + * The items to be displayed in the drawer [Pair] of "top" and "bottom" items. + */ + val items: Pair, List> = emptyList() to emptyList() ) diff --git a/app/src/main/kotlin/com/wire/android/ui/home/drawer/HomeDrawerViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/drawer/HomeDrawerViewModel.kt index d5639686f96..b2ada2a8ced 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/drawer/HomeDrawerViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/drawer/HomeDrawerViewModel.kt @@ -25,8 +25,16 @@ import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.wire.android.datastore.GlobalDataStore +import com.wire.android.navigation.HomeDestination +import com.wire.android.util.EMPTY +import com.wire.kalium.logic.data.user.type.UserType import com.wire.kalium.logic.feature.conversation.ObserveArchivedUnreadConversationsCountUseCase +import com.wire.kalium.logic.feature.server.GetTeamUrlUseCase +import com.wire.kalium.logic.feature.user.ObserveSelfUserUseCase import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import javax.inject.Inject @@ -34,33 +42,83 @@ import javax.inject.Inject @HiltViewModel class HomeDrawerViewModel @Inject constructor( val savedStateHandle: SavedStateHandle, - private val observeArchivedUnreadConversationsCountUseCase: ObserveArchivedUnreadConversationsCountUseCase, + private val observeArchivedUnreadConversationsCount: ObserveArchivedUnreadConversationsCountUseCase, + private val observeSelfUser: ObserveSelfUserUseCase, + private val getTeamUrl: GetTeamUrlUseCase, private val globalDataStore: GlobalDataStore, ) : ViewModel() { - var drawerState by mutableStateOf( - HomeDrawerState( - unreadArchiveConversationsCount = 0, - showFilesOption = false, - ) - ) + var drawerState by mutableStateOf(HomeDrawerState()) private set init { - observeUnreadArchiveConversationsCount() - observeWireCellsFeatureState() + buildDrawerItems() } - private fun observeWireCellsFeatureState() = viewModelScope.launch { - globalDataStore.wireCellsEnabled().collect { - drawerState = drawerState.copy(showFilesOption = it) + private suspend fun observeTeamManagementUrlForUser(): Flow { + return observeSelfUser().map { + when (it.userType) { + UserType.ADMIN, + UserType.OWNER -> { + getTeamUrl() + } + + UserType.INTERNAL, + UserType.EXTERNAL, + UserType.FEDERATED, + UserType.GUEST, + UserType.SERVICE, + UserType.NONE -> { + String.EMPTY + } + } } } - private fun observeUnreadArchiveConversationsCount() { + private fun buildDrawerItems() { viewModelScope.launch { - observeArchivedUnreadConversationsCountUseCase() - .collect { drawerState = drawerState.copy(unreadArchiveConversationsCount = it.toInt()) } + combine( + globalDataStore.wireCellsEnabled(), + observeArchivedUnreadConversationsCount(), + observeTeamManagementUrlForUser() + ) { wireCellsEnabled, unreadArchiveConversationsCount, teamManagementUrl -> + buildList { + add(DrawerUiItem.RegularItem(destination = HomeDestination.Conversations)) + if (wireCellsEnabled) { + add(DrawerUiItem.RegularItem(destination = HomeDestination.Cells)) + } + add( + DrawerUiItem.UnreadCounterItem( + destination = HomeDestination.Archive, + unreadCount = unreadArchiveConversationsCount + ) + ) + } to buildList { + add(DrawerUiItem.RegularItem(destination = HomeDestination.WhatsNew)) + add(DrawerUiItem.RegularItem(destination = HomeDestination.Settings)) + if (teamManagementUrl.isNotBlank()) { + add( + DrawerUiItem.DynamicExternalNavigationItem( + destination = HomeDestination.TeamManagement, + url = teamManagementUrl + ) + ) + } + add(DrawerUiItem.RegularItem(destination = HomeDestination.Support)) + } + }.collect { + drawerState = drawerState.copy(items = it) + } } } } + +/** + * The type of the main navigation item. + * Regular, with counter or with external navigation. + */ +sealed class DrawerUiItem(open val destination: HomeDestination) { + data class RegularItem(override val destination: HomeDestination) : DrawerUiItem(destination) + data class UnreadCounterItem(override val destination: HomeDestination, val unreadCount: Long) : DrawerUiItem(destination) + data class DynamicExternalNavigationItem(override val destination: HomeDestination, val url: String) : DrawerUiItem(destination) +} diff --git a/app/src/main/kotlin/com/wire/android/ui/userprofile/self/SelfUserProfileScreen.kt b/app/src/main/kotlin/com/wire/android/ui/userprofile/self/SelfUserProfileScreen.kt index 03ad9063f5a..be5b5ba7288 100644 --- a/app/src/main/kotlin/com/wire/android/ui/userprofile/self/SelfUserProfileScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/userprofile/self/SelfUserProfileScreen.kt @@ -578,6 +578,7 @@ fun PersonalSelfUserProfileScreenPreview() { fullName = "Some User", userName = "some-user", teamName = null, + teamUrl = "some-url", otherAccounts = listOf( OtherAccount( id = UserId("id1", "domain"), diff --git a/app/src/main/res/drawable/ic_team_management.xml b/app/src/main/res/drawable/ic_team_management.xml new file mode 100644 index 00000000000..db7a8c027af --- /dev/null +++ b/app/src/main/res/drawable/ic_team_management.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3a7e88dc95c..86a9fbebd21 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -318,6 +318,7 @@ Back up & Restore Conversations Search conversations No matches found + Team Management What\'s new 👋 Welcome to Wire\'s New Android App! diff --git a/app/src/test/kotlin/com/wire/android/ui/home/drawer/HomeDrawerViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/drawer/HomeDrawerViewModelTest.kt index 7c7e084c3a2..c4295d29c82 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/drawer/HomeDrawerViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/drawer/HomeDrawerViewModelTest.kt @@ -21,7 +21,11 @@ import androidx.lifecycle.SavedStateHandle import com.wire.android.config.CoroutineTestExtension import com.wire.android.config.NavigationTestExtension import com.wire.android.datastore.GlobalDataStore +import com.wire.android.framework.TestUser +import com.wire.kalium.logic.data.user.type.UserType import com.wire.kalium.logic.feature.conversation.ObserveArchivedUnreadConversationsCountUseCase +import com.wire.kalium.logic.feature.server.GetTeamUrlUseCase +import com.wire.kalium.logic.feature.user.ObserveSelfUserUseCase import io.mockk.MockKAnnotations import io.mockk.coEvery import io.mockk.every @@ -32,7 +36,7 @@ import kotlinx.coroutines.flow.consumeAsFlow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest -import org.amshove.kluent.internal.assertEquals +import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith @@ -54,7 +58,38 @@ class HomeDrawerViewModelTest { advanceUntilIdle() // Then - assertEquals(unreadCount.toInt(), viewModel.drawerState.unreadArchiveConversationsCount) + assertEquals( + unreadCount, + listOf( + viewModel.drawerState.items.first, + viewModel.drawerState.items.second + ).flatten() + .filterIsInstance() + .first().unreadCount + ) + } + + @Test + fun `given userIsAdmin, when starts observing, then set team url`() = runTest { + // Given + val (arrangement, viewModel) = Arrangement() + .withSelfUserType(UserType.ADMIN) + .arrange() + + // When + arrangement.unreadArchivedConversationsCountChannel.send(0L) + advanceUntilIdle() + + // Then + assertEquals( + Arrangement.TEAM_URL, + listOf( + viewModel.drawerState.items.first, + viewModel.drawerState.items.second + ).flatten() + .filterIsInstance() + .first().url + ) } private class Arrangement { @@ -68,18 +103,36 @@ class HomeDrawerViewModelTest { @MockK lateinit var globalDataStore: GlobalDataStore + @MockK + lateinit var observeSelfUserUseCase: ObserveSelfUserUseCase + + @MockK + lateinit var getTeamUrlUseCase: GetTeamUrlUseCase + val unreadArchivedConversationsCountChannel = Channel(capacity = Channel.UNLIMITED) init { MockKAnnotations.init(this, relaxUnitFun = true) coEvery { observeArchivedUnreadConversationsCount() } returns unreadArchivedConversationsCountChannel.consumeAsFlow() every { globalDataStore.wireCellsEnabled() } returns flowOf(false) + withSelfUserType() + coEvery { getTeamUrlUseCase() } returns TEAM_URL + } + + fun withSelfUserType(type: UserType = UserType.INTERNAL) = apply { + coEvery { observeSelfUserUseCase() } returns flowOf(TestUser.SELF_USER.copy(userType = type)) } fun arrange() = this to HomeDrawerViewModel( savedStateHandle = savedStateHandle, - observeArchivedUnreadConversationsCountUseCase = observeArchivedUnreadConversationsCount, - globalDataStore = globalDataStore + observeArchivedUnreadConversationsCount = observeArchivedUnreadConversationsCount, + globalDataStore = globalDataStore, + observeSelfUser = observeSelfUserUseCase, + getTeamUrl = getTeamUrlUseCase ) + + companion object { + const val TEAM_URL = "some-url" + } } }