diff --git a/app/src/main/kotlin/com/wire/android/ui/home/HomeScaffold.kt b/app/src/main/kotlin/com/wire/android/ui/home/HomeScaffold.kt new file mode 100644 index 0000000000..3545300624 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/home/HomeScaffold.kt @@ -0,0 +1,451 @@ +/* + * 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 androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.focusGroup +import androidx.compose.foundation.focusable +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material3.DrawerDefaults +import androidx.compose.material3.DrawerState +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalDrawerSheet +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusProperties +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.isShiftPressed +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.isTraversalGroup +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.traversalIndex +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.min +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LocalLifecycleOwner +import com.ramcosta.composedestinations.DestinationsNavHost +import com.ramcosta.composedestinations.generated.app.destinations.GlobalCellsScreenDestination +import com.ramcosta.composedestinations.generated.app.destinations.HomeScreenDestination +import com.ramcosta.composedestinations.generated.app.navgraphs.HomeGraph +import com.ramcosta.composedestinations.navigation.dependency +import com.ramcosta.composedestinations.navigation.destination +import com.wire.android.di.wireViewModel +import com.wire.android.feature.cells.ui.CellViewModel +import com.wire.android.navigation.HomeDestination +import com.wire.android.navigation.HomeDestination.FabOptions +import com.wire.android.navigation.rememberWireNavHostEngine +import com.wire.android.ui.common.CollapsingTopBarScaffold +import com.wire.android.ui.common.button.FloatingActionButton +import com.wire.android.ui.common.dimensions +import com.wire.android.ui.common.onEscapeOrBackKey +import com.wire.android.ui.common.topappbar.search.SearchTopBar +import com.wire.android.ui.home.drawer.HomeDrawer +import com.wire.android.ui.home.drawer.HomeDrawerState + +@Composable +internal fun HomeDrawerSheet( + currentRoute: String, + homeDrawerState: HomeDrawerState, + focusTrapState: HomeDrawerSheetFocusTrapState, + focusTrapActions: HomeDrawerSheetFocusTrapActions, + firstItemFocusRequester: FocusRequester, + lastItemFocusRequester: FocusRequester, + onNavigateToHomeItem: (HomeDestination) -> Unit, + onCloseDrawer: () -> Unit, +) { + BoxWithConstraints { + val width = min(maxWidth - dimensions().homeDrawerSheetEndPadding, DrawerDefaults.MaximumDrawerWidth) + ModalDrawerSheet( + drawerContainerColor = MaterialTheme.colorScheme.surface, + drawerTonalElevation = 0.dp, + drawerShape = RectangleShape, + modifier = Modifier + .width(width) + .homeDrawerSheetFocusTrap( + state = focusTrapState, + actions = focusTrapActions + ) + ) { + HomeDrawer( + currentRoute = currentRoute, + homeDrawerState = homeDrawerState, + navigateToHomeItem = onNavigateToHomeItem, + onCloseDrawer = onCloseDrawer, + isFocusTrapEnabled = focusTrapState.enabled, + firstItemFocusRequester = firstItemFocusRequester, + lastItemFocusRequester = lastItemFocusRequester + ) + } + } +} + +internal data class HomeScaffoldFocusRequesters( + val search: FocusRequester, + val fab: FocusRequester, +) + +internal data class HomeScaffoldActions( + val onDrawerItemFocusRequested: (isShiftPressed: Boolean) -> Unit, + val onNewConversationClick: () -> Unit, + val onSelfUserClick: () -> Unit, + val onHamburgerMenuClick: () -> Unit, +) + +@OptIn(ExperimentalAnimationApi::class) +@Composable +internal fun HomeScaffold( + homeState: HomeState, + homeStateHolder: HomeStateHolder, + drawerState: DrawerState, + focusRequesters: HomeScaffoldFocusRequesters, + actions: HomeScaffoldActions, +) { + with(homeStateHolder) { + CollapsingTopBarScaffold( + modifier = Modifier.homeScaffoldFocusTrap( + isDrawerOpen = drawerState.isOpen, + onDrawerItemFocusRequested = actions.onDrawerItemFocusRequested + ), + snapOnFling = false, + topBarHeader = { + HomeTopBarHeader( + homeState = homeState, + homeStateHolder = homeStateHolder, + searchFocusRequester = focusRequesters.search, + onSelfUserClick = actions.onSelfUserClick, + onHamburgerMenuClick = actions.onHamburgerMenuClick + ) + }, + topBarCollapsing = { + HomeSearchTopBar( + homeStateHolder = homeStateHolder, + searchFocusRequester = focusRequesters.search, + fabFocusRequester = focusRequesters.fab + ) + }, + collapsingEnabled = !searchBarState.isSearchActive, + contentLazyListState = lazyListStateFor(currentNavigationItem, currentConversationFilter), + content = { + HomeNavHost(homeStateHolder = homeStateHolder) + }, + floatingActionButton = { + HomeScaffoldFloatingActionButton( + homeStateHolder = homeStateHolder, + fabFocusRequester = focusRequesters.fab, + onNewConversationClick = actions.onNewConversationClick + ) + } + ) + } +} + +@OptIn(ExperimentalAnimationApi::class) +@Composable +private fun HomeTopBarHeader( + homeState: HomeState, + homeStateHolder: HomeStateHolder, + searchFocusRequester: FocusRequester, + onSelfUserClick: () -> Unit, + onHamburgerMenuClick: () -> Unit, +) { + with(homeStateHolder) { + AnimatedVisibility( + modifier = Modifier.background(MaterialTheme.colorScheme.background), + visible = !searchBarState.isSearchActive, + enter = fadeIn() + expandVertically(), + exit = shrinkVertically() + fadeOut(), + ) { + HomeTopBar( + title = currentTitle.asString(), + currentConversationFilter = currentConversationFilter, + navigationItem = currentNavigationItem, + userAvatarData = homeState.userAvatarData, + elevation = dimensions().spacing0x, // CollapsingTopBarScaffold manages applied elevation + withLegalHoldIndicator = homeState.shouldDisplayLegalHoldIndicator, + shouldShowCreateTeamUnreadIndicator = homeState.shouldShowCreateTeamUnreadIndicator, + onHamburgerMenuClick = onHamburgerMenuClick, + onNavigateToSelfUserProfile = onSelfUserClick, + onOpenConversationFilter = { + conversationsFilterBottomSheetState.show(Unit) + }, + nextFocusRequester = searchFocusRequester, + ) + } + } +} + +@OptIn(ExperimentalAnimationApi::class) +@Composable +private fun HomeSearchTopBar( + homeStateHolder: HomeStateHolder, + searchFocusRequester: FocusRequester, + fabFocusRequester: FocusRequester, +) { + with(homeStateHolder) { + currentNavigationItem.searchBar?.let { searchBar -> + AnimatedVisibility( + visible = searchBarState.isSearchVisible, + enter = fadeIn() + slideInVertically(), + exit = fadeOut() + slideOutVertically() + ) { + SearchTopBar( + isSearchActive = searchBarState.isSearchActive, + searchBarHint = stringResource(searchBar.hint), + searchQueryTextState = searchBarState.searchQueryTextState, + onCloseSearchClicked = searchBarState::closeSearch, + onActiveChanged = { isFocused -> + if (isFocused) { + searchBarState.openSearch() + } + }, + externalFocusRequester = searchFocusRequester, + nextFocusRequester = when { + searchBarState.isSearchActive -> emptySearchResultFocusRequester + currentNavigationItem.fab != null -> fabFocusRequester + else -> firstConversationFocusRequester + }, + activateSearchOnFocus = false, + ) + } + } + } +} + +@Composable +private fun HomeNavHost(homeStateHolder: HomeStateHolder) { + /** + * This "if" is a workaround, otherwise it can crash because of the SubcomposeLayout's nature. + * We need to communicate to the sub-compositions when they are to be disposed by the parent and ignore + * compositions in the round they are to be disposed. More here: + * https://github.com/google/accompanist/issues/1487 + * https://issuetracker.google.com/issues/268422136 + * https://issuetracker.google.com/issues/254645321 + */ + val lifecycleState by LocalLifecycleOwner.current.lifecycle.currentStateFlow.collectAsState() + if (lifecycleState != Lifecycle.State.DESTROYED) { + val navHostEngine = rememberWireNavHostEngine() + DestinationsNavHost( + navGraph = HomeGraph, + start = HomeGraph.defaultStartDirection, + engine = navHostEngine, + navController = homeStateHolder.navController, + dependenciesContainerBuilder = { + dependency(homeStateHolder) + + // Scope CellViewModel to HomeScreen so SearchScreen can reuse it via previousBackStackEntry. + destination(GlobalCellsScreenDestination) { + val parentEntry = remember(navBackStackEntry) { + homeStateHolder.navigator.navController.getBackStackEntry(HomeScreenDestination.route) + } + dependency(wireViewModel(parentEntry)) + } + } + ) + } +} + +@OptIn(ExperimentalAnimationApi::class) +@Composable +private fun HomeScaffoldFloatingActionButton( + homeStateHolder: HomeStateHolder, + fabFocusRequester: FocusRequester, + onNewConversationClick: () -> Unit, +) { + with(homeStateHolder) { + AnimatedVisibility( + visible = currentNavigationItem.fab != null && !searchBarState.isSearchActive, + enter = scaleIn(), + exit = scaleOut(), + ) { + HomeFloatingActionButton( + currentNavigationItem = currentNavigationItem, + fabFocusRequester = fabFocusRequester, + nextFocusRequester = firstConversationFocusRequester, + onNewConversationClick = onNewConversationClick, + onNewMeetingClick = { newMeetingBottomSheetState.show(Unit) } + ) + } + } +} + +internal data class HomeDrawerSheetFocusTrapState( + val enabled: Boolean, + val focusRequester: FocusRequester, + val isSheetFocused: Boolean, +) + +internal data class HomeDrawerSheetFocusTrapActions( + val onSheetFocusChanged: (Boolean) -> Unit, + val onItemFocusRequested: (isShiftPressed: Boolean) -> Unit, + val onClose: () -> Unit, +) + +internal fun Modifier.homeDrawerKeyboardNavigation( + isDrawerOpen: Boolean, + isDrawerSheetFocused: Boolean, + onDrawerItemFocusRequested: (isShiftPressed: Boolean) -> Unit, + onCloseDrawer: () -> Unit, +): Modifier = onPreviewKeyEvent { event -> + val isKeyDown = event.type == KeyEventType.KeyDown + val isTabKeyDown = isKeyDown && event.key == Key.Tab + val isCloseKeyDown = isKeyDown && (event.key == Key.Escape || event.key == Key.Back) + + when { + isDrawerOpen && isCloseKeyDown -> { + onCloseDrawer() + true + } + + isDrawerOpen && isDrawerSheetFocused && isTabKeyDown -> { + onDrawerItemFocusRequested(event.isShiftPressed) + true + } + + else -> false + } +} + +private fun Modifier.homeScaffoldFocusTrap( + isDrawerOpen: Boolean, + onDrawerItemFocusRequested: (isShiftPressed: Boolean) -> Unit, +): Modifier = focusProperties { + canFocus = !isDrawerOpen +} + .onPreviewKeyEvent { event -> + val isTabKeyDown = event.type == KeyEventType.KeyDown && event.key == Key.Tab + if (isDrawerOpen && isTabKeyDown) { + onDrawerItemFocusRequested(event.isShiftPressed) + true + } else { + false + } + } + .then( + if (isDrawerOpen) { + Modifier.clearAndSetSemantics { } + } else { + Modifier + } + ) + +private fun Modifier.homeDrawerSheetFocusTrap( + state: HomeDrawerSheetFocusTrapState, + actions: HomeDrawerSheetFocusTrapActions, +): Modifier = this + .semantics { + isTraversalGroup = true + traversalIndex = -1f + } + .focusRequester(state.focusRequester) + .focusProperties { + onExit = { + if (state.enabled) { + cancelFocusChange() + } + } + } + .onFocusChanged { + actions.onSheetFocusChanged(it.isFocused) + } + .onPreviewKeyEvent { event -> + val isTabKeyDown = event.type == KeyEventType.KeyDown && event.key == Key.Tab + if (state.enabled && state.isSheetFocused && isTabKeyDown) { + actions.onItemFocusRequested(event.isShiftPressed) + true + } else { + false + } + } + .onEscapeOrBackKey( + enabled = state.enabled, + onKeyPressed = actions.onClose + ) + .focusGroup() + .focusable() + +@Composable +private fun HomeFloatingActionButton( + currentNavigationItem: HomeDestination, + fabFocusRequester: FocusRequester, + nextFocusRequester: FocusRequester, + onNewConversationClick: () -> Unit, + onNewMeetingClick: () -> Unit, +) { + var currentFab by remember { mutableStateOf(currentNavigationItem.fab ?: FabOptions.NewConversation) } + // to keep the fab during the exit animation, we need to keep last known (non-null) fab data + currentNavigationItem.fab?.let { currentFab = it } + + FloatingActionButton( + text = stringResource(currentFab.text), + modifier = Modifier + .focusRequester(fabFocusRequester) + .focusProperties { + next = nextFocusRequester + }, + icon = { + Image( + painter = painterResource(currentFab.icon), + contentDescription = stringResource(currentFab.contentDescription), + contentScale = ContentScale.FillBounds, + colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onPrimary), + modifier = Modifier + .padding(start = dimensions().spacing4x, top = dimensions().spacing2x) + .size(dimensions().fabIconSize) + ) + }, + onClick = { + when (currentNavigationItem.fab) { + FabOptions.NewConversation -> onNewConversationClick() + FabOptions.NewMeeting -> onNewMeetingClick() + else -> { /* no-op */ } + } + } + ) +} 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 8c9d0733ff..d6c30cbd9b 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 @@ -19,88 +19,48 @@ package com.wire.android.ui.home import androidx.activity.compose.BackHandler -import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.ExperimentalAnimationApi -import androidx.compose.animation.expandVertically -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.scaleIn -import androidx.compose.animation.scaleOut -import androidx.compose.animation.shrinkVertically -import androidx.compose.animation.slideInVertically -import androidx.compose.animation.slideOutVertically -import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.BoxWithConstraints -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.material3.DrawerDefaults -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalDrawerSheet import androidx.compose.material3.ModalNavigationDrawer import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue +import androidx.compose.runtime.withFrameNanos import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusProperties -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.graphics.ColorFilter -import androidx.compose.ui.graphics.RectangleShape -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.unit.dp -import androidx.compose.ui.unit.min -import com.wire.android.di.wireViewModel +import androidx.compose.ui.platform.LocalFocusManager import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner -import com.ramcosta.composedestinations.DestinationsNavHost import com.ramcosta.composedestinations.generated.app.destinations.ConversationFoldersScreenDestination import com.ramcosta.composedestinations.generated.app.destinations.ConversationScreenDestination import com.ramcosta.composedestinations.generated.app.destinations.NewConversationSearchPeopleScreenDestination import com.ramcosta.composedestinations.generated.app.destinations.OtherUserProfileScreenDestination import com.ramcosta.composedestinations.generated.app.destinations.SelfUserProfileScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.GlobalCellsScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.HomeScreenDestination -import com.ramcosta.composedestinations.generated.app.navgraphs.HomeGraph -import com.ramcosta.composedestinations.navigation.dependency -import com.ramcosta.composedestinations.navigation.destination -import com.wire.android.feature.cells.ui.CellViewModel import com.ramcosta.composedestinations.result.NavResult import com.ramcosta.composedestinations.result.ResultRecipient import com.wire.android.R import com.wire.android.appLogger +import com.wire.android.di.wireViewModel import com.wire.android.navigation.HomeDestination -import com.wire.android.navigation.HomeDestination.FabOptions import com.wire.android.navigation.NavigationCommand import com.wire.android.navigation.Navigator import com.wire.android.navigation.annotation.app.WireRootDestination import com.wire.android.navigation.handleNavigation -import com.wire.android.navigation.rememberWireNavHostEngine import com.wire.android.ui.analytics.AnalyticsUsageViewModel -import com.wire.android.ui.common.CollapsingTopBarScaffold import com.wire.android.ui.common.HandleActions -import com.wire.android.ui.common.button.FloatingActionButton import com.wire.android.ui.common.dialogs.PermissionPermanentlyDeniedDialog -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.common.visbility.rememberVisibilityState import com.wire.android.ui.home.conversations.PermissionPermanentlyDeniedDialogState import com.wire.android.ui.home.conversations.details.GroupConversationActionType import com.wire.android.ui.home.conversations.details.GroupConversationDetailsNavBackArgs import com.wire.android.ui.home.conversations.folder.ConversationFoldersNavBackArgs -import com.wire.android.ui.home.drawer.HomeDrawer import com.wire.android.ui.home.drawer.HomeDrawerState import com.wire.android.ui.home.drawer.HomeDrawerViewModel import com.wire.android.util.permission.rememberShowNotificationsPermissionFlow @@ -268,10 +228,37 @@ fun HomeContent( modifier: Modifier = Modifier, ) { val context = LocalContext.current + val focusManager = LocalFocusManager.current val searchFocusRequester = remember { FocusRequester() } val fabFocusRequester = remember { FocusRequester() } + val drawerFocusRequester = remember { FocusRequester() } + val firstDrawerItemFocusRequester = remember { FocusRequester() } + val lastDrawerItemFocusRequester = remember { FocusRequester() } + var isDrawerSheetFocused by remember { mutableStateOf(false) } + + LaunchedEffect(homeStateHolder.drawerState.isOpen) { + if (homeStateHolder.drawerState.isOpen) { + withFrameNanos { } + if (!firstDrawerItemFocusRequester.requestFocus()) { + drawerFocusRequester.requestFocus() + } + } + } with(homeStateHolder) { + fun closeHomeDrawer() { + focusManager.clearFocus(force = true) + closeDrawer() + } + + fun requestDrawerItemFocus(isShiftPressed: Boolean) { + if (isShiftPressed) { + lastDrawerItemFocusRequester.requestFocus() + } else { + firstDrawerItemFocusRequester.requestFocus() + } + } + fun openWireHomeDestination(item: HomeDestination) { item.direction.handleNavigation( context = context, @@ -289,174 +276,55 @@ fun HomeContent( ) } + val drawerSheetFocusTrapState = HomeDrawerSheetFocusTrapState( + enabled = drawerState.isOpen, + focusRequester = drawerFocusRequester, + isSheetFocused = isDrawerSheetFocused + ) + val drawerSheetFocusTrapActions = HomeDrawerSheetFocusTrapActions( + onSheetFocusChanged = { isDrawerSheetFocused = it }, + onItemFocusRequested = ::requestDrawerItemFocus, + onClose = ::closeHomeDrawer + ) + ModalNavigationDrawer( - modifier = modifier, + modifier = modifier.homeDrawerKeyboardNavigation( + isDrawerOpen = drawerState.isOpen, + isDrawerSheetFocused = isDrawerSheetFocused, + onDrawerItemFocusRequested = ::requestDrawerItemFocus, + onCloseDrawer = ::closeHomeDrawer + ), drawerState = drawerState, drawerContent = { - BoxWithConstraints { - val width = min(this.maxWidth - dimensions().homeDrawerSheetEndPadding, DrawerDefaults.MaximumDrawerWidth) - ModalDrawerSheet( - drawerContainerColor = MaterialTheme.colorScheme.surface, - drawerTonalElevation = 0.dp, - drawerShape = RectangleShape, - modifier = Modifier.width(width) - ) { - HomeDrawer( - currentRoute = currentNavigationItem.direction.route, - homeDrawerState = homeDrawerState, - navigateToHomeItem = ::openWireHomeDestination, - onCloseDrawer = ::closeDrawer - ) - } - } + HomeDrawerSheet( + currentRoute = currentNavigationItem.direction.route, + homeDrawerState = homeDrawerState, + focusTrapState = drawerSheetFocusTrapState, + focusTrapActions = drawerSheetFocusTrapActions, + firstItemFocusRequester = firstDrawerItemFocusRequester, + lastItemFocusRequester = lastDrawerItemFocusRequester, + onNavigateToHomeItem = ::openWireHomeDestination, + onCloseDrawer = ::closeHomeDrawer + ) }, gesturesEnabled = drawerState.isOpen, content = { - CollapsingTopBarScaffold( - snapOnFling = false, - topBarHeader = { - AnimatedVisibility( - modifier = Modifier.background(MaterialTheme.colorScheme.background), - visible = !searchBarState.isSearchActive, - enter = fadeIn() + expandVertically(), - exit = shrinkVertically() + fadeOut(), - ) { - HomeTopBar( - title = currentTitle.asString(), - currentConversationFilter = currentConversationFilter, - navigationItem = currentNavigationItem, - userAvatarData = homeState.userAvatarData, - elevation = dimensions().spacing0x, // CollapsingTopBarScaffold manages applied elevation - withLegalHoldIndicator = homeState.shouldDisplayLegalHoldIndicator, - shouldShowCreateTeamUnreadIndicator = homeState.shouldShowCreateTeamUnreadIndicator, - onHamburgerMenuClick = ::openDrawer, - onNavigateToSelfUserProfile = onSelfUserClick, - onOpenConversationFilter = { - homeStateHolder.conversationsFilterBottomSheetState.show(Unit) - }, - nextFocusRequester = searchFocusRequester, - ) - } - }, - topBarCollapsing = { - currentNavigationItem.searchBar?.let { searchBar -> - AnimatedVisibility( - visible = searchBarState.isSearchVisible, - enter = fadeIn() + slideInVertically(), - exit = fadeOut() + slideOutVertically() - ) { - SearchTopBar( - isSearchActive = searchBarState.isSearchActive, - searchBarHint = stringResource(searchBar.hint), - searchQueryTextState = searchBarState.searchQueryTextState, - onCloseSearchClicked = searchBarState::closeSearch, - onActiveChanged = { isFocused -> - if (isFocused) { - searchBarState.openSearch() - } - }, - externalFocusRequester = searchFocusRequester, - nextFocusRequester = when { - searchBarState.isSearchActive -> homeStateHolder.emptySearchResultFocusRequester - currentNavigationItem.fab != null -> fabFocusRequester - else -> homeStateHolder.firstConversationFocusRequester - }, - activateSearchOnFocus = false, - ) - } - } - }, - collapsingEnabled = !searchBarState.isSearchActive, - contentLazyListState = homeStateHolder.lazyListStateFor(currentNavigationItem, currentConversationFilter), - content = { - /** - * This "if" is a workaround, otherwise it can crash because of the SubcomposeLayout's nature. - * We need to communicate to the sub-compositions when they are to be disposed by the parent and ignore - * compositions in the round they are to be disposed. More here: - * https://github.com/google/accompanist/issues/1487 - * https://issuetracker.google.com/issues/268422136 - * https://issuetracker.google.com/issues/254645321 - */ - val lifecycleState by LocalLifecycleOwner.current.lifecycle.currentStateFlow.collectAsState() - if (lifecycleState != Lifecycle.State.DESTROYED) { - val navHostEngine = rememberWireNavHostEngine() - DestinationsNavHost( - navGraph = HomeGraph, - start = HomeGraph.defaultStartDirection, - engine = navHostEngine, - navController = navController, - dependenciesContainerBuilder = { - dependency(homeStateHolder) - - // 👇 Scope CellViewModel to HomeScreen so SearchScreen can reuse it via previousBackStackEntry - destination(GlobalCellsScreenDestination) { - val parentEntry = remember(navBackStackEntry) { - homeStateHolder.navigator.navController - .getBackStackEntry(HomeScreenDestination.route) - } - dependency(wireViewModel(parentEntry)) - } - } - ) - } - }, - floatingActionButton = { - AnimatedVisibility( - visible = currentNavigationItem.fab != null && !searchBarState.isSearchActive, - enter = scaleIn(), - exit = scaleOut(), - ) { - HomeFloatingActionButton( - currentNavigationItem = currentNavigationItem, - fabFocusRequester = fabFocusRequester, - nextFocusRequester = homeStateHolder.firstConversationFocusRequester, - onNewConversationClick = onNewConversationClick, - onNewMeetingClick = { homeStateHolder.newMeetingBottomSheetState.show(Unit) } - ) - } - } + HomeScaffold( + homeState = homeState, + homeStateHolder = homeStateHolder, + drawerState = drawerState, + focusRequesters = HomeScaffoldFocusRequesters( + search = searchFocusRequester, + fab = fabFocusRequester + ), + actions = HomeScaffoldActions( + onDrawerItemFocusRequested = ::requestDrawerItemFocus, + onNewConversationClick = onNewConversationClick, + onSelfUserClick = onSelfUserClick, + onHamburgerMenuClick = ::openDrawer + ) ) } ) } } - -@Composable -private fun HomeFloatingActionButton( - currentNavigationItem: HomeDestination, - fabFocusRequester: FocusRequester, - nextFocusRequester: FocusRequester, - onNewConversationClick: () -> Unit, - onNewMeetingClick: () -> Unit, -) { - var currentFab by remember { mutableStateOf(currentNavigationItem.fab ?: FabOptions.NewConversation) } - // to keep the fab during the exit animation, we need to keep last known (non-null) fab data - currentNavigationItem.fab?.let { currentFab = it } - - FloatingActionButton( - text = stringResource(currentFab.text), - modifier = Modifier - .focusRequester(fabFocusRequester) - .focusProperties { - next = nextFocusRequester - }, - icon = { - Image( - painter = painterResource(currentFab.icon), - contentDescription = stringResource(currentFab.contentDescription), - contentScale = ContentScale.FillBounds, - colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onPrimary), - modifier = Modifier - .padding(start = dimensions().spacing4x, top = dimensions().spacing2x) - .size(dimensions().fabIconSize) - ) - }, - onClick = { - when (currentNavigationItem.fab) { - FabOptions.NewConversation -> onNewConversationClick() - FabOptions.NewMeeting -> onNewMeetingClick() - else -> { /* no-op */ } - } - } - ) -} 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 86d65a46f8..b3bde75b13 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 @@ -38,23 +38,29 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusProperties +import androidx.compose.ui.focus.focusRequester 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.semantics.selected +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextAlign import com.wire.android.R import com.wire.android.ui.common.R as commonR +import com.wire.android.model.Clickable 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.clickable 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 import com.wire.android.ui.home.conversationslist.common.UnreadMessageEventBadge import com.wire.android.ui.theme.WireTheme @@ -68,7 +74,10 @@ fun HomeDrawer( currentRoute: String?, navigateToHomeItem: (HomeDestination) -> Unit, onCloseDrawer: () -> Unit, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + isFocusTrapEnabled: Boolean = false, + firstItemFocusRequester: FocusRequester? = null, + lastItemFocusRequester: FocusRequester? = null, ) { Column( modifier = modifier @@ -90,14 +99,39 @@ fun HomeDrawer( ) val (topItems, bottomItems) = homeDrawerState.items - topItems.forEach { item -> - MapToDrawerItem(navigateToHomeItem, onCloseDrawer, currentRoute, item) + val allItems = topItems + bottomItems + val focusRequesters = remember(allItems.size, firstItemFocusRequester, lastItemFocusRequester) { + List(allItems.size) { index -> + when (index) { + 0 -> firstItemFocusRequester ?: FocusRequester() + allItems.lastIndex -> lastItemFocusRequester ?: FocusRequester() + else -> FocusRequester() + } + } + } + + topItems.forEachIndexed { index, item -> + MapToDrawerItem( + navigateToHomeItem = navigateToHomeItem, + onCloseDrawer = onCloseDrawer, + currentRoute = currentRoute, + drawerUiItem = item, + modifier = drawerItemFocusModifier(index, focusRequesters, isFocusTrapEnabled), + enabled = isFocusTrapEnabled + ) } Spacer(modifier = Modifier.weight(1f)) - bottomItems.forEach { item -> - MapToDrawerItem(navigateToHomeItem, onCloseDrawer, currentRoute, item) + bottomItems.forEachIndexed { index, item -> + MapToDrawerItem( + navigateToHomeItem = navigateToHomeItem, + onCloseDrawer = onCloseDrawer, + currentRoute = currentRoute, + drawerUiItem = item, + modifier = drawerItemFocusModifier(topItems.size + index, focusRequesters, isFocusTrapEnabled), + enabled = isFocusTrapEnabled + ) } } } @@ -107,7 +141,9 @@ fun MapToDrawerItem( navigateToHomeItem: (HomeDestination) -> Unit, onCloseDrawer: () -> Unit, currentRoute: String?, - drawerUiItem: DrawerUiItem + drawerUiItem: DrawerUiItem, + modifier: Modifier = Modifier, + enabled: Boolean = true, ) { val context = LocalContext.current fun navigateAndCloseDrawer(item: HomeDestination) { @@ -120,7 +156,9 @@ fun MapToDrawerItem( is DrawerUiItem.DynamicExternalNavigationItem -> DrawerItem( destination = destination, selected = currentRoute == destination.direction.route, - onItemClick = remember { + enabled = enabled, + modifier = modifier, + onItemClick = remember(url, onCloseDrawer) { { com.wire.android.util.CustomTabsHelper.launchUrl(context, url) onCloseDrawer() @@ -131,29 +169,79 @@ fun MapToDrawerItem( is DrawerUiItem.RegularItem -> DrawerItem( destination = destination, selected = currentRoute == destination.direction.route, - onItemClick = remember { { navigateAndCloseDrawer(destination) } } + enabled = enabled, + modifier = modifier, + onItemClick = remember(destination, navigateToHomeItem, onCloseDrawer) { + { + navigateAndCloseDrawer(destination) + } + } ) is DrawerUiItem.UnreadCounterItem -> DrawerItem( destination = destination, unreadCount = this.unreadCount.toInt(), selected = currentRoute == destination.direction.route, - onItemClick = remember { { navigateAndCloseDrawer(destination) } } + enabled = enabled, + modifier = modifier, + onItemClick = remember(destination, navigateToHomeItem, onCloseDrawer) { + { + navigateAndCloseDrawer(destination) + } + } ) } } } +private fun drawerItemFocusModifier( + index: Int, + focusRequesters: List, + isFocusTrapEnabled: Boolean, +): Modifier { + return if (focusRequesters.isNotEmpty() && isFocusTrapEnabled) { + val previousIndex = if (index == 0) focusRequesters.lastIndex else index - 1 + val nextIndex = if (index == focusRequesters.lastIndex) 0 else index + 1 + Modifier + .focusRequester(focusRequesters[index]) + .focusProperties { + previous = focusRequesters[previousIndex] + next = focusRequesters[nextIndex] + } + } else { + Modifier + } +} + @Composable fun DrawerItem( destination: HomeDestination, selected: Boolean, onItemClick: () -> Unit, modifier: Modifier = Modifier, + enabled: Boolean = true, unreadCount: Int = 0, ) { val backgroundColor = if (selected) MaterialTheme.colorScheme.primary else Color.Transparent val contentColor = if (selected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onBackground + val clickableModifier = if (enabled) { + Modifier.clickable( + Clickable( + onClickDescription = stringResource(R.string.content_description_open_label), + onClick = onItemClick + ) + ) + } else { + Modifier + } + val selectionModifier = if (enabled && selected) { + Modifier.semantics { + this.selected = true + } + } else { + Modifier + } + Row( verticalAlignment = Alignment.CenterVertically, modifier = modifier @@ -162,7 +250,8 @@ fun DrawerItem( .fillMaxWidth() .height(dimensions().spacing40x) .background(backgroundColor) - .selectableBackground(selected, stringResource(R.string.content_description_open_label), onItemClick), + .then(clickableModifier) + .then(selectionModifier), ) { Box( contentAlignment = Alignment.CenterStart,