android: improve dropdowns

ai generated
This commit is contained in:
Kavish Devar
2025-09-21 01:34:42 +05:30
parent 5aeb47b835
commit ecfdc05dbf
2 changed files with 539 additions and 130 deletions

View File

@@ -22,36 +22,39 @@ package me.kavishdevar.librepods.composables
import android.util.Log import android.util.Log
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInParent
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
@@ -89,27 +92,55 @@ fun CallControlSettings() {
val callControlEnabledValue = service.aacpManager.controlCommandStatusList.find { val callControlEnabledValue = service.aacpManager.controlCommandStatusList.find {
it.identifier == AACPManager.Companion.ControlCommandIdentifiers.CALL_MANAGEMENT_CONFIG it.identifier == AACPManager.Companion.ControlCommandIdentifiers.CALL_MANAGEMENT_CONFIG
}?.value ?: byteArrayOf(0x00, 0x03) }?.value ?: byteArrayOf(0x00, 0x03)
var flipped by remember { mutableStateOf(callControlEnabledValue.contentEquals(byteArrayOf(0x00, 0x02))) } val pressOnceText = stringResource(R.string.press_once)
var singlePressAction by remember { mutableStateOf(if (flipped) "Press Twice" else "Press Once") } val pressTwiceText = stringResource(R.string.press_twice)
var doublePressAction by remember { mutableStateOf(if (flipped) "Press Once" else "Press Twice") }
var flipped by remember {
mutableStateOf(
callControlEnabledValue.contentEquals(
byteArrayOf(
0x00,
0x02
)
)
)
}
var singlePressAction by remember { mutableStateOf(if (flipped) pressTwiceText else pressOnceText) }
var doublePressAction by remember { mutableStateOf(if (flipped) pressOnceText else pressTwiceText) }
var showSinglePressDropdown by remember { mutableStateOf(false) } var showSinglePressDropdown by remember { mutableStateOf(false) }
var touchOffsetSingle by remember { mutableStateOf<Offset?>(null) }
var boxPositionSingle by remember { mutableStateOf(Offset.Zero) }
var lastDismissTimeSingle by remember { mutableLongStateOf(0L) }
var parentHoveredIndexSingle by remember { mutableStateOf<Int?>(null) }
var parentDragActiveSingle by remember { mutableStateOf(false) }
var showDoublePressDropdown by remember { mutableStateOf(false) } var showDoublePressDropdown by remember { mutableStateOf(false) }
var touchOffsetDouble by remember { mutableStateOf<Offset?>(null) }
var boxPositionDouble by remember { mutableStateOf(Offset.Zero) }
var lastDismissTimeDouble by remember { mutableLongStateOf(0L) }
var parentHoveredIndexDouble by remember { mutableStateOf<Int?>(null) }
var parentDragActiveDouble by remember { mutableStateOf(false) }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
val listener = object : AACPManager.ControlCommandListener { val listener = object : AACPManager.ControlCommandListener {
override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) { override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) {
if (AACPManager.Companion.ControlCommandIdentifiers.fromByte(controlCommand.identifier) == if (AACPManager.Companion.ControlCommandIdentifiers.fromByte(controlCommand.identifier) ==
AACPManager.Companion.ControlCommandIdentifiers.CALL_MANAGEMENT_CONFIG) { AACPManager.Companion.ControlCommandIdentifiers.CALL_MANAGEMENT_CONFIG
) {
val newFlipped = controlCommand.value.contentEquals(byteArrayOf(0x00, 0x02)) val newFlipped = controlCommand.value.contentEquals(byteArrayOf(0x00, 0x02))
flipped = newFlipped flipped = newFlipped
singlePressAction = if (newFlipped) "Press Twice" else "Press Once" singlePressAction = if (newFlipped) pressTwiceText else pressOnceText
doublePressAction = if (newFlipped) "Press Once" else "Press Twice" doublePressAction = if (newFlipped) pressOnceText else pressTwiceText
Log.d("CallControlSettings", "Control command received, flipped: $newFlipped") Log.d(
"CallControlSettings",
"Control command received, flipped: $newFlipped"
)
} }
} }
} }
service.aacpManager.registerControlCommandListener( service.aacpManager.registerControlCommandListener(
AACPManager.Companion.ControlCommandIdentifiers.CALL_MANAGEMENT_CONFIG, AACPManager.Companion.ControlCommandIdentifiers.CALL_MANAGEMENT_CONFIG,
listener listener
@@ -121,11 +152,13 @@ fun CallControlSettings() {
service.aacpManager.controlCommandListeners[AACPManager.Companion.ControlCommandIdentifiers.CALL_MANAGEMENT_CONFIG]?.clear() service.aacpManager.controlCommandListeners[AACPManager.Companion.ControlCommandIdentifiers.CALL_MANAGEMENT_CONFIG]?.clear()
} }
} }
LaunchedEffect(flipped) { LaunchedEffect(flipped) {
Log.d("CallControlSettings", "Call control flipped: $flipped") Log.d("CallControlSettings", "Call control flipped: $flipped")
} }
val density = LocalDensity.current
val itemHeightPx = with(density) { 48.dp.toPx() }
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -161,7 +194,66 @@ fun CallControlSettings() {
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(start = 12.dp, end = 12.dp) .padding(start = 12.dp, end = 12.dp)
.height(50.dp), .height(50.dp)
.pointerInput(Unit) {
detectTapGestures { offset ->
val now = System.currentTimeMillis()
if (showSinglePressDropdown) {
showSinglePressDropdown = false
lastDismissTimeSingle = now
} else {
if (now - lastDismissTimeSingle > 250L) {
touchOffsetSingle = offset
showSinglePressDropdown = true
}
}
}
}
.pointerInput(Unit) {
detectDragGesturesAfterLongPress(
onDragStart = { offset ->
val now = System.currentTimeMillis()
touchOffsetSingle = offset
if (!showSinglePressDropdown && now - lastDismissTimeSingle > 250L) {
showSinglePressDropdown = true
}
lastDismissTimeSingle = now
parentDragActiveSingle = true
parentHoveredIndexSingle = 0
},
onDrag = { change, _ ->
val current = change.position
val touch = touchOffsetSingle ?: current
val posInPopupY = current.y - touch.y
val idx = (posInPopupY / itemHeightPx).toInt()
parentHoveredIndexSingle = idx
},
onDragEnd = {
parentDragActiveSingle = false
parentHoveredIndexSingle?.let { idx ->
val options = listOf(pressOnceText, pressTwiceText)
if (idx in options.indices) {
val option = options[idx]
singlePressAction = option
doublePressAction =
if (option == pressOnceText) pressTwiceText else pressOnceText
showSinglePressDropdown = false
lastDismissTimeSingle = System.currentTimeMillis()
val bytes = if (option == pressOnceText) byteArrayOf(
0x00,
0x03
) else byteArrayOf(0x00, 0x02)
service.aacpManager.sendControlCommand(0x24, bytes)
}
}
parentHoveredIndexSingle = null
},
onDragCancel = {
parentDragActiveSingle = false
parentHoveredIndexSingle = null
}
)
},
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
@@ -171,13 +263,16 @@ fun CallControlSettings() {
color = textColor, color = textColor,
modifier = Modifier.padding(bottom = 4.dp) modifier = Modifier.padding(bottom = 4.dp)
) )
Box { Box(
modifier = Modifier.onGloballyPositioned { coordinates ->
boxPositionSingle = coordinates.positionInParent()
}
) {
Row( Row(
modifier = Modifier.clickable { showSinglePressDropdown = true },
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Text( Text(
text = if (singlePressAction == "Press Once") stringResource(R.string.press_once) else stringResource(R.string.press_twice), text = singlePressAction,
fontSize = 16.sp, fontSize = 16.sp,
color = textColor.copy(alpha = 0.8f) color = textColor.copy(alpha = 0.8f)
) )
@@ -188,29 +283,31 @@ fun CallControlSettings() {
tint = textColor.copy(alpha = 0.6f) tint = textColor.copy(alpha = 0.6f)
) )
} }
DropdownMenu(
DragSelectableDropdown(
expanded = showSinglePressDropdown, expanded = showSinglePressDropdown,
onDismissRequest = { showSinglePressDropdown = false } onDismissRequest = {
) { showSinglePressDropdown = false
DropdownMenuItem( lastDismissTimeSingle = System.currentTimeMillis()
text = { Text(stringResource(R.string.press_once)) }, },
onClick = { options = listOf(pressOnceText, pressTwiceText),
singlePressAction = "Press Once" selectedOption = singlePressAction,
doublePressAction = "Press Twice" touchOffset = touchOffsetSingle,
showSinglePressDropdown = false boxPosition = boxPositionSingle,
service.aacpManager.sendControlCommand(0x24, byteArrayOf(0x00, 0x03)) externalHoveredIndex = parentHoveredIndexSingle,
} externalDragActive = parentDragActiveSingle,
) onOptionSelected = { option ->
DropdownMenuItem( singlePressAction = option
text = { Text(stringResource(R.string.press_twice)) }, doublePressAction =
onClick = { if (option == pressOnceText) pressTwiceText else pressOnceText
singlePressAction = "Press Twice" showSinglePressDropdown = false
doublePressAction = "Press Once" val bytes = if (option == pressOnceText) byteArrayOf(
showSinglePressDropdown = false 0x00,
service.aacpManager.sendControlCommand(0x24, byteArrayOf(0x00, 0x02)) 0x03
} ) else byteArrayOf(0x00, 0x02)
) service.aacpManager.sendControlCommand(0x24, bytes)
} }
)
} }
} }
HorizontalDivider( HorizontalDivider(
@@ -224,7 +321,66 @@ fun CallControlSettings() {
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(start = 12.dp, end = 12.dp) .padding(start = 12.dp, end = 12.dp)
.height(50.dp), .height(50.dp)
.pointerInput(Unit) {
detectTapGestures { offset ->
val now = System.currentTimeMillis()
if (showDoublePressDropdown) {
showDoublePressDropdown = false
lastDismissTimeDouble = now
} else {
if (now - lastDismissTimeDouble > 250L) {
touchOffsetDouble = offset
showDoublePressDropdown = true
}
}
}
}
.pointerInput(Unit) {
detectDragGesturesAfterLongPress(
onDragStart = { offset ->
val now = System.currentTimeMillis()
touchOffsetDouble = offset
if (!showDoublePressDropdown && now - lastDismissTimeDouble > 250L) {
showDoublePressDropdown = true
}
lastDismissTimeDouble = now
parentDragActiveDouble = true
parentHoveredIndexDouble = 0
},
onDrag = { change, _ ->
val current = change.position
val touch = touchOffsetDouble ?: current
val posInPopupY = current.y - touch.y
val idx = (posInPopupY / itemHeightPx).toInt()
parentHoveredIndexDouble = idx
},
onDragEnd = {
parentDragActiveDouble = false
parentHoveredIndexDouble?.let { idx ->
val options = listOf(pressOnceText, pressTwiceText)
if (idx in options.indices) {
val option = options[idx]
doublePressAction = option
singlePressAction =
if (option == pressOnceText) pressTwiceText else pressOnceText
showDoublePressDropdown = false
lastDismissTimeDouble = System.currentTimeMillis()
val bytes = if (option == pressOnceText) byteArrayOf(
0x00,
0x02
) else byteArrayOf(0x00, 0x03)
service.aacpManager.sendControlCommand(0x24, bytes)
}
}
parentHoveredIndexDouble = null
},
onDragCancel = {
parentDragActiveDouble = false
parentHoveredIndexDouble = null
}
)
},
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
@@ -234,13 +390,16 @@ fun CallControlSettings() {
color = textColor, color = textColor,
modifier = Modifier.padding(bottom = 4.dp) modifier = Modifier.padding(bottom = 4.dp)
) )
Box { Box(
modifier = Modifier.onGloballyPositioned { coordinates ->
boxPositionDouble = coordinates.positionInParent()
}
) {
Row( Row(
modifier = Modifier.clickable { showDoublePressDropdown = true },
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Text( Text(
text = if (doublePressAction == "Press Once") stringResource(R.string.press_once) else stringResource(R.string.press_twice), text = doublePressAction,
fontSize = 16.sp, fontSize = 16.sp,
color = textColor.copy(alpha = 0.8f) color = textColor.copy(alpha = 0.8f)
) )
@@ -251,29 +410,31 @@ fun CallControlSettings() {
tint = textColor.copy(alpha = 0.6f) tint = textColor.copy(alpha = 0.6f)
) )
} }
DropdownMenu(
DragSelectableDropdown(
expanded = showDoublePressDropdown, expanded = showDoublePressDropdown,
onDismissRequest = { showDoublePressDropdown = false } onDismissRequest = {
) { showDoublePressDropdown = false
DropdownMenuItem( lastDismissTimeDouble = System.currentTimeMillis()
text = { Text(stringResource(R.string.press_once)) }, },
onClick = { options = listOf(pressOnceText, pressTwiceText),
doublePressAction = "Press Once" selectedOption = doublePressAction,
singlePressAction = "Press Twice" touchOffset = touchOffsetDouble,
showDoublePressDropdown = false boxPosition = boxPositionDouble,
service.aacpManager.sendControlCommand(0x24, byteArrayOf(0x00, 0x02)) externalHoveredIndex = parentHoveredIndexDouble,
} externalDragActive = parentDragActiveDouble,
) onOptionSelected = { option ->
DropdownMenuItem( doublePressAction = option
text = { Text(stringResource(R.string.press_twice)) }, singlePressAction =
onClick = { if (option == pressOnceText) pressTwiceText else pressOnceText
doublePressAction = "Press Twice" showDoublePressDropdown = false
singlePressAction = "Press Once" val bytes = if (option == pressOnceText) byteArrayOf(
showDoublePressDropdown = false 0x00,
service.aacpManager.sendControlCommand(0x24, byteArrayOf(0x00, 0x03)) 0x02
} ) else byteArrayOf(0x00, 0x03)
) service.aacpManager.sendControlCommand(0x24, bytes)
} }
)
} }
} }
} }
@@ -284,4 +445,4 @@ fun CallControlSettings() {
@Composable @Composable
fun CallControlSettingsPreview() { fun CallControlSettingsPreview() {
CallControlSettings() CallControlSettings()
} }

View File

@@ -21,14 +21,22 @@
package me.kavishdevar.librepods.composables package me.kavishdevar.librepods.composables
import android.util.Log import android.util.Log
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
@@ -37,27 +45,37 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material3.DropdownMenu import androidx.compose.material3.Card
import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon import androidx.compose.material3.Checkbox
import androidx.compose.material3.CheckboxDefaults
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInParent
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Popup
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.services.ServiceManager import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.utils.AACPManager import me.kavishdevar.librepods.utils.AACPManager
@@ -79,8 +97,8 @@ fun MicrophoneSettings() {
val micModeValue = service.aacpManager.controlCommandStatusList.find { val micModeValue = service.aacpManager.controlCommandStatusList.find {
it.identifier == AACPManager.Companion.ControlCommandIdentifiers.MIC_MODE it.identifier == AACPManager.Companion.ControlCommandIdentifiers.MIC_MODE
}?.value?.get(0) ?: 0x00.toByte() }?.value?.get(0) ?: 0x00.toByte()
var selectedMode by remember { var selectedMode by remember {
mutableStateOf( mutableStateOf(
when (micModeValue) { when (micModeValue) {
0x00.toByte() -> "Automatic" 0x00.toByte() -> "Automatic"
@@ -91,22 +109,27 @@ fun MicrophoneSettings() {
) )
} }
var showDropdown by remember { mutableStateOf(false) } var showDropdown by remember { mutableStateOf(false) }
var touchOffset by remember { mutableStateOf<Offset?>(null) }
var boxPosition by remember { mutableStateOf(Offset.Zero) }
var lastDismissTime by remember { mutableLongStateOf(0L) }
val reopenThresholdMs = 250L
val listener = object : AACPManager.ControlCommandListener { val listener = object : AACPManager.ControlCommandListener {
override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) { override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) {
if (AACPManager.Companion.ControlCommandIdentifiers.fromByte(controlCommand.identifier) == if (AACPManager.Companion.ControlCommandIdentifiers.fromByte(controlCommand.identifier) ==
AACPManager.Companion.ControlCommandIdentifiers.MIC_MODE) { AACPManager.Companion.ControlCommandIdentifiers.MIC_MODE
selectedMode = when (controlCommand.value.get(0)) { ) {
0x00.toByte() -> "Automatic" selectedMode = when (controlCommand.value[0]) {
0x01.toByte() -> "Always Right" 0x00.toByte() -> "Automatic"
0x02.toByte() -> "Always Left" 0x01.toByte() -> "Always Right"
else -> "Automatic" 0x02.toByte() -> "Always Left"
} else -> "Automatic"
Log.d("MicrophoneSettings", "Microphone mode received: $selectedMode")
} }
Log.d("MicrophoneSettings", "Microphone mode received: $selectedMode")
} }
} }
}
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
service.aacpManager.registerControlCommandListener( service.aacpManager.registerControlCommandListener(
AACPManager.Companion.ControlCommandIdentifiers.MIC_MODE, AACPManager.Companion.ControlCommandIdentifiers.MIC_MODE,
@@ -123,11 +146,84 @@ fun MicrophoneSettings() {
} }
} }
val density = LocalDensity.current
val itemHeightPx = with(density) { 48.dp.toPx() }
var parentHoveredIndex by remember { mutableStateOf<Int?>(null) }
var parentDragActive by remember { mutableStateOf(false) }
val microphoneAutomaticText = stringResource(R.string.microphone_automatic)
val microphoneAlwaysRightText = stringResource(R.string.microphone_always_right)
val microphoneAlwaysLeftText = stringResource(R.string.microphone_always_left)
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(start = 12.dp, end = 12.dp) .padding(start = 12.dp, end = 12.dp)
.height(55.dp), .height(55.dp)
.pointerInput(Unit) {
detectTapGestures { offset ->
val now = System.currentTimeMillis()
if (showDropdown) {
showDropdown = false
lastDismissTime = now
} else {
if (now - lastDismissTime > reopenThresholdMs) {
touchOffset = offset
showDropdown = true
}
}
}
}
.pointerInput(Unit) {
detectDragGesturesAfterLongPress(
onDragStart = { offset ->
val now = System.currentTimeMillis()
touchOffset = offset
if (!showDropdown && now - lastDismissTime > reopenThresholdMs) {
showDropdown = true
}
lastDismissTime = now
parentDragActive = true
parentHoveredIndex = 0
},
onDrag = { change, _ ->
val current = change.position
val touch = touchOffset ?: current
val posInPopupY = current.y - touch.y
val idx = (posInPopupY / itemHeightPx).toInt()
parentHoveredIndex = idx
},
onDragEnd = {
parentDragActive = false
parentHoveredIndex?.let { idx ->
val options = listOf(
microphoneAutomaticText,
microphoneAlwaysRightText,
microphoneAlwaysLeftText
)
if (idx in options.indices) {
val option = options[idx]
selectedMode = option
showDropdown = false
lastDismissTime = System.currentTimeMillis()
val byteValue = when (option) {
options[0] -> 0x00
options[1] -> 0x01
options[2] -> 0x02
else -> 0x00
}
service.aacpManager.sendControlCommand(
AACPManager.Companion.ControlCommandIdentifiers.MIC_MODE.value,
byteArrayOf(byteValue.toByte())
)
}
}
parentHoveredIndex = null
},
onDragCancel = {
parentDragActive = false
parentHoveredIndex = null
}
)
},
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
@@ -137,9 +233,12 @@ fun MicrophoneSettings() {
color = textColor, color = textColor,
modifier = Modifier.padding(bottom = 4.dp) modifier = Modifier.padding(bottom = 4.dp)
) )
Box { Box(
modifier = Modifier.onGloballyPositioned { coordinates ->
boxPosition = coordinates.positionInParent()
}
) {
Row( Row(
modifier = Modifier.clickable { showDropdown = true },
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Text( Text(
@@ -154,44 +253,42 @@ fun MicrophoneSettings() {
tint = textColor.copy(alpha = 0.6f) tint = textColor.copy(alpha = 0.6f)
) )
} }
DropdownMenu(
val microphoneAutomaticText = stringResource(R.string.microphone_automatic)
val microphoneAlwaysRightText = stringResource(R.string.microphone_always_right)
val microphoneAlwaysLeftText = stringResource(R.string.microphone_always_left)
DragSelectableDropdown(
expanded = showDropdown, expanded = showDropdown,
onDismissRequest = { showDropdown = false } onDismissRequest = {
) { showDropdown = false
DropdownMenuItem( lastDismissTime = System.currentTimeMillis()
text = { Text(stringResource(R.string.microphone_automatic)) }, },
onClick = { options = listOf(
selectedMode = "Automatic" microphoneAutomaticText,
showDropdown = false microphoneAlwaysRightText,
service.aacpManager.sendControlCommand( microphoneAlwaysLeftText
AACPManager.Companion.ControlCommandIdentifiers.MIC_MODE.value, ),
byteArrayOf(0x00) selectedOption = selectedMode,
) touchOffset = touchOffset,
boxPosition = boxPosition,
externalHoveredIndex = parentHoveredIndex,
externalDragActive = parentDragActive,
onOptionSelected = { option ->
selectedMode = option
showDropdown = false
val byteValue = when (option) {
microphoneAutomaticText -> 0x00
microphoneAlwaysRightText -> 0x01
microphoneAlwaysLeftText -> 0x02
else -> 0x00
} }
) service.aacpManager.sendControlCommand(
DropdownMenuItem( AACPManager.Companion.ControlCommandIdentifiers.MIC_MODE.value,
text = { Text(stringResource(R.string.microphone_always_right)) }, byteArrayOf(byteValue.toByte())
onClick = { )
selectedMode = "Always Right" }
showDropdown = false )
service.aacpManager.sendControlCommand(
AACPManager.Companion.ControlCommandIdentifiers.MIC_MODE.value,
byteArrayOf(0x01)
)
}
)
DropdownMenuItem(
text = { Text(stringResource(R.string.microphone_always_left)) },
onClick = {
selectedMode = "Always Left"
showDropdown = false
service.aacpManager.sendControlCommand(
AACPManager.Companion.ControlCommandIdentifiers.MIC_MODE.value,
byteArrayOf(0x02)
)
}
)
}
} }
} }
} }
@@ -201,4 +298,155 @@ fun MicrophoneSettings() {
@Composable @Composable
fun MicrophoneSettingsPreview() { fun MicrophoneSettingsPreview() {
MicrophoneSettings() MicrophoneSettings()
} }
@Composable
fun DragSelectableDropdown(
expanded: Boolean,
onDismissRequest: () -> Unit,
options: List<String>,
selectedOption: String,
touchOffset: Offset?,
boxPosition: Offset,
onOptionSelected: (String) -> Unit,
externalHoveredIndex: Int? = null,
externalDragActive: Boolean = false,
modifier: Modifier = Modifier
) {
if (expanded) {
val relativeOffset = touchOffset?.let { it - boxPosition } ?: Offset.Zero
Popup(
offset = IntOffset(relativeOffset.x.toInt(), relativeOffset.y.toInt()),
onDismissRequest = onDismissRequest
) {
AnimatedVisibility(
visible = true,
enter = slideInVertically(initialOffsetY = { -it }) + fadeIn(),
exit = slideOutVertically(targetOffsetY = { -it }) + fadeOut()
) {
Card(
modifier = modifier
.padding(8.dp)
.width(300.dp)
.background(
if (isSystemInDarkTheme()) Color(0xFF2C2C2E) else Color(0xFFFFFFFF)
)
.clip(RoundedCornerShape(8.dp)),
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
) {
var hoveredIndex by remember { mutableStateOf<Int?>(null) }
val itemHeight = 48.dp
var popupSize by remember { mutableStateOf(IntSize(0, 0)) }
var lastDragPosition by remember { mutableStateOf<Offset?>(null) }
LaunchedEffect(externalHoveredIndex, externalDragActive) {
if (externalDragActive) {
hoveredIndex = externalHoveredIndex
}
}
Column(
modifier = Modifier
.onGloballyPositioned { coordinates ->
popupSize = coordinates.size
}
.pointerInput(popupSize) {
detectDragGestures(
onDragStart = { offset ->
hoveredIndex = (offset.y / itemHeight.toPx()).toInt()
lastDragPosition = offset
},
onDrag = { change, _ ->
val y = change.position.y
hoveredIndex = (y / itemHeight.toPx()).toInt()
lastDragPosition = change.position
},
onDragEnd = {
val pos = lastDragPosition
val withinBounds = pos != null &&
pos.x >= 0f && pos.y >= 0f &&
pos.x <= popupSize.width.toFloat() && pos.y <= popupSize.height.toFloat()
if (withinBounds) {
hoveredIndex?.let { idx ->
if (idx in options.indices) {
onOptionSelected(options[idx])
}
}
onDismissRequest()
} else {
hoveredIndex = null
}
}
)
}
) {
options.forEachIndexed { index, text ->
val isHovered =
if (externalDragActive && externalHoveredIndex != null) {
index == externalHoveredIndex
} else {
index == hoveredIndex
}
Box(
modifier = Modifier
.fillMaxWidth()
.height(itemHeight)
.background(
if (isHovered) (if (isSystemInDarkTheme()) Color(0xFF3A3A3C) else Color(
0xFFD1D1D6
)) else Color.Transparent
)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null
) {
onOptionSelected(text)
onDismissRequest()
}
.padding(horizontal = 12.dp),
contentAlignment = Alignment.CenterStart
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text,
color = if (isSystemInDarkTheme()) Color.White else Color.Black
)
Checkbox(
checked = text == selectedOption,
onCheckedChange = { onOptionSelected(text) },
colors = CheckboxDefaults.colors().copy(
checkedCheckmarkColor = Color(0xFF007AFF),
uncheckedCheckmarkColor = Color.Transparent,
checkedBoxColor = Color.Transparent,
uncheckedBoxColor = Color.Transparent,
checkedBorderColor = Color.Transparent,
uncheckedBorderColor = Color.Transparent,
disabledBorderColor = Color.Transparent,
disabledCheckedBoxColor = Color.Transparent,
disabledUncheckedBoxColor = Color.Transparent,
disabledUncheckedBorderColor = Color.Transparent
)
)
}
}
if (index != options.lastIndex) {
HorizontalDivider(
thickness = 1.5.dp,
color = Color(0x40888888),
modifier = Modifier.padding(start = 12.dp, end = 0.dp)
)
}
}
}
}
}
}
}
}