Add Spinner for SpaLib

This can be used to select one of the options.

Bug: 235727273
Test: Unit test & Manual with Gallery App
Change-Id: Ic43328dc85909611b8125b4af4a3f666b6484ec1
diff --git a/packages/SettingsLib/Spa/gallery/src/com/android/settingslib/spa/gallery/SpaEnvironment.kt b/packages/SettingsLib/Spa/gallery/src/com/android/settingslib/spa/gallery/SpaEnvironment.kt
index e300624..3f37534 100644
--- a/packages/SettingsLib/Spa/gallery/src/com/android/settingslib/spa/gallery/SpaEnvironment.kt
+++ b/packages/SettingsLib/Spa/gallery/src/com/android/settingslib/spa/gallery/SpaEnvironment.kt
@@ -24,6 +24,7 @@
 import com.android.settingslib.spa.gallery.page.SettingsPagerPageProvider
 import com.android.settingslib.spa.gallery.page.SliderPageProvider
 import com.android.settingslib.spa.gallery.page.SwitchPreferencePageProvider
+import com.android.settingslib.spa.gallery.ui.SpinnerPageProvider
 
 val galleryPageProviders = SettingsPageProviderRepository(
     allPagesList = listOf(
@@ -32,6 +33,7 @@
         SwitchPreferencePageProvider,
         ArgumentPageProvider,
         SliderPageProvider,
+        SpinnerPageProvider,
         SettingsPagerPageProvider,
         FooterPageProvider,
     ),
diff --git a/packages/SettingsLib/Spa/gallery/src/com/android/settingslib/spa/gallery/home/HomePage.kt b/packages/SettingsLib/Spa/gallery/src/com/android/settingslib/spa/gallery/home/HomePage.kt
index a85ee2a..089920c 100644
--- a/packages/SettingsLib/Spa/gallery/src/com/android/settingslib/spa/gallery/home/HomePage.kt
+++ b/packages/SettingsLib/Spa/gallery/src/com/android/settingslib/spa/gallery/home/HomePage.kt
@@ -29,6 +29,7 @@
 import com.android.settingslib.spa.gallery.page.SettingsPagerPageProvider
 import com.android.settingslib.spa.gallery.page.SliderPageProvider
 import com.android.settingslib.spa.gallery.page.SwitchPreferencePageProvider
+import com.android.settingslib.spa.gallery.ui.SpinnerPageProvider
 import com.android.settingslib.spa.widget.scaffold.HomeScaffold
 
 object HomePageProvider : SettingsPageProvider {
@@ -48,6 +49,7 @@
         ArgumentPageProvider.EntryItem(stringParam = "foo", intParam = 0)
 
         SliderPageProvider.EntryItem()
+        SpinnerPageProvider.EntryItem()
         SettingsPagerPageProvider.EntryItem()
         FooterPageProvider.EntryItem()
     }
diff --git a/packages/SettingsLib/Spa/gallery/src/com/android/settingslib/spa/gallery/ui/SpinnerPage.kt b/packages/SettingsLib/Spa/gallery/src/com/android/settingslib/spa/gallery/ui/SpinnerPage.kt
new file mode 100644
index 0000000..7efa85b
--- /dev/null
+++ b/packages/SettingsLib/Spa/gallery/src/com/android/settingslib/spa/gallery/ui/SpinnerPage.kt
@@ -0,0 +1,75 @@
+/*
+ * 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.settingslib.spa.gallery.ui
+
+import android.os.Bundle
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.ui.tooling.preview.Preview
+import com.android.settingslib.spa.framework.common.SettingsPageProvider
+import com.android.settingslib.spa.framework.compose.navigator
+import com.android.settingslib.spa.framework.theme.SettingsTheme
+import com.android.settingslib.spa.widget.preference.Preference
+import com.android.settingslib.spa.widget.preference.PreferenceModel
+import com.android.settingslib.spa.widget.scaffold.RegularScaffold
+import com.android.settingslib.spa.widget.ui.Spinner
+
+private const val TITLE = "Sample Spinner"
+
+object SpinnerPageProvider : SettingsPageProvider {
+    override val name = "Spinner"
+
+    @Composable
+    override fun Page(arguments: Bundle?) {
+        SpinnerPage()
+    }
+
+    @Composable
+    fun EntryItem() {
+        Preference(object : PreferenceModel {
+            override val title = TITLE
+            override val onClick = navigator(name)
+        })
+    }
+}
+
+@Composable
+private fun SpinnerPage() {
+    RegularScaffold(title = TITLE) {
+        val selectedIndex = rememberSaveable { mutableStateOf(0) }
+        Spinner(
+            options = (1..3).map { "Option $it" },
+            selectedIndex = selectedIndex.value,
+            setIndex = { selectedIndex.value = it },
+        )
+        Preference(object : PreferenceModel {
+            override val title = "Selected index"
+            override val summary = remember { derivedStateOf { selectedIndex.value.toString() } }
+        })
+    }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun SpinnerPagePreview() {
+    SettingsTheme {
+        SpinnerPage()
+    }
+}
diff --git a/packages/SettingsLib/Spa/spa/src/com/android/settingslib/spa/framework/theme/SettingsColors.kt b/packages/SettingsLib/Spa/spa/src/com/android/settingslib/spa/framework/theme/SettingsColors.kt
index 27fdc91..bc316f7 100644
--- a/packages/SettingsLib/Spa/spa/src/com/android/settingslib/spa/framework/theme/SettingsColors.kt
+++ b/packages/SettingsLib/Spa/spa/src/com/android/settingslib/spa/framework/theme/SettingsColors.kt
@@ -31,6 +31,10 @@
     val secondaryText: Color = Color.Unspecified,
     val primaryContainer: Color = Color.Unspecified,
     val onPrimaryContainer: Color = Color.Unspecified,
+    val spinnerHeaderContainer: Color = Color.Unspecified,
+    val onSpinnerHeaderContainer: Color = Color.Unspecified,
+    val spinnerItemContainer: Color = Color.Unspecified,
+    val onSpinnerItemContainer: Color = Color.Unspecified,
 )
 
 internal val LocalColorScheme = staticCompositionLocalOf { SettingsColorScheme() }
@@ -65,6 +69,10 @@
         secondaryText = tonalPalette.neutralVariant30,
         primaryContainer = tonalPalette.primary90,
         onPrimaryContainer = tonalPalette.neutral10,
+        spinnerHeaderContainer = tonalPalette.primary90,
+        onSpinnerHeaderContainer = tonalPalette.neutral10,
+        spinnerItemContainer = tonalPalette.secondary90,
+        onSpinnerItemContainer = tonalPalette.neutralVariant30,
     )
 }
 
@@ -87,5 +95,9 @@
         secondaryText = tonalPalette.neutralVariant80,
         primaryContainer = tonalPalette.secondary90,
         onPrimaryContainer = tonalPalette.neutral10,
+        spinnerHeaderContainer = tonalPalette.primary90,
+        onSpinnerHeaderContainer = tonalPalette.neutral10,
+        spinnerItemContainer = tonalPalette.secondary90,
+        onSpinnerItemContainer = tonalPalette.neutralVariant30,
     )
 }
diff --git a/packages/SettingsLib/Spa/spa/src/com/android/settingslib/spa/widget/ui/Spinner.kt b/packages/SettingsLib/Spa/spa/src/com/android/settingslib/spa/widget/ui/Spinner.kt
new file mode 100644
index 0000000..429b81a
--- /dev/null
+++ b/packages/SettingsLib/Spa/spa/src/com/android/settingslib/spa/widget/ui/Spinner.kt
@@ -0,0 +1,131 @@
+/*
+ * 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.settingslib.spa.widget.ui
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.selection.selectableGroup
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.ArrowDropDown
+import androidx.compose.material.icons.outlined.ArrowDropUp
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.DropdownMenu
+import androidx.compose.material3.DropdownMenuItem
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.DpOffset
+import androidx.compose.ui.unit.dp
+import com.android.settingslib.spa.framework.theme.SettingsDimension
+import com.android.settingslib.spa.framework.theme.SettingsTheme
+
+@Composable
+fun Spinner(options: List<String>, selectedIndex: Int, setIndex: (index: Int) -> Unit) {
+    if (options.isEmpty()) {
+        return
+    }
+
+    var expanded by rememberSaveable { mutableStateOf(false) }
+
+    Box(
+        modifier = Modifier
+            .padding(SettingsDimension.itemPadding)
+            .selectableGroup(),
+    ) {
+        val contentPadding = PaddingValues(horizontal = SettingsDimension.itemPaddingEnd)
+        Button(
+            onClick = { expanded = true },
+            modifier = Modifier.height(36.dp),
+            colors = ButtonDefaults.buttonColors(
+                containerColor = SettingsTheme.colorScheme.spinnerHeaderContainer,
+                contentColor = SettingsTheme.colorScheme.onSpinnerHeaderContainer,
+            ),
+            contentPadding = contentPadding,
+        ) {
+            SpinnerText(options[selectedIndex])
+            Icon(
+                imageVector = when {
+                    expanded -> Icons.Outlined.ArrowDropUp
+                    else -> Icons.Outlined.ArrowDropDown
+                },
+                contentDescription = null,
+            )
+        }
+        DropdownMenu(
+            expanded = expanded,
+            onDismissRequest = { expanded = false },
+            modifier = Modifier.background(SettingsTheme.colorScheme.spinnerItemContainer),
+            offset = DpOffset(x = 0.dp, y = 4.dp),
+        ) {
+            options.forEachIndexed { index, option ->
+                DropdownMenuItem(
+                    text = {
+                        SpinnerText(
+                            text = option,
+                            modifier = Modifier.padding(end = 24.dp),
+                            color = SettingsTheme.colorScheme.onSpinnerItemContainer,
+                        )
+                    },
+                    onClick = {
+                        expanded = false
+                        setIndex(index)
+                    },
+                    contentPadding = contentPadding,
+                )
+            }
+        }
+    }
+}
+
+@Composable
+private fun SpinnerText(
+    text: String,
+    modifier: Modifier = Modifier,
+    color: Color = Color.Unspecified,
+) {
+    Text(
+        text = text,
+        modifier = modifier.padding(end = SettingsDimension.itemPaddingEnd),
+        color = color,
+        style = MaterialTheme.typography.labelLarge,
+    )
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun SpinnerPreview() {
+    SettingsTheme {
+        var selectedIndex by rememberSaveable { mutableStateOf(0) }
+        Spinner(
+            options = (1..3).map { "Option $it" },
+            selectedIndex = selectedIndex,
+            setIndex = { selectedIndex = it },
+        )
+    }
+}
diff --git a/packages/SettingsLib/Spa/tests/Android.bp b/packages/SettingsLib/Spa/tests/Android.bp
index 037d8c4..1ce49fa 100644
--- a/packages/SettingsLib/Spa/tests/Android.bp
+++ b/packages/SettingsLib/Spa/tests/Android.bp
@@ -31,6 +31,7 @@
         "androidx.compose.runtime_runtime",
         "androidx.compose.ui_ui-test-junit4",
         "androidx.compose.ui_ui-test-manifest",
+        "truth-prebuilt",
     ],
     kotlincflags: ["-Xjvm-default=all"],
 }
diff --git a/packages/SettingsLib/Spa/tests/build.gradle b/packages/SettingsLib/Spa/tests/build.gradle
index be5a5ec..5f93a9f 100644
--- a/packages/SettingsLib/Spa/tests/build.gradle
+++ b/packages/SettingsLib/Spa/tests/build.gradle
@@ -63,5 +63,6 @@
     androidTestImplementation(project(":spa"))
     androidTestImplementation 'androidx.test.ext:junit-ktx:1.1.3'
     androidTestImplementation("androidx.compose.ui:ui-test-junit4:$jetpack_compose_version")
+    androidTestImplementation 'com.google.truth:truth:1.1.3'
     androidTestDebugImplementation "androidx.compose.ui:ui-test-manifest:$jetpack_compose_version"
 }
diff --git a/packages/SettingsLib/Spa/tests/src/com/android/settingslib/spa/widget/ui/SpinnerTest.kt b/packages/SettingsLib/Spa/tests/src/com/android/settingslib/spa/widget/ui/SpinnerTest.kt
new file mode 100644
index 0000000..6c56d63
--- /dev/null
+++ b/packages/SettingsLib/Spa/tests/src/com/android/settingslib/spa/widget/ui/SpinnerTest.kt
@@ -0,0 +1,71 @@
+/*
+ * 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.settingslib.spa.widget.ui
+
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.test.onNodeWithText
+import androidx.compose.ui.test.performClick
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.google.common.truth.Truth.assertThat
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class SpinnerTest {
+    @get:Rule
+    val composeTestRule = createComposeRule()
+
+    @Test
+    fun spinner_initialState() {
+        var selectedIndex by mutableStateOf(0)
+        composeTestRule.setContent {
+            Spinner(
+                options = (1..3).map { "Option $it" },
+                selectedIndex = selectedIndex,
+                setIndex = { selectedIndex = it },
+            )
+        }
+
+        composeTestRule.onNodeWithText("Option 1").assertIsDisplayed()
+        composeTestRule.onNodeWithText("Option 2").assertDoesNotExist()
+        assertThat(selectedIndex).isEqualTo(0)
+    }
+
+    @Test
+    fun spinner_canChangeState() {
+        var selectedIndex by mutableStateOf(0)
+        composeTestRule.setContent {
+            Spinner(
+                options = (1..3).map { "Option $it" },
+                selectedIndex = selectedIndex,
+                setIndex = { selectedIndex = it },
+            )
+        }
+
+        composeTestRule.onNodeWithText("Option 1").performClick()
+        composeTestRule.onNodeWithText("Option 2").performClick()
+
+        composeTestRule.onNodeWithText("Option 1").assertDoesNotExist()
+        composeTestRule.onNodeWithText("Option 2").assertIsDisplayed()
+        assertThat(selectedIndex).isEqualTo(1)
+    }
+}