Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 5 additions & 8 deletions Jetchat/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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).
Expand All @@ -57,22 +57,19 @@ 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:

#### [ConversationTest](app/src/androidTest/java/com/example/compose/jetchat/ConversationTest.kt)
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.
Expand Down
8 changes: 4 additions & 4 deletions Jetchat/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -77,7 +78,6 @@ android {

buildFeatures {
compose = true
viewBinding = true
}

packaging.resources {
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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()
Expand All @@ -69,7 +63,7 @@ class NavigationTest {
Espresso.pressBack()

// Check that we're home
assertEquals(navController.currentDestination?.id, R.id.nav_home)
assertConversationIsDisplayed()
}

/**
Expand All @@ -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) {
Expand All @@ -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)
}
}
115 changes: 115 additions & 0 deletions Jetchat/app/src/main/java/com/example/compose/jetchat/JetchatApp.kt
Original file line number Diff line number Diff line change
@@ -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<Conversation> { 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<Profile> { 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!!,
)
}
}
},
)
}
}

This file was deleted.

Loading
Loading