From 3c12be354e4bead5a5b6438e49cec5e02ade4414 Mon Sep 17 00:00:00 2001 From: davotoula Date: Fri, 9 Jan 2026 11:51:42 +0100 Subject: [PATCH] change from "hold to record" to "click to start, click to stop" --- .../ui/actions/uploads/RecordAudio.kt | 69 ++++---- .../amethyst/ui/components/ClickableBox.kt | 150 ++++-------------- 2 files changed, 70 insertions(+), 149 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordAudio.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordAudio.kt index c5083fb12..32af08e0b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordAudio.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordAudio.kt @@ -37,7 +37,7 @@ import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.isGranted import com.google.accompanist.permissions.rememberPermissionState import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.ui.components.ClickAndHoldBoxComposable +import com.vitorpamplona.amethyst.ui.components.ToggleableBox import com.vitorpamplona.amethyst.ui.stringRes import kotlinx.coroutines.delay import kotlinx.coroutines.isActive @@ -52,15 +52,16 @@ fun RecordAudioBox( val mediaRecorder = remember { mutableStateOf(null) } val context = LocalContext.current var elapsedSeconds by remember { mutableIntStateOf(0) } - var wantsToRecord by remember { mutableStateOf(false) } + var pendingPermissionStart by remember { mutableStateOf(false) } // Must be called at Composable scope, not in callback val recordPermissionState = rememberPermissionState(Manifest.permission.RECORD_AUDIO) val scope = rememberCoroutineScope() + val isRecording = mediaRecorder.value != null + DisposableEffect(Unit) { onDispose { - wantsToRecord = false mediaRecorder.value?.stop() mediaRecorder.value = null } @@ -74,8 +75,25 @@ fun RecordAudioBox( } } - LaunchedEffect(recordPermissionState.status.isGranted, wantsToRecord) { - if (recordPermissionState.status.isGranted && wantsToRecord) { + fun stopRecording() { + val result = mediaRecorder.value?.stop() + mediaRecorder.value = null + if (result != null) { + onRecordTaken(result) + } else { + Toast + .makeText( + context, + stringRes(context, R.string.record_a_message_description), + Toast.LENGTH_SHORT, + ).show() + } + } + + // Start recording after permission is granted + LaunchedEffect(recordPermissionState.status.isGranted) { + if (recordPermissionState.status.isGranted && pendingPermissionStart) { + pendingPermissionStart = false startRecording() } } @@ -96,38 +114,21 @@ fun RecordAudioBox( } } - ClickAndHoldBoxComposable( + ToggleableBox( modifier = modifier, - onPress = { - wantsToRecord = true - if (!recordPermissionState.status.isGranted) { - recordPermissionState.launchPermissionRequest() + isActive = isRecording, + onClick = { + if (isRecording) { + stopRecording() } else { - // Start immediately for responsive UX when permission already granted - startRecording() + if (!recordPermissionState.status.isGranted) { + pendingPermissionStart = true + recordPermissionState.launchPermissionRequest() + } else { + startRecording() + } } }, - onRelease = { - wantsToRecord = false - val result = mediaRecorder.value?.stop() - mediaRecorder.value = null - if (result != null) { - onRecordTaken(result) - } else { - // less disruptive than error messages - Toast - .makeText( - context, - stringRes(context, R.string.record_a_message_description), - Toast.LENGTH_SHORT, - ).show() - } - }, - onCancel = { - wantsToRecord = false - mediaRecorder.value?.stop() - mediaRecorder.value = null - }, - content = @Composable { isRecording -> content(isRecording, elapsedSeconds) }, + content = { active -> content(active, elapsedSeconds) }, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableBox.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableBox.kt index 6df79b5cd..a63656d4b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableBox.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableBox.kt @@ -28,17 +28,12 @@ import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.interaction.PressInteraction -import androidx.compose.foundation.interaction.collectIsPressedAsState import androidx.compose.foundation.layout.Box import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect 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.scale @@ -64,116 +59,6 @@ fun ClickableBox( } } -@Composable -fun ClickAndHoldBox( - modifier: Modifier = Modifier, - onPress: () -> Unit, - onRelease: () -> Unit, - content: @Composable (Boolean) -> Unit, -) { - val interactionSource = remember { MutableInteractionSource() } - val isPressed by interactionSource.collectIsPressedAsState() - - LaunchedEffect(isPressed) { - if (isPressed) { - // Button is pressed - onPress() - } else { - // Button is released - onRelease() - } - } - - // Animation for the button scale - val scale by animateFloatAsState( - targetValue = if (isPressed) 1.5f else 1.0f, // Scale up when recording - animationSpec = tween(durationMillis = 150), // Smooth animation - ) - - // Animation for the button color - val backgroundColor by animateColorAsState( - targetValue = if (isPressed) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.background, - animationSpec = tween(durationMillis = 150), - ) - - Box( - modifier - .scale(scale) - .background(backgroundColor, CircleShape) - .clickable( - role = Role.Button, - interactionSource = interactionSource, - indication = ripple24dp, - onClick = { }, - ), - contentAlignment = Alignment.Center, - ) { - content(isPressed) - } -} - -@Composable -fun ClickAndHoldBoxComposable( - modifier: Modifier = Modifier, - onPress: () -> Unit, - onRelease: suspend () -> Unit, - onCancel: suspend () -> Unit, - content: @Composable (Boolean) -> Unit, -) { - val interactionSource = remember { MutableInteractionSource() } - var isPressed by remember { mutableStateOf(false) } - - LaunchedEffect(interactionSource) { - val pressInteractions = mutableListOf() - interactionSource.interactions.collect { interaction -> - when (interaction) { - is PressInteraction.Press -> { - if (pressInteractions.isEmpty()) { - onPress() - } - pressInteractions.add(interaction) - } - is PressInteraction.Release -> { - onRelease() - pressInteractions.remove(interaction.press) - } - is PressInteraction.Cancel -> { - onCancel() - pressInteractions.remove(interaction.press) - } - } - isPressed = pressInteractions.isNotEmpty() - } - } - - // Animation for the button scale - val scale by animateFloatAsState( - targetValue = if (isPressed) 1.5f else 1.0f, // Scale up when recording - animationSpec = tween(durationMillis = 150), // Smooth animation - ) - - // Animation for the button color - val backgroundColor by animateColorAsState( - targetValue = if (isPressed) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.background, - animationSpec = tween(durationMillis = 150), - ) - - Box( - modifier - .scale(scale) - .background(backgroundColor, CircleShape) - .clickable( - role = Role.Button, - interactionSource = interactionSource, - indication = ripple24dp, - onClick = { }, - ), - contentAlignment = Alignment.Center, - ) { - content(isPressed) - } -} - @OptIn(ExperimentalFoundationApi::class) @Composable fun ClickableBox( @@ -195,3 +80,38 @@ fun ClickableBox( content() } } + +@Composable +fun ToggleableBox( + modifier: Modifier = Modifier, + isActive: Boolean, + onClick: () -> Unit, + content: @Composable (Boolean) -> Unit, +) { + // Animation for the button scale + val scale by animateFloatAsState( + targetValue = if (isActive) 1.5f else 1.0f, + animationSpec = tween(durationMillis = 150), + ) + + // Animation for the button color + val backgroundColor by animateColorAsState( + targetValue = if (isActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.background, + animationSpec = tween(durationMillis = 150), + ) + + Box( + modifier + .scale(scale) + .background(backgroundColor, CircleShape) + .clickable( + role = Role.Button, + interactionSource = remember { MutableInteractionSource() }, + indication = ripple24dp, + onClick = onClick, + ), + contentAlignment = Alignment.Center, + ) { + content(isActive) + } +}