diff --git a/WordPress/src/main/java/org/wordpress/android/ui/voicetocontent/VoiceToContentDialogFragment.kt b/WordPress/src/main/java/org/wordpress/android/ui/voicetocontent/VoiceToContentDialogFragment.kt index b804a6cb3311..ff491318e2bc 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/voicetocontent/VoiceToContentDialogFragment.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/voicetocontent/VoiceToContentDialogFragment.kt @@ -1,5 +1,7 @@ package org.wordpress.android.ui.voicetocontent +import android.annotation.SuppressLint +import android.app.Dialog import android.content.Intent import android.net.Uri import android.os.Bundle @@ -16,7 +18,10 @@ import org.wordpress.android.ui.compose.theme.AppTheme import org.wordpress.android.R import org.wordpress.android.util.audio.IAudioRecorder.Companion.REQUIRED_RECORDING_PERMISSIONS import android.provider.Settings +import android.widget.FrameLayout import androidx.compose.material.ExperimentalMaterialApi +import com.google.android.material.bottomsheet.BottomSheetBehavior +import com.google.android.material.bottomsheet.BottomSheetDialog import org.wordpress.android.ui.ActivityNavigator import javax.inject.Inject @@ -46,6 +51,25 @@ class VoiceToContentDialogFragment : BottomSheetDialogFragment() { viewModel.start() } + @SuppressLint("ClickableViewAccessibility") + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + val dialog = super.onCreateDialog(savedInstanceState) as BottomSheetDialog + dialog.setOnShowListener { + val bottomSheet: FrameLayout = dialog.findViewById( + com.google.android.material.R.id.design_bottom_sheet + ) ?: return@setOnShowListener + + val behavior = BottomSheetBehavior.from(bottomSheet) + behavior.isDraggable = true + behavior.skipCollapsed = true + behavior.state = BottomSheetBehavior.STATE_EXPANDED + + // Disable touch interception by the bottom sheet to allow nested scrolling + bottomSheet.setOnTouchListener { _, _ -> false } + } + return dialog + } + private fun observeViewModel() { viewModel.requestPermission.observe(viewLifecycleOwner) { requestAllPermissionsForRecording() diff --git a/WordPress/src/main/java/org/wordpress/android/ui/voicetocontent/VoiceToContentScreen.kt b/WordPress/src/main/java/org/wordpress/android/ui/voicetocontent/VoiceToContentScreen.kt index 3ae4dd42e18c..4cba83806d76 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/voicetocontent/VoiceToContentScreen.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/voicetocontent/VoiceToContentScreen.kt @@ -16,6 +16,8 @@ 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.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.material.CircularProgressIndicator import androidx.compose.material.ContentAlpha import androidx.compose.material.Icon @@ -34,7 +36,9 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.rememberNestedScrollInteropConnection import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextStyle @@ -48,16 +52,22 @@ import androidx.constraintlayout.compose.Dimension import org.wordpress.android.R import org.wordpress.android.ui.compose.components.buttons.Drawable import org.wordpress.android.ui.compose.theme.AppTheme +import org.wordpress.android.util.audio.RecordingUpdate @Composable fun VoiceToContentScreen( viewModel: VoiceToContentViewModel ) { val state by viewModel.state.collectAsState() - val amplitudes by viewModel.amplitudes.observeAsState(initial = listOf()) + val recordingUpdate by viewModel.recordingUpdate.observeAsState(initial = RecordingUpdate()) val configuration = LocalConfiguration.current val screenHeight = configuration.screenHeightDp.dp - val bottomSheetHeight = screenHeight * 0.6f // Set to 60% of screen height - but how can it be dynamic? + // Adjust the bottom sheet height based on orientation + val bottomSheetHeight = if (configuration.orientation == Configuration.ORIENTATION_LANDSCAPE) { + screenHeight // Full height in landscape + } else { + screenHeight * 0.6f // 60% height in portrait + } Surface( modifier = Modifier @@ -65,12 +75,19 @@ fun VoiceToContentScreen( .height(bottomSheetHeight), color = MaterialTheme.colors.surface ) { - VoiceToContentView(state, amplitudes) + Box( + modifier = Modifier + .fillMaxSize() + .nestedScroll(rememberNestedScrollInteropConnection()) // Enable nested scrolling for the bottom sheet + .verticalScroll(rememberScrollState()) // Enable vertical scrolling for the bottom sheet + ) { + VoiceToContentView(state, recordingUpdate) + } } } @Composable -fun VoiceToContentView(state: VoiceToContentUiState, amplitudes: List) { +fun VoiceToContentView(state: VoiceToContentUiState, recordingUpdate: RecordingUpdate) { Column( horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier @@ -84,7 +101,7 @@ fun VoiceToContentView(state: VoiceToContentUiState, amplitudes: List) { else -> { Header(state.header) SecondaryHeader(state.secondaryHeader) - RecordingPanel(state, amplitudes) + RecordingPanel(state, recordingUpdate) } } } @@ -167,7 +184,7 @@ fun SecondaryHeader(model: SecondaryHeaderUIModel?) { } @Composable -fun RecordingPanel(model: VoiceToContentUiState, amplitudes: List) { +fun RecordingPanel(model: VoiceToContentUiState, recordingUpdate: RecordingUpdate) { model.recordingPanel?.let { Row( verticalAlignment = Alignment.CenterVertically, @@ -189,14 +206,7 @@ fun RecordingPanel(model: VoiceToContentUiState, amplitudes: List) { .height(IntrinsicSize.Max) .padding(48.dp) ) { - WaveformVisualizer( - amplitudes = amplitudes, - modifier = Modifier - .fillMaxWidth() - .height(40.dp) - .padding(16.dp), - color = MaterialTheme.colors.primary - ) + ScrollingWaveformVisualizer(recordingUpdate = recordingUpdate) } } else if (model.uiStateType == VoiceToContentUIStateType.INELIGIBLE_FOR_FEATURE) { InEligible(model = it) @@ -338,7 +348,7 @@ fun PreviewInitializingView() { hasPermission = false ) ) - VoiceToContentView(state = state, amplitudes = listOf()) + VoiceToContentView(state = state, recordingUpdate = RecordingUpdate()) } } @@ -360,7 +370,7 @@ fun PreviewReadyToRecordView() { isEligibleForFeature = true ) ) - VoiceToContentView(state = state, amplitudes = listOf()) + VoiceToContentView(state = state, recordingUpdate = RecordingUpdate()) } } @@ -381,7 +391,7 @@ fun PreviewNotEligibleToRecordView() { upgradeUrl = "https://www.wordpress.com" ) ) - VoiceToContentView(state = state, amplitudes = listOf()) + VoiceToContentView(state = state, recordingUpdate = RecordingUpdate()) } } @@ -404,18 +414,7 @@ fun PreviewRecordingView() { isEligibleForFeature = true ) ) - VoiceToContentView( - state = state, - amplitudes = listOf( - 1.1f, - 2.2f, - 3.3f, - 4.4f, - 2.2f, - 3.3f, - 1.1f - ) - ) + VoiceToContentView(state = state, recordingUpdate = RecordingUpdate()) } } @@ -428,6 +427,6 @@ fun PreviewProcessingView() { uiStateType = VoiceToContentUIStateType.PROCESSING, header = HeaderUIModel(label = R.string.voice_to_content_processing_label, onClose = { }) ) - VoiceToContentView(state = state, amplitudes = listOf()) + VoiceToContentView(state = state, recordingUpdate = RecordingUpdate()) } } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/voicetocontent/VoiceToContentViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/voicetocontent/VoiceToContentViewModel.kt index 56af82ddac9d..6bb4264b368a 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/voicetocontent/VoiceToContentViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/voicetocontent/VoiceToContentViewModel.kt @@ -26,6 +26,7 @@ import org.wordpress.android.ui.voicetocontent.VoiceToContentUIStateType.RECORDI import org.wordpress.android.util.audio.IAudioRecorder import org.wordpress.android.util.audio.IAudioRecorder.AudioRecorderResult.Error import org.wordpress.android.util.audio.IAudioRecorder.AudioRecorderResult.Success +import org.wordpress.android.util.audio.RecordingUpdate import org.wordpress.android.viewmodel.ContextProvider import org.wordpress.android.viewmodel.ScopedViewModel import java.io.File @@ -49,8 +50,8 @@ class VoiceToContentViewModel @Inject constructor( private val _dismiss = MutableLiveData() val dismiss = _dismiss as LiveData - private val _amplitudes = MutableLiveData>() - val amplitudes: LiveData> get() = _amplitudes + private val _recordingUpdate = MutableLiveData() + val recordingUpdate: LiveData get() = _recordingUpdate private val _onIneligibleForVoiceToContent = MutableLiveData() val onIneligibleForVoiceToContent = _onIneligibleForVoiceToContent as LiveData @@ -104,11 +105,8 @@ class VoiceToContentViewModel @Inject constructor( } // Recording - // todo: This doesn't work as expected - @Suppress("MagicNumber") - private fun updateAmplitudes(newAmplitudes: List) { - _amplitudes.value = listOf(1.1f, 2.2f, 4.4f, 3.2f, 1.1f, 2.2f, 1.0f, 3.5f) - Log.d(javaClass.simpleName, "Update amplitudes: $newAmplitudes") + private fun updateRecordingData(recordingUpdate: RecordingUpdate) { + _recordingUpdate.value = recordingUpdate } private fun observeRecordingUpdates() { @@ -117,7 +115,7 @@ class VoiceToContentViewModel @Inject constructor( if (update.fileSizeLimitExceeded) { stopRecording() } else { - updateAmplitudes(update.amplitudes) + updateRecordingData(update) // todo: Handle other updates if needed when UI is ready, e.g., elapsed time and file size Log.d("AudioRecorder", "Recording update: $update") } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/voicetocontent/WaveformVisualizer.kt b/WordPress/src/main/java/org/wordpress/android/ui/voicetocontent/WaveformVisualizer.kt index 45ea2427a950..3704426e5fbe 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/voicetocontent/WaveformVisualizer.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/voicetocontent/WaveformVisualizer.kt @@ -1,149 +1,76 @@ package org.wordpress.android.ui.voicetocontent -// import androidx.compose.animation.core.* import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.material.MaterialTheme import androidx.compose.runtime.Composable -// import androidx.compose.runtime.getValue +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalDensity -// port androidx.compose.ui.unit.Density +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import kotlinx.coroutines.delay +import org.wordpress.android.util.audio.RecordingUpdate @Composable -fun WaveformVisualizer( - amplitudes: List, - modifier: Modifier = Modifier, - color: Color = MaterialTheme.colors.primary -) { - val spacingDp = 8.dp - val density = LocalDensity.current - val spacingPx = with(density) { spacingDp.toPx() } // 2dp spacing between bars - val strokeWidth = with(density) { 2.dp.toPx() } +fun WaveformOblongVisualizer(recordingUpdate: RecordingUpdate, currentPosition: Int) { + val amplitudeList = recordingUpdate.amplitudes + val maxRadius = 150f // increased maximum radius for the oblongs + val minRadius = 50f // increased minimum radius for the oblongs + val maxAmplitude = 32767f // maximum possible amplitude from MediaRecorder + val oblongWidth = 20f // fixed width of the oblongs + val color = MaterialTheme.colors.primary - Canvas(modifier = modifier) { + Canvas(modifier = Modifier + .fillMaxWidth() + .height(150.dp)) { val width = size.width val height = size.height - val centerY = height / 2 + val barSpacing = width / 20 // number of visible oblongs + val visibleAmplitudes = amplitudeList.takeLast(currentPosition + 20).take(20) - // Calculate the number of lines that can fit within the width given the spacing - val numberOfLines = (width / spacingPx).toInt() - - // Adjust amplitudeStep to match the number of lines we can fit - val amplitudeStep = maxOf(1, amplitudes.size / numberOfLines) - - for (i in 0 until numberOfLines) { - val index = i * amplitudeStep - if (index >= amplitudes.size) break - - val x = i * spacingPx - val amplitude = amplitudes[index] - val y1 = centerY - (amplitude * height * 0.5f) - val y2 = centerY + (amplitude * height * 0.5f) - - drawLine( + visibleAmplitudes.forEachIndexed { index, amplitude -> + val normalizedAmplitude = amplitude.coerceIn(0f, maxAmplitude) + val oblongHeight = minRadius + (normalizedAmplitude / maxAmplitude) * (maxRadius - minRadius) + val xOffset = index * barSpacing + val yOffset = (height - oblongHeight) / 2 + drawRoundRect( color = color, - start = androidx.compose.ui.geometry.Offset(x, y1), - end = androidx.compose.ui.geometry.Offset(x, y2), - strokeWidth = strokeWidth + topLeft = Offset(xOffset, yOffset), + size = androidx.compose.ui.geometry.Size(oblongWidth, oblongHeight), + cornerRadius = CornerRadius(10f, 10f) // rounded corners to make it oblong ) } } } -// -//private fun Float.toPx(density: Density): Float { -// return with(density) { this@toPx.dp.toPx() } -//} - - - // Try three -// Canvas(modifier = modifier) { -// val width = size.width -// val height = size.height -// val centerY = height / 2 -// val stepWidth = width / (amplitudes.size.toFloat() - 1) -// -// for (i in amplitudes.indices) { -// val x = i * stepWidth -// val amplitude = amplitudes[i] -// val y1 = centerY - (amplitude * height * 0.5f) -// val y2 = centerY + (amplitude * height * 0.5f) -// -// drawLine( -// color = color, -// start = androidx.compose.ui.geometry.Offset(x, y1), -// end = androidx.compose.ui.geometry.Offset(x, y2), -// strokeWidth = 2.dp.toPx() -// ) -// } -// } - // Try two -// val infiniteTransition = rememberInfiniteTransition(label = "") -// val phase by infiniteTransition.animateFloat( -// initialValue = 0f, -// targetValue = 1f, -// animationSpec = infiniteRepeatable( -// animation = tween(durationMillis = 2000, easing = LinearEasing), -// repeatMode = RepeatMode.Restart -// ), label = "" -// ) -// -// Canvas(modifier = modifier) { -// val width = size.width -// val height = size.height -// val centerY = height / 2 -// -// // val amplitudeStep = maxOf(1, amplitudes.size / width.toInt()) -// val stepWidth = width / amplitudes.size.toFloat() -// -// for (i in amplitudes.indices) { -// // for (i in amplitudes.indices step amplitudeStep) { -// // Calculate the x-coordinate based on phase to create a scrolling effect -// val x = (i * stepWidth + phase * width) % width -// val amplitude = amplitudes[i] -// val y1 = centerY - (amplitude * height * 0.5f) -// val y2 = centerY + (amplitude * height * 0.5f) -// -// drawLine( -// color = color, -// start = androidx.compose.ui.geometry.Offset(x, y1), -// end = androidx.compose.ui.geometry.Offset(x, y2), -// strokeWidth = 2.dp.toPx() -// ) -// } - - // Try One -// val infiniteTransition = rememberInfiniteTransition(label = "") -// val phase by infiniteTransition.animateFloat( -// initialValue = 0f, -// targetValue = 1f, -// animationSpec = infiniteRepeatable( -// animation = tween(durationMillis = 1000, easing = LinearEasing), -// repeatMode = RepeatMode.Restart -// ), label = "" -// ) -// -// Canvas(modifier = modifier) { -// val width = size.width -// val height = size.height -// val centerY = height / 2 -// val amplitudeStep = maxOf(1, amplitudes.size / width.toInt()) -// -// for (i in amplitudes.indices step amplitudeStep) { -// val x = ((i / amplitudeStep) + phase * width) % width -// val amplitude = amplitudes[i] -// val y1 = centerY - (amplitude * height * 0.5f) -// val y2 = centerY + (amplitude * height * 0.5f) -// -// drawLine( -// color = color, -// start = androidx.compose.ui.geometry.Offset(x, y1), -// end = androidx.compose.ui.geometry.Offset(x, y2), -// strokeWidth = 2.dp.toPx() -// ) -// } -// } -//} +@Composable +fun ScrollingWaveformVisualizer(recordingUpdate: RecordingUpdate) { + val currentPosition = remember { mutableStateOf(0) } + LaunchedEffect(recordingUpdate) { + while (true) { + delay(100) // adjust delay as needed for scrolling speed + currentPosition.value += 1 + if (currentPosition.value >= recordingUpdate.amplitudes.size) { + currentPosition.value = 0 // reset to start if we reach the end + } + } + } + WaveformOblongVisualizer(recordingUpdate, currentPosition.value) +} +@Preview(showBackground = true) +@Composable +fun WaveformVisualizerOblongPreview() { + val mockRecordingUpdate = RecordingUpdate( + amplitudes = listOf( + 1000f, 5000f, 10000f, 20000f, 30000f, 15000f, 25000f, 12000f, 17000f, 11000f, + 1000f, 5000f, 10000f, 20000f, 30000f, 15000f, 25000f, 12000f, 17000f, 11000f + ) + ) + WaveformOblongVisualizer(recordingUpdate = mockRecordingUpdate, currentPosition = 0) +} diff --git a/WordPress/src/main/java/org/wordpress/android/util/audio/AudioRecorder.kt b/WordPress/src/main/java/org/wordpress/android/util/audio/AudioRecorder.kt index 283d18593793..73f957289151 100644 --- a/WordPress/src/main/java/org/wordpress/android/util/audio/AudioRecorder.kt +++ b/WordPress/src/main/java/org/wordpress/android/util/audio/AudioRecorder.kt @@ -76,20 +76,16 @@ class AudioRecorder( } } catch (e: IOException) { val errorMessage = "Error preparing MediaRecorder: ${e.message}" - Log.e(TAG, errorMessage) onRecordingFinished(Error(errorMessage)) } catch (e: IllegalStateException) { val errorMessage = "Illegal state when starting recording: ${e.message}" - Log.e(TAG, errorMessage) onRecordingFinished(Error(errorMessage)) } catch (e: SecurityException) { val errorMessage = "Security exception when starting recording: ${e.message}" - Log.e(TAG, errorMessage) onRecordingFinished(Error(errorMessage)) } } else { val errorMessage = "Permission to record audio not granted" - Log.e(TAG, errorMessage) onRecordingFinished(Error(errorMessage)) } } @@ -146,16 +142,22 @@ class AudioRecorder( private fun startRecordingUpdates() { recordingJob = coroutineScope.launch { var elapsedTimeInSeconds = 0 + val amplitudeList = mutableListOf() while (recorder != null) { delay(RECORDING_UPDATE_INTERVAL) elapsedTimeInSeconds += (RECORDING_UPDATE_INTERVAL / 1000).toInt() val fileSize = File(filePath).length() val amplitude = recorder?.maxAmplitude?.toFloat() ?: 0f + amplitudeList.add(amplitude) + // Keep the list to a manageable size (e.g., last 1000 samples) + if (amplitudeList.size > 1000) { + amplitudeList.removeAt(0) + } _recordingUpdates.value = RecordingUpdate( elapsedTime = elapsedTimeInSeconds, fileSize = fileSize, fileSizeLimitExceeded = fileSize >= recordingStrategy.maxFileSize, - amplitudes = listOf(amplitude) + amplitudes = amplitudeList.toList() ) if ( maxFileSizeExceeded(fileSize) || maxDurationExceeded(elapsedTimeInSeconds) ) { @@ -198,7 +200,7 @@ class AudioRecorder( companion object { private const val TAG = "AudioRecorder" - private const val RECORDING_UPDATE_INTERVAL = 1000L // in milliseconds + private const val RECORDING_UPDATE_INTERVAL = 100L // in milliseconds private const val RESUME_DELAY = 500L // in milliseconds private const val FILE_SIZE_THRESHOLD = 100000L private const val DURATION_THRESHOLD = 1