diff --git a/WordPress/src/main/java/org/wordpress/android/ui/voicetocontent/MicToStopIcon.kt b/WordPress/src/main/java/org/wordpress/android/ui/voicetocontent/MicToStopIcon.kt index f91d25e5d86d..d02459df998d 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/voicetocontent/MicToStopIcon.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/voicetocontent/MicToStopIcon.kt @@ -14,6 +14,7 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.ContentAlpha import androidx.compose.material.MaterialTheme import androidx.compose.runtime.* import androidx.compose.ui.Alignment @@ -31,216 +32,47 @@ import org.wordpress.android.ui.compose.theme.AppTheme @OptIn(ExperimentalAnimationApi::class) @Suppress("DEPRECATION") @Composable -fun MicToStopIcon(state: VoiceToContentUiState.ReadyToRecord) { - val isEnabled = state.isEligibleForFeature - var isTapped by remember { mutableStateOf(false) } +fun MicToStopIcon(model: RecordingPanelUIModel) { + val isEnabled = model.isEnabled var isMic by remember { mutableStateOf(true) } val isLight = !isSystemInDarkTheme() - // For tries 1-3 -// val innerCircleColor by animateColorAsState( -// targetValue = if (isMic) MaterialTheme.colors.primary else if (isLight) Color.Black else Color.White -// ) - - // For try 4 val circleColor by animateColorAsState( - targetValue = if (isMic) MaterialTheme.colors.primary else if (isLight) Color.Black else Color.White, label = "" + targetValue = if (!isEnabled) MaterialTheme.colors.onSurface.copy(alpha = 0.3f) //ContentAlpha.disabled or 0.3f) + else if (isMic) MaterialTheme.colors.primary + else if (isLight) Color.Black + else Color.White, label = "" ) val iconColor by animateColorAsState( - targetValue = - if (isMic) Color.White else if (isLight) Color.White else Color.Black, label = "" + targetValue = if (!isEnabled) MaterialTheme.colors.onSurface.copy(alpha = ContentAlpha.disabled) + else if (isMic) Color.White + else if (isLight) Color.White + else Color.Black, label = "" ) val micIcon: Painter = painterResource(id = R.drawable.v2c_mic) val stopIcon: Painter = painterResource(id = R.drawable.v2c_stop) - // Tries 1-5 - // val icon: Painter = if (isMic) painterResource(id = R.drawable.v2c_mic) else painterResource(id = R.drawable.v2c_stop) - - // First try with the box showing that is not transparent -// Box( -// contentAlignment = Alignment.Center, -// modifier = Modifier -// .size(100.dp) // Adjust the size as needed -// .background(MaterialTheme.colors.primary, shape = CircleShape) -// .clickable { isMic = !isMic } -// .animateContentSize() // Ensures smooth resizing if needed -// ) { -// Box( -// contentAlignment = Alignment.Center, -// modifier = Modifier -// .size(80.dp) // Adjust the size to make space for the inner circle -// .background(innerCircleColor, shape = CircleShape) -// ) { -// Image( -// painter = icon, -// contentDescription = null, -// modifier = Modifier.size(50.dp), // Adjust the size as needed -// colorFilter = androidx.compose.ui.graphics.ColorFilter.tint(iconColor) -// ) -// } -// } - - // Second attempt with the box being transparent -// Box( -// contentAlignment = Alignment.Center, -// modifier = Modifier -// .size(100.dp) // Adjust the size as needed -// .clickable { -// isMic = !isMic -// // todo myViewModel.onMicOrStopIconClicked(isMic) -// } -// .animateContentSize() // Ensures smooth resizing if needed -// ) { -// Box( -// contentAlignment = Alignment.Center, -// modifier = Modifier -// .size(80.dp) // Adjust the size to make space for the inner circle -// .background(MaterialTheme.colors.primary, shape = CircleShape) -// ) { -// Box( -// contentAlignment = Alignment.Center, -// modifier = Modifier -// .size(70.dp) // Adjust the size to make space for the inner circle -// .background(innerCircleColor, shape = CircleShape) -// ) { -// Image( -// painter = icon, -// contentDescription = null, -// modifier = Modifier.size(50.dp), // Adjust the size as needed -// colorFilter = ColorFilter.tint(iconColor) -// ) -// } -// } -// } - - // Third attempt with the box being transparent and the inner circle being animated -// Box( -// contentAlignment = Alignment.Center, -// modifier = Modifier -// .size(100.dp) // Adjust the size as needed -// .clickable { -// isMic = !isMic -// // myViewModel.onMicOrStopIconClicked(isMic) -// } -// ) { -// // Outer Circle -// Box( -// contentAlignment = Alignment.Center, -// modifier = Modifier -// .size(100.dp) -// .background(MaterialTheme.colors.primary, shape = CircleShape) -// ) { -// // Inner Circle -// Box( -// contentAlignment = Alignment.Center, -// modifier = Modifier -// .size(80.dp) -// .background(innerCircleColor, shape = CircleShape) -// ) { -// Image( -// painter = icon, -// contentDescription = null, -// modifier = Modifier.size(50.dp), // Adjust the size as needed -// colorFilter = ColorFilter.tint(iconColor) -// ) -// } -// } -// } - - // Four attempt -// Box( -// contentAlignment = Alignment.Center, -// modifier = Modifier -// .size(100.dp) // Adjust the size as needed -// .background(circleColor, shape = CircleShape) -// .clickable { -// isMic = !isMic -// // myViewModel.onMicOrStopIconClicked(isMic) -// } -// ) { -// Image( -// painter = icon, -// contentDescription = null, -// modifier = Modifier.size(50.dp), // Adjust the size as needed -// colorFilter = ColorFilter.tint(iconColor) -// ) -// } - - // Attempt five -// Box( -// contentAlignment = Alignment.Center, -// modifier = Modifier -// .size(100.dp) // Adjust the size as needed -// .clickable { -// isMic = !isMic -// // myViewModel.onMicOrStopIconClicked(isMic) -// } -// ) { -// Box( -// modifier = Modifier -// .size(100.dp) -// .background(circleColor, shape = CircleShape) -// .align(Alignment.Center) -// ) { -// Image( -// painter = icon, -// contentDescription = null, -// modifier = Modifier -// .size(50.dp) // Adjust the size as needed -// .align(Alignment.Center), -// colorFilter = ColorFilter.tint(iconColor) -// ) -// } -// } - -// // Attempt six -// Box( -// contentAlignment = Alignment.Center, -// modifier = Modifier -// .size(100.dp) // Adjust the size as needed -// .clickable { -// isMic = !isMic -// // myViewModel.onMicOrStopIconClicked(isMic) -// } -// ) { -// Box( -// modifier = Modifier -// .size(100.dp) -// .background(circleColor, shape = CircleShape) -// ) -// -// AnimatedContent( -// targetState = isMic, -// transitionSpec = { -// fadeIn(animationSpec = tween(300)) with fadeOut(animationSpec = tween(300)) -// }, label = "" -// ) { targetState -> -// val icon: Painter = if (targetState) painterResource(id = R.drawable.v2c_mic) else painterResource(id = R.drawable.v2c_stop) -// Image( -// painter = icon, -// contentDescription = null, -// modifier = Modifier.size(50.dp), // Adjust the size as needed -// colorFilter = ColorFilter.tint(iconColor) -// ) -// } -// } - // Attempt 7 Box( contentAlignment = Alignment.Center, modifier = Modifier .size(100.dp) + .background(Color.Transparent) // Ensure transparent background .clickable( - enabled = isEnabled && !isTapped, + enabled = isEnabled, onClick = { - if (isMic) { - isMic = false - isTapped = true - // todo: state.onMicTap() + if (model.hasPermission) { + if (isMic) { + model.onMicTap?.invoke() + } else { + model.onStopTap?.invoke() + } + // isMic = !isMic } else { - // todo: nothing yet state.onMicTap + model.onRequestPermission?.invoke() } + isMic = !isMic } ) ) { @@ -249,19 +81,28 @@ fun MicToStopIcon(state: VoiceToContentUiState.ReadyToRecord) { .size(100.dp) .background(circleColor, shape = CircleShape) ) - - AnimatedContent( - targetState = isMic, - transitionSpec = { - fadeIn(animationSpec = tween(300)) with fadeOut(animationSpec = tween(300)) - }, label = "" - ) { targetState -> - val icon: Painter = if (targetState) micIcon else stopIcon - val iconSize = if (targetState) 50.dp else 35.dp + if (model.hasPermission) { + AnimatedContent( + targetState = isMic, + transitionSpec = { + fadeIn(animationSpec = tween(300)) with fadeOut(animationSpec = tween(300)) + }, label = "" + ) { targetState -> + val icon: Painter = if (targetState) micIcon else stopIcon + val iconSize = if (targetState) 50.dp else 35.dp + Image( + painter = icon, + contentDescription = null, + modifier = Modifier.size(iconSize), + colorFilter = ColorFilter.tint(iconColor) + ) + } + } else { + // Display mic icon statically if permission is not granted Image( - painter = icon, + painter = micIcon, contentDescription = null, - modifier = Modifier.size(iconSize), + modifier = Modifier.size(50.dp), colorFilter = ColorFilter.tint(iconColor) ) } @@ -274,16 +115,15 @@ fun MicToStopIcon(state: VoiceToContentUiState.ReadyToRecord) { @Composable fun ExistingLayoutPreview() { AppTheme { - MicToStopIcon(VoiceToContentUiState.ReadyToRecord( - header = R.string.voice_to_content_ready_to_record, - labelText = R.string.voice_to_content_ready_to_record_label, - subLabelText = R.string.voice_to_content_tap_to_start, - requestsAvailable = 0, - isEligibleForFeature = true, - onMicTap = {}, - onCloseAction = {}, - hasPermission = true, - onRequestPermission = {} - )) + MicToStopIcon( + RecordingPanelUIModel( + isEligibleForFeature = true, + onMicTap = {}, + onStopTap = {}, + hasPermission = true, + onRequestPermission = {}, + actionLabel = R.string.voice_to_content_ready_to_record_label, isEnabled = false + ) + ) } } 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 252e7b712675..2f1b60114b4d 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 @@ -1,29 +1,37 @@ package org.wordpress.android.ui.voicetocontent -import androidx.annotation.StringRes +import android.content.res.Configuration +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize 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.material.CircularProgressIndicator import androidx.compose.material.Icon import androidx.compose.material.IconButton import androidx.compose.material.MaterialTheme import androidx.compose.material.Text import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Call -import androidx.compose.material.icons.filled.Check -import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.Surface import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Devices import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import org.wordpress.android.R @@ -34,188 +42,248 @@ fun VoiceToContentScreen( viewModel: VoiceToContentViewModel ) { val state by viewModel.state.collectAsState() - Column( - horizontalAlignment = Alignment.CenterHorizontally, + val amplitudes by viewModel.amplitudes.observeAsState(initial = listOf()) + 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? + + Surface( modifier = Modifier .fillMaxWidth() - .padding(16.dp) + .height(bottomSheetHeight), + color = MaterialTheme.colors.surface ) { - when (val currentState = state) { - is VoiceToContentUiState.Initializing -> InitializingView(currentState) - is VoiceToContentUiState.ReadyToRecord -> ReadyToRecordView(currentState) - is VoiceToContentUiState.Recording -> RecordingView(currentState) - is VoiceToContentUiState.Processing -> ProcessingView(currentState) - is VoiceToContentUiState.Error -> ErrorView(currentState) - is VoiceToContentUiState.Finished -> FinishedView(currentState) - } + VoiceToContentView(state, amplitudes) } } @Composable -fun InitializingView(state: VoiceToContentUiState.Initializing) { - Column { - Header(state.header, state.onClose) - Spacer(modifier = Modifier.height(16.dp)) - Text(stringResource(id = state.labelText)) - CircularProgressIndicator() - } -} - -@Composable -fun ReadyToRecordView( - state: VoiceToContentUiState.ReadyToRecord -) { - Column { - Header(state.header, state.onClose) - Spacer(modifier = Modifier.height(16.dp)) - Text(stringResource(id = state.labelText)) - Text(stringResource(id = state.subLabelText)) - Spacer(modifier = Modifier.height(16.dp)) - IconButton( - enabled = true, - onClick = if (state.hasPermission) { - state.onMicTap - } else { - state.onRequestPermission +fun VoiceToContentView(state: VoiceToContentUiState, amplitudes: List) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + .background(MaterialTheme.colors.surface) // Use theme-aware background color + ) { + when (state.uiStateType) { + VoiceToContentUIStateType.PROCESSING -> ProcessingView(state) + VoiceToContentUIStateType.ERROR -> ErrorView(state) + else -> { + Header(state.header) + SecondaryHeader(state.secondaryHeader) + RecordingPanel(state.recordingPanel, amplitudes) } - ) { - Icon(imageVector = Icons.Default.CheckCircle, contentDescription = null) } - Spacer(modifier = Modifier.height(16.dp)) - MicToStopIcon(state) } } @Composable -fun RecordingView(state: VoiceToContentUiState.Recording) { - Column { - Header(state.header, state.onClose) - Spacer(modifier = Modifier.height(16.dp)) - Text(state.elapsedTime) - Spacer(modifier = Modifier.height(16.dp)) - Icon(imageVector = Icons.Default.Call, contentDescription = null) +fun ProcessingView(model: VoiceToContentUiState) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .fillMaxWidth() + ) { + Header(model.header) Spacer(modifier = Modifier.height(16.dp)) - IconButton(onClick = state.onStopTap, enabled = true) { - Icon(imageVector = Icons.Default.Check, contentDescription = null) + Box( + modifier = Modifier.size(100.dp) // size the progress indicator + ) { + CircularProgressIndicator( + modifier = Modifier.fillMaxSize() + ) } } } @Composable -fun ProcessingView(state: VoiceToContentUiState.Processing) { - Column { - Header(state.header, state.onClose) - Spacer(modifier = Modifier.height(16.dp)) - CircularProgressIndicator() - } -} - -@Composable -fun ErrorView(state: VoiceToContentUiState.Error) { - Column { - Header(state.header, state.onClose) - Spacer(modifier = Modifier.height(16.dp)) - Text(state.message) - } -} - -@Composable -fun FinishedView(state: VoiceToContentUiState.Finished) { - Column { - Header(state.header, state.onClose) +fun ErrorView(model: VoiceToContentUiState) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .fillMaxWidth() + ) { + Header(model.header) Spacer(modifier = Modifier.height(16.dp)) - Text(state.content) + Text("Unable to use Voice to Content at the moment, please try again later") } } @Composable -fun Header(@StringRes headerText: Int, onClose: () -> Unit) { +fun Header(model: HeaderUIModel) { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth() ) { - Text(text = stringResource(id = headerText), style = MaterialTheme.typography.h6) - IconButton(onClick = onClose) { + Text(text = stringResource(id = model.label), style = MaterialTheme.typography.h6) + IconButton(onClick = model.onClose) { Icon(imageVector = Icons.Default.Close, contentDescription = null) } } } -@Preview(showBackground = true) @Composable -fun PreviewInitializingView() { - AppTheme { - InitializingView(VoiceToContentUiState.Initializing( - header = R.string.voice_to_content_initializing, - labelText = R.string.voice_to_content_preparing, - onCloseAction = {} - )) +fun SecondaryHeader(model: SecondaryHeaderUIModel?) { + model?.let { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + Text(text = stringResource(id = model.label), style = MaterialTheme.typography.subtitle2) + Spacer(modifier = Modifier.width(8.dp)) // Add space between text and progress + if (model.isProgressIndicatorVisible) { + Box( + modifier = Modifier.size(20.dp) // size the progress indicator + ) { + CircularProgressIndicator( + modifier = Modifier.fillMaxSize() + ) + } + } else { + Text(text = model.requestsAvailable, style = MaterialTheme.typography.subtitle2) + } + Spacer(modifier = Modifier.height(16.dp)) + } + } +} + +@Composable +fun RecordingPanel(model: RecordingPanelUIModel?, amplitudes: List) { + model?.let { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + modifier = Modifier + .fillMaxWidth() + .background(Color.Transparent) // todo: annmarie double check if this is needed + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + modifier = Modifier.padding(8.dp) // Adjust padding as needed + ) { + if (model.isEligibleForFeature) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .fillMaxWidth() + .height(IntrinsicSize.Max) + .padding(48.dp) + ) { + WaveformVisualizer( + amplitudes = amplitudes, + modifier = Modifier + .fillMaxWidth() + .height(40.dp) + .padding(16.dp), + color = MaterialTheme.colors.primary + ) + } + } else { + Text(text = stringResource(id = model.message)) + Text(text = model.urlLink) + } + MicToStopIcon(model) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = stringResource(id = model.actionLabel), + style = MaterialTheme.typography.h6, + ) + Spacer(modifier = Modifier.height(16.dp)) + } + } } } + @Preview(showBackground = true) +@Preview(showBackground = true, device = Devices.PIXEL_4_XL, uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable -fun PreviewReadyToRecordView() { +fun PreviewInitializingView() { AppTheme { - ReadyToRecordView(VoiceToContentUiState.ReadyToRecord( - header = R.string.voice_to_content_ready_to_record, - labelText = R.string.voice_to_content_ready_to_record_label, - subLabelText = R.string.voice_to_content_tap_to_start, - requestsAvailable = 0, - isEligibleForFeature = true, - onMicTap = {}, - onCloseAction = {}, - hasPermission = true, - onRequestPermission = {} - )) + val state = VoiceToContentUiState( + uiStateType = VoiceToContentUIStateType.INITIALIZING, + header = HeaderUIModel(label = R.string.voice_to_content_base_header_label, onClose = { }), + secondaryHeader = SecondaryHeaderUIModel(label = R.string.voice_to_content_secondary_header_label, isProgressIndicatorVisible = true), + recordingPanel = RecordingPanelUIModel(actionLabel = R.string.voice_to_content_begin_recording_label, isEnabled = false, hasPermission = false) + ) + VoiceToContentView(state = state, amplitudes = listOf()) } } @Preview(showBackground = true) +@Preview(showBackground = true, device = Devices.PIXEL_4_XL, uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable -fun PreviewRecordingView() { +fun PreviewReadyToRecordView() { AppTheme { - RecordingView(VoiceToContentUiState.Recording( - header = R.string.voice_to_content_recording, - elapsedTime = "0 sec", - onStopTap = {}, - onCloseAction = {} - )) + val state = VoiceToContentUiState( + uiStateType = VoiceToContentUIStateType.READY_TO_RECORD, + header = HeaderUIModel(label = R.string.voice_to_content_ready_to_record_label, onClose = { }), + secondaryHeader = SecondaryHeaderUIModel(label = R.string.voice_to_content_secondary_header_label), + recordingPanel = RecordingPanelUIModel( + actionLabel = R.string.voice_to_content_begin_recording_label, + isEnabled = true, + onMicTap = {}, + onStopTap = {}, + onRequestPermission = {}, + isEligibleForFeature = true) + ) + VoiceToContentView(state = state, amplitudes = listOf()) } } @Preview(showBackground = true) +@Preview(showBackground = true, device = Devices.PIXEL_4_XL, uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable -fun PreviewProcessingView() { +fun PreviewNotEligibleToRecordView() { AppTheme { - ProcessingView(VoiceToContentUiState.Processing( - header = R.string.voice_to_content_processing, - onCloseAction = {} - )) + val state = VoiceToContentUiState( + uiStateType = VoiceToContentUIStateType.INELIGIBLE_FOR_FEATURE, + header = HeaderUIModel(label = R.string.voice_to_content_ready_to_record_label, onClose = { }), + secondaryHeader = SecondaryHeaderUIModel(label = R.string.voice_to_content_secondary_header_label), + recordingPanel = RecordingPanelUIModel( + actionLabel = R.string.voice_to_content_begin_recording_label, + isEnabled = false, + isEligibleForFeature = false, + urlLink = "https://www.wordpress.com") + ) + VoiceToContentView(state = state, amplitudes = listOf()) } } @Preview(showBackground = true) +@Preview(showBackground = true, device = Devices.PIXEL_4_XL, uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable -fun PreviewErrorView() { +fun PreviewRecordingView() { AppTheme { - ErrorView(VoiceToContentUiState.Error( - header = R.string.voice_to_content_error_label, - message = "Something bad happened and we can't continue", - onCloseAction = {} - )) + val state = VoiceToContentUiState( + uiStateType = VoiceToContentUIStateType.RECORDING, + header = HeaderUIModel(label = R.string.voice_to_content_recording_label, onClose = { }), + secondaryHeader = SecondaryHeaderUIModel(label = R.string.voice_to_content_secondary_header_label), + recordingPanel = RecordingPanelUIModel( + actionLabel = R.string.voice_to_content_begin_recording_label, + isEnabled = true, + hasPermission = true, + onMicTap = {}, + onStopTap = {}, + onRequestPermission = {}, + isEligibleForFeature = true) + ) + VoiceToContentView(state = state, amplitudes = listOf(1.1f, 2.2f, 3.3f, 4.4f, 2.2f, 3.3f, 1.1f, 2.2f, 3.3f, 4.4f, 2.2f, 3.3f, 1.1f, 2.2f, 3.3f, 4.4f, 2.2f, 3.3f)) } } @Preview(showBackground = true) +@Preview(showBackground = true, device = Devices.PIXEL_4_XL, uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable -fun PreviewFinishedView() { +fun PreviewProcessingView() { AppTheme { - FinishedView(VoiceToContentUiState.Finished( - header = R.string.voice_to_content_finished_label, - content = "This is the transcribed text", - onCloseAction = {} - )) + val state = VoiceToContentUiState( + uiStateType = VoiceToContentUIStateType.PROCESSING, + header = HeaderUIModel(label = R.string.voice_to_content_processing_label, onClose = { }) + ) + VoiceToContentView(state = state, amplitudes = listOf()) } } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/voicetocontent/VoiceToContentUiState.kt b/WordPress/src/main/java/org/wordpress/android/ui/voicetocontent/VoiceToContentUiState.kt index 639d893de0bd..2250cccf2508 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/voicetocontent/VoiceToContentUiState.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/voicetocontent/VoiceToContentUiState.kt @@ -1,124 +1,48 @@ package org.wordpress.android.ui.voicetocontent import androidx.annotation.StringRes - -sealed class VoiceToContentUiState( - @StringRes open val header: Int, - @StringRes open val subHeader: Int, - open val isSubHeaderVisible: Boolean, - open val isInitializingProgressIndicatorVisible: Boolean, - open val isRequestsAvailableVisible: Boolean, - open val requestsAvailable: Int, - open val isCountDownVisible: Boolean, - open val isMicButtonVisible: Boolean, - open val isMicButtonEnabled: Boolean, - open val isStopButtonVisible: Boolean, - open val isStopButtonEnabled: Boolean, - open val isWaveFormVisible: Boolean, - @StringRes open val status: Int, - open val isStatusVisible: Int, - - open val onClose: () -> Unit, - open val onCloseAction: () -> Unit, -) { - data class Initializing( - @StringRes override val header: Int, - @StringRes override val subHeader: Int, - override val onCloseAction: () -> Unit, - override val onClose: () -> Unit = onCloseAction - ) : VoiceToContentUiState(header, onCloseAction, onClose) - - data class ReadyToRecord( - @StringRes override val header: Int, - @StringRes val labelText: Int, - @StringRes val subLabelText: Int, - val requestsAvailable: Int, - val isEligibleForFeature: Boolean, - val onMicTap: () -> Unit, - override val onCloseAction: () -> Unit, - val hasPermission: Boolean, - val onRequestPermission: () -> Unit, - override val onClose: () -> Unit = onCloseAction - ) : VoiceToContentUiState(header, onCloseAction, onClose) - - data class Recording( - @StringRes override val header: Int, - val elapsedTime: String, - val onStopTap: () -> Unit, - override val onCloseAction: () -> Unit, - override val onClose: () -> Unit = onCloseAction - ) : VoiceToContentUiState(header, onCloseAction, onClose) - - data class Processing( - @StringRes override val header: Int, - override val onCloseAction: () -> Unit, - override val onClose: () -> Unit = onCloseAction - ) : VoiceToContentUiState(header, onCloseAction, onClose) - - data class Finished( - @StringRes override val header: Int, - val content: String, // Adjust as needed - override val onCloseAction: () -> Unit, - override val onClose: () -> Unit = onCloseAction - ) : VoiceToContentUiState(header, onCloseAction, onClose) - - data class Error( - @StringRes override val header: Int, - val message: String, // Adjust as needed - override val onCloseAction: () -> Unit, - override val onClose: () -> Unit = onCloseAction - ) : VoiceToContentUiState(header, onCloseAction, onClose) +import org.wordpress.android.R + +data class HeaderUIModel ( + @StringRes val label: Int, + val onClose: () -> Unit, +) + +data class SecondaryHeaderUIModel( + @StringRes val label: Int, + val isLabelVisible: Boolean = true, + val isProgressIndicatorVisible: Boolean = false, + val requestsAvailable: String = "0", + val timeElapsed: String = "00:00:00", + val isTimeElapsedVisible: Boolean = false +) + +data class RecordingPanelUIModel( + val onMicTap: (() -> Unit)? = null, + val onStopTap: (() -> Unit)? = null, + val isEligibleForFeature: Boolean = false, + val hasPermission: Boolean = false, + val onRequestPermission: (() -> Unit)? = null, + val isRecordEnabled: Boolean = false, + val isEnabled: Boolean = false, + @StringRes val message: Int = R.string.voice_to_content_not_eligible_for_feature, + val urlLink: String = "", + val onLinkTap: ((String) -> Unit)? = null, + @StringRes val actionLabel: Int +) + +enum class VoiceToContentUIStateType(val trackingName: String) { + INITIALIZING("initializing"), + READY_TO_RECORD("ready_to_record"), + INELIGIBLE_FOR_FEATURE("ineligible_for_feature"), + RECORDING("recording"), + PROCESSING("processing"), + ERROR("error") } -//sealed class VoiceToContentUiState { -// abstract val headerText: Int -// abstract val onClose: () -> Unit -// -// data class Initializing( -// override val headerText: Int, -// val labelText: Int, -// val onCloseAction: () -> Unit, -// override val onClose: () -> Unit = onCloseAction -// ) : VoiceToContentUiState() -// -// data class ReadyToRecord( -// override val headerText: Int, -// val labelText: Int, -// val subLabelText: Int, -// val requestsAvailable: Int, -// val isEligibleForFeature: Boolean, -// val onMicTap: () -> Unit, -// val onCloseAction: () -> Unit, -// val hasPermission: Boolean, -// val onRequestPermission: () -> Unit, -// override val onClose: () -> Unit = onCloseAction -// ) : VoiceToContentUiState() -// -// data class Recording( -// override val headerText: Int, -// val elapsedTime: String, -// val onStopTap: () -> Unit, -// val onCloseAction: () -> Unit, -// override val onClose: () -> Unit = onCloseAction -// ) : VoiceToContentUiState() -// -// data class Processing( -// override val headerText: Int, -// val onCloseAction: () -> Unit, -// override val onClose: () -> Unit = onCloseAction -// ) : VoiceToContentUiState() -// -// data class Finished( -// override val headerText: Int, -// val content: String, // todo: this is wrong -// val onCloseAction: () -> Unit, -// override val onClose: () -> Unit = onCloseAction -// ) : VoiceToContentUiState() -// -// data class Error( -// override val headerText: Int, -// val message: String, // todo: this is wrong -// val onCloseAction: () -> Unit, -// override val onClose: () -> Unit = onCloseAction -// ) : VoiceToContentUiState() -//} +data class VoiceToContentUiState( + val uiStateType: VoiceToContentUIStateType, + val header: HeaderUIModel, + val secondaryHeader: SecondaryHeaderUIModel? = null, + val recordingPanel: RecordingPanelUIModel? = null +) 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 9c54a6f0fb85..015c5913867d 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 @@ -8,6 +8,7 @@ import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -41,10 +42,22 @@ class VoiceToContentViewModel @Inject constructor( private val _dismiss = MutableLiveData() val dismiss = _dismiss as LiveData - private val _state = MutableStateFlow(VoiceToContentUiState.Initializing( - header = R.string.voice_to_content_initializing, - labelText = R.string.voice_to_content_preparing, - onCloseAction = ::onClose + private val _amplitudes = MutableLiveData>() + val amplitudes: LiveData> get() = _amplitudes + + private val _state = MutableStateFlow(VoiceToContentUiState( + uiStateType = VoiceToContentUIStateType.INITIALIZING, + header = HeaderUIModel( + label = R.string.voice_to_content_base_header_label, + onClose = ::onClose), + secondaryHeader = SecondaryHeaderUIModel( + label = R.string.voice_to_content_secondary_header_label, + isLabelVisible = true, + isProgressIndicatorVisible = true, + isTimeElapsedVisible = false), + recordingPanel = RecordingPanelUIModel( + actionLabel = R.string.voice_to_content_begin_recording_label, + isEnabled = false) )) val state: StateFlow = _state.asStateFlow() @@ -55,32 +68,38 @@ class VoiceToContentViewModel @Inject constructor( } fun start() { - val site = selectedSiteRepository.getSelectedSite() ?: run { - transitionToError() - return - } - if (isVoiceToContentEnabled()) { - viewModelScope.launch { - when (val result = prepareVoiceToContentUseCase.execute(site)) { - is PrepareVoiceToContentResult.Success -> { - transitionToReadyToRecord(result.model) - } + val site = selectedSiteRepository.getSelectedSite() + if (site == null || !isVoiceToContentEnabled()) return - is PrepareVoiceToContentResult.Error -> { - transitionToError() - } + viewModelScope.launch { + when (val result = prepareVoiceToContentUseCase.execute(site)) { + is PrepareVoiceToContentResult.Success -> { + if (result.model.siteRequireUpgrade) + delay(1000) // todo: annmarie remove this for debug only + transitionToReadyToRecordOrIneligibleForFeature(result.model) + } + + is PrepareVoiceToContentResult.Error -> { + transitionToError() } } } } // Recording + // todo: This doesn't work as expected + 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 observeRecordingUpdates() { viewModelScope.launch { recordingUseCase.recordingUpdates().collect { update -> if (update.fileSizeLimitExceeded) { stopRecording() } else { + updateAmplitudes(update.amplitudes) // todo: Handle other updates if needed when UI is ready, e.g., elapsed time and file size Log.d("AudioRecorder", "Recording update: $update") } @@ -97,11 +116,11 @@ class VoiceToContentViewModel @Inject constructor( file?.let { executeVoiceToContent(it) } ?: run { - transitionToError() + transitionToError() } } is Error -> { - transitionToError() + transitionToError() } } } @@ -130,7 +149,8 @@ class VoiceToContentViewModel @Inject constructor( viewModelScope.launch { val result = voiceToContentUseCase.execute(site, file) - transitionToFinished(result.content) + Log.i(javaClass.simpleName, "***=> result is ${result.content}") + _dismiss.postValue(Unit) } } @@ -166,52 +186,56 @@ class VoiceToContentViewModel @Inject constructor( } // transitions - private fun transitionToReadyToRecord(model: JetpackAIAssistantFeature) { - // todo: annmarie- put together the proper labels; especially the requests available count - _state.value = VoiceToContentUiState.ReadyToRecord( - header = R.string.voice_to_content_ready_to_record, - labelText = R.string.voice_to_content_ready_to_record_label, - subLabelText = R.string.voice_to_content_tap_to_start, - requestsAvailable = voiceToContentFeatureUtils.getRequestLimit(model), - isEligibleForFeature = voiceToContentFeatureUtils.isEligibleForVoiceToContent(model), - onMicTap = ::onMicTap, - onCloseAction = ::onClose, - onRequestPermission = ::onRequestPermission, - hasPermission = hasAllPermissionsForRecording() + private fun transitionToReadyToRecordOrIneligibleForFeature(model: JetpackAIAssistantFeature) { + val isEligibleForFeature = voiceToContentFeatureUtils.isEligibleForVoiceToContent(model) + val requestsAvailable = voiceToContentFeatureUtils.getRequestLimit(model) + val currentState = _state.value + _state.value = currentState.copy( + uiStateType = if (isEligibleForFeature) VoiceToContentUIStateType.READY_TO_RECORD else VoiceToContentUIStateType.INELIGIBLE_FOR_FEATURE, + secondaryHeader = currentState.secondaryHeader?.copy(requestsAvailable = requestsAvailable.toString(), isProgressIndicatorVisible = false), + recordingPanel = currentState.recordingPanel?.copy( + isEnabled = isEligibleForFeature, + isEligibleForFeature = isEligibleForFeature, + onMicTap = ::onMicTap, + onRequestPermission = ::onRequestPermission, + hasPermission = hasAllPermissionsForRecording()) ) } private fun transitionToRecording() { - _state.value = VoiceToContentUiState.Recording( - header = R.string.voice_to_content_recording, - elapsedTime = "0 sec", - onStopTap = ::onStopTap, - onCloseAction = ::onClose + val currentState = _state.value + _state.value = currentState.copy( + uiStateType = VoiceToContentUIStateType.RECORDING, + header = currentState.header.copy(label = R.string.voice_to_content_recording_label), + secondaryHeader = currentState.secondaryHeader?.copy( + timeElapsed = "00:00:00", + isTimeElapsedVisible = true + ), + recordingPanel = currentState.recordingPanel?.copy( + onStopTap = ::onStopTap, + hasPermission = true, + actionLabel = R.string.voice_to_content_done_label) ) } private fun transitionToProcessing() { - _state.value = VoiceToContentUiState.Processing( - header = R.string.voice_to_content_processing, - onCloseAction = ::onClose + val currentState = _state.value + _state.value = currentState.copy( + uiStateType = VoiceToContentUIStateType.PROCESSING, + header = currentState.header.copy(label = R.string.voice_to_content_processing), + secondaryHeader = null, + recordingPanel = null ) } - // todo: annmarie - transition to error hasn't been fully fleshed out ... some errors will be shown on top of - // the existing screen + // todo: annmarie - transition to error hasn't been fully fleshed out private fun transitionToError() { - _state.value = VoiceToContentUiState.Error( - header = R.string.voice_to_content_ready_to_record, - message = "Something bad happened and we can't continue", - onCloseAction = ::onClose - ) - } - // todo: annmarie - transition to finished MUST be removed, as we are pushing the user to editPostActivity - private fun transitionToFinished(content: String?) { - _state.value = VoiceToContentUiState.Finished( - header = R.string.voice_to_content_finished_label, - content = content ?: "", - onCloseAction = ::onClose + val currentState = _state.value + _state.value = currentState.copy( + uiStateType = VoiceToContentUIStateType.ERROR, + header = currentState.header.copy( label = R.string.voice_to_content_ready_to_record), + secondaryHeader = null, + recordingPanel = null ) } } diff --git a/WordPress/src/main/res/values/strings.xml b/WordPress/src/main/res/values/strings.xml index 4295974f3214..743a1bed51e5 100644 --- a/WordPress/src/main/res/values/strings.xml +++ b/WordPress/src/main/res/values/strings.xml @@ -4914,4 +4914,12 @@ translators: %s: Select control option value e.g: "Auto, 25%". --> Error Processing Finished + + Post from Audio + Requests available: + Begin Recording + Recording + Out of requests or need requests message + Done +