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/actions/uploads/RecordingIndicators.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordingIndicators.kt index a4e3ecd65..0f7fb0946 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordingIndicators.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordingIndicators.kt @@ -35,7 +35,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.FiberManualRecord +import androidx.compose.material.icons.filled.Stop import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -206,8 +206,8 @@ fun FloatingRecordingIndicator( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(horizontal = innerPadding), ) { - // Pulsing red dot - val infiniteTransition = rememberInfiniteTransition(label = "recording_dot") + // Pulsing stop square + val infiniteTransition = rememberInfiniteTransition(label = "recording_stop") val dotAlpha by infiniteTransition.animateFloat( initialValue = 1f, targetValue = 0.5f, @@ -220,7 +220,7 @@ fun FloatingRecordingIndicator( ) Icon( - imageVector = Icons.Default.FiberManualRecord, + imageVector = Icons.Default.Stop, contentDescription = recordingLabel, tint = Color.White, modifier = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/AudioWaveformReadOnly.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/AudioWaveformReadOnly.kt index b6453b4cf..490352912 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/AudioWaveformReadOnly.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/AudioWaveformReadOnly.kt @@ -26,7 +26,6 @@ import androidx.compose.animation.core.tween import androidx.compose.foundation.Canvas import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.requiredHeight -import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf @@ -71,7 +70,6 @@ fun AudioWaveformReadOnly( progressBrush: Brush = SolidColor(Color.Blue), waveformAlignment: WaveformAlignment = WaveformAlignment.Center, amplitudeType: AmplitudeType = AmplitudeType.Avg, - onProgressChangeFinished: (() -> Unit)? = null, spikeAnimationSpec: AnimationSpec = tween(500), spikeWidth: Dp = 3.dp, spikeRadius: Dp = 2.dp, @@ -80,7 +78,6 @@ fun AudioWaveformReadOnly( amplitudes: List, onProgressChange: (Float) -> Unit, ) { - val backgroundColor = MaterialTheme.colorScheme.background val progressState = remember(progress) { progress.coerceIn(MIN_PROGRESS, MAX_PROGRESS) } val spikeWidthState = remember(spikeWidth) { spikeWidth.coerceIn(MinSpikeWidthDp, MaxSpikeWidthDp) } @@ -195,7 +192,20 @@ internal fun Iterable.chunkToSize( internal fun Iterable.normalize( min: Float, max: Float, -): List = map { (max - min) * ((it - min()) / (max() - min())) + min } +): List { + val values = toList() + if (values.isEmpty()) return emptyList() + + val currentMin = values.minOrNull() ?: return emptyList() + val currentMax = values.maxOrNull() ?: return emptyList() + val range = currentMax - currentMin + if (!range.isFinite() || range == 0f) { + return List(values.size) { min } + } + + val scale = max - min + return values.map { scale * ((it - currentMin) / range) + min } +} private fun Int.safeDiv(value: Int): Float { return if (value == 0) return 0F else this / value.toFloat() 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) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyScreen.kt index da1dd4b1c..15f30bb19 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyScreen.kt @@ -35,6 +35,7 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Mic +import androidx.compose.material.icons.filled.Stop import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -53,6 +54,7 @@ import com.vitorpamplona.amethyst.ui.actions.mediaServers.FileServerSelectionRow import com.vitorpamplona.amethyst.ui.actions.uploads.RecordAudioBox import com.vitorpamplona.amethyst.ui.actions.uploads.UploadProgressIndicator import com.vitorpamplona.amethyst.ui.actions.uploads.VoiceMessagePreview +import com.vitorpamplona.amethyst.ui.actions.uploads.formatSecondsToTime import com.vitorpamplona.amethyst.ui.navigation.navs.Nav import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar import com.vitorpamplona.amethyst.ui.note.NoteCompose @@ -212,29 +214,44 @@ private fun ReRecordButton(viewModel: VoiceReplyViewModel) { onRecordTaken = { recording -> viewModel.selectRecording(recording) }, - ) { isRecording, _ -> + ) { isRecording, elapsedSeconds -> + val contentColor = + if (isRecording) { + MaterialTheme.colorScheme.onPrimary + } else { + MaterialTheme.colorScheme.onBackground + } + val icon = + if (isRecording) { + Icons.Default.Stop + } else { + Icons.Default.Mic + } + val label = + if (isRecording) { + formatSecondsToTime(elapsedSeconds) + } else { + stringRes(id = R.string.re_record) + } + val iconDescription = + if (isRecording) { + stringRes(id = R.string.recording_indicator_description) + } else { + stringRes(id = R.string.record_a_message) + } Row( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), ) { Icon( - imageVector = Icons.Default.Mic, - contentDescription = stringRes(id = R.string.record_a_message), - tint = - if (isRecording) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onBackground - }, + imageVector = icon, + contentDescription = iconDescription, + tint = contentColor, ) Text( - text = stringRes(id = R.string.re_record), - color = - if (isRecording) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onBackground - }, + text = label, + color = contentColor, ) } } diff --git a/amethyst/src/main/res/values-cs-rCZ/strings.xml b/amethyst/src/main/res/values-cs-rCZ/strings.xml index 8ff4d900a..7db2029cb 100644 --- a/amethyst/src/main/res/values-cs-rCZ/strings.xml +++ b/amethyst/src/main/res/values-cs-rCZ/strings.xml @@ -673,6 +673,7 @@ Politika příspěvků Zásady ochrany soukromí Podmínky & ujednání + N/A Chyby a upozornění z tohoto relé Délka zprávy Předplatné diff --git a/amethyst/src/main/res/values-de-rDE/strings.xml b/amethyst/src/main/res/values-de-rDE/strings.xml index f31726918..d1e1a9f3e 100644 --- a/amethyst/src/main/res/values-de-rDE/strings.xml +++ b/amethyst/src/main/res/values-de-rDE/strings.xml @@ -678,6 +678,7 @@ anz der Bedingungen ist erforderlich Veröffentlichungsrichtlinie Datenschutzerklärung Allgemeine Geschäftsbedingungen + N/A Fehler und Hinweise von diesem Relais Nachrichtenlänge Abonnements diff --git a/amethyst/src/main/res/values-pl-rPL/strings.xml b/amethyst/src/main/res/values-pl-rPL/strings.xml index bb966d36b..cb844defb 100644 --- a/amethyst/src/main/res/values-pl-rPL/strings.xml +++ b/amethyst/src/main/res/values-pl-rPL/strings.xml @@ -64,7 +64,7 @@ Zapy Liczba wyświetleń Powtórz - powtórzony + powtórzono edytowano edytuj #%1$s oryginalny @@ -135,6 +135,8 @@ Dodaj użytkownika Dodaj Transmiter Imię + Imię (dla @tagging) + Moje imię @tag Nazwa użytkownika Mój nick G Braun diff --git a/amethyst/src/main/res/values-pt-rBR/strings.xml b/amethyst/src/main/res/values-pt-rBR/strings.xml index d3d01e993..dcd1a2b45 100644 --- a/amethyst/src/main/res/values-pt-rBR/strings.xml +++ b/amethyst/src/main/res/values-pt-rBR/strings.xml @@ -673,6 +673,7 @@ Política de postagem Política de privacidade Termos & condições + N/A Erros e Avisos deste Relé Tamanho da mensagem Assinaturas @@ -686,6 +687,8 @@ Aceita até %1$s no futuro há %1$s + %1$s zeros + %1$s bits Retenção de eventos Tamanho do conteúdo Conectividade diff --git a/amethyst/src/main/res/values-sv-rSE/strings.xml b/amethyst/src/main/res/values-sv-rSE/strings.xml index 623a0eadb..74ffeae20 100644 --- a/amethyst/src/main/res/values-sv-rSE/strings.xml +++ b/amethyst/src/main/res/values-sv-rSE/strings.xml @@ -672,6 +672,7 @@ Publiceringspolicy Integritetspolicy Villkor & bestämmelser + N/A Fel och meddelanden från detta relä Meddelandelängd Prenumerationer diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/note/NoteCard.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/note/NoteCard.kt index 304929af4..67a70a500 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/note/NoteCard.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/note/NoteCard.kt @@ -28,7 +28,6 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/TimeAgoFormatter.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/TimeAgoFormatter.kt index 12307ecd2..43221805b 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/TimeAgoFormatter.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/TimeAgoFormatter.kt @@ -20,7 +20,6 @@ */ package com.vitorpamplona.amethyst.commons.util -import com.sun.org.apache.xalan.internal.lib.ExsltDatetime.time import com.vitorpamplona.quartz.utils.TimeUtils import java.text.SimpleDateFormat import java.util.Locale