Implement the People UI in Compose (1/2)
This CL implements the People UI using Jetpack Compose. Note that this
implementation won't be used by SystemUI yet, and can only be viewed
using the SystemUIGallery app at the moment.
Once ag/19500380 is submitted (blocked by b/236146570), the screenshot
tests will use the Compose implementation. You can see the visual
difference between the implementations in ag/19498514. As explained in
b/238993727#comment10 the result is slightly different (and improved
over the current View implementation).
Note that the fake values of this screen have been moved from the
current PeopleSpaceScreenshotTest, and are now reused there as well (see
ag/19568794).
Bug: 238993727
Test: atest SystemUIGoogleScreenshotTests
Change-Id: I4d2f241ccf768ef3fccc01ce196418ce7fc26492
diff --git a/packages/SystemUI/compose/features/Android.bp b/packages/SystemUI/compose/features/Android.bp
index 40218de..325ede6 100644
--- a/packages/SystemUI/compose/features/Android.bp
+++ b/packages/SystemUI/compose/features/Android.bp
@@ -30,6 +30,7 @@
],
static_libs: [
+ "SystemUI-core",
"SystemUIComposeCore",
"androidx.compose.runtime_runtime",
diff --git a/packages/SystemUI/compose/features/AndroidManifest.xml b/packages/SystemUI/compose/features/AndroidManifest.xml
index 0aea99d..eada40e 100644
--- a/packages/SystemUI/compose/features/AndroidManifest.xml
+++ b/packages/SystemUI/compose/features/AndroidManifest.xml
@@ -16,7 +16,38 @@
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:tools="http://schemas.android.com/tools"
+
package="com.android.systemui.compose.features">
-
-
+ <application
+ android:name="android.app.Application"
+ android:appComponentFactory="androidx.core.app.AppComponentFactory"
+ tools:replace="android:name,android:appComponentFactory">
+ <!-- Disable providers from SystemUI -->
+ <provider android:name="com.android.systemui.keyguard.KeyguardSliceProvider"
+ android:authorities="com.android.systemui.test.keyguard.disabled"
+ android:enabled="false"
+ tools:replace="android:authorities"
+ tools:node="remove" />
+ <provider android:name="com.google.android.systemui.keyguard.KeyguardSliceProviderGoogle"
+ android:authorities="com.android.systemui.test.keyguard.disabled"
+ android:enabled="false"
+ tools:replace="android:authorities"
+ tools:node="remove" />
+ <provider android:name="com.android.keyguard.clock.ClockOptionsProvider"
+ android:authorities="com.android.systemui.test.keyguard.clock.disabled"
+ android:enabled="false"
+ tools:replace="android:authorities"
+ tools:node="remove" />
+ <provider android:name="com.android.systemui.people.PeopleProvider"
+ android:authorities="com.android.systemui.test.people.disabled"
+ android:enabled="false"
+ tools:replace="android:authorities"
+ tools:node="remove" />
+ <provider android:name="androidx.core.content.FileProvider"
+ android:authorities="com.android.systemui.test.fileprovider.disabled"
+ android:enabled="false"
+ tools:replace="android:authorities"
+ tools:node="remove"/>
+ </application>
</manifest>
diff --git a/packages/SystemUI/compose/features/src/com/android/systemui/people/ui/compose/PeopleScreen.kt b/packages/SystemUI/compose/features/src/com/android/systemui/people/ui/compose/PeopleScreen.kt
new file mode 100644
index 0000000..2bf1937
--- /dev/null
+++ b/packages/SystemUI/compose/features/src/com/android/systemui/people/ui/compose/PeopleScreen.kt
@@ -0,0 +1,233 @@
+/*
+ * Copyright (C) 2022 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
+ *
+ * http://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.android.systemui.people.ui.compose
+
+import android.annotation.StringRes
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.LazyListScope
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Divider
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.asImageBitmap
+import androidx.compose.ui.platform.LocalLifecycleOwner
+import androidx.compose.ui.res.dimensionResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.repeatOnLifecycle
+import com.android.systemui.R
+import com.android.systemui.compose.theme.LocalAndroidColorScheme
+import com.android.systemui.people.ui.viewmodel.PeopleTileViewModel
+import com.android.systemui.people.ui.viewmodel.PeopleViewModel
+import kotlinx.coroutines.flow.collect
+
+/**
+ * Compose the screen associated to a [PeopleViewModel].
+ *
+ * @param viewModel the [PeopleViewModel] that should be composed.
+ * @param onResult the callback called with the result of this screen. Callers should usually finish
+ * the Activity/Fragment/View hosting this Composable once a result is available.
+ */
+@Composable
+fun PeopleScreen(
+ viewModel: PeopleViewModel,
+ onResult: (PeopleViewModel.Result) -> Unit,
+) {
+ val priorityTiles by viewModel.priorityTiles.collectAsState()
+ val recentTiles by viewModel.recentTiles.collectAsState()
+
+ // Make sure to refresh the tiles/conversations when the lifecycle is resumed, so that it
+ // updates them when going back to the Activity after leaving it.
+ val lifecycleOwner = LocalLifecycleOwner.current
+ LaunchedEffect(lifecycleOwner, viewModel) {
+ lifecycleOwner.repeatOnLifecycle(Lifecycle.State.RESUMED) {
+ viewModel.onTileRefreshRequested()
+ }
+ }
+
+ // Call [onResult] this activity when the ViewModel tells us so.
+ LaunchedEffect(viewModel.result) {
+ viewModel.result.collect { result ->
+ if (result != null) {
+ viewModel.clearResult()
+ onResult(result)
+ }
+ }
+ }
+
+ // Make sure to use the Android colors and not the default Material3 colors to have the exact
+ // same colors as the View implementation.
+ val androidColors = LocalAndroidColorScheme.current
+ Surface(
+ color = androidColors.colorBackground,
+ contentColor = androidColors.textColorPrimary,
+ modifier = Modifier.fillMaxSize(),
+ ) {
+ if (priorityTiles.isNotEmpty() || recentTiles.isNotEmpty()) {
+ PeopleScreenWithConversations(priorityTiles, recentTiles, viewModel::onTileClicked)
+ } else {
+ PeopleScreenEmpty(viewModel::onUserJourneyCancelled)
+ }
+ }
+}
+
+@Composable
+private fun PeopleScreenWithConversations(
+ priorityTiles: List<PeopleTileViewModel>,
+ recentTiles: List<PeopleTileViewModel>,
+ onTileClicked: (PeopleTileViewModel) -> Unit,
+) {
+ Column {
+ Column(
+ Modifier.fillMaxWidth().padding(PeopleSpacePadding),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Text(
+ stringResource(R.string.select_conversation_title),
+ style = MaterialTheme.typography.headlineSmall,
+ textAlign = TextAlign.Center,
+ )
+
+ Spacer(Modifier.height(24.dp))
+
+ Text(
+ stringResource(R.string.select_conversation_text),
+ Modifier.padding(horizontal = 24.dp),
+ style = MaterialTheme.typography.bodyLarge,
+ textAlign = TextAlign.Center,
+ )
+ }
+
+ LazyColumn(
+ Modifier.fillMaxWidth(),
+ contentPadding =
+ PaddingValues(
+ top = 16.dp,
+ bottom = PeopleSpacePadding,
+ start = 8.dp,
+ end = 8.dp,
+ )
+ ) {
+ ConversationList(R.string.priority_conversations, priorityTiles, onTileClicked)
+ item { Spacer(Modifier.height(35.dp)) }
+ ConversationList(R.string.recent_conversations, recentTiles, onTileClicked)
+ }
+ }
+}
+
+private fun LazyListScope.ConversationList(
+ @StringRes headerTextResource: Int,
+ tiles: List<PeopleTileViewModel>,
+ onTileClicked: (PeopleTileViewModel) -> Unit
+) {
+ item {
+ Text(
+ stringResource(headerTextResource),
+ Modifier.padding(start = 16.dp),
+ style = MaterialTheme.typography.labelLarge,
+ color = LocalAndroidColorScheme.current.colorAccentPrimaryVariant,
+ )
+
+ Spacer(Modifier.height(10.dp))
+ }
+
+ tiles.forEachIndexed { index, tile ->
+ if (index > 0) {
+ item {
+ Divider(
+ color = LocalAndroidColorScheme.current.colorBackground,
+ thickness = 2.dp,
+ )
+ }
+ }
+
+ item(tile.key.toString()) {
+ Tile(
+ tile,
+ onTileClicked,
+ withTopCornerRadius = index == 0,
+ withBottomCornerRadius = index == tiles.lastIndex,
+ )
+ }
+ }
+}
+
+@Composable
+private fun Tile(
+ tile: PeopleTileViewModel,
+ onTileClicked: (PeopleTileViewModel) -> Unit,
+ withTopCornerRadius: Boolean,
+ withBottomCornerRadius: Boolean,
+) {
+ val androidColors = LocalAndroidColorScheme.current
+ val cornerRadius = dimensionResource(R.dimen.people_space_widget_radius)
+ val topCornerRadius = if (withTopCornerRadius) cornerRadius else 0.dp
+ val bottomCornerRadius = if (withBottomCornerRadius) cornerRadius else 0.dp
+
+ Surface(
+ color = androidColors.colorSurface,
+ contentColor = androidColors.textColorPrimary,
+ shape =
+ RoundedCornerShape(
+ topStart = topCornerRadius,
+ topEnd = topCornerRadius,
+ bottomStart = bottomCornerRadius,
+ bottomEnd = bottomCornerRadius,
+ ),
+ ) {
+ Row(
+ Modifier.fillMaxWidth().clickable { onTileClicked(tile) }.padding(12.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Image(
+ tile.icon.asImageBitmap(),
+ // TODO(b/238993727): Add a content description.
+ contentDescription = null,
+ Modifier.size(dimensionResource(R.dimen.avatar_size_for_medium)),
+ )
+
+ Text(
+ tile.username ?: "",
+ Modifier.padding(horizontal = 16.dp),
+ style = MaterialTheme.typography.titleLarge,
+ )
+ }
+ }
+}
+
+/** The padding applied to the PeopleSpace screen. */
+internal val PeopleSpacePadding = 24.dp
diff --git a/packages/SystemUI/compose/features/src/com/android/systemui/people/ui/compose/PeopleScreenEmpty.kt b/packages/SystemUI/compose/features/src/com/android/systemui/people/ui/compose/PeopleScreenEmpty.kt
new file mode 100644
index 0000000..5c9358f
--- /dev/null
+++ b/packages/SystemUI/compose/features/src/com/android/systemui/people/ui/compose/PeopleScreenEmpty.kt
@@ -0,0 +1,124 @@
+/*
+ * Copyright (C) 2022 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
+ *
+ * http://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.android.systemui.people.ui.compose
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.defaultMinSize
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import com.android.systemui.R
+import com.android.systemui.compose.theme.LocalAndroidColorScheme
+
+@Composable
+internal fun PeopleScreenEmpty(
+ onGotItClicked: () -> Unit,
+) {
+ Column(
+ Modifier.fillMaxSize().padding(PeopleSpacePadding),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Text(
+ stringResource(R.string.select_conversation_title),
+ style = MaterialTheme.typography.headlineSmall,
+ textAlign = TextAlign.Center,
+ )
+
+ Spacer(Modifier.height(50.dp))
+
+ Text(
+ stringResource(R.string.no_conversations_text),
+ style = MaterialTheme.typography.bodyLarge,
+ textAlign = TextAlign.Center,
+ )
+
+ Spacer(Modifier.weight(1f))
+ ExampleTile()
+ Spacer(Modifier.weight(1f))
+
+ val androidColors = LocalAndroidColorScheme.current
+ Button(
+ onGotItClicked,
+ Modifier.fillMaxWidth().defaultMinSize(minHeight = 56.dp),
+ colors =
+ ButtonDefaults.buttonColors(
+ containerColor = androidColors.colorAccentPrimary,
+ contentColor = androidColors.textColorOnAccent,
+ )
+ ) { Text(stringResource(R.string.got_it)) }
+ }
+}
+
+@Composable
+private fun ExampleTile() {
+ val androidColors = LocalAndroidColorScheme.current
+ Surface(
+ shape = RoundedCornerShape(28.dp),
+ color = androidColors.colorSurface,
+ contentColor = androidColors.textColorPrimary,
+ ) {
+ Row(
+ Modifier.padding(vertical = 20.dp, horizontal = 16.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
+ // TODO(b/238993727): Add a content description.
+ Image(
+ painterResource(R.drawable.ic_avatar_with_badge),
+ contentDescription = null,
+ Modifier.size(40.dp),
+ )
+ Spacer(Modifier.height(2.dp))
+ Text(
+ stringResource(R.string.empty_user_name),
+ style = MaterialTheme.typography.labelMedium,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+
+ Spacer(Modifier.width(24.dp))
+
+ Text(
+ stringResource(R.string.empty_status),
+ style = MaterialTheme.typography.labelMedium,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+ }
+}