change from "hold to record" to "click to start, click to stop"

This commit is contained in:
davotoula
2026-01-09 11:51:42 +01:00
parent d8b8650048
commit 3c12be354e
2 changed files with 70 additions and 149 deletions
@@ -37,7 +37,7 @@ import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState import com.google.accompanist.permissions.rememberPermissionState
import com.vitorpamplona.amethyst.R 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 com.vitorpamplona.amethyst.ui.stringRes
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
@@ -52,15 +52,16 @@ fun RecordAudioBox(
val mediaRecorder = remember { mutableStateOf<VoiceMessageRecorder?>(null) } val mediaRecorder = remember { mutableStateOf<VoiceMessageRecorder?>(null) }
val context = LocalContext.current val context = LocalContext.current
var elapsedSeconds by remember { mutableIntStateOf(0) } 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 // Must be called at Composable scope, not in callback
val recordPermissionState = rememberPermissionState(Manifest.permission.RECORD_AUDIO) val recordPermissionState = rememberPermissionState(Manifest.permission.RECORD_AUDIO)
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val isRecording = mediaRecorder.value != null
DisposableEffect(Unit) { DisposableEffect(Unit) {
onDispose { onDispose {
wantsToRecord = false
mediaRecorder.value?.stop() mediaRecorder.value?.stop()
mediaRecorder.value = null mediaRecorder.value = null
} }
@@ -74,8 +75,25 @@ fun RecordAudioBox(
} }
} }
LaunchedEffect(recordPermissionState.status.isGranted, wantsToRecord) { fun stopRecording() {
if (recordPermissionState.status.isGranted && wantsToRecord) { 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() startRecording()
} }
} }
@@ -96,38 +114,21 @@ fun RecordAudioBox(
} }
} }
ClickAndHoldBoxComposable( ToggleableBox(
modifier = modifier, modifier = modifier,
onPress = { isActive = isRecording,
wantsToRecord = true onClick = {
if (isRecording) {
stopRecording()
} else {
if (!recordPermissionState.status.isGranted) { if (!recordPermissionState.status.isGranted) {
pendingPermissionStart = true
recordPermissionState.launchPermissionRequest() recordPermissionState.launchPermissionRequest()
} else { } else {
// Start immediately for responsive UX when permission already granted
startRecording() 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 = { content = { active -> content(active, elapsedSeconds) },
wantsToRecord = false
mediaRecorder.value?.stop()
mediaRecorder.value = null
},
content = @Composable { isRecording -> content(isRecording, elapsedSeconds) },
) )
} }
@@ -28,17 +28,12 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.interaction.MutableInteractionSource 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.layout.Box
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
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.scale 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<PressInteraction.Press>()
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) @OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
fun ClickableBox( fun ClickableBox(
@@ -195,3 +80,38 @@ fun ClickableBox(
content() 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)
}
}