Add a demo of building a custom PIN input with BasicTextField2.

Also added TextFieldState.clearText().

Screencast: http://screencast/cast/NjI0OTUxODk2MzA5NzYwMHw5NDQ3NTgyMC00Mg

Bug: b/277380808
Test: n/a
Relnote: n/a
Change-Id: I71876e87dd4aee62cd91980c3ec6bf43e17f508c
diff --git a/compose/foundation/foundation/api/public_plus_experimental_current.txt b/compose/foundation/foundation/api/public_plus_experimental_current.txt
index f1eb981..8717a03 100644
--- a/compose/foundation/foundation/api/public_plus_experimental_current.txt
+++ b/compose/foundation/foundation/api/public_plus_experimental_current.txt
@@ -1457,6 +1457,7 @@
   }
 
   public final class TextFieldStateKt {
+    method @androidx.compose.foundation.ExperimentalFoundationApi public static void clearText(androidx.compose.foundation.text2.input.TextFieldState);
     method @androidx.compose.foundation.ExperimentalFoundationApi public static suspend Object? forEachTextValue(androidx.compose.foundation.text2.input.TextFieldState, kotlin.jvm.functions.Function2<? super androidx.compose.foundation.text2.input.TextFieldCharSequence,? super kotlin.coroutines.Continuation<? super kotlin.Unit>,?> block, kotlin.coroutines.Continuation<?>);
     method @androidx.compose.foundation.ExperimentalFoundationApi @androidx.compose.runtime.Composable public static androidx.compose.foundation.text2.input.TextFieldState rememberTextFieldState();
     method @androidx.compose.foundation.ExperimentalFoundationApi public static void setTextAndPlaceCursorAtEnd(androidx.compose.foundation.text2.input.TextFieldState, String text);
diff --git a/compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/text/TextDemos.kt b/compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/text/TextDemos.kt
index 5df02d0..9e8d876 100644
--- a/compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/text/TextDemos.kt
+++ b/compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/text/TextDemos.kt
@@ -17,11 +17,12 @@
 package androidx.compose.foundation.demos.text
 
 import androidx.compose.foundation.demos.text2.BasicSecureTextFieldDemos
+import androidx.compose.foundation.demos.text2.BasicTextField2CustomPinFieldDemo
 import androidx.compose.foundation.demos.text2.BasicTextField2Demos
 import androidx.compose.foundation.demos.text2.BasicTextField2FilterDemos
-import androidx.compose.foundation.demos.text2.ScrollableDemos
 import androidx.compose.foundation.demos.text2.DecorationBoxDemos
 import androidx.compose.foundation.demos.text2.KeyboardOptionsDemos
+import androidx.compose.foundation.demos.text2.ScrollableDemos
 import androidx.compose.integration.demos.common.ComposableDemo
 import androidx.compose.integration.demos.common.DemoCategory
 
@@ -123,24 +124,13 @@
         DemoCategory(
             "BasicTextField2",
             listOf(
-                ComposableDemo("Basic text input") {
-                    BasicTextField2Demos()
-                },
-                ComposableDemo("Keyboard Options") {
-                    KeyboardOptionsDemos()
-                },
-                ComposableDemo("Decoration Box") {
-                    DecorationBoxDemos()
-                },
-                ComposableDemo("Scroll") {
-                    ScrollableDemos()
-                },
-                ComposableDemo("Filters") {
-                    BasicTextField2FilterDemos()
-                },
-                ComposableDemo("Secure Field") {
-                    BasicSecureTextFieldDemos()
-                }
+                ComposableDemo("Basic text input") { BasicTextField2Demos() },
+                ComposableDemo("Keyboard Options") { KeyboardOptionsDemos() },
+                ComposableDemo("Decoration Box") { DecorationBoxDemos() },
+                ComposableDemo("Scroll") { ScrollableDemos() },
+                ComposableDemo("Filters") { BasicTextField2FilterDemos() },
+                ComposableDemo("Secure Field") { BasicSecureTextFieldDemos() },
+                ComposableDemo("Custom PIN field") { BasicTextField2CustomPinFieldDemo() },
             )
         ),
         DemoCategory(
diff --git a/compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/text2/BasicTextField2CustomPinFieldDemo.kt b/compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/text2/BasicTextField2CustomPinFieldDemo.kt
new file mode 100644
index 0000000..2fca5e7
--- /dev/null
+++ b/compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/text2/BasicTextField2CustomPinFieldDemo.kt
@@ -0,0 +1,226 @@
+/*
+ * Copyright 2023 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.
+ */
+
+@file:OptIn(ExperimentalFoundationApi::class)
+
+package androidx.compose.foundation.demos.text2
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.core.animateDpAsState
+import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.border
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.wrapContentHeight
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.foundation.text2.BasicTextField2
+import androidx.compose.foundation.text2.input.TextEditFilter
+import androidx.compose.foundation.text2.input.TextFieldBufferWithSelection
+import androidx.compose.foundation.text2.input.TextFieldCharSequence
+import androidx.compose.foundation.text2.input.TextFieldState
+import androidx.compose.foundation.text2.input.clearText
+import androidx.compose.foundation.text2.input.maxLengthInChars
+import androidx.compose.foundation.text2.input.then
+import androidx.compose.material.CircularProgressIndicator
+import androidx.compose.material.LocalContentAlpha
+import androidx.compose.material.LocalContentColor
+import androidx.compose.material.MaterialTheme
+import androidx.compose.material.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.Stable
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.snapshotFlow
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.graphics.BlurEffect
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.TileMode
+import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.text.SpanStyle
+import androidx.compose.ui.text.buildAnnotatedString
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.text.style.TextDecoration
+import androidx.compose.ui.text.withStyle
+import androidx.compose.ui.unit.dp
+import androidx.core.text.isDigitsOnly
+import kotlin.random.Random
+import kotlinx.coroutines.awaitCancellation
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.flow.filter
+
+@Composable
+fun BasicTextField2CustomPinFieldDemo() {
+    val viewModel = remember { VerifyPinViewModel() }
+    VerifyPinScreen(viewModel)
+}
+
+@Suppress("AnimateAsStateLabel")
+@Composable
+private fun VerifyPinScreen(viewModel: VerifyPinViewModel) {
+    val focusRequester = remember { FocusRequester() }
+
+    LaunchedEffect(viewModel) {
+        viewModel.run()
+    }
+
+    if (!viewModel.isLoading) {
+        DisposableEffect(Unit) {
+            focusRequester.requestFocus()
+            onDispose {}
+        }
+    }
+    val blurRadius by animateDpAsState(if (viewModel.isLoading) 5.dp else 0.dp)
+    val scale by animateFloatAsState(if (viewModel.isLoading) 0.85f else 1f)
+
+    Column(
+        horizontalAlignment = Alignment.CenterHorizontally,
+        modifier = Modifier
+            .fillMaxSize()
+            .wrapContentHeight()
+    ) {
+        PinField(
+            viewModel.pinState,
+            enabled = !viewModel.isLoading,
+            modifier = Modifier
+                .focusRequester(focusRequester)
+                .graphicsLayer {
+                    if (blurRadius != 0.dp) {
+                        val blurRadiusPx = blurRadius.toPx()
+                        renderEffect =
+                            BlurEffect(blurRadiusPx, blurRadiusPx, edgeTreatment = TileMode.Decal)
+                    }
+                    scaleX = scale
+                    scaleY = scale
+                }
+        )
+        AnimatedVisibility(visible = viewModel.isLoading) {
+            CircularProgressIndicator(Modifier.padding(top = 8.dp))
+        }
+    }
+}
+
+private class VerifyPinViewModel {
+    val pinState = PinState(maxDigits = 6)
+    val isLoading: Boolean by derivedStateOf { pinState.digits.length == 6 }
+
+    suspend fun run() {
+        snapshotFlow { pinState.digits }
+            .filter { it.length == 6 }
+            .collectLatest { digits ->
+                validatePin(digits)
+            }
+    }
+
+    private suspend fun validatePin(digits: String): Boolean {
+        val random = Random(digits.toInt())
+        val isValid = random.nextBoolean()
+
+        if (isValid) {
+            awaitCancellation()
+        } else {
+            val delay = random.nextInt(3, 8) * 250
+            delay(delay.toLong())
+            pinState.clear()
+            return false
+        }
+    }
+}
+
+@Stable
+private class PinState(val maxDigits: Int) {
+    val digits: String by derivedStateOf {
+        textState.text.toString()
+    }
+
+    /*internal*/ val textState = TextFieldState()
+    /*internal*/ val filter: TextEditFilter = OnlyDigitsFilter.then(
+        TextEditFilter.maxLengthInChars(maxDigits),
+        keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.NumberPassword)
+    )
+
+    fun clear() {
+        textState.clearText()
+    }
+
+    private object OnlyDigitsFilter : TextEditFilter {
+        override fun filter(
+            originalValue: TextFieldCharSequence,
+            valueWithChanges: TextFieldBufferWithSelection
+        ) {
+            if (!valueWithChanges.isDigitsOnly()) {
+                valueWithChanges.revertAllChanges()
+            }
+        }
+    }
+}
+
+@Composable
+private fun PinField(
+    state: PinState,
+    modifier: Modifier = Modifier,
+    enabled: Boolean = true
+) {
+    val contentAlpha = if (enabled) 1f else 0.3f
+    val contentColor = LocalContentColor.current.copy(alpha = contentAlpha)
+
+    BasicTextField2(
+        state = state.textState,
+        filter = state.filter,
+        modifier = modifier
+            .border(1.dp, contentColor, RoundedCornerShape(8.dp))
+            .padding(8.dp),
+        enabled = enabled
+    ) {
+        CompositionLocalProvider(LocalContentAlpha provides contentAlpha) {
+            // Ignore inner field, we'll draw it ourselves.
+            PinContents(state)
+        }
+    }
+}
+
+@Composable
+private fun PinContents(state: PinState) {
+    val focusedColor = MaterialTheme.colors.secondary.copy(alpha = LocalContentAlpha.current)
+    val text = buildAnnotatedString {
+        val digits = state.digits
+        repeat(state.maxDigits) { i ->
+            withStyle(
+                SpanStyle(
+                    textDecoration = TextDecoration.Underline,
+                    background = if (digits.length == i) focusedColor else Color.Unspecified,
+                )
+            ) {
+                append(if (digits.length > i) digits[i].toString() else " ")
+            }
+            if (i < state.maxDigits - 1) {
+                append(" - ")
+            }
+        }
+    }
+    Text(text, fontFamily = FontFamily.Monospace)
+}
\ No newline at end of file
diff --git a/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text2/input/TextFieldState.kt b/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text2/input/TextFieldState.kt
index 6173da3..f88738b 100644
--- a/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text2/input/TextFieldState.kt
+++ b/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text2/input/TextFieldState.kt
@@ -33,8 +33,8 @@
  * The editable text state of a text field, including both the [text] itself and position of the
  * cursor or selection.
  *
- * To change the text field contents programmatically, call [edit], [setTextAndSelectAll], or
- * [setTextAndPlaceCursorAtEnd]. To observe the value of the field over time, call
+ * To change the text field contents programmatically, call [edit], [setTextAndSelectAll],
+ * [setTextAndPlaceCursorAtEnd], or [clearText]. To observe the value of the field over time, call
  * [forEachTextValue] or [textAsFlow].
  *
  * When instantiating this class from a composable, use [rememberTextFieldState] to automatically
@@ -170,6 +170,7 @@
  * ```
  *
  * @see setTextAndSelectAll
+ * @see clearText
  * @see TextFieldBuffer.placeCursorAtEnd
  */
 @ExperimentalFoundationApi
@@ -194,6 +195,7 @@
  * ```
  *
  * @see setTextAndPlaceCursorAtEnd
+ * @see clearText
  * @see TextFieldBuffer.selectAll
  */
 @ExperimentalFoundationApi
@@ -205,6 +207,29 @@
 }
 
 /**
+ * Deletes all the text in the state.
+ *
+ * To perform more complicated edits on the text, call [TextFieldState.edit]. This function is
+ * equivalent to calling:
+ * ```
+ * edit {
+ *   delete(0, length)
+ *   placeCursorAtEnd()
+ * }
+ * ```
+ *
+ * @see setTextAndPlaceCursorAtEnd
+ * @see setTextAndSelectAll
+ */
+@ExperimentalFoundationApi
+fun TextFieldState.clearText() {
+    edit {
+        delete(0, length)
+        placeCursorAtEnd()
+    }
+}
+
+/**
  * Invokes [block] with the value of [TextFieldState.text], and every time the value is changed.
  *
  * The caller will be suspended until its coroutine is cancelled. If the text is changed while