Implement the QS footer actions in Compose (1/2)

This CL reimplements the FooterActions in Compose. This is going to be
one of the first features to be hidden behind a build time flag in our
SystemUICompose build once that flag is available.

See http://b/242040009#comment20 for a video and http://ag/20745047 for
the screenshot difference between the Compose and View implementations.

Bug: 242040009
Test: atest FooterActionsScreenshotTest
Change-Id: I83df8d191c1ab5d95466471c834ac1b0903ad21a
diff --git a/core/res/res/values/symbols.xml b/core/res/res/values/symbols.xml
index b925921..d6fdc55 100644
--- a/core/res/res/values/symbols.xml
+++ b/core/res/res/values/symbols.xml
@@ -2331,6 +2331,7 @@
   <java-symbol type="drawable" name="scrubber_control_selector_holo" />
   <java-symbol type="drawable" name="scrubber_progress_horizontal_holo_dark" />
   <java-symbol type="drawable" name="progress_small_material" />
+  <java-symbol type="drawable" name="ic_chevron_end" />
   <java-symbol type="string" name="chooseUsbActivity" />
   <java-symbol type="string" name="ext_media_badremoval_notification_message" />
   <java-symbol type="string" name="ext_media_badremoval_notification_title" />
diff --git a/packages/SystemUI/compose/core/src/com/android/systemui/compose/animation/Expandable.kt b/packages/SystemUI/compose/core/src/com/android/systemui/compose/animation/Expandable.kt
index 18534f4..d31ca51 100644
--- a/packages/SystemUI/compose/core/src/com/android/systemui/compose/animation/Expandable.kt
+++ b/packages/SystemUI/compose/core/src/com/android/systemui/compose/animation/Expandable.kt
@@ -24,6 +24,7 @@
 import androidx.compose.foundation.background
 import androidx.compose.foundation.border
 import androidx.compose.foundation.clickable
+import androidx.compose.foundation.interaction.MutableInteractionSource
 import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.Spacer
 import androidx.compose.foundation.layout.defaultMinSize
@@ -62,6 +63,7 @@
 import androidx.compose.ui.graphics.drawscope.Stroke
 import androidx.compose.ui.graphics.drawscope.scale
 import androidx.compose.ui.layout.boundsInRoot
+import androidx.compose.ui.layout.findRootCoordinates
 import androidx.compose.ui.layout.layout
 import androidx.compose.ui.layout.onGloballyPositioned
 import androidx.compose.ui.platform.ComposeView
@@ -118,12 +120,14 @@
     contentColor: Color = contentColorFor(color),
     borderStroke: BorderStroke? = null,
     onClick: ((Expandable) -> Unit)? = null,
+    interactionSource: MutableInteractionSource? = null,
     content: @Composable (Expandable) -> Unit,
 ) {
     Expandable(
         rememberExpandableController(color, shape, contentColor, borderStroke),
         modifier,
         onClick,
+        interactionSource,
         content,
     )
 }
@@ -158,6 +162,7 @@
     controller: ExpandableController,
     modifier: Modifier = Modifier,
     onClick: ((Expandable) -> Unit)? = null,
