From 238547b08e3ddc6d3ca409245c97f71a2d5ea617 Mon Sep 17 00:00:00 2001 From: Ham BeomJoon Date: Mon, 25 May 2026 00:49:32 +0900 Subject: [PATCH 01/15] =?UTF-8?q?feat:=20=EB=B6=84=EC=84=9D=20=ED=94=8C?= =?UTF-8?q?=EB=A1=9C=EC=9A=B0=20=EB=82=B4=20=EC=9D=8C=EC=84=B1=20=EB=85=B9?= =?UTF-8?q?=EC=9D=8C=20=EA=B8=B0=EB=8A=A5=20=EB=B0=8F=20=EC=9D=BC=EC=8B=9C?= =?UTF-8?q?=EC=A0=95=EC=A7=80=20=EB=A1=9C=EC=A7=81=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 분석 플로우의 새로운 단계로 `VOICE_RECORDING` 단계 추가 - 음성 녹음 화면(`VoiceRecordingScreen`) 및 녹음 권한 핸들러 구현 - `AudioSessionState`에 `PausedRecording` 상태 추가 및 관련 일시정지/재개 로직 구현 - `RecordingAudioController` 및 `MediaRecorderSession`에 녹음 일시정지(`pause`), 재개(`resume`) 기능 추가 - 분석 데이터 제출 시 녹음된 파일 경로(`recordingFilePath`)를 포함하도록 개선 - 녹음 상태 관련 다국어 문자열 및 UI 에러 메시지 정의 - 연습 녹음 기능(`PracticeRecordingViewModel`)에 일시정지 상태 대응 로직 반영 --- .../prezel/core/audio/AudioRecorderSession.kt | 4 + .../prezel/core/audio/AudioSessionState.kt | 4 + .../prezel/core/audio/MediaRecorderSession.kt | 10 + .../audio/MediaRecordingAudioController.kt | 37 +- .../core/audio/RecordingAudioController.kt | 4 + Prezel/feature/analysis/impl/build.gradle.kts | 2 + .../analysis/impl/AnalysisFlowViewModel.kt | 116 ++- .../feature/analysis/impl/AnalysisScreen.kt | 53 ++ .../impl/contract/AnalysisFlowUiIntent.kt | 6 + .../impl/contract/AnalysisFlowUiState.kt | 19 +- .../analysis/impl/model/AnalysisUiMessage.kt | 5 + .../impl/recording/RecordAudioPermission.kt | 39 + .../impl/recording/VoiceRecordingScreen.kt | 841 ++++++++++++++++++ .../impl/src/main/res/values/strings.xml | 12 + .../recording/PracticeRecordingViewModel.kt | 1 + .../impl/recording/RecordAudioPermission.kt | 1 + .../component/PracticeRecordingContent.kt | 1 + .../component/PracticeRecordingControl.kt | 8 +- .../contract/PracticeRecordingUiState.kt | 2 + 19 files changed, 1134 insertions(+), 31 deletions(-) create mode 100644 Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/recording/RecordAudioPermission.kt create mode 100644 Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/recording/VoiceRecordingScreen.kt diff --git a/Prezel/core/audio/src/main/java/com/team/prezel/core/audio/AudioRecorderSession.kt b/Prezel/core/audio/src/main/java/com/team/prezel/core/audio/AudioRecorderSession.kt index dd05183f..8d539c4c 100644 --- a/Prezel/core/audio/src/main/java/com/team/prezel/core/audio/AudioRecorderSession.kt +++ b/Prezel/core/audio/src/main/java/com/team/prezel/core/audio/AudioRecorderSession.kt @@ -3,6 +3,10 @@ package com.team.prezel.core.audio internal interface AudioRecorderSession { fun start(): Result + fun pause(): Result + + fun resume(): Result + fun stop(elapsedSeconds: Int): Result fun reset() diff --git a/Prezel/core/audio/src/main/java/com/team/prezel/core/audio/AudioSessionState.kt b/Prezel/core/audio/src/main/java/com/team/prezel/core/audio/AudioSessionState.kt index 4f47208c..e4f5969c 100644 --- a/Prezel/core/audio/src/main/java/com/team/prezel/core/audio/AudioSessionState.kt +++ b/Prezel/core/audio/src/main/java/com/team/prezel/core/audio/AudioSessionState.kt @@ -10,6 +10,10 @@ sealed interface AudioSessionState { val elapsedSeconds: Int, ) : AudioSessionState + data class PausedRecording( + val elapsedSeconds: Int, + ) : AudioSessionState + data class ReadyToPlay( val source: AudioSource, val positionSeconds: Int = 0, diff --git a/Prezel/core/audio/src/main/java/com/team/prezel/core/audio/MediaRecorderSession.kt b/Prezel/core/audio/src/main/java/com/team/prezel/core/audio/MediaRecorderSession.kt index e715d3ea..285f5d11 100644 --- a/Prezel/core/audio/src/main/java/com/team/prezel/core/audio/MediaRecorderSession.kt +++ b/Prezel/core/audio/src/main/java/com/team/prezel/core/audio/MediaRecorderSession.kt @@ -41,6 +41,16 @@ internal class MediaRecorderSession @Inject constructor( reset() } + override fun pause(): Result = + runCatching { + recorder!!.pause() + } + + override fun resume(): Result = + runCatching { + recorder!!.resume() + } + override fun stop(elapsedSeconds: Int): Result = runCatching { val file = currentAudioFile!! diff --git a/Prezel/core/audio/src/main/java/com/team/prezel/core/audio/MediaRecordingAudioController.kt b/Prezel/core/audio/src/main/java/com/team/prezel/core/audio/MediaRecordingAudioController.kt index 4e74fdda..e7bbfd26 100644 --- a/Prezel/core/audio/src/main/java/com/team/prezel/core/audio/MediaRecordingAudioController.kt +++ b/Prezel/core/audio/src/main/java/com/team/prezel/core/audio/MediaRecordingAudioController.kt @@ -47,6 +47,7 @@ internal class MediaRecordingAudioController @Inject constructor( override fun stopRecording() { val elapsedSeconds = when (val state = audioSessionState.value) { is AudioSessionState.Recording -> state.elapsedSeconds + is AudioSessionState.PausedRecording -> state.elapsedSeconds else -> return } @@ -64,6 +65,38 @@ internal class MediaRecordingAudioController @Inject constructor( } } + override fun pauseRecording() { + val elapsedSeconds = when (val state = audioSessionState.value) { + is AudioSessionState.Recording -> state.elapsedSeconds + else -> return + } + + recorderSession + .pause() + .onSuccess { + recordingTimerJob?.cancel() + _audioSessionState.value = AudioSessionState.PausedRecording(elapsedSeconds = elapsedSeconds) + }.onFailure { + emitEffect(AudioSessionEffect.RecordingStopFailed) + } + } + + override fun resumeRecording() { + val elapsedSeconds = when (val state = audioSessionState.value) { + is AudioSessionState.PausedRecording -> state.elapsedSeconds + else -> return + } + + recorderSession + .resume() + .onSuccess { + _audioSessionState.value = AudioSessionState.Recording(elapsedSeconds = elapsedSeconds) + startRecordingTimer() + }.onFailure { + emitEffect(AudioSessionEffect.RecordingStartFailed) + } + } + override fun startPlayback() { when (val state = audioSessionState.value) { is AudioSessionState.ReadyToPlay -> startPlayback( @@ -185,7 +218,7 @@ internal class MediaRecordingAudioController @Inject constructor( AudioSessionState.Playing( source = state.source, - positionSeconds = playerSession.currentPositionSeconds(), + positionSeconds = (state.positionSeconds + 1).coerceAtMost(state.durationSeconds), durationSeconds = state.durationSeconds, ) } @@ -204,6 +237,6 @@ internal class MediaRecordingAudioController @Inject constructor( private companion object { const val RECORDING_TIMER_DELAY_MILLIS = 1_000L - const val PLAYBACK_TIMER_DELAY_MILLIS = 250L + const val PLAYBACK_TIMER_DELAY_MILLIS = 1_000L } } diff --git a/Prezel/core/audio/src/main/java/com/team/prezel/core/audio/RecordingAudioController.kt b/Prezel/core/audio/src/main/java/com/team/prezel/core/audio/RecordingAudioController.kt index 6d7377c2..1f7e2bd2 100644 --- a/Prezel/core/audio/src/main/java/com/team/prezel/core/audio/RecordingAudioController.kt +++ b/Prezel/core/audio/src/main/java/com/team/prezel/core/audio/RecordingAudioController.kt @@ -10,6 +10,10 @@ interface RecordingAudioController { fun startRecording() + fun pauseRecording() + + fun resumeRecording() + fun stopRecording() fun startPlayback() diff --git a/Prezel/feature/analysis/impl/build.gradle.kts b/Prezel/feature/analysis/impl/build.gradle.kts index 0cf92b9b..f9d43533 100644 --- a/Prezel/feature/analysis/impl/build.gradle.kts +++ b/Prezel/feature/analysis/impl/build.gradle.kts @@ -7,6 +7,8 @@ android { } dependencies { + implementation(projects.coreAudio) + implementation(projects.coreCommon) implementation(projects.coreDomain) implementation(projects.coreModel) implementation(projects.featureAnalysisApi) 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 4904267c..9be798c3 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,6 +1,9 @@ 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.practice.AnalyzePresentationRecordingUseCase import com.team.prezel.core.model.presentation.Audience import com.team.prezel.core.model.presentation.Category @@ -17,6 +20,8 @@ import com.team.prezel.feature.analysis.impl.contract.AnalysisForm import com.team.prezel.feature.analysis.impl.contract.AnalysisSituationOption 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.contract.recordingFilePath +import com.team.prezel.feature.analysis.impl.model.AnalysisUiMessage import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Job import kotlinx.coroutines.launch @@ -27,9 +32,15 @@ import javax.inject.Inject internal class AnalysisFlowViewModel @Inject constructor( private val analyzePresentationRecordingUseCase: AnalyzePresentationRecordingUseCase, private val analysisFileCache: AnalysisFileCache, + private val audioController: RecordingAudioController, ) : BaseViewModel(AnalysisFlowUiState()) { private var analyzeJob: Job? = null + init { + collectAudioSessionState() + collectAudioSessionEffect() + } + override fun onIntent(intent: AnalysisFlowUiIntent) { when (intent) { is AnalysisFlowUiIntent.UpdatePresentationTitle -> updateForm { copy(presentationTitle = intent.title) } @@ -39,6 +50,9 @@ internal class AnalysisFlowViewModel @Inject constructor( is AnalysisFlowUiIntent.UpdateScript -> updateForm { copy(script = intent.script) } is AnalysisFlowUiIntent.SelectScriptFile -> updateForm { copy(scriptFileUri = intent.fileUri) } is AnalysisFlowUiIntent.SelectAudioFile -> updateForm { copy(audioFileUri = intent.fileUri) } + AnalysisFlowUiIntent.ClickRecordingControl -> handleRecordingControlClick() + AnalysisFlowUiIntent.StopRecording -> audioController.stopRecording() + AnalysisFlowUiIntent.ResetRecording -> audioController.reset() is AnalysisFlowUiIntent.RetryFileUpload -> retryFileUpload(intent.uploadType) AnalysisFlowUiIntent.Next -> moveNext() AnalysisFlowUiIntent.SkipScript -> skipScript() @@ -60,7 +74,7 @@ internal class AnalysisFlowViewModel @Inject constructor( private fun moveNext() { if (!currentState.canMoveNext) return - if (currentState.step == AnalysisFlowStep.AUDIO_UPLOAD) { + if (currentState.step == AnalysisFlowStep.VOICE_RECORDING || currentState.step == AnalysisFlowStep.AUDIO_UPLOAD) { analyzePresentation() return } @@ -70,8 +84,9 @@ internal class AnalysisFlowViewModel @Inject constructor( step = when (step) { AnalysisFlowStep.PRESENTATION_SCHEDULE -> AnalysisFlowStep.PRESENTATION_SITUATION AnalysisFlowStep.PRESENTATION_SITUATION -> AnalysisFlowStep.SCRIPT_INPUT - AnalysisFlowStep.SCRIPT_INPUT -> AnalysisFlowStep.AUDIO_UPLOAD + AnalysisFlowStep.SCRIPT_INPUT -> AnalysisFlowStep.VOICE_RECORDING AnalysisFlowStep.AUDIO_UPLOAD, + AnalysisFlowStep.VOICE_RECORDING, AnalysisFlowStep.ANALYZING, AnalysisFlowStep.REPORT, AnalysisFlowStep.FILE_RECOGNITION_FAILED, @@ -83,7 +98,7 @@ internal class AnalysisFlowViewModel @Inject constructor( } private fun analyzePresentation() { - val submission = currentState.form.toPresentationAnalysisSubmissionOrNull() ?: return + val submission = currentState.toPresentationAnalysisSubmissionOrNull() ?: return updateState { copy(step = AnalysisFlowStep.ANALYZING) } @@ -101,10 +116,15 @@ internal class AnalysisFlowViewModel @Inject constructor( private suspend fun PresentationAnalysisSubmission.analyzePresentationRecording(): Result = runCatching { - val audioFile = analysisFileCache.copyUriToCache( - uriString = audioFileUri, - prefix = "audio", - ) + val audioFilePath = audioFileUri + ?.let { uri -> + val audioFile = analysisFileCache.copyUriToCache( + uriString = uri, + prefix = "audio", + ) + audioFile.absolutePath + } + ?: recordingFilePath val scriptFile = scriptFileUri?.let { uri -> analysisFileCache.copyUriToCache( uriString = uri, @@ -121,7 +141,7 @@ internal class AnalysisFlowViewModel @Inject constructor( audience = audience, script = script, scriptFilePath = scriptFile?.absolutePath, - audioFilePath = audioFile.absolutePath, + audioFilePath = audioFilePath, ).getOrThrow() } @@ -149,7 +169,7 @@ internal class AnalysisFlowViewModel @Inject constructor( is AnalysisFailureAction.ShowMessage -> { viewModelScope.launch { sendEffect(AnalysisFlowUiEffect.ShowMessage(action.message)) } - updateState { copy(step = AnalysisFlowStep.AUDIO_UPLOAD) } + updateState { copy(step = AnalysisFlowStep.VOICE_RECORDING) } } } } @@ -164,10 +184,11 @@ internal class AnalysisFlowViewModel @Inject constructor( private fun retryAudioUpload() { updateState { copy( - step = AnalysisFlowStep.AUDIO_UPLOAD, + step = AnalysisFlowStep.VOICE_RECORDING, form = form.copy(audioFileUri = null), ) } + audioController.reset() } private fun retryScriptFileUpload() { @@ -185,7 +206,7 @@ internal class AnalysisFlowViewModel @Inject constructor( private fun skipScript() { if (currentState.step != AnalysisFlowStep.SCRIPT_INPUT) return - updateState { copy(step = AnalysisFlowStep.AUDIO_UPLOAD) } + updateState { copy(step = AnalysisFlowStep.VOICE_RECORDING) } } private fun moveBack() { @@ -196,9 +217,10 @@ internal class AnalysisFlowViewModel @Inject constructor( AnalysisFlowStep.PRESENTATION_SITUATION -> AnalysisFlowStep.PRESENTATION_SCHEDULE AnalysisFlowStep.SCRIPT_INPUT -> AnalysisFlowStep.PRESENTATION_SITUATION AnalysisFlowStep.AUDIO_UPLOAD -> AnalysisFlowStep.SCRIPT_INPUT - AnalysisFlowStep.ANALYZING -> AnalysisFlowStep.AUDIO_UPLOAD - AnalysisFlowStep.REPORT -> AnalysisFlowStep.AUDIO_UPLOAD - AnalysisFlowStep.FILE_RECOGNITION_FAILED -> AnalysisFlowStep.AUDIO_UPLOAD + AnalysisFlowStep.VOICE_RECORDING -> AnalysisFlowStep.SCRIPT_INPUT + AnalysisFlowStep.ANALYZING -> AnalysisFlowStep.VOICE_RECORDING + AnalysisFlowStep.REPORT -> AnalysisFlowStep.VOICE_RECORDING + AnalysisFlowStep.FILE_RECOGNITION_FAILED -> AnalysisFlowStep.VOICE_RECORDING AnalysisFlowStep.SCRIPT_FILE_RECOGNITION_FAILED -> AnalysisFlowStep.SCRIPT_INPUT } @@ -212,6 +234,44 @@ internal class AnalysisFlowViewModel @Inject constructor( private fun updateForm(reducer: AnalysisForm.() -> AnalysisForm) { updateState { copy(form = form.reducer()) } } + + private fun handleRecordingControlClick() { + when (currentState.recordingState) { + AudioSessionState.Idle -> audioController.startRecording() + is AudioSessionState.Recording -> audioController.pauseRecording() + is AudioSessionState.PausedRecording -> audioController.resumeRecording() + is AudioSessionState.ReadyToPlay -> audioController.startPlayback() + is AudioSessionState.Playing -> audioController.stopPlayback() + } + } + + private fun collectAudioSessionState() { + viewModelScope.launch { + audioController.audioSessionState.collect { audioState -> + updateState { copy(recordingState = audioState) } + } + } + } + + private fun collectAudioSessionEffect() { + viewModelScope.launch { + audioController.audioSessionEffect.collect { effect -> + sendEffect(AnalysisFlowUiEffect.ShowMessage(effect.toUiMessage())) + } + } + } + + private fun AudioSessionEffect.toUiMessage(): AnalysisUiMessage = + when (this) { + AudioSessionEffect.RecordingStartFailed -> AnalysisUiMessage.RECORDING_START_FAILED + AudioSessionEffect.RecordingStopFailed -> AnalysisUiMessage.RECORDING_STOP_FAILED + AudioSessionEffect.PlaybackStartFailed -> AnalysisUiMessage.PLAYBACK_START_FAILED + } + + override fun onCleared() { + audioController.release() + super.onCleared() + } } private data class PresentationAnalysisSubmission( @@ -223,27 +283,29 @@ private data class PresentationAnalysisSubmission( val audience: Audience, val script: String?, val scriptFileUri: String?, - val audioFileUri: String, + val audioFileUri: String?, + val recordingFilePath: String, ) -private fun AnalysisForm.toPresentationAnalysisSubmissionOrNull(): PresentationAnalysisSubmission? { - val category = category ?: return null - val purpose = purpose ?: return null - val style = style ?: return null - val audience = audience ?: return null - val audioFileUri = audioFileUri ?: return null - val isFileUpload = scriptInputType == ScriptInputType.FILE_UPLOAD +private fun AnalysisFlowUiState.toPresentationAnalysisSubmissionOrNull(): PresentationAnalysisSubmission? { + val category = form.category ?: return null + val purpose = form.purpose ?: return null + val style = form.style ?: return null + val audience = form.audience ?: return null + val recordingFilePath = form.audioFileUri ?: recordingState.recordingFilePath ?: return null + val isFileUpload = form.scriptInputType == ScriptInputType.FILE_UPLOAD return PresentationAnalysisSubmission( - name = presentationTitle.trim(), - date = presentationDate, + name = form.presentationTitle.trim(), + date = form.presentationDate, category = category, purpose = purpose, style = style, audience = audience, - script = script.takeIf { !isFileUpload && it.isNotBlank() }, - scriptFileUri = scriptFileUri.takeIf { isFileUpload && !it.isNullOrBlank() }, - audioFileUri = audioFileUri, + script = form.script.takeIf { !isFileUpload && it.isNotBlank() }, + scriptFileUri = form.scriptFileUri.takeIf { isFileUpload && !it.isNullOrBlank() }, + audioFileUri = form.audioFileUri, + recordingFilePath = recordingFilePath, ) } 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 a04522b9..41e3d84d 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 @@ -2,6 +2,7 @@ package com.team.prezel.feature.analysis.impl import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.platform.LocalResources import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -14,6 +15,8 @@ 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.VoiceRecordingScreen +import com.team.prezel.feature.analysis.impl.recording.rememberAnalysisRecordAudioPermissionControlClickHandler import com.team.prezel.feature.analysis.impl.result.AnalysisLoadingScreen import com.team.prezel.feature.analysis.impl.result.AnalysisReportScreen import com.team.prezel.feature.analysis.impl.result.FileRecognitionFailedScreen @@ -21,6 +24,7 @@ import com.team.prezel.feature.analysis.impl.result.ScriptFileRecognitionFailedS import com.team.prezel.feature.analysis.impl.schedule.PresentationScheduleScreen import com.team.prezel.feature.analysis.impl.script.ScriptInputScreen import com.team.prezel.feature.analysis.impl.situation.PresentationSituationScreen +import kotlinx.coroutines.launch @Composable internal fun AnalysisScreen( @@ -41,6 +45,15 @@ internal fun AnalysisScreen( AnalysisUiMessage.ANALYSIS_FAILED -> R.string.feature_analysis_impl_error_analysis_failed AnalysisUiMessage.NETWORK_FAILED -> R.string.feature_analysis_impl_error_network_failed AnalysisUiMessage.UNKNOWN_FAILED -> R.string.feature_analysis_impl_error_unknown_failed + AnalysisUiMessage.RECORD_AUDIO_PERMISSION_DENIED -> + R.string.feature_analysis_impl_voice_recording_permission_denied + + AnalysisUiMessage.RECORD_AUDIO_PERMISSION_PERMANENTLY_DENIED -> + R.string.feature_analysis_impl_voice_recording_permission_permanently_denied + + AnalysisUiMessage.RECORDING_START_FAILED -> R.string.feature_analysis_impl_voice_recording_failed + AnalysisUiMessage.RECORDING_STOP_FAILED -> R.string.feature_analysis_impl_voice_recording_stop_failed + AnalysisUiMessage.PLAYBACK_START_FAILED -> R.string.feature_analysis_impl_voice_recording_playback_failed } snackbarHostState.showPrezelSnackbar(message = resources.getString(resId)) } @@ -59,6 +72,37 @@ private fun AnalysisScreen( uiState: AnalysisFlowUiState, onIntent: (AnalysisFlowUiIntent) -> Unit, ) { + val resources = LocalResources.current + val snackbarHostState = LocalSnackbarHostState.current + val coroutineScope = rememberCoroutineScope() + + LaunchedEffect(uiState.step) { + if (uiState.step == AnalysisFlowStep.VOICE_RECORDING) { + snackbarHostState.showPrezelSnackbar( + message = resources.getString(R.string.feature_analysis_impl_voice_recording_guide), + ) + } + } + + val onClickRecordingControl = rememberAnalysisRecordAudioPermissionControlClickHandler( + recordingState = uiState.recordingState, + onClickRecordingControl = { onIntent(AnalysisFlowUiIntent.ClickRecordingControl) }, + onPermissionDenied = { + coroutineScope.launch { + snackbarHostState.showPrezelSnackbar( + message = resources.getString(R.string.feature_analysis_impl_voice_recording_permission_denied), + ) + } + }, + onPermissionPermanentlyDenied = { + coroutineScope.launch { + snackbarHostState.showPrezelSnackbar( + message = resources.getString(R.string.feature_analysis_impl_voice_recording_permission_permanently_denied), + ) + } + }, + ) + when (uiState.step) { AnalysisFlowStep.PRESENTATION_SCHEDULE -> PresentationScheduleScreen( uiState = uiState, @@ -95,6 +139,15 @@ private fun AnalysisScreen( onBack = { onIntent(AnalysisFlowUiIntent.Back) }, ) + AnalysisFlowStep.VOICE_RECORDING -> VoiceRecordingScreen( + uiState = uiState, + onClickRecordingControl = onClickRecordingControl, + onStopRecording = { onIntent(AnalysisFlowUiIntent.StopRecording) }, + onResetRecording = { onIntent(AnalysisFlowUiIntent.ResetRecording) }, + onAnalyze = { onIntent(AnalysisFlowUiIntent.Next) }, + onBack = { onIntent(AnalysisFlowUiIntent.Back) }, + ) + AnalysisFlowStep.ANALYZING -> AnalysisLoadingScreen() AnalysisFlowStep.REPORT -> AnalysisReportScreen() 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 e60b5f9a..a7602a57 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 @@ -35,6 +35,12 @@ internal sealed interface AnalysisFlowUiIntent : UiIntent { val fileUri: String?, ) : AnalysisFlowUiIntent + data object ClickRecordingControl : AnalysisFlowUiIntent + + data object StopRecording : AnalysisFlowUiIntent + + data object ResetRecording : AnalysisFlowUiIntent + data object Next : AnalysisFlowUiIntent data class RetryFileUpload( diff --git a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/contract/AnalysisFlowUiState.kt b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/contract/AnalysisFlowUiState.kt index 737ccb0a..c57df194 100644 --- a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/contract/AnalysisFlowUiState.kt +++ b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/contract/AnalysisFlowUiState.kt @@ -1,6 +1,7 @@ package com.team.prezel.feature.analysis.impl.contract import androidx.compose.runtime.Immutable +import com.team.prezel.core.audio.AudioSessionState import com.team.prezel.core.model.presentation.Audience import com.team.prezel.core.model.presentation.Category import com.team.prezel.core.model.presentation.PresentationRecordingAnalysisResult @@ -12,6 +13,7 @@ import com.team.prezel.core.ui.base.UiState internal data class AnalysisFlowUiState( val step: AnalysisFlowStep = AnalysisFlowStep.PRESENTATION_SCHEDULE, val form: AnalysisForm = AnalysisForm(), + val recordingState: AudioSessionState = AudioSessionState.Idle, val analysisResult: PresentationRecordingAnalysisResult? = null, ) : UiState { val progress: Float @@ -22,7 +24,10 @@ internal data class AnalysisFlowUiState( AnalysisFlowStep.SCRIPT_FILE_RECOGNITION_FAILED, -> 0.5f - AnalysisFlowStep.AUDIO_UPLOAD -> 0.75f + AnalysisFlowStep.AUDIO_UPLOAD, + AnalysisFlowStep.VOICE_RECORDING, + -> 0.75f + AnalysisFlowStep.ANALYZING, AnalysisFlowStep.REPORT, AnalysisFlowStep.FILE_RECOGNITION_FAILED, @@ -44,6 +49,7 @@ internal data class AnalysisFlowUiState( } AnalysisFlowStep.AUDIO_UPLOAD -> !form.audioFileUri.isNullOrBlank() + AnalysisFlowStep.VOICE_RECORDING -> recordingState.recordingFilePath != null AnalysisFlowStep.ANALYZING, AnalysisFlowStep.REPORT, AnalysisFlowStep.FILE_RECOGNITION_FAILED, @@ -76,8 +82,19 @@ internal enum class AnalysisFlowStep { PRESENTATION_SITUATION, SCRIPT_INPUT, AUDIO_UPLOAD, + VOICE_RECORDING, ANALYZING, REPORT, FILE_RECOGNITION_FAILED, SCRIPT_FILE_RECOGNITION_FAILED, } + +internal val AudioSessionState.recordingFilePath: String? + get() = when (this) { + is AudioSessionState.ReadyToPlay -> source.filePath + is AudioSessionState.Playing -> source.filePath + AudioSessionState.Idle, + is AudioSessionState.Recording, + is AudioSessionState.PausedRecording, + -> null + } diff --git a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/model/AnalysisUiMessage.kt b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/model/AnalysisUiMessage.kt index 6202fea0..db033a82 100644 --- a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/model/AnalysisUiMessage.kt +++ b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/model/AnalysisUiMessage.kt @@ -5,4 +5,9 @@ internal enum class AnalysisUiMessage { ANALYSIS_FAILED, NETWORK_FAILED, UNKNOWN_FAILED, + RECORD_AUDIO_PERMISSION_DENIED, + RECORD_AUDIO_PERMISSION_PERMANENTLY_DENIED, + RECORDING_START_FAILED, + RECORDING_STOP_FAILED, + PLAYBACK_START_FAILED, } diff --git a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/recording/RecordAudioPermission.kt b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/recording/RecordAudioPermission.kt new file mode 100644 index 00000000..ef68ffe3 --- /dev/null +++ b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/recording/RecordAudioPermission.kt @@ -0,0 +1,39 @@ +package com.team.prezel.feature.analysis.impl.recording + +import android.Manifest +import androidx.compose.runtime.Composable +import com.team.prezel.core.audio.AudioSessionState +import com.team.prezel.core.ui.util.rememberPermissionRequest + +@Composable +internal fun rememberAnalysisRecordAudioPermissionControlClickHandler( + recordingState: AudioSessionState, + onClickRecordingControl: () -> Unit, + onPermissionDenied: () -> Unit, + onPermissionPermanentlyDenied: () -> Unit, +): () -> Unit { + val permissionRequest = rememberPermissionRequest( + permission = Manifest.permission.RECORD_AUDIO, + onPermissionGranted = onClickRecordingControl, + onPermissionDenied = onPermissionDenied, + onPermissionPermanentlyDenied = onPermissionPermanentlyDenied, + ) + + return { + when (recordingState) { + AudioSessionState.Idle -> { + when { + permissionRequest.isGranted -> onClickRecordingControl() + permissionRequest.isPermanentlyDenied -> permissionRequest.onPermanentlyDenied() + else -> permissionRequest.launch() + } + } + + is AudioSessionState.Recording, + is AudioSessionState.PausedRecording, + is AudioSessionState.ReadyToPlay, + is AudioSessionState.Playing, + -> onClickRecordingControl() + } + } +} 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 new file mode 100644 index 00000000..cd83308b --- /dev/null +++ b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/recording/VoiceRecordingScreen.kt @@ -0,0 +1,841 @@ +package com.team.prezel.feature.analysis.impl.recording + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.background +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.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.statusBarsPadding +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.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.dp +import com.team.prezel.core.audio.AudioSessionState +import com.team.prezel.core.audio.AudioSource +import com.team.prezel.core.common.event.EdgeToEdgeStatusBarStyle +import com.team.prezel.core.common.event.GlobalEvent +import com.team.prezel.core.common.event.GlobalEventBus +import com.team.prezel.core.designsystem.component.actions.area.PrezelButtonArea +import com.team.prezel.core.designsystem.component.actions.button.PrezelButton +import com.team.prezel.core.designsystem.component.actions.button.PrezelIconButton +import com.team.prezel.core.designsystem.component.actions.button.config.ButtonHierarchy +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.actions.button.config.PrezelButtonDefaults +import com.team.prezel.core.designsystem.icon.PrezelIcons +import com.team.prezel.core.designsystem.preview.BasicPreview +import com.team.prezel.core.designsystem.theme.PrezelTheme +import com.team.prezel.feature.analysis.impl.R +import com.team.prezel.feature.analysis.impl.contract.AnalysisFlowStep +import com.team.prezel.feature.analysis.impl.contract.AnalysisFlowUiState +import com.team.prezel.feature.analysis.impl.contract.AnalysisForm +import dagger.hilt.EntryPoint +import dagger.hilt.InstallIn +import dagger.hilt.android.EntryPointAccessors +import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.delay + +@Composable +internal fun VoiceRecordingScreen( + uiState: AnalysisFlowUiState, + onClickRecordingControl: () -> Unit, + onStopRecording: () -> Unit, + onResetRecording: () -> Unit, + onAnalyze: () -> Unit, + onBack: () -> Unit, +) { + VoiceRecordingScreen( + script = uiState.form.script, + recordingState = uiState.recordingState, + analyzeEnabled = uiState.canMoveNext, + onClickRecordingControl = onClickRecordingControl, + onStopRecording = onStopRecording, + onResetRecording = onResetRecording, + onAnalyze = onAnalyze, + onBack = onBack, + ) +} + +@Composable +private fun VoiceRecordingScreen( + script: String, + recordingState: AudioSessionState, + analyzeEnabled: Boolean, + onClickRecordingControl: () -> Unit, + onStopRecording: () -> Unit, + onResetRecording: () -> Unit, + onAnalyze: () -> Unit, + onBack: () -> Unit, +) { + VoiceRecordingStatusBarStyle( + style = if (recordingState.isCompleted) { + EdgeToEdgeStatusBarStyle.BG_REGULAR + } else { + EdgeToEdgeStatusBarStyle.BG_MEDIUM + }, + ) + + Column( + modifier = Modifier + .fillMaxSize() + .background(PrezelTheme.colors.bgRegular), + ) { + if (recordingState.isCompleted) { + VoiceRecordingCompletedTopBar(onBack = onBack) + } else { + VoiceRecordingHeader( + recordingState = recordingState, + onBack = onBack, + ) + } + + VoiceRecordingContent( + script = script, + recordingState = recordingState, + onClickRecordingControl = onClickRecordingControl, + modifier = Modifier.weight(1f), + ) + + VoiceRecordingButtonArea( + recordingState = recordingState, + analyzeEnabled = analyzeEnabled, + onClickRecordingControl = onClickRecordingControl, + onStopRecording = onStopRecording, + onResetRecording = onResetRecording, + onAnalyze = onAnalyze, + ) + } +} + +@Composable +private fun VoiceRecordingStatusBarStyle(style: EdgeToEdgeStatusBarStyle) { + if (LocalInspectionMode.current) return + + val globalEventBus = rememberGlobalEventBus() + + LaunchedEffect(globalEventBus, style) { + globalEventBus.emit(GlobalEvent.ChangeEdgeToEdgeStatusBarStyle(style)) + } + + DisposableEffect(globalEventBus) { + onDispose { + globalEventBus.tryEmit(GlobalEvent.ResetEdgeToEdgeStatusBarStyle) + } + } +} + +@Composable +private fun rememberGlobalEventBus(): GlobalEventBus { + val applicationContext = LocalContext.current.applicationContext + + return remember(applicationContext) { + EntryPointAccessors + .fromApplication( + applicationContext, + VoiceRecordingGlobalEventBusEntryPoint::class.java, + ).globalEventBus() + } +} + +@Composable +private fun VoiceRecordingCompletedTopBar(onBack: () -> Unit) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(64.dp) + .background(PrezelTheme.colors.bgRegular), + ) { + VoiceRecordingCloseButton( + onBack = onBack, + modifier = Modifier + .align(Alignment.TopEnd) + .statusBarsPadding() + .padding(top = PrezelTheme.spacing.V4, end = PrezelTheme.spacing.V8), + ) + } +} + +@Composable +private fun VoiceRecordingHeader( + recordingState: AudioSessionState, + onBack: () -> Unit, +) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(252.dp) + .background(PrezelTheme.colors.bgMedium), + ) { + VoiceRecordingCloseButton( + onBack = onBack, + modifier = Modifier + .align(Alignment.TopEnd) + .padding(top = PrezelTheme.spacing.V4, end = PrezelTheme.spacing.V8), + ) + + Text( + text = stringResource(recordingState.titleResId), + color = PrezelTheme.colors.interactiveRegular, + style = PrezelTheme.typography.title1Bold, + modifier = Modifier.align(Alignment.Center), + textAlign = TextAlign.Center, + ) + } +} + +@Composable +private fun VoiceRecordingCloseButton( + onBack: () -> Unit, + modifier: Modifier = Modifier, +) { + IconButton( + modifier = modifier.size(48.dp), + onClick = onBack, + ) { + Icon( + painter = painterResource(PrezelIcons.Cancel), + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = PrezelTheme.colors.iconRegular, + ) + } +} + +@Composable +private fun VoiceRecordingContent( + script: String, + recordingState: AudioSessionState, + onClickRecordingControl: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxWidth() + .background(PrezelTheme.colors.bgRegular) + .padding(vertical = PrezelTheme.spacing.V16), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + if (!recordingState.isCompleted) { + VoiceRecordingScriptHeader() + Spacer(modifier = Modifier.height(PrezelTheme.spacing.V16)) + } + + VoiceRecordingScriptBody( + script = script, + modifier = Modifier.weight(1f), + ) + + if (recordingState !is AudioSessionState.Idle) { + VoiceRecordingStatusArea( + recordingState = recordingState, + onClickRecordingControl = onClickRecordingControl, + ) + } + } +} + +@Composable +private fun VoiceRecordingScriptHeader() { + Box( + modifier = Modifier + .fillMaxWidth() + .height(20.dp) + .padding(horizontal = PrezelTheme.spacing.V20), + ) { + Text( + text = stringResource(R.string.feature_analysis_impl_voice_recording_script_label), + color = PrezelTheme.colors.textMedium, + style = PrezelTheme.typography.body3Medium, + modifier = Modifier.align(Alignment.CenterStart), + ) + + ScriptZoomButton( + modifier = Modifier + .align(Alignment.CenterEnd) + .size(48.dp), + ) + } +} + +@Composable +private fun VoiceRecordingScriptBody( + script: String, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = PrezelTheme.spacing.V20), + ) { + 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, + modifier = Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()), + ) + + VoiceRecordingScriptGradient( + modifier = Modifier.align(Alignment.TopCenter), + brush = Brush.verticalGradient( + colors = listOf(backgroundColor, Color.Transparent), + ), + ) + VoiceRecordingScriptGradient( + modifier = Modifier.align(Alignment.BottomCenter), + brush = Brush.verticalGradient( + colors = listOf(Color.Transparent, backgroundColor), + ), + ) + } +} + +@Composable +private fun VoiceRecordingScriptGradient( + brush: Brush, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier + .fillMaxWidth() + .height(20.dp) + .background(brush), + ) +} + +@Composable +private fun VoiceRecordingStatusArea( + recordingState: AudioSessionState, + onClickRecordingControl: () -> Unit, +) { + Spacer(modifier = Modifier.height(PrezelTheme.spacing.V16)) + RecordingWaveform(modifier = Modifier.fillMaxWidth()) + Spacer(modifier = Modifier.height(recordingState.recordingStatusSpacing)) + + if (recordingState.isCompleted) { + RecordingPlayerControl( + recordingState = recordingState, + onClickRecordingControl = onClickRecordingControl, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = PrezelTheme.spacing.V20), + ) + } else { + RecordingTimer( + currentSeconds = recordingState.currentSeconds, + totalSeconds = recordingState.totalSeconds, + recordingState = recordingState, + modifier = Modifier.fillMaxWidth(), + ) + } +} + +@Composable +private fun ScriptZoomButton(modifier: Modifier = Modifier) { + IconButton( + modifier = modifier, + onClick = {}, + ) { + Icon( + painter = painterResource(PrezelIcons.ZoomIn), + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = PrezelTheme.colors.iconRegular, + ) + } +} + +@Composable +private fun RecordingWaveform(modifier: Modifier = Modifier) { + Spacer( + modifier = modifier + .height(60.dp) + .background(color = PrezelTheme.colors.bgLarge), + ) +} + +@Composable +private fun RecordingTimer( + currentSeconds: Int, + totalSeconds: Int, + recordingState: AudioSessionState, + modifier: Modifier = Modifier, +) { + val timerText = when (recordingState) { + is AudioSessionState.ReadyToPlay -> buildAnnotatedString { + withStyle(SpanStyle(color = PrezelTheme.colors.textLarge)) { + append(totalSeconds.toTimerText()) + } + } + + is AudioSessionState.Playing -> { + buildAnnotatedString { + withStyle(SpanStyle(color = PrezelTheme.colors.interactiveRegular)) { + append(currentSeconds.toTimerText()) + } + withStyle(SpanStyle(color = PrezelTheme.colors.textSmall)) { + append("/") + append(totalSeconds.toTimerText()) + } + } + } + + else -> buildAnnotatedString { + withStyle( + SpanStyle( + color = if (recordingState is AudioSessionState.PausedRecording) { + PrezelTheme.colors.textDisabled + } else { + PrezelTheme.colors.textMedium + }, + ), + ) { + append(currentSeconds.toTimerText()) + } + } + } + + Text( + text = timerText, + modifier = modifier, + color = PrezelTheme.colors.textMedium, + textAlign = if (recordingState.isCompleted) TextAlign.Start else TextAlign.Center, + style = PrezelTheme.typography.title1Medium, + ) +} + +@Composable +private fun RecordingPlayerControl( + recordingState: AudioSessionState, + onClickRecordingControl: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + ) { + RecordingTimer( + currentSeconds = recordingState.currentSeconds, + totalSeconds = recordingState.totalSeconds, + recordingState = recordingState, + modifier = Modifier.weight(1f), + ) + + RecordingRoundIconButton( + iconResId = recordingState.actionIconResId, + iconColor = recordingState.actionIconColor, + onClick = onClickRecordingControl, + ) + } +} + +@Composable +private fun VoiceRecordingButtonArea( + recordingState: AudioSessionState, + analyzeEnabled: Boolean, + onClickRecordingControl: () -> Unit, + onStopRecording: () -> Unit, + onResetRecording: () -> Unit, + onAnalyze: () -> Unit, +) { + when (recordingState) { + AudioSessionState.Idle -> IdleRecordingButtonArea( + recordingState = recordingState, + onClickRecordingControl = onClickRecordingControl, + ) + + is AudioSessionState.Recording, + is AudioSessionState.PausedRecording, + -> ActiveRecordingButtonArea( + recordingState = recordingState, + onClickRecordingControl = onClickRecordingControl, + onStopRecording = onStopRecording, + ) + + is AudioSessionState.ReadyToPlay, + is AudioSessionState.Playing, + -> CompletedRecordingButtonArea( + analyzeEnabled = analyzeEnabled, + onResetRecording = onResetRecording, + onAnalyze = onAnalyze, + ) + } +} + +@Composable +private fun IdleRecordingButtonArea( + recordingState: AudioSessionState, + onClickRecordingControl: () -> Unit, +) { + PrezelButtonArea( + showBackground = true, + mainButton = { buttonModifier -> + RecordingIconButton( + iconResId = recordingState.actionIconResId, + iconColor = recordingState.actionIconColor, + modifier = buttonModifier, + onClick = onClickRecordingControl, + ) + }, + ) +} + +@Composable +private fun ActiveRecordingButtonArea( + recordingState: AudioSessionState, + onClickRecordingControl: () -> Unit, + onStopRecording: () -> Unit, +) { + PrezelButtonArea( + showBackground = true, + isVertical = false, + isStrongStrength = false, + mainButton = { buttonModifier -> + RecordingIconButton( + iconResId = PrezelIcons.Stop, + iconColor = PrezelTheme.colors.iconRegular, + modifier = buttonModifier, + onClick = onStopRecording, + ) + }, + subButton = { buttonModifier -> + RecordingIconButton( + iconResId = recordingState.actionIconResId, + iconColor = recordingState.actionIconColor, + modifier = buttonModifier, + onClick = onClickRecordingControl, + ) + }, + ) +} + +@Composable +private fun CompletedRecordingButtonArea( + analyzeEnabled: Boolean, + onResetRecording: () -> Unit, + onAnalyze: () -> Unit, +) { + PrezelButtonArea( + showBackground = true, + isVertical = false, + mainButton = { buttonModifier -> + PrezelButton( + text = stringResource(R.string.feature_analysis_impl_analyze), + modifier = buttonModifier, + enabled = analyzeEnabled, + type = ButtonType.FILLED, + hierarchy = ButtonHierarchy.PRIMARY, + onClick = onAnalyze, + ) + }, + subButton = { buttonModifier -> + RecordingResetButton( + iconResId = PrezelIcons.Reset, + iconColor = PrezelTheme.colors.iconRegular, + modifier = buttonModifier.width(52.dp), + onClick = onResetRecording, + ) + }, + ) +} + +@Composable +private fun RecordingRoundIconButton( + @DrawableRes iconResId: Int, + iconColor: Color, + onClick: () -> Unit, +) { + PrezelIconButton( + iconResId = iconResId, + modifier = Modifier.size(48.dp), + buttonDefault = PrezelButtonDefaults.getDefault( + isIconOnly = true, + isRounded = true, + type = ButtonType.FILLED, + size = ButtonSize.REGULAR, + hierarchy = ButtonHierarchy.SECONDARY, + contentColor = iconColor, + backgroundColor = PrezelTheme.colors.bgLarge, + iconSize = 20.dp, + ), + onClick = onClick, + ) +} + +@Composable +private fun RecordingResetButton( + @DrawableRes iconResId: Int, + iconColor: Color, + modifier: Modifier = Modifier, + onClick: () -> Unit, +) { + PrezelIconButton( + iconResId = iconResId, + modifier = modifier.height(48.dp), + buttonDefault = PrezelButtonDefaults.getDefault( + isIconOnly = true, + isRounded = false, + type = ButtonType.GHOST, + size = ButtonSize.REGULAR, + hierarchy = ButtonHierarchy.SECONDARY, + contentColor = iconColor, + backgroundColor = Color.Transparent, + iconSize = 20.dp, + ), + onClick = onClick, + ) +} + +@Composable +private fun RecordingIconButton( + @DrawableRes iconResId: Int, + iconColor: Color, + modifier: Modifier = Modifier, + onClick: () -> Unit, +) { + PrezelIconButton( + iconResId = iconResId, + modifier = modifier + .height(48.dp) + .clip(RoundedCornerShape(PrezelTheme.radius.V8)), + buttonDefault = PrezelButtonDefaults.getDefault( + isIconOnly = true, + type = ButtonType.FILLED, + size = ButtonSize.REGULAR, + hierarchy = ButtonHierarchy.SECONDARY, + isRounded = false, + contentColor = iconColor, + backgroundColor = PrezelTheme.colors.bgLarge, + iconSize = 20.dp, + ), + onClick = onClick, + ) +} + +private val AudioSessionState.currentSeconds: Int + get() = when (this) { + AudioSessionState.Idle -> 0 + is AudioSessionState.Recording -> elapsedSeconds + is AudioSessionState.PausedRecording -> elapsedSeconds + is AudioSessionState.ReadyToPlay -> positionSeconds + is AudioSessionState.Playing -> positionSeconds + } + +private val AudioSessionState.totalSeconds: Int + get() = when (this) { + AudioSessionState.Idle, + is AudioSessionState.Recording, + is AudioSessionState.PausedRecording, + -> 0 + + is AudioSessionState.ReadyToPlay -> durationSeconds + is AudioSessionState.Playing -> durationSeconds + } + +private val AudioSessionState.isCompleted: Boolean + get() = this is AudioSessionState.ReadyToPlay || this is AudioSessionState.Playing + +private val AudioSessionState.recordingStatusSpacing + @Composable get() = if (isCompleted) PrezelTheme.spacing.V12 else PrezelTheme.spacing.V8 + +private val AudioSessionState.titleResId: Int + get() = when (this) { + AudioSessionState.Idle -> R.string.feature_analysis_impl_voice_recording_ready_title + is AudioSessionState.Recording -> R.string.feature_analysis_impl_voice_recording_recording_title + is AudioSessionState.PausedRecording -> R.string.feature_analysis_impl_voice_recording_paused_title + is AudioSessionState.ReadyToPlay -> R.string.feature_analysis_impl_voice_recording_playing_title + is AudioSessionState.Playing -> R.string.feature_analysis_impl_voice_recording_playing_title + } + +private val AudioSessionState.actionIconResId: Int + get() = when (this) { + AudioSessionState.Idle -> PrezelIcons.Recording + is AudioSessionState.Recording -> PrezelIcons.Pause + is AudioSessionState.PausedRecording -> PrezelIcons.Recording + is AudioSessionState.ReadyToPlay -> PrezelIcons.Play + is AudioSessionState.Playing -> PrezelIcons.Pause + } + +private val AudioSessionState.actionIconColor: Color + @Composable get() = when (this) { + AudioSessionState.Idle -> PrezelTheme.colors.feedbackBadRegular + is AudioSessionState.PausedRecording -> PrezelTheme.colors.feedbackBadRegular + is AudioSessionState.Recording, + is AudioSessionState.ReadyToPlay, + is AudioSessionState.Playing, + -> PrezelTheme.colors.iconRegular + } + +private fun Int.toTimerText(): String { + val minutes = this / 60 + val seconds = this % 60 + return "%02d:%02d".format(minutes, seconds) +} + +@EntryPoint +@InstallIn(SingletonComponent::class) +private interface VoiceRecordingGlobalEventBusEntryPoint { + fun globalEventBus(): GlobalEventBus +} + +@BasicPreview +@Composable +private fun VoiceRecordingScreenIdlePreview() { + PrezelTheme { + VoiceRecordingScreenPreviewContent(AudioSessionState.Idle) + } +} + +@BasicPreview +@Composable +private fun VoiceRecordingScreenRecordingPreview() { + PrezelTheme { + VoiceRecordingScreenPreviewContent(AudioSessionState.Recording(elapsedSeconds = 12)) + } +} + +@BasicPreview +@Composable +private fun VoiceRecordingScreenPausedPreview() { + PrezelTheme { + VoiceRecordingScreenPreviewContent(AudioSessionState.PausedRecording(elapsedSeconds = 754)) + } +} + +@BasicPreview +@Composable +private fun VoiceRecordingScreenCompletedPreview() { + PrezelTheme { + VoiceRecordingScreenPreviewContent( + AudioSessionState.ReadyToPlay( + source = AudioSource.RecordedFile(filePath = "preview.m4a"), + durationSeconds = 1_232, + ), + ) + } +} + +@BasicPreview +@Composable +private fun VoiceRecordingScreenPlayingPreview() { + PrezelTheme { + VoiceRecordingScreenPreviewContent( + AudioSessionState.Playing( + source = AudioSource.RecordedFile(filePath = "preview.m4a"), + durationSeconds = 1_232, + positionSeconds = 12, + ), + ) + } +} + +@Composable +private fun VoiceRecordingScreenPreviewContent(recordingState: AudioSessionState) { + VoiceRecordingScreen( + uiState = AnalysisFlowUiState( + step = AnalysisFlowStep.VOICE_RECORDING, + form = AnalysisForm(script = "한 번쯤 발표하면서 긴장하신 경험 있으시죠. 오늘도 다들 긴장되는 마음으로 오셨을 것 같습니다."), + recordingState = recordingState, + ), + onClickRecordingControl = {}, + onStopRecording = {}, + onResetRecording = {}, + onAnalyze = {}, + onBack = {}, + ) +} + +@BasicPreview +@Composable +private fun VoiceRecordingScreenInteractiveFlowPreview() { + var recordingState by remember { mutableStateOf(AudioSessionState.Idle) } + + LaunchedEffect(recordingState) { + while (recordingState is AudioSessionState.Recording) { + delay(1_000) + recordingState = when (val state = recordingState) { + is AudioSessionState.Recording -> state.copy(elapsedSeconds = state.elapsedSeconds + 1) + else -> state + } + } + } + + PrezelTheme { + VoiceRecordingScreen( + script = "한 번쯤 발표하면서 긴장하신 경험 있으시죠. 오늘도 다들 긴장되는 마음으로 오셨을 것 같습니다.", + recordingState = recordingState, + analyzeEnabled = recordingState.isCompleted, + onClickRecordingControl = { + recordingState = when (val state = recordingState) { + AudioSessionState.Idle -> AudioSessionState.Recording(elapsedSeconds = 0) + is AudioSessionState.Recording -> AudioSessionState.PausedRecording(elapsedSeconds = state.elapsedSeconds) + is AudioSessionState.PausedRecording -> AudioSessionState.Recording(elapsedSeconds = state.elapsedSeconds) + is AudioSessionState.ReadyToPlay -> AudioSessionState.Playing( + source = state.source, + durationSeconds = state.durationSeconds, + positionSeconds = 0, + ) + + is AudioSessionState.Playing -> AudioSessionState.ReadyToPlay( + source = state.source, + durationSeconds = state.durationSeconds, + positionSeconds = state.positionSeconds, + ) + } + }, + onStopRecording = { + recordingState = when (val state = recordingState) { + is AudioSessionState.Recording -> AudioSessionState.ReadyToPlay( + source = AudioSource.RecordedFile(filePath = "preview.m4a"), + durationSeconds = state.elapsedSeconds.coerceAtLeast(1), + ) + + is AudioSessionState.PausedRecording -> AudioSessionState.ReadyToPlay( + source = AudioSource.RecordedFile(filePath = "preview.m4a"), + durationSeconds = state.elapsedSeconds.coerceAtLeast(1), + ) + + else -> state + } + }, + onResetRecording = { + recordingState = AudioSessionState.Idle + }, + onAnalyze = {}, + onBack = {}, + ) + } +} 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 e6570fdc..04f26e5f 100644 --- a/Prezel/feature/analysis/impl/src/main/res/values/strings.xml +++ b/Prezel/feature/analysis/impl/src/main/res/values/strings.xml @@ -55,6 +55,18 @@ 제공하는 파일 형식 : m4a, mp4, mp3 음성 파일 삭제 파일 추가하기 + 조용한 공간에서 녹음을 시작해주세요. 주변 소음으로 분석 정확도가 낮아질 수 있어요. + 지금부터 발표해볼까요? + 듣고 있어요 + 일시정지됐어요 + 녹음을 확인해보세요 + 대본 + 입력된 대본이 없어요. + 마이크 권한이 필요해요. + 설정에서 마이크 권한을 허용해주세요. + 녹음을 시작하지 못했어요. 다시 시도해 주세요. + 녹음을 저장하지 못했어요. 다시 시도해 주세요. + 녹음을 재생하지 못했어요. 파일 업로드 직접 입력 diff --git a/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/recording/PracticeRecordingViewModel.kt b/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/recording/PracticeRecordingViewModel.kt index 847c2e38..c6ae8a0b 100644 --- a/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/recording/PracticeRecordingViewModel.kt +++ b/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/recording/PracticeRecordingViewModel.kt @@ -42,6 +42,7 @@ internal class PracticeRecordingViewModel @Inject constructor( } is AudioSessionState.Recording -> audioController.stopRecording() + is AudioSessionState.PausedRecording -> audioController.stopRecording() is AudioSessionState.ReadyToPlay -> audioController.startPlayback() is AudioSessionState.Playing -> audioController.stopPlayback() } diff --git a/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/recording/RecordAudioPermission.kt b/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/recording/RecordAudioPermission.kt index 400974c6..8d25f21f 100644 --- a/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/recording/RecordAudioPermission.kt +++ b/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/recording/RecordAudioPermission.kt @@ -30,6 +30,7 @@ internal fun rememberRecordAudioPermissionControlClickHandler( } is AudioSessionState.Recording, + is AudioSessionState.PausedRecording, is AudioSessionState.ReadyToPlay, is AudioSessionState.Playing, -> onClickRecordingControl() diff --git a/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/recording/component/PracticeRecordingContent.kt b/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/recording/component/PracticeRecordingContent.kt index d630da40..b662f847 100644 --- a/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/recording/component/PracticeRecordingContent.kt +++ b/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/recording/component/PracticeRecordingContent.kt @@ -63,6 +63,7 @@ internal fun PracticeRecordingContent( AudioSessionState.Idle, is AudioSessionState.Recording, + is AudioSessionState.PausedRecording, -> PrezelTheme.colors.textLarge }, textAlign = TextAlign.Center, diff --git a/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/recording/component/PracticeRecordingControl.kt b/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/recording/component/PracticeRecordingControl.kt index 34a6f63d..2d377e5c 100644 --- a/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/recording/component/PracticeRecordingControl.kt +++ b/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/recording/component/PracticeRecordingControl.kt @@ -79,7 +79,11 @@ private fun PracticeRecordingTimeText( totalSeconds: Int, audioSessionState: AudioSessionState, ) { - if (audioSessionState == AudioSessionState.Idle || audioSessionState is AudioSessionState.Recording) { + if ( + audioSessionState == AudioSessionState.Idle || + audioSessionState is AudioSessionState.Recording || + audioSessionState is AudioSessionState.PausedRecording + ) { Text( text = currentSeconds.toTimerText(), style = PrezelTheme.typography.title1Medium, @@ -121,6 +125,8 @@ private fun AudioSessionState.action(): PracticeRecordingControlAction = is AudioSessionState.Recording -> stopAction() + is AudioSessionState.PausedRecording -> stopAction() + is AudioSessionState.ReadyToPlay -> PracticeRecordingControlAction( iconResId = PrezelIcons.Play, diff --git a/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/recording/contract/PracticeRecordingUiState.kt b/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/recording/contract/PracticeRecordingUiState.kt index f3814e2d..ebc67ab0 100644 --- a/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/recording/contract/PracticeRecordingUiState.kt +++ b/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/recording/contract/PracticeRecordingUiState.kt @@ -13,6 +13,7 @@ internal data class PracticeRecordingUiState( get() = when (val state = recordingState) { AudioSessionState.Idle -> 0 is AudioSessionState.Recording -> state.elapsedSeconds + is AudioSessionState.PausedRecording -> state.elapsedSeconds is AudioSessionState.ReadyToPlay -> state.positionSeconds is AudioSessionState.Playing -> state.positionSeconds } @@ -21,6 +22,7 @@ internal data class PracticeRecordingUiState( get() = when (val state = recordingState) { AudioSessionState.Idle, is AudioSessionState.Recording, + is AudioSessionState.PausedRecording, -> 0 is AudioSessionState.ReadyToPlay -> state.durationSeconds From 92821431bc27e6e3a6bad122a71eb8fd64404c1d Mon Sep 17 00:00:00 2001 From: Ham BeomJoon Date: Mon, 25 May 2026 00:49:42 +0900 Subject: [PATCH 02/15] =?UTF-8?q?feat:=20GlobalEvent=EB=A5=BC=20=ED=86=B5?= =?UTF-8?q?=ED=95=9C=20=EC=83=81=ED=83=9C=EB=B0=94=20=EC=8A=A4=ED=83=80?= =?UTF-8?q?=EC=9D=BC=20=EC=A0=9C=EC=96=B4=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GlobalEvent에 `ChangeEdgeToEdgeStatusBarStyle`, `ResetEdgeToEdgeStatusBarStyle` 이벤트 추가 - GlobalEventBus에 비동기 컨텍스트 외에서 사용 가능한 `tryEmit` 메서드 추가 - `PrezelApp`에서 전역 이벤트를 수집하여 상태바 배경색 및 아이콘 스타일을 동적으로 변경하는 로직 구현 - 상태바 스타일을 위한 `EdgeToEdgeStatusBarStyle` 열거형 정의 (DEFAULT, BG_REGULAR, BG_MEDIUM) - Activity 확장 함수를 통해 Window 인셋 컨트롤러 및 상태바 색상 설정 로직 추가 --- .../main/java/com/team/prezel/ui/PrezelApp.kt | 148 ++++++++++++++---- .../prezel/core/common/event/GlobalEvent.kt | 43 +++++ .../core/common/event/GlobalEventBus.kt | 19 +++ .../core/common/event/GlobalEventBusImpl.kt | 2 + 4 files changed, 185 insertions(+), 27 deletions(-) diff --git a/Prezel/app/src/main/java/com/team/prezel/ui/PrezelApp.kt b/Prezel/app/src/main/java/com/team/prezel/ui/PrezelApp.kt index 8a419d76..df2232ec 100644 --- a/Prezel/app/src/main/java/com/team/prezel/ui/PrezelApp.kt +++ b/Prezel/app/src/main/java/com/team/prezel/ui/PrezelApp.kt @@ -1,26 +1,46 @@ package com.team.prezel.ui +import android.app.Activity +import android.content.Context +import android.content.ContextWrapper import androidx.compose.animation.SharedTransitionLayout import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.togetherWith +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.windowInsetsTopHeight import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.stringResource +import androidx.core.view.WindowCompat import androidx.navigation3.runtime.EntryProviderScope import androidx.navigation3.runtime.NavKey import androidx.navigation3.runtime.entryProvider import androidx.navigation3.ui.NavDisplay +import com.team.prezel.core.common.event.EdgeToEdgeStatusBarStyle import com.team.prezel.core.common.event.GlobalEvent import com.team.prezel.core.common.event.GlobalEventBus import com.team.prezel.core.designsystem.component.PrezelNavigationScaffold import com.team.prezel.core.designsystem.component.PrezelNavigationScope +import com.team.prezel.core.designsystem.theme.PrezelTheme import com.team.prezel.core.navigation.LocalNavigator import com.team.prezel.core.navigation.Navigator import com.team.prezel.core.navigation.ProvideSharedTransitionScope @@ -60,61 +80,135 @@ private fun PrezelAppContent( entryBuilders: ImmutableSet.() -> Unit>, ) { val navigator = LocalNavigator.current + var statusBarStyle by remember { mutableStateOf(EdgeToEdgeStatusBarStyle.DEFAULT) } ObserveGlobalEvents( globalEventBus = globalEventBus, navigateToSplash = { navigator.replaceRoot(SplashNavKey) }, + onStatusBarStyleChange = { statusBarStyle = it }, ) SharedTransitionLayout { - ProvideSharedTransitionScope(this@SharedTransitionLayout) { - val provider = remember(entryBuilders, navigator) { - entryProvider { - entryBuilders.forEach { builder -> this.builder() } + Box { + ProvideSharedTransitionScope(this@SharedTransitionLayout) { + val provider = remember(entryBuilders, navigator) { + entryProvider { + entryBuilders.forEach { builder -> this.builder() } + } } - } - PrezelNavigationScaffold( - showNavigationBar = appState.shouldShowNavigationBar, - snackbarHostState = LocalSnackbarHostState.current, - navigationItems = { AppNavigationItems(appState = appState, navigateToKey = { key -> navigator.navigate(key) }) }, - ) { padding -> - NavDisplay( - entries = appState.navigationState.toEntries(provider), - onBack = navigator::goBack, - modifier = Modifier.padding(padding), - transitionSpec = { - fadeIn(animationSpec = tween(durationMillis = 100)) togetherWith - fadeOut(animationSpec = tween(durationMillis = 100)) - }, - popTransitionSpec = { - fadeIn(animationSpec = tween(durationMillis = 100)) togetherWith - fadeOut(animationSpec = tween(durationMillis = 100)) - }, - predictivePopTransitionSpec = { - fadeIn(animationSpec = tween(durationMillis = 100)) togetherWith - fadeOut(animationSpec = tween(durationMillis = 100)) - }, - ) + PrezelNavigationScaffold( + showNavigationBar = appState.shouldShowNavigationBar, + snackbarHostState = LocalSnackbarHostState.current, + navigationItems = { AppNavigationItems(appState = appState, navigateToKey = { key -> navigator.navigate(key) }) }, + ) { padding -> + NavDisplay( + entries = appState.navigationState.toEntries(provider), + onBack = navigator::goBack, + modifier = Modifier.padding(padding), + transitionSpec = { + fadeIn(animationSpec = tween(durationMillis = 100)) togetherWith + fadeOut(animationSpec = tween(durationMillis = 100)) + }, + popTransitionSpec = { + fadeIn(animationSpec = tween(durationMillis = 100)) togetherWith + fadeOut(animationSpec = tween(durationMillis = 100)) + }, + predictivePopTransitionSpec = { + fadeIn(animationSpec = tween(durationMillis = 100)) togetherWith + fadeOut(animationSpec = tween(durationMillis = 100)) + }, + ) + } } + + EdgeToEdgeStatusBarBackground(style = statusBarStyle) } } } +@Composable +private fun EdgeToEdgeStatusBarBackground(style: EdgeToEdgeStatusBarStyle) { + Spacer( + modifier = Modifier + .fillMaxWidth() + .windowInsetsTopHeight(WindowInsets.statusBars) + .background(style.toStatusBarColor()), + ) +} + +@Composable +private fun EdgeToEdgeStatusBarStyle.toStatusBarColor(): Color = + when (this) { + EdgeToEdgeStatusBarStyle.DEFAULT -> Color.Transparent + EdgeToEdgeStatusBarStyle.BG_REGULAR -> PrezelTheme.colors.bgRegular + EdgeToEdgeStatusBarStyle.BG_MEDIUM -> PrezelTheme.colors.bgMedium + } + @Composable private fun ObserveGlobalEvents( globalEventBus: GlobalEventBus, navigateToSplash: () -> Unit, + onStatusBarStyleChange: (EdgeToEdgeStatusBarStyle) -> Unit, ) { + val context = LocalContext.current + val view = LocalView.current + val activity = context.findActivity() + val bgRegular = PrezelTheme.colors.bgRegular + val bgMedium = PrezelTheme.colors.bgMedium + LaunchedEffect(globalEventBus) { globalEventBus.events.collect { event -> when (event) { GlobalEvent.ForceLogout -> navigateToSplash() + is GlobalEvent.ChangeEdgeToEdgeStatusBarStyle -> { + onStatusBarStyleChange(event.style) + activity?.applyEdgeToEdgeStatusBarStyle( + view = view, + style = event.style, + bgRegular = bgRegular, + bgMedium = bgMedium, + ) + } + + GlobalEvent.ResetEdgeToEdgeStatusBarStyle -> { + onStatusBarStyleChange(EdgeToEdgeStatusBarStyle.DEFAULT) + activity?.applyEdgeToEdgeStatusBarStyle( + view = view, + style = EdgeToEdgeStatusBarStyle.DEFAULT, + bgRegular = bgRegular, + bgMedium = bgMedium, + ) + } } } } } +@Suppress("DEPRECATION") +private fun Activity.applyEdgeToEdgeStatusBarStyle( + view: android.view.View, + style: EdgeToEdgeStatusBarStyle, + bgRegular: Color, + bgMedium: Color, +) { + val insetsController = WindowCompat.getInsetsController(window, view) + + window.statusBarColor = when (style) { + EdgeToEdgeStatusBarStyle.DEFAULT -> Color.Transparent + EdgeToEdgeStatusBarStyle.BG_REGULAR -> bgRegular + EdgeToEdgeStatusBarStyle.BG_MEDIUM -> bgMedium + }.toArgb() + insetsController.isAppearanceLightStatusBars = true +} + +private tailrec fun Context.findActivity(): Activity? = + when (this) { + is Activity -> this + is ContextWrapper -> baseContext.findActivity() + else -> null + } + @Composable private fun PrezelNavigationScope.AppNavigationItems( appState: PrezelAppState, diff --git a/Prezel/core/common/src/main/kotlin/com/team/prezel/core/common/event/GlobalEvent.kt b/Prezel/core/common/src/main/kotlin/com/team/prezel/core/common/event/GlobalEvent.kt index 65d4894a..7389da0d 100644 --- a/Prezel/core/common/src/main/kotlin/com/team/prezel/core/common/event/GlobalEvent.kt +++ b/Prezel/core/common/src/main/kotlin/com/team/prezel/core/common/event/GlobalEvent.kt @@ -1,5 +1,48 @@ package com.team.prezel.core.common.event +/** + * 앱 전역에서 단발성으로 처리해야 하는 이벤트입니다. + * + * 화면 전환, 인증 만료 처리, 시스템 바 스타일 변경처럼 특정 feature가 직접 app 계층을 참조하지 않고 + * 루트 화면에서 일괄 처리해야 하는 동작을 전달할 때 사용합니다. + */ sealed interface GlobalEvent { + /** + * 인증 상태가 만료되어 앱을 splash/root 흐름으로 되돌려야 하는 이벤트입니다. + */ data object ForceLogout : GlobalEvent + + /** + * edge-to-edge 상태바 영역의 배경 스타일을 변경합니다. + * + * 실제 Window 및 상태바 overlay 적용은 app 계층에서 처리합니다. + */ + data class ChangeEdgeToEdgeStatusBarStyle( + val style: EdgeToEdgeStatusBarStyle, + ) : GlobalEvent + + /** + * feature 화면에서 변경한 edge-to-edge 상태바 스타일을 앱 기본 상태로 되돌립니다. + */ + data object ResetEdgeToEdgeStatusBarStyle : GlobalEvent +} + +/** + * edge-to-edge 상태바 영역에 적용할 앱 공통 배경 스타일입니다. + */ +enum class EdgeToEdgeStatusBarStyle { + /** + * 앱 기본 edge-to-edge 상태로 복원합니다. + */ + DEFAULT, + + /** + * 일반 배경색을 상태바 영역에 적용합니다. + */ + BG_REGULAR, + + /** + * 강조/헤더 배경색을 상태바 영역에 적용합니다. + */ + BG_MEDIUM, } diff --git a/Prezel/core/common/src/main/kotlin/com/team/prezel/core/common/event/GlobalEventBus.kt b/Prezel/core/common/src/main/kotlin/com/team/prezel/core/common/event/GlobalEventBus.kt index a92e62cd..166338ed 100644 --- a/Prezel/core/common/src/main/kotlin/com/team/prezel/core/common/event/GlobalEventBus.kt +++ b/Prezel/core/common/src/main/kotlin/com/team/prezel/core/common/event/GlobalEventBus.kt @@ -2,8 +2,27 @@ package com.team.prezel.core.common.event import kotlinx.coroutines.flow.Flow +/** + * feature와 app 계층 사이에서 단발성 전역 이벤트를 전달하는 버스입니다. + * + * feature 모듈은 app 모듈을 직접 참조하지 않고 이벤트만 발행하고, + * app 모듈은 [events]를 수집해 실제 navigation, system bar 변경 같은 앱 단위 동작을 수행합니다. + */ interface GlobalEventBus { + /** + * 앱 전역 이벤트 스트림입니다. + */ val events: Flow + /** + * suspend context에서 전역 이벤트를 발행합니다. + */ suspend fun emit(event: GlobalEvent) + + /** + * suspend할 수 없는 context에서 전역 이벤트 발행을 시도합니다. + * + * Compose dispose callback처럼 suspend 함수를 호출할 수 없는 곳에서 복원 이벤트를 발행할 때 사용합니다. + */ + fun tryEmit(event: GlobalEvent): Boolean } diff --git a/Prezel/core/common/src/main/kotlin/com/team/prezel/core/common/event/GlobalEventBusImpl.kt b/Prezel/core/common/src/main/kotlin/com/team/prezel/core/common/event/GlobalEventBusImpl.kt index 141317ea..20da90f1 100644 --- a/Prezel/core/common/src/main/kotlin/com/team/prezel/core/common/event/GlobalEventBusImpl.kt +++ b/Prezel/core/common/src/main/kotlin/com/team/prezel/core/common/event/GlobalEventBusImpl.kt @@ -19,4 +19,6 @@ class GlobalEventBusImpl @Inject constructor() : GlobalEventBus { override suspend fun emit(event: GlobalEvent) { _events.emit(event) } + + override fun tryEmit(event: GlobalEvent): Boolean = _events.tryEmit(event) } From bde3fd8aa32db150dfb70bf6fde3d31632adff6b Mon Sep 17 00:00:00 2001 From: Ham BeomJoon Date: Mon, 25 May 2026 01:24:52 +0900 Subject: [PATCH 03/15] =?UTF-8?q?refactor:=20=EC=9D=8C=EC=84=B1=20?= =?UTF-8?q?=EB=85=B9=EC=9D=8C=20=ED=99=94=EB=A9=B4=20=EA=B5=AC=EC=A1=B0=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0=20=EB=B0=8F=20=EB=B9=84=EC=A6=88=EB=8B=88?= =?UTF-8?q?=EC=8A=A4=20=EB=A1=9C=EC=A7=81=20=EB=A6=AC=ED=8C=A9=ED=84=B0?= =?UTF-8?q?=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 음성 녹음 화면(`VoiceRecordingScreen.kt`)에서 대규모 UI 컴포넌트들을 별도 파일로 분리 - `VoiceRecordingButtonArea.kt`: 녹음 상태별 버튼 영역 분리 - `VoiceRecordingContent.kt`: 스크립트 및 녹음 상태 표시 영역 분리 - `VoiceRecordingStateProperties.kt`: `AudioSessionState` 기반 확장 프로퍼티 정리 - `VoiceRecordingStatusBarStyle.kt`: 상태바 스타일 제어 로직 분리 - `AnalysisFlowViewModel` 내 UI 상태 변경 및 분석 요청 로직 개선 - `reduceFormOrNull` 확장 함수를 통해 `AnalysisFlowUiIntent`에 따른 `AnalysisForm` 갱신 로직 간소화 - `analyzePresentationRecording` 로직을 별도 함수로 추출하고 파일 캐시 처리 방식 개선 - 스크립트 건너뛰기(`skipScript`) 및 파일 업로드 재시도 로직 보완 - `MediaRecordingAudioController` 내 `AudioSessionEffect` 전송 방식을 `emit` 확장 함수로 통일 - `ScriptInputScreen` 내 직접 입력 모드에서 텍스트 필드 하단 여백(`Spacer`) 추가 및 미리보기 케이스 추가 - `AnalysisScreen` 내 스낵바 표시 및 녹음 권한 핸들러 호출 로직 구조 개선 (컴포넌트 분리) --- .../audio/MediaRecordingAudioController.kt | 18 +- .../analysis/impl/AnalysisFlowViewModel.kt | 188 +++--- .../feature/analysis/impl/AnalysisScreen.kt | 90 ++- .../recording/VoiceRecordingButtonArea.kt | 183 +++++ .../impl/recording/VoiceRecordingContent.kt | 309 +++++++++ .../impl/recording/VoiceRecordingScreen.kt | 634 ++---------------- .../VoiceRecordingStateProperties.kt | 68 ++ .../recording/VoiceRecordingStatusBarStyle.kt | 51 ++ .../analysis/impl/script/ScriptInputScreen.kt | 42 ++ 9 files changed, 900 insertions(+), 683 deletions(-) create mode 100644 Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/recording/VoiceRecordingButtonArea.kt create mode 100644 Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/recording/VoiceRecordingContent.kt create mode 100644 Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/recording/VoiceRecordingStateProperties.kt create mode 100644 Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/recording/VoiceRecordingStatusBarStyle.kt diff --git a/Prezel/core/audio/src/main/java/com/team/prezel/core/audio/MediaRecordingAudioController.kt b/Prezel/core/audio/src/main/java/com/team/prezel/core/audio/MediaRecordingAudioController.kt index e7bbfd26..59338a4b 100644 --- a/Prezel/core/audio/src/main/java/com/team/prezel/core/audio/MediaRecordingAudioController.kt +++ b/Prezel/core/audio/src/main/java/com/team/prezel/core/audio/MediaRecordingAudioController.kt @@ -40,7 +40,7 @@ internal class MediaRecordingAudioController @Inject constructor( }.onFailure { recorderSession.reset() _audioSessionState.value = AudioSessionState.Idle - emitEffect(AudioSessionEffect.RecordingStartFailed) + _audioSessionEffect.emit(AudioSessionEffect.RecordingStartFailed) } } @@ -61,7 +61,7 @@ internal class MediaRecordingAudioController @Inject constructor( ) }.onFailure { _audioSessionState.value = AudioSessionState.Idle - emitEffect(AudioSessionEffect.RecordingStopFailed) + _audioSessionEffect.emit(AudioSessionEffect.RecordingStopFailed) } } @@ -77,7 +77,7 @@ internal class MediaRecordingAudioController @Inject constructor( recordingTimerJob?.cancel() _audioSessionState.value = AudioSessionState.PausedRecording(elapsedSeconds = elapsedSeconds) }.onFailure { - emitEffect(AudioSessionEffect.RecordingStopFailed) + _audioSessionEffect.emit(AudioSessionEffect.RecordingStopFailed) } } @@ -93,7 +93,7 @@ internal class MediaRecordingAudioController @Inject constructor( _audioSessionState.value = AudioSessionState.Recording(elapsedSeconds = elapsedSeconds) startRecordingTimer() }.onFailure { - emitEffect(AudioSessionEffect.RecordingStartFailed) + _audioSessionEffect.emit(AudioSessionEffect.RecordingStartFailed) } } @@ -192,7 +192,7 @@ internal class MediaRecordingAudioController @Inject constructor( source = source, durationSeconds = durationSeconds, ) - emitEffect(AudioSessionEffect.PlaybackStartFailed) + _audioSessionEffect.emit(AudioSessionEffect.PlaybackStartFailed) } private fun startRecordingTimer() { @@ -231,12 +231,12 @@ internal class MediaRecordingAudioController @Inject constructor( playerSession.release() } - private fun emitEffect(effect: AudioSessionEffect) { - _audioSessionEffect.trySend(effect) - } - private companion object { const val RECORDING_TIMER_DELAY_MILLIS = 1_000L const val PLAYBACK_TIMER_DELAY_MILLIS = 1_000L } } + +private fun Channel.emit(effect: AudioSessionEffect) { + trySend(effect) +} 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 9be798c3..fdae0dfc 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 @@ -42,14 +42,12 @@ internal class AnalysisFlowViewModel @Inject constructor( } override fun onIntent(intent: AnalysisFlowUiIntent) { + intent.reduceFormOrNull(currentState.form)?.let { nextForm -> + updateState { copy(form = nextForm) } + return + } + when (intent) { - is AnalysisFlowUiIntent.UpdatePresentationTitle -> updateForm { copy(presentationTitle = intent.title) } - is AnalysisFlowUiIntent.UpdatePresentationDate -> updateForm { copy(presentationDate = intent.date) } - is AnalysisFlowUiIntent.SelectSituationOption -> selectSituationOption(intent.option) - is AnalysisFlowUiIntent.SelectScriptInputType -> updateForm { copy(scriptInputType = intent.inputType) } - is AnalysisFlowUiIntent.UpdateScript -> updateForm { copy(script = intent.script) } - is AnalysisFlowUiIntent.SelectScriptFile -> updateForm { copy(scriptFileUri = intent.fileUri) } - is AnalysisFlowUiIntent.SelectAudioFile -> updateForm { copy(audioFileUri = intent.fileUri) } AnalysisFlowUiIntent.ClickRecordingControl -> handleRecordingControlClick() AnalysisFlowUiIntent.StopRecording -> audioController.stopRecording() AnalysisFlowUiIntent.ResetRecording -> audioController.reset() @@ -57,17 +55,7 @@ internal class AnalysisFlowViewModel @Inject constructor( AnalysisFlowUiIntent.Next -> moveNext() AnalysisFlowUiIntent.SkipScript -> skipScript() AnalysisFlowUiIntent.Back -> moveBack() - } - } - - private fun selectSituationOption(option: AnalysisSituationOption) { - updateForm { - when (option) { - is AnalysisSituationOption.CategoryOption -> copy(category = option.category) - is AnalysisSituationOption.PurposeOption -> copy(purpose = option.purpose) - is AnalysisSituationOption.StyleOption -> copy(style = option.style) - is AnalysisSituationOption.AudienceOption -> copy(audience = option.audience) - } + else -> Unit } } @@ -104,52 +92,25 @@ internal class AnalysisFlowViewModel @Inject constructor( analyzeJob?.cancel() analyzeJob = viewModelScope.launch { - submission - .analyzePresentationRecording() - .onSuccess { result -> + val analysisResult = submission.analyzePresentationRecording( + analysisFileCache = analysisFileCache, + analyzePresentationRecordingUseCase = analyzePresentationRecordingUseCase, + ) + + analysisResult.fold( + onSuccess = { result -> if (currentState.step == AnalysisFlowStep.ANALYZING) { - handleAnalysisSuccess(result) + updateState { + copy( + step = AnalysisFlowStep.REPORT, + analysisResult = result, + ) + } } - }.onFailure { throwable -> handleAnalysisFailure(throwable.toAnalysisFailureAction()) } - } - } - - private suspend fun PresentationAnalysisSubmission.analyzePresentationRecording(): Result = - runCatching { - val audioFilePath = audioFileUri - ?.let { uri -> - val audioFile = analysisFileCache.copyUriToCache( - uriString = uri, - prefix = "audio", - ) - audioFile.absolutePath - } - ?: recordingFilePath - val scriptFile = scriptFileUri?.let { uri -> - analysisFileCache.copyUriToCache( - uriString = uri, - prefix = "script", - ) - } - - analyzePresentationRecordingUseCase( - name = name, - date = date.toRequestDate(), - category = category, - purpose = purpose, - style = style, - audience = audience, - script = script, - scriptFilePath = scriptFile?.absolutePath, - audioFilePath = audioFilePath, - ).getOrThrow() - } - - private fun handleAnalysisSuccess(analysisResult: PresentationRecordingAnalysisResult) { - updateState { - copy( - step = AnalysisFlowStep.REPORT, - analysisResult = analysisResult, + }, + onFailure = { throwable -> + handleAnalysisFailure(throwable.toAnalysisFailureAction()) + }, ) } } @@ -176,39 +137,44 @@ internal class AnalysisFlowViewModel @Inject constructor( private fun retryFileUpload(uploadType: AnalysisUploadType) { when (uploadType) { - AnalysisUploadType.SCRIPT -> retryScriptFileUpload() - AnalysisUploadType.AUDIO -> retryAudioUpload() - } - } + AnalysisUploadType.SCRIPT -> { + updateState { + copy( + step = AnalysisFlowStep.SCRIPT_INPUT, + form = form.copy( + scriptInputType = ScriptInputType.FILE_UPLOAD, + scriptFileUri = null, + ), + ) + } + } - private fun retryAudioUpload() { - updateState { - copy( - step = AnalysisFlowStep.VOICE_RECORDING, - form = form.copy(audioFileUri = null), - ) + AnalysisUploadType.AUDIO -> { + updateState { + copy( + step = AnalysisFlowStep.VOICE_RECORDING, + form = form.copy(audioFileUri = null), + ) + } + audioController.reset() + } } - audioController.reset() } - private fun retryScriptFileUpload() { + private fun skipScript() { + if (currentState.step != AnalysisFlowStep.SCRIPT_INPUT) return + updateState { copy( - step = AnalysisFlowStep.SCRIPT_INPUT, + step = AnalysisFlowStep.VOICE_RECORDING, form = form.copy( - scriptInputType = ScriptInputType.FILE_UPLOAD, + script = "", scriptFileUri = null, ), ) } } - private fun skipScript() { - if (currentState.step != AnalysisFlowStep.SCRIPT_INPUT) return - - updateState { copy(step = AnalysisFlowStep.VOICE_RECORDING) } - } - private fun moveBack() { if (currentState.step == AnalysisFlowStep.ANALYZING) analyzeJob?.cancel() @@ -231,10 +197,6 @@ internal class AnalysisFlowViewModel @Inject constructor( } } - private fun updateForm(reducer: AnalysisForm.() -> AnalysisForm) { - updateState { copy(form = form.reducer()) } - } - private fun handleRecordingControlClick() { when (currentState.recordingState) { AudioSessionState.Idle -> audioController.startRecording() @@ -287,6 +249,60 @@ private data class PresentationAnalysisSubmission( val recordingFilePath: String, ) +private fun AnalysisFlowUiIntent.reduceFormOrNull(form: AnalysisForm): AnalysisForm? = + when (this) { + is AnalysisFlowUiIntent.UpdatePresentationTitle -> form.copy(presentationTitle = title) + is AnalysisFlowUiIntent.UpdatePresentationDate -> form.copy(presentationDate = date) + is AnalysisFlowUiIntent.SelectScriptInputType -> form.copy(scriptInputType = inputType) + is AnalysisFlowUiIntent.UpdateScript -> form.copy(script = script) + is AnalysisFlowUiIntent.SelectScriptFile -> form.copy(scriptFileUri = fileUri) + is AnalysisFlowUiIntent.SelectAudioFile -> form.copy(audioFileUri = fileUri) + is AnalysisFlowUiIntent.SelectSituationOption -> form.selectSituationOption(option) + else -> null + } + +private fun AnalysisForm.selectSituationOption(option: AnalysisSituationOption): AnalysisForm = + when (option) { + is AnalysisSituationOption.CategoryOption -> copy(category = option.category) + is AnalysisSituationOption.PurposeOption -> copy(purpose = option.purpose) + is AnalysisSituationOption.StyleOption -> copy(style = option.style) + is AnalysisSituationOption.AudienceOption -> copy(audience = option.audience) + } + +private suspend fun PresentationAnalysisSubmission.analyzePresentationRecording( + analysisFileCache: AnalysisFileCache, + analyzePresentationRecordingUseCase: AnalyzePresentationRecordingUseCase, +): Result = + runCatching { + val audioFilePath = audioFileUri + ?.let { uri -> + val audioFile = analysisFileCache.copyUriToCache( + uriString = uri, + prefix = "audio", + ) + audioFile.absolutePath + } + ?: recordingFilePath + val scriptFile = scriptFileUri?.let { uri -> + analysisFileCache.copyUriToCache( + uriString = uri, + prefix = "script", + ) + } + + analyzePresentationRecordingUseCase( + name = name, + date = date.toRequestDate(), + category = category, + purpose = purpose, + style = style, + audience = audience, + script = script, + scriptFilePath = scriptFile?.absolutePath, + audioFilePath = audioFilePath, + ).getOrThrow() + } + private fun AnalysisFlowUiState.toPresentationAnalysisSubmissionOrNull(): PresentationAnalysisSubmission? { val category = form.category ?: return null val purpose = form.purpose ?: return 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 41e3d84d..23937ddb 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 @@ -72,19 +72,44 @@ private fun AnalysisScreen( uiState: AnalysisFlowUiState, onIntent: (AnalysisFlowUiIntent) -> Unit, ) { + VoiceRecordingGuideSnackbar(step = uiState.step) + + val onClickRecordingControl = rememberVoiceRecordingControlClick( + uiState = uiState, + onIntent = onIntent, + ) + + AnalysisStepContent( + uiState = uiState, + onIntent = onIntent, + onClickRecordingControl = onClickRecordingControl, + ) +} + +@Composable +private fun VoiceRecordingGuideSnackbar(step: AnalysisFlowStep) { val resources = LocalResources.current val snackbarHostState = LocalSnackbarHostState.current - val coroutineScope = rememberCoroutineScope() - LaunchedEffect(uiState.step) { - if (uiState.step == AnalysisFlowStep.VOICE_RECORDING) { + LaunchedEffect(step) { + if (step == AnalysisFlowStep.VOICE_RECORDING) { snackbarHostState.showPrezelSnackbar( message = resources.getString(R.string.feature_analysis_impl_voice_recording_guide), ) } } +} - val onClickRecordingControl = rememberAnalysisRecordAudioPermissionControlClickHandler( +@Composable +private fun rememberVoiceRecordingControlClick( + uiState: AnalysisFlowUiState, + onIntent: (AnalysisFlowUiIntent) -> Unit, +): () -> Unit { + val resources = LocalResources.current + val snackbarHostState = LocalSnackbarHostState.current + val coroutineScope = rememberCoroutineScope() + + return rememberAnalysisRecordAudioPermissionControlClickHandler( recordingState = uiState.recordingState, onClickRecordingControl = { onIntent(AnalysisFlowUiIntent.ClickRecordingControl) }, onPermissionDenied = { @@ -102,7 +127,43 @@ private fun AnalysisScreen( } }, ) +} +@Composable +private fun AnalysisStepContent( + uiState: AnalysisFlowUiState, + onIntent: (AnalysisFlowUiIntent) -> Unit, + onClickRecordingControl: () -> Unit, +) { + when (uiState.step) { + AnalysisFlowStep.PRESENTATION_SCHEDULE, + AnalysisFlowStep.PRESENTATION_SITUATION, + AnalysisFlowStep.SCRIPT_INPUT, + AnalysisFlowStep.AUDIO_UPLOAD, + AnalysisFlowStep.VOICE_RECORDING, + -> AnalysisInputStepContent( + uiState = uiState, + onIntent = onIntent, + onClickRecordingControl = onClickRecordingControl, + ) + + AnalysisFlowStep.ANALYZING, + AnalysisFlowStep.REPORT, + AnalysisFlowStep.FILE_RECOGNITION_FAILED, + AnalysisFlowStep.SCRIPT_FILE_RECOGNITION_FAILED, + -> AnalysisResultStepContent( + step = uiState.step, + onIntent = onIntent, + ) + } +} + +@Composable +private fun AnalysisInputStepContent( + uiState: AnalysisFlowUiState, + onIntent: (AnalysisFlowUiIntent) -> Unit, + onClickRecordingControl: () -> Unit, +) { when (uiState.step) { AnalysisFlowStep.PRESENTATION_SCHEDULE -> PresentationScheduleScreen( uiState = uiState, @@ -148,6 +209,20 @@ private fun AnalysisScreen( onBack = { onIntent(AnalysisFlowUiIntent.Back) }, ) + AnalysisFlowStep.ANALYZING, + AnalysisFlowStep.REPORT, + AnalysisFlowStep.FILE_RECOGNITION_FAILED, + AnalysisFlowStep.SCRIPT_FILE_RECOGNITION_FAILED, + -> Unit + } +} + +@Composable +private fun AnalysisResultStepContent( + step: AnalysisFlowStep, + onIntent: (AnalysisFlowUiIntent) -> Unit, +) { + when (step) { AnalysisFlowStep.ANALYZING -> AnalysisLoadingScreen() AnalysisFlowStep.REPORT -> AnalysisReportScreen() @@ -159,5 +234,12 @@ private fun AnalysisScreen( AnalysisFlowStep.SCRIPT_FILE_RECOGNITION_FAILED -> ScriptFileRecognitionFailedScreen( onRetry = { onIntent(AnalysisFlowUiIntent.RetryFileUpload(AnalysisUploadType.SCRIPT)) }, ) + + AnalysisFlowStep.PRESENTATION_SCHEDULE, + AnalysisFlowStep.PRESENTATION_SITUATION, + AnalysisFlowStep.SCRIPT_INPUT, + AnalysisFlowStep.AUDIO_UPLOAD, + AnalysisFlowStep.VOICE_RECORDING, + -> Unit } } 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 new file mode 100644 index 00000000..d5c42397 --- /dev/null +++ b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/recording/VoiceRecordingButtonArea.kt @@ -0,0 +1,183 @@ +package com.team.prezel.feature.analysis.impl.recording + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.team.prezel.core.audio.AudioSessionState +import com.team.prezel.core.designsystem.component.actions.area.PrezelButtonArea +import com.team.prezel.core.designsystem.component.actions.button.PrezelButton +import com.team.prezel.core.designsystem.component.actions.button.PrezelIconButton +import com.team.prezel.core.designsystem.component.actions.button.config.ButtonHierarchy +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.actions.button.config.PrezelButtonDefaults +import com.team.prezel.core.designsystem.icon.PrezelIcons +import com.team.prezel.core.designsystem.theme.PrezelTheme +import com.team.prezel.feature.analysis.impl.R + +@Composable +internal fun VoiceRecordingButtonArea( + recordingState: AudioSessionState, + analyzeEnabled: Boolean, + onClickRecordingControl: () -> Unit, + onStopRecording: () -> Unit, + onResetRecording: () -> Unit, + onAnalyze: () -> Unit, +) { + when (recordingState) { + AudioSessionState.Idle -> IdleRecordingButtonArea( + recordingState = recordingState, + onClickRecordingControl = onClickRecordingControl, + ) + + is AudioSessionState.Recording, + is AudioSessionState.PausedRecording, + -> ActiveRecordingButtonArea( + recordingState = recordingState, + onClickRecordingControl = onClickRecordingControl, + onStopRecording = onStopRecording, + ) + + is AudioSessionState.ReadyToPlay, + is AudioSessionState.Playing, + -> CompletedRecordingButtonArea( + analyzeEnabled = analyzeEnabled, + onResetRecording = onResetRecording, + onAnalyze = onAnalyze, + ) + } +} + +@Composable +private fun IdleRecordingButtonArea( + recordingState: AudioSessionState, + onClickRecordingControl: () -> Unit, +) { + PrezelButtonArea( + showBackground = true, + mainButton = { buttonModifier -> + RecordingIconButton( + iconResId = recordingState.actionIconResId, + iconColor = recordingState.actionIconColor, + modifier = buttonModifier, + onClick = onClickRecordingControl, + ) + }, + ) +} + +@Composable +private fun ActiveRecordingButtonArea( + recordingState: AudioSessionState, + onClickRecordingControl: () -> Unit, + onStopRecording: () -> Unit, +) { + PrezelButtonArea( + showBackground = true, + isVertical = false, + isStrongStrength = false, + mainButton = { buttonModifier -> + RecordingIconButton( + iconResId = PrezelIcons.Stop, + iconColor = PrezelTheme.colors.iconRegular, + modifier = buttonModifier, + onClick = onStopRecording, + ) + }, + subButton = { buttonModifier -> + RecordingIconButton( + iconResId = recordingState.actionIconResId, + iconColor = recordingState.actionIconColor, + modifier = buttonModifier, + onClick = onClickRecordingControl, + ) + }, + ) +} + +@Composable +private fun CompletedRecordingButtonArea( + analyzeEnabled: Boolean, + onResetRecording: () -> Unit, + onAnalyze: () -> Unit, +) { + PrezelButtonArea( + showBackground = true, + isVertical = false, + mainButton = { buttonModifier -> + PrezelButton( + text = stringResource(R.string.feature_analysis_impl_analyze), + modifier = buttonModifier, + enabled = analyzeEnabled, + type = ButtonType.FILLED, + hierarchy = ButtonHierarchy.PRIMARY, + onClick = onAnalyze, + ) + }, + subButton = { buttonModifier -> + RecordingResetButton( + iconResId = PrezelIcons.Reset, + iconColor = PrezelTheme.colors.iconRegular, + modifier = buttonModifier.width(52.dp), + onClick = onResetRecording, + ) + }, + ) +} + +@Composable +private fun RecordingResetButton( + @DrawableRes iconResId: Int, + iconColor: Color, + modifier: Modifier = Modifier, + onClick: () -> Unit, +) { + PrezelIconButton( + iconResId = iconResId, + modifier = modifier.height(48.dp), + buttonDefault = PrezelButtonDefaults.getDefault( + isIconOnly = true, + isRounded = false, + type = ButtonType.GHOST, + size = ButtonSize.REGULAR, + hierarchy = ButtonHierarchy.SECONDARY, + contentColor = iconColor, + backgroundColor = Color.Transparent, + iconSize = 20.dp, + ), + onClick = onClick, + ) +} + +@Composable +private fun RecordingIconButton( + @DrawableRes iconResId: Int, + iconColor: Color, + modifier: Modifier = Modifier, + onClick: () -> Unit, +) { + PrezelIconButton( + iconResId = iconResId, + modifier = modifier + .height(48.dp) + .clip(RoundedCornerShape(PrezelTheme.radius.V8)), + buttonDefault = PrezelButtonDefaults.getDefault( + isIconOnly = true, + type = ButtonType.FILLED, + size = ButtonSize.REGULAR, + hierarchy = ButtonHierarchy.SECONDARY, + isRounded = false, + contentColor = iconColor, + backgroundColor = PrezelTheme.colors.bgLarge, + iconSize = 20.dp, + ), + onClick = onClick, + ) +} 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 new file mode 100644 index 00000000..43f1938e --- /dev/null +++ b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/recording/VoiceRecordingContent.kt @@ -0,0 +1,309 @@ +package com.team.prezel.feature.analysis.impl.recording + +import androidx.compose.foundation.background +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.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +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.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.dp +import com.team.prezel.core.audio.AudioSessionState +import com.team.prezel.core.designsystem.component.actions.button.PrezelIconButton +import com.team.prezel.core.designsystem.component.actions.button.config.ButtonHierarchy +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.actions.button.config.PrezelButtonDefaults +import com.team.prezel.core.designsystem.icon.PrezelIcons +import com.team.prezel.core.designsystem.theme.PrezelTheme +import com.team.prezel.feature.analysis.impl.R + +@Composable +internal fun VoiceRecordingContent( + script: String, + recordingState: AudioSessionState, + onClickRecordingControl: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxWidth() + .background(PrezelTheme.colors.bgRegular) + .padding(vertical = PrezelTheme.spacing.V16), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + if (!recordingState.isCompleted) { + VoiceRecordingScriptHeader() + Spacer(modifier = Modifier.height(PrezelTheme.spacing.V16)) + } + + VoiceRecordingScriptBody( + script = script, + modifier = Modifier.weight(1f), + ) + + if (recordingState !is AudioSessionState.Idle) { + VoiceRecordingStatusArea( + recordingState = recordingState, + onClickRecordingControl = onClickRecordingControl, + ) + } + } +} + +@Composable +private fun VoiceRecordingScriptHeader() { + Box( + modifier = Modifier + .fillMaxWidth() + .height(20.dp) + .padding(horizontal = PrezelTheme.spacing.V20), + ) { + Text( + text = stringResource(R.string.feature_analysis_impl_voice_recording_script_label), + color = PrezelTheme.colors.textMedium, + style = PrezelTheme.typography.body3Medium, + modifier = Modifier.align(Alignment.CenterStart), + ) + + ScriptZoomButton( + modifier = Modifier + .align(Alignment.CenterEnd) + .offset(x = PrezelTheme.spacing.V12) + .size(48.dp), + ) + } +} + +@Composable +private fun VoiceRecordingScriptBody( + script: String, + modifier: Modifier = Modifier, +) { + val scrollState = rememberScrollState() + val showTopGradient by remember { + derivedStateOf { scrollState.value > 0 } + } + val showBottomGradient by remember { + derivedStateOf { scrollState.value < scrollState.maxValue } + } + + Box( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = PrezelTheme.spacing.V20), + ) { + 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, + modifier = Modifier + .fillMaxWidth() + .verticalScroll(scrollState), + ) + + if (showTopGradient) { + VoiceRecordingScriptGradient( + modifier = Modifier.align(Alignment.TopCenter), + brush = Brush.verticalGradient( + colors = listOf(backgroundColor, Color.Transparent), + ), + ) + } + + if (showBottomGradient) { + VoiceRecordingScriptGradient( + modifier = Modifier.align(Alignment.BottomCenter), + brush = Brush.verticalGradient( + colors = listOf(Color.Transparent, backgroundColor), + ), + ) + } + } +} + +@Composable +private fun VoiceRecordingScriptGradient( + brush: Brush, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier + .fillMaxWidth() + .height(20.dp) + .background(brush), + ) +} + +@Composable +private fun VoiceRecordingStatusArea( + recordingState: AudioSessionState, + onClickRecordingControl: () -> Unit, +) { + Spacer(modifier = Modifier.height(PrezelTheme.spacing.V16)) + RecordingWaveform(modifier = Modifier.fillMaxWidth()) + Spacer(modifier = Modifier.height(recordingState.recordingStatusSpacing)) + + if (recordingState.isCompleted) { + RecordingPlayerControl( + recordingState = recordingState, + onClickRecordingControl = onClickRecordingControl, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = PrezelTheme.spacing.V20), + ) + } else { + RecordingTimer( + currentSeconds = recordingState.currentSeconds, + totalSeconds = recordingState.totalSeconds, + recordingState = recordingState, + modifier = Modifier.fillMaxWidth(), + ) + } +} + +@Composable +private fun ScriptZoomButton(modifier: Modifier = Modifier) { + IconButton( + modifier = modifier, + onClick = {}, + ) { + Icon( + painter = painterResource(PrezelIcons.ZoomIn), + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = PrezelTheme.colors.iconRegular, + ) + } +} + +@Composable +private fun RecordingWaveform(modifier: Modifier = Modifier) { + Spacer( + modifier = modifier + .height(60.dp) + .background(color = PrezelTheme.colors.bgLarge), + ) +} + +@Composable +private fun RecordingTimer( + currentSeconds: Int, + totalSeconds: Int, + recordingState: AudioSessionState, + modifier: Modifier = Modifier, +) { + val timerText = when (recordingState) { + is AudioSessionState.ReadyToPlay -> buildAnnotatedString { + withStyle(SpanStyle(color = PrezelTheme.colors.textLarge)) { + append(totalSeconds.toTimerText()) + } + } + + is AudioSessionState.Playing -> { + buildAnnotatedString { + withStyle(SpanStyle(color = PrezelTheme.colors.interactiveRegular)) { + append(currentSeconds.toTimerText()) + } + withStyle(SpanStyle(color = PrezelTheme.colors.textSmall)) { + append("/") + append(totalSeconds.toTimerText()) + } + } + } + + else -> buildAnnotatedString { + withStyle( + SpanStyle( + color = if (recordingState is AudioSessionState.PausedRecording) { + PrezelTheme.colors.textDisabled + } else { + PrezelTheme.colors.textMedium + }, + ), + ) { + append(currentSeconds.toTimerText()) + } + } + } + + Text( + text = timerText, + modifier = modifier, + color = PrezelTheme.colors.textMedium, + textAlign = if (recordingState.isCompleted) TextAlign.Start else TextAlign.Center, + style = PrezelTheme.typography.title1Medium, + ) +} + +@Composable +private fun RecordingPlayerControl( + recordingState: AudioSessionState, + onClickRecordingControl: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + ) { + RecordingTimer( + currentSeconds = recordingState.currentSeconds, + totalSeconds = recordingState.totalSeconds, + recordingState = recordingState, + modifier = Modifier.weight(1f), + ) + + RecordingRoundIconButton( + iconResId = recordingState.actionIconResId, + iconColor = recordingState.actionIconColor, + onClick = onClickRecordingControl, + ) + } +} + +@Composable +private fun RecordingRoundIconButton( + iconResId: Int, + iconColor: Color, + onClick: () -> Unit, +) { + PrezelIconButton( + iconResId = iconResId, + modifier = Modifier.size(48.dp), + buttonDefault = PrezelButtonDefaults.getDefault( + isIconOnly = true, + isRounded = true, + type = ButtonType.FILLED, + size = ButtonSize.REGULAR, + hierarchy = ButtonHierarchy.SECONDARY, + contentColor = iconColor, + backgroundColor = PrezelTheme.colors.bgLarge, + iconSize = 20.dp, + ), + onClick = onClick, + ) +} 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 cd83308b..e7aa50f6 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,26 +1,18 @@ package com.team.prezel.feature.analysis.impl.recording -import androidx.annotation.DrawableRes import androidx.compose.foundation.background 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.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.statusBarsPadding -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.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -28,41 +20,19 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.SpanStyle -import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp import com.team.prezel.core.audio.AudioSessionState import com.team.prezel.core.audio.AudioSource import com.team.prezel.core.common.event.EdgeToEdgeStatusBarStyle -import com.team.prezel.core.common.event.GlobalEvent -import com.team.prezel.core.common.event.GlobalEventBus -import com.team.prezel.core.designsystem.component.actions.area.PrezelButtonArea -import com.team.prezel.core.designsystem.component.actions.button.PrezelButton -import com.team.prezel.core.designsystem.component.actions.button.PrezelIconButton -import com.team.prezel.core.designsystem.component.actions.button.config.ButtonHierarchy -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.actions.button.config.PrezelButtonDefaults import com.team.prezel.core.designsystem.icon.PrezelIcons import com.team.prezel.core.designsystem.preview.BasicPreview import com.team.prezel.core.designsystem.theme.PrezelTheme -import com.team.prezel.feature.analysis.impl.R import com.team.prezel.feature.analysis.impl.contract.AnalysisFlowStep import com.team.prezel.feature.analysis.impl.contract.AnalysisFlowUiState import com.team.prezel.feature.analysis.impl.contract.AnalysisForm -import dagger.hilt.EntryPoint -import dagger.hilt.InstallIn -import dagger.hilt.android.EntryPointAccessors -import dagger.hilt.components.SingletonComponent import kotlinx.coroutines.delay @Composable @@ -137,36 +107,6 @@ private fun VoiceRecordingScreen( } } -@Composable -private fun VoiceRecordingStatusBarStyle(style: EdgeToEdgeStatusBarStyle) { - if (LocalInspectionMode.current) return - - val globalEventBus = rememberGlobalEventBus() - - LaunchedEffect(globalEventBus, style) { - globalEventBus.emit(GlobalEvent.ChangeEdgeToEdgeStatusBarStyle(style)) - } - - DisposableEffect(globalEventBus) { - onDispose { - globalEventBus.tryEmit(GlobalEvent.ResetEdgeToEdgeStatusBarStyle) - } - } -} - -@Composable -private fun rememberGlobalEventBus(): GlobalEventBus { - val applicationContext = LocalContext.current.applicationContext - - return remember(applicationContext) { - EntryPointAccessors - .fromApplication( - applicationContext, - VoiceRecordingGlobalEventBusEntryPoint::class.java, - ).globalEventBus() - } -} - @Composable private fun VoiceRecordingCompletedTopBar(onBack: () -> Unit) { Box( @@ -231,486 +171,6 @@ private fun VoiceRecordingCloseButton( } } -@Composable -private fun VoiceRecordingContent( - script: String, - recordingState: AudioSessionState, - onClickRecordingControl: () -> Unit, - modifier: Modifier = Modifier, -) { - Column( - modifier = modifier - .fillMaxWidth() - .background(PrezelTheme.colors.bgRegular) - .padding(vertical = PrezelTheme.spacing.V16), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - if (!recordingState.isCompleted) { - VoiceRecordingScriptHeader() - Spacer(modifier = Modifier.height(PrezelTheme.spacing.V16)) - } - - VoiceRecordingScriptBody( - script = script, - modifier = Modifier.weight(1f), - ) - - if (recordingState !is AudioSessionState.Idle) { - VoiceRecordingStatusArea( - recordingState = recordingState, - onClickRecordingControl = onClickRecordingControl, - ) - } - } -} - -@Composable -private fun VoiceRecordingScriptHeader() { - Box( - modifier = Modifier - .fillMaxWidth() - .height(20.dp) - .padding(horizontal = PrezelTheme.spacing.V20), - ) { - Text( - text = stringResource(R.string.feature_analysis_impl_voice_recording_script_label), - color = PrezelTheme.colors.textMedium, - style = PrezelTheme.typography.body3Medium, - modifier = Modifier.align(Alignment.CenterStart), - ) - - ScriptZoomButton( - modifier = Modifier - .align(Alignment.CenterEnd) - .size(48.dp), - ) - } -} - -@Composable -private fun VoiceRecordingScriptBody( - script: String, - modifier: Modifier = Modifier, -) { - Box( - modifier = modifier - .fillMaxWidth() - .padding(horizontal = PrezelTheme.spacing.V20), - ) { - 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, - modifier = Modifier - .fillMaxWidth() - .verticalScroll(rememberScrollState()), - ) - - VoiceRecordingScriptGradient( - modifier = Modifier.align(Alignment.TopCenter), - brush = Brush.verticalGradient( - colors = listOf(backgroundColor, Color.Transparent), - ), - ) - VoiceRecordingScriptGradient( - modifier = Modifier.align(Alignment.BottomCenter), - brush = Brush.verticalGradient( - colors = listOf(Color.Transparent, backgroundColor), - ), - ) - } -} - -@Composable -private fun VoiceRecordingScriptGradient( - brush: Brush, - modifier: Modifier = Modifier, -) { - Box( - modifier = modifier - .fillMaxWidth() - .height(20.dp) - .background(brush), - ) -} - -@Composable -private fun VoiceRecordingStatusArea( - recordingState: AudioSessionState, - onClickRecordingControl: () -> Unit, -) { - Spacer(modifier = Modifier.height(PrezelTheme.spacing.V16)) - RecordingWaveform(modifier = Modifier.fillMaxWidth()) - Spacer(modifier = Modifier.height(recordingState.recordingStatusSpacing)) - - if (recordingState.isCompleted) { - RecordingPlayerControl( - recordingState = recordingState, - onClickRecordingControl = onClickRecordingControl, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = PrezelTheme.spacing.V20), - ) - } else { - RecordingTimer( - currentSeconds = recordingState.currentSeconds, - totalSeconds = recordingState.totalSeconds, - recordingState = recordingState, - modifier = Modifier.fillMaxWidth(), - ) - } -} - -@Composable -private fun ScriptZoomButton(modifier: Modifier = Modifier) { - IconButton( - modifier = modifier, - onClick = {}, - ) { - Icon( - painter = painterResource(PrezelIcons.ZoomIn), - contentDescription = null, - modifier = Modifier.size(24.dp), - tint = PrezelTheme.colors.iconRegular, - ) - } -} - -@Composable -private fun RecordingWaveform(modifier: Modifier = Modifier) { - Spacer( - modifier = modifier - .height(60.dp) - .background(color = PrezelTheme.colors.bgLarge), - ) -} - -@Composable -private fun RecordingTimer( - currentSeconds: Int, - totalSeconds: Int, - recordingState: AudioSessionState, - modifier: Modifier = Modifier, -) { - val timerText = when (recordingState) { - is AudioSessionState.ReadyToPlay -> buildAnnotatedString { - withStyle(SpanStyle(color = PrezelTheme.colors.textLarge)) { - append(totalSeconds.toTimerText()) - } - } - - is AudioSessionState.Playing -> { - buildAnnotatedString { - withStyle(SpanStyle(color = PrezelTheme.colors.interactiveRegular)) { - append(currentSeconds.toTimerText()) - } - withStyle(SpanStyle(color = PrezelTheme.colors.textSmall)) { - append("/") - append(totalSeconds.toTimerText()) - } - } - } - - else -> buildAnnotatedString { - withStyle( - SpanStyle( - color = if (recordingState is AudioSessionState.PausedRecording) { - PrezelTheme.colors.textDisabled - } else { - PrezelTheme.colors.textMedium - }, - ), - ) { - append(currentSeconds.toTimerText()) - } - } - } - - Text( - text = timerText, - modifier = modifier, - color = PrezelTheme.colors.textMedium, - textAlign = if (recordingState.isCompleted) TextAlign.Start else TextAlign.Center, - style = PrezelTheme.typography.title1Medium, - ) -} - -@Composable -private fun RecordingPlayerControl( - recordingState: AudioSessionState, - onClickRecordingControl: () -> Unit, - modifier: Modifier = Modifier, -) { - Row( - modifier = modifier, - verticalAlignment = Alignment.CenterVertically, - ) { - RecordingTimer( - currentSeconds = recordingState.currentSeconds, - totalSeconds = recordingState.totalSeconds, - recordingState = recordingState, - modifier = Modifier.weight(1f), - ) - - RecordingRoundIconButton( - iconResId = recordingState.actionIconResId, - iconColor = recordingState.actionIconColor, - onClick = onClickRecordingControl, - ) - } -} - -@Composable -private fun VoiceRecordingButtonArea( - recordingState: AudioSessionState, - analyzeEnabled: Boolean, - onClickRecordingControl: () -> Unit, - onStopRecording: () -> Unit, - onResetRecording: () -> Unit, - onAnalyze: () -> Unit, -) { - when (recordingState) { - AudioSessionState.Idle -> IdleRecordingButtonArea( - recordingState = recordingState, - onClickRecordingControl = onClickRecordingControl, - ) - - is AudioSessionState.Recording, - is AudioSessionState.PausedRecording, - -> ActiveRecordingButtonArea( - recordingState = recordingState, - onClickRecordingControl = onClickRecordingControl, - onStopRecording = onStopRecording, - ) - - is AudioSessionState.ReadyToPlay, - is AudioSessionState.Playing, - -> CompletedRecordingButtonArea( - analyzeEnabled = analyzeEnabled, - onResetRecording = onResetRecording, - onAnalyze = onAnalyze, - ) - } -} - -@Composable -private fun IdleRecordingButtonArea( - recordingState: AudioSessionState, - onClickRecordingControl: () -> Unit, -) { - PrezelButtonArea( - showBackground = true, - mainButton = { buttonModifier -> - RecordingIconButton( - iconResId = recordingState.actionIconResId, - iconColor = recordingState.actionIconColor, - modifier = buttonModifier, - onClick = onClickRecordingControl, - ) - }, - ) -} - -@Composable -private fun ActiveRecordingButtonArea( - recordingState: AudioSessionState, - onClickRecordingControl: () -> Unit, - onStopRecording: () -> Unit, -) { - PrezelButtonArea( - showBackground = true, - isVertical = false, - isStrongStrength = false, - mainButton = { buttonModifier -> - RecordingIconButton( - iconResId = PrezelIcons.Stop, - iconColor = PrezelTheme.colors.iconRegular, - modifier = buttonModifier, - onClick = onStopRecording, - ) - }, - subButton = { buttonModifier -> - RecordingIconButton( - iconResId = recordingState.actionIconResId, - iconColor = recordingState.actionIconColor, - modifier = buttonModifier, - onClick = onClickRecordingControl, - ) - }, - ) -} - -@Composable -private fun CompletedRecordingButtonArea( - analyzeEnabled: Boolean, - onResetRecording: () -> Unit, - onAnalyze: () -> Unit, -) { - PrezelButtonArea( - showBackground = true, - isVertical = false, - mainButton = { buttonModifier -> - PrezelButton( - text = stringResource(R.string.feature_analysis_impl_analyze), - modifier = buttonModifier, - enabled = analyzeEnabled, - type = ButtonType.FILLED, - hierarchy = ButtonHierarchy.PRIMARY, - onClick = onAnalyze, - ) - }, - subButton = { buttonModifier -> - RecordingResetButton( - iconResId = PrezelIcons.Reset, - iconColor = PrezelTheme.colors.iconRegular, - modifier = buttonModifier.width(52.dp), - onClick = onResetRecording, - ) - }, - ) -} - -@Composable -private fun RecordingRoundIconButton( - @DrawableRes iconResId: Int, - iconColor: Color, - onClick: () -> Unit, -) { - PrezelIconButton( - iconResId = iconResId, - modifier = Modifier.size(48.dp), - buttonDefault = PrezelButtonDefaults.getDefault( - isIconOnly = true, - isRounded = true, - type = ButtonType.FILLED, - size = ButtonSize.REGULAR, - hierarchy = ButtonHierarchy.SECONDARY, - contentColor = iconColor, - backgroundColor = PrezelTheme.colors.bgLarge, - iconSize = 20.dp, - ), - onClick = onClick, - ) -} - -@Composable -private fun RecordingResetButton( - @DrawableRes iconResId: Int, - iconColor: Color, - modifier: Modifier = Modifier, - onClick: () -> Unit, -) { - PrezelIconButton( - iconResId = iconResId, - modifier = modifier.height(48.dp), - buttonDefault = PrezelButtonDefaults.getDefault( - isIconOnly = true, - isRounded = false, - type = ButtonType.GHOST, - size = ButtonSize.REGULAR, - hierarchy = ButtonHierarchy.SECONDARY, - contentColor = iconColor, - backgroundColor = Color.Transparent, - iconSize = 20.dp, - ), - onClick = onClick, - ) -} - -@Composable -private fun RecordingIconButton( - @DrawableRes iconResId: Int, - iconColor: Color, - modifier: Modifier = Modifier, - onClick: () -> Unit, -) { - PrezelIconButton( - iconResId = iconResId, - modifier = modifier - .height(48.dp) - .clip(RoundedCornerShape(PrezelTheme.radius.V8)), - buttonDefault = PrezelButtonDefaults.getDefault( - isIconOnly = true, - type = ButtonType.FILLED, - size = ButtonSize.REGULAR, - hierarchy = ButtonHierarchy.SECONDARY, - isRounded = false, - contentColor = iconColor, - backgroundColor = PrezelTheme.colors.bgLarge, - iconSize = 20.dp, - ), - onClick = onClick, - ) -} - -private val AudioSessionState.currentSeconds: Int - get() = when (this) { - AudioSessionState.Idle -> 0 - is AudioSessionState.Recording -> elapsedSeconds - is AudioSessionState.PausedRecording -> elapsedSeconds - is AudioSessionState.ReadyToPlay -> positionSeconds - is AudioSessionState.Playing -> positionSeconds - } - -private val AudioSessionState.totalSeconds: Int - get() = when (this) { - AudioSessionState.Idle, - is AudioSessionState.Recording, - is AudioSessionState.PausedRecording, - -> 0 - - is AudioSessionState.ReadyToPlay -> durationSeconds - is AudioSessionState.Playing -> durationSeconds - } - -private val AudioSessionState.isCompleted: Boolean - get() = this is AudioSessionState.ReadyToPlay || this is AudioSessionState.Playing - -private val AudioSessionState.recordingStatusSpacing - @Composable get() = if (isCompleted) PrezelTheme.spacing.V12 else PrezelTheme.spacing.V8 - -private val AudioSessionState.titleResId: Int - get() = when (this) { - AudioSessionState.Idle -> R.string.feature_analysis_impl_voice_recording_ready_title - is AudioSessionState.Recording -> R.string.feature_analysis_impl_voice_recording_recording_title - is AudioSessionState.PausedRecording -> R.string.feature_analysis_impl_voice_recording_paused_title - is AudioSessionState.ReadyToPlay -> R.string.feature_analysis_impl_voice_recording_playing_title - is AudioSessionState.Playing -> R.string.feature_analysis_impl_voice_recording_playing_title - } - -private val AudioSessionState.actionIconResId: Int - get() = when (this) { - AudioSessionState.Idle -> PrezelIcons.Recording - is AudioSessionState.Recording -> PrezelIcons.Pause - is AudioSessionState.PausedRecording -> PrezelIcons.Recording - is AudioSessionState.ReadyToPlay -> PrezelIcons.Play - is AudioSessionState.Playing -> PrezelIcons.Pause - } - -private val AudioSessionState.actionIconColor: Color - @Composable get() = when (this) { - AudioSessionState.Idle -> PrezelTheme.colors.feedbackBadRegular - is AudioSessionState.PausedRecording -> PrezelTheme.colors.feedbackBadRegular - is AudioSessionState.Recording, - is AudioSessionState.ReadyToPlay, - is AudioSessionState.Playing, - -> PrezelTheme.colors.iconRegular - } - -private fun Int.toTimerText(): String { - val minutes = this / 60 - val seconds = this % 60 - return "%02d:%02d".format(minutes, seconds) -} - -@EntryPoint -@InstallIn(SingletonComponent::class) -private interface VoiceRecordingGlobalEventBusEntryPoint { - fun globalEventBus(): GlobalEventBus -} - @BasicPreview @Composable private fun VoiceRecordingScreenIdlePreview() { @@ -784,58 +244,64 @@ private fun VoiceRecordingScreenInteractiveFlowPreview() { var recordingState by remember { mutableStateOf(AudioSessionState.Idle) } LaunchedEffect(recordingState) { - while (recordingState is AudioSessionState.Recording) { + while (recordingState is AudioSessionState.Recording || recordingState is AudioSessionState.Playing) { delay(1_000) - recordingState = when (val state = recordingState) { - is AudioSessionState.Recording -> state.copy(elapsedSeconds = state.elapsedSeconds + 1) - else -> state - } + recordingState = recordingState.tick() } } PrezelTheme { VoiceRecordingScreen( - script = "한 번쯤 발표하면서 긴장하신 경험 있으시죠. 오늘도 다들 긴장되는 마음으로 오셨을 것 같습니다.", - recordingState = recordingState, - analyzeEnabled = recordingState.isCompleted, - onClickRecordingControl = { - recordingState = when (val state = recordingState) { - AudioSessionState.Idle -> AudioSessionState.Recording(elapsedSeconds = 0) - is AudioSessionState.Recording -> AudioSessionState.PausedRecording(elapsedSeconds = state.elapsedSeconds) - is AudioSessionState.PausedRecording -> AudioSessionState.Recording(elapsedSeconds = state.elapsedSeconds) - is AudioSessionState.ReadyToPlay -> AudioSessionState.Playing( - source = state.source, - durationSeconds = state.durationSeconds, - positionSeconds = 0, - ) - - is AudioSessionState.Playing -> AudioSessionState.ReadyToPlay( - source = state.source, - durationSeconds = state.durationSeconds, - positionSeconds = state.positionSeconds, - ) - } - }, - onStopRecording = { - recordingState = when (val state = recordingState) { - is AudioSessionState.Recording -> AudioSessionState.ReadyToPlay( - source = AudioSource.RecordedFile(filePath = "preview.m4a"), - durationSeconds = state.elapsedSeconds.coerceAtLeast(1), - ) - - is AudioSessionState.PausedRecording -> AudioSessionState.ReadyToPlay( - source = AudioSource.RecordedFile(filePath = "preview.m4a"), - durationSeconds = state.elapsedSeconds.coerceAtLeast(1), - ) - - else -> state - } - }, - onResetRecording = { - recordingState = AudioSessionState.Idle - }, + uiState = AnalysisFlowUiState( + step = AnalysisFlowStep.VOICE_RECORDING, + form = AnalysisForm(script = "한 번쯤 발표하면서 긴장하신 경험 있으시죠. 오늘도 다들 긴장되는 마음으로 오셨을 것 같습니다."), + recordingState = recordingState, + ), + onClickRecordingControl = { recordingState = recordingState.nextControlState() }, + onStopRecording = { recordingState = recordingState.stopPreviewRecording() }, + onResetRecording = { recordingState = AudioSessionState.Idle }, onAnalyze = {}, onBack = {}, ) } } + +private fun AudioSessionState.tick(): AudioSessionState = + when (this) { + is AudioSessionState.Recording -> copy(elapsedSeconds = elapsedSeconds + 1) + is AudioSessionState.Playing -> copy(positionSeconds = (positionSeconds + 1).coerceAtMost(durationSeconds)) + else -> this + } + +private fun AudioSessionState.nextControlState(): AudioSessionState = + when (this) { + AudioSessionState.Idle -> AudioSessionState.Recording(elapsedSeconds = 0) + is AudioSessionState.Recording -> AudioSessionState.PausedRecording(elapsedSeconds = elapsedSeconds) + is AudioSessionState.PausedRecording -> AudioSessionState.Recording(elapsedSeconds = elapsedSeconds) + is AudioSessionState.ReadyToPlay -> AudioSessionState.Playing( + source = source, + durationSeconds = durationSeconds, + positionSeconds = 0, + ) + + is AudioSessionState.Playing -> AudioSessionState.ReadyToPlay( + source = source, + durationSeconds = durationSeconds, + positionSeconds = positionSeconds, + ) + } + +private fun AudioSessionState.stopPreviewRecording(): AudioSessionState = + when (this) { + is AudioSessionState.Recording -> AudioSessionState.ReadyToPlay( + source = AudioSource.RecordedFile(filePath = "preview.m4a"), + durationSeconds = elapsedSeconds.coerceAtLeast(1), + ) + + is AudioSessionState.PausedRecording -> AudioSessionState.ReadyToPlay( + source = AudioSource.RecordedFile(filePath = "preview.m4a"), + durationSeconds = elapsedSeconds.coerceAtLeast(1), + ) + + else -> this + } 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 new file mode 100644 index 00000000..a48c95ed --- /dev/null +++ b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/recording/VoiceRecordingStateProperties.kt @@ -0,0 +1,68 @@ +package com.team.prezel.feature.analysis.impl.recording + +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import com.team.prezel.core.audio.AudioSessionState +import com.team.prezel.core.designsystem.icon.PrezelIcons +import com.team.prezel.core.designsystem.theme.PrezelTheme +import com.team.prezel.feature.analysis.impl.R + +internal val AudioSessionState.currentSeconds: Int + get() = when (this) { + AudioSessionState.Idle -> 0 + is AudioSessionState.Recording -> elapsedSeconds + is AudioSessionState.PausedRecording -> elapsedSeconds + is AudioSessionState.ReadyToPlay -> positionSeconds + is AudioSessionState.Playing -> positionSeconds + } + +internal val AudioSessionState.totalSeconds: Int + get() = when (this) { + AudioSessionState.Idle, + is AudioSessionState.Recording, + is AudioSessionState.PausedRecording, + -> 0 + + is AudioSessionState.ReadyToPlay -> durationSeconds + is AudioSessionState.Playing -> durationSeconds + } + +internal val AudioSessionState.isCompleted: Boolean + get() = this is AudioSessionState.ReadyToPlay || this is AudioSessionState.Playing + +internal val AudioSessionState.recordingStatusSpacing + @Composable get() = if (isCompleted) PrezelTheme.spacing.V12 else PrezelTheme.spacing.V8 + +internal val AudioSessionState.titleResId: Int + get() = when (this) { + AudioSessionState.Idle -> R.string.feature_analysis_impl_voice_recording_ready_title + is AudioSessionState.Recording -> R.string.feature_analysis_impl_voice_recording_recording_title + is AudioSessionState.PausedRecording -> R.string.feature_analysis_impl_voice_recording_paused_title + is AudioSessionState.ReadyToPlay -> R.string.feature_analysis_impl_voice_recording_playing_title + is AudioSessionState.Playing -> R.string.feature_analysis_impl_voice_recording_playing_title + } + +internal val AudioSessionState.actionIconResId: Int + get() = when (this) { + AudioSessionState.Idle -> PrezelIcons.Recording + is AudioSessionState.Recording -> PrezelIcons.Pause + is AudioSessionState.PausedRecording -> PrezelIcons.Recording + is AudioSessionState.ReadyToPlay -> PrezelIcons.Play + is AudioSessionState.Playing -> PrezelIcons.Pause + } + +internal val AudioSessionState.actionIconColor: Color + @Composable get() = when (this) { + AudioSessionState.Idle -> PrezelTheme.colors.feedbackBadRegular + is AudioSessionState.PausedRecording -> PrezelTheme.colors.feedbackBadRegular + is AudioSessionState.Recording, + is AudioSessionState.ReadyToPlay, + is AudioSessionState.Playing, + -> PrezelTheme.colors.iconRegular + } + +internal fun Int.toTimerText(): String { + val minutes = this / 60 + val seconds = this % 60 + return "%02d:%02d".format(minutes, seconds) +} diff --git a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/recording/VoiceRecordingStatusBarStyle.kt b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/recording/VoiceRecordingStatusBarStyle.kt new file mode 100644 index 00000000..8291ed74 --- /dev/null +++ b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/recording/VoiceRecordingStatusBarStyle.kt @@ -0,0 +1,51 @@ +package com.team.prezel.feature.analysis.impl.recording + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalInspectionMode +import com.team.prezel.core.common.event.EdgeToEdgeStatusBarStyle +import com.team.prezel.core.common.event.GlobalEvent +import com.team.prezel.core.common.event.GlobalEventBus +import dagger.hilt.EntryPoint +import dagger.hilt.InstallIn +import dagger.hilt.android.EntryPointAccessors +import dagger.hilt.components.SingletonComponent + +@Composable +internal fun VoiceRecordingStatusBarStyle(style: EdgeToEdgeStatusBarStyle) { + if (LocalInspectionMode.current) return + + val globalEventBus = rememberGlobalEventBus() + + LaunchedEffect(globalEventBus, style) { + globalEventBus.emit(GlobalEvent.ChangeEdgeToEdgeStatusBarStyle(style)) + } + + DisposableEffect(globalEventBus) { + onDispose { + globalEventBus.tryEmit(GlobalEvent.ResetEdgeToEdgeStatusBarStyle) + } + } +} + +@Composable +private fun rememberGlobalEventBus(): GlobalEventBus { + val applicationContext = LocalContext.current.applicationContext + + return remember(applicationContext) { + EntryPointAccessors + .fromApplication( + applicationContext, + VoiceRecordingGlobalEventBusEntryPoint::class.java, + ).globalEventBus() + } +} + +@EntryPoint +@InstallIn(SingletonComponent::class) +private interface VoiceRecordingGlobalEventBusEntryPoint { + fun globalEventBus(): GlobalEventBus +} 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 bb84cd1c..6f7a310f 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 @@ -165,6 +165,7 @@ private fun ScriptInputScreen( maxLength = SCRIPT_MAX_LENGTH, modifier = Modifier.fillMaxWidth(), ) + Spacer(modifier = Modifier.height(PrezelTheme.spacing.V16)) } } } @@ -355,3 +356,44 @@ private fun ScriptInputDirectScreenPreview() { ) } } + +@BasicPreview +@Composable +private fun ScriptInputLongDirectScreenPreview() { + PrezelTheme { + ScriptInputScreen( + uiState = AnalysisFlowUiState( + step = AnalysisFlowStep.SCRIPT_INPUT, + form = AnalysisForm( + scriptInputType = ScriptInputType.DIRECT_INPUT, + script = + """ + 안녕하세요 자신의 말로 자신있게 세상을 설득할 수 있는 날을 기다리는 팀 손가락입니다. + 저희 팀은 고승환, 박하영, 조민경, 최수빈, 한효주 총 5명으로 구성되어 있으며, + 다음과 같은 목차로 발표 진행하겠습니다. + 한 번쯤 발표하면서 긴장하신 경험 있으시죠. 오늘도 다들 긴장되는 마음으로 오셨을 것 같습니다. + 저희는 학교에서의 간단한 자기소개부터 회사의 성과보고까지 정말 다양하게, + 그리고 정말 자주 발표를 경험합니다. 하지만 많은 발표를 해왔음에도 불구하고 + 발표를 생각했을 때 긴장하게 되는데요. 이처럼 발표를 앞둔 상황에서 경험하는 + 두려움과 심리적 압박감을 발표 불안이라 합니다. + 면접에서도, 학교에서도, 발표 능력을 기본 역량처럼 여기는 사회 분위기로 인해 + 이런 불안이 더해지고자 때문이었습니다. 가장 발표를 자주 경험하는 직장인을 예시로 들었을 때, + 실수에 대한 두려움을 발표 불안의 주된 원인으로 꼽았습니다. + 즉, 발표와 가까운 환경의 사람들조차 실수가 두려워 발표에 어려움을 겪고 있다는 건데요. + 이때 사람들은 클래스 수강, 집단 상담 등 발표 불안을 이겨내기 위해 다양한 시도를 하고 있었습니다. + 특히 발표 코칭 학원을 등록하며 적극적인 대처를 취하는 사람들까지는 증가하고 있습니다. + 그러나 대학생과 사회 초년생의 평균 수입과 비교했을 때 비싼 비용과 시간적 여유가 없어 + 지속적으로 수강하기 어렵다는 문제점이 있었습니다. + 비용과 시간, 이런 고질적인 문제를 해결할 방법은 없을까요? + """.trimIndent(), + ), + ), + onSelectInputType = {}, + onScriptChange = {}, + onScriptFileSelected = {}, + onNext = {}, + onSkip = {}, + onBack = {}, + ) + } +} From b3ab3f89916a54771081cddddc380c22836d8c48 Mon Sep 17 00:00:00 2001 From: Ham BeomJoon Date: Tue, 26 May 2026 20:08:35 +0900 Subject: [PATCH 04/15] =?UTF-8?q?feat:=20=EB=B6=84=EC=84=9D=20=ED=9D=90?= =?UTF-8?q?=EB=A6=84=20=EB=A1=9C=EC=A7=81=20=EA=B0=9C=EC=84=A0=20=EB=B0=8F?= =?UTF-8?q?=20=EC=9D=8C=EC=84=B1=20=EB=85=B9=EC=9D=8C=20UI=20=EC=97=85?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `AnalysisFlowViewModel`의 분석 로직을 개선하고 결과 화면 이동 방식을 `NavigateToReport` Effect 전송으로 변경 - `AnalyzePresentationRecordingUseCase` 사용을 중단하고 `AnalyzePresentationUseCase`로 단일화 - 음성 녹음 화면에 `PrezelVoiceChrome` 컴포넌트를 적용하여 시각적 피드백 강화 - `AudioSessionState`를 `VoiceChromeStatus`로 변환하는 확장 함수 추가 - `AnalysisFlowStep`에서 `REPORT` 단계를 제거하고 화면 전환 흐름 최적화 - `PrezelVoiceChrome` 컴포넌트가 부모 너비에 맞게 확장되도록 수정 - 일시정지 상태에 대한 시스템 문자열 리소스 수정 (`일시정지됨` -> `일시정지됐어요`) --- .../component/voice/PrezelVoiceChrome.kt | 4 +- .../src/main/res/values/strings.xml | 2 +- .../analysis/impl/AnalysisFlowViewModel.kt | 71 ++++--------------- .../feature/analysis/impl/AnalysisScreen.kt | 2 - .../impl/contract/AnalysisFlowUiState.kt | 1 - .../impl/recording/VoiceRecordingScreen.kt | 40 ++++++----- .../VoiceRecordingStateProperties.kt | 11 +++ 7 files changed, 47 insertions(+), 84 deletions(-) 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 254f6958..ce4a6bad 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 @@ -17,7 +17,6 @@ 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.size import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable @@ -129,7 +128,8 @@ private fun PrezelVoiceChromeContent( Box( modifier = modifier - .size(width = 360.dp, height = 160.dp) + .fillMaxWidth() + .height(160.dp) .voiceChromeBackground( status = status, gradientStop = gradientStop, diff --git a/Prezel/core/designsystem/src/main/res/values/strings.xml b/Prezel/core/designsystem/src/main/res/values/strings.xml index a51ac86d..ac3b09c2 100644 --- a/Prezel/core/designsystem/src/main/res/values/strings.xml +++ b/Prezel/core/designsystem/src/main/res/values/strings.xml @@ -14,7 +14,7 @@ 발화 트랙 툴팁 닫기 듣고 있어요 - 일시정지됨 + 일시정지됐어요 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 caf6a3b2..4e677b20 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 @@ -4,7 +4,6 @@ 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.practice.AnalyzePresentationRecordingUseCase import com.team.prezel.core.domain.usecase.presentation.AnalyzePresentationUseCase import com.team.prezel.core.model.presentation.Audience import com.team.prezel.core.model.presentation.Category @@ -91,20 +90,12 @@ internal class AnalysisFlowViewModel @Inject constructor( analyzeJob?.cancel() analyzeJob = viewModelScope.launch { - val analysisResult = submission.analyzePresentationRecording( - analysisFileCache = analysisFileCache, - analyzePresentationRecordingUseCase = analyzePresentationRecordingUseCase, - ) + val analysisResult = submission.analyzePresentationRecording() analysisResult.fold( onSuccess = { result -> if (currentState.step == AnalysisFlowStep.ANALYZING) { - updateState { - copy( - step = AnalysisFlowStep.REPORT, - analysisResult = result, - ) - } + sendEffect(AnalysisFlowUiEffect.NavigateToReport(presentationId = result)) } }, onFailure = { throwable -> @@ -114,18 +105,17 @@ internal class AnalysisFlowViewModel @Inject constructor( } } - sendEffect(AnalysisFlowUiEffect.NavigateToReport(presentationId = result)) - } - }.onFailure { throwable -> handleAnalysisFailure(throwable.toAnalysisFailureAction()) } - } - } - private suspend fun PresentationAnalysisSubmission.analyzePresentationRecording(): Result = runCatching { - val audioFile = analysisFileCache.copyUriToCache( - uriString = audioFileUri, - prefix = "audio", - ) + val audioFilePath = audioFileUri + ?.let { uri -> + val audioFile = analysisFileCache.copyUriToCache( + uriString = uri, + prefix = "audio", + ) + audioFile.absolutePath + } + ?: recordingFilePath val scriptFile = scriptFileUri?.let { uri -> analysisFileCache.copyUriToCache( uriString = uri, @@ -141,7 +131,7 @@ internal class AnalysisFlowViewModel @Inject constructor( audience = audience, script = script, scriptFilePath = scriptFile?.absolutePath, - audioFilePath = audioFile.absolutePath, + audioFilePath = audioFilePath, ).getOrThrow() } @@ -215,10 +205,7 @@ internal class AnalysisFlowViewModel @Inject constructor( AnalysisFlowStep.AUDIO_UPLOAD -> AnalysisFlowStep.SCRIPT_INPUT AnalysisFlowStep.VOICE_RECORDING -> AnalysisFlowStep.SCRIPT_INPUT AnalysisFlowStep.ANALYZING -> AnalysisFlowStep.VOICE_RECORDING - AnalysisFlowStep.REPORT -> AnalysisFlowStep.VOICE_RECORDING AnalysisFlowStep.FILE_RECOGNITION_FAILED -> AnalysisFlowStep.VOICE_RECORDING - AnalysisFlowStep.ANALYZING -> AnalysisFlowStep.AUDIO_UPLOAD - AnalysisFlowStep.FILE_RECOGNITION_FAILED -> AnalysisFlowStep.AUDIO_UPLOAD AnalysisFlowStep.SCRIPT_FILE_RECOGNITION_FAILED -> AnalysisFlowStep.SCRIPT_INPUT } @@ -301,40 +288,6 @@ private fun AnalysisForm.selectSituationOption(option: AnalysisSituationOption): is AnalysisSituationOption.AudienceOption -> copy(audience = option.audience) } -private suspend fun PresentationAnalysisSubmission.analyzePresentationRecording( - analysisFileCache: AnalysisFileCache, - analyzePresentationRecordingUseCase: AnalyzePresentationRecordingUseCase, -): Result = - runCatching { - val audioFilePath = audioFileUri - ?.let { uri -> - val audioFile = analysisFileCache.copyUriToCache( - uriString = uri, - prefix = "audio", - ) - audioFile.absolutePath - } - ?: recordingFilePath - val scriptFile = scriptFileUri?.let { uri -> - analysisFileCache.copyUriToCache( - uriString = uri, - prefix = "script", - ) - } - - analyzePresentationRecordingUseCase( - name = name, - date = date.toRequestDate(), - category = category, - purpose = purpose, - style = style, - audience = audience, - script = script, - scriptFilePath = scriptFile?.absolutePath, - audioFilePath = audioFilePath, - ).getOrThrow() - } - private fun AnalysisFlowUiState.toPresentationAnalysisSubmissionOrNull(): PresentationAnalysisSubmission? { val category = form.category ?: return null val purpose = form.purpose ?: return 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 48d4f8a1..fe63c31e 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 @@ -149,7 +149,6 @@ private fun AnalysisStepContent( ) AnalysisFlowStep.ANALYZING, - AnalysisFlowStep.REPORT, AnalysisFlowStep.FILE_RECOGNITION_FAILED, AnalysisFlowStep.SCRIPT_FILE_RECOGNITION_FAILED, -> AnalysisResultStepContent( @@ -211,7 +210,6 @@ private fun AnalysisInputStepContent( ) AnalysisFlowStep.ANALYZING, - AnalysisFlowStep.REPORT, AnalysisFlowStep.FILE_RECOGNITION_FAILED, AnalysisFlowStep.SCRIPT_FILE_RECOGNITION_FAILED, -> Unit diff --git a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/contract/AnalysisFlowUiState.kt b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/contract/AnalysisFlowUiState.kt index fc62983a..f7f5d48b 100644 --- a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/contract/AnalysisFlowUiState.kt +++ b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/contract/AnalysisFlowUiState.kt @@ -13,7 +13,6 @@ internal data class AnalysisFlowUiState( val step: AnalysisFlowStep = AnalysisFlowStep.PRESENTATION_SCHEDULE, val form: AnalysisForm = AnalysisForm(), val recordingState: AudioSessionState = AudioSessionState.Idle, - val analysisResult: PresentationRecordingAnalysisResult? = null, ) : UiState { val progress: Float get() = when (step) { 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 e7aa50f6..7a362aa4 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 @@ -11,7 +11,6 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.material3.Icon import androidx.compose.material3.IconButton -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -22,11 +21,12 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import com.team.prezel.core.audio.AudioSessionState import com.team.prezel.core.audio.AudioSource import com.team.prezel.core.common.event.EdgeToEdgeStatusBarStyle +import com.team.prezel.core.designsystem.component.voice.PrezelVoiceChrome +import com.team.prezel.core.designsystem.component.voice.VoiceChromeGradient import com.team.prezel.core.designsystem.icon.PrezelIcons import com.team.prezel.core.designsystem.preview.BasicPreview import com.team.prezel.core.designsystem.theme.PrezelTheme @@ -83,7 +83,7 @@ private fun VoiceRecordingScreen( if (recordingState.isCompleted) { VoiceRecordingCompletedTopBar(onBack = onBack) } else { - VoiceRecordingHeader( + VoiceRecordingChromeTopBar( recordingState = recordingState, onBack = onBack, ) @@ -119,36 +119,38 @@ private fun VoiceRecordingCompletedTopBar(onBack: () -> Unit) { onBack = onBack, modifier = Modifier .align(Alignment.TopEnd) - .statusBarsPadding() .padding(top = PrezelTheme.spacing.V4, end = PrezelTheme.spacing.V8), ) } } @Composable -private fun VoiceRecordingHeader( +private fun VoiceRecordingChromeTopBar( recordingState: AudioSessionState, onBack: () -> Unit, ) { - Box( + Column( modifier = Modifier .fillMaxWidth() - .height(252.dp) .background(PrezelTheme.colors.bgMedium), ) { - VoiceRecordingCloseButton( - onBack = onBack, - modifier = Modifier - .align(Alignment.TopEnd) - .padding(top = PrezelTheme.spacing.V4, end = PrezelTheme.spacing.V8), - ) + Box( + modifier = Modifier.fillMaxWidth(), + ) { + VoiceRecordingCloseButton( + onBack = onBack, + modifier = Modifier + .align(Alignment.TopEnd) + .statusBarsPadding() + .padding(top = PrezelTheme.spacing.V4, end = PrezelTheme.spacing.V8), + ) + } - Text( - text = stringResource(recordingState.titleResId), - color = PrezelTheme.colors.interactiveRegular, - style = PrezelTheme.typography.title1Bold, - modifier = Modifier.align(Alignment.Center), - textAlign = TextAlign.Center, + PrezelVoiceChrome( + titleText = stringResource(recordingState.titleResId), + status = recordingState.toVoiceChromeStatus(), + gradient = VoiceChromeGradient.MIN, + modifier = Modifier.fillMaxWidth(), ) } } 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 a48c95ed..bd50c5ae 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 @@ -3,6 +3,7 @@ package com.team.prezel.feature.analysis.impl.recording 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.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 @@ -42,6 +43,16 @@ internal val AudioSessionState.titleResId: Int is AudioSessionState.Playing -> R.string.feature_analysis_impl_voice_recording_playing_title } +internal fun AudioSessionState.toVoiceChromeStatus(): VoiceChromeStatus = + when (this) { + is AudioSessionState.Recording -> VoiceChromeStatus.LISTENING + is AudioSessionState.PausedRecording -> VoiceChromeStatus.WAITING + AudioSessionState.Idle, + is AudioSessionState.ReadyToPlay, + is AudioSessionState.Playing, + -> VoiceChromeStatus.IDLE + } + internal val AudioSessionState.actionIconResId: Int get() = when (this) { AudioSessionState.Idle -> PrezelIcons.Recording From 7838c4bdcd9424f461182e44951d0bb50715da2c Mon Sep 17 00:00:00 2001 From: Ham BeomJoon Date: Tue, 26 May 2026 21:56:14 +0900 Subject: [PATCH 05/15] =?UTF-8?q?feat:=20=EB=B6=84=EC=84=9D=20=ED=9D=90?= =?UTF-8?q?=EB=A6=84=20=EB=A1=9C=EC=A7=81=20=EA=B0=9C=EC=84=A0=20=EB=B0=8F?= =?UTF-8?q?=20=EC=9D=8C=EC=84=B1=20=EB=85=B9=EC=9D=8C=20UI=20=EC=97=85?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `AnalysisFlowViewModel`의 분석 로직을 개선하고 결과 화면 이동 방식을 `NavigateToReport` Effect 전송으로 변경 - `AnalyzePresentationRecordingUseCase` 사용을 중단하고 `AnalyzePresentationUseCase`로 단일화 - 음성 녹음 화면에 `PrezelVoiceChrome` 컴포넌트를 적용하여 시각적 피드백 강화 - `AudioSessionState`를 `VoiceChromeStatus`로 변환하는 확장 함수 추가 - `AnalysisFlowStep`에서 `REPORT` 단계를 제거하고 화면 전환 흐름 최적화 - `PrezelVoiceChrome` 컴포넌트가 부모 너비에 맞게 확장되도록 수정 - 일시정지 상태에 대한 시스템 문자열 리소스 수정 (`일시정지됨` -> `일시정지됐어요`) --- Prezel/core/audio/build.gradle.kts | 1 + .../prezel/core/audio/AudioRecorderSession.kt | 2 + .../prezel/core/audio/MediaRecorderSession.kt | 2 + .../audio/MediaRecordingAudioController.kt | 129 +++++++++++++----- .../core/audio/RecordingAudioController.kt | 3 + .../component/voice/PrezelVoiceChrome.kt | 3 +- .../component/voice/PrezelVoiceChromeWave.kt | 12 +- .../analysis/impl/AnalysisFlowViewModel.kt | 9 ++ .../impl/contract/AnalysisFlowUiState.kt | 3 + .../impl/recording/VoiceRecordingContent.kt | 91 +++++++++++- .../impl/recording/VoiceRecordingScreen.kt | 6 +- .../VoiceRecordingStateProperties.kt | 7 +- 12 files changed, 212 insertions(+), 56 deletions(-) diff --git a/Prezel/core/audio/build.gradle.kts b/Prezel/core/audio/build.gradle.kts index c7f093e6..e2263170 100644 --- a/Prezel/core/audio/build.gradle.kts +++ b/Prezel/core/audio/build.gradle.kts @@ -10,5 +10,6 @@ android { dependencies { implementation(platform(libs.androidx.compose.bom)) implementation(libs.androidx.compose.runtime) + implementation(libs.kotlinx.collections.immutable) implementation(libs.kotlinx.coroutines.core) } diff --git a/Prezel/core/audio/src/main/java/com/team/prezel/core/audio/AudioRecorderSession.kt b/Prezel/core/audio/src/main/java/com/team/prezel/core/audio/AudioRecorderSession.kt index 8d539c4c..11865cf7 100644 --- a/Prezel/core/audio/src/main/java/com/team/prezel/core/audio/AudioRecorderSession.kt +++ b/Prezel/core/audio/src/main/java/com/team/prezel/core/audio/AudioRecorderSession.kt @@ -7,6 +7,8 @@ internal interface AudioRecorderSession { fun resume(): Result + fun maxAmplitude(): Int + fun stop(elapsedSeconds: Int): Result fun reset() diff --git a/Prezel/core/audio/src/main/java/com/team/prezel/core/audio/MediaRecorderSession.kt b/Prezel/core/audio/src/main/java/com/team/prezel/core/audio/MediaRecorderSession.kt index 285f5d11..dde38a2a 100644 --- a/Prezel/core/audio/src/main/java/com/team/prezel/core/audio/MediaRecorderSession.kt +++ b/Prezel/core/audio/src/main/java/com/team/prezel/core/audio/MediaRecorderSession.kt @@ -51,6 +51,8 @@ internal class MediaRecorderSession @Inject constructor( recorder!!.resume() } + override fun maxAmplitude(): Int = recorder?.maxAmplitude ?: 0 + override fun stop(elapsedSeconds: Int): Result = runCatching { val file = currentAudioFile!! diff --git a/Prezel/core/audio/src/main/java/com/team/prezel/core/audio/MediaRecordingAudioController.kt b/Prezel/core/audio/src/main/java/com/team/prezel/core/audio/MediaRecordingAudioController.kt index 59338a4b..6daf0b19 100644 --- a/Prezel/core/audio/src/main/java/com/team/prezel/core/audio/MediaRecordingAudioController.kt +++ b/Prezel/core/audio/src/main/java/com/team/prezel/core/audio/MediaRecordingAudioController.kt @@ -1,5 +1,8 @@ package com.team.prezel.core.audio +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -16,6 +19,18 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import javax.inject.Inject +// Android MediaRecorder의 maxAmplitude는 0..32767 범위로 전달된다. +private const val MAX_RECORDING_AMPLITUDE = 32_767f + +// 화면에 표시되는 녹음 시간은 초 단위라서 1초마다 갱신한다. +private const val RECORDING_TIMER_DELAY_MILLIS = 1_000L + +// 파형이 부드럽게 반응하도록 초당 20번 입력 볼륨을 샘플링한다. +private const val RECORDING_VOLUME_DELAY_MILLIS = 50L + +// 재생 시간 상태는 초 단위로 갱신하고, 파형 진행은 UI에서 보간한다. +private const val PLAYBACK_TIMER_DELAY_MILLIS = 1_000L + internal class MediaRecordingAudioController @Inject constructor( private val recorderSession: AudioRecorderSession, private val playerSession: AudioPlayerSession, @@ -25,20 +40,34 @@ internal class MediaRecordingAudioController @Inject constructor( private val _audioSessionState = MutableStateFlow(AudioSessionState.Idle) override val audioSessionState: StateFlow = _audioSessionState.asStateFlow() + private val _recordingVolumes = MutableStateFlow>(persistentListOf()) + override val recordingVolumes: StateFlow> = _recordingVolumes.asStateFlow() + private val _audioSessionEffect = Channel(capacity = Channel.BUFFERED) override val audioSessionEffect: Flow = _audioSessionEffect.receiveAsFlow() private var recordingTimerJob: Job? = null + private var recordingVolumeJob: Job? = null private var playbackTimerJob: Job? = null override fun startRecording() { runCatching { stopPlayback() recorderSession.start().getOrThrow() + _recordingVolumes.value = persistentListOf() _audioSessionState.value = AudioSessionState.Recording(elapsedSeconds = 0) - startRecordingTimer() + recordingTimerJob?.cancel() + recordingTimerJob = controllerScope.launchRecordingTimer(_audioSessionState) + recordingVolumeJob?.cancel() + recordingVolumeJob = controllerScope.launchRecordingVolumeMeter( + audioSessionState = audioSessionState, + recorderSession = recorderSession, + recordingVolumes = _recordingVolumes, + ) }.onFailure { recorderSession.reset() + recordingVolumeJob?.cancel() + _recordingVolumes.value = persistentListOf() _audioSessionState.value = AudioSessionState.Idle _audioSessionEffect.emit(AudioSessionEffect.RecordingStartFailed) } @@ -52,6 +81,7 @@ internal class MediaRecordingAudioController @Inject constructor( } recordingTimerJob?.cancel() + recordingVolumeJob?.cancel() recorderSession .stop(elapsedSeconds = elapsedSeconds) .onSuccess { recordedAudio -> @@ -60,6 +90,7 @@ internal class MediaRecordingAudioController @Inject constructor( durationSeconds = recordedAudio.durationSeconds, ) }.onFailure { + _recordingVolumes.value = persistentListOf() _audioSessionState.value = AudioSessionState.Idle _audioSessionEffect.emit(AudioSessionEffect.RecordingStopFailed) } @@ -75,6 +106,7 @@ internal class MediaRecordingAudioController @Inject constructor( .pause() .onSuccess { recordingTimerJob?.cancel() + recordingVolumeJob?.cancel() _audioSessionState.value = AudioSessionState.PausedRecording(elapsedSeconds = elapsedSeconds) }.onFailure { _audioSessionEffect.emit(AudioSessionEffect.RecordingStopFailed) @@ -91,7 +123,14 @@ internal class MediaRecordingAudioController @Inject constructor( .resume() .onSuccess { _audioSessionState.value = AudioSessionState.Recording(elapsedSeconds = elapsedSeconds) - startRecordingTimer() + recordingTimerJob?.cancel() + recordingTimerJob = controllerScope.launchRecordingTimer(_audioSessionState) + recordingVolumeJob?.cancel() + recordingVolumeJob = controllerScope.launchRecordingVolumeMeter( + audioSessionState = audioSessionState, + recorderSession = recorderSession, + recordingVolumes = _recordingVolumes, + ) }.onFailure { _audioSessionEffect.emit(AudioSessionEffect.RecordingStartFailed) } @@ -130,9 +169,11 @@ internal class MediaRecordingAudioController @Inject constructor( override fun reset() { recordingTimerJob?.cancel() + recordingVolumeJob?.cancel() playbackTimerJob?.cancel() recorderSession.reset() releasePlayer() + _recordingVolumes.value = persistentListOf() _audioSessionState.value = AudioSessionState.Idle } @@ -162,7 +203,8 @@ internal class MediaRecordingAudioController @Inject constructor( positionSeconds = startPositionSeconds, durationSeconds = durationSeconds.coerceAtLeast(playerDurationMillis.toSeconds()), ) - startPlaybackTimer() + playbackTimerJob?.cancel() + playbackTimerJob = controllerScope.launchPlaybackTimer(_audioSessionState) }.onFailure { handlePlaybackStartFailure( source = source, @@ -195,48 +237,61 @@ internal class MediaRecordingAudioController @Inject constructor( _audioSessionEffect.emit(AudioSessionEffect.PlaybackStartFailed) } - private fun startRecordingTimer() { - recordingTimerJob?.cancel() - recordingTimerJob = controllerScope.launch { - while (true) { - delay(RECORDING_TIMER_DELAY_MILLIS) - _audioSessionState.update { state -> - if (state !is AudioSessionState.Recording) return@update state - AudioSessionState.Recording(elapsedSeconds = state.elapsedSeconds + 1) - } + private fun releasePlayer() { + playbackTimerJob?.cancel() + playerSession.release() + } +} + +private fun Channel.emit(effect: AudioSessionEffect) { + trySend(effect) +} + +private fun CoroutineScope.launchRecordingTimer(audioSessionState: MutableStateFlow): Job = + launch { + while (true) { + delay(RECORDING_TIMER_DELAY_MILLIS) + audioSessionState.update { state -> + if (state !is AudioSessionState.Recording) return@update state + AudioSessionState.Recording(elapsedSeconds = state.elapsedSeconds + 1) } } } - private fun startPlaybackTimer() { - playbackTimerJob?.cancel() - playbackTimerJob = controllerScope.launch { - while (true) { - delay(PLAYBACK_TIMER_DELAY_MILLIS) - _audioSessionState.update { state -> - if (state !is AudioSessionState.Playing) return@update state - - AudioSessionState.Playing( - source = state.source, - positionSeconds = (state.positionSeconds + 1).coerceAtMost(state.durationSeconds), - durationSeconds = state.durationSeconds, - ) - } +private fun CoroutineScope.launchRecordingVolumeMeter( + audioSessionState: StateFlow, + recorderSession: AudioRecorderSession, + recordingVolumes: MutableStateFlow>, +): Job = + launch { + while (true) { + delay(RECORDING_VOLUME_DELAY_MILLIS) + if (audioSessionState.value !is AudioSessionState.Recording) continue + + recordingVolumes.update { volumes -> + (volumes + recorderSession.maxAmplitude().toVolume()).toImmutableList() } } } - private fun releasePlayer() { - playbackTimerJob?.cancel() - playerSession.release() - } +private fun CoroutineScope.launchPlaybackTimer(audioSessionState: MutableStateFlow): Job = + launch { + while (true) { + delay(PLAYBACK_TIMER_DELAY_MILLIS) + audioSessionState.update { state -> + if (state !is AudioSessionState.Playing) return@update state - private companion object { - const val RECORDING_TIMER_DELAY_MILLIS = 1_000L - const val PLAYBACK_TIMER_DELAY_MILLIS = 1_000L + AudioSessionState.Playing( + source = state.source, + positionSeconds = (state.positionSeconds + 1).coerceAtMost(state.durationSeconds), + durationSeconds = state.durationSeconds, + ) + } + } } -} -private fun Channel.emit(effect: AudioSessionEffect) { - trySend(effect) -} +private fun Int.toVolume(): Float = + (this / MAX_RECORDING_AMPLITUDE).coerceIn( + minimumValue = 0.1f, + maximumValue = 1f, + ) diff --git a/Prezel/core/audio/src/main/java/com/team/prezel/core/audio/RecordingAudioController.kt b/Prezel/core/audio/src/main/java/com/team/prezel/core/audio/RecordingAudioController.kt index 1f7e2bd2..1a8d5b1b 100644 --- a/Prezel/core/audio/src/main/java/com/team/prezel/core/audio/RecordingAudioController.kt +++ b/Prezel/core/audio/src/main/java/com/team/prezel/core/audio/RecordingAudioController.kt @@ -1,11 +1,14 @@ package com.team.prezel.core.audio +import kotlinx.collections.immutable.ImmutableList import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow interface RecordingAudioController { val audioSessionState: StateFlow + val recordingVolumes: StateFlow> + val audioSessionEffect: Flow fun startRecording() 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 ce4a6bad..dcfaf66a 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 @@ -17,6 +17,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.width import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable @@ -392,7 +393,7 @@ private fun VoiceChromePreviewItem( label: String, content: @Composable () -> Unit, ) { - Column { + Column(modifier = Modifier.width(360.dp)) { Text( text = label, style = PrezelTheme.typography.caption1Medium, diff --git a/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/voice/PrezelVoiceChromeWave.kt b/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/voice/PrezelVoiceChromeWave.kt index 2ae0779d..e4a163a2 100644 --- a/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/voice/PrezelVoiceChromeWave.kt +++ b/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/voice/PrezelVoiceChromeWave.kt @@ -7,8 +7,8 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column 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.size import androidx.compose.foundation.layout.width import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -64,7 +64,7 @@ fun PrezelVoiceChromeWave( label = "VoiceChromeWaveActivationProgress", ) val volumeProgress by animateFloatAsState( - targetValue = if (status == VoiceChromeStatus.LISTENING) 1f else 0f, + targetValue = if (status == VoiceChromeStatus.IDLE) 0f else 1f, animationSpec = tween(durationMillis = 440), label = "VoiceChromeWaveVolumeProgress", ) @@ -90,7 +90,7 @@ private fun Modifier.drawVoiceChromeWave( ): Modifier { val colors = PrezelTheme.colors - return size(width = 360.dp, height = 60.dp).drawWithCache { + return fillMaxWidth().height(60.dp).drawWithCache { val barWidth = 2.dp.toPx() val barSpacing = 6.dp.toPx() val minBarHeight = 4.dp.toPx() @@ -245,8 +245,8 @@ private fun DrawScope.drawVoiceChromeWaveBaseline( drawLine( color = color, - start = Offset(x = 0f, y = size.height / 2f), - end = Offset(x = size.width, y = size.height / 2f), + start = Offset(x = size.width / 2f, y = 0f), + end = Offset(x = size.width / 2f, y = size.height), strokeWidth = strokeWidth, ) } @@ -395,7 +395,7 @@ private fun VoiceChromeWavePreviewItem( label: String, content: @Composable () -> Unit, ) { - Column { + Column(modifier = Modifier.width(360.dp)) { Text( text = label, style = PrezelTheme.typography.caption1Medium, 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 4e677b20..41154e6d 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 @@ -37,6 +37,7 @@ internal class AnalysisFlowViewModel @Inject constructor( init { collectAudioSessionState() + collectRecordingVolumes() collectAudioSessionEffect() } @@ -234,6 +235,14 @@ internal class AnalysisFlowViewModel @Inject constructor( } } + private fun collectRecordingVolumes() { + viewModelScope.launch { + audioController.recordingVolumes.collect { volumes -> + updateState { copy(recordingVolumes = volumes) } + } + } + } + private fun collectAudioSessionEffect() { viewModelScope.launch { audioController.audioSessionEffect.collect { effect -> diff --git a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/contract/AnalysisFlowUiState.kt b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/contract/AnalysisFlowUiState.kt index f7f5d48b..87a84c63 100644 --- a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/contract/AnalysisFlowUiState.kt +++ b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/contract/AnalysisFlowUiState.kt @@ -7,12 +7,15 @@ import com.team.prezel.core.model.presentation.Category import com.team.prezel.core.model.presentation.Purpose import com.team.prezel.core.model.presentation.Style import com.team.prezel.core.ui.base.UiState +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf @Immutable internal data class AnalysisFlowUiState( val step: AnalysisFlowStep = AnalysisFlowStep.PRESENTATION_SCHEDULE, val form: AnalysisForm = AnalysisForm(), val recordingState: AudioSessionState = AudioSessionState.Idle, + val recordingVolumes: ImmutableList = persistentListOf(), ) : UiState { val progress: Float get() = when (step) { 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 43f1938e..e6ba1234 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 @@ -1,5 +1,8 @@ package com.team.prezel.feature.analysis.impl.recording +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -18,6 +21,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.key import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -36,14 +40,18 @@ import com.team.prezel.core.designsystem.component.actions.button.config.ButtonH 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.actions.button.config.PrezelButtonDefaults +import com.team.prezel.core.designsystem.component.voice.PrezelVoiceChromeWave 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 kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList @Composable internal fun VoiceRecordingContent( script: String, recordingState: AudioSessionState, + recordingVolumes: ImmutableList, onClickRecordingControl: () -> Unit, modifier: Modifier = Modifier, ) { @@ -67,6 +75,7 @@ internal fun VoiceRecordingContent( if (recordingState !is AudioSessionState.Idle) { VoiceRecordingStatusArea( recordingState = recordingState, + recordingVolumes = recordingVolumes, onClickRecordingControl = onClickRecordingControl, ) } @@ -162,10 +171,15 @@ private fun VoiceRecordingScriptGradient( @Composable private fun VoiceRecordingStatusArea( recordingState: AudioSessionState, + recordingVolumes: ImmutableList, onClickRecordingControl: () -> Unit, ) { Spacer(modifier = Modifier.height(PrezelTheme.spacing.V16)) - RecordingWaveform(modifier = Modifier.fillMaxWidth()) + RecordingWaveform( + recordingState = recordingState, + recordingVolumes = recordingVolumes, + modifier = Modifier.fillMaxWidth(), + ) Spacer(modifier = Modifier.height(recordingState.recordingStatusSpacing)) if (recordingState.isCompleted) { @@ -202,14 +216,79 @@ private fun ScriptZoomButton(modifier: Modifier = Modifier) { } @Composable -private fun RecordingWaveform(modifier: Modifier = Modifier) { - Spacer( - modifier = modifier - .height(60.dp) - .background(color = PrezelTheme.colors.bgLarge), +private fun RecordingWaveform( + recordingState: AudioSessionState, + recordingVolumes: ImmutableList, + modifier: Modifier = Modifier, +) { + val playbackProgress = recordingState.playbackProgress() + + PrezelVoiceChromeWave( + status = recordingState.toVoiceChromeStatus(), + volumes = recordingState.visibleRecordingVolumes( + recordingVolumes = recordingVolumes, + playbackProgress = playbackProgress, + ), + showBaseline = false, + modifier = modifier, ) } +@Composable +private fun AudioSessionState.playbackProgress(): Float { + if (this !is AudioSessionState.Playing) return playbackProgress + + return key(source, durationSeconds) { + val animatedPlaybackProgress by animateFloatAsState( + targetValue = playbackProgress, + animationSpec = tween( + durationMillis = 1000, + easing = LinearEasing, + ), + label = "RecordingWaveformPlaybackProgress", + ) + + animatedPlaybackProgress + } +} + +private fun AudioSessionState.visibleRecordingVolumes( + recordingVolumes: ImmutableList, + playbackProgress: Float, +): ImmutableList = + when (this) { + is AudioSessionState.Playing -> { + if (durationSeconds <= 0 || recordingVolumes.isEmpty()) { + recordingVolumes + } else { + val visibleCount = (playbackProgress * recordingVolumes.size) + .toInt() + .coerceIn(1, recordingVolumes.size) + recordingVolumes.take(visibleCount).toImmutableList() + } + } + + AudioSessionState.Idle, + is AudioSessionState.Recording, + is AudioSessionState.PausedRecording, + is AudioSessionState.ReadyToPlay, + -> recordingVolumes + } + +private val AudioSessionState.playbackProgress: Float + get() = when (this) { + is AudioSessionState.Playing -> { + if (durationSeconds <= 0) 0f else positionSeconds.toFloat() / durationSeconds + } + + AudioSessionState.Idle, + is AudioSessionState.Recording, + is AudioSessionState.PausedRecording, + -> 0f + + is AudioSessionState.ReadyToPlay -> 1f + } + @Composable private fun RecordingTimer( currentSeconds: Int, 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 7a362aa4..e986f490 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 @@ -8,7 +8,6 @@ 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.statusBarsPadding import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.runtime.Composable @@ -33,6 +32,7 @@ import com.team.prezel.core.designsystem.theme.PrezelTheme import com.team.prezel.feature.analysis.impl.contract.AnalysisFlowStep import com.team.prezel.feature.analysis.impl.contract.AnalysisFlowUiState import com.team.prezel.feature.analysis.impl.contract.AnalysisForm +import kotlinx.collections.immutable.ImmutableList import kotlinx.coroutines.delay @Composable @@ -47,6 +47,7 @@ internal fun VoiceRecordingScreen( VoiceRecordingScreen( script = uiState.form.script, recordingState = uiState.recordingState, + recordingVolumes = uiState.recordingVolumes, analyzeEnabled = uiState.canMoveNext, onClickRecordingControl = onClickRecordingControl, onStopRecording = onStopRecording, @@ -60,6 +61,7 @@ internal fun VoiceRecordingScreen( private fun VoiceRecordingScreen( script: String, recordingState: AudioSessionState, + recordingVolumes: ImmutableList, analyzeEnabled: Boolean, onClickRecordingControl: () -> Unit, onStopRecording: () -> Unit, @@ -92,6 +94,7 @@ private fun VoiceRecordingScreen( VoiceRecordingContent( script = script, recordingState = recordingState, + recordingVolumes = recordingVolumes, onClickRecordingControl = onClickRecordingControl, modifier = Modifier.weight(1f), ) @@ -141,7 +144,6 @@ private fun VoiceRecordingChromeTopBar( onBack = onBack, modifier = Modifier .align(Alignment.TopEnd) - .statusBarsPadding() .padding(top = PrezelTheme.spacing.V4, end = PrezelTheme.spacing.V8), ) } 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 bd50c5ae..5c429806 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 @@ -47,10 +47,9 @@ internal fun AudioSessionState.toVoiceChromeStatus(): VoiceChromeStatus = when (this) { is AudioSessionState.Recording -> VoiceChromeStatus.LISTENING is AudioSessionState.PausedRecording -> VoiceChromeStatus.WAITING - AudioSessionState.Idle, - is AudioSessionState.ReadyToPlay, - is AudioSessionState.Playing, - -> VoiceChromeStatus.IDLE + is AudioSessionState.Playing -> VoiceChromeStatus.LISTENING + is AudioSessionState.ReadyToPlay -> VoiceChromeStatus.WAITING + AudioSessionState.Idle -> VoiceChromeStatus.IDLE } internal val AudioSessionState.actionIconResId: Int From 7e099ff3cbde6e6ad6167dae099ba48169d716ae Mon Sep 17 00:00:00 2001 From: Ham BeomJoon Date: Wed, 27 May 2026 20:22:29 +0900 Subject: [PATCH 06/15] =?UTF-8?q?feat:=20=EB=B0=9C=ED=91=9C=20=EB=B6=84?= =?UTF-8?q?=EC=84=9D=20=EC=9E=AC=EC=8B=9C=EC=9E=91=20=EB=B0=8F=20=EB=8B=A8?= =?UTF-8?q?=EA=B3=84=EB=B3=84=20=EB=84=A4=EB=B9=84=EA=B2=8C=EC=9D=B4?= =?UTF-8?q?=EC=85=98=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 분석 프로세스의 각 단계(`Schedule`, `Situation`, `Script`, `Recording` 등)에 대한 세분화된 `AnalysisNavKey` 정의 - 기존 분석 기록을 바탕으로 다시 녹음하거나 스크립트를 수정하여 재분석하는 기능 추가 - `AnalysisFlowViewModel`에서 단계별 상태 관리 및 `NavigateToStep` 이펙트 처리 로직 구현 - `PresentationRepository` 및 원격 데이터 소스에 대본과 음성 파일을 포함한 재분석 API(`reAnalyzePresentation`) 연동 - 리포트 화면에서 재녹음 및 스크립트 수정 화면으로 이동하는 네비게이션 로직 연결 - `AnalysisRoute`를 도입하여 `ViewModelStoreOwner`를 수동으로 관리하고 분석 flow 내 상태 유지 개선 - 분석 진행률(progress) 계산 로직을 변경된 단계에 맞춰 업데이트 --- Prezel/.gitignore | 2 + .../repository/PresentationRepositoryImpl.kt | 4 + .../presentation/PresentationRepository.kt | 2 + .../ReAnalyzePresentationUseCase.kt | 4 + .../PresentationRemoteDataSource.kt | 2 + .../PresentationRemoteDataSourceImpl.kt | 28 ++- .../feature/analysis/api/AnalysisNavKey.kt | 48 +++- .../analysis/impl/AnalysisFlowViewModel.kt | 231 +++++++++++++++--- .../feature/analysis/impl/AnalysisScreen.kt | 10 +- .../impl/contract/AnalysisFlowUiEffect.kt | 4 + .../impl/contract/AnalysisFlowUiIntent.kt | 16 ++ .../impl/contract/AnalysisFlowUiState.kt | 8 +- .../impl/navigation/AnalysisEntryBuilder.kt | 134 +++++++++- .../impl/navigation/HistoryEntryBuilder.kt | 2 +- .../home/impl/navigation/HomeEntryBuilder.kt | 4 +- Prezel/feature/report/impl/build.gradle.kts | 1 + .../report/impl/AnalysisReportScreen.kt | 8 +- .../report/impl/AnalysisReportViewModel.kt | 15 +- .../impl/contract/AnalysisReportUiEffect.kt | 2 + .../impl/navigation/ReportEntryBuilder.kt | 19 +- 20 files changed, 468 insertions(+), 76 deletions(-) diff --git a/Prezel/.gitignore b/Prezel/.gitignore index aa724b77..327e5f4f 100644 --- a/Prezel/.gitignore +++ b/Prezel/.gitignore @@ -13,3 +13,5 @@ .externalNativeBuild .cxx local.properties +.agents/ +skills-lock.json diff --git a/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/PresentationRepositoryImpl.kt b/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/PresentationRepositoryImpl.kt index 27c215ed..fa6112c3 100644 --- a/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/PresentationRepositoryImpl.kt +++ b/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/PresentationRepositoryImpl.kt @@ -47,11 +47,15 @@ internal class PresentationRepositoryImpl @Inject constructor( override suspend fun reAnalyzePresentation( presentationId: Long, + script: String?, + scriptFilePath: String?, audioFilePath: String, ): Result = runCatching { presentationRemoteDataSource.reAnalyzePresentation( presentationId = presentationId, + script = script, + scriptFilePath = scriptFilePath, audioFilePath = audioFilePath, ) }.mapCatching { response -> diff --git a/Prezel/core/domain/src/main/kotlin/com/team/prezel/core/domain/repository/presentation/PresentationRepository.kt b/Prezel/core/domain/src/main/kotlin/com/team/prezel/core/domain/repository/presentation/PresentationRepository.kt index 9612cdb2..2ec66df2 100644 --- a/Prezel/core/domain/src/main/kotlin/com/team/prezel/core/domain/repository/presentation/PresentationRepository.kt +++ b/Prezel/core/domain/src/main/kotlin/com/team/prezel/core/domain/repository/presentation/PresentationRepository.kt @@ -24,6 +24,8 @@ interface PresentationRepository { suspend fun reAnalyzePresentation( presentationId: Long, + script: String?, + scriptFilePath: String?, audioFilePath: String, ): Result diff --git a/Prezel/core/domain/src/main/kotlin/com/team/prezel/core/domain/usecase/presentation/ReAnalyzePresentationUseCase.kt b/Prezel/core/domain/src/main/kotlin/com/team/prezel/core/domain/usecase/presentation/ReAnalyzePresentationUseCase.kt index 13e992b7..e2ebd062 100644 --- a/Prezel/core/domain/src/main/kotlin/com/team/prezel/core/domain/usecase/presentation/ReAnalyzePresentationUseCase.kt +++ b/Prezel/core/domain/src/main/kotlin/com/team/prezel/core/domain/usecase/presentation/ReAnalyzePresentationUseCase.kt @@ -9,10 +9,14 @@ class ReAnalyzePresentationUseCase @Inject constructor( ) { suspend operator fun invoke( presentationId: Long, + script: String?, + scriptFilePath: String?, audioFilePath: String, ): Result = presentationRepository.reAnalyzePresentation( presentationId = presentationId, + script = script, + scriptFilePath = scriptFilePath, audioFilePath = audioFilePath, ) } diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/PresentationRemoteDataSource.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/PresentationRemoteDataSource.kt index b7366c42..1f7a1079 100644 --- a/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/PresentationRemoteDataSource.kt +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/PresentationRemoteDataSource.kt @@ -20,6 +20,8 @@ interface PresentationRemoteDataSource { suspend fun reAnalyzePresentation( presentationId: Long, + script: String?, + scriptFilePath: String?, audioFilePath: String, ): PresentationSummaryResponse diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/PresentationRemoteDataSourceImpl.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/PresentationRemoteDataSourceImpl.kt index 8d83fd03..0160105c 100644 --- a/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/PresentationRemoteDataSourceImpl.kt +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/PresentationRemoteDataSourceImpl.kt @@ -56,13 +56,28 @@ internal class PresentationRemoteDataSourceImpl @Inject constructor( override suspend fun reAnalyzePresentation( presentationId: Long, + script: String?, + scriptFilePath: String?, audioFilePath: String, - ): PresentationSummaryResponse = - presentationService + ): PresentationSummaryResponse { + require(audioFilePath.isNotBlank()) { "발표 음성 파일 경로가 비어 있습니다." } + + val multipart = MultiPartFormDataContent( + formData { + appendAudioPart(audioFilePath = audioFilePath) + script?.takeIf(String::isNotBlank)?.let { append("script", it) } + scriptFilePath?.takeIf(String::isNotBlank)?.let { path -> + appendScriptPart(scriptFilePath = path) + } + }, + ) + + return presentationService .reAnalyzePresentation( presentationId = presentationId, - multipart = audioFilePath.toAudioMultipart(), + multipart = multipart, ).requireData() + } override suspend fun getScriptDetail(analysisResultId: Long): PresentationScriptDetailResponse = presentationService.getScriptDetail(analysisResultId = analysisResultId).requireData() @@ -84,13 +99,6 @@ internal class PresentationRemoteDataSourceImpl @Inject constructor( override suspend fun getPastPresentationDetail(presentationId: Long): PresentationSummaryResponse = presentationService.getPastPresentationDetail(presentationId = presentationId).requireData().analysisResult - private fun String.toAudioMultipart(): MultiPartFormDataContent = - MultiPartFormDataContent( - formData { - appendAudioPart(this@toAudioMultipart) - }, - ) - private fun FormBuilder.appendAudioPart(audioFilePath: String) { val audioFile = File(audioFilePath) diff --git a/Prezel/feature/analysis/api/src/main/java/com/team/prezel/feature/analysis/api/AnalysisNavKey.kt b/Prezel/feature/analysis/api/src/main/java/com/team/prezel/feature/analysis/api/AnalysisNavKey.kt index 75b780ec..92806d43 100644 --- a/Prezel/feature/analysis/api/src/main/java/com/team/prezel/feature/analysis/api/AnalysisNavKey.kt +++ b/Prezel/feature/analysis/api/src/main/java/com/team/prezel/feature/analysis/api/AnalysisNavKey.kt @@ -2,9 +2,55 @@ package com.team.prezel.feature.analysis.api import androidx.navigation3.runtime.NavKey import kotlinx.serialization.Serializable +import java.util.UUID @Serializable sealed interface AnalysisNavKey : NavKey { + val flowId: String + + @Serializable + data class Schedule( + override val flowId: String = newAnalysisFlowId(), + ) : AnalysisNavKey + + @Serializable + data class Situation( + override val flowId: String, + ) : AnalysisNavKey + + @Serializable + data class Script( + override val flowId: String, + ) : AnalysisNavKey + + @Serializable + data class AudioUpload( + override val flowId: String, + ) : AnalysisNavKey + + @Serializable + data class Recording( + override val flowId: String, + ) : AnalysisNavKey + + @Serializable + data class Analyzing( + override val flowId: String, + ) : AnalysisNavKey + + @Serializable + data class ReRecording( + val presentationId: Long, + val isPast: Boolean = false, + override val flowId: String = newAnalysisFlowId(), + ) : AnalysisNavKey + @Serializable - data object Create : AnalysisNavKey + data class ReWritingScript( + val presentationId: Long, + val isPast: Boolean = false, + override val flowId: String = newAnalysisFlowId(), + ) : AnalysisNavKey } + +private fun newAnalysisFlowId(): String = UUID.randomUUID().toString() 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 41154e6d..57acfcd0 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 @@ -5,8 +5,11 @@ 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 +import com.team.prezel.core.domain.usecase.presentation.FetchPresentationDetailUseCase +import com.team.prezel.core.domain.usecase.presentation.ReAnalyzePresentationUseCase import com.team.prezel.core.model.presentation.Audience import com.team.prezel.core.model.presentation.Category +import com.team.prezel.core.model.presentation.PresentationAnalysisSummary import com.team.prezel.core.model.presentation.Purpose import com.team.prezel.core.model.presentation.Style import com.team.prezel.core.ui.base.BaseViewModel @@ -30,6 +33,8 @@ import javax.inject.Inject @HiltViewModel internal class AnalysisFlowViewModel @Inject constructor( private val analyzePresentationUseCase: AnalyzePresentationUseCase, + private val reAnalyzePresentationUseCase: ReAnalyzePresentationUseCase, + private val fetchPresentationDetailUseCase: FetchPresentationDetailUseCase, private val analysisFileCache: AnalysisFileCache, private val audioController: RecordingAudioController, ) : BaseViewModel(AnalysisFlowUiState()) { @@ -48,6 +53,17 @@ internal class AnalysisFlowViewModel @Inject constructor( } when (intent) { + is AnalysisFlowUiIntent.EnterStep -> updateState { copy(step = intent.step) } + is AnalysisFlowUiIntent.StartReRecording -> startReRecording( + presentationId = intent.presentationId, + isPast = intent.isPast, + ) + + is AnalysisFlowUiIntent.StartReWritingScript -> startReWritingScript( + presentationId = intent.presentationId, + isPast = intent.isPast, + ) + AnalysisFlowUiIntent.ClickRecordingControl -> handleRecordingControlClick() AnalysisFlowUiIntent.StopRecording -> audioController.stopRecording() AnalysisFlowUiIntent.ResetRecording -> audioController.reset() @@ -67,35 +83,47 @@ internal class AnalysisFlowViewModel @Inject constructor( return } - updateState { - copy( - step = when (step) { - AnalysisFlowStep.PRESENTATION_SCHEDULE -> AnalysisFlowStep.PRESENTATION_SITUATION - AnalysisFlowStep.PRESENTATION_SITUATION -> AnalysisFlowStep.SCRIPT_INPUT - AnalysisFlowStep.SCRIPT_INPUT -> AnalysisFlowStep.VOICE_RECORDING - AnalysisFlowStep.AUDIO_UPLOAD, - AnalysisFlowStep.VOICE_RECORDING, - AnalysisFlowStep.ANALYZING, - AnalysisFlowStep.FILE_RECOGNITION_FAILED, - AnalysisFlowStep.SCRIPT_FILE_RECOGNITION_FAILED, - -> step - }, - ) + val nextStep = when (currentState.step) { + AnalysisFlowStep.PRESENTATION_SCHEDULE -> AnalysisFlowStep.PRESENTATION_SITUATION + AnalysisFlowStep.PRESENTATION_SITUATION -> AnalysisFlowStep.SCRIPT_INPUT + AnalysisFlowStep.SCRIPT_INPUT -> AnalysisFlowStep.VOICE_RECORDING + AnalysisFlowStep.AUDIO_UPLOAD, + AnalysisFlowStep.VOICE_RECORDING, + AnalysisFlowStep.ANALYZING, + AnalysisFlowStep.FILE_RECOGNITION_FAILED, + AnalysisFlowStep.SCRIPT_FILE_RECOGNITION_FAILED, + -> currentState.step } + + updateState { copy(step = nextStep) } + viewModelScope.launch { sendEffect(AnalysisFlowUiEffect.NavigateToStep(step = nextStep)) } } private fun analyzePresentation() { val submission = currentState.toPresentationAnalysisSubmissionOrNull() ?: return + val reAnalyzePresentationId = currentState.reRecordingPresentationId + ?: currentState.reWritingScriptPresentationId + + if (reAnalyzePresentationId != null) { + reAnalyzePresentation( + presentationId = reAnalyzePresentationId, + submission = submission, + ) + return + } updateState { copy(step = AnalysisFlowStep.ANALYZING) } analyzeJob?.cancel() analyzeJob = viewModelScope.launch { + sendEffect(AnalysisFlowUiEffect.NavigateToStep(step = AnalysisFlowStep.ANALYZING)) + val analysisResult = submission.analyzePresentationRecording() analysisResult.fold( onSuccess = { result -> if (currentState.step == AnalysisFlowStep.ANALYZING) { + audioController.release() sendEffect(AnalysisFlowUiEffect.NavigateToReport(presentationId = result)) } }, @@ -106,6 +134,94 @@ internal class AnalysisFlowViewModel @Inject constructor( } } + private fun reAnalyzePresentation( + presentationId: Long, + submission: PresentationAnalysisSubmission, + ) { + updateState { copy(step = AnalysisFlowStep.ANALYZING) } + + analyzeJob?.cancel() + analyzeJob = viewModelScope.launch { + sendEffect(AnalysisFlowUiEffect.NavigateToStep(step = AnalysisFlowStep.ANALYZING)) + + val reAnalyzeResult = submission.reAnalyzePresentationRecording(presentationId) + + reAnalyzeResult.fold( + onSuccess = { result -> + if (currentState.step == AnalysisFlowStep.ANALYZING) { + audioController.release() + sendEffect(AnalysisFlowUiEffect.NavigateToReport(presentationId = result.presentationId)) + } + }, + onFailure = { throwable -> + handleAnalysisFailure(throwable.toAnalysisFailureAction()) + }, + ) + } + } + + private fun startReRecording( + presentationId: Long, + isPast: Boolean, + ) { + if (currentState.reRecordingPresentationId == presentationId) return + + audioController.reset() + updateState { + copy( + step = AnalysisFlowStep.VOICE_RECORDING, + reRecordingPresentationId = presentationId, + ) + } + + viewModelScope.launch { + fetchPresentationDetailUseCase(presentationId = presentationId, isPast = isPast) + .onSuccess { summary -> + updateState { + copy( + form = summary.toAnalysisForm(), + step = AnalysisFlowStep.VOICE_RECORDING, + reRecordingPresentationId = presentationId, + ) + } + }.onFailure { + sendEffect(AnalysisFlowUiEffect.ShowMessage(AnalysisUiMessage.ANALYSIS_FAILED)) + sendEffect(AnalysisFlowUiEffect.NavigateBack) + } + } + } + + private fun startReWritingScript( + presentationId: Long, + isPast: Boolean, + ) { + if (currentState.reWritingScriptPresentationId == presentationId && currentState.step == AnalysisFlowStep.SCRIPT_INPUT) return + + audioController.reset() + updateState { + copy( + step = AnalysisFlowStep.SCRIPT_INPUT, + reWritingScriptPresentationId = presentationId, + ) + } + + viewModelScope.launch { + fetchPresentationDetailUseCase(presentationId = presentationId, isPast = isPast) + .onSuccess { summary -> + updateState { + copy( + form = summary.toAnalysisForm().copy(scriptInputType = ScriptInputType.DIRECT_INPUT), + step = AnalysisFlowStep.SCRIPT_INPUT, + reWritingScriptPresentationId = presentationId, + ) + } + }.onFailure { + sendEffect(AnalysisFlowUiEffect.ShowMessage(AnalysisUiMessage.ANALYSIS_FAILED)) + sendEffect(AnalysisFlowUiEffect.NavigateBack) + } + } + } + private suspend fun PresentationAnalysisSubmission.analyzePresentationRecording(): Result = runCatching { val audioFilePath = audioFileUri @@ -136,22 +252,54 @@ internal class AnalysisFlowViewModel @Inject constructor( ).getOrThrow() } + private suspend fun PresentationAnalysisSubmission.reAnalyzePresentationRecording( + presentationId: Long, + ): Result = + runCatching { + val audioFilePath = audioFileUri + ?.let { uri -> + val audioFile = analysisFileCache.copyUriToCache( + uriString = uri, + prefix = "audio", + ) + audioFile.absolutePath + } + ?: recordingFilePath + val scriptFile = scriptFileUri?.let { uri -> + analysisFileCache.copyUriToCache( + uriString = uri, + prefix = "script", + ) + } + reAnalyzePresentationUseCase( + presentationId = presentationId, + script = script, + scriptFilePath = scriptFile?.absolutePath, + audioFilePath = audioFilePath, + ).getOrThrow() + } + private fun handleAnalysisFailure(action: AnalysisFailureAction) { when (action) { is AnalysisFailureAction.RetryFileUpload -> { + val failureStep = when (action.uploadType) { + AnalysisUploadType.AUDIO -> AnalysisFlowStep.FILE_RECOGNITION_FAILED + AnalysisUploadType.SCRIPT -> AnalysisFlowStep.SCRIPT_FILE_RECOGNITION_FAILED + } + updateState { copy( - step = when (action.uploadType) { - AnalysisUploadType.AUDIO -> AnalysisFlowStep.FILE_RECOGNITION_FAILED - AnalysisUploadType.SCRIPT -> AnalysisFlowStep.SCRIPT_FILE_RECOGNITION_FAILED - }, + step = failureStep, ) } } is AnalysisFailureAction.ShowMessage -> { - viewModelScope.launch { sendEffect(AnalysisFlowUiEffect.ShowMessage(action.message)) } - updateState { copy(step = AnalysisFlowStep.VOICE_RECORDING) } + val retryStep = AnalysisFlowStep.VOICE_RECORDING + viewModelScope.launch { + sendEffect(AnalysisFlowUiEffect.ShowMessage(action.message)) + } + updateState { copy(step = retryStep) } } } } @@ -159,25 +307,29 @@ internal class AnalysisFlowViewModel @Inject constructor( private fun retryFileUpload(uploadType: AnalysisUploadType) { when (uploadType) { AnalysisUploadType.SCRIPT -> { + val retryStep = AnalysisFlowStep.SCRIPT_INPUT updateState { copy( - step = AnalysisFlowStep.SCRIPT_INPUT, + step = retryStep, form = form.copy( scriptInputType = ScriptInputType.FILE_UPLOAD, scriptFileUri = null, ), ) } + viewModelScope.launch { sendEffect(AnalysisFlowUiEffect.NavigateToStep(step = retryStep)) } } AnalysisUploadType.AUDIO -> { + val retryStep = AnalysisFlowStep.VOICE_RECORDING updateState { copy( - step = AnalysisFlowStep.VOICE_RECORDING, + step = retryStep, form = form.copy(audioFileUri = null), ) } audioController.reset() + viewModelScope.launch { sendEffect(AnalysisFlowUiEffect.NavigateToStep(step = retryStep)) } } } } @@ -185,36 +337,31 @@ internal class AnalysisFlowViewModel @Inject constructor( private fun skipScript() { if (currentState.step != AnalysisFlowStep.SCRIPT_INPUT) return + val nextStep = AnalysisFlowStep.VOICE_RECORDING updateState { copy( - step = AnalysisFlowStep.VOICE_RECORDING, + step = nextStep, form = form.copy( script = "", scriptFileUri = null, ), ) } + viewModelScope.launch { sendEffect(AnalysisFlowUiEffect.NavigateToStep(step = nextStep)) } } private fun moveBack() { if (currentState.step == AnalysisFlowStep.ANALYZING) analyzeJob?.cancel() - val previousStep = when (currentState.step) { - AnalysisFlowStep.PRESENTATION_SCHEDULE -> null - AnalysisFlowStep.PRESENTATION_SITUATION -> AnalysisFlowStep.PRESENTATION_SCHEDULE - AnalysisFlowStep.SCRIPT_INPUT -> AnalysisFlowStep.PRESENTATION_SITUATION - AnalysisFlowStep.AUDIO_UPLOAD -> AnalysisFlowStep.SCRIPT_INPUT - AnalysisFlowStep.VOICE_RECORDING -> AnalysisFlowStep.SCRIPT_INPUT - AnalysisFlowStep.ANALYZING -> AnalysisFlowStep.VOICE_RECORDING - AnalysisFlowStep.FILE_RECOGNITION_FAILED -> AnalysisFlowStep.VOICE_RECORDING - AnalysisFlowStep.SCRIPT_FILE_RECOGNITION_FAILED -> AnalysisFlowStep.SCRIPT_INPUT + if ( + currentState.step == AnalysisFlowStep.PRESENTATION_SCHEDULE || + currentState.reRecordingPresentationId != null || + currentState.reWritingScriptPresentationId != null + ) { + audioController.release() } - if (previousStep == null) { - viewModelScope.launch { sendEffect(AnalysisFlowUiEffect.NavigateBack) } - } else { - updateState { copy(step = previousStep) } - } + viewModelScope.launch { sendEffect(AnalysisFlowUiEffect.NavigateBack) } } private fun handleRecordingControlClick() { @@ -297,6 +444,16 @@ private fun AnalysisForm.selectSituationOption(option: AnalysisSituationOption): is AnalysisSituationOption.AudienceOption -> copy(audience = option.audience) } +private fun PresentationAnalysisSummary.toAnalysisForm(): AnalysisForm = + AnalysisForm( + presentationTitle = title, + presentationDate = analyzedAt, + category = category, + purpose = purpose, + style = style, + audience = audience, + ) + private fun AnalysisFlowUiState.toPresentationAnalysisSubmissionOrNull(): PresentationAnalysisSubmission? { val category = form.category ?: return null val purpose = form.purpose ?: return 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 fe63c31e..acabe0de 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 @@ -1,10 +1,10 @@ package com.team.prezel.feature.analysis.impl +import androidx.activity.compose.BackHandler import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.platform.LocalResources -import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.team.prezel.core.designsystem.component.feedback.snackbar.showPrezelSnackbar import com.team.prezel.core.ui.state.LocalSnackbarHostState @@ -28,17 +28,23 @@ import kotlinx.coroutines.launch @Composable internal fun AnalysisScreen( onBack: () -> Unit, + navigateToStep: (AnalysisFlowStep) -> Unit, navigateToReport: (presentationId: Long) -> Unit, - viewModel: AnalysisFlowViewModel = hiltViewModel(), + viewModel: AnalysisFlowViewModel, ) { val uiState = viewModel.uiState.collectAsStateWithLifecycle().value val resources = LocalResources.current val snackbarHostState = LocalSnackbarHostState.current + BackHandler { + viewModel.onIntent(AnalysisFlowUiIntent.Back) + } + LaunchedEffect(Unit) { viewModel.uiEffect.collect { effect -> when (effect) { AnalysisFlowUiEffect.NavigateBack -> onBack() + is AnalysisFlowUiEffect.NavigateToStep -> navigateToStep(effect.step) is AnalysisFlowUiEffect.NavigateToReport -> navigateToReport(effect.presentationId) is AnalysisFlowUiEffect.ShowMessage -> { val resId = when (effect.message) { 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 677b1846..db73c372 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 @@ -6,6 +6,10 @@ import com.team.prezel.feature.analysis.impl.model.AnalysisUiMessage internal sealed interface AnalysisFlowUiEffect : UiEffect { data object NavigateBack : AnalysisFlowUiEffect + data class NavigateToStep( + val step: AnalysisFlowStep, + ) : AnalysisFlowUiEffect + data class NavigateToReport( val presentationId: Long, ) : AnalysisFlowUiEffect 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 a7602a57..f5b4536f 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 @@ -1,12 +1,28 @@ package com.team.prezel.feature.analysis.impl.contract +import androidx.compose.runtime.Immutable import com.team.prezel.core.model.presentation.Audience import com.team.prezel.core.model.presentation.Category import com.team.prezel.core.model.presentation.Purpose import com.team.prezel.core.model.presentation.Style import com.team.prezel.core.ui.base.UiIntent +@Immutable internal sealed interface AnalysisFlowUiIntent : UiIntent { + data class EnterStep( + val step: AnalysisFlowStep, + ) : AnalysisFlowUiIntent + + data class StartReRecording( + val presentationId: Long, + val isPast: Boolean, + ) : AnalysisFlowUiIntent + + data class StartReWritingScript( + val presentationId: Long, + val isPast: Boolean, + ) : AnalysisFlowUiIntent + data class UpdatePresentationTitle( val title: String, ) : AnalysisFlowUiIntent diff --git a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/contract/AnalysisFlowUiState.kt b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/contract/AnalysisFlowUiState.kt index 87a84c63..b2634e1a 100644 --- a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/contract/AnalysisFlowUiState.kt +++ b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/contract/AnalysisFlowUiState.kt @@ -16,19 +16,19 @@ internal data class AnalysisFlowUiState( val form: AnalysisForm = AnalysisForm(), val recordingState: AudioSessionState = AudioSessionState.Idle, val recordingVolumes: ImmutableList = persistentListOf(), + val reRecordingPresentationId: Long? = null, + val reWritingScriptPresentationId: Long? = null, ) : UiState { val progress: Float get() = when (step) { AnalysisFlowStep.PRESENTATION_SCHEDULE -> 0.25f - AnalysisFlowStep.PRESENTATION_SITUATION, + AnalysisFlowStep.PRESENTATION_SITUATION -> 0.5f AnalysisFlowStep.SCRIPT_INPUT, AnalysisFlowStep.SCRIPT_FILE_RECOGNITION_FAILED, - -> 0.5f + -> 0.75f AnalysisFlowStep.AUDIO_UPLOAD, AnalysisFlowStep.VOICE_RECORDING, - -> 0.75f - AnalysisFlowStep.ANALYZING, AnalysisFlowStep.FILE_RECOGNITION_FAILED, -> 1f 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 4441369f..373ecacf 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 @@ -1,10 +1,20 @@ package com.team.prezel.feature.analysis.impl.navigation +import android.content.Context +import android.content.ContextWrapper +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.platform.LocalContext +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.ViewModelStoreOwner import androidx.navigation3.runtime.EntryProviderScope import androidx.navigation3.runtime.NavKey import com.team.prezel.core.navigation.LocalNavigator import com.team.prezel.feature.analysis.api.AnalysisNavKey +import com.team.prezel.feature.analysis.impl.AnalysisFlowViewModel import com.team.prezel.feature.analysis.impl.AnalysisScreen +import com.team.prezel.feature.analysis.impl.contract.AnalysisFlowStep +import com.team.prezel.feature.analysis.impl.contract.AnalysisFlowUiIntent import com.team.prezel.feature.report.api.ReportNavKey import dagger.Module import dagger.Provides @@ -13,21 +23,123 @@ import dagger.hilt.android.components.ActivityRetainedComponent import dagger.multibindings.IntoSet internal fun EntryProviderScope.featureAnalysisEntryBuilder() { - entry { - val navigator = LocalNavigator.current - - AnalysisScreen( - onBack = { navigator.goBack() }, - navigateToReport = { presentationId -> - navigator.navigate( - key = ReportNavKey(presentationId = presentationId), - clearStack = true, - ) - }, + entry { key -> + AnalysisRoute( + flowId = key.flowId, + enterIntent = AnalysisFlowUiIntent.EnterStep(AnalysisFlowStep.PRESENTATION_SCHEDULE), + stepToNavKey = key::toNavKey, + ) + } + entry { key -> + AnalysisRoute( + flowId = key.flowId, + enterIntent = AnalysisFlowUiIntent.EnterStep(AnalysisFlowStep.PRESENTATION_SITUATION), + stepToNavKey = key::toNavKey, + ) + } + entry { key -> + AnalysisRoute( + flowId = key.flowId, + enterIntent = AnalysisFlowUiIntent.EnterStep(AnalysisFlowStep.SCRIPT_INPUT), + stepToNavKey = key::toNavKey, + ) + } + entry { key -> + AnalysisRoute( + flowId = key.flowId, + enterIntent = AnalysisFlowUiIntent.EnterStep(AnalysisFlowStep.AUDIO_UPLOAD), + stepToNavKey = key::toNavKey, + ) + } + entry { key -> + AnalysisRoute( + flowId = key.flowId, + enterIntent = AnalysisFlowUiIntent.EnterStep(AnalysisFlowStep.VOICE_RECORDING), + stepToNavKey = key::toNavKey, + ) + } + entry { key -> + AnalysisRoute( + flowId = key.flowId, + enterIntent = AnalysisFlowUiIntent.EnterStep(AnalysisFlowStep.ANALYZING), + stepToNavKey = key::toNavKey, + ) + } + entry { key -> + AnalysisRoute( + flowId = key.flowId, + enterIntent = AnalysisFlowUiIntent.StartReRecording( + presentationId = key.presentationId, + isPast = key.isPast, + ), + stepToNavKey = key::toNavKey, ) } + entry { key -> + AnalysisRoute( + flowId = key.flowId, + enterIntent = AnalysisFlowUiIntent.StartReWritingScript( + presentationId = key.presentationId, + isPast = key.isPast, + ), + stepToNavKey = key::toNavKey, + ) + } +} + +@Composable +private fun AnalysisRoute( + flowId: String, + enterIntent: AnalysisFlowUiIntent, + stepToNavKey: (AnalysisFlowStep) -> AnalysisNavKey, +) { + val navigator = LocalNavigator.current + val viewModelStoreOwner = LocalContext.current.findViewModelStoreOwner() + val viewModel = hiltViewModel( + viewModelStoreOwner = viewModelStoreOwner, + key = flowId, + ) + + LaunchedEffect(enterIntent) { + viewModel.onIntent(enterIntent) + } + + AnalysisScreen( + onBack = { navigator.goBack() }, + navigateToStep = { step -> navigator.navigate(key = stepToNavKey(step)) }, + navigateToReport = { presentationId -> + navigator.navigate( + key = ReportNavKey(presentationId = presentationId), + clearStack = true, + ) + }, + viewModel = viewModel, + ) } +private fun AnalysisNavKey.toNavKey(step: AnalysisFlowStep): AnalysisNavKey = + when (step) { + AnalysisFlowStep.PRESENTATION_SCHEDULE -> AnalysisNavKey.Schedule(flowId = flowId) + AnalysisFlowStep.PRESENTATION_SITUATION -> AnalysisNavKey.Situation(flowId = flowId) + AnalysisFlowStep.SCRIPT_INPUT, + AnalysisFlowStep.SCRIPT_FILE_RECOGNITION_FAILED, + -> AnalysisNavKey.Script(flowId = flowId) + + AnalysisFlowStep.AUDIO_UPLOAD -> AnalysisNavKey.AudioUpload(flowId = flowId) + AnalysisFlowStep.VOICE_RECORDING, + AnalysisFlowStep.FILE_RECOGNITION_FAILED, + -> AnalysisNavKey.Recording(flowId = flowId) + + AnalysisFlowStep.ANALYZING -> AnalysisNavKey.Analyzing(flowId = flowId) + } + +private tailrec fun Context.findViewModelStoreOwner(): ViewModelStoreOwner = + when (this) { + is ViewModelStoreOwner -> this + is ContextWrapper -> baseContext.findViewModelStoreOwner() + else -> error("ViewModelStoreOwner is not available from context: $this") + } + @Module @InstallIn(ActivityRetainedComponent::class) object FeatureAnalysisModule { diff --git a/Prezel/feature/history/impl/src/main/java/com/team/prezel/feature/history/impl/navigation/HistoryEntryBuilder.kt b/Prezel/feature/history/impl/src/main/java/com/team/prezel/feature/history/impl/navigation/HistoryEntryBuilder.kt index 54f9fa67..6b285d3c 100644 --- a/Prezel/feature/history/impl/src/main/java/com/team/prezel/feature/history/impl/navigation/HistoryEntryBuilder.kt +++ b/Prezel/feature/history/impl/src/main/java/com/team/prezel/feature/history/impl/navigation/HistoryEntryBuilder.kt @@ -22,7 +22,7 @@ internal fun EntryProviderScope.featureHistoryEntryBuilder() { navigator.navigate(ReportNavKey(presentationId = presentationId, isPast = isPast)) }, navigateToAnalysis = { - navigator.navigate(AnalysisNavKey.Create) + navigator.navigate(AnalysisNavKey.Schedule()) }, ) } diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/navigation/HomeEntryBuilder.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/navigation/HomeEntryBuilder.kt index 89450139..9f077746 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/navigation/HomeEntryBuilder.kt +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/navigation/HomeEntryBuilder.kt @@ -18,10 +18,10 @@ internal fun EntryProviderScope.featureHomeEntryBuilder() { HomeScreen( navigateToFileUploadAnalysis = { - navigator.navigate(AnalysisNavKey.Create) + navigator.navigate(AnalysisNavKey.Schedule()) }, navigateToVoiceRecordingAnalysis = { - navigator.navigate(AnalysisNavKey.Create) + navigator.navigate(AnalysisNavKey.Schedule()) }, ) } diff --git a/Prezel/feature/report/impl/build.gradle.kts b/Prezel/feature/report/impl/build.gradle.kts index 7be7306b..9b714254 100644 --- a/Prezel/feature/report/impl/build.gradle.kts +++ b/Prezel/feature/report/impl/build.gradle.kts @@ -10,6 +10,7 @@ dependencies { implementation(projects.coreModel) implementation(projects.coreDomain) implementation(projects.coreUi) + implementation(projects.featureAnalysisApi) implementation(projects.featureReportApi) implementation(libs.kotlinx.datetime) diff --git a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/AnalysisReportScreen.kt b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/AnalysisReportScreen.kt index 6682e6d5..d6b531af 100644 --- a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/AnalysisReportScreen.kt +++ b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/AnalysisReportScreen.kt @@ -31,8 +31,8 @@ import com.team.prezel.feature.report.impl.preview.ReportPreviewUpcomingUiState @Composable internal fun AnalysisReportScreen( onBack: () -> Unit, - navigateToAnalysisScript: (presentationId: Long) -> Unit, - navigateToAnalysisRecording: (presentationId: Long) -> Unit, + navigateToAnalysisScript: (presentationId: Long, isPast: Boolean) -> Unit, + navigateToAnalysisRecording: (presentationId: Long, isPast: Boolean) -> Unit, navigateToSelfFeedbackWrite: (presentationId: Long) -> Unit, modifier: Modifier = Modifier, viewModel: AnalysisReportViewModel = hiltViewModel(), @@ -52,8 +52,8 @@ internal fun AnalysisReportScreen( ) } - is AnalysisReportUiEffect.NavigateToAnalysisScript -> navigateToAnalysisScript(effect.presentationId) - is AnalysisReportUiEffect.NavigateToAnalysisRecording -> navigateToAnalysisRecording(effect.presentationId) + is AnalysisReportUiEffect.NavigateToAnalysisScript -> navigateToAnalysisScript(effect.presentationId, effect.isPast) + is AnalysisReportUiEffect.NavigateToAnalysisRecording -> navigateToAnalysisRecording(effect.presentationId, effect.isPast) is AnalysisReportUiEffect.NavigateToSelfFeedbackWrite -> navigateToSelfFeedbackWrite(effect.presentationId) } } diff --git a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/AnalysisReportViewModel.kt b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/AnalysisReportViewModel.kt index 956d1932..b9094245 100644 --- a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/AnalysisReportViewModel.kt +++ b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/AnalysisReportViewModel.kt @@ -30,6 +30,7 @@ internal class AnalysisReportViewModel @AssistedInject constructor( private var presentationId: Long? = null private var analysisResultId: Long? = null + private val isPast: Boolean = navKey.isPast init { fetchData(presentationId = navKey.presentationId, isPast = navKey.isPast) @@ -89,12 +90,22 @@ internal class AnalysisReportViewModel @AssistedInject constructor( } private fun navigateToAnalysisRecording() { - val effect = presentationId?.let(AnalysisReportUiEffect::NavigateToAnalysisRecording) ?: return + val effect = presentationId?.let { + AnalysisReportUiEffect.NavigateToAnalysisRecording( + presentationId = it, + isPast = isPast, + ) + } ?: return viewModelScope.launch { sendEffect(effect) } } private fun navigateToAnalysisScript() { - val effect = presentationId?.let(AnalysisReportUiEffect::NavigateToAnalysisScript) ?: return + val effect = presentationId?.let { + AnalysisReportUiEffect.NavigateToAnalysisScript( + presentationId = it, + isPast = isPast, + ) + } ?: return viewModelScope.launch { sendEffect(effect) } } diff --git a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/contract/AnalysisReportUiEffect.kt b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/contract/AnalysisReportUiEffect.kt index 37f471ec..c5b26bde 100644 --- a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/contract/AnalysisReportUiEffect.kt +++ b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/contract/AnalysisReportUiEffect.kt @@ -12,10 +12,12 @@ internal sealed interface AnalysisReportUiEffect : UiEffect { data class NavigateToAnalysisScript( val presentationId: Long, + val isPast: Boolean, ) : AnalysisReportUiEffect data class NavigateToAnalysisRecording( val presentationId: Long, + val isPast: Boolean, ) : AnalysisReportUiEffect data class NavigateToSelfFeedbackWrite( diff --git a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/navigation/ReportEntryBuilder.kt b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/navigation/ReportEntryBuilder.kt index 9bed2cad..9bf39fc6 100644 --- a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/navigation/ReportEntryBuilder.kt +++ b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/navigation/ReportEntryBuilder.kt @@ -4,6 +4,7 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.navigation3.runtime.EntryProviderScope import androidx.navigation3.runtime.NavKey import com.team.prezel.core.navigation.LocalNavigator +import com.team.prezel.feature.analysis.api.AnalysisNavKey import com.team.prezel.feature.report.api.ReportNavKey import com.team.prezel.feature.report.impl.AnalysisReportScreen import com.team.prezel.feature.report.impl.AnalysisReportViewModel @@ -19,8 +20,22 @@ internal fun EntryProviderScope.featureAnalysisReportEntryBuilder() { AnalysisReportScreen( onBack = { navigator.goBack() }, - navigateToAnalysisScript = {}, - navigateToAnalysisRecording = {}, + navigateToAnalysisScript = { presentationId, isPast -> + navigator.navigate( + AnalysisNavKey.ReWritingScript( + presentationId = presentationId, + isPast = isPast, + ), + ) + }, + navigateToAnalysisRecording = { presentationId, isPast -> + navigator.navigate( + AnalysisNavKey.ReRecording( + presentationId = presentationId, + isPast = isPast, + ), + ) + }, navigateToSelfFeedbackWrite = {}, viewModel = hiltViewModel( creationCallback = { factory -> factory.create(key) }, From 5a8f929016c47ff406486b1dafa5e541b386083b Mon Sep 17 00:00:00 2001 From: Ham BeomJoon Date: Wed, 27 May 2026 21:04:19 +0900 Subject: [PATCH 07/15] =?UTF-8?q?refactor:=20=EB=B0=9C=ED=91=9C=20?= =?UTF-8?q?=EB=B6=84=EC=84=9D=20=ED=9D=90=EB=A6=84=20=EA=B4=80=EB=A0=A8=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EB=A6=AC=ED=8C=A9=ED=84=B0=EB=A7=81=20?= =?UTF-8?q?=EB=B0=8F=20=EA=B5=AC=EC=A1=B0=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `AnalysisFlowViewModel` 내부에 혼재되어 있던 폼 매핑 및 상태 감소 로직을 `AnalysisFormMapper.kt`와 `AnalysisBackStateReducer.kt`로 분리 - `AnalysisEntryBuilder`에서 중복되는 네비게이션 엔트리 생성 로직을 `analysisEntry` 확장 함수로 공통화 - 녹음 및 오디오 세션 제어 로직을 별도 함수로 추출하여 `AnalysisFlowViewModel` 코드 간소화 - 뒤로 가기 동작 시 상태별 리소스 해제 및 데이터 초기화 로직 개선 - ViewModelStoreOwner 조회 실패 시 에러 메시지 한글화 및 가독성 개선 --- .../analysis/impl/AnalysisBackStateReducer.kt | 48 ++++++ .../analysis/impl/AnalysisFlowViewModel.kt | 138 ++++-------------- .../analysis/impl/AnalysisFormMapper.kt | 90 ++++++++++++ .../impl/navigation/AnalysisEntryBuilder.kt | 74 +++------- 4 files changed, 184 insertions(+), 166 deletions(-) create mode 100644 Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AnalysisBackStateReducer.kt create mode 100644 Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AnalysisFormMapper.kt 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 new file mode 100644 index 00000000..5244b56c --- /dev/null +++ b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AnalysisBackStateReducer.kt @@ -0,0 +1,48 @@ +package com.team.prezel.feature.analysis.impl + +import com.team.prezel.feature.analysis.impl.contract.AnalysisFlowStep +import com.team.prezel.feature.analysis.impl.contract.AnalysisFlowUiState +import com.team.prezel.feature.analysis.impl.contract.AnalysisForm +import com.team.prezel.feature.analysis.impl.contract.recordingFilePath + +internal val AnalysisFlowUiState.shouldResetRecordingOnBack: Boolean + get() = step == AnalysisFlowStep.VOICE_RECORDING && recordingState.recordingFilePath != null + +internal val AnalysisFlowUiState.shouldReleaseAudioOnBack: Boolean + get() = + step == AnalysisFlowStep.PRESENTATION_SCHEDULE || + reRecordingPresentationId != null || + reWritingScriptPresentationId != null + +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_SCHEDULE, + AnalysisFlowStep.AUDIO_UPLOAD, + AnalysisFlowStep.VOICE_RECORDING, + AnalysisFlowStep.ANALYZING, + AnalysisFlowStep.FILE_RECOGNITION_FAILED, + AnalysisFlowStep.SCRIPT_FILE_RECOGNITION_FAILED, + -> 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 = "", + scriptFileUri = null, + ) 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 57acfcd0..b3e11c47 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 @@ -7,27 +7,19 @@ import com.team.prezel.core.audio.RecordingAudioController import com.team.prezel.core.domain.usecase.presentation.AnalyzePresentationUseCase import com.team.prezel.core.domain.usecase.presentation.FetchPresentationDetailUseCase import com.team.prezel.core.domain.usecase.presentation.ReAnalyzePresentationUseCase -import com.team.prezel.core.model.presentation.Audience -import com.team.prezel.core.model.presentation.Category import com.team.prezel.core.model.presentation.PresentationAnalysisSummary -import com.team.prezel.core.model.presentation.Purpose -import com.team.prezel.core.model.presentation.Style import com.team.prezel.core.ui.base.BaseViewModel import com.team.prezel.feature.analysis.impl.cache.AnalysisFileCache import com.team.prezel.feature.analysis.impl.contract.AnalysisFlowStep import com.team.prezel.feature.analysis.impl.contract.AnalysisFlowUiEffect 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.AnalysisForm -import com.team.prezel.feature.analysis.impl.contract.AnalysisSituationOption 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.contract.recordingFilePath import com.team.prezel.feature.analysis.impl.model.AnalysisUiMessage import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Job import kotlinx.coroutines.launch -import kotlinx.datetime.LocalDate import javax.inject.Inject @HiltViewModel @@ -41,9 +33,7 @@ internal class AnalysisFlowViewModel @Inject constructor( private var analyzeJob: Job? = null init { - collectAudioSessionState() - collectRecordingVolumes() - collectAudioSessionEffect() + collectAudioSession() } override fun onIntent(intent: AnalysisFlowUiIntent) { @@ -64,7 +54,7 @@ internal class AnalysisFlowViewModel @Inject constructor( isPast = intent.isPast, ) - AnalysisFlowUiIntent.ClickRecordingControl -> handleRecordingControlClick() + AnalysisFlowUiIntent.ClickRecordingControl -> audioController.handleControlClick(currentState.recordingState) AnalysisFlowUiIntent.StopRecording -> audioController.stopRecording() AnalysisFlowUiIntent.ResetRecording -> audioController.reset() is AnalysisFlowUiIntent.RetryFileUpload -> retryFileUpload(intent.uploadType) @@ -252,9 +242,7 @@ internal class AnalysisFlowViewModel @Inject constructor( ).getOrThrow() } - private suspend fun PresentationAnalysisSubmission.reAnalyzePresentationRecording( - presentationId: Long, - ): Result = + private suspend fun PresentationAnalysisSubmission.reAnalyzePresentationRecording(presentationId: Long): Result = runCatching { val audioFilePath = audioFileUri ?.let { uri -> @@ -353,44 +341,36 @@ internal class AnalysisFlowViewModel @Inject constructor( private fun moveBack() { if (currentState.step == AnalysisFlowStep.ANALYZING) analyzeJob?.cancel() - if ( - currentState.step == AnalysisFlowStep.PRESENTATION_SCHEDULE || - currentState.reRecordingPresentationId != null || - currentState.reWritingScriptPresentationId != null - ) { - audioController.release() + if (currentState.shouldResetRecordingOnBack) { + audioController.reset() } - viewModelScope.launch { sendEffect(AnalysisFlowUiEffect.NavigateBack) } - } + currentState.backClearedFormOrNull()?.let { clearedForm -> + updateState { + copy(form = clearedForm) + } + } - private fun handleRecordingControlClick() { - when (currentState.recordingState) { - AudioSessionState.Idle -> audioController.startRecording() - is AudioSessionState.Recording -> audioController.pauseRecording() - is AudioSessionState.PausedRecording -> audioController.resumeRecording() - is AudioSessionState.ReadyToPlay -> audioController.startPlayback() - is AudioSessionState.Playing -> audioController.stopPlayback() + if (currentState.shouldReleaseAudioOnBack) { + audioController.release() } + + viewModelScope.launch { sendEffect(AnalysisFlowUiEffect.NavigateBack) } } - private fun collectAudioSessionState() { + private fun collectAudioSession() { viewModelScope.launch { audioController.audioSessionState.collect { audioState -> updateState { copy(recordingState = audioState) } } } - } - private fun collectRecordingVolumes() { viewModelScope.launch { audioController.recordingVolumes.collect { volumes -> updateState { copy(recordingVolumes = volumes) } } } - } - private fun collectAudioSessionEffect() { viewModelScope.launch { audioController.audioSessionEffect.collect { effect -> sendEffect(AnalysisFlowUiEffect.ShowMessage(effect.toUiMessage())) @@ -398,91 +378,25 @@ internal class AnalysisFlowViewModel @Inject constructor( } } - private fun AudioSessionEffect.toUiMessage(): AnalysisUiMessage = - when (this) { - AudioSessionEffect.RecordingStartFailed -> AnalysisUiMessage.RECORDING_START_FAILED - AudioSessionEffect.RecordingStopFailed -> AnalysisUiMessage.RECORDING_STOP_FAILED - AudioSessionEffect.PlaybackStartFailed -> AnalysisUiMessage.PLAYBACK_START_FAILED - } - override fun onCleared() { audioController.release() super.onCleared() } } -private data class PresentationAnalysisSubmission( - val name: String, - val date: String, - val category: Category, - val purpose: Purpose, - val style: Style, - val audience: Audience, - val script: String?, - val scriptFileUri: String?, - val audioFileUri: String?, - val recordingFilePath: String, -) - -private fun AnalysisFlowUiIntent.reduceFormOrNull(form: AnalysisForm): AnalysisForm? = +private fun AudioSessionEffect.toUiMessage(): AnalysisUiMessage = when (this) { - is AnalysisFlowUiIntent.UpdatePresentationTitle -> form.copy(presentationTitle = title) - is AnalysisFlowUiIntent.UpdatePresentationDate -> form.copy(presentationDate = date) - is AnalysisFlowUiIntent.SelectScriptInputType -> form.copy(scriptInputType = inputType) - is AnalysisFlowUiIntent.UpdateScript -> form.copy(script = script) - is AnalysisFlowUiIntent.SelectScriptFile -> form.copy(scriptFileUri = fileUri) - is AnalysisFlowUiIntent.SelectAudioFile -> form.copy(audioFileUri = fileUri) - is AnalysisFlowUiIntent.SelectSituationOption -> form.selectSituationOption(option) - else -> null + AudioSessionEffect.RecordingStartFailed -> AnalysisUiMessage.RECORDING_START_FAILED + AudioSessionEffect.RecordingStopFailed -> AnalysisUiMessage.RECORDING_STOP_FAILED + AudioSessionEffect.PlaybackStartFailed -> AnalysisUiMessage.PLAYBACK_START_FAILED } -private fun AnalysisForm.selectSituationOption(option: AnalysisSituationOption): AnalysisForm = - when (option) { - is AnalysisSituationOption.CategoryOption -> copy(category = option.category) - is AnalysisSituationOption.PurposeOption -> copy(purpose = option.purpose) - is AnalysisSituationOption.StyleOption -> copy(style = option.style) - is AnalysisSituationOption.AudienceOption -> copy(audience = option.audience) +private fun RecordingAudioController.handleControlClick(recordingState: AudioSessionState) { + when (recordingState) { + AudioSessionState.Idle -> startRecording() + is AudioSessionState.Recording -> pauseRecording() + is AudioSessionState.PausedRecording -> resumeRecording() + is AudioSessionState.ReadyToPlay -> startPlayback() + is AudioSessionState.Playing -> stopPlayback() } - -private fun PresentationAnalysisSummary.toAnalysisForm(): AnalysisForm = - AnalysisForm( - presentationTitle = title, - presentationDate = analyzedAt, - category = category, - purpose = purpose, - style = style, - audience = audience, - ) - -private fun AnalysisFlowUiState.toPresentationAnalysisSubmissionOrNull(): PresentationAnalysisSubmission? { - val category = form.category ?: return null - val purpose = form.purpose ?: return null - val style = form.style ?: return null - val audience = form.audience ?: return null - val recordingFilePath = form.audioFileUri ?: recordingState.recordingFilePath ?: return null - val isFileUpload = form.scriptInputType == ScriptInputType.FILE_UPLOAD - - return PresentationAnalysisSubmission( - name = form.presentationTitle.trim(), - date = form.presentationDate, - category = category, - purpose = purpose, - style = style, - audience = audience, - script = form.script.takeIf { !isFileUpload && it.isNotBlank() }, - scriptFileUri = form.scriptFileUri.takeIf { isFileUpload && !it.isNullOrBlank() }, - audioFileUri = form.audioFileUri, - recordingFilePath = recordingFilePath, - ) } - -private fun String.toRequestDate(): String = - runCatching { - val (year, month, day) = split("년 ", "월 ", "일") - - LocalDate( - year = year.toInt(), - month = month.toInt(), - day = day.toInt(), - ).toString() - }.getOrDefault(this) 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 new file mode 100644 index 00000000..1ecc447d --- /dev/null +++ b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AnalysisFormMapper.kt @@ -0,0 +1,90 @@ +package com.team.prezel.feature.analysis.impl + +import com.team.prezel.core.model.presentation.Audience +import com.team.prezel.core.model.presentation.Category +import com.team.prezel.core.model.presentation.PresentationAnalysisSummary +import com.team.prezel.core.model.presentation.Purpose +import com.team.prezel.core.model.presentation.Style +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.AnalysisForm +import com.team.prezel.feature.analysis.impl.contract.AnalysisSituationOption +import com.team.prezel.feature.analysis.impl.contract.ScriptInputType +import com.team.prezel.feature.analysis.impl.contract.recordingFilePath +import kotlinx.datetime.LocalDate + +internal data class PresentationAnalysisSubmission( + val name: String, + val date: String, + val category: Category, + val purpose: Purpose, + val style: Style, + val audience: Audience, + val script: String?, + val scriptFileUri: String?, + val audioFileUri: String?, + val recordingFilePath: String, +) + +internal fun AnalysisFlowUiIntent.reduceFormOrNull(form: AnalysisForm): AnalysisForm? = + when (this) { + is AnalysisFlowUiIntent.UpdatePresentationTitle -> form.copy(presentationTitle = title) + is AnalysisFlowUiIntent.UpdatePresentationDate -> form.copy(presentationDate = date) + is AnalysisFlowUiIntent.SelectScriptInputType -> form.copy(scriptInputType = inputType) + is AnalysisFlowUiIntent.UpdateScript -> form.copy(script = script) + is AnalysisFlowUiIntent.SelectScriptFile -> form.copy(scriptFileUri = fileUri) + is AnalysisFlowUiIntent.SelectAudioFile -> form.copy(audioFileUri = fileUri) + is AnalysisFlowUiIntent.SelectSituationOption -> form.selectSituationOption(option) + else -> null + } + +internal fun PresentationAnalysisSummary.toAnalysisForm(): AnalysisForm = + AnalysisForm( + presentationTitle = title, + presentationDate = analyzedAt, + category = category, + purpose = purpose, + style = style, + audience = audience, + ) + +internal fun AnalysisFlowUiState.toPresentationAnalysisSubmissionOrNull(): PresentationAnalysisSubmission? { + val category = form.category ?: return null + val purpose = form.purpose ?: return null + val style = form.style ?: return null + val audience = form.audience ?: return null + val recordingFilePath = form.audioFileUri ?: recordingState.recordingFilePath ?: return null + val isFileUpload = form.scriptInputType == ScriptInputType.FILE_UPLOAD + + return PresentationAnalysisSubmission( + name = form.presentationTitle.trim(), + date = form.presentationDate, + category = category, + purpose = purpose, + style = style, + audience = audience, + script = form.script.takeIf { !isFileUpload && it.isNotBlank() }, + scriptFileUri = form.scriptFileUri.takeIf { isFileUpload && !it.isNullOrBlank() }, + audioFileUri = form.audioFileUri, + recordingFilePath = recordingFilePath, + ) +} + +internal fun String.toRequestDate(): String = + runCatching { + val (year, month, day) = split("년 ", "월 ", "일") + + LocalDate( + year = year.toInt(), + month = month.toInt(), + day = day.toInt(), + ).toString() + }.getOrDefault(this) + +private fun AnalysisForm.selectSituationOption(option: AnalysisSituationOption): AnalysisForm = + when (option) { + is AnalysisSituationOption.CategoryOption -> copy(category = option.category) + is AnalysisSituationOption.PurposeOption -> copy(purpose = option.purpose) + is AnalysisSituationOption.StyleOption -> copy(style = option.style) + is AnalysisSituationOption.AudienceOption -> copy(audience = option.audience) + } 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 373ecacf..28ef2600 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 @@ -23,65 +23,31 @@ import dagger.hilt.android.components.ActivityRetainedComponent import dagger.multibindings.IntoSet internal fun EntryProviderScope.featureAnalysisEntryBuilder() { - entry { key -> - AnalysisRoute( - flowId = key.flowId, - enterIntent = AnalysisFlowUiIntent.EnterStep(AnalysisFlowStep.PRESENTATION_SCHEDULE), - stepToNavKey = key::toNavKey, + analysisEntry { AnalysisFlowUiIntent.EnterStep(AnalysisFlowStep.PRESENTATION_SCHEDULE) } + analysisEntry { AnalysisFlowUiIntent.EnterStep(AnalysisFlowStep.PRESENTATION_SITUATION) } + analysisEntry { AnalysisFlowUiIntent.EnterStep(AnalysisFlowStep.SCRIPT_INPUT) } + analysisEntry { AnalysisFlowUiIntent.EnterStep(AnalysisFlowStep.AUDIO_UPLOAD) } + analysisEntry { AnalysisFlowUiIntent.EnterStep(AnalysisFlowStep.VOICE_RECORDING) } + analysisEntry { AnalysisFlowUiIntent.EnterStep(AnalysisFlowStep.ANALYZING) } + analysisEntry { key -> + AnalysisFlowUiIntent.StartReRecording( + presentationId = key.presentationId, + isPast = key.isPast, ) } - entry { key -> - AnalysisRoute( - flowId = key.flowId, - enterIntent = AnalysisFlowUiIntent.EnterStep(AnalysisFlowStep.PRESENTATION_SITUATION), - stepToNavKey = key::toNavKey, + analysisEntry { key -> + AnalysisFlowUiIntent.StartReWritingScript( + presentationId = key.presentationId, + isPast = key.isPast, ) } - entry { key -> - AnalysisRoute( - flowId = key.flowId, - enterIntent = AnalysisFlowUiIntent.EnterStep(AnalysisFlowStep.SCRIPT_INPUT), - stepToNavKey = key::toNavKey, - ) - } - entry { key -> - AnalysisRoute( - flowId = key.flowId, - enterIntent = AnalysisFlowUiIntent.EnterStep(AnalysisFlowStep.AUDIO_UPLOAD), - stepToNavKey = key::toNavKey, - ) - } - entry { key -> - AnalysisRoute( - flowId = key.flowId, - enterIntent = AnalysisFlowUiIntent.EnterStep(AnalysisFlowStep.VOICE_RECORDING), - stepToNavKey = key::toNavKey, - ) - } - entry { key -> - AnalysisRoute( - flowId = key.flowId, - enterIntent = AnalysisFlowUiIntent.EnterStep(AnalysisFlowStep.ANALYZING), - stepToNavKey = key::toNavKey, - ) - } - entry { key -> - AnalysisRoute( - flowId = key.flowId, - enterIntent = AnalysisFlowUiIntent.StartReRecording( - presentationId = key.presentationId, - isPast = key.isPast, - ), - stepToNavKey = key::toNavKey, - ) - } - entry { key -> +} + +private inline fun EntryProviderScope.analysisEntry(crossinline enterIntent: (T) -> AnalysisFlowUiIntent) { + entry { key -> AnalysisRoute( flowId = key.flowId, - enterIntent = AnalysisFlowUiIntent.StartReWritingScript( - presentationId = key.presentationId, - isPast = key.isPast, - ), + enterIntent = enterIntent(key), stepToNavKey = key::toNavKey, ) } @@ -137,7 +103,7 @@ private tailrec fun Context.findViewModelStoreOwner(): ViewModelStoreOwner = when (this) { is ViewModelStoreOwner -> this is ContextWrapper -> baseContext.findViewModelStoreOwner() - else -> error("ViewModelStoreOwner is not available from context: $this") + else -> error("Context에서 ViewModelStoreOwner를 찾을 수 없습니다: $this") } @Module From 1e1a76528f745b01e9996d605b33cc3ca0115855 Mon Sep 17 00:00:00 2001 From: Ham BeomJoon Date: Wed, 27 May 2026 21:13:37 +0900 Subject: [PATCH 08/15] =?UTF-8?q?feat:=20=EB=B6=84=EC=84=9D=20=EC=8B=9C?= =?UTF-8?q?=EC=9E=91=20=ED=83=80=EC=9E=85(=EB=85=B9=EC=9D=8C/=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=20=EC=97=85=EB=A1=9C=EB=93=9C)=20=EA=B5=AC=EB=B6=84?= =?UTF-8?q?=20=EB=A1=9C=EC=A7=81=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `AnalysisNavKey`에 `AnalysisStartType` 필드를 추가하여 진입 경로 구분 - 시작 타입에 따라 오디오 입력 단계(`AUDIO_UPLOAD` 또는 `VOICE_RECORDING`)를 동적으로 결정하도록 개선 - `AnalysisFlowViewModel`에서 `startType`에 따른 단계 전환 및 재시도 로직 반영 - 홈 화면에서 파일 업로드와 음성 녹음 분석 진입 시 각각의 `startType` 전달하도록 수정 --- .../feature/analysis/api/AnalysisNavKey.kt | 15 ++++++++++ .../analysis/impl/AnalysisFlowViewModel.kt | 26 ++++++++++++---- .../impl/contract/AnalysisFlowUiIntent.kt | 2 ++ .../impl/contract/AnalysisFlowUiState.kt | 2 ++ .../impl/navigation/AnalysisEntryBuilder.kt | 30 +++++++++++-------- .../home/impl/navigation/HomeEntryBuilder.kt | 5 ++-- 6 files changed, 60 insertions(+), 20 deletions(-) diff --git a/Prezel/feature/analysis/api/src/main/java/com/team/prezel/feature/analysis/api/AnalysisNavKey.kt b/Prezel/feature/analysis/api/src/main/java/com/team/prezel/feature/analysis/api/AnalysisNavKey.kt index 92806d43..b748b6b5 100644 --- a/Prezel/feature/analysis/api/src/main/java/com/team/prezel/feature/analysis/api/AnalysisNavKey.kt +++ b/Prezel/feature/analysis/api/src/main/java/com/team/prezel/feature/analysis/api/AnalysisNavKey.kt @@ -7,35 +7,42 @@ import java.util.UUID @Serializable sealed interface AnalysisNavKey : NavKey { val flowId: String + val startType: AnalysisStartType @Serializable data class Schedule( override val flowId: String = newAnalysisFlowId(), + override val startType: AnalysisStartType = AnalysisStartType.VOICE_RECORDING, ) : AnalysisNavKey @Serializable data class Situation( override val flowId: String, + override val startType: AnalysisStartType, ) : AnalysisNavKey @Serializable data class Script( override val flowId: String, + override val startType: AnalysisStartType, ) : AnalysisNavKey @Serializable data class AudioUpload( override val flowId: String, + override val startType: AnalysisStartType, ) : AnalysisNavKey @Serializable data class Recording( override val flowId: String, + override val startType: AnalysisStartType, ) : AnalysisNavKey @Serializable data class Analyzing( override val flowId: String, + override val startType: AnalysisStartType, ) : AnalysisNavKey @Serializable @@ -43,6 +50,7 @@ sealed interface AnalysisNavKey : NavKey { val presentationId: Long, val isPast: Boolean = false, override val flowId: String = newAnalysisFlowId(), + override val startType: AnalysisStartType = AnalysisStartType.VOICE_RECORDING, ) : AnalysisNavKey @Serializable @@ -50,7 +58,14 @@ sealed interface AnalysisNavKey : NavKey { val presentationId: Long, val isPast: Boolean = false, override val flowId: String = newAnalysisFlowId(), + override val startType: AnalysisStartType = AnalysisStartType.VOICE_RECORDING, ) : AnalysisNavKey } +@Serializable +enum class AnalysisStartType { + VOICE_RECORDING, + FILE_UPLOAD, +} + private fun newAnalysisFlowId(): String = UUID.randomUUID().toString() 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 b3e11c47..2166675c 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 @@ -9,6 +9,7 @@ import com.team.prezel.core.domain.usecase.presentation.FetchPresentationDetailU import com.team.prezel.core.domain.usecase.presentation.ReAnalyzePresentationUseCase import com.team.prezel.core.model.presentation.PresentationAnalysisSummary import com.team.prezel.core.ui.base.BaseViewModel +import com.team.prezel.feature.analysis.api.AnalysisStartType import com.team.prezel.feature.analysis.impl.cache.AnalysisFileCache import com.team.prezel.feature.analysis.impl.contract.AnalysisFlowStep import com.team.prezel.feature.analysis.impl.contract.AnalysisFlowUiEffect @@ -43,7 +44,12 @@ internal class AnalysisFlowViewModel @Inject constructor( } when (intent) { - is AnalysisFlowUiIntent.EnterStep -> updateState { copy(step = intent.step) } + is AnalysisFlowUiIntent.EnterStep -> updateState { + copy( + step = intent.step, + startType = intent.startType, + ) + } is AnalysisFlowUiIntent.StartReRecording -> startReRecording( presentationId = intent.presentationId, isPast = intent.isPast, @@ -76,7 +82,7 @@ internal class AnalysisFlowViewModel @Inject constructor( val nextStep = when (currentState.step) { AnalysisFlowStep.PRESENTATION_SCHEDULE -> AnalysisFlowStep.PRESENTATION_SITUATION AnalysisFlowStep.PRESENTATION_SITUATION -> AnalysisFlowStep.SCRIPT_INPUT - AnalysisFlowStep.SCRIPT_INPUT -> AnalysisFlowStep.VOICE_RECORDING + AnalysisFlowStep.SCRIPT_INPUT -> currentState.audioInputStep AnalysisFlowStep.AUDIO_UPLOAD, AnalysisFlowStep.VOICE_RECORDING, AnalysisFlowStep.ANALYZING, @@ -283,7 +289,7 @@ internal class AnalysisFlowViewModel @Inject constructor( } is AnalysisFailureAction.ShowMessage -> { - val retryStep = AnalysisFlowStep.VOICE_RECORDING + val retryStep = currentState.audioInputStep viewModelScope.launch { sendEffect(AnalysisFlowUiEffect.ShowMessage(action.message)) } @@ -309,14 +315,16 @@ internal class AnalysisFlowViewModel @Inject constructor( } AnalysisUploadType.AUDIO -> { - val retryStep = AnalysisFlowStep.VOICE_RECORDING + val retryStep = currentState.audioInputStep updateState { copy( step = retryStep, form = form.copy(audioFileUri = null), ) } - audioController.reset() + if (retryStep == AnalysisFlowStep.VOICE_RECORDING) { + audioController.reset() + } viewModelScope.launch { sendEffect(AnalysisFlowUiEffect.NavigateToStep(step = retryStep)) } } } @@ -325,7 +333,7 @@ internal class AnalysisFlowViewModel @Inject constructor( private fun skipScript() { if (currentState.step != AnalysisFlowStep.SCRIPT_INPUT) return - val nextStep = AnalysisFlowStep.VOICE_RECORDING + val nextStep = currentState.audioInputStep updateState { copy( step = nextStep, @@ -384,6 +392,12 @@ internal class AnalysisFlowViewModel @Inject constructor( } } +private val AnalysisFlowUiState.audioInputStep: AnalysisFlowStep + get() = when (startType) { + AnalysisStartType.FILE_UPLOAD -> AnalysisFlowStep.AUDIO_UPLOAD + AnalysisStartType.VOICE_RECORDING -> AnalysisFlowStep.VOICE_RECORDING + } + private fun AudioSessionEffect.toUiMessage(): AnalysisUiMessage = when (this) { AudioSessionEffect.RecordingStartFailed -> AnalysisUiMessage.RECORDING_START_FAILED 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 f5b4536f..13bd39c0 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 @@ -6,11 +6,13 @@ import com.team.prezel.core.model.presentation.Category import com.team.prezel.core.model.presentation.Purpose import com.team.prezel.core.model.presentation.Style import com.team.prezel.core.ui.base.UiIntent +import com.team.prezel.feature.analysis.api.AnalysisStartType @Immutable internal sealed interface AnalysisFlowUiIntent : UiIntent { data class EnterStep( val step: AnalysisFlowStep, + val startType: AnalysisStartType, ) : AnalysisFlowUiIntent data class StartReRecording( diff --git a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/contract/AnalysisFlowUiState.kt b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/contract/AnalysisFlowUiState.kt index b2634e1a..3067eefc 100644 --- a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/contract/AnalysisFlowUiState.kt +++ b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/contract/AnalysisFlowUiState.kt @@ -7,6 +7,7 @@ import com.team.prezel.core.model.presentation.Category import com.team.prezel.core.model.presentation.Purpose import com.team.prezel.core.model.presentation.Style import com.team.prezel.core.ui.base.UiState +import com.team.prezel.feature.analysis.api.AnalysisStartType import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf @@ -18,6 +19,7 @@ internal data class AnalysisFlowUiState( val recordingVolumes: ImmutableList = persistentListOf(), val reRecordingPresentationId: Long? = null, val reWritingScriptPresentationId: Long? = null, + val startType: AnalysisStartType = AnalysisStartType.VOICE_RECORDING, ) : UiState { val progress: Float get() = when (step) { 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 28ef2600..5dd67212 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 @@ -23,12 +23,12 @@ import dagger.hilt.android.components.ActivityRetainedComponent import dagger.multibindings.IntoSet internal fun EntryProviderScope.featureAnalysisEntryBuilder() { - analysisEntry { AnalysisFlowUiIntent.EnterStep(AnalysisFlowStep.PRESENTATION_SCHEDULE) } - analysisEntry { AnalysisFlowUiIntent.EnterStep(AnalysisFlowStep.PRESENTATION_SITUATION) } - analysisEntry { AnalysisFlowUiIntent.EnterStep(AnalysisFlowStep.SCRIPT_INPUT) } - analysisEntry { AnalysisFlowUiIntent.EnterStep(AnalysisFlowStep.AUDIO_UPLOAD) } - analysisEntry { AnalysisFlowUiIntent.EnterStep(AnalysisFlowStep.VOICE_RECORDING) } - analysisEntry { AnalysisFlowUiIntent.EnterStep(AnalysisFlowStep.ANALYZING) } + analysisEntry { key -> key.enterStep(AnalysisFlowStep.PRESENTATION_SCHEDULE) } + analysisEntry { key -> key.enterStep(AnalysisFlowStep.PRESENTATION_SITUATION) } + analysisEntry { key -> key.enterStep(AnalysisFlowStep.SCRIPT_INPUT) } + analysisEntry { key -> key.enterStep(AnalysisFlowStep.AUDIO_UPLOAD) } + analysisEntry { key -> key.enterStep(AnalysisFlowStep.VOICE_RECORDING) } + analysisEntry { key -> key.enterStep(AnalysisFlowStep.ANALYZING) } analysisEntry { key -> AnalysisFlowUiIntent.StartReRecording( presentationId = key.presentationId, @@ -43,6 +43,12 @@ internal fun EntryProviderScope.featureAnalysisEntryBuilder() { } } +private fun AnalysisNavKey.enterStep(step: AnalysisFlowStep): AnalysisFlowUiIntent = + AnalysisFlowUiIntent.EnterStep( + step = step, + startType = startType, + ) + private inline fun EntryProviderScope.analysisEntry(crossinline enterIntent: (T) -> AnalysisFlowUiIntent) { entry { key -> AnalysisRoute( @@ -85,18 +91,18 @@ private fun AnalysisRoute( private fun AnalysisNavKey.toNavKey(step: AnalysisFlowStep): AnalysisNavKey = when (step) { - AnalysisFlowStep.PRESENTATION_SCHEDULE -> AnalysisNavKey.Schedule(flowId = flowId) - AnalysisFlowStep.PRESENTATION_SITUATION -> AnalysisNavKey.Situation(flowId = flowId) + AnalysisFlowStep.PRESENTATION_SCHEDULE -> AnalysisNavKey.Schedule(flowId = flowId, startType = startType) + AnalysisFlowStep.PRESENTATION_SITUATION -> AnalysisNavKey.Situation(flowId = flowId, startType = startType) AnalysisFlowStep.SCRIPT_INPUT, AnalysisFlowStep.SCRIPT_FILE_RECOGNITION_FAILED, - -> AnalysisNavKey.Script(flowId = flowId) + -> AnalysisNavKey.Script(flowId = flowId, startType = startType) - AnalysisFlowStep.AUDIO_UPLOAD -> AnalysisNavKey.AudioUpload(flowId = flowId) + AnalysisFlowStep.AUDIO_UPLOAD -> AnalysisNavKey.AudioUpload(flowId = flowId, startType = startType) AnalysisFlowStep.VOICE_RECORDING, AnalysisFlowStep.FILE_RECOGNITION_FAILED, - -> AnalysisNavKey.Recording(flowId = flowId) + -> AnalysisNavKey.Recording(flowId = flowId, startType = startType) - AnalysisFlowStep.ANALYZING -> AnalysisNavKey.Analyzing(flowId = flowId) + AnalysisFlowStep.ANALYZING -> AnalysisNavKey.Analyzing(flowId = flowId, startType = startType) } private tailrec fun Context.findViewModelStoreOwner(): ViewModelStoreOwner = diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/navigation/HomeEntryBuilder.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/navigation/HomeEntryBuilder.kt index 9f077746..a08029b5 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/navigation/HomeEntryBuilder.kt +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/navigation/HomeEntryBuilder.kt @@ -4,6 +4,7 @@ import androidx.navigation3.runtime.EntryProviderScope import androidx.navigation3.runtime.NavKey import com.team.prezel.core.navigation.LocalNavigator import com.team.prezel.feature.analysis.api.AnalysisNavKey +import com.team.prezel.feature.analysis.api.AnalysisStartType import com.team.prezel.feature.home.api.HomeNavKey import com.team.prezel.feature.home.impl.main.HomeScreen import dagger.Module @@ -18,10 +19,10 @@ internal fun EntryProviderScope.featureHomeEntryBuilder() { HomeScreen( navigateToFileUploadAnalysis = { - navigator.navigate(AnalysisNavKey.Schedule()) + navigator.navigate(AnalysisNavKey.Schedule(startType = AnalysisStartType.FILE_UPLOAD)) }, navigateToVoiceRecordingAnalysis = { - navigator.navigate(AnalysisNavKey.Schedule()) + navigator.navigate(AnalysisNavKey.Schedule(startType = AnalysisStartType.VOICE_RECORDING)) }, ) } From 111d7d796f4589f3dbbdb90b0f426fe6bd2db3d3 Mon Sep 17 00:00:00 2001 From: Ham BeomJoon Date: Fri, 29 May 2026 21:42:02 +0900 Subject: [PATCH 09/15] =?UTF-8?q?feat:=20=EB=B6=84=EC=84=9D=20=EC=9E=AC?= =?UTF-8?q?=EB=85=B9=EC=9D=8C=20=EC=8B=9C=20=EA=B8=B0=EC=A1=B4=20=EB=8C=80?= =?UTF-8?q?=EB=B3=B8=20=EB=B6=88=EB=9F=AC=EC=98=A4=EA=B8=B0=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EB=B0=8F=20=EC=9D=8C=EC=84=B1=20=EB=85=B9=EC=9D=8C?= =?UTF-8?q?=20=ED=99=94=EB=A9=B4=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 분석 재녹음 시 `FetchPresentationScriptDetailUseCase`를 사용하여 기존 대본을 자동으로 로드하도록 개선 - `AnalysisUiMessage`에 `SCRIPT_LOAD_FAILED` 에러 타입 및 문자열 리소스 추가 - 음성 녹음 화면에서 대본 확대/축소(`ZoomIn`, `ZoomOut`) 기능 및 UI 연동 - 대본 확대 시 상단 바 노출 여부 및 상태 표시줄 스타일 동적 변경 로직 추가 - `ReportNavKey`에 `refreshKey`를 추가하여 분석 완료 후 리포트로 이동 시 화면 갱신 강제 - 분석 리포트 화면에서 다이얼로그 확인 클릭 시 상태 초기화 로직 보완 - `AnalysisFlowUiState`의 뒤로가기 시 녹음 리셋 판단 로직 수정 --- .../analysis/impl/AnalysisBackStateReducer.kt | 3 +- .../analysis/impl/AnalysisFlowViewModel.kt | 35 +++++++++++++---- .../feature/analysis/impl/AnalysisScreen.kt | 35 +++++++++-------- .../analysis/impl/model/AnalysisUiMessage.kt | 1 + .../impl/navigation/AnalysisEntryBuilder.kt | 8 +++- .../impl/recording/VoiceRecordingContent.kt | 38 ++++++++++++++----- .../impl/recording/VoiceRecordingScreen.kt | 23 +++++++---- .../impl/src/main/res/values/strings.xml | 1 + .../prezel/feature/report/api/ReportNavKey.kt | 1 + .../report/impl/AnalysisReportViewModel.kt | 1 + 10 files changed, 103 insertions(+), 43 deletions(-) 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 5244b56c..b276d47e 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 @@ -3,10 +3,9 @@ package com.team.prezel.feature.analysis.impl import com.team.prezel.feature.analysis.impl.contract.AnalysisFlowStep import com.team.prezel.feature.analysis.impl.contract.AnalysisFlowUiState import com.team.prezel.feature.analysis.impl.contract.AnalysisForm -import com.team.prezel.feature.analysis.impl.contract.recordingFilePath internal val AnalysisFlowUiState.shouldResetRecordingOnBack: Boolean - get() = step == AnalysisFlowStep.VOICE_RECORDING && recordingState.recordingFilePath != null + get() = step == AnalysisFlowStep.VOICE_RECORDING internal val AnalysisFlowUiState.shouldReleaseAudioOnBack: Boolean get() = 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 2166675c..dd189cca 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 @@ -6,6 +6,7 @@ import com.team.prezel.core.audio.AudioSessionState import com.team.prezel.core.audio.RecordingAudioController import com.team.prezel.core.domain.usecase.presentation.AnalyzePresentationUseCase import com.team.prezel.core.domain.usecase.presentation.FetchPresentationDetailUseCase +import com.team.prezel.core.domain.usecase.presentation.FetchPresentationScriptDetailUseCase import com.team.prezel.core.domain.usecase.presentation.ReAnalyzePresentationUseCase import com.team.prezel.core.model.presentation.PresentationAnalysisSummary import com.team.prezel.core.ui.base.BaseViewModel @@ -28,6 +29,7 @@ internal class AnalysisFlowViewModel @Inject constructor( private val analyzePresentationUseCase: AnalyzePresentationUseCase, private val reAnalyzePresentationUseCase: ReAnalyzePresentationUseCase, private val fetchPresentationDetailUseCase: FetchPresentationDetailUseCase, + private val fetchPresentationScriptDetailUseCase: FetchPresentationScriptDetailUseCase, private val analysisFileCache: AnalysisFileCache, private val audioController: RecordingAudioController, ) : BaseViewModel(AnalysisFlowUiState()) { @@ -173,13 +175,23 @@ internal class AnalysisFlowViewModel @Inject constructor( viewModelScope.launch { fetchPresentationDetailUseCase(presentationId = presentationId, isPast = isPast) .onSuccess { summary -> - updateState { - copy( - form = summary.toAnalysisForm(), - step = AnalysisFlowStep.VOICE_RECORDING, - reRecordingPresentationId = presentationId, - ) - } + summary + .fetchOriginalScript(fetchPresentationScriptDetailUseCase) + .onSuccess { script -> + updateState { + copy( + form = summary.toAnalysisForm().copy( + scriptInputType = ScriptInputType.DIRECT_INPUT, + script = script.orEmpty(), + ), + step = AnalysisFlowStep.VOICE_RECORDING, + reRecordingPresentationId = presentationId, + ) + } + }.onFailure { + sendEffect(AnalysisFlowUiEffect.ShowMessage(AnalysisUiMessage.SCRIPT_LOAD_FAILED)) + sendEffect(AnalysisFlowUiEffect.NavigateBack) + } }.onFailure { sendEffect(AnalysisFlowUiEffect.ShowMessage(AnalysisUiMessage.ANALYSIS_FAILED)) sendEffect(AnalysisFlowUiEffect.NavigateBack) @@ -398,6 +410,15 @@ private val AnalysisFlowUiState.audioInputStep: AnalysisFlowStep AnalysisStartType.VOICE_RECORDING -> AnalysisFlowStep.VOICE_RECORDING } +private suspend fun PresentationAnalysisSummary.fetchOriginalScript( + fetchPresentationScriptDetailUseCase: FetchPresentationScriptDetailUseCase, +): Result { + if (accuracyScore == null || scriptMatchRate == null) return Result.success(null) + + return fetchPresentationScriptDetailUseCase(analysisResultId = analysisResultId) + .map { it.originalScript } +} + private fun AudioSessionEffect.toUiMessage(): AnalysisUiMessage = when (this) { AudioSessionEffect.RecordingStartFailed -> AnalysisUiMessage.RECORDING_START_FAILED 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 acabe0de..611a38c2 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 @@ -1,6 +1,7 @@ package com.team.prezel.feature.analysis.impl import androidx.activity.compose.BackHandler +import androidx.annotation.StringRes import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.rememberCoroutineScope @@ -47,22 +48,7 @@ internal fun AnalysisScreen( is AnalysisFlowUiEffect.NavigateToStep -> navigateToStep(effect.step) is AnalysisFlowUiEffect.NavigateToReport -> navigateToReport(effect.presentationId) is AnalysisFlowUiEffect.ShowMessage -> { - val resId = when (effect.message) { - AnalysisUiMessage.AUTH_EXPIRED -> R.string.feature_analysis_impl_error_auth_expired - AnalysisUiMessage.ANALYSIS_FAILED -> R.string.feature_analysis_impl_error_analysis_failed - AnalysisUiMessage.NETWORK_FAILED -> R.string.feature_analysis_impl_error_network_failed - AnalysisUiMessage.UNKNOWN_FAILED -> R.string.feature_analysis_impl_error_unknown_failed - AnalysisUiMessage.RECORD_AUDIO_PERMISSION_DENIED -> - R.string.feature_analysis_impl_voice_recording_permission_denied - - AnalysisUiMessage.RECORD_AUDIO_PERMISSION_PERMANENTLY_DENIED -> - R.string.feature_analysis_impl_voice_recording_permission_permanently_denied - - AnalysisUiMessage.RECORDING_START_FAILED -> R.string.feature_analysis_impl_voice_recording_failed - AnalysisUiMessage.RECORDING_STOP_FAILED -> R.string.feature_analysis_impl_voice_recording_stop_failed - AnalysisUiMessage.PLAYBACK_START_FAILED -> R.string.feature_analysis_impl_voice_recording_playback_failed - } - snackbarHostState.showPrezelSnackbar(message = resources.getString(resId)) + snackbarHostState.showPrezelSnackbar(message = resources.getString(effect.message.toStringRes())) } } } @@ -74,6 +60,23 @@ internal fun AnalysisScreen( ) } +@StringRes +private fun AnalysisUiMessage.toStringRes(): Int = + when (this) { + AnalysisUiMessage.AUTH_EXPIRED -> R.string.feature_analysis_impl_error_auth_expired + AnalysisUiMessage.ANALYSIS_FAILED -> R.string.feature_analysis_impl_error_analysis_failed + AnalysisUiMessage.SCRIPT_LOAD_FAILED -> R.string.feature_analysis_impl_error_script_load_failed + AnalysisUiMessage.NETWORK_FAILED -> R.string.feature_analysis_impl_error_network_failed + AnalysisUiMessage.UNKNOWN_FAILED -> R.string.feature_analysis_impl_error_unknown_failed + AnalysisUiMessage.RECORD_AUDIO_PERMISSION_DENIED -> R.string.feature_analysis_impl_voice_recording_permission_denied + AnalysisUiMessage.RECORD_AUDIO_PERMISSION_PERMANENTLY_DENIED -> + R.string.feature_analysis_impl_voice_recording_permission_permanently_denied + + AnalysisUiMessage.RECORDING_START_FAILED -> R.string.feature_analysis_impl_voice_recording_failed + AnalysisUiMessage.RECORDING_STOP_FAILED -> R.string.feature_analysis_impl_voice_recording_stop_failed + AnalysisUiMessage.PLAYBACK_START_FAILED -> R.string.feature_analysis_impl_voice_recording_playback_failed + } + @Composable private fun AnalysisScreen( uiState: AnalysisFlowUiState, diff --git a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/model/AnalysisUiMessage.kt b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/model/AnalysisUiMessage.kt index db033a82..73271dbe 100644 --- a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/model/AnalysisUiMessage.kt +++ b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/model/AnalysisUiMessage.kt @@ -3,6 +3,7 @@ package com.team.prezel.feature.analysis.impl.model internal enum class AnalysisUiMessage { AUTH_EXPIRED, ANALYSIS_FAILED, + SCRIPT_LOAD_FAILED, NETWORK_FAILED, UNKNOWN_FAILED, RECORD_AUDIO_PERMISSION_DENIED, 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 5dd67212..9e63d57c 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 @@ -21,6 +21,7 @@ import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.android.components.ActivityRetainedComponent import dagger.multibindings.IntoSet +import java.util.UUID internal fun EntryProviderScope.featureAnalysisEntryBuilder() { analysisEntry { key -> key.enterStep(AnalysisFlowStep.PRESENTATION_SCHEDULE) } @@ -81,7 +82,10 @@ private fun AnalysisRoute( navigateToStep = { step -> navigator.navigate(key = stepToNavKey(step)) }, navigateToReport = { presentationId -> navigator.navigate( - key = ReportNavKey(presentationId = presentationId), + key = ReportNavKey( + presentationId = presentationId, + refreshKey = newReportRefreshKey(), + ), clearStack = true, ) }, @@ -122,3 +126,5 @@ object FeatureAnalysisModule { featureAnalysisEntryBuilder() } } + +private fun newReportRefreshKey(): String = UUID.randomUUID().toString() 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 e6ba1234..1d081477 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 @@ -16,7 +16,6 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll 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 @@ -43,6 +42,7 @@ import com.team.prezel.core.designsystem.component.actions.button.config.PrezelB import com.team.prezel.core.designsystem.component.voice.PrezelVoiceChromeWave import com.team.prezel.core.designsystem.icon.PrezelIcons 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.toImmutableList @@ -52,6 +52,8 @@ internal fun VoiceRecordingContent( script: String, recordingState: AudioSessionState, recordingVolumes: ImmutableList, + isScriptExpanded: Boolean, + onToggleScriptExpanded: () -> Unit, onClickRecordingControl: () -> Unit, modifier: Modifier = Modifier, ) { @@ -62,8 +64,11 @@ internal fun VoiceRecordingContent( .padding(vertical = PrezelTheme.spacing.V16), horizontalAlignment = Alignment.CenterHorizontally, ) { - if (!recordingState.isCompleted) { - VoiceRecordingScriptHeader() + if (!recordingState.isCompleted || isScriptExpanded) { + VoiceRecordingScriptHeader( + isScriptExpanded = isScriptExpanded, + onToggleScriptExpanded = onToggleScriptExpanded, + ) Spacer(modifier = Modifier.height(PrezelTheme.spacing.V16)) } @@ -83,7 +88,10 @@ internal fun VoiceRecordingContent( } @Composable -private fun VoiceRecordingScriptHeader() { +private fun VoiceRecordingScriptHeader( + isScriptExpanded: Boolean, + onToggleScriptExpanded: () -> Unit, +) { Box( modifier = Modifier .fillMaxWidth() @@ -98,10 +106,12 @@ private fun VoiceRecordingScriptHeader() { ) ScriptZoomButton( + isScriptExpanded = isScriptExpanded, modifier = Modifier .align(Alignment.CenterEnd) .offset(x = PrezelTheme.spacing.V12) .size(48.dp), + onClick = onToggleScriptExpanded, ) } } @@ -201,13 +211,23 @@ private fun VoiceRecordingStatusArea( } @Composable -private fun ScriptZoomButton(modifier: Modifier = Modifier) { - IconButton( - modifier = modifier, - onClick = {}, +private fun ScriptZoomButton( + isScriptExpanded: Boolean, + modifier: Modifier = Modifier, + onClick: () -> Unit, +) { + Box( + modifier = modifier.noRippleClickable(onClick = onClick), + contentAlignment = Alignment.Center, ) { Icon( - painter = painterResource(PrezelIcons.ZoomIn), + painter = painterResource( + if (isScriptExpanded) { + PrezelIcons.ZoomOut + } else { + PrezelIcons.ZoomIn + }, + ), contentDescription = null, modifier = Modifier.size(24.dp), tint = PrezelTheme.colors.iconRegular, 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 e986f490..f0adbb12 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 @@ -15,6 +15,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -69,8 +70,10 @@ private fun VoiceRecordingScreen( onAnalyze: () -> Unit, onBack: () -> Unit, ) { + var isScriptExpanded by rememberSaveable { mutableStateOf(false) } + VoiceRecordingStatusBarStyle( - style = if (recordingState.isCompleted) { + style = if (recordingState.isCompleted || isScriptExpanded) { EdgeToEdgeStatusBarStyle.BG_REGULAR } else { EdgeToEdgeStatusBarStyle.BG_MEDIUM @@ -82,19 +85,23 @@ private fun VoiceRecordingScreen( .fillMaxSize() .background(PrezelTheme.colors.bgRegular), ) { - if (recordingState.isCompleted) { - VoiceRecordingCompletedTopBar(onBack = onBack) - } else { - VoiceRecordingChromeTopBar( - recordingState = recordingState, - onBack = onBack, - ) + if (!isScriptExpanded) { + if (recordingState.isCompleted) { + VoiceRecordingCompletedTopBar(onBack = onBack) + } else { + VoiceRecordingChromeTopBar( + recordingState = recordingState, + onBack = onBack, + ) + } } VoiceRecordingContent( script = script, recordingState = recordingState, recordingVolumes = recordingVolumes, + isScriptExpanded = isScriptExpanded, + onToggleScriptExpanded = { isScriptExpanded = !isScriptExpanded }, onClickRecordingControl = onClickRecordingControl, modifier = Modifier.weight(1f), ) 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 04f26e5f..d287c537 100644 --- a/Prezel/feature/analysis/impl/src/main/res/values/strings.xml +++ b/Prezel/feature/analysis/impl/src/main/res/values/strings.xml @@ -79,6 +79,7 @@ 다른 텍스트 파일로 다시 시도해 주세요. 로그인이 만료되었어요. 다시 로그인해 주세요. 분석 중 오류가 발생했어요. 잠시 후 다시 시도해 주세요. + 기존 대본을 불러오지 못했어요. 다시 시도해 주세요. 네트워크 연결을 확인해 주세요. 알 수 없는 오류가 발생했어요. 뒤로가기 diff --git a/Prezel/feature/report/api/src/main/java/com/team/prezel/feature/report/api/ReportNavKey.kt b/Prezel/feature/report/api/src/main/java/com/team/prezel/feature/report/api/ReportNavKey.kt index 5cb4de82..2825db54 100644 --- a/Prezel/feature/report/api/src/main/java/com/team/prezel/feature/report/api/ReportNavKey.kt +++ b/Prezel/feature/report/api/src/main/java/com/team/prezel/feature/report/api/ReportNavKey.kt @@ -7,4 +7,5 @@ import kotlinx.serialization.Serializable data class ReportNavKey( val presentationId: Long, val isPast: Boolean = false, + val refreshKey: String = "", ) : NavKey diff --git a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/AnalysisReportViewModel.kt b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/AnalysisReportViewModel.kt index b9094245..9d3c092f 100644 --- a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/AnalysisReportViewModel.kt +++ b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/AnalysisReportViewModel.kt @@ -71,6 +71,7 @@ internal class AnalysisReportViewModel @AssistedInject constructor( private fun handleClickDialogConform() { val dialog = contentState?.reportDialog ?: return + updateContent { copy(reportDialog = null) } when (dialog) { AnalysisReportDialog.RE_RECORDING -> navigateToAnalysisRecording() From 9ef8a7de9a42b0b3ee6f6072df397bd9f6dab09b Mon Sep 17 00:00:00 2001 From: Ham BeomJoon Date: Fri, 29 May 2026 22:23:20 +0900 Subject: [PATCH 10/15] =?UTF-8?q?feat:=20=EB=B6=84=EC=84=9D=20=EC=8B=A4?= =?UTF-8?q?=ED=8C=A8=20=EC=B2=98=EB=A6=AC=20=EB=A1=9C=EC=A7=81=20=EA=B0=95?= =?UTF-8?q?=ED=99=94=20=EB=B0=8F=20=EB=8C=80=EB=B3=B8=20=ED=8C=8C=EC=9D=BC?= =?UTF-8?q?=20=EC=9D=BD=EA=B8=B0=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `VOICE_ANALYSIS_FAILED` 에러 타입 추가 및 서버 에러 코드 매핑 - 분석 실패 시 재시도 처리를 위한 `ANALYSIS_FAILED` 단계 및 화면 추가 - `AnalysisFileCache`에 URI로부터 텍스트를 읽어오는 `readTextFromUri` 메서드 구현 - `AnalysisFlowViewModel`에서 대본 파일 선택 시 텍스트 내용을 자동으로 불러오도록 개선 - 음성 인식 및 분석 실패 시나리오에 따른 내비게이션 및 에러 메시지 세분화 - 분석 프로세스 중 뒤로 가기 시 오디오 컨트롤러 리셋 로직 개선 - 분석 실패(error_analysis) 및 음성 인식 오류 관련 벡터 그래픽 리소스 추가 및 수정 --- .../team/prezel/core/common/error/AppError.kt | 1 + .../prezel/core/data/error/AppErrorExt.kt | 4 +- .../prezel/core/network/model/BaseResponse.kt | 1 + .../analysis/impl/AnalysisBackStateReducer.kt | 9 +- .../analysis/impl/AnalysisFailureHandler.kt | 6 +- .../analysis/impl/AnalysisFlowViewModel.kt | 92 +++++++++++++------ .../analysis/impl/AnalysisFormMapper.kt | 7 +- .../feature/analysis/impl/AnalysisScreen.kt | 8 ++ .../analysis/impl/cache/AnalysisFileCache.kt | 5 + .../impl/cache/AnalysisFileCacheImpl.kt | 8 ++ .../impl/contract/AnalysisFlowUiState.kt | 3 + .../analysis/impl/model/AnalysisUiMessage.kt | 1 + .../impl/navigation/AnalysisEntryBuilder.kt | 1 + .../result/FileRecognitionFailedScreen.kt | 24 ++++- .../feature_analysis_impl_error_analysis.xml | 42 +++++++++ .../feature_analysis_impl_error_voice.xml | 24 ++--- .../impl/src/main/res/values/strings.xml | 11 ++- 17 files changed, 191 insertions(+), 56 deletions(-) create mode 100644 Prezel/feature/analysis/impl/src/main/res/drawable/feature_analysis_impl_error_analysis.xml diff --git a/Prezel/core/common/src/main/kotlin/com/team/prezel/core/common/error/AppError.kt b/Prezel/core/common/src/main/kotlin/com/team/prezel/core/common/error/AppError.kt index d34b8651..9557e2fc 100644 --- a/Prezel/core/common/src/main/kotlin/com/team/prezel/core/common/error/AppError.kt +++ b/Prezel/core/common/src/main/kotlin/com/team/prezel/core/common/error/AppError.kt @@ -7,6 +7,7 @@ enum class AppError { NOT_FOUND, DUPLICATE, VOICE_RECOGNITION_FAILED, + VOICE_ANALYSIS_FAILED, SCRIPT_FILE_RECOGNITION_FAILED, NETWORK, UNKNOWN, diff --git a/Prezel/core/data/src/main/java/com/team/prezel/core/data/error/AppErrorExt.kt b/Prezel/core/data/src/main/java/com/team/prezel/core/data/error/AppErrorExt.kt index 66bbf75a..8e94c8b9 100644 --- a/Prezel/core/data/src/main/java/com/team/prezel/core/data/error/AppErrorExt.kt +++ b/Prezel/core/data/src/main/java/com/team/prezel/core/data/error/AppErrorExt.kt @@ -44,15 +44,17 @@ private fun ServerErrorCode.toDomainError(): AppError = ServerErrorCode.INVALID_REQUEST, ServerErrorCode.REQUIRED_TERMS_DISAGREED, ServerErrorCode.FILE_UPLOAD_FAILED, + ServerErrorCode.UNSUPPORTED_FILE_FORMAT, -> AppError.INVALID_REQUEST ServerErrorCode.FILE_IS_EMPTY -> AppError.SCRIPT_FILE_RECOGNITION_FAILED ServerErrorCode.SERVER_ERROR, ServerErrorCode.SENTENCE_NOT_FOUND, - ServerErrorCode.VOICE_ANALYSIS_FAILED, -> AppError.SERVER_ERROR + ServerErrorCode.VOICE_ANALYSIS_FAILED -> AppError.VOICE_ANALYSIS_FAILED + ServerErrorCode.TERMS_NOT_FOUND, ServerErrorCode.PRESENTATION_NOT_FOUND, ServerErrorCode.ANALYSIS_RESULT_NOT_FOUND, diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/model/BaseResponse.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/model/BaseResponse.kt index bd16ff58..e6a18ef6 100644 --- a/Prezel/core/network/src/main/java/com/team/prezel/core/network/model/BaseResponse.kt +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/model/BaseResponse.kt @@ -57,6 +57,7 @@ enum class ServerErrorCode( REQUIRED_TERMS_DISAGREED("TR002"), FILE_IS_EMPTY("F001"), FILE_UPLOAD_FAILED("F002"), + UNSUPPORTED_FILE_FORMAT("F004"), PRESENTATION_NOT_FOUND("P001"), ANALYSIS_RESULT_NOT_FOUND("A001"), SENTENCE_NOT_FOUND("S001"), 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 b276d47e..a65d9d46 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 @@ -4,12 +4,10 @@ import com.team.prezel.feature.analysis.impl.contract.AnalysisFlowStep import com.team.prezel.feature.analysis.impl.contract.AnalysisFlowUiState import com.team.prezel.feature.analysis.impl.contract.AnalysisForm -internal val AnalysisFlowUiState.shouldResetRecordingOnBack: Boolean - get() = step == AnalysisFlowStep.VOICE_RECORDING - -internal val AnalysisFlowUiState.shouldReleaseAudioOnBack: Boolean +internal val AnalysisFlowUiState.shouldResetAudioOnBack: Boolean get() = - step == AnalysisFlowStep.PRESENTATION_SCHEDULE || + step == AnalysisFlowStep.VOICE_RECORDING || + step == AnalysisFlowStep.PRESENTATION_SCHEDULE || reRecordingPresentationId != null || reWritingScriptPresentationId != null @@ -21,6 +19,7 @@ internal fun AnalysisFlowUiState.backClearedFormOrNull(): AnalysisForm? = AnalysisFlowStep.AUDIO_UPLOAD, AnalysisFlowStep.VOICE_RECORDING, AnalysisFlowStep.ANALYZING, + AnalysisFlowStep.ANALYSIS_FAILED, AnalysisFlowStep.FILE_RECOGNITION_FAILED, AnalysisFlowStep.SCRIPT_FILE_RECOGNITION_FAILED, -> null 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 1bda155d..42f5cf0b 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 @@ -6,6 +6,8 @@ import com.team.prezel.feature.analysis.impl.contract.AnalysisUploadType import com.team.prezel.feature.analysis.impl.model.AnalysisUiMessage internal sealed interface AnalysisFailureAction { + data object RetryAnalysis : AnalysisFailureAction + data class RetryFileUpload( val uploadType: AnalysisUploadType, ) : AnalysisFailureAction @@ -20,12 +22,14 @@ internal fun Throwable.toAnalysisFailureAction(): AnalysisFailureAction { return when (error) { AppError.INVALID_REQUEST, - AppError.VOICE_RECOGNITION_FAILED, -> AnalysisFailureAction.RetryFileUpload(uploadType = AnalysisUploadType.AUDIO) AppError.SCRIPT_FILE_RECOGNITION_FAILED -> AnalysisFailureAction.RetryFileUpload(uploadType = AnalysisUploadType.SCRIPT) AppError.UNAUTHORIZED -> AnalysisFailureAction.ShowMessage(message = AnalysisUiMessage.AUTH_EXPIRED) + AppError.VOICE_RECOGNITION_FAILED, + AppError.VOICE_ANALYSIS_FAILED, + -> AnalysisFailureAction.RetryAnalysis AppError.SERVER_ERROR -> AnalysisFailureAction.ShowMessage(message = AnalysisUiMessage.ANALYSIS_FAILED) AppError.NETWORK -> AnalysisFailureAction.ShowMessage(message = AnalysisUiMessage.NETWORK_FAILED) 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 dd189cca..5f3bdb97 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 @@ -20,8 +20,10 @@ 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.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import javax.inject.Inject @HiltViewModel @@ -36,7 +38,23 @@ internal class AnalysisFlowViewModel @Inject constructor( private var analyzeJob: Job? = null init { - collectAudioSession() + viewModelScope.launch { + audioController.audioSessionState.collect { audioState -> + updateState { copy(recordingState = audioState) } + } + } + + viewModelScope.launch { + audioController.recordingVolumes.collect { volumes -> + updateState { copy(recordingVolumes = volumes) } + } + } + + viewModelScope.launch { + audioController.audioSessionEffect.collect { effect -> + sendEffect(AnalysisFlowUiEffect.ShowMessage(effect.toUiMessage())) + } + } } override fun onIntent(intent: AnalysisFlowUiIntent) { @@ -62,6 +80,7 @@ internal class AnalysisFlowViewModel @Inject constructor( isPast = intent.isPast, ) + is AnalysisFlowUiIntent.SelectScriptFile -> selectScriptFile(intent.fileUri) AnalysisFlowUiIntent.ClickRecordingControl -> audioController.handleControlClick(currentState.recordingState) AnalysisFlowUiIntent.StopRecording -> audioController.stopRecording() AnalysisFlowUiIntent.ResetRecording -> audioController.reset() @@ -73,6 +92,33 @@ internal class AnalysisFlowViewModel @Inject constructor( } } + private fun selectScriptFile(fileUri: String?) { + updateState { + copy( + form = form.copy( + scriptFileUri = fileUri, + script = if (fileUri == null) "" else form.script, + ), + ) + } + + if (fileUri == null) return + + viewModelScope.launch { + runCatching { withContext(Dispatchers.IO) { analysisFileCache.readTextFromUri(fileUri) } } + .onSuccess { script -> + if (currentState.form.scriptFileUri == fileUri) { + updateState { copy(form = form.copy(script = script)) } + } + }.onFailure { + if (currentState.form.scriptFileUri == fileUri) { + updateState { copy(form = form.copy(script = "")) } + } + sendEffect(AnalysisFlowUiEffect.ShowMessage(AnalysisUiMessage.SCRIPT_FILE_LOAD_FAILED)) + } + } + } + private fun moveNext() { if (!currentState.canMoveNext) return @@ -88,6 +134,7 @@ internal class AnalysisFlowViewModel @Inject constructor( AnalysisFlowStep.AUDIO_UPLOAD, AnalysisFlowStep.VOICE_RECORDING, AnalysisFlowStep.ANALYZING, + AnalysisFlowStep.ANALYSIS_FAILED, AnalysisFlowStep.FILE_RECOGNITION_FAILED, AnalysisFlowStep.SCRIPT_FILE_RECOGNITION_FAILED, -> currentState.step @@ -121,7 +168,7 @@ internal class AnalysisFlowViewModel @Inject constructor( analysisResult.fold( onSuccess = { result -> if (currentState.step == AnalysisFlowStep.ANALYZING) { - audioController.release() + audioController.reset() sendEffect(AnalysisFlowUiEffect.NavigateToReport(presentationId = result)) } }, @@ -147,7 +194,7 @@ internal class AnalysisFlowViewModel @Inject constructor( reAnalyzeResult.fold( onSuccess = { result -> if (currentState.step == AnalysisFlowStep.ANALYZING) { - audioController.release() + audioController.reset() sendEffect(AnalysisFlowUiEffect.NavigateToReport(presentationId = result.presentationId)) } }, @@ -287,6 +334,10 @@ internal class AnalysisFlowViewModel @Inject constructor( private fun handleAnalysisFailure(action: AnalysisFailureAction) { when (action) { + AnalysisFailureAction.RetryAnalysis -> { + updateState { copy(step = AnalysisFlowStep.ANALYSIS_FAILED) } + } + is AnalysisFailureAction.RetryFileUpload -> { val failureStep = when (action.uploadType) { AnalysisUploadType.AUDIO -> AnalysisFlowStep.FILE_RECOGNITION_FAILED @@ -327,14 +378,19 @@ internal class AnalysisFlowViewModel @Inject constructor( } AnalysisUploadType.AUDIO -> { - val retryStep = currentState.audioInputStep + val isAnalysisFailed = currentState.step == AnalysisFlowStep.ANALYSIS_FAILED + val retryStep = if (isAnalysisFailed) { + AnalysisFlowStep.PRESENTATION_SCHEDULE + } else { + currentState.audioInputStep + } updateState { copy( step = retryStep, form = form.copy(audioFileUri = null), ) } - if (retryStep == AnalysisFlowStep.VOICE_RECORDING) { + if (retryStep == AnalysisFlowStep.VOICE_RECORDING || isAnalysisFailed) { audioController.reset() } viewModelScope.launch { sendEffect(AnalysisFlowUiEffect.NavigateToStep(step = retryStep)) } @@ -361,7 +417,7 @@ internal class AnalysisFlowViewModel @Inject constructor( private fun moveBack() { if (currentState.step == AnalysisFlowStep.ANALYZING) analyzeJob?.cancel() - if (currentState.shouldResetRecordingOnBack) { + if (currentState.shouldResetAudioOnBack) { audioController.reset() } @@ -371,33 +427,9 @@ internal class AnalysisFlowViewModel @Inject constructor( } } - if (currentState.shouldReleaseAudioOnBack) { - audioController.release() - } - viewModelScope.launch { sendEffect(AnalysisFlowUiEffect.NavigateBack) } } - private fun collectAudioSession() { - viewModelScope.launch { - audioController.audioSessionState.collect { audioState -> - updateState { copy(recordingState = audioState) } - } - } - - viewModelScope.launch { - audioController.recordingVolumes.collect { volumes -> - updateState { copy(recordingVolumes = volumes) } - } - } - - viewModelScope.launch { - audioController.audioSessionEffect.collect { effect -> - sendEffect(AnalysisFlowUiEffect.ShowMessage(effect.toUiMessage())) - } - } - } - override fun onCleared() { audioController.release() super.onCleared() 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 1ecc447d..242cccd7 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 @@ -30,9 +30,12 @@ internal fun AnalysisFlowUiIntent.reduceFormOrNull(form: AnalysisForm): Analysis when (this) { is AnalysisFlowUiIntent.UpdatePresentationTitle -> form.copy(presentationTitle = title) is AnalysisFlowUiIntent.UpdatePresentationDate -> form.copy(presentationDate = date) - is AnalysisFlowUiIntent.SelectScriptInputType -> form.copy(scriptInputType = inputType) + is AnalysisFlowUiIntent.SelectScriptInputType -> form.copy( + scriptInputType = inputType, + script = "", + scriptFileUri = null, + ) is AnalysisFlowUiIntent.UpdateScript -> form.copy(script = script) - is AnalysisFlowUiIntent.SelectScriptFile -> form.copy(scriptFileUri = fileUri) 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 611a38c2..975934ea 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 @@ -18,6 +18,7 @@ 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.VoiceRecordingScreen import com.team.prezel.feature.analysis.impl.recording.rememberAnalysisRecordAudioPermissionControlClickHandler +import com.team.prezel.feature.analysis.impl.result.AnalysisFailedScreen import com.team.prezel.feature.analysis.impl.result.AnalysisLoadingScreen import com.team.prezel.feature.analysis.impl.result.FileRecognitionFailedScreen import com.team.prezel.feature.analysis.impl.result.ScriptFileRecognitionFailedScreen @@ -66,6 +67,7 @@ private fun AnalysisUiMessage.toStringRes(): Int = AnalysisUiMessage.AUTH_EXPIRED -> R.string.feature_analysis_impl_error_auth_expired AnalysisUiMessage.ANALYSIS_FAILED -> R.string.feature_analysis_impl_error_analysis_failed AnalysisUiMessage.SCRIPT_LOAD_FAILED -> R.string.feature_analysis_impl_error_script_load_failed + AnalysisUiMessage.SCRIPT_FILE_LOAD_FAILED -> R.string.feature_analysis_impl_error_script_file_load_failed AnalysisUiMessage.NETWORK_FAILED -> R.string.feature_analysis_impl_error_network_failed AnalysisUiMessage.UNKNOWN_FAILED -> R.string.feature_analysis_impl_error_unknown_failed AnalysisUiMessage.RECORD_AUDIO_PERMISSION_DENIED -> R.string.feature_analysis_impl_voice_recording_permission_denied @@ -158,6 +160,7 @@ private fun AnalysisStepContent( ) AnalysisFlowStep.ANALYZING, + AnalysisFlowStep.ANALYSIS_FAILED, AnalysisFlowStep.FILE_RECOGNITION_FAILED, AnalysisFlowStep.SCRIPT_FILE_RECOGNITION_FAILED, -> AnalysisResultStepContent( @@ -219,6 +222,7 @@ private fun AnalysisInputStepContent( ) AnalysisFlowStep.ANALYZING, + AnalysisFlowStep.ANALYSIS_FAILED, AnalysisFlowStep.FILE_RECOGNITION_FAILED, AnalysisFlowStep.SCRIPT_FILE_RECOGNITION_FAILED, -> Unit @@ -233,6 +237,10 @@ private fun AnalysisResultStepContent( when (step) { AnalysisFlowStep.ANALYZING -> AnalysisLoadingScreen() + AnalysisFlowStep.ANALYSIS_FAILED -> AnalysisFailedScreen( + onRetry = { onIntent(AnalysisFlowUiIntent.RetryFileUpload(AnalysisUploadType.AUDIO)) }, + ) + AnalysisFlowStep.FILE_RECOGNITION_FAILED -> FileRecognitionFailedScreen( onRetry = { onIntent(AnalysisFlowUiIntent.RetryFileUpload(AnalysisUploadType.AUDIO)) }, ) diff --git a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/cache/AnalysisFileCache.kt b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/cache/AnalysisFileCache.kt index 3c82b202..ea09df2f 100644 --- a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/cache/AnalysisFileCache.kt +++ b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/cache/AnalysisFileCache.kt @@ -18,4 +18,9 @@ internal interface AnalysisFileCache { uriString: String, prefix: String, ): File + + /** + * [uriString]이 가리키는 텍스트 파일 내용을 읽어 반환한다. + */ + fun readTextFromUri(uriString: String): String } diff --git a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/cache/AnalysisFileCacheImpl.kt b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/cache/AnalysisFileCacheImpl.kt index 4d096445..a0f4d414 100644 --- a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/cache/AnalysisFileCacheImpl.kt +++ b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/cache/AnalysisFileCacheImpl.kt @@ -50,6 +50,14 @@ internal class AnalysisFileCacheImpl @Inject constructor( } } + override fun readTextFromUri(uriString: String): String { + val uri = uriString.toUri() + return context.contentResolver.openInputStream(uri).use { input -> + requireNotNull(input) { "Cannot open uri: $uriString" } + input.bufferedReader(Charsets.UTF_8).use { reader -> reader.readText() } + } + } + private companion object { const val DEFAULT_EXTENSION = "tmp" } diff --git a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/contract/AnalysisFlowUiState.kt b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/contract/AnalysisFlowUiState.kt index 3067eefc..db63a0e5 100644 --- a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/contract/AnalysisFlowUiState.kt +++ b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/contract/AnalysisFlowUiState.kt @@ -32,6 +32,7 @@ internal data class AnalysisFlowUiState( AnalysisFlowStep.AUDIO_UPLOAD, AnalysisFlowStep.VOICE_RECORDING, AnalysisFlowStep.ANALYZING, + AnalysisFlowStep.ANALYSIS_FAILED, AnalysisFlowStep.FILE_RECOGNITION_FAILED, -> 1f } @@ -53,6 +54,7 @@ internal data class AnalysisFlowUiState( AnalysisFlowStep.AUDIO_UPLOAD -> !form.audioFileUri.isNullOrBlank() AnalysisFlowStep.VOICE_RECORDING -> recordingState.recordingFilePath != null AnalysisFlowStep.ANALYZING, + AnalysisFlowStep.ANALYSIS_FAILED, AnalysisFlowStep.FILE_RECOGNITION_FAILED, AnalysisFlowStep.SCRIPT_FILE_RECOGNITION_FAILED, -> false @@ -85,6 +87,7 @@ internal enum class AnalysisFlowStep { AUDIO_UPLOAD, VOICE_RECORDING, ANALYZING, + ANALYSIS_FAILED, FILE_RECOGNITION_FAILED, SCRIPT_FILE_RECOGNITION_FAILED, } diff --git a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/model/AnalysisUiMessage.kt b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/model/AnalysisUiMessage.kt index 73271dbe..952530c1 100644 --- a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/model/AnalysisUiMessage.kt +++ b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/model/AnalysisUiMessage.kt @@ -4,6 +4,7 @@ internal enum class AnalysisUiMessage { AUTH_EXPIRED, ANALYSIS_FAILED, SCRIPT_LOAD_FAILED, + SCRIPT_FILE_LOAD_FAILED, NETWORK_FAILED, UNKNOWN_FAILED, RECORD_AUDIO_PERMISSION_DENIED, 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 9e63d57c..01e480fc 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 @@ -103,6 +103,7 @@ private fun AnalysisNavKey.toNavKey(step: AnalysisFlowStep): AnalysisNavKey = AnalysisFlowStep.AUDIO_UPLOAD -> AnalysisNavKey.AudioUpload(flowId = flowId, startType = startType) AnalysisFlowStep.VOICE_RECORDING, + AnalysisFlowStep.ANALYSIS_FAILED, AnalysisFlowStep.FILE_RECOGNITION_FAILED, -> AnalysisNavKey.Recording(flowId = flowId, startType = startType) diff --git a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/result/FileRecognitionFailedScreen.kt b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/result/FileRecognitionFailedScreen.kt index 50f6ce1f..cade0dc1 100644 --- a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/result/FileRecognitionFailedScreen.kt +++ b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/result/FileRecognitionFailedScreen.kt @@ -1,5 +1,6 @@ package com.team.prezel.feature.analysis.impl.result +import androidx.annotation.DrawableRes import androidx.compose.foundation.Image import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.size @@ -23,6 +24,7 @@ internal fun FileRecognitionFailedScreen(onRetry: () -> Unit) { FileRecognitionFailedStatusView( title = stringResource(R.string.feature_analysis_impl_file_recognition_failed_title), description = stringResource(R.string.feature_analysis_impl_file_recognition_failed_description), + visualResId = R.drawable.feature_analysis_impl_error_voice, onRetry = onRetry, ) } @@ -32,6 +34,17 @@ internal fun ScriptFileRecognitionFailedScreen(onRetry: () -> Unit) { FileRecognitionFailedStatusView( title = stringResource(R.string.feature_analysis_impl_script_file_recognition_failed_title), description = stringResource(R.string.feature_analysis_impl_script_file_recognition_failed_description), + visualResId = R.drawable.feature_analysis_impl_error_voice, + onRetry = onRetry, + ) +} + +@Composable +internal fun AnalysisFailedScreen(onRetry: () -> Unit) { + FileRecognitionFailedStatusView( + title = stringResource(R.string.feature_analysis_impl_analysis_failed_title), + description = stringResource(R.string.feature_analysis_impl_analysis_failed_description), + visualResId = R.drawable.feature_analysis_impl_error_analysis, onRetry = onRetry, ) } @@ -40,6 +53,7 @@ internal fun ScriptFileRecognitionFailedScreen(onRetry: () -> Unit) { private fun FileRecognitionFailedStatusView( title: String, description: String, + @DrawableRes visualResId: Int, onRetry: () -> Unit, ) { StatusView( @@ -48,7 +62,7 @@ private fun FileRecognitionFailedStatusView( modifier = Modifier.fillMaxSize(), visual = { Image( - painter = painterResource(R.drawable.feature_analysis_impl_error_voice), + painter = painterResource(visualResId), contentDescription = null, modifier = Modifier.size(120.dp), ) @@ -82,3 +96,11 @@ private fun ScriptFileRecognitionFailedScreenPreview() { ScriptFileRecognitionFailedScreen(onRetry = {}) } } + +@BasicPreview +@Composable +private fun AnalysisFailedScreenPreview() { + PrezelTheme { + AnalysisFailedScreen(onRetry = {}) + } +} diff --git a/Prezel/feature/analysis/impl/src/main/res/drawable/feature_analysis_impl_error_analysis.xml b/Prezel/feature/analysis/impl/src/main/res/drawable/feature_analysis_impl_error_analysis.xml new file mode 100644 index 00000000..203e2793 --- /dev/null +++ b/Prezel/feature/analysis/impl/src/main/res/drawable/feature_analysis_impl_error_analysis.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + diff --git a/Prezel/feature/analysis/impl/src/main/res/drawable/feature_analysis_impl_error_voice.xml b/Prezel/feature/analysis/impl/src/main/res/drawable/feature_analysis_impl_error_voice.xml index e2f402b8..5cf1abf5 100644 --- a/Prezel/feature/analysis/impl/src/main/res/drawable/feature_analysis_impl_error_voice.xml +++ b/Prezel/feature/analysis/impl/src/main/res/drawable/feature_analysis_impl_error_voice.xml @@ -3,16 +3,16 @@ android:height="120dp" android:viewportWidth="120" android:viewportHeight="120"> - - - - + + + + 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 d287c537..44c2d26d 100644 --- a/Prezel/feature/analysis/impl/src/main/res/values/strings.xml +++ b/Prezel/feature/analysis/impl/src/main/res/values/strings.xml @@ -73,13 +73,16 @@ 분석 중 발표 음성을 분석하고 있어요 분석 리포트 화면 - 분석할 수 있는 음성 파일을 찾지 못했어요. - 다른 음성 파일로 다시 시도해 주세요. - 분석할 수 있는 텍스트 파일을 찾지 못했어요. - 다른 텍스트 파일로 다시 시도해 주세요. + 분석할 음성을 인식하지 못했어요. + 음성이 작거나 주변 소음이 많았을 수 있어요.\n조용한 환경에서 다시 녹음해 주세요. + 분석할 수 있는 음성 파일을 찾지 못했어요. + 다른 음성 파일로 다시 시도해 주세요. + 분석 중 문제가 발생했어요. + 일시적인 오류로 분석을 완료하지 못했어요.\n잠시 후 다시 시도해 주세요. 로그인이 만료되었어요. 다시 로그인해 주세요. 분석 중 오류가 발생했어요. 잠시 후 다시 시도해 주세요. 기존 대본을 불러오지 못했어요. 다시 시도해 주세요. + 대본 파일을 불러오지 못했어요. 다시 시도해 주세요. 네트워크 연결을 확인해 주세요. 알 수 없는 오류가 발생했어요. 뒤로가기 From d786791a9588e2b2a40efee7488f53f67d47ce3d Mon Sep 17 00:00:00 2001 From: Ham BeomJoon Date: Fri, 29 May 2026 23:06:11 +0900 Subject: [PATCH 11/15] =?UTF-8?q?feat:=20=EC=9D=8C=EC=84=B1=20=EB=85=B9?= =?UTF-8?q?=EC=9D=8C=20=ED=99=94=EB=A9=B4=EC=9D=98=20=EC=83=81=ED=83=9C?= =?UTF-8?q?=EB=B0=94=20=EC=8A=A4=ED=83=80=EC=9D=BC=20=EB=8F=99=EC=A0=81=20?= =?UTF-8?q?=EC=A0=9C=EC=96=B4=20=EB=A1=9C=EC=A7=81=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `AnalysisScreen`에서 대본 확장 여부와 녹음 상태에 따라 `EdgeToEdgeStatusBarStyle`을 결정하도록 개선 - `VoiceRecordingScreen`의 대본 확장 상태(`isScriptExpanded`)를 상위 컴포넌트로 전달하기 위한 콜백 추가 - `VoiceRecordingStatusBarStyle`에서 불필요한 `onDispose` 스타일 초기화 로직 제거 - `PrezelApp`의 메인 컨테이너 `Box`에 `fillMaxSize` 수정자 적용 - 녹음 가이드 스낵바 표시 로직을 별도 컴포저블에서 `AnalysisScreen` 내 `LaunchedEffect`로 통합 --- .../main/java/com/team/prezel/ui/PrezelApp.kt | 8 +-- .../feature/analysis/impl/AnalysisScreen.kt | 50 +++++++++++++------ .../impl/recording/VoiceRecordingScreen.kt | 19 ++++--- .../recording/VoiceRecordingStatusBarStyle.kt | 7 --- 4 files changed, 49 insertions(+), 35 deletions(-) diff --git a/Prezel/app/src/main/java/com/team/prezel/ui/PrezelApp.kt b/Prezel/app/src/main/java/com/team/prezel/ui/PrezelApp.kt index df2232ec..1ea7c232 100644 --- a/Prezel/app/src/main/java/com/team/prezel/ui/PrezelApp.kt +++ b/Prezel/app/src/main/java/com/team/prezel/ui/PrezelApp.kt @@ -12,6 +12,7 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.statusBars @@ -89,7 +90,7 @@ private fun PrezelAppContent( ) SharedTransitionLayout { - Box { + Box(modifier = Modifier.fillMaxSize()) { ProvideSharedTransitionScope(this@SharedTransitionLayout) { val provider = remember(entryBuilders, navigator) { entryProvider { @@ -193,12 +194,13 @@ private fun Activity.applyEdgeToEdgeStatusBarStyle( bgMedium: Color, ) { val insetsController = WindowCompat.getInsetsController(window, view) - - window.statusBarColor = when (style) { + val statusBarColor = when (style) { EdgeToEdgeStatusBarStyle.DEFAULT -> Color.Transparent EdgeToEdgeStatusBarStyle.BG_REGULAR -> bgRegular EdgeToEdgeStatusBarStyle.BG_MEDIUM -> bgMedium }.toArgb() + + window.statusBarColor = statusBarColor insetsController.isAppearanceLightStatusBars = true } 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 975934ea..52b3011f 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 @@ -4,9 +4,14 @@ import androidx.activity.compose.BackHandler import androidx.annotation.StringRes import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.rememberCoroutineScope +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.common.event.EdgeToEdgeStatusBarStyle import com.team.prezel.core.designsystem.component.feedback.snackbar.showPrezelSnackbar import com.team.prezel.core.ui.state.LocalSnackbarHostState import com.team.prezel.feature.analysis.impl.audio.AudioUploadScreen @@ -17,6 +22,8 @@ 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.VoiceRecordingScreen +import com.team.prezel.feature.analysis.impl.recording.VoiceRecordingStatusBarStyle +import com.team.prezel.feature.analysis.impl.recording.isCompleted import com.team.prezel.feature.analysis.impl.recording.rememberAnalysisRecordAudioPermissionControlClickHandler import com.team.prezel.feature.analysis.impl.result.AnalysisFailedScreen import com.team.prezel.feature.analysis.impl.result.AnalysisLoadingScreen @@ -37,6 +44,9 @@ internal fun AnalysisScreen( val uiState = viewModel.uiState.collectAsStateWithLifecycle().value val resources = LocalResources.current val snackbarHostState = LocalSnackbarHostState.current + var isScriptExpanded by rememberSaveable(uiState.step) { mutableStateOf(false) } + + VoiceRecordingStatusBarStyle(style = uiState.statusBarStyle(isScriptExpanded = isScriptExpanded)) BackHandler { viewModel.onIntent(AnalysisFlowUiIntent.Back) @@ -58,6 +68,7 @@ internal fun AnalysisScreen( AnalysisScreen( uiState = uiState, onIntent = viewModel::onIntent, + onScriptExpandedChange = { isScriptExpanded = it }, ) } @@ -79,12 +90,30 @@ private fun AnalysisUiMessage.toStringRes(): Int = AnalysisUiMessage.PLAYBACK_START_FAILED -> R.string.feature_analysis_impl_voice_recording_playback_failed } +private fun AnalysisFlowUiState.statusBarStyle(isScriptExpanded: Boolean): EdgeToEdgeStatusBarStyle = + when (step) { + AnalysisFlowStep.VOICE_RECORDING if isScriptExpanded -> EdgeToEdgeStatusBarStyle.BG_REGULAR + AnalysisFlowStep.VOICE_RECORDING if recordingState.isCompleted -> EdgeToEdgeStatusBarStyle.BG_REGULAR + AnalysisFlowStep.VOICE_RECORDING -> EdgeToEdgeStatusBarStyle.BG_MEDIUM + else -> EdgeToEdgeStatusBarStyle.DEFAULT + } + @Composable private fun AnalysisScreen( uiState: AnalysisFlowUiState, onIntent: (AnalysisFlowUiIntent) -> Unit, + onScriptExpandedChange: (Boolean) -> Unit, ) { - VoiceRecordingGuideSnackbar(step = uiState.step) + val resources = LocalResources.current + val snackbarHostState = LocalSnackbarHostState.current + + LaunchedEffect(uiState.step) { + if (uiState.step == AnalysisFlowStep.VOICE_RECORDING) { + snackbarHostState.showPrezelSnackbar( + message = resources.getString(R.string.feature_analysis_impl_voice_recording_guide), + ) + } + } val onClickRecordingControl = rememberVoiceRecordingControlClick( uiState = uiState, @@ -95,23 +124,10 @@ private fun AnalysisScreen( uiState = uiState, onIntent = onIntent, onClickRecordingControl = onClickRecordingControl, + onScriptExpandedChange = onScriptExpandedChange, ) } -@Composable -private fun VoiceRecordingGuideSnackbar(step: AnalysisFlowStep) { - val resources = LocalResources.current - val snackbarHostState = LocalSnackbarHostState.current - - LaunchedEffect(step) { - if (step == AnalysisFlowStep.VOICE_RECORDING) { - snackbarHostState.showPrezelSnackbar( - message = resources.getString(R.string.feature_analysis_impl_voice_recording_guide), - ) - } - } -} - @Composable private fun rememberVoiceRecordingControlClick( uiState: AnalysisFlowUiState, @@ -146,6 +162,7 @@ private fun AnalysisStepContent( uiState: AnalysisFlowUiState, onIntent: (AnalysisFlowUiIntent) -> Unit, onClickRecordingControl: () -> Unit, + onScriptExpandedChange: (Boolean) -> Unit, ) { when (uiState.step) { AnalysisFlowStep.PRESENTATION_SCHEDULE, @@ -157,6 +174,7 @@ private fun AnalysisStepContent( uiState = uiState, onIntent = onIntent, onClickRecordingControl = onClickRecordingControl, + onScriptExpandedChange = onScriptExpandedChange, ) AnalysisFlowStep.ANALYZING, @@ -175,6 +193,7 @@ private fun AnalysisInputStepContent( uiState: AnalysisFlowUiState, onIntent: (AnalysisFlowUiIntent) -> Unit, onClickRecordingControl: () -> Unit, + onScriptExpandedChange: (Boolean) -> Unit, ) { when (uiState.step) { AnalysisFlowStep.PRESENTATION_SCHEDULE -> PresentationScheduleScreen( @@ -218,6 +237,7 @@ private fun AnalysisInputStepContent( onStopRecording = { onIntent(AnalysisFlowUiIntent.StopRecording) }, onResetRecording = { onIntent(AnalysisFlowUiIntent.ResetRecording) }, onAnalyze = { onIntent(AnalysisFlowUiIntent.Next) }, + onScriptExpandedChange = onScriptExpandedChange, onBack = { onIntent(AnalysisFlowUiIntent.Back) }, ) 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 f0adbb12..a70919a7 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 @@ -24,7 +24,6 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.team.prezel.core.audio.AudioSessionState import com.team.prezel.core.audio.AudioSource -import com.team.prezel.core.common.event.EdgeToEdgeStatusBarStyle import com.team.prezel.core.designsystem.component.voice.PrezelVoiceChrome import com.team.prezel.core.designsystem.component.voice.VoiceChromeGradient import com.team.prezel.core.designsystem.icon.PrezelIcons @@ -43,6 +42,7 @@ internal fun VoiceRecordingScreen( onStopRecording: () -> Unit, onResetRecording: () -> Unit, onAnalyze: () -> Unit, + onScriptExpandedChange: (Boolean) -> Unit, onBack: () -> Unit, ) { VoiceRecordingScreen( @@ -54,6 +54,7 @@ internal fun VoiceRecordingScreen( onStopRecording = onStopRecording, onResetRecording = onResetRecording, onAnalyze = onAnalyze, + onScriptExpandedChange = onScriptExpandedChange, onBack = onBack, ) } @@ -68,18 +69,11 @@ private fun VoiceRecordingScreen( onStopRecording: () -> Unit, onResetRecording: () -> Unit, onAnalyze: () -> Unit, + onScriptExpandedChange: (Boolean) -> Unit, onBack: () -> Unit, ) { var isScriptExpanded by rememberSaveable { mutableStateOf(false) } - VoiceRecordingStatusBarStyle( - style = if (recordingState.isCompleted || isScriptExpanded) { - EdgeToEdgeStatusBarStyle.BG_REGULAR - } else { - EdgeToEdgeStatusBarStyle.BG_MEDIUM - }, - ) - Column( modifier = Modifier .fillMaxSize() @@ -101,7 +95,10 @@ private fun VoiceRecordingScreen( recordingState = recordingState, recordingVolumes = recordingVolumes, isScriptExpanded = isScriptExpanded, - onToggleScriptExpanded = { isScriptExpanded = !isScriptExpanded }, + onToggleScriptExpanded = { + isScriptExpanded = !isScriptExpanded + onScriptExpandedChange(isScriptExpanded) + }, onClickRecordingControl = onClickRecordingControl, modifier = Modifier.weight(1f), ) @@ -245,6 +242,7 @@ private fun VoiceRecordingScreenPreviewContent(recordingState: AudioSessionState onStopRecording = {}, onResetRecording = {}, onAnalyze = {}, + onScriptExpandedChange = {}, onBack = {}, ) } @@ -272,6 +270,7 @@ private fun VoiceRecordingScreenInteractiveFlowPreview() { onStopRecording = { recordingState = recordingState.stopPreviewRecording() }, onResetRecording = { recordingState = AudioSessionState.Idle }, onAnalyze = {}, + onScriptExpandedChange = {}, onBack = {}, ) } diff --git a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/recording/VoiceRecordingStatusBarStyle.kt b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/recording/VoiceRecordingStatusBarStyle.kt index 8291ed74..c2a1036f 100644 --- a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/recording/VoiceRecordingStatusBarStyle.kt +++ b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/recording/VoiceRecordingStatusBarStyle.kt @@ -1,7 +1,6 @@ package com.team.prezel.feature.analysis.impl.recording import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember import androidx.compose.ui.platform.LocalContext @@ -23,12 +22,6 @@ internal fun VoiceRecordingStatusBarStyle(style: EdgeToEdgeStatusBarStyle) { LaunchedEffect(globalEventBus, style) { globalEventBus.emit(GlobalEvent.ChangeEdgeToEdgeStatusBarStyle(style)) } - - DisposableEffect(globalEventBus) { - onDispose { - globalEventBus.tryEmit(GlobalEvent.ResetEdgeToEdgeStatusBarStyle) - } - } } @Composable From e3aebc948250bc3a6e7964a1a32ec9552b30da12 Mon Sep 17 00:00:00 2001 From: Ham BeomJoon Date: Sat, 30 May 2026 21:26:16 +0900 Subject: [PATCH 12/15] =?UTF-8?q?refactor:=20=EB=B6=84=EC=84=9D=20?= =?UTF-8?q?=EB=85=B9=EC=9D=8C=20=EB=B0=8F=20=EC=98=A4=EB=94=94=EC=98=A4=20?= =?UTF-8?q?=EC=84=B8=EC=85=98=20=EA=B4=80=EB=A6=AC=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `MediaRecorderSession`의 `pause`, `resume` 메서드에서 안전한 호출(Safe Call) 적용 및 초기화 예외 처리 추가 - `AnalysisFlowViewModel` 내 파일 경로 해결 로직을 `resolveAudioFilePath`, `resolveScriptFilePath`로 분리하여 코드 중복 제거 - 분석 요청 처리 시 `CancellationException` 발생 시 코루틴 취소가 정상적으로 전파되도록 수정 - `VoiceRecordingScreen`의 스크립트 확장 상태(`isScriptExpanded`)를 상위 컴포넌트에서 제어하도록 끌어올리기(State Hoisting) 수행 - `String.format` 사용 시 `Locale.US`를 명시하여 지역 설정에 따른 포맷 오동작 방지 - `MainActivity`에서 테마 설정을 라이트 모드(`isDarkTheme = false`)로 고정 --- .../main/java/com/team/prezel/MainActivity.kt | 2 +- .../prezel/core/audio/MediaRecorderSession.kt | 4 +- .../analysis/impl/AnalysisFlowViewModel.kt | 61 +++++++++---------- .../feature/analysis/impl/AnalysisScreen.kt | 7 +++ .../impl/recording/VoiceRecordingScreen.kt | 11 ++-- .../VoiceRecordingStateProperties.kt | 3 +- 6 files changed, 47 insertions(+), 41 deletions(-) diff --git a/Prezel/app/src/main/java/com/team/prezel/MainActivity.kt b/Prezel/app/src/main/java/com/team/prezel/MainActivity.kt index d3903118..eac0183f 100644 --- a/Prezel/app/src/main/java/com/team/prezel/MainActivity.kt +++ b/Prezel/app/src/main/java/com/team/prezel/MainActivity.kt @@ -31,7 +31,7 @@ class MainActivity : ComponentActivity() { enableEdgeToEdge() setContent { - PrezelTheme { + PrezelTheme(isDarkTheme = false) { val appState = rememberPrezelAppState(networkMonitor = networkMonitor) PrezelApp( diff --git a/Prezel/core/audio/src/main/java/com/team/prezel/core/audio/MediaRecorderSession.kt b/Prezel/core/audio/src/main/java/com/team/prezel/core/audio/MediaRecorderSession.kt index dde38a2a..6797c45d 100644 --- a/Prezel/core/audio/src/main/java/com/team/prezel/core/audio/MediaRecorderSession.kt +++ b/Prezel/core/audio/src/main/java/com/team/prezel/core/audio/MediaRecorderSession.kt @@ -43,12 +43,12 @@ internal class MediaRecorderSession @Inject constructor( override fun pause(): Result = runCatching { - recorder!!.pause() + recorder?.pause() ?: error("Recorder not initialized") } override fun resume(): Result = runCatching { - recorder!!.resume() + recorder?.resume() ?: error("Recorder not initialized") } override fun maxAmplitude(): Int = recorder?.maxAmplitude ?: 0 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 5f3bdb97..b1659bfb 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 @@ -20,6 +20,7 @@ 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 @@ -279,21 +280,8 @@ internal class AnalysisFlowViewModel @Inject constructor( private suspend fun PresentationAnalysisSubmission.analyzePresentationRecording(): Result = runCatching { - val audioFilePath = audioFileUri - ?.let { uri -> - val audioFile = analysisFileCache.copyUriToCache( - uriString = uri, - prefix = "audio", - ) - audioFile.absolutePath - } - ?: recordingFilePath - val scriptFile = scriptFileUri?.let { uri -> - analysisFileCache.copyUriToCache( - uriString = uri, - prefix = "script", - ) - } + val audioFilePath = resolveAudioFilePath() + val scriptFilePath = resolveScriptFilePath() analyzePresentationUseCase( name = name, date = date.toRequestDate(), @@ -302,34 +290,43 @@ internal class AnalysisFlowViewModel @Inject constructor( style = style, audience = audience, script = script, - scriptFilePath = scriptFile?.absolutePath, + scriptFilePath = scriptFilePath, audioFilePath = audioFilePath, ).getOrThrow() + }.onFailure { + if (it is CancellationException) throw it } private suspend fun PresentationAnalysisSubmission.reAnalyzePresentationRecording(presentationId: Long): Result = runCatching { - val audioFilePath = audioFileUri - ?.let { uri -> - val audioFile = analysisFileCache.copyUriToCache( - uriString = uri, - prefix = "audio", - ) - audioFile.absolutePath - } - ?: recordingFilePath - val scriptFile = scriptFileUri?.let { uri -> - analysisFileCache.copyUriToCache( - uriString = uri, - prefix = "script", - ) - } + val audioFilePath = resolveAudioFilePath() + val scriptFilePath = resolveScriptFilePath() reAnalyzePresentationUseCase( presentationId = presentationId, script = script, - scriptFilePath = scriptFile?.absolutePath, + scriptFilePath = scriptFilePath, audioFilePath = audioFilePath, ).getOrThrow() + }.onFailure { + if (it is CancellationException) throw it + } + + private fun PresentationAnalysisSubmission.resolveAudioFilePath(): String = + audioFileUri + ?.let { uri -> + analysisFileCache.copyUriToCache( + uriString = uri, + prefix = "audio", + ).absolutePath + } + ?: recordingFilePath + + private fun PresentationAnalysisSubmission.resolveScriptFilePath(): String? = + scriptFileUri?.let { uri -> + analysisFileCache.copyUriToCache( + uriString = uri, + prefix = "script", + ).absolutePath } private fun handleAnalysisFailure(action: AnalysisFailureAction) { 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 52b3011f..5ce3037f 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 @@ -67,6 +67,7 @@ internal fun AnalysisScreen( AnalysisScreen( uiState = uiState, + isScriptExpanded = isScriptExpanded, onIntent = viewModel::onIntent, onScriptExpandedChange = { isScriptExpanded = it }, ) @@ -101,6 +102,7 @@ private fun AnalysisFlowUiState.statusBarStyle(isScriptExpanded: Boolean): EdgeT @Composable private fun AnalysisScreen( uiState: AnalysisFlowUiState, + isScriptExpanded: Boolean, onIntent: (AnalysisFlowUiIntent) -> Unit, onScriptExpandedChange: (Boolean) -> Unit, ) { @@ -122,6 +124,7 @@ private fun AnalysisScreen( AnalysisStepContent( uiState = uiState, + isScriptExpanded = isScriptExpanded, onIntent = onIntent, onClickRecordingControl = onClickRecordingControl, onScriptExpandedChange = onScriptExpandedChange, @@ -160,6 +163,7 @@ private fun rememberVoiceRecordingControlClick( @Composable private fun AnalysisStepContent( uiState: AnalysisFlowUiState, + isScriptExpanded: Boolean, onIntent: (AnalysisFlowUiIntent) -> Unit, onClickRecordingControl: () -> Unit, onScriptExpandedChange: (Boolean) -> Unit, @@ -172,6 +176,7 @@ private fun AnalysisStepContent( AnalysisFlowStep.VOICE_RECORDING, -> AnalysisInputStepContent( uiState = uiState, + isScriptExpanded = isScriptExpanded, onIntent = onIntent, onClickRecordingControl = onClickRecordingControl, onScriptExpandedChange = onScriptExpandedChange, @@ -191,6 +196,7 @@ private fun AnalysisStepContent( @Composable private fun AnalysisInputStepContent( uiState: AnalysisFlowUiState, + isScriptExpanded: Boolean, onIntent: (AnalysisFlowUiIntent) -> Unit, onClickRecordingControl: () -> Unit, onScriptExpandedChange: (Boolean) -> Unit, @@ -233,6 +239,7 @@ private fun AnalysisInputStepContent( AnalysisFlowStep.VOICE_RECORDING -> VoiceRecordingScreen( uiState = uiState, + isScriptExpanded = isScriptExpanded, onClickRecordingControl = onClickRecordingControl, onStopRecording = { onIntent(AnalysisFlowUiIntent.StopRecording) }, onResetRecording = { onIntent(AnalysisFlowUiIntent.ResetRecording) }, 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 a70919a7..2a474fbf 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 @@ -15,7 +15,6 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -38,6 +37,7 @@ import kotlinx.coroutines.delay @Composable internal fun VoiceRecordingScreen( uiState: AnalysisFlowUiState, + isScriptExpanded: Boolean, onClickRecordingControl: () -> Unit, onStopRecording: () -> Unit, onResetRecording: () -> Unit, @@ -50,6 +50,7 @@ internal fun VoiceRecordingScreen( recordingState = uiState.recordingState, recordingVolumes = uiState.recordingVolumes, analyzeEnabled = uiState.canMoveNext, + isScriptExpanded = isScriptExpanded, onClickRecordingControl = onClickRecordingControl, onStopRecording = onStopRecording, onResetRecording = onResetRecording, @@ -65,6 +66,7 @@ private fun VoiceRecordingScreen( recordingState: AudioSessionState, recordingVolumes: ImmutableList, analyzeEnabled: Boolean, + isScriptExpanded: Boolean, onClickRecordingControl: () -> Unit, onStopRecording: () -> Unit, onResetRecording: () -> Unit, @@ -72,8 +74,6 @@ private fun VoiceRecordingScreen( onScriptExpandedChange: (Boolean) -> Unit, onBack: () -> Unit, ) { - var isScriptExpanded by rememberSaveable { mutableStateOf(false) } - Column( modifier = Modifier .fillMaxSize() @@ -96,8 +96,7 @@ private fun VoiceRecordingScreen( recordingVolumes = recordingVolumes, isScriptExpanded = isScriptExpanded, onToggleScriptExpanded = { - isScriptExpanded = !isScriptExpanded - onScriptExpandedChange(isScriptExpanded) + onScriptExpandedChange(!isScriptExpanded) }, onClickRecordingControl = onClickRecordingControl, modifier = Modifier.weight(1f), @@ -238,6 +237,7 @@ private fun VoiceRecordingScreenPreviewContent(recordingState: AudioSessionState form = AnalysisForm(script = "한 번쯤 발표하면서 긴장하신 경험 있으시죠. 오늘도 다들 긴장되는 마음으로 오셨을 것 같습니다."), recordingState = recordingState, ), + isScriptExpanded = false, onClickRecordingControl = {}, onStopRecording = {}, onResetRecording = {}, @@ -266,6 +266,7 @@ private fun VoiceRecordingScreenInteractiveFlowPreview() { form = AnalysisForm(script = "한 번쯤 발표하면서 긴장하신 경험 있으시죠. 오늘도 다들 긴장되는 마음으로 오셨을 것 같습니다."), recordingState = recordingState, ), + isScriptExpanded = false, onClickRecordingControl = { recordingState = recordingState.nextControlState() }, onStopRecording = { recordingState = recordingState.stopPreviewRecording() }, onResetRecording = { recordingState = AudioSessionState.Idle }, 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 5c429806..1537bc77 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 @@ -7,6 +7,7 @@ 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 val AudioSessionState.currentSeconds: Int get() = when (this) { @@ -74,5 +75,5 @@ internal val AudioSessionState.actionIconColor: Color internal fun Int.toTimerText(): String { val minutes = this / 60 val seconds = this % 60 - return "%02d:%02d".format(minutes, seconds) + return String.format(Locale.US, "%02d:%02d", minutes, seconds) } From 76a1d4568152701512350e83288960102d59dec8 Mon Sep 17 00:00:00 2001 From: Ham BeomJoon Date: Sat, 30 May 2026 21:34:44 +0900 Subject: [PATCH 13/15] =?UTF-8?q?refactor:=20=EB=B6=84=EC=84=9D=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EA=B0=9C=EC=84=A0=20=EB=B0=8F=20UI=20?= =?UTF-8?q?=EA=B5=AC=EC=A1=B0=20=EB=A6=AC=ED=8C=A9=ED=84=B0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `PresentationRemoteDataSourceImpl`에서 불필요한 `toAudioMultipart` 확장 함수와 중복된 `appendAudioPart` 선언 제거 - `AnalysisFlowViewModel` 내 파일 경로 해결 로직(`resolveAudioFilePath`, `resolveScriptFilePath`)을 외부 private 함수로 분리하고 `AnalysisFileCache` 의존성을 명시적으로 전달하도록 수정 - `fetchPresentationDetailUseCase` 결과 처리 시 `detail.analysisSummary`를 참조하도록 데이터 접근 로직 보정 - `AnalysisScreen`의 가독성 향상을 위해 `AnalysisFormInputStepContent` 컴포저블을 분리하고 단계별 렌더링 로직 최적화 - `PrezelApp.kt`에서 불필요한 레이아웃 중복 코드 및 잘못된 위치의 `EdgeToEdgeStatusBarBackground` 호출부 정리 --- .../main/java/com/team/prezel/ui/PrezelApp.kt | 33 +---------- .../PresentationRemoteDataSourceImpl.kt | 10 ---- .../analysis/impl/AnalysisFlowViewModel.kt | 56 ++++++++++--------- .../feature/analysis/impl/AnalysisScreen.kt | 37 ++++++++---- 4 files changed, 56 insertions(+), 80 deletions(-) diff --git a/Prezel/app/src/main/java/com/team/prezel/ui/PrezelApp.kt b/Prezel/app/src/main/java/com/team/prezel/ui/PrezelApp.kt index 6872cf7c..70ce3f4e 100644 --- a/Prezel/app/src/main/java/com/team/prezel/ui/PrezelApp.kt +++ b/Prezel/app/src/main/java/com/team/prezel/ui/PrezelApp.kt @@ -15,9 +15,6 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.windowInsetsTopHeight @@ -110,6 +107,7 @@ private fun PrezelAppContent( } } + EdgeToEdgeStatusBarBackground(style = statusBarStyle) AppDimmerOverlay(isVisible = appDimmerState.isVisible, onDismiss = appDimmerState::dismiss) } } @@ -160,35 +158,6 @@ private fun AppDimmerOverlay( private fun defaultPrezelNavTransition(): ContentTransform = fadeIn(animationSpec = tween(durationMillis = 100)) togetherWith fadeOut(animationSpec = tween(durationMillis = 100)) - PrezelNavigationScaffold( - showNavigationBar = appState.shouldShowNavigationBar, - snackbarHostState = LocalSnackbarHostState.current, - navigationItems = { AppNavigationItems(appState = appState, navigateToKey = { key -> navigator.navigate(key) }) }, - ) { padding -> - NavDisplay( - entries = appState.navigationState.toEntries(provider), - onBack = navigator::goBack, - modifier = Modifier.padding(padding), - transitionSpec = { - fadeIn(animationSpec = tween(durationMillis = 100)) togetherWith - fadeOut(animationSpec = tween(durationMillis = 100)) - }, - popTransitionSpec = { - fadeIn(animationSpec = tween(durationMillis = 100)) togetherWith - fadeOut(animationSpec = tween(durationMillis = 100)) - }, - predictivePopTransitionSpec = { - fadeIn(animationSpec = tween(durationMillis = 100)) togetherWith - fadeOut(animationSpec = tween(durationMillis = 100)) - }, - ) - } - } - - EdgeToEdgeStatusBarBackground(style = statusBarStyle) - } - } -} @Composable private fun EdgeToEdgeStatusBarBackground(style: EdgeToEdgeStatusBarStyle) { diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/PresentationRemoteDataSourceImpl.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/PresentationRemoteDataSourceImpl.kt index 31d9a454..ec62b5cf 100644 --- a/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/PresentationRemoteDataSourceImpl.kt +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/PresentationRemoteDataSourceImpl.kt @@ -107,18 +107,8 @@ internal class PresentationRemoteDataSourceImpl @Inject constructor( override suspend fun getMainData(): List = presentationService.getMainData().requireData() } -private fun String.toAudioMultipart(): MultiPartFormDataContent = - MultiPartFormDataContent( - formData { - appendAudioPart(this@toAudioMultipart) - }, - ) - private fun FormBuilder.appendAudioPart(audioFilePath: String) { val audioFile = File(audioFilePath) - private fun FormBuilder.appendAudioPart(audioFilePath: String) { - val audioFile = File(audioFilePath) - append( key = "audio", value = audioFile.toChannelProvider(), 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 b1659bfb..c86b6974 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 @@ -222,13 +222,13 @@ internal class AnalysisFlowViewModel @Inject constructor( viewModelScope.launch { fetchPresentationDetailUseCase(presentationId = presentationId, isPast = isPast) - .onSuccess { summary -> - summary + .onSuccess { detail -> + detail.analysisSummary .fetchOriginalScript(fetchPresentationScriptDetailUseCase) .onSuccess { script -> updateState { copy( - form = summary.toAnalysisForm().copy( + form = detail.analysisSummary.toAnalysisForm().copy( scriptInputType = ScriptInputType.DIRECT_INPUT, script = script.orEmpty(), ), @@ -263,10 +263,10 @@ internal class AnalysisFlowViewModel @Inject constructor( viewModelScope.launch { fetchPresentationDetailUseCase(presentationId = presentationId, isPast = isPast) - .onSuccess { summary -> + .onSuccess { detail -> updateState { copy( - form = summary.toAnalysisForm().copy(scriptInputType = ScriptInputType.DIRECT_INPUT), + form = detail.analysisSummary.toAnalysisForm().copy(scriptInputType = ScriptInputType.DIRECT_INPUT), step = AnalysisFlowStep.SCRIPT_INPUT, reWritingScriptPresentationId = presentationId, ) @@ -280,8 +280,8 @@ internal class AnalysisFlowViewModel @Inject constructor( private suspend fun PresentationAnalysisSubmission.analyzePresentationRecording(): Result = runCatching { - val audioFilePath = resolveAudioFilePath() - val scriptFilePath = resolveScriptFilePath() + val audioFilePath = resolveAudioFilePath(analysisFileCache) + val scriptFilePath = resolveScriptFilePath(analysisFileCache) analyzePresentationUseCase( name = name, date = date.toRequestDate(), @@ -299,8 +299,8 @@ internal class AnalysisFlowViewModel @Inject constructor( private suspend fun PresentationAnalysisSubmission.reAnalyzePresentationRecording(presentationId: Long): Result = runCatching { - val audioFilePath = resolveAudioFilePath() - val scriptFilePath = resolveScriptFilePath() + val audioFilePath = resolveAudioFilePath(analysisFileCache) + val scriptFilePath = resolveScriptFilePath(analysisFileCache) reAnalyzePresentationUseCase( presentationId = presentationId, script = script, @@ -311,24 +311,6 @@ internal class AnalysisFlowViewModel @Inject constructor( if (it is CancellationException) throw it } - private fun PresentationAnalysisSubmission.resolveAudioFilePath(): String = - audioFileUri - ?.let { uri -> - analysisFileCache.copyUriToCache( - uriString = uri, - prefix = "audio", - ).absolutePath - } - ?: recordingFilePath - - private fun PresentationAnalysisSubmission.resolveScriptFilePath(): String? = - scriptFileUri?.let { uri -> - analysisFileCache.copyUriToCache( - uriString = uri, - prefix = "script", - ).absolutePath - } - private fun handleAnalysisFailure(action: AnalysisFailureAction) { when (action) { AnalysisFailureAction.RetryAnalysis -> { @@ -448,6 +430,26 @@ private suspend fun PresentationAnalysisSummary.fetchOriginalScript( .map { it.originalScript } } +private fun PresentationAnalysisSubmission.resolveAudioFilePath(analysisFileCache: AnalysisFileCache): String = + audioFileUri + ?.let { uri -> + analysisFileCache + .copyUriToCache( + uriString = uri, + prefix = "audio", + ).absolutePath + } + ?: recordingFilePath + +private fun PresentationAnalysisSubmission.resolveScriptFilePath(analysisFileCache: AnalysisFileCache): String? = + scriptFileUri?.let { uri -> + analysisFileCache + .copyUriToCache( + uriString = uri, + prefix = "script", + ).absolutePath + } + private fun AudioSessionEffect.toUiMessage(): AnalysisUiMessage = when (this) { AudioSessionEffect.RecordingStartFailed -> AnalysisUiMessage.RECORDING_START_FAILED 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 5ce3037f..11b82c82 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 @@ -200,6 +200,31 @@ private fun AnalysisInputStepContent( onIntent: (AnalysisFlowUiIntent) -> Unit, onClickRecordingControl: () -> Unit, onScriptExpandedChange: (Boolean) -> Unit, +) { + if (uiState.step == AnalysisFlowStep.VOICE_RECORDING) { + VoiceRecordingScreen( + uiState = uiState, + isScriptExpanded = isScriptExpanded, + onClickRecordingControl = onClickRecordingControl, + onStopRecording = { onIntent(AnalysisFlowUiIntent.StopRecording) }, + onResetRecording = { onIntent(AnalysisFlowUiIntent.ResetRecording) }, + onAnalyze = { onIntent(AnalysisFlowUiIntent.Next) }, + onScriptExpandedChange = onScriptExpandedChange, + onBack = { onIntent(AnalysisFlowUiIntent.Back) }, + ) + return + } + + AnalysisFormInputStepContent( + uiState = uiState, + onIntent = onIntent, + ) +} + +@Composable +private fun AnalysisFormInputStepContent( + uiState: AnalysisFlowUiState, + onIntent: (AnalysisFlowUiIntent) -> Unit, ) { when (uiState.step) { AnalysisFlowStep.PRESENTATION_SCHEDULE -> PresentationScheduleScreen( @@ -237,17 +262,7 @@ private fun AnalysisInputStepContent( onBack = { onIntent(AnalysisFlowUiIntent.Back) }, ) - AnalysisFlowStep.VOICE_RECORDING -> VoiceRecordingScreen( - uiState = uiState, - isScriptExpanded = isScriptExpanded, - onClickRecordingControl = onClickRecordingControl, - onStopRecording = { onIntent(AnalysisFlowUiIntent.StopRecording) }, - onResetRecording = { onIntent(AnalysisFlowUiIntent.ResetRecording) }, - onAnalyze = { onIntent(AnalysisFlowUiIntent.Next) }, - onScriptExpandedChange = onScriptExpandedChange, - onBack = { onIntent(AnalysisFlowUiIntent.Back) }, - ) - + AnalysisFlowStep.VOICE_RECORDING, AnalysisFlowStep.ANALYZING, AnalysisFlowStep.ANALYSIS_FAILED, AnalysisFlowStep.FILE_RECOGNITION_FAILED, From 1372a5214a105e32fcd351e89a968fa9144d68da Mon Sep 17 00:00:00 2001 From: Ham BeomJoon Date: Sat, 30 May 2026 21:48:05 +0900 Subject: [PATCH 14/15] =?UTF-8?q?refactor:=20=EC=9D=8C=EC=84=B1=20?= =?UTF-8?q?=EB=85=B9=EC=9D=8C=20=EC=83=81=ED=83=9C=EB=B0=94=20=EC=8A=A4?= =?UTF-8?q?=ED=83=80=EC=9D=BC=20=EB=9D=BC=EC=9D=B4=ED=94=84=EC=82=AC?= =?UTF-8?q?=EC=9D=B4=ED=81=B4=20=EB=B0=8F=20=EC=A0=81=EC=9A=A9=20=EC=8B=9C?= =?UTF-8?q?=EC=A0=90=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `VoiceRecordingStatusBarStyle`: `DisposableEffect`를 추가하여 컴포저블이 해제될 때 `ResetEdgeToEdgeStatusBarStyle` 이벤트를 발생시켜 상태바 스타일을 초기화하도록 개선 - `AnalysisScreen`: 상태바 스타일 적용 로직을 음성 녹음 단계(`VOICE_RECORDING`) 내부로 이동하여 특정 단계에서만 스타일이 활성화되도록 수정 --- .../team/prezel/feature/analysis/impl/AnalysisScreen.kt | 4 ++-- .../impl/recording/VoiceRecordingStatusBarStyle.kt | 7 +++++++ 2 files changed, 9 insertions(+), 2 deletions(-) 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 11b82c82..193e191e 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 @@ -46,8 +46,6 @@ internal fun AnalysisScreen( val snackbarHostState = LocalSnackbarHostState.current var isScriptExpanded by rememberSaveable(uiState.step) { mutableStateOf(false) } - VoiceRecordingStatusBarStyle(style = uiState.statusBarStyle(isScriptExpanded = isScriptExpanded)) - BackHandler { viewModel.onIntent(AnalysisFlowUiIntent.Back) } @@ -202,6 +200,8 @@ private fun AnalysisInputStepContent( onScriptExpandedChange: (Boolean) -> Unit, ) { if (uiState.step == AnalysisFlowStep.VOICE_RECORDING) { + VoiceRecordingStatusBarStyle(style = uiState.statusBarStyle(isScriptExpanded = isScriptExpanded)) + VoiceRecordingScreen( uiState = uiState, isScriptExpanded = isScriptExpanded, diff --git a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/recording/VoiceRecordingStatusBarStyle.kt b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/recording/VoiceRecordingStatusBarStyle.kt index c2a1036f..8291ed74 100644 --- a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/recording/VoiceRecordingStatusBarStyle.kt +++ b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/recording/VoiceRecordingStatusBarStyle.kt @@ -1,6 +1,7 @@ package com.team.prezel.feature.analysis.impl.recording import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember import androidx.compose.ui.platform.LocalContext @@ -22,6 +23,12 @@ internal fun VoiceRecordingStatusBarStyle(style: EdgeToEdgeStatusBarStyle) { LaunchedEffect(globalEventBus, style) { globalEventBus.emit(GlobalEvent.ChangeEdgeToEdgeStatusBarStyle(style)) } + + DisposableEffect(globalEventBus) { + onDispose { + globalEventBus.tryEmit(GlobalEvent.ResetEdgeToEdgeStatusBarStyle) + } + } } @Composable From 58b619b37ea494f35bd7bfe7708459a27e00a5f9 Mon Sep 17 00:00:00 2001 From: Ham BeomJoon Date: Sat, 30 May 2026 22:24:24 +0900 Subject: [PATCH 15/15] =?UTF-8?q?feat:=20=EC=9D=8C=EC=84=B1=20=EB=85=B9?= =?UTF-8?q?=EC=9D=8C=20=ED=99=94=EB=A9=B4=20=EC=A0=91=EA=B7=BC=EC=84=B1=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0=20=EB=B0=8F=20=ED=85=8C=EB=A7=88=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `VoiceRecordingScreen`의 닫기 아이콘에 `contentDescription` 적용 및 관련 문자열 리소스 추가 - `MainActivity`에서 `PrezelTheme` 호출 시 불필요한 `isDarkTheme` 매개변수 제거하여 기본 테마 설정을 따르도록 수정 --- Prezel/app/src/main/java/com/team/prezel/MainActivity.kt | 2 +- .../feature/analysis/impl/recording/VoiceRecordingScreen.kt | 3 ++- Prezel/feature/analysis/impl/src/main/res/values/strings.xml | 1 + 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/Prezel/app/src/main/java/com/team/prezel/MainActivity.kt b/Prezel/app/src/main/java/com/team/prezel/MainActivity.kt index eac0183f..d3903118 100644 --- a/Prezel/app/src/main/java/com/team/prezel/MainActivity.kt +++ b/Prezel/app/src/main/java/com/team/prezel/MainActivity.kt @@ -31,7 +31,7 @@ class MainActivity : ComponentActivity() { enableEdgeToEdge() setContent { - PrezelTheme(isDarkTheme = false) { + PrezelTheme { val appState = rememberPrezelAppState(networkMonitor = networkMonitor) PrezelApp( 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 2a474fbf..b43f5ee7 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 @@ -28,6 +28,7 @@ import com.team.prezel.core.designsystem.component.voice.VoiceChromeGradient import com.team.prezel.core.designsystem.icon.PrezelIcons import com.team.prezel.core.designsystem.preview.BasicPreview import com.team.prezel.core.designsystem.theme.PrezelTheme +import com.team.prezel.feature.analysis.impl.R import com.team.prezel.feature.analysis.impl.contract.AnalysisFlowStep import com.team.prezel.feature.analysis.impl.contract.AnalysisFlowUiState import com.team.prezel.feature.analysis.impl.contract.AnalysisForm @@ -171,7 +172,7 @@ private fun VoiceRecordingCloseButton( ) { Icon( painter = painterResource(PrezelIcons.Cancel), - contentDescription = null, + contentDescription = stringResource(R.string.feature_analysis_impl_close), modifier = Modifier.size(24.dp), tint = PrezelTheme.colors.iconRegular, ) 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 44c2d26d..bc31e60f 100644 --- a/Prezel/feature/analysis/impl/src/main/res/values/strings.xml +++ b/Prezel/feature/analysis/impl/src/main/res/values/strings.xml @@ -86,6 +86,7 @@ 네트워크 연결을 확인해 주세요. 알 수 없는 오류가 발생했어요. 뒤로가기 + 닫기 다음 건너뛰기 다시 시도하기