Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 00bf08c1 authored by Fabian Kozynski's avatar Fabian Kozynski Committed by Android (Google) Code Review
Browse files

Merge "Add a modifier for reading percentage in sliders" into main

parents cafc13ae 042fce81
Loading
Loading
Loading
Loading
+93 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2025 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.compose.modifiers

import androidx.compose.ui.Modifier
import androidx.compose.ui.node.CompositionLocalConsumerModifierNode
import androidx.compose.ui.node.ModifierNodeElement
import androidx.compose.ui.node.ObserverModifierNode
import androidx.compose.ui.node.SemanticsModifierNode
import androidx.compose.ui.node.currentValueOf
import androidx.compose.ui.node.invalidateSemantics
import androidx.compose.ui.node.observeReads
import androidx.compose.ui.platform.InspectorInfo
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.semantics.SemanticsPropertyReceiver
import androidx.compose.ui.semantics.stateDescription
import java.text.NumberFormat
import java.util.Locale

/**
 * Replaces the default state description in a slider to read out the percentage. The value returned
 * by the lambda should be in [0, 1] to correspond to a percentage in the slider.
 *
 * The modifier will merge the semantics with that of its descendants
 */
fun Modifier.sliderPercentage(percentage: () -> Float) =
    this then SliderPercentageElement(percentage)

private data class SliderPercentageElement(val percentage: () -> Float) :
    ModifierNodeElement<SliderPercentageNode>() {
    override fun create(): SliderPercentageNode {
        return SliderPercentageNode(percentage)
    }

    override fun update(node: SliderPercentageNode) {
        node.percentage = percentage
    }

    override fun InspectorInfo.inspectableProperties() {
        name = "sliderPercentage"
        properties["percentage"] = percentage()
    }
}

