Merge "[expressive design] Migrate AppList Page." into main
diff --git a/packages/SettingsLib/Spa/gallery/src/com/android/settingslib/spa/gallery/ui/CategoryPageProvider.kt b/packages/SettingsLib/Spa/gallery/src/com/android/settingslib/spa/gallery/ui/CategoryPageProvider.kt
index 4d3a78a5..f2bc380 100644
--- a/packages/SettingsLib/Spa/gallery/src/com/android/settingslib/spa/gallery/ui/CategoryPageProvider.kt
+++ b/packages/SettingsLib/Spa/gallery/src/com/android/settingslib/spa/gallery/ui/CategoryPageProvider.kt
@@ -17,8 +17,12 @@
 package com.android.settingslib.spa.gallery.ui
 
 import android.os.Bundle
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.height
 import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
 import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
 import com.android.settingslib.spa.framework.common.SettingsEntry
 import com.android.settingslib.spa.framework.common.SettingsEntryBuilder
 import com.android.settingslib.spa.framework.common.SettingsPageProvider
@@ -30,6 +34,7 @@
 import com.android.settingslib.spa.widget.preference.SimplePreferenceMacro
 import com.android.settingslib.spa.widget.scaffold.RegularScaffold
 import com.android.settingslib.spa.widget.ui.Category
+import com.android.settingslib.spa.widget.ui.LazyCategory
 
 private const val TITLE = "Sample Category"
 
@@ -65,7 +70,7 @@
         )
         entryList.add(
             SettingsEntryBuilder.create("Preference 3", owner)
-                .setMacro { SimplePreferenceMacro(title = "Preference 2", summary = "Summary 3") }
+                .setMacro { SimplePreferenceMacro(title = "Preference 3", summary = "Summary 3") }
                 .build()
         )
         entryList.add(
@@ -88,6 +93,13 @@
                 entries[2].UiLayout()
                 entries[3].UiLayout()
             }
+            Column(Modifier.height(200.dp)) {
+                LazyCategory(
+                    list = entries,
+                    entry = { index: Int -> @Composable { entries[index].UiLayout() } },
+                    title = { index: Int -> if (index == 0 || index == 2) "LazyCategory" else null },
+                ) {}
+            }
         }
     }
 }
diff --git a/packages/SettingsLib/Spa/spa/src/com/android/settingslib/spa/widget/ui/Category.kt b/packages/SettingsLib/Spa/spa/src/com/android/settingslib/spa/widget/ui/Category.kt
index 66680fa..28b2b4a 100644
--- a/packages/SettingsLib/Spa/spa/src/com/android/settingslib/spa/widget/ui/Category.kt
+++ b/packages/SettingsLib/Spa/spa/src/com/android/settingslib/spa/widget/ui/Category.kt
@@ -19,8 +19,13 @@
 import androidx.compose.foundation.layout.Arrangement
 import androidx.compose.foundation.layout.Column
 import androidx.compose.foundation.layout.ColumnScope
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.fillMaxSize
 import androidx.compose.foundation.layout.fillMaxWidth
 import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.LazyListState
+import androidx.compose.foundation.lazy.rememberLazyListState
 import androidx.compose.material.icons.Icons
 import androidx.compose.material.icons.outlined.TouchApp
 import androidx.compose.material3.MaterialTheme
@@ -34,6 +39,7 @@
 import androidx.compose.ui.draw.clip
 import androidx.compose.ui.layout.onGloballyPositioned
 import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.Dp
 import androidx.compose.ui.unit.dp
 import com.android.settingslib.spa.framework.theme.SettingsDimension
 import com.android.settingslib.spa.framework.theme.SettingsShape
@@ -98,6 +104,57 @@
     }
 }
 