+    interactionSource: MutableInteractionSource? = null,
     content: @Composable (Expandable) -> Unit,
 ) {
     val controller = controller as ExpandableControllerImpl
@@ -190,6 +195,18 @@
 
     var thisExpandableSize by remember { mutableStateOf(Size.Zero) }
 
+    /** Set the current element size as this Expandable size. */
+    fun Modifier.updateExpandableSize(): Modifier {
+        return this.onGloballyPositioned { coords ->
+            thisExpandableSize =
+                coords
+                    .findRootCoordinates()
+                    // Make sure that we report the actual size, and not the visual/clipped one.
+                    .localBoundingBoxOf(coords, clipBounds = false)
+                    .size
+        }
+    }
+
     // Make sure we don't read animatorState directly here to avoid recomposition every time the
     // state changes (i.e. every frame of the animation).
     val isAnimating by remember {
@@ -247,7 +264,7 @@
         controller.isDialogShowing.value -> {
             Box(
                 modifier
-                    .onGloballyPositioned { thisExpandableSize = it.boundsInRoot().size }
+                    .updateExpandableSize()
                     .then(minInteractiveSizeModifier)
                     .drawWithContent { /* Don't draw anything when the dialog is shown. */}
                     .onGloballyPositioned {
@@ -258,18 +275,25 @@
         else -> {
             val clickModifier =
                 if (onClick != null) {
-                    Modifier.clickable { onClick(controller.expandable) }
+                    if (interactionSource != null) {
+                        // If the caller provided an interaction source, then that means that they
+                        // will draw the click indication themselves.
+                        Modifier.clickable(interactionSource, indication = null) {
+                            onClick(controller.expandable)
+                        }
+                    } else {
+                        // If no interaction source is provided, we draw the default indication (a
+                        // ripple) and make sure it's clipped by the expandable shape.
+                        Modifier.clip(shape).clickable { onClick(controller.expandable) }
+                    }
                 } else {
                     Modifier
                 }
 
             Box(
                 modifier
-                    .onGloballyPositioned { thisExpandableSize = it.boundsInRoot().size }
+                    .updateExpandableSize()
                     .then(minInteractiveSizeModifier)
-                    // Note that clip() *must* be above the clickModifier to properly clip the
-                    // ripple.
-                    .clip(shape)
                     .then(clickModifier)
                     .background(color, shape)
                     .border(controller)
diff --git a/packages/SystemUI/compose/core/src/com/android/systemui/compose/theme/AndroidColorScheme.kt b/packages/SystemUI/compose/core/src/com/android/systemui/compose/theme/AndroidColorScheme.kt
index b8639e6..caa7e5f 100644
--- a/packages/SystemUI/compose/core/src/com/android/systemui/compose/theme/AndroidColorScheme.kt
+++ b/packages/SystemUI/compose/core/src/com/android/systemui/compose/theme/AndroidColorScheme.kt
@@ -65,10 +65,12 @@
     val colorForeground = getColor(context, R.attr.colorForeground)
     val colorForegroundInverse = getColor(context, R.attr.colorForegroundInverse)
 
-    private fun getColor(context: Context, attr: Int): Color {
-        val ta = context.obtainStyledAttributes(intArrayOf(attr))
-        @ColorInt val color = ta.getColor(0, 0)
-        ta.recycle()
-        return Color(color)
+    companion object {
+        fun getColor(context: Context, attr: Int): Color {
+            val ta = context.obtainStyledAttributes(intArrayOf(attr))
+            @ColorInt val color = ta.getColor(0, 0)
+            ta.recycle()
+            return Color(color)
+        }
     }
 }
diff --git a/packages/SystemUI/compose/core/src/com/android/systemui/compose/theme/Color.kt b/packages/SystemUI/compose/core/src/com/android/systemui/compose/theme/Color.kt
new file mode 100644
index 0000000..de47cce
--- /dev/null
+++ b/packages/SystemUI/compose/core/src/com/android/systemui/compose/theme/Color.kt
@@ -0,0 +1,30 @@
+/*
+ * 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.compose.theme
+
+import android.annotation.AttrRes
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.ReadOnlyComposable
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalContext
+
+/** Read the [Color] from the given [attribute]. */
+@Composable
+@ReadOnlyComposable
+fun colorAttr(@AttrRes attribute: Int): Color {
+    return AndroidColorScheme.getColor(LocalContext.current, attribute)
+}
diff --git a/packages/SystemUI/compose/features/src/com/android/systemui/common/ui/compose/ContentDescription.kt b/packages/SystemUI/compose/features/src/com/android/systemui/common/ui/compose/ContentDescription.kt
new file mode 100644
index 0000000..4a5ad65
--- /dev/null
+++ b/packages/SystemUI/compose/features/src/com/android/systemui/common/ui/compose/ContentDescription.kt
@@ -0,0 +1,30 @@
+/*
+ * 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.common.ui.compose
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.res.stringResource
+import com.android.systemui.common.shared.model.ContentDescription
+
+/** Returns the loaded [String] or `null` if there isn't one. */
+@Composable
+fun ContentDescription.load(): String? {
+    return when (this) {
+        is ContentDescription.Loaded -> description
+        is ContentDescription.Resource -> stringResource(res)
+    }
+}
diff --git a/packages/SystemUI/compose/features/src/com/android/systemui/common/ui/compose/Icon.kt b/packages/SystemUI/compose/features/src/com/android/systemui/common/ui/compose/Icon.kt
new file mode 100644
index 0000000..6e83124
--- /dev/null
+++ b/packages/SystemUI/compose/features/src/com/android/systemui/common/ui/compose/Icon.kt
@@ -0,0 +1,47 @@
+/*
+ * 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.common.ui.compose
+
+import androidx.compose.material3.Icon
+import androidx.compose.material3.LocalContentColor
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.asImageBitmap
+import androidx.compose.ui.res.painterResource
+import androidx.core.graphics.drawable.toBitmap
+import com.android.systemui.common.shared.model.Icon
+
+/**
+ * Icon composable that draws [icon] using [tint].
+ *
+ * Note: You can use [Color.Unspecified] to disable the tint and keep the original icon colors.
+ */
+@Composable
+fun Icon(
+    icon: Icon,
+    modifier: Modifier = Modifier,
+    tint: Color = LocalContentColor.current,
+) {
+    val contentDescription = icon.contentDescription?.load()
+    when (icon) {
+        is Icon.Loaded -> {
+            Icon(icon.drawable.toBitmap().asImageBitmap(), contentDescription, modifier, tint)
+        }
+        is Icon.Resource -> Icon(painterResource(icon.res), contentDescription, modifier, tint)
+    }
+}
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
index 2bf1937..2aac46e 100644
--- 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
@@ -53,7 +53,6 @@
 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].
diff --git a/packages/SystemUI/compose/features/src/com/android/systemui/qs/footer/ui/compose/FooterActions.kt b/packages/SystemUI/compose/features/src/com/android/systemui/qs/footer/ui/compose/FooterActions.kt
new file mode 100644
index 0000000..654b723
--- /dev/null
+++ b/packages/SystemUI/compose/features/src/com/android/systemui/qs/footer/ui/compose/FooterActions.kt
@@ -0,0 +1,361 @@
+/*
+ * 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.qs.footer.ui.compose
+
+import androidx.compose.foundation.BorderStroke
+import androidx.compose.foundation.Canvas
+import androidx.compose.foundation.LocalIndication
+import androidx.compose.foundation.indication
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.RowScope
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Icon
+import androidx.compose.material3.LocalContentColor
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.draw.drawWithContent
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.layout.layout
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.dimensionResource
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.constrainHeight
+import androidx.compose.ui.unit.constrainWidth
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.em
+import androidx.compose.ui.unit.sp
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleOwner
+import androidx.lifecycle.repeatOnLifecycle
+import com.android.systemui.R
+import com.android.systemui.animation.Expandable
+import com.android.systemui.common.shared.model.Icon
+import com.android.systemui.common.ui.compose.Icon
+import com.android.systemui.compose.animation.Expandable
+import com.android.systemui.compose.modifiers.background
+import com.android.systemui.compose.theme.LocalAndroidColorScheme
+import com.android.systemui.compose.theme.colorAttr
+import com.android.systemui.qs.footer.ui.viewmodel.FooterActionsButtonViewModel
+import com.android.systemui.qs.footer.ui.viewmodel.FooterActionsForegroundServicesButtonViewModel
+import com.android.systemui.qs.footer.ui.viewmodel.FooterActionsSecurityButtonViewModel
+import com.android.systemui.qs.footer.ui.viewmodel.FooterActionsViewModel
+import kotlinx.coroutines.launch
+
+/** The Quick Settings footer actions row. */
+@Composable
+fun FooterActions(
+    viewModel: FooterActionsViewModel,
+    qsVisibilityLifecycleOwner: LifecycleOwner,
+    modifier: Modifier = Modifier,
+) {
+    val context = LocalContext.current
+
+    // Collect visibility and alphas as soon as we are composed, even when not visible.
+    val isVisible by viewModel.isVisible.collectAsState()
+    val alpha by viewModel.alpha.collectAsState()
+    val backgroundAlpha = viewModel.backgroundAlpha.collectAsState()
+
+    var security by remember { mutableStateOf<FooterActionsSecurityButtonViewModel?>(null) }
+    var foregroundServices by remember {
+        mutableStateOf<FooterActionsForegroundServicesButtonViewModel?>(null)
+    }
+    var userSwitcher by remember { mutableStateOf<FooterActionsButtonViewModel?>(null) }
+
+    LaunchedEffect(
+        context,
+        qsVisibilityLifecycleOwner,
+        viewModel,
+        viewModel.security,
+        viewModel.foregroundServices,
+        viewModel.userSwitcher,
+    ) {
+        launch {
+            // Listen for dialog requests as soon as we are composed, even when not visible.
+            viewModel.observeDeviceMonitoringDialogRequests(context)
+        }
+
+        // Listen for model changes only when QS are visible.
+        qsVisibilityLifecycleOwner.repeatOnLifecycle(Lifecycle.State.RESUMED) {
+            launch { viewModel.security.collect { security = it } }
+            launch { viewModel.foregroundServices.collect { foregroundServices = it } }
+            launch { viewModel.userSwitcher.collect { userSwitcher = it } }
+        }
+    }
+
+    val backgroundColor = colorAttr(R.attr.underSurfaceColor)
+    val contentColor = LocalAndroidColorScheme.current.textColorPrimary
+    val backgroundTopRadius = dimensionResource(R.dimen.qs_corner_radius)
+    val backgroundModifier =
+        remember(
+            backgroundColor,
+            backgroundAlpha,
+            backgroundTopRadius,
+        ) {
+            Modifier.background(
+                backgroundColor,
+                backgroundAlpha::value,
+                RoundedCornerShape(topStart = backgroundTopRadius, topEnd = backgroundTopRadius),
+            )
+        }
+
+    Row(
+        modifier
+            .fillMaxWidth()
+            .graphicsLayer { this.alpha = alpha }
+            .drawWithContent {
+                if (isVisible) {
+                    drawContent()
+                }
+            }
+            .then(backgroundModifier)
+            .padding(
+                top = dimensionResource(R.dimen.qs_footer_actions_top_padding),
+                bottom = dimensionResource(R.dimen.qs_footer_actions_bottom_padding),
+            )
+            .layout { measurable, constraints ->
+                // All buttons have a 4dp padding to increase their touch size. To be consistent
+                // with the View implementation, we want to left-most and right-most buttons to be
+                // visually aligned with the left and right sides of this row. So we let this
+                // component be 2*4dp wider and then offset it by -4dp to the start.
+                val inset = 4.dp.roundToPx()
+                val additionalWidth = inset * 2
+                val newConstraints =
+                    if (constraints.hasBoundedWidth) {
+                        constraints.copy(maxWidth = constraints.maxWidth + additionalWidth)
+                    } else {
+                        constraints
+                    }
+                val placeable = measurable.measure(newConstraints)
+
+                val width = constraints.constrainWidth(placeable.width - additionalWidth)
+                val height = constraints.constrainHeight(placeable.height)
+                layout(width, height) { placeable.place(-inset, 0) }
+            },
+        verticalAlignment = Alignment.CenterVertically,
+    ) {
+        CompositionLocalProvider(
+            LocalContentColor provides contentColor,
+        ) {
+            if (security == null && foregroundServices == null) {
+                Spacer(Modifier.weight(1f))
+            }
+
+            security?.let { SecurityButton(it, Modifier.weight(1f)) }
+            foregroundServices?.let { ForegroundServicesButton(it) }
+            userSwitcher?.let { IconButton(it) }
+            IconButton(viewModel.settings)
+            viewModel.power?.let { IconButton(it) }
+        }
+    }
+}
+
+/** The security button. */
+@Composable
+private fun SecurityButton(
+    model: FooterActionsSecurityButtonViewModel,
+    modifier: Modifier = Modifier,
+) {
+    val onClick: ((Expandable) -> Unit)? =
+        model.onClick?.let { onClick ->
+            val context = LocalContext.current
+            { expandable -> onClick(context, expandable) }
+        }
+
+    TextButton(
+        model.icon,
+        model.text,
+        showNewDot = false,
+        onClick = onClick,
+        modifier,
+    )
+}
+
+/** The foreground services button. */
+@Composable
+private fun RowScope.ForegroundServicesButton(
+    model: FooterActionsForegroundServicesButtonViewModel,
+) {
+    if (model.displayText) {
+        TextButton(
+            Icon.Resource(R.drawable.ic_info_outline, contentDescription = null),
+            model.text,
+            showNewDot = model.hasNewChanges,
+            onClick = model.onClick,
+            Modifier.weight(1f),
+        )
+    } else {
+        NumberButton(
+            model.foregroundServicesCount,
+            showNewDot = model.hasNewChanges,
+            onClick = model.onClick,
+        )
+    }
+}
+
+/** A button with an icon. */
+@Composable
+private fun IconButton(
+    model: FooterActionsButtonViewModel,
+    modifier: Modifier = Modifier,
+) {
+    Expandable(
+        color = colorAttr(model.backgroundColor),
+        shape = CircleShape,
+        onClick = model.onClick,
+        modifier = modifier,
+    ) {
+        val tint = model.iconTint?.let { Color(it) } ?: Color.Unspecified
+        Icon(
+            model.icon,
+            tint = tint,
+            modifier = Modifier.size(20.dp),
+        )
+    }
+}
+
+/** A button with a number an an optional dot (to indicate new changes). */
+@Composable
+private fun NumberButton(
+    number: Int,
+    showNewDot: Boolean,
+    onClick: (Expandable) -> Unit,
+    modifier: Modifier = Modifier,
+) {
+    // By default Expandable will show a ripple above its content when clicked, and clip the content
+    // with the shape of the expandable. In this case we also want to show a "new changes dot"
+    // outside of the shape, so we can't clip. To work around that we can pass our own interaction
+    // source and draw the ripple indication ourselves above the text but below the "new changes
+    // dot".
+    val interactionSource = remember { MutableInteractionSource() }
+
+    Expandable(
+        color = colorAttr(R.attr.offStateColor),
+        shape = CircleShape,
+        onClick = onClick,
+        interactionSource = interactionSource,
+        modifier = modifier,
+    ) {
+        Box(Modifier.size(40.dp)) {
+            Box(
+                Modifier.fillMaxSize()
+                    .clip(CircleShape)
+                    .indication(
+                        interactionSource,
+                        LocalIndication.current,
+                    )
+            ) {
+                Text(
+                    number.toString(),
+                    modifier = Modifier.align(Alignment.Center),
+                    style = MaterialTheme.typography.bodyLarge,
+                    color = LocalAndroidColorScheme.current.textColorPrimary,
+                    // TODO(b/242040009): This should only use a standard text style instead and
+                    // should not override the text size.
+                    fontSize = 18.sp,
+                )
+            }
+
+            if (showNewDot) {
+                NewChangesDot(Modifier.align(Alignment.BottomEnd))
+            }
+        }
+    }
+}
+
+/** A dot that indicates new changes. */
+@Composable
+private fun NewChangesDot(modifier: Modifier = Modifier) {
+    val contentDescription = stringResource(R.string.fgs_dot_content_description)
+    val color = LocalAndroidColorScheme.current.colorAccentTertiary
+
+    Canvas(modifier.size(12.dp).semantics { this.contentDescription = contentDescription }) {
+        drawCircle(color)
+    }
+}
+
+/** A larger button with an icon, some text and an optional dot (to indicate new changes). */
+@Composable
+private fun TextButton(
+    icon: Icon,
+    text: String,
+    showNewDot: Boolean,
+    onClick: ((Expandable) -> Unit)?,
+    modifier: Modifier = Modifier,
+) {
+    Expandable(
+        shape = CircleShape,
+        color = colorAttr(R.attr.underSurfaceColor),
+        contentColor = LocalAndroidColorScheme.current.textColorSecondary,
+        borderStroke = BorderStroke(1.dp, LocalAndroidColorScheme.current.colorBackground),
+        modifier = modifier.padding(horizontal = 4.dp),
+        onClick = onClick,
+    ) {
+        Row(
+            Modifier.padding(horizontal = dimensionResource(R.dimen.qs_footer_padding)),
+            verticalAlignment = Alignment.CenterVertically,
+        ) {
+            Icon(icon, Modifier.padding(end = 12.dp).size(20.dp))
+
+            Text(
+                text,
+                Modifier.weight(1f),
+                style = MaterialTheme.typography.bodyMedium,
+                // TODO(b/242040009): Remove this letter spacing. We should only use the M3 text
+                // styles without modifying them.
+                letterSpacing = 0.01.em,
+                maxLines = 1,
+                overflow = TextOverflow.Ellipsis,
+            )
+
+            if (showNewDot) {
+                NewChangesDot(Modifier.padding(start = 8.dp))
+            }
+
+            if (onClick != null) {
+                Icon(
+                    painterResource(com.android.internal.R.drawable.ic_chevron_end),
+                    contentDescription = null,
+                    Modifier.padding(start = 8.dp).size(20.dp),
+                )
+            }
+        }
+    }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/qs/footer/ui/binder/FooterActionsViewBinder.kt b/packages/SystemUI/src/com/android/systemui/qs/footer/ui/binder/FooterActionsViewBinder.kt
index 6db3c99..30f8124 100644
--- a/packages/SystemUI/src/com/android/systemui/qs/footer/ui/binder/FooterActionsViewBinder.kt
+++ b/packages/SystemUI/src/com/android/systemui/qs/footer/ui/binder/FooterActionsViewBinder.kt
@@ -237,7 +237,13 @@
             return
         }
 
-        buttonView.setBackgroundResource(model.background)
+        val backgroundResource =
+            when (model.backgroundColor) {
+                R.attr.offStateColor -> R.drawable.qs_footer_action_circle
+                com.android.internal.R.attr.colorAccent -> R.drawable.qs_footer_action_circle_color
+                else -> error("Unsupported icon background resource ${model.backgroundColor}")
+            }
+        buttonView.setBackgroundResource(backgroundResource)
         buttonView.setOnClickListener { model.onClick(Expandable.fromView(buttonView)) }
 
         val icon = model.icon
diff --git a/packages/SystemUI/src/com/android/systemui/qs/footer/ui/viewmodel/FooterActionsButtonViewModel.kt b/packages/SystemUI/src/com/android/systemui/qs/footer/ui/viewmodel/FooterActionsButtonViewModel.kt
index 8d819da..2670787 100644
--- a/packages/SystemUI/src/com/android/systemui/qs/footer/ui/viewmodel/FooterActionsButtonViewModel.kt
+++ b/packages/SystemUI/src/com/android/systemui/qs/footer/ui/viewmodel/FooterActionsButtonViewModel.kt
@@ -16,7 +16,8 @@
 
 package com.android.systemui.qs.footer.ui.viewmodel
 
-import android.annotation.DrawableRes
+import android.annotation.AttrRes
+import android.annotation.ColorInt
 import com.android.systemui.animation.Expandable
 import com.android.systemui.common.shared.model.Icon
 
@@ -27,7 +28,7 @@
 data class FooterActionsButtonViewModel(
     val id: Int,
     val icon: Icon,
-    val iconTint: Int?,
-    @DrawableRes val background: Int,
+    @ColorInt val iconTint: Int?,
+    @AttrRes val backgroundColor: Int,
     val onClick: (Expandable) -> Unit,
 )
diff --git a/packages/SystemUI/src/com/android/systemui/qs/footer/ui/viewmodel/FooterActionsViewModel.kt b/packages/SystemUI/src/com/android/systemui/qs/footer/ui/viewmodel/FooterActionsViewModel.kt
index dee6fad..fbf32b3 100644
--- a/packages/SystemUI/src/com/android/systemui/qs/footer/ui/viewmodel/FooterActionsViewModel.kt
+++ b/packages/SystemUI/src/com/android/systemui/qs/footer/ui/viewmodel/FooterActionsViewModel.kt
@@ -18,6 +18,7 @@
 
 import android.content.Context
 import android.util.Log
+import android.view.ContextThemeWrapper
 import androidx.lifecycle.DefaultLifecycleObserver
 import androidx.lifecycle.Lifecycle
 import androidx.lifecycle.LifecycleOwner
@@ -49,12 +50,15 @@
 
 /** A ViewModel for the footer actions. */
 class FooterActionsViewModel(
-    @Application private val context: Context,
+    @Application appContext: Context,
     private val footerActionsInteractor: FooterActionsInteractor,
     private val falsingManager: FalsingManager,
     private val globalActionsDialogLite: GlobalActionsDialogLite,
     showPowerButton: Boolean,
 ) {
+    /** The context themed with the Quick Settings colors. */
+    private val context = ContextThemeWrapper(appContext, R.style.Theme_SystemUI_QuickSettings)
+
     /**
      * Whether the UI rendering this ViewModel should be visible. Note that even when this is false,
      * the UI should still participate to the layout it is included in (i.e. in the View world it
@@ -142,7 +146,7 @@
                 ContentDescription.Resource(R.string.accessibility_quick_settings_settings)
             ),
             iconTint = null,
-            R.drawable.qs_footer_action_circle,
+            backgroundColor = R.attr.offStateColor,
             this::onSettingsButtonClicked,
         )
 
@@ -160,7 +164,7 @@
                         context,
                         com.android.internal.R.attr.textColorOnAccent,
                     ),
-                R.drawable.qs_footer_action_circle_color,
+                backgroundColor = com.android.internal.R.attr.colorAccent,
                 this::onPowerButtonClicked,
             )
         } else {
@@ -260,7 +264,7 @@
                     ),
                 ),
             iconTint = null,
-            background = R.drawable.qs_footer_action_circle,
+            backgroundColor = R.attr.offStateColor,
             onClick = this::onUserSwitcherClicked,
         )
     }
diff --git a/packages/SystemUI/tests/src/com/android/systemui/qs/footer/ui/viewmodel/FooterActionsViewModelTest.kt b/packages/SystemUI/tests/src/com/android/systemui/qs/footer/ui/viewmodel/FooterActionsViewModelTest.kt
index 01411c9..0b9fbd9 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/qs/footer/ui/viewmodel/FooterActionsViewModelTest.kt
+++ b/packages/SystemUI/tests/src/com/android/systemui/qs/footer/ui/viewmodel/FooterActionsViewModelTest.kt
@@ -84,7 +84,7 @@
                     ContentDescription.Resource(R.string.accessibility_quick_settings_settings)
                 )
             )
-        assertThat(settings.background).isEqualTo(R.drawable.qs_footer_action_circle)
+        assertThat(settings.backgroundColor).isEqualTo(R.attr.offStateColor)
         assertThat(settings.iconTint).isNull()
     }
 
@@ -105,7 +105,7 @@
                     ContentDescription.Resource(R.string.accessibility_quick_settings_power_menu)
                 )
             )
-        assertThat(power.background).isEqualTo(R.drawable.qs_footer_action_circle_color)
+        assertThat(power.backgroundColor).isEqualTo(com.android.internal.R.attr.colorAccent)
         assertThat(power.iconTint)
             .isEqualTo(
                 Utils.getColorAttrDefaultColor(
@@ -170,7 +170,7 @@
         assertThat(userSwitcher).isNotNull()
         assertThat(userSwitcher!!.icon)
             .isEqualTo(Icon.Loaded(picture, ContentDescription.Loaded("Signed in as foo")))
-        assertThat(userSwitcher.background).isEqualTo(R.drawable.qs_footer_action_circle)
+        assertThat(userSwitcher.backgroundColor).isEqualTo(R.attr.offStateColor)
 
         // Change the current user name.
         userSwitcherControllerWrapper.currentUserName = "bar"