diff --git a/Jetchat/README.md b/Jetchat/README.md index 0433a4585d..52cb68a6b5 100644 --- a/Jetchat/README.md +++ b/Jetchat/README.md @@ -13,7 +13,7 @@ project from Android Studio following the steps This sample showcases: * UI state management -* Integration with Architecture Components: Navigation, Fragments, ViewModel +* Integration with Architecture Components: Navigation 3, ViewModel * Back button handling * Text Input and focus management * Multiple types of animations and transitions @@ -39,8 +39,8 @@ 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 -The [ProfileFragment](app/src/main/java/com/example/compose/jetchat/profile/ProfileFragment.kt) shows how to pass data between fragments with the [Navigation component](https://developer.android.com/guide/navigation) and observe state from a -[ViewModel](https://developer.android.com/topic/libraries/architecture/viewmodel), served via [LiveData](https://developer.android.com/topic/libraries/architecture/livedata). +[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 +[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 When the Emoji selector is shown, pressing back in the app closes it, intercepting any navigation events. The implementation can be found in [UserInput](app/src/main/java/com/example/compose/jetchat/conversation/UserInput.kt). @@ -57,14 +57,11 @@ This sample is laid out [edge-to-edge](https://medium.com/androiddevelopers/gest The sample also supports synchronized IME transitions when running on API 30+ devices. See the use of `Modifier.navigationBarsPadding().imePadding()` in [ConversationContent](app/src/main/java/com/example/compose/jetchat/conversation/UserInput.kt). ### Saved state across configuration changes -Some composable state survives activity or process recreation, like `currentInputSelector` in [UserInput](app/src/main/java/com/example/compose/jetchat/conversation/UserInput.kt). +Some composable state survives activity or process recreation, like `currentInputSelector` in [UserInput](app/src/main/java/com/example/compose/jetchat/conversation/UserInput.kt). The navigation back stack is saved too, see `rememberNavBackStack` in [JetchatApp](app/src/main/java/com/example/compose/jetchat/JetchatApp.kt). ### Material Design 3 theming and Material You dynamic color Jetchat follows the [Material Design 3](https://m3.material.io) principles and uses the `MaterialTheme` composable and M3 components. On Android 12+ Jetchat supports Material You dynamic color, which extracts a custom color scheme from the device wallpaper. Jetchat uses a custom, branded color scheme as a fallback. It also implements custom typography using the Karla and Montserrat font families. -### Nested scrolling interop -Jetchat contains an example of how to use [`rememberNestedScrollInteropConnection()`](https://developer.android.com/reference/kotlin/androidx/compose/ui/platform/package-summary#rememberNestedScrollInteropConnection()) to achieve successful nested scroll interop between a View parent that implements `androidx.core.view.NestedScrollingParent3` and a Compose child. The example used here is a combination of a View parent `CoordinatorLayout` and a nested, Compose child `BoxWithConstraints` in [ProfileFragment](app/src/main/java/com/example/compose/jetchat/profile/ProfileFragment.kt). - ### UI tests In [androidTest](app/src/androidTest/java/com/example/compose/jetchat) you'll find a suite of UI tests that showcase interesting patterns in Compose: @@ -72,7 +69,7 @@ In [androidTest](app/src/androidTest/java/com/example/compose/jetchat) you'll fi UI tests for the Conversation screen. Includes a test that checks the behavior of the app when dark mode changes. #### [NavigationTest](app/src/androidTest/java/com/example/compose/jetchat/NavigationTest.kt) -Shows how to write tests that assert directly on the [Navigation Controller](https://developer.android.com/reference/androidx/navigation/NavController). +Checks the navigation flows of the app (opening profiles from the drawer, going back, popping the back stack) by asserting on what is displayed. #### [UserInputTest](app/src/androidTest/java/com/example/compose/jetchat/UserInputTest.kt) Checks that the user input composable, including extended controls, behave as expected showing and hiding the keyboard. diff --git a/Jetchat/app/build.gradle.kts b/Jetchat/app/build.gradle.kts index 55c6ffb042..305dab0f32 100644 --- a/Jetchat/app/build.gradle.kts +++ b/Jetchat/app/build.gradle.kts @@ -20,6 +20,7 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { alias(libs.plugins.android.application) alias(libs.plugins.compose) + alias(libs.plugins.kotlin.serialization) } android { @@ -77,7 +78,6 @@ android { buildFeatures { compose = true - viewBinding = true } packaging.resources { @@ -105,15 +105,15 @@ dependencies { implementation(libs.androidx.compose.runtime.livedata) implementation(libs.androidx.lifecycle.viewModelCompose) implementation(libs.androidx.lifecycle.runtime.compose) - implementation(libs.androidx.navigation.fragment) - implementation(libs.androidx.navigation.ui.ktx) + implementation(libs.androidx.lifecycle.viewmodel.navigation3) + implementation(libs.androidx.navigation3.runtime) + implementation(libs.androidx.navigation3.ui) implementation(libs.androidx.compose.foundation.layout) implementation(libs.androidx.compose.material3) implementation(libs.androidx.compose.ui.tooling.preview) debugImplementation(libs.androidx.compose.ui.tooling) implementation(libs.androidx.compose.ui.util) - implementation(libs.androidx.compose.ui.viewbinding) implementation(libs.androidx.compose.ui.googlefonts) implementation(libs.androidx.media3.exoplayer) 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 ef057174d9..ea71e5e6e3 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 @@ -26,15 +26,12 @@ import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule import androidx.compose.ui.test.onFirst import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithText -import androidx.compose.ui.test.onRoot import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performScrollTo import androidx.compose.ui.test.performTouchInput import androidx.compose.ui.test.swipeRight -import androidx.navigation.NavController -import androidx.navigation.findNavController import androidx.test.espresso.Espresso -import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue import org.junit.Rule import org.junit.Test @@ -49,17 +46,14 @@ class NavigationTest { @Test fun app_launches() { // Check app launches at the correct destination - assertEquals(getNavController().currentDestination?.id, R.id.nav_home) + assertConversationIsDisplayed() } @Test fun profileScreen_back_conversationScreen() { - val navController = getNavController() // Navigate to profile navigateToProfile("Taylor Brooks") - // Check profile is displayed - assertEquals(navController.currentDestination?.id, R.id.nav_profile) - // Extra UI check (scroll to Display name as the new profile header is taller) + // Check profile is displayed (scroll to Display name as the new profile header is taller) composeTestRule .onNodeWithText(composeTestRule.activity.getString(R.string.display_name)) .performScrollTo() @@ -69,7 +63,7 @@ class NavigationTest { Espresso.pressBack() // Check that we're home - assertEquals(navController.currentDestination?.id, R.id.nav_home) + assertConversationIsDisplayed() } /** @@ -83,7 +77,19 @@ class NavigationTest { navigateToHomeFromProfile() // Chewie, we're home - assertEquals(getNavController().currentDestination?.id, R.id.nav_home) + assertConversationIsDisplayed() + + // The profiles were popped off the back stack, so pressing back leaves the app + val activity = composeTestRule.activity + Espresso.pressBackUnconditionally() + composeTestRule.runOnIdle { + assertTrue(activity.isFinishing) + } + } + + 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() } private fun navigateToProfile(name: String) { @@ -101,22 +107,10 @@ class NavigationTest { composeTestRule.activity.getString(androidx.compose.ui.R.string.navigation_menu), ) - private fun navigateToHome() { - composeTestRule.onNodeWithContentDescription( - composeTestRule.activity.getString(R.string.navigation_drawer_open), - ).performClick() - - composeTestRule.onNode(hasText("composers") and isInDrawer()).performClick() - } - private fun navigateToHomeFromProfile() { composeTestRule.onAllNodes(isRoot()).onFirst().performTouchInput { swipeRight() } composeTestRule.onNode(hasText("composers") and isInDrawer()).performClick() } - - private fun getNavController(): NavController { - return composeTestRule.activity.findNavController(R.id.nav_host_fragment) - } } 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 new file mode 100644 index 0000000000..d6abd6e364 --- /dev/null +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/JetchatApp.kt @@ -0,0 +1,115 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.compose.jetchat + +import androidx.compose.material3.DrawerValue.Closed +import androidx.compose.material3.rememberDrawerState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.rememberCoroutineScope +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator +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.conversation.ConversationContent +import com.example.compose.jetchat.conversation.ConversationViewModel +import com.example.compose.jetchat.navigation.Conversation +import com.example.compose.jetchat.navigation.Profile +import com.example.compose.jetchat.navigation.openConversation +import com.example.compose.jetchat.navigation.openProfile +import com.example.compose.jetchat.profile.ProfileError +import com.example.compose.jetchat.profile.ProfileScreen +import com.example.compose.jetchat.profile.ProfileViewModel +import kotlinx.coroutines.launch + +/** + * Root composable of the app: the navigation drawer around a Navigation 3 [NavDisplay]. + * + * 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. + */ +@Composable +fun JetchatApp() { + // The back stack survives configuration changes and process death + val backStack = rememberNavBackStack(Conversation("composers")) + val drawerState = rememberDrawerState(initialValue = Closed) + val scope = rememberCoroutineScope() + + 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) + } + scope.launch { + drawerState.close() + } + }, + drawerState = drawerState, + ) { + NavDisplay( + backStack = backStack, + onBack = { backStack.removeLastOrNull() }, + // Scope the ViewModels to their entry. The ViewModelStoreNavEntryDecorator + // requires the default SaveableStateHolderNavEntryDecorator, which saves the + // state of each entry. + entryDecorators = listOf( + rememberSaveableStateHolderNavEntryDecorator(), + rememberViewModelStoreNavEntryDecorator(), + ), + entryProvider = entryProvider { + entry { 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) } + ConversationContent( + uiState = viewModel.uiState, + navigateToProfile = { userId -> backStack.openProfile(userId) }, + onNavIconPressed = { + scope.launch { + drawerState.open() + } + }, + onMessageLikeToggled = viewModel::toggleLike, + ) + } + entry { key -> + // The user id comes from the key, so set it when the ViewModel of this + // entry is created + val viewModel = viewModel { + ProfileViewModel().apply { setUserId(key.userId) } + } + val userData by viewModel.userData.observeAsState() + + if (userData == null) { + ProfileError() + } else { + ProfileScreen( + userData = userData!!, + ) + } + } + }, + ) + } +} diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/MainViewModel.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/MainViewModel.kt deleted file mode 100644 index 51bb6d040a..0000000000 --- a/Jetchat/app/src/main/java/com/example/compose/jetchat/MainViewModel.kt +++ /dev/null @@ -1,38 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.compose.jetchat - -import androidx.lifecycle.ViewModel -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow - -/** - * Used to communicate between screens. - */ -class MainViewModel : ViewModel() { - - private val _drawerShouldBeOpened = MutableStateFlow(false) - val drawerShouldBeOpened = _drawerShouldBeOpened.asStateFlow() - - fun openDrawer() { - _drawerShouldBeOpened.value = true - } - - fun resetOpenDrawerAction() { - _drawerShouldBeOpened.value = false - } -} diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/NavActivity.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/NavActivity.kt index dff10570d9..2e019cf2f5 100644 --- a/Jetchat/app/src/main/java/com/example/compose/jetchat/NavActivity.kt +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/NavActivity.kt @@ -17,101 +17,23 @@ package com.example.compose.jetchat import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge -import androidx.activity.viewModels -import androidx.appcompat.app.AppCompatActivity -import androidx.compose.foundation.layout.consumeWindowInsets -import androidx.compose.material3.DrawerValue.Closed -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.rememberDrawerState -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue -import androidx.compose.ui.platform.ComposeView -import androidx.compose.ui.viewinterop.AndroidViewBinding -import androidx.core.os.bundleOf import androidx.core.view.ViewCompat -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.navigation.NavController -import androidx.navigation.fragment.NavHostFragment -import com.example.compose.jetchat.components.JetchatDrawer -import com.example.compose.jetchat.databinding.ContentMainBinding -import kotlinx.coroutines.launch /** * Main activity for the app. */ -class NavActivity : AppCompatActivity() { - private val viewModel: MainViewModel by viewModels() +class NavActivity : ComponentActivity() { - @OptIn(ExperimentalMaterial3Api::class) override fun onCreate(savedInstanceState: Bundle?) { enableEdgeToEdge() super.onCreate(savedInstanceState) ViewCompat.setOnApplyWindowInsetsListener(window.decorView) { _, insets -> insets } - setContentView( - ComposeView(this).apply { - consumeWindowInsets = false - setContent { - val drawerState = rememberDrawerState(initialValue = Closed) - val drawerOpen by viewModel.drawerShouldBeOpened - .collectAsStateWithLifecycle() - - var selectedMenu by remember { mutableStateOf("composers") } - if (drawerOpen) { - // Open drawer and reset state in VM. - LaunchedEffect(Unit) { - // wrap in try-finally to handle interruption whiles opening drawer - try { - drawerState.open() - } finally { - viewModel.resetOpenDrawerAction() - } - } - } - - val scope = rememberCoroutineScope() - - JetchatDrawer( - drawerState = drawerState, - selectedMenu = selectedMenu, - onChatClicked = { - findNavController().popBackStack(R.id.nav_home, false) - scope.launch { - drawerState.close() - } - selectedMenu = it - }, - onProfileClicked = { - val bundle = bundleOf("userId" to it) - findNavController().navigate(R.id.nav_profile, bundle) - scope.launch { - drawerState.close() - } - selectedMenu = it - }, - ) { - AndroidViewBinding(ContentMainBinding::inflate) - } - } - }, - ) - } - - override fun onSupportNavigateUp(): Boolean { - return findNavController().navigateUp() || super.onSupportNavigateUp() - } - - /** - * See https://issuetracker.google.com/142847973 - */ - private fun findNavController(): NavController { - val navHostFragment = - supportFragmentManager.findFragmentById(R.id.nav_host_fragment) as NavHostFragment - return navHostFragment.navController + setContent { + JetchatApp() + } } } diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/components/JetchatDrawer.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/components/JetchatDrawer.kt index e17cb33d90..404d66f73c 100644 --- a/Jetchat/app/src/main/java/com/example/compose/jetchat/components/JetchatDrawer.kt +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/components/JetchatDrawer.kt @@ -55,14 +55,23 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.navigation3.runtime.NavKey import com.example.compose.jetchat.R import com.example.compose.jetchat.data.colleagueProfile import com.example.compose.jetchat.data.meProfile +import com.example.compose.jetchat.navigation.Conversation +import com.example.compose.jetchat.navigation.Profile import com.example.compose.jetchat.theme.JetchatTheme import com.example.compose.jetchat.widget.WidgetReceiver +/** + * Content of the navigation drawer. + * + * @param selectedKey the key of the screen currently displayed, used to highlight its item + * @param onNavigate called with the key of the item the user tapped + */ @Composable -fun JetchatDrawerContent(onProfileClicked: (String) -> Unit, onChatClicked: (String) -> Unit, selectedMenu: String = "composers") { +fun JetchatDrawerContent(selectedKey: NavKey, onNavigate: (NavKey) -> Unit) { // Use windowInsetsTopHeight() to add a spacer which pushes the drawer content // below the status bar (y-axis) Column { @@ -70,26 +79,12 @@ fun JetchatDrawerContent(onProfileClicked: (String) -> Unit, onChatClicked: (Str DrawerHeader() DividerItem() DrawerItemHeader("Chats") - ChatItem("composers", selectedMenu == "composers") { - onChatClicked("composers") - } - ChatItem("droidcon-nyc", selectedMenu == "droidcon-nyc") { - onChatClicked("droidcon-nyc") - } + ChatItem(Conversation("composers"), selectedKey, onNavigate) + ChatItem(Conversation("droidcon-nyc"), selectedKey, onNavigate) DividerItem(modifier = Modifier.padding(horizontal = 28.dp)) DrawerItemHeader("Recent Profiles") - ProfileItem( - "Ali Conors (you)", meProfile.photo, - selectedMenu == meProfile.userId, - ) { - onProfileClicked(meProfile.userId) - } - ProfileItem( - "Taylor Brooks", colleagueProfile.photo, - selectedMenu == colleagueProfile.userId, - ) { - onProfileClicked(colleagueProfile.userId) - } + ProfileItem("Ali Conors (you)", meProfile.photo, Profile(meProfile.userId), selectedKey, onNavigate) + ProfileItem("Taylor Brooks", colleagueProfile.photo, Profile(colleagueProfile.userId), selectedKey, onNavigate) if (widgetAddingIsSupported(LocalContext.current)) { DividerItem(modifier = Modifier.padding(horizontal = 28.dp)) DrawerItemHeader("Settings") @@ -105,9 +100,10 @@ private fun DrawerHeader() { contentDescription = null, modifier = Modifier.size(24.dp), ) - Image( + Icon( painter = painterResource(id = R.drawable.jetchat_logo), contentDescription = null, + tint = MaterialTheme.colorScheme.onSurface, modifier = Modifier.padding(start = 8.dp), ) } @@ -130,7 +126,8 @@ private fun DrawerItemHeader(text: String) { } @Composable -private fun ChatItem(text: String, selected: Boolean, onChatClicked: () -> Unit) { +private fun ChatItem(key: Conversation, selectedKey: NavKey, onNavigate: (NavKey) -> Unit) { + val selected = key == selectedKey val background = if (selected) { Modifier.background(MaterialTheme.colorScheme.primaryContainer) } else { @@ -143,7 +140,7 @@ private fun ChatItem(text: String, selected: Boolean, onChatClicked: () -> Unit) .padding(horizontal = 12.dp) .clip(CircleShape) .then(background) - .clickable(onClick = onChatClicked), + .clickable(onClick = { onNavigate(key) }), verticalAlignment = CenterVertically, ) { val iconTint = if (selected) { @@ -158,7 +155,7 @@ private fun ChatItem(text: String, selected: Boolean, onChatClicked: () -> Unit) contentDescription = null, ) Text( - text, + key.channel, style = MaterialTheme.typography.bodyMedium, color = if (selected) { MaterialTheme.colorScheme.primary @@ -171,8 +168,8 @@ private fun ChatItem(text: String, selected: Boolean, onChatClicked: () -> Unit) } @Composable -private fun ProfileItem(text: String, @DrawableRes profilePic: Int?, selected: Boolean = false, onProfileClicked: () -> Unit) { - val background = if (selected) { +private fun ProfileItem(text: String, @DrawableRes profilePic: Int?, key: Profile, selectedKey: NavKey, onNavigate: (NavKey) -> Unit) { + val background = if (key == selectedKey) { Modifier.background(MaterialTheme.colorScheme.primaryContainer) } else { Modifier @@ -184,7 +181,7 @@ private fun ProfileItem(text: String, @DrawableRes profilePic: Int?, selected: B .padding(horizontal = 12.dp) .clip(CircleShape) .then(background) - .clickable(onClick = onProfileClicked), + .clickable(onClick = { onNavigate(key) }), verticalAlignment = CenterVertically, ) { val paddingSizeModifier = Modifier @@ -223,7 +220,7 @@ fun DrawerPreview() { JetchatTheme { Surface { Column { - JetchatDrawerContent({}, {}) + JetchatDrawerContent(selectedKey = Conversation("composers"), onNavigate = {}) } } } @@ -235,7 +232,7 @@ fun DrawerPreviewDark() { JetchatTheme(isDarkTheme = true) { Surface { Column { - JetchatDrawerContent({}, {}) + JetchatDrawerContent(selectedKey = Conversation("composers"), onNavigate = {}) } } } 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 2f19fb88bf..be59da0f8e 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 @@ -23,14 +23,20 @@ import androidx.compose.material3.ModalDrawerSheet import androidx.compose.material3.ModalNavigationDrawer import androidx.compose.material3.rememberDrawerState import androidx.compose.runtime.Composable +import androidx.navigation3.runtime.NavKey import com.example.compose.jetchat.theme.JetchatTheme +/** + * Navigation drawer of the app. + * + * @param selectedKey the key of the screen currently displayed, used to highlight its drawer item + * @param onNavigate called with the key of the drawer item the user tapped + */ @Composable fun JetchatDrawer( + selectedKey: NavKey, + onNavigate: (NavKey) -> Unit, drawerState: DrawerState = rememberDrawerState(initialValue = Closed), - selectedMenu: String, - onProfileClicked: (String) -> Unit, - onChatClicked: (String) -> Unit, content: @Composable () -> Unit, ) { JetchatTheme { @@ -43,9 +49,8 @@ fun JetchatDrawer( drawerContentColor = MaterialTheme.colorScheme.onBackground, ) { JetchatDrawerContent( - onProfileClicked = onProfileClicked, - onChatClicked = onChatClicked, - selectedMenu = selectedMenu, + selectedKey = selectedKey, + onNavigate = onNavigate, ) } }, diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/ConversationFragment.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/ConversationFragment.kt deleted file mode 100644 index a99dce1776..0000000000 --- a/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/ConversationFragment.kt +++ /dev/null @@ -1,64 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.compose.jetchat.conversation - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.view.ViewGroup.LayoutParams -import android.view.ViewGroup.LayoutParams.MATCH_PARENT -import androidx.compose.ui.platform.ComposeView -import androidx.core.os.bundleOf -import androidx.fragment.app.Fragment -import androidx.fragment.app.activityViewModels -import androidx.fragment.app.viewModels -import androidx.navigation.findNavController -import com.example.compose.jetchat.MainViewModel -import com.example.compose.jetchat.R -import com.example.compose.jetchat.theme.JetchatTheme - -class ConversationFragment : Fragment() { - - private val activityViewModel: MainViewModel by activityViewModels() - private val viewModel: ConversationViewModel by viewModels() - - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View = - ComposeView(inflater.context).apply { - layoutParams = LayoutParams(MATCH_PARENT, MATCH_PARENT) - - setContent { - JetchatTheme { - ConversationContent( - uiState = viewModel.uiState, - navigateToProfile = { user -> - // Click callback - val bundle = bundleOf("userId" to user) - findNavController().navigate( - R.id.nav_profile, - bundle, - ) - }, - onNavIconPressed = { - activityViewModel.openDrawer() - }, - onMessageLikeToggled = viewModel::toggleLike, - ) - } - } - } -} 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 c0cf90ca9e..6b19258290 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 @@ -26,12 +26,14 @@ import com.example.compose.jetchat.data.initialMessages /** * Holds the conversation state so it survives configuration changes, and handles message * actions such as liking a message. + * + * @param channel the channel of the conversation */ -class ConversationViewModel : ViewModel() { +class ConversationViewModel(channel: String) : ViewModel() { var uiState by mutableStateOf( ConversationUiState( - channelName = exampleUiState.channelName, + channelName = "#$channel", channelMembers = exampleUiState.channelMembers, initialMessages = initialMessages, ), 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 new file mode 100644 index 0000000000..b8555efe07 --- /dev/null +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/navigation/JetchatNavKeys.kt @@ -0,0 +1,55 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.compose.jetchat.navigation + +import androidx.navigation3.runtime.NavBackStack +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 keys are [Serializable] and implement [NavKey] so that the back stack created with + * `rememberNavBackStack` survives configuration changes and process death. + */ +@Serializable +data class Conversation(val channel: String) : NavKey + +/** The profile screen of the user identified by [userId]. */ +@Serializable +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. + */ +fun NavBackStack.openConversation(channel: String) { + removeAll { it !is Conversation } + this[0] = Conversation(channel) +} + +/** + * 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. + */ +fun NavBackStack.openProfile(userId: String) { + val profile = Profile(userId) + if (lastOrNull() != profile) { + add(profile) + } +} diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/profile/ProfileFragment.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/profile/ProfileFragment.kt deleted file mode 100644 index 8af0b5f957..0000000000 --- a/Jetchat/app/src/main/java/com/example/compose/jetchat/profile/ProfileFragment.kt +++ /dev/null @@ -1,68 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.compose.jetchat.profile - -import android.content.Context -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState -import androidx.compose.ui.ExperimentalComposeUiApi -import androidx.compose.ui.platform.ComposeView -import androidx.fragment.app.Fragment -import androidx.fragment.app.activityViewModels -import androidx.fragment.app.viewModels -import com.example.compose.jetchat.MainViewModel -import com.example.compose.jetchat.theme.JetchatTheme - -class ProfileFragment : Fragment() { - - private val viewModel: ProfileViewModel by viewModels() - private val activityViewModel: MainViewModel by activityViewModels() - - override fun onAttach(context: Context) { - super.onAttach(context) - // Consider using safe args plugin - val userId = arguments?.getString("userId") - viewModel.setUserId(userId) - } - - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View = - ComposeView(inflater.context).apply { - layoutParams = ViewGroup.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.MATCH_PARENT, - ) - - setContent { - val userData by viewModel.userData.observeAsState() - - JetchatTheme { - if (userData == null) { - ProfileError() - } else { - ProfileScreen( - userData = userData!!, - ) - } - } - } - } -} diff --git a/Jetchat/app/src/main/res/drawable/jetchat_logo.xml b/Jetchat/app/src/main/res/drawable/jetchat_logo.xml index 6a89c0b954..36d29550ed 100644 --- a/Jetchat/app/src/main/res/drawable/jetchat_logo.xml +++ b/Jetchat/app/src/main/res/drawable/jetchat_logo.xml @@ -21,12 +21,12 @@ android:viewportHeight="24"> + android:fillColor="@android:color/white"/> + android:fillColor="@android:color/white"/> diff --git a/Jetchat/app/src/main/res/layout/content_main.xml b/Jetchat/app/src/main/res/layout/content_main.xml deleted file mode 100644 index 31302894b9..0000000000 --- a/Jetchat/app/src/main/res/layout/content_main.xml +++ /dev/null @@ -1,34 +0,0 @@ - - - - - - - - diff --git a/Jetchat/app/src/main/res/navigation/mobile_navigation.xml b/Jetchat/app/src/main/res/navigation/mobile_navigation.xml deleted file mode 100644 index 3db49d9468..0000000000 --- a/Jetchat/app/src/main/res/navigation/mobile_navigation.xml +++ /dev/null @@ -1,37 +0,0 @@ - - - - - - - - - - - - diff --git a/Jetchat/app/src/main/res/values/ids.xml b/Jetchat/app/src/main/res/values/ids.xml deleted file mode 100644 index a749058c41..0000000000 --- a/Jetchat/app/src/main/res/values/ids.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - - - - diff --git a/Jetchat/app/src/main/res/values/themes.xml b/Jetchat/app/src/main/res/values/themes.xml index 0e815ceaa4..03fb7885f7 100644 --- a/Jetchat/app/src/main/res/values/themes.xml +++ b/Jetchat/app/src/main/res/values/themes.xml @@ -35,8 +35,4 @@ true -