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/app/src/main/java/com/team/prezel/ui/PrezelApp.kt b/Prezel/app/src/main/java/com/team/prezel/ui/PrezelApp.kt index a87e27c8..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 @@ -1,5 +1,8 @@ package com.team.prezel.ui +import android.app.Activity +import android.content.Context +import android.content.ContextWrapper import androidx.compose.animation.ContentTransform import androidx.compose.animation.SharedTransitionLayout import androidx.compose.animation.core.tween @@ -8,19 +11,33 @@ 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.fillMaxSize +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 @@ -71,10 +88,12 @@ private fun PrezelAppContent( ) { val navigator = LocalNavigator.current val appDimmerState = LocalAppDimmerState.current + var statusBarStyle by remember { mutableStateOf(EdgeToEdgeStatusBarStyle.DEFAULT) } ObserveGlobalEvents( globalEventBus = globalEventBus, navigateToSplash = { navigator.replaceRoot(SplashNavKey) }, + onStatusBarStyleChange = { statusBarStyle = it }, ) Box(modifier = Modifier.fillMaxSize()) { @@ -88,6 +107,7 @@ private fun PrezelAppContent( } } + EdgeToEdgeStatusBarBackground(style = statusBarStyle) AppDimmerOverlay(isVisible = appDimmerState.isVisible, onDismiss = appDimmerState::dismiss) } } @@ -139,20 +159,89 @@ private fun defaultPrezelNavTransition(): ContentTransform = fadeIn(animationSpec = tween(durationMillis = 100)) togetherWith fadeOut(animationSpec = tween(durationMillis = 100)) +@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) + val statusBarColor = when (style) { + EdgeToEdgeStatusBarStyle.DEFAULT -> Color.Transparent + EdgeToEdgeStatusBarStyle.BG_REGULAR -> bgRegular + EdgeToEdgeStatusBarStyle.BG_MEDIUM -> bgMedium + }.toArgb() + + window.statusBarColor = statusBarColor + 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/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 dd05183f..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 @@ -3,6 +3,12 @@ package com.team.prezel.core.audio internal interface AudioRecorderSession { fun start(): Result + fun pause(): Result + + 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/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..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 @@ -41,6 +41,18 @@ internal class MediaRecorderSession @Inject constructor( reset() } + override fun pause(): Result = + runCatching { + recorder?.pause() ?: error("Recorder not initialized") + } + + override fun resume(): Result = + runCatching { + recorder?.resume() ?: error("Recorder not initialized") + } + + 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 4e74fdda..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,32 +40,48 @@ 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 - emitEffect(AudioSessionEffect.RecordingStartFailed) + _audioSessionEffect.emit(AudioSessionEffect.RecordingStartFailed) } } override fun stopRecording() { val elapsedSeconds = when (val state = audioSessionState.value) { is AudioSessionState.Recording -> state.elapsedSeconds + is AudioSessionState.PausedRecording -> state.elapsedSeconds else -> return } recordingTimerJob?.cancel() + recordingVolumeJob?.cancel() recorderSession .stop(elapsedSeconds = elapsedSeconds) .onSuccess { recordedAudio -> @@ -59,8 +90,49 @@ internal class MediaRecordingAudioController @Inject constructor( durationSeconds = recordedAudio.durationSeconds, ) }.onFailure { + _recordingVolumes.value = persistentListOf() _audioSessionState.value = AudioSessionState.Idle - emitEffect(AudioSessionEffect.RecordingStopFailed) + _audioSessionEffect.emit(AudioSessionEffect.RecordingStopFailed) + } + } + + override fun pauseRecording() { + val elapsedSeconds = when (val state = audioSessionState.value) { + is AudioSessionState.Recording -> state.elapsedSeconds + else -> return + } + + recorderSession + .pause() + .onSuccess { + recordingTimerJob?.cancel() + recordingVolumeJob?.cancel() + _audioSessionState.value = AudioSessionState.PausedRecording(elapsedSeconds = elapsedSeconds) + }.onFailure { + _audioSessionEffect.emit(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) + recordingTimerJob?.cancel() + recordingTimerJob = controllerScope.launchRecordingTimer(_audioSessionState) + recordingVolumeJob?.cancel() + recordingVolumeJob = controllerScope.launchRecordingVolumeMeter( + audioSessionState = audioSessionState, + recorderSession = recorderSession, + recordingVolumes = _recordingVolumes, + ) + }.onFailure { + _audioSessionEffect.emit(AudioSessionEffect.RecordingStartFailed) } } @@ -97,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 } @@ -129,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, @@ -159,51 +234,64 @@ internal class MediaRecordingAudioController @Inject constructor( source = source, durationSeconds = durationSeconds, ) - emitEffect(AudioSessionEffect.PlaybackStartFailed) + _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 = playerSession.currentPositionSeconds(), - 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 fun emitEffect(effect: AudioSessionEffect) { - _audioSessionEffect.trySend(effect) + AudioSessionState.Playing( + source = state.source, + positionSeconds = (state.positionSeconds + 1).coerceAtMost(state.durationSeconds), + durationSeconds = state.durationSeconds, + ) + } + } } - private companion object { - const val RECORDING_TIMER_DELAY_MILLIS = 1_000L - const val PLAYBACK_TIMER_DELAY_MILLIS = 250L - } -} +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 6d7377c2..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,15 +1,22 @@ 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() + fun pauseRecording() + + fun resumeRecording() + fun stopRecording() fun startPlayback() 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/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) } 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/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 fc5f0310..0787871e 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 @@ -50,11 +50,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/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..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,7 +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.size +import androidx.compose.foundation.layout.width import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable @@ -129,7 +129,8 @@ private fun PrezelVoiceChromeContent( Box( modifier = modifier - .size(width = 360.dp, height = 160.dp) + .fillMaxWidth() + .height(160.dp) .voiceChromeBackground( status = status, gradientStop = gradientStop, @@ -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/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/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 14e988e0..6e0e3b3e 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 @@ -26,6 +26,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 28da0ce5..6ab70f33 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 @@ -22,6 +22,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 87bd7a9e..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 @@ -58,13 +58,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() @@ -92,16 +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) - append( key = "audio", value = audioFile.toChannelProvider(), 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/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..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 @@ -2,9 +2,70 @@ 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 + 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 object Create : AnalysisNavKey + 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 + data class ReRecording( + val presentationId: Long, + val isPast: Boolean = false, + override val flowId: String = newAnalysisFlowId(), + override val startType: AnalysisStartType = AnalysisStartType.VOICE_RECORDING, + ) : AnalysisNavKey + + @Serializable + data class ReWritingScript( + 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/build.gradle.kts b/Prezel/feature/analysis/impl/build.gradle.kts index 2fbc0fa9..1161df57 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/AnalysisBackStateReducer.kt b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AnalysisBackStateReducer.kt new file mode 100644 index 00000000..a65d9d46 --- /dev/null +++ b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AnalysisBackStateReducer.kt @@ -0,0 +1,46 @@ +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 + +internal val AnalysisFlowUiState.shouldResetAudioOnBack: Boolean + get() = + step == AnalysisFlowStep.VOICE_RECORDING || + 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.ANALYSIS_FAILED, + 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/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 f502e9a8..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 @@ -1,114 +1,287 @@ package com.team.prezel.feature.analysis.impl import androidx.lifecycle.viewModelScope +import com.team.prezel.core.audio.AudioSessionEffect +import com.team.prezel.core.audio.AudioSessionState +import com.team.prezel.core.audio.RecordingAudioController import com.team.prezel.core.domain.usecase.presentation.AnalyzePresentationUseCase -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.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 +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 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.model.AnalysisUiMessage import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.launch -import kotlinx.datetime.LocalDate +import kotlinx.coroutines.withContext import javax.inject.Inject @HiltViewModel 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()) { private var analyzeJob: Job? = null + init { + 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) { + 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) } + is AnalysisFlowUiIntent.EnterStep -> updateState { + copy( + step = intent.step, + startType = intent.startType, + ) + } + is AnalysisFlowUiIntent.StartReRecording -> startReRecording( + presentationId = intent.presentationId, + isPast = intent.isPast, + ) + + is AnalysisFlowUiIntent.StartReWritingScript -> startReWritingScript( + presentationId = intent.presentationId, + isPast = intent.isPast, + ) + + is AnalysisFlowUiIntent.SelectScriptFile -> selectScriptFile(intent.fileUri) + AnalysisFlowUiIntent.ClickRecordingControl -> audioController.handleControlClick(currentState.recordingState) + AnalysisFlowUiIntent.StopRecording -> audioController.stopRecording() + AnalysisFlowUiIntent.ResetRecording -> audioController.reset() is AnalysisFlowUiIntent.RetryFileUpload -> retryFileUpload(intent.uploadType) AnalysisFlowUiIntent.Next -> moveNext() AnalysisFlowUiIntent.SkipScript -> skipScript() AnalysisFlowUiIntent.Back -> moveBack() + else -> Unit } } - 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) - } + 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 - if (currentState.step == AnalysisFlowStep.AUDIO_UPLOAD) { + if (currentState.step == AnalysisFlowStep.VOICE_RECORDING || currentState.step == AnalysisFlowStep.AUDIO_UPLOAD) { analyzePresentation() return } - updateState { - copy( - step = when (step) { - AnalysisFlowStep.PRESENTATION_SCHEDULE -> AnalysisFlowStep.PRESENTATION_SITUATION - AnalysisFlowStep.PRESENTATION_SITUATION -> AnalysisFlowStep.SCRIPT_INPUT - AnalysisFlowStep.SCRIPT_INPUT -> AnalysisFlowStep.AUDIO_UPLOAD - AnalysisFlowStep.AUDIO_UPLOAD, - 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 -> currentState.audioInputStep + AnalysisFlowStep.AUDIO_UPLOAD, + AnalysisFlowStep.VOICE_RECORDING, + AnalysisFlowStep.ANALYZING, + AnalysisFlowStep.ANALYSIS_FAILED, + 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.form.toPresentationAnalysisSubmissionOrNull() ?: return + 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 { - submission - .analyzePresentationRecording() - .onSuccess { result -> + sendEffect(AnalysisFlowUiEffect.NavigateToStep(step = AnalysisFlowStep.ANALYZING)) + + val analysisResult = submission.analyzePresentationRecording() + + analysisResult.fold( + onSuccess = { result -> if (currentState.step == AnalysisFlowStep.ANALYZING) { + audioController.reset() sendEffect(AnalysisFlowUiEffect.NavigateToReport(presentationId = result)) } - }.onFailure { throwable -> handleAnalysisFailure(throwable.toAnalysisFailureAction()) } + }, + onFailure = { throwable -> + handleAnalysisFailure(throwable.toAnalysisFailureAction()) + }, + ) + } + } + + 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.reset() + 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 { detail -> + detail.analysisSummary + .fetchOriginalScript(fetchPresentationScriptDetailUseCase) + .onSuccess { script -> + updateState { + copy( + form = detail.analysisSummary.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) + } + } + } + + 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 { detail -> + updateState { + copy( + form = detail.analysisSummary.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 audioFile = analysisFileCache.copyUriToCache( - uriString = audioFileUri, - prefix = "audio", - ) - val scriptFile = scriptFileUri?.let { uri -> - analysisFileCache.copyUriToCache( - uriString = uri, - prefix = "script", - ) - } + val audioFilePath = resolveAudioFilePath(analysisFileCache) + val scriptFilePath = resolveScriptFilePath(analysisFileCache) analyzePresentationUseCase( name = name, date = date.toRequestDate(), @@ -117,130 +290,179 @@ internal class AnalysisFlowViewModel @Inject constructor( style = style, audience = audience, script = script, - scriptFilePath = scriptFile?.absolutePath, - audioFilePath = audioFile.absolutePath, + scriptFilePath = scriptFilePath, + audioFilePath = audioFilePath, ).getOrThrow() + }.onFailure { + if (it is CancellationException) throw it + } + + private suspend fun PresentationAnalysisSubmission.reAnalyzePresentationRecording(presentationId: Long): Result = + runCatching { + val audioFilePath = resolveAudioFilePath(analysisFileCache) + val scriptFilePath = resolveScriptFilePath(analysisFileCache) + reAnalyzePresentationUseCase( + presentationId = presentationId, + script = script, + scriptFilePath = scriptFilePath, + audioFilePath = audioFilePath, + ).getOrThrow() + }.onFailure { + if (it is CancellationException) throw it } private fun handleAnalysisFailure(action: AnalysisFailureAction) { when (action) { + AnalysisFailureAction.RetryAnalysis -> { + updateState { copy(step = AnalysisFlowStep.ANALYSIS_FAILED) } + } + 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.AUDIO_UPLOAD) } + val retryStep = currentState.audioInputStep + viewModelScope.launch { + sendEffect(AnalysisFlowUiEffect.ShowMessage(action.message)) + } + updateState { copy(step = retryStep) } } } } private fun retryFileUpload(uploadType: AnalysisUploadType) { when (uploadType) { - AnalysisUploadType.SCRIPT -> retryScriptFileUpload() - AnalysisUploadType.AUDIO -> retryAudioUpload() - } - } + AnalysisUploadType.SCRIPT -> { + val retryStep = AnalysisFlowStep.SCRIPT_INPUT + updateState { + copy( + step = retryStep, + form = form.copy( + scriptInputType = ScriptInputType.FILE_UPLOAD, + scriptFileUri = null, + ), + ) + } + viewModelScope.launch { sendEffect(AnalysisFlowUiEffect.NavigateToStep(step = retryStep)) } + } - private fun retryAudioUpload() { - updateState { - copy( - step = AnalysisFlowStep.AUDIO_UPLOAD, - form = form.copy(audioFileUri = null), - ) + AnalysisUploadType.AUDIO -> { + 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 || isAnalysisFailed) { + audioController.reset() + } + viewModelScope.launch { sendEffect(AnalysisFlowUiEffect.NavigateToStep(step = retryStep)) } + } } } - private fun retryScriptFileUpload() { + private fun skipScript() { + if (currentState.step != AnalysisFlowStep.SCRIPT_INPUT) return + + val nextStep = currentState.audioInputStep updateState { copy( - step = AnalysisFlowStep.SCRIPT_INPUT, + step = nextStep, form = form.copy( - scriptInputType = ScriptInputType.FILE_UPLOAD, + script = "", scriptFileUri = null, ), ) } - } - - private fun skipScript() { - if (currentState.step != AnalysisFlowStep.SCRIPT_INPUT) return - - updateState { copy(step = AnalysisFlowStep.AUDIO_UPLOAD) } + 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.ANALYZING -> AnalysisFlowStep.AUDIO_UPLOAD - AnalysisFlowStep.FILE_RECOGNITION_FAILED -> AnalysisFlowStep.AUDIO_UPLOAD - AnalysisFlowStep.SCRIPT_FILE_RECOGNITION_FAILED -> AnalysisFlowStep.SCRIPT_INPUT + if (currentState.shouldResetAudioOnBack) { + audioController.reset() } - if (previousStep == null) { - viewModelScope.launch { sendEffect(AnalysisFlowUiEffect.NavigateBack) } - } else { - updateState { copy(step = previousStep) } + currentState.backClearedFormOrNull()?.let { clearedForm -> + updateState { + copy(form = clearedForm) + } } + + viewModelScope.launch { sendEffect(AnalysisFlowUiEffect.NavigateBack) } } - private fun updateForm(reducer: AnalysisForm.() -> AnalysisForm) { - updateState { copy(form = form.reducer()) } + 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, -) - -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 - - return PresentationAnalysisSubmission( - name = presentationTitle.trim(), - date = presentationDate, - category = category, - purpose = purpose, - style = style, - audience = audience, - script = script.takeIf { !isFileUpload && it.isNotBlank() }, - scriptFileUri = scriptFileUri.takeIf { isFileUpload && !it.isNullOrBlank() }, - audioFileUri = audioFileUri, - ) +private val AnalysisFlowUiState.audioInputStep: AnalysisFlowStep + get() = when (startType) { + AnalysisStartType.FILE_UPLOAD -> AnalysisFlowStep.AUDIO_UPLOAD + 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 String.toRequestDate(): String = - runCatching { - val (year, month, day) = split("년 ", "월 ", "일") +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 + } - LocalDate( - year = year.toInt(), - month = month.toInt(), - day = day.toInt(), - ).toString() - }.getOrDefault(this) +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 + } + +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() + } +} 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..242cccd7 --- /dev/null +++ b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AnalysisFormMapper.kt @@ -0,0 +1,93 @@ +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, + script = "", + scriptFileUri = null, + ) + is AnalysisFlowUiIntent.UpdateScript -> form.copy(script = script) + 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/AnalysisScreen.kt b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AnalysisScreen.kt index 6e61b0ae..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 @@ -1,10 +1,17 @@ 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.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.hilt.lifecycle.viewmodel.compose.hiltViewModel 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 @@ -14,36 +21,43 @@ 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.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 import com.team.prezel.feature.analysis.impl.result.FileRecognitionFailedScreen import com.team.prezel.feature.analysis.impl.result.ScriptFileRecognitionFailedScreen 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( 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 + var isScriptExpanded by rememberSaveable(uiState.step) { mutableStateOf(false) } + + 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) { - 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 - } - snackbarHostState.showPrezelSnackbar(message = resources.getString(resId)) + snackbarHostState.showPrezelSnackbar(message = resources.getString(effect.message.toStringRes())) } } } @@ -51,12 +65,164 @@ internal fun AnalysisScreen( AnalysisScreen( uiState = uiState, + isScriptExpanded = isScriptExpanded, onIntent = viewModel::onIntent, + onScriptExpandedChange = { isScriptExpanded = it }, ) } +@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.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 + 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 + } + +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, + isScriptExpanded: Boolean, + onIntent: (AnalysisFlowUiIntent) -> Unit, + onScriptExpandedChange: (Boolean) -> Unit, +) { + 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, + onIntent = onIntent, + ) + + AnalysisStepContent( + uiState = uiState, + isScriptExpanded = isScriptExpanded, + onIntent = onIntent, + onClickRecordingControl = onClickRecordingControl, + onScriptExpandedChange = onScriptExpandedChange, + ) +} + +@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 = { + 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), + ) + } + }, + ) +} + +@Composable +private fun AnalysisStepContent( + uiState: AnalysisFlowUiState, + isScriptExpanded: Boolean, + onIntent: (AnalysisFlowUiIntent) -> Unit, + onClickRecordingControl: () -> Unit, + onScriptExpandedChange: (Boolean) -> Unit, +) { + when (uiState.step) { + AnalysisFlowStep.PRESENTATION_SCHEDULE, + AnalysisFlowStep.PRESENTATION_SITUATION, + AnalysisFlowStep.SCRIPT_INPUT, + AnalysisFlowStep.AUDIO_UPLOAD, + AnalysisFlowStep.VOICE_RECORDING, + -> AnalysisInputStepContent( + uiState = uiState, + isScriptExpanded = isScriptExpanded, + onIntent = onIntent, + onClickRecordingControl = onClickRecordingControl, + onScriptExpandedChange = onScriptExpandedChange, + ) + + AnalysisFlowStep.ANALYZING, + AnalysisFlowStep.ANALYSIS_FAILED, + AnalysisFlowStep.FILE_RECOGNITION_FAILED, + AnalysisFlowStep.SCRIPT_FILE_RECOGNITION_FAILED, + -> AnalysisResultStepContent( + step = uiState.step, + onIntent = onIntent, + ) + } +} + +@Composable +private fun AnalysisInputStepContent( + uiState: AnalysisFlowUiState, + isScriptExpanded: Boolean, + onIntent: (AnalysisFlowUiIntent) -> Unit, + onClickRecordingControl: () -> Unit, + onScriptExpandedChange: (Boolean) -> Unit, +) { + if (uiState.step == AnalysisFlowStep.VOICE_RECORDING) { + VoiceRecordingStatusBarStyle(style = uiState.statusBarStyle(isScriptExpanded = isScriptExpanded)) + + 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, ) { @@ -96,8 +262,27 @@ private fun AnalysisScreen( onBack = { onIntent(AnalysisFlowUiIntent.Back) }, ) + AnalysisFlowStep.VOICE_RECORDING, + AnalysisFlowStep.ANALYZING, + AnalysisFlowStep.ANALYSIS_FAILED, + 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.ANALYSIS_FAILED -> AnalysisFailedScreen( + onRetry = { onIntent(AnalysisFlowUiIntent.RetryFileUpload(AnalysisUploadType.AUDIO)) }, + ) + AnalysisFlowStep.FILE_RECOGNITION_FAILED -> FileRecognitionFailedScreen( onRetry = { onIntent(AnalysisFlowUiIntent.RetryFileUpload(AnalysisUploadType.AUDIO)) }, ) @@ -105,5 +290,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/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/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 e60b5f9a..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 @@ -1,12 +1,30 @@ 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 +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( + val presentationId: Long, + val isPast: Boolean, + ) : AnalysisFlowUiIntent + + data class StartReWritingScript( + val presentationId: Long, + val isPast: Boolean, + ) : AnalysisFlowUiIntent + data class UpdatePresentationTitle( val title: String, ) : AnalysisFlowUiIntent @@ -35,6 +53,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 3b4d2496..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 @@ -1,27 +1,38 @@ 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.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 @Immutable internal data class AnalysisFlowUiState( val step: AnalysisFlowStep = AnalysisFlowStep.PRESENTATION_SCHEDULE, val form: AnalysisForm = AnalysisForm(), + val recordingState: AudioSessionState = AudioSessionState.Idle, + 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) { 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 -> 0.75f + AnalysisFlowStep.AUDIO_UPLOAD, + AnalysisFlowStep.VOICE_RECORDING, AnalysisFlowStep.ANALYZING, + AnalysisFlowStep.ANALYSIS_FAILED, AnalysisFlowStep.FILE_RECOGNITION_FAILED, -> 1f } @@ -41,7 +52,9 @@ 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 @@ -72,7 +85,19 @@ internal enum class AnalysisFlowStep { PRESENTATION_SITUATION, SCRIPT_INPUT, AUDIO_UPLOAD, + VOICE_RECORDING, ANALYZING, + ANALYSIS_FAILED, 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..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 @@ -3,6 +3,13 @@ package com.team.prezel.feature.analysis.impl.model internal enum class AnalysisUiMessage { AUTH_EXPIRED, ANALYSIS_FAILED, + SCRIPT_LOAD_FAILED, + SCRIPT_FILE_LOAD_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/navigation/AnalysisEntryBuilder.kt b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/navigation/AnalysisEntryBuilder.kt index 4441369f..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 @@ -1,33 +1,122 @@ 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 import dagger.hilt.InstallIn import dagger.hilt.android.components.ActivityRetainedComponent import dagger.multibindings.IntoSet +import java.util.UUID internal fun EntryProviderScope.featureAnalysisEntryBuilder() { - entry { - val navigator = LocalNavigator.current - - AnalysisScreen( - onBack = { navigator.goBack() }, - navigateToReport = { presentationId -> - navigator.navigate( - key = ReportNavKey(presentationId = presentationId), - clearStack = true, - ) - }, + 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, + isPast = key.isPast, + ) + } + analysisEntry { key -> + AnalysisFlowUiIntent.StartReWritingScript( + presentationId = key.presentationId, + isPast = key.isPast, + ) + } +} + +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( + flowId = key.flowId, + enterIntent = enterIntent(key), + 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, + refreshKey = newReportRefreshKey(), + ), + clearStack = true, + ) + }, + viewModel = viewModel, + ) +} + +private fun AnalysisNavKey.toNavKey(step: AnalysisFlowStep): AnalysisNavKey = + when (step) { + 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, startType = startType) + + 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) + + AnalysisFlowStep.ANALYZING -> AnalysisNavKey.Analyzing(flowId = flowId, startType = startType) + } + +private tailrec fun Context.findViewModelStoreOwner(): ViewModelStoreOwner = + when (this) { + is ViewModelStoreOwner -> this + is ContextWrapper -> baseContext.findViewModelStoreOwner() + else -> error("Context에서 ViewModelStoreOwner를 찾을 수 없습니다: $this") + } + @Module @InstallIn(ActivityRetainedComponent::class) object FeatureAnalysisModule { @@ -38,3 +127,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/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/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..1d081477 --- /dev/null +++ b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/recording/VoiceRecordingContent.kt @@ -0,0 +1,408 @@ +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 +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.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 +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.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 + +@Composable +internal fun VoiceRecordingContent( + script: String, + recordingState: AudioSessionState, + recordingVolumes: ImmutableList, + isScriptExpanded: Boolean, + onToggleScriptExpanded: () -> Unit, + onClickRecordingControl: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxWidth() + .background(PrezelTheme.colors.bgRegular) + .padding(vertical = PrezelTheme.spacing.V16), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + if (!recordingState.isCompleted || isScriptExpanded) { + VoiceRecordingScriptHeader( + isScriptExpanded = isScriptExpanded, + onToggleScriptExpanded = onToggleScriptExpanded, + ) + Spacer(modifier = Modifier.height(PrezelTheme.spacing.V16)) + } + + VoiceRecordingScriptBody( + script = script, + modifier = Modifier.weight(1f), + ) + + if (recordingState !is AudioSessionState.Idle) { + VoiceRecordingStatusArea( + recordingState = recordingState, + recordingVolumes = recordingVolumes, + onClickRecordingControl = onClickRecordingControl, + ) + } + } +} + +@Composable +private fun VoiceRecordingScriptHeader( + isScriptExpanded: Boolean, + onToggleScriptExpanded: () -> Unit, +) { + 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( + isScriptExpanded = isScriptExpanded, + modifier = Modifier + .align(Alignment.CenterEnd) + .offset(x = PrezelTheme.spacing.V12) + .size(48.dp), + onClick = onToggleScriptExpanded, + ) + } +} + +@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, + recordingVolumes: ImmutableList, + onClickRecordingControl: () -> Unit, +) { + Spacer(modifier = Modifier.height(PrezelTheme.spacing.V16)) + RecordingWaveform( + recordingState = recordingState, + recordingVolumes = recordingVolumes, + 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( + isScriptExpanded: Boolean, + modifier: Modifier = Modifier, + onClick: () -> Unit, +) { + Box( + modifier = modifier.noRippleClickable(onClick = onClick), + contentAlignment = Alignment.Center, + ) { + Icon( + painter = painterResource( + if (isScriptExpanded) { + PrezelIcons.ZoomOut + } else { + PrezelIcons.ZoomIn + }, + ), + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = PrezelTheme.colors.iconRegular, + ) + } +} + +@Composable +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, + 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 new file mode 100644 index 00000000..b43f5ee7 --- /dev/null +++ b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/recording/VoiceRecordingScreen.kt @@ -0,0 +1,319 @@ +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.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.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.runtime.Composable +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.res.painterResource +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.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 +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 kotlinx.collections.immutable.ImmutableList +import kotlinx.coroutines.delay + +@Composable +internal fun VoiceRecordingScreen( + uiState: AnalysisFlowUiState, + isScriptExpanded: Boolean, + onClickRecordingControl: () -> Unit, + onStopRecording: () -> Unit, + onResetRecording: () -> Unit, + onAnalyze: () -> Unit, + onScriptExpandedChange: (Boolean) -> Unit, + onBack: () -> Unit, +) { + VoiceRecordingScreen( + script = uiState.form.script, + recordingState = uiState.recordingState, + recordingVolumes = uiState.recordingVolumes, + analyzeEnabled = uiState.canMoveNext, + isScriptExpanded = isScriptExpanded, + onClickRecordingControl = onClickRecordingControl, + onStopRecording = onStopRecording, + onResetRecording = onResetRecording, + onAnalyze = onAnalyze, + onScriptExpandedChange = onScriptExpandedChange, + onBack = onBack, + ) +} + +@Composable +private fun VoiceRecordingScreen( + script: String, + recordingState: AudioSessionState, + recordingVolumes: ImmutableList, + analyzeEnabled: Boolean, + isScriptExpanded: Boolean, + onClickRecordingControl: () -> Unit, + onStopRecording: () -> Unit, + onResetRecording: () -> Unit, + onAnalyze: () -> Unit, + onScriptExpandedChange: (Boolean) -> Unit, + onBack: () -> Unit, +) { + Column( + modifier = Modifier + .fillMaxSize() + .background(PrezelTheme.colors.bgRegular), + ) { + if (!isScriptExpanded) { + if (recordingState.isCompleted) { + VoiceRecordingCompletedTopBar(onBack = onBack) + } else { + VoiceRecordingChromeTopBar( + recordingState = recordingState, + onBack = onBack, + ) + } + } + + VoiceRecordingContent( + script = script, + recordingState = recordingState, + recordingVolumes = recordingVolumes, + isScriptExpanded = isScriptExpanded, + onToggleScriptExpanded = { + onScriptExpandedChange(!isScriptExpanded) + }, + onClickRecordingControl = onClickRecordingControl, + modifier = Modifier.weight(1f), + ) + + VoiceRecordingButtonArea( + recordingState = recordingState, + analyzeEnabled = analyzeEnabled, + onClickRecordingControl = onClickRecordingControl, + onStopRecording = onStopRecording, + onResetRecording = onResetRecording, + onAnalyze = onAnalyze, + ) + } +} + +@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) + .padding(top = PrezelTheme.spacing.V4, end = PrezelTheme.spacing.V8), + ) + } +} + +@Composable +private fun VoiceRecordingChromeTopBar( + recordingState: AudioSessionState, + onBack: () -> Unit, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .background(PrezelTheme.colors.bgMedium), + ) { + Box( + modifier = Modifier.fillMaxWidth(), + ) { + VoiceRecordingCloseButton( + onBack = onBack, + modifier = Modifier + .align(Alignment.TopEnd) + .padding(top = PrezelTheme.spacing.V4, end = PrezelTheme.spacing.V8), + ) + } + + PrezelVoiceChrome( + titleText = stringResource(recordingState.titleResId), + status = recordingState.toVoiceChromeStatus(), + gradient = VoiceChromeGradient.MIN, + modifier = Modifier.fillMaxWidth(), + ) + } +} + +@Composable +private fun VoiceRecordingCloseButton( + onBack: () -> Unit, + modifier: Modifier = Modifier, +) { + IconButton( + modifier = modifier.size(48.dp), + onClick = onBack, + ) { + Icon( + painter = painterResource(PrezelIcons.Cancel), + contentDescription = stringResource(R.string.feature_analysis_impl_close), + modifier = Modifier.size(24.dp), + tint = PrezelTheme.colors.iconRegular, + ) + } +} + +@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, + ), + isScriptExpanded = false, + onClickRecordingControl = {}, + onStopRecording = {}, + onResetRecording = {}, + onAnalyze = {}, + onScriptExpandedChange = {}, + onBack = {}, + ) +} + +@BasicPreview +@Composable +private fun VoiceRecordingScreenInteractiveFlowPreview() { + var recordingState by remember { mutableStateOf(AudioSessionState.Idle) } + + LaunchedEffect(recordingState) { + while (recordingState is AudioSessionState.Recording || recordingState is AudioSessionState.Playing) { + delay(1_000) + recordingState = recordingState.tick() + } + } + + PrezelTheme { + VoiceRecordingScreen( + uiState = AnalysisFlowUiState( + step = AnalysisFlowStep.VOICE_RECORDING, + form = AnalysisForm(script = "한 번쯤 발표하면서 긴장하신 경험 있으시죠. 오늘도 다들 긴장되는 마음으로 오셨을 것 같습니다."), + recordingState = recordingState, + ), + isScriptExpanded = false, + onClickRecordingControl = { recordingState = recordingState.nextControlState() }, + onStopRecording = { recordingState = recordingState.stopPreviewRecording() }, + onResetRecording = { recordingState = AudioSessionState.Idle }, + onAnalyze = {}, + onScriptExpandedChange = {}, + 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..1537bc77 --- /dev/null +++ b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/recording/VoiceRecordingStateProperties.kt @@ -0,0 +1,79 @@ +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 +import java.util.Locale + +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 fun AudioSessionState.toVoiceChromeStatus(): VoiceChromeStatus = + when (this) { + is AudioSessionState.Recording -> VoiceChromeStatus.LISTENING + is AudioSessionState.PausedRecording -> VoiceChromeStatus.WAITING + is AudioSessionState.Playing -> VoiceChromeStatus.LISTENING + is AudioSessionState.ReadyToPlay -> VoiceChromeStatus.WAITING + AudioSessionState.Idle -> VoiceChromeStatus.IDLE + } + +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 String.format(Locale.US, "%02d:%02d", 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/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/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 = {}, + ) + } +} 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 e6570fdc..bc31e60f 100644 --- a/Prezel/feature/analysis/impl/src/main/res/values/strings.xml +++ b/Prezel/feature/analysis/impl/src/main/res/values/strings.xml @@ -55,21 +55,38 @@ 제공하는 파일 형식 : m4a, mp4, mp3 음성 파일 삭제 파일 추가하기 + 조용한 공간에서 녹음을 시작해주세요. 주변 소음으로 분석 정확도가 낮아질 수 있어요. + 지금부터 발표해볼까요? + 듣고 있어요 + 일시정지됐어요 + 녹음을 확인해보세요 + 대본 + 입력된 대본이 없어요. + 마이크 권한이 필요해요. + 설정에서 마이크 권한을 허용해주세요. + 녹음을 시작하지 못했어요. 다시 시도해 주세요. + 녹음을 저장하지 못했어요. 다시 시도해 주세요. + 녹음을 재생하지 못했어요. 파일 업로드 직접 입력 분석 중 발표 음성을 분석하고 있어요 분석 리포트 화면 - 분석할 수 있는 음성 파일을 찾지 못했어요. - 다른 음성 파일로 다시 시도해 주세요. - 분석할 수 있는 텍스트 파일을 찾지 못했어요. - 다른 텍스트 파일로 다시 시도해 주세요. + 분석할 음성을 인식하지 못했어요. + 음성이 작거나 주변 소음이 많았을 수 있어요.\n조용한 환경에서 다시 녹음해 주세요. + 분석할 수 있는 음성 파일을 찾지 못했어요. + 다른 음성 파일로 다시 시도해 주세요. + 분석 중 문제가 발생했어요. + 일시적인 오류로 분석을 완료하지 못했어요.\n잠시 후 다시 시도해 주세요. 로그인이 만료되었어요. 다시 로그인해 주세요. 분석 중 오류가 발생했어요. 잠시 후 다시 시도해 주세요. + 기존 대본을 불러오지 못했어요. 다시 시도해 주세요. + 대본 파일을 불러오지 못했어요. 다시 시도해 주세요. 네트워크 연결을 확인해 주세요. 알 수 없는 오류가 발생했어요. 뒤로가기 + 닫기 다음 건너뛰기 다시 시도하기 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 93f6d055..5cf4ddaf 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 com.team.prezel.feature.practice.api.PracticeNavKey @@ -22,10 +23,10 @@ internal fun EntryProviderScope.featureHomeEntryBuilder() { navigator.navigate(PracticeNavKey(presentationId = presentationId)) }, navigateToFileUploadAnalysis = { - navigator.navigate(AnalysisNavKey.Create) + navigator.navigate(AnalysisNavKey.Schedule(startType = AnalysisStartType.FILE_UPLOAD)) }, navigateToVoiceRecordingAnalysis = { - navigator.navigate(AnalysisNavKey.Create) + navigator.navigate(AnalysisNavKey.Schedule(startType = AnalysisStartType.VOICE_RECORDING)) }, ) } 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 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/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 9e5ab1ae..8a5e3b62 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) @@ -70,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() @@ -89,12 +91,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) },