diff --git a/.idea/checkstyle-idea.xml b/.idea/checkstyle-idea.xml index 7f921dc3495d..b084c99aaa78 100644 --- a/.idea/checkstyle-idea.xml +++ b/.idea/checkstyle-idea.xml @@ -1,18 +1,18 @@ - - - + + + 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 new file mode 100644 index 000000000000..a85466a27b44 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/voicetocontent/MicToStopIcon.kt @@ -0,0 +1,134 @@ +package org.wordpress.android.ui.voicetocontent + +import android.content.res.Configuration +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.with +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.ContentAlpha +import androidx.compose.material.MaterialTheme +import androidx.compose.runtime.Composable +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.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.res.painterResource +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 +import org.wordpress.android.ui.compose.theme.AppTheme + +@OptIn(ExperimentalAnimationApi::class) +@Suppress("DEPRECATION") +@Composable +fun MicToStopIcon(model: RecordingPanelUIModel) { + val isEnabled = model.isEnabled + var isMic by remember { mutableStateOf(true) } + val isLight = !isSystemInDarkTheme() + + val circleColor by animateColorAsState( + targetValue = if (!isEnabled) MaterialTheme.colors.onSurface.copy(alpha = 0.3f) + else if (isMic) MaterialTheme.colors.primary + else if (isLight) Color.Black + else Color.White, label = "" + ) + + val iconColor by animateColorAsState( + 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.ic_mic_none_24) + val stopIcon: Painter = painterResource(id = R.drawable.v2c_stop) + + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .size(100.dp) + .background(Color.Transparent) // Ensure transparent background + .clickable( + enabled = isEnabled, + onClick = { + if (model.hasPermission) { + if (isMic) { + model.onMicTap?.invoke() + } else { + model.onStopTap?.invoke() + } + // isMic = !isMic + } else { + model.onRequestPermission?.invoke() + } + isMic = !isMic + } + ) + ) { + Box( + modifier = Modifier + .size(100.dp) + .background(circleColor, shape = CircleShape) + ) + 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 = micIcon, + contentDescription = null, + modifier = Modifier.size(50.dp), + colorFilter = ColorFilter.tint(iconColor) + ) + } + } +} + +@Preview(showBackground = true) +@Preview(showBackground = true, device = Devices.PIXEL_4_XL) +@Preview(showBackground = true, device = Devices.PIXEL_4_XL, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +fun ExistingLayoutPreview() { + AppTheme { + MicToStopIcon( + RecordingPanelUIModel( + isEligibleForFeature = true, + onMicTap = {}, + onStopTap = {}, + hasPermission = true, + onRequestPermission = {}, + actionLabel = R.string.voice_to_content_base_header_label, isEnabled = false + ) + ) + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/voicetocontent/PrepareVoiceToContentUseCase.kt b/WordPress/src/main/java/org/wordpress/android/ui/voicetocontent/PrepareVoiceToContentUseCase.kt new file mode 100644 index 000000000000..487eea60d28c --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/voicetocontent/PrepareVoiceToContentUseCase.kt @@ -0,0 +1,30 @@ +package org.wordpress.android.ui.voicetocontent + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.model.jetpackai.JetpackAIAssistantFeature +import org.wordpress.android.fluxc.network.rest.wpcom.jetpackai.JetpackAIAssistantFeatureResponse +import org.wordpress.android.fluxc.store.jetpackai.JetpackAIStore +import javax.inject.Inject + +class PrepareVoiceToContentUseCase @Inject constructor( + private val jetpackAIStore: JetpackAIStore +) { + suspend fun execute(site: SiteModel): PrepareVoiceToContentResult = + withContext(Dispatchers.IO) { + when (val response = jetpackAIStore.fetchJetpackAIAssistantFeature(site)) { + is JetpackAIAssistantFeatureResponse.Success -> { + PrepareVoiceToContentResult.Success(model = response.model) + } + is JetpackAIAssistantFeatureResponse.Error -> { + PrepareVoiceToContentResult.Error + } + } + } +} + +sealed class PrepareVoiceToContentResult { + data class Success(val model: JetpackAIAssistantFeature) : PrepareVoiceToContentResult() + data object Error : PrepareVoiceToContentResult() +} 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 25b39aefa519..b257f0ac7206 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,7 +1,6 @@ package org.wordpress.android.ui.voicetocontent import android.content.Intent -import android.content.pm.PackageManager import android.net.Uri import android.os.Bundle import android.view.LayoutInflater @@ -9,58 +8,55 @@ import android.view.View import android.view.ViewGroup import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.AlertDialog -import androidx.compose.foundation.clickable -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.ui.platform.ComposeView import androidx.fragment.app.viewModels import com.google.android.material.bottomsheet.BottomSheetDialogFragment import dagger.hilt.android.AndroidEntryPoint import org.wordpress.android.ui.compose.theme.AppTheme -import androidx.compose.foundation.layout.Column -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.material.Icon -import androidx.compose.material.Text -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.core.content.ContextCompat import org.wordpress.android.R import org.wordpress.android.util.audio.IAudioRecorder.Companion.REQUIRED_RECORDING_PERMISSIONS import android.provider.Settings +import androidx.compose.material.ExperimentalMaterialApi @AndroidEntryPoint class VoiceToContentDialogFragment : BottomSheetDialogFragment() { private val viewModel: VoiceToContentViewModel by viewModels() + @ExperimentalMaterialApi override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View = ComposeView(requireContext()).apply { setContent { AppTheme { VoiceToContentScreen( - viewModel = viewModel, - onRequestPermission = { requestAllPermissionsForRecording() }, - hasPermission = { hasAllPermissionsForRecording() } + viewModel = viewModel ) } } } + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + observeViewModel() + viewModel.start() + } + + private fun observeViewModel() { + viewModel.requestPermission.observe(viewLifecycleOwner) { + requestAllPermissionsForRecording() + } + + viewModel.dismiss.observe(viewLifecycleOwner) { + dismiss() + } + } + private val requestMultiplePermissionsLauncher = registerForActivityResult( ActivityResultContracts.RequestMultiplePermissions() ) { permissions -> val areAllPermissionsGranted = permissions.entries.all { it.value } if (areAllPermissionsGranted) { - viewModel.startRecording() + viewModel.onPermissionGranted() } else { // Check if any permissions were denied permanently if (permissions.entries.any { !it.value }) { @@ -69,15 +65,6 @@ class VoiceToContentDialogFragment : BottomSheetDialogFragment() { } } - private fun hasAllPermissionsForRecording(): Boolean { - return REQUIRED_RECORDING_PERMISSIONS.all { - ContextCompat.checkSelfPermission( - requireContext(), - it - ) == PackageManager.PERMISSION_GRANTED - } - } - private fun requestAllPermissionsForRecording() { requestMultiplePermissionsLauncher.launch(REQUIRED_RECORDING_PERMISSIONS) } @@ -104,63 +91,3 @@ class VoiceToContentDialogFragment : BottomSheetDialogFragment() { fun newInstance() = VoiceToContentDialogFragment() } } - -@Composable -fun VoiceToContentScreen( - viewModel: VoiceToContentViewModel, - onRequestPermission: () -> Unit, - hasPermission: () -> Boolean -) { - val result by viewModel.uiState.observeAsState() - val assistantFeature by viewModel.aiAssistantFeatureState.observeAsState() - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier - .fillMaxWidth() - .padding(16.dp) - ) { - when { - result?.isError == true -> { - Text(text = "Error happened", fontSize = 20.sp, fontWeight = FontWeight.Bold) - } - - result?.content != null -> { - Text(text = result?.content!!, fontSize = 20.sp, fontWeight = FontWeight.Bold) - } - - assistantFeature != null -> { - Text(text = "Assistant Feature Returned Successfully", fontSize = 20.sp, fontWeight = FontWeight.Bold) - Spacer(modifier = Modifier.height(16.dp)) - } - - else -> { - Text(text = "Ready to fake record - tap microphone", fontSize = 20.sp, fontWeight = FontWeight.Bold) - Spacer(modifier = Modifier.height(16.dp)) - Icon( - painterResource(id = R.drawable.ic_mic_white_24dp), - contentDescription = "Microphone", - modifier = Modifier - .size(64.dp) - .clickable { - if (hasPermission()) { - viewModel.startRecording() - } else { - onRequestPermission() - } - } - ) - - Spacer(modifier = Modifier.height(16.dp)) - Icon( - painterResource(id = com.google.android.exoplayer2.ui.R.drawable.exo_icon_stop), - contentDescription = "Stop", - modifier = Modifier - .size(64.dp) - .clickable { - viewModel.stopRecording() - } - ) - } - } - } -} 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 new file mode 100644 index 000000000000..e2f3fe49a760 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/voicetocontent/VoiceToContentScreen.kt @@ -0,0 +1,427 @@ +package org.wordpress.android.ui.voicetocontent + +import android.content.res.Configuration +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +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.ContentAlpha +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.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.graphics.ColorFilter +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Devices +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.constraintlayout.compose.ConstraintLayout +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 + +@Composable +fun VoiceToContentScreen( + viewModel: VoiceToContentViewModel +) { + val state by viewModel.state.collectAsState() + 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() + .height(bottomSheetHeight), + color = MaterialTheme.colors.surface + ) { + VoiceToContentView(state, amplitudes) + } +} + +@Composable +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, amplitudes) + } + } + } +} + +@Composable +fun ProcessingView(model: VoiceToContentUiState) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .fillMaxWidth() + ) { + Header(model.header) + Spacer(modifier = Modifier.height(16.dp)) + Box( + modifier = Modifier.size(100.dp) // size the progress indicator + ) { + CircularProgressIndicator( + modifier = Modifier.fillMaxSize() + ) + } + } +} + +@Composable +fun ErrorView(model: VoiceToContentUiState) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .fillMaxWidth() + ) { + Header(model.header) + Spacer(modifier = Modifier.height(16.dp)) + Text("Unable to use Voice to Content at the moment, please try again later") + } +} + +@Composable +fun Header(model: HeaderUIModel) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.fillMaxWidth() + ) { + Text(text = stringResource(id = model.label), style = headerStyle) + IconButton(onClick = model.onClose) { + Icon(imageVector = Icons.Default.Close, contentDescription = null) + } + } +} + +@Composable +fun SecondaryHeader(model: SecondaryHeaderUIModel?) { + model?.let { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + Text(text = stringResource(id = model.label), style = secondaryHeaderStyle) + 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 = secondaryHeaderStyle) + } + Spacer(modifier = Modifier.height(16.dp)) + } + } +} + +@Composable +fun RecordingPanel(model: VoiceToContentUiState, amplitudes: List) { + model.recordingPanel?.let { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + modifier = Modifier + .fillMaxWidth() + .background(Color.Transparent) + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + modifier = Modifier.padding(8.dp) // Adjust padding as needed + ) { + if (it.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 if (model.uiStateType == VoiceToContentUIStateType.INELIGIBLE_FOR_FEATURE) { + InEligible(model = it) + } + MicToStopIcon(it) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = stringResource(id = it.actionLabel), + style = if (it.isEnabled) actionLabelStyle else actionLabelStyleDisabled + ) + Spacer(modifier = Modifier.height(16.dp)) + } + } + } +} + +@Composable +fun InEligible( + model: RecordingPanelUIModel, + modifier: Modifier = Modifier +) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = modifier + .fillMaxWidth() + ) { + Text(text = stringResource(id = model.ineligibleMessage), style = errorMessageStyle) + if (model.upgradeUrl?.isNotBlank() == true) { + ClickableTextViewWithLinkImage( + text = stringResource(id = model.upgradeMessage), + drawableRight = Drawable(R.drawable.ic_external_white_24dp), + onClick = { model.onLinkTap?.invoke(model.upgradeUrl) } + ) + } + Spacer(modifier = Modifier.height(8.dp)) + } +} + +@Composable +fun ClickableTextViewWithLinkImage( + modifier: Modifier = Modifier, + drawableRight: Drawable? = null, + text: String, + onClick: () -> Unit +) { + ConstraintLayout(modifier = modifier + .clickable { onClick.invoke() }) { + val (buttonTextRef) = createRefs() + Box(modifier = Modifier + .constrainAs(buttonTextRef) { + end.linkTo(parent.end, drawableRight?.iconSize ?: 0.dp) + width = Dimension.wrapContent + } + ) { + Text( + text = text, + style = errorUrlLinkCTA + ) + } + + drawableRight?.let { drawable -> + val (imageRight) = createRefs() + Image( + modifier = Modifier + .constrainAs(imageRight) { + top.linkTo(parent.top) + bottom.linkTo(parent.bottom) + start.linkTo(buttonTextRef.end, margin = 0.dp) + } + .size(16.dp), + painter = painterResource(id = drawable.resId), + colorFilter = ColorFilter.tint(MaterialTheme.colors.primary), + contentDescription = null + ) + } + } +} + + +private val headerStyle: TextStyle + @Composable + get() = androidx.compose.material3.MaterialTheme.typography.bodyLarge.copy( + fontWeight = FontWeight.SemiBold, + fontSize = 20.sp, + color = MaterialTheme.colors.onSurface.copy(alpha = ContentAlpha.high) + ) + +private val secondaryHeaderStyle: TextStyle + @Composable + get() = androidx.compose.material3.MaterialTheme.typography.bodySmall.copy( + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + color = MaterialTheme.colors.onSurface.copy(alpha = ContentAlpha.medium) + ) + +private val actionLabelStyle: TextStyle + @Composable + get() = androidx.compose.material3.MaterialTheme.typography.bodyMedium.copy( + color = MaterialTheme.colors.onSurface.copy(alpha = ContentAlpha.high) + ) + +private val actionLabelStyleDisabled: TextStyle + @Composable + get() = androidx.compose.material3.MaterialTheme.typography.bodyMedium.copy( + color = MaterialTheme.colors.onSurface.copy(alpha = ContentAlpha.disabled) + ) + +private val errorMessageStyle: TextStyle + @Composable + get() = androidx.compose.material3.MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + color = MaterialTheme.colors.onSurface.copy(alpha = ContentAlpha.high) + ) + +private val errorUrlLinkCTA: TextStyle + @Composable + get() = androidx.compose.material3.MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + color = MaterialTheme.colors.primary + ) + +@Preview(showBackground = true) +@Preview(showBackground = true, device = Devices.PIXEL_4_XL, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +fun PreviewInitializingView() { + AppTheme { + 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 PreviewReadyToRecordView() { + AppTheme { + val state = VoiceToContentUiState( + uiStateType = VoiceToContentUIStateType.READY_TO_RECORD, + header = HeaderUIModel(label = R.string.voice_to_content_base_header_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 PreviewNotEligibleToRecordView() { + AppTheme { + val state = VoiceToContentUiState( + uiStateType = VoiceToContentUIStateType.INELIGIBLE_FOR_FEATURE, + header = HeaderUIModel(label = R.string.voice_to_content_base_header_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, + upgradeMessage = R.string.voice_to_content_upgrade, + upgradeUrl = "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 PreviewRecordingView() { + AppTheme { + 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 + ) + ) + } +} + +@Preview(showBackground = true) +@Preview(showBackground = true, device = Devices.PIXEL_4_XL, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +fun PreviewProcessingView() { + AppTheme { + 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 new file mode 100644 index 000000000000..d8d23fa27a7b --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/voicetocontent/VoiceToContentUiState.kt @@ -0,0 +1,49 @@ +package org.wordpress.android.ui.voicetocontent + +import androidx.annotation.StringRes +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 ineligibleMessage: Int = R.string.voice_to_content_ineligible, + @StringRes val upgradeMessage: Int = R.string.voice_to_content_upgrade, + val upgradeUrl: String? = null, + 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") +} + +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 845d7e533f98..58c55824c4e9 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 @@ -1,20 +1,31 @@ package org.wordpress.android.ui.voicetocontent +import android.content.pm.PackageManager import android.util.Log +import androidx.core.content.ContextCompat import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch -import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.R import org.wordpress.android.fluxc.model.jetpackai.JetpackAIAssistantFeature -import org.wordpress.android.fluxc.network.rest.wpcom.jetpackai.JetpackAIAssistantFeatureResponse -import org.wordpress.android.fluxc.store.jetpackai.JetpackAIStore import org.wordpress.android.modules.UI_THREAD import org.wordpress.android.ui.mysite.SelectedSiteRepository +import org.wordpress.android.util.audio.IAudioRecorder +import org.wordpress.android.viewmodel.ContextProvider import org.wordpress.android.viewmodel.ScopedViewModel +import org.wordpress.android.ui.voicetocontent.VoiceToContentUIStateType.INITIALIZING +import org.wordpress.android.ui.voicetocontent.VoiceToContentUIStateType.READY_TO_RECORD +import org.wordpress.android.ui.voicetocontent.VoiceToContentUIStateType.RECORDING +import org.wordpress.android.ui.voicetocontent.VoiceToContentUIStateType.ERROR +import org.wordpress.android.ui.voicetocontent.VoiceToContentUIStateType.INELIGIBLE_FOR_FEATURE +import org.wordpress.android.ui.voicetocontent.VoiceToContentUIStateType.PROCESSING + import java.io.File import javax.inject.Inject import javax.inject.Named @@ -27,14 +38,34 @@ class VoiceToContentViewModel @Inject constructor( private val voiceToContentFeatureUtils: VoiceToContentFeatureUtils, private val voiceToContentUseCase: VoiceToContentUseCase, private val selectedSiteRepository: SelectedSiteRepository, - private val jetpackAIStore: JetpackAIStore, - private val recordingUseCase: RecordingUseCase + private val recordingUseCase: RecordingUseCase, + private val contextProvider: ContextProvider, + private val prepareVoiceToContentUseCase: PrepareVoiceToContentUseCase ) : ScopedViewModel(mainDispatcher) { - private val _uiState = MutableLiveData() - val uiState = _uiState as LiveData + private val _requestPermission = MutableLiveData() + val requestPermission = _requestPermission as LiveData + + private val _dismiss = MutableLiveData() + val dismiss = _dismiss as LiveData + + private val _amplitudes = MutableLiveData>() + val amplitudes: LiveData> get() = _amplitudes - private val _aiAssistantFeatureState = MutableLiveData() - val aiAssistantFeatureState = _aiAssistantFeatureState as LiveData + private val _state = MutableStateFlow(VoiceToContentUiState( + uiStateType = 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() private fun isVoiceToContentEnabled() = voiceToContentFeatureUtils.isVoiceToContentEnabled() @@ -42,12 +73,39 @@ class VoiceToContentViewModel @Inject constructor( observeRecordingUpdates() } + @Suppress("MagicNumber") + fun start() { + val site = selectedSiteRepository.getSelectedSite() + if (site == null || !isVoiceToContentEnabled()) return + + viewModelScope.launch { + when (val result = prepareVoiceToContentUseCase.execute(site)) { + is PrepareVoiceToContentResult.Success -> { + transitionToReadyToRecordOrIneligibleForFeature(result.model) + } + + is PrepareVoiceToContentResult.Error -> { + transitionToError() + } + } + } + } + + // 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 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") } @@ -55,7 +113,8 @@ class VoiceToContentViewModel @Inject constructor( } } - fun startRecording() { + private fun startRecording() { + transitionToRecording() recordingUseCase.startRecording { audioRecorderResult -> when (audioRecorderResult) { is Success -> { @@ -63,11 +122,11 @@ class VoiceToContentViewModel @Inject constructor( file?.let { executeVoiceToContent(it) } ?: run { - _uiState.postValue(VoiceToContentResult(isError = true)) + transitionToError() } } is Error -> { - _uiState.postValue(VoiceToContentResult(isError = true)) + transitionToError() } } } @@ -82,39 +141,113 @@ class VoiceToContentViewModel @Inject constructor( return recordingFile } - fun stopRecording() { - recordingUseCase.stopRecording() + private fun stopRecording() { + transitionToProcessing() + recordingUseCase.stopRecording() } - fun executeVoiceToContent(file: File) { + // Workflow + private fun executeVoiceToContent(file: File) { val site = selectedSiteRepository.getSelectedSite() ?: run { - _uiState.postValue(VoiceToContentResult(isError = true)) + transitionToError() return } - if (isVoiceToContentEnabled()) { - viewModelScope.launch(Dispatchers.IO) { - val result = jetpackAIStore.fetchJetpackAIAssistantFeature(site) - when (result) { - is JetpackAIAssistantFeatureResponse.Success -> { - _aiAssistantFeatureState.postValue(result.model) - startVoiceToContentFlow(site, file) - } - is JetpackAIAssistantFeatureResponse.Error -> { - _uiState.postValue(VoiceToContentResult(isError = true)) - } - } - } + viewModelScope.launch { + val result = voiceToContentUseCase.execute(site, file) + Log.i(javaClass.simpleName, "***=> result is ${result.content}") + _dismiss.postValue(Unit) } } - private fun startVoiceToContentFlow(site: SiteModel, file: File) { - if (isVoiceToContentEnabled()) { - viewModelScope.launch { - val result = voiceToContentUseCase.execute(site, file) - _uiState.postValue(result) - } + // Permissions + private fun onRequestPermission() { + _requestPermission.postValue(Unit) + } + + private fun hasAllPermissionsForRecording(): Boolean { + return IAudioRecorder.REQUIRED_RECORDING_PERMISSIONS.all { + ContextCompat.checkSelfPermission( + contextProvider.getContext(), + it + ) == PackageManager.PERMISSION_GRANTED } } + + fun onPermissionGranted() { + startRecording() + } + + // user actions + private fun onMicTap() { + startRecording() + } + + private fun onStopTap() { + stopRecording() + } + + private fun onClose() { + _dismiss.postValue(Unit) + } + + // transitions + 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) READY_TO_RECORD else 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(), + upgradeUrl = model.upgradeUrl + ) + ) + } + + private fun transitionToRecording() { + val currentState = _state.value + _state.value = currentState.copy( + uiStateType = 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() { + val currentState = _state.value + _state.value = currentState.copy( + uiStateType = 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 + private fun transitionToError() { + val currentState = _state.value + _state.value = currentState.copy( + uiStateType = ERROR, + header = currentState.header.copy( label = R.string.voice_to_content_error_label), + secondaryHeader = null, + recordingPanel = null + ) + } } 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 new file mode 100644 index 000000000000..45ea2427a950 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/voicetocontent/WaveformVisualizer.kt @@ -0,0 +1,149 @@ +package org.wordpress.android.ui.voicetocontent + +// import androidx.compose.animation.core.* +import androidx.compose.foundation.Canvas +import androidx.compose.material.MaterialTheme +import androidx.compose.runtime.Composable +// import androidx.compose.runtime.getValue +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.unit.dp + +@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() } + + Canvas(modifier = modifier) { + val width = size.width + val height = size.height + val centerY = height / 2 + + // 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( + color = color, + start = androidx.compose.ui.geometry.Offset(x, y1), + end = androidx.compose.ui.geometry.Offset(x, y2), + strokeWidth = strokeWidth + ) + } + } +} +// +//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() +// ) +// } +// } +//} + 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 7b6f73ad6d90..283d18593793 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 @@ -150,10 +150,12 @@ class AudioRecorder( delay(RECORDING_UPDATE_INTERVAL) elapsedTimeInSeconds += (RECORDING_UPDATE_INTERVAL / 1000).toInt() val fileSize = File(filePath).length() + val amplitude = recorder?.maxAmplitude?.toFloat() ?: 0f _recordingUpdates.value = RecordingUpdate( elapsedTime = elapsedTimeInSeconds, fileSize = fileSize, fileSizeLimitExceeded = fileSize >= recordingStrategy.maxFileSize, + amplitudes = listOf(amplitude) ) if ( maxFileSizeExceeded(fileSize) || maxDurationExceeded(elapsedTimeInSeconds) ) { diff --git a/WordPress/src/main/java/org/wordpress/android/util/audio/RecordingUpdate.kt b/WordPress/src/main/java/org/wordpress/android/util/audio/RecordingUpdate.kt index fbd7ceabce38..f067d6cff8a4 100644 --- a/WordPress/src/main/java/org/wordpress/android/util/audio/RecordingUpdate.kt +++ b/WordPress/src/main/java/org/wordpress/android/util/audio/RecordingUpdate.kt @@ -3,5 +3,6 @@ package org.wordpress.android.util.audio data class RecordingUpdate( val elapsedTime: Int = 0, // in seconds val fileSize: Long = 0L, // in bytes - val fileSizeLimitExceeded: Boolean = false + val fileSizeLimitExceeded: Boolean = false, + val amplitudes: List = emptyList() ) diff --git a/WordPress/src/main/res/drawable/ic_mic_none_24.xml b/WordPress/src/main/res/drawable/ic_mic_none_24.xml new file mode 100644 index 000000000000..6c4b34e4b2e1 --- /dev/null +++ b/WordPress/src/main/res/drawable/ic_mic_none_24.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/WordPress/src/main/res/drawable/v2c_stop.xml b/WordPress/src/main/res/drawable/v2c_stop.xml new file mode 100644 index 000000000000..c06853463df7 --- /dev/null +++ b/WordPress/src/main/res/drawable/v2c_stop.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/WordPress/src/main/res/values/strings.xml b/WordPress/src/main/res/values/strings.xml index dc156115e4a0..390b9124caa2 100644 --- a/WordPress/src/main/res/values/strings.xml +++ b/WordPress/src/main/res/values/strings.xml @@ -4899,10 +4899,19 @@ translators: %s: Select control option value e.g: "Auto, 25%". --> You can copy your post text in case your content is impacted. Copy error details to debug and share with support. Clear selected color Link label - + Tap to edit + Audio Recording Permission Required To record audio, this app needs permission to access your microphone. You have previously denied this permission. Please enable the microphone permission in the app settings to use this feature. - Tap to edit - + Processing + Error + Processing + Post from Audio + Requests available: + Begin Recording + Recording + You don\'t have enough requests available to create a post from audio. + Upgrade for more requests + Done diff --git a/WordPress/src/test/java/org/wordpress/android/ui/voicetocontent/VoiceToContentFeatureUtilsTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/voicetocontent/VoiceToContentFeatureUtilsTest.kt index b9b4fb433897..166cdfb33892 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/voicetocontent/VoiceToContentFeatureUtilsTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/voicetocontent/VoiceToContentFeatureUtilsTest.kt @@ -80,6 +80,7 @@ class VoiceToContentFeatureUtilsTest { requestsLimit = 0, usagePeriod = null, siteRequireUpgrade = true, + upgradeUrl = null, upgradeType = "", currentTier = null, nextTier = null, @@ -100,6 +101,7 @@ class VoiceToContentFeatureUtilsTest { usagePeriod = null, siteRequireUpgrade = false, upgradeType = "", + upgradeUrl = null, currentTier = null, nextTier = null, tierPlans = emptyList(), @@ -119,6 +121,7 @@ class VoiceToContentFeatureUtilsTest { usagePeriod = null, siteRequireUpgrade = false, upgradeType = "", + upgradeUrl = null, currentTier = Tier(JETPACK_AI_FREE, 0, 0, null), nextTier = null, tierPlans = emptyList(), @@ -141,6 +144,7 @@ class VoiceToContentFeatureUtilsTest { usagePeriod = null, siteRequireUpgrade = false, upgradeType = "", + upgradeUrl = null, currentTier = Tier("", 0, 1, null), nextTier = null, tierPlans = emptyList(), @@ -160,6 +164,7 @@ class VoiceToContentFeatureUtilsTest { usagePeriod = UsagePeriod("2024-01-01", "2024-02-01", 100), siteRequireUpgrade = false, upgradeType = "", + upgradeUrl = null, currentTier = Tier("", 200, 0, null), nextTier = null, tierPlans = emptyList(), diff --git a/WordPress/src/test/java/org/wordpress/android/ui/voicetocontent/VoiceToContentViewModelTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/voicetocontent/VoiceToContentViewModelTest.kt index 6e56b26950dd..c437637ce5ac 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/voicetocontent/VoiceToContentViewModelTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/voicetocontent/VoiceToContentViewModelTest.kt @@ -2,26 +2,17 @@ package org.wordpress.android.ui.voicetocontent import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.test.runTest -import org.assertj.core.api.Assertions.assertThat import org.junit.Before -import org.junit.Test -import org.junit.runner.RunWith import org.mockito.Mock -import org.mockito.junit.MockitoJUnitRunner -import org.mockito.kotlin.any -import org.mockito.kotlin.verify import org.mockito.kotlin.verifyNoInteractions import org.mockito.kotlin.whenever import org.wordpress.android.BaseUnitTest -import org.wordpress.android.fluxc.model.SiteModel -import org.wordpress.android.fluxc.store.jetpackai.JetpackAIStore import org.wordpress.android.ui.mysite.SelectedSiteRepository import org.wordpress.android.util.audio.RecordingUpdate -import java.io.File +import org.wordpress.android.viewmodel.ContextProvider +import kotlin.test.Test @ExperimentalCoroutinesApi -@RunWith(MockitoJUnitRunner::class) class VoiceToContentViewModelTest : BaseUnitTest() { @Mock lateinit var voiceToContentFeatureUtils: VoiceToContentFeatureUtils @@ -36,13 +27,30 @@ class VoiceToContentViewModelTest : BaseUnitTest() { lateinit var selectedSiteRepository: SelectedSiteRepository @Mock - lateinit var jetpackAIStore: JetpackAIStore + lateinit var prepareVoiceToContentUseCase: PrepareVoiceToContentUseCase - private lateinit var viewModel: VoiceToContentViewModel + @Mock + lateinit var contextProvider: ContextProvider - private lateinit var uiState: MutableList + private lateinit var viewModel: VoiceToContentViewModel - /* private val jetpackAIAssistantFeature = JetpackAIAssistantFeature( +// private var uiStateChanges = mutableListOf() +// private val uiState +// get() = viewModel.state.value + +// private fun testUiStateChanges( +// block: suspend CoroutineScope.() -> T +// ) { +// test { +// uiStateChanges.clear() +// val job = launch(testDispatcher()) { +// viewModel.state.toList(uiStateChanges) +// } +// this.block() +// job.cancel() +// } +// } + /* private val jetpackAIAssistantFeature = JetpackAIAssistantFeature( hasFeature = true, isOverLimit = false, requestsCount = 0, @@ -67,16 +75,10 @@ class VoiceToContentViewModelTest : BaseUnitTest() { voiceToContentFeatureUtils, voiceToContentUseCase, selectedSiteRepository, - jetpackAIStore, - recordingUseCase + recordingUseCase, + contextProvider, + prepareVoiceToContentUseCase ) - - uiState = mutableListOf() - viewModel.uiState.observeForever { event -> - event?.let { result -> - uiState.add(result) - } - } } // Helper function to create a consistent flow @@ -87,46 +89,9 @@ class VoiceToContentViewModelTest : BaseUnitTest() { @Test fun `when site is null, then execute posts error state `() = test { whenever(selectedSiteRepository.getSelectedSite()).thenReturn(null) - val dummyFile = File("dummy_path") - viewModel.executeVoiceToContent(dummyFile) - - val expectedState = VoiceToContentResult(isError = true) - assertThat(uiState.first()).isEqualTo(expectedState) - } - - /* @Test - fun `when voice to content is enabled, then execute invokes use case `() = test { - val site = SiteModel().apply { id = 1 } - val dummyFile = File("dummy_path") - - whenever(selectedSiteRepository.getSelectedSite()).thenReturn(site) - whenever(voiceToContentFeatureUtils.isVoiceToContentEnabled()).thenReturn(true) - whenever(jetpackAIStore.fetchJetpackAIAssistantFeature(site)) - .thenReturn(JetpackAIAssistantFeatureResponse.Success(jetpackAIAssistantFeature)) - - viewModel.executeVoiceToContent(dummyFile) - verify(voiceToContentUseCase).execute(site, dummyFile) - }*/ + viewModel.start() - @Test - fun `when voice to content is disabled, then executeVoiceToContent does not invoke use case`() = runTest { - val site = SiteModel().apply { id = 1 } - whenever(selectedSiteRepository.getSelectedSite()).thenReturn(site) - whenever(voiceToContentFeatureUtils.isVoiceToContentEnabled()).thenReturn(false) - val dummyFile = File("dummy_path") - - viewModel.executeVoiceToContent(dummyFile) - - verifyNoInteractions(voiceToContentUseCase) - } - - @Test - fun `when startRecording is called, then recordingUseCase starts recording`() { - viewModel.startRecording() - - verify(recordingUseCase).startRecording(any()) + verifyNoInteractions(prepareVoiceToContentUseCase) } } - - diff --git a/build.gradle b/build.gradle index ebbc2036d83a..531047d75949 100644 --- a/build.gradle +++ b/build.gradle @@ -25,7 +25,7 @@ ext { automatticTracksVersion = '5.1.0' gutenbergMobileVersion = 'v1.120.0' wordPressAztecVersion = 'v2.1.3' - wordPressFluxCVersion = 'trunk-b5d95fda4257bd1b3c94b33088f5e2a3f48ff1c2' + wordPressFluxCVersion = 'trunk-0fe67fa241426afeaaa66bc3970ba46634efa5c8' wordPressLoginVersion = '1.15.0' wordPressPersistentEditTextVersion = '1.0.2' wordPressUtilsVersion = '3.14.0'