change from "hold to record" to "click to start, click to stop"
This commit is contained in:
+35
-34
@@ -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<VoiceMessageRecorder?>(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) },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<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)
|
||||
@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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user