diff --git a/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/PrezelAccordion.kt b/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/PrezelAccordion.kt index c31d973a..5853181b 100644 --- a/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/PrezelAccordion.kt +++ b/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/PrezelAccordion.kt @@ -49,13 +49,22 @@ fun PrezelAccordion( title: String, modifier: Modifier = Modifier, initiallyExpanded: Boolean = false, + expanded: Boolean? = null, + onExpandedChange: ((Boolean) -> Unit)? = null, nested: Boolean = false, showDivider: Boolean = false, leadingContent: @Composable (() -> Unit)? = null, trailingContent: @Composable (() -> Unit)? = null, content: @Composable (() -> Unit), ) { - var expanded by rememberSaveable { mutableStateOf(initiallyExpanded) } + var internalExpanded by rememberSaveable { mutableStateOf(initiallyExpanded) } + val currentExpanded = expanded ?: internalExpanded + val updateExpanded: (Boolean) -> Unit = { nextExpanded -> + if (expanded == null) { + internalExpanded = nextExpanded + } + onExpandedChange?.invoke(nextExpanded) + } Column( modifier = modifier @@ -71,26 +80,26 @@ fun PrezelAccordion( .clickable( interactionSource = null, indication = null, - onClick = { expanded = !expanded }, + onClick = { updateExpanded(!currentExpanded) }, ), size = if (nested) PrezelListSize.SMALL else PrezelListSize.REGULAR, nested = nested, leadingContent = leadingContent, trailingContent = { trailingContent?.invoke() - PrezelAccordionChevron(expanded = expanded) + PrezelAccordionChevron(expanded = currentExpanded) }, ) } - if (showDivider && !expanded) { + if (showDivider && !currentExpanded) { PrezelHorizontalDivider( type = PrezelDividerType.THICK, ) } PrezelAccordionContent( - expanded = expanded, + expanded = currentExpanded, content = content, ) } diff --git a/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/datepicker/PrezelDatePicker.kt b/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/datepicker/PrezelDatePicker.kt index bf4bc363..3c5683b2 100644 --- a/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/datepicker/PrezelDatePicker.kt +++ b/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/datepicker/PrezelDatePicker.kt @@ -89,7 +89,7 @@ fun PrezelDatePicker( } DatePickerFooter( - enabled = initialSelectedDate != selectedDate, + enabled = selectedDate != null, onClick = { selectedDate?.let(onConfirm) }, ) } diff --git a/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/navigations/PrezelTabSize.kt b/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/navigations/PrezelTabSize.kt index 1f33bad4..3ca1194b 100644 --- a/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/navigations/PrezelTabSize.kt +++ b/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/navigations/PrezelTabSize.kt @@ -1,3 +1,3 @@ package com.team.prezel.core.designsystem.component.navigations -enum class PrezelTabSize { REGULAR, MEDIUM } +enum class PrezelTabSize { REGULAR, SMALL } diff --git a/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/navigations/PrezelTabs.kt b/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/navigations/PrezelTabs.kt index 44247382..732c8996 100644 --- a/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/navigations/PrezelTabs.kt +++ b/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/navigations/PrezelTabs.kt @@ -68,11 +68,11 @@ private fun PrezelTab( Tab( selected = selected, onClick = onClick, - modifier = modifier.height(if (size == PrezelTabSize.REGULAR) 36.dp else 48.dp), + modifier = modifier.height(if (size == PrezelTabSize.SMALL) 36.dp else 48.dp), text = { Text( text = label, - style = if (size == PrezelTabSize.REGULAR) PrezelTextStyles.Body3Medium.toTextStyle() else PrezelTextStyles.Body2Bold.toTextStyle(), + style = if (size == PrezelTabSize.SMALL) PrezelTextStyles.Body3Medium.toTextStyle() else PrezelTextStyles.Body2Bold.toTextStyle(), ) }, selectedContentColor = PrezelTheme.colors.solidBlack, diff --git a/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/navigations/PrezelTabsPager.kt b/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/navigations/PrezelTabsPager.kt index c9561915..23120830 100644 --- a/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/navigations/PrezelTabsPager.kt +++ b/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/navigations/PrezelTabsPager.kt @@ -100,7 +100,7 @@ private fun PrezelMediumTabPreview() { PrezelTabsPager( tabs = tabs, pagerState = pagerState, - size = PrezelTabSize.MEDIUM, + size = PrezelTabSize.SMALL, modifier = Modifier, ) { page -> Box( diff --git a/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/textfield/PrezelTextArea.kt b/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/textfield/PrezelTextArea.kt index 60ec6ecb..f0f664d1 100644 --- a/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/textfield/PrezelTextArea.kt +++ b/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/textfield/PrezelTextArea.kt @@ -4,6 +4,8 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn @@ -46,6 +48,7 @@ fun PrezelTextArea( status: PrezelTextFieldStatus = PrezelTextFieldStatus.DEFAULT, enabled: Boolean = true, showCount: Boolean = false, + fillContainerHeight: Boolean = false, keyboardOptions: KeyboardOptions = KeyboardOptions.Default, keyboardActions: KeyboardActions = KeyboardActions.Default, ) { @@ -82,6 +85,7 @@ fun PrezelTextArea( label = label, enabled = enabled, showCount = showCount, + fillContainerHeight = fillContainerHeight, keyboardOptions = keyboardOptions, keyboardActions = keyboardActions, ) @@ -99,6 +103,7 @@ private fun PrezelTextArea( label: String?, enabled: Boolean, showCount: Boolean, + fillContainerHeight: Boolean, keyboardOptions: KeyboardOptions, keyboardActions: KeyboardActions, modifier: Modifier = Modifier, @@ -115,8 +120,13 @@ private fun PrezelTextArea( enabled = enabled, modifier = Modifier .fillMaxWidth() - .heightIn(min = 72.dp) - .onFocusChanged { focusState -> onFocusChange(focusState.isFocused) }, + .then( + if (fillContainerHeight) { + Modifier.weight(1f) + } else { + Modifier.heightIn(min = 72.dp) + }, + ).onFocusChanged { focusState -> onFocusChange(focusState.isFocused) }, textStyle = PrezelTheme.typography.body2Regular.copy(color = style.textColor()), cursorBrush = SolidColor(PrezelTheme.colors.interactiveRegular), keyboardOptions = keyboardOptions, @@ -133,7 +143,8 @@ private fun PrezelTextArea( Counter(currentLength = value.text.length, maxLength = maxLength, state = style) } }, - modifier = Modifier.heightIn(min = 72.dp), + modifier = if (fillContainerHeight) Modifier.fillMaxHeight() else Modifier.heightIn(min = 72.dp), + fillContainerHeight = fillContainerHeight, ) }, ) @@ -170,6 +181,7 @@ private fun PrezelTextAreaDecorationBox( state: PrezelTextFieldStyle, showCounter: Boolean, modifier: Modifier = Modifier, + fillContainerHeight: Boolean = false, ) { Surface( modifier = modifier.fillMaxWidth(), @@ -180,12 +192,12 @@ private fun PrezelTextAreaDecorationBox( ) { Box( modifier = Modifier - .fillMaxWidth() + .then(if (fillContainerHeight) Modifier.fillMaxSize() else Modifier.fillMaxWidth()) .padding(PrezelTheme.spacing.V12), ) { Box( modifier = Modifier - .fillMaxWidth() + .then(if (fillContainerHeight) Modifier.fillMaxSize() else Modifier.fillMaxWidth()) .padding(bottom = if (showCounter) PrezelTheme.spacing.V24 else 0.dp), ) { innerTextField() @@ -335,6 +347,7 @@ private fun PrezelTextAreaPreviewItem( onFocusChange = {}, enabled = enabled, showCount = true, + fillContainerHeight = false, keyboardOptions = KeyboardOptions.Default, keyboardActions = KeyboardActions.Default, ) diff --git a/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/textfield/PrezelTextField.kt b/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/textfield/PrezelTextField.kt index d627b18d..cde34494 100644 --- a/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/textfield/PrezelTextField.kt +++ b/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/textfield/PrezelTextField.kt @@ -5,8 +5,10 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.ime import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.text.BasicTextField @@ -27,6 +29,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.input.TextFieldValue @@ -56,6 +59,9 @@ fun PrezelTextField( ) { var focused by remember { mutableStateOf(false) } var textFieldValue by remember { mutableStateOf(TextFieldValue(text = value, selection = TextRange(value.length))) } + val density = LocalDensity.current + val imeVisible = WindowInsets.ime.getBottom(density) > 0 + val typing = focused && imeVisible LaunchedEffect(value) { if (value != textFieldValue.text) { @@ -66,7 +72,7 @@ fun PrezelTextField( val state = rememberPrezelTextFieldState( value = textFieldValue.text, enabled = enabled, - focused = focused, + focused = typing, ).let { state -> PrezelTextFieldStyle(state = state, status = status) } PrezelTextField( @@ -80,7 +86,7 @@ fun PrezelTextField( }, placeholder = placeholder, style = state, - focused = focused, + focused = typing, onFocusChange = { isFocused -> focused = isFocused }, modifier = modifier, label = label, diff --git a/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/voice/PrezelVoiceChrome.kt b/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/voice/PrezelVoiceChrome.kt index dcfaf66a..29b1c538 100644 --- a/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/voice/PrezelVoiceChrome.kt +++ b/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/voice/PrezelVoiceChrome.kt @@ -33,11 +33,9 @@ import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.drawscope.clipRect import androidx.compose.ui.graphics.drawscope.withTransform -import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp -import com.team.prezel.core.designsystem.R import com.team.prezel.core.designsystem.preview.BasicPreview import com.team.prezel.core.designsystem.preview.LargeDevicePreview import com.team.prezel.core.designsystem.preview.PreviewColumn @@ -166,13 +164,13 @@ private fun VoiceChromeTitle( ) VoiceChromeStatus.LISTENING -> Text( - text = stringResource(R.string.core_designsystem_voice_chrome_listening), + text = titleText, style = baseStyle, color = color, ) VoiceChromeStatus.WAITING -> Text( - text = stringResource(R.string.core_designsystem_voice_chrome_waiting), + text = titleText, style = baseStyle, color = color, ) diff --git a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AnalysisBackStateReducer.kt b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AnalysisBackStateReducer.kt index a65d9d46..e33d1694 100644 --- a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AnalysisBackStateReducer.kt +++ b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AnalysisBackStateReducer.kt @@ -13,8 +13,8 @@ internal val AnalysisFlowUiState.shouldResetAudioOnBack: Boolean internal fun AnalysisFlowUiState.backClearedFormOrNull(): AnalysisForm? = when (step) { - AnalysisFlowStep.PRESENTATION_SITUATION -> form.takeIf { it.hasSituationInput }?.clearSituationInput() AnalysisFlowStep.SCRIPT_INPUT -> form.takeIf { it.hasScriptInput }?.clearScriptInput() + AnalysisFlowStep.PRESENTATION_SITUATION, AnalysisFlowStep.PRESENTATION_SCHEDULE, AnalysisFlowStep.AUDIO_UPLOAD, AnalysisFlowStep.VOICE_RECORDING, @@ -25,20 +25,9 @@ internal fun AnalysisFlowUiState.backClearedFormOrNull(): AnalysisForm? = -> null } -private val AnalysisForm.hasSituationInput: Boolean - get() = listOf(category, purpose, style, audience).any { it != null } - private val AnalysisForm.hasScriptInput: Boolean get() = script.isNotBlank() || scriptFileUri != null -private fun AnalysisForm.clearSituationInput(): AnalysisForm = - copy( - category = null, - purpose = null, - style = null, - audience = null, - ) - private fun AnalysisForm.clearScriptInput(): AnalysisForm = copy( script = "", diff --git a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AnalysisFailureHandler.kt b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AnalysisFailureHandler.kt index 42f5cf0b..6dcdeb54 100644 --- a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AnalysisFailureHandler.kt +++ b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AnalysisFailureHandler.kt @@ -4,6 +4,8 @@ import com.team.prezel.core.common.error.AppError import com.team.prezel.core.common.error.AppException import com.team.prezel.feature.analysis.impl.contract.AnalysisUploadType import com.team.prezel.feature.analysis.impl.model.AnalysisUiMessage +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.TimeoutCancellationException internal sealed interface AnalysisFailureAction { data object RetryAnalysis : AnalysisFailureAction @@ -18,6 +20,14 @@ internal sealed interface AnalysisFailureAction { } internal fun Throwable.toAnalysisFailureAction(): AnalysisFailureAction { + if (this is TimeoutCancellationException) { + return AnalysisFailureAction.RetryAnalysis + } + + if (this is CancellationException) { + return AnalysisFailureAction.RetryAnalysis + } + val error = (this as? AppException)?.error return when (error) { @@ -27,11 +37,12 @@ internal fun Throwable.toAnalysisFailureAction(): AnalysisFailureAction { AppError.SCRIPT_FILE_RECOGNITION_FAILED -> AnalysisFailureAction.RetryFileUpload(uploadType = AnalysisUploadType.SCRIPT) AppError.UNAUTHORIZED -> AnalysisFailureAction.ShowMessage(message = AnalysisUiMessage.AUTH_EXPIRED) - AppError.VOICE_RECOGNITION_FAILED, + AppError.VOICE_RECOGNITION_FAILED -> AnalysisFailureAction.RetryFileUpload(uploadType = AnalysisUploadType.AUDIO) + AppError.VOICE_ANALYSIS_FAILED, + AppError.SERVER_ERROR, + AppError.NETWORK, -> AnalysisFailureAction.RetryAnalysis - AppError.SERVER_ERROR -> AnalysisFailureAction.ShowMessage(message = AnalysisUiMessage.ANALYSIS_FAILED) - AppError.NETWORK -> AnalysisFailureAction.ShowMessage(message = AnalysisUiMessage.NETWORK_FAILED) AppError.NOT_FOUND, AppError.DUPLICATE, diff --git a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AnalysisFlowViewModel.kt b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AnalysisFlowViewModel.kt index c86b6974..aab393b0 100644 --- a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AnalysisFlowViewModel.kt +++ b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AnalysisFlowViewModel.kt @@ -1,7 +1,6 @@ package com.team.prezel.feature.analysis.impl import androidx.lifecycle.viewModelScope -import com.team.prezel.core.audio.AudioSessionEffect import com.team.prezel.core.audio.AudioSessionState import com.team.prezel.core.audio.RecordingAudioController import com.team.prezel.core.domain.usecase.presentation.AnalyzePresentationUseCase @@ -20,13 +19,16 @@ import com.team.prezel.feature.analysis.impl.contract.AnalysisUploadType import com.team.prezel.feature.analysis.impl.contract.ScriptInputType import com.team.prezel.feature.analysis.impl.model.AnalysisUiMessage import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout import javax.inject.Inject +private const val ANALYSIS_TIMEOUT_MILLIS = 15_000L +private val SUPPORTED_AUDIO_FILE_EXTENSIONS = setOf("m4a", "mp4", "mp3") + @HiltViewModel internal class AnalysisFlowViewModel @Inject constructor( private val analyzePresentationUseCase: AnalyzePresentationUseCase, @@ -52,8 +54,8 @@ internal class AnalysisFlowViewModel @Inject constructor( } viewModelScope.launch { - audioController.audioSessionEffect.collect { effect -> - sendEffect(AnalysisFlowUiEffect.ShowMessage(effect.toUiMessage())) + audioController.audioSessionEffect.collect { + updateState { copy(step = AnalysisFlowStep.ANALYSIS_FAILED) } } } } @@ -63,6 +65,7 @@ internal class AnalysisFlowViewModel @Inject constructor( updateState { copy(form = nextForm) } return } + if (intent.handleRecordingIntent(audioController, currentState.recordingState)) return when (intent) { is AnalysisFlowUiIntent.EnterStep -> updateState { @@ -82,9 +85,10 @@ internal class AnalysisFlowViewModel @Inject constructor( ) is AnalysisFlowUiIntent.SelectScriptFile -> selectScriptFile(intent.fileUri) - AnalysisFlowUiIntent.ClickRecordingControl -> audioController.handleControlClick(currentState.recordingState) - AnalysisFlowUiIntent.StopRecording -> audioController.stopRecording() - AnalysisFlowUiIntent.ResetRecording -> audioController.reset() + is AnalysisFlowUiIntent.SelectAudioFile -> selectAudioFile( + fileUri = intent.fileUri, + fileName = intent.fileName, + ) is AnalysisFlowUiIntent.RetryFileUpload -> retryFileUpload(intent.uploadType) AnalysisFlowUiIntent.Next -> moveNext() AnalysisFlowUiIntent.SkipScript -> skipScript() @@ -93,6 +97,25 @@ internal class AnalysisFlowViewModel @Inject constructor( } } + private fun selectAudioFile( + fileUri: String?, + fileName: String?, + ) { + if (!fileName.isSupportedAudioFileName()) { + updateState { + copy( + step = AnalysisFlowStep.FILE_RECOGNITION_FAILED, + form = form.copy(audioFileUri = null), + ) + } + return + } + + updateState { + copy(form = form.copy(audioFileUri = fileUri)) + } + } + private fun selectScriptFile(fileUri: String?) { updateState { copy( @@ -149,6 +172,7 @@ internal class AnalysisFlowViewModel @Inject constructor( val submission = currentState.toPresentationAnalysisSubmissionOrNull() ?: return val reAnalyzePresentationId = currentState.reRecordingPresentationId ?: currentState.reWritingScriptPresentationId + audioController.stopPlayback() if (reAnalyzePresentationId != null) { reAnalyzePresentation( @@ -164,7 +188,10 @@ internal class AnalysisFlowViewModel @Inject constructor( analyzeJob = viewModelScope.launch { sendEffect(AnalysisFlowUiEffect.NavigateToStep(step = AnalysisFlowStep.ANALYZING)) - val analysisResult = submission.analyzePresentationRecording() + val analysisResult = submission.analyzePresentationRecording( + analysisFileCache = analysisFileCache, + analyzePresentationUseCase = analyzePresentationUseCase, + ) analysisResult.fold( onSuccess = { result -> @@ -190,7 +217,11 @@ internal class AnalysisFlowViewModel @Inject constructor( analyzeJob = viewModelScope.launch { sendEffect(AnalysisFlowUiEffect.NavigateToStep(step = AnalysisFlowStep.ANALYZING)) - val reAnalyzeResult = submission.reAnalyzePresentationRecording(presentationId) + val reAnalyzeResult = submission.reAnalyzePresentationRecording( + presentationId = presentationId, + analysisFileCache = analysisFileCache, + reAnalyzePresentationUseCase = reAnalyzePresentationUseCase, + ) reAnalyzeResult.fold( onSuccess = { result -> @@ -278,39 +309,6 @@ internal class AnalysisFlowViewModel @Inject constructor( } } - private suspend fun PresentationAnalysisSubmission.analyzePresentationRecording(): Result = - runCatching { - val audioFilePath = resolveAudioFilePath(analysisFileCache) - val scriptFilePath = resolveScriptFilePath(analysisFileCache) - analyzePresentationUseCase( - name = name, - date = date.toRequestDate(), - category = category, - purpose = purpose, - style = style, - audience = audience, - script = script, - scriptFilePath = scriptFilePath, - audioFilePath = audioFilePath, - ).getOrThrow() - }.onFailure { - if (it is CancellationException) throw it - } - - private suspend fun PresentationAnalysisSubmission.reAnalyzePresentationRecording(presentationId: Long): Result = - runCatching { - val audioFilePath = resolveAudioFilePath(analysisFileCache) - val scriptFilePath = resolveScriptFilePath(analysisFileCache) - reAnalyzePresentationUseCase( - presentationId = presentationId, - script = script, - scriptFilePath = scriptFilePath, - audioFilePath = audioFilePath, - ).getOrThrow() - }.onFailure { - if (it is CancellationException) throw it - } - private fun handleAnalysisFailure(action: AnalysisFailureAction) { when (action) { AnalysisFailureAction.RetryAnalysis -> { @@ -357,22 +355,24 @@ internal class AnalysisFlowViewModel @Inject constructor( } AnalysisUploadType.AUDIO -> { - val isAnalysisFailed = currentState.step == AnalysisFlowStep.ANALYSIS_FAILED - val retryStep = if (isAnalysisFailed) { - AnalysisFlowStep.PRESENTATION_SCHEDULE - } else { - currentState.audioInputStep - } + val retryStep = currentState.audioRetryStep updateState { copy( step = retryStep, form = form.copy(audioFileUri = null), ) } - if (retryStep == AnalysisFlowStep.VOICE_RECORDING || isAnalysisFailed) { + if (retryStep == AnalysisFlowStep.VOICE_RECORDING) { audioController.reset() } - viewModelScope.launch { sendEffect(AnalysisFlowUiEffect.NavigateToStep(step = retryStep)) } + viewModelScope.launch { + sendEffect( + AnalysisFlowUiEffect.NavigateToStep( + step = retryStep, + clearStack = true, + ), + ) + } } } } @@ -421,6 +421,17 @@ private val AnalysisFlowUiState.audioInputStep: AnalysisFlowStep AnalysisStartType.VOICE_RECORDING -> AnalysisFlowStep.VOICE_RECORDING } +private val AnalysisFlowUiState.audioRetryStep: AnalysisFlowStep + get() = when (step) { + AnalysisFlowStep.ANALYSIS_FAILED -> AnalysisFlowStep.VOICE_RECORDING + AnalysisFlowStep.FILE_RECOGNITION_FAILED -> when (startType) { + AnalysisStartType.FILE_UPLOAD -> AnalysisFlowStep.SCRIPT_INPUT + AnalysisStartType.VOICE_RECORDING -> AnalysisFlowStep.VOICE_RECORDING + } + + else -> audioInputStep + } + private suspend fun PresentationAnalysisSummary.fetchOriginalScript( fetchPresentationScriptDetailUseCase: FetchPresentationScriptDetailUseCase, ): Result { @@ -430,6 +441,49 @@ private suspend fun PresentationAnalysisSummary.fetchOriginalScript( .map { it.originalScript } } +private suspend fun runAnalysisCatching(block: suspend () -> T): Result = + try { + Result.success(withTimeout(ANALYSIS_TIMEOUT_MILLIS) { block() }) + } catch (throwable: Throwable) { + Result.failure(throwable) + } + +private suspend fun PresentationAnalysisSubmission.analyzePresentationRecording( + analysisFileCache: AnalysisFileCache, + analyzePresentationUseCase: AnalyzePresentationUseCase, +): Result = + runAnalysisCatching { + val audioFilePath = resolveAudioFilePath(analysisFileCache) + val scriptFilePath = resolveScriptFilePath(analysisFileCache) + analyzePresentationUseCase( + name = name, + date = date.toRequestDate(), + category = category, + purpose = purpose, + style = style, + audience = audience, + script = script, + scriptFilePath = scriptFilePath, + audioFilePath = audioFilePath, + ).getOrThrow() + } + +private suspend fun PresentationAnalysisSubmission.reAnalyzePresentationRecording( + presentationId: Long, + analysisFileCache: AnalysisFileCache, + reAnalyzePresentationUseCase: ReAnalyzePresentationUseCase, +): Result = + runAnalysisCatching { + val audioFilePath = resolveAudioFilePath(analysisFileCache) + val scriptFilePath = resolveScriptFilePath(analysisFileCache) + reAnalyzePresentationUseCase( + presentationId = presentationId, + script = script, + scriptFilePath = scriptFilePath, + audioFilePath = audioFilePath, + ).getOrThrow() + } + private fun PresentationAnalysisSubmission.resolveAudioFilePath(analysisFileCache: AnalysisFileCache): String = audioFileUri ?.let { uri -> @@ -450,12 +504,27 @@ private fun PresentationAnalysisSubmission.resolveScriptFilePath(analysisFileCac ).absolutePath } -private fun AudioSessionEffect.toUiMessage(): AnalysisUiMessage = +private fun String?.isSupportedAudioFileName(): Boolean { + if (this == null) return true + + val extension = substringAfterLast('.', missingDelimiterValue = "") + .lowercase() + + return extension in SUPPORTED_AUDIO_FILE_EXTENSIONS +} + +private fun AnalysisFlowUiIntent.handleRecordingIntent( + audioController: RecordingAudioController, + recordingState: AudioSessionState, +): Boolean { when (this) { - AudioSessionEffect.RecordingStartFailed -> AnalysisUiMessage.RECORDING_START_FAILED - AudioSessionEffect.RecordingStopFailed -> AnalysisUiMessage.RECORDING_STOP_FAILED - AudioSessionEffect.PlaybackStartFailed -> AnalysisUiMessage.PLAYBACK_START_FAILED + AnalysisFlowUiIntent.ClickRecordingControl -> audioController.handleControlClick(recordingState) + AnalysisFlowUiIntent.StopRecording -> audioController.stopRecording() + AnalysisFlowUiIntent.ResetRecording -> audioController.reset() + else -> return false } + return true +} private fun RecordingAudioController.handleControlClick(recordingState: AudioSessionState) { when (recordingState) { diff --git a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AnalysisFormMapper.kt b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AnalysisFormMapper.kt index 242cccd7..6d35895c 100644 --- a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AnalysisFormMapper.kt +++ b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AnalysisFormMapper.kt @@ -36,7 +36,6 @@ internal fun AnalysisFlowUiIntent.reduceFormOrNull(form: AnalysisForm): Analysis scriptFileUri = null, ) is AnalysisFlowUiIntent.UpdateScript -> form.copy(script = script) - is AnalysisFlowUiIntent.SelectAudioFile -> form.copy(audioFileUri = fileUri) is AnalysisFlowUiIntent.SelectSituationOption -> form.selectSituationOption(option) else -> null } diff --git a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AnalysisScreen.kt b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AnalysisScreen.kt index 193e191e..c97a9fea 100644 --- a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AnalysisScreen.kt +++ b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AnalysisScreen.kt @@ -11,8 +11,11 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.platform.LocalResources import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.team.prezel.core.audio.AudioSessionState import com.team.prezel.core.common.event.EdgeToEdgeStatusBarStyle import com.team.prezel.core.designsystem.component.feedback.snackbar.showPrezelSnackbar +import com.team.prezel.core.designsystem.component.voice.VoiceChromeGradient +import com.team.prezel.core.designsystem.component.voice.VoiceChromeStatus import com.team.prezel.core.ui.state.LocalSnackbarHostState import com.team.prezel.feature.analysis.impl.audio.AudioUploadScreen import com.team.prezel.feature.analysis.impl.contract.AnalysisFlowStep @@ -21,6 +24,7 @@ import com.team.prezel.feature.analysis.impl.contract.AnalysisFlowUiIntent import com.team.prezel.feature.analysis.impl.contract.AnalysisFlowUiState import com.team.prezel.feature.analysis.impl.contract.AnalysisUploadType import com.team.prezel.feature.analysis.impl.model.AnalysisUiMessage +import com.team.prezel.feature.analysis.impl.recording.VoiceRecordingChromeUi import com.team.prezel.feature.analysis.impl.recording.VoiceRecordingScreen import com.team.prezel.feature.analysis.impl.recording.VoiceRecordingStatusBarStyle import com.team.prezel.feature.analysis.impl.recording.isCompleted @@ -34,10 +38,17 @@ import com.team.prezel.feature.analysis.impl.script.ScriptInputScreen import com.team.prezel.feature.analysis.impl.situation.PresentationSituationScreen import kotlinx.coroutines.launch +private const val RECORDING_VOLUME_SAMPLE_INTERVAL_MILLIS = 50 +private const val VOICE_RECORDING_FEEDBACK_DURATION_MILLIS = 3_000 +private const val VOICE_RECORDING_FEEDBACK_SAMPLE_COUNT = + VOICE_RECORDING_FEEDBACK_DURATION_MILLIS / RECORDING_VOLUME_SAMPLE_INTERVAL_MILLIS +private const val SILENCE_VOLUME_THRESHOLD = 0.12f +private const val LOW_AVERAGE_VOLUME_THRESHOLD = 0.25f + @Composable internal fun AnalysisScreen( onBack: () -> Unit, - navigateToStep: (AnalysisFlowStep) -> Unit, + navigateToStep: (step: AnalysisFlowStep, clearStack: Boolean) -> Unit, navigateToReport: (presentationId: Long) -> Unit, viewModel: AnalysisFlowViewModel, ) { @@ -54,7 +65,7 @@ internal fun AnalysisScreen( viewModel.uiEffect.collect { effect -> when (effect) { AnalysisFlowUiEffect.NavigateBack -> onBack() - is AnalysisFlowUiEffect.NavigateToStep -> navigateToStep(effect.step) + is AnalysisFlowUiEffect.NavigateToStep -> navigateToStep(effect.step, effect.clearStack) is AnalysisFlowUiEffect.NavigateToReport -> navigateToReport(effect.presentationId) is AnalysisFlowUiEffect.ShowMessage -> { snackbarHostState.showPrezelSnackbar(message = resources.getString(effect.message.toStringRes())) @@ -106,6 +117,8 @@ private fun AnalysisScreen( ) { val resources = LocalResources.current val snackbarHostState = LocalSnackbarHostState.current + val voiceRecordingFeedback = uiState.voiceRecordingFeedback + val voiceRecordingChromeUi = voiceRecordingFeedback?.toChromeUi() LaunchedEffect(uiState.step) { if (uiState.step == AnalysisFlowStep.VOICE_RECORDING) { @@ -115,6 +128,12 @@ private fun AnalysisScreen( } } + LaunchedEffect(voiceRecordingFeedback) { + if (voiceRecordingFeedback != null) { + snackbarHostState.currentSnackbarData?.dismiss() + } + } + val onClickRecordingControl = rememberVoiceRecordingControlClick( uiState = uiState, onIntent = onIntent, @@ -123,12 +142,51 @@ private fun AnalysisScreen( AnalysisStepContent( uiState = uiState, isScriptExpanded = isScriptExpanded, + voiceRecordingChromeUi = voiceRecordingChromeUi, onIntent = onIntent, onClickRecordingControl = onClickRecordingControl, onScriptExpandedChange = onScriptExpandedChange, ) } +private enum class VoiceRecordingFeedback { + READY_TO_CONTINUE, + SPEAK_LOUDER, +} + +private fun VoiceRecordingFeedback.toChromeUi(): VoiceRecordingChromeUi = + when (this) { + VoiceRecordingFeedback.READY_TO_CONTINUE -> VoiceRecordingChromeUi( + titleResId = R.string.feature_analysis_impl_voice_recording_ready_to_continue_feedback, + status = VoiceChromeStatus.LISTENING, + gradient = VoiceChromeGradient.NONE, + hideWaveform = true, + ) + + VoiceRecordingFeedback.SPEAK_LOUDER -> VoiceRecordingChromeUi( + titleResId = R.string.feature_analysis_impl_voice_recording_speak_louder_feedback, + ) + } + +private val AnalysisFlowUiState.voiceRecordingFeedback: VoiceRecordingFeedback? + get() { + if (step != AnalysisFlowStep.VOICE_RECORDING || recordingState !is AudioSessionState.Recording) return null + + val recentVolumes = recordingVolumes.takeLast(VOICE_RECORDING_FEEDBACK_SAMPLE_COUNT) + if (recentVolumes.size < VOICE_RECORDING_FEEDBACK_SAMPLE_COUNT) return null + + if (recentVolumes.all { volume -> volume <= SILENCE_VOLUME_THRESHOLD }) { + return VoiceRecordingFeedback.READY_TO_CONTINUE + } + + val averageVolume = recentVolumes.average().toFloat() + return if (averageVolume <= LOW_AVERAGE_VOLUME_THRESHOLD) { + VoiceRecordingFeedback.SPEAK_LOUDER + } else { + null + } + } + @Composable private fun rememberVoiceRecordingControlClick( uiState: AnalysisFlowUiState, @@ -162,6 +220,7 @@ private fun rememberVoiceRecordingControlClick( private fun AnalysisStepContent( uiState: AnalysisFlowUiState, isScriptExpanded: Boolean, + voiceRecordingChromeUi: VoiceRecordingChromeUi?, onIntent: (AnalysisFlowUiIntent) -> Unit, onClickRecordingControl: () -> Unit, onScriptExpandedChange: (Boolean) -> Unit, @@ -175,6 +234,7 @@ private fun AnalysisStepContent( -> AnalysisInputStepContent( uiState = uiState, isScriptExpanded = isScriptExpanded, + voiceRecordingChromeUi = voiceRecordingChromeUi, onIntent = onIntent, onClickRecordingControl = onClickRecordingControl, onScriptExpandedChange = onScriptExpandedChange, @@ -195,6 +255,7 @@ private fun AnalysisStepContent( private fun AnalysisInputStepContent( uiState: AnalysisFlowUiState, isScriptExpanded: Boolean, + voiceRecordingChromeUi: VoiceRecordingChromeUi?, onIntent: (AnalysisFlowUiIntent) -> Unit, onClickRecordingControl: () -> Unit, onScriptExpandedChange: (Boolean) -> Unit, @@ -205,6 +266,7 @@ private fun AnalysisInputStepContent( VoiceRecordingScreen( uiState = uiState, isScriptExpanded = isScriptExpanded, + voiceChromeUi = voiceRecordingChromeUi, onClickRecordingControl = onClickRecordingControl, onStopRecording = { onIntent(AnalysisFlowUiIntent.StopRecording) }, onResetRecording = { onIntent(AnalysisFlowUiIntent.ResetRecording) }, @@ -257,7 +319,14 @@ private fun AnalysisFormInputStepContent( AnalysisFlowStep.AUDIO_UPLOAD -> AudioUploadScreen( uiState = uiState, - onAudioFileSelected = { onIntent(AnalysisFlowUiIntent.SelectAudioFile(it)) }, + onAudioFileSelected = { fileUri, fileName -> + onIntent( + AnalysisFlowUiIntent.SelectAudioFile( + fileUri = fileUri, + fileName = fileName, + ), + ) + }, onAnalyze = { onIntent(AnalysisFlowUiIntent.Next) }, onBack = { onIntent(AnalysisFlowUiIntent.Back) }, ) diff --git a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/audio/AudioUploadScreen.kt b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/audio/AudioUploadScreen.kt index fc93d065..417538e5 100644 --- a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/audio/AudioUploadScreen.kt +++ b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/audio/AudioUploadScreen.kt @@ -61,10 +61,11 @@ private const val AUDIO_UPLOAD_TAB_COUNT = 1 @Composable internal fun AudioUploadScreen( uiState: AnalysisFlowUiState, - onAudioFileSelected: (String?) -> Unit, + onAudioFileSelected: (fileUri: String?, fileName: String?) -> Unit, onAnalyze: () -> Unit, onBack: () -> Unit, ) { + val context = LocalContext.current var pendingAudioFileUri by remember { mutableStateOf(null) } val uploadProgress by animateFloatAsState( targetValue = if (pendingAudioFileUri != null) 1f else 0f, @@ -84,7 +85,7 @@ internal fun AudioUploadScreen( LaunchedEffect(pendingAudioFileUri) { val fileUri = pendingAudioFileUri ?: return@LaunchedEffect delay(AUDIO_UPLOAD_PROGRESS_DURATION_MILLIS.toLong()) - onAudioFileSelected(fileUri) + onAudioFileSelected(fileUri, fileUri.toFileName(context)) } LaunchedEffect(uiState.form.audioFileUri, pendingAudioFileUri) { @@ -104,7 +105,7 @@ internal fun AudioUploadScreen( if (pendingAudioFileUri != null) { pendingAudioFileUri = null } else { - onAudioFileSelected(null) + onAudioFileSelected(null, null) } }, onAnalyze = onAnalyze, @@ -145,7 +146,7 @@ private fun AudioUploadScreen( PrezelTabs( tabs = tabs, pagerState = pagerState, - size = PrezelTabSize.MEDIUM, + size = PrezelTabSize.SMALL, onClickTab = {}, ) @@ -352,7 +353,7 @@ private fun AudioUploadScreenPreview() { PrezelTheme { AudioUploadScreen( uiState = AnalysisFlowUiState(step = AnalysisFlowStep.AUDIO_UPLOAD), - onAudioFileSelected = {}, + onAudioFileSelected = { _, _ -> }, onAnalyze = {}, onBack = {}, ) @@ -386,7 +387,7 @@ private fun AudioUploadScreenSelectedPreview() { step = AnalysisFlowStep.AUDIO_UPLOAD, form = AnalysisForm(audioFileUri = "content://prezel/sample.m4a"), ), - onAudioFileSelected = {}, + onAudioFileSelected = { _, _ -> }, onAnalyze = {}, onBack = {}, ) diff --git a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/component/AnalysisStepLayout.kt b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/component/AnalysisStepLayout.kt index feab4c3b..64f49fcc 100644 --- a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/component/AnalysisStepLayout.kt +++ b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/component/AnalysisStepLayout.kt @@ -1,5 +1,6 @@ package com.team.prezel.feature.analysis.impl.component +import androidx.compose.foundation.ScrollState import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -12,12 +13,16 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource @@ -46,8 +51,14 @@ internal fun AnalysisStepLayout( onTrailingTextClick: (() -> Unit)? = null, subButtonText: String? = null, onSubButtonClick: (() -> Unit)? = null, + contentScrollable: Boolean = true, content: @Composable ColumnScope.() -> Unit, ) { + val contentScrollState = rememberScrollState() + val showButtonAreaDivider by remember { + derivedStateOf { contentScrollable && contentScrollState.maxValue > 0 } + } + Column( modifier = modifier .fillMaxSize() @@ -73,11 +84,16 @@ internal fun AnalysisStepLayout( ProgressBar(progress = progress) - AnalysisStepContent(content = content) + AnalysisStepContent( + scrollState = contentScrollState, + scrollable = contentScrollable, + content = content, + ) AnalysisStepButtonArea( buttonText = buttonText, buttonEnabled = buttonEnabled, + showDivider = showButtonAreaDivider, onButtonClick = onButtonClick, subButtonText = subButtonText, onSubButtonClick = onSubButtonClick, @@ -106,11 +122,15 @@ private fun AnalysisStepTrailingText( } @Composable -private fun ColumnScope.AnalysisStepContent(content: @Composable ColumnScope.() -> Unit) { +private fun ColumnScope.AnalysisStepContent( + scrollState: ScrollState, + scrollable: Boolean, + content: @Composable ColumnScope.() -> Unit, +) { Column( modifier = Modifier .weight(1f) - .verticalScroll(rememberScrollState()) + .then(if (scrollable) Modifier.verticalScroll(scrollState) else Modifier) .padding(horizontal = PrezelTheme.spacing.V20) .padding(top = PrezelTheme.spacing.V40), ) { @@ -122,12 +142,14 @@ private fun ColumnScope.AnalysisStepContent(content: @Composable ColumnScope.() private fun AnalysisStepButtonArea( buttonText: String, buttonEnabled: Boolean, + showDivider: Boolean, onButtonClick: () -> Unit, subButtonText: String?, onSubButtonClick: (() -> Unit)?, ) { PrezelButtonArea( - showBackground = true, + modifier = Modifier.background(PrezelTheme.colors.bgRegular), + showBackground = showDivider, mainButton = { modifier -> PrezelButton( modifier = modifier, @@ -184,7 +206,13 @@ private fun ProgressBar(progress: Float) { modifier = Modifier .fillMaxWidth(progress.coerceIn(0f, 1f)) .height(4.dp) - .background(PrezelTheme.colors.interactiveRegular), + .background( + color = PrezelTheme.colors.interactiveRegular, + shape = RoundedCornerShape( + topEnd = PrezelTheme.radius.V1000, + bottomEnd = PrezelTheme.radius.V1000, + ), + ), ) } } diff --git a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/contract/AnalysisFlowUiEffect.kt b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/contract/AnalysisFlowUiEffect.kt index db73c372..2f8a818d 100644 --- a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/contract/AnalysisFlowUiEffect.kt +++ b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/contract/AnalysisFlowUiEffect.kt @@ -8,6 +8,7 @@ internal sealed interface AnalysisFlowUiEffect : UiEffect { data class NavigateToStep( val step: AnalysisFlowStep, + val clearStack: Boolean = false, ) : AnalysisFlowUiEffect data class NavigateToReport( diff --git a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/contract/AnalysisFlowUiIntent.kt b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/contract/AnalysisFlowUiIntent.kt index 13bd39c0..a3fd26c6 100644 --- a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/contract/AnalysisFlowUiIntent.kt +++ b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/contract/AnalysisFlowUiIntent.kt @@ -51,6 +51,7 @@ internal sealed interface AnalysisFlowUiIntent : UiIntent { data class SelectAudioFile( val fileUri: String?, + val fileName: String? = null, ) : AnalysisFlowUiIntent data object ClickRecordingControl : AnalysisFlowUiIntent @@ -70,13 +71,13 @@ internal sealed interface AnalysisFlowUiIntent : UiIntent { data object Back : AnalysisFlowUiIntent companion object { - fun selectSituationOption(category: Category): AnalysisFlowUiIntent = SelectSituationOption(AnalysisSituationOption.CategoryOption(category)) + fun selectSituationOption(category: Category?): AnalysisFlowUiIntent = SelectSituationOption(AnalysisSituationOption.CategoryOption(category)) - fun selectSituationOption(purpose: Purpose): AnalysisFlowUiIntent = SelectSituationOption(AnalysisSituationOption.PurposeOption(purpose)) + fun selectSituationOption(purpose: Purpose?): AnalysisFlowUiIntent = SelectSituationOption(AnalysisSituationOption.PurposeOption(purpose)) - fun selectSituationOption(style: Style): AnalysisFlowUiIntent = SelectSituationOption(AnalysisSituationOption.StyleOption(style)) + fun selectSituationOption(style: Style?): AnalysisFlowUiIntent = SelectSituationOption(AnalysisSituationOption.StyleOption(style)) - fun selectSituationOption(audience: Audience): AnalysisFlowUiIntent = SelectSituationOption(AnalysisSituationOption.AudienceOption(audience)) + fun selectSituationOption(audience: Audience?): AnalysisFlowUiIntent = SelectSituationOption(AnalysisSituationOption.AudienceOption(audience)) } } @@ -87,18 +88,18 @@ internal enum class AnalysisUploadType { internal sealed interface AnalysisSituationOption { data class CategoryOption( - val category: Category, + val category: Category?, ) : AnalysisSituationOption data class PurposeOption( - val purpose: Purpose, + val purpose: Purpose?, ) : AnalysisSituationOption data class StyleOption( - val style: Style, + val style: Style?, ) : AnalysisSituationOption data class AudienceOption( - val audience: Audience, + val audience: Audience?, ) : AnalysisSituationOption } diff --git a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/navigation/AnalysisEntryBuilder.kt b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/navigation/AnalysisEntryBuilder.kt index e1cb486b..0968d33c 100644 --- a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/navigation/AnalysisEntryBuilder.kt +++ b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/navigation/AnalysisEntryBuilder.kt @@ -78,7 +78,12 @@ private fun AnalysisRoute( AnalysisScreen( onBack = { navigator.goBack() }, - navigateToStep = { step -> navigator.navigate(key = stepToNavKey(step)) }, + navigateToStep = { step, clearStack -> + navigator.navigate( + key = stepToNavKey(step), + clearStack = clearStack, + ) + }, navigateToReport = { presentationId -> navigator.navigate( key = ReportNavKey( diff --git a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/recording/VoiceRecordingButtonArea.kt b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/recording/VoiceRecordingButtonArea.kt index d5c42397..14ae9fac 100644 --- a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/recording/VoiceRecordingButtonArea.kt +++ b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/recording/VoiceRecordingButtonArea.kt @@ -26,6 +26,7 @@ import com.team.prezel.feature.analysis.impl.R internal fun VoiceRecordingButtonArea( recordingState: AudioSessionState, analyzeEnabled: Boolean, + showDivider: Boolean, onClickRecordingControl: () -> Unit, onStopRecording: () -> Unit, onResetRecording: () -> Unit, @@ -34,6 +35,7 @@ internal fun VoiceRecordingButtonArea( when (recordingState) { AudioSessionState.Idle -> IdleRecordingButtonArea( recordingState = recordingState, + showDivider = showDivider, onClickRecordingControl = onClickRecordingControl, ) @@ -41,6 +43,7 @@ internal fun VoiceRecordingButtonArea( is AudioSessionState.PausedRecording, -> ActiveRecordingButtonArea( recordingState = recordingState, + showDivider = showDivider, onClickRecordingControl = onClickRecordingControl, onStopRecording = onStopRecording, ) @@ -49,6 +52,7 @@ internal fun VoiceRecordingButtonArea( is AudioSessionState.Playing, -> CompletedRecordingButtonArea( analyzeEnabled = analyzeEnabled, + showDivider = showDivider, onResetRecording = onResetRecording, onAnalyze = onAnalyze, ) @@ -58,10 +62,11 @@ internal fun VoiceRecordingButtonArea( @Composable private fun IdleRecordingButtonArea( recordingState: AudioSessionState, + showDivider: Boolean, onClickRecordingControl: () -> Unit, ) { PrezelButtonArea( - showBackground = true, + showBackground = showDivider, mainButton = { buttonModifier -> RecordingIconButton( iconResId = recordingState.actionIconResId, @@ -76,11 +81,12 @@ private fun IdleRecordingButtonArea( @Composable private fun ActiveRecordingButtonArea( recordingState: AudioSessionState, + showDivider: Boolean, onClickRecordingControl: () -> Unit, onStopRecording: () -> Unit, ) { PrezelButtonArea( - showBackground = true, + showBackground = showDivider, isVertical = false, isStrongStrength = false, mainButton = { buttonModifier -> @@ -105,11 +111,12 @@ private fun ActiveRecordingButtonArea( @Composable private fun CompletedRecordingButtonArea( analyzeEnabled: Boolean, + showDivider: Boolean, onResetRecording: () -> Unit, onAnalyze: () -> Unit, ) { PrezelButtonArea( - showBackground = true, + showBackground = showDivider, isVertical = false, mainButton = { buttonModifier -> PrezelButton( diff --git a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/recording/VoiceRecordingContent.kt b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/recording/VoiceRecordingContent.kt index 1d081477..09c73291 100644 --- a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/recording/VoiceRecordingContent.kt +++ b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/recording/VoiceRecordingContent.kt @@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size @@ -18,6 +19,7 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.key @@ -45,6 +47,7 @@ import com.team.prezel.core.designsystem.theme.PrezelTheme import com.team.prezel.core.ui.util.noRippleClickable import com.team.prezel.feature.analysis.impl.R import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList @Composable @@ -56,6 +59,9 @@ internal fun VoiceRecordingContent( onToggleScriptExpanded: () -> Unit, onClickRecordingControl: () -> Unit, modifier: Modifier = Modifier, + voiceChromeUi: VoiceRecordingChromeUi? = null, + useMinimumScriptHeight: Boolean = false, + onScriptScrollableChange: (Boolean) -> Unit = {}, ) { Column( modifier = modifier @@ -74,13 +80,19 @@ internal fun VoiceRecordingContent( VoiceRecordingScriptBody( script = script, - modifier = Modifier.weight(1f), + modifier = if (useMinimumScriptHeight) { + Modifier.heightIn(min = 84.dp) + } else { + Modifier.weight(1f) + }, + onScrollableChange = onScriptScrollableChange, ) if (recordingState !is AudioSessionState.Idle) { VoiceRecordingStatusArea( recordingState = recordingState, recordingVolumes = recordingVolumes, + voiceChromeUi = voiceChromeUi, onClickRecordingControl = onClickRecordingControl, ) } @@ -120,8 +132,12 @@ private fun VoiceRecordingScriptHeader( private fun VoiceRecordingScriptBody( script: String, modifier: Modifier = Modifier, + onScrollableChange: (Boolean) -> Unit = {}, ) { val scrollState = rememberScrollState() + val scrollable by remember { + derivedStateOf { scrollState.maxValue > 0 } + } val showTopGradient by remember { derivedStateOf { scrollState.value > 0 } } @@ -129,6 +145,10 @@ private fun VoiceRecordingScriptBody( derivedStateOf { scrollState.value < scrollState.maxValue } } + LaunchedEffect(scrollable) { + onScrollableChange(scrollable) + } + Box( modifier = modifier .fillMaxWidth() @@ -136,10 +156,8 @@ private fun VoiceRecordingScriptBody( ) { val backgroundColor = PrezelTheme.colors.bgRegular - Text( - text = script.ifBlank { stringResource(R.string.feature_analysis_impl_voice_recording_no_script) }, - color = PrezelTheme.colors.textLarge, - style = PrezelTheme.typography.body2Regular, + VoiceRecordingScriptText( + script = script, modifier = Modifier .fillMaxWidth() .verticalScroll(scrollState), @@ -165,6 +183,29 @@ private fun VoiceRecordingScriptBody( } } +@Composable +private fun VoiceRecordingScriptText( + script: String, + modifier: Modifier = Modifier, +) { + val isScriptBlank = script.isBlank() + + Text( + text = if (isScriptBlank) { + stringResource(R.string.feature_analysis_impl_voice_recording_no_script) + } else { + script + }, + color = if (isScriptBlank) { + PrezelTheme.colors.textRegular + } else { + PrezelTheme.colors.textLarge + }, + style = PrezelTheme.typography.body2Regular, + modifier = modifier, + ) +} + @Composable private fun VoiceRecordingScriptGradient( brush: Brush, @@ -182,12 +223,14 @@ private fun VoiceRecordingScriptGradient( private fun VoiceRecordingStatusArea( recordingState: AudioSessionState, recordingVolumes: ImmutableList, + voiceChromeUi: VoiceRecordingChromeUi?, onClickRecordingControl: () -> Unit, ) { Spacer(modifier = Modifier.height(PrezelTheme.spacing.V16)) RecordingWaveform( recordingState = recordingState, recordingVolumes = recordingVolumes, + voiceChromeUi = voiceChromeUi, modifier = Modifier.fillMaxWidth(), ) Spacer(modifier = Modifier.height(recordingState.recordingStatusSpacing)) @@ -239,16 +282,22 @@ private fun ScriptZoomButton( private fun RecordingWaveform( recordingState: AudioSessionState, recordingVolumes: ImmutableList, + voiceChromeUi: VoiceRecordingChromeUi?, modifier: Modifier = Modifier, ) { val playbackProgress = recordingState.playbackProgress() - - PrezelVoiceChromeWave( - status = recordingState.toVoiceChromeStatus(), - volumes = recordingState.visibleRecordingVolumes( + val visibleVolumes = if (voiceChromeUi?.hideWaveform == true) { + persistentListOf() + } else { + recordingState.visibleRecordingVolumes( recordingVolumes = recordingVolumes, playbackProgress = playbackProgress, - ), + ) + } + + PrezelVoiceChromeWave( + status = voiceChromeUi?.status ?: recordingState.toVoiceChromeStatus(), + volumes = visibleVolumes, showBaseline = false, modifier = modifier, ) @@ -318,6 +367,10 @@ private fun RecordingTimer( ) { val timerText = when (recordingState) { is AudioSessionState.ReadyToPlay -> buildAnnotatedString { + withStyle(SpanStyle(color = PrezelTheme.colors.textDisabled)) { + append(currentSeconds.toTimerText()) + append("/") + } withStyle(SpanStyle(color = PrezelTheme.colors.textLarge)) { append(totalSeconds.toTimerText()) } diff --git a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/recording/VoiceRecordingScreen.kt b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/recording/VoiceRecordingScreen.kt index b43f5ee7..6cb696db 100644 --- a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/recording/VoiceRecordingScreen.kt +++ b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/recording/VoiceRecordingScreen.kt @@ -1,8 +1,10 @@ package com.team.prezel.feature.analysis.impl.recording +import androidx.annotation.StringRes import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -25,6 +27,7 @@ import com.team.prezel.core.audio.AudioSessionState import com.team.prezel.core.audio.AudioSource import com.team.prezel.core.designsystem.component.voice.PrezelVoiceChrome import com.team.prezel.core.designsystem.component.voice.VoiceChromeGradient +import com.team.prezel.core.designsystem.component.voice.VoiceChromeStatus import com.team.prezel.core.designsystem.icon.PrezelIcons import com.team.prezel.core.designsystem.preview.BasicPreview import com.team.prezel.core.designsystem.theme.PrezelTheme @@ -39,6 +42,7 @@ import kotlinx.coroutines.delay internal fun VoiceRecordingScreen( uiState: AnalysisFlowUiState, isScriptExpanded: Boolean, + voiceChromeUi: VoiceRecordingChromeUi? = null, onClickRecordingControl: () -> Unit, onStopRecording: () -> Unit, onResetRecording: () -> Unit, @@ -50,6 +54,7 @@ internal fun VoiceRecordingScreen( script = uiState.form.script, recordingState = uiState.recordingState, recordingVolumes = uiState.recordingVolumes, + voiceChromeUi = voiceChromeUi, analyzeEnabled = uiState.canMoveNext, isScriptExpanded = isScriptExpanded, onClickRecordingControl = onClickRecordingControl, @@ -66,6 +71,7 @@ private fun VoiceRecordingScreen( script: String, recordingState: AudioSessionState, recordingVolumes: ImmutableList, + voiceChromeUi: VoiceRecordingChromeUi? = null, analyzeEnabled: Boolean, isScriptExpanded: Boolean, onClickRecordingControl: () -> Unit, @@ -75,37 +81,49 @@ private fun VoiceRecordingScreen( onScriptExpandedChange: (Boolean) -> Unit, onBack: () -> Unit, ) { + var showButtonAreaDivider by remember { mutableStateOf(false) } + val useEmptyScriptLayout = script.isBlank() && !isScriptExpanded && !recordingState.isCompleted + Column( modifier = Modifier .fillMaxSize() .background(PrezelTheme.colors.bgRegular), ) { if (!isScriptExpanded) { - if (recordingState.isCompleted) { - VoiceRecordingCompletedTopBar(onBack = onBack) - } else { - VoiceRecordingChromeTopBar( - recordingState = recordingState, - onBack = onBack, - ) - } + VoiceRecordingTopBar( + recordingState = recordingState, + voiceChromeUi = voiceChromeUi, + expandChrome = useEmptyScriptLayout, + onBack = onBack, + ) } + val contentModifier = if (useEmptyScriptLayout) { + Modifier.fillMaxWidth() + } else { + Modifier.weight(1f) + } VoiceRecordingContent( script = script, recordingState = recordingState, recordingVolumes = recordingVolumes, + voiceChromeUi = voiceChromeUi, isScriptExpanded = isScriptExpanded, onToggleScriptExpanded = { onScriptExpandedChange(!isScriptExpanded) }, onClickRecordingControl = onClickRecordingControl, - modifier = Modifier.weight(1f), + useMinimumScriptHeight = useEmptyScriptLayout, + onScriptScrollableChange = { scrollable -> + showButtonAreaDivider = scrollable + }, + modifier = contentModifier, ) VoiceRecordingButtonArea( recordingState = recordingState, analyzeEnabled = analyzeEnabled, + showDivider = showButtonAreaDivider, onClickRecordingControl = onClickRecordingControl, onStopRecording = onStopRecording, onResetRecording = onResetRecording, @@ -114,6 +132,27 @@ private fun VoiceRecordingScreen( } } +@Composable +private fun ColumnScope.VoiceRecordingTopBar( + recordingState: AudioSessionState, + voiceChromeUi: VoiceRecordingChromeUi?, + expandChrome: Boolean, + onBack: () -> Unit, +) { + if (recordingState.isCompleted) { + VoiceRecordingCompletedTopBar(onBack = onBack) + return + } + + VoiceRecordingChromeTopBar( + titleResId = voiceChromeUi?.titleResId ?: recordingState.titleResId, + status = voiceChromeUi?.status ?: recordingState.toVoiceChromeStatus(), + gradient = voiceChromeUi?.gradient ?: VoiceChromeGradient.MIN, + expandChrome = expandChrome, + onBack = onBack, + ) +} + @Composable private fun VoiceRecordingCompletedTopBar(onBack: () -> Unit) { Box( @@ -132,13 +171,17 @@ private fun VoiceRecordingCompletedTopBar(onBack: () -> Unit) { } @Composable -private fun VoiceRecordingChromeTopBar( - recordingState: AudioSessionState, +private fun ColumnScope.VoiceRecordingChromeTopBar( + @StringRes titleResId: Int, + status: VoiceChromeStatus, + gradient: VoiceChromeGradient, + expandChrome: Boolean, onBack: () -> Unit, ) { Column( modifier = Modifier .fillMaxWidth() + .then(if (expandChrome) Modifier.weight(1f) else Modifier) .background(PrezelTheme.colors.bgMedium), ) { Box( @@ -153,10 +196,16 @@ private fun VoiceRecordingChromeTopBar( } PrezelVoiceChrome( - titleText = stringResource(recordingState.titleResId), - status = recordingState.toVoiceChromeStatus(), - gradient = VoiceChromeGradient.MIN, - modifier = Modifier.fillMaxWidth(), + titleText = stringResource(titleResId), + status = status, + gradient = gradient, + modifier = if (expandChrome) { + Modifier + .weight(1f) + .fillMaxWidth() + } else { + Modifier.fillMaxWidth() + }, ) } } @@ -195,6 +244,17 @@ private fun VoiceRecordingScreenRecordingPreview() { } } +@BasicPreview +@Composable +private fun VoiceRecordingScreenEmptyScriptPreview() { + PrezelTheme { + VoiceRecordingScreenPreviewContent( + recordingState = AudioSessionState.Idle, + script = "", + ) + } +} + @BasicPreview @Composable private fun VoiceRecordingScreenPausedPreview() { @@ -231,11 +291,14 @@ private fun VoiceRecordingScreenPlayingPreview() { } @Composable -private fun VoiceRecordingScreenPreviewContent(recordingState: AudioSessionState) { +private fun VoiceRecordingScreenPreviewContent( + recordingState: AudioSessionState, + script: String = "한 번쯤 발표하면서 긴장하신 경험 있으시죠. 오늘도 다들 긴장되는 마음으로 오셨을 것 같습니다.", +) { VoiceRecordingScreen( uiState = AnalysisFlowUiState( step = AnalysisFlowStep.VOICE_RECORDING, - form = AnalysisForm(script = "한 번쯤 발표하면서 긴장하신 경험 있으시죠. 오늘도 다들 긴장되는 마음으로 오셨을 것 같습니다."), + form = AnalysisForm(script = script), recordingState = recordingState, ), isScriptExpanded = false, diff --git a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/recording/VoiceRecordingStateProperties.kt b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/recording/VoiceRecordingStateProperties.kt index 1537bc77..bf0feabf 100644 --- a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/recording/VoiceRecordingStateProperties.kt +++ b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/recording/VoiceRecordingStateProperties.kt @@ -1,14 +1,23 @@ package com.team.prezel.feature.analysis.impl.recording +import androidx.annotation.StringRes import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.Color import com.team.prezel.core.audio.AudioSessionState +import com.team.prezel.core.designsystem.component.voice.VoiceChromeGradient import com.team.prezel.core.designsystem.component.voice.VoiceChromeStatus import com.team.prezel.core.designsystem.icon.PrezelIcons import com.team.prezel.core.designsystem.theme.PrezelTheme import com.team.prezel.feature.analysis.impl.R import java.util.Locale +internal data class VoiceRecordingChromeUi( + @param:StringRes val titleResId: Int? = null, + val status: VoiceChromeStatus? = null, + val gradient: VoiceChromeGradient? = null, + val hideWaveform: Boolean = false, +) + internal val AudioSessionState.currentSeconds: Int get() = when (this) { AudioSessionState.Idle -> 0 diff --git a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/schedule/PresentationScheduleScreen.kt b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/schedule/PresentationScheduleScreen.kt index 2c5373fc..3a3dd52f 100644 --- a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/schedule/PresentationScheduleScreen.kt +++ b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/schedule/PresentationScheduleScreen.kt @@ -89,10 +89,11 @@ private fun PresentationScheduleScreen( Spacer(modifier = Modifier.height(PrezelTheme.spacing.V32)) PrezelTextField( - label = stringResource(R.string.feature_analysis_impl_presentation_name_label), value = form.presentationTitle, onValueChange = onTitleChange, placeholder = stringResource(R.string.feature_analysis_impl_presentation_name_placeholder), + label = stringResource(R.string.feature_analysis_impl_presentation_name_label), + maxLength = 10, ) Spacer(modifier = Modifier.height(PrezelTheme.spacing.V32)) diff --git a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/script/ScriptInputScreen.kt b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/script/ScriptInputScreen.kt index 6f7a310f..8347e3f1 100644 --- a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/script/ScriptInputScreen.kt +++ b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/script/ScriptInputScreen.kt @@ -6,7 +6,10 @@ import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.ColumnScope 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.size @@ -25,7 +28,8 @@ import androidx.compose.ui.unit.dp import com.team.prezel.core.designsystem.component.actions.button.PrezelButton import com.team.prezel.core.designsystem.component.actions.button.config.ButtonSize import com.team.prezel.core.designsystem.component.actions.button.config.ButtonType -import com.team.prezel.core.designsystem.component.navigations.PrezelTabSize +import com.team.prezel.core.designsystem.component.feedback.dialog.PrezelDialog +import com.team.prezel.core.designsystem.component.feedback.dialog.PrezelDialogScope.ActionType import com.team.prezel.core.designsystem.component.navigations.PrezelTabs import com.team.prezel.core.designsystem.component.textfield.PrezelTextArea import com.team.prezel.core.designsystem.icon.PrezelIcons @@ -95,6 +99,7 @@ internal fun ScriptInputScreen( progress = uiState.progress, buttonEnabled = uiState.canMoveNext, onSelectInputType = onSelectInputType, + onClearPendingScriptFile = { pendingScriptFileUri = null }, onScriptChange = onScriptChange, onScriptFileUploadClick = { scriptPicker.launch(SCRIPT_FILE_MIME_TYPES) }, onScriptFileClear = { @@ -118,6 +123,7 @@ private fun ScriptInputScreen( progress: Float, buttonEnabled: Boolean, onSelectInputType: (ScriptInputType) -> Unit, + onClearPendingScriptFile: () -> Unit, onScriptChange: (String) -> Unit, onScriptFileUploadClick: () -> Unit, onScriptFileClear: () -> Unit, @@ -125,6 +131,20 @@ private fun ScriptInputScreen( onSkip: () -> Unit, onBack: () -> Unit, ) { + var pendingInputTypeChange by remember { mutableStateOf(null) } + + pendingInputTypeChange?.let { inputType -> + ScriptInputTypeChangeDialog( + targetInputType = inputType, + onDismiss = { pendingInputTypeChange = null }, + onConfirm = { + onClearPendingScriptFile() + onSelectInputType(inputType) + pendingInputTypeChange = null + }, + ) + } + AnalysisStepLayout( title = stringResource(R.string.feature_analysis_impl_script_title), progress = progress, @@ -134,6 +154,7 @@ private fun ScriptInputScreen( onBack = onBack, trailingText = stringResource(R.string.feature_analysis_impl_skip), onTrailingTextClick = onSkip, + contentScrollable = false, ) { AnalysisStepTitle( title = stringResource(R.string.feature_analysis_impl_script_headline), @@ -142,31 +163,102 @@ private fun ScriptInputScreen( Spacer(modifier = Modifier.height(PrezelTheme.spacing.V32)) - ScriptInputTabs( - selectedType = form.scriptInputType, - onSelect = onSelectInputType, + ScriptInputContent( + form = form, + pendingScriptFileUri = pendingScriptFileUri, + uploadProgress = uploadProgress, + onSelectInputType = onSelectInputType, + onRequestInputTypeChange = { inputType -> + pendingInputTypeChange = inputType + }, + onScriptChange = onScriptChange, + onScriptFileUploadClick = onScriptFileUploadClick, + onScriptFileClear = onScriptFileClear, ) + } +} - Spacer(modifier = Modifier.height(PrezelTheme.spacing.V16)) - - when (form.scriptInputType) { - ScriptInputType.FILE_UPLOAD -> ScriptUploadCard( - fileUri = pendingScriptFileUri ?: form.scriptFileUri, - uploadProgress = if (pendingScriptFileUri != null) uploadProgress else null, - onClick = onScriptFileUploadClick, - onClear = onScriptFileClear, - ) +@Composable +private fun ColumnScope.ScriptInputContent( + form: AnalysisForm, + pendingScriptFileUri: String?, + uploadProgress: Float, + onSelectInputType: (ScriptInputType) -> Unit, + onRequestInputTypeChange: (ScriptInputType) -> Unit, + onScriptChange: (String) -> Unit, + onScriptFileUploadClick: () -> Unit, + onScriptFileClear: () -> Unit, +) { + ScriptInputTabs( + selectedType = form.scriptInputType, + onSelect = { inputType -> + if (inputType == form.scriptInputType) return@ScriptInputTabs - ScriptInputType.DIRECT_INPUT -> { - PrezelTextArea( - value = form.script, - onValueChange = onScriptChange, - placeholder = stringResource(R.string.feature_analysis_impl_script_placeholder), - maxLength = SCRIPT_MAX_LENGTH, - modifier = Modifier.fillMaxWidth(), - ) - Spacer(modifier = Modifier.height(PrezelTheme.spacing.V16)) + if (form.hasScriptInputHistory || pendingScriptFileUri != null) { + onRequestInputTypeChange(inputType) + } else { + onSelectInputType(inputType) } + }, + ) + + Spacer(modifier = Modifier.height(PrezelTheme.spacing.V16)) + + when (form.scriptInputType) { + ScriptInputType.FILE_UPLOAD -> ScriptUploadCard( + fileUri = pendingScriptFileUri ?: form.scriptFileUri, + uploadProgress = if (pendingScriptFileUri != null) uploadProgress else null, + onClick = onScriptFileUploadClick, + onClear = onScriptFileClear, + modifier = Modifier + .fillMaxWidth() + .weight(1f), + ) + + ScriptInputType.DIRECT_INPUT -> DirectScriptInput( + script = form.script, + onScriptChange = onScriptChange, + ) + } +} + +@Composable +private fun ColumnScope.DirectScriptInput( + script: String, + onScriptChange: (String) -> Unit, +) { + PrezelTextArea( + value = script, + onValueChange = onScriptChange, + placeholder = stringResource(R.string.feature_analysis_impl_script_placeholder), + maxLength = SCRIPT_MAX_LENGTH, + modifier = Modifier + .fillMaxWidth() + .weight(1f), + fillContainerHeight = true, + ) + Spacer(modifier = Modifier.height(PrezelTheme.spacing.V16)) +} + +@Composable +private fun ScriptInputTypeChangeDialog( + targetInputType: ScriptInputType, + onDismiss: () -> Unit, + onConfirm: () -> Unit, +) { + PrezelDialog( + title = stringResource(targetInputType.changeDialogTitleResId), + description = stringResource(targetInputType.changeDialogDescriptionResId), + onDismiss = onDismiss, + ) { + Action(label = stringResource(R.string.feature_analysis_impl_script_input_type_change_dialog_cancel)) { + onDismiss() + } + Action( + label = stringResource(targetInputType.changeDialogConfirmResId), + type = ActionType.BAD, + ) { + onConfirm() } } } @@ -192,14 +284,33 @@ private fun ScriptInputTabs( PrezelTabs( tabs = tabs, pagerState = pagerState, - size = PrezelTabSize.MEDIUM, onClickTab = { page -> - pagerState.requestScrollToPage(page) onSelect(page.toScriptInputType()) }, ) } +private val AnalysisForm.hasScriptInputHistory: Boolean + get() = script.isNotBlank() || scriptFileUri != null + +private val ScriptInputType.changeDialogTitleResId: Int + get() = when (this) { + ScriptInputType.FILE_UPLOAD -> R.string.feature_analysis_impl_script_upload_change_dialog_title + ScriptInputType.DIRECT_INPUT -> R.string.feature_analysis_impl_script_direct_change_dialog_title + } + +private val ScriptInputType.changeDialogDescriptionResId: Int + get() = when (this) { + ScriptInputType.FILE_UPLOAD -> R.string.feature_analysis_impl_script_upload_change_dialog_description + ScriptInputType.DIRECT_INPUT -> R.string.feature_analysis_impl_script_direct_change_dialog_description + } + +private val ScriptInputType.changeDialogConfirmResId: Int + get() = when (this) { + ScriptInputType.FILE_UPLOAD -> R.string.feature_analysis_impl_script_upload_change_dialog_confirm + ScriptInputType.DIRECT_INPUT -> R.string.feature_analysis_impl_script_direct_change_dialog_confirm + } + private fun ScriptInputType.toTabPage(): Int = when (this) { ScriptInputType.FILE_UPLOAD -> 0 @@ -218,26 +329,33 @@ private fun ScriptUploadCard( uploadProgress: Float?, onClick: () -> Unit, onClear: () -> Unit, + modifier: Modifier = Modifier, ) { if (fileUri == null) { - EmptyScriptUploadContent(onClick = onClick) - } else { - UploadedScriptFileCard( - fileUri = fileUri, - uploadProgress = uploadProgress, - onClear = onClear, + EmptyScriptUploadContent( + onClick = onClick, + modifier = modifier, ) + } else { + Box(modifier = modifier.fillMaxSize()) { + UploadedScriptFileCard( + fileUri = fileUri, + uploadProgress = uploadProgress, + onClear = onClear, + ) + } } } @Composable -private fun EmptyScriptUploadContent(onClick: () -> Unit) { +private fun EmptyScriptUploadContent( + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { StatusView( title = stringResource(R.string.feature_analysis_impl_script_file_placeholder), description = stringResource(R.string.feature_analysis_impl_script_file_format), - modifier = Modifier - .fillMaxWidth() - .height(420.dp), + modifier = modifier, visual = { Image( painter = painterResource(R.drawable.feature_analysis_impl_no_script), @@ -306,6 +424,7 @@ private fun ScriptInputUploadProgressScreenPreview() { progress = AnalysisFlowUiState(step = AnalysisFlowStep.SCRIPT_INPUT).progress, buttonEnabled = false, onSelectInputType = {}, + onClearPendingScriptFile = {}, onScriptChange = {}, onScriptFileUploadClick = {}, onScriptFileClear = {}, diff --git a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/situation/PresentationSituationScreen.kt b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/situation/PresentationSituationScreen.kt index 0f9854c6..351c8a42 100644 --- a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/situation/PresentationSituationScreen.kt +++ b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/situation/PresentationSituationScreen.kt @@ -19,6 +19,10 @@ import androidx.compose.material3.LocalContentColor import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource @@ -49,10 +53,10 @@ import kotlinx.collections.immutable.toImmutableList @Composable internal fun PresentationSituationScreen( uiState: AnalysisFlowUiState, - onSelectCategory: (Category) -> Unit, - onSelectPurpose: (Purpose) -> Unit, - onSelectStyle: (Style) -> Unit, - onSelectAudience: (Audience) -> Unit, + onSelectCategory: (Category?) -> Unit, + onSelectPurpose: (Purpose?) -> Unit, + onSelectStyle: (Style?) -> Unit, + onSelectAudience: (Audience?) -> Unit, onNext: () -> Unit, onBack: () -> Unit, ) { @@ -74,10 +78,10 @@ private fun PresentationSituationScreen( form: AnalysisForm, progress: Float, buttonEnabled: Boolean, - onSelectCategory: (Category) -> Unit, - onSelectPurpose: (Purpose) -> Unit, - onSelectStyle: (Style) -> Unit, - onSelectAudience: (Audience) -> Unit, + onSelectCategory: (Category?) -> Unit, + onSelectPurpose: (Purpose?) -> Unit, + onSelectStyle: (Style?) -> Unit, + onSelectAudience: (Audience?) -> Unit, onNext: () -> Unit, onBack: () -> Unit, ) { @@ -111,57 +115,159 @@ private fun PresentationSituationScreen( @Composable private fun SituationAccordions( form: AnalysisForm, - onSelectCategory: (Category) -> Unit, - onSelectPurpose: (Purpose) -> Unit, - onSelectStyle: (Style) -> Unit, - onSelectAudience: (Audience) -> Unit, + onSelectCategory: (Category?) -> Unit, + onSelectPurpose: (Purpose?) -> Unit, + onSelectStyle: (Style?) -> Unit, + onSelectAudience: (Audience?) -> Unit, ) { + var expandedType by rememberSaveable { mutableStateOf(null) } val categoryOptions = categoryOptions() val purposeOptions = purposeOptions() val styleOptions = styleOptions() val audienceOptions = audienceOptions() + CategorySituationAccordion( + form = form, + expandedType = expandedType, + categoryOptions = categoryOptions, + onExpandedTypeChange = { expandedType = it }, + onSelectCategory = onSelectCategory, + ) + PurposeSituationAccordion( + form = form, + expandedType = expandedType, + purposeOptions = purposeOptions, + onExpandedTypeChange = { expandedType = it }, + onSelectPurpose = onSelectPurpose, + ) + StyleSituationAccordion( + form = form, + expandedType = expandedType, + styleOptions = styleOptions, + onExpandedTypeChange = { expandedType = it }, + onSelectStyle = onSelectStyle, + ) + AudienceSituationAccordion( + form = form, + expandedType = expandedType, + audienceOptions = audienceOptions, + onExpandedTypeChange = { expandedType = it }, + onSelectAudience = onSelectAudience, + ) +} + +@Composable +private fun CategorySituationAccordion( + form: AnalysisForm, + expandedType: SituationAccordionType?, + categoryOptions: ImmutableList, + onExpandedTypeChange: (SituationAccordionType?) -> Unit, + onSelectCategory: (Category?) -> Unit, +) { SituationAccordion( + type = SituationAccordionType.CATEGORY, + expandedType = expandedType, + onExpandedTypeChange = onExpandedTypeChange, title = stringResource(R.string.feature_analysis_impl_situation_category_label), selectedText = categoryOptions.firstOrNull { it.value == form.category }?.title, ) { CategoryOptionGrid( selectedValue = form.category, options = categoryOptions, - onSelect = onSelectCategory, + onSelect = { selectedCategory -> + onSelectCategory(selectedCategory) + onExpandedTypeChange(selectedCategory.nextExpandedType(SituationAccordionType.CATEGORY)) + }, ) } +} + +@Composable +private fun PurposeSituationAccordion( + form: AnalysisForm, + expandedType: SituationAccordionType?, + purposeOptions: ImmutableList>, + onExpandedTypeChange: (SituationAccordionType?) -> Unit, + onSelectPurpose: (Purpose?) -> Unit, +) { SituationAccordion( + type = SituationAccordionType.PURPOSE, + expandedType = expandedType, + onExpandedTypeChange = onExpandedTypeChange, title = stringResource(R.string.feature_analysis_impl_situation_purpose_label), selectedText = purposeOptions.firstOrNull { it.value == form.purpose }?.text, ) { ChipOptionsContent( options = purposeOptions.toChipContentOptions(form.purpose), - onSelect = { index -> onSelectPurpose(purposeOptions[index].value) }, + onSelect = { index -> + val selectedPurpose = purposeOptions[index].value + val nextPurpose = selectedPurpose.toggleIfSelected(form.purpose) + onSelectPurpose(nextPurpose) + onExpandedTypeChange(nextPurpose.nextExpandedType(SituationAccordionType.PURPOSE)) + }, ) } +} + +@Composable +private fun StyleSituationAccordion( + form: AnalysisForm, + expandedType: SituationAccordionType?, + styleOptions: ImmutableList>, + onExpandedTypeChange: (SituationAccordionType?) -> Unit, + onSelectStyle: (Style?) -> Unit, +) { SituationAccordion( + type = SituationAccordionType.STYLE, + expandedType = expandedType, + onExpandedTypeChange = onExpandedTypeChange, title = stringResource(R.string.feature_analysis_impl_situation_style_label), selectedText = styleOptions.firstOrNull { it.value == form.style }?.text, ) { ChipOptionsContent( options = styleOptions.toChipContentOptions(form.style), - onSelect = { index -> onSelectStyle(styleOptions[index].value) }, + onSelect = { index -> + val selectedStyle = styleOptions[index].value + val nextStyle = selectedStyle.toggleIfSelected(form.style) + onSelectStyle(nextStyle) + onExpandedTypeChange(nextStyle.nextExpandedType(SituationAccordionType.STYLE)) + }, ) } +} + +@Composable +private fun AudienceSituationAccordion( + form: AnalysisForm, + expandedType: SituationAccordionType?, + audienceOptions: ImmutableList>, + onExpandedTypeChange: (SituationAccordionType?) -> Unit, + onSelectAudience: (Audience?) -> Unit, +) { SituationAccordion( + type = SituationAccordionType.AUDIENCE, + expandedType = expandedType, + onExpandedTypeChange = onExpandedTypeChange, title = stringResource(R.string.feature_analysis_impl_situation_scale_label), selectedText = audienceOptions.firstOrNull { it.value == form.audience }?.text, ) { ChipOptionsContent( options = audienceOptions.toChipContentOptions(form.audience), - onSelect = { index -> onSelectAudience(audienceOptions[index].value) }, + onSelect = { index -> + val selectedAudience = audienceOptions[index].value + val nextAudience = selectedAudience.toggleIfSelected(form.audience) + onSelectAudience(nextAudience) + onExpandedTypeChange(nextAudience.nextExpandedType(SituationAccordionType.AUDIENCE)) + }, ) } } @Composable private fun SituationAccordion( + type: SituationAccordionType, + expandedType: SituationAccordionType?, + onExpandedTypeChange: (SituationAccordionType?) -> Unit, title: String, selectedText: String?, content: @Composable () -> Unit, @@ -171,6 +277,10 @@ private fun SituationAccordion( ) { PrezelAccordion( title = title, + expanded = expandedType == type, + onExpandedChange = { expanded -> + onExpandedTypeChange(if (expanded) type else null) + }, showDivider = true, trailingContent = selectedText?.let { text -> { @@ -190,7 +300,7 @@ private fun SituationAccordion( private fun CategoryOptionGrid( selectedValue: Category?, options: ImmutableList, - onSelect: (Category) -> Unit, + onSelect: (Category?) -> Unit, ) { Column(verticalArrangement = Arrangement.spacedBy(PrezelTheme.spacing.V16)) { options.chunked(2).forEach { rowOptions -> @@ -202,7 +312,7 @@ private fun CategoryOptionGrid( CategoryOptionCard( option = option, selected = selectedValue == option.value, - onClick = { onSelect(option.value) }, + onClick = { onSelect(option.value.toggleIfSelected(selectedValue)) }, modifier = Modifier .weight(1f) .aspectRatio(1f), @@ -216,6 +326,26 @@ private fun CategoryOptionGrid( } } +private enum class SituationAccordionType { + CATEGORY, + PURPOSE, + STYLE, + AUDIENCE, + ; + + val next: SituationAccordionType? + get() = when (this) { + CATEGORY -> PURPOSE + PURPOSE -> STYLE + STYLE -> AUDIENCE + AUDIENCE -> null + } +} + +private fun T.toggleIfSelected(selectedValue: T?): T? = if (this == selectedValue) null else this + +private fun Any?.nextExpandedType(currentType: SituationAccordionType): SituationAccordionType? = if (this == null) currentType else currentType.next + @Composable private fun CategoryOptionCard( option: SituationCategoryOption, diff --git a/Prezel/feature/analysis/impl/src/main/res/values/strings.xml b/Prezel/feature/analysis/impl/src/main/res/values/strings.xml index bc31e60f..0e55a190 100644 --- a/Prezel/feature/analysis/impl/src/main/res/values/strings.xml +++ b/Prezel/feature/analysis/impl/src/main/res/values/strings.xml @@ -41,21 +41,28 @@ 대본 입력 발표에 사용할 대본을 추가해주세요. 대본 파일을 업로드하거나 직접 입력할 수 있어요. - 발표 대본을 입력해주세요 + 대본을 입력해주세요 대본 파일을 추가해주세요. - 제공하는 파일 형식 : txt + 제공하는 파일 형식 : .txt 파일 추가하기 대본 파일 삭제 직접 입력 + 대본을 업로드하시나요? + 직접 입력한 대본이 삭제됩니다. + 업로드 + 대본을 직접 입력하시나요? + 업로드한 파일이 삭제됩니다. + 직접 입력 + 취소 음성 녹음 대본을 읽은 음성 파일을 업로드해주세요. 분석을 위해 음성 파일이 필요해요. 음성 파일을 업로드하세요 - 제공하는 파일 형식 : m4a, mp4, mp3 + 제공하는 파일 형식 : .m4a, .mp4, .mp3 음성 파일 삭제 파일 추가하기 - 조용한 공간에서 녹음을 시작해주세요. 주변 소음으로 분석 정확도가 낮아질 수 있어요. + 조용한 공간에서 녹음을 시작해주세요. \n주변 소음으로 분석 정확도가 낮아질 수 있어요. 지금부터 발표해볼까요? 듣고 있어요 일시정지됐어요 @@ -67,6 +74,8 @@ 녹음을 시작하지 못했어요. 다시 시도해 주세요. 녹음을 저장하지 못했어요. 다시 시도해 주세요. 녹음을 재생하지 못했어요. + 조금 더 크게 말해볼까요? + 이어서 말해주세요 파일 업로드 직접 입력 diff --git a/Prezel/feature/history/impl/src/main/java/com/team/prezel/feature/history/impl/HistoryScreen.kt b/Prezel/feature/history/impl/src/main/java/com/team/prezel/feature/history/impl/HistoryScreen.kt index beb12b2d..8fb04ef6 100644 --- a/Prezel/feature/history/impl/src/main/java/com/team/prezel/feature/history/impl/HistoryScreen.kt +++ b/Prezel/feature/history/impl/src/main/java/com/team/prezel/feature/history/impl/HistoryScreen.kt @@ -134,7 +134,7 @@ private fun HistoryContent( PrezelTabsPager( tabs = tabs, pagerState = pagerState, - size = PrezelTabSize.MEDIUM, + size = PrezelTabSize.SMALL, modifier = Modifier .fillMaxSize() .background(PrezelTheme.colors.bgRegular), diff --git a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/accuracydetail/AccuracyDetailScreen.kt b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/accuracydetail/AccuracyDetailScreen.kt index 7d3a2f17..2c2e483f 100644 --- a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/accuracydetail/AccuracyDetailScreen.kt +++ b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/accuracydetail/AccuracyDetailScreen.kt @@ -310,7 +310,7 @@ private fun AccuracyDetailScaffold( PrezelTabs( tabs = tabLabels, pagerState = pagerState, - size = PrezelTabSize.MEDIUM, + size = PrezelTabSize.SMALL, onClickTab = onClickTab, ) ScriptDetailList(