Add multi-toggle preference UI for device details page
BUG: 343317785
Test: atest DeviceSettingRepositoryTest
Flag: com.android.settings.flags.enable_bluetooth_device_details_polish
Change-Id: I67e7647fee39e789cc1342943f69e7ddc170d0eb
diff --git a/res/drawable/ic_close.xml b/res/drawable/ic_close.xml
new file mode 100644
index 0000000..de2085c
--- /dev/null
+++ b/res/drawable/ic_close.xml
@@ -0,0 +1,24 @@
+<!--
+Copyright (C) 2024 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.
+-->
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="24.0dp"
+ android:height="24.0dp"
+ android:viewportWidth="24.0"
+ android:viewportHeight="24.0">
+ <path
+ android:pathData="M19.000000,6.400000l-1.400000,-1.400000 -5.600000,5.600000 -5.600000,-5.600000 -1.400000,1.400000 5.600000,5.600000 -5.600000,5.600000 1.400000,1.400000 5.600000,-5.600000 5.600000,5.600000 1.400000,-1.400000 -5.600000,-5.600000z"
+ android:fillColor="#FF000000"/>
+</vector>
\ No newline at end of file
diff --git a/src/com/android/settings/bluetooth/ui/MultiTogglePreferenceGroup.kt b/src/com/android/settings/bluetooth/ui/MultiTogglePreferenceGroup.kt
new file mode 100644
index 0000000..e4ca00d
--- /dev/null
+++ b/src/com/android/settings/bluetooth/ui/MultiTogglePreferenceGroup.kt
@@ -0,0 +1,293 @@
+/*
+ * Copyright (C) 2024 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.settings.bluetooth.ui
+
+import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.offset
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.BasicAlertDialog
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.Card
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.LocalContentColor
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+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.geometry.Rect
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.asImageBitmap
+import androidx.compose.ui.layout.boundsInParent
+import androidx.compose.ui.layout.onGloballyPositioned
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.role
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.semantics.toggleableState
+import androidx.compose.ui.state.ToggleableState
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.compose.ui.window.DialogProperties
+import com.android.settings.R
+import com.android.settingslib.bluetooth.devicesettings.shared.model.DeviceSettingModel
+import com.android.settingslib.bluetooth.devicesettings.shared.model.DeviceSettingStateModel
+import com.android.settingslib.spa.framework.theme.SettingsDimension
+import com.android.settingslib.spa.widget.dialog.getDialogWidth
+
+@Composable
+fun MultiTogglePreferenceGroup(
+ preferenceModels: List<DeviceSettingModel.MultiTogglePreference>,
+) {
+ var settingIdForPopUp by remember { mutableStateOf<Int?>(null) }
+
+ settingIdForPopUp?.let { id ->
+ preferenceModels.find { it.id == id }?.let { dialog(it) { settingIdForPopUp = null } }
+ }
+
+ Row(
+ modifier = Modifier.padding(SettingsDimension.itemPadding),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(24.dp),
+ ) {
+ preferenceModels.forEach { preferenceModel ->
+ Column(
+ modifier = Modifier.weight(1f),
+ verticalArrangement = Arrangement.Top,
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Row {
+ Surface(
+ modifier = Modifier.height(64.dp),
+ shape = RoundedCornerShape(28.dp),
+ color = MaterialTheme.colorScheme.surface
+ ) {
+ Button(
+ modifier =
+ Modifier.fillMaxSize().padding(8.dp).semantics {
+ role = Role.Switch
+ toggleableState =
+ if (preferenceModel.isActive) {
+ ToggleableState.On
+ } else {
+ ToggleableState.Off
+ }
+ contentDescription = preferenceModel.title
+ },
+ onClick = { settingIdForPopUp = preferenceModel.id },
+ shape = RoundedCornerShape(20.dp),
+ colors = getButtonColors(preferenceModel.isActive),
+ contentPadding = PaddingValues(0.dp)
+ ) {
+ Icon(
+ preferenceModel.toggles[preferenceModel.state.selectedIndex]
+ .icon
+ .asImageBitmap(),
+ contentDescription = null,
+ modifier = Modifier.size(24.dp),
+ tint = LocalContentColor.current
+ )
+ }
+ }
+ }
+ Row { Text(text = preferenceModel.title, fontSize = 12.sp) }
+ }
+ }
+ }
+}
+
+@Composable
+private fun getButtonColors(isActive: Boolean) =
+ if (isActive) {
+ ButtonDefaults.buttonColors(
+ containerColor = MaterialTheme.colorScheme.tertiaryContainer,
+ contentColor = MaterialTheme.colorScheme.onTertiaryContainer,
+ )
+ } else {
+ ButtonDefaults.buttonColors(
+ containerColor = Color.Transparent,
+ contentColor = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun dialog(
+ multiTogglePreference: DeviceSettingModel.MultiTogglePreference,
+ onDismiss: () -> Unit
+) {
+ BasicAlertDialog(
+ onDismissRequest = { onDismiss() },
+ modifier = Modifier.width(getDialogWidth()),
+ properties = DialogProperties(usePlatformDefaultWidth = false),
+ content = {
+ Card(
+ shape = RoundedCornerShape(28.dp),
+ modifier = Modifier.fillMaxWidth().height(192.dp),
+ content = {
+ Box {
+ Button(
+ onClick = { onDismiss() },
+ modifier = Modifier.padding(8.dp).align(Alignment.TopEnd).size(48.dp),
+ contentPadding = PaddingValues(12.dp),
+ colors =
+ ButtonDefaults.buttonColors(containerColor = Color.Transparent),
+ ) {
+ Icon(
+ painterResource(id = R.drawable.ic_close),
+ null,
+ tint = MaterialTheme.colorScheme.inverseSurface
+ )
+ }
+ Box(modifier = Modifier.padding(horizontal = 8.dp, vertical = 20.dp)) {
+ dialogContent(multiTogglePreference)
+ }
+ }
+ },
+ )
+ }
+ )
+}
+
+@Composable
+private fun dialogContent(multiTogglePreference: DeviceSettingModel.MultiTogglePreference) {
+ Column {
+ Row(
+ modifier = Modifier.fillMaxWidth().height(24.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.SpaceEvenly,
+ ) {
+ Text(text = multiTogglePreference.title, fontSize = 16.sp)
+ }
+ Spacer(modifier = Modifier.height(20.dp))
+ var selectedRect by remember { mutableStateOf<Rect?>(null) }
+ val offset =
+ selectedRect?.let { rect ->
+ animateFloatAsState(targetValue = rect.left, finishedListener = {}).value
+ }
+
+ Row(
+ modifier =
+ Modifier.fillMaxWidth()
+ .height(64.dp)
+ .background(
+ MaterialTheme.colorScheme.surface,
+ shape = RoundedCornerShape(28.dp)
+ ),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.SpaceEvenly,
+ ) {
+ Box {
+ offset?.let { offset ->
+ with(LocalDensity.current) {
+ Box(
+ modifier =
+ Modifier.offset(offset.toDp(), 0.dp)
+ .height(selectedRect!!.height.toDp())
+ .width(selectedRect!!.width.toDp())
+ .background(
+ MaterialTheme.colorScheme.tertiaryContainer,
+ shape = RoundedCornerShape(20.dp)
+ )
+ )
+ }
+ }
+ Row {
+ for ((idx, toggle) in multiTogglePreference.toggles.withIndex()) {
+ val selected = idx == multiTogglePreference.state.selectedIndex
+ Column(
+ modifier =
+ Modifier.weight(1f)
+ .padding(horizontal = 8.dp)
+ .height(48.dp)
+ .background(
+ Color.Transparent,
+ shape = RoundedCornerShape(28.dp)
+ )
+ .onGloballyPositioned { layoutCoordinates ->
+ if (selected) {
+ selectedRect = layoutCoordinates.boundsInParent()
+ }
+ },
+ verticalArrangement = Arrangement.Center,
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Button(
+ onClick = {
+ multiTogglePreference.updateState(
+ DeviceSettingStateModel.MultiTogglePreferenceState(idx)
+ )
+ },
+ modifier = Modifier.fillMaxSize(),
+ colors =
+ ButtonDefaults.buttonColors(
+ containerColor = Color.Transparent,
+ contentColor = LocalContentColor.current
+ ),
+ ) {
+ Icon(
+ bitmap = toggle.icon.asImageBitmap(),
+ null,
+ modifier = Modifier.size(24.dp),
+ tint = LocalContentColor.current
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+ Spacer(modifier = Modifier.height(12.dp))
+ Row(
+ modifier = Modifier.fillMaxWidth().height(32.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.SpaceEvenly,
+ ) {
+ for (toggle in multiTogglePreference.toggles) {
+ Text(
+ text = toggle.label,
+ fontSize = 12.sp,
+ textAlign = TextAlign.Center,
+ modifier = Modifier.weight(1f).padding(horizontal = 8.dp)
+ )
+ }
+ }
+ }
+}