From 6b9b24716abc28c88563c2bdcd845a092fd57ae8 Mon Sep 17 00:00:00 2001 From: raystatic Date: Wed, 21 Oct 2026 05:43:02 +0530 Subject: [PATCH 1/4] Show the chat list next to the conversation on wide windows Use Navigation 3's Material ListDetailSceneStrategy to display the chat list as the list pane, conversations as the detail pane, and profiles as the extra pane on wide windows, while keeping the existing ModalNavigationDrawer flow on single-pane phones. --- Jetchat/README.md | 2 +- Jetchat/app/build.gradle.kts | 3 + .../example/compose/jetchat/NavigationTest.kt | 29 +++- .../com/example/compose/jetchat/JetchatApp.kt | 145 ++++++++++++++++-- .../jetchat/components/JetchatAppBar.kt | 18 ++- .../jetchat/components/JetchatScaffold.kt | 2 + .../jetchat/conversation/Conversation.kt | 53 ++++--- .../conversation/ConversationViewModel.kt | 19 ++- .../jetchat/navigation/JetchatNavKeys.kt | 28 ++-- Jetchat/app/src/main/res/values/strings.xml | 2 + Jetchat/gradle/libs.versions.toml | 1 + 11 files changed, 233 insertions(+), 69 deletions(-) diff --git a/Jetchat/README.md b/Jetchat/README.md index 52cb68a6b5..6ce5aeee93 100644 --- a/Jetchat/README.md +++ b/Jetchat/README.md @@ -39,7 +39,7 @@ Jetchat is still in under development, and some features are not yet implemented The [ConversationContent](app/src/main/java/com/example/compose/jetchat/conversation/Conversation.kt) composable is the entry point to this screen and takes a [ConversationUiState](app/src/main/java/com/example/compose/jetchat/conversation/ConversationUiState.kt) that defines the data to be displayed. This doesn't mean all the state is served from a single point: composables can have their own state too. For an example, see `scrollState` in [ConversationContent](app/src/main/java/com/example/compose/jetchat/conversation/Conversation.kt) or `currentInputSelector` in [UserInput](app/src/main/java/com/example/compose/jetchat/conversation/UserInput.kt) ### Architecture Components -[JetchatApp](app/src/main/java/com/example/compose/jetchat/JetchatApp.kt) hosts the screens in a [Navigation 3](https://developer.android.com/guide/navigation/navigation-3) `NavDisplay`. The destinations are the typed keys defined in [JetchatNavKeys](app/src/main/java/com/example/compose/jetchat/navigation/JetchatNavKeys.kt): opening a profile pushes a `Profile(userId)` key onto the back stack, which is created with `rememberNavBackStack` so that it survives configuration changes and process death. The back stack is the single source of truth for navigation: the drawer derives its selected item from the key on top of it. The profile entry passes the `userId` of its key to a +[JetchatApp](app/src/main/java/com/example/compose/jetchat/JetchatApp.kt) hosts the screens in a [Navigation 3](https://developer.android.com/guide/navigation/navigation-3) `NavDisplay`. It uses the Material `ListDetailSceneStrategy` for an adaptive list-detail layout: on wide windows, such as tablets and unfolded foldables in landscape, the chat list is shown next to the conversation, and opening a profile shows it in an extra pane. On narrower windows, one pane is shown at a time. The destinations are the typed keys defined in [JetchatNavKeys](app/src/main/java/com/example/compose/jetchat/navigation/JetchatNavKeys.kt): opening a profile pushes a `Profile(userId)` key onto the back stack, which is created with `rememberNavBackStack` so that it survives configuration changes and process death. The back stack is the single source of truth for navigation: the drawer and chat list derive their selected item from the key on top of it. The profile entry passes the `userId` of its key to a [ViewModel](https://developer.android.com/topic/libraries/architecture/viewmodel) that is scoped to that entry with `rememberViewModelStoreNavEntryDecorator`, and observes its state served via [LiveData](https://developer.android.com/topic/libraries/architecture/livedata). ### Back button handling diff --git a/Jetchat/app/build.gradle.kts b/Jetchat/app/build.gradle.kts index 305dab0f32..e5eb085ccf 100644 --- a/Jetchat/app/build.gradle.kts +++ b/Jetchat/app/build.gradle.kts @@ -111,6 +111,9 @@ dependencies { implementation(libs.androidx.compose.foundation.layout) implementation(libs.androidx.compose.material3) + implementation(libs.androidx.compose.material3.adaptive) + implementation(libs.androidx.compose.material3.adaptive.layout) + implementation(libs.androidx.compose.material3.adaptive.navigation3) implementation(libs.androidx.compose.ui.tooling.preview) debugImplementation(libs.androidx.compose.ui.tooling) implementation(libs.androidx.compose.ui.util) diff --git a/Jetchat/app/src/androidTest/java/com/example/compose/jetchat/NavigationTest.kt b/Jetchat/app/src/androidTest/java/com/example/compose/jetchat/NavigationTest.kt index 2638b8df54..89882999ec 100644 --- a/Jetchat/app/src/androidTest/java/com/example/compose/jetchat/NavigationTest.kt +++ b/Jetchat/app/src/androidTest/java/com/example/compose/jetchat/NavigationTest.kt @@ -62,8 +62,11 @@ class NavigationTest { // Press back Espresso.pressBack() - // Check that we're home + // Check that we're home and the profile was popped assertConversationIsDisplayed() + composeTestRule + .onNodeWithText(composeTestRule.activity.getString(R.string.display_name)) + .assertDoesNotExist() } /** @@ -89,18 +92,28 @@ class NavigationTest { } private fun assertConversationIsDisplayed() { - // The channel name is shown in the conversation's app bar (and in the drawer, which is excluded) + // The channel name is shown in the conversation's app bar (and in the drawer/list pane, + // which is excluded) composeTestRule.onNode(hasText("composers") and !isInDrawer()).assertIsDisplayed() } private fun navigateToProfile(name: String) { - composeTestRule.onNodeWithContentDescription( - composeTestRule.activity.getString(R.string.navigation_drawer_open), - ).performClick() + if (!isDrawerDisplayed()) { + composeTestRule.onNodeWithContentDescription( + composeTestRule.activity.getString(R.string.navigation_drawer_open), + ).performClick() + } composeTestRule.onNode(hasText(name) and isInDrawer()).performClick() } + private fun isDrawerDisplayed(): Boolean = try { + composeTestRule.onNode(hasText("Recent Profiles") and isInDrawer()).assertIsDisplayed() + true + } catch (e: AssertionError) { + false + } + private fun isInDrawer() = hasAnyAncestor(isDrawer()) private fun isDrawer() = SemanticsMatcher.expectValue( @@ -109,8 +122,10 @@ class NavigationTest { ) private fun navigateToHomeFromProfile() { - composeTestRule.onAllNodes(isRoot()).onFirst().performTouchInput { - swipeRight() + if (!isDrawerDisplayed()) { + composeTestRule.onAllNodes(isRoot()).onFirst().performTouchInput { + swipeRight() + } } composeTestRule.onNode(hasText("composers") and isInDrawer()).performClick() } diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/JetchatApp.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/JetchatApp.kt index d6abd6e364..06af969d83 100644 --- a/Jetchat/app/src/main/java/com/example/compose/jetchat/JetchatApp.kt +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/JetchatApp.kt @@ -16,21 +16,45 @@ package com.example.compose.jetchat +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding import androidx.compose.material3.DrawerValue.Closed +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi +import androidx.compose.material3.adaptive.currentWindowAdaptiveInfoV2 +import androidx.compose.material3.adaptive.layout.calculatePaneScaffoldDirective +import androidx.compose.material3.adaptive.navigation3.ListDetailSceneStrategy +import androidx.compose.material3.adaptive.navigation3.LocalListDetailSceneScope +import androidx.compose.material3.adaptive.navigation3.rememberListDetailSceneStrategy import androidx.compose.material3.rememberDrawerState import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.paneTitle +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator +import androidx.navigation3.runtime.NavKey import androidx.navigation3.runtime.entryProvider import androidx.navigation3.runtime.rememberNavBackStack import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator import androidx.navigation3.ui.NavDisplay import com.example.compose.jetchat.components.JetchatDrawer +import com.example.compose.jetchat.components.JetchatDrawerContent import com.example.compose.jetchat.conversation.ConversationContent import com.example.compose.jetchat.conversation.ConversationViewModel +import com.example.compose.jetchat.navigation.ChatList import com.example.compose.jetchat.navigation.Conversation import com.example.compose.jetchat.navigation.Profile import com.example.compose.jetchat.navigation.openConversation @@ -46,30 +70,67 @@ import kotlinx.coroutines.launch * The back stack is the single source of truth for navigation. The drawer derives its selection * from it and sends navigation events back to it, following unidirectional data flow. */ +@OptIn(ExperimentalMaterial3AdaptiveApi::class) @Composable fun JetchatApp() { - // The back stack survives configuration changes and process death - val backStack = rememberNavBackStack(Conversation("composers")) + // The back stack survives configuration changes and process death. On wide screens, + // ChatList and Conversation are shown side by side via ListDetailSceneStrategy; on + // single-pane phones, the existing ModalNavigationDrawer flow is used. + val backStack = rememberNavBackStack(ChatList, Conversation("composers")) val drawerState = rememberDrawerState(initialValue = Closed) val scope = rememberCoroutineScope() + // Override the defaults so that there isn't a horizontal space between the panes. + // See b/418201867 + val windowAdaptiveInfo = currentWindowAdaptiveInfoV2() + val directive = remember(windowAdaptiveInfo) { + calculatePaneScaffoldDirective(windowAdaptiveInfo) + .copy(horizontalPartitionSpacerSize = 0.dp) + } + val isMultiPane = directive.maxHorizontalPartitions > 1 + val isListAndDetailVisible = isMultiPane && backStack.lastOrNull() is Conversation + val listDetailStrategy = rememberListDetailSceneStrategy(directive = directive) + + LaunchedEffect(isListAndDetailVisible) { + if (isListAndDetailVisible && drawerState.isOpen) { + drawerState.close() + } + } + + val selectedKey = backStack.lastOrNull() ?: Conversation("composers") + val onNavigate: (NavKey) -> Unit = { key -> + when (key) { + is Conversation -> backStack.openConversation(key.channel) + is Profile -> backStack.openProfile(key.userId) + } + scope.launch { + drawerState.close() + } + } + + val displayBackStack = if (isMultiPane) { + backStack + } else { + backStack.filter { it !is ChatList } + } + JetchatDrawer( // Derived from the back stack, so it can't get out of sync with the displayed screen - selectedKey = backStack.last(), - onNavigate = { key -> - when (key) { - is Conversation -> backStack.openConversation(key.channel) - is Profile -> backStack.openProfile(key.userId) - } + selectedKey = selectedKey, + onNavigate = onNavigate, + drawerState = drawerState, + gesturesEnabled = !isListAndDetailVisible, + ) { + BackHandler(enabled = drawerState.isOpen) { scope.launch { drawerState.close() } - }, - drawerState = drawerState, - ) { + } + NavDisplay( - backStack = backStack, + backStack = displayBackStack, onBack = { backStack.removeLastOrNull() }, + sceneStrategies = listOf(listDetailStrategy), // Scope the ViewModels to their entry. The ViewModelStoreNavEntryDecorator // requires the default SaveableStateHolderNavEntryDecorator, which saves the // state of each entry. @@ -78,22 +139,54 @@ fun JetchatApp() { rememberViewModelStoreNavEntryDecorator(), ), entryProvider = entryProvider { - entry { key -> + entry( + metadata = ListDetailSceneStrategy.listPane( + detailPlaceholder = { ChatPlaceholder() }, + ), + ) { + val navigationMenuTitle = stringResource( + androidx.compose.ui.R.string.navigation_menu, + ) + Surface( + modifier = Modifier + .fillMaxSize() + .semantics { paneTitle = navigationMenuTitle }, + color = MaterialTheme.colorScheme.background, + contentColor = MaterialTheme.colorScheme.onBackground, + ) { + JetchatDrawerContent( + selectedKey = selectedKey, + onNavigate = onNavigate, + ) + } + } + entry( + metadata = ListDetailSceneStrategy.detailPane(), + ) { key -> // The channel comes from the key, so pass it when the ViewModel of this // entry is created val viewModel = viewModel { ConversationViewModel(channel = key.channel) } + // Detail screens hide the navigation drawer button when in the + // multi-pane list-detail scene + val inListDetailScene = LocalListDetailSceneScope.current != null ConversationContent( uiState = viewModel.uiState, navigateToProfile = { userId -> backStack.openProfile(userId) }, - onNavIconPressed = { - scope.launch { - drawerState.open() + onNavIconPressed = if (inListDetailScene) { + null + } else { + { + scope.launch { + drawerState.open() + } } }, onMessageLikeToggled = viewModel::toggleLike, ) } - entry { key -> + entry( + metadata = ListDetailSceneStrategy.extraPane(), + ) { key -> // The user id comes from the key, so set it when the ViewModel of this // entry is created val viewModel = viewModel { @@ -113,3 +206,21 @@ fun JetchatApp() { ) } } + +/** Shown in the detail pane when no conversation is selected. */ +@Composable +private fun ChatPlaceholder() { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.surfaceContainer, + ) { + Box(contentAlignment = Alignment.Center) { + Text( + text = stringResource(R.string.select_a_chat), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(24.dp), + ) + } + } +} diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/components/JetchatAppBar.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/components/JetchatAppBar.kt index 15d8f36d2d..793b1bc9ca 100644 --- a/Jetchat/app/src/main/java/com/example/compose/jetchat/components/JetchatAppBar.kt +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/components/JetchatAppBar.kt @@ -41,17 +41,19 @@ import com.example.compose.jetchat.theme.JetchatTheme fun JetchatAppBar( modifier: Modifier = Modifier, scrollBehavior: TopAppBarScrollBehavior? = null, - onNavIconPressed: () -> Unit = { }, + onNavIconPressed: (() -> Unit)? = { }, title: @Composable () -> Unit, actions: @Composable RowScope.() -> Unit = {}, navigationIcon: @Composable () -> Unit = { - JetchatIcon( - contentDescription = stringResource(id = R.string.navigation_drawer_open), - modifier = Modifier - .size(64.dp) - .clickable(onClick = onNavIconPressed) - .padding(16.dp), - ) + if (onNavIconPressed != null) { + JetchatIcon( + contentDescription = stringResource(id = R.string.navigation_drawer_open), + modifier = Modifier + .size(64.dp) + .clickable(onClick = onNavIconPressed) + .padding(16.dp), + ) + } }, ) { TopAppBar( diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/components/JetchatScaffold.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/components/JetchatScaffold.kt index be59da0f8e..73fb838e6f 100644 --- a/Jetchat/app/src/main/java/com/example/compose/jetchat/components/JetchatScaffold.kt +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/components/JetchatScaffold.kt @@ -37,11 +37,13 @@ fun JetchatDrawer( selectedKey: NavKey, onNavigate: (NavKey) -> Unit, drawerState: DrawerState = rememberDrawerState(initialValue = Closed), + gesturesEnabled: Boolean = true, content: @Composable () -> Unit, ) { JetchatTheme { ModalNavigationDrawer( drawerState = drawerState, + gesturesEnabled = gesturesEnabled, drawerContent = { ModalDrawerSheet( drawerState = drawerState, diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/Conversation.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/Conversation.kt index 333aa8ec97..608e08c937 100644 --- a/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/Conversation.kt +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/Conversation.kt @@ -140,7 +140,8 @@ import kotlinx.coroutines.launch * @param uiState [ConversationUiState] that contains messages to display * @param navigateToProfile User action when navigation to a profile is requested * @param modifier [Modifier] to apply to this layout node - * @param onNavIconPressed Sends an event up when the user clicks on the menu + * @param onNavIconPressed Sends an event up when the user clicks on the menu, which is only shown + * when this is not null * @param onMessageLikeToggled Sends an event up when the user double taps a message to like it */ @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @@ -149,7 +150,7 @@ fun ConversationContent( uiState: ConversationUiState, navigateToProfile: (String) -> Unit, modifier: Modifier = Modifier, - onNavIconPressed: () -> Unit = { }, + onNavIconPressed: (() -> Unit)? = { }, onMessageLikeToggled: (messageId: String) -> Unit = { }, ) { val authorMe = stringResource(R.string.author_me) @@ -289,18 +290,34 @@ fun ConversationContent( target = dragAndDropCallback, ), ) { - Messages( - messages = uiState.messages, - navigateToProfile = navigateToProfile, - modifier = Modifier.weight(1f), - scrollState = scrollState, - contentPadding = paddingValues, - onVideoClick = { videoUri, messageText -> - activeVideoUri = videoUri - activeVideoText = messageText - }, - onMessageLikeToggled = onMessageLikeToggled, - ) + if (uiState.messages.isEmpty()) { + Box( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .padding(paddingValues), + contentAlignment = Alignment.Center, + ) { + Text( + text = stringResource(R.string.no_messages), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } else { + Messages( + messages = uiState.messages, + navigateToProfile = navigateToProfile, + modifier = Modifier.weight(1f), + scrollState = scrollState, + contentPadding = paddingValues, + onVideoClick = { videoUri, messageText -> + activeVideoUri = videoUri + activeVideoText = messageText + }, + onMessageLikeToggled = onMessageLikeToggled, + ) + } } } @@ -330,7 +347,7 @@ fun ChannelNameBar( channelMembers: Int, modifier: Modifier = Modifier, scrollBehavior: TopAppBarScrollBehavior? = null, - onNavIconPressed: () -> Unit = { }, + onNavIconPressed: (() -> Unit)? = { }, ) { var functionalityNotAvailablePopupShown by remember { mutableStateOf(false) } if (functionalityNotAvailablePopupShown) { @@ -350,14 +367,8 @@ fun ChannelNameBar( ), scrollBehavior = scrollBehavior, onNavIconPressed = onNavIconPressed, - navigationIcon = {}, title = { - val navDrawerDescription = stringResource(R.string.navigation_drawer_open) Surface( - onClick = onNavIconPressed, - modifier = Modifier.semantics { - contentDescription = navDrawerDescription - }, shape = RoundedCornerShape(20.dp), color = MaterialTheme.colorScheme.surface.copy(alpha = 0.65f), ) { diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/ConversationViewModel.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/ConversationViewModel.kt index 6b19258290..e6a3332469 100644 --- a/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/ConversationViewModel.kt +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/ConversationViewModel.kt @@ -32,11 +32,20 @@ import com.example.compose.jetchat.data.initialMessages class ConversationViewModel(channel: String) : ViewModel() { var uiState by mutableStateOf( - ConversationUiState( - channelName = "#$channel", - channelMembers = exampleUiState.channelMembers, - initialMessages = initialMessages, - ), + if (channel == exampleUiState.channelName.removePrefix("#")) { + ConversationUiState( + channelName = exampleUiState.channelName, + channelMembers = exampleUiState.channelMembers, + initialMessages = initialMessages, + ) + } else { + // Only #composers has sample messages, other channels start empty + ConversationUiState( + channelName = "#$channel", + channelMembers = 0, + initialMessages = emptyList(), + ) + }, ) private set diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/navigation/JetchatNavKeys.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/navigation/JetchatNavKeys.kt index b8555efe07..3c1fe9a434 100644 --- a/Jetchat/app/src/main/java/com/example/compose/jetchat/navigation/JetchatNavKeys.kt +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/navigation/JetchatNavKeys.kt @@ -21,13 +21,16 @@ import androidx.navigation3.runtime.NavKey import kotlinx.serialization.Serializable /** - * The conversation screen of [channel], which is the start destination and always the root of the - * back stack. + * The list of chats and recent profiles, shown in the list pane of the list-detail layout. * * The keys are [Serializable] and implement [NavKey] so that the back stack created with * `rememberNavBackStack` survives configuration changes and process death. */ @Serializable +data object ChatList : NavKey + +/** The conversation screen of [channel]. */ +@Serializable data class Conversation(val channel: String) : NavKey /** The profile screen of the user identified by [userId]. */ @@ -35,21 +38,26 @@ data class Conversation(val channel: String) : NavKey data class Profile(val userId: String) : NavKey /** - * Opens the conversation of [channel]. The conversation is always the root of the back stack, so - * this replaces the current conversation and pops everything above it. + * Opens the conversation of [channel]. Only keeps [ChatList] and the selected conversation so + * that profiles opened before are popped off the back stack. */ fun NavBackStack.openConversation(channel: String) { - removeAll { it !is Conversation } - this[0] = Conversation(channel) + val conversation = Conversation(channel) + removeAll { it != ChatList && it != conversation } + if (lastOrNull() != conversation) { + add(conversation) + } } /** - * Opens the profile of the user identified by [userId] on top of the current screen. The same - * profile is never stacked twice, e.g. when an avatar is tapped repeatedly. + * Opens the profile of the user identified by [userId] on top of the current screen, replacing + * any profile that is already shown instead of stacking profiles. */ fun NavBackStack.openProfile(userId: String) { val profile = Profile(userId) - if (lastOrNull() != profile) { - add(profile) + when (lastOrNull()) { + profile -> Unit + is Profile -> this[lastIndex] = profile + else -> add(profile) } } diff --git a/Jetchat/app/src/main/res/values/strings.xml b/Jetchat/app/src/main/res/values/strings.xml index 4ed4843a01..d9d1c28206 100644 --- a/Jetchat/app/src/main/res/values/strings.xml +++ b/Jetchat/app/src/main/res/values/strings.xml @@ -28,6 +28,8 @@ Gallery Slideshow Conversations + Select a chat to start messaging + No messages yet Profile Jump to bottom Send diff --git a/Jetchat/gradle/libs.versions.toml b/Jetchat/gradle/libs.versions.toml index d163dba3e4..41d75c6cb3 100644 --- a/Jetchat/gradle/libs.versions.toml +++ b/Jetchat/gradle/libs.versions.toml @@ -77,6 +77,7 @@ androidx-compose-material3 = { module = "androidx.compose.material3:material3" } androidx-compose-material3-adaptive = { module = "androidx.compose.material3.adaptive:adaptive" } androidx-compose-material3-adaptive-layout = { module = "androidx.compose.material3.adaptive:adaptive-layout" } androidx-compose-material3-adaptive-navigation = { module = "androidx.compose.material3.adaptive:adaptive-navigation" } +androidx-compose-material3-adaptive-navigation3 = { module = "androidx.compose.material3.adaptive:adaptive-navigation3" } androidx-compose-material3-adaptive-navigationSuite = { module = "androidx.compose.material3:material3-adaptive-navigation-suite" } androidx-compose-materialWindow = { module = "androidx.compose.material3:material3-window-size-class" } androidx-compose-runtime = { module = "androidx.compose.runtime:runtime" } From 1ea78143918b88cb7dd71ca4893b80c6dd30e231 Mon Sep 17 00:00:00 2001 From: raystatic Date: Wed, 21 Oct 2026 05:48:39 +0530 Subject: [PATCH 2/4] Only show the keyboard when the conversation input is tapped --- .../java/com/example/compose/jetchat/conversation/UserInput.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/UserInput.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/UserInput.kt index 7e52cdc0bc..58103f8561 100644 --- a/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/UserInput.kt +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/UserInput.kt @@ -865,6 +865,7 @@ private fun BoxScope.UserInputTextField( keyboardOptions = KeyboardOptions( keyboardType = keyboardType, imeAction = ImeAction.Send, + showKeyboardOnFocus = false, ), onKeyboardAction = { if (formattedTextState.text.isNotBlank()) onMessageSent() From cfaa6ed04252daaead5a6743a7960887fd87e306 Mon Sep 17 00:00:00 2001 From: raystatic Date: Thu, 22 Oct 2026 11:36:54 +0530 Subject: [PATCH 3/4] - Make the channel name in the conversation app bar the navigation affordance. - Provide per-channel conversation data from the fake data set instead of showing the #composers conversation in every channel. - Give the list pane its own accessibility pane title instead of the private navigation_menu string of Compose UI, and let NavigationTest find the chat list in either the drawer or the list pane. - Trim comments --- .../example/compose/jetchat/NavigationTest.kt | 29 +++++++----- .../com/example/compose/jetchat/JetchatApp.kt | 45 ++++++++++--------- .../jetchat/conversation/Conversation.kt | 13 +++++- .../conversation/ConversationViewModel.kt | 20 +-------- .../example/compose/jetchat/data/FakeData.kt | 23 ++++++++-- Jetchat/app/src/main/res/values/strings.xml | 1 + 6 files changed, 76 insertions(+), 55 deletions(-) diff --git a/Jetchat/app/src/androidTest/java/com/example/compose/jetchat/NavigationTest.kt b/Jetchat/app/src/androidTest/java/com/example/compose/jetchat/NavigationTest.kt index 89882999ec..98186572c4 100644 --- a/Jetchat/app/src/androidTest/java/com/example/compose/jetchat/NavigationTest.kt +++ b/Jetchat/app/src/androidTest/java/com/example/compose/jetchat/NavigationTest.kt @@ -92,41 +92,48 @@ class NavigationTest { } private fun assertConversationIsDisplayed() { - // The channel name is shown in the conversation's app bar (and in the drawer/list pane, - // which is excluded) - composeTestRule.onNode(hasText("composers") and !isInDrawer()).assertIsDisplayed() + // The channel name is shown in the conversation's app bar (and in the chat list, which + // is excluded) + composeTestRule.onNode(hasText("composers") and !isInChatList()).assertIsDisplayed() } private fun navigateToProfile(name: String) { - if (!isDrawerDisplayed()) { + if (!isChatListDisplayed()) { composeTestRule.onNodeWithContentDescription( composeTestRule.activity.getString(R.string.navigation_drawer_open), ).performClick() } - composeTestRule.onNode(hasText(name) and isInDrawer()).performClick() + composeTestRule.onNode(hasText(name) and isInChatList()).performClick() } - private fun isDrawerDisplayed(): Boolean = try { - composeTestRule.onNode(hasText("Recent Profiles") and isInDrawer()).assertIsDisplayed() + private fun isChatListDisplayed(): Boolean = try { + composeTestRule.onNode(hasText("Recent Profiles") and isInChatList()).assertIsDisplayed() true } catch (e: AssertionError) { false } - private fun isInDrawer() = hasAnyAncestor(isDrawer()) + private fun isInChatList() = hasAnyAncestor(isChatList()) - private fun isDrawer() = SemanticsMatcher.expectValue( + /** + * The chat list is shown in the navigation drawer on single-pane windows and in the list pane + * on wide windows, which are identified by their pane titles. + */ + private fun isChatList() = SemanticsMatcher.expectValue( SemanticsProperties.PaneTitle, composeTestRule.activity.getString(androidx.compose.ui.R.string.navigation_menu), + ) or SemanticsMatcher.expectValue( + SemanticsProperties.PaneTitle, + composeTestRule.activity.getString(R.string.chat_list), ) private fun navigateToHomeFromProfile() { - if (!isDrawerDisplayed()) { + if (!isChatListDisplayed()) { composeTestRule.onAllNodes(isRoot()).onFirst().performTouchInput { swipeRight() } } - composeTestRule.onNode(hasText("composers") and isInDrawer()).performClick() + composeTestRule.onNode(hasText("composers") and isInChatList()).performClick() } } diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/JetchatApp.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/JetchatApp.kt index 06af969d83..112c479648 100644 --- a/Jetchat/app/src/main/java/com/example/compose/jetchat/JetchatApp.kt +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/JetchatApp.kt @@ -73,15 +73,12 @@ import kotlinx.coroutines.launch @OptIn(ExperimentalMaterial3AdaptiveApi::class) @Composable fun JetchatApp() { - // The back stack survives configuration changes and process death. On wide screens, - // ChatList and Conversation are shown side by side via ListDetailSceneStrategy; on - // single-pane phones, the existing ModalNavigationDrawer flow is used. + // The back stack survives configuration changes and process death val backStack = rememberNavBackStack(ChatList, Conversation("composers")) val drawerState = rememberDrawerState(initialValue = Closed) val scope = rememberCoroutineScope() - // Override the defaults so that there isn't a horizontal space between the panes. - // See b/418201867 + // Override the defaults so that there isn't a horizontal space between the panes val windowAdaptiveInfo = currentWindowAdaptiveInfoV2() val directive = remember(windowAdaptiveInfo) { calculatePaneScaffoldDirective(windowAdaptiveInfo) @@ -91,13 +88,13 @@ fun JetchatApp() { val isListAndDetailVisible = isMultiPane && backStack.lastOrNull() is Conversation val listDetailStrategy = rememberListDetailSceneStrategy(directive = directive) - LaunchedEffect(isListAndDetailVisible) { - if (isListAndDetailVisible && drawerState.isOpen) { + LaunchedEffect(isMultiPane) { + if (isMultiPane && drawerState.isOpen) { drawerState.close() } } - val selectedKey = backStack.lastOrNull() ?: Conversation("composers") + val selectedKey = backStack.last() val onNavigate: (NavKey) -> Unit = { key -> when (key) { is Conversation -> backStack.openConversation(key.channel) @@ -108,6 +105,7 @@ fun JetchatApp() { } } + // On single-pane windows the chat list is shown in the drawer instead of a screen val displayBackStack = if (isMultiPane) { backStack } else { @@ -119,7 +117,8 @@ fun JetchatApp() { selectedKey = selectedKey, onNavigate = onNavigate, drawerState = drawerState, - gesturesEnabled = !isListAndDetailVisible, + // The list pane replaces the drawer on wide windows + gesturesEnabled = !isMultiPane, ) { BackHandler(enabled = drawerState.isOpen) { scope.launch { @@ -144,13 +143,11 @@ fun JetchatApp() { detailPlaceholder = { ChatPlaceholder() }, ), ) { - val navigationMenuTitle = stringResource( - androidx.compose.ui.R.string.navigation_menu, - ) + val chatListTitle = stringResource(R.string.chat_list) Surface( modifier = Modifier .fillMaxSize() - .semantics { paneTitle = navigationMenuTitle }, + .semantics { paneTitle = chatListTitle }, color = MaterialTheme.colorScheme.background, contentColor = MaterialTheme.colorScheme.onBackground, ) { @@ -166,18 +163,24 @@ fun JetchatApp() { // The channel comes from the key, so pass it when the ViewModel of this // entry is created val viewModel = viewModel { ConversationViewModel(channel = key.channel) } - // Detail screens hide the navigation drawer button when in the - // multi-pane list-detail scene + // The channel name shows the chat list: it opens the drawer on single-pane + // windows and pops the profile that hides the list pane on wide windows val inListDetailScene = LocalListDetailSceneScope.current != null ConversationContent( uiState = viewModel.uiState, navigateToProfile = { userId -> backStack.openProfile(userId) }, - onNavIconPressed = if (inListDetailScene) { - null - } else { - { - scope.launch { - drawerState.open() + onNavIconPressed = when { + isListAndDetailVisible -> null + + inListDetailScene -> { + { backStack.removeLastOrNull() } + } + + else -> { + { + scope.launch { + drawerState.open() + } } } }, diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/Conversation.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/Conversation.kt index 608e08c937..263a71db63 100644 --- a/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/Conversation.kt +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/Conversation.kt @@ -367,9 +367,20 @@ fun ChannelNameBar( ), scrollBehavior = scrollBehavior, onNavIconPressed = onNavIconPressed, + navigationIcon = {}, title = { + val navDrawerDescription = stringResource(R.string.navigation_drawer_open) + val shape = RoundedCornerShape(20.dp) Surface( - shape = RoundedCornerShape(20.dp), + modifier = if (onNavIconPressed != null) { + Modifier + .clip(shape) + .clickable(onClick = onNavIconPressed) + .semantics { contentDescription = navDrawerDescription } + } else { + Modifier + }, + shape = shape, color = MaterialTheme.colorScheme.surface.copy(alpha = 0.65f), ) { Column( diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/ConversationViewModel.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/ConversationViewModel.kt index e6a3332469..eedf6e4c50 100644 --- a/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/ConversationViewModel.kt +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/ConversationViewModel.kt @@ -20,8 +20,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel -import com.example.compose.jetchat.data.exampleUiState -import com.example.compose.jetchat.data.initialMessages +import com.example.compose.jetchat.data.conversationUiState /** * Holds the conversation state so it survives configuration changes, and handles message @@ -31,22 +30,7 @@ import com.example.compose.jetchat.data.initialMessages */ class ConversationViewModel(channel: String) : ViewModel() { - var uiState by mutableStateOf( - if (channel == exampleUiState.channelName.removePrefix("#")) { - ConversationUiState( - channelName = exampleUiState.channelName, - channelMembers = exampleUiState.channelMembers, - initialMessages = initialMessages, - ) - } else { - // Only #composers has sample messages, other channels start empty - ConversationUiState( - channelName = "#$channel", - channelMembers = 0, - initialMessages = emptyList(), - ) - }, - ) + var uiState by mutableStateOf(conversationUiState(channel)) private set fun toggleLike(messageId: String) { diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/data/FakeData.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/data/FakeData.kt index c9f2869aca..fcdfae5506 100644 --- a/Jetchat/app/src/main/java/com/example/compose/jetchat/data/FakeData.kt +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/data/FakeData.kt @@ -238,12 +238,27 @@ val initialMessages = listOf( val unreadMessages = initialMessages.filter { it.author != "me" } -val exampleUiState = ConversationUiState( - initialMessages = initialMessages, - channelName = "#composers", - channelMembers = 42, +/** The fake data of a channel: its member count and the messages it starts with. */ +private class FakeChannel(val members: Int, val messages: List) + +/** The channels shown in the chat list. Only #composers has sample messages. */ +private val channels = mapOf( + "composers" to FakeChannel(members = 42, messages = initialMessages), + "droidcon-nyc" to FakeChannel(members = 7, messages = emptyList()), ) +/** Creates the initial [ConversationUiState] of [channel] from the fake data. */ +fun conversationUiState(channel: String): ConversationUiState { + val fakeChannel = channels.getValue(channel) + return ConversationUiState( + channelName = "#$channel", + channelMembers = fakeChannel.members, + initialMessages = fakeChannel.messages, + ) +} + +val exampleUiState = conversationUiState("composers") + /** * Example colleague profile */ diff --git a/Jetchat/app/src/main/res/values/strings.xml b/Jetchat/app/src/main/res/values/strings.xml index d9d1c28206..6de8b447c0 100644 --- a/Jetchat/app/src/main/res/values/strings.xml +++ b/Jetchat/app/src/main/res/values/strings.xml @@ -28,6 +28,7 @@ Gallery Slideshow Conversations + Chat list Select a chat to start messaging No messages yet Profile From 24876961bd62373f4f349f32892c36bff11ce6e2 Mon Sep 17 00:00:00 2001 From: raystatic Date: Thu, 22 Oct 2026 16:13:09 +0530 Subject: [PATCH 4/4] Hide the channel name button whenever the list pane is shown (also on 3-pane windows), label it "Show chat list", and fix NavigationTest on wide windows. --- Jetchat/README.md | 2 +- .../example/compose/jetchat/NavigationTest.kt | 59 +++++++++++++------ .../com/example/compose/jetchat/JetchatApp.kt | 41 +++++++++---- .../jetchat/conversation/Conversation.kt | 7 ++- .../example/compose/jetchat/data/FakeData.kt | 7 ++- Jetchat/app/src/main/res/values/strings.xml | 1 + 6 files changed, 81 insertions(+), 36 deletions(-) diff --git a/Jetchat/README.md b/Jetchat/README.md index 6ce5aeee93..cef57d896d 100644 --- a/Jetchat/README.md +++ b/Jetchat/README.md @@ -39,7 +39,7 @@ Jetchat is still in under development, and some features are not yet implemented The [ConversationContent](app/src/main/java/com/example/compose/jetchat/conversation/Conversation.kt) composable is the entry point to this screen and takes a [ConversationUiState](app/src/main/java/com/example/compose/jetchat/conversation/ConversationUiState.kt) that defines the data to be displayed. This doesn't mean all the state is served from a single point: composables can have their own state too. For an example, see `scrollState` in [ConversationContent](app/src/main/java/com/example/compose/jetchat/conversation/Conversation.kt) or `currentInputSelector` in [UserInput](app/src/main/java/com/example/compose/jetchat/conversation/UserInput.kt) ### Architecture Components -[JetchatApp](app/src/main/java/com/example/compose/jetchat/JetchatApp.kt) hosts the screens in a [Navigation 3](https://developer.android.com/guide/navigation/navigation-3) `NavDisplay`. It uses the Material `ListDetailSceneStrategy` for an adaptive list-detail layout: on wide windows, such as tablets and unfolded foldables in landscape, the chat list is shown next to the conversation, and opening a profile shows it in an extra pane. On narrower windows, one pane is shown at a time. The destinations are the typed keys defined in [JetchatNavKeys](app/src/main/java/com/example/compose/jetchat/navigation/JetchatNavKeys.kt): opening a profile pushes a `Profile(userId)` key onto the back stack, which is created with `rememberNavBackStack` so that it survives configuration changes and process death. The back stack is the single source of truth for navigation: the drawer and chat list derive their selected item from the key on top of it. The profile entry passes the `userId` of its key to a +[JetchatApp](app/src/main/java/com/example/compose/jetchat/JetchatApp.kt) hosts the screens in a [Navigation 3](https://developer.android.com/guide/navigation/navigation-3) `NavDisplay`. It uses the Material `ListDetailSceneStrategy` for an adaptive list-detail layout: on wide windows, such as tablets and unfolded foldables in landscape, the chat list is shown next to the conversation, and opening a profile shows it in an extra pane. On narrower windows, one pane is shown at a time and the chat list stays in the navigation drawer, so the phone flow is unchanged. The destinations are the typed keys defined in [JetchatNavKeys](app/src/main/java/com/example/compose/jetchat/navigation/JetchatNavKeys.kt): opening a profile pushes a `Profile(userId)` key onto the back stack, which is created with `rememberNavBackStack` so that it survives configuration changes and process death. The back stack is the single source of truth for navigation: the drawer and chat list derive their selected item from the key on top of it. The profile entry passes the `userId` of its key to a [ViewModel](https://developer.android.com/topic/libraries/architecture/viewmodel) that is scoped to that entry with `rememberViewModelStoreNavEntryDecorator`, and observes its state served via [LiveData](https://developer.android.com/topic/libraries/architecture/livedata). ### Back button handling diff --git a/Jetchat/app/src/androidTest/java/com/example/compose/jetchat/NavigationTest.kt b/Jetchat/app/src/androidTest/java/com/example/compose/jetchat/NavigationTest.kt index 98186572c4..31a39477bb 100644 --- a/Jetchat/app/src/androidTest/java/com/example/compose/jetchat/NavigationTest.kt +++ b/Jetchat/app/src/androidTest/java/com/example/compose/jetchat/NavigationTest.kt @@ -94,11 +94,11 @@ class NavigationTest { private fun assertConversationIsDisplayed() { // The channel name is shown in the conversation's app bar (and in the chat list, which // is excluded) - composeTestRule.onNode(hasText("composers") and !isInChatList()).assertIsDisplayed() + composeTestRule.onNode(hasText("composers") and !isInAnyChatList()).assertIsDisplayed() } private fun navigateToProfile(name: String) { - if (!isChatListDisplayed()) { + if (!chatListIsPane) { composeTestRule.onNodeWithContentDescription( composeTestRule.activity.getString(R.string.navigation_drawer_open), ).performClick() @@ -107,33 +107,54 @@ class NavigationTest { composeTestRule.onNode(hasText(name) and isInChatList()).performClick() } - private fun isChatListDisplayed(): Boolean = try { - composeTestRule.onNode(hasText("Recent Profiles") and isInChatList()).assertIsDisplayed() + private fun navigateToHomeFromProfile() { + if (chatListIsPane) { + // On two-pane windows the profile hides the list pane; the channel name brings it + // back. On wider windows the list pane stays next to the conversation. + if (!isListPaneDisplayed()) { + composeTestRule.onNodeWithContentDescription( + composeTestRule.activity.getString(R.string.show_chat_list), + ).performClick() + } + } else { + composeTestRule.onAllNodes(isRoot()).onFirst().performTouchInput { + swipeRight() + } + } + composeTestRule.onNode(hasText("composers") and isInChatList()).performClick() + } + + /** + * Whether the chat list is shown in the list pane next to the conversation (wide windows) + * rather than in the navigation drawer (single-pane windows). + * + * Resolved on first use, which must happen while the conversation is on top of the back + * stack: on two-pane windows a profile hides the list pane. + */ + private val chatListIsPane: Boolean by lazy { isListPaneDisplayed() } + + private fun isListPaneDisplayed(): Boolean = try { + composeTestRule.onNode(isListPane()).assertIsDisplayed() true } catch (e: AssertionError) { false } - private fun isInChatList() = hasAnyAncestor(isChatList()) - /** - * The chat list is shown in the navigation drawer on single-pane windows and in the list pane - * on wide windows, which are identified by their pane titles. + * Matches nodes in the container that currently shows the chat list. The closed drawer keeps + * its content composed on wide windows, so the two containers are never matched together. */ - private fun isChatList() = SemanticsMatcher.expectValue( + private fun isInChatList() = hasAnyAncestor(if (chatListIsPane) isListPane() else isDrawer()) + + private fun isInAnyChatList() = hasAnyAncestor(isDrawer() or isListPane()) + + private fun isDrawer() = SemanticsMatcher.expectValue( SemanticsProperties.PaneTitle, composeTestRule.activity.getString(androidx.compose.ui.R.string.navigation_menu), - ) or SemanticsMatcher.expectValue( + ) + + private fun isListPane() = SemanticsMatcher.expectValue( SemanticsProperties.PaneTitle, composeTestRule.activity.getString(R.string.chat_list), ) - - private fun navigateToHomeFromProfile() { - if (!isChatListDisplayed()) { - composeTestRule.onAllNodes(isRoot()).onFirst().performTouchInput { - swipeRight() - } - } - composeTestRule.onNode(hasText("composers") and isInChatList()).performClick() - } } diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/JetchatApp.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/JetchatApp.kt index 112c479648..68c687a8d8 100644 --- a/Jetchat/app/src/main/java/com/example/compose/jetchat/JetchatApp.kt +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/JetchatApp.kt @@ -26,6 +26,8 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi import androidx.compose.material3.adaptive.currentWindowAdaptiveInfoV2 +import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffoldRole +import androidx.compose.material3.adaptive.layout.PaneAdaptedValue import androidx.compose.material3.adaptive.layout.calculatePaneScaffoldDirective import androidx.compose.material3.adaptive.navigation3.ListDetailSceneStrategy import androidx.compose.material3.adaptive.navigation3.LocalListDetailSceneScope @@ -85,7 +87,6 @@ fun JetchatApp() { .copy(horizontalPartitionSpacerSize = 0.dp) } val isMultiPane = directive.maxHorizontalPartitions > 1 - val isListAndDetailVisible = isMultiPane && backStack.lastOrNull() is Conversation val listDetailStrategy = rememberListDetailSceneStrategy(directive = directive) LaunchedEffect(isMultiPane) { @@ -105,11 +106,14 @@ fun JetchatApp() { } } - // On single-pane windows the chat list is shown in the drawer instead of a screen + // On single-pane windows the chat list is shown in the drawer instead of a screen. The + // conversation is never popped, so the displayed back stack is never empty. val displayBackStack = if (isMultiPane) { backStack } else { - backStack.filter { it !is ChatList } + backStack.filter { it !is ChatList }.also { + check(it.isNotEmpty()) { "A conversation must always be on the back stack" } + } } JetchatDrawer( @@ -164,26 +168,39 @@ fun JetchatApp() { // entry is created val viewModel = viewModel { ConversationViewModel(channel = key.channel) } // The channel name shows the chat list: it opens the drawer on single-pane - // windows and pops the profile that hides the list pane on wide windows - val inListDetailScene = LocalListDetailSceneScope.current != null + // windows, where there is no list-detail scene, and pops the profile that + // hides the list pane on wide windows. Whether the list pane is shown comes + // from the scaffold, as it depends on the number of panes: on 3-pane windows + // it stays next to the conversation while a profile is shown. + val sceneScope = LocalListDetailSceneScope.current + val isListPaneShown = sceneScope?.scaffoldTransitionScope + ?.scaffoldStateTransition?.targetState + ?.get(ListDetailPaneScaffoldRole.List) == PaneAdaptedValue.Expanded ConversationContent( uiState = viewModel.uiState, navigateToProfile = { userId -> backStack.openProfile(userId) }, onNavIconPressed = when { - isListAndDetailVisible -> null - - inListDetailScene -> { - { backStack.removeLastOrNull() } - } - - else -> { + sceneScope == null -> { { scope.launch { drawerState.open() } } } + + isListPaneShown -> null + + else -> { + { backStack.removeLastOrNull() } + } }, + navIconContentDescription = stringResource( + if (sceneScope == null) { + R.string.navigation_drawer_open + } else { + R.string.show_chat_list + }, + ), onMessageLikeToggled = viewModel::toggleLike, ) } diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/Conversation.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/Conversation.kt index 263a71db63..bf56c490a9 100644 --- a/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/Conversation.kt +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/Conversation.kt @@ -142,6 +142,7 @@ import kotlinx.coroutines.launch * @param modifier [Modifier] to apply to this layout node * @param onNavIconPressed Sends an event up when the user clicks on the menu, which is only shown * when this is not null + * @param navIconContentDescription Accessibility description of what [onNavIconPressed] does * @param onMessageLikeToggled Sends an event up when the user double taps a message to like it */ @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @@ -151,6 +152,7 @@ fun ConversationContent( navigateToProfile: (String) -> Unit, modifier: Modifier = Modifier, onNavIconPressed: (() -> Unit)? = { }, + navIconContentDescription: String = stringResource(R.string.navigation_drawer_open), onMessageLikeToggled: (messageId: String) -> Unit = { }, ) { val authorMe = stringResource(R.string.author_me) @@ -219,6 +221,7 @@ fun ConversationContent( channelName = uiState.channelName, channelMembers = uiState.channelMembers, onNavIconPressed = onNavIconPressed, + navIconContentDescription = navIconContentDescription, scrollBehavior = scrollBehavior, ) }, @@ -348,6 +351,7 @@ fun ChannelNameBar( modifier: Modifier = Modifier, scrollBehavior: TopAppBarScrollBehavior? = null, onNavIconPressed: (() -> Unit)? = { }, + navIconContentDescription: String = stringResource(R.string.navigation_drawer_open), ) { var functionalityNotAvailablePopupShown by remember { mutableStateOf(false) } if (functionalityNotAvailablePopupShown) { @@ -369,14 +373,13 @@ fun ChannelNameBar( onNavIconPressed = onNavIconPressed, navigationIcon = {}, title = { - val navDrawerDescription = stringResource(R.string.navigation_drawer_open) val shape = RoundedCornerShape(20.dp) Surface( modifier = if (onNavIconPressed != null) { Modifier .clip(shape) .clickable(onClick = onNavIconPressed) - .semantics { contentDescription = navDrawerDescription } + .semantics { contentDescription = navIconContentDescription } } else { Modifier }, diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/data/FakeData.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/data/FakeData.kt index fcdfae5506..d0c02f7933 100644 --- a/Jetchat/app/src/main/java/com/example/compose/jetchat/data/FakeData.kt +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/data/FakeData.kt @@ -247,9 +247,12 @@ private val channels = mapOf( "droidcon-nyc" to FakeChannel(members = 7, messages = emptyList()), ) -/** Creates the initial [ConversationUiState] of [channel] from the fake data. */ +/** + * Creates the initial [ConversationUiState] of [channel] from the fake data. Unknown channels are + * empty. + */ fun conversationUiState(channel: String): ConversationUiState { - val fakeChannel = channels.getValue(channel) + val fakeChannel = channels[channel] ?: FakeChannel(members = 0, messages = emptyList()) return ConversationUiState( channelName = "#$channel", channelMembers = fakeChannel.members, diff --git a/Jetchat/app/src/main/res/values/strings.xml b/Jetchat/app/src/main/res/values/strings.xml index 6de8b447c0..4fbd0aa1f9 100644 --- a/Jetchat/app/src/main/res/values/strings.xml +++ b/Jetchat/app/src/main/res/values/strings.xml @@ -29,6 +29,7 @@ Slideshow Conversations Chat list + Show chat list Select a chat to start messaging No messages yet Profile