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

Commit 278ca97a authored by Anton Potapov's avatar Anton Potapov
Browse files

Add device settings components to show in the Volume Panel

Flag: com.android.systemui.volume_redesign
Bug: 439511163
Test: passes presubmits
Change-Id: I0b693a5cc6d9fc9ca854e99f735838ead78c60a0
parent 6a6b94e5
Loading
Loading
Loading
Loading
+37 −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.bluetooth.devicesettings.shared.model

import android.annotation.SuppressLint
import android.content.Context
import androidx.core.graphics.drawable.toDrawable
import com.android.settingslib.bluetooth.devicesettings.shared.model.DeviceSettingIcon
import com.android.systemui.common.shared.model.ContentDescription
import com.android.systemui.common.shared.model.Icon

@SuppressLint("UseCompatLoadingForDrawables")
fun DeviceSettingIcon.toSysUiIcon(context: Context, contentDescription: ContentDescription?): Icon {
    return when (this) {
        is DeviceSettingIcon.BitmapIcon ->
            Icon.Loaded(
                drawable = bitmap.toDrawable(context.resources),
                contentDescription = contentDescription,
            )
        is DeviceSettingIcon.ResourceIcon ->
            Icon.Resource(res = resId, contentDescription = contentDescription)
    }
}
+10 −9
Original line number Diff line number Diff line
@@ -48,8 +48,9 @@ fun VolumePanelButton(
    icon: Icon?,
    isActive: Boolean,
    onClick: (expandable: Expandable) -> Unit,
    modifier: Modifier = Modifier,
    semanticsRole: Role,
    modifier: Modifier = Modifier,
    isEnabled: Boolean = true,
) {
    Column(
        modifier = modifier,
@@ -63,17 +64,17 @@ fun VolumePanelButton(
                    contentDescription = label
                },
            color =
                if (isActive) {
                    MaterialTheme.colorScheme.primary
                } else {
                    MaterialTheme.colorScheme.surfaceContainerHigh
                when {
                    !isEnabled -> MaterialTheme.colorScheme.surfaceContainerHighest
                    isActive -> MaterialTheme.colorScheme.primary
                    else -> MaterialTheme.colorScheme.surfaceContainerHigh
                },
            shape = RoundedCornerShape(20.dp),
            contentColor =
                if (isActive) {
                    MaterialTheme.colorScheme.onPrimary
                } else {
                    MaterialTheme.colorScheme.onSurface
                when {
                    !isEnabled -> MaterialTheme.colorScheme.outline
                    isActive -> MaterialTheme.colorScheme.onPrimary
                    else -> MaterialTheme.colorScheme.onSurface
                },
            onClick = { onClick(it) },
        ) {
+113 −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.volume.panel.component.devicesetting.ui.composable

import android.view.Gravity
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.dp
import com.android.settingslib.bluetooth.devicesettings.shared.model.DeviceSettingModel
import com.android.settingslib.bluetooth.devicesettings.shared.model.DeviceSettingStateModel
import com.android.systemui.bluetooth.devicesettings.shared.model.toSysUiIcon
import com.android.systemui.lifecycle.rememberViewModel
import com.android.systemui.volume.panel.component.button.ui.composable.VolumePanelButton
import com.android.systemui.volume.panel.component.devicesetting.ui.viewmodel.DeviceSettingComponentViewModel
import com.android.systemui.volume.panel.component.popup.ui.composable.VolumePanelPopup.Companion.calculateGravity
import com.android.systemui.volume.panel.shared.VolumePanelLogger
import com.android.systemui.volume.panel.ui.composable.ComposeVolumePanelUiComponent
import com.android.systemui.volume.panel.ui.composable.VolumePanelComposeScope
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject

/** [ComposeVolumePanelUiComponent] that represents spatial audio button in the Volume Panel. */
class DeviceSettingComponent
@AssistedInject
constructor(
    @Assisted private val viewModelFactory: () -> DeviceSettingComponentViewModel,
    private val popupFactory: DeviceSettingPopup.Factory,
    private val volumePanelLogger: VolumePanelLogger,
) : ComposeVolumePanelUiComponent {

    @Composable
    override fun VolumePanelComposeScope.Content(modifier: Modifier) {
        val viewModel = rememberViewModel("DeviceSettingComponent#viewModel") { viewModelFactory() }
        when (val setting = viewModel.setting) {
            is DeviceSettingModel.ActionSwitchPreference -> {
                val isChecked = setting.switchState?.checked == true
                VolumePanelButton(
                    label = setting.title,
                    icon = setting.icon?.toSysUiIcon(LocalContext.current, null),
                    isActive = isChecked,
                    isEnabled = setting.isAllowedChangingState,
                    onClick = {
                        setting.updateState?.invoke(
                            DeviceSettingStateModel.ActionSwitchPreferenceState(!isChecked)
                        )
                    },
                    semanticsRole = Role.Switch,
                )
            }
            is DeviceSettingModel.MultiTogglePreference -> {
                val screenWidth: Float =
                    with(LocalDensity.current) {
                        LocalConfiguration.current.screenWidthDp.dp.toPx()
                    }
                var gravity by remember { mutableIntStateOf(Gravity.CENTER_HORIZONTAL) }
                val selectedToggle = setting.toggles[setting.state.selectedIndex]
                VolumePanelButton(
                    label = selectedToggle.label,
                    icon = selectedToggle.icon.toSysUiIcon(LocalContext.current, null),
                    isActive = setting.isActive,
                    isEnabled = setting.isAllowedChangingState,
                    onClick = { expandable ->
                        popupFactory
                            .create(viewModelFactory)
                            .show(expandable = expandable, horizontalGravity = gravity)
                    },
                    semanticsRole = Role.Button,
                    modifier =
                        modifier.onGloballyPositioned {
                            gravity = calculateGravity(it, screenWidth)
                        },
                )
            }
            else -> {
                setting?.let {
                    LaunchedEffect(it) {
                        volumePanelLogger.receivedUnsupportedDeviceSetting(it::class.simpleName)
                    }
                }
            }
        }
    }

    @AssistedFactory
    interface Factory {
        fun create(viewModelFactory: () -> DeviceSettingComponentViewModel): DeviceSettingComponent
    }
}
+184 −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.volume.panel.component.devicesetting.ui.composable

import android.view.Gravity
import androidx.compose.foundation.basicMarquee
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastForEachIndexed
import com.android.settingslib.bluetooth.devicesettings.shared.model.DeviceSettingModel
import com.android.settingslib.bluetooth.devicesettings.shared.model.DeviceSettingStateModel
import com.android.settingslib.bluetooth.devicesettings.shared.model.ToggleModel
import com.android.systemui.animation.Expandable
import com.android.systemui.bluetooth.devicesettings.shared.model.toSysUiIcon
import com.android.systemui.common.ui.compose.Icon
import com.android.systemui.lifecycle.rememberViewModel
import com.android.systemui.statusbar.phone.SystemUIDialog
import com.android.systemui.volume.panel.component.devicesetting.ui.viewmodel.DeviceSettingComponentViewModel
import com.android.systemui.volume.panel.component.popup.ui.composable.VolumePanelPopup
import com.android.systemui.volume.panel.component.popup.ui.composable.VolumePanelPopupDefaults
import com.android.systemui.volume.panel.component.selector.ui.composable.VolumePanelRadioButtonBar
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject

class DeviceSettingPopup
@AssistedInject
constructor(
    @Assisted private val viewModelFactory: () -> DeviceSettingComponentViewModel,
    private val volumePanelPopup: VolumePanelPopup,
) {

    /** Shows a popup with the [expandable] animation. */
    fun show(expandable: Expandable, horizontalGravity: Int) {
        val gravity = horizontalGravity or Gravity.BOTTOM
        volumePanelPopup.show(
            expandable,
            gravity,
            body = {
                val viewModel =
                    rememberViewModel("DeviceSettingPopup#viewModel") { viewModelFactory() }
                viewModel.setting?.let { setting ->
                    Box(
                        modifier =
                            Modifier.padding(horizontal = 80.dp).fillMaxWidth().wrapContentHeight(),
                        contentAlignment = Alignment.Center,
                    ) {
                        VolumePanelPopupDefaults.Title(setting.label)
                    }

                    Box(
                        modifier =
                            Modifier.padding(horizontal = 16.dp).fillMaxWidth().wrapContentHeight(),
                        contentAlignment = Alignment.Center,
                    ) {
                        Content(it, setting)
                    }
                }
            },
        )
    }

    @Composable
    private fun Content(dialog: SystemUIDialog, setting: DeviceSettingModel) {
        val togglesSetting = setting.ensureToggle()
        val toggles = togglesSetting?.toggles
        if (toggles.isNullOrEmpty()) {
            SideEffect { dialog.dismiss() }
            return
        }

        VolumePanelRadioButtonBar {
            toggles.fastForEachIndexed { index, toggleModel ->
                item(
                    isSelected = index == togglesSetting.state.selectedIndex,
                    onItemSelected = {
                        togglesSetting.updateState(
                            DeviceSettingStateModel.MultiTogglePreferenceState(index)
                        )
                    },
                    contentDescription = toggleModel.label,
                    icon = {
                        Icon(icon = toggleModel.icon.toSysUiIcon(LocalContext.current, null))
                    },
                    label = {
                        Text(
                            modifier = Modifier.basicMarquee(),
                            text = toggleModel.label,
                            style = MaterialTheme.typography.labelMedium,
                            color = LocalContentColor.current,
                            textAlign = TextAlign.Center,
                            maxLines = 1,
                        )
                    },
                )
            }
        }
    }

    @AssistedFactory
    interface Factory {

        fun create(viewModelFactory: () -> DeviceSettingComponentViewModel): DeviceSettingPopup
    }
}

private val DeviceSettingModel.label
    get() =
        when (this) {
            is DeviceSettingModel.MultiTogglePreference -> title
            is DeviceSettingModel.ActionSwitchPreference -> title
            else -> error("Unsupported setting type: $this")
        }

/**
 * Ensures that the [DeviceSettingModel] is [DeviceSettingModel.MultiTogglePreference] by converting
 * [DeviceSettingModel.ActionSwitchPreference] to [DeviceSettingModel.MultiTogglePreference] when
 * needed. Returns null when the [DeviceSettingModel] is not supported.
 */
@Composable
private fun DeviceSettingModel.ensureToggle(): DeviceSettingModel.MultiTogglePreference? =
    remember(this) {
        when (this) {
            is DeviceSettingModel.MultiTogglePreference -> this
            is DeviceSettingModel.ActionSwitchPreference -> {
                val active = 1
                val inactive = 0
                val isActive = switchState?.checked == true
                DeviceSettingModel.MultiTogglePreference(
                    isAllowedChangingState = isAllowedChangingState,
                    isActive = isActive,
                    title = title,
                    state =
                        DeviceSettingStateModel.MultiTogglePreferenceState(
                            if (isActive) active else inactive
                        ),
                    id = id,
                    cachedDevice = cachedDevice,
                    updateState = {
                        updateState?.invoke(
                            DeviceSettingStateModel.ActionSwitchPreferenceState(
                                it.selectedIndex == active
                            )
                        )
                    },
                    toggles =
                        icon?.let { icon ->
                            listOf(
                                ToggleModel(icon = icon, label = label),
                                ToggleModel(icon = icon, label = label),
                            )
                        } ?: emptyList(),
                )
            }
            else -> null
        }
    }
+23 −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.volume.panel.component.devicesetting.ui.viewmodel

import com.android.settingslib.bluetooth.devicesettings.shared.model.DeviceSettingModel

interface DeviceSettingComponentViewModel {
    val setting: DeviceSettingModel?
}
Loading