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 be0f3b14b5..317862cef7 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 @@ -22,9 +22,13 @@ import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.hasAnyAncestor import androidx.compose.ui.test.hasText import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule +import androidx.compose.ui.test.onAllNodesWithContentDescription 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.lifecycle.ViewModelProvider import androidx.navigation.NavController import androidx.navigation.findNavController import androidx.test.espresso.Espresso @@ -49,13 +53,14 @@ class NavigationTest { @Test fun profileScreen_back_conversationScreen() { val navController = getNavController() - // Navigate to profile \ + // Navigate to profile navigateToProfile("Taylor Brooks") // Check profile is displayed assertEquals(navController.currentDestination?.id, R.id.nav_profile) - // Extra UI check + // Extra UI check (scroll to Display name as the new profile header is taller) composeTestRule .onNodeWithText(composeTestRule.activity.getString(R.string.display_name)) + .performScrollTo() .assertIsDisplayed() // Press back @@ -79,10 +84,25 @@ class NavigationTest { assertEquals(getNavController().currentDestination?.id, R.id.nav_home) } + private fun openDrawer() { + val navDrawerNodes = composeTestRule + .onAllNodesWithContentDescription( + composeTestRule.activity.getString(R.string.navigation_drawer_open), + ) + .fetchSemanticsNodes() + if (navDrawerNodes.isNotEmpty()) { + composeTestRule.onNodeWithContentDescription( + composeTestRule.activity.getString(R.string.navigation_drawer_open), + ).performClick() + } else { + composeTestRule.runOnUiThread { + ViewModelProvider(composeTestRule.activity)[MainViewModel::class.java].openDrawer() + } + } + } + private fun navigateToProfile(name: String) { - composeTestRule.onNodeWithContentDescription( - composeTestRule.activity.getString(R.string.navigation_drawer_open), - ).performClick() + openDrawer() composeTestRule.onNode(hasText(name) and isInDrawer()).performClick() } @@ -95,9 +115,7 @@ class NavigationTest { ) private fun navigateToHome() { - composeTestRule.onNodeWithContentDescription( - composeTestRule.activity.getString(R.string.navigation_drawer_open), - ).performClick() + openDrawer() composeTestRule.onNode(hasText("composers") and isInDrawer()).performClick() } 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 ce0020e2bd..bfb587d029 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 @@ -249,7 +249,7 @@ val exampleUiState = ConversationUiState( */ val colleagueProfile = ProfileScreenState( userId = "12345", - photo = R.drawable.someone_else, + photo = R.drawable.android_profile_pic, name = "Taylor Brooks", status = "Away", displayName = "taylor", @@ -264,7 +264,7 @@ val colleagueProfile = ProfileScreenState( */ val meProfile = ProfileScreenState( userId = "me", - photo = R.drawable.ali, + photo = R.drawable.android_profile_pic, name = "Ali Conors", status = "Online", displayName = "aliconors", diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/profile/Profile.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/profile/Profile.kt index 94e54a9208..e3414f7e3c 100644 --- a/Jetchat/app/src/main/java/com/example/compose/jetchat/profile/Profile.kt +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/profile/Profile.kt @@ -16,22 +16,20 @@ package com.example.compose.jetchat.profile +import android.util.Log import androidx.compose.foundation.Image -import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.background import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.navigationBarsPadding -import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.systemBarsPadding import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.verticalScroll import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FloatingActionButton @@ -41,21 +39,18 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.input.nestedscroll.NestedScrollConnection -import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.draw.BlurredEdgeTreatment +import androidx.compose.ui.draw.blur +import androidx.compose.ui.graphics.blur.BlurRadiusSpec +import androidx.compose.ui.graphics.blur.BlurStop import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.platform.rememberNestedScrollInteropConnection import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview @@ -68,13 +63,11 @@ import com.example.compose.jetchat.components.baselineHeight import com.example.compose.jetchat.data.colleagueProfile import com.example.compose.jetchat.data.meProfile import com.example.compose.jetchat.theme.JetchatTheme +import com.example.compose.jetchat.theme.ProfileTheme @OptIn(ExperimentalMaterial3Api::class, ExperimentalComposeUiApi::class) @Composable -fun ProfileScreen( - userData: ProfileScreenState, - nestedScrollInteropConnection: NestedScrollConnection = rememberNestedScrollInteropConnection(), -) { +fun ProfileScreen(userData: ProfileScreenState) { var functionalityNotAvailablePopupShown by remember { mutableStateOf(false) } if (functionalityNotAvailablePopupShown) { FunctionalityNotAvailablePopup { functionalityNotAvailablePopupShown = false } @@ -82,44 +75,30 @@ fun ProfileScreen( val scrollState = rememberScrollState() - BoxWithConstraints( - modifier = Modifier - .fillMaxSize() - .nestedScroll(nestedScrollInteropConnection) - .systemBarsPadding(), - ) { - Surface { - Column( - modifier = Modifier - .fillMaxSize() - .verticalScroll(scrollState), - ) { - ProfileHeader( - scrollState, - userData, - this@BoxWithConstraints.maxHeight, - ) - UserInfoFields(userData, this@BoxWithConstraints.maxHeight) + ProfileTheme { + BoxWithConstraints( + modifier = Modifier + .fillMaxSize(), + ) { + Surface { + Column( + modifier = Modifier + .fillMaxSize() + .background(color = MaterialTheme.colorScheme.surfaceContainer) + .verticalScroll(scrollState), + ) { + ProfileHeader(userData) + UserInfoFields(userData, this@BoxWithConstraints.maxHeight) + } } } - - val fabExtended by remember { derivedStateOf { scrollState.value == 0 } } - ProfileFab( - extended = fabExtended, - userIsMe = userData.isMe(), - modifier = Modifier - .align(Alignment.BottomEnd) - // Offsets the FAB to compensate for CoordinatorLayout collapsing behaviour - .offset(y = ((-100).dp)), - onFabClicked = { functionalityNotAvailablePopupShown = true }, - ) } } @Composable private fun UserInfoFields(userData: ProfileScreenState, containerHeight: Dp) { Column { - Spacer(modifier = Modifier.height(8.dp)) + Spacer(modifier = Modifier.height(40.dp)) NameAndPosition(userData) @@ -160,7 +139,8 @@ private fun Name(userData: ProfileScreenState, modifier: Modifier = Modifier) { Text( text = userData.name, modifier = modifier, - style = MaterialTheme.typography.headlineSmall, + style = MaterialTheme.typography.headlineLarge, + color = MaterialTheme.colorScheme.onSurface, ) } @@ -175,22 +155,23 @@ private fun Position(userData: ProfileScreenState, modifier: Modifier = Modifier } @Composable -private fun ProfileHeader(scrollState: ScrollState, data: ProfileScreenState, containerHeight: Dp) { - val offset = (scrollState.value / 2) - val offsetDp = with(LocalDensity.current) { offset.toDp() } +private fun ProfileHeader(data: ProfileScreenState) { data.photo?.let { Image( modifier = Modifier - .heightIn(max = containerHeight / 2) .fillMaxWidth() - // TODO: Update to use offset to avoid recomposition - .padding( - start = 16.dp, - top = offsetDp, - end = 16.dp, - ) - .clip(CircleShape), + .aspectRatio(0.96f) + .blur { + radius = BlurRadiusSpec.verticalGradient( + listOf( + BlurStop(0.0f, 0.dp), + BlurStop(0.5f, 0.dp), + BlurStop(1.0f, 32.dp), + ), + ) + edgeTreatment = BlurredEdgeTreatment.Unbounded + }, painter = painterResource(id = it), contentScale = ContentScale.Crop, contentDescription = null, @@ -205,7 +186,7 @@ fun ProfileProperty(label: String, value: String, isLink: Boolean = false) { Text( text = label, modifier = Modifier.baselineHeight(24.dp), - style = MaterialTheme.typography.bodySmall, + style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) val style = if (isLink) { 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 index daeaed63db..aa737a7830 100644 --- 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 @@ -21,32 +21,15 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.ExperimentalComposeUiApi -import androidx.compose.ui.Modifier import androidx.compose.ui.platform.ComposeView -import androidx.compose.ui.platform.rememberNestedScrollInteropConnection -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels import androidx.fragment.app.viewModels -import com.example.compose.jetchat.FunctionalityNotAvailablePopup import com.example.compose.jetchat.MainViewModel -import com.example.compose.jetchat.R -import com.example.compose.jetchat.components.JetchatAppBar import com.example.compose.jetchat.theme.JetchatTheme class ProfileFragment : Fragment() { @@ -62,45 +45,15 @@ class ProfileFragment : Fragment() { } @OptIn(ExperimentalComposeUiApi::class, ExperimentalMaterial3Api::class) - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { - val rootView: View = inflater.inflate(R.layout.fragment_profile, container, false) + 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, + ) - rootView.findViewById(R.id.toolbar_compose_view).apply { - setContent { - var functionalityNotAvailablePopupShown by remember { mutableStateOf(false) } - if (functionalityNotAvailablePopupShown) { - FunctionalityNotAvailablePopup { functionalityNotAvailablePopupShown = false } - } - - JetchatTheme { - JetchatAppBar( - // Reset the minimum bounds that are passed to the root of a compose tree - modifier = Modifier.wrapContentSize(), - onNavIconPressed = { activityViewModel.openDrawer() }, - title = { }, - actions = { - // More icon - Icon( - painter = painterResource(id = R.drawable.ic_more_vert), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier - .clickable(onClick = { - functionalityNotAvailablePopupShown = true - }) - .padding(horizontal = 12.dp, vertical = 16.dp) - .height(24.dp), - contentDescription = stringResource(id = R.string.more_options), - ) - }, - ) - } - } - } - - rootView.findViewById(R.id.profile_compose_view).apply { setContent { val userData by viewModel.userData.observeAsState() - val nestedScrollInteropConnection = rememberNestedScrollInteropConnection() JetchatTheme { if (userData == null) { @@ -108,12 +61,9 @@ class ProfileFragment : Fragment() { } else { ProfileScreen( userData = userData!!, - nestedScrollInteropConnection = nestedScrollInteropConnection, ) } } } } - return rootView - } } diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/theme/Color.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/theme/Color.kt index 35d5181f7d..d11517f0ca 100644 --- a/Jetchat/app/src/main/java/com/example/compose/jetchat/theme/Color.kt +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/theme/Color.kt @@ -58,3 +58,7 @@ val BlueGrey50 = Color(0xFF767680) val BlueGrey60 = Color(0xFF90909A) val BlueGrey80 = Color(0xFFC6C5D0) val BlueGrey90 = Color(0xFFE2E1EC) + +val ProfileSurface = Color(0xFFEAE9FC) +val ProfileOnSurface = Color(0xFF1D1B1F) +val ProfileOnSurfaceVariant = Color(0xFF48454F) diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/theme/Themes.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/theme/Themes.kt index c323fcd1b5..47d0f51673 100644 --- a/Jetchat/app/src/main/java/com/example/compose/jetchat/theme/Themes.kt +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/theme/Themes.kt @@ -27,6 +27,13 @@ import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.googlefonts.Font +import androidx.compose.ui.unit.sp +import com.example.compose.jetchat.R val JetchatDarkColorScheme = darkColorScheme( primary = Blue80, @@ -86,6 +93,12 @@ val JetchatLightColorScheme = lightColorScheme( outline = BlueGrey50, ) +val ProfileColorScheme = JetchatLightColorScheme.copy( + surfaceContainer = ProfileSurface, + onSurface = ProfileOnSurface, + onSurfaceVariant = ProfileOnSurfaceVariant, +) + @SuppressLint("NewApi") @Composable fun JetchatTheme(isDarkTheme: Boolean = isSystemInDarkTheme(), isDynamicColor: Boolean = true, content: @Composable () -> Unit) { @@ -110,3 +123,11 @@ fun JetchatTheme(isDarkTheme: Boolean = isSystemInDarkTheme(), isDynamicColor: B content = content, ) } +@Composable +fun ProfileTheme(content: @Composable () -> Unit) { + MaterialTheme( + colorScheme = ProfileColorScheme, + typography = ProfileTypography, + content = content, + ) +} diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/theme/Typography.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/theme/Typography.kt index 2ede128c04..501b767b9b 100644 --- a/Jetchat/app/src/main/java/com/example/compose/jetchat/theme/Typography.kt +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/theme/Typography.kt @@ -161,3 +161,22 @@ val JetchatTypography = Typography( letterSpacing = 0.5.sp, ), ) + +val ProfileTypography = JetchatTypography.copy( + headlineLarge = TextStyle( + fontSize = 74.sp, + lineHeight = 70.82.sp, + fontWeight = FontWeight.ExtraBold, + ), + bodyLarge = TextStyle( + fontSize = 16.sp, + lineHeight = 24.sp, + fontWeight = FontWeight.Normal, + ), + labelSmall = TextStyle( + fontSize = 12.sp, + lineHeight = 16.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 0.15.sp, + ), +) diff --git a/Jetchat/app/src/main/res/drawable-nodpi/android_profile_pic.png b/Jetchat/app/src/main/res/drawable-nodpi/android_profile_pic.png new file mode 100644 index 0000000000..cc02960d89 Binary files /dev/null and b/Jetchat/app/src/main/res/drawable-nodpi/android_profile_pic.png differ diff --git a/Jetchat/app/src/main/res/drawable/android_profile_pic.png b/Jetchat/app/src/main/res/drawable/android_profile_pic.png new file mode 100644 index 0000000000..cc02960d89 Binary files /dev/null and b/Jetchat/app/src/main/res/drawable/android_profile_pic.png differ diff --git a/Jetchat/app/src/main/res/layout/fragment_profile.xml b/Jetchat/app/src/main/res/layout/fragment_profile.xml deleted file mode 100644 index 3cdf5eae3b..0000000000 --- a/Jetchat/app/src/main/res/layout/fragment_profile.xml +++ /dev/null @@ -1,42 +0,0 @@ - - - - - - - - - - - - \ No newline at end of file diff --git a/Jetchat/gradle/libs.versions.toml b/Jetchat/gradle/libs.versions.toml index f5e80315d1..b86936ae7e 100644 --- a/Jetchat/gradle/libs.versions.toml +++ b/Jetchat/gradle/libs.versions.toml @@ -8,7 +8,7 @@ android-material3 = "1.14.0" androidGradlePlugin = "9.3.1" androidx-activity-compose = "1.13.0" androidx-appcompat = "1.8.0" -androidx-compose-bom = "2026.08.01" +androidx-compose-bom = "2026.09.00" androidx-constraintlayout = "1.1.2" androidx-core-splashscreen = "1.2.0" androidx-corektx = "1.19.0"