diff --git a/Jetchat/README.md b/Jetchat/README.md index 52cb68a6b5..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`. 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 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/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..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 @@ -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,29 +92,69 @@ class NavigationTest { } private fun assertConversationIsDisplayed() { - // The channel name is shown in the conversation's app bar (and in the drawer, 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 !isInAnyChatList()).assertIsDisplayed() } private fun navigateToProfile(name: String) { - composeTestRule.onNodeWithContentDescription( - composeTestRule.activity.getString(R.string.navigation_drawer_open), - ).performClick() + if (!chatListIsPane) { + composeTestRule.onNodeWithContentDescription( + composeTestRule.activity.getString(R.string.navigation_drawer_open), + ).performClick() + } + + composeTestRule.onNode(hasText(name) and isInChatList()).performClick() + } + + 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() } - composeTestRule.onNode(hasText(name) and isInDrawer()).performClick() + private fun isListPaneDisplayed(): Boolean = try { + composeTestRule.onNode(isListPane()).assertIsDisplayed() + true + } catch (e: AssertionError) { + false } - private fun isInDrawer() = hasAnyAncestor(isDrawer()) + /** + * 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 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), ) - private fun navigateToHomeFromProfile() { - composeTestRule.onAllNodes(isRoot()).onFirst().performTouchInput { - swipeRight() - } - composeTestRule.onNode(hasText("composers") and isInDrawer()).performClick() - } + private fun isListPane() = SemanticsMatcher.expectValue( + SemanticsProperties.PaneTitle, + composeTestRule.activity.getString(R.string.chat_list), + ) } 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..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 @@ -16,21 +16,47 @@ 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.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 +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 +72,68 @@ 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")) + 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 + val windowAdaptiveInfo = currentWindowAdaptiveInfoV2() + val directive = remember(windowAdaptiveInfo) { + calculatePaneScaffoldDirective(windowAdaptiveInfo) + .copy(horizontalPartitionSpacerSize = 0.dp) + } + val isMultiPane = directive.maxHorizontalPartitions > 1 + val listDetailStrategy = rememberListDetailSceneStrategy(directive = directive) + + LaunchedEffect(isMultiPane) { + if (isMultiPane && drawerState.isOpen) { + drawerState.close() + } + } + + val selectedKey = backStack.last() + val onNavigate: (NavKey) -> Unit = { key -> + when (key) { + is Conversation -> backStack.openConversation(key.channel) + is Profile -> backStack.openProfile(key.userId) + } + scope.launch { + drawerState.close() + } + } + + // 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 }.also { + check(it.isNotEmpty()) { "A conversation must always be on the back stack" } + } + } + 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, + // The list pane replaces the drawer on wide windows + gesturesEnabled = !isMultiPane, + ) { + 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 +142,71 @@ fun JetchatApp() { rememberViewModelStoreNavEntryDecorator(), ), entryProvider = entryProvider { - entry { key -> + entry( + metadata = ListDetailSceneStrategy.listPane( + detailPlaceholder = { ChatPlaceholder() }, + ), + ) { + val chatListTitle = stringResource(R.string.chat_list) + Surface( + modifier = Modifier + .fillMaxSize() + .semantics { paneTitle = chatListTitle }, + 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) } + // The channel name shows the chat list: it opens the drawer on single-pane + // 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 = { - scope.launch { - drawerState.open() + onNavIconPressed = when { + 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, ) } - 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 +226,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..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 @@ -140,7 +140,9 @@ 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 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) @@ -149,7 +151,8 @@ fun ConversationContent( uiState: ConversationUiState, navigateToProfile: (String) -> Unit, modifier: Modifier = Modifier, - onNavIconPressed: () -> Unit = { }, + onNavIconPressed: (() -> Unit)? = { }, + navIconContentDescription: String = stringResource(R.string.navigation_drawer_open), onMessageLikeToggled: (messageId: String) -> Unit = { }, ) { val authorMe = stringResource(R.string.author_me) @@ -218,6 +221,7 @@ fun ConversationContent( channelName = uiState.channelName, channelMembers = uiState.channelMembers, onNavIconPressed = onNavIconPressed, + navIconContentDescription = navIconContentDescription, scrollBehavior = scrollBehavior, ) }, @@ -289,18 +293,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 +350,8 @@ fun ChannelNameBar( channelMembers: Int, modifier: Modifier = Modifier, scrollBehavior: TopAppBarScrollBehavior? = null, - onNavIconPressed: () -> Unit = { }, + onNavIconPressed: (() -> Unit)? = { }, + navIconContentDescription: String = stringResource(R.string.navigation_drawer_open), ) { var functionalityNotAvailablePopupShown by remember { mutableStateOf(false) } if (functionalityNotAvailablePopupShown) { @@ -352,13 +373,17 @@ fun ChannelNameBar( onNavIconPressed = onNavIconPressed, navigationIcon = {}, title = { - val navDrawerDescription = stringResource(R.string.navigation_drawer_open) + val shape = RoundedCornerShape(20.dp) Surface( - onClick = onNavIconPressed, - modifier = Modifier.semantics { - contentDescription = navDrawerDescription + modifier = if (onNavIconPressed != null) { + Modifier + .clip(shape) + .clickable(onClick = onNavIconPressed) + .semantics { contentDescription = navIconContentDescription } + } else { + Modifier }, - shape = RoundedCornerShape(20.dp), + 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 6b19258290..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,13 +30,7 @@ import com.example.compose.jetchat.data.initialMessages */ class ConversationViewModel(channel: String) : ViewModel() { - var uiState by mutableStateOf( - ConversationUiState( - channelName = "#$channel", - channelMembers = exampleUiState.channelMembers, - initialMessages = initialMessages, - ), - ) + var uiState by mutableStateOf(conversationUiState(channel)) private set fun toggleLike(messageId: String) { 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() 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..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 @@ -238,12 +238,30 @@ 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. Unknown channels are + * empty. + */ +fun conversationUiState(channel: String): ConversationUiState { + val fakeChannel = channels[channel] ?: FakeChannel(members = 0, messages = emptyList()) + return ConversationUiState( + channelName = "#$channel", + channelMembers = fakeChannel.members, + initialMessages = fakeChannel.messages, + ) +} + +val exampleUiState = conversationUiState("composers") + /** * Example colleague profile */ 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..4fbd0aa1f9 100644 --- a/Jetchat/app/src/main/res/values/strings.xml +++ b/Jetchat/app/src/main/res/values/strings.xml @@ -28,6 +28,10 @@ Gallery Slideshow Conversations + Chat list + Show chat list + 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" }