private class SliderPercentageNode(var percentage: () -> Float) :
    Modifier.Node(),
    SemanticsModifierNode,
    CompositionLocalConsumerModifierNode,
    ObserverModifierNode {

    override val shouldMergeDescendantSemantics: Boolean
        get() = true

    private var locale: Locale? = null

    override fun SemanticsPropertyReceiver.applySemantics() {
        val percentInstance =
            locale?.let { NumberFormat.getPercentInstance(it) } ?: NumberFormat.getPercentInstance()
        this.stateDescription = percentInstance.format(percentage())
    }

    override fun onAttach() {
        onObservedReadsChanged()
    }

    override fun onDetach() {
        locale = null
    }

    override fun onObservedReadsChanged() {
        observeReads {
            val oldLocale = locale
            locale = currentValueOf(LocalConfiguration).locales.get(0)
            if (locale != oldLocale) {
                invalidateSemantics()
            }
        }
    }
}
+4 −0
Original line number Diff line number Diff line
@@ -80,6 +80,7 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.android.app.tracing.coroutines.launchTraced as launch
import com.android.compose.modifiers.padding
import com.android.compose.modifiers.sliderPercentage
import com.android.compose.modifiers.thenIf
import com.android.compose.theme.LocalAndroidColorScheme
import com.android.compose.ui.graphics.drawInOverlay
@@ -220,6 +221,9 @@ fun BrightnessSlider(
                .semantics(mergeDescendants = true) {
                    this.text = AnnotatedString(contentDescription)
                }
                .sliderPercentage {
                    (value - valueRange.first).toFloat() / (valueRange.last - valueRange.first)
                }
                .thenIf(isRestricted) {
                    Modifier.clickable {
                        if (restriction is PolicyRestriction.Restricted) {
+7 −1
Original line number Diff line number Diff line
@@ -60,6 +60,7 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import com.android.compose.modifiers.sliderPercentage
import com.android.compose.theme.LocalAndroidColorScheme
import com.android.systemui.biometrics.Utils.toBitmap
import com.android.systemui.common.shared.model.Icon
@@ -188,7 +189,12 @@ fun DualIconSlider(
                onStop(value)
            }
        },
        modifier = modifier.sysuiResTag("slider"),
        modifier =
            modifier
                .sliderPercentage {
                    (value - valueRange.first).toFloat() / (valueRange.last - valueRange.first)
                }
                .sysuiResTag("slider"),
        interactionSource = interactionSource,
        thumb = {
            SliderDefaults.Thumb(
+159 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2025 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.brightness.ui.compose

import android.content.res.Configuration
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.test.assert
import androidx.compose.ui.test.hasStateDescription
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.SmallTest
import com.android.compose.theme.PlatformTheme
import com.android.systemui.SysuiTestCase
import com.android.systemui.brightness.ui.viewmodel.BrightnessSliderViewModel
import com.android.systemui.common.shared.model.asIcon
import com.android.systemui.haptics.slider.sliderHapticsViewModelFactory
import com.android.systemui.res.R
import com.android.systemui.testKosmos
import com.android.systemui.utils.PolicyRestriction
import java.util.Locale
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith

@SmallTest
@RunWith(AndroidJUnit4::class)
class BrightnessSliderTest : SysuiTestCase() {

    @get:Rule val composeRule = createComposeRule()

    private val kosmos = testKosmos()

    private val englishLocaleConfiguration =
        Configuration(context.resources.configuration).apply { setLocale(Locale.US) }

    private val franceLocaleConfiguration =
        Configuration(context.resources.configuration).apply { setLocale(Locale.FRANCE) }

    private var localeConfiguration by mutableStateOf(englishLocaleConfiguration)

    @Test
    fun stateDescription_hasPercentage() {
        val value = 25
        val range = 0..200
        composeRule.setContent {
            PlatformTheme {
                CompositionLocalProvider(LocalConfiguration provides localeConfiguration) {
                    BrightnessSlider(
                        gammaValue = value,
                        modifier = Modifier.wrapContentHeight().fillMaxWidth(),
                        valueRange = range,
                        iconResProvider = BrightnessSliderViewModel::getIconForPercentage,
                        imageLoader = { resId, context ->
                            context.getDrawable(resId)!!.asIcon(null)
                        },
                        restriction = PolicyRestriction.NoRestriction,
                        onRestrictedClick = {},
                        onDrag = {},
                        onStop = {},
                        overriddenByAppState = false,
                        hapticsViewModelFactory = kosmos.sliderHapticsViewModelFactory,
                    )
                }
            }
        }
        composeRule
            .onNodeWithText(context.getString(R.string.accessibility_brightness))
            .assert(hasStateDescription("12%"))
    }

    @Test
    fun stateDescription_updatesWithValue() {
        var value by mutableIntStateOf(25)
        val range = 0..200
        composeRule.setContent {
            PlatformTheme {
                CompositionLocalProvider(LocalConfiguration provides localeConfiguration) {
                    BrightnessSlider(
                        gammaValue = value,
                        modifier = Modifier.wrapContentHeight().fillMaxWidth(),
                        valueRange = range,
                        iconResProvider = BrightnessSliderViewModel::getIconForPercentage,
                        imageLoader = { resId, context ->
                            context.getDrawable(resId)!!.asIcon(null)
                        },
                        restriction = PolicyRestriction.NoRestriction,
                        onRestrictedClick = {},
                        onDrag = {},
                        onStop = {},
                        overriddenByAppState = false,
                        hapticsViewModelFactory = kosmos.sliderHapticsViewModelFactory,
                    )
                }
            }
        }
        composeRule.waitForIdle()

        value = 150
        composeRule
            .onNodeWithText(context.getString(R.string.accessibility_brightness))
            .assert(hasStateDescription("75%"))
    }

    @Test
    fun stateDescription_updatesWithConfiguration() {
        val value = 25
        val range = 0..200
        composeRule.setContent {
            PlatformTheme {
                CompositionLocalProvider(LocalConfiguration provides localeConfiguration) {
                    BrightnessSlider(
                        gammaValue = value,
                        modifier = Modifier.wrapContentHeight().fillMaxWidth(),
                        valueRange = range,
                        iconResProvider = BrightnessSliderViewModel::getIconForPercentage,
                        imageLoader = { resId, context ->
                            context.getDrawable(resId)!!.asIcon(null)
                        },
                        restriction = PolicyRestriction.NoRestriction,
                        onRestrictedClick = {},
                        onDrag = {},
                        onStop = {},
                        overriddenByAppState = false,
                        hapticsViewModelFactory = kosmos.sliderHapticsViewModelFactory,
                    )
                }
            }
        }
        composeRule.waitForIdle()

        localeConfiguration = franceLocaleConfiguration
        composeRule
            .onNodeWithText(context.getString(R.string.accessibility_brightness))
            .assert(hasStateDescription("12 %")) // extra NBSP
    }
}
+144 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2025 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.util.ui.compose

import android.content.res.Configuration
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.test.assert
import androidx.compose.ui.test.hasStateDescription
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.SmallTest
import com.android.compose.theme.PlatformTheme
import com.android.systemui.SysuiTestCase
import com.android.systemui.common.shared.model.asIcon
import com.android.systemui.compose.modifiers.resIdToTestTag
import com.android.systemui.haptics.slider.sliderHapticsViewModelFactory
import com.android.systemui.res.R
import com.android.systemui.testKosmos
import java.util.Locale
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith

@SmallTest
@RunWith(AndroidJUnit4::class)
class DualIconSliderTest : SysuiTestCase() {
    @get:Rule val composeRule = createComposeRule()

    private val kosmos = testKosmos()

    private val englishLocaleConfiguration =
        Configuration(context.resources.configuration).apply { setLocale(Locale.US) }

    private val franceLocaleConfiguration =
        Configuration(context.resources.configuration).apply { setLocale(Locale.FRANCE) }

    private var localeConfiguration by mutableStateOf(englishLocaleConfiguration)

    @Test
    fun stateDescription_hasPercentage() {
        val value = 25
        val range = 0..200
        composeRule.setContent {
            PlatformTheme {
                CompositionLocalProvider(LocalConfiguration provides localeConfiguration) {
                    DualIconSlider(
                        levelValue = value,
                        modifier = Modifier.wrapContentHeight().fillMaxWidth(),
                        valueRange = range,
                        iconResProvider = { R.drawable.android },
                        imageLoader = { resId, context ->
                            context.getDrawable(resId)!!.asIcon(null)
                        },
                        onDrag = {},
                        onStop = {},
                        hapticsViewModelFactory = kosmos.sliderHapticsViewModelFactory,
                    )
                }
            }
        }
        composeRule.onNodeWithTag(resIdToTestTag("slider")).assert(hasStateDescription("12%"))
    }

    @Test
    fun stateDescription_updatesWithValue() {
        var value by mutableIntStateOf(25)
        val range = 0..200
        composeRule.setContent {
            PlatformTheme {
                CompositionLocalProvider(LocalConfiguration provides localeConfiguration) {
                    DualIconSlider(
                        levelValue = value,
                        modifier = Modifier.wrapContentHeight().fillMaxWidth(),
                        valueRange = range,
                        iconResProvider = { R.drawable.android },
                        imageLoader = { resId, context ->
                            context.getDrawable(resId)!!.asIcon(null)
                        },
                        onDrag = {},
                        onStop = {},
                        hapticsViewModelFactory = kosmos.sliderHapticsViewModelFactory,
                    )
                }
            }
        }
        composeRule.waitForIdle()

        value = 150
        composeRule.onNodeWithTag(resIdToTestTag("slider")).assert(hasStateDescription("75%"))
    }

    @Test
    fun stateDescription_updatesWithConfiguration() {
        val value = 25
        val range = 0..200
        composeRule.setContent {
            PlatformTheme {
                CompositionLocalProvider(LocalConfiguration provides localeConfiguration) {
                    DualIconSlider(
                        levelValue = value,
                        modifier = Modifier.wrapContentHeight().fillMaxWidth(),
                        valueRange = range,
                        iconResProvider = { R.drawable.android },
                        imageLoader = { resId, context ->
                            context.getDrawable(resId)!!.asIcon(null)
                        },
                        onDrag = {},
                        onStop = {},
                        hapticsViewModelFactory = kosmos.sliderHapticsViewModelFactory,
                    )
                }
            }
        }
        composeRule.waitForIdle()

        localeConfiguration = franceLocaleConfiguration
        composeRule
            .onNodeWithTag(resIdToTestTag("slider"))
            .assert(hasStateDescription("12 %")) // extra NBSP
    }
}