+/**
+ * A container that is used to group items with lazy loading.
+ *
+ * @param list The list of items to display.
+ * @param entry The entry for each list item according to its index in list.
+ * @param key Optional. The key for each item in list to provide unique item identifiers, making
+ * the list more efficient.
+ * @param title Optional. Category title for each item or each group of items in the list. It
+ * should be decided by the index.
+ * @param bottomPadding Optional. Bottom outside padding of the category.
+ * @param state Optional. State of LazyList.
+ * @param content Optional. Content to be shown at the top of the category.
+ */
+
+@Composable
+fun LazyCategory(
+    list: List<Any>,
+    entry: (Int) -> @Composable () -> Unit,
+    key: ((Int) -> Any)? = null,
+    title: ((Int) -> String?)? = null,
+    bottomPadding: Dp = SettingsDimension.paddingSmall,
+    state: LazyListState = rememberLazyListState(),
+    content: @Composable () -> Unit,
+) {
+    Column(
+        Modifier.padding(
+                PaddingValues(
+                    start = SettingsDimension.paddingLarge,
+                    end = SettingsDimension.paddingLarge,
+                    top = SettingsDimension.paddingSmall,
+                    bottom = bottomPadding,
+                )
+            )
+            .clip(SettingsShape.CornerMedium2)
+    ) {
+        LazyColumn(
+            modifier = Modifier.fillMaxSize(),
+            verticalArrangement = Arrangement.spacedBy(SettingsDimension.paddingTiny),
+            state = state,
+        ) {
+            item { content() }
+
+            items(count = list.size, key = key) {
+                title?.invoke(it)?.let { title -> CategoryTitle(title) }
+                val entryPreference = entry(it)
+                entryPreference()
+            }
+        }
+    }
+}
+
 @Preview
 @Composable
 private fun CategoryPreview() {
diff --git a/packages/SettingsLib/Spa/tests/src/com/android/settingslib/spa/widget/ui/CategoryTest.kt b/packages/SettingsLib/Spa/tests/src/com/android/settingslib/spa/widget/ui/CategoryTest.kt
index 09a6e6d..4b4a8c2 100644
--- a/packages/SettingsLib/Spa/tests/src/com/android/settingslib/spa/widget/ui/CategoryTest.kt
+++ b/packages/SettingsLib/Spa/tests/src/com/android/settingslib/spa/widget/ui/CategoryTest.kt
@@ -16,10 +16,16 @@
 
 package com.android.settingslib.spa.widget.ui
 
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.height
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
 import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
 import androidx.compose.ui.test.assertIsDisplayed
 import androidx.compose.ui.test.junit4.createComposeRule
 import androidx.compose.ui.test.onNodeWithText
+import androidx.compose.ui.unit.dp
 import androidx.test.ext.junit.runners.AndroidJUnit4
 import com.android.settingslib.spa.widget.preference.Preference
 import com.android.settingslib.spa.widget.preference.PreferenceModel
@@ -30,14 +36,11 @@
 @RunWith(AndroidJUnit4::class)
 class CategoryTest {
 
-    @get:Rule
-    val composeTestRule = createComposeRule()
+    @get:Rule val composeTestRule = createComposeRule()
 
     @Test
     fun categoryTitle() {
-        composeTestRule.setContent {
-            CategoryTitle(title = "CategoryTitle")
-        }
+        composeTestRule.setContent { CategoryTitle(title = "CategoryTitle") }
 
         composeTestRule.onNodeWithText("CategoryTitle").assertIsDisplayed()
     }
@@ -46,12 +49,14 @@
     fun category_hasContent_titleDisplayed() {
         composeTestRule.setContent {
             Category(title = "CategoryTitle") {
-                Preference(remember {
-                    object : PreferenceModel {
-                        override val title = "Some Preference"
-                        override val summary = { "Some summary" }
+                Preference(
+                    remember {
+                        object : PreferenceModel {
+                            override val title = "Some Preference"
+                            override val summary = { "Some summary" }
+                        }
                     }
-                })
+                )
             }
         }
 
@@ -60,10 +65,45 @@
 
     @Test
     fun category_noContent_titleNotDisplayed() {
-        composeTestRule.setContent {
-            Category(title = "CategoryTitle") {}
-        }
+        composeTestRule.setContent { Category(title = "CategoryTitle") {} }
 
         composeTestRule.onNodeWithText("CategoryTitle").assertDoesNotExist()
     }
+
+    @Test
+    fun lazyCategory_content_displayed() {
+        composeTestRule.setContent { TestLazyCategory() }
+
+        composeTestRule.onNodeWithText("text").assertExists()
+    }
+
+    @Test
+    fun lazyCategory_title_displayed() {
+        composeTestRule.setContent { TestLazyCategory() }
+
+        composeTestRule.onNodeWithText("LazyCategory 0").assertExists()
+        composeTestRule.onNodeWithText("LazyCategory 1").assertDoesNotExist()
+    }
+}
+
+@Composable
+private fun TestLazyCategory() {
+    val list: List<PreferenceModel> =
+        listOf(
+            object : PreferenceModel {
+                override val title = "title"
+            },
+            object : PreferenceModel {
+                override val title = "title"
+            },
+        )
+    Column(Modifier.height(200.dp)) {
+        LazyCategory(
+            list = list,
+            entry = { index: Int -> @Composable { Preference(list[index]) } },
+            title = { index: Int -> if (index == 0) "LazyCategory $index" else null },
+        ) {
+            Text("text")
+        }
+    }
 }
diff --git a/packages/SettingsLib/SpaPrivileged/src/com/android/settingslib/spaprivileged/template/app/AppList.kt b/packages/SettingsLib/SpaPrivileged/src/com/android/settingslib/spaprivileged/template/app/AppList.kt
index bededf0..2a214b6 100644
--- a/packages/SettingsLib/SpaPrivileged/src/com/android/settingslib/spaprivileged/template/app/AppList.kt
+++ b/packages/SettingsLib/SpaPrivileged/src/com/android/settingslib/spaprivileged/template/app/AppList.kt
@@ -37,7 +37,9 @@
 import com.android.settingslib.spa.framework.compose.LogCompositions
 import com.android.settingslib.spa.framework.compose.TimeMeasurer.Companion.rememberTimeMeasurer
 import com.android.settingslib.spa.framework.compose.rememberLazyListStateAndHideKeyboardWhenStartScroll
+import com.android.settingslib.spa.framework.theme.isSpaExpressiveEnabled
 import com.android.settingslib.spa.widget.ui.CategoryTitle
+import com.android.settingslib.spa.widget.ui.LazyCategory
 import com.android.settingslib.spa.widget.ui.PlaceholderTitle
 import com.android.settingslib.spa.widget.ui.Spinner
 import com.android.settingslib.spa.widget.ui.SpinnerOption
@@ -55,19 +57,14 @@
 private const val TAG = "AppList"
 private const val CONTENT_TYPE_HEADER = "header"
 
-/**
- * The config used to load the App List.
- */
+/** The config used to load the App List. */
 data class AppListConfig(
     val userIds: List<Int>,
     val showInstantApps: Boolean,
     val matchAnyUserForAdmin: Boolean,
 )
 
-data class AppListState(
-    val showSystem: () -> Boolean,
-    val searchQuery: () -> String,
-)
+data class AppListState(val showSystem: () -> Boolean, val searchQuery: () -> String)
 
 data class AppListInput<T : AppRecord>(
     val config: AppListConfig,
@@ -90,7 +87,7 @@
 
 @Composable
 internal fun <T : AppRecord> AppListInput<T>.AppListImpl(
-    viewModelSupplier: @Composable () -> IAppListViewModel<T>,
+    viewModelSupplier: @Composable () -> IAppListViewModel<T>
 ) {
     LogCompositions(TAG, config.userIds.toString())
     val viewModel = viewModelSupplier()
@@ -125,7 +122,7 @@
     appListData: State<AppListData<T>?>,
     header: @Composable () -> Unit,
     bottomPadding: Dp,
-    noItemMessage: String?
+    noItemMessage: String?,
 ) {
     val timeMeasurer = rememberTimeMeasurer(TAG)
     appListData.value?.let { (list, option) ->
@@ -135,40 +132,61 @@
             PlaceholderTitle(noItemMessage ?: stringResource(R.string.no_applications))
             return
         }
-        LazyColumn(
-            modifier = Modifier.fillMaxSize(),
-            state = rememberLazyListStateAndHideKeyboardWhenStartScroll(),
-            contentPadding = PaddingValues(bottom = bottomPadding),
-        ) {
-            item(contentType = CONTENT_TYPE_HEADER) {
+        if (isSpaExpressiveEnabled) {
+            LazyCategory(
+                list = list,
+                entry = { index: Int ->
+                    @Composable {
+                        val appEntry = list[index]
+                        val summary = getSummary(option, appEntry.record) ?: { "" }
+                        remember(appEntry) {
+                                AppListItemModel(appEntry.record, appEntry.label, summary)
+                            }
+                            .AppItem()
+                    }
+                },
+                key = { index: Int -> list[index].record.itemKey(option) },
+                title = { index: Int -> getGroupTitle(option, list[index].record) },
+                bottomPadding = bottomPadding,
+                state = rememberLazyListStateAndHideKeyboardWhenStartScroll(),
+            ) {
                 header()
             }
+        } else {
+            LazyColumn(
+                modifier = Modifier.fillMaxSize(),
+                state = rememberLazyListStateAndHideKeyboardWhenStartScroll(),
+                contentPadding = PaddingValues(bottom = bottomPadding),
+            ) {
+                item(contentType = CONTENT_TYPE_HEADER) { header() }
 
-            items(count = list.size, key = { list[it].record.itemKey(option) }) {
-                remember(list) { getGroupTitleIfFirst(option, list, it) }
-                    ?.let { group -> CategoryTitle(title = group) }
+                items(count = list.size, key = { list[it].record.itemKey(option) }) {
+                    remember(list) { getGroupTitleIfFirst(option, list, it) }
+                        ?.let { group -> CategoryTitle(title = group) }
 
-                val appEntry = list[it]
-                val summary = getSummary(option, appEntry.record) ?: { "" }
-                remember(appEntry) {
-                    AppListItemModel(appEntry.record, appEntry.label, summary)
-                }.AppItem()
+                    val appEntry = list[it]
+                    val summary = getSummary(option, appEntry.record) ?: { "" }
+                    remember(appEntry) {
+                            AppListItemModel(appEntry.record, appEntry.label, summary)
+                        }
+                        .AppItem()
+                }
             }
         }
     }
 }
 
-private fun <T : AppRecord> T.itemKey(option: Int) =
-    listOf(option, app.packageName, app.userId)
+private fun <T : AppRecord> T.itemKey(option: Int) = listOf(option, app.packageName, app.userId)
 
 /** Returns group title if this is the first item of the group. */
 private fun <T : AppRecord> AppListModel<T>.getGroupTitleIfFirst(
     option: Int,
     list: List<AppEntry<T>>,
     index: Int,
-): String? = getGroupTitle(option, list[index].record)?.takeIf {
-    index == 0 || it != getGroupTitle(option, list[index - 1].record)
-}
+): String? =
+    getGroupTitle(option, list[index].record)?.takeIf {
+        index == 0 || it != getGroupTitle(option, list[index - 1].record)
+    }
 
 @Composable
 private fun <T : AppRecord> rememberViewModel(
@@ -183,16 +201,19 @@
     viewModel.searchQuery.Sync(state.searchQuery)
 
     LifecycleEffect(onStart = { viewModel.reloadApps() })
-    val intentFilter = IntentFilter(Intent.ACTION_PACKAGE_ADDED).apply {
-        addAction(Intent.ACTION_PACKAGE_REMOVED)
-        addAction(Intent.ACTION_PACKAGE_CHANGED)
-        addDataScheme("package")
-    }
+    val intentFilter =
+        IntentFilter(Intent.ACTION_PACKAGE_ADDED).apply {
+            addAction(Intent.ACTION_PACKAGE_REMOVED)
+            addAction(Intent.ACTION_PACKAGE_CHANGED)
+            addDataScheme("package")
+        }
     for (userId in config.userIds) {
         DisposableBroadcastReceiverAsUser(
             intentFilter = intentFilter,
             userHandle = UserHandle.of(userId),
-        ) { viewModel.reloadApps() }
+        ) {
+            viewModel.reloadApps()
+        }
     }
     return viewModel
 }