From c35256ca9d701afcf0f6444767bfc32cc3bb3019 Mon Sep 17 00:00:00 2001 From: Ham BeomJoon Date: Thu, 30 Apr 2026 21:36:18 +0900 Subject: [PATCH 01/27] =?UTF-8?q?feat:=20=EC=97=B0=EC=8A=B5=20=EB=85=B9?= =?UTF-8?q?=EC=9D=8C=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84=20=EB=B0=8F?= =?UTF-8?q?=20=ED=99=88=20=ED=99=94=EB=A9=B4=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * **feat: 연습 녹음(Practice Recording) 핵심 로직 구현** * `PracticeRecordingAudioController`: `MediaRecorder`와 `MediaPlayer`를 사용한 음성 녹음 및 재생 제어 클래스 추가. * `PracticeRecordingViewModel`: 녹음 상태(IDLE, RECORDING, RECORDED, PLAYING) 관리 및 타이머 로직 구현. * `PracticeRecordingUiState` 및 `Contract`: MVI 패턴 기반의 상태, 의향(Intent), 효과(Effect) 정의. * **feat: 연습 녹음 UI 컴포넌트 및 화면 추가** * `PracticeRecordingScreen`: 권한 체크 로직을 포함한 연습 녹음 메인 화면 구현. * `PracticeRecordingControl`: 녹음/정지/재생 상태에 따른 아이콘 및 타이머 텍스트 표시 컴포넌트 추가. * `PracticeScriptCard`: 화면 중앙에 연습용 스크립트를 표시하는 카드 컴포넌트 추가. * `strings.xml`: 연습 녹음 관련 타이틀, 안내 문구 및 랜덤 연습용 스크립트 배열 추가. * **feat: 홈 화면 연동 및 네비게이션 설정** * `HomeScreen`: 바텀 시트 내 '연습하기' 버튼을 추가하여 연습 녹음 화면으로의 네비게이션 연동. * `PresentationSheet` & `EmptyPresentationSheet`: '연습하기' 버튼 UI 및 클릭 이벤트 리스너 추가. * `HomeEntryBuilder`: `PracticeRecordingNavKey`를 통한 화면 진입점 정의. * `PrezelAppState`: 앱 시작 화면을 `SplashNavKey`에서 `HomeNavKey`로 임시 변경. * **build: 권한 설정 추가** * `AndroidManifest.xml`: 오디오 녹음을 위한 `RECORD_AUDIO` 권한 추가. --- Prezel/app/src/main/AndroidManifest.xml | 1 + .../java/com/team/prezel/ui/PrezelAppState.kt | 4 +- .../prezel/feature/home/impl/HomeScreen.kt | 32 ++- .../component/body/EmptyPresentationSheet.kt | 14 +- .../impl/component/body/PresentationSheet.kt | 20 +- .../home/impl/navigation/HomeEntryBuilder.kt | 8 + .../navigation/PracticeRecordingNavKey.kt | 7 + .../PracticeRecordingAudioController.kt | 102 +++++++++ .../impl/practice/PracticeRecordingScreen.kt | 201 ++++++++++++++++++ .../practice/PracticeRecordingViewModel.kt | 122 +++++++++++ .../component/PracticeRecordingButtonArea.kt | 24 +++ .../component/PracticeRecordingContent.kt | 53 +++++ .../component/PracticeRecordingControl.kt | 134 ++++++++++++ .../practice/component/PracticeScriptCard.kt | 34 +++ .../contract/PracticeRecordingUiEffect.kt | 5 + .../contract/PracticeRecordingUiIntent.kt | 7 + .../contract/PracticeRecordingUiState.kt | 44 ++++ .../home/impl/src/main/res/values/strings.xml | 16 ++ 18 files changed, 819 insertions(+), 9 deletions(-) create mode 100644 Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/navigation/PracticeRecordingNavKey.kt create mode 100644 Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/PracticeRecordingAudioController.kt create mode 100644 Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/PracticeRecordingScreen.kt create mode 100644 Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/PracticeRecordingViewModel.kt create mode 100644 Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/component/PracticeRecordingButtonArea.kt create mode 100644 Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/component/PracticeRecordingContent.kt create mode 100644 Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/component/PracticeRecordingControl.kt create mode 100644 Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/component/PracticeScriptCard.kt create mode 100644 Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/contract/PracticeRecordingUiEffect.kt create mode 100644 Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/contract/PracticeRecordingUiIntent.kt create mode 100644 Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/contract/PracticeRecordingUiState.kt diff --git a/Prezel/app/src/main/AndroidManifest.xml b/Prezel/app/src/main/AndroidManifest.xml index 246b5326..4a169428 100644 --- a/Prezel/app/src/main/AndroidManifest.xml +++ b/Prezel/app/src/main/AndroidManifest.xml @@ -2,6 +2,7 @@ + Unit, + onClickPracticeRecording: () -> Unit, onClickAnalyzePresentation: (PresentationUiModel) -> Unit, onClickWriteFeedback: (PresentationUiModel) -> Unit, modifier: Modifier = Modifier, @@ -99,6 +104,7 @@ private fun HomeScreen( maxHeight = maxScreenHeight, headerHeight = headerHeight, onClickAddPresentation = onClickAddPresentation, + onClickPracticeRecording = onClickPracticeRecording, onClickAnalyzePresentation = onClickAnalyzePresentation, onClickWriteFeedback = onClickWriteFeedback, ) @@ -120,6 +126,7 @@ private fun HomeContent( maxHeight: Dp, headerHeight: Dp, onClickAddPresentation: () -> Unit, + onClickPracticeRecording: () -> Unit, onClickAnalyzePresentation: (PresentationUiModel) -> Unit, onClickWriteFeedback: (PresentationUiModel) -> Unit, ) { @@ -131,6 +138,7 @@ private fun HomeContent( headerHeight = headerHeight, uiState = uiState, onClickAddPresentation = onClickAddPresentation, + onClickPracticeRecording = onClickPracticeRecording, ) } @@ -139,6 +147,7 @@ private fun HomeContent( uiState = uiState, maxHeight = maxHeight, headerHeight = headerHeight, + onClickPracticeRecording = onClickPracticeRecording, onClickAnalyzePresentation = onClickAnalyzePresentation, onClickWriteFeedback = onClickWriteFeedback, ) @@ -150,6 +159,7 @@ private fun HomeContent( pagerState = pagerState, maxHeight = maxHeight, headerHeight = headerHeight, + onClickPracticeRecording = onClickPracticeRecording, onClickAnalyzePresentation = onClickAnalyzePresentation, onClickWriteFeedback = onClickWriteFeedback, ) @@ -163,11 +173,12 @@ private fun HomeEmptyContent( headerHeight: Dp, uiState: HomeUiState.Empty, onClickAddPresentation: () -> Unit, + onClickPracticeRecording: () -> Unit, ) { HomePageLayout( maxHeight = maxHeight, headerHeight = headerHeight, - sheetContent = { EmptyPresentationSheet() }, + sheetContent = { EmptyPresentationSheet(onClickPracticeRecording = onClickPracticeRecording) }, heroContent = { EmptyPresentationHero( nickname = uiState.nickname, @@ -182,6 +193,7 @@ private fun HomeSingleContent( uiState: HomeUiState.SingleContent, maxHeight: Dp, headerHeight: Dp, + onClickPracticeRecording: () -> Unit, onClickAnalyzePresentation: (PresentationUiModel) -> Unit, onClickWriteFeedback: (PresentationUiModel) -> Unit, ) { @@ -190,7 +202,12 @@ private fun HomeSingleContent( HomePageLayout( maxHeight = maxHeight, headerHeight = headerHeight, - sheetContent = { PresentationSheet(practiceCount = presentation.practiceCount) }, + sheetContent = { + PresentationSheet( + practiceCount = presentation.practiceCount, + onClickPracticeRecording = onClickPracticeRecording, + ) + }, heroContent = { PresentationHero( presentation = presentation, @@ -207,6 +224,7 @@ private fun HomeMultipleContent( pagerState: PagerState, maxHeight: Dp, headerHeight: Dp, + onClickPracticeRecording: () -> Unit, onClickAnalyzePresentation: (PresentationUiModel) -> Unit, onClickWriteFeedback: (PresentationUiModel) -> Unit, ) { @@ -222,7 +240,12 @@ private fun HomeMultipleContent( HomePageLayout( maxHeight = maxHeight, headerHeight = headerHeight, - sheetContent = { PresentationSheet(practiceCount = presentation.practiceCount) }, + sheetContent = { + PresentationSheet( + practiceCount = presentation.practiceCount, + onClickPracticeRecording = onClickPracticeRecording, + ) + }, heroContent = { PresentationHero( presentation = presentation, @@ -243,6 +266,7 @@ private fun HomeScreenEmptyPreview() { uiState = uiState, pagerState = rememberPagerState(0) { uiState.presentationCount() }, onClickAddPresentation = { }, + onClickPracticeRecording = { }, onClickAnalyzePresentation = { }, onClickWriteFeedback = { }, ) @@ -266,6 +290,7 @@ private fun HomeScreenSinglePreview() { uiState = uiState, pagerState = rememberPagerState(0) { uiState.presentationCount() }, onClickAddPresentation = { }, + onClickPracticeRecording = { }, onClickAnalyzePresentation = { }, onClickWriteFeedback = { }, ) @@ -291,6 +316,7 @@ private fun HomeScreenMultiplePreview() { uiState = uiState, pagerState = rememberPagerState(0) { uiState.presentationCount() }, onClickAddPresentation = { }, + onClickPracticeRecording = { }, onClickAnalyzePresentation = { }, onClickWriteFeedback = { }, ) diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/component/body/EmptyPresentationSheet.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/component/body/EmptyPresentationSheet.kt index 30b5be30..8ab681f2 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/component/body/EmptyPresentationSheet.kt +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/component/body/EmptyPresentationSheet.kt @@ -2,23 +2,33 @@ package com.team.prezel.feature.home.impl.component.body import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import com.team.prezel.core.designsystem.component.actions.button.PrezelButton import com.team.prezel.core.designsystem.preview.BasicPreview import com.team.prezel.core.designsystem.theme.PrezelTheme import com.team.prezel.feature.home.impl.R @Composable -internal fun EmptyPresentationSheet(modifier: Modifier = Modifier) { +internal fun EmptyPresentationSheet( + onClickPracticeRecording: () -> Unit, + modifier: Modifier = Modifier, +) { HomeBottomSheetContent( modifier = modifier, contentPadding = PaddingValues(vertical = PrezelTheme.spacing.V32, horizontal = PrezelTheme.spacing.V20), ) { HomeBottomSheetTitle(title = stringResource(R.string.feature_home_impl_bottom_sheet_empty_title)) + Spacer(modifier = Modifier.height(12.dp)) + PrezelButton( + text = stringResource(R.string.feature_home_impl_practice_recording_action), + onClick = onClickPracticeRecording, + ) } } @@ -31,7 +41,7 @@ private fun EmptyPresentationContentPreview() { .height(100.dp) .padding(top = 16.dp), ) { - EmptyPresentationSheet() + EmptyPresentationSheet(onClickPracticeRecording = {}) } } } diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/component/body/PresentationSheet.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/component/body/PresentationSheet.kt index dd8f59e3..590de8e0 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/component/body/PresentationSheet.kt +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/component/body/PresentationSheet.kt @@ -1,12 +1,15 @@ package com.team.prezel.feature.home.impl.component.body import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import com.team.prezel.core.designsystem.component.actions.button.PrezelButton import com.team.prezel.core.designsystem.preview.BasicPreview import com.team.prezel.core.designsystem.theme.PrezelTheme import com.team.prezel.core.model.presentation.Category @@ -17,15 +20,25 @@ import kotlinx.datetime.LocalDate @Composable internal fun PresentationSheet( practiceCount: Int, + onClickPracticeRecording: () -> Unit, modifier: Modifier = Modifier, ) { val itemModifier = Modifier.padding(horizontal = PrezelTheme.spacing.V20) - HomeBottomSheetContent(modifier = modifier) { + HomeBottomSheetContent( + modifier = modifier, + contentPadding = PaddingValues(vertical = PrezelTheme.spacing.V32), + ) { HomeBottomSheetTitle( title = stringResource(R.string.feature_home_impl_bottom_sheet_content_title, practiceCount), modifier = itemModifier, ) + Spacer(modifier = Modifier.height(PrezelTheme.spacing.V12)) + PrezelButton( + text = stringResource(R.string.feature_home_impl_practice_recording_action), + modifier = itemModifier, + onClick = onClickPracticeRecording, + ) } } @@ -47,7 +60,10 @@ private fun PresentationContentPreview() { .height(100.dp) .padding(top = 16.dp), ) { - PresentationSheet(practiceCount = presentation.practiceCount) + PresentationSheet( + practiceCount = presentation.practiceCount, + onClickPracticeRecording = {}, + ) } } } 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 d13244ef..668b7005 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 @@ -2,8 +2,10 @@ package com.team.prezel.feature.home.impl.navigation import androidx.navigation3.runtime.EntryProviderScope import androidx.navigation3.runtime.NavKey +import com.team.prezel.core.navigation.LocalNavigator import com.team.prezel.feature.home.api.HomeNavKey import com.team.prezel.feature.home.impl.HomeScreen +import com.team.prezel.feature.home.impl.practice.PracticeRecordingScreen import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -14,6 +16,12 @@ internal fun EntryProviderScope.featureHomeEntryBuilder() { entry { HomeScreen() } + + entry { + val navigator = LocalNavigator.current + + PracticeRecordingScreen(onBack = navigator::goBack) + } } @Module diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/navigation/PracticeRecordingNavKey.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/navigation/PracticeRecordingNavKey.kt new file mode 100644 index 00000000..8e95d3a9 --- /dev/null +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/navigation/PracticeRecordingNavKey.kt @@ -0,0 +1,7 @@ +package com.team.prezel.feature.home.impl.navigation + +import androidx.navigation3.runtime.NavKey +import kotlinx.serialization.Serializable + +@Serializable +internal data object PracticeRecordingNavKey : NavKey diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/PracticeRecordingAudioController.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/PracticeRecordingAudioController.kt new file mode 100644 index 00000000..9c417ce7 --- /dev/null +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/PracticeRecordingAudioController.kt @@ -0,0 +1,102 @@ +package com.team.prezel.feature.home.impl.practice + +import android.content.Context +import android.media.MediaPlayer +import android.media.MediaRecorder +import android.os.Build +import dagger.hilt.android.qualifiers.ApplicationContext +import java.io.File +import javax.inject.Inject +import kotlin.math.max + +internal class PracticeRecordingAudioController( + private val context: Context, +) { + private var recorder: MediaRecorder? = null + private var player: MediaPlayer? = null + private var recordingStartedAt: Long = 0L + private var recordingFile: File? = null + + fun startRecording(): String { + stopPlayback() + releaseRecorder() + + val file = File.createTempFile("practice_recording_", ".m4a", context.cacheDir) + val newRecorder = createMediaRecorder().apply { + setAudioSource(MediaRecorder.AudioSource.MIC) + setOutputFormat(MediaRecorder.OutputFormat.MPEG_4) + setAudioEncoder(MediaRecorder.AudioEncoder.AAC) + setOutputFile(file.absolutePath) + prepare() + start() + } + + recorder = newRecorder + recordingFile = file + recordingStartedAt = System.currentTimeMillis() + return file.absolutePath + } + + fun stopRecording(): Int { + val durationSeconds = ((System.currentTimeMillis() - recordingStartedAt) / 1_000L).toInt() + + recorder?.runCatching { stop() } + releaseRecorder() + + return max(durationSeconds, 0) + } + + fun startPlayback( + filePath: String, + onComplete: () -> Unit, + ): Int { + stopPlayback() + + val newPlayer = MediaPlayer().apply { + setDataSource(filePath) + prepare() + setOnCompletionListener { + stopPlayback() + onComplete() + } + start() + } + + player = newPlayer + return newPlayer.duration.toSeconds() + } + + fun stopPlayback() { + player?.runCatching { stop() } + player?.release() + player = null + } + + fun playbackPositionSeconds(): Int = player?.currentPosition?.toSeconds() ?: 0 + + fun release() { + releaseRecorder() + stopPlayback() + } + + @Suppress("DEPRECATION") + private fun createMediaRecorder(): MediaRecorder = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + MediaRecorder(context) + } else { + MediaRecorder() + } + + private fun releaseRecorder() { + recorder?.release() + recorder = null + } +} + +internal class PracticeRecordingAudioControllerFactory @Inject constructor( + @param:ApplicationContext private val context: Context, +) { + fun create(): PracticeRecordingAudioController = PracticeRecordingAudioController(context) +} + +private fun Int.toSeconds(): Int = this / 1_000 diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/PracticeRecordingScreen.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/PracticeRecordingScreen.kt new file mode 100644 index 00000000..7e27d6b6 --- /dev/null +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/PracticeRecordingScreen.kt @@ -0,0 +1,201 @@ +package com.team.prezel.feature.home.impl.practice + +import android.Manifest +import android.content.pm.PackageManager +import androidx.activity.compose.BackHandler +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.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.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringArrayResource +import androidx.compose.ui.res.stringResource +import androidx.core.content.ContextCompat +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.team.prezel.core.designsystem.component.PrezelTopAppBar +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.home.impl.R +import com.team.prezel.feature.home.impl.practice.component.PracticeRecordingButtonArea +import com.team.prezel.feature.home.impl.practice.component.PracticeRecordingContent +import com.team.prezel.feature.home.impl.practice.component.PracticeRecordingControlState +import com.team.prezel.feature.home.impl.practice.contract.PracticeRecordingPhase +import com.team.prezel.feature.home.impl.practice.contract.PracticeRecordingUiIntent +import com.team.prezel.feature.home.impl.practice.contract.PracticeRecordingUiState + +@Composable +internal fun PracticeRecordingScreen( + onBack: () -> Unit, + modifier: Modifier = Modifier, + viewModel: PracticeRecordingViewModel = hiltViewModel(), +) { + val context = LocalContext.current + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + var hasRecordAudioPermission by remember { + mutableStateOf( + ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED, + ) + } + val scripts = stringArrayResource(R.array.feature_home_impl_practice_recording_scripts) + val practiceScript = remember { scripts.random() } + + val recordAudioPermissionLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestPermission(), + ) { isGranted -> + hasRecordAudioPermission = isGranted + if (isGranted) viewModel.onIntent(PracticeRecordingUiIntent.ClickControl) + } + + fun onClickRecordingControl() { + when (uiState.phase) { + PracticeRecordingPhase.IDLE -> { + if (hasRecordAudioPermission) { + viewModel.onIntent(PracticeRecordingUiIntent.ClickControl) + } else { + recordAudioPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO) + } + } + + PracticeRecordingPhase.RECORDING, + PracticeRecordingPhase.RECORDED, + PracticeRecordingPhase.PLAYING, + -> viewModel.onIntent(PracticeRecordingUiIntent.ClickControl) + } + } + + PracticeRecordingScreen( + uiState = uiState, + practiceScript = practiceScript, + onClickControl = ::onClickRecordingControl, + onClickAnalyze = {}, + onBack = onBack, + modifier = modifier, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun PracticeRecordingScreen( + uiState: PracticeRecordingUiState, + practiceScript: String, + onClickControl: () -> Unit, + onClickAnalyze: () -> Unit, + onBack: () -> Unit, + modifier: Modifier = Modifier, +) { + BackHandler(onBack = onBack) + + Column( + modifier = modifier + .fillMaxSize() + .background(PrezelTheme.colors.bgRegular), + ) { + PrezelTopAppBar( + title = { Text(text = stringResource(R.string.feature_home_impl_practice_recording_title)) }, + leadingIcon = { + IconButton(onClick = onBack) { + Icon( + painter = painterResource(PrezelIcons.ArrowLeft), + contentDescription = stringResource(R.string.feature_home_impl_practice_recording_back), + ) + } + }, + ) + + PracticeRecordingContent( + practiceScript = practiceScript, + currentSeconds = uiState.currentSeconds, + totalSeconds = uiState.totalSeconds, + controlState = uiState.phase.toControlState(), + onClickControl = onClickControl, + modifier = Modifier.weight(1f), + ) + + PracticeRecordingButtonArea( + enabled = uiState.analyzeEnabled, + onClickAnalyze = onClickAnalyze, + ) + } +} + +private fun PracticeRecordingPhase.toControlState(): PracticeRecordingControlState = + when (this) { + PracticeRecordingPhase.IDLE -> PracticeRecordingControlState.READY_TO_RECORD + PracticeRecordingPhase.RECORDING -> PracticeRecordingControlState.RECORDING + PracticeRecordingPhase.RECORDED -> PracticeRecordingControlState.READY_TO_PLAY + PracticeRecordingPhase.PLAYING -> PracticeRecordingControlState.PLAYING + } + +@BasicPreview +@Composable +private fun PracticeRecordingScreenIdlePreview() { + PrezelTheme { + PracticeRecordingScreenPreviewContent(uiState = PracticeRecordingUiState()) + } +} + +@BasicPreview +@Composable +private fun PracticeRecordingScreenRecordingPreview() { + PrezelTheme { + PracticeRecordingScreenPreviewContent( + uiState = PracticeRecordingUiState( + phase = PracticeRecordingPhase.RECORDING, + recordingSeconds = 12, + ), + ) + } +} + +@BasicPreview +@Composable +private fun PracticeRecordingScreenRecordedPreview() { + PrezelTheme { + PracticeRecordingScreenPreviewContent( + uiState = PracticeRecordingUiState( + phase = PracticeRecordingPhase.RECORDED, + playbackSeconds = 0, + recordedDurationSeconds = 32, + ), + ) + } +} + +@BasicPreview +@Composable +private fun PracticeRecordingScreenPlayingPreview() { + PrezelTheme { + PracticeRecordingScreenPreviewContent( + uiState = PracticeRecordingUiState( + phase = PracticeRecordingPhase.PLAYING, + playbackSeconds = 12, + recordedDurationSeconds = 32, + ), + ) + } +} + +@Composable +private fun PracticeRecordingScreenPreviewContent(uiState: PracticeRecordingUiState) { + PracticeRecordingScreen( + uiState = uiState, + practiceScript = "내가 그린 기린 그림은 잘 그린 기린 그림이고,\n네가 그린 기린 그림은 잘못 그린 기린 그림이다.", + onClickControl = {}, + onClickAnalyze = {}, + onBack = {}, + ) +} diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/PracticeRecordingViewModel.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/PracticeRecordingViewModel.kt new file mode 100644 index 00000000..92ca8bb6 --- /dev/null +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/PracticeRecordingViewModel.kt @@ -0,0 +1,122 @@ +package com.team.prezel.feature.home.impl.practice + +import androidx.lifecycle.viewModelScope +import com.team.prezel.core.ui.base.BaseViewModel +import com.team.prezel.feature.home.impl.practice.contract.PracticeRecordingPhase +import com.team.prezel.feature.home.impl.practice.contract.PracticeRecordingUiEffect +import com.team.prezel.feature.home.impl.practice.contract.PracticeRecordingUiIntent +import com.team.prezel.feature.home.impl.practice.contract.PracticeRecordingUiState +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +internal class PracticeRecordingViewModel @Inject constructor( + audioControllerFactory: PracticeRecordingAudioControllerFactory, +) : BaseViewModel( + PracticeRecordingUiState(), + ) { + private val audioController = audioControllerFactory.create() + private var recordingFilePath: String? = null + private var timerJob: Job? = null + + override fun onIntent(intent: PracticeRecordingUiIntent) { + when (intent) { + PracticeRecordingUiIntent.ClickControl -> onClickControl() + } + } + + private fun onClickControl() { + when (currentState.phase) { + PracticeRecordingPhase.IDLE -> startRecording() + PracticeRecordingPhase.RECORDING -> stopRecording() + PracticeRecordingPhase.RECORDED -> startPlayback() + PracticeRecordingPhase.PLAYING -> stopPlayback() + } + } + + private fun startRecording() { + recordingFilePath = audioController.startRecording() + updateState { + copy( + phase = PracticeRecordingPhase.RECORDING, + recordingSeconds = 0, + playbackSeconds = 0, + recordedDurationSeconds = 0, + ) + } + startRecordingTimer() + } + + private fun stopRecording() { + val durationSeconds = audioController.stopRecording() + timerJob?.cancel() + updateState { + copy( + phase = PracticeRecordingPhase.RECORDED, + recordedDurationSeconds = durationSeconds.coerceAtLeast(recordingSeconds), + playbackSeconds = 0, + ) + } + } + + private fun startPlayback() { + val filePath = recordingFilePath ?: return + val durationSeconds = audioController.startPlayback(filePath) { + timerJob?.cancel() + updateState { + copy( + phase = PracticeRecordingPhase.RECORDED, + playbackSeconds = recordedDurationSeconds, + ) + } + } + updateState { + copy( + phase = PracticeRecordingPhase.PLAYING, + recordedDurationSeconds = durationSeconds.coerceAtLeast(recordedDurationSeconds), + playbackSeconds = 0, + ) + } + startPlaybackTimer() + } + + private fun stopPlayback() { + audioController.stopPlayback() + timerJob?.cancel() + updateState { + copy( + phase = PracticeRecordingPhase.RECORDED, + playbackSeconds = 0, + ) + } + } + + private fun startRecordingTimer() { + timerJob?.cancel() + timerJob = viewModelScope.launch { + while (true) { + delay(1_000) + updateState { copy(recordingSeconds = recordingSeconds + 1) } + } + } + } + + private fun startPlaybackTimer() { + timerJob?.cancel() + timerJob = viewModelScope.launch { + while (true) { + delay(250) + updateState { copy(playbackSeconds = audioController.playbackPositionSeconds()) } + } + } + } + + override fun onCleared() { + timerJob?.cancel() + audioController.release() + super.onCleared() + } +} diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/component/PracticeRecordingButtonArea.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/component/PracticeRecordingButtonArea.kt new file mode 100644 index 00000000..7a4bedab --- /dev/null +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/component/PracticeRecordingButtonArea.kt @@ -0,0 +1,24 @@ +package com.team.prezel.feature.home.impl.practice.component + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import com.team.prezel.core.designsystem.component.actions.area.PrezelButtonArea +import com.team.prezel.feature.home.impl.R + +@Composable +internal fun PracticeRecordingButtonArea( + enabled: Boolean, + onClickAnalyze: () -> Unit, + modifier: Modifier = Modifier, +) { + val analyzeLabel = stringResource(R.string.feature_home_impl_practice_recording_analyze) + + PrezelButtonArea(modifier = modifier) { + MainButton( + label = analyzeLabel, + enabled = enabled, + onClick = onClickAnalyze, + ) + } +} diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/component/PracticeRecordingContent.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/component/PracticeRecordingContent.kt new file mode 100644 index 00000000..de0a4a5c --- /dev/null +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/component/PracticeRecordingContent.kt @@ -0,0 +1,53 @@ +package com.team.prezel.feature.home.impl.practice.component + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import com.team.prezel.core.designsystem.theme.PrezelTheme +import com.team.prezel.feature.home.impl.R + +@Composable +internal fun PracticeRecordingContent( + practiceScript: String, + currentSeconds: Int, + totalSeconds: Int, + controlState: PracticeRecordingControlState, + onClickControl: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = PrezelTheme.spacing.V20, vertical = PrezelTheme.spacing.V16), + ) { + Text( + text = stringResource(R.string.feature_home_impl_practice_recording_instruction), + style = PrezelTheme.typography.title2Bold, + color = PrezelTheme.colors.textLarge, + ) + + Spacer(modifier = Modifier.height(PrezelTheme.spacing.V32)) + + PracticeScriptCard( + text = practiceScript, + modifier = Modifier + .fillMaxWidth() + .weight(1f), + ) + + Spacer(modifier = Modifier.height(PrezelTheme.spacing.V32)) + + PracticeRecordingControl( + currentSeconds = currentSeconds, + totalSeconds = totalSeconds, + state = controlState, + onClickControl = onClickControl, + ) + } +} diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/component/PracticeRecordingControl.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/component/PracticeRecordingControl.kt new file mode 100644 index 00000000..9f3f03f7 --- /dev/null +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/component/PracticeRecordingControl.kt @@ -0,0 +1,134 @@ +package com.team.prezel.feature.home.impl.practice.component + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.dp +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 + +internal enum class PracticeRecordingControlState { + READY_TO_RECORD, + RECORDING, + READY_TO_PLAY, + PLAYING, +} + +@Composable +internal fun PracticeRecordingControl( + currentSeconds: Int, + totalSeconds: Int, + state: PracticeRecordingControlState, + onClickControl: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + PracticeRecordingTimeText( + currentSeconds = currentSeconds, + totalSeconds = totalSeconds, + state = state, + ) + + PrezelIconButton( + iconResId = state.iconResId, + modifier = Modifier.size(48.dp), + isRounded = true, + buttonDefault = PrezelButtonDefaults.getDefault( + isIconOnly = true, + isRounded = true, + type = ButtonType.FILLED, + size = ButtonSize.REGULAR, + hierarchy = ButtonHierarchy.SECONDARY, + contentColor = state.iconColor(), + backgroundColor = PrezelTheme.colors.bgLarge, + iconSize = 20.dp, + ), + onClick = onClickControl, + ) + } +} + +@Composable +private fun PracticeRecordingTimeText( + currentSeconds: Int, + totalSeconds: Int, + state: PracticeRecordingControlState, +) { + if (state != PracticeRecordingControlState.PLAYING) { + Text( + text = state + .displaySeconds( + currentSeconds = currentSeconds, + totalSeconds = totalSeconds, + ).toTimerText(), + style = PrezelTheme.typography.title1Medium, + color = PrezelTheme.colors.textMedium, + ) + return + } + + val currentColor = PrezelTheme.colors.interactiveRegular + val totalColor = PrezelTheme.colors.textSmall + + Text( + text = buildAnnotatedString { + withStyle(SpanStyle(color = currentColor)) { + append(currentSeconds.toTimerText()) + } + withStyle(SpanStyle(color = totalColor)) { + append("/") + append(totalSeconds.toTimerText()) + } + }, + style = PrezelTheme.typography.title1Medium, + ) +} + +private val PracticeRecordingControlState.iconResId: Int + get() = when (this) { + PracticeRecordingControlState.READY_TO_RECORD -> PrezelIcons.Recording + PracticeRecordingControlState.RECORDING -> PrezelIcons.Stop + PracticeRecordingControlState.READY_TO_PLAY -> PrezelIcons.Play + PracticeRecordingControlState.PLAYING -> PrezelIcons.Stop + } + +@Composable +private fun PracticeRecordingControlState.iconColor() = + when (this) { + PracticeRecordingControlState.READY_TO_RECORD -> PrezelTheme.colors.feedbackBadRegular + PracticeRecordingControlState.RECORDING -> PrezelTheme.colors.iconRegular + PracticeRecordingControlState.READY_TO_PLAY -> PrezelTheme.colors.iconRegular + PracticeRecordingControlState.PLAYING -> PrezelTheme.colors.iconRegular + } + +private fun PracticeRecordingControlState.displaySeconds( + currentSeconds: Int, + totalSeconds: Int, +): Int = + when (this) { + PracticeRecordingControlState.READY_TO_PLAY -> totalSeconds + else -> currentSeconds + } + +private fun Int.toTimerText(): String { + val minutes = this / 60 + val seconds = this % 60 + return "%02d:%02d".format(minutes, seconds) +} diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/component/PracticeScriptCard.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/component/PracticeScriptCard.kt new file mode 100644 index 00000000..d99a3b97 --- /dev/null +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/component/PracticeScriptCard.kt @@ -0,0 +1,34 @@ +package com.team.prezel.feature.home.impl.practice.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.style.TextAlign +import com.team.prezel.core.designsystem.theme.PrezelTheme + +@Composable +internal fun PracticeScriptCard( + text: String, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier + .clip(RoundedCornerShape(PrezelTheme.radius.V6)) + .background(PrezelTheme.colors.bgMedium) + .padding(horizontal = PrezelTheme.spacing.V16, vertical = PrezelTheme.spacing.V12), + contentAlignment = Alignment.Center, + ) { + Text( + text = text, + style = PrezelTheme.typography.body2Regular, + color = PrezelTheme.colors.textLarge, + textAlign = TextAlign.Center, + ) + } +} diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/contract/PracticeRecordingUiEffect.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/contract/PracticeRecordingUiEffect.kt new file mode 100644 index 00000000..e48a4547 --- /dev/null +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/contract/PracticeRecordingUiEffect.kt @@ -0,0 +1,5 @@ +package com.team.prezel.feature.home.impl.practice.contract + +import com.team.prezel.core.ui.base.UiEffect + +internal sealed interface PracticeRecordingUiEffect : UiEffect diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/contract/PracticeRecordingUiIntent.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/contract/PracticeRecordingUiIntent.kt new file mode 100644 index 00000000..c31ec34e --- /dev/null +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/contract/PracticeRecordingUiIntent.kt @@ -0,0 +1,7 @@ +package com.team.prezel.feature.home.impl.practice.contract + +import com.team.prezel.core.ui.base.UiIntent + +internal sealed interface PracticeRecordingUiIntent : UiIntent { + data object ClickControl : PracticeRecordingUiIntent +} diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/contract/PracticeRecordingUiState.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/contract/PracticeRecordingUiState.kt new file mode 100644 index 00000000..cccaf21d --- /dev/null +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/contract/PracticeRecordingUiState.kt @@ -0,0 +1,44 @@ +package com.team.prezel.feature.home.impl.practice.contract + +import androidx.compose.runtime.Immutable +import com.team.prezel.core.ui.base.UiState + +internal enum class PracticeRecordingPhase { + IDLE, + RECORDING, + RECORDED, + PLAYING, +} + +@Immutable +internal data class PracticeRecordingUiState( + val phase: PracticeRecordingPhase = PracticeRecordingPhase.IDLE, + val recordingSeconds: Int = 0, + val playbackSeconds: Int = 0, + val recordedDurationSeconds: Int = 0, +) : UiState { + val currentSeconds: Int + get() = when (phase) { + PracticeRecordingPhase.IDLE, + PracticeRecordingPhase.RECORDING, + -> recordingSeconds + + PracticeRecordingPhase.RECORDED, + PracticeRecordingPhase.PLAYING, + -> playbackSeconds + } + + val totalSeconds: Int + get() = when (phase) { + PracticeRecordingPhase.IDLE, + PracticeRecordingPhase.RECORDING, + -> 0 + + PracticeRecordingPhase.RECORDED, + PracticeRecordingPhase.PLAYING, + -> recordedDurationSeconds + } + + val analyzeEnabled: Boolean + get() = recordedDurationSeconds > 0 && phase != PracticeRecordingPhase.RECORDING +} diff --git a/Prezel/feature/home/impl/src/main/res/values/strings.xml b/Prezel/feature/home/impl/src/main/res/values/strings.xml index 140c2c8b..150a019d 100644 --- a/Prezel/feature/home/impl/src/main/res/values/strings.xml +++ b/Prezel/feature/home/impl/src/main/res/values/strings.xml @@ -17,6 +17,22 @@ 행사·공개 학술·교육 업무·보고 + + + 연습하기 + 연습 녹음 + 뒤로가기 + 아래 문장을 소리내어 읽어주세요. + 분석하기 + + 내가 그린 기린 그림은 잘 그린 기린 그림이고,\n네가 그린 기린 그림은 잘못 그린 기린 그림이다. + 간장 공장 공장장은 강 공장장이고,\n된장 공장 공장장은 공 공장장이다. + 저기 있는 말뚝이 말 맬 말뚝이냐,\n말 못 맬 말뚝이냐. + 서울특별시 특허허가과 허가과장 허 과장. + 신진 샹송 가수의 신춘 샹송 쇼. + 작년에 온 솥 장수는 새 솥 장수이고,\n금년에 온 솥 장수는 헌 솥 장수이다. + 상표 붙인 큰 깡통은 깐 깡통인가,\n안 깐 깡통인가. + 데이터를 불러오지 못했습니다. From ce7bbbdbd6beca08205530e6e349a2ba87dd0be6 Mon Sep 17 00:00:00 2001 From: Ham BeomJoon Date: Thu, 30 Apr 2026 22:39:29 +0900 Subject: [PATCH 02/27] =?UTF-8?q?feat:=20=EC=97=B0=EC=8A=B5=20=EB=85=B9?= =?UTF-8?q?=EC=9D=8C=20=EB=B6=84=EC=84=9D=20=EA=B2=B0=EA=B3=BC=20=EB=B0=8F?= =?UTF-8?q?=20=EC=83=81=ED=83=9C(Loading/Error/Success)=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 연습 녹음 분석 단계별 UI 컴포넌트 추가 * `PracticeRecordingAnalysisLoadingPage`: 분석 중임을 나타내는 Lottie 애니메이션 및 문구 표시 * `PracticeRecordingAnalysisErrorPage`: 분석 실패 또는 음성 인식 오류 시 재시도 안내 화면 구현 * `PracticeRecordingAnalysisSuccessPage`: 발음 점수 및 속도 분석 결과를 카드 형태의 시각적 요소와 함께 표시 * feat: `PracticeRecordingViewModel` 및 `UiState` 내 분석 로직 연동 * `PracticeRecordingAnalysisStatus`: 분석 상태(Ready, Loading, Success, Error)를 관리하는 Sealed Interface 추가 * `ClickAnalyze` 인텐트 처리 및 가상의 로딩 지연(3초) 후 결과 화면 전환 로직 구현 * refactor: `PracticeRecordingScreen` 구조 개선 * 분석 상태에 따라 상단 바와 메인 컨텐츠 영역이 전환되도록 화면 흐름 분리 * 분석 완료 시 홈으로 이동할 수 있도록 `navigateToHome` 내비게이션 콜백 추가 * docs: 분석 결과 표시를 위한 리소스 추가 * 성공 결과 등급별 카드 이미지(`perfect`, `good`, `try`) 및 오류 아이콘 벡터 추가 * 분석 관련 다국어 문자열(발화 점수, 속도 상태 등) 추가 --- .../res/drawable/core_ui_error_analyze.xml | 42 +++ .../main/res/drawable/core_ui_error_voice.xml | 18 ++ .../home/impl/navigation/HomeEntryBuilder.kt | 5 +- .../impl/practice/PracticeRecordingScreen.kt | 105 +++++-- .../practice/PracticeRecordingViewModel.kt | 17 ++ .../PracticeRecordingAnalysisPages.kt | 105 +++++++ .../PracticeRecordingAnalysisSuccessPage.kt | 272 ++++++++++++++++++ .../contract/PracticeRecordingUiIntent.kt | 2 + .../contract/PracticeRecordingUiState.kt | 18 ++ .../drawable/feature_home_impl_card_good.xml | 56 ++++ .../feature_home_impl_card_perfect.xml | 56 ++++ .../drawable/feature_home_impl_card_try.xml | 56 ++++ .../home/impl/src/main/res/values/strings.xml | 14 + 13 files changed, 746 insertions(+), 20 deletions(-) create mode 100644 Prezel/core/ui/src/main/res/drawable/core_ui_error_analyze.xml create mode 100644 Prezel/core/ui/src/main/res/drawable/core_ui_error_voice.xml create mode 100644 Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/component/PracticeRecordingAnalysisPages.kt create mode 100644 Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/component/PracticeRecordingAnalysisSuccessPage.kt create mode 100644 Prezel/feature/home/impl/src/main/res/drawable/feature_home_impl_card_good.xml create mode 100644 Prezel/feature/home/impl/src/main/res/drawable/feature_home_impl_card_perfect.xml create mode 100644 Prezel/feature/home/impl/src/main/res/drawable/feature_home_impl_card_try.xml diff --git a/Prezel/core/ui/src/main/res/drawable/core_ui_error_analyze.xml b/Prezel/core/ui/src/main/res/drawable/core_ui_error_analyze.xml new file mode 100644 index 00000000..debd560c --- /dev/null +++ b/Prezel/core/ui/src/main/res/drawable/core_ui_error_analyze.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + diff --git a/Prezel/core/ui/src/main/res/drawable/core_ui_error_voice.xml b/Prezel/core/ui/src/main/res/drawable/core_ui_error_voice.xml new file mode 100644 index 00000000..23b46fdf --- /dev/null +++ b/Prezel/core/ui/src/main/res/drawable/core_ui_error_voice.xml @@ -0,0 +1,18 @@ + + + + + + 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 668b7005..548720c5 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 @@ -20,7 +20,10 @@ internal fun EntryProviderScope.featureHomeEntryBuilder() { entry { val navigator = LocalNavigator.current - PracticeRecordingScreen(onBack = navigator::goBack) + PracticeRecordingScreen( + onBack = navigator::goBack, + navigateToHome = { navigator.replaceRoot(HomeNavKey) }, + ) } } diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/PracticeRecordingScreen.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/PracticeRecordingScreen.kt index 7e27d6b6..91beda67 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/PracticeRecordingScreen.kt +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/PracticeRecordingScreen.kt @@ -30,9 +30,14 @@ 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.home.impl.R +import com.team.prezel.feature.home.impl.practice.component.PracticeAnalysisSpeed +import com.team.prezel.feature.home.impl.practice.component.PracticeRecordingAnalysisErrorPage +import com.team.prezel.feature.home.impl.practice.component.PracticeRecordingAnalysisLoadingPage +import com.team.prezel.feature.home.impl.practice.component.PracticeRecordingAnalysisSuccessPage import com.team.prezel.feature.home.impl.practice.component.PracticeRecordingButtonArea import com.team.prezel.feature.home.impl.practice.component.PracticeRecordingContent import com.team.prezel.feature.home.impl.practice.component.PracticeRecordingControlState +import com.team.prezel.feature.home.impl.practice.contract.PracticeRecordingAnalysisStatus import com.team.prezel.feature.home.impl.practice.contract.PracticeRecordingPhase import com.team.prezel.feature.home.impl.practice.contract.PracticeRecordingUiIntent import com.team.prezel.feature.home.impl.practice.contract.PracticeRecordingUiState @@ -40,6 +45,7 @@ import com.team.prezel.feature.home.impl.practice.contract.PracticeRecordingUiSt @Composable internal fun PracticeRecordingScreen( onBack: () -> Unit, + navigateToHome: () -> Unit, modifier: Modifier = Modifier, viewModel: PracticeRecordingViewModel = hiltViewModel(), ) { @@ -81,13 +87,13 @@ internal fun PracticeRecordingScreen( uiState = uiState, practiceScript = practiceScript, onClickControl = ::onClickRecordingControl, - onClickAnalyze = {}, + onClickAnalyze = { viewModel.onIntent(PracticeRecordingUiIntent.ClickAnalyze) }, onBack = onBack, + navigateToHome = navigateToHome, modifier = modifier, ) } -@OptIn(ExperimentalMaterial3Api::class) @Composable private fun PracticeRecordingScreen( uiState: PracticeRecordingUiState, @@ -95,27 +101,75 @@ private fun PracticeRecordingScreen( onClickControl: () -> Unit, onClickAnalyze: () -> Unit, onBack: () -> Unit, + navigateToHome: () -> Unit, modifier: Modifier = Modifier, ) { BackHandler(onBack = onBack) + if (uiState.analysisStatus != PracticeRecordingAnalysisStatus.Ready) { + PracticeRecordingAnalysisScreen( + analysisStatus = uiState.analysisStatus, + onBack = onBack, + onRetry = onClickAnalyze, + onComplete = navigateToHome, + modifier = modifier, + ) + return + } + + PracticeRecordingReadyScreen( + uiState = uiState, + practiceScript = practiceScript, + onClickControl = onClickControl, + onClickAnalyze = onClickAnalyze, + onBack = onBack, + modifier = modifier, + ) +} + +@Composable +private fun PracticeRecordingAnalysisScreen( + analysisStatus: PracticeRecordingAnalysisStatus, + onBack: () -> Unit, + onRetry: () -> Unit, + onComplete: () -> Unit, + modifier: Modifier = Modifier, +) { + when (analysisStatus) { + PracticeRecordingAnalysisStatus.Loading -> PracticeRecordingAnalysisLoadingPage(modifier = modifier) + PracticeRecordingAnalysisStatus.Success -> PracticeRecordingAnalysisSuccessPage( + pronunciationScore = 90, + speed = PracticeAnalysisSpeed.ADEQUATE, + onBack = onBack, + onComplete = onComplete, + modifier = modifier, + ) + + is PracticeRecordingAnalysisStatus.Error -> PracticeRecordingAnalysisErrorPage( + errorType = analysisStatus.type, + onRetry = onRetry, + modifier = modifier, + ) + + PracticeRecordingAnalysisStatus.Ready -> Unit + } +} + +@Composable +private fun PracticeRecordingReadyScreen( + uiState: PracticeRecordingUiState, + practiceScript: String, + onClickControl: () -> Unit, + onClickAnalyze: () -> Unit, + onBack: () -> Unit, + modifier: Modifier = Modifier, +) { Column( modifier = modifier .fillMaxSize() .background(PrezelTheme.colors.bgRegular), ) { - PrezelTopAppBar( - title = { Text(text = stringResource(R.string.feature_home_impl_practice_recording_title)) }, - leadingIcon = { - IconButton(onClick = onBack) { - Icon( - painter = painterResource(PrezelIcons.ArrowLeft), - contentDescription = stringResource(R.string.feature_home_impl_practice_recording_back), - ) - } - }, - ) - + PracticeRecordingTopAppBar(onBack = onBack) PracticeRecordingContent( practiceScript = practiceScript, currentSeconds = uiState.currentSeconds, @@ -124,14 +178,26 @@ private fun PracticeRecordingScreen( onClickControl = onClickControl, modifier = Modifier.weight(1f), ) - - PracticeRecordingButtonArea( - enabled = uiState.analyzeEnabled, - onClickAnalyze = onClickAnalyze, - ) + PracticeRecordingButtonArea(enabled = uiState.analyzeEnabled, onClickAnalyze = onClickAnalyze) } } +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun PracticeRecordingTopAppBar(onBack: () -> Unit) { + PrezelTopAppBar( + title = { Text(text = stringResource(R.string.feature_home_impl_practice_recording_title)) }, + leadingIcon = { + IconButton(onClick = onBack) { + Icon( + painter = painterResource(PrezelIcons.ArrowLeft), + contentDescription = stringResource(R.string.feature_home_impl_practice_recording_back), + ) + } + }, + ) +} + private fun PracticeRecordingPhase.toControlState(): PracticeRecordingControlState = when (this) { PracticeRecordingPhase.IDLE -> PracticeRecordingControlState.READY_TO_RECORD @@ -197,5 +263,6 @@ private fun PracticeRecordingScreenPreviewContent(uiState: PracticeRecordingUiSt onClickControl = {}, onClickAnalyze = {}, onBack = {}, + navigateToHome = {}, ) } diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/PracticeRecordingViewModel.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/PracticeRecordingViewModel.kt index 92ca8bb6..f42293fd 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/PracticeRecordingViewModel.kt +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/PracticeRecordingViewModel.kt @@ -2,6 +2,7 @@ package com.team.prezel.feature.home.impl.practice import androidx.lifecycle.viewModelScope import com.team.prezel.core.ui.base.BaseViewModel +import com.team.prezel.feature.home.impl.practice.contract.PracticeRecordingAnalysisStatus import com.team.prezel.feature.home.impl.practice.contract.PracticeRecordingPhase import com.team.prezel.feature.home.impl.practice.contract.PracticeRecordingUiEffect import com.team.prezel.feature.home.impl.practice.contract.PracticeRecordingUiIntent @@ -21,10 +22,12 @@ internal class PracticeRecordingViewModel @Inject constructor( private val audioController = audioControllerFactory.create() private var recordingFilePath: String? = null private var timerJob: Job? = null + private var analysisJob: Job? = null override fun onIntent(intent: PracticeRecordingUiIntent) { when (intent) { PracticeRecordingUiIntent.ClickControl -> onClickControl() + PracticeRecordingUiIntent.ClickAnalyze -> startAnalysis() } } @@ -94,6 +97,15 @@ internal class PracticeRecordingViewModel @Inject constructor( } } + private fun startAnalysis() { + analysisJob?.cancel() + analysisJob = viewModelScope.launch { + updateState { copy(analysisStatus = PracticeRecordingAnalysisStatus.Loading) } + delay(ANALYSIS_LOADING_DELAY_MILLIS) + updateState { copy(analysisStatus = PracticeRecordingAnalysisStatus.Success) } + } + } + private fun startRecordingTimer() { timerJob?.cancel() timerJob = viewModelScope.launch { @@ -116,7 +128,12 @@ internal class PracticeRecordingViewModel @Inject constructor( override fun onCleared() { timerJob?.cancel() + analysisJob?.cancel() audioController.release() super.onCleared() } + + private companion object { + const val ANALYSIS_LOADING_DELAY_MILLIS = 3_000L + } } diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/component/PracticeRecordingAnalysisPages.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/component/PracticeRecordingAnalysisPages.kt new file mode 100644 index 00000000..c9a134e2 --- /dev/null +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/component/PracticeRecordingAnalysisPages.kt @@ -0,0 +1,105 @@ +package com.team.prezel.feature.home.impl.practice.component + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +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.designsystem.component.actions.button.PrezelButton +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.icon.PrezelIcons +import com.team.prezel.core.designsystem.preview.BasicPreview +import com.team.prezel.core.designsystem.theme.PrezelTheme +import com.team.prezel.core.ui.component.PrezelLottie +import com.team.prezel.core.ui.component.StatusView +import com.team.prezel.feature.home.impl.R +import com.team.prezel.feature.home.impl.practice.contract.PracticeRecordingAnalysisErrorType +import com.team.prezel.core.ui.R as CoreUiR + +@Composable +internal fun PracticeRecordingAnalysisLoadingPage(modifier: Modifier = Modifier) { + StatusView( + title = stringResource(R.string.feature_home_impl_practice_recording_analysis_loading_title), + description = stringResource(R.string.feature_home_impl_practice_recording_analysis_loading_description), + modifier = modifier, + visual = { + PrezelLottie( + resId = CoreUiR.raw.core_ui_asset_loading, + modifier = Modifier.size(80.dp), + ) + }, + ) +} + +@Composable +internal fun PracticeRecordingAnalysisErrorPage( + errorType: PracticeRecordingAnalysisErrorType, + onRetry: () -> Unit, + modifier: Modifier = Modifier, +) { + StatusView( + title = stringResource(R.string.feature_home_impl_practice_recording_analysis_error_title), + description = stringResource(R.string.feature_home_impl_practice_recording_analysis_error_description), + modifier = modifier, + visual = { + Image( + painter = painterResource(errorType.drawableResId), + contentDescription = null, + modifier = Modifier.size(120.dp), + ) + }, + action = { + PrezelButton( + text = stringResource(R.string.feature_home_impl_practice_recording_analysis_retry), + iconResId = PrezelIcons.Reset, + type = ButtonType.FILLED, + size = ButtonSize.SMALL, + hierarchy = ButtonHierarchy.SECONDARY, + isRounded = true, + onClick = onRetry, + ) + }, + ) +} + +private val PracticeRecordingAnalysisErrorType.drawableResId: Int + @DrawableRes + get() = when (this) { + PracticeRecordingAnalysisErrorType.ANALYZE -> CoreUiR.drawable.core_ui_error_analyze + PracticeRecordingAnalysisErrorType.VOICE -> CoreUiR.drawable.core_ui_error_voice + } + +@BasicPreview +@Composable +private fun PracticeRecordingAnalysisLoadingPagePreview() { + PrezelTheme { + PracticeRecordingAnalysisLoadingPage() + } +} + +@BasicPreview +@Composable +private fun PracticeRecordingAnalysisAnalyzeErrorPagePreview() { + PrezelTheme { + PracticeRecordingAnalysisErrorPage( + errorType = PracticeRecordingAnalysisErrorType.ANALYZE, + onRetry = {}, + ) + } +} + +@BasicPreview +@Composable +private fun PracticeRecordingAnalysisVoiceErrorPagePreview() { + PrezelTheme { + PracticeRecordingAnalysisErrorPage( + errorType = PracticeRecordingAnalysisErrorType.VOICE, + onRetry = {}, + ) + } +} diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/component/PracticeRecordingAnalysisSuccessPage.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/component/PracticeRecordingAnalysisSuccessPage.kt new file mode 100644 index 00000000..7c652a03 --- /dev/null +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/component/PracticeRecordingAnalysisSuccessPage.kt @@ -0,0 +1,272 @@ +package com.team.prezel.feature.home.impl.practice.component + +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.team.prezel.core.designsystem.component.PrezelTopAppBar +import com.team.prezel.core.designsystem.component.actions.area.PrezelButtonArea +import com.team.prezel.core.designsystem.component.chip.PrezelChip +import com.team.prezel.core.designsystem.component.chip.config.PrezelChipDefaults +import com.team.prezel.core.designsystem.component.chip.config.PrezelChipSize +import com.team.prezel.core.designsystem.component.chip.config.PrezelChipType +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.home.impl.R + +internal enum class PracticeAnalysisSpeed( + @param:StringRes val labelResId: Int, +) { + SLOW(R.string.feature_home_impl_practice_recording_analysis_speed_slow), + ADEQUATE(R.string.feature_home_impl_practice_recording_analysis_speed_adequate), + FAST(R.string.feature_home_impl_practice_recording_analysis_speed_fast), +} + +private enum class PracticeAnalysisOverallResult( + @param:StringRes val contentDescriptionResId: Int, + @param:DrawableRes val cardResId: Int, +) { + PERFECT( + contentDescriptionResId = R.string.feature_home_impl_practice_recording_analysis_card_perfect, + cardResId = R.drawable.feature_home_impl_card_perfect, + ), + GOOD( + contentDescriptionResId = R.string.feature_home_impl_practice_recording_analysis_card_good, + cardResId = R.drawable.feature_home_impl_card_good, + ), + TRY( + contentDescriptionResId = R.string.feature_home_impl_practice_recording_analysis_card_try, + cardResId = R.drawable.feature_home_impl_card_try, + ), +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun PracticeRecordingAnalysisSuccessPage( + pronunciationScore: Int, + speed: PracticeAnalysisSpeed, + onBack: () -> Unit, + onComplete: () -> Unit, + modifier: Modifier = Modifier, +) { + val overallResult = rememberOverallResult( + pronunciationScore = pronunciationScore, + speed = speed, + ) + + Column( + modifier = modifier + .fillMaxSize() + .background(PrezelTheme.colors.bgRegular), + ) { + PrezelTopAppBar( + leadingIcon = { + IconButton(onClick = onBack) { + Icon( + painter = painterResource(PrezelIcons.ArrowLeft), + contentDescription = stringResource(R.string.feature_home_impl_practice_recording_back), + ) + } + }, + ) + + PracticeRecordingAnalysisSuccessContent( + cardResId = overallResult.cardResId, + cardContentDescription = stringResource(overallResult.contentDescriptionResId), + pronunciationScore = pronunciationScore, + speed = speed, + modifier = Modifier.weight(1f), + ) + + PracticeRecordingAnalysisSuccessButtonArea(onComplete = onComplete) + } +} + +@Composable +private fun PracticeRecordingAnalysisSuccessContent( + @DrawableRes cardResId: Int, + cardContentDescription: String, + pronunciationScore: Int, + speed: PracticeAnalysisSpeed, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Image( + painter = painterResource(cardResId), + contentDescription = cardContentDescription, + modifier = Modifier + .fillMaxWidth() + .weight(1f), + contentScale = ContentScale.Fit, + ) + + PracticeAnalysisMetricRow( + pronunciationScore = pronunciationScore, + speed = speed, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = PrezelTheme.spacing.V20, vertical = PrezelTheme.spacing.V16), + ) + } +} + +@Composable +private fun PracticeRecordingAnalysisSuccessButtonArea( + onComplete: () -> Unit, + modifier: Modifier = Modifier, +) { + val completeLabel = stringResource(R.string.feature_home_impl_practice_recording_analysis_complete) + + PrezelButtonArea(modifier = modifier) { + MainButton( + label = completeLabel, + enabled = true, + onClick = onComplete, + ) + } +} + +private fun rememberOverallResult( + pronunciationScore: Int, + speed: PracticeAnalysisSpeed, +): PracticeAnalysisOverallResult = + when { + pronunciationScore >= 95 && speed == PracticeAnalysisSpeed.ADEQUATE -> PracticeAnalysisOverallResult.PERFECT + pronunciationScore >= 70 && speed == PracticeAnalysisSpeed.ADEQUATE -> PracticeAnalysisOverallResult.GOOD + pronunciationScore >= 95 && speed != PracticeAnalysisSpeed.ADEQUATE -> PracticeAnalysisOverallResult.GOOD + pronunciationScore <= 60 -> PracticeAnalysisOverallResult.TRY + else -> PracticeAnalysisOverallResult.TRY + } + +@Composable +private fun PracticeAnalysisMetricRow( + pronunciationScore: Int, + speed: PracticeAnalysisSpeed, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + ) { + Row( + modifier = Modifier.weight(1f).padding(start = 8.dp, top = 5.dp, bottom = 5.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + PracticeAnalysisMetricLabel(text = stringResource(R.string.feature_home_impl_practice_recording_analysis_pronunciation)) + Text( + text = "$pronunciationScore%", + style = PrezelTheme.typography.body1Medium, + color = PrezelTheme.colors.textLarge, + ) + } + + Spacer(modifier = Modifier.width(PrezelTheme.spacing.V12)) + + Box( + modifier = Modifier + .width(1.dp) + .height(24.dp) + .background(PrezelTheme.colors.borderRegular), + ) + + Spacer(modifier = Modifier.width(PrezelTheme.spacing.V12)) + + Row( + modifier = Modifier.weight(1f).padding(end = 8.dp, top = 5.dp, bottom = 5.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + PracticeAnalysisMetricLabel(text = stringResource(R.string.feature_home_impl_practice_recording_analysis_speed)) + PrezelChip( + text = stringResource(speed.labelResId), + modifier = Modifier, + config = PrezelChipDefaults.getDefault( + iconOnly = false, + type = PrezelChipType.FILLED, + size = PrezelChipSize.REGULAR, + textStyle = PrezelTheme.typography.caption1Medium, + containerColor = PrezelTheme.colors.bgLarge, + textColor = PrezelTheme.colors.textMedium, + ), + ) + } + } +} + +@Composable +private fun PracticeAnalysisMetricLabel( + text: String, + modifier: Modifier = Modifier, +) { + Text( + text = text, + style = PrezelTheme.typography.caption1Medium, + color = PrezelTheme.colors.textMedium, + modifier = modifier, + ) +} + +@BasicPreview +@Composable +private fun PracticeRecordingAnalysisPerfectPagePreview() { + PrezelTheme { + PracticeRecordingAnalysisSuccessPage( + pronunciationScore = 96, + speed = PracticeAnalysisSpeed.ADEQUATE, + onBack = {}, + onComplete = {}, + ) + } +} + +@BasicPreview +@Composable +private fun PracticeRecordingAnalysisGoodPagePreview() { + PrezelTheme { + PracticeRecordingAnalysisSuccessPage( + pronunciationScore = 90, + speed = PracticeAnalysisSpeed.ADEQUATE, + onBack = {}, + onComplete = {}, + ) + } +} + +@BasicPreview +@Composable +private fun PracticeRecordingAnalysisTryPagePreview() { + PrezelTheme { + PracticeRecordingAnalysisSuccessPage( + pronunciationScore = 58, + speed = PracticeAnalysisSpeed.FAST, + onBack = {}, + onComplete = {}, + ) + } +} diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/contract/PracticeRecordingUiIntent.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/contract/PracticeRecordingUiIntent.kt index c31ec34e..a64f5f13 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/contract/PracticeRecordingUiIntent.kt +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/contract/PracticeRecordingUiIntent.kt @@ -4,4 +4,6 @@ import com.team.prezel.core.ui.base.UiIntent internal sealed interface PracticeRecordingUiIntent : UiIntent { data object ClickControl : PracticeRecordingUiIntent + + data object ClickAnalyze : PracticeRecordingUiIntent } diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/contract/PracticeRecordingUiState.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/contract/PracticeRecordingUiState.kt index cccaf21d..6bc82308 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/contract/PracticeRecordingUiState.kt +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/contract/PracticeRecordingUiState.kt @@ -10,9 +10,27 @@ internal enum class PracticeRecordingPhase { PLAYING, } +internal sealed interface PracticeRecordingAnalysisStatus { + data object Ready : PracticeRecordingAnalysisStatus + + data object Loading : PracticeRecordingAnalysisStatus + + data object Success : PracticeRecordingAnalysisStatus + + data class Error( + val type: PracticeRecordingAnalysisErrorType, + ) : PracticeRecordingAnalysisStatus +} + +internal enum class PracticeRecordingAnalysisErrorType { + ANALYZE, + VOICE, +} + @Immutable internal data class PracticeRecordingUiState( val phase: PracticeRecordingPhase = PracticeRecordingPhase.IDLE, + val analysisStatus: PracticeRecordingAnalysisStatus = PracticeRecordingAnalysisStatus.Ready, val recordingSeconds: Int = 0, val playbackSeconds: Int = 0, val recordedDurationSeconds: Int = 0, diff --git a/Prezel/feature/home/impl/src/main/res/drawable/feature_home_impl_card_good.xml b/Prezel/feature/home/impl/src/main/res/drawable/feature_home_impl_card_good.xml new file mode 100644 index 00000000..70de122f --- /dev/null +++ b/Prezel/feature/home/impl/src/main/res/drawable/feature_home_impl_card_good.xml @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/Prezel/feature/home/impl/src/main/res/drawable/feature_home_impl_card_perfect.xml b/Prezel/feature/home/impl/src/main/res/drawable/feature_home_impl_card_perfect.xml new file mode 100644 index 00000000..d1c29065 --- /dev/null +++ b/Prezel/feature/home/impl/src/main/res/drawable/feature_home_impl_card_perfect.xml @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/Prezel/feature/home/impl/src/main/res/drawable/feature_home_impl_card_try.xml b/Prezel/feature/home/impl/src/main/res/drawable/feature_home_impl_card_try.xml new file mode 100644 index 00000000..161c39cc --- /dev/null +++ b/Prezel/feature/home/impl/src/main/res/drawable/feature_home_impl_card_try.xml @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/Prezel/feature/home/impl/src/main/res/values/strings.xml b/Prezel/feature/home/impl/src/main/res/values/strings.xml index 150a019d..c402aabb 100644 --- a/Prezel/feature/home/impl/src/main/res/values/strings.xml +++ b/Prezel/feature/home/impl/src/main/res/values/strings.xml @@ -24,6 +24,20 @@ 뒤로가기 아래 문장을 소리내어 읽어주세요. 분석하기 + 분석중 + 잠시만 기다려주세요 + 분석에 실패했어요 + 음성이 작거나 주변 소음이 많았을 수 있어요.\n조용한 환경에서 다시 시도해 주세요. + 다시 시도하기 + 완료 + 발화 + 속도 + 느려요 + 적당해요 + 빨라요 + perfect + good + try 내가 그린 기린 그림은 잘 그린 기린 그림이고,\n네가 그린 기린 그림은 잘못 그린 기린 그림이다. 간장 공장 공장장은 강 공장장이고,\n된장 공장 공장장은 공 공장장이다. From 0c901bbb0d992f0c2b6961dbedc8be557b316a05 Mon Sep 17 00:00:00 2001 From: Ham BeomJoon Date: Fri, 1 May 2026 10:33:07 +0900 Subject: [PATCH 03/27] =?UTF-8?q?feat:=20=EC=97=B0=EC=8A=B5=20=EB=85=B9?= =?UTF-8?q?=EC=9D=8C=20=EA=B8=B0=EB=8A=A5=EC=9D=98=20=EC=98=88=EC=99=B8=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC=20=EA=B0=95=ED=99=94=20=EB=B0=8F=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=20=EA=B4=80=EB=A6=AC=20=EB=A6=AC=ED=8C=A9=ED=84=B0?= =?UTF-8?q?=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * **feat: 녹음 및 재생 시 예외 처리 로직 추가** * `PracticeRecordingAudioController`의 주요 메서드(`startRecording`, `stopRecording`, `startPlayback`) 반환 타입을 `Result`로 변경하여 오류 상황을 캡처하도록 개선했습니다. * 녹음 시작 실패 시 사용자에게 알림을 주기 위한 `PracticeRecordingUiMessage` 및 `PracticeRecordingUiEffect.ShowMessage`를 추가했습니다. * `LaunchedEffect`를 통해 스낵바(`showPrezelSnackbar`)로 녹음 실패 메시지를 출력하는 로직을 구현했습니다. * **refactor: `PracticeRecordingUiState` 구조 개선** * 기존 `PracticeRecordingPhase` 열거형을 제거하고, 상태별 데이터를 포함할 수 있는 `PracticeRecordingState` sealed interface로 개편했습니다. (`Idle`, `Recording`, `Recorded`, `Playing`) * 상태 전이에 따른 `currentSeconds` 및 `totalSeconds` 계산 로직을 도메인 모델 내부로 캡슐화했습니다. * **refactor: `PracticeRecordingViewModel` 로직 고도화** * `PracticeRecordingState` 개편에 맞춰 녹음/재생 제어 및 타이머 로직을 리팩터링했습니다. * 오디오 컨트롤러의 `Result` 반환값에 따라 성공/실패 처리를 명시적으로 수행하며, 실패 시 에러 상태(`PracticeRecordingAnalysisStatus.Error`)로 전환하도록 보완했습니다. * 상수를 사용하여 지연 시간(`TIMER_DELAY_MILLIS` 등)을 체계적으로 관리합니다. * **etc: 리소스 및 컴포넌트 업데이트** * 녹음 실패 관련 문자열 리소스를 추가했습니다. * `PracticeRecordingAnalysisErrorType`의 명칭을 보다 명확하게 변경했습니다 (`ANALYZE` -> `ANALYSIS_FAILED`, `VOICE` -> `VOICE_RECOGNITION_FAILED`). --- .../PracticeRecordingAudioController.kt | 95 +++++---- .../impl/practice/PracticeRecordingScreen.kt | 63 ++++-- .../practice/PracticeRecordingViewModel.kt | 201 +++++++++++++----- .../PracticeRecordingAnalysisPages.kt | 8 +- .../contract/PracticeRecordingUiEffect.kt | 7 +- .../contract/PracticeRecordingUiState.kt | 98 +++++---- .../model/PracticeRecordingUiMessage.kt | 5 + .../home/impl/src/main/res/values/strings.xml | 1 + 8 files changed, 324 insertions(+), 154 deletions(-) create mode 100644 Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/model/PracticeRecordingUiMessage.kt diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/PracticeRecordingAudioController.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/PracticeRecordingAudioController.kt index 9c417ce7..a597b770 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/PracticeRecordingAudioController.kt +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/PracticeRecordingAudioController.kt @@ -17,54 +17,75 @@ internal class PracticeRecordingAudioController( private var recordingStartedAt: Long = 0L private var recordingFile: File? = null - fun startRecording(): String { - stopPlayback() - releaseRecorder() + fun startRecording(): Result = + runCatching { + stopPlayback() + releaseRecorder() + + val file = File.createTempFile("practice_recording_", ".m4a", context.cacheDir) + var pendingRecorder: MediaRecorder? = null + val newRecorder = runCatching { + val recorder = createMediaRecorder() + pendingRecorder = recorder + recorder.apply { + setAudioSource(MediaRecorder.AudioSource.MIC) + setOutputFormat(MediaRecorder.OutputFormat.MPEG_4) + setAudioEncoder(MediaRecorder.AudioEncoder.AAC) + setOutputFile(file.absolutePath) + prepare() + start() + } + }.getOrElse { throwable -> + pendingRecorder?.release() + file.delete() + throw throwable + } - val file = File.createTempFile("practice_recording_", ".m4a", context.cacheDir) - val newRecorder = createMediaRecorder().apply { - setAudioSource(MediaRecorder.AudioSource.MIC) - setOutputFormat(MediaRecorder.OutputFormat.MPEG_4) - setAudioEncoder(MediaRecorder.AudioEncoder.AAC) - setOutputFile(file.absolutePath) - prepare() - start() + recorder = newRecorder + recordingFile = file + recordingStartedAt = System.currentTimeMillis() + file.absolutePath } - recorder = newRecorder - recordingFile = file - recordingStartedAt = System.currentTimeMillis() - return file.absolutePath - } - - fun stopRecording(): Int { + fun stopRecording(): Result { val durationSeconds = ((System.currentTimeMillis() - recordingStartedAt) / 1_000L).toInt() - recorder?.runCatching { stop() } - releaseRecorder() - - return max(durationSeconds, 0) + return runCatching { + recorder?.stop() + max(durationSeconds, 0) + }.also { + releaseRecorder() + } } fun startPlayback( filePath: String, onComplete: () -> Unit, - ): Int { - stopPlayback() - - val newPlayer = MediaPlayer().apply { - setDataSource(filePath) - prepare() - setOnCompletionListener { - stopPlayback() - onComplete() + ): Result = + runCatching { + stopPlayback() + + var pendingPlayer: MediaPlayer? = null + val newPlayer = runCatching { + val mediaPlayer = MediaPlayer() + pendingPlayer = mediaPlayer + mediaPlayer.apply { + setDataSource(filePath) + prepare() + setOnCompletionListener { + stopPlayback() + onComplete() + } + start() + } + }.getOrElse { throwable -> + pendingPlayer?.release() + throw throwable } - start() - } - player = newPlayer - return newPlayer.duration.toSeconds() - } + player = newPlayer + newPlayer.duration.toSeconds() + } fun stopPlayback() { player?.runCatching { stop() } @@ -72,7 +93,7 @@ internal class PracticeRecordingAudioController( player = null } - fun playbackPositionSeconds(): Int = player?.currentPosition?.toSeconds() ?: 0 + fun playbackPositionSeconds(): Int = runCatching { player?.currentPosition?.toSeconds() }.getOrNull() ?: 0 fun release() { releaseRecorder() diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/PracticeRecordingScreen.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/PracticeRecordingScreen.kt index 91beda67..01fc870b 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/PracticeRecordingScreen.kt +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/PracticeRecordingScreen.kt @@ -13,12 +13,14 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringArrayResource import androidx.compose.ui.res.stringResource @@ -26,9 +28,11 @@ import androidx.core.content.ContextCompat import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.team.prezel.core.designsystem.component.PrezelTopAppBar +import com.team.prezel.core.designsystem.component.feedback.snackbar.showPrezelSnackbar 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.core.ui.state.LocalSnackbarHostState import com.team.prezel.feature.home.impl.R import com.team.prezel.feature.home.impl.practice.component.PracticeAnalysisSpeed import com.team.prezel.feature.home.impl.practice.component.PracticeRecordingAnalysisErrorPage @@ -38,9 +42,11 @@ import com.team.prezel.feature.home.impl.practice.component.PracticeRecordingBut import com.team.prezel.feature.home.impl.practice.component.PracticeRecordingContent import com.team.prezel.feature.home.impl.practice.component.PracticeRecordingControlState import com.team.prezel.feature.home.impl.practice.contract.PracticeRecordingAnalysisStatus -import com.team.prezel.feature.home.impl.practice.contract.PracticeRecordingPhase +import com.team.prezel.feature.home.impl.practice.contract.PracticeRecordingState +import com.team.prezel.feature.home.impl.practice.contract.PracticeRecordingUiEffect import com.team.prezel.feature.home.impl.practice.contract.PracticeRecordingUiIntent import com.team.prezel.feature.home.impl.practice.contract.PracticeRecordingUiState +import com.team.prezel.feature.home.impl.practice.model.PracticeRecordingUiMessage @Composable internal fun PracticeRecordingScreen( @@ -51,6 +57,8 @@ internal fun PracticeRecordingScreen( ) { val context = LocalContext.current val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val resources = LocalResources.current + val snackbarHostState = LocalSnackbarHostState.current var hasRecordAudioPermission by remember { mutableStateOf( ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED, @@ -66,9 +74,22 @@ internal fun PracticeRecordingScreen( if (isGranted) viewModel.onIntent(PracticeRecordingUiIntent.ClickControl) } + LaunchedEffect(Unit) { + viewModel.uiEffect.collect { effect -> + when (effect) { + is PracticeRecordingUiEffect.ShowMessage -> { + val resId = when (effect.message) { + PracticeRecordingUiMessage.RECORDING_START_FAILED -> R.string.feature_home_impl_practice_recording_failed + } + snackbarHostState.showPrezelSnackbar(message = resources.getString(resId)) + } + } + } + } + fun onClickRecordingControl() { - when (uiState.phase) { - PracticeRecordingPhase.IDLE -> { + when (uiState.recordingState) { + PracticeRecordingState.Idle -> { if (hasRecordAudioPermission) { viewModel.onIntent(PracticeRecordingUiIntent.ClickControl) } else { @@ -76,9 +97,9 @@ internal fun PracticeRecordingScreen( } } - PracticeRecordingPhase.RECORDING, - PracticeRecordingPhase.RECORDED, - PracticeRecordingPhase.PLAYING, + is PracticeRecordingState.Recording, + is PracticeRecordingState.Recorded, + is PracticeRecordingState.Playing, -> viewModel.onIntent(PracticeRecordingUiIntent.ClickControl) } } @@ -174,7 +195,7 @@ private fun PracticeRecordingReadyScreen( practiceScript = practiceScript, currentSeconds = uiState.currentSeconds, totalSeconds = uiState.totalSeconds, - controlState = uiState.phase.toControlState(), + controlState = uiState.recordingState.toControlState(), onClickControl = onClickControl, modifier = Modifier.weight(1f), ) @@ -198,12 +219,12 @@ private fun PracticeRecordingTopAppBar(onBack: () -> Unit) { ) } -private fun PracticeRecordingPhase.toControlState(): PracticeRecordingControlState = +private fun PracticeRecordingState.toControlState(): PracticeRecordingControlState = when (this) { - PracticeRecordingPhase.IDLE -> PracticeRecordingControlState.READY_TO_RECORD - PracticeRecordingPhase.RECORDING -> PracticeRecordingControlState.RECORDING - PracticeRecordingPhase.RECORDED -> PracticeRecordingControlState.READY_TO_PLAY - PracticeRecordingPhase.PLAYING -> PracticeRecordingControlState.PLAYING + PracticeRecordingState.Idle -> PracticeRecordingControlState.READY_TO_RECORD + is PracticeRecordingState.Recording -> PracticeRecordingControlState.RECORDING + is PracticeRecordingState.Recorded -> PracticeRecordingControlState.READY_TO_PLAY + is PracticeRecordingState.Playing -> PracticeRecordingControlState.PLAYING } @BasicPreview @@ -220,8 +241,9 @@ private fun PracticeRecordingScreenRecordingPreview() { PrezelTheme { PracticeRecordingScreenPreviewContent( uiState = PracticeRecordingUiState( - phase = PracticeRecordingPhase.RECORDING, - recordingSeconds = 12, + recordingState = PracticeRecordingState.Recording( + recordingSeconds = 12, + ), ), ) } @@ -233,9 +255,9 @@ private fun PracticeRecordingScreenRecordedPreview() { PrezelTheme { PracticeRecordingScreenPreviewContent( uiState = PracticeRecordingUiState( - phase = PracticeRecordingPhase.RECORDED, - playbackSeconds = 0, - recordedDurationSeconds = 32, + recordingState = PracticeRecordingState.Recorded( + recordedDurationSeconds = 32, + ), ), ) } @@ -247,9 +269,10 @@ private fun PracticeRecordingScreenPlayingPreview() { PrezelTheme { PracticeRecordingScreenPreviewContent( uiState = PracticeRecordingUiState( - phase = PracticeRecordingPhase.PLAYING, - playbackSeconds = 12, - recordedDurationSeconds = 32, + recordingState = PracticeRecordingState.Playing( + playbackSeconds = 12, + recordedDurationSeconds = 32, + ), ), ) } diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/PracticeRecordingViewModel.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/PracticeRecordingViewModel.kt index f42293fd..1c4aa117 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/PracticeRecordingViewModel.kt +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/PracticeRecordingViewModel.kt @@ -2,11 +2,13 @@ package com.team.prezel.feature.home.impl.practice import androidx.lifecycle.viewModelScope import com.team.prezel.core.ui.base.BaseViewModel +import com.team.prezel.feature.home.impl.practice.contract.PracticeRecordingAnalysisErrorType import com.team.prezel.feature.home.impl.practice.contract.PracticeRecordingAnalysisStatus -import com.team.prezel.feature.home.impl.practice.contract.PracticeRecordingPhase +import com.team.prezel.feature.home.impl.practice.contract.PracticeRecordingState import com.team.prezel.feature.home.impl.practice.contract.PracticeRecordingUiEffect import com.team.prezel.feature.home.impl.practice.contract.PracticeRecordingUiIntent import com.team.prezel.feature.home.impl.practice.contract.PracticeRecordingUiState +import com.team.prezel.feature.home.impl.practice.model.PracticeRecordingUiMessage import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Job import kotlinx.coroutines.delay @@ -16,9 +18,7 @@ import javax.inject.Inject @HiltViewModel internal class PracticeRecordingViewModel @Inject constructor( audioControllerFactory: PracticeRecordingAudioControllerFactory, -) : BaseViewModel( - PracticeRecordingUiState(), - ) { +) : BaseViewModel(PracticeRecordingUiState()) { private val audioController = audioControllerFactory.create() private var recordingFilePath: String? = null private var timerJob: Job? = null @@ -32,77 +32,153 @@ internal class PracticeRecordingViewModel @Inject constructor( } private fun onClickControl() { - when (currentState.phase) { - PracticeRecordingPhase.IDLE -> startRecording() - PracticeRecordingPhase.RECORDING -> stopRecording() - PracticeRecordingPhase.RECORDED -> startPlayback() - PracticeRecordingPhase.PLAYING -> stopPlayback() + when (currentState.recordingState) { + PracticeRecordingState.Idle -> startRecording() + is PracticeRecordingState.Recording -> stopRecording() + is PracticeRecordingState.Recorded -> startPlayback() + is PracticeRecordingState.Playing -> stopPlayback() } } private fun startRecording() { - recordingFilePath = audioController.startRecording() - updateState { - copy( - phase = PracticeRecordingPhase.RECORDING, - recordingSeconds = 0, - playbackSeconds = 0, - recordedDurationSeconds = 0, - ) - } - startRecordingTimer() + audioController + .startRecording() + .onSuccess { filePath -> + recordingFilePath = filePath + + updateState { + copy( + recordingState = PracticeRecordingState.Recording( + recordingSeconds = 0, + ), + analysisStatus = PracticeRecordingAnalysisStatus.Ready, + ) + } + + startRecordingTimer() + }.onFailure { + recordingFilePath = null + timerJob?.cancel() + updateState { + copy( + recordingState = PracticeRecordingState.Idle, + analysisStatus = PracticeRecordingAnalysisStatus.Ready, + ) + } + viewModelScope.launch { + sendEffect( + PracticeRecordingUiEffect.ShowMessage( + PracticeRecordingUiMessage.RECORDING_START_FAILED, + ), + ) + } + } } private fun stopRecording() { - val durationSeconds = audioController.stopRecording() + val previousState = currentState.recordingState + if (previousState !is PracticeRecordingState.Recording) return + timerJob?.cancel() - updateState { - copy( - phase = PracticeRecordingPhase.RECORDED, - recordedDurationSeconds = durationSeconds.coerceAtLeast(recordingSeconds), - playbackSeconds = 0, - ) - } + + audioController + .stopRecording() + .onSuccess { durationSeconds -> + updateState { + copy( + recordingState = PracticeRecordingState.Recorded( + recordedDurationSeconds = durationSeconds.coerceAtLeast( + previousState.recordingSeconds, + ), + ), + ) + } + }.onFailure { + recordingFilePath = null + updateState { + copy( + recordingState = PracticeRecordingState.Idle, + analysisStatus = PracticeRecordingAnalysisStatus.Error( + PracticeRecordingAnalysisErrorType.VOICE_RECOGNITION_FAILED, + ), + ) + } + } } private fun startPlayback() { + val previousState = currentState.recordingState + if (previousState !is PracticeRecordingState.Recorded) return + val filePath = recordingFilePath ?: return - val durationSeconds = audioController.startPlayback(filePath) { - timerJob?.cancel() - updateState { - copy( - phase = PracticeRecordingPhase.RECORDED, - playbackSeconds = recordedDurationSeconds, - ) + + audioController + .startPlayback(filePath) { + timerJob?.cancel() + updateState { + copy( + recordingState = PracticeRecordingState.Recorded( + recordedDurationSeconds = previousState.recordedDurationSeconds, + ), + ) + } + }.onSuccess { durationSeconds -> + updateState { + copy( + recordingState = PracticeRecordingState.Playing( + playbackSeconds = 0, + recordedDurationSeconds = durationSeconds.coerceAtLeast( + previousState.recordedDurationSeconds, + ), + ), + ) + } + + startPlaybackTimer() + }.onFailure { + updateState { + copy( + recordingState = PracticeRecordingState.Recorded( + recordedDurationSeconds = previousState.recordedDurationSeconds, + ), + analysisStatus = PracticeRecordingAnalysisStatus.Error( + PracticeRecordingAnalysisErrorType.VOICE_RECOGNITION_FAILED, + ), + ) + } } - } - updateState { - copy( - phase = PracticeRecordingPhase.PLAYING, - recordedDurationSeconds = durationSeconds.coerceAtLeast(recordedDurationSeconds), - playbackSeconds = 0, - ) - } - startPlaybackTimer() } private fun stopPlayback() { + val previousState = currentState.recordingState + if (previousState !is PracticeRecordingState.Playing) return + audioController.stopPlayback() timerJob?.cancel() + updateState { copy( - phase = PracticeRecordingPhase.RECORDED, - playbackSeconds = 0, + recordingState = PracticeRecordingState.Recorded( + recordedDurationSeconds = previousState.recordedDurationSeconds, + ), ) } } private fun startAnalysis() { + if (!currentState.analyzeEnabled) return + analysisJob?.cancel() analysisJob = viewModelScope.launch { - updateState { copy(analysisStatus = PracticeRecordingAnalysisStatus.Loading) } + updateState { + copy(analysisStatus = PracticeRecordingAnalysisStatus.Loading) + } + delay(ANALYSIS_LOADING_DELAY_MILLIS) - updateState { copy(analysisStatus = PracticeRecordingAnalysisStatus.Success) } + + updateState { + copy(analysisStatus = PracticeRecordingAnalysisStatus.Success) + } } } @@ -110,8 +186,18 @@ internal class PracticeRecordingViewModel @Inject constructor( timerJob?.cancel() timerJob = viewModelScope.launch { while (true) { - delay(1_000) - updateState { copy(recordingSeconds = recordingSeconds + 1) } + delay(TIMER_DELAY_MILLIS) + + updateState { + val state = recordingState + if (state !is PracticeRecordingState.Recording) return@updateState this + + copy( + recordingState = PracticeRecordingState.Recording( + recordingSeconds = state.recordingSeconds + 1, + ), + ) + } } } } @@ -120,8 +206,19 @@ internal class PracticeRecordingViewModel @Inject constructor( timerJob?.cancel() timerJob = viewModelScope.launch { while (true) { - delay(250) - updateState { copy(playbackSeconds = audioController.playbackPositionSeconds()) } + delay(PLAYBACK_TIMER_DELAY_MILLIS) + + updateState { + val state = recordingState + if (state !is PracticeRecordingState.Playing) return@updateState this + + copy( + recordingState = PracticeRecordingState.Playing( + playbackSeconds = audioController.playbackPositionSeconds(), + recordedDurationSeconds = state.recordedDurationSeconds, + ), + ) + } } } } @@ -135,5 +232,7 @@ internal class PracticeRecordingViewModel @Inject constructor( private companion object { const val ANALYSIS_LOADING_DELAY_MILLIS = 3_000L + const val TIMER_DELAY_MILLIS = 1_000L + const val PLAYBACK_TIMER_DELAY_MILLIS = 250L } } diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/component/PracticeRecordingAnalysisPages.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/component/PracticeRecordingAnalysisPages.kt index c9a134e2..11c547bf 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/component/PracticeRecordingAnalysisPages.kt +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/component/PracticeRecordingAnalysisPages.kt @@ -70,8 +70,8 @@ internal fun PracticeRecordingAnalysisErrorPage( private val PracticeRecordingAnalysisErrorType.drawableResId: Int @DrawableRes get() = when (this) { - PracticeRecordingAnalysisErrorType.ANALYZE -> CoreUiR.drawable.core_ui_error_analyze - PracticeRecordingAnalysisErrorType.VOICE -> CoreUiR.drawable.core_ui_error_voice + PracticeRecordingAnalysisErrorType.ANALYSIS_FAILED -> CoreUiR.drawable.core_ui_error_analyze + PracticeRecordingAnalysisErrorType.VOICE_RECOGNITION_FAILED -> CoreUiR.drawable.core_ui_error_voice } @BasicPreview @@ -87,7 +87,7 @@ private fun PracticeRecordingAnalysisLoadingPagePreview() { private fun PracticeRecordingAnalysisAnalyzeErrorPagePreview() { PrezelTheme { PracticeRecordingAnalysisErrorPage( - errorType = PracticeRecordingAnalysisErrorType.ANALYZE, + errorType = PracticeRecordingAnalysisErrorType.ANALYSIS_FAILED, onRetry = {}, ) } @@ -98,7 +98,7 @@ private fun PracticeRecordingAnalysisAnalyzeErrorPagePreview() { private fun PracticeRecordingAnalysisVoiceErrorPagePreview() { PrezelTheme { PracticeRecordingAnalysisErrorPage( - errorType = PracticeRecordingAnalysisErrorType.VOICE, + errorType = PracticeRecordingAnalysisErrorType.VOICE_RECOGNITION_FAILED, onRetry = {}, ) } diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/contract/PracticeRecordingUiEffect.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/contract/PracticeRecordingUiEffect.kt index e48a4547..43fe18b5 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/contract/PracticeRecordingUiEffect.kt +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/contract/PracticeRecordingUiEffect.kt @@ -1,5 +1,10 @@ package com.team.prezel.feature.home.impl.practice.contract import com.team.prezel.core.ui.base.UiEffect +import com.team.prezel.feature.home.impl.practice.model.PracticeRecordingUiMessage -internal sealed interface PracticeRecordingUiEffect : UiEffect +internal sealed interface PracticeRecordingUiEffect : UiEffect { + data class ShowMessage( + val message: PracticeRecordingUiMessage, + ) : PracticeRecordingUiEffect +} diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/contract/PracticeRecordingUiState.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/contract/PracticeRecordingUiState.kt index 6bc82308..9a3158c3 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/contract/PracticeRecordingUiState.kt +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/contract/PracticeRecordingUiState.kt @@ -3,13 +3,63 @@ package com.team.prezel.feature.home.impl.practice.contract import androidx.compose.runtime.Immutable import com.team.prezel.core.ui.base.UiState -internal enum class PracticeRecordingPhase { - IDLE, - RECORDING, - RECORDED, - PLAYING, +@Immutable +internal data class PracticeRecordingUiState( + val recordingState: PracticeRecordingState = PracticeRecordingState.Idle, + val analysisStatus: PracticeRecordingAnalysisStatus = PracticeRecordingAnalysisStatus.Ready, +) : UiState { + val currentSeconds: Int + get() = recordingState.currentSeconds + + val totalSeconds: Int + get() = recordingState.totalSeconds + + val analyzeEnabled: Boolean + get() = recordingState is PracticeRecordingState.Recorded && + analysisStatus !is PracticeRecordingAnalysisStatus.Loading } +@Immutable +internal sealed interface PracticeRecordingState { + val currentSeconds: Int + val totalSeconds: Int + + data object Idle : PracticeRecordingState { + override val currentSeconds: Int = 0 + override val totalSeconds: Int = 0 + } + + data class Recording( + val recordingSeconds: Int, + ) : PracticeRecordingState { + override val currentSeconds: Int + get() = recordingSeconds + + override val totalSeconds: Int = 0 + } + + data class Recorded( + val recordedDurationSeconds: Int, + ) : PracticeRecordingState { + override val currentSeconds: Int = 0 + + override val totalSeconds: Int + get() = recordedDurationSeconds + } + + data class Playing( + val playbackSeconds: Int, + val recordedDurationSeconds: Int, + ) : PracticeRecordingState { + override val currentSeconds: Int + get() = playbackSeconds + + override val totalSeconds: Int + get() = recordedDurationSeconds + } +} + +@Immutable internal sealed interface PracticeRecordingAnalysisStatus { data object Ready : PracticeRecordingAnalysisStatus @@ -23,40 +73,6 @@ internal sealed interface PracticeRecordingAnalysisStatus { } internal enum class PracticeRecordingAnalysisErrorType { - ANALYZE, - VOICE, -} - -@Immutable -internal data class PracticeRecordingUiState( - val phase: PracticeRecordingPhase = PracticeRecordingPhase.IDLE, - val analysisStatus: PracticeRecordingAnalysisStatus = PracticeRecordingAnalysisStatus.Ready, - val recordingSeconds: Int = 0, - val playbackSeconds: Int = 0, - val recordedDurationSeconds: Int = 0, -) : UiState { - val currentSeconds: Int - get() = when (phase) { - PracticeRecordingPhase.IDLE, - PracticeRecordingPhase.RECORDING, - -> recordingSeconds - - PracticeRecordingPhase.RECORDED, - PracticeRecordingPhase.PLAYING, - -> playbackSeconds - } - - val totalSeconds: Int - get() = when (phase) { - PracticeRecordingPhase.IDLE, - PracticeRecordingPhase.RECORDING, - -> 0 - - PracticeRecordingPhase.RECORDED, - PracticeRecordingPhase.PLAYING, - -> recordedDurationSeconds - } - - val analyzeEnabled: Boolean - get() = recordedDurationSeconds > 0 && phase != PracticeRecordingPhase.RECORDING + VOICE_RECOGNITION_FAILED, + ANALYSIS_FAILED, } diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/model/PracticeRecordingUiMessage.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/model/PracticeRecordingUiMessage.kt new file mode 100644 index 00000000..c6769d66 --- /dev/null +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/model/PracticeRecordingUiMessage.kt @@ -0,0 +1,5 @@ +package com.team.prezel.feature.home.impl.practice.model + +internal enum class PracticeRecordingUiMessage { + RECORDING_START_FAILED, +} diff --git a/Prezel/feature/home/impl/src/main/res/values/strings.xml b/Prezel/feature/home/impl/src/main/res/values/strings.xml index c402aabb..ec6f7c36 100644 --- a/Prezel/feature/home/impl/src/main/res/values/strings.xml +++ b/Prezel/feature/home/impl/src/main/res/values/strings.xml @@ -24,6 +24,7 @@ 뒤로가기 아래 문장을 소리내어 읽어주세요. 분석하기 + 녹음을 시작하지 못했습니다. 분석중 잠시만 기다려주세요 분석에 실패했어요 From eb8b122ba1b632e111dcab312b1191209c356b21 Mon Sep 17 00:00:00 2001 From: Ham BeomJoon Date: Fri, 1 May 2026 11:37:46 +0900 Subject: [PATCH 04/27] =?UTF-8?q?refactor:=20=EC=97=B0=EC=8A=B5=20?= =?UTF-8?q?=EB=85=B9=EC=9D=8C=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=EC=A1=B0=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0=20=EB=B0=8F=20=EC=BD=94=EB=93=9C=20=EB=A6=AC?= =?UTF-8?q?=ED=8C=A9=ED=84=B0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * **refactor: 연습 녹음 관련 컴포넌트 및 로직 패키지 구조 재구성** * 분석 관련 컴포넌트(`PracticeRecordingAnalysisPages`, `PracticeRecordingAnalysisSuccessPage`)를 `practice.analysis.component` 패키지로 이동했습니다. * 오디오 제어 로직(`PracticeRecordingAudioController`)을 `practice.audio` 패키지로 이동했습니다. * 패키지 이동에 따른 `PracticeRecordingViewModel` 및 관련 클래스의 임포트 경로를 수정했습니다. * **feat: PracticeRecordingAnalysisScreen 분리 및 UI 로직 개선** * `PracticeRecordingScreen`에 포함되어 있던 분석 상태별 UI 분기 로직을 별도의 `PracticeRecordingAnalysisScreen` 컴포넌트로 추출했습니다. * `PracticeRecordingScreen`에서 권한 요청 및 녹음 제어 클릭 핸들러를 `remember` API를 사용해 최적화하고 가독성을 높였습니다. * **refactor: 도메인 모델 및 확장 함수 정리** * `PracticeRecordingState`를 `PracticeRecordingControlState`로 변환하는 `toControlState` 확장 함수를 `PracticeRecordingControl.kt`로 이동하여 응집도를 높였습니다. * `PracticeRecordingScreen` 내부에서 사용하던 스크립트 선택 및 권한 상태 관리 로직을 전용 `remember` 함수(`rememberPracticeScript`, `rememberRecordAudioPermissionState`)로 분리했습니다. --- .../impl/practice/PracticeRecordingScreen.kt | 172 +++++++++--------- .../practice/PracticeRecordingViewModel.kt | 1 + .../PracticeRecordingAnalysisScreen.kt | 37 ++++ .../PracticeRecordingAnalysisPages.kt | 2 +- .../PracticeRecordingAnalysisSuccessPage.kt | 2 +- .../PracticeRecordingAudioController.kt | 2 +- .../component/PracticeRecordingControl.kt | 9 + 7 files changed, 139 insertions(+), 86 deletions(-) create mode 100644 Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/analysis/PracticeRecordingAnalysisScreen.kt rename Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/{ => analysis}/component/PracticeRecordingAnalysisPages.kt (98%) rename Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/{ => analysis}/component/PracticeRecordingAnalysisSuccessPage.kt (99%) rename Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/{ => audio}/PracticeRecordingAudioController.kt (98%) diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/PracticeRecordingScreen.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/PracticeRecordingScreen.kt index 01fc870b..bc4e0019 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/PracticeRecordingScreen.kt +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/PracticeRecordingScreen.kt @@ -17,6 +17,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext @@ -34,13 +35,10 @@ import com.team.prezel.core.designsystem.preview.BasicPreview import com.team.prezel.core.designsystem.theme.PrezelTheme import com.team.prezel.core.ui.state.LocalSnackbarHostState import com.team.prezel.feature.home.impl.R -import com.team.prezel.feature.home.impl.practice.component.PracticeAnalysisSpeed -import com.team.prezel.feature.home.impl.practice.component.PracticeRecordingAnalysisErrorPage -import com.team.prezel.feature.home.impl.practice.component.PracticeRecordingAnalysisLoadingPage -import com.team.prezel.feature.home.impl.practice.component.PracticeRecordingAnalysisSuccessPage +import com.team.prezel.feature.home.impl.practice.analysis.PracticeRecordingAnalysisScreen import com.team.prezel.feature.home.impl.practice.component.PracticeRecordingButtonArea import com.team.prezel.feature.home.impl.practice.component.PracticeRecordingContent -import com.team.prezel.feature.home.impl.practice.component.PracticeRecordingControlState +import com.team.prezel.feature.home.impl.practice.component.toControlState import com.team.prezel.feature.home.impl.practice.contract.PracticeRecordingAnalysisStatus import com.team.prezel.feature.home.impl.practice.contract.PracticeRecordingState import com.team.prezel.feature.home.impl.practice.contract.PracticeRecordingUiEffect @@ -55,24 +53,19 @@ internal fun PracticeRecordingScreen( modifier: Modifier = Modifier, viewModel: PracticeRecordingViewModel = hiltViewModel(), ) { - val context = LocalContext.current val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val practiceScript = rememberPracticeScript() val resources = LocalResources.current val snackbarHostState = LocalSnackbarHostState.current - var hasRecordAudioPermission by remember { - mutableStateOf( - ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED, - ) - } - val scripts = stringArrayResource(R.array.feature_home_impl_practice_recording_scripts) - val practiceScript = remember { scripts.random() } - - val recordAudioPermissionLauncher = rememberLauncherForActivityResult( - contract = ActivityResultContracts.RequestPermission(), - ) { isGranted -> - hasRecordAudioPermission = isGranted - if (isGranted) viewModel.onIntent(PracticeRecordingUiIntent.ClickControl) - } + val recordAudioPermissionState = rememberRecordAudioPermissionState( + onPermissionGranted = { viewModel.onIntent(PracticeRecordingUiIntent.ClickControl) }, + ) + val onClickRecordingControl = rememberClickRecordingControlHandler( + recordingState = uiState.recordingState, + hasRecordAudioPermission = recordAudioPermissionState.isGranted, + onRequestRecordAudioPermission = recordAudioPermissionState.request, + onClickControl = { viewModel.onIntent(PracticeRecordingUiIntent.ClickControl) }, + ) LaunchedEffect(Unit) { viewModel.uiEffect.collect { effect -> @@ -87,27 +80,10 @@ internal fun PracticeRecordingScreen( } } - fun onClickRecordingControl() { - when (uiState.recordingState) { - PracticeRecordingState.Idle -> { - if (hasRecordAudioPermission) { - viewModel.onIntent(PracticeRecordingUiIntent.ClickControl) - } else { - recordAudioPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO) - } - } - - is PracticeRecordingState.Recording, - is PracticeRecordingState.Recorded, - is PracticeRecordingState.Playing, - -> viewModel.onIntent(PracticeRecordingUiIntent.ClickControl) - } - } - PracticeRecordingScreen( uiState = uiState, practiceScript = practiceScript, - onClickControl = ::onClickRecordingControl, + onClickControl = onClickRecordingControl, onClickAnalyze = { viewModel.onIntent(PracticeRecordingUiIntent.ClickAnalyze) }, onBack = onBack, navigateToHome = navigateToHome, @@ -127,52 +103,23 @@ private fun PracticeRecordingScreen( ) { BackHandler(onBack = onBack) - if (uiState.analysisStatus != PracticeRecordingAnalysisStatus.Ready) { - PracticeRecordingAnalysisScreen( - analysisStatus = uiState.analysisStatus, + when (uiState.analysisStatus) { + PracticeRecordingAnalysisStatus.Ready -> PracticeRecordingReadyScreen( + uiState = uiState, + practiceScript = practiceScript, + onClickControl = onClickControl, + onClickAnalyze = onClickAnalyze, onBack = onBack, - onRetry = onClickAnalyze, - onComplete = navigateToHome, modifier = modifier, ) - return - } - - PracticeRecordingReadyScreen( - uiState = uiState, - practiceScript = practiceScript, - onClickControl = onClickControl, - onClickAnalyze = onClickAnalyze, - onBack = onBack, - modifier = modifier, - ) -} -@Composable -private fun PracticeRecordingAnalysisScreen( - analysisStatus: PracticeRecordingAnalysisStatus, - onBack: () -> Unit, - onRetry: () -> Unit, - onComplete: () -> Unit, - modifier: Modifier = Modifier, -) { - when (analysisStatus) { - PracticeRecordingAnalysisStatus.Loading -> PracticeRecordingAnalysisLoadingPage(modifier = modifier) - PracticeRecordingAnalysisStatus.Success -> PracticeRecordingAnalysisSuccessPage( - pronunciationScore = 90, - speed = PracticeAnalysisSpeed.ADEQUATE, + else -> PracticeRecordingAnalysisScreen( + analysisStatus = uiState.analysisStatus, onBack = onBack, - onComplete = onComplete, - modifier = modifier, - ) - - is PracticeRecordingAnalysisStatus.Error -> PracticeRecordingAnalysisErrorPage( - errorType = analysisStatus.type, - onRetry = onRetry, + onRetry = onClickAnalyze, + onComplete = navigateToHome, modifier = modifier, ) - - PracticeRecordingAnalysisStatus.Ready -> Unit } } @@ -219,12 +166,71 @@ private fun PracticeRecordingTopAppBar(onBack: () -> Unit) { ) } -private fun PracticeRecordingState.toControlState(): PracticeRecordingControlState = - when (this) { - PracticeRecordingState.Idle -> PracticeRecordingControlState.READY_TO_RECORD - is PracticeRecordingState.Recording -> PracticeRecordingControlState.RECORDING - is PracticeRecordingState.Recorded -> PracticeRecordingControlState.READY_TO_PLAY - is PracticeRecordingState.Playing -> PracticeRecordingControlState.PLAYING +@Composable +private fun rememberPracticeScript(): String { + val scripts = stringArrayResource(R.array.feature_home_impl_practice_recording_scripts) + return remember { scripts.random() } +} + +@Composable +private fun rememberRecordAudioPermissionState(onPermissionGranted: () -> Unit): RecordAudioPermissionState { + val context = LocalContext.current + val currentOnPermissionGranted by rememberUpdatedState(onPermissionGranted) + var hasRecordAudioPermission by remember { + mutableStateOf( + ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED, + ) + } + + val launcher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestPermission(), + ) { isGranted -> + hasRecordAudioPermission = isGranted + if (isGranted) currentOnPermissionGranted() + } + + return remember(hasRecordAudioPermission, launcher) { + RecordAudioPermissionState( + isGranted = hasRecordAudioPermission, + request = { launcher.launch(Manifest.permission.RECORD_AUDIO) }, + ) + } +} + +private data class RecordAudioPermissionState( + val isGranted: Boolean, + val request: () -> Unit, +) + +@Composable +private fun rememberClickRecordingControlHandler( + recordingState: PracticeRecordingState, + hasRecordAudioPermission: Boolean, + onRequestRecordAudioPermission: () -> Unit, + onClickControl: () -> Unit, +): () -> Unit = + remember( + recordingState, + hasRecordAudioPermission, + onRequestRecordAudioPermission, + onClickControl, + ) { + { + when (recordingState) { + PracticeRecordingState.Idle -> { + if (hasRecordAudioPermission) { + onClickControl() + } else { + onRequestRecordAudioPermission() + } + } + + is PracticeRecordingState.Recording, + is PracticeRecordingState.Recorded, + is PracticeRecordingState.Playing, + -> onClickControl() + } + } } @BasicPreview diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/PracticeRecordingViewModel.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/PracticeRecordingViewModel.kt index 1c4aa117..e73e644b 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/PracticeRecordingViewModel.kt +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/PracticeRecordingViewModel.kt @@ -2,6 +2,7 @@ package com.team.prezel.feature.home.impl.practice import androidx.lifecycle.viewModelScope import com.team.prezel.core.ui.base.BaseViewModel +import com.team.prezel.feature.home.impl.practice.audio.PracticeRecordingAudioControllerFactory import com.team.prezel.feature.home.impl.practice.contract.PracticeRecordingAnalysisErrorType import com.team.prezel.feature.home.impl.practice.contract.PracticeRecordingAnalysisStatus import com.team.prezel.feature.home.impl.practice.contract.PracticeRecordingState diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/analysis/PracticeRecordingAnalysisScreen.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/analysis/PracticeRecordingAnalysisScreen.kt new file mode 100644 index 00000000..3fe58322 --- /dev/null +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/analysis/PracticeRecordingAnalysisScreen.kt @@ -0,0 +1,37 @@ +package com.team.prezel.feature.home.impl.practice.analysis + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.team.prezel.feature.home.impl.practice.analysis.component.PracticeAnalysisSpeed +import com.team.prezel.feature.home.impl.practice.analysis.component.PracticeRecordingAnalysisErrorPage +import com.team.prezel.feature.home.impl.practice.analysis.component.PracticeRecordingAnalysisLoadingPage +import com.team.prezel.feature.home.impl.practice.analysis.component.PracticeRecordingAnalysisSuccessPage +import com.team.prezel.feature.home.impl.practice.contract.PracticeRecordingAnalysisStatus + +@Composable +internal fun PracticeRecordingAnalysisScreen( + analysisStatus: PracticeRecordingAnalysisStatus, + onBack: () -> Unit, + onRetry: () -> Unit, + onComplete: () -> Unit, + modifier: Modifier = Modifier, +) { + when (analysisStatus) { + PracticeRecordingAnalysisStatus.Loading -> PracticeRecordingAnalysisLoadingPage(modifier = modifier) + PracticeRecordingAnalysisStatus.Success -> PracticeRecordingAnalysisSuccessPage( + pronunciationScore = 90, + speed = PracticeAnalysisSpeed.ADEQUATE, + onBack = onBack, + onComplete = onComplete, + modifier = modifier, + ) + + is PracticeRecordingAnalysisStatus.Error -> PracticeRecordingAnalysisErrorPage( + errorType = analysisStatus.type, + onRetry = onRetry, + modifier = modifier, + ) + + PracticeRecordingAnalysisStatus.Ready -> Unit + } +} diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/component/PracticeRecordingAnalysisPages.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/analysis/component/PracticeRecordingAnalysisPages.kt similarity index 98% rename from Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/component/PracticeRecordingAnalysisPages.kt rename to Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/analysis/component/PracticeRecordingAnalysisPages.kt index 11c547bf..72280a2d 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/component/PracticeRecordingAnalysisPages.kt +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/analysis/component/PracticeRecordingAnalysisPages.kt @@ -1,4 +1,4 @@ -package com.team.prezel.feature.home.impl.practice.component +package com.team.prezel.feature.home.impl.practice.analysis.component import androidx.annotation.DrawableRes import androidx.compose.foundation.Image diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/component/PracticeRecordingAnalysisSuccessPage.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/analysis/component/PracticeRecordingAnalysisSuccessPage.kt similarity index 99% rename from Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/component/PracticeRecordingAnalysisSuccessPage.kt rename to Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/analysis/component/PracticeRecordingAnalysisSuccessPage.kt index 7c652a03..af92f2c2 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/component/PracticeRecordingAnalysisSuccessPage.kt +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/analysis/component/PracticeRecordingAnalysisSuccessPage.kt @@ -1,4 +1,4 @@ -package com.team.prezel.feature.home.impl.practice.component +package com.team.prezel.feature.home.impl.practice.analysis.component import androidx.annotation.DrawableRes import androidx.annotation.StringRes diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/PracticeRecordingAudioController.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/audio/PracticeRecordingAudioController.kt similarity index 98% rename from Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/PracticeRecordingAudioController.kt rename to Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/audio/PracticeRecordingAudioController.kt index a597b770..1931dcfd 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/PracticeRecordingAudioController.kt +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/audio/PracticeRecordingAudioController.kt @@ -1,4 +1,4 @@ -package com.team.prezel.feature.home.impl.practice +package com.team.prezel.feature.home.impl.practice.audio import android.content.Context import android.media.MediaPlayer diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/component/PracticeRecordingControl.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/component/PracticeRecordingControl.kt index 9f3f03f7..ab985f4d 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/component/PracticeRecordingControl.kt +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/component/PracticeRecordingControl.kt @@ -19,6 +19,7 @@ import com.team.prezel.core.designsystem.component.actions.button.config.ButtonT 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.home.impl.practice.contract.PracticeRecordingState internal enum class PracticeRecordingControlState { READY_TO_RECORD, @@ -27,6 +28,14 @@ internal enum class PracticeRecordingControlState { PLAYING, } +internal fun PracticeRecordingState.toControlState(): PracticeRecordingControlState = + when (this) { + PracticeRecordingState.Idle -> PracticeRecordingControlState.READY_TO_RECORD + is PracticeRecordingState.Recording -> PracticeRecordingControlState.RECORDING + is PracticeRecordingState.Recorded -> PracticeRecordingControlState.READY_TO_PLAY + is PracticeRecordingState.Playing -> PracticeRecordingControlState.PLAYING + } + @Composable internal fun PracticeRecordingControl( currentSeconds: Int, From 24c2b478a83ade719a89bf45e7c536c0a0c841dd Mon Sep 17 00:00:00 2001 From: Ham BeomJoon Date: Fri, 1 May 2026 16:37:42 +0900 Subject: [PATCH 05/27] =?UTF-8?q?refactor:=20=EC=97=B0=EC=8A=B5=20?= =?UTF-8?q?=EB=85=B9=EC=9D=8C=20=EB=B6=84=EC=84=9D=20=EA=B2=B0=EA=B3=BC=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=20=ED=8C=A8=ED=82=A4=EC=A7=80=20=EA=B5=AC?= =?UTF-8?q?=EC=A1=B0=20=EA=B0=9C=EC=84=A0=20=EB=B0=8F=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * **refactor: 분석 결과 관련 화면 패키지 이동 및 명칭 변경** * `practice.analysis` 패키지를 `practice.result`로 변경하고 관련 컴포넌트들을 이동했습니다. * `PracticeRecordingAnalysisScreen`을 `PracticeRecordingResultScreen`으로 변경했습니다. * `PracticeRecordingAnalysisSuccessPage`를 `PracticeRecordingResultPage`로 변경했습니다. * `PracticeRecordingAnalysisErrorPage`를 `PracticeRecordingAnalysisFailurePage`로 변경했습니다. * **refactor: 연습 녹음 화면(PracticeRecordingScreen) UI 구조 개선** * 별도 파일로 분리되어 있던 `PracticeRecordingButtonArea`를 제거하고 `PracticeRecordingScreen` 내부에 `PrezelButtonArea`를 직접 구현하여 구조를 단순화했습니다. * `PracticeScriptCard` 컴포넌트를 제거하고 `PracticeRecordingContent` 내부에 인라인 `Box` 형태로 통합했습니다. * **refactor: 분석 로딩 화면 및 컴포넌트 정리** * 사용되지 않는 `PracticeRecordingAnalysisPages.kt` 파일을 삭제하고 필요한 로딩 페이지 로직을 `practice.result` 패키지로 재배치했습니다. --- .../impl/practice/PracticeRecordingScreen.kt | 18 +++++++--- .../component/PracticeRecordingButtonArea.kt | 24 ------------- .../component/PracticeRecordingContent.kt | 26 ++++++++++---- .../practice/component/PracticeScriptCard.kt | 34 ------------------ .../PracticeRecordingResultScreen.kt} | 16 ++++----- .../PracticeRecordingAnalysisFailurePage.kt} | 36 ++++--------------- .../PracticeRecordingAnalysisLoadingPage.kt | 36 +++++++++++++++++++ .../component/PracticeRecordingResultPage.kt} | 24 ++++++------- 8 files changed, 96 insertions(+), 118 deletions(-) delete mode 100644 Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/component/PracticeRecordingButtonArea.kt delete mode 100644 Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/component/PracticeScriptCard.kt rename Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/{analysis/PracticeRecordingAnalysisScreen.kt => result/PracticeRecordingResultScreen.kt} (63%) rename Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/{analysis/component/PracticeRecordingAnalysisPages.kt => result/component/PracticeRecordingAnalysisFailurePage.kt} (71%) create mode 100644 Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/result/component/PracticeRecordingAnalysisLoadingPage.kt rename Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/{analysis/component/PracticeRecordingAnalysisSuccessPage.kt => result/component/PracticeRecordingResultPage.kt} (92%) diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/PracticeRecordingScreen.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/PracticeRecordingScreen.kt index bc4e0019..a2862834 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/PracticeRecordingScreen.kt +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/PracticeRecordingScreen.kt @@ -29,14 +29,13 @@ import androidx.core.content.ContextCompat import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.team.prezel.core.designsystem.component.PrezelTopAppBar +import com.team.prezel.core.designsystem.component.actions.area.PrezelButtonArea import com.team.prezel.core.designsystem.component.feedback.snackbar.showPrezelSnackbar 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.core.ui.state.LocalSnackbarHostState import com.team.prezel.feature.home.impl.R -import com.team.prezel.feature.home.impl.practice.analysis.PracticeRecordingAnalysisScreen -import com.team.prezel.feature.home.impl.practice.component.PracticeRecordingButtonArea import com.team.prezel.feature.home.impl.practice.component.PracticeRecordingContent import com.team.prezel.feature.home.impl.practice.component.toControlState import com.team.prezel.feature.home.impl.practice.contract.PracticeRecordingAnalysisStatus @@ -45,6 +44,7 @@ import com.team.prezel.feature.home.impl.practice.contract.PracticeRecordingUiEf import com.team.prezel.feature.home.impl.practice.contract.PracticeRecordingUiIntent import com.team.prezel.feature.home.impl.practice.contract.PracticeRecordingUiState import com.team.prezel.feature.home.impl.practice.model.PracticeRecordingUiMessage +import com.team.prezel.feature.home.impl.practice.result.PracticeRecordingResultScreen @Composable internal fun PracticeRecordingScreen( @@ -113,7 +113,7 @@ private fun PracticeRecordingScreen( modifier = modifier, ) - else -> PracticeRecordingAnalysisScreen( + else -> PracticeRecordingResultScreen( analysisStatus = uiState.analysisStatus, onBack = onBack, onRetry = onClickAnalyze, @@ -132,12 +132,15 @@ private fun PracticeRecordingReadyScreen( onBack: () -> Unit, modifier: Modifier = Modifier, ) { + val analyzeLabel = stringResource(R.string.feature_home_impl_practice_recording_analyze) + Column( modifier = modifier .fillMaxSize() .background(PrezelTheme.colors.bgRegular), ) { PracticeRecordingTopAppBar(onBack = onBack) + PracticeRecordingContent( practiceScript = practiceScript, currentSeconds = uiState.currentSeconds, @@ -146,7 +149,14 @@ private fun PracticeRecordingReadyScreen( onClickControl = onClickControl, modifier = Modifier.weight(1f), ) - PracticeRecordingButtonArea(enabled = uiState.analyzeEnabled, onClickAnalyze = onClickAnalyze) + + PrezelButtonArea(modifier = modifier) { + MainButton( + label = analyzeLabel, + enabled = uiState.analyzeEnabled, + onClick = onClickAnalyze, + ) + } } } diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/component/PracticeRecordingButtonArea.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/component/PracticeRecordingButtonArea.kt deleted file mode 100644 index 7a4bedab..00000000 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/component/PracticeRecordingButtonArea.kt +++ /dev/null @@ -1,24 +0,0 @@ -package com.team.prezel.feature.home.impl.practice.component - -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import com.team.prezel.core.designsystem.component.actions.area.PrezelButtonArea -import com.team.prezel.feature.home.impl.R - -@Composable -internal fun PracticeRecordingButtonArea( - enabled: Boolean, - onClickAnalyze: () -> Unit, - modifier: Modifier = Modifier, -) { - val analyzeLabel = stringResource(R.string.feature_home_impl_practice_recording_analyze) - - PrezelButtonArea(modifier = modifier) { - MainButton( - label = analyzeLabel, - enabled = enabled, - onClick = onClickAnalyze, - ) - } -} diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/component/PracticeRecordingContent.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/component/PracticeRecordingContent.kt index de0a4a5c..f6b8014f 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/component/PracticeRecordingContent.kt +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/component/PracticeRecordingContent.kt @@ -1,14 +1,20 @@ package com.team.prezel.feature.home.impl.practice.component +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign import com.team.prezel.core.designsystem.theme.PrezelTheme import com.team.prezel.feature.home.impl.R @@ -34,12 +40,20 @@ internal fun PracticeRecordingContent( Spacer(modifier = Modifier.height(PrezelTheme.spacing.V32)) - PracticeScriptCard( - text = practiceScript, - modifier = Modifier - .fillMaxWidth() - .weight(1f), - ) + Box( + modifier = modifier + .clip(RoundedCornerShape(PrezelTheme.radius.V6)) + .background(PrezelTheme.colors.bgMedium) + .padding(horizontal = PrezelTheme.spacing.V16, vertical = PrezelTheme.spacing.V12), + contentAlignment = Alignment.Center, + ) { + Text( + text = practiceScript, + style = PrezelTheme.typography.body2Regular, + color = PrezelTheme.colors.textLarge, + textAlign = TextAlign.Center, + ) + } Spacer(modifier = Modifier.height(PrezelTheme.spacing.V32)) diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/component/PracticeScriptCard.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/component/PracticeScriptCard.kt deleted file mode 100644 index d99a3b97..00000000 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/component/PracticeScriptCard.kt +++ /dev/null @@ -1,34 +0,0 @@ -package com.team.prezel.feature.home.impl.practice.component - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.text.style.TextAlign -import com.team.prezel.core.designsystem.theme.PrezelTheme - -@Composable -internal fun PracticeScriptCard( - text: String, - modifier: Modifier = Modifier, -) { - Box( - modifier = modifier - .clip(RoundedCornerShape(PrezelTheme.radius.V6)) - .background(PrezelTheme.colors.bgMedium) - .padding(horizontal = PrezelTheme.spacing.V16, vertical = PrezelTheme.spacing.V12), - contentAlignment = Alignment.Center, - ) { - Text( - text = text, - style = PrezelTheme.typography.body2Regular, - color = PrezelTheme.colors.textLarge, - textAlign = TextAlign.Center, - ) - } -} diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/analysis/PracticeRecordingAnalysisScreen.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/result/PracticeRecordingResultScreen.kt similarity index 63% rename from Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/analysis/PracticeRecordingAnalysisScreen.kt rename to Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/result/PracticeRecordingResultScreen.kt index 3fe58322..d685571e 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/analysis/PracticeRecordingAnalysisScreen.kt +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/result/PracticeRecordingResultScreen.kt @@ -1,15 +1,15 @@ -package com.team.prezel.feature.home.impl.practice.analysis +package com.team.prezel.feature.home.impl.practice.result import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import com.team.prezel.feature.home.impl.practice.analysis.component.PracticeAnalysisSpeed -import com.team.prezel.feature.home.impl.practice.analysis.component.PracticeRecordingAnalysisErrorPage -import com.team.prezel.feature.home.impl.practice.analysis.component.PracticeRecordingAnalysisLoadingPage -import com.team.prezel.feature.home.impl.practice.analysis.component.PracticeRecordingAnalysisSuccessPage import com.team.prezel.feature.home.impl.practice.contract.PracticeRecordingAnalysisStatus +import com.team.prezel.feature.home.impl.practice.result.component.PracticeAnalysisSpeed +import com.team.prezel.feature.home.impl.practice.result.component.PracticeRecordingAnalysisFailurePage +import com.team.prezel.feature.home.impl.practice.result.component.PracticeRecordingAnalysisLoadingPage +import com.team.prezel.feature.home.impl.practice.result.component.PracticeRecordingResultPage @Composable -internal fun PracticeRecordingAnalysisScreen( +internal fun PracticeRecordingResultScreen( analysisStatus: PracticeRecordingAnalysisStatus, onBack: () -> Unit, onRetry: () -> Unit, @@ -18,7 +18,7 @@ internal fun PracticeRecordingAnalysisScreen( ) { when (analysisStatus) { PracticeRecordingAnalysisStatus.Loading -> PracticeRecordingAnalysisLoadingPage(modifier = modifier) - PracticeRecordingAnalysisStatus.Success -> PracticeRecordingAnalysisSuccessPage( + PracticeRecordingAnalysisStatus.Success -> PracticeRecordingResultPage( pronunciationScore = 90, speed = PracticeAnalysisSpeed.ADEQUATE, onBack = onBack, @@ -26,7 +26,7 @@ internal fun PracticeRecordingAnalysisScreen( modifier = modifier, ) - is PracticeRecordingAnalysisStatus.Error -> PracticeRecordingAnalysisErrorPage( + is PracticeRecordingAnalysisStatus.Error -> PracticeRecordingAnalysisFailurePage( errorType = analysisStatus.type, onRetry = onRetry, modifier = modifier, diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/analysis/component/PracticeRecordingAnalysisPages.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/result/component/PracticeRecordingAnalysisFailurePage.kt similarity index 71% rename from Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/analysis/component/PracticeRecordingAnalysisPages.kt rename to Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/result/component/PracticeRecordingAnalysisFailurePage.kt index 72280a2d..cb05c51e 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/analysis/component/PracticeRecordingAnalysisPages.kt +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/result/component/PracticeRecordingAnalysisFailurePage.kt @@ -1,4 +1,4 @@ -package com.team.prezel.feature.home.impl.practice.analysis.component +package com.team.prezel.feature.home.impl.practice.result.component import androidx.annotation.DrawableRes import androidx.compose.foundation.Image @@ -15,29 +15,13 @@ import com.team.prezel.core.designsystem.component.actions.button.config.ButtonT 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.core.ui.component.PrezelLottie import com.team.prezel.core.ui.component.StatusView import com.team.prezel.feature.home.impl.R import com.team.prezel.feature.home.impl.practice.contract.PracticeRecordingAnalysisErrorType import com.team.prezel.core.ui.R as CoreUiR @Composable -internal fun PracticeRecordingAnalysisLoadingPage(modifier: Modifier = Modifier) { - StatusView( - title = stringResource(R.string.feature_home_impl_practice_recording_analysis_loading_title), - description = stringResource(R.string.feature_home_impl_practice_recording_analysis_loading_description), - modifier = modifier, - visual = { - PrezelLottie( - resId = CoreUiR.raw.core_ui_asset_loading, - modifier = Modifier.size(80.dp), - ) - }, - ) -} - -@Composable -internal fun PracticeRecordingAnalysisErrorPage( +internal fun PracticeRecordingAnalysisFailurePage( errorType: PracticeRecordingAnalysisErrorType, onRetry: () -> Unit, modifier: Modifier = Modifier, @@ -76,17 +60,9 @@ private val PracticeRecordingAnalysisErrorType.drawableResId: Int @BasicPreview @Composable -private fun PracticeRecordingAnalysisLoadingPagePreview() { - PrezelTheme { - PracticeRecordingAnalysisLoadingPage() - } -} - -@BasicPreview -@Composable -private fun PracticeRecordingAnalysisAnalyzeErrorPagePreview() { +private fun PracticeRecordingAnalysisAnalyzeFailurePagePreview() { PrezelTheme { - PracticeRecordingAnalysisErrorPage( + PracticeRecordingAnalysisFailurePage( errorType = PracticeRecordingAnalysisErrorType.ANALYSIS_FAILED, onRetry = {}, ) @@ -95,9 +71,9 @@ private fun PracticeRecordingAnalysisAnalyzeErrorPagePreview() { @BasicPreview @Composable -private fun PracticeRecordingAnalysisVoiceErrorPagePreview() { +private fun PracticeRecordingAnalysisVoiceFailurePagePreview() { PrezelTheme { - PracticeRecordingAnalysisErrorPage( + PracticeRecordingAnalysisFailurePage( errorType = PracticeRecordingAnalysisErrorType.VOICE_RECOGNITION_FAILED, onRetry = {}, ) diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/result/component/PracticeRecordingAnalysisLoadingPage.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/result/component/PracticeRecordingAnalysisLoadingPage.kt new file mode 100644 index 00000000..cae12fb4 --- /dev/null +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/result/component/PracticeRecordingAnalysisLoadingPage.kt @@ -0,0 +1,36 @@ +package com.team.prezel.feature.home.impl.practice.result.component + +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.team.prezel.core.designsystem.preview.BasicPreview +import com.team.prezel.core.designsystem.theme.PrezelTheme +import com.team.prezel.core.ui.component.PrezelLottie +import com.team.prezel.core.ui.component.StatusView +import com.team.prezel.feature.home.impl.R +import com.team.prezel.core.ui.R as CoreUiR + +@Composable +internal fun PracticeRecordingAnalysisLoadingPage(modifier: Modifier = Modifier) { + StatusView( + title = stringResource(R.string.feature_home_impl_practice_recording_analysis_loading_title), + description = stringResource(R.string.feature_home_impl_practice_recording_analysis_loading_description), + modifier = modifier, + visual = { + PrezelLottie( + resId = CoreUiR.raw.core_ui_asset_loading, + modifier = Modifier.size(80.dp), + ) + }, + ) +} + +@BasicPreview +@Composable +private fun PracticeRecordingAnalysisLoadingPagePreview() { + PrezelTheme { + PracticeRecordingAnalysisLoadingPage() + } +} diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/analysis/component/PracticeRecordingAnalysisSuccessPage.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/result/component/PracticeRecordingResultPage.kt similarity index 92% rename from Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/analysis/component/PracticeRecordingAnalysisSuccessPage.kt rename to Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/result/component/PracticeRecordingResultPage.kt index af92f2c2..93d20b9a 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/analysis/component/PracticeRecordingAnalysisSuccessPage.kt +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/result/component/PracticeRecordingResultPage.kt @@ -1,4 +1,4 @@ -package com.team.prezel.feature.home.impl.practice.analysis.component +package com.team.prezel.feature.home.impl.practice.result.component import androidx.annotation.DrawableRes import androidx.annotation.StringRes @@ -64,7 +64,7 @@ private enum class PracticeAnalysisOverallResult( @OptIn(ExperimentalMaterial3Api::class) @Composable -internal fun PracticeRecordingAnalysisSuccessPage( +internal fun PracticeRecordingResultPage( pronunciationScore: Int, speed: PracticeAnalysisSpeed, onBack: () -> Unit, @@ -92,7 +92,7 @@ internal fun PracticeRecordingAnalysisSuccessPage( }, ) - PracticeRecordingAnalysisSuccessContent( + PracticeRecordingResultContent( cardResId = overallResult.cardResId, cardContentDescription = stringResource(overallResult.contentDescriptionResId), pronunciationScore = pronunciationScore, @@ -100,12 +100,12 @@ internal fun PracticeRecordingAnalysisSuccessPage( modifier = Modifier.weight(1f), ) - PracticeRecordingAnalysisSuccessButtonArea(onComplete = onComplete) + PracticeRecordingResultButtonArea(onComplete = onComplete) } } @Composable -private fun PracticeRecordingAnalysisSuccessContent( +private fun PracticeRecordingResultContent( @DrawableRes cardResId: Int, cardContentDescription: String, pronunciationScore: Int, @@ -136,7 +136,7 @@ private fun PracticeRecordingAnalysisSuccessContent( } @Composable -private fun PracticeRecordingAnalysisSuccessButtonArea( +private fun PracticeRecordingResultButtonArea( onComplete: () -> Unit, modifier: Modifier = Modifier, ) { @@ -234,9 +234,9 @@ private fun PracticeAnalysisMetricLabel( @BasicPreview @Composable -private fun PracticeRecordingAnalysisPerfectPagePreview() { +private fun PracticeRecordingResultPerfectPagePreview() { PrezelTheme { - PracticeRecordingAnalysisSuccessPage( + PracticeRecordingResultPage( pronunciationScore = 96, speed = PracticeAnalysisSpeed.ADEQUATE, onBack = {}, @@ -247,9 +247,9 @@ private fun PracticeRecordingAnalysisPerfectPagePreview() { @BasicPreview @Composable -private fun PracticeRecordingAnalysisGoodPagePreview() { +private fun PracticeRecordingResultGoodPagePreview() { PrezelTheme { - PracticeRecordingAnalysisSuccessPage( + PracticeRecordingResultPage( pronunciationScore = 90, speed = PracticeAnalysisSpeed.ADEQUATE, onBack = {}, @@ -260,9 +260,9 @@ private fun PracticeRecordingAnalysisGoodPagePreview() { @BasicPreview @Composable -private fun PracticeRecordingAnalysisTryPagePreview() { +private fun PracticeRecordingResultTryPagePreview() { PrezelTheme { - PracticeRecordingAnalysisSuccessPage( + PracticeRecordingResultPage( pronunciationScore = 58, speed = PracticeAnalysisSpeed.FAST, onBack = {}, From 11f38549118eaaa1276d3a8b3ebff4ef77d1bf4d Mon Sep 17 00:00:00 2001 From: Ham BeomJoon Date: Fri, 1 May 2026 17:14:28 +0900 Subject: [PATCH 06/27] =?UTF-8?q?feat:=20=EC=97=B0=EC=8A=B5=20=EB=85=B9?= =?UTF-8?q?=EC=9D=8C=20=EB=B6=84=EC=84=9D=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20=EB=B0=8F=20=EA=B4=80=EB=A0=A8=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8/=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=EC=96=B4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * **feat: 연습(Practice) 관련 도메인 모델 및 Repository 추가** * 연습 스크립트(`PracticeScript`), 녹음 업로드(`PracticeRecordingUpload`), 분석 결과(`PracticeRecordingAnalysisResult`)를 정의하는 도메인 모델을 추가했습니다. * `PracticeRepository` 인터페이스를 정의하고, 가짜 데이터(Fake Data)를 반환하는 `PracticeRepositoryImpl`을 구현했습니다. * **feat: 연습 관련 UseCase 구현 및 DI 설정** * `FetchPracticeScriptUseCase`, `UploadPracticeRecordingUseCase`, `FetchPracticeRecordingAnalysisResultUseCase`를 추가하여 비즈니스 로직을 분리했습니다. * `RepositoryModule`에 `PracticeRepository` 의존성 주입 설정을 추가했습니다. * **feat: 연습 녹음 화면(PracticeRecordingScreen) 기능 확장** * 기존에 로컬 리소스에서 랜덤하게 가져오던 연습 스크립트를 서버(UseCase)에서 불러오도록 변경했습니다. * 녹음 완료 후 '분석하기' 클릭 시 실제 녹음 파일을 업로드하고 분석 결과를 조회하는 흐름을 구현했습니다. * 녹음 중단 실패 및 재생 실패에 대한 예외 처리와 스낵바 메시지(`PracticeRecordingUiMessage`)를 추가했습니다. * **refactor: UI 상태 및 결과 화면 연동** * `PracticeRecordingUiState`에 `practiceScript`를 추가하고, `Success` 상태에 분석 결과 모델을 포함하도록 수정했습니다. * `PracticeRecordingResultScreen`에서 실제 분석 데이터(발음 점수, 속도)를 표시하도록 연동했습니다. * `PracticeRecordingContent` 레이아웃이 화면 전체 높이를 적절히 활용하도록 수정했습니다. * **build: feature:home:impl 모듈 의존성 추가** * `core:domain` 모듈 의존성을 추가하여 UseCase를 사용할 수 있도록 설정했습니다. --- .../prezel/core/data/di/RepositoryModule.kt | 6 ++ .../data/repository/PracticeRepositoryImpl.kt | 68 ++++++++++++++++++ .../repository/practice/PracticeRepository.kt | 13 ++++ ...hPracticeRecordingAnalysisResultUseCase.kt | 12 ++++ .../practice/FetchPracticeScriptUseCase.kt | 11 +++ .../UploadPracticeRecordingUseCase.kt | 12 ++++ .../PracticeRecordingAnalysisResult.kt | 12 ++++ .../model/practice/PracticeRecordingUpload.kt | 5 ++ .../core/model/practice/PracticeScript.kt | 6 ++ Prezel/feature/home/impl/build.gradle.kts | 1 + .../impl/practice/PracticeRecordingScreen.kt | 25 +++---- .../practice/PracticeRecordingViewModel.kt | 72 ++++++++++++++----- .../component/PracticeRecordingContent.kt | 4 +- .../contract/PracticeRecordingUiIntent.kt | 2 + .../contract/PracticeRecordingUiState.kt | 6 +- .../model/PracticeRecordingUiMessage.kt | 2 + .../result/PracticeRecordingResultScreen.kt | 14 +++- .../home/impl/src/main/res/values/strings.xml | 15 ++-- 18 files changed, 238 insertions(+), 48 deletions(-) create mode 100644 Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/PracticeRepositoryImpl.kt create mode 100644 Prezel/core/domain/src/main/java/com/team/prezel/core/domain/repository/practice/PracticeRepository.kt create mode 100644 Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/practice/FetchPracticeRecordingAnalysisResultUseCase.kt create mode 100644 Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/practice/FetchPracticeScriptUseCase.kt create mode 100644 Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/practice/UploadPracticeRecordingUseCase.kt create mode 100644 Prezel/core/model/src/main/java/com/team/prezel/core/model/practice/PracticeRecordingAnalysisResult.kt create mode 100644 Prezel/core/model/src/main/java/com/team/prezel/core/model/practice/PracticeRecordingUpload.kt create mode 100644 Prezel/core/model/src/main/java/com/team/prezel/core/model/practice/PracticeScript.kt diff --git a/Prezel/core/data/src/main/java/com/team/prezel/core/data/di/RepositoryModule.kt b/Prezel/core/data/src/main/java/com/team/prezel/core/data/di/RepositoryModule.kt index b027abf2..a7717968 100644 --- a/Prezel/core/data/src/main/java/com/team/prezel/core/data/di/RepositoryModule.kt +++ b/Prezel/core/data/src/main/java/com/team/prezel/core/data/di/RepositoryModule.kt @@ -1,8 +1,10 @@ package com.team.prezel.core.data.di import com.team.prezel.core.data.repository.AuthRepositoryImpl +import com.team.prezel.core.data.repository.PracticeRepositoryImpl import com.team.prezel.core.data.repository.UserRepositoryImpl import com.team.prezel.core.domain.repository.auth.AuthRepository +import com.team.prezel.core.domain.repository.practice.PracticeRepository import com.team.prezel.core.domain.repository.profile.UserRepository import dagger.Binds import dagger.Module @@ -20,4 +22,8 @@ internal abstract class RepositoryModule { @Binds @Singleton abstract fun bindUserRepository(impl: UserRepositoryImpl): UserRepository + + @Binds + @Singleton + abstract fun bindPracticeRepository(impl: PracticeRepositoryImpl): PracticeRepository } diff --git a/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/PracticeRepositoryImpl.kt b/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/PracticeRepositoryImpl.kt new file mode 100644 index 00000000..e72f4d8e --- /dev/null +++ b/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/PracticeRepositoryImpl.kt @@ -0,0 +1,68 @@ +package com.team.prezel.core.data.repository + +import com.team.prezel.core.domain.repository.practice.PracticeRepository +import com.team.prezel.core.model.practice.PracticeRecordingAnalysisResult +import com.team.prezel.core.model.practice.PracticeRecordingSpeed +import com.team.prezel.core.model.practice.PracticeRecordingUpload +import com.team.prezel.core.model.practice.PracticeScript +import kotlinx.coroutines.delay +import javax.inject.Inject + +internal class PracticeRepositoryImpl @Inject constructor() : PracticeRepository { + override suspend fun fetchPracticeScript(): Result = + runCatching { + delay(FAKE_API_DELAY_MILLIS) + fakePracticeScripts.random() + } + + override suspend fun uploadPracticeRecording(recordingFilePath: String): Result = + runCatching { + delay(FAKE_API_DELAY_MILLIS) + PracticeRecordingUpload(id = FAKE_RECORDING_ID) + } + + override suspend fun fetchPracticeRecordingAnalysisResult(recordingId: Long): Result = + runCatching { + delay(FAKE_API_DELAY_MILLIS) + PracticeRecordingAnalysisResult( + pronunciationScore = 90, + speed = PracticeRecordingSpeed.ADEQUATE, + ) + } + + private companion object { + const val FAKE_API_DELAY_MILLIS = 300L + const val FAKE_RECORDING_ID = 1L + + val fakePracticeScripts = listOf( + PracticeScript( + id = 1L, + content = "내가 그린 기린 그림은 잘 그린 기린 그림이고,\n네가 그린 기린 그림은 잘못 그린 기린 그림이다.", + ), + PracticeScript( + id = 2L, + content = "간장 공장 공장장은 강 공장장이고,\n된장 공장 공장장은 공 공장장이다.", + ), + PracticeScript( + id = 3L, + content = "저기 있는 말뚝이 말 맬 말뚝이냐,\n말 못 맬 말뚝이냐.", + ), + PracticeScript( + id = 4L, + content = "서울특별시 특허허가과 허가과장 허 과장.", + ), + PracticeScript( + id = 5L, + content = "신진 샹송 가수의 신춘 샹송 쇼.", + ), + PracticeScript( + id = 6L, + content = "작년에 온 솥 장수는 새 솥 장수이고,\n금년에 온 솥 장수는 헌 솥 장수이다.", + ), + PracticeScript( + id = 7L, + content = "상표 붙인 큰 깡통은 깐 깡통인가,\n안 깐 깡통인가.", + ), + ) + } +} diff --git a/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/repository/practice/PracticeRepository.kt b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/repository/practice/PracticeRepository.kt new file mode 100644 index 00000000..065fecfc --- /dev/null +++ b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/repository/practice/PracticeRepository.kt @@ -0,0 +1,13 @@ +package com.team.prezel.core.domain.repository.practice + +import com.team.prezel.core.model.practice.PracticeRecordingAnalysisResult +import com.team.prezel.core.model.practice.PracticeRecordingUpload +import com.team.prezel.core.model.practice.PracticeScript + +interface PracticeRepository { + suspend fun fetchPracticeScript(): Result + + suspend fun uploadPracticeRecording(recordingFilePath: String): Result + + suspend fun fetchPracticeRecordingAnalysisResult(recordingId: Long): Result +} diff --git a/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/practice/FetchPracticeRecordingAnalysisResultUseCase.kt b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/practice/FetchPracticeRecordingAnalysisResultUseCase.kt new file mode 100644 index 00000000..f9cd63d9 --- /dev/null +++ b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/practice/FetchPracticeRecordingAnalysisResultUseCase.kt @@ -0,0 +1,12 @@ +package com.team.prezel.core.domain.usecase.practice + +import com.team.prezel.core.domain.repository.practice.PracticeRepository +import com.team.prezel.core.model.practice.PracticeRecordingAnalysisResult +import javax.inject.Inject + +class FetchPracticeRecordingAnalysisResultUseCase @Inject constructor( + private val practiceRepository: PracticeRepository, +) { + suspend operator fun invoke(recordingId: Long): Result = + practiceRepository.fetchPracticeRecordingAnalysisResult(recordingId = recordingId) +} diff --git a/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/practice/FetchPracticeScriptUseCase.kt b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/practice/FetchPracticeScriptUseCase.kt new file mode 100644 index 00000000..f8e16653 --- /dev/null +++ b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/practice/FetchPracticeScriptUseCase.kt @@ -0,0 +1,11 @@ +package com.team.prezel.core.domain.usecase.practice + +import com.team.prezel.core.domain.repository.practice.PracticeRepository +import com.team.prezel.core.model.practice.PracticeScript +import javax.inject.Inject + +class FetchPracticeScriptUseCase @Inject constructor( + private val practiceRepository: PracticeRepository, +) { + suspend operator fun invoke(): Result = practiceRepository.fetchPracticeScript() +} diff --git a/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/practice/UploadPracticeRecordingUseCase.kt b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/practice/UploadPracticeRecordingUseCase.kt new file mode 100644 index 00000000..3805af18 --- /dev/null +++ b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/practice/UploadPracticeRecordingUseCase.kt @@ -0,0 +1,12 @@ +package com.team.prezel.core.domain.usecase.practice + +import com.team.prezel.core.domain.repository.practice.PracticeRepository +import com.team.prezel.core.model.practice.PracticeRecordingUpload +import javax.inject.Inject + +class UploadPracticeRecordingUseCase @Inject constructor( + private val practiceRepository: PracticeRepository, +) { + suspend operator fun invoke(recordingFilePath: String): Result = + practiceRepository.uploadPracticeRecording(recordingFilePath = recordingFilePath) +} diff --git a/Prezel/core/model/src/main/java/com/team/prezel/core/model/practice/PracticeRecordingAnalysisResult.kt b/Prezel/core/model/src/main/java/com/team/prezel/core/model/practice/PracticeRecordingAnalysisResult.kt new file mode 100644 index 00000000..499e592d --- /dev/null +++ b/Prezel/core/model/src/main/java/com/team/prezel/core/model/practice/PracticeRecordingAnalysisResult.kt @@ -0,0 +1,12 @@ +package com.team.prezel.core.model.practice + +data class PracticeRecordingAnalysisResult( + val pronunciationScore: Int, + val speed: PracticeRecordingSpeed, +) + +enum class PracticeRecordingSpeed { + SLOW, + ADEQUATE, + FAST, +} diff --git a/Prezel/core/model/src/main/java/com/team/prezel/core/model/practice/PracticeRecordingUpload.kt b/Prezel/core/model/src/main/java/com/team/prezel/core/model/practice/PracticeRecordingUpload.kt new file mode 100644 index 00000000..9120fa49 --- /dev/null +++ b/Prezel/core/model/src/main/java/com/team/prezel/core/model/practice/PracticeRecordingUpload.kt @@ -0,0 +1,5 @@ +package com.team.prezel.core.model.practice + +data class PracticeRecordingUpload( + val id: Long, +) diff --git a/Prezel/core/model/src/main/java/com/team/prezel/core/model/practice/PracticeScript.kt b/Prezel/core/model/src/main/java/com/team/prezel/core/model/practice/PracticeScript.kt new file mode 100644 index 00000000..d633d215 --- /dev/null +++ b/Prezel/core/model/src/main/java/com/team/prezel/core/model/practice/PracticeScript.kt @@ -0,0 +1,6 @@ +package com.team.prezel.core.model.practice + +data class PracticeScript( + val id: Long, + val content: String, +) diff --git a/Prezel/feature/home/impl/build.gradle.kts b/Prezel/feature/home/impl/build.gradle.kts index 8e5ce3e3..4ef4389a 100644 --- a/Prezel/feature/home/impl/build.gradle.kts +++ b/Prezel/feature/home/impl/build.gradle.kts @@ -7,6 +7,7 @@ android { } dependencies { + implementation(projects.coreDomain) implementation(projects.coreModel) implementation(projects.featureHomeApi) diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/PracticeRecordingScreen.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/PracticeRecordingScreen.kt index a2862834..f60905ce 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/PracticeRecordingScreen.kt +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/PracticeRecordingScreen.kt @@ -23,7 +23,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringArrayResource import androidx.compose.ui.res.stringResource import androidx.core.content.ContextCompat import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel @@ -54,7 +53,6 @@ internal fun PracticeRecordingScreen( viewModel: PracticeRecordingViewModel = hiltViewModel(), ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() - val practiceScript = rememberPracticeScript() val resources = LocalResources.current val snackbarHostState = LocalSnackbarHostState.current val recordAudioPermissionState = rememberRecordAudioPermissionState( @@ -67,12 +65,18 @@ internal fun PracticeRecordingScreen( onClickControl = { viewModel.onIntent(PracticeRecordingUiIntent.ClickControl) }, ) + LaunchedEffect(Unit) { + viewModel.onIntent(PracticeRecordingUiIntent.LoadPracticeScript) + } + LaunchedEffect(Unit) { viewModel.uiEffect.collect { effect -> when (effect) { is PracticeRecordingUiEffect.ShowMessage -> { val resId = when (effect.message) { PracticeRecordingUiMessage.RECORDING_START_FAILED -> R.string.feature_home_impl_practice_recording_failed + PracticeRecordingUiMessage.RECORDING_STOP_FAILED -> R.string.feature_home_impl_practice_recording_stop_failed + PracticeRecordingUiMessage.PLAYBACK_START_FAILED -> R.string.feature_home_impl_practice_recording_playback_failed } snackbarHostState.showPrezelSnackbar(message = resources.getString(resId)) } @@ -82,7 +86,6 @@ internal fun PracticeRecordingScreen( PracticeRecordingScreen( uiState = uiState, - practiceScript = practiceScript, onClickControl = onClickRecordingControl, onClickAnalyze = { viewModel.onIntent(PracticeRecordingUiIntent.ClickAnalyze) }, onBack = onBack, @@ -94,7 +97,6 @@ internal fun PracticeRecordingScreen( @Composable private fun PracticeRecordingScreen( uiState: PracticeRecordingUiState, - practiceScript: String, onClickControl: () -> Unit, onClickAnalyze: () -> Unit, onBack: () -> Unit, @@ -106,7 +108,6 @@ private fun PracticeRecordingScreen( when (uiState.analysisStatus) { PracticeRecordingAnalysisStatus.Ready -> PracticeRecordingReadyScreen( uiState = uiState, - practiceScript = practiceScript, onClickControl = onClickControl, onClickAnalyze = onClickAnalyze, onBack = onBack, @@ -126,7 +127,6 @@ private fun PracticeRecordingScreen( @Composable private fun PracticeRecordingReadyScreen( uiState: PracticeRecordingUiState, - practiceScript: String, onClickControl: () -> Unit, onClickAnalyze: () -> Unit, onBack: () -> Unit, @@ -142,7 +142,7 @@ private fun PracticeRecordingReadyScreen( PracticeRecordingTopAppBar(onBack = onBack) PracticeRecordingContent( - practiceScript = practiceScript, + practiceScript = uiState.practiceScript, currentSeconds = uiState.currentSeconds, totalSeconds = uiState.totalSeconds, controlState = uiState.recordingState.toControlState(), @@ -176,12 +176,6 @@ private fun PracticeRecordingTopAppBar(onBack: () -> Unit) { ) } -@Composable -private fun rememberPracticeScript(): String { - val scripts = stringArrayResource(R.array.feature_home_impl_practice_recording_scripts) - return remember { scripts.random() } -} - @Composable private fun rememberRecordAudioPermissionState(onPermissionGranted: () -> Unit): RecordAudioPermissionState { val context = LocalContext.current @@ -297,8 +291,9 @@ private fun PracticeRecordingScreenPlayingPreview() { @Composable private fun PracticeRecordingScreenPreviewContent(uiState: PracticeRecordingUiState) { PracticeRecordingScreen( - uiState = uiState, - practiceScript = "내가 그린 기린 그림은 잘 그린 기린 그림이고,\n네가 그린 기린 그림은 잘못 그린 기린 그림이다.", + uiState = uiState.copy( + practiceScript = "내가 그린 기린 그림은 잘 그린 기린 그림이고,\n네가 그린 기린 그림은 잘못 그린 기린 그림이다.", + ), onClickControl = {}, onClickAnalyze = {}, onBack = {}, diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/PracticeRecordingViewModel.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/PracticeRecordingViewModel.kt index e73e644b..e26a3c70 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/PracticeRecordingViewModel.kt +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/PracticeRecordingViewModel.kt @@ -1,6 +1,9 @@ package com.team.prezel.feature.home.impl.practice import androidx.lifecycle.viewModelScope +import com.team.prezel.core.domain.usecase.practice.FetchPracticeRecordingAnalysisResultUseCase +import com.team.prezel.core.domain.usecase.practice.FetchPracticeScriptUseCase +import com.team.prezel.core.domain.usecase.practice.UploadPracticeRecordingUseCase import com.team.prezel.core.ui.base.BaseViewModel import com.team.prezel.feature.home.impl.practice.audio.PracticeRecordingAudioControllerFactory import com.team.prezel.feature.home.impl.practice.contract.PracticeRecordingAnalysisErrorType @@ -19,6 +22,9 @@ import javax.inject.Inject @HiltViewModel internal class PracticeRecordingViewModel @Inject constructor( audioControllerFactory: PracticeRecordingAudioControllerFactory, + private val fetchPracticeScriptUseCase: FetchPracticeScriptUseCase, + private val uploadPracticeRecordingUseCase: UploadPracticeRecordingUseCase, + private val fetchPracticeRecordingAnalysisResultUseCase: FetchPracticeRecordingAnalysisResultUseCase, ) : BaseViewModel(PracticeRecordingUiState()) { private val audioController = audioControllerFactory.create() private var recordingFilePath: String? = null @@ -27,6 +33,7 @@ internal class PracticeRecordingViewModel @Inject constructor( override fun onIntent(intent: PracticeRecordingUiIntent) { when (intent) { + PracticeRecordingUiIntent.LoadPracticeScript -> fetchPracticeScript() PracticeRecordingUiIntent.ClickControl -> onClickControl() PracticeRecordingUiIntent.ClickAnalyze -> startAnalysis() } @@ -66,13 +73,7 @@ internal class PracticeRecordingViewModel @Inject constructor( analysisStatus = PracticeRecordingAnalysisStatus.Ready, ) } - viewModelScope.launch { - sendEffect( - PracticeRecordingUiEffect.ShowMessage( - PracticeRecordingUiMessage.RECORDING_START_FAILED, - ), - ) - } + sendMessage(PracticeRecordingUiMessage.RECORDING_START_FAILED) } } @@ -99,11 +100,10 @@ internal class PracticeRecordingViewModel @Inject constructor( updateState { copy( recordingState = PracticeRecordingState.Idle, - analysisStatus = PracticeRecordingAnalysisStatus.Error( - PracticeRecordingAnalysisErrorType.VOICE_RECOGNITION_FAILED, - ), + analysisStatus = PracticeRecordingAnalysisStatus.Ready, ) } + sendMessage(PracticeRecordingUiMessage.RECORDING_STOP_FAILED) } } @@ -111,7 +111,11 @@ internal class PracticeRecordingViewModel @Inject constructor( val previousState = currentState.recordingState if (previousState !is PracticeRecordingState.Recorded) return - val filePath = recordingFilePath ?: return + val filePath = recordingFilePath + if (filePath == null) { + sendMessage(PracticeRecordingUiMessage.PLAYBACK_START_FAILED) + return + } audioController .startPlayback(filePath) { @@ -142,11 +146,10 @@ internal class PracticeRecordingViewModel @Inject constructor( recordingState = PracticeRecordingState.Recorded( recordedDurationSeconds = previousState.recordedDurationSeconds, ), - analysisStatus = PracticeRecordingAnalysisStatus.Error( - PracticeRecordingAnalysisErrorType.VOICE_RECOGNITION_FAILED, - ), + analysisStatus = PracticeRecordingAnalysisStatus.Ready, ) } + sendMessage(PracticeRecordingUiMessage.PLAYBACK_START_FAILED) } } @@ -168,6 +171,7 @@ internal class PracticeRecordingViewModel @Inject constructor( private fun startAnalysis() { if (!currentState.analyzeEnabled) return + val filePath = recordingFilePath ?: return analysisJob?.cancel() analysisJob = viewModelScope.launch { @@ -177,9 +181,37 @@ internal class PracticeRecordingViewModel @Inject constructor( delay(ANALYSIS_LOADING_DELAY_MILLIS) - updateState { - copy(analysisStatus = PracticeRecordingAnalysisStatus.Success) - } + uploadPracticeRecordingUseCase(recordingFilePath = filePath) + .mapCatching { upload -> + fetchPracticeRecordingAnalysisResultUseCase(recordingId = upload.id).getOrThrow() + }.onSuccess { result -> + updateState { + copy( + analysisStatus = PracticeRecordingAnalysisStatus.Success( + result = result, + ), + ) + } + }.onFailure { + updateState { + copy( + analysisStatus = PracticeRecordingAnalysisStatus.Error( + type = PracticeRecordingAnalysisErrorType.ANALYSIS_FAILED, + ), + ) + } + } + } + } + + private fun fetchPracticeScript() { + viewModelScope.launch { + fetchPracticeScriptUseCase() + .onSuccess { script -> + updateState { + copy(practiceScript = script.content) + } + } } } @@ -224,6 +256,12 @@ internal class PracticeRecordingViewModel @Inject constructor( } } + private fun sendMessage(message: PracticeRecordingUiMessage) { + viewModelScope.launch { + sendEffect(PracticeRecordingUiEffect.ShowMessage(message)) + } + } + override fun onCleared() { timerJob?.cancel() analysisJob?.cancel() diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/component/PracticeRecordingContent.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/component/PracticeRecordingContent.kt index f6b8014f..c6a8fd0d 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/component/PracticeRecordingContent.kt +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/component/PracticeRecordingContent.kt @@ -41,7 +41,9 @@ internal fun PracticeRecordingContent( Spacer(modifier = Modifier.height(PrezelTheme.spacing.V32)) Box( - modifier = modifier + modifier = Modifier + .fillMaxWidth() + .weight(1f) .clip(RoundedCornerShape(PrezelTheme.radius.V6)) .background(PrezelTheme.colors.bgMedium) .padding(horizontal = PrezelTheme.spacing.V16, vertical = PrezelTheme.spacing.V12), diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/contract/PracticeRecordingUiIntent.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/contract/PracticeRecordingUiIntent.kt index a64f5f13..0c49192c 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/contract/PracticeRecordingUiIntent.kt +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/contract/PracticeRecordingUiIntent.kt @@ -3,6 +3,8 @@ package com.team.prezel.feature.home.impl.practice.contract import com.team.prezel.core.ui.base.UiIntent internal sealed interface PracticeRecordingUiIntent : UiIntent { + data object LoadPracticeScript : PracticeRecordingUiIntent + data object ClickControl : PracticeRecordingUiIntent data object ClickAnalyze : PracticeRecordingUiIntent diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/contract/PracticeRecordingUiState.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/contract/PracticeRecordingUiState.kt index 9a3158c3..f4429923 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/contract/PracticeRecordingUiState.kt +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/contract/PracticeRecordingUiState.kt @@ -1,10 +1,12 @@ package com.team.prezel.feature.home.impl.practice.contract import androidx.compose.runtime.Immutable +import com.team.prezel.core.model.practice.PracticeRecordingAnalysisResult import com.team.prezel.core.ui.base.UiState @Immutable internal data class PracticeRecordingUiState( + val practiceScript: String = "", val recordingState: PracticeRecordingState = PracticeRecordingState.Idle, val analysisStatus: PracticeRecordingAnalysisStatus = PracticeRecordingAnalysisStatus.Ready, ) : UiState { @@ -65,7 +67,9 @@ internal sealed interface PracticeRecordingAnalysisStatus { data object Loading : PracticeRecordingAnalysisStatus - data object Success : PracticeRecordingAnalysisStatus + data class Success( + val result: PracticeRecordingAnalysisResult, + ) : PracticeRecordingAnalysisStatus data class Error( val type: PracticeRecordingAnalysisErrorType, diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/model/PracticeRecordingUiMessage.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/model/PracticeRecordingUiMessage.kt index c6769d66..c128bf4e 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/model/PracticeRecordingUiMessage.kt +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/model/PracticeRecordingUiMessage.kt @@ -2,4 +2,6 @@ package com.team.prezel.feature.home.impl.practice.model internal enum class PracticeRecordingUiMessage { RECORDING_START_FAILED, + RECORDING_STOP_FAILED, + PLAYBACK_START_FAILED, } diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/result/PracticeRecordingResultScreen.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/result/PracticeRecordingResultScreen.kt index d685571e..3d9d261f 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/result/PracticeRecordingResultScreen.kt +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/result/PracticeRecordingResultScreen.kt @@ -2,6 +2,7 @@ package com.team.prezel.feature.home.impl.practice.result import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import com.team.prezel.core.model.practice.PracticeRecordingSpeed import com.team.prezel.feature.home.impl.practice.contract.PracticeRecordingAnalysisStatus import com.team.prezel.feature.home.impl.practice.result.component.PracticeAnalysisSpeed import com.team.prezel.feature.home.impl.practice.result.component.PracticeRecordingAnalysisFailurePage @@ -18,9 +19,9 @@ internal fun PracticeRecordingResultScreen( ) { when (analysisStatus) { PracticeRecordingAnalysisStatus.Loading -> PracticeRecordingAnalysisLoadingPage(modifier = modifier) - PracticeRecordingAnalysisStatus.Success -> PracticeRecordingResultPage( - pronunciationScore = 90, - speed = PracticeAnalysisSpeed.ADEQUATE, + is PracticeRecordingAnalysisStatus.Success -> PracticeRecordingResultPage( + pronunciationScore = analysisStatus.result.pronunciationScore, + speed = analysisStatus.result.speed.toUiModel(), onBack = onBack, onComplete = onComplete, modifier = modifier, @@ -35,3 +36,10 @@ internal fun PracticeRecordingResultScreen( PracticeRecordingAnalysisStatus.Ready -> Unit } } + +private fun PracticeRecordingSpeed.toUiModel(): PracticeAnalysisSpeed = + when (this) { + PracticeRecordingSpeed.SLOW -> PracticeAnalysisSpeed.SLOW + PracticeRecordingSpeed.ADEQUATE -> PracticeAnalysisSpeed.ADEQUATE + PracticeRecordingSpeed.FAST -> PracticeAnalysisSpeed.FAST + } diff --git a/Prezel/feature/home/impl/src/main/res/values/strings.xml b/Prezel/feature/home/impl/src/main/res/values/strings.xml index ec6f7c36..c833bec1 100644 --- a/Prezel/feature/home/impl/src/main/res/values/strings.xml +++ b/Prezel/feature/home/impl/src/main/res/values/strings.xml @@ -17,7 +17,7 @@ 행사·공개 학술·교육 업무·보고 - + 연습하기 연습 녹음 @@ -25,6 +25,8 @@ 아래 문장을 소리내어 읽어주세요. 분석하기 녹음을 시작하지 못했습니다. + 녹음을 저장하지 못했습니다. + 녹음을 재생하지 못했습니다. 분석중 잠시만 기다려주세요 분석에 실패했어요 @@ -39,16 +41,7 @@ perfect good try - - 내가 그린 기린 그림은 잘 그린 기린 그림이고,\n네가 그린 기린 그림은 잘못 그린 기린 그림이다. - 간장 공장 공장장은 강 공장장이고,\n된장 공장 공장장은 공 공장장이다. - 저기 있는 말뚝이 말 맬 말뚝이냐,\n말 못 맬 말뚝이냐. - 서울특별시 특허허가과 허가과장 허 과장. - 신진 샹송 가수의 신춘 샹송 쇼. - 작년에 온 솥 장수는 새 솥 장수이고,\n금년에 온 솥 장수는 헌 솥 장수이다. - 상표 붙인 큰 깡통은 깐 깡통인가,\n안 깐 깡통인가. - - + 데이터를 불러오지 못했습니다. From 9f90412dc7504a7ea857120504fb3fea7a94dfa7 Mon Sep 17 00:00:00 2001 From: Ham BeomJoon Date: Sat, 2 May 2026 17:15:34 +0900 Subject: [PATCH 07/27] =?UTF-8?q?feat:=20=EC=97=B0=EC=8A=B5=20=EB=85=B9?= =?UTF-8?q?=EC=9D=8C=20=ED=99=94=EB=A9=B4=20=EA=B6=8C=ED=95=9C=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=20=EB=A1=9C=EC=A7=81=20=EA=B0=9C=EC=84=A0=20=EB=B0=8F?= =?UTF-8?q?=20=EC=98=A4=EB=A5=98=20=EC=B2=98=EB=A6=AC=20=EA=B0=95=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * **feat: 마이크 권한 관리 로직 분리 및 고도화** * 권한 요청 및 상태 관리를 위한 `RecordAudioPermission.kt`를 추가하여 관련 로직을 모듈화했습니다. * `rememberRecordAudioPermissionControlClickHandler`를 통해 녹음 버튼 클릭 시 권한 상태(허용, 거부, 영구 거부)에 따른 분기 처리를 구현했습니다. * 일반 거부와 영구 거부(`shouldShowRequestPermissionRationale`)를 구분하여 각각 다른 UI 피드백을 제공하도록 개선했습니다. * **feat: 녹음 화면 UI 컴포넌트 정리 및 기능 추가** * `PracticeRecordingTopAppBar`를 별도 파일로 분리하여 코드 가독성을 높였습니다. * 대본 로드 실패, 권한 거부 등에 대한 새로운 에러 메시지(`PracticeRecordingUiMessage`)와 관련 문자열 리소스를 추가했습니다. * 스낵바 표시 시 `collectLatest`와 `dismiss()`를 사용하여 이전 메시지를 정리하고 최신 상태를 즉시 반영하도록 수정했습니다. * **refactor: PracticeRecordingViewModel 비즈니스 로직 보강** * `fetchPracticeScript` 로직을 개선하여 데이터 로드 실패 시 에러 효과(Effect)를 발생시키도록 수정했습니다. * 권한 거부 상황을 처리하기 위한 `DenyRecordAudioPermission`, `DenyRecordAudioPermissionPermanently` Intent를 추가했습니다. * 내부 메서드 명칭 변경 (`sendMessage` -> `showMessage`) 및 전반적인 예외 처리 로직을 강화했습니다. * **build: 관련 리소스 추가** * 마이크 권한 거부 및 대본 로드 실패 안내를 위한 다국어 문자열을 `strings.xml`에 추가했습니다. --- .../impl/practice/PracticeRecordingScreen.kt | 127 +++--------------- .../practice/PracticeRecordingViewModel.kt | 38 +++--- .../impl/practice/RecordAudioPermission.kt | 108 +++++++++++++++ .../component/PracticeRecordingTopAppBar.kt | 28 ++++ .../contract/PracticeRecordingUiIntent.kt | 4 + .../model/PracticeRecordingUiMessage.kt | 3 + .../home/impl/src/main/res/values/strings.xml | 3 + 7 files changed, 190 insertions(+), 121 deletions(-) create mode 100644 Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/RecordAudioPermission.kt create mode 100644 Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/component/PracticeRecordingTopAppBar.kt diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/PracticeRecordingScreen.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/PracticeRecordingScreen.kt index f60905ce..83df99ca 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/PracticeRecordingScreen.kt +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/PracticeRecordingScreen.kt @@ -1,41 +1,25 @@ package com.team.prezel.feature.home.impl.practice -import android.Manifest -import android.content.pm.PackageManager import androidx.activity.compose.BackHandler -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.background import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberUpdatedState -import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalResources -import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import androidx.core.content.ContextCompat import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.team.prezel.core.designsystem.component.PrezelTopAppBar import com.team.prezel.core.designsystem.component.actions.area.PrezelButtonArea import com.team.prezel.core.designsystem.component.feedback.snackbar.showPrezelSnackbar -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.core.ui.state.LocalSnackbarHostState import com.team.prezel.feature.home.impl.R import com.team.prezel.feature.home.impl.practice.component.PracticeRecordingContent +import com.team.prezel.feature.home.impl.practice.component.PracticeRecordingTopAppBar import com.team.prezel.feature.home.impl.practice.component.toControlState import com.team.prezel.feature.home.impl.practice.contract.PracticeRecordingAnalysisStatus import com.team.prezel.feature.home.impl.practice.contract.PracticeRecordingState @@ -44,6 +28,7 @@ import com.team.prezel.feature.home.impl.practice.contract.PracticeRecordingUiIn import com.team.prezel.feature.home.impl.practice.contract.PracticeRecordingUiState import com.team.prezel.feature.home.impl.practice.model.PracticeRecordingUiMessage import com.team.prezel.feature.home.impl.practice.result.PracticeRecordingResultScreen +import kotlinx.coroutines.flow.collectLatest @Composable internal fun PracticeRecordingScreen( @@ -55,14 +40,13 @@ internal fun PracticeRecordingScreen( val uiState by viewModel.uiState.collectAsStateWithLifecycle() val resources = LocalResources.current val snackbarHostState = LocalSnackbarHostState.current - val recordAudioPermissionState = rememberRecordAudioPermissionState( - onPermissionGranted = { viewModel.onIntent(PracticeRecordingUiIntent.ClickControl) }, - ) - val onClickRecordingControl = rememberClickRecordingControlHandler( + val onClickRecordingControl = rememberRecordAudioPermissionControlClickHandler( recordingState = uiState.recordingState, - hasRecordAudioPermission = recordAudioPermissionState.isGranted, - onRequestRecordAudioPermission = recordAudioPermissionState.request, onClickControl = { viewModel.onIntent(PracticeRecordingUiIntent.ClickControl) }, + onPermissionDenied = { viewModel.onIntent(PracticeRecordingUiIntent.DenyRecordAudioPermission) }, + onPermissionPermanentlyDenied = { + viewModel.onIntent(PracticeRecordingUiIntent.DenyRecordAudioPermissionPermanently) + }, ) LaunchedEffect(Unit) { @@ -70,15 +54,13 @@ internal fun PracticeRecordingScreen( } LaunchedEffect(Unit) { - viewModel.uiEffect.collect { effect -> + viewModel.uiEffect.collectLatest { effect -> when (effect) { is PracticeRecordingUiEffect.ShowMessage -> { - val resId = when (effect.message) { - PracticeRecordingUiMessage.RECORDING_START_FAILED -> R.string.feature_home_impl_practice_recording_failed - PracticeRecordingUiMessage.RECORDING_STOP_FAILED -> R.string.feature_home_impl_practice_recording_stop_failed - PracticeRecordingUiMessage.PLAYBACK_START_FAILED -> R.string.feature_home_impl_practice_recording_playback_failed - } - snackbarHostState.showPrezelSnackbar(message = resources.getString(resId)) + snackbarHostState.currentSnackbarData?.dismiss() + snackbarHostState.showPrezelSnackbar( + message = resources.getString(effect.message.resId), + ) } } } @@ -150,7 +132,7 @@ private fun PracticeRecordingReadyScreen( modifier = Modifier.weight(1f), ) - PrezelButtonArea(modifier = modifier) { + PrezelButtonArea { MainButton( label = analyzeLabel, enabled = uiState.analyzeEnabled, @@ -160,81 +142,16 @@ private fun PracticeRecordingReadyScreen( } } -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun PracticeRecordingTopAppBar(onBack: () -> Unit) { - PrezelTopAppBar( - title = { Text(text = stringResource(R.string.feature_home_impl_practice_recording_title)) }, - leadingIcon = { - IconButton(onClick = onBack) { - Icon( - painter = painterResource(PrezelIcons.ArrowLeft), - contentDescription = stringResource(R.string.feature_home_impl_practice_recording_back), - ) - } - }, - ) -} - -@Composable -private fun rememberRecordAudioPermissionState(onPermissionGranted: () -> Unit): RecordAudioPermissionState { - val context = LocalContext.current - val currentOnPermissionGranted by rememberUpdatedState(onPermissionGranted) - var hasRecordAudioPermission by remember { - mutableStateOf( - ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED, - ) - } - - val launcher = rememberLauncherForActivityResult( - contract = ActivityResultContracts.RequestPermission(), - ) { isGranted -> - hasRecordAudioPermission = isGranted - if (isGranted) currentOnPermissionGranted() - } +private val PracticeRecordingUiMessage.resId: Int + get() = when (this) { + PracticeRecordingUiMessage.FETCH_PRACTICE_SCRIPT_FAILED -> R.string.feature_home_impl_practice_recording_fetch_script_failed + PracticeRecordingUiMessage.RECORD_AUDIO_PERMISSION_DENIED -> R.string.feature_home_impl_practice_recording_permission_denied + PracticeRecordingUiMessage.RECORD_AUDIO_PERMISSION_PERMANENTLY_DENIED -> + R.string.feature_home_impl_practice_recording_permission_permanently_denied - return remember(hasRecordAudioPermission, launcher) { - RecordAudioPermissionState( - isGranted = hasRecordAudioPermission, - request = { launcher.launch(Manifest.permission.RECORD_AUDIO) }, - ) - } -} - -private data class RecordAudioPermissionState( - val isGranted: Boolean, - val request: () -> Unit, -) - -@Composable -private fun rememberClickRecordingControlHandler( - recordingState: PracticeRecordingState, - hasRecordAudioPermission: Boolean, - onRequestRecordAudioPermission: () -> Unit, - onClickControl: () -> Unit, -): () -> Unit = - remember( - recordingState, - hasRecordAudioPermission, - onRequestRecordAudioPermission, - onClickControl, - ) { - { - when (recordingState) { - PracticeRecordingState.Idle -> { - if (hasRecordAudioPermission) { - onClickControl() - } else { - onRequestRecordAudioPermission() - } - } - - is PracticeRecordingState.Recording, - is PracticeRecordingState.Recorded, - is PracticeRecordingState.Playing, - -> onClickControl() - } - } + PracticeRecordingUiMessage.RECORDING_START_FAILED -> R.string.feature_home_impl_practice_recording_failed + PracticeRecordingUiMessage.RECORDING_STOP_FAILED -> R.string.feature_home_impl_practice_recording_stop_failed + PracticeRecordingUiMessage.PLAYBACK_START_FAILED -> R.string.feature_home_impl_practice_recording_playback_failed } @BasicPreview diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/PracticeRecordingViewModel.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/PracticeRecordingViewModel.kt index e26a3c70..4da729c4 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/PracticeRecordingViewModel.kt +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/PracticeRecordingViewModel.kt @@ -34,11 +34,28 @@ internal class PracticeRecordingViewModel @Inject constructor( override fun onIntent(intent: PracticeRecordingUiIntent) { when (intent) { PracticeRecordingUiIntent.LoadPracticeScript -> fetchPracticeScript() + PracticeRecordingUiIntent.DenyRecordAudioPermission -> showMessage(PracticeRecordingUiMessage.RECORD_AUDIO_PERMISSION_DENIED) + PracticeRecordingUiIntent.DenyRecordAudioPermissionPermanently -> showMessage( + PracticeRecordingUiMessage.RECORD_AUDIO_PERMISSION_PERMANENTLY_DENIED, + ) PracticeRecordingUiIntent.ClickControl -> onClickControl() PracticeRecordingUiIntent.ClickAnalyze -> startAnalysis() } } + private fun fetchPracticeScript() { + viewModelScope.launch { + fetchPracticeScriptUseCase() + .onSuccess { script -> + updateState { + copy(practiceScript = script.content) + } + }.onFailure { + showMessage(PracticeRecordingUiMessage.FETCH_PRACTICE_SCRIPT_FAILED) + } + } + } + private fun onClickControl() { when (currentState.recordingState) { PracticeRecordingState.Idle -> startRecording() @@ -73,7 +90,7 @@ internal class PracticeRecordingViewModel @Inject constructor( analysisStatus = PracticeRecordingAnalysisStatus.Ready, ) } - sendMessage(PracticeRecordingUiMessage.RECORDING_START_FAILED) + showMessage(PracticeRecordingUiMessage.RECORDING_START_FAILED) } } @@ -103,7 +120,7 @@ internal class PracticeRecordingViewModel @Inject constructor( analysisStatus = PracticeRecordingAnalysisStatus.Ready, ) } - sendMessage(PracticeRecordingUiMessage.RECORDING_STOP_FAILED) + showMessage(PracticeRecordingUiMessage.RECORDING_STOP_FAILED) } } @@ -113,7 +130,7 @@ internal class PracticeRecordingViewModel @Inject constructor( val filePath = recordingFilePath if (filePath == null) { - sendMessage(PracticeRecordingUiMessage.PLAYBACK_START_FAILED) + showMessage(PracticeRecordingUiMessage.PLAYBACK_START_FAILED) return } @@ -149,7 +166,7 @@ internal class PracticeRecordingViewModel @Inject constructor( analysisStatus = PracticeRecordingAnalysisStatus.Ready, ) } - sendMessage(PracticeRecordingUiMessage.PLAYBACK_START_FAILED) + showMessage(PracticeRecordingUiMessage.PLAYBACK_START_FAILED) } } @@ -204,17 +221,6 @@ internal class PracticeRecordingViewModel @Inject constructor( } } - private fun fetchPracticeScript() { - viewModelScope.launch { - fetchPracticeScriptUseCase() - .onSuccess { script -> - updateState { - copy(practiceScript = script.content) - } - } - } - } - private fun startRecordingTimer() { timerJob?.cancel() timerJob = viewModelScope.launch { @@ -256,7 +262,7 @@ internal class PracticeRecordingViewModel @Inject constructor( } } - private fun sendMessage(message: PracticeRecordingUiMessage) { + private fun showMessage(message: PracticeRecordingUiMessage) { viewModelScope.launch { sendEffect(PracticeRecordingUiEffect.ShowMessage(message)) } diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/RecordAudioPermission.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/RecordAudioPermission.kt new file mode 100644 index 00000000..cf3cfcb2 --- /dev/null +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/RecordAudioPermission.kt @@ -0,0 +1,108 @@ +package com.team.prezel.feature.home.impl.practice + +import android.Manifest +import android.content.pm.PackageManager +import androidx.activity.compose.LocalActivity +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalContext +import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat +import com.team.prezel.feature.home.impl.practice.contract.PracticeRecordingState + +@Composable +internal fun rememberRecordAudioPermissionControlClickHandler( + recordingState: PracticeRecordingState, + onClickControl: () -> Unit, + onPermissionDenied: () -> Unit, + onPermissionPermanentlyDenied: () -> Unit, +): () -> Unit { + val permissionRequest = rememberRecordAudioPermissionRequest( + onPermissionGranted = onClickControl, + onPermissionDenied = onPermissionDenied, + onPermissionPermanentlyDenied = onPermissionPermanentlyDenied, + ) + val currentOnClickControl by rememberUpdatedState(onClickControl) + + return remember(recordingState, permissionRequest) { + { + when (recordingState) { + PracticeRecordingState.Idle -> { + when { + permissionRequest.isGranted -> currentOnClickControl() + permissionRequest.isPermanentlyDenied -> permissionRequest.onPermanentlyDenied() + else -> permissionRequest.launch() + } + } + + is PracticeRecordingState.Recording, + is PracticeRecordingState.Recorded, + is PracticeRecordingState.Playing, + -> currentOnClickControl() + } + } + } +} + +@Composable +private fun rememberRecordAudioPermissionRequest( + onPermissionGranted: () -> Unit, + onPermissionDenied: () -> Unit, + onPermissionPermanentlyDenied: () -> Unit, +): RecordAudioPermissionRequest { + val context = LocalContext.current + val activity = LocalActivity.current + val currentOnPermissionGranted by rememberUpdatedState(onPermissionGranted) + val currentOnPermissionDenied by rememberUpdatedState(onPermissionDenied) + val currentOnPermissionPermanentlyDenied by rememberUpdatedState(onPermissionPermanentlyDenied) + var hasRecordAudioPermission by remember { + mutableStateOf( + ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED, + ) + } + var isPermanentlyDenied by remember { mutableStateOf(false) } + + val launcher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestPermission(), + ) { isGranted -> + hasRecordAudioPermission = isGranted + if (isGranted) { + isPermanentlyDenied = false + currentOnPermissionGranted() + } else { + isPermanentlyDenied = activity?.let { + !ActivityCompat.shouldShowRequestPermissionRationale( + it, + Manifest.permission.RECORD_AUDIO, + ) + } == true + if (isPermanentlyDenied) { + currentOnPermissionPermanentlyDenied() + } else { + currentOnPermissionDenied() + } + } + } + + return remember(hasRecordAudioPermission, isPermanentlyDenied, launcher) { + RecordAudioPermissionRequest( + isGranted = hasRecordAudioPermission, + isPermanentlyDenied = isPermanentlyDenied, + launch = { launcher.launch(Manifest.permission.RECORD_AUDIO) }, + onPermanentlyDenied = currentOnPermissionPermanentlyDenied, + ) + } +} + +private data class RecordAudioPermissionRequest( + val isGranted: Boolean, + val isPermanentlyDenied: Boolean, + val launch: () -> Unit, + val onPermanentlyDenied: () -> Unit, +) diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/component/PracticeRecordingTopAppBar.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/component/PracticeRecordingTopAppBar.kt new file mode 100644 index 00000000..3c5c5c18 --- /dev/null +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/component/PracticeRecordingTopAppBar.kt @@ -0,0 +1,28 @@ +package com.team.prezel.feature.home.impl.practice.component + +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import com.team.prezel.core.designsystem.component.PrezelTopAppBar +import com.team.prezel.core.designsystem.icon.PrezelIcons +import com.team.prezel.feature.home.impl.R + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun PracticeRecordingTopAppBar(onBack: () -> Unit) { + PrezelTopAppBar( + title = { Text(text = stringResource(R.string.feature_home_impl_practice_recording_title)) }, + leadingIcon = { + IconButton(onClick = onBack) { + Icon( + painter = painterResource(PrezelIcons.ArrowLeft), + contentDescription = stringResource(R.string.feature_home_impl_practice_recording_back), + ) + } + }, + ) +} diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/contract/PracticeRecordingUiIntent.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/contract/PracticeRecordingUiIntent.kt index 0c49192c..19a8d57a 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/contract/PracticeRecordingUiIntent.kt +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/contract/PracticeRecordingUiIntent.kt @@ -5,6 +5,10 @@ import com.team.prezel.core.ui.base.UiIntent internal sealed interface PracticeRecordingUiIntent : UiIntent { data object LoadPracticeScript : PracticeRecordingUiIntent + data object DenyRecordAudioPermission : PracticeRecordingUiIntent + + data object DenyRecordAudioPermissionPermanently : PracticeRecordingUiIntent + data object ClickControl : PracticeRecordingUiIntent data object ClickAnalyze : PracticeRecordingUiIntent diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/model/PracticeRecordingUiMessage.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/model/PracticeRecordingUiMessage.kt index c128bf4e..619c4120 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/model/PracticeRecordingUiMessage.kt +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/model/PracticeRecordingUiMessage.kt @@ -1,6 +1,9 @@ package com.team.prezel.feature.home.impl.practice.model internal enum class PracticeRecordingUiMessage { + FETCH_PRACTICE_SCRIPT_FAILED, + RECORD_AUDIO_PERMISSION_DENIED, + RECORD_AUDIO_PERMISSION_PERMANENTLY_DENIED, RECORDING_START_FAILED, RECORDING_STOP_FAILED, PLAYBACK_START_FAILED, diff --git a/Prezel/feature/home/impl/src/main/res/values/strings.xml b/Prezel/feature/home/impl/src/main/res/values/strings.xml index c833bec1..8932898a 100644 --- a/Prezel/feature/home/impl/src/main/res/values/strings.xml +++ b/Prezel/feature/home/impl/src/main/res/values/strings.xml @@ -24,6 +24,9 @@ 뒤로가기 아래 문장을 소리내어 읽어주세요. 분석하기 + 대본을 불러오지 못했습니다. + 마이크 권한이 필요합니다. + 설정에서 마이크 권한을 허용해 주세요. 녹음을 시작하지 못했습니다. 녹음을 저장하지 못했습니다. 녹음을 재생하지 못했습니다. From cf07025a5d3d4de1aa92511c1a02552a0559f55c Mon Sep 17 00:00:00 2001 From: Ham BeomJoon Date: Sat, 2 May 2026 18:57:41 +0900 Subject: [PATCH 08/27] =?UTF-8?q?feat:=20=EC=97=B0=EC=8A=B5(Practice)=20?= =?UTF-8?q?=EA=B4=80=EB=A0=A8=20UseCase=20KDoc=20=EC=A3=BC=EC=84=9D=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs: 연습 관련 UseCase 클래스에 KDoc 주석 추가 `core:domain` 모듈 내 연습(Practice) 기능과 관련된 UseCase 클래스들에 동작 흐름과 역할을 설명하는 한글 주석을 추가했습니다. * `FetchPracticeRecordingAnalysisResultUseCase`: 녹음본 ID를 기반으로 분석 결과를 조회하는 로직 설명 추가 * `UploadPracticeRecordingUseCase`: 녹음본 파일 업로드 요청 처리 로직 설명 추가 * `FetchPracticeScriptUseCase`: 연습용 대본 조회 요청 처리 로직 설명 추가 --- .../FetchPracticeRecordingAnalysisResultUseCase.kt | 8 ++++++++ .../domain/usecase/practice/FetchPracticeScriptUseCase.kt | 8 ++++++++ .../usecase/practice/UploadPracticeRecordingUseCase.kt | 8 ++++++++ 3 files changed, 24 insertions(+) diff --git a/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/practice/FetchPracticeRecordingAnalysisResultUseCase.kt b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/practice/FetchPracticeRecordingAnalysisResultUseCase.kt index f9cd63d9..af3615c0 100644 --- a/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/practice/FetchPracticeRecordingAnalysisResultUseCase.kt +++ b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/practice/FetchPracticeRecordingAnalysisResultUseCase.kt @@ -4,6 +4,14 @@ import com.team.prezel.core.domain.repository.practice.PracticeRepository import com.team.prezel.core.model.practice.PracticeRecordingAnalysisResult import javax.inject.Inject +/** + * 업로드된 연습 녹음본의 분석 결과를 조회하는 UseCase. + * + * ### 동작 흐름 + * 1. 호출부로부터 전달받은 녹음본 ID를 입력값으로 받습니다. + * 2. [com.team.prezel.core.domain.repository.practice.PracticeRepository.fetchPracticeRecordingAnalysisResult]를 호출하여 분석 결과 조회를 요청합니다. + * 3. 조회 결과에 따라 분석 결과 또는 예외를 포함한 [Result]를 반환합니다. + */ class FetchPracticeRecordingAnalysisResultUseCase @Inject constructor( private val practiceRepository: PracticeRepository, ) { diff --git a/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/practice/FetchPracticeScriptUseCase.kt b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/practice/FetchPracticeScriptUseCase.kt index f8e16653..0fc0ade0 100644 --- a/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/practice/FetchPracticeScriptUseCase.kt +++ b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/practice/FetchPracticeScriptUseCase.kt @@ -4,6 +4,14 @@ import com.team.prezel.core.domain.repository.practice.PracticeRepository import com.team.prezel.core.model.practice.PracticeScript import javax.inject.Inject +/** + * 연습 녹음에 사용할 대본을 조회하는 UseCase. + * + * ### 동작 흐름 + * 1. [com.team.prezel.core.domain.repository.practice.PracticeRepository.fetchPracticeScript]를 호출하여 연습 대본 조회를 요청합니다. + * 2. repository가 서버 또는 임시 데이터 소스로부터 대본을 가져옵니다. + * 3. 조회 결과에 따라 연습 대본 또는 예외를 포함한 [Result]를 반환합니다. + */ class FetchPracticeScriptUseCase @Inject constructor( private val practiceRepository: PracticeRepository, ) { diff --git a/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/practice/UploadPracticeRecordingUseCase.kt b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/practice/UploadPracticeRecordingUseCase.kt index 3805af18..7f48476a 100644 --- a/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/practice/UploadPracticeRecordingUseCase.kt +++ b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/practice/UploadPracticeRecordingUseCase.kt @@ -4,6 +4,14 @@ import com.team.prezel.core.domain.repository.practice.PracticeRepository import com.team.prezel.core.model.practice.PracticeRecordingUpload import javax.inject.Inject +/** + * 연습 녹음본 파일을 업로드하는 UseCase. + * + * ### 동작 흐름 + * 1. 호출부로부터 전달받은 녹음본 파일 경로를 입력값으로 받습니다. + * 2. [com.team.prezel.core.domain.repository.practice.PracticeRepository.uploadPracticeRecording]을 호출하여 녹음본 업로드를 요청합니다. + * 3. 업로드 결과에 따라 녹음본 업로드 정보 또는 예외를 포함한 [Result]를 반환합니다. + */ class UploadPracticeRecordingUseCase @Inject constructor( private val practiceRepository: PracticeRepository, ) { From 52331c62d6444889dc89b51002d8968afd7bb35d Mon Sep 17 00:00:00 2001 From: Ham BeomJoon Date: Sat, 2 May 2026 20:51:08 +0900 Subject: [PATCH 09/27] =?UTF-8?q?refactor:=20=EB=85=B9=EC=9D=8C=20?= =?UTF-8?q?=EA=B6=8C=ED=95=9C=20=EC=9A=94=EC=B2=AD=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EA=B0=84=EC=86=8C=ED=99=94=20=EB=B0=8F=20=EC=98=A4=EB=94=94?= =?UTF-8?q?=EC=98=A4=20=EC=BB=A8=ED=8A=B8=EB=A1=A4=EB=9F=AC=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=20=EC=B4=88=EA=B8=B0=ED=99=94=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * **refactor: `RecordAudioPermission` 관련 Compose 코드 최적화** * 불필요한 `remember` 및 `rememberUpdatedState`를 제거하여 권한 요청 람다와 `RecordAudioPermissionRequest` 생성 로직을 단순화했습니다. * 상태 변경에 따른 불필요한 리컴포지션 오버헤드를 줄였습니다. * **fix: `PracticeRecordingAudioController` 리소스 해제 로직 보완** * `releaseRecorder()` 호출 시 `recordingStartedAt` 시간을 0으로 초기화하도록 수정하여, 녹음기 해제 후 발생할 수 있는 시간 계산 오류를 방지했습니다. --- .../impl/practice/RecordAudioPermission.kt | 41 ++++++++----------- .../audio/PracticeRecordingAudioController.kt | 1 + 2 files changed, 19 insertions(+), 23 deletions(-) diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/RecordAudioPermission.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/RecordAudioPermission.kt index cf3cfcb2..f4ef45f9 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/RecordAudioPermission.kt +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/RecordAudioPermission.kt @@ -28,24 +28,21 @@ internal fun rememberRecordAudioPermissionControlClickHandler( onPermissionDenied = onPermissionDenied, onPermissionPermanentlyDenied = onPermissionPermanentlyDenied, ) - val currentOnClickControl by rememberUpdatedState(onClickControl) - return remember(recordingState, permissionRequest) { - { - when (recordingState) { - PracticeRecordingState.Idle -> { - when { - permissionRequest.isGranted -> currentOnClickControl() - permissionRequest.isPermanentlyDenied -> permissionRequest.onPermanentlyDenied() - else -> permissionRequest.launch() - } + return { + when (recordingState) { + PracticeRecordingState.Idle -> { + when { + permissionRequest.isGranted -> onClickControl() + permissionRequest.isPermanentlyDenied -> permissionRequest.onPermanentlyDenied() + else -> permissionRequest.launch() } - - is PracticeRecordingState.Recording, - is PracticeRecordingState.Recorded, - is PracticeRecordingState.Playing, - -> currentOnClickControl() } + + is PracticeRecordingState.Recording, + is PracticeRecordingState.Recorded, + is PracticeRecordingState.Playing, + -> onClickControl() } } } @@ -90,14 +87,12 @@ private fun rememberRecordAudioPermissionRequest( } } - return remember(hasRecordAudioPermission, isPermanentlyDenied, launcher) { - RecordAudioPermissionRequest( - isGranted = hasRecordAudioPermission, - isPermanentlyDenied = isPermanentlyDenied, - launch = { launcher.launch(Manifest.permission.RECORD_AUDIO) }, - onPermanentlyDenied = currentOnPermissionPermanentlyDenied, - ) - } + return RecordAudioPermissionRequest( + isGranted = hasRecordAudioPermission, + isPermanentlyDenied = isPermanentlyDenied, + launch = { launcher.launch(Manifest.permission.RECORD_AUDIO) }, + onPermanentlyDenied = currentOnPermissionPermanentlyDenied, + ) } private data class RecordAudioPermissionRequest( diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/audio/PracticeRecordingAudioController.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/audio/PracticeRecordingAudioController.kt index 1931dcfd..6841005b 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/audio/PracticeRecordingAudioController.kt +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/audio/PracticeRecordingAudioController.kt @@ -111,6 +111,7 @@ internal class PracticeRecordingAudioController( private fun releaseRecorder() { recorder?.release() recorder = null + recordingStartedAt = 0L } } From 97b3d742dc948de17e468a88adf2444e87764238 Mon Sep 17 00:00:00 2001 From: Ham BeomJoon Date: Sun, 3 May 2026 22:03:24 +0900 Subject: [PATCH 10/27] =?UTF-8?q?feat:=20=EA=B3=B5=ED=86=B5=20=EA=B6=8C?= =?UTF-8?q?=ED=95=9C=20=EC=9A=94=EC=B2=AD=20=EC=9C=A0=ED=8B=B8=EB=A6=AC?= =?UTF-8?q?=ED=8B=B0=20=EA=B5=AC=ED=98=84=20=EB=B0=8F=20=EC=98=A4=EB=94=94?= =?UTF-8?q?=EC=98=A4=20=EB=85=B9=EC=9D=8C=20=EB=A1=9C=EC=A7=81=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * **feat: 공통 권한 요청 `rememberPermissionRequest` 구현** * 기존 `RecordAudioPermission` 내부에 중복되어 있던 권한 요청 로직을 `core:ui` 모듈의 공통 유틸리티 클래스로 추출했습니다. * `LifecycleEventObserver`를 추가하여 앱이 다시 활성화(`ON_RESUME`)될 때 권한 상태를 자동으로 동기화하도록 개선했습니다. * 권한 허용 여부, 영구 거부 상태를 추적하고 콜백을 관리하는 `PermissionRequest` 데이터 클래스를 정의했습니다. * **refactor: `RecordAudioPermission` 리팩터링** * 새로 구현한 공통 유틸리티 `rememberPermissionRequest`를 사용하도록 기존 코드를 제거하고 구조를 단순화했습니다. * **fix: `PracticeRecordingAudioController` 리소스 관리 및 예외 처리 강화** * 새로운 녹음을 시작할 때 이전 임시 파일을 삭제하도록 수정했습니다. * `stopRecording` 호출 시 녹음기가 비활성 상태인 경우에 대한 예외 처리를 추가했습니다. * `release` 호출 시 현재 녹음 중인 파일을 명시적으로 삭제하도록 리소스 관리 로직을 보완했습니다. --- .../prezel/core/ui/util/PermissionRequest.kt | 100 ++++++++++++++++++ .../impl/practice/RecordAudioPermission.kt | 71 +------------ .../audio/PracticeRecordingAudioController.kt | 8 ++ 3 files changed, 111 insertions(+), 68 deletions(-) create mode 100644 Prezel/core/ui/src/main/java/com/team/prezel/core/ui/util/PermissionRequest.kt diff --git a/Prezel/core/ui/src/main/java/com/team/prezel/core/ui/util/PermissionRequest.kt b/Prezel/core/ui/src/main/java/com/team/prezel/core/ui/util/PermissionRequest.kt new file mode 100644 index 00000000..ddc214cd --- /dev/null +++ b/Prezel/core/ui/src/main/java/com/team/prezel/core/ui/util/PermissionRequest.kt @@ -0,0 +1,100 @@ +package com.team.prezel.core.ui.util + +import android.content.pm.PackageManager +import androidx.activity.compose.LocalActivity +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalContext +import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner + +@Composable +fun rememberPermissionRequest( + permission: String, + onPermissionGranted: () -> Unit, + onPermissionDenied: () -> Unit, + onPermissionPermanentlyDenied: () -> Unit, +): PermissionRequest { + val context = LocalContext.current + val activity = LocalActivity.current + val lifecycleOwner = LocalLifecycleOwner.current + val currentOnPermissionGranted by rememberUpdatedState(onPermissionGranted) + val currentOnPermissionDenied by rememberUpdatedState(onPermissionDenied) + val currentOnPermissionPermanentlyDenied by rememberUpdatedState(onPermissionPermanentlyDenied) + var isGranted by remember(permission) { + mutableStateOf( + ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED, + ) + } + var hasRequestedPermission by remember(permission) { mutableStateOf(false) } + var isPermanentlyDenied by remember(permission) { mutableStateOf(false) } + + fun syncPermissionState() { + val syncedIsGranted = ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED + isGranted = syncedIsGranted + isPermanentlyDenied = if (syncedIsGranted) { + false + } else { + hasRequestedPermission && + activity?.let { + !ActivityCompat.shouldShowRequestPermissionRationale(it, permission) + } == true + } + } + + DisposableEffect(lifecycleOwner, context, activity, permission) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { + syncPermissionState() + } + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { + lifecycleOwner.lifecycle.removeObserver(observer) + } + } + + val launcher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestPermission(), + ) { launcherIsGranted -> + hasRequestedPermission = true + isGranted = launcherIsGranted + if (launcherIsGranted) { + isPermanentlyDenied = false + currentOnPermissionGranted() + } else { + isPermanentlyDenied = activity?.let { + !ActivityCompat.shouldShowRequestPermissionRationale(it, permission) + } == true + if (isPermanentlyDenied) { + currentOnPermissionPermanentlyDenied() + } else { + currentOnPermissionDenied() + } + } + } + + return PermissionRequest( + isGranted = isGranted, + isPermanentlyDenied = isPermanentlyDenied, + launch = { launcher.launch(permission) }, + onPermanentlyDenied = currentOnPermissionPermanentlyDenied, + ) +} + +data class PermissionRequest( + val isGranted: Boolean, + val isPermanentlyDenied: Boolean, + val launch: () -> Unit, + val onPermanentlyDenied: () -> Unit, +) diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/RecordAudioPermission.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/RecordAudioPermission.kt index f4ef45f9..f4d1bd64 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/RecordAudioPermission.kt +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/RecordAudioPermission.kt @@ -1,19 +1,8 @@ package com.team.prezel.feature.home.impl.practice import android.Manifest -import android.content.pm.PackageManager -import androidx.activity.compose.LocalActivity -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberUpdatedState -import androidx.compose.runtime.setValue -import androidx.compose.ui.platform.LocalContext -import androidx.core.app.ActivityCompat -import androidx.core.content.ContextCompat +import com.team.prezel.core.ui.util.rememberPermissionRequest import com.team.prezel.feature.home.impl.practice.contract.PracticeRecordingState @Composable @@ -23,7 +12,8 @@ internal fun rememberRecordAudioPermissionControlClickHandler( onPermissionDenied: () -> Unit, onPermissionPermanentlyDenied: () -> Unit, ): () -> Unit { - val permissionRequest = rememberRecordAudioPermissionRequest( + val permissionRequest = rememberPermissionRequest( + permission = Manifest.permission.RECORD_AUDIO, onPermissionGranted = onClickControl, onPermissionDenied = onPermissionDenied, onPermissionPermanentlyDenied = onPermissionPermanentlyDenied, @@ -46,58 +36,3 @@ internal fun rememberRecordAudioPermissionControlClickHandler( } } } - -@Composable -private fun rememberRecordAudioPermissionRequest( - onPermissionGranted: () -> Unit, - onPermissionDenied: () -> Unit, - onPermissionPermanentlyDenied: () -> Unit, -): RecordAudioPermissionRequest { - val context = LocalContext.current - val activity = LocalActivity.current - val currentOnPermissionGranted by rememberUpdatedState(onPermissionGranted) - val currentOnPermissionDenied by rememberUpdatedState(onPermissionDenied) - val currentOnPermissionPermanentlyDenied by rememberUpdatedState(onPermissionPermanentlyDenied) - var hasRecordAudioPermission by remember { - mutableStateOf( - ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED, - ) - } - var isPermanentlyDenied by remember { mutableStateOf(false) } - - val launcher = rememberLauncherForActivityResult( - contract = ActivityResultContracts.RequestPermission(), - ) { isGranted -> - hasRecordAudioPermission = isGranted - if (isGranted) { - isPermanentlyDenied = false - currentOnPermissionGranted() - } else { - isPermanentlyDenied = activity?.let { - !ActivityCompat.shouldShowRequestPermissionRationale( - it, - Manifest.permission.RECORD_AUDIO, - ) - } == true - if (isPermanentlyDenied) { - currentOnPermissionPermanentlyDenied() - } else { - currentOnPermissionDenied() - } - } - } - - return RecordAudioPermissionRequest( - isGranted = hasRecordAudioPermission, - isPermanentlyDenied = isPermanentlyDenied, - launch = { launcher.launch(Manifest.permission.RECORD_AUDIO) }, - onPermanentlyDenied = currentOnPermissionPermanentlyDenied, - ) -} - -private data class RecordAudioPermissionRequest( - val isGranted: Boolean, - val isPermanentlyDenied: Boolean, - val launch: () -> Unit, - val onPermanentlyDenied: () -> Unit, -) diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/audio/PracticeRecordingAudioController.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/audio/PracticeRecordingAudioController.kt index 6841005b..37ab089c 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/audio/PracticeRecordingAudioController.kt +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/audio/PracticeRecordingAudioController.kt @@ -21,6 +21,7 @@ internal class PracticeRecordingAudioController( runCatching { stopPlayback() releaseRecorder() + val previousRecordingFile = recordingFile val file = File.createTempFile("practice_recording_", ".m4a", context.cacheDir) var pendingRecorder: MediaRecorder? = null @@ -44,10 +45,15 @@ internal class PracticeRecordingAudioController( recorder = newRecorder recordingFile = file recordingStartedAt = System.currentTimeMillis() + previousRecordingFile?.delete() file.absolutePath } fun stopRecording(): Result { + if (recorder == null || recordingStartedAt <= 0L) { + return Result.failure(IllegalStateException("Recording is not active.")) + } + val durationSeconds = ((System.currentTimeMillis() - recordingStartedAt) / 1_000L).toInt() return runCatching { @@ -98,6 +104,8 @@ internal class PracticeRecordingAudioController( fun release() { releaseRecorder() stopPlayback() + recordingFile?.delete() + recordingFile = null } @Suppress("DEPRECATION") From 3fc366302ea12bbe711bb235aeae89e823003bcf Mon Sep 17 00:00:00 2001 From: Ham BeomJoon Date: Mon, 4 May 2026 18:04:19 +0900 Subject: [PATCH 11/27] =?UTF-8?q?feat:=20=EC=97=B0=EC=8A=B5=20=EB=85=B9?= =?UTF-8?q?=EC=9D=8C=20=EB=B6=84=EC=84=9D=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20=EB=B0=8F=20=EC=98=A4=EB=94=94=EC=98=A4=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EB=A6=AC=ED=8C=A9=ED=84=B0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * **feat: `AnalyzePracticeRecordingUseCase` 및 관련 모델 추가** * 녹음본 업로드와 분석 결과 조회를 통합 수행하는 `AnalyzePracticeRecordingUseCase`를 추가했습니다. * UI 레이어에서 사용할 분석 상태(`PracticeRecordingAnalysisStatus`) 및 모델(`PracticeRecordingAnalysisUiModel`)을 정의했습니다. * `PracticeRecordingState` 및 분석 관련 모델들을 별도 패키지(`model`)로 분리하여 구조를 개선했습니다. * **feat: `RecordingAudioController` 추상화 및 구현** * 오디오 녹음 및 재생 로직을 인터페이스(`RecordingAudioController`)로 추상화했습니다. * `MediaRecorder`와 `MediaPlayer`를 사용하는 `MediaRecordingAudioController` 구현체를 추가하고, Hilt를 이용한 의존성 주입(`RecordingAudioModule`)을 설정했습니다. * **refactor: `PracticeRecordingViewModel` 로직 개선** * 녹음 분석 시 `AnalyzePracticeRecordingUseCase`를 사용하도록 변경했습니다. * Intent 명칭을 보다 명확하게 변경했습니다. (`ClickControl` -> `ToggleRecordingControl`, `ClickAnalyze` -> `AnalyzeClicked` 등) * 분석 시작 시 재생 중인 오디오를 중지하는 로직을 추가했습니다. * **refactor: `PracticeRepositoryImpl` 위치 변경 및 패키징 정리** * `PracticeRepositoryImpl`을 `repository.practice` 패키지로 이동하여 일관성을 높였습니다. * **ui: 분석 결과 화면 및 권한 처리 연동** * 도메인 모델(`PracticeRecordingSpeed`) 대신 UI 모델(`PracticeRecordingAnalysisSpeed`)을 사용하도록 결과 화면을 수정했습니다. * 오디오 권한 거부 관련 Intent 처리 로직을 갱신된 명칭에 맞게 수정했습니다. --- .../prezel/core/data/di/RepositoryModule.kt | 2 +- .../{ => practice}/PracticeRepositoryImpl.kt | 2 +- .../AnalyzePracticeRecordingUseCase.kt | 19 ++++++ .../impl/practice/PracticeRecordingScreen.kt | 12 ++-- .../practice/PracticeRecordingViewModel.kt | 54 ++++++++++------ .../impl/practice/RecordAudioPermission.kt | 2 +- ...er.kt => MediaRecordingAudioController.kt} | 24 +++---- .../audio/RecordingAudioController.kt | 18 ++++++ .../practice/audio/RecordingAudioModule.kt | 15 +++++ .../component/PracticeRecordingControl.kt | 2 +- .../contract/PracticeRecordingUiIntent.kt | 8 +-- .../contract/PracticeRecordingUiState.kt | 63 +------------------ .../model/PracticeRecordingAnalysisStatus.kt | 23 +++++++ .../model/PracticeRecordingAnalysisUiModel.kt | 15 +++++ .../practice/model/PracticeRecordingState.kt | 43 +++++++++++++ .../result/PracticeRecordingResultScreen.kt | 12 ++-- .../PracticeRecordingAnalysisFailurePage.kt | 2 +- 17 files changed, 199 insertions(+), 117 deletions(-) rename Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/{ => practice}/PracticeRepositoryImpl.kt (98%) create mode 100644 Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/practice/AnalyzePracticeRecordingUseCase.kt rename Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/audio/{PracticeRecordingAudioController.kt => MediaRecordingAudioController.kt} (87%) create mode 100644 Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/audio/RecordingAudioController.kt create mode 100644 Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/audio/RecordingAudioModule.kt create mode 100644 Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/model/PracticeRecordingAnalysisStatus.kt create mode 100644 Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/model/PracticeRecordingAnalysisUiModel.kt create mode 100644 Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/model/PracticeRecordingState.kt diff --git a/Prezel/core/data/src/main/java/com/team/prezel/core/data/di/RepositoryModule.kt b/Prezel/core/data/src/main/java/com/team/prezel/core/data/di/RepositoryModule.kt index a7717968..7b3b0748 100644 --- a/Prezel/core/data/src/main/java/com/team/prezel/core/data/di/RepositoryModule.kt +++ b/Prezel/core/data/src/main/java/com/team/prezel/core/data/di/RepositoryModule.kt @@ -1,8 +1,8 @@ package com.team.prezel.core.data.di import com.team.prezel.core.data.repository.AuthRepositoryImpl -import com.team.prezel.core.data.repository.PracticeRepositoryImpl import com.team.prezel.core.data.repository.UserRepositoryImpl +import com.team.prezel.core.data.repository.practice.PracticeRepositoryImpl import com.team.prezel.core.domain.repository.auth.AuthRepository import com.team.prezel.core.domain.repository.practice.PracticeRepository import com.team.prezel.core.domain.repository.profile.UserRepository diff --git a/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/PracticeRepositoryImpl.kt b/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/practice/PracticeRepositoryImpl.kt similarity index 98% rename from Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/PracticeRepositoryImpl.kt rename to Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/practice/PracticeRepositoryImpl.kt index e72f4d8e..58c48c2c 100644 --- a/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/PracticeRepositoryImpl.kt +++ b/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/practice/PracticeRepositoryImpl.kt @@ -1,4 +1,4 @@ -package com.team.prezel.core.data.repository +package com.team.prezel.core.data.repository.practice import com.team.prezel.core.domain.repository.practice.PracticeRepository import com.team.prezel.core.model.practice.PracticeRecordingAnalysisResult diff --git a/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/practice/AnalyzePracticeRecordingUseCase.kt b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/practice/AnalyzePracticeRecordingUseCase.kt new file mode 100644 index 00000000..169db33d --- /dev/null +++ b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/practice/AnalyzePracticeRecordingUseCase.kt @@ -0,0 +1,19 @@ +package com.team.prezel.core.domain.usecase.practice + +import com.team.prezel.core.domain.repository.practice.PracticeRepository +import com.team.prezel.core.model.practice.PracticeRecordingAnalysisResult +import javax.inject.Inject + +/** + * 연습 녹음본을 업로드하고 분석 결과를 조회하는 UseCase. + */ +class AnalyzePracticeRecordingUseCase @Inject constructor( + private val practiceRepository: PracticeRepository, +) { + suspend operator fun invoke(recordingFilePath: String): Result = + practiceRepository + .uploadPracticeRecording(recordingFilePath = recordingFilePath) + .mapCatching { upload -> + practiceRepository.fetchPracticeRecordingAnalysisResult(recordingId = upload.id).getOrThrow() + } +} diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/PracticeRecordingScreen.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/PracticeRecordingScreen.kt index 83df99ca..cda7f6be 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/PracticeRecordingScreen.kt +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/PracticeRecordingScreen.kt @@ -21,11 +21,11 @@ import com.team.prezel.feature.home.impl.R import com.team.prezel.feature.home.impl.practice.component.PracticeRecordingContent import com.team.prezel.feature.home.impl.practice.component.PracticeRecordingTopAppBar import com.team.prezel.feature.home.impl.practice.component.toControlState -import com.team.prezel.feature.home.impl.practice.contract.PracticeRecordingAnalysisStatus -import com.team.prezel.feature.home.impl.practice.contract.PracticeRecordingState import com.team.prezel.feature.home.impl.practice.contract.PracticeRecordingUiEffect import com.team.prezel.feature.home.impl.practice.contract.PracticeRecordingUiIntent import com.team.prezel.feature.home.impl.practice.contract.PracticeRecordingUiState +import com.team.prezel.feature.home.impl.practice.model.PracticeRecordingAnalysisStatus +import com.team.prezel.feature.home.impl.practice.model.PracticeRecordingState import com.team.prezel.feature.home.impl.practice.model.PracticeRecordingUiMessage import com.team.prezel.feature.home.impl.practice.result.PracticeRecordingResultScreen import kotlinx.coroutines.flow.collectLatest @@ -42,10 +42,10 @@ internal fun PracticeRecordingScreen( val snackbarHostState = LocalSnackbarHostState.current val onClickRecordingControl = rememberRecordAudioPermissionControlClickHandler( recordingState = uiState.recordingState, - onClickControl = { viewModel.onIntent(PracticeRecordingUiIntent.ClickControl) }, - onPermissionDenied = { viewModel.onIntent(PracticeRecordingUiIntent.DenyRecordAudioPermission) }, + onClickControl = { viewModel.onIntent(PracticeRecordingUiIntent.ToggleRecordingControl) }, + onPermissionDenied = { viewModel.onIntent(PracticeRecordingUiIntent.RecordAudioPermissionDenied) }, onPermissionPermanentlyDenied = { - viewModel.onIntent(PracticeRecordingUiIntent.DenyRecordAudioPermissionPermanently) + viewModel.onIntent(PracticeRecordingUiIntent.RecordAudioPermissionPermanentlyDenied) }, ) @@ -69,7 +69,7 @@ internal fun PracticeRecordingScreen( PracticeRecordingScreen( uiState = uiState, onClickControl = onClickRecordingControl, - onClickAnalyze = { viewModel.onIntent(PracticeRecordingUiIntent.ClickAnalyze) }, + onClickAnalyze = { viewModel.onIntent(PracticeRecordingUiIntent.AnalyzeClicked) }, onBack = onBack, navigateToHome = navigateToHome, modifier = modifier, diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/PracticeRecordingViewModel.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/PracticeRecordingViewModel.kt index 4da729c4..4df17cd4 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/PracticeRecordingViewModel.kt +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/PracticeRecordingViewModel.kt @@ -1,17 +1,20 @@ package com.team.prezel.feature.home.impl.practice import androidx.lifecycle.viewModelScope -import com.team.prezel.core.domain.usecase.practice.FetchPracticeRecordingAnalysisResultUseCase +import com.team.prezel.core.domain.usecase.practice.AnalyzePracticeRecordingUseCase import com.team.prezel.core.domain.usecase.practice.FetchPracticeScriptUseCase -import com.team.prezel.core.domain.usecase.practice.UploadPracticeRecordingUseCase +import com.team.prezel.core.model.practice.PracticeRecordingAnalysisResult +import com.team.prezel.core.model.practice.PracticeRecordingSpeed import com.team.prezel.core.ui.base.BaseViewModel -import com.team.prezel.feature.home.impl.practice.audio.PracticeRecordingAudioControllerFactory -import com.team.prezel.feature.home.impl.practice.contract.PracticeRecordingAnalysisErrorType -import com.team.prezel.feature.home.impl.practice.contract.PracticeRecordingAnalysisStatus -import com.team.prezel.feature.home.impl.practice.contract.PracticeRecordingState +import com.team.prezel.feature.home.impl.practice.audio.RecordingAudioController import com.team.prezel.feature.home.impl.practice.contract.PracticeRecordingUiEffect import com.team.prezel.feature.home.impl.practice.contract.PracticeRecordingUiIntent import com.team.prezel.feature.home.impl.practice.contract.PracticeRecordingUiState +import com.team.prezel.feature.home.impl.practice.model.PracticeRecordingAnalysisErrorType +import com.team.prezel.feature.home.impl.practice.model.PracticeRecordingAnalysisSpeed +import com.team.prezel.feature.home.impl.practice.model.PracticeRecordingAnalysisStatus +import com.team.prezel.feature.home.impl.practice.model.PracticeRecordingAnalysisUiModel +import com.team.prezel.feature.home.impl.practice.model.PracticeRecordingState import com.team.prezel.feature.home.impl.practice.model.PracticeRecordingUiMessage import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Job @@ -21,12 +24,10 @@ import javax.inject.Inject @HiltViewModel internal class PracticeRecordingViewModel @Inject constructor( - audioControllerFactory: PracticeRecordingAudioControllerFactory, + private val audioController: RecordingAudioController, private val fetchPracticeScriptUseCase: FetchPracticeScriptUseCase, - private val uploadPracticeRecordingUseCase: UploadPracticeRecordingUseCase, - private val fetchPracticeRecordingAnalysisResultUseCase: FetchPracticeRecordingAnalysisResultUseCase, + private val analyzePracticeRecordingUseCase: AnalyzePracticeRecordingUseCase, ) : BaseViewModel(PracticeRecordingUiState()) { - private val audioController = audioControllerFactory.create() private var recordingFilePath: String? = null private var timerJob: Job? = null private var analysisJob: Job? = null @@ -34,12 +35,13 @@ internal class PracticeRecordingViewModel @Inject constructor( override fun onIntent(intent: PracticeRecordingUiIntent) { when (intent) { PracticeRecordingUiIntent.LoadPracticeScript -> fetchPracticeScript() - PracticeRecordingUiIntent.DenyRecordAudioPermission -> showMessage(PracticeRecordingUiMessage.RECORD_AUDIO_PERMISSION_DENIED) - PracticeRecordingUiIntent.DenyRecordAudioPermissionPermanently -> showMessage( + PracticeRecordingUiIntent.RecordAudioPermissionDenied -> showMessage(PracticeRecordingUiMessage.RECORD_AUDIO_PERMISSION_DENIED) + PracticeRecordingUiIntent.RecordAudioPermissionPermanentlyDenied -> showMessage( PracticeRecordingUiMessage.RECORD_AUDIO_PERMISSION_PERMANENTLY_DENIED, ) - PracticeRecordingUiIntent.ClickControl -> onClickControl() - PracticeRecordingUiIntent.ClickAnalyze -> startAnalysis() + + PracticeRecordingUiIntent.ToggleRecordingControl -> toggleRecordingControl() + PracticeRecordingUiIntent.AnalyzeClicked -> startAnalysis() } } @@ -56,7 +58,7 @@ internal class PracticeRecordingViewModel @Inject constructor( } } - private fun onClickControl() { + private fun toggleRecordingControl() { when (currentState.recordingState) { PracticeRecordingState.Idle -> startRecording() is PracticeRecordingState.Recording -> stopRecording() @@ -190,6 +192,7 @@ internal class PracticeRecordingViewModel @Inject constructor( if (!currentState.analyzeEnabled) return val filePath = recordingFilePath ?: return + audioController.stopPlayback() analysisJob?.cancel() analysisJob = viewModelScope.launch { updateState { @@ -198,14 +201,12 @@ internal class PracticeRecordingViewModel @Inject constructor( delay(ANALYSIS_LOADING_DELAY_MILLIS) - uploadPracticeRecordingUseCase(recordingFilePath = filePath) - .mapCatching { upload -> - fetchPracticeRecordingAnalysisResultUseCase(recordingId = upload.id).getOrThrow() - }.onSuccess { result -> + analyzePracticeRecordingUseCase(recordingFilePath = filePath) + .onSuccess { result -> updateState { copy( analysisStatus = PracticeRecordingAnalysisStatus.Success( - result = result, + result = result.toUiModel(), ), ) } @@ -281,3 +282,16 @@ internal class PracticeRecordingViewModel @Inject constructor( const val PLAYBACK_TIMER_DELAY_MILLIS = 250L } } + +private fun PracticeRecordingAnalysisResult.toUiModel(): PracticeRecordingAnalysisUiModel = + PracticeRecordingAnalysisUiModel( + pronunciationScore = pronunciationScore, + speed = speed.toUiModel(), + ) + +private fun PracticeRecordingSpeed.toUiModel(): PracticeRecordingAnalysisSpeed = + when (this) { + PracticeRecordingSpeed.SLOW -> PracticeRecordingAnalysisSpeed.SLOW + PracticeRecordingSpeed.ADEQUATE -> PracticeRecordingAnalysisSpeed.ADEQUATE + PracticeRecordingSpeed.FAST -> PracticeRecordingAnalysisSpeed.FAST + } diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/RecordAudioPermission.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/RecordAudioPermission.kt index f4d1bd64..3eade917 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/RecordAudioPermission.kt +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/RecordAudioPermission.kt @@ -3,7 +3,7 @@ package com.team.prezel.feature.home.impl.practice import android.Manifest import androidx.compose.runtime.Composable import com.team.prezel.core.ui.util.rememberPermissionRequest -import com.team.prezel.feature.home.impl.practice.contract.PracticeRecordingState +import com.team.prezel.feature.home.impl.practice.model.PracticeRecordingState @Composable internal fun rememberRecordAudioPermissionControlClickHandler( diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/audio/PracticeRecordingAudioController.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/audio/MediaRecordingAudioController.kt similarity index 87% rename from Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/audio/PracticeRecordingAudioController.kt rename to Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/audio/MediaRecordingAudioController.kt index 37ab089c..dc96b108 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/audio/PracticeRecordingAudioController.kt +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/audio/MediaRecordingAudioController.kt @@ -9,15 +9,15 @@ import java.io.File import javax.inject.Inject import kotlin.math.max -internal class PracticeRecordingAudioController( - private val context: Context, -) { +internal class MediaRecordingAudioController @Inject constructor( + @param:ApplicationContext private val context: Context, +) : RecordingAudioController { private var recorder: MediaRecorder? = null private var player: MediaPlayer? = null private var recordingStartedAt: Long = 0L private var recordingFile: File? = null - fun startRecording(): Result = + override fun startRecording(): Result = runCatching { stopPlayback() releaseRecorder() @@ -49,7 +49,7 @@ internal class PracticeRecordingAudioController( file.absolutePath } - fun stopRecording(): Result { + override fun stopRecording(): Result { if (recorder == null || recordingStartedAt <= 0L) { return Result.failure(IllegalStateException("Recording is not active.")) } @@ -64,7 +64,7 @@ internal class PracticeRecordingAudioController( } } - fun startPlayback( + override fun startPlayback( filePath: String, onComplete: () -> Unit, ): Result = @@ -93,15 +93,15 @@ internal class PracticeRecordingAudioController( newPlayer.duration.toSeconds() } - fun stopPlayback() { + override fun stopPlayback() { player?.runCatching { stop() } player?.release() player = null } - fun playbackPositionSeconds(): Int = runCatching { player?.currentPosition?.toSeconds() }.getOrNull() ?: 0 + override fun playbackPositionSeconds(): Int = runCatching { player?.currentPosition?.toSeconds() }.getOrNull() ?: 0 - fun release() { + override fun release() { releaseRecorder() stopPlayback() recordingFile?.delete() @@ -123,10 +123,4 @@ internal class PracticeRecordingAudioController( } } -internal class PracticeRecordingAudioControllerFactory @Inject constructor( - @param:ApplicationContext private val context: Context, -) { - fun create(): PracticeRecordingAudioController = PracticeRecordingAudioController(context) -} - private fun Int.toSeconds(): Int = this / 1_000 diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/audio/RecordingAudioController.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/audio/RecordingAudioController.kt new file mode 100644 index 00000000..0e15d0d6 --- /dev/null +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/audio/RecordingAudioController.kt @@ -0,0 +1,18 @@ +package com.team.prezel.feature.home.impl.practice.audio + +internal interface RecordingAudioController { + fun startRecording(): Result + + fun stopRecording(): Result + + fun startPlayback( + filePath: String, + onComplete: () -> Unit, + ): Result + + fun stopPlayback() + + fun playbackPositionSeconds(): Int + + fun release() +} diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/audio/RecordingAudioModule.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/audio/RecordingAudioModule.kt new file mode 100644 index 00000000..746c9ef9 --- /dev/null +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/audio/RecordingAudioModule.kt @@ -0,0 +1,15 @@ +package com.team.prezel.feature.home.impl.practice.audio + +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ViewModelComponent +import dagger.hilt.android.scopes.ViewModelScoped + +@Module +@InstallIn(ViewModelComponent::class) +internal abstract class RecordingAudioModule { + @Binds + @ViewModelScoped + abstract fun bindRecordingAudioController(impl: MediaRecordingAudioController): RecordingAudioController +} diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/component/PracticeRecordingControl.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/component/PracticeRecordingControl.kt index ab985f4d..5e0788f9 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/component/PracticeRecordingControl.kt +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/component/PracticeRecordingControl.kt @@ -19,7 +19,7 @@ import com.team.prezel.core.designsystem.component.actions.button.config.ButtonT 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.home.impl.practice.contract.PracticeRecordingState +import com.team.prezel.feature.home.impl.practice.model.PracticeRecordingState internal enum class PracticeRecordingControlState { READY_TO_RECORD, diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/contract/PracticeRecordingUiIntent.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/contract/PracticeRecordingUiIntent.kt index 19a8d57a..120e7f57 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/contract/PracticeRecordingUiIntent.kt +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/contract/PracticeRecordingUiIntent.kt @@ -5,11 +5,11 @@ import com.team.prezel.core.ui.base.UiIntent internal sealed interface PracticeRecordingUiIntent : UiIntent { data object LoadPracticeScript : PracticeRecordingUiIntent - data object DenyRecordAudioPermission : PracticeRecordingUiIntent + data object RecordAudioPermissionDenied : PracticeRecordingUiIntent - data object DenyRecordAudioPermissionPermanently : PracticeRecordingUiIntent + data object RecordAudioPermissionPermanentlyDenied : PracticeRecordingUiIntent - data object ClickControl : PracticeRecordingUiIntent + data object ToggleRecordingControl : PracticeRecordingUiIntent - data object ClickAnalyze : PracticeRecordingUiIntent + data object AnalyzeClicked : PracticeRecordingUiIntent } diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/contract/PracticeRecordingUiState.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/contract/PracticeRecordingUiState.kt index f4429923..05a592ce 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/contract/PracticeRecordingUiState.kt +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/contract/PracticeRecordingUiState.kt @@ -1,8 +1,9 @@ package com.team.prezel.feature.home.impl.practice.contract import androidx.compose.runtime.Immutable -import com.team.prezel.core.model.practice.PracticeRecordingAnalysisResult import com.team.prezel.core.ui.base.UiState +import com.team.prezel.feature.home.impl.practice.model.PracticeRecordingAnalysisStatus +import com.team.prezel.feature.home.impl.practice.model.PracticeRecordingState @Immutable internal data class PracticeRecordingUiState( @@ -20,63 +21,3 @@ internal data class PracticeRecordingUiState( get() = recordingState is PracticeRecordingState.Recorded && analysisStatus !is PracticeRecordingAnalysisStatus.Loading } - -@Immutable -internal sealed interface PracticeRecordingState { - val currentSeconds: Int - val totalSeconds: Int - - data object Idle : PracticeRecordingState { - override val currentSeconds: Int = 0 - override val totalSeconds: Int = 0 - } - - data class Recording( - val recordingSeconds: Int, - ) : PracticeRecordingState { - override val currentSeconds: Int - get() = recordingSeconds - - override val totalSeconds: Int = 0 - } - - data class Recorded( - val recordedDurationSeconds: Int, - ) : PracticeRecordingState { - override val currentSeconds: Int = 0 - - override val totalSeconds: Int - get() = recordedDurationSeconds - } - - data class Playing( - val playbackSeconds: Int, - val recordedDurationSeconds: Int, - ) : PracticeRecordingState { - override val currentSeconds: Int - get() = playbackSeconds - - override val totalSeconds: Int - get() = recordedDurationSeconds - } -} - -@Immutable -internal sealed interface PracticeRecordingAnalysisStatus { - data object Ready : PracticeRecordingAnalysisStatus - - data object Loading : PracticeRecordingAnalysisStatus - - data class Success( - val result: PracticeRecordingAnalysisResult, - ) : PracticeRecordingAnalysisStatus - - data class Error( - val type: PracticeRecordingAnalysisErrorType, - ) : PracticeRecordingAnalysisStatus -} - -internal enum class PracticeRecordingAnalysisErrorType { - VOICE_RECOGNITION_FAILED, - ANALYSIS_FAILED, -} diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/model/PracticeRecordingAnalysisStatus.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/model/PracticeRecordingAnalysisStatus.kt new file mode 100644 index 00000000..5e55040f --- /dev/null +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/model/PracticeRecordingAnalysisStatus.kt @@ -0,0 +1,23 @@ +package com.team.prezel.feature.home.impl.practice.model + +import androidx.compose.runtime.Immutable + +@Immutable +internal sealed interface PracticeRecordingAnalysisStatus { + data object Ready : PracticeRecordingAnalysisStatus + + data object Loading : PracticeRecordingAnalysisStatus + + data class Success( + val result: PracticeRecordingAnalysisUiModel, + ) : PracticeRecordingAnalysisStatus + + data class Error( + val type: PracticeRecordingAnalysisErrorType, + ) : PracticeRecordingAnalysisStatus +} + +internal enum class PracticeRecordingAnalysisErrorType { + VOICE_RECOGNITION_FAILED, + ANALYSIS_FAILED, +} diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/model/PracticeRecordingAnalysisUiModel.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/model/PracticeRecordingAnalysisUiModel.kt new file mode 100644 index 00000000..bbe2a962 --- /dev/null +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/model/PracticeRecordingAnalysisUiModel.kt @@ -0,0 +1,15 @@ +package com.team.prezel.feature.home.impl.practice.model + +import androidx.compose.runtime.Immutable + +@Immutable +internal data class PracticeRecordingAnalysisUiModel( + val pronunciationScore: Int, + val speed: PracticeRecordingAnalysisSpeed, +) + +internal enum class PracticeRecordingAnalysisSpeed { + SLOW, + ADEQUATE, + FAST, +} diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/model/PracticeRecordingState.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/model/PracticeRecordingState.kt new file mode 100644 index 00000000..738b5660 --- /dev/null +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/model/PracticeRecordingState.kt @@ -0,0 +1,43 @@ +package com.team.prezel.feature.home.impl.practice.model + +import androidx.compose.runtime.Immutable + +@Immutable +internal sealed interface PracticeRecordingState { + val currentSeconds: Int + val totalSeconds: Int + + data object Idle : PracticeRecordingState { + override val currentSeconds: Int = 0 + override val totalSeconds: Int = 0 + } + + data class Recording( + val recordingSeconds: Int, + ) : PracticeRecordingState { + override val currentSeconds: Int + get() = recordingSeconds + + override val totalSeconds: Int = 0 + } + + data class Recorded( + val recordedDurationSeconds: Int, + ) : PracticeRecordingState { + override val currentSeconds: Int = 0 + + override val totalSeconds: Int + get() = recordedDurationSeconds + } + + data class Playing( + val playbackSeconds: Int, + val recordedDurationSeconds: Int, + ) : PracticeRecordingState { + override val currentSeconds: Int + get() = playbackSeconds + + override val totalSeconds: Int + get() = recordedDurationSeconds + } +} diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/result/PracticeRecordingResultScreen.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/result/PracticeRecordingResultScreen.kt index 3d9d261f..69c9bec5 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/result/PracticeRecordingResultScreen.kt +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/result/PracticeRecordingResultScreen.kt @@ -2,8 +2,8 @@ package com.team.prezel.feature.home.impl.practice.result import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import com.team.prezel.core.model.practice.PracticeRecordingSpeed -import com.team.prezel.feature.home.impl.practice.contract.PracticeRecordingAnalysisStatus +import com.team.prezel.feature.home.impl.practice.model.PracticeRecordingAnalysisSpeed +import com.team.prezel.feature.home.impl.practice.model.PracticeRecordingAnalysisStatus import com.team.prezel.feature.home.impl.practice.result.component.PracticeAnalysisSpeed import com.team.prezel.feature.home.impl.practice.result.component.PracticeRecordingAnalysisFailurePage import com.team.prezel.feature.home.impl.practice.result.component.PracticeRecordingAnalysisLoadingPage @@ -37,9 +37,9 @@ internal fun PracticeRecordingResultScreen( } } -private fun PracticeRecordingSpeed.toUiModel(): PracticeAnalysisSpeed = +private fun PracticeRecordingAnalysisSpeed.toUiModel(): PracticeAnalysisSpeed = when (this) { - PracticeRecordingSpeed.SLOW -> PracticeAnalysisSpeed.SLOW - PracticeRecordingSpeed.ADEQUATE -> PracticeAnalysisSpeed.ADEQUATE - PracticeRecordingSpeed.FAST -> PracticeAnalysisSpeed.FAST + PracticeRecordingAnalysisSpeed.SLOW -> PracticeAnalysisSpeed.SLOW + PracticeRecordingAnalysisSpeed.ADEQUATE -> PracticeAnalysisSpeed.ADEQUATE + PracticeRecordingAnalysisSpeed.FAST -> PracticeAnalysisSpeed.FAST } diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/result/component/PracticeRecordingAnalysisFailurePage.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/result/component/PracticeRecordingAnalysisFailurePage.kt index cb05c51e..e216a03b 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/result/component/PracticeRecordingAnalysisFailurePage.kt +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/result/component/PracticeRecordingAnalysisFailurePage.kt @@ -17,7 +17,7 @@ import com.team.prezel.core.designsystem.preview.BasicPreview import com.team.prezel.core.designsystem.theme.PrezelTheme import com.team.prezel.core.ui.component.StatusView import com.team.prezel.feature.home.impl.R -import com.team.prezel.feature.home.impl.practice.contract.PracticeRecordingAnalysisErrorType +import com.team.prezel.feature.home.impl.practice.model.PracticeRecordingAnalysisErrorType import com.team.prezel.core.ui.R as CoreUiR @Composable From d8e4d9c4e6390996540452e4d288c39ee9b59984 Mon Sep 17 00:00:00 2001 From: Ham BeomJoon Date: Mon, 4 May 2026 18:13:44 +0900 Subject: [PATCH 12/27] =?UTF-8?q?refactor:=20=EC=98=A4=EB=94=94=EC=98=A4?= =?UTF-8?q?=20=EB=85=B9=EC=9D=8C=20=EB=A1=9C=EC=A7=81=20core=20=EB=AA=A8?= =?UTF-8?q?=EB=93=88=20=EB=B6=84=EB=A6=AC=20=EB=B0=8F=20=EA=B6=8C=ED=95=9C?= =?UTF-8?q?=20=EC=9A=94=EC=B2=AD=20=EB=A1=9C=EC=A7=81=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * **feat: `core:audio` 모듈 신설 및 오디오 제어 로직 이동** * 기존 `feature:home:impl` 내에 위치하던 오디오 녹음 및 재생 관련 로직을 공통 모듈인 `core:audio`로 추출했습니다. * `RecordingAudioController`: 인터페이스를 `core:audio`로 이동하고 외부 접근이 가능하도록 `public`으로 변경했습니다. * `MediaRecordingAudioController`: `MediaRecorder`와 `MediaPlayer`를 사용하는 구현체를 이동했습니다. * `RecordingAudioModule`: Hilt를 이용한 의존성 주입 설정을 이동했습니다. * `feature:home:impl`에서 `core:audio` 모듈을 의존성에 추가하고 관련 import를 수정했습니다. * **refactor: PermissionRequest 유틸리티 코드 정리** * `core:ui` 모듈의 `PermissionRequest` 내 중복되는 권한 확인 로직을 확장 함수(`isPermissionGranted`, `isPermissionPermanentlyDenied`)로 추출하여 가독성을 높였습니다. * 권한 결과 처리 로직(when 문 사용) 및 상태 동기화 로직을 간결하게 개선했습니다. * **build: 프로젝트 설정 업데이트** * `settings.gradle.kts`에 `:core:audio` 모듈을 추가했습니다. * `core:audio` 모듈의 `build.gradle.kts`를 정의하고 필요한 플러그인을 설정했습니다. --- Prezel/core/audio/build.gradle.kts | 8 ++++ .../audio/MediaRecordingAudioController.kt | 4 +- .../core}/audio/RecordingAudioController.kt | 4 +- .../core}/audio/RecordingAudioModule.kt | 2 +- .../prezel/core/ui/util/PermissionRequest.kt | 44 +++++++++---------- Prezel/feature/home/impl/build.gradle.kts | 1 + .../practice/PracticeRecordingViewModel.kt | 2 +- Prezel/settings.gradle.kts | 1 + 8 files changed, 38 insertions(+), 28 deletions(-) create mode 100644 Prezel/core/audio/build.gradle.kts rename Prezel/{feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice => core/audio/src/main/java/com/team/prezel/core}/audio/MediaRecordingAudioController.kt (96%) rename Prezel/{feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice => core/audio/src/main/java/com/team/prezel/core}/audio/RecordingAudioController.kt (72%) rename Prezel/{feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice => core/audio/src/main/java/com/team/prezel/core}/audio/RecordingAudioModule.kt (87%) diff --git a/Prezel/core/audio/build.gradle.kts b/Prezel/core/audio/build.gradle.kts new file mode 100644 index 00000000..a5f4eba6 --- /dev/null +++ b/Prezel/core/audio/build.gradle.kts @@ -0,0 +1,8 @@ +plugins { + alias(libs.plugins.prezel.android.library) + alias(libs.plugins.prezel.hilt) +} + +android { + namespace = "com.team.prezel.core.audio" +} diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/audio/MediaRecordingAudioController.kt b/Prezel/core/audio/src/main/java/com/team/prezel/core/audio/MediaRecordingAudioController.kt similarity index 96% rename from Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/audio/MediaRecordingAudioController.kt rename to Prezel/core/audio/src/main/java/com/team/prezel/core/audio/MediaRecordingAudioController.kt index dc96b108..20441a6c 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/audio/MediaRecordingAudioController.kt +++ b/Prezel/core/audio/src/main/java/com/team/prezel/core/audio/MediaRecordingAudioController.kt @@ -1,4 +1,4 @@ -package com.team.prezel.feature.home.impl.practice.audio +package com.team.prezel.core.audio import android.content.Context import android.media.MediaPlayer @@ -23,7 +23,7 @@ internal class MediaRecordingAudioController @Inject constructor( releaseRecorder() val previousRecordingFile = recordingFile - val file = File.createTempFile("practice_recording_", ".m4a", context.cacheDir) + val file = File.createTempFile("recording_", ".m4a", context.cacheDir) var pendingRecorder: MediaRecorder? = null val newRecorder = runCatching { val recorder = createMediaRecorder() diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/audio/RecordingAudioController.kt b/Prezel/core/audio/src/main/java/com/team/prezel/core/audio/RecordingAudioController.kt similarity index 72% rename from Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/audio/RecordingAudioController.kt rename to Prezel/core/audio/src/main/java/com/team/prezel/core/audio/RecordingAudioController.kt index 0e15d0d6..3260997a 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/audio/RecordingAudioController.kt +++ b/Prezel/core/audio/src/main/java/com/team/prezel/core/audio/RecordingAudioController.kt @@ -1,6 +1,6 @@ -package com.team.prezel.feature.home.impl.practice.audio +package com.team.prezel.core.audio -internal interface RecordingAudioController { +interface RecordingAudioController { fun startRecording(): Result fun stopRecording(): Result diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/audio/RecordingAudioModule.kt b/Prezel/core/audio/src/main/java/com/team/prezel/core/audio/RecordingAudioModule.kt similarity index 87% rename from Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/audio/RecordingAudioModule.kt rename to Prezel/core/audio/src/main/java/com/team/prezel/core/audio/RecordingAudioModule.kt index 746c9ef9..f91b3d42 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/audio/RecordingAudioModule.kt +++ b/Prezel/core/audio/src/main/java/com/team/prezel/core/audio/RecordingAudioModule.kt @@ -1,4 +1,4 @@ -package com.team.prezel.feature.home.impl.practice.audio +package com.team.prezel.core.audio import dagger.Binds import dagger.Module diff --git a/Prezel/core/ui/src/main/java/com/team/prezel/core/ui/util/PermissionRequest.kt b/Prezel/core/ui/src/main/java/com/team/prezel/core/ui/util/PermissionRequest.kt index ddc214cd..65c1340d 100644 --- a/Prezel/core/ui/src/main/java/com/team/prezel/core/ui/util/PermissionRequest.kt +++ b/Prezel/core/ui/src/main/java/com/team/prezel/core/ui/util/PermissionRequest.kt @@ -1,5 +1,7 @@ package com.team.prezel.core.ui.util +import android.app.Activity +import android.content.Context import android.content.pm.PackageManager import androidx.activity.compose.LocalActivity import androidx.activity.compose.rememberLauncherForActivityResult @@ -31,25 +33,16 @@ fun rememberPermissionRequest( val currentOnPermissionGranted by rememberUpdatedState(onPermissionGranted) val currentOnPermissionDenied by rememberUpdatedState(onPermissionDenied) val currentOnPermissionPermanentlyDenied by rememberUpdatedState(onPermissionPermanentlyDenied) - var isGranted by remember(permission) { - mutableStateOf( - ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED, - ) - } + var isGranted by remember(permission) { mutableStateOf(context.isPermissionGranted(permission)) } var hasRequestedPermission by remember(permission) { mutableStateOf(false) } var isPermanentlyDenied by remember(permission) { mutableStateOf(false) } fun syncPermissionState() { - val syncedIsGranted = ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED + val syncedIsGranted = context.isPermissionGranted(permission) isGranted = syncedIsGranted - isPermanentlyDenied = if (syncedIsGranted) { - false - } else { + isPermanentlyDenied = !syncedIsGranted && hasRequestedPermission && - activity?.let { - !ActivityCompat.shouldShowRequestPermissionRationale(it, permission) - } == true - } + activity.isPermissionPermanentlyDenied(permission) } DisposableEffect(lifecycleOwner, context, activity, permission) { @@ -69,16 +62,15 @@ fun rememberPermissionRequest( ) { launcherIsGranted -> hasRequestedPermission = true isGranted = launcherIsGranted - if (launcherIsGranted) { - isPermanentlyDenied = false - currentOnPermissionGranted() - } else { - isPermanentlyDenied = activity?.let { - !ActivityCompat.shouldShowRequestPermissionRationale(it, permission) - } == true - if (isPermanentlyDenied) { + isPermanentlyDenied = !launcherIsGranted && activity.isPermissionPermanentlyDenied(permission) + when { + launcherIsGranted -> { + currentOnPermissionGranted() + } + isPermanentlyDenied -> { currentOnPermissionPermanentlyDenied() - } else { + } + else -> { currentOnPermissionDenied() } } @@ -92,6 +84,14 @@ fun rememberPermissionRequest( ) } +private fun Context.isPermissionGranted(permission: String): Boolean = + ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED + +private fun Activity?.isPermissionPermanentlyDenied(permission: String): Boolean = + this?.let { + !ActivityCompat.shouldShowRequestPermissionRationale(it, permission) + } == true + data class PermissionRequest( val isGranted: Boolean, val isPermanentlyDenied: Boolean, diff --git a/Prezel/feature/home/impl/build.gradle.kts b/Prezel/feature/home/impl/build.gradle.kts index 4ef4389a..96efc948 100644 --- a/Prezel/feature/home/impl/build.gradle.kts +++ b/Prezel/feature/home/impl/build.gradle.kts @@ -7,6 +7,7 @@ android { } dependencies { + implementation(projects.coreAudio) implementation(projects.coreDomain) implementation(projects.coreModel) implementation(projects.featureHomeApi) diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/PracticeRecordingViewModel.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/PracticeRecordingViewModel.kt index 4df17cd4..b7ffcf5f 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/PracticeRecordingViewModel.kt +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/PracticeRecordingViewModel.kt @@ -1,12 +1,12 @@ package com.team.prezel.feature.home.impl.practice import androidx.lifecycle.viewModelScope +import com.team.prezel.core.audio.RecordingAudioController import com.team.prezel.core.domain.usecase.practice.AnalyzePracticeRecordingUseCase import com.team.prezel.core.domain.usecase.practice.FetchPracticeScriptUseCase import com.team.prezel.core.model.practice.PracticeRecordingAnalysisResult import com.team.prezel.core.model.practice.PracticeRecordingSpeed import com.team.prezel.core.ui.base.BaseViewModel -import com.team.prezel.feature.home.impl.practice.audio.RecordingAudioController import com.team.prezel.feature.home.impl.practice.contract.PracticeRecordingUiEffect import com.team.prezel.feature.home.impl.practice.contract.PracticeRecordingUiIntent import com.team.prezel.feature.home.impl.practice.contract.PracticeRecordingUiState diff --git a/Prezel/settings.gradle.kts b/Prezel/settings.gradle.kts index 22be6806..14aa680e 100644 --- a/Prezel/settings.gradle.kts +++ b/Prezel/settings.gradle.kts @@ -39,6 +39,7 @@ enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS") includeAuto( ":app", + ":core:audio", ":core:auth", ":core:common", ":core:data", From aa8c63471feb17bdb59264c50ae801cb3d9deaad Mon Sep 17 00:00:00 2001 From: Ham BeomJoon Date: Wed, 6 May 2026 23:55:27 +0900 Subject: [PATCH 13/27] =?UTF-8?q?feat:=20=EC=9D=8C=EC=84=B1=20=EB=85=B9?= =?UTF-8?q?=EC=9D=8C=20=EB=B0=8F=20=EC=99=B8=EB=B6=80=20=EC=98=A4=EB=94=94?= =?UTF-8?q?=EC=98=A4=20=ED=8C=8C=EC=9D=BC=20=EC=84=A0=ED=83=9D=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EA=B3=A0=EB=8F=84=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * **feat: `RecordingAudioController` 상태 기반 아키텍처 도입 및 기능 확장** * `RecordingAudioController`를 `StateFlow`(`AudioSessionState`) 및 `SharedFlow`(`AudioSessionEvent`) 기반의 반응형 구조로 개편했습니다. * 녹음 일시정지(`pause`), 재개(`resume`), 초기화(`reset`) 기능을 추가했습니다. * 외부 오디오 파일 불러오기(`loadAudioFile`) 기능을 추가하여 `Uri`를 통한 오디오 소스 선택을 지원합니다. * 내부적으로 `CoroutineScope`를 사용하여 녹음 및 재생 타이머 로직을 관리하도록 개선했습니다. * **feat: 연습 녹음 화면(PracticeRecordingScreen) UI 및 기능 개선** * **외부 파일 선택:** `ActivityResultContracts.GetContent`를 이용해 기기 내 오디오 파일을 선택할 수 있는 기능을 추가했습니다. * **세분화된 제어 기능:** 기존 단일 제어 버튼을 상황에 맞는 다중 액션 버튼(일시정지, 중지, 초기화, 파일 선택 등)으로 확장했습니다. * **상태 매핑:** `PracticeRecordingState`에 일시정지 상태(`RecordingPaused`, `PlaybackPaused`) 및 오디오 소스 타입(`RECORDED_FILE`, `EXTERNAL_FILE`)을 추가했습니다. * **refactor: `PracticeRecordingViewModel` 로직 최적화** * 기존의 수동 타이머 관리 로직을 제거하고, `RecordingAudioController`의 상태 흐름을 구독하여 UI를 갱신하도록 변경했습니다. * `PracticeRecordingUiIntent`를 세분화하여 각 녹음/재생 제어 동작을 명확히 정의했습니다. * **build: 의존성 및 리소스 추가** * `core:audio` 모듈에 `kotlinx-coroutines-core` 의존성을 추가했습니다. * 오디오 파일 로드 실패 관련 에러 메시지 리소스를 추가했습니다. --- Prezel/core/audio/build.gradle.kts | 4 + .../audio/MediaRecordingAudioController.kt | 336 ++++++++++++++++-- .../core/audio/RecordingAudioController.kt | 86 ++++- .../impl/practice/PracticeRecordingScreen.kt | 91 ++++- .../practice/PracticeRecordingViewModel.kt | 282 ++++++--------- .../impl/practice/RecordAudioPermission.kt | 12 +- .../component/PracticeRecordingContent.kt | 22 +- .../component/PracticeRecordingControl.kt | 205 +++++++++-- .../contract/PracticeRecordingUiIntent.kt | 23 +- .../contract/PracticeRecordingUiState.kt | 2 +- .../practice/model/PracticeRecordingState.kt | 43 ++- .../model/PracticeRecordingUiMessage.kt | 1 + .../home/impl/src/main/res/values/strings.xml | 1 + 13 files changed, 825 insertions(+), 283 deletions(-) diff --git a/Prezel/core/audio/build.gradle.kts b/Prezel/core/audio/build.gradle.kts index a5f4eba6..913624c4 100644 --- a/Prezel/core/audio/build.gradle.kts +++ b/Prezel/core/audio/build.gradle.kts @@ -6,3 +6,7 @@ plugins { android { namespace = "com.team.prezel.core.audio" } + +dependencies { + implementation(libs.kotlinx.coroutines.core) +} 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 20441a6c..366e4550 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 @@ -3,8 +3,23 @@ package com.team.prezel.core.audio import android.content.Context import android.media.MediaPlayer import android.media.MediaRecorder +import android.net.Uri import android.os.Build import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch import java.io.File import javax.inject.Inject import kotlin.math.max @@ -12,16 +27,25 @@ import kotlin.math.max internal class MediaRecordingAudioController @Inject constructor( @param:ApplicationContext private val context: Context, ) : RecordingAudioController { + private val controllerScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + + private val _audioSessionState = MutableStateFlow(AudioSessionState.Idle) + override val audioSessionState: StateFlow = _audioSessionState.asStateFlow() + + private val _audioSessionEvent = MutableSharedFlow(extraBufferCapacity = EVENT_BUFFER_CAPACITY) + override val audioSessionEvent: SharedFlow = _audioSessionEvent.asSharedFlow() + private var recorder: MediaRecorder? = null private var player: MediaPlayer? = null - private var recordingStartedAt: Long = 0L - private var recordingFile: File? = null + private var currentAudioFile: File? = null + private var recordingTimerJob: Job? = null + private var playbackTimerJob: Job? = null - override fun startRecording(): Result = + override fun startRecording() { runCatching { stopPlayback() releaseRecorder() - val previousRecordingFile = recordingFile + deleteCurrentAudioFile() val file = File.createTempFile("recording_", ".m4a", context.cacheDir) var pendingRecorder: MediaRecorder? = null @@ -43,44 +67,220 @@ internal class MediaRecordingAudioController @Inject constructor( } recorder = newRecorder - recordingFile = file - recordingStartedAt = System.currentTimeMillis() - previousRecordingFile?.delete() - file.absolutePath + currentAudioFile = file + _audioSessionState.value = AudioSessionState.Recording(elapsedSeconds = 0) + startRecordingTimer() + }.onFailure { + releaseRecorder() + deleteCurrentAudioFile() + _audioSessionState.value = AudioSessionState.Idle + emitEvent(AudioSessionEvent.RecordingStartFailed) } + } + + override fun pauseRecording() { + val state = audioSessionState.value as? AudioSessionState.Recording ?: return + + runCatching { + recorder?.pause() ?: error("Recording is not active.") + }.onSuccess { + recordingTimerJob?.cancel() + _audioSessionState.value = AudioSessionState.RecordingPaused( + elapsedSeconds = state.elapsedSeconds, + ) + }.onFailure { + emitEvent(AudioSessionEvent.RecordingPauseFailed) + } + } + + override fun resumeRecording() { + val state = audioSessionState.value as? AudioSessionState.RecordingPaused ?: return - override fun stopRecording(): Result { - if (recorder == null || recordingStartedAt <= 0L) { - return Result.failure(IllegalStateException("Recording is not active.")) + runCatching { + recorder?.resume() ?: error("Recording is not active.") + }.onSuccess { + _audioSessionState.value = AudioSessionState.Recording( + elapsedSeconds = state.elapsedSeconds, + ) + startRecordingTimer() + }.onFailure { + emitEvent(AudioSessionEvent.RecordingResumeFailed) } + } - val durationSeconds = ((System.currentTimeMillis() - recordingStartedAt) / 1_000L).toInt() + override fun stopRecording() { + val state = audioSessionState.value + val elapsedSeconds = when (state) { + is AudioSessionState.Recording -> state.elapsedSeconds + is AudioSessionState.RecordingPaused -> state.elapsedSeconds + else -> return + } + val file = currentAudioFile ?: return emitEvent(AudioSessionEvent.RecordingStopFailed) - return runCatching { - recorder?.stop() - max(durationSeconds, 0) - }.also { + recordingTimerJob?.cancel() + runCatching { + recorder?.stop() ?: error("Recording is not active.") + max(elapsedSeconds, 0) + }.onSuccess { durationSeconds -> + releaseRecorder() + _audioSessionState.value = AudioSessionState.ReadyToPlay( + source = AudioSource.RecordedFile(filePath = file.absolutePath), + durationSeconds = durationSeconds, + ) + }.onFailure { releaseRecorder() + deleteCurrentAudioFile() + _audioSessionState.value = AudioSessionState.Idle + emitEvent(AudioSessionEvent.RecordingStopFailed) } } - override fun startPlayback( - filePath: String, - onComplete: () -> Unit, - ): Result = + override fun resetRecording() { + when (audioSessionState.value) { + is AudioSessionState.Recording, + is AudioSessionState.RecordingPaused, + -> { + recordingTimerJob?.cancel() + releaseRecorder() + deleteCurrentAudioFile() + _audioSessionState.value = AudioSessionState.Idle + } + + else -> Unit + } + } + + override fun loadAudioFile(uri: Uri) { runCatching { stopPlayback() + releaseRecorder() + deleteCurrentAudioFile() + + val file = File.createTempFile("external_audio_", ".m4a", context.cacheDir) + context.contentResolver.openInputStream(uri)?.use { input -> + file.outputStream().use { output -> + input.copyTo(output) + } + } ?: error("Audio file could not be opened.") + + val durationSeconds = readDurationSeconds(file) + currentAudioFile = file + _audioSessionState.value = AudioSessionState.ReadyToPlay( + source = AudioSource.ExternalFile(filePath = file.absolutePath), + durationSeconds = durationSeconds, + ) + }.onFailure { + deleteCurrentAudioFile() + _audioSessionState.value = AudioSessionState.Idle + emitEvent(AudioSessionEvent.FileLoadFailed) + } + } + + override fun startPlayback() { + when (val state = audioSessionState.value) { + is AudioSessionState.ReadyToPlay -> startPlayback( + source = state.source, + durationSeconds = state.durationSeconds, + startPositionSeconds = 0, + ) + + is AudioSessionState.PlaybackPaused -> startPlayback( + source = state.source, + durationSeconds = state.durationSeconds, + startPositionSeconds = state.positionSeconds, + ) + + else -> Unit + } + } + + override fun pausePlayback() { + val state = audioSessionState.value as? AudioSessionState.Playing ?: return + + runCatching { + player?.pause() ?: error("Playback is not active.") + }.onSuccess { + playbackTimerJob?.cancel() + _audioSessionState.value = AudioSessionState.PlaybackPaused( + source = state.source, + positionSeconds = playbackPositionSeconds().coerceAtLeast(state.positionSeconds), + durationSeconds = state.durationSeconds, + ) + } + } + + override fun resumePlayback() { + val state = audioSessionState.value as? AudioSessionState.PlaybackPaused ?: return + + runCatching { + player?.start() ?: error("Playback is not active.") + }.onSuccess { + _audioSessionState.value = AudioSessionState.Playing( + source = state.source, + positionSeconds = state.positionSeconds, + durationSeconds = state.durationSeconds, + ) + startPlaybackTimer() + }.onFailure { + emitEvent(AudioSessionEvent.PlaybackStartFailed) + } + } + + override fun stopPlayback() { + val readyState = when (val state = audioSessionState.value) { + is AudioSessionState.Playing -> AudioSessionState.ReadyToPlay( + source = state.source, + durationSeconds = state.durationSeconds, + ) + + is AudioSessionState.PlaybackPaused -> AudioSessionState.ReadyToPlay( + source = state.source, + durationSeconds = state.durationSeconds, + ) + + else -> null + } + + releasePlayer() + if (readyState != null) { + _audioSessionState.value = readyState + } + } + + override fun release() { + recordingTimerJob?.cancel() + playbackTimerJob?.cancel() + releaseRecorder() + releasePlayer() + deleteCurrentAudioFile() + controllerScope.cancel() + _audioSessionState.value = AudioSessionState.Idle + } + + private fun startPlayback( + source: AudioSource, + durationSeconds: Int, + startPositionSeconds: Int, + ) { + runCatching { + releasePlayer() var pendingPlayer: MediaPlayer? = null val newPlayer = runCatching { val mediaPlayer = MediaPlayer() pendingPlayer = mediaPlayer mediaPlayer.apply { - setDataSource(filePath) + setDataSource(source.filePath) prepare() + if (startPositionSeconds > 0) { + seekTo(startPositionSeconds * MILLIS_PER_SECOND) + } setOnCompletionListener { - stopPlayback() - onComplete() + releasePlayer() + _audioSessionState.value = AudioSessionState.ReadyToPlay( + source = source, + durationSeconds = durationSeconds, + ) } start() } @@ -90,24 +290,64 @@ internal class MediaRecordingAudioController @Inject constructor( } player = newPlayer - newPlayer.duration.toSeconds() + _audioSessionState.value = AudioSessionState.Playing( + source = source, + positionSeconds = startPositionSeconds, + durationSeconds = durationSeconds.coerceAtLeast(newPlayer.duration.toSeconds()), + ) + startPlaybackTimer() + }.onFailure { + releasePlayer() + _audioSessionState.value = AudioSessionState.ReadyToPlay( + source = source, + durationSeconds = durationSeconds, + ) + emitEvent(AudioSessionEvent.PlaybackStartFailed) } - - override fun stopPlayback() { - player?.runCatching { stop() } - player?.release() - player = null } - override fun playbackPositionSeconds(): Int = runCatching { player?.currentPosition?.toSeconds() }.getOrNull() ?: 0 + 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) + } + } + } + } - override fun release() { - releaseRecorder() - stopPlayback() - recordingFile?.delete() - recordingFile = null + 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 = playbackPositionSeconds(), + durationSeconds = state.durationSeconds, + ) + } + } + } } + private fun readDurationSeconds(file: File): Int = + runCatching { + val mediaPlayer = MediaPlayer() + try { + mediaPlayer.setDataSource(file.absolutePath) + mediaPlayer.prepare() + mediaPlayer.duration.toSeconds() + } finally { + mediaPlayer.release() + } + }.getOrDefault(0) + @Suppress("DEPRECATION") private fun createMediaRecorder(): MediaRecorder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { @@ -119,7 +359,31 @@ internal class MediaRecordingAudioController @Inject constructor( private fun releaseRecorder() { recorder?.release() recorder = null - recordingStartedAt = 0L + } + + private fun releasePlayer() { + playbackTimerJob?.cancel() + player?.runCatching { stop() } + player?.release() + player = null + } + + private fun playbackPositionSeconds(): Int = runCatching { player?.currentPosition?.toSeconds() }.getOrNull() ?: 0 + + private fun deleteCurrentAudioFile() { + currentAudioFile?.delete() + currentAudioFile = null + } + + private fun emitEvent(event: AudioSessionEvent) { + _audioSessionEvent.tryEmit(event) + } + + private companion object { + const val EVENT_BUFFER_CAPACITY = 8 + const val MILLIS_PER_SECOND = 1_000 + const val RECORDING_TIMER_DELAY_MILLIS = 1_000L + const val PLAYBACK_TIMER_DELAY_MILLIS = 250L } } 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 3260997a..f15f2b17 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,18 +1,88 @@ package com.team.prezel.core.audio +import android.net.Uri +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow + interface RecordingAudioController { - fun startRecording(): Result + val audioSessionState: StateFlow - fun stopRecording(): Result + val audioSessionEvent: SharedFlow - fun startPlayback( - filePath: String, - onComplete: () -> Unit, - ): Result + fun startRecording() - fun stopPlayback() + fun pauseRecording() + + fun resumeRecording() + + fun stopRecording() + + fun resetRecording() + + fun loadAudioFile(uri: Uri) + + fun startPlayback() - fun playbackPositionSeconds(): Int + fun pausePlayback() + + fun resumePlayback() + + fun stopPlayback() fun release() } + +sealed interface AudioSessionState { + data object Idle : AudioSessionState + + data class Recording( + val elapsedSeconds: Int, + ) : AudioSessionState + + data class RecordingPaused( + val elapsedSeconds: Int, + ) : AudioSessionState + + data class ReadyToPlay( + val source: AudioSource, + val durationSeconds: Int, + ) : AudioSessionState + + data class Playing( + val source: AudioSource, + val positionSeconds: Int, + val durationSeconds: Int, + ) : AudioSessionState + + data class PlaybackPaused( + val source: AudioSource, + val positionSeconds: Int, + val durationSeconds: Int, + ) : AudioSessionState +} + +sealed interface AudioSource { + val filePath: String + + data class RecordedFile( + override val filePath: String, + ) : AudioSource + + data class ExternalFile( + override val filePath: String, + ) : AudioSource +} + +sealed interface AudioSessionEvent { + data object RecordingStartFailed : AudioSessionEvent + + data object RecordingPauseFailed : AudioSessionEvent + + data object RecordingResumeFailed : AudioSessionEvent + + data object RecordingStopFailed : AudioSessionEvent + + data object PlaybackStartFailed : AudioSessionEvent + + data object FileLoadFailed : AudioSessionEvent +} diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/PracticeRecordingScreen.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/PracticeRecordingScreen.kt index cda7f6be..a8c1aee2 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/PracticeRecordingScreen.kt +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/PracticeRecordingScreen.kt @@ -1,6 +1,8 @@ package com.team.prezel.feature.home.impl.practice import androidx.activity.compose.BackHandler +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.background import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize @@ -40,9 +42,15 @@ internal fun PracticeRecordingScreen( val uiState by viewModel.uiState.collectAsStateWithLifecycle() val resources = LocalResources.current val snackbarHostState = LocalSnackbarHostState.current - val onClickRecordingControl = rememberRecordAudioPermissionControlClickHandler( + val audioFilePickerLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.GetContent(), + ) { uri -> + if (uri == null) return@rememberLauncherForActivityResult + viewModel.onIntent(PracticeRecordingUiIntent.AudioFileSelected(uri)) + } + val onStartRecording = rememberRecordAudioPermissionControlClickHandler( recordingState = uiState.recordingState, - onClickControl = { viewModel.onIntent(PracticeRecordingUiIntent.ToggleRecordingControl) }, + onStartRecording = { viewModel.onIntent(PracticeRecordingUiIntent.StartRecording) }, onPermissionDenied = { viewModel.onIntent(PracticeRecordingUiIntent.RecordAudioPermissionDenied) }, onPermissionPermanentlyDenied = { viewModel.onIntent(PracticeRecordingUiIntent.RecordAudioPermissionPermanentlyDenied) @@ -68,7 +76,16 @@ internal fun PracticeRecordingScreen( PracticeRecordingScreen( uiState = uiState, - onClickControl = onClickRecordingControl, + onStartRecording = onStartRecording, + onPauseRecording = { viewModel.onIntent(PracticeRecordingUiIntent.PauseRecording) }, + onResumeRecording = { viewModel.onIntent(PracticeRecordingUiIntent.ResumeRecording) }, + onStopRecording = { viewModel.onIntent(PracticeRecordingUiIntent.StopRecording) }, + onResetRecording = { viewModel.onIntent(PracticeRecordingUiIntent.ResetRecording) }, + onSelectAudioFile = { audioFilePickerLauncher.launch(AUDIO_FILE_MIME_TYPE) }, + onStartPlayback = { viewModel.onIntent(PracticeRecordingUiIntent.StartPlayback) }, + onPausePlayback = { viewModel.onIntent(PracticeRecordingUiIntent.PausePlayback) }, + onResumePlayback = { viewModel.onIntent(PracticeRecordingUiIntent.ResumePlayback) }, + onStopPlayback = { viewModel.onIntent(PracticeRecordingUiIntent.StopPlayback) }, onClickAnalyze = { viewModel.onIntent(PracticeRecordingUiIntent.AnalyzeClicked) }, onBack = onBack, navigateToHome = navigateToHome, @@ -79,7 +96,16 @@ internal fun PracticeRecordingScreen( @Composable private fun PracticeRecordingScreen( uiState: PracticeRecordingUiState, - onClickControl: () -> Unit, + onStartRecording: () -> Unit, + onPauseRecording: () -> Unit, + onResumeRecording: () -> Unit, + onStopRecording: () -> Unit, + onResetRecording: () -> Unit, + onSelectAudioFile: () -> Unit, + onStartPlayback: () -> Unit, + onPausePlayback: () -> Unit, + onResumePlayback: () -> Unit, + onStopPlayback: () -> Unit, onClickAnalyze: () -> Unit, onBack: () -> Unit, navigateToHome: () -> Unit, @@ -90,7 +116,16 @@ private fun PracticeRecordingScreen( when (uiState.analysisStatus) { PracticeRecordingAnalysisStatus.Ready -> PracticeRecordingReadyScreen( uiState = uiState, - onClickControl = onClickControl, + onStartRecording = onStartRecording, + onPauseRecording = onPauseRecording, + onResumeRecording = onResumeRecording, + onStopRecording = onStopRecording, + onResetRecording = onResetRecording, + onSelectAudioFile = onSelectAudioFile, + onStartPlayback = onStartPlayback, + onPausePlayback = onPausePlayback, + onResumePlayback = onResumePlayback, + onStopPlayback = onStopPlayback, onClickAnalyze = onClickAnalyze, onBack = onBack, modifier = modifier, @@ -109,7 +144,16 @@ private fun PracticeRecordingScreen( @Composable private fun PracticeRecordingReadyScreen( uiState: PracticeRecordingUiState, - onClickControl: () -> Unit, + onStartRecording: () -> Unit, + onPauseRecording: () -> Unit, + onResumeRecording: () -> Unit, + onStopRecording: () -> Unit, + onResetRecording: () -> Unit, + onSelectAudioFile: () -> Unit, + onStartPlayback: () -> Unit, + onPausePlayback: () -> Unit, + onResumePlayback: () -> Unit, + onStopPlayback: () -> Unit, onClickAnalyze: () -> Unit, onBack: () -> Unit, modifier: Modifier = Modifier, @@ -128,7 +172,16 @@ private fun PracticeRecordingReadyScreen( currentSeconds = uiState.currentSeconds, totalSeconds = uiState.totalSeconds, controlState = uiState.recordingState.toControlState(), - onClickControl = onClickControl, + onStartRecording = onStartRecording, + onPauseRecording = onPauseRecording, + onResumeRecording = onResumeRecording, + onStopRecording = onStopRecording, + onResetRecording = onResetRecording, + onSelectAudioFile = onSelectAudioFile, + onStartPlayback = onStartPlayback, + onPausePlayback = onPausePlayback, + onResumePlayback = onResumePlayback, + onStopPlayback = onStopPlayback, modifier = Modifier.weight(1f), ) @@ -152,6 +205,7 @@ private val PracticeRecordingUiMessage.resId: Int PracticeRecordingUiMessage.RECORDING_START_FAILED -> R.string.feature_home_impl_practice_recording_failed PracticeRecordingUiMessage.RECORDING_STOP_FAILED -> R.string.feature_home_impl_practice_recording_stop_failed PracticeRecordingUiMessage.PLAYBACK_START_FAILED -> R.string.feature_home_impl_practice_recording_playback_failed + PracticeRecordingUiMessage.AUDIO_FILE_LOAD_FAILED -> R.string.feature_home_impl_practice_recording_file_load_failed } @BasicPreview @@ -182,8 +236,10 @@ private fun PracticeRecordingScreenRecordedPreview() { PrezelTheme { PracticeRecordingScreenPreviewContent( uiState = PracticeRecordingUiState( - recordingState = PracticeRecordingState.Recorded( - recordedDurationSeconds = 32, + recordingState = PracticeRecordingState.ReadyToPlay( + filePath = "", + durationSeconds = 32, + sourceType = PracticeRecordingState.SourceType.RECORDED_FILE, ), ), ) @@ -197,8 +253,10 @@ private fun PracticeRecordingScreenPlayingPreview() { PracticeRecordingScreenPreviewContent( uiState = PracticeRecordingUiState( recordingState = PracticeRecordingState.Playing( + filePath = "", playbackSeconds = 12, - recordedDurationSeconds = 32, + durationSeconds = 32, + sourceType = PracticeRecordingState.SourceType.RECORDED_FILE, ), ), ) @@ -211,9 +269,20 @@ private fun PracticeRecordingScreenPreviewContent(uiState: PracticeRecordingUiSt uiState = uiState.copy( practiceScript = "내가 그린 기린 그림은 잘 그린 기린 그림이고,\n네가 그린 기린 그림은 잘못 그린 기린 그림이다.", ), - onClickControl = {}, + onStartRecording = {}, + onPauseRecording = {}, + onResumeRecording = {}, + onStopRecording = {}, + onResetRecording = {}, + onSelectAudioFile = {}, + onStartPlayback = {}, + onPausePlayback = {}, + onResumePlayback = {}, + onStopPlayback = {}, onClickAnalyze = {}, onBack = {}, navigateToHome = {}, ) } + +private const val AUDIO_FILE_MIME_TYPE = "audio/*" diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/PracticeRecordingViewModel.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/PracticeRecordingViewModel.kt index b7ffcf5f..51151843 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/PracticeRecordingViewModel.kt +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/PracticeRecordingViewModel.kt @@ -1,6 +1,9 @@ package com.team.prezel.feature.home.impl.practice import androidx.lifecycle.viewModelScope +import com.team.prezel.core.audio.AudioSessionEvent +import com.team.prezel.core.audio.AudioSessionState +import com.team.prezel.core.audio.AudioSource import com.team.prezel.core.audio.RecordingAudioController import com.team.prezel.core.domain.usecase.practice.AnalyzePracticeRecordingUseCase import com.team.prezel.core.domain.usecase.practice.FetchPracticeScriptUseCase @@ -17,7 +20,6 @@ import com.team.prezel.feature.home.impl.practice.model.PracticeRecordingAnalysi import com.team.prezel.feature.home.impl.practice.model.PracticeRecordingState import com.team.prezel.feature.home.impl.practice.model.PracticeRecordingUiMessage import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.launch import javax.inject.Inject @@ -28,9 +30,10 @@ internal class PracticeRecordingViewModel @Inject constructor( private val fetchPracticeScriptUseCase: FetchPracticeScriptUseCase, private val analyzePracticeRecordingUseCase: AnalyzePracticeRecordingUseCase, ) : BaseViewModel(PracticeRecordingUiState()) { - private var recordingFilePath: String? = null - private var timerJob: Job? = null - private var analysisJob: Job? = null + init { + collectAudioSessionState() + collectAudioSessionEvent() + } override fun onIntent(intent: PracticeRecordingUiIntent) { when (intent) { @@ -40,161 +43,69 @@ internal class PracticeRecordingViewModel @Inject constructor( PracticeRecordingUiMessage.RECORD_AUDIO_PERMISSION_PERMANENTLY_DENIED, ) - PracticeRecordingUiIntent.ToggleRecordingControl -> toggleRecordingControl() - PracticeRecordingUiIntent.AnalyzeClicked -> startAnalysis() - } - } + PracticeRecordingUiIntent.StartRecording -> { + updateState { copy(analysisStatus = PracticeRecordingAnalysisStatus.Ready) } + audioController.startRecording() + } - private fun fetchPracticeScript() { - viewModelScope.launch { - fetchPracticeScriptUseCase() - .onSuccess { script -> - updateState { - copy(practiceScript = script.content) - } - }.onFailure { - showMessage(PracticeRecordingUiMessage.FETCH_PRACTICE_SCRIPT_FAILED) - } - } - } + PracticeRecordingUiIntent.PauseRecording -> audioController.pauseRecording() + PracticeRecordingUiIntent.ResumeRecording -> audioController.resumeRecording() + PracticeRecordingUiIntent.StopRecording -> audioController.stopRecording() + PracticeRecordingUiIntent.ResetRecording -> { + updateState { copy(analysisStatus = PracticeRecordingAnalysisStatus.Ready) } + audioController.resetRecording() + } + + is PracticeRecordingUiIntent.AudioFileSelected -> { + updateState { copy(analysisStatus = PracticeRecordingAnalysisStatus.Ready) } + audioController.loadAudioFile(intent.uri) + } - private fun toggleRecordingControl() { - when (currentState.recordingState) { - PracticeRecordingState.Idle -> startRecording() - is PracticeRecordingState.Recording -> stopRecording() - is PracticeRecordingState.Recorded -> startPlayback() - is PracticeRecordingState.Playing -> stopPlayback() + PracticeRecordingUiIntent.StartPlayback -> audioController.startPlayback() + PracticeRecordingUiIntent.PausePlayback -> audioController.pausePlayback() + PracticeRecordingUiIntent.ResumePlayback -> audioController.resumePlayback() + PracticeRecordingUiIntent.StopPlayback -> audioController.stopPlayback() + PracticeRecordingUiIntent.AnalyzeClicked -> startAnalysis() } } - private fun startRecording() { - audioController - .startRecording() - .onSuccess { filePath -> - recordingFilePath = filePath - - updateState { - copy( - recordingState = PracticeRecordingState.Recording( - recordingSeconds = 0, - ), - analysisStatus = PracticeRecordingAnalysisStatus.Ready, - ) - } - - startRecordingTimer() - }.onFailure { - recordingFilePath = null - timerJob?.cancel() + private fun collectAudioSessionState() { + viewModelScope.launch { + audioController.audioSessionState.collect { audioState -> updateState { - copy( - recordingState = PracticeRecordingState.Idle, - analysisStatus = PracticeRecordingAnalysisStatus.Ready, - ) + copy(recordingState = audioState.toPracticeRecordingState()) } - showMessage(PracticeRecordingUiMessage.RECORDING_START_FAILED) } + } } - private fun stopRecording() { - val previousState = currentState.recordingState - if (previousState !is PracticeRecordingState.Recording) return - - timerJob?.cancel() - - audioController - .stopRecording() - .onSuccess { durationSeconds -> - updateState { - copy( - recordingState = PracticeRecordingState.Recorded( - recordedDurationSeconds = durationSeconds.coerceAtLeast( - previousState.recordingSeconds, - ), - ), - ) - } - }.onFailure { - recordingFilePath = null - updateState { - copy( - recordingState = PracticeRecordingState.Idle, - analysisStatus = PracticeRecordingAnalysisStatus.Ready, - ) - } - showMessage(PracticeRecordingUiMessage.RECORDING_STOP_FAILED) + private fun collectAudioSessionEvent() { + viewModelScope.launch { + audioController.audioSessionEvent.collect { event -> + showMessage(event.toUiMessage()) } - } - - private fun startPlayback() { - val previousState = currentState.recordingState - if (previousState !is PracticeRecordingState.Recorded) return - - val filePath = recordingFilePath - if (filePath == null) { - showMessage(PracticeRecordingUiMessage.PLAYBACK_START_FAILED) - return } - - audioController - .startPlayback(filePath) { - timerJob?.cancel() - updateState { - copy( - recordingState = PracticeRecordingState.Recorded( - recordedDurationSeconds = previousState.recordedDurationSeconds, - ), - ) - } - }.onSuccess { durationSeconds -> - updateState { - copy( - recordingState = PracticeRecordingState.Playing( - playbackSeconds = 0, - recordedDurationSeconds = durationSeconds.coerceAtLeast( - previousState.recordedDurationSeconds, - ), - ), - ) - } - - startPlaybackTimer() - }.onFailure { - updateState { - copy( - recordingState = PracticeRecordingState.Recorded( - recordedDurationSeconds = previousState.recordedDurationSeconds, - ), - analysisStatus = PracticeRecordingAnalysisStatus.Ready, - ) - } - showMessage(PracticeRecordingUiMessage.PLAYBACK_START_FAILED) - } } - private fun stopPlayback() { - val previousState = currentState.recordingState - if (previousState !is PracticeRecordingState.Playing) return - - audioController.stopPlayback() - timerJob?.cancel() - - updateState { - copy( - recordingState = PracticeRecordingState.Recorded( - recordedDurationSeconds = previousState.recordedDurationSeconds, - ), - ) + private fun fetchPracticeScript() { + viewModelScope.launch { + fetchPracticeScriptUseCase() + .onSuccess { script -> + updateState { + copy(practiceScript = script.content) + } + }.onFailure { + showMessage(PracticeRecordingUiMessage.FETCH_PRACTICE_SCRIPT_FAILED) + } } } private fun startAnalysis() { if (!currentState.analyzeEnabled) return - val filePath = recordingFilePath ?: return + val filePath = currentState.recordingState.filePath ?: return audioController.stopPlayback() - analysisJob?.cancel() - analysisJob = viewModelScope.launch { + viewModelScope.launch { updateState { copy(analysisStatus = PracticeRecordingAnalysisStatus.Loading) } @@ -222,47 +133,6 @@ internal class PracticeRecordingViewModel @Inject constructor( } } - private fun startRecordingTimer() { - timerJob?.cancel() - timerJob = viewModelScope.launch { - while (true) { - delay(TIMER_DELAY_MILLIS) - - updateState { - val state = recordingState - if (state !is PracticeRecordingState.Recording) return@updateState this - - copy( - recordingState = PracticeRecordingState.Recording( - recordingSeconds = state.recordingSeconds + 1, - ), - ) - } - } - } - } - - private fun startPlaybackTimer() { - timerJob?.cancel() - timerJob = viewModelScope.launch { - while (true) { - delay(PLAYBACK_TIMER_DELAY_MILLIS) - - updateState { - val state = recordingState - if (state !is PracticeRecordingState.Playing) return@updateState this - - copy( - recordingState = PracticeRecordingState.Playing( - playbackSeconds = audioController.playbackPositionSeconds(), - recordedDurationSeconds = state.recordedDurationSeconds, - ), - ) - } - } - } - } - private fun showMessage(message: PracticeRecordingUiMessage) { viewModelScope.launch { sendEffect(PracticeRecordingUiEffect.ShowMessage(message)) @@ -270,19 +140,65 @@ internal class PracticeRecordingViewModel @Inject constructor( } override fun onCleared() { - timerJob?.cancel() - analysisJob?.cancel() audioController.release() super.onCleared() } private companion object { const val ANALYSIS_LOADING_DELAY_MILLIS = 3_000L - const val TIMER_DELAY_MILLIS = 1_000L - const val PLAYBACK_TIMER_DELAY_MILLIS = 250L } } +private fun AudioSessionState.toPracticeRecordingState(): PracticeRecordingState = + when (this) { + AudioSessionState.Idle -> PracticeRecordingState.Idle + is AudioSessionState.Recording -> PracticeRecordingState.Recording( + recordingSeconds = elapsedSeconds, + ) + + is AudioSessionState.RecordingPaused -> PracticeRecordingState.RecordingPaused( + recordingSeconds = elapsedSeconds, + ) + + is AudioSessionState.ReadyToPlay -> PracticeRecordingState.ReadyToPlay( + filePath = source.filePath, + durationSeconds = durationSeconds, + sourceType = source.toPracticeSourceType(), + ) + + is AudioSessionState.Playing -> PracticeRecordingState.Playing( + filePath = source.filePath, + playbackSeconds = positionSeconds, + durationSeconds = durationSeconds, + sourceType = source.toPracticeSourceType(), + ) + + is AudioSessionState.PlaybackPaused -> PracticeRecordingState.PlaybackPaused( + filePath = source.filePath, + playbackSeconds = positionSeconds, + durationSeconds = durationSeconds, + sourceType = source.toPracticeSourceType(), + ) + } + +private fun AudioSource.toPracticeSourceType(): PracticeRecordingState.SourceType = + when (this) { + is AudioSource.RecordedFile -> PracticeRecordingState.SourceType.RECORDED_FILE + is AudioSource.ExternalFile -> PracticeRecordingState.SourceType.EXTERNAL_FILE + } + +private fun AudioSessionEvent.toUiMessage(): PracticeRecordingUiMessage = + when (this) { + AudioSessionEvent.RecordingStartFailed, + AudioSessionEvent.RecordingPauseFailed, + AudioSessionEvent.RecordingResumeFailed, + -> PracticeRecordingUiMessage.RECORDING_START_FAILED + + AudioSessionEvent.RecordingStopFailed -> PracticeRecordingUiMessage.RECORDING_STOP_FAILED + AudioSessionEvent.PlaybackStartFailed -> PracticeRecordingUiMessage.PLAYBACK_START_FAILED + AudioSessionEvent.FileLoadFailed -> PracticeRecordingUiMessage.AUDIO_FILE_LOAD_FAILED + } + private fun PracticeRecordingAnalysisResult.toUiModel(): PracticeRecordingAnalysisUiModel = PracticeRecordingAnalysisUiModel( pronunciationScore = pronunciationScore, diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/RecordAudioPermission.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/RecordAudioPermission.kt index 3eade917..a4d18c13 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/RecordAudioPermission.kt +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/RecordAudioPermission.kt @@ -8,13 +8,13 @@ import com.team.prezel.feature.home.impl.practice.model.PracticeRecordingState @Composable internal fun rememberRecordAudioPermissionControlClickHandler( recordingState: PracticeRecordingState, - onClickControl: () -> Unit, + onStartRecording: () -> Unit, onPermissionDenied: () -> Unit, onPermissionPermanentlyDenied: () -> Unit, ): () -> Unit { val permissionRequest = rememberPermissionRequest( permission = Manifest.permission.RECORD_AUDIO, - onPermissionGranted = onClickControl, + onPermissionGranted = onStartRecording, onPermissionDenied = onPermissionDenied, onPermissionPermanentlyDenied = onPermissionPermanentlyDenied, ) @@ -23,16 +23,18 @@ internal fun rememberRecordAudioPermissionControlClickHandler( when (recordingState) { PracticeRecordingState.Idle -> { when { - permissionRequest.isGranted -> onClickControl() + permissionRequest.isGranted -> onStartRecording() permissionRequest.isPermanentlyDenied -> permissionRequest.onPermanentlyDenied() else -> permissionRequest.launch() } } is PracticeRecordingState.Recording, - is PracticeRecordingState.Recorded, + is PracticeRecordingState.RecordingPaused, + is PracticeRecordingState.ReadyToPlay, is PracticeRecordingState.Playing, - -> onClickControl() + is PracticeRecordingState.PlaybackPaused, + -> Unit } } } diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/component/PracticeRecordingContent.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/component/PracticeRecordingContent.kt index c6a8fd0d..ae240ace 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/component/PracticeRecordingContent.kt +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/component/PracticeRecordingContent.kt @@ -24,7 +24,16 @@ internal fun PracticeRecordingContent( currentSeconds: Int, totalSeconds: Int, controlState: PracticeRecordingControlState, - onClickControl: () -> Unit, + onStartRecording: () -> Unit, + onPauseRecording: () -> Unit, + onResumeRecording: () -> Unit, + onStopRecording: () -> Unit, + onResetRecording: () -> Unit, + onSelectAudioFile: () -> Unit, + onStartPlayback: () -> Unit, + onPausePlayback: () -> Unit, + onResumePlayback: () -> Unit, + onStopPlayback: () -> Unit, modifier: Modifier = Modifier, ) { Column( @@ -63,7 +72,16 @@ internal fun PracticeRecordingContent( currentSeconds = currentSeconds, totalSeconds = totalSeconds, state = controlState, - onClickControl = onClickControl, + onStartRecording = onStartRecording, + onPauseRecording = onPauseRecording, + onResumeRecording = onResumeRecording, + onStopRecording = onStopRecording, + onResetRecording = onResetRecording, + onSelectAudioFile = onSelectAudioFile, + onStartPlayback = onStartPlayback, + onPausePlayback = onPausePlayback, + onResumePlayback = onResumePlayback, + onStopPlayback = onStopPlayback, ) } } diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/component/PracticeRecordingControl.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/component/PracticeRecordingControl.kt index 5e0788f9..46628bbb 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/component/PracticeRecordingControl.kt +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/component/PracticeRecordingControl.kt @@ -1,5 +1,6 @@ package com.team.prezel.feature.home.impl.practice.component +import androidx.annotation.DrawableRes import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth @@ -24,16 +25,20 @@ import com.team.prezel.feature.home.impl.practice.model.PracticeRecordingState internal enum class PracticeRecordingControlState { READY_TO_RECORD, RECORDING, + RECORDING_PAUSED, READY_TO_PLAY, PLAYING, + PLAYBACK_PAUSED, } internal fun PracticeRecordingState.toControlState(): PracticeRecordingControlState = when (this) { PracticeRecordingState.Idle -> PracticeRecordingControlState.READY_TO_RECORD is PracticeRecordingState.Recording -> PracticeRecordingControlState.RECORDING - is PracticeRecordingState.Recorded -> PracticeRecordingControlState.READY_TO_PLAY + is PracticeRecordingState.RecordingPaused -> PracticeRecordingControlState.RECORDING_PAUSED + is PracticeRecordingState.ReadyToPlay -> PracticeRecordingControlState.READY_TO_PLAY is PracticeRecordingState.Playing -> PracticeRecordingControlState.PLAYING + is PracticeRecordingState.PlaybackPaused -> PracticeRecordingControlState.PLAYBACK_PAUSED } @Composable @@ -41,9 +46,31 @@ internal fun PracticeRecordingControl( currentSeconds: Int, totalSeconds: Int, state: PracticeRecordingControlState, - onClickControl: () -> Unit, + onStartRecording: () -> Unit, + onPauseRecording: () -> Unit, + onResumeRecording: () -> Unit, + onStopRecording: () -> Unit, + onResetRecording: () -> Unit, + onSelectAudioFile: () -> Unit, + onStartPlayback: () -> Unit, + onPausePlayback: () -> Unit, + onResumePlayback: () -> Unit, + onStopPlayback: () -> Unit, modifier: Modifier = Modifier, ) { + val actions = state.actions( + onStartRecording = onStartRecording, + onPauseRecording = onPauseRecording, + onResumeRecording = onResumeRecording, + onStopRecording = onStopRecording, + onResetRecording = onResetRecording, + onSelectAudioFile = onSelectAudioFile, + onStartPlayback = onStartPlayback, + onPausePlayback = onPausePlayback, + onResumePlayback = onResumePlayback, + onStopPlayback = onStopPlayback, + ) + Row( modifier = modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, @@ -55,22 +82,29 @@ internal fun PracticeRecordingControl( state = state, ) - PrezelIconButton( - iconResId = state.iconResId, - modifier = Modifier.size(48.dp), - isRounded = true, - buttonDefault = PrezelButtonDefaults.getDefault( - isIconOnly = true, - isRounded = true, - type = ButtonType.FILLED, - size = ButtonSize.REGULAR, - hierarchy = ButtonHierarchy.SECONDARY, - contentColor = state.iconColor(), - backgroundColor = PrezelTheme.colors.bgLarge, - iconSize = 20.dp, - ), - onClick = onClickControl, - ) + Row( + horizontalArrangement = Arrangement.spacedBy(PrezelTheme.spacing.V8), + verticalAlignment = Alignment.CenterVertically, + ) { + actions.forEach { action -> + PrezelIconButton( + iconResId = action.iconResId, + modifier = Modifier.size(48.dp), + isRounded = true, + buttonDefault = PrezelButtonDefaults.getDefault( + isIconOnly = true, + isRounded = true, + type = ButtonType.FILLED, + size = ButtonSize.REGULAR, + hierarchy = ButtonHierarchy.SECONDARY, + contentColor = action.iconColor(), + backgroundColor = PrezelTheme.colors.bgLarge, + iconSize = 20.dp, + ), + onClick = action.onClick, + ) + } + } } } @@ -80,7 +114,7 @@ private fun PracticeRecordingTimeText( totalSeconds: Int, state: PracticeRecordingControlState, ) { - if (state != PracticeRecordingControlState.PLAYING) { + if (state != PracticeRecordingControlState.PLAYING && state != PracticeRecordingControlState.PLAYBACK_PAUSED) { Text( text = state .displaySeconds( @@ -110,21 +144,130 @@ private fun PracticeRecordingTimeText( ) } -private val PracticeRecordingControlState.iconResId: Int - get() = when (this) { - PracticeRecordingControlState.READY_TO_RECORD -> PrezelIcons.Recording - PracticeRecordingControlState.RECORDING -> PrezelIcons.Stop - PracticeRecordingControlState.READY_TO_PLAY -> PrezelIcons.Play - PracticeRecordingControlState.PLAYING -> PrezelIcons.Stop +private data class PracticeRecordingControlAction( + @param:DrawableRes val iconResId: Int, + val colorType: PracticeRecordingControlActionColorType, + val onClick: () -> Unit, +) + +private enum class PracticeRecordingControlActionColorType { + RECORD, + REGULAR, +} + +private fun PracticeRecordingControlState.actions( + onStartRecording: () -> Unit, + onPauseRecording: () -> Unit, + onResumeRecording: () -> Unit, + onStopRecording: () -> Unit, + onResetRecording: () -> Unit, + onSelectAudioFile: () -> Unit, + onStartPlayback: () -> Unit, + onPausePlayback: () -> Unit, + onResumePlayback: () -> Unit, + onStopPlayback: () -> Unit, +): List = + when (this) { + PracticeRecordingControlState.READY_TO_RECORD -> listOf( + PracticeRecordingControlAction( + iconResId = PrezelIcons.Recording, + colorType = PracticeRecordingControlActionColorType.RECORD, + onClick = onStartRecording, + ), + PracticeRecordingControlAction( + iconResId = PrezelIcons.Folder, + colorType = PracticeRecordingControlActionColorType.REGULAR, + onClick = onSelectAudioFile, + ), + ) + + PracticeRecordingControlState.RECORDING -> recordingActions( + primaryIconResId = PrezelIcons.Pause, + onPrimaryClick = onPauseRecording, + onStopRecording = onStopRecording, + onResetRecording = onResetRecording, + ) + + PracticeRecordingControlState.RECORDING_PAUSED -> recordingActions( + primaryIconResId = PrezelIcons.Play, + onPrimaryClick = onResumeRecording, + onStopRecording = onStopRecording, + onResetRecording = onResetRecording, + ) + + PracticeRecordingControlState.READY_TO_PLAY -> listOf( + PracticeRecordingControlAction( + iconResId = PrezelIcons.Play, + colorType = PracticeRecordingControlActionColorType.REGULAR, + onClick = onStartPlayback, + ), + PracticeRecordingControlAction( + iconResId = PrezelIcons.Folder, + colorType = PracticeRecordingControlActionColorType.REGULAR, + onClick = onSelectAudioFile, + ), + ) + + PracticeRecordingControlState.PLAYING -> playbackActions( + primaryIconResId = PrezelIcons.Pause, + onPrimaryClick = onPausePlayback, + onStopPlayback = onStopPlayback, + ) + + PracticeRecordingControlState.PLAYBACK_PAUSED -> playbackActions( + primaryIconResId = PrezelIcons.Play, + onPrimaryClick = onResumePlayback, + onStopPlayback = onStopPlayback, + ) } +private fun recordingActions( + @DrawableRes primaryIconResId: Int, + onPrimaryClick: () -> Unit, + onStopRecording: () -> Unit, + onResetRecording: () -> Unit, +): List = + listOf( + PracticeRecordingControlAction( + iconResId = primaryIconResId, + colorType = PracticeRecordingControlActionColorType.REGULAR, + onClick = onPrimaryClick, + ), + PracticeRecordingControlAction( + iconResId = PrezelIcons.Stop, + colorType = PracticeRecordingControlActionColorType.REGULAR, + onClick = onStopRecording, + ), + PracticeRecordingControlAction( + iconResId = PrezelIcons.Reset, + colorType = PracticeRecordingControlActionColorType.REGULAR, + onClick = onResetRecording, + ), + ) + +private fun playbackActions( + @DrawableRes primaryIconResId: Int, + onPrimaryClick: () -> Unit, + onStopPlayback: () -> Unit, +): List = + listOf( + PracticeRecordingControlAction( + iconResId = primaryIconResId, + colorType = PracticeRecordingControlActionColorType.REGULAR, + onClick = onPrimaryClick, + ), + PracticeRecordingControlAction( + iconResId = PrezelIcons.Stop, + colorType = PracticeRecordingControlActionColorType.REGULAR, + onClick = onStopPlayback, + ), + ) + @Composable -private fun PracticeRecordingControlState.iconColor() = - when (this) { - PracticeRecordingControlState.READY_TO_RECORD -> PrezelTheme.colors.feedbackBadRegular - PracticeRecordingControlState.RECORDING -> PrezelTheme.colors.iconRegular - PracticeRecordingControlState.READY_TO_PLAY -> PrezelTheme.colors.iconRegular - PracticeRecordingControlState.PLAYING -> PrezelTheme.colors.iconRegular +private fun PracticeRecordingControlAction.iconColor() = + when (colorType) { + PracticeRecordingControlActionColorType.RECORD -> PrezelTheme.colors.feedbackBadRegular + PracticeRecordingControlActionColorType.REGULAR -> PrezelTheme.colors.iconRegular } private fun PracticeRecordingControlState.displaySeconds( diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/contract/PracticeRecordingUiIntent.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/contract/PracticeRecordingUiIntent.kt index 120e7f57..6cfcea69 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/contract/PracticeRecordingUiIntent.kt +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/contract/PracticeRecordingUiIntent.kt @@ -1,5 +1,6 @@ package com.team.prezel.feature.home.impl.practice.contract +import android.net.Uri import com.team.prezel.core.ui.base.UiIntent internal sealed interface PracticeRecordingUiIntent : UiIntent { @@ -9,7 +10,27 @@ internal sealed interface PracticeRecordingUiIntent : UiIntent { data object RecordAudioPermissionPermanentlyDenied : PracticeRecordingUiIntent - data object ToggleRecordingControl : PracticeRecordingUiIntent + data object StartRecording : PracticeRecordingUiIntent + + data object PauseRecording : PracticeRecordingUiIntent + + data object ResumeRecording : PracticeRecordingUiIntent + + data object StopRecording : PracticeRecordingUiIntent + + data object ResetRecording : PracticeRecordingUiIntent + + data class AudioFileSelected( + val uri: Uri, + ) : PracticeRecordingUiIntent + + data object StartPlayback : PracticeRecordingUiIntent + + data object PausePlayback : PracticeRecordingUiIntent + + data object ResumePlayback : PracticeRecordingUiIntent + + data object StopPlayback : PracticeRecordingUiIntent data object AnalyzeClicked : PracticeRecordingUiIntent } diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/contract/PracticeRecordingUiState.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/contract/PracticeRecordingUiState.kt index 05a592ce..ef1fb42c 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/contract/PracticeRecordingUiState.kt +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/contract/PracticeRecordingUiState.kt @@ -18,6 +18,6 @@ internal data class PracticeRecordingUiState( get() = recordingState.totalSeconds val analyzeEnabled: Boolean - get() = recordingState is PracticeRecordingState.Recorded && + get() = recordingState is PracticeRecordingState.ReadyToPlay && analysisStatus !is PracticeRecordingAnalysisStatus.Loading } diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/model/PracticeRecordingState.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/model/PracticeRecordingState.kt index 738b5660..0a499392 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/model/PracticeRecordingState.kt +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/model/PracticeRecordingState.kt @@ -6,6 +6,8 @@ import androidx.compose.runtime.Immutable internal sealed interface PracticeRecordingState { val currentSeconds: Int val totalSeconds: Int + val filePath: String? + get() = null data object Idle : PracticeRecordingState { override val currentSeconds: Int = 0 @@ -21,23 +23,54 @@ internal sealed interface PracticeRecordingState { override val totalSeconds: Int = 0 } - data class Recorded( - val recordedDurationSeconds: Int, + data class RecordingPaused( + val recordingSeconds: Int, + ) : PracticeRecordingState { + override val currentSeconds: Int + get() = recordingSeconds + + override val totalSeconds: Int = 0 + } + + data class ReadyToPlay( + override val filePath: String, + val durationSeconds: Int, + val sourceType: SourceType, ) : PracticeRecordingState { override val currentSeconds: Int = 0 override val totalSeconds: Int - get() = recordedDurationSeconds + get() = durationSeconds } data class Playing( + override val filePath: String, + val playbackSeconds: Int, + val durationSeconds: Int, + val sourceType: SourceType, + ) : PracticeRecordingState { + override val currentSeconds: Int + get() = playbackSeconds + + override val totalSeconds: Int + get() = durationSeconds + } + + data class PlaybackPaused( + override val filePath: String, val playbackSeconds: Int, - val recordedDurationSeconds: Int, + val durationSeconds: Int, + val sourceType: SourceType, ) : PracticeRecordingState { override val currentSeconds: Int get() = playbackSeconds override val totalSeconds: Int - get() = recordedDurationSeconds + get() = durationSeconds + } + + enum class SourceType { + RECORDED_FILE, + EXTERNAL_FILE, } } diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/model/PracticeRecordingUiMessage.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/model/PracticeRecordingUiMessage.kt index 619c4120..140e15b4 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/model/PracticeRecordingUiMessage.kt +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/model/PracticeRecordingUiMessage.kt @@ -7,4 +7,5 @@ internal enum class PracticeRecordingUiMessage { RECORDING_START_FAILED, RECORDING_STOP_FAILED, PLAYBACK_START_FAILED, + AUDIO_FILE_LOAD_FAILED, } diff --git a/Prezel/feature/home/impl/src/main/res/values/strings.xml b/Prezel/feature/home/impl/src/main/res/values/strings.xml index 8932898a..91b0a297 100644 --- a/Prezel/feature/home/impl/src/main/res/values/strings.xml +++ b/Prezel/feature/home/impl/src/main/res/values/strings.xml @@ -30,6 +30,7 @@ 녹음을 시작하지 못했습니다. 녹음을 저장하지 못했습니다. 녹음을 재생하지 못했습니다. + 음성 파일을 불러오지 못했습니다. 분석중 잠시만 기다려주세요 분석에 실패했어요 From 7b06176f019b3bb0a77d2eca3ca15205b632259b Mon Sep 17 00:00:00 2001 From: Ham BeomJoon Date: Fri, 8 May 2026 16:35:46 +0900 Subject: [PATCH 14/27] =?UTF-8?q?refactor:=20=ED=99=88=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20=EC=BD=94=EB=93=9C=20=EA=B5=AC=EC=A1=B0=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0=20=EB=B0=8F=20=EB=85=B9=EC=9D=8C=20=EB=A1=9C=EC=A7=81?= =?UTF-8?q?=20=EA=B0=84=EC=86=8C=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * **refactor: 홈 화면 관련 패키지 구조 재구성** * 홈 화면 구성 컴포넌트들을 `feature:home:impl` 내 `main` 패키지로 이동하여 관리 구조를 개선했습니다. * `HomeScreen`, `HomeViewModel`, `HomeUiState` 등 핵심 UI 로직을 `main` 패키지로 그룹화했습니다. * **refactor: 발표 연습 녹음(Practice Recording) 로직 간소화** * `RecordingAudioController`에서 복잡한 일시정지(`pause`), 재개(`resume`), 외부 파일 로드(`loadAudioFile`) 기능을 제거하고 핵심적인 녹음 및 재생 로직에 집중하도록 리팩터링했습니다. * `AudioSessionState` 및 `AudioSessionEffect` 모델을 단순화하여 녹음 상태 관리 효율성을 높였습니다. * 녹음 화면 UI 컴포넌트(`PracticeRecordingControl` 등)가 단순화된 오디오 상태 모델을 사용하도록 업데이트했습니다. * **refactor: 도메인 모델 직접 참조 및 불필요한 모델 제거** * UI 레이어에서 별도로 정의하여 사용하던 `PracticeRecordingAnalysisSpeed` 및 `PracticeAnalysisSpeed`를 제거하고, `core:model`의 `PracticeRecordingSpeed`를 직접 사용하도록 수정했습니다. * `PracticeRecordingState` 인터페이스를 삭제하고 `core:audio`의 `AudioSessionState`로 통합했습니다. * **feat: UI 컴포넌트 미리보기(Preview) 추가** * `PracticeRecordingContent`, `PracticeRecordingControl` 등 주요 연습 관련 컴포넌트에 다양한 상태별 `Preview`를 추가하여 개발 편의성을 향상했습니다. * **refactor: 버튼 영역(ButtonArea) 호출 방식 개선** * `PrezelButtonArea` 사용 시 내부 버튼을 슬롯 방식(`mainButton`)으로 전달하도록 변경하여 확장성을 개선했습니다. --- .../audio/MediaRecordingAudioController.kt | 155 ++----------- .../core/audio/RecordingAudioController.kt | 49 +--- .../home/impl/{ => main}/HomeScreen.kt | 25 ++- .../home/impl/{ => main}/HomeViewModel.kt | 12 +- .../{ => main}/component/HomePageLayout.kt | 8 +- .../component/body/EmptyPresentationSheet.kt | 2 +- .../component/body/HomeBottomSheetContent.kt | 2 +- .../component/body/HomeBottomSheetTitle.kt | 2 +- .../component/body/PresentationSheet.kt | 4 +- .../component/head/HomeHeadSection.kt | 6 +- .../component/title/EmptyPresentationHero.kt | 2 +- .../component/title/HomeHeroLayout.kt | 2 +- .../component/title/PracticeActionCard.kt | 2 +- .../component/title/PresentationHero.kt | 4 +- .../impl/{ => main}/contract/HomeUiEffect.kt | 4 +- .../impl/{ => main}/contract/HomeUiIntent.kt | 2 +- .../impl/{ => main}/contract/HomeUiState.kt | 4 +- .../impl/{ => main}/model/HomeUiMessage.kt | 2 +- .../{ => main}/model/PresentationUiModel.kt | 2 +- .../home/impl/navigation/HomeEntryBuilder.kt | 2 +- .../impl/practice/PracticeRecordingScreen.kt | 88 ++------ .../practice/PracticeRecordingViewModel.kt | 93 +------- .../impl/practice/RecordAudioPermission.kt | 14 +- .../component/PracticeRecordingContent.kt | 70 ++++-- .../component/PracticeRecordingControl.kt | 209 +++++++----------- .../component/PracticeRecordingTopAppBar.kt | 10 + .../contract/PracticeRecordingUiIntent.kt | 15 -- .../contract/PracticeRecordingUiState.kt | 29 ++- .../model/PracticeRecordingAnalysisUiModel.kt | 9 +- .../practice/model/PracticeRecordingState.kt | 76 ------- .../model/PracticeRecordingUiMessage.kt | 1 - .../result/PracticeRecordingResultScreen.kt | 11 +- .../component/PracticeRecordingResultPage.kt | 56 ++--- .../home/impl/src/main/res/values/strings.xml | 1 - 34 files changed, 311 insertions(+), 662 deletions(-) rename Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/{ => main}/HomeScreen.kt (92%) rename Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/{ => main}/HomeViewModel.kt (83%) rename Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/{ => main}/component/HomePageLayout.kt (93%) rename Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/{ => main}/component/body/EmptyPresentationSheet.kt (96%) rename Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/{ => main}/component/body/HomeBottomSheetContent.kt (98%) rename Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/{ => main}/component/body/HomeBottomSheetTitle.kt (93%) rename Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/{ => main}/component/body/PresentationSheet.kt (94%) rename Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/{ => main}/component/head/HomeHeadSection.kt (94%) rename Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/{ => main}/component/title/EmptyPresentationHero.kt (96%) rename Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/{ => main}/component/title/HomeHeroLayout.kt (96%) rename Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/{ => main}/component/title/PracticeActionCard.kt (97%) rename Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/{ => main}/component/title/PresentationHero.kt (97%) rename Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/{ => main}/contract/HomeUiEffect.kt (60%) rename Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/{ => main}/contract/HomeUiIntent.kt (71%) rename Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/{ => main}/contract/HomeUiState.kt (91%) rename Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/{ => main}/model/HomeUiMessage.kt (50%) rename Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/{ => main}/model/PresentationUiModel.kt (95%) delete mode 100644 Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/model/PracticeRecordingState.kt diff --git a/Prezel/core/audio/src/main/java/com/team/prezel/core/audio/MediaRecordingAudioController.kt b/Prezel/core/audio/src/main/java/com/team/prezel/core/audio/MediaRecordingAudioController.kt index 366e4550..9223d6ec 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 @@ -3,7 +3,6 @@ package com.team.prezel.core.audio import android.content.Context import android.media.MediaPlayer import android.media.MediaRecorder -import android.net.Uri import android.os.Build import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineScope @@ -11,13 +10,13 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import java.io.File @@ -32,8 +31,8 @@ internal class MediaRecordingAudioController @Inject constructor( private val _audioSessionState = MutableStateFlow(AudioSessionState.Idle) override val audioSessionState: StateFlow = _audioSessionState.asStateFlow() - private val _audioSessionEvent = MutableSharedFlow(extraBufferCapacity = EVENT_BUFFER_CAPACITY) - override val audioSessionEvent: SharedFlow = _audioSessionEvent.asSharedFlow() + private val _audioSessionEffect = Channel(capacity = Channel.BUFFERED) + override val audioSessionEffect: Flow = _audioSessionEffect.receiveAsFlow() private var recorder: MediaRecorder? = null private var player: MediaPlayer? = null @@ -74,37 +73,7 @@ internal class MediaRecordingAudioController @Inject constructor( releaseRecorder() deleteCurrentAudioFile() _audioSessionState.value = AudioSessionState.Idle - emitEvent(AudioSessionEvent.RecordingStartFailed) - } - } - - override fun pauseRecording() { - val state = audioSessionState.value as? AudioSessionState.Recording ?: return - - runCatching { - recorder?.pause() ?: error("Recording is not active.") - }.onSuccess { - recordingTimerJob?.cancel() - _audioSessionState.value = AudioSessionState.RecordingPaused( - elapsedSeconds = state.elapsedSeconds, - ) - }.onFailure { - emitEvent(AudioSessionEvent.RecordingPauseFailed) - } - } - - override fun resumeRecording() { - val state = audioSessionState.value as? AudioSessionState.RecordingPaused ?: return - - runCatching { - recorder?.resume() ?: error("Recording is not active.") - }.onSuccess { - _audioSessionState.value = AudioSessionState.Recording( - elapsedSeconds = state.elapsedSeconds, - ) - startRecordingTimer() - }.onFailure { - emitEvent(AudioSessionEvent.RecordingResumeFailed) + emitEffect(AudioSessionEffect.RecordingStartFailed) } } @@ -112,10 +81,9 @@ internal class MediaRecordingAudioController @Inject constructor( val state = audioSessionState.value val elapsedSeconds = when (state) { is AudioSessionState.Recording -> state.elapsedSeconds - is AudioSessionState.RecordingPaused -> state.elapsedSeconds else -> return } - val file = currentAudioFile ?: return emitEvent(AudioSessionEvent.RecordingStopFailed) + val file = currentAudioFile ?: return emitEffect(AudioSessionEffect.RecordingStopFailed) recordingTimerJob?.cancel() runCatching { @@ -131,48 +99,7 @@ internal class MediaRecordingAudioController @Inject constructor( releaseRecorder() deleteCurrentAudioFile() _audioSessionState.value = AudioSessionState.Idle - emitEvent(AudioSessionEvent.RecordingStopFailed) - } - } - - override fun resetRecording() { - when (audioSessionState.value) { - is AudioSessionState.Recording, - is AudioSessionState.RecordingPaused, - -> { - recordingTimerJob?.cancel() - releaseRecorder() - deleteCurrentAudioFile() - _audioSessionState.value = AudioSessionState.Idle - } - - else -> Unit - } - } - - override fun loadAudioFile(uri: Uri) { - runCatching { - stopPlayback() - releaseRecorder() - deleteCurrentAudioFile() - - val file = File.createTempFile("external_audio_", ".m4a", context.cacheDir) - context.contentResolver.openInputStream(uri)?.use { input -> - file.outputStream().use { output -> - input.copyTo(output) - } - } ?: error("Audio file could not be opened.") - - val durationSeconds = readDurationSeconds(file) - currentAudioFile = file - _audioSessionState.value = AudioSessionState.ReadyToPlay( - source = AudioSource.ExternalFile(filePath = file.absolutePath), - durationSeconds = durationSeconds, - ) - }.onFailure { - deleteCurrentAudioFile() - _audioSessionState.value = AudioSessionState.Idle - emitEvent(AudioSessionEvent.FileLoadFailed) + emitEffect(AudioSessionEffect.RecordingStopFailed) } } @@ -181,60 +108,18 @@ internal class MediaRecordingAudioController @Inject constructor( is AudioSessionState.ReadyToPlay -> startPlayback( source = state.source, durationSeconds = state.durationSeconds, - startPositionSeconds = 0, - ) - - is AudioSessionState.PlaybackPaused -> startPlayback( - source = state.source, - durationSeconds = state.durationSeconds, - startPositionSeconds = state.positionSeconds, + startPositionSeconds = state.positionSeconds.takeIf { it < state.durationSeconds } ?: 0, ) else -> Unit } } - override fun pausePlayback() { - val state = audioSessionState.value as? AudioSessionState.Playing ?: return - - runCatching { - player?.pause() ?: error("Playback is not active.") - }.onSuccess { - playbackTimerJob?.cancel() - _audioSessionState.value = AudioSessionState.PlaybackPaused( - source = state.source, - positionSeconds = playbackPositionSeconds().coerceAtLeast(state.positionSeconds), - durationSeconds = state.durationSeconds, - ) - } - } - - override fun resumePlayback() { - val state = audioSessionState.value as? AudioSessionState.PlaybackPaused ?: return - - runCatching { - player?.start() ?: error("Playback is not active.") - }.onSuccess { - _audioSessionState.value = AudioSessionState.Playing( - source = state.source, - positionSeconds = state.positionSeconds, - durationSeconds = state.durationSeconds, - ) - startPlaybackTimer() - }.onFailure { - emitEvent(AudioSessionEvent.PlaybackStartFailed) - } - } - override fun stopPlayback() { val readyState = when (val state = audioSessionState.value) { is AudioSessionState.Playing -> AudioSessionState.ReadyToPlay( source = state.source, - durationSeconds = state.durationSeconds, - ) - - is AudioSessionState.PlaybackPaused -> AudioSessionState.ReadyToPlay( - source = state.source, + positionSeconds = playbackPositionSeconds().coerceAtLeast(state.positionSeconds), durationSeconds = state.durationSeconds, ) @@ -279,6 +164,7 @@ internal class MediaRecordingAudioController @Inject constructor( releasePlayer() _audioSessionState.value = AudioSessionState.ReadyToPlay( source = source, + positionSeconds = durationSeconds, durationSeconds = durationSeconds, ) } @@ -302,7 +188,7 @@ internal class MediaRecordingAudioController @Inject constructor( source = source, durationSeconds = durationSeconds, ) - emitEvent(AudioSessionEvent.PlaybackStartFailed) + emitEffect(AudioSessionEffect.PlaybackStartFailed) } } @@ -336,18 +222,6 @@ internal class MediaRecordingAudioController @Inject constructor( } } - private fun readDurationSeconds(file: File): Int = - runCatching { - val mediaPlayer = MediaPlayer() - try { - mediaPlayer.setDataSource(file.absolutePath) - mediaPlayer.prepare() - mediaPlayer.duration.toSeconds() - } finally { - mediaPlayer.release() - } - }.getOrDefault(0) - @Suppress("DEPRECATION") private fun createMediaRecorder(): MediaRecorder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { @@ -375,12 +249,11 @@ internal class MediaRecordingAudioController @Inject constructor( currentAudioFile = null } - private fun emitEvent(event: AudioSessionEvent) { - _audioSessionEvent.tryEmit(event) + private fun emitEffect(effect: AudioSessionEffect) { + _audioSessionEffect.trySend(effect) } private companion object { - const val EVENT_BUFFER_CAPACITY = 8 const val MILLIS_PER_SECOND = 1_000 const val RECORDING_TIMER_DELAY_MILLIS = 1_000L const val PLAYBACK_TIMER_DELAY_MILLIS = 250L 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 f15f2b17..eae71baf 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,37 +1,26 @@ package com.team.prezel.core.audio -import android.net.Uri -import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow +import javax.annotation.concurrent.Immutable interface RecordingAudioController { val audioSessionState: StateFlow - val audioSessionEvent: SharedFlow + val audioSessionEffect: Flow fun startRecording() - fun pauseRecording() - - fun resumeRecording() - fun stopRecording() - fun resetRecording() - - fun loadAudioFile(uri: Uri) - fun startPlayback() - fun pausePlayback() - - fun resumePlayback() - fun stopPlayback() fun release() } +@Immutable sealed interface AudioSessionState { data object Idle : AudioSessionState @@ -39,12 +28,9 @@ sealed interface AudioSessionState { val elapsedSeconds: Int, ) : AudioSessionState - data class RecordingPaused( - val elapsedSeconds: Int, - ) : AudioSessionState - data class ReadyToPlay( val source: AudioSource, + val positionSeconds: Int = 0, val durationSeconds: Int, ) : AudioSessionState @@ -53,36 +39,21 @@ sealed interface AudioSessionState { val positionSeconds: Int, val durationSeconds: Int, ) : AudioSessionState - - data class PlaybackPaused( - val source: AudioSource, - val positionSeconds: Int, - val durationSeconds: Int, - ) : AudioSessionState } +@Immutable sealed interface AudioSource { val filePath: String data class RecordedFile( override val filePath: String, ) : AudioSource - - data class ExternalFile( - override val filePath: String, - ) : AudioSource } -sealed interface AudioSessionEvent { - data object RecordingStartFailed : AudioSessionEvent - - data object RecordingPauseFailed : AudioSessionEvent - - data object RecordingResumeFailed : AudioSessionEvent - - data object RecordingStopFailed : AudioSessionEvent +sealed interface AudioSessionEffect { + data object RecordingStartFailed : AudioSessionEffect - data object PlaybackStartFailed : AudioSessionEvent + data object RecordingStopFailed : AudioSessionEffect - data object FileLoadFailed : AudioSessionEvent + data object PlaybackStartFailed : AudioSessionEffect } diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/HomeScreen.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/HomeScreen.kt similarity index 92% rename from Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/HomeScreen.kt rename to Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/HomeScreen.kt index d0042317..b4f66b6a 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/HomeScreen.kt +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/HomeScreen.kt @@ -1,4 +1,4 @@ -package com.team.prezel.feature.home.impl +package com.team.prezel.feature.home.impl.main import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints @@ -27,17 +27,18 @@ import com.team.prezel.core.model.presentation.Category import com.team.prezel.core.navigation.LocalNavigator import com.team.prezel.core.ui.state.LocalSnackbarHostState import com.team.prezel.core.ui.util.onHeightChanged -import com.team.prezel.feature.home.impl.component.HomePageLayout -import com.team.prezel.feature.home.impl.component.body.EmptyPresentationSheet -import com.team.prezel.feature.home.impl.component.body.PresentationSheet -import com.team.prezel.feature.home.impl.component.head.HomeHeadSection -import com.team.prezel.feature.home.impl.component.title.EmptyPresentationHero -import com.team.prezel.feature.home.impl.component.title.PresentationHero -import com.team.prezel.feature.home.impl.contract.HomeUiEffect -import com.team.prezel.feature.home.impl.contract.HomeUiIntent -import com.team.prezel.feature.home.impl.contract.HomeUiState -import com.team.prezel.feature.home.impl.model.HomeUiMessage -import com.team.prezel.feature.home.impl.model.PresentationUiModel +import com.team.prezel.feature.home.impl.R +import com.team.prezel.feature.home.impl.main.component.HomePageLayout +import com.team.prezel.feature.home.impl.main.component.body.EmptyPresentationSheet +import com.team.prezel.feature.home.impl.main.component.body.PresentationSheet +import com.team.prezel.feature.home.impl.main.component.head.HomeHeadSection +import com.team.prezel.feature.home.impl.main.component.title.EmptyPresentationHero +import com.team.prezel.feature.home.impl.main.component.title.PresentationHero +import com.team.prezel.feature.home.impl.main.contract.HomeUiEffect +import com.team.prezel.feature.home.impl.main.contract.HomeUiIntent +import com.team.prezel.feature.home.impl.main.contract.HomeUiState +import com.team.prezel.feature.home.impl.main.model.HomeUiMessage +import com.team.prezel.feature.home.impl.main.model.PresentationUiModel import com.team.prezel.feature.home.impl.navigation.PracticeRecordingNavKey import kotlinx.collections.immutable.toPersistentList import kotlinx.coroutines.launch diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/HomeViewModel.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/HomeViewModel.kt similarity index 83% rename from Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/HomeViewModel.kt rename to Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/HomeViewModel.kt index b51ec51f..5f314636 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/HomeViewModel.kt +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/HomeViewModel.kt @@ -1,4 +1,4 @@ -package com.team.prezel.feature.home.impl +package com.team.prezel.feature.home.impl.main import androidx.lifecycle.viewModelScope import com.team.prezel.core.model.presentation.Audience @@ -7,11 +7,11 @@ import com.team.prezel.core.model.presentation.Presentation import com.team.prezel.core.model.presentation.Purpose import com.team.prezel.core.model.presentation.Style import com.team.prezel.core.ui.base.BaseViewModel -import com.team.prezel.feature.home.impl.contract.HomeUiEffect -import com.team.prezel.feature.home.impl.contract.HomeUiIntent -import com.team.prezel.feature.home.impl.contract.HomeUiState -import com.team.prezel.feature.home.impl.model.PresentationUiModel -import com.team.prezel.feature.home.impl.model.PresentationUiModel.Companion.toUiModel +import com.team.prezel.feature.home.impl.main.contract.HomeUiEffect +import com.team.prezel.feature.home.impl.main.contract.HomeUiIntent +import com.team.prezel.feature.home.impl.main.contract.HomeUiState +import com.team.prezel.feature.home.impl.main.model.PresentationUiModel +import com.team.prezel.feature.home.impl.main.model.PresentationUiModel.Companion.toUiModel import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.launch import kotlinx.datetime.LocalDate diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/component/HomePageLayout.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/HomePageLayout.kt similarity index 93% rename from Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/component/HomePageLayout.kt rename to Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/HomePageLayout.kt index 95b2bf8d..690d3fe4 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/component/HomePageLayout.kt +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/HomePageLayout.kt @@ -1,4 +1,4 @@ -package com.team.prezel.feature.home.impl.component +package com.team.prezel.feature.home.impl.main.component import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope @@ -24,9 +24,9 @@ import com.team.prezel.core.designsystem.preview.BasicPreview import com.team.prezel.core.designsystem.theme.PrezelTheme import com.team.prezel.core.ui.util.onHeightChanged import com.team.prezel.feature.home.impl.R -import com.team.prezel.feature.home.impl.component.body.HomeBottomSheetContent -import com.team.prezel.feature.home.impl.component.body.HomeBottomSheetTitle -import com.team.prezel.feature.home.impl.component.title.HomeHeroLayout +import com.team.prezel.feature.home.impl.main.component.body.HomeBottomSheetContent +import com.team.prezel.feature.home.impl.main.component.body.HomeBottomSheetTitle +import com.team.prezel.feature.home.impl.main.component.title.HomeHeroLayout private data class HomeBottomSheetLayoutState( val sheetPeekHeight: Dp, diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/component/body/EmptyPresentationSheet.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/body/EmptyPresentationSheet.kt similarity index 96% rename from Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/component/body/EmptyPresentationSheet.kt rename to Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/body/EmptyPresentationSheet.kt index 8ab681f2..0ee5c218 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/component/body/EmptyPresentationSheet.kt +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/body/EmptyPresentationSheet.kt @@ -1,4 +1,4 @@ -package com.team.prezel.feature.home.impl.component.body +package com.team.prezel.feature.home.impl.main.component.body import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/component/body/HomeBottomSheetContent.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/body/HomeBottomSheetContent.kt similarity index 98% rename from Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/component/body/HomeBottomSheetContent.kt rename to Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/body/HomeBottomSheetContent.kt index dd1173fb..8349ee6b 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/component/body/HomeBottomSheetContent.kt +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/body/HomeBottomSheetContent.kt @@ -1,4 +1,4 @@ -package com.team.prezel.feature.home.impl.component.body +package com.team.prezel.feature.home.impl.main.component.body import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/component/body/HomeBottomSheetTitle.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/body/HomeBottomSheetTitle.kt similarity index 93% rename from Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/component/body/HomeBottomSheetTitle.kt rename to Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/body/HomeBottomSheetTitle.kt index 9b8aec7c..ee4efc4c 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/component/body/HomeBottomSheetTitle.kt +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/body/HomeBottomSheetTitle.kt @@ -1,4 +1,4 @@ -package com.team.prezel.feature.home.impl.component.body +package com.team.prezel.feature.home.impl.main.component.body import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxWidth diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/component/body/PresentationSheet.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/body/PresentationSheet.kt similarity index 94% rename from Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/component/body/PresentationSheet.kt rename to Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/body/PresentationSheet.kt index 590de8e0..a98c7247 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/component/body/PresentationSheet.kt +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/body/PresentationSheet.kt @@ -1,4 +1,4 @@ -package com.team.prezel.feature.home.impl.component.body +package com.team.prezel.feature.home.impl.main.component.body import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues @@ -14,7 +14,7 @@ import com.team.prezel.core.designsystem.preview.BasicPreview import com.team.prezel.core.designsystem.theme.PrezelTheme import com.team.prezel.core.model.presentation.Category import com.team.prezel.feature.home.impl.R -import com.team.prezel.feature.home.impl.model.PresentationUiModel +import com.team.prezel.feature.home.impl.main.model.PresentationUiModel import kotlinx.datetime.LocalDate @Composable diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/component/head/HomeHeadSection.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/head/HomeHeadSection.kt similarity index 94% rename from Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/component/head/HomeHeadSection.kt rename to Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/head/HomeHeadSection.kt index 36e53fc3..6b9a814d 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/component/head/HomeHeadSection.kt +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/head/HomeHeadSection.kt @@ -1,4 +1,4 @@ -package com.team.prezel.feature.home.impl.component.head +package com.team.prezel.feature.home.impl.main.component.head import androidx.compose.foundation.background import androidx.compose.foundation.layout.Column @@ -17,8 +17,8 @@ import com.team.prezel.core.designsystem.preview.BasicPreview import com.team.prezel.core.designsystem.theme.PrezelTheme import com.team.prezel.core.model.presentation.Category import com.team.prezel.feature.home.impl.R -import com.team.prezel.feature.home.impl.contract.HomeUiState -import com.team.prezel.feature.home.impl.model.PresentationUiModel +import com.team.prezel.feature.home.impl.main.contract.HomeUiState +import com.team.prezel.feature.home.impl.main.model.PresentationUiModel import kotlinx.collections.immutable.toImmutableList import kotlinx.datetime.LocalDate diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/component/title/EmptyPresentationHero.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/title/EmptyPresentationHero.kt similarity index 96% rename from Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/component/title/EmptyPresentationHero.kt rename to Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/title/EmptyPresentationHero.kt index f6d3563c..89be3899 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/component/title/EmptyPresentationHero.kt +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/title/EmptyPresentationHero.kt @@ -1,4 +1,4 @@ -package com.team.prezel.feature.home.impl.component.title +package com.team.prezel.feature.home.impl.main.component.title import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/component/title/HomeHeroLayout.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/title/HomeHeroLayout.kt similarity index 96% rename from Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/component/title/HomeHeroLayout.kt rename to Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/title/HomeHeroLayout.kt index 2f225e73..c61348f1 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/component/title/HomeHeroLayout.kt +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/title/HomeHeroLayout.kt @@ -1,4 +1,4 @@ -package com.team.prezel.feature.home.impl.component.title +package com.team.prezel.feature.home.impl.main.component.title import androidx.annotation.DrawableRes import androidx.compose.foundation.layout.Column diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/component/title/PracticeActionCard.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/title/PracticeActionCard.kt similarity index 97% rename from Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/component/title/PracticeActionCard.kt rename to Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/title/PracticeActionCard.kt index 6619074d..6547f2cd 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/component/title/PracticeActionCard.kt +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/title/PracticeActionCard.kt @@ -1,4 +1,4 @@ -package com.team.prezel.feature.home.impl.component.title +package com.team.prezel.feature.home.impl.main.component.title import androidx.compose.foundation.background import androidx.compose.foundation.border diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/component/title/PresentationHero.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/title/PresentationHero.kt similarity index 97% rename from Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/component/title/PresentationHero.kt rename to Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/title/PresentationHero.kt index ce8d5ade..5598b7a6 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/component/title/PresentationHero.kt +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/title/PresentationHero.kt @@ -1,4 +1,4 @@ -package com.team.prezel.feature.home.impl.component.title +package com.team.prezel.feature.home.impl.main.component.title import androidx.annotation.DrawableRes import androidx.annotation.StringRes @@ -16,7 +16,7 @@ import com.team.prezel.core.designsystem.preview.BasicPreview import com.team.prezel.core.designsystem.theme.PrezelTheme import com.team.prezel.core.model.presentation.Category import com.team.prezel.feature.home.impl.R -import com.team.prezel.feature.home.impl.model.PresentationUiModel +import com.team.prezel.feature.home.impl.main.model.PresentationUiModel import kotlinx.datetime.LocalDate import kotlinx.datetime.number diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/contract/HomeUiEffect.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/contract/HomeUiEffect.kt similarity index 60% rename from Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/contract/HomeUiEffect.kt rename to Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/contract/HomeUiEffect.kt index ad982ba7..60d98093 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/contract/HomeUiEffect.kt +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/contract/HomeUiEffect.kt @@ -1,7 +1,7 @@ -package com.team.prezel.feature.home.impl.contract +package com.team.prezel.feature.home.impl.main.contract import com.team.prezel.core.ui.base.UiEffect -import com.team.prezel.feature.home.impl.model.HomeUiMessage +import com.team.prezel.feature.home.impl.main.model.HomeUiMessage internal sealed interface HomeUiEffect : UiEffect { data class ShowMessage( diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/contract/HomeUiIntent.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/contract/HomeUiIntent.kt similarity index 71% rename from Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/contract/HomeUiIntent.kt rename to Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/contract/HomeUiIntent.kt index 103fa67f..cc2fc967 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/contract/HomeUiIntent.kt +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/contract/HomeUiIntent.kt @@ -1,4 +1,4 @@ -package com.team.prezel.feature.home.impl.contract +package com.team.prezel.feature.home.impl.main.contract import com.team.prezel.core.ui.base.UiIntent diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/contract/HomeUiState.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/contract/HomeUiState.kt similarity index 91% rename from Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/contract/HomeUiState.kt rename to Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/contract/HomeUiState.kt index 742cb022..cf6b5cd9 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/contract/HomeUiState.kt +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/contract/HomeUiState.kt @@ -1,8 +1,8 @@ -package com.team.prezel.feature.home.impl.contract +package com.team.prezel.feature.home.impl.main.contract import androidx.compose.runtime.Immutable import com.team.prezel.core.ui.base.UiState -import com.team.prezel.feature.home.impl.model.PresentationUiModel +import com.team.prezel.feature.home.impl.main.model.PresentationUiModel import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/model/HomeUiMessage.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/model/HomeUiMessage.kt similarity index 50% rename from Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/model/HomeUiMessage.kt rename to Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/model/HomeUiMessage.kt index 5cd796f8..5ddf2ee7 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/model/HomeUiMessage.kt +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/model/HomeUiMessage.kt @@ -1,4 +1,4 @@ -package com.team.prezel.feature.home.impl.model +package com.team.prezel.feature.home.impl.main.model enum class HomeUiMessage { FETCH_DATA_FAILED, diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/model/PresentationUiModel.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/model/PresentationUiModel.kt similarity index 95% rename from Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/model/PresentationUiModel.kt rename to Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/model/PresentationUiModel.kt index 5d7d055e..78e1766d 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/model/PresentationUiModel.kt +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/model/PresentationUiModel.kt @@ -1,4 +1,4 @@ -package com.team.prezel.feature.home.impl.model +package com.team.prezel.feature.home.impl.main.model import androidx.compose.runtime.Immutable import com.team.prezel.core.model.presentation.Category 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 548720c5..a8938159 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,7 +4,7 @@ import androidx.navigation3.runtime.EntryProviderScope import androidx.navigation3.runtime.NavKey import com.team.prezel.core.navigation.LocalNavigator import com.team.prezel.feature.home.api.HomeNavKey -import com.team.prezel.feature.home.impl.HomeScreen +import com.team.prezel.feature.home.impl.main.HomeScreen import com.team.prezel.feature.home.impl.practice.PracticeRecordingScreen import dagger.Module import dagger.Provides diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/PracticeRecordingScreen.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/PracticeRecordingScreen.kt index a8c1aee2..bf5d0bfa 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/PracticeRecordingScreen.kt +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/PracticeRecordingScreen.kt @@ -1,8 +1,6 @@ package com.team.prezel.feature.home.impl.practice import androidx.activity.compose.BackHandler -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.background import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize @@ -14,7 +12,10 @@ import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.res.stringResource import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.team.prezel.core.audio.AudioSessionState +import com.team.prezel.core.audio.AudioSource 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.feedback.snackbar.showPrezelSnackbar import com.team.prezel.core.designsystem.preview.BasicPreview import com.team.prezel.core.designsystem.theme.PrezelTheme @@ -22,12 +23,10 @@ import com.team.prezel.core.ui.state.LocalSnackbarHostState import com.team.prezel.feature.home.impl.R import com.team.prezel.feature.home.impl.practice.component.PracticeRecordingContent import com.team.prezel.feature.home.impl.practice.component.PracticeRecordingTopAppBar -import com.team.prezel.feature.home.impl.practice.component.toControlState import com.team.prezel.feature.home.impl.practice.contract.PracticeRecordingUiEffect import com.team.prezel.feature.home.impl.practice.contract.PracticeRecordingUiIntent import com.team.prezel.feature.home.impl.practice.contract.PracticeRecordingUiState import com.team.prezel.feature.home.impl.practice.model.PracticeRecordingAnalysisStatus -import com.team.prezel.feature.home.impl.practice.model.PracticeRecordingState import com.team.prezel.feature.home.impl.practice.model.PracticeRecordingUiMessage import com.team.prezel.feature.home.impl.practice.result.PracticeRecordingResultScreen import kotlinx.coroutines.flow.collectLatest @@ -42,12 +41,6 @@ internal fun PracticeRecordingScreen( val uiState by viewModel.uiState.collectAsStateWithLifecycle() val resources = LocalResources.current val snackbarHostState = LocalSnackbarHostState.current - val audioFilePickerLauncher = rememberLauncherForActivityResult( - contract = ActivityResultContracts.GetContent(), - ) { uri -> - if (uri == null) return@rememberLauncherForActivityResult - viewModel.onIntent(PracticeRecordingUiIntent.AudioFileSelected(uri)) - } val onStartRecording = rememberRecordAudioPermissionControlClickHandler( recordingState = uiState.recordingState, onStartRecording = { viewModel.onIntent(PracticeRecordingUiIntent.StartRecording) }, @@ -68,6 +61,7 @@ internal fun PracticeRecordingScreen( snackbarHostState.currentSnackbarData?.dismiss() snackbarHostState.showPrezelSnackbar( message = resources.getString(effect.message.resId), + useRaisedPosition = false, ) } } @@ -77,14 +71,8 @@ internal fun PracticeRecordingScreen( PracticeRecordingScreen( uiState = uiState, onStartRecording = onStartRecording, - onPauseRecording = { viewModel.onIntent(PracticeRecordingUiIntent.PauseRecording) }, - onResumeRecording = { viewModel.onIntent(PracticeRecordingUiIntent.ResumeRecording) }, onStopRecording = { viewModel.onIntent(PracticeRecordingUiIntent.StopRecording) }, - onResetRecording = { viewModel.onIntent(PracticeRecordingUiIntent.ResetRecording) }, - onSelectAudioFile = { audioFilePickerLauncher.launch(AUDIO_FILE_MIME_TYPE) }, onStartPlayback = { viewModel.onIntent(PracticeRecordingUiIntent.StartPlayback) }, - onPausePlayback = { viewModel.onIntent(PracticeRecordingUiIntent.PausePlayback) }, - onResumePlayback = { viewModel.onIntent(PracticeRecordingUiIntent.ResumePlayback) }, onStopPlayback = { viewModel.onIntent(PracticeRecordingUiIntent.StopPlayback) }, onClickAnalyze = { viewModel.onIntent(PracticeRecordingUiIntent.AnalyzeClicked) }, onBack = onBack, @@ -97,14 +85,8 @@ internal fun PracticeRecordingScreen( private fun PracticeRecordingScreen( uiState: PracticeRecordingUiState, onStartRecording: () -> Unit, - onPauseRecording: () -> Unit, - onResumeRecording: () -> Unit, onStopRecording: () -> Unit, - onResetRecording: () -> Unit, - onSelectAudioFile: () -> Unit, onStartPlayback: () -> Unit, - onPausePlayback: () -> Unit, - onResumePlayback: () -> Unit, onStopPlayback: () -> Unit, onClickAnalyze: () -> Unit, onBack: () -> Unit, @@ -117,14 +99,8 @@ private fun PracticeRecordingScreen( PracticeRecordingAnalysisStatus.Ready -> PracticeRecordingReadyScreen( uiState = uiState, onStartRecording = onStartRecording, - onPauseRecording = onPauseRecording, - onResumeRecording = onResumeRecording, onStopRecording = onStopRecording, - onResetRecording = onResetRecording, - onSelectAudioFile = onSelectAudioFile, onStartPlayback = onStartPlayback, - onPausePlayback = onPausePlayback, - onResumePlayback = onResumePlayback, onStopPlayback = onStopPlayback, onClickAnalyze = onClickAnalyze, onBack = onBack, @@ -145,14 +121,8 @@ private fun PracticeRecordingScreen( private fun PracticeRecordingReadyScreen( uiState: PracticeRecordingUiState, onStartRecording: () -> Unit, - onPauseRecording: () -> Unit, - onResumeRecording: () -> Unit, onStopRecording: () -> Unit, - onResetRecording: () -> Unit, - onSelectAudioFile: () -> Unit, onStartPlayback: () -> Unit, - onPausePlayback: () -> Unit, - onResumePlayback: () -> Unit, onStopPlayback: () -> Unit, onClickAnalyze: () -> Unit, onBack: () -> Unit, @@ -171,27 +141,24 @@ private fun PracticeRecordingReadyScreen( practiceScript = uiState.practiceScript, currentSeconds = uiState.currentSeconds, totalSeconds = uiState.totalSeconds, - controlState = uiState.recordingState.toControlState(), + recordingState = uiState.recordingState, onStartRecording = onStartRecording, - onPauseRecording = onPauseRecording, - onResumeRecording = onResumeRecording, onStopRecording = onStopRecording, - onResetRecording = onResetRecording, - onSelectAudioFile = onSelectAudioFile, onStartPlayback = onStartPlayback, - onPausePlayback = onPausePlayback, - onResumePlayback = onResumePlayback, onStopPlayback = onStopPlayback, modifier = Modifier.weight(1f), ) - PrezelButtonArea { - MainButton( - label = analyzeLabel, - enabled = uiState.analyzeEnabled, - onClick = onClickAnalyze, - ) - } + PrezelButtonArea( + mainButton = { buttonModifier -> + PrezelButton( + text = analyzeLabel, + modifier = buttonModifier, + enabled = uiState.analyzeEnabled, + onClick = onClickAnalyze, + ) + }, + ) } } @@ -205,7 +172,6 @@ private val PracticeRecordingUiMessage.resId: Int PracticeRecordingUiMessage.RECORDING_START_FAILED -> R.string.feature_home_impl_practice_recording_failed PracticeRecordingUiMessage.RECORDING_STOP_FAILED -> R.string.feature_home_impl_practice_recording_stop_failed PracticeRecordingUiMessage.PLAYBACK_START_FAILED -> R.string.feature_home_impl_practice_recording_playback_failed - PracticeRecordingUiMessage.AUDIO_FILE_LOAD_FAILED -> R.string.feature_home_impl_practice_recording_file_load_failed } @BasicPreview @@ -222,8 +188,8 @@ private fun PracticeRecordingScreenRecordingPreview() { PrezelTheme { PracticeRecordingScreenPreviewContent( uiState = PracticeRecordingUiState( - recordingState = PracticeRecordingState.Recording( - recordingSeconds = 12, + recordingState = AudioSessionState.Recording( + elapsedSeconds = 12, ), ), ) @@ -236,10 +202,9 @@ private fun PracticeRecordingScreenRecordedPreview() { PrezelTheme { PracticeRecordingScreenPreviewContent( uiState = PracticeRecordingUiState( - recordingState = PracticeRecordingState.ReadyToPlay( - filePath = "", + recordingState = AudioSessionState.ReadyToPlay( + source = AudioSource.RecordedFile(filePath = ""), durationSeconds = 32, - sourceType = PracticeRecordingState.SourceType.RECORDED_FILE, ), ), ) @@ -252,11 +217,10 @@ private fun PracticeRecordingScreenPlayingPreview() { PrezelTheme { PracticeRecordingScreenPreviewContent( uiState = PracticeRecordingUiState( - recordingState = PracticeRecordingState.Playing( - filePath = "", - playbackSeconds = 12, + recordingState = AudioSessionState.Playing( + source = AudioSource.RecordedFile(filePath = ""), + positionSeconds = 12, durationSeconds = 32, - sourceType = PracticeRecordingState.SourceType.RECORDED_FILE, ), ), ) @@ -270,19 +234,11 @@ private fun PracticeRecordingScreenPreviewContent(uiState: PracticeRecordingUiSt practiceScript = "내가 그린 기린 그림은 잘 그린 기린 그림이고,\n네가 그린 기린 그림은 잘못 그린 기린 그림이다.", ), onStartRecording = {}, - onPauseRecording = {}, - onResumeRecording = {}, onStopRecording = {}, - onResetRecording = {}, - onSelectAudioFile = {}, onStartPlayback = {}, - onPausePlayback = {}, - onResumePlayback = {}, onStopPlayback = {}, onClickAnalyze = {}, onBack = {}, navigateToHome = {}, ) } - -private const val AUDIO_FILE_MIME_TYPE = "audio/*" diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/PracticeRecordingViewModel.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/PracticeRecordingViewModel.kt index 51151843..baf9cc0c 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/PracticeRecordingViewModel.kt +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/PracticeRecordingViewModel.kt @@ -1,23 +1,18 @@ package com.team.prezel.feature.home.impl.practice import androidx.lifecycle.viewModelScope -import com.team.prezel.core.audio.AudioSessionEvent -import com.team.prezel.core.audio.AudioSessionState -import com.team.prezel.core.audio.AudioSource +import com.team.prezel.core.audio.AudioSessionEffect import com.team.prezel.core.audio.RecordingAudioController import com.team.prezel.core.domain.usecase.practice.AnalyzePracticeRecordingUseCase import com.team.prezel.core.domain.usecase.practice.FetchPracticeScriptUseCase import com.team.prezel.core.model.practice.PracticeRecordingAnalysisResult -import com.team.prezel.core.model.practice.PracticeRecordingSpeed import com.team.prezel.core.ui.base.BaseViewModel import com.team.prezel.feature.home.impl.practice.contract.PracticeRecordingUiEffect import com.team.prezel.feature.home.impl.practice.contract.PracticeRecordingUiIntent import com.team.prezel.feature.home.impl.practice.contract.PracticeRecordingUiState import com.team.prezel.feature.home.impl.practice.model.PracticeRecordingAnalysisErrorType -import com.team.prezel.feature.home.impl.practice.model.PracticeRecordingAnalysisSpeed import com.team.prezel.feature.home.impl.practice.model.PracticeRecordingAnalysisStatus import com.team.prezel.feature.home.impl.practice.model.PracticeRecordingAnalysisUiModel -import com.team.prezel.feature.home.impl.practice.model.PracticeRecordingState import com.team.prezel.feature.home.impl.practice.model.PracticeRecordingUiMessage import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.delay @@ -32,7 +27,7 @@ internal class PracticeRecordingViewModel @Inject constructor( ) : BaseViewModel(PracticeRecordingUiState()) { init { collectAudioSessionState() - collectAudioSessionEvent() + collectAudioSessionEffect() } override fun onIntent(intent: PracticeRecordingUiIntent) { @@ -48,22 +43,8 @@ internal class PracticeRecordingViewModel @Inject constructor( audioController.startRecording() } - PracticeRecordingUiIntent.PauseRecording -> audioController.pauseRecording() - PracticeRecordingUiIntent.ResumeRecording -> audioController.resumeRecording() PracticeRecordingUiIntent.StopRecording -> audioController.stopRecording() - PracticeRecordingUiIntent.ResetRecording -> { - updateState { copy(analysisStatus = PracticeRecordingAnalysisStatus.Ready) } - audioController.resetRecording() - } - - is PracticeRecordingUiIntent.AudioFileSelected -> { - updateState { copy(analysisStatus = PracticeRecordingAnalysisStatus.Ready) } - audioController.loadAudioFile(intent.uri) - } - PracticeRecordingUiIntent.StartPlayback -> audioController.startPlayback() - PracticeRecordingUiIntent.PausePlayback -> audioController.pausePlayback() - PracticeRecordingUiIntent.ResumePlayback -> audioController.resumePlayback() PracticeRecordingUiIntent.StopPlayback -> audioController.stopPlayback() PracticeRecordingUiIntent.AnalyzeClicked -> startAnalysis() } @@ -73,16 +54,16 @@ internal class PracticeRecordingViewModel @Inject constructor( viewModelScope.launch { audioController.audioSessionState.collect { audioState -> updateState { - copy(recordingState = audioState.toPracticeRecordingState()) + copy(recordingState = audioState) } } } } - private fun collectAudioSessionEvent() { + private fun collectAudioSessionEffect() { viewModelScope.launch { - audioController.audioSessionEvent.collect { event -> - showMessage(event.toUiMessage()) + audioController.audioSessionEffect.collect { effect -> + showMessage(effect.toUiMessage()) } } } @@ -102,7 +83,7 @@ internal class PracticeRecordingViewModel @Inject constructor( private fun startAnalysis() { if (!currentState.analyzeEnabled) return - val filePath = currentState.recordingState.filePath ?: return + val filePath = currentState.recordingFilePath ?: return audioController.stopPlayback() viewModelScope.launch { @@ -149,65 +130,15 @@ internal class PracticeRecordingViewModel @Inject constructor( } } -private fun AudioSessionState.toPracticeRecordingState(): PracticeRecordingState = +private fun AudioSessionEffect.toUiMessage(): PracticeRecordingUiMessage = when (this) { - AudioSessionState.Idle -> PracticeRecordingState.Idle - is AudioSessionState.Recording -> PracticeRecordingState.Recording( - recordingSeconds = elapsedSeconds, - ) - - is AudioSessionState.RecordingPaused -> PracticeRecordingState.RecordingPaused( - recordingSeconds = elapsedSeconds, - ) - - is AudioSessionState.ReadyToPlay -> PracticeRecordingState.ReadyToPlay( - filePath = source.filePath, - durationSeconds = durationSeconds, - sourceType = source.toPracticeSourceType(), - ) - - is AudioSessionState.Playing -> PracticeRecordingState.Playing( - filePath = source.filePath, - playbackSeconds = positionSeconds, - durationSeconds = durationSeconds, - sourceType = source.toPracticeSourceType(), - ) - - is AudioSessionState.PlaybackPaused -> PracticeRecordingState.PlaybackPaused( - filePath = source.filePath, - playbackSeconds = positionSeconds, - durationSeconds = durationSeconds, - sourceType = source.toPracticeSourceType(), - ) - } - -private fun AudioSource.toPracticeSourceType(): PracticeRecordingState.SourceType = - when (this) { - is AudioSource.RecordedFile -> PracticeRecordingState.SourceType.RECORDED_FILE - is AudioSource.ExternalFile -> PracticeRecordingState.SourceType.EXTERNAL_FILE - } - -private fun AudioSessionEvent.toUiMessage(): PracticeRecordingUiMessage = - when (this) { - AudioSessionEvent.RecordingStartFailed, - AudioSessionEvent.RecordingPauseFailed, - AudioSessionEvent.RecordingResumeFailed, - -> PracticeRecordingUiMessage.RECORDING_START_FAILED - - AudioSessionEvent.RecordingStopFailed -> PracticeRecordingUiMessage.RECORDING_STOP_FAILED - AudioSessionEvent.PlaybackStartFailed -> PracticeRecordingUiMessage.PLAYBACK_START_FAILED - AudioSessionEvent.FileLoadFailed -> PracticeRecordingUiMessage.AUDIO_FILE_LOAD_FAILED + AudioSessionEffect.RecordingStartFailed -> PracticeRecordingUiMessage.RECORDING_START_FAILED + AudioSessionEffect.RecordingStopFailed -> PracticeRecordingUiMessage.RECORDING_STOP_FAILED + AudioSessionEffect.PlaybackStartFailed -> PracticeRecordingUiMessage.PLAYBACK_START_FAILED } private fun PracticeRecordingAnalysisResult.toUiModel(): PracticeRecordingAnalysisUiModel = PracticeRecordingAnalysisUiModel( pronunciationScore = pronunciationScore, - speed = speed.toUiModel(), + speed = speed, ) - -private fun PracticeRecordingSpeed.toUiModel(): PracticeRecordingAnalysisSpeed = - when (this) { - PracticeRecordingSpeed.SLOW -> PracticeRecordingAnalysisSpeed.SLOW - PracticeRecordingSpeed.ADEQUATE -> PracticeRecordingAnalysisSpeed.ADEQUATE - PracticeRecordingSpeed.FAST -> PracticeRecordingAnalysisSpeed.FAST - } diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/RecordAudioPermission.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/RecordAudioPermission.kt index a4d18c13..561af186 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/RecordAudioPermission.kt +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/RecordAudioPermission.kt @@ -2,12 +2,12 @@ package com.team.prezel.feature.home.impl.practice import android.Manifest import androidx.compose.runtime.Composable +import com.team.prezel.core.audio.AudioSessionState import com.team.prezel.core.ui.util.rememberPermissionRequest -import com.team.prezel.feature.home.impl.practice.model.PracticeRecordingState @Composable internal fun rememberRecordAudioPermissionControlClickHandler( - recordingState: PracticeRecordingState, + recordingState: AudioSessionState, onStartRecording: () -> Unit, onPermissionDenied: () -> Unit, onPermissionPermanentlyDenied: () -> Unit, @@ -21,7 +21,7 @@ internal fun rememberRecordAudioPermissionControlClickHandler( return { when (recordingState) { - PracticeRecordingState.Idle -> { + AudioSessionState.Idle -> { when { permissionRequest.isGranted -> onStartRecording() permissionRequest.isPermanentlyDenied -> permissionRequest.onPermanentlyDenied() @@ -29,11 +29,9 @@ internal fun rememberRecordAudioPermissionControlClickHandler( } } - is PracticeRecordingState.Recording, - is PracticeRecordingState.RecordingPaused, - is PracticeRecordingState.ReadyToPlay, - is PracticeRecordingState.Playing, - is PracticeRecordingState.PlaybackPaused, + is AudioSessionState.Recording, + is AudioSessionState.ReadyToPlay, + is AudioSessionState.Playing, -> Unit } } diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/component/PracticeRecordingContent.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/component/PracticeRecordingContent.kt index ae240ace..591bab34 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/component/PracticeRecordingContent.kt +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/component/PracticeRecordingContent.kt @@ -15,6 +15,10 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.team.prezel.core.audio.AudioSessionState +import com.team.prezel.core.audio.AudioSource +import com.team.prezel.core.designsystem.preview.BasicPreview import com.team.prezel.core.designsystem.theme.PrezelTheme import com.team.prezel.feature.home.impl.R @@ -23,16 +27,10 @@ internal fun PracticeRecordingContent( practiceScript: String, currentSeconds: Int, totalSeconds: Int, - controlState: PracticeRecordingControlState, + recordingState: AudioSessionState, onStartRecording: () -> Unit, - onPauseRecording: () -> Unit, - onResumeRecording: () -> Unit, onStopRecording: () -> Unit, - onResetRecording: () -> Unit, - onSelectAudioFile: () -> Unit, onStartPlayback: () -> Unit, - onPausePlayback: () -> Unit, - onResumePlayback: () -> Unit, onStopPlayback: () -> Unit, modifier: Modifier = Modifier, ) { @@ -61,7 +59,15 @@ internal fun PracticeRecordingContent( Text( text = practiceScript, style = PrezelTheme.typography.body2Regular, - color = PrezelTheme.colors.textLarge, + color = when (recordingState) { + is AudioSessionState.ReadyToPlay, + is AudioSessionState.Playing, + -> PrezelTheme.colors.textDisabled + + AudioSessionState.Idle, + is AudioSessionState.Recording, + -> PrezelTheme.colors.textLarge + }, textAlign = TextAlign.Center, ) } @@ -71,17 +77,51 @@ internal fun PracticeRecordingContent( PracticeRecordingControl( currentSeconds = currentSeconds, totalSeconds = totalSeconds, - state = controlState, + audioSessionState = recordingState, onStartRecording = onStartRecording, - onPauseRecording = onPauseRecording, - onResumeRecording = onResumeRecording, onStopRecording = onStopRecording, - onResetRecording = onResetRecording, - onSelectAudioFile = onSelectAudioFile, onStartPlayback = onStartPlayback, - onPausePlayback = onPausePlayback, - onResumePlayback = onResumePlayback, onStopPlayback = onStopPlayback, ) } } + +@BasicPreview +@Composable +private fun PracticeRecordingContentReadyToRecordPreview() { + PrezelTheme { + PracticeRecordingContent( + practiceScript = "안녕하세요. 오늘은 제가 준비한 발표 연습을 시작해보겠습니다.", + currentSeconds = 0, + totalSeconds = 0, + recordingState = AudioSessionState.Idle, + onStartRecording = {}, + onStopRecording = {}, + onStartPlayback = {}, + onStopPlayback = {}, + modifier = Modifier.height(520.dp), + ) + } +} + +@BasicPreview +@Composable +private fun PracticeRecordingContentReadyToPlayPreview() { + PrezelTheme { + PracticeRecordingContent( + practiceScript = "안녕하세요. 오늘은 제가 준비한 발표 연습을 시작해보겠습니다.", + currentSeconds = 12, + totalSeconds = 45, + recordingState = AudioSessionState.ReadyToPlay( + source = AudioSource.RecordedFile(filePath = "preview.m4a"), + positionSeconds = 12, + durationSeconds = 45, + ), + onStartRecording = {}, + onStopRecording = {}, + onStartPlayback = {}, + onStopPlayback = {}, + modifier = Modifier.height(520.dp), + ) + } +} diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/component/PracticeRecordingControl.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/component/PracticeRecordingControl.kt index 46628bbb..ab7efe61 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/component/PracticeRecordingControl.kt +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/component/PracticeRecordingControl.kt @@ -1,9 +1,12 @@ package com.team.prezel.feature.home.impl.practice.component import androidx.annotation.DrawableRes +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -13,61 +16,32 @@ import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp +import com.team.prezel.core.audio.AudioSessionState +import com.team.prezel.core.audio.AudioSource import com.team.prezel.core.designsystem.component.actions.button.PrezelIconButton import com.team.prezel.core.designsystem.component.actions.button.config.ButtonHierarchy import com.team.prezel.core.designsystem.component.actions.button.config.ButtonSize import com.team.prezel.core.designsystem.component.actions.button.config.ButtonType import com.team.prezel.core.designsystem.component.actions.button.config.PrezelButtonDefaults import com.team.prezel.core.designsystem.icon.PrezelIcons +import com.team.prezel.core.designsystem.preview.BasicPreview import com.team.prezel.core.designsystem.theme.PrezelTheme -import com.team.prezel.feature.home.impl.practice.model.PracticeRecordingState - -internal enum class PracticeRecordingControlState { - READY_TO_RECORD, - RECORDING, - RECORDING_PAUSED, - READY_TO_PLAY, - PLAYING, - PLAYBACK_PAUSED, -} - -internal fun PracticeRecordingState.toControlState(): PracticeRecordingControlState = - when (this) { - PracticeRecordingState.Idle -> PracticeRecordingControlState.READY_TO_RECORD - is PracticeRecordingState.Recording -> PracticeRecordingControlState.RECORDING - is PracticeRecordingState.RecordingPaused -> PracticeRecordingControlState.RECORDING_PAUSED - is PracticeRecordingState.ReadyToPlay -> PracticeRecordingControlState.READY_TO_PLAY - is PracticeRecordingState.Playing -> PracticeRecordingControlState.PLAYING - is PracticeRecordingState.PlaybackPaused -> PracticeRecordingControlState.PLAYBACK_PAUSED - } @Composable internal fun PracticeRecordingControl( currentSeconds: Int, totalSeconds: Int, - state: PracticeRecordingControlState, + audioSessionState: AudioSessionState, onStartRecording: () -> Unit, - onPauseRecording: () -> Unit, - onResumeRecording: () -> Unit, onStopRecording: () -> Unit, - onResetRecording: () -> Unit, - onSelectAudioFile: () -> Unit, onStartPlayback: () -> Unit, - onPausePlayback: () -> Unit, - onResumePlayback: () -> Unit, onStopPlayback: () -> Unit, modifier: Modifier = Modifier, ) { - val actions = state.actions( + val actions = audioSessionState.actions( onStartRecording = onStartRecording, - onPauseRecording = onPauseRecording, - onResumeRecording = onResumeRecording, onStopRecording = onStopRecording, - onResetRecording = onResetRecording, - onSelectAudioFile = onSelectAudioFile, onStartPlayback = onStartPlayback, - onPausePlayback = onPausePlayback, - onResumePlayback = onResumePlayback, onStopPlayback = onStopPlayback, ) @@ -79,7 +53,7 @@ internal fun PracticeRecordingControl( PracticeRecordingTimeText( currentSeconds = currentSeconds, totalSeconds = totalSeconds, - state = state, + audioSessionState = audioSessionState, ) Row( @@ -112,15 +86,11 @@ internal fun PracticeRecordingControl( private fun PracticeRecordingTimeText( currentSeconds: Int, totalSeconds: Int, - state: PracticeRecordingControlState, + audioSessionState: AudioSessionState, ) { - if (state != PracticeRecordingControlState.PLAYING && state != PracticeRecordingControlState.PLAYBACK_PAUSED) { + if (audioSessionState == AudioSessionState.Idle || audioSessionState is AudioSessionState.Recording) { Text( - text = state - .displaySeconds( - currentSeconds = currentSeconds, - totalSeconds = totalSeconds, - ).toTimerText(), + text = currentSeconds.toTimerText(), style = PrezelTheme.typography.title1Medium, color = PrezelTheme.colors.textMedium, ) @@ -155,111 +125,40 @@ private enum class PracticeRecordingControlActionColorType { REGULAR, } -private fun PracticeRecordingControlState.actions( +private fun AudioSessionState.actions( onStartRecording: () -> Unit, - onPauseRecording: () -> Unit, - onResumeRecording: () -> Unit, onStopRecording: () -> Unit, - onResetRecording: () -> Unit, - onSelectAudioFile: () -> Unit, onStartPlayback: () -> Unit, - onPausePlayback: () -> Unit, - onResumePlayback: () -> Unit, onStopPlayback: () -> Unit, ): List = when (this) { - PracticeRecordingControlState.READY_TO_RECORD -> listOf( + AudioSessionState.Idle -> listOf( PracticeRecordingControlAction( iconResId = PrezelIcons.Recording, colorType = PracticeRecordingControlActionColorType.RECORD, onClick = onStartRecording, ), - PracticeRecordingControlAction( - iconResId = PrezelIcons.Folder, - colorType = PracticeRecordingControlActionColorType.REGULAR, - onClick = onSelectAudioFile, - ), - ) - - PracticeRecordingControlState.RECORDING -> recordingActions( - primaryIconResId = PrezelIcons.Pause, - onPrimaryClick = onPauseRecording, - onStopRecording = onStopRecording, - onResetRecording = onResetRecording, ) - PracticeRecordingControlState.RECORDING_PAUSED -> recordingActions( - primaryIconResId = PrezelIcons.Play, - onPrimaryClick = onResumeRecording, - onStopRecording = onStopRecording, - onResetRecording = onResetRecording, - ) + is AudioSessionState.Recording -> stopAction(onStop = onStopRecording) - PracticeRecordingControlState.READY_TO_PLAY -> listOf( + is AudioSessionState.ReadyToPlay -> listOf( PracticeRecordingControlAction( iconResId = PrezelIcons.Play, colorType = PracticeRecordingControlActionColorType.REGULAR, onClick = onStartPlayback, ), - PracticeRecordingControlAction( - iconResId = PrezelIcons.Folder, - colorType = PracticeRecordingControlActionColorType.REGULAR, - onClick = onSelectAudioFile, - ), - ) - - PracticeRecordingControlState.PLAYING -> playbackActions( - primaryIconResId = PrezelIcons.Pause, - onPrimaryClick = onPausePlayback, - onStopPlayback = onStopPlayback, ) - PracticeRecordingControlState.PLAYBACK_PAUSED -> playbackActions( - primaryIconResId = PrezelIcons.Play, - onPrimaryClick = onResumePlayback, - onStopPlayback = onStopPlayback, - ) + is AudioSessionState.Playing -> stopAction(onStop = onStopPlayback) } -private fun recordingActions( - @DrawableRes primaryIconResId: Int, - onPrimaryClick: () -> Unit, - onStopRecording: () -> Unit, - onResetRecording: () -> Unit, -): List = - listOf( - PracticeRecordingControlAction( - iconResId = primaryIconResId, - colorType = PracticeRecordingControlActionColorType.REGULAR, - onClick = onPrimaryClick, - ), - PracticeRecordingControlAction( - iconResId = PrezelIcons.Stop, - colorType = PracticeRecordingControlActionColorType.REGULAR, - onClick = onStopRecording, - ), - PracticeRecordingControlAction( - iconResId = PrezelIcons.Reset, - colorType = PracticeRecordingControlActionColorType.REGULAR, - onClick = onResetRecording, - ), - ) - -private fun playbackActions( - @DrawableRes primaryIconResId: Int, - onPrimaryClick: () -> Unit, - onStopPlayback: () -> Unit, -): List = +private fun stopAction(onStop: () -> Unit): List = listOf( - PracticeRecordingControlAction( - iconResId = primaryIconResId, - colorType = PracticeRecordingControlActionColorType.REGULAR, - onClick = onPrimaryClick, - ), PracticeRecordingControlAction( iconResId = PrezelIcons.Stop, colorType = PracticeRecordingControlActionColorType.REGULAR, - onClick = onStopPlayback, + onClick = onStop, ), ) @@ -270,17 +169,69 @@ private fun PracticeRecordingControlAction.iconColor() = PracticeRecordingControlActionColorType.REGULAR -> PrezelTheme.colors.iconRegular } -private fun PracticeRecordingControlState.displaySeconds( - currentSeconds: Int, - totalSeconds: Int, -): Int = - when (this) { - PracticeRecordingControlState.READY_TO_PLAY -> totalSeconds - else -> currentSeconds - } - private fun Int.toTimerText(): String { val minutes = this / 60 val seconds = this % 60 return "%02d:%02d".format(minutes, seconds) } + +@BasicPreview +@Composable +private fun PracticeRecordingControlPreview() { + PrezelTheme { + Column( + modifier = Modifier + .background(PrezelTheme.colors.bgRegular) + .padding(PrezelTheme.spacing.V20), + verticalArrangement = Arrangement.spacedBy(PrezelTheme.spacing.V20), + ) { + PracticeRecordingControl( + currentSeconds = 0, + totalSeconds = 0, + audioSessionState = AudioSessionState.Idle, + onStartRecording = {}, + onStopRecording = {}, + onStartPlayback = {}, + onStopPlayback = {}, + ) + + PracticeRecordingControl( + currentSeconds = 8, + totalSeconds = 0, + audioSessionState = AudioSessionState.Recording(elapsedSeconds = 8), + onStartRecording = {}, + onStopRecording = {}, + onStartPlayback = {}, + onStopPlayback = {}, + ) + + PracticeRecordingControl( + currentSeconds = 16, + totalSeconds = 45, + audioSessionState = AudioSessionState.ReadyToPlay( + source = AudioSource.RecordedFile(filePath = "preview.m4a"), + positionSeconds = 16, + durationSeconds = 45, + ), + onStartRecording = {}, + onStopRecording = {}, + onStartPlayback = {}, + onStopPlayback = {}, + ) + + PracticeRecordingControl( + currentSeconds = 24, + totalSeconds = 45, + audioSessionState = AudioSessionState.Playing( + source = AudioSource.RecordedFile(filePath = "preview.m4a"), + positionSeconds = 24, + durationSeconds = 45, + ), + onStartRecording = {}, + onStopRecording = {}, + onStartPlayback = {}, + onStopPlayback = {}, + ) + } + } +} diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/component/PracticeRecordingTopAppBar.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/component/PracticeRecordingTopAppBar.kt index 3c5c5c18..6c113b14 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/component/PracticeRecordingTopAppBar.kt +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/component/PracticeRecordingTopAppBar.kt @@ -9,6 +9,8 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import com.team.prezel.core.designsystem.component.PrezelTopAppBar 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.home.impl.R @OptIn(ExperimentalMaterial3Api::class) @@ -26,3 +28,11 @@ internal fun PracticeRecordingTopAppBar(onBack: () -> Unit) { }, ) } + +@BasicPreview +@Composable +private fun PracticeRecordingTopAppBarPreview() { + PrezelTheme { + PracticeRecordingTopAppBar(onBack = {}) + } +} diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/contract/PracticeRecordingUiIntent.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/contract/PracticeRecordingUiIntent.kt index 6cfcea69..a4dc1ff0 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/contract/PracticeRecordingUiIntent.kt +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/contract/PracticeRecordingUiIntent.kt @@ -1,6 +1,5 @@ package com.team.prezel.feature.home.impl.practice.contract -import android.net.Uri import com.team.prezel.core.ui.base.UiIntent internal sealed interface PracticeRecordingUiIntent : UiIntent { @@ -12,24 +11,10 @@ internal sealed interface PracticeRecordingUiIntent : UiIntent { data object StartRecording : PracticeRecordingUiIntent - data object PauseRecording : PracticeRecordingUiIntent - - data object ResumeRecording : PracticeRecordingUiIntent - data object StopRecording : PracticeRecordingUiIntent - data object ResetRecording : PracticeRecordingUiIntent - - data class AudioFileSelected( - val uri: Uri, - ) : PracticeRecordingUiIntent - data object StartPlayback : PracticeRecordingUiIntent - data object PausePlayback : PracticeRecordingUiIntent - - data object ResumePlayback : PracticeRecordingUiIntent - data object StopPlayback : PracticeRecordingUiIntent data object AnalyzeClicked : PracticeRecordingUiIntent diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/contract/PracticeRecordingUiState.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/contract/PracticeRecordingUiState.kt index ef1fb42c..de015c13 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/contract/PracticeRecordingUiState.kt +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/contract/PracticeRecordingUiState.kt @@ -1,23 +1,42 @@ package com.team.prezel.feature.home.impl.practice.contract import androidx.compose.runtime.Immutable +import com.team.prezel.core.audio.AudioSessionState import com.team.prezel.core.ui.base.UiState import com.team.prezel.feature.home.impl.practice.model.PracticeRecordingAnalysisStatus -import com.team.prezel.feature.home.impl.practice.model.PracticeRecordingState @Immutable internal data class PracticeRecordingUiState( val practiceScript: String = "", - val recordingState: PracticeRecordingState = PracticeRecordingState.Idle, + val recordingState: AudioSessionState = AudioSessionState.Idle, val analysisStatus: PracticeRecordingAnalysisStatus = PracticeRecordingAnalysisStatus.Ready, ) : UiState { val currentSeconds: Int - get() = recordingState.currentSeconds + get() = when (val state = recordingState) { + AudioSessionState.Idle -> 0 + is AudioSessionState.Recording -> state.elapsedSeconds + is AudioSessionState.ReadyToPlay -> state.positionSeconds + is AudioSessionState.Playing -> state.positionSeconds + } val totalSeconds: Int - get() = recordingState.totalSeconds + get() = when (val state = recordingState) { + AudioSessionState.Idle, + is AudioSessionState.Recording, + -> 0 + + is AudioSessionState.ReadyToPlay -> state.durationSeconds + is AudioSessionState.Playing -> state.durationSeconds + } + + val recordingFilePath: String? + get() = when (val state = recordingState) { + is AudioSessionState.ReadyToPlay -> state.source.filePath + is AudioSessionState.Playing -> state.source.filePath + else -> null + } val analyzeEnabled: Boolean - get() = recordingState is PracticeRecordingState.ReadyToPlay && + get() = recordingState is AudioSessionState.ReadyToPlay && analysisStatus !is PracticeRecordingAnalysisStatus.Loading } diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/model/PracticeRecordingAnalysisUiModel.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/model/PracticeRecordingAnalysisUiModel.kt index bbe2a962..60ad1d55 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/model/PracticeRecordingAnalysisUiModel.kt +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/model/PracticeRecordingAnalysisUiModel.kt @@ -1,15 +1,10 @@ package com.team.prezel.feature.home.impl.practice.model import androidx.compose.runtime.Immutable +import com.team.prezel.core.model.practice.PracticeRecordingSpeed @Immutable internal data class PracticeRecordingAnalysisUiModel( val pronunciationScore: Int, - val speed: PracticeRecordingAnalysisSpeed, + val speed: PracticeRecordingSpeed, ) - -internal enum class PracticeRecordingAnalysisSpeed { - SLOW, - ADEQUATE, - FAST, -} diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/model/PracticeRecordingState.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/model/PracticeRecordingState.kt deleted file mode 100644 index 0a499392..00000000 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/model/PracticeRecordingState.kt +++ /dev/null @@ -1,76 +0,0 @@ -package com.team.prezel.feature.home.impl.practice.model - -import androidx.compose.runtime.Immutable - -@Immutable -internal sealed interface PracticeRecordingState { - val currentSeconds: Int - val totalSeconds: Int - val filePath: String? - get() = null - - data object Idle : PracticeRecordingState { - override val currentSeconds: Int = 0 - override val totalSeconds: Int = 0 - } - - data class Recording( - val recordingSeconds: Int, - ) : PracticeRecordingState { - override val currentSeconds: Int - get() = recordingSeconds - - override val totalSeconds: Int = 0 - } - - data class RecordingPaused( - val recordingSeconds: Int, - ) : PracticeRecordingState { - override val currentSeconds: Int - get() = recordingSeconds - - override val totalSeconds: Int = 0 - } - - data class ReadyToPlay( - override val filePath: String, - val durationSeconds: Int, - val sourceType: SourceType, - ) : PracticeRecordingState { - override val currentSeconds: Int = 0 - - override val totalSeconds: Int - get() = durationSeconds - } - - data class Playing( - override val filePath: String, - val playbackSeconds: Int, - val durationSeconds: Int, - val sourceType: SourceType, - ) : PracticeRecordingState { - override val currentSeconds: Int - get() = playbackSeconds - - override val totalSeconds: Int - get() = durationSeconds - } - - data class PlaybackPaused( - override val filePath: String, - val playbackSeconds: Int, - val durationSeconds: Int, - val sourceType: SourceType, - ) : PracticeRecordingState { - override val currentSeconds: Int - get() = playbackSeconds - - override val totalSeconds: Int - get() = durationSeconds - } - - enum class SourceType { - RECORDED_FILE, - EXTERNAL_FILE, - } -} diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/model/PracticeRecordingUiMessage.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/model/PracticeRecordingUiMessage.kt index 140e15b4..619c4120 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/model/PracticeRecordingUiMessage.kt +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/model/PracticeRecordingUiMessage.kt @@ -7,5 +7,4 @@ internal enum class PracticeRecordingUiMessage { RECORDING_START_FAILED, RECORDING_STOP_FAILED, PLAYBACK_START_FAILED, - AUDIO_FILE_LOAD_FAILED, } diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/result/PracticeRecordingResultScreen.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/result/PracticeRecordingResultScreen.kt index 69c9bec5..80760b14 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/result/PracticeRecordingResultScreen.kt +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/result/PracticeRecordingResultScreen.kt @@ -2,9 +2,7 @@ package com.team.prezel.feature.home.impl.practice.result import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import com.team.prezel.feature.home.impl.practice.model.PracticeRecordingAnalysisSpeed import com.team.prezel.feature.home.impl.practice.model.PracticeRecordingAnalysisStatus -import com.team.prezel.feature.home.impl.practice.result.component.PracticeAnalysisSpeed import com.team.prezel.feature.home.impl.practice.result.component.PracticeRecordingAnalysisFailurePage import com.team.prezel.feature.home.impl.practice.result.component.PracticeRecordingAnalysisLoadingPage import com.team.prezel.feature.home.impl.practice.result.component.PracticeRecordingResultPage @@ -21,7 +19,7 @@ internal fun PracticeRecordingResultScreen( PracticeRecordingAnalysisStatus.Loading -> PracticeRecordingAnalysisLoadingPage(modifier = modifier) is PracticeRecordingAnalysisStatus.Success -> PracticeRecordingResultPage( pronunciationScore = analysisStatus.result.pronunciationScore, - speed = analysisStatus.result.speed.toUiModel(), + speed = analysisStatus.result.speed, onBack = onBack, onComplete = onComplete, modifier = modifier, @@ -36,10 +34,3 @@ internal fun PracticeRecordingResultScreen( PracticeRecordingAnalysisStatus.Ready -> Unit } } - -private fun PracticeRecordingAnalysisSpeed.toUiModel(): PracticeAnalysisSpeed = - when (this) { - PracticeRecordingAnalysisSpeed.SLOW -> PracticeAnalysisSpeed.SLOW - PracticeRecordingAnalysisSpeed.ADEQUATE -> PracticeAnalysisSpeed.ADEQUATE - PracticeRecordingAnalysisSpeed.FAST -> PracticeAnalysisSpeed.FAST - } diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/result/component/PracticeRecordingResultPage.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/result/component/PracticeRecordingResultPage.kt index 93d20b9a..d7e3cb59 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/result/component/PracticeRecordingResultPage.kt +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/result/component/PracticeRecordingResultPage.kt @@ -27,6 +27,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.team.prezel.core.designsystem.component.PrezelTopAppBar 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.chip.PrezelChip import com.team.prezel.core.designsystem.component.chip.config.PrezelChipDefaults import com.team.prezel.core.designsystem.component.chip.config.PrezelChipSize @@ -34,16 +35,9 @@ import com.team.prezel.core.designsystem.component.chip.config.PrezelChipType 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.core.model.practice.PracticeRecordingSpeed import com.team.prezel.feature.home.impl.R -internal enum class PracticeAnalysisSpeed( - @param:StringRes val labelResId: Int, -) { - SLOW(R.string.feature_home_impl_practice_recording_analysis_speed_slow), - ADEQUATE(R.string.feature_home_impl_practice_recording_analysis_speed_adequate), - FAST(R.string.feature_home_impl_practice_recording_analysis_speed_fast), -} - private enum class PracticeAnalysisOverallResult( @param:StringRes val contentDescriptionResId: Int, @param:DrawableRes val cardResId: Int, @@ -66,7 +60,7 @@ private enum class PracticeAnalysisOverallResult( @Composable internal fun PracticeRecordingResultPage( pronunciationScore: Int, - speed: PracticeAnalysisSpeed, + speed: PracticeRecordingSpeed, onBack: () -> Unit, onComplete: () -> Unit, modifier: Modifier = Modifier, @@ -109,7 +103,7 @@ private fun PracticeRecordingResultContent( @DrawableRes cardResId: Int, cardContentDescription: String, pronunciationScore: Int, - speed: PracticeAnalysisSpeed, + speed: PracticeRecordingSpeed, modifier: Modifier = Modifier, ) { Column( @@ -142,23 +136,27 @@ private fun PracticeRecordingResultButtonArea( ) { val completeLabel = stringResource(R.string.feature_home_impl_practice_recording_analysis_complete) - PrezelButtonArea(modifier = modifier) { - MainButton( - label = completeLabel, - enabled = true, - onClick = onComplete, - ) - } + PrezelButtonArea( + modifier = modifier, + mainButton = { buttonModifier -> + PrezelButton( + text = completeLabel, + modifier = buttonModifier, + enabled = true, + onClick = onComplete, + ) + }, + ) } private fun rememberOverallResult( pronunciationScore: Int, - speed: PracticeAnalysisSpeed, + speed: PracticeRecordingSpeed, ): PracticeAnalysisOverallResult = when { - pronunciationScore >= 95 && speed == PracticeAnalysisSpeed.ADEQUATE -> PracticeAnalysisOverallResult.PERFECT - pronunciationScore >= 70 && speed == PracticeAnalysisSpeed.ADEQUATE -> PracticeAnalysisOverallResult.GOOD - pronunciationScore >= 95 && speed != PracticeAnalysisSpeed.ADEQUATE -> PracticeAnalysisOverallResult.GOOD + pronunciationScore >= 95 && speed == PracticeRecordingSpeed.ADEQUATE -> PracticeAnalysisOverallResult.PERFECT + pronunciationScore >= 70 && speed == PracticeRecordingSpeed.ADEQUATE -> PracticeAnalysisOverallResult.GOOD + pronunciationScore >= 95 && speed != PracticeRecordingSpeed.ADEQUATE -> PracticeAnalysisOverallResult.GOOD pronunciationScore <= 60 -> PracticeAnalysisOverallResult.TRY else -> PracticeAnalysisOverallResult.TRY } @@ -166,7 +164,7 @@ private fun rememberOverallResult( @Composable private fun PracticeAnalysisMetricRow( pronunciationScore: Int, - speed: PracticeAnalysisSpeed, + speed: PracticeRecordingSpeed, modifier: Modifier = Modifier, ) { Row( @@ -219,6 +217,14 @@ private fun PracticeAnalysisMetricRow( } } +private val PracticeRecordingSpeed.labelResId: Int + @StringRes + get() = when (this) { + PracticeRecordingSpeed.SLOW -> R.string.feature_home_impl_practice_recording_analysis_speed_slow + PracticeRecordingSpeed.ADEQUATE -> R.string.feature_home_impl_practice_recording_analysis_speed_adequate + PracticeRecordingSpeed.FAST -> R.string.feature_home_impl_practice_recording_analysis_speed_fast + } + @Composable private fun PracticeAnalysisMetricLabel( text: String, @@ -238,7 +244,7 @@ private fun PracticeRecordingResultPerfectPagePreview() { PrezelTheme { PracticeRecordingResultPage( pronunciationScore = 96, - speed = PracticeAnalysisSpeed.ADEQUATE, + speed = PracticeRecordingSpeed.ADEQUATE, onBack = {}, onComplete = {}, ) @@ -251,7 +257,7 @@ private fun PracticeRecordingResultGoodPagePreview() { PrezelTheme { PracticeRecordingResultPage( pronunciationScore = 90, - speed = PracticeAnalysisSpeed.ADEQUATE, + speed = PracticeRecordingSpeed.ADEQUATE, onBack = {}, onComplete = {}, ) @@ -264,7 +270,7 @@ private fun PracticeRecordingResultTryPagePreview() { PrezelTheme { PracticeRecordingResultPage( pronunciationScore = 58, - speed = PracticeAnalysisSpeed.FAST, + speed = PracticeRecordingSpeed.FAST, onBack = {}, onComplete = {}, ) diff --git a/Prezel/feature/home/impl/src/main/res/values/strings.xml b/Prezel/feature/home/impl/src/main/res/values/strings.xml index 91b0a297..8932898a 100644 --- a/Prezel/feature/home/impl/src/main/res/values/strings.xml +++ b/Prezel/feature/home/impl/src/main/res/values/strings.xml @@ -30,7 +30,6 @@ 녹음을 시작하지 못했습니다. 녹음을 저장하지 못했습니다. 녹음을 재생하지 못했습니다. - 음성 파일을 불러오지 못했습니다. 분석중 잠시만 기다려주세요 분석에 실패했어요 From ac2b6aa56f03d0b639d4160425df4105c5e25ea6 Mon Sep 17 00:00:00 2001 From: Ham BeomJoon Date: Fri, 8 May 2026 23:08:16 +0900 Subject: [PATCH 15/27] =?UTF-8?q?refactor:=20=EC=97=B0=EC=8A=B5=20?= =?UTF-8?q?=EB=85=B9=EC=9D=8C(Practice=20Recording)=20=EC=83=81=ED=83=9C?= =?UTF-8?q?=20=EA=B4=80=EB=A6=AC=20=EB=B0=8F=20=EA=B2=B0=EA=B3=BC=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=20=EB=A1=9C=EC=A7=81=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * **feat: 녹음 초기화 및 재시도 기능 구현** * `RecordingAudioController` 인터페이스 및 `MediaRecordingAudioController` 구현체에 `reset()` 메서드를 추가했습니다. * `PracticeRecordingUiIntent.RetryRecordingClicked` 인텐트를 추가하여 녹음 결과 화면에서 다시 시도할 수 있는 로직을 구현했습니다. * `PracticeRecordingViewModel`에서 `resetPracticeRecording`을 호출하여 오디오 컨트롤러를 초기화하고 분석 상태를 `Ready`로 되돌리도록 처리했습니다. * **refactor: 오디오 컨트롤러 생명주기 및 리소스 관리 개선** * `MediaRecordingAudioController`에서 `release()`와 `reset()` 로직을 분리하여 리소스 해제 후에도 Scope가 유지될 수 있도록 수정했습니다. * `createMediaRecorder` 함수를 클래스 외부 유틸리티 함수로 분리하고 Context 주입 방식을 개선했습니다. * **ui: 연습 결과 화면 및 네비게이션 로직 수정** * `PracticeRecordingResultPage`에서 불필요한 상단 앱바(`PrezelTopAppBar`)와 뒤로 가기(`onBack`) 파라미터를 제거했습니다. * `PracticeRecordingScreen`에서 분석이 완료된 후에는 뒤로 가기 동작(`BackHandler`)을 제한하여 상태 불일치를 방지했습니다. * 결과 화면에서 '다시 시도' 클릭 시 분석 로직을 다시 실행하는 대신 녹음 준비 단계로 돌아가도록 `onRetry` 콜백을 `onRetryRecording` 인텐트와 연결했습니다. * **chore: 불필요한 의존성 및 코드 정리** * `PracticeRecordingResultPage`에서 사용하지 않는 Material3 컴포넌트 및 아이콘 관련 import를 삭제했습니다. * Preview 코드 내 불필요해진 `onBack` 인자 전달 로직을 정리했습니다. --- .../audio/MediaRecordingAudioController.kt | 26 +++++++++++-------- .../core/audio/RecordingAudioController.kt | 2 ++ .../impl/practice/PracticeRecordingScreen.kt | 14 +++++++--- .../practice/PracticeRecordingViewModel.kt | 8 ++++++ .../contract/PracticeRecordingUiIntent.kt | 2 ++ .../result/PracticeRecordingResultScreen.kt | 2 -- .../component/PracticeRecordingResultPage.kt | 21 --------------- 7 files changed, 38 insertions(+), 37 deletions(-) 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 9223d6ec..4f606878 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 @@ -49,7 +49,7 @@ internal class MediaRecordingAudioController @Inject constructor( val file = File.createTempFile("recording_", ".m4a", context.cacheDir) var pendingRecorder: MediaRecorder? = null val newRecorder = runCatching { - val recorder = createMediaRecorder() + val recorder = createMediaRecorder(context = context) pendingRecorder = recorder recorder.apply { setAudioSource(MediaRecorder.AudioSource.MIC) @@ -132,16 +132,20 @@ internal class MediaRecordingAudioController @Inject constructor( } } - override fun release() { + override fun reset() { recordingTimerJob?.cancel() playbackTimerJob?.cancel() releaseRecorder() releasePlayer() deleteCurrentAudioFile() - controllerScope.cancel() _audioSessionState.value = AudioSessionState.Idle } + override fun release() { + reset() + controllerScope.cancel() + } + private fun startPlayback( source: AudioSource, durationSeconds: Int, @@ -222,14 +226,6 @@ internal class MediaRecordingAudioController @Inject constructor( } } - @Suppress("DEPRECATION") - private fun createMediaRecorder(): MediaRecorder = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - MediaRecorder(context) - } else { - MediaRecorder() - } - private fun releaseRecorder() { recorder?.release() recorder = null @@ -260,4 +256,12 @@ internal class MediaRecordingAudioController @Inject constructor( } } +@Suppress("DEPRECATION") +private fun createMediaRecorder(context: Context): MediaRecorder = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + MediaRecorder(context) + } else { + MediaRecorder() + } + private fun Int.toSeconds(): Int = this / 1_000 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 eae71baf..9cfc95a9 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 @@ -17,6 +17,8 @@ interface RecordingAudioController { fun stopPlayback() + fun reset() + fun release() } diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/PracticeRecordingScreen.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/PracticeRecordingScreen.kt index bf5d0bfa..cde7dadf 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/PracticeRecordingScreen.kt +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/PracticeRecordingScreen.kt @@ -75,6 +75,7 @@ internal fun PracticeRecordingScreen( onStartPlayback = { viewModel.onIntent(PracticeRecordingUiIntent.StartPlayback) }, onStopPlayback = { viewModel.onIntent(PracticeRecordingUiIntent.StopPlayback) }, onClickAnalyze = { viewModel.onIntent(PracticeRecordingUiIntent.AnalyzeClicked) }, + onRetryRecording = { viewModel.onIntent(PracticeRecordingUiIntent.RetryRecordingClicked) }, onBack = onBack, navigateToHome = navigateToHome, modifier = modifier, @@ -89,11 +90,18 @@ private fun PracticeRecordingScreen( onStartPlayback: () -> Unit, onStopPlayback: () -> Unit, onClickAnalyze: () -> Unit, + onRetryRecording: () -> Unit, onBack: () -> Unit, navigateToHome: () -> Unit, modifier: Modifier = Modifier, ) { - BackHandler(onBack = onBack) + BackHandler( + onBack = { + if (uiState.analysisStatus == PracticeRecordingAnalysisStatus.Ready) { + onBack() + } + }, + ) when (uiState.analysisStatus) { PracticeRecordingAnalysisStatus.Ready -> PracticeRecordingReadyScreen( @@ -109,8 +117,7 @@ private fun PracticeRecordingScreen( else -> PracticeRecordingResultScreen( analysisStatus = uiState.analysisStatus, - onBack = onBack, - onRetry = onClickAnalyze, + onRetry = onRetryRecording, onComplete = navigateToHome, modifier = modifier, ) @@ -238,6 +245,7 @@ private fun PracticeRecordingScreenPreviewContent(uiState: PracticeRecordingUiSt onStartPlayback = {}, onStopPlayback = {}, onClickAnalyze = {}, + onRetryRecording = {}, onBack = {}, navigateToHome = {}, ) diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/PracticeRecordingViewModel.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/PracticeRecordingViewModel.kt index baf9cc0c..def9f03d 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/PracticeRecordingViewModel.kt +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/PracticeRecordingViewModel.kt @@ -47,6 +47,7 @@ internal class PracticeRecordingViewModel @Inject constructor( PracticeRecordingUiIntent.StartPlayback -> audioController.startPlayback() PracticeRecordingUiIntent.StopPlayback -> audioController.stopPlayback() PracticeRecordingUiIntent.AnalyzeClicked -> startAnalysis() + PracticeRecordingUiIntent.RetryRecordingClicked -> resetPracticeRecording() } } @@ -114,6 +115,13 @@ internal class PracticeRecordingViewModel @Inject constructor( } } + private fun resetPracticeRecording() { + audioController.reset() + updateState { + copy(analysisStatus = PracticeRecordingAnalysisStatus.Ready) + } + } + private fun showMessage(message: PracticeRecordingUiMessage) { viewModelScope.launch { sendEffect(PracticeRecordingUiEffect.ShowMessage(message)) diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/contract/PracticeRecordingUiIntent.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/contract/PracticeRecordingUiIntent.kt index a4dc1ff0..e8fa26fa 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/contract/PracticeRecordingUiIntent.kt +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/contract/PracticeRecordingUiIntent.kt @@ -18,4 +18,6 @@ internal sealed interface PracticeRecordingUiIntent : UiIntent { data object StopPlayback : PracticeRecordingUiIntent data object AnalyzeClicked : PracticeRecordingUiIntent + + data object RetryRecordingClicked : PracticeRecordingUiIntent } diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/result/PracticeRecordingResultScreen.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/result/PracticeRecordingResultScreen.kt index 80760b14..86b10684 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/result/PracticeRecordingResultScreen.kt +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/result/PracticeRecordingResultScreen.kt @@ -10,7 +10,6 @@ import com.team.prezel.feature.home.impl.practice.result.component.PracticeRecor @Composable internal fun PracticeRecordingResultScreen( analysisStatus: PracticeRecordingAnalysisStatus, - onBack: () -> Unit, onRetry: () -> Unit, onComplete: () -> Unit, modifier: Modifier = Modifier, @@ -20,7 +19,6 @@ internal fun PracticeRecordingResultScreen( is PracticeRecordingAnalysisStatus.Success -> PracticeRecordingResultPage( pronunciationScore = analysisStatus.result.pronunciationScore, speed = analysisStatus.result.speed, - onBack = onBack, onComplete = onComplete, modifier = modifier, ) diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/result/component/PracticeRecordingResultPage.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/result/component/PracticeRecordingResultPage.kt index d7e3cb59..e7e028f1 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/result/component/PracticeRecordingResultPage.kt +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/result/component/PracticeRecordingResultPage.kt @@ -14,9 +14,6 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment @@ -25,14 +22,12 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp -import com.team.prezel.core.designsystem.component.PrezelTopAppBar 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.chip.PrezelChip import com.team.prezel.core.designsystem.component.chip.config.PrezelChipDefaults import com.team.prezel.core.designsystem.component.chip.config.PrezelChipSize import com.team.prezel.core.designsystem.component.chip.config.PrezelChipType -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.core.model.practice.PracticeRecordingSpeed @@ -56,12 +51,10 @@ private enum class PracticeAnalysisOverallResult( ), } -@OptIn(ExperimentalMaterial3Api::class) @Composable internal fun PracticeRecordingResultPage( pronunciationScore: Int, speed: PracticeRecordingSpeed, - onBack: () -> Unit, onComplete: () -> Unit, modifier: Modifier = Modifier, ) { @@ -75,17 +68,6 @@ internal fun PracticeRecordingResultPage( .fillMaxSize() .background(PrezelTheme.colors.bgRegular), ) { - PrezelTopAppBar( - leadingIcon = { - IconButton(onClick = onBack) { - Icon( - painter = painterResource(PrezelIcons.ArrowLeft), - contentDescription = stringResource(R.string.feature_home_impl_practice_recording_back), - ) - } - }, - ) - PracticeRecordingResultContent( cardResId = overallResult.cardResId, cardContentDescription = stringResource(overallResult.contentDescriptionResId), @@ -245,7 +227,6 @@ private fun PracticeRecordingResultPerfectPagePreview() { PracticeRecordingResultPage( pronunciationScore = 96, speed = PracticeRecordingSpeed.ADEQUATE, - onBack = {}, onComplete = {}, ) } @@ -258,7 +239,6 @@ private fun PracticeRecordingResultGoodPagePreview() { PracticeRecordingResultPage( pronunciationScore = 90, speed = PracticeRecordingSpeed.ADEQUATE, - onBack = {}, onComplete = {}, ) } @@ -271,7 +251,6 @@ private fun PracticeRecordingResultTryPagePreview() { PracticeRecordingResultPage( pronunciationScore = 58, speed = PracticeRecordingSpeed.FAST, - onBack = {}, onComplete = {}, ) } From 92fc811df8611383d11cce300c7b127725132b07 Mon Sep 17 00:00:00 2001 From: Ham BeomJoon Date: Sun, 10 May 2026 23:18:06 +0900 Subject: [PATCH 16/27] =?UTF-8?q?refactor:=20`RecordingAudioController`=20?= =?UTF-8?q?=EB=82=B4=20`Immutable`=20=EC=96=B4=EB=85=B8=ED=85=8C=EC=9D=B4?= =?UTF-8?q?=EC=85=98=20=EA=B5=90=EC=B2=B4=20=EB=B0=8F=20=EC=9D=98=EC=A1=B4?= =?UTF-8?q?=EC=84=B1=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * **refactor: `Immutable` 어노테이션 패키지 변경** * `RecordingAudioController`에서 사용하던 `javax.annotation.concurrent.Immutable`을 Compose Runtime의 `@Immutable` 어노테이션(`androidx.compose.runtime.Immutable`)으로 변경했습니다. * **build: `core:audio` 모듈 의존성 추가** * Compose Runtime 어노테이션 사용을 위해 `core:audio` 모듈의 `build.gradle.kts`에 `androidx-compose-bom` 및 `androidx-compose-runtime` 의존성을 추가했습니다. * `libs.versions.toml`에 `androidx-compose-runtime` 라이브러리 정의를 추가했습니다. --- Prezel/core/audio/build.gradle.kts | 2 ++ .../java/com/team/prezel/core/audio/RecordingAudioController.kt | 2 +- Prezel/gradle/libs.versions.toml | 1 + 3 files changed, 4 insertions(+), 1 deletion(-) diff --git a/Prezel/core/audio/build.gradle.kts b/Prezel/core/audio/build.gradle.kts index 913624c4..c7f093e6 100644 --- a/Prezel/core/audio/build.gradle.kts +++ b/Prezel/core/audio/build.gradle.kts @@ -8,5 +8,7 @@ android { } dependencies { + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.compose.runtime) implementation(libs.kotlinx.coroutines.core) } 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 9cfc95a9..b7918e97 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,8 +1,8 @@ package com.team.prezel.core.audio +import androidx.compose.runtime.Immutable import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow -import javax.annotation.concurrent.Immutable interface RecordingAudioController { val audioSessionState: StateFlow diff --git a/Prezel/gradle/libs.versions.toml b/Prezel/gradle/libs.versions.toml index 0b64faba..64bb3d93 100644 --- a/Prezel/gradle/libs.versions.toml +++ b/Prezel/gradle/libs.versions.toml @@ -41,6 +41,7 @@ androidx-lifecycle-runtimeCompose = { group = "androidx.lifecycle", name = "life androidx-lifecycle-runtimeTesting = { group = "androidx.lifecycle", name = "lifecycle-runtime-testing", version.ref = "androidxLifecycle" } androidx-lifecycle-viewModelCompose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "androidxLifecycle" } androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } +androidx-compose-runtime = { group = "androidx.compose.runtime", name = "runtime" } androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" } androidx-compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" } androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } From f2a0fa22794ceca88ddded8614718af993060c2b Mon Sep 17 00:00:00 2001 From: Ham BeomJoon Date: Sun, 10 May 2026 23:52:10 +0900 Subject: [PATCH 17/27] =?UTF-8?q?refactor:=20=EC=98=A4=EB=94=94=EC=98=A4?= =?UTF-8?q?=20=EA=B4=80=EB=A0=A8=20=EB=8F=84=EB=A9=94=EC=9D=B8=20=EB=AA=A8?= =?UTF-8?q?=EB=8D=B8=20=EB=B0=8F=20=EC=83=81=ED=83=9C=20=EC=9D=B8=ED=84=B0?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=8A=A4=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * **refactor: `RecordingAudioController.kt` 내 데이터 모델 파일 분리** * 하나의 파일에 정의되어 있던 `AudioSessionState`, `AudioSource`, `AudioSessionEffect`를 각각 별도의 파일로 추출하여 모듈 구조를 개선했습니다. * `AudioSessionState.kt`: `AudioSessionState` 및 `AudioSource` 인터페이스 이동 * `AudioSessionEffect.kt`: `AudioSessionEffect` 인터페이스 이동 * **style: `MediaRecordingAudioController` 로직 가독성 개선** * `stopRecording` 메서드 내에서 `audioSessionState` 값을 확인하고 처리하는 `when` 식의 변수 할당 방식을 개선했습니다. --- .../prezel/core/audio/AudioSessionEffect.kt | 9 +++++ .../prezel/core/audio/AudioSessionState.kt | 33 ++++++++++++++++ .../audio/MediaRecordingAudioController.kt | 3 +- .../core/audio/RecordingAudioController.kt | 39 ------------------- 4 files changed, 43 insertions(+), 41 deletions(-) create mode 100644 Prezel/core/audio/src/main/java/com/team/prezel/core/audio/AudioSessionEffect.kt create mode 100644 Prezel/core/audio/src/main/java/com/team/prezel/core/audio/AudioSessionState.kt diff --git a/Prezel/core/audio/src/main/java/com/team/prezel/core/audio/AudioSessionEffect.kt b/Prezel/core/audio/src/main/java/com/team/prezel/core/audio/AudioSessionEffect.kt new file mode 100644 index 00000000..60dac9e3 --- /dev/null +++ b/Prezel/core/audio/src/main/java/com/team/prezel/core/audio/AudioSessionEffect.kt @@ -0,0 +1,9 @@ +package com.team.prezel.core.audio + +sealed interface AudioSessionEffect { + data object RecordingStartFailed : AudioSessionEffect + + data object RecordingStopFailed : AudioSessionEffect + + data object PlaybackStartFailed : AudioSessionEffect +} 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 new file mode 100644 index 00000000..4f47208c --- /dev/null +++ b/Prezel/core/audio/src/main/java/com/team/prezel/core/audio/AudioSessionState.kt @@ -0,0 +1,33 @@ +package com.team.prezel.core.audio + +import androidx.compose.runtime.Immutable + +@Immutable +sealed interface AudioSessionState { + data object Idle : AudioSessionState + + data class Recording( + val elapsedSeconds: Int, + ) : AudioSessionState + + data class ReadyToPlay( + val source: AudioSource, + val positionSeconds: Int = 0, + val durationSeconds: Int, + ) : AudioSessionState + + data class Playing( + val source: AudioSource, + val positionSeconds: Int, + val durationSeconds: Int, + ) : AudioSessionState +} + +@Immutable +sealed interface AudioSource { + val filePath: String + + data class RecordedFile( + override val filePath: String, + ) : AudioSource +} 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 4f606878..b60dd8d6 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 @@ -78,8 +78,7 @@ internal class MediaRecordingAudioController @Inject constructor( } override fun stopRecording() { - val state = audioSessionState.value - val elapsedSeconds = when (state) { + val elapsedSeconds = when (val state = audioSessionState.value) { is AudioSessionState.Recording -> state.elapsedSeconds else -> return } 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 b7918e97..6d7377c2 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,6 +1,5 @@ package com.team.prezel.core.audio -import androidx.compose.runtime.Immutable import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow @@ -21,41 +20,3 @@ interface RecordingAudioController { fun release() } - -@Immutable -sealed interface AudioSessionState { - data object Idle : AudioSessionState - - data class Recording( - val elapsedSeconds: Int, - ) : AudioSessionState - - data class ReadyToPlay( - val source: AudioSource, - val positionSeconds: Int = 0, - val durationSeconds: Int, - ) : AudioSessionState - - data class Playing( - val source: AudioSource, - val positionSeconds: Int, - val durationSeconds: Int, - ) : AudioSessionState -} - -@Immutable -sealed interface AudioSource { - val filePath: String - - data class RecordedFile( - override val filePath: String, - ) : AudioSource -} - -sealed interface AudioSessionEffect { - data object RecordingStartFailed : AudioSessionEffect - - data object RecordingStopFailed : AudioSessionEffect - - data object PlaybackStartFailed : AudioSessionEffect -} From a83f59abe44529003406d3291a3df195521e0928 Mon Sep 17 00:00:00 2001 From: Ham BeomJoon Date: Mon, 11 May 2026 14:44:41 +0900 Subject: [PATCH 18/27] =?UTF-8?q?refactor:=20=EC=97=B0=EC=8A=B5=20?= =?UTF-8?q?=EA=B2=B0=EA=B3=BC=20=ED=8E=98=EC=9D=B4=EC=A7=80=EC=9D=98=20?= =?UTF-8?q?=EC=B9=A9(Chip)=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=20=EB=A1=9C=EC=A7=81=20=EA=B0=84=EC=86=8C?= =?UTF-8?q?=ED=99=94=20=EB=B0=8F=20=EC=BD=94=EB=93=9C=20=ED=8F=AC=EB=A7=B7?= =?UTF-8?q?=ED=8C=85=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * **refactor: `PrezelChip` 컴포넌트 적용 방식 변경** * `PracticeRecordingResultPage`에서 `PrezelChip`의 복잡한 설정을 제거하고 기본형으로 간소화했습니다. * 디자인 시스템 패키지 구조 변경에 따라 `PrezelChip`의 import 경로를 수정했습니다. (`chip.config` 관련 import 제거) * **style: Compose UI 코드 포맷팅 수정** * 가독성 향상을 위해 `PracticeAnalysisMetricItem` 내 `Modifier` 체이닝 줄바꿈을 조정했습니다. --- .../component/PracticeRecordingResultPage.kt | 26 ++++++------------- 1 file changed, 8 insertions(+), 18 deletions(-) diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/result/component/PracticeRecordingResultPage.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/result/component/PracticeRecordingResultPage.kt index e7e028f1..104be8e3 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/result/component/PracticeRecordingResultPage.kt +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/result/component/PracticeRecordingResultPage.kt @@ -24,10 +24,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp 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.chip.PrezelChip -import com.team.prezel.core.designsystem.component.chip.config.PrezelChipDefaults -import com.team.prezel.core.designsystem.component.chip.config.PrezelChipSize -import com.team.prezel.core.designsystem.component.chip.config.PrezelChipType +import com.team.prezel.core.designsystem.component.chip.chip.PrezelChip import com.team.prezel.core.designsystem.preview.BasicPreview import com.team.prezel.core.designsystem.theme.PrezelTheme import com.team.prezel.core.model.practice.PracticeRecordingSpeed @@ -154,7 +151,9 @@ private fun PracticeAnalysisMetricRow( verticalAlignment = Alignment.CenterVertically, ) { Row( - modifier = Modifier.weight(1f).padding(start = 8.dp, top = 5.dp, bottom = 5.dp), + modifier = Modifier + .weight(1f) + .padding(start = 8.dp, top = 5.dp, bottom = 5.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween, ) { @@ -178,23 +177,14 @@ private fun PracticeAnalysisMetricRow( Spacer(modifier = Modifier.width(PrezelTheme.spacing.V12)) Row( - modifier = Modifier.weight(1f).padding(end = 8.dp, top = 5.dp, bottom = 5.dp), + modifier = Modifier + .weight(1f) + .padding(end = 8.dp, top = 5.dp, bottom = 5.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween, ) { PracticeAnalysisMetricLabel(text = stringResource(R.string.feature_home_impl_practice_recording_analysis_speed)) - PrezelChip( - text = stringResource(speed.labelResId), - modifier = Modifier, - config = PrezelChipDefaults.getDefault( - iconOnly = false, - type = PrezelChipType.FILLED, - size = PrezelChipSize.REGULAR, - textStyle = PrezelTheme.typography.caption1Medium, - containerColor = PrezelTheme.colors.bgLarge, - textColor = PrezelTheme.colors.textMedium, - ), - ) + PrezelChip(text = stringResource(speed.labelResId)) } } } From 853d1d26df33aefb702cdccbb1f37a9b26149939 Mon Sep 17 00:00:00 2001 From: Ham BeomJoon Date: Wed, 13 May 2026 14:31:13 +0900 Subject: [PATCH 19/27] =?UTF-8?q?feat:=20=EC=97=B0=EC=8A=B5=20=EB=85=B9?= =?UTF-8?q?=EC=9D=8C=20=EB=B6=84=EC=84=9D=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20=EB=B0=8F=20=EA=B4=80=EB=A0=A8=20API=20=EC=97=B0?= =?UTF-8?q?=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * **feat: 연습 녹음 분석(Analyze) 및 대본 조회 API 구현** * `PracticeService`: 연습 대본 조회(`GET recording/practice/sentence`) 및 녹음 분석(`POST recording/practice/analyze`) 엔드포인트 추가 * `PracticeRemoteDataSource`: Ktor `MultiPartFormDataContent`를 사용한 오디오 파일 업로드 및 분석 요청 로직 구현 * `AnalyzePracticeRecordingResponse`, `PracticeSentenceResponse`: 네트워크 응답 데이터 모델 추가 * **feat: 연습 도메인 모델 및 UseCase 리팩터링** * `PracticeRepository`: `upload`와 `fetchResult`로 나뉘어 있던 로직을 `analyzePracticeRecording`으로 통합 * `AnalyzePracticeRecordingUseCase`: 녹음 파일 경로와 대본 텍스트를 전달받아 분석 결과를 반환하도록 수정 * 불필요해진 `UploadPracticeRecordingUseCase`, `FetchPracticeRecordingAnalysisResultUseCase` 및 관련 모델(`PracticeRecordingUpload`) 삭제 * **refactor: PracticeRepositoryImpl 실제 API 연동 및 매핑 로직 추가** * 기존 페이크 데이터 기반 구현을 `PracticeRemoteDataSource` 호출 방식으로 전환 * 서버 응답 데이터(발음 정확도 점수, 속도 평가 등)를 도메인 모델(`PracticeRecordingAnalysisResult`)로 변환하는 매퍼 추가 * `PracticeRepositoryImplTest`: API 연동 및 데이터 변환 로직에 대한 단위 테스트 추가 * **refactor: 에러 처리 및 앱 설정 개선** * `ServerErrorCode`: 문장 미조회(`S001`), 음성 인식/분석 실패(`V001`, `V002`) 에러 코드 추가 및 `AppError` 매핑 * `PrezelAppState`: 앱 시작 화면을 `HomeNavKey`에서 `SplashNavKey`로 변경 * `LoginScreen`: 스낵바 노출 위치 조정을 위한 `useRaisedPosition` 파라미터 수정 * **chore: 소스 파일 위치 및 패키지 정리** * `core:domain` 내 UseCase 및 Repository 인터페이스 파일 확장자 변경 및 패키지 구조 재정의 (`.java` -> `.kt`) * `PracticeRepositoryImpl` 위치를 `core:data` 하위 폴더에서 최상위로 이동 및 DI 바인딩 갱신 --- .../java/com/team/prezel/ui/PrezelAppState.kt | 4 +- .../prezel/core/data/di/RepositoryModule.kt | 2 +- .../prezel/core/data/error/AppErrorExt.kt | 6 +- .../data/repository/PracticeRepositoryImpl.kt | 59 +++++++++++++++ .../practice/PracticeRepositoryImpl.kt | 68 ----------------- .../practice/PracticeRepositoryImplTest.kt | 74 +++++++++++++++++++ .../AnalyzePracticeRecordingUseCase.kt | 19 ----- ...hPracticeRecordingAnalysisResultUseCase.kt | 20 ----- .../practice/FetchPracticeScriptUseCase.kt | 19 ----- .../UploadPracticeRecordingUseCase.kt | 20 ----- .../repository/practice/PracticeRepository.kt | 8 +- .../AnalyzePracticeRecordingUseCase.kt | 18 +++++ .../practice/FetchPracticeScriptUseCase.kt | 11 +++ .../model/practice/PracticeRecordingUpload.kt | 5 -- .../datasource/PracticeRemoteDataSource.kt | 13 ++++ .../PracticeRemoteDataSourceImpl.kt | 43 +++++++++++ .../core/network/di/DataSourceModule.kt | 6 ++ .../prezel/core/network/di/NetworkModule.kt | 6 ++ .../prezel/core/network/model/BaseResponse.kt | 3 + .../AnalyzePracticeRecordingResponse.kt | 14 ++++ .../practice/PracticeSentenceResponse.kt | 10 +++ .../core/network/service/PracticeService.kt | 21 ++++++ .../practice/PracticeRecordingViewModel.kt | 36 ++++----- .../prezel/feature/login/impl/LoginScreen.kt | 2 +- 24 files changed, 310 insertions(+), 177 deletions(-) create mode 100644 Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/PracticeRepositoryImpl.kt delete mode 100644 Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/practice/PracticeRepositoryImpl.kt create mode 100644 Prezel/core/data/src/test/java/com/team/prezel/core/data/repository/practice/PracticeRepositoryImplTest.kt delete mode 100644 Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/practice/AnalyzePracticeRecordingUseCase.kt delete mode 100644 Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/practice/FetchPracticeRecordingAnalysisResultUseCase.kt delete mode 100644 Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/practice/FetchPracticeScriptUseCase.kt delete mode 100644 Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/practice/UploadPracticeRecordingUseCase.kt rename Prezel/core/domain/src/main/{java => kotlin}/com/team/prezel/core/domain/repository/practice/PracticeRepository.kt (50%) create mode 100644 Prezel/core/domain/src/main/kotlin/com/team/prezel/core/domain/usecase/practice/AnalyzePracticeRecordingUseCase.kt create mode 100644 Prezel/core/domain/src/main/kotlin/com/team/prezel/core/domain/usecase/practice/FetchPracticeScriptUseCase.kt delete mode 100644 Prezel/core/model/src/main/java/com/team/prezel/core/model/practice/PracticeRecordingUpload.kt create mode 100644 Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/PracticeRemoteDataSource.kt create mode 100644 Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/PracticeRemoteDataSourceImpl.kt create mode 100644 Prezel/core/network/src/main/java/com/team/prezel/core/network/model/practice/AnalyzePracticeRecordingResponse.kt create mode 100644 Prezel/core/network/src/main/java/com/team/prezel/core/network/model/practice/PracticeSentenceResponse.kt create mode 100644 Prezel/core/network/src/main/java/com/team/prezel/core/network/service/PracticeService.kt diff --git a/Prezel/app/src/main/java/com/team/prezel/ui/PrezelAppState.kt b/Prezel/app/src/main/java/com/team/prezel/ui/PrezelAppState.kt index ec3f88d6..312ed5f1 100644 --- a/Prezel/app/src/main/java/com/team/prezel/ui/PrezelAppState.kt +++ b/Prezel/app/src/main/java/com/team/prezel/ui/PrezelAppState.kt @@ -7,7 +7,7 @@ import androidx.compose.runtime.rememberCoroutineScope import com.team.prezel.core.data.NetworkMonitor import com.team.prezel.core.navigation.NavigationState import com.team.prezel.core.navigation.rememberNavigationState -import com.team.prezel.feature.home.api.HomeNavKey +import com.team.prezel.feature.splash.api.SplashNavKey import com.team.prezel.navigation.MAIN_NAV_KEYS import com.team.prezel.navigation.TOP_LEVEL_KEYS import kotlinx.coroutines.CoroutineScope @@ -22,7 +22,7 @@ fun rememberPrezelAppState( coroutineScope: CoroutineScope = rememberCoroutineScope(), ): PrezelAppState { val navigationState = rememberNavigationState( - startKey = HomeNavKey, + startKey = SplashNavKey, topLevelKeys = TOP_LEVEL_KEYS, ) diff --git a/Prezel/core/data/src/main/java/com/team/prezel/core/data/di/RepositoryModule.kt b/Prezel/core/data/src/main/java/com/team/prezel/core/data/di/RepositoryModule.kt index dad55b3f..4b74d1ab 100644 --- a/Prezel/core/data/src/main/java/com/team/prezel/core/data/di/RepositoryModule.kt +++ b/Prezel/core/data/src/main/java/com/team/prezel/core/data/di/RepositoryModule.kt @@ -1,9 +1,9 @@ package com.team.prezel.core.data.di import com.team.prezel.core.data.repository.AuthRepositoryImpl +import com.team.prezel.core.data.repository.PracticeRepositoryImpl import com.team.prezel.core.data.repository.TermsRepositoryImpl import com.team.prezel.core.data.repository.UserRepositoryImpl -import com.team.prezel.core.data.repository.practice.PracticeRepositoryImpl import com.team.prezel.core.domain.repository.auth.AuthRepository import com.team.prezel.core.domain.repository.practice.PracticeRepository import com.team.prezel.core.domain.repository.profile.UserRepository 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 0e726ffa..ab0838e6 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 @@ -45,9 +45,11 @@ private fun ServerErrorCode.toDomainError(): AppError = ServerErrorCode.SERVER_ERROR, ServerErrorCode.FILE_UPLOAD_FAILED, + ServerErrorCode.VOICE_ANALYSIS_FAILED, -> AppError.SERVER_ERROR ServerErrorCode.TERMS_NOT_FOUND, + ServerErrorCode.SENTENCE_NOT_FOUND, -> AppError.NOT_FOUND ServerErrorCode.DUPLICATE_NICKNAME -> AppError.DUPLICATE @@ -60,5 +62,7 @@ private fun ServerErrorCode.toDomainError(): AppError = ServerErrorCode.INVALID_ID_TOKEN, -> AppError.UNAUTHORIZED - ServerErrorCode.UNKNOWN -> AppError.UNKNOWN + ServerErrorCode.VOICE_RECOGNITION_FAILED, + ServerErrorCode.UNKNOWN, + -> AppError.UNKNOWN } diff --git a/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/PracticeRepositoryImpl.kt b/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/PracticeRepositoryImpl.kt new file mode 100644 index 00000000..ed6bd7e4 --- /dev/null +++ b/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/PracticeRepositoryImpl.kt @@ -0,0 +1,59 @@ +package com.team.prezel.core.data.repository + +import com.team.prezel.core.data.error.mapDomainFailure +import com.team.prezel.core.domain.repository.practice.PracticeRepository +import com.team.prezel.core.model.practice.PracticeRecordingAnalysisResult +import com.team.prezel.core.model.practice.PracticeRecordingSpeed +import com.team.prezel.core.model.practice.PracticeScript +import com.team.prezel.core.network.datasource.PracticeRemoteDataSource +import com.team.prezel.core.network.model.practice.AnalyzePracticeRecordingResponse +import com.team.prezel.core.network.model.practice.PracticeSentenceResponse +import javax.inject.Inject +import kotlin.math.roundToInt + +internal class PracticeRepositoryImpl @Inject constructor( + private val practiceRemoteDataSource: PracticeRemoteDataSource, +) : PracticeRepository { + override suspend fun fetchPracticeScript(): Result = + runCatching { + practiceRemoteDataSource.getPracticeSentence() + }.mapCatching { response -> + response.toDomain() + }.mapDomainFailure() + + override suspend fun analyzePracticeRecording( + recordingFilePath: String, + referenceText: String, + ): Result = + runCatching { + practiceRemoteDataSource.analyzePracticeRecording( + recordingFilePath = recordingFilePath, + referenceText = referenceText, + ) + }.mapCatching { response -> + response.toDomain() + }.mapDomainFailure() + + private fun PracticeSentenceResponse.toDomain(): PracticeScript = + PracticeScript( + id = PRACTICE_SCRIPT_ID, + content = sentence, + ) + + private fun AnalyzePracticeRecordingResponse.toDomain(): PracticeRecordingAnalysisResult = + PracticeRecordingAnalysisResult( + pronunciationScore = accuracyScore.roundToInt(), + speed = speedEvaluation.toPracticeRecordingSpeed(), + ) + + private fun String.toPracticeRecordingSpeed(): PracticeRecordingSpeed = + when { + contains("느려요") -> PracticeRecordingSpeed.SLOW + contains("빨라요") -> PracticeRecordingSpeed.FAST + else -> PracticeRecordingSpeed.ADEQUATE + } + + private companion object { + const val PRACTICE_SCRIPT_ID = 0L + } +} diff --git a/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/practice/PracticeRepositoryImpl.kt b/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/practice/PracticeRepositoryImpl.kt deleted file mode 100644 index 58c48c2c..00000000 --- a/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/practice/PracticeRepositoryImpl.kt +++ /dev/null @@ -1,68 +0,0 @@ -package com.team.prezel.core.data.repository.practice - -import com.team.prezel.core.domain.repository.practice.PracticeRepository -import com.team.prezel.core.model.practice.PracticeRecordingAnalysisResult -import com.team.prezel.core.model.practice.PracticeRecordingSpeed -import com.team.prezel.core.model.practice.PracticeRecordingUpload -import com.team.prezel.core.model.practice.PracticeScript -import kotlinx.coroutines.delay -import javax.inject.Inject - -internal class PracticeRepositoryImpl @Inject constructor() : PracticeRepository { - override suspend fun fetchPracticeScript(): Result = - runCatching { - delay(FAKE_API_DELAY_MILLIS) - fakePracticeScripts.random() - } - - override suspend fun uploadPracticeRecording(recordingFilePath: String): Result = - runCatching { - delay(FAKE_API_DELAY_MILLIS) - PracticeRecordingUpload(id = FAKE_RECORDING_ID) - } - - override suspend fun fetchPracticeRecordingAnalysisResult(recordingId: Long): Result = - runCatching { - delay(FAKE_API_DELAY_MILLIS) - PracticeRecordingAnalysisResult( - pronunciationScore = 90, - speed = PracticeRecordingSpeed.ADEQUATE, - ) - } - - private companion object { - const val FAKE_API_DELAY_MILLIS = 300L - const val FAKE_RECORDING_ID = 1L - - val fakePracticeScripts = listOf( - PracticeScript( - id = 1L, - content = "내가 그린 기린 그림은 잘 그린 기린 그림이고,\n네가 그린 기린 그림은 잘못 그린 기린 그림이다.", - ), - PracticeScript( - id = 2L, - content = "간장 공장 공장장은 강 공장장이고,\n된장 공장 공장장은 공 공장장이다.", - ), - PracticeScript( - id = 3L, - content = "저기 있는 말뚝이 말 맬 말뚝이냐,\n말 못 맬 말뚝이냐.", - ), - PracticeScript( - id = 4L, - content = "서울특별시 특허허가과 허가과장 허 과장.", - ), - PracticeScript( - id = 5L, - content = "신진 샹송 가수의 신춘 샹송 쇼.", - ), - PracticeScript( - id = 6L, - content = "작년에 온 솥 장수는 새 솥 장수이고,\n금년에 온 솥 장수는 헌 솥 장수이다.", - ), - PracticeScript( - id = 7L, - content = "상표 붙인 큰 깡통은 깐 깡통인가,\n안 깐 깡통인가.", - ), - ) - } -} diff --git a/Prezel/core/data/src/test/java/com/team/prezel/core/data/repository/practice/PracticeRepositoryImplTest.kt b/Prezel/core/data/src/test/java/com/team/prezel/core/data/repository/practice/PracticeRepositoryImplTest.kt new file mode 100644 index 00000000..1dd4e71e --- /dev/null +++ b/Prezel/core/data/src/test/java/com/team/prezel/core/data/repository/practice/PracticeRepositoryImplTest.kt @@ -0,0 +1,74 @@ +package com.team.prezel.core.data.repository.practice + +import com.team.prezel.core.data.repository.PracticeRepositoryImpl +import com.team.prezel.core.model.practice.PracticeRecordingSpeed +import com.team.prezel.core.network.datasource.PracticeRemoteDataSource +import com.team.prezel.core.network.model.practice.AnalyzePracticeRecordingResponse +import com.team.prezel.core.network.model.practice.PracticeSentenceResponse +import kotlinx.coroutines.runBlocking +import kotlin.test.Test +import kotlin.test.assertEquals + +class PracticeRepositoryImplTest { + @Test + fun `연습 녹음 대본을 조회한다`() = + runBlocking { + val repository = PracticeRepositoryImpl( + practiceRemoteDataSource = FakePracticeRemoteDataSource( + sentence = "간장 공장 공장장은 강 공장장이다.", + ), + ) + + val result = repository.fetchPracticeScript().getOrThrow() + + assertEquals(0L, result.id) + assertEquals("간장 공장 공장장은 강 공장장이다.", result.content) + } + + @Test + fun `연습 녹음 분석을 요청하고 분석 결과로 변환한다`() = + runBlocking { + val remoteDataSource = FakePracticeRemoteDataSource( + sentence = "간장 공장 공장장은 강 공장장이다.", + ) + val repository = PracticeRepositoryImpl( + practiceRemoteDataSource = remoteDataSource, + ) + + val result = repository + .analyzePracticeRecording( + recordingFilePath = "/tmp/practice.wav", + referenceText = "간장 공장 공장장은 강 공장장이다.", + ).getOrThrow() + + assertEquals("/tmp/practice.wav", remoteDataSource.analyzeRecordingFilePath) + assertEquals("간장 공장 공장장은 강 공장장이다.", remoteDataSource.analyzeReferenceText) + assertEquals(86, result.pronunciationScore) + assertEquals(PracticeRecordingSpeed.ADEQUATE, result.speed) + } + + private class FakePracticeRemoteDataSource( + private val sentence: String, + ) : PracticeRemoteDataSource { + var analyzeRecordingFilePath: String? = null + private set + var analyzeReferenceText: String? = null + private set + + override suspend fun getPracticeSentence(): PracticeSentenceResponse = PracticeSentenceResponse(sentence = sentence) + + override suspend fun analyzePracticeRecording( + recordingFilePath: String, + referenceText: String, + ): AnalyzePracticeRecordingResponse { + analyzeRecordingFilePath = recordingFilePath + analyzeReferenceText = referenceText + + return AnalyzePracticeRecordingResponse( + accuracyScore = 85.5, + speedEvaluation = "적당해요", + overallEvaluation = "Good", + ) + } + } +} diff --git a/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/practice/AnalyzePracticeRecordingUseCase.kt b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/practice/AnalyzePracticeRecordingUseCase.kt deleted file mode 100644 index 169db33d..00000000 --- a/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/practice/AnalyzePracticeRecordingUseCase.kt +++ /dev/null @@ -1,19 +0,0 @@ -package com.team.prezel.core.domain.usecase.practice - -import com.team.prezel.core.domain.repository.practice.PracticeRepository -import com.team.prezel.core.model.practice.PracticeRecordingAnalysisResult -import javax.inject.Inject - -/** - * 연습 녹음본을 업로드하고 분석 결과를 조회하는 UseCase. - */ -class AnalyzePracticeRecordingUseCase @Inject constructor( - private val practiceRepository: PracticeRepository, -) { - suspend operator fun invoke(recordingFilePath: String): Result = - practiceRepository - .uploadPracticeRecording(recordingFilePath = recordingFilePath) - .mapCatching { upload -> - practiceRepository.fetchPracticeRecordingAnalysisResult(recordingId = upload.id).getOrThrow() - } -} diff --git a/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/practice/FetchPracticeRecordingAnalysisResultUseCase.kt b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/practice/FetchPracticeRecordingAnalysisResultUseCase.kt deleted file mode 100644 index af3615c0..00000000 --- a/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/practice/FetchPracticeRecordingAnalysisResultUseCase.kt +++ /dev/null @@ -1,20 +0,0 @@ -package com.team.prezel.core.domain.usecase.practice - -import com.team.prezel.core.domain.repository.practice.PracticeRepository -import com.team.prezel.core.model.practice.PracticeRecordingAnalysisResult -import javax.inject.Inject - -/** - * 업로드된 연습 녹음본의 분석 결과를 조회하는 UseCase. - * - * ### 동작 흐름 - * 1. 호출부로부터 전달받은 녹음본 ID를 입력값으로 받습니다. - * 2. [com.team.prezel.core.domain.repository.practice.PracticeRepository.fetchPracticeRecordingAnalysisResult]를 호출하여 분석 결과 조회를 요청합니다. - * 3. 조회 결과에 따라 분석 결과 또는 예외를 포함한 [Result]를 반환합니다. - */ -class FetchPracticeRecordingAnalysisResultUseCase @Inject constructor( - private val practiceRepository: PracticeRepository, -) { - suspend operator fun invoke(recordingId: Long): Result = - practiceRepository.fetchPracticeRecordingAnalysisResult(recordingId = recordingId) -} diff --git a/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/practice/FetchPracticeScriptUseCase.kt b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/practice/FetchPracticeScriptUseCase.kt deleted file mode 100644 index 0fc0ade0..00000000 --- a/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/practice/FetchPracticeScriptUseCase.kt +++ /dev/null @@ -1,19 +0,0 @@ -package com.team.prezel.core.domain.usecase.practice - -import com.team.prezel.core.domain.repository.practice.PracticeRepository -import com.team.prezel.core.model.practice.PracticeScript -import javax.inject.Inject - -/** - * 연습 녹음에 사용할 대본을 조회하는 UseCase. - * - * ### 동작 흐름 - * 1. [com.team.prezel.core.domain.repository.practice.PracticeRepository.fetchPracticeScript]를 호출하여 연습 대본 조회를 요청합니다. - * 2. repository가 서버 또는 임시 데이터 소스로부터 대본을 가져옵니다. - * 3. 조회 결과에 따라 연습 대본 또는 예외를 포함한 [Result]를 반환합니다. - */ -class FetchPracticeScriptUseCase @Inject constructor( - private val practiceRepository: PracticeRepository, -) { - suspend operator fun invoke(): Result = practiceRepository.fetchPracticeScript() -} diff --git a/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/practice/UploadPracticeRecordingUseCase.kt b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/practice/UploadPracticeRecordingUseCase.kt deleted file mode 100644 index 7f48476a..00000000 --- a/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/practice/UploadPracticeRecordingUseCase.kt +++ /dev/null @@ -1,20 +0,0 @@ -package com.team.prezel.core.domain.usecase.practice - -import com.team.prezel.core.domain.repository.practice.PracticeRepository -import com.team.prezel.core.model.practice.PracticeRecordingUpload -import javax.inject.Inject - -/** - * 연습 녹음본 파일을 업로드하는 UseCase. - * - * ### 동작 흐름 - * 1. 호출부로부터 전달받은 녹음본 파일 경로를 입력값으로 받습니다. - * 2. [com.team.prezel.core.domain.repository.practice.PracticeRepository.uploadPracticeRecording]을 호출하여 녹음본 업로드를 요청합니다. - * 3. 업로드 결과에 따라 녹음본 업로드 정보 또는 예외를 포함한 [Result]를 반환합니다. - */ -class UploadPracticeRecordingUseCase @Inject constructor( - private val practiceRepository: PracticeRepository, -) { - suspend operator fun invoke(recordingFilePath: String): Result = - practiceRepository.uploadPracticeRecording(recordingFilePath = recordingFilePath) -} diff --git a/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/repository/practice/PracticeRepository.kt b/Prezel/core/domain/src/main/kotlin/com/team/prezel/core/domain/repository/practice/PracticeRepository.kt similarity index 50% rename from Prezel/core/domain/src/main/java/com/team/prezel/core/domain/repository/practice/PracticeRepository.kt rename to Prezel/core/domain/src/main/kotlin/com/team/prezel/core/domain/repository/practice/PracticeRepository.kt index 065fecfc..340ccf6a 100644 --- a/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/repository/practice/PracticeRepository.kt +++ b/Prezel/core/domain/src/main/kotlin/com/team/prezel/core/domain/repository/practice/PracticeRepository.kt @@ -1,13 +1,13 @@ package com.team.prezel.core.domain.repository.practice import com.team.prezel.core.model.practice.PracticeRecordingAnalysisResult -import com.team.prezel.core.model.practice.PracticeRecordingUpload import com.team.prezel.core.model.practice.PracticeScript interface PracticeRepository { suspend fun fetchPracticeScript(): Result - suspend fun uploadPracticeRecording(recordingFilePath: String): Result - - suspend fun fetchPracticeRecordingAnalysisResult(recordingId: Long): Result + suspend fun analyzePracticeRecording( + recordingFilePath: String, + referenceText: String, + ): Result } diff --git a/Prezel/core/domain/src/main/kotlin/com/team/prezel/core/domain/usecase/practice/AnalyzePracticeRecordingUseCase.kt b/Prezel/core/domain/src/main/kotlin/com/team/prezel/core/domain/usecase/practice/AnalyzePracticeRecordingUseCase.kt new file mode 100644 index 00000000..021eda97 --- /dev/null +++ b/Prezel/core/domain/src/main/kotlin/com/team/prezel/core/domain/usecase/practice/AnalyzePracticeRecordingUseCase.kt @@ -0,0 +1,18 @@ +package com.team.prezel.core.domain.usecase.practice + +import com.team.prezel.core.domain.repository.practice.PracticeRepository +import com.team.prezel.core.model.practice.PracticeRecordingAnalysisResult +import javax.inject.Inject + +class AnalyzePracticeRecordingUseCase @Inject constructor( + private val practiceRepository: PracticeRepository, +) { + suspend operator fun invoke( + recordingFilePath: String, + referenceText: String, + ): Result = + practiceRepository.analyzePracticeRecording( + recordingFilePath = recordingFilePath, + referenceText = referenceText, + ) +} diff --git a/Prezel/core/domain/src/main/kotlin/com/team/prezel/core/domain/usecase/practice/FetchPracticeScriptUseCase.kt b/Prezel/core/domain/src/main/kotlin/com/team/prezel/core/domain/usecase/practice/FetchPracticeScriptUseCase.kt new file mode 100644 index 00000000..f8e16653 --- /dev/null +++ b/Prezel/core/domain/src/main/kotlin/com/team/prezel/core/domain/usecase/practice/FetchPracticeScriptUseCase.kt @@ -0,0 +1,11 @@ +package com.team.prezel.core.domain.usecase.practice + +import com.team.prezel.core.domain.repository.practice.PracticeRepository +import com.team.prezel.core.model.practice.PracticeScript +import javax.inject.Inject + +class FetchPracticeScriptUseCase @Inject constructor( + private val practiceRepository: PracticeRepository, +) { + suspend operator fun invoke(): Result = practiceRepository.fetchPracticeScript() +} diff --git a/Prezel/core/model/src/main/java/com/team/prezel/core/model/practice/PracticeRecordingUpload.kt b/Prezel/core/model/src/main/java/com/team/prezel/core/model/practice/PracticeRecordingUpload.kt deleted file mode 100644 index 9120fa49..00000000 --- a/Prezel/core/model/src/main/java/com/team/prezel/core/model/practice/PracticeRecordingUpload.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.team.prezel.core.model.practice - -data class PracticeRecordingUpload( - val id: Long, -) diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/PracticeRemoteDataSource.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/PracticeRemoteDataSource.kt new file mode 100644 index 00000000..1619412d --- /dev/null +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/PracticeRemoteDataSource.kt @@ -0,0 +1,13 @@ +package com.team.prezel.core.network.datasource + +import com.team.prezel.core.network.model.practice.AnalyzePracticeRecordingResponse +import com.team.prezel.core.network.model.practice.PracticeSentenceResponse + +interface PracticeRemoteDataSource { + suspend fun getPracticeSentence(): PracticeSentenceResponse + + suspend fun analyzePracticeRecording( + recordingFilePath: String, + referenceText: String, + ): AnalyzePracticeRecordingResponse +} diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/PracticeRemoteDataSourceImpl.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/PracticeRemoteDataSourceImpl.kt new file mode 100644 index 00000000..93075ad3 --- /dev/null +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/PracticeRemoteDataSourceImpl.kt @@ -0,0 +1,43 @@ +package com.team.prezel.core.network.datasource + +import com.team.prezel.core.network.model.practice.AnalyzePracticeRecordingResponse +import com.team.prezel.core.network.model.practice.PracticeSentenceResponse +import com.team.prezel.core.network.model.requireData +import com.team.prezel.core.network.service.PracticeService +import io.ktor.client.request.forms.MultiPartFormDataContent +import io.ktor.client.request.forms.formData +import io.ktor.http.Headers +import io.ktor.http.HttpHeaders +import java.io.File +import javax.inject.Inject + +internal class PracticeRemoteDataSourceImpl @Inject constructor( + private val practiceService: PracticeService, +) : PracticeRemoteDataSource { + override suspend fun getPracticeSentence(): PracticeSentenceResponse = practiceService.getPracticeSentence().requireData() + + override suspend fun analyzePracticeRecording( + recordingFilePath: String, + referenceText: String, + ): AnalyzePracticeRecordingResponse { + val audioFile = File(recordingFilePath) + val multipart = MultiPartFormDataContent( + formData { + append( + key = "audio", + value = audioFile.readBytes(), + headers = Headers.build { + append(HttpHeaders.ContentType, "audio/${audioFile.extension}") + append(HttpHeaders.ContentDisposition, "filename=\"${audioFile.name}\"") + }, + ) + }, + ) + + return practiceService + .analyzePracticeRecording( + referenceText = referenceText, + audio = multipart, + ).requireData() + } +} diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/DataSourceModule.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/DataSourceModule.kt index 606ef58e..94809a86 100644 --- a/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/DataSourceModule.kt +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/DataSourceModule.kt @@ -2,6 +2,8 @@ package com.team.prezel.core.network.di import com.team.prezel.core.network.datasource.AuthRemoteDataSource import com.team.prezel.core.network.datasource.AuthRemoteDataSourceImpl +import com.team.prezel.core.network.datasource.PracticeRemoteDataSource +import com.team.prezel.core.network.datasource.PracticeRemoteDataSourceImpl import com.team.prezel.core.network.datasource.TermsRemoteDataSource import com.team.prezel.core.network.datasource.TermsRemoteDataSourceImpl import com.team.prezel.core.network.datasource.UserRemoteDataSource @@ -23,6 +25,10 @@ internal abstract class DataSourceModule { @Singleton abstract fun bindUserRemoteDataSource(impl: UserRemoteDataSourceImpl): UserRemoteDataSource + @Binds + @Singleton + abstract fun bindPracticeRemoteDataSource(impl: PracticeRemoteDataSourceImpl): PracticeRemoteDataSource + @Binds @Singleton abstract fun bindTermsRemoteDataSource(impl: TermsRemoteDataSourceImpl): TermsRemoteDataSource diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/NetworkModule.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/NetworkModule.kt index 9796d08a..f8179227 100644 --- a/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/NetworkModule.kt +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/NetworkModule.kt @@ -2,9 +2,11 @@ package com.team.prezel.core.network.di import com.team.prezel.core.network.client.HttpClientFactory import com.team.prezel.core.network.service.AuthService +import com.team.prezel.core.network.service.PracticeService import com.team.prezel.core.network.service.TermsService import com.team.prezel.core.network.service.UserService import com.team.prezel.core.network.service.createAuthService +import com.team.prezel.core.network.service.createPracticeService import com.team.prezel.core.network.service.createTermsService import com.team.prezel.core.network.service.createUserService import dagger.Module @@ -38,6 +40,10 @@ object NetworkModule { @Singleton internal fun provideUserService(ktorfit: Ktorfit): UserService = ktorfit.createUserService() + @Provides + @Singleton + internal fun providePracticeService(ktorfit: Ktorfit): PracticeService = ktorfit.createPracticeService() + @Provides @Singleton internal fun provideTermsService(ktorfit: Ktorfit): TermsService = ktorfit.createTermsService() 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 2823c33e..7b6cd46d 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,9 @@ enum class ServerErrorCode( REQUIRED_TERMS_DISAGREED("TR002"), FILE_IS_EMPTY("F001"), FILE_UPLOAD_FAILED("F002"), + SENTENCE_NOT_FOUND("S001"), + VOICE_RECOGNITION_FAILED("V001"), + VOICE_ANALYSIS_FAILED("V002"), UNKNOWN("UNKNOWN"), ; diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/model/practice/AnalyzePracticeRecordingResponse.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/model/practice/AnalyzePracticeRecordingResponse.kt new file mode 100644 index 00000000..31110982 --- /dev/null +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/model/practice/AnalyzePracticeRecordingResponse.kt @@ -0,0 +1,14 @@ +package com.team.prezel.core.network.model.practice + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class AnalyzePracticeRecordingResponse( + @SerialName("accuracyScore") + val accuracyScore: Double, + @SerialName("speedEvaluation") + val speedEvaluation: String, + @SerialName("overallEvaluation") + val overallEvaluation: String, +) diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/model/practice/PracticeSentenceResponse.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/model/practice/PracticeSentenceResponse.kt new file mode 100644 index 00000000..80439075 --- /dev/null +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/model/practice/PracticeSentenceResponse.kt @@ -0,0 +1,10 @@ +package com.team.prezel.core.network.model.practice + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class PracticeSentenceResponse( + @SerialName("sentence") + val sentence: String, +) diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/service/PracticeService.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/service/PracticeService.kt new file mode 100644 index 00000000..e0c1fa8a --- /dev/null +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/service/PracticeService.kt @@ -0,0 +1,21 @@ +package com.team.prezel.core.network.service + +import com.team.prezel.core.network.model.BaseResponse +import com.team.prezel.core.network.model.practice.AnalyzePracticeRecordingResponse +import com.team.prezel.core.network.model.practice.PracticeSentenceResponse +import de.jensklingenberg.ktorfit.http.Body +import de.jensklingenberg.ktorfit.http.GET +import de.jensklingenberg.ktorfit.http.POST +import de.jensklingenberg.ktorfit.http.Query +import io.ktor.client.request.forms.MultiPartFormDataContent + +interface PracticeService { + @GET("recording/practice/sentence") + suspend fun getPracticeSentence(): BaseResponse + + @POST("recording/practice/analyze") + suspend fun analyzePracticeRecording( + @Query("referenceText") referenceText: String, + @Body audio: MultiPartFormDataContent, + ): BaseResponse +} diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/PracticeRecordingViewModel.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/PracticeRecordingViewModel.kt index def9f03d..7a4c107f 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/PracticeRecordingViewModel.kt +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/PracticeRecordingViewModel.kt @@ -94,24 +94,26 @@ internal class PracticeRecordingViewModel @Inject constructor( delay(ANALYSIS_LOADING_DELAY_MILLIS) - analyzePracticeRecordingUseCase(recordingFilePath = filePath) - .onSuccess { result -> - updateState { - copy( - analysisStatus = PracticeRecordingAnalysisStatus.Success( - result = result.toUiModel(), - ), - ) - } - }.onFailure { - updateState { - copy( - analysisStatus = PracticeRecordingAnalysisStatus.Error( - type = PracticeRecordingAnalysisErrorType.ANALYSIS_FAILED, - ), - ) - } + analyzePracticeRecordingUseCase( + recordingFilePath = filePath, + referenceText = currentState.practiceScript, + ).onSuccess { result -> + updateState { + copy( + analysisStatus = PracticeRecordingAnalysisStatus.Success( + result = result.toUiModel(), + ), + ) + } + }.onFailure { + updateState { + copy( + analysisStatus = PracticeRecordingAnalysisStatus.Error( + type = PracticeRecordingAnalysisErrorType.ANALYSIS_FAILED, + ), + ) } + } } } diff --git a/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/LoginScreen.kt b/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/LoginScreen.kt index 9fa7fc43..b54077b7 100644 --- a/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/LoginScreen.kt +++ b/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/LoginScreen.kt @@ -84,7 +84,7 @@ internal fun SharedTransitionScope.LoginScreen( LoginUiMessage.LOGIN_CANCELLED -> R.string.feature_login_impl_kakao_cancelled LoginUiMessage.LOGIN_FAILED_UNKNOWN -> R.string.feature_login_impl_login_failed } - snackbarHostState.showPrezelSnackbar(message = resources.getString(resId)) + snackbarHostState.showPrezelSnackbar(message = resources.getString(resId), useRaisedPosition = false) } } } From cb1713d1ad62c862eca5b71911026813a6c9c92b Mon Sep 17 00:00:00 2001 From: Ham BeomJoon Date: Wed, 13 May 2026 17:58:44 +0900 Subject: [PATCH 20/27] =?UTF-8?q?feat:=20=EC=97=B0=EC=8A=B5=20=EB=85=B9?= =?UTF-8?q?=EC=9D=8C=20=EB=B6=84=EC=84=9D=20=EA=B2=B0=EA=B3=BC=EC=97=90=20?= =?UTF-8?q?=EC=A2=85=ED=95=A9=20=ED=8F=89=EA=B0=80(Overall=20Evaluation)?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EB=8F=84=EB=A9=94=EC=9D=B8?= =?UTF-8?q?=20=EB=AA=A8=EB=8D=B8=20=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: `PracticeRecordingOverallEvaluation` 도메인 모델 추가 및 연동 * `PERFECT`, `GOOD`, `TRY` 세 가지 상태를 가지는 종합 평가 enum 클래스를 `core:model`에 추가했습니다. * `PracticeRecordingAnalysisResult` 모델에 `overallEvaluation` 필드를 추가했습니다. * `PracticeRepositoryImpl`에서 서버 응답 문자열을 `PracticeRecordingOverallEvaluation`으로 변환하는 매핑 로직을 구현했습니다. * refactor: UI 레이어 모델 간소화 및 도메인 모델 직접 사용 * `feature:home:impl`에서 별도로 관리하던 `PracticeRecordingAnalysisUiModel`을 제거하고, 도메인 모델인 `PracticeRecordingAnalysisResult`를 직접 사용하도록 리팩터링했습니다. * `PracticeRecordingAnalysisStatus.Success`가 UI 모델 대신 도메인 모델을 보유하도록 수정했습니다. * `PracticeRecordingViewModel`에서 불필요한 `toUiModel()` 변환 로직을 삭제했습니다. * refactor: 연습 결과 화면 로직 개선 및 리소스 업데이트 * `PracticeRecordingResultPage`에서 점수 기반으로 계산하던 종합 평가 로직을 제거하고, 데이터 레이어에서 전달된 `overallEvaluation` 값을 사용하도록 변경했습니다. * `PracticeRecordingOverallEvaluation` 타입에 따른 `contentDescription` 및 카드 이미지 리소스를 반환하는 확장 프로퍼티를 추가했습니다. * `feature_home_impl_card_good.xml` 벡터 이미지의 색상 값(보라색 계열 -> 청록색 계열) 및 패스 데이터를 수정했습니다. * test: `PracticeRepositoryImplTest` 내 결과 검증 로직 추가 * 녹음 분석 결과 테스트 시 `overallEvaluation` 값이 올바르게 매핑되는지 확인하는 검증 로직을 추가했습니다. --- .../data/repository/PracticeRepositoryImpl.kt | 10 ++++ .../practice/PracticeRepositoryImplTest.kt | 2 + .../PracticeRecordingAnalysisResult.kt | 7 +++ .../practice/PracticeRecordingViewModel.kt | 10 +--- .../model/PracticeRecordingAnalysisStatus.kt | 3 +- .../model/PracticeRecordingAnalysisUiModel.kt | 10 ---- .../result/PracticeRecordingResultScreen.kt | 1 + .../component/PracticeRecordingResultPage.kt | 60 +++++++------------ .../drawable/feature_home_impl_card_good.xml | 17 +++--- 9 files changed, 54 insertions(+), 66 deletions(-) delete mode 100644 Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/model/PracticeRecordingAnalysisUiModel.kt diff --git a/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/PracticeRepositoryImpl.kt b/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/PracticeRepositoryImpl.kt index ed6bd7e4..73795356 100644 --- a/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/PracticeRepositoryImpl.kt +++ b/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/PracticeRepositoryImpl.kt @@ -3,6 +3,7 @@ package com.team.prezel.core.data.repository import com.team.prezel.core.data.error.mapDomainFailure import com.team.prezel.core.domain.repository.practice.PracticeRepository import com.team.prezel.core.model.practice.PracticeRecordingAnalysisResult +import com.team.prezel.core.model.practice.PracticeRecordingOverallEvaluation import com.team.prezel.core.model.practice.PracticeRecordingSpeed import com.team.prezel.core.model.practice.PracticeScript import com.team.prezel.core.network.datasource.PracticeRemoteDataSource @@ -44,6 +45,7 @@ internal class PracticeRepositoryImpl @Inject constructor( PracticeRecordingAnalysisResult( pronunciationScore = accuracyScore.roundToInt(), speed = speedEvaluation.toPracticeRecordingSpeed(), + overallEvaluation = overallEvaluation.toPracticeRecordingOverallEvaluation(), ) private fun String.toPracticeRecordingSpeed(): PracticeRecordingSpeed = @@ -53,6 +55,14 @@ internal class PracticeRepositoryImpl @Inject constructor( else -> PracticeRecordingSpeed.ADEQUATE } + private fun String.toPracticeRecordingOverallEvaluation(): PracticeRecordingOverallEvaluation = + when (this) { + "Perfect" -> PracticeRecordingOverallEvaluation.PERFECT + "Good" -> PracticeRecordingOverallEvaluation.GOOD + "Try" -> PracticeRecordingOverallEvaluation.TRY + else -> PracticeRecordingOverallEvaluation.TRY + } + private companion object { const val PRACTICE_SCRIPT_ID = 0L } diff --git a/Prezel/core/data/src/test/java/com/team/prezel/core/data/repository/practice/PracticeRepositoryImplTest.kt b/Prezel/core/data/src/test/java/com/team/prezel/core/data/repository/practice/PracticeRepositoryImplTest.kt index 1dd4e71e..785093e6 100644 --- a/Prezel/core/data/src/test/java/com/team/prezel/core/data/repository/practice/PracticeRepositoryImplTest.kt +++ b/Prezel/core/data/src/test/java/com/team/prezel/core/data/repository/practice/PracticeRepositoryImplTest.kt @@ -1,6 +1,7 @@ package com.team.prezel.core.data.repository.practice import com.team.prezel.core.data.repository.PracticeRepositoryImpl +import com.team.prezel.core.model.practice.PracticeRecordingOverallEvaluation import com.team.prezel.core.model.practice.PracticeRecordingSpeed import com.team.prezel.core.network.datasource.PracticeRemoteDataSource import com.team.prezel.core.network.model.practice.AnalyzePracticeRecordingResponse @@ -45,6 +46,7 @@ class PracticeRepositoryImplTest { assertEquals("간장 공장 공장장은 강 공장장이다.", remoteDataSource.analyzeReferenceText) assertEquals(86, result.pronunciationScore) assertEquals(PracticeRecordingSpeed.ADEQUATE, result.speed) + assertEquals(PracticeRecordingOverallEvaluation.GOOD, result.overallEvaluation) } private class FakePracticeRemoteDataSource( diff --git a/Prezel/core/model/src/main/java/com/team/prezel/core/model/practice/PracticeRecordingAnalysisResult.kt b/Prezel/core/model/src/main/java/com/team/prezel/core/model/practice/PracticeRecordingAnalysisResult.kt index 499e592d..f755d27b 100644 --- a/Prezel/core/model/src/main/java/com/team/prezel/core/model/practice/PracticeRecordingAnalysisResult.kt +++ b/Prezel/core/model/src/main/java/com/team/prezel/core/model/practice/PracticeRecordingAnalysisResult.kt @@ -3,6 +3,7 @@ package com.team.prezel.core.model.practice data class PracticeRecordingAnalysisResult( val pronunciationScore: Int, val speed: PracticeRecordingSpeed, + val overallEvaluation: PracticeRecordingOverallEvaluation, ) enum class PracticeRecordingSpeed { @@ -10,3 +11,9 @@ enum class PracticeRecordingSpeed { ADEQUATE, FAST, } + +enum class PracticeRecordingOverallEvaluation { + PERFECT, + GOOD, + TRY, +} diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/PracticeRecordingViewModel.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/PracticeRecordingViewModel.kt index 7a4c107f..896a2e22 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/PracticeRecordingViewModel.kt +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/PracticeRecordingViewModel.kt @@ -5,14 +5,12 @@ import com.team.prezel.core.audio.AudioSessionEffect import com.team.prezel.core.audio.RecordingAudioController import com.team.prezel.core.domain.usecase.practice.AnalyzePracticeRecordingUseCase import com.team.prezel.core.domain.usecase.practice.FetchPracticeScriptUseCase -import com.team.prezel.core.model.practice.PracticeRecordingAnalysisResult import com.team.prezel.core.ui.base.BaseViewModel import com.team.prezel.feature.home.impl.practice.contract.PracticeRecordingUiEffect import com.team.prezel.feature.home.impl.practice.contract.PracticeRecordingUiIntent import com.team.prezel.feature.home.impl.practice.contract.PracticeRecordingUiState import com.team.prezel.feature.home.impl.practice.model.PracticeRecordingAnalysisErrorType import com.team.prezel.feature.home.impl.practice.model.PracticeRecordingAnalysisStatus -import com.team.prezel.feature.home.impl.practice.model.PracticeRecordingAnalysisUiModel import com.team.prezel.feature.home.impl.practice.model.PracticeRecordingUiMessage import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.delay @@ -101,7 +99,7 @@ internal class PracticeRecordingViewModel @Inject constructor( updateState { copy( analysisStatus = PracticeRecordingAnalysisStatus.Success( - result = result.toUiModel(), + result = result, ), ) } @@ -146,9 +144,3 @@ private fun AudioSessionEffect.toUiMessage(): PracticeRecordingUiMessage = AudioSessionEffect.RecordingStopFailed -> PracticeRecordingUiMessage.RECORDING_STOP_FAILED AudioSessionEffect.PlaybackStartFailed -> PracticeRecordingUiMessage.PLAYBACK_START_FAILED } - -private fun PracticeRecordingAnalysisResult.toUiModel(): PracticeRecordingAnalysisUiModel = - PracticeRecordingAnalysisUiModel( - pronunciationScore = pronunciationScore, - speed = speed, - ) diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/model/PracticeRecordingAnalysisStatus.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/model/PracticeRecordingAnalysisStatus.kt index 5e55040f..515ef5ce 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/model/PracticeRecordingAnalysisStatus.kt +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/model/PracticeRecordingAnalysisStatus.kt @@ -1,6 +1,7 @@ package com.team.prezel.feature.home.impl.practice.model import androidx.compose.runtime.Immutable +import com.team.prezel.core.model.practice.PracticeRecordingAnalysisResult @Immutable internal sealed interface PracticeRecordingAnalysisStatus { @@ -9,7 +10,7 @@ internal sealed interface PracticeRecordingAnalysisStatus { data object Loading : PracticeRecordingAnalysisStatus data class Success( - val result: PracticeRecordingAnalysisUiModel, + val result: PracticeRecordingAnalysisResult, ) : PracticeRecordingAnalysisStatus data class Error( diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/model/PracticeRecordingAnalysisUiModel.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/model/PracticeRecordingAnalysisUiModel.kt deleted file mode 100644 index 60ad1d55..00000000 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/model/PracticeRecordingAnalysisUiModel.kt +++ /dev/null @@ -1,10 +0,0 @@ -package com.team.prezel.feature.home.impl.practice.model - -import androidx.compose.runtime.Immutable -import com.team.prezel.core.model.practice.PracticeRecordingSpeed - -@Immutable -internal data class PracticeRecordingAnalysisUiModel( - val pronunciationScore: Int, - val speed: PracticeRecordingSpeed, -) diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/result/PracticeRecordingResultScreen.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/result/PracticeRecordingResultScreen.kt index 86b10684..defd1afa 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/result/PracticeRecordingResultScreen.kt +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/result/PracticeRecordingResultScreen.kt @@ -19,6 +19,7 @@ internal fun PracticeRecordingResultScreen( is PracticeRecordingAnalysisStatus.Success -> PracticeRecordingResultPage( pronunciationScore = analysisStatus.result.pronunciationScore, speed = analysisStatus.result.speed, + overallEvaluation = analysisStatus.result.overallEvaluation, onComplete = onComplete, modifier = modifier, ) diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/result/component/PracticeRecordingResultPage.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/result/component/PracticeRecordingResultPage.kt index 104be8e3..a931911c 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/result/component/PracticeRecordingResultPage.kt +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/result/component/PracticeRecordingResultPage.kt @@ -27,47 +27,26 @@ import com.team.prezel.core.designsystem.component.actions.button.PrezelButton import com.team.prezel.core.designsystem.component.chip.chip.PrezelChip import com.team.prezel.core.designsystem.preview.BasicPreview import com.team.prezel.core.designsystem.theme.PrezelTheme +import com.team.prezel.core.model.practice.PracticeRecordingOverallEvaluation import com.team.prezel.core.model.practice.PracticeRecordingSpeed import com.team.prezel.feature.home.impl.R -private enum class PracticeAnalysisOverallResult( - @param:StringRes val contentDescriptionResId: Int, - @param:DrawableRes val cardResId: Int, -) { - PERFECT( - contentDescriptionResId = R.string.feature_home_impl_practice_recording_analysis_card_perfect, - cardResId = R.drawable.feature_home_impl_card_perfect, - ), - GOOD( - contentDescriptionResId = R.string.feature_home_impl_practice_recording_analysis_card_good, - cardResId = R.drawable.feature_home_impl_card_good, - ), - TRY( - contentDescriptionResId = R.string.feature_home_impl_practice_recording_analysis_card_try, - cardResId = R.drawable.feature_home_impl_card_try, - ), -} - @Composable internal fun PracticeRecordingResultPage( pronunciationScore: Int, speed: PracticeRecordingSpeed, + overallEvaluation: PracticeRecordingOverallEvaluation, onComplete: () -> Unit, modifier: Modifier = Modifier, ) { - val overallResult = rememberOverallResult( - pronunciationScore = pronunciationScore, - speed = speed, - ) - Column( modifier = modifier .fillMaxSize() .background(PrezelTheme.colors.bgRegular), ) { PracticeRecordingResultContent( - cardResId = overallResult.cardResId, - cardContentDescription = stringResource(overallResult.contentDescriptionResId), + cardResId = overallEvaluation.cardResId, + cardContentDescription = stringResource(overallEvaluation.contentDescriptionResId), pronunciationScore = pronunciationScore, speed = speed, modifier = Modifier.weight(1f), @@ -128,18 +107,6 @@ private fun PracticeRecordingResultButtonArea( ) } -private fun rememberOverallResult( - pronunciationScore: Int, - speed: PracticeRecordingSpeed, -): PracticeAnalysisOverallResult = - when { - pronunciationScore >= 95 && speed == PracticeRecordingSpeed.ADEQUATE -> PracticeAnalysisOverallResult.PERFECT - pronunciationScore >= 70 && speed == PracticeRecordingSpeed.ADEQUATE -> PracticeAnalysisOverallResult.GOOD - pronunciationScore >= 95 && speed != PracticeRecordingSpeed.ADEQUATE -> PracticeAnalysisOverallResult.GOOD - pronunciationScore <= 60 -> PracticeAnalysisOverallResult.TRY - else -> PracticeAnalysisOverallResult.TRY - } - @Composable private fun PracticeAnalysisMetricRow( pronunciationScore: Int, @@ -197,6 +164,22 @@ private val PracticeRecordingSpeed.labelResId: Int PracticeRecordingSpeed.FAST -> R.string.feature_home_impl_practice_recording_analysis_speed_fast } +private val PracticeRecordingOverallEvaluation.contentDescriptionResId: Int + @StringRes + get() = when (this) { + PracticeRecordingOverallEvaluation.PERFECT -> R.string.feature_home_impl_practice_recording_analysis_card_perfect + PracticeRecordingOverallEvaluation.GOOD -> R.string.feature_home_impl_practice_recording_analysis_card_good + PracticeRecordingOverallEvaluation.TRY -> R.string.feature_home_impl_practice_recording_analysis_card_try + } + +private val PracticeRecordingOverallEvaluation.cardResId: Int + @DrawableRes + get() = when (this) { + PracticeRecordingOverallEvaluation.PERFECT -> R.drawable.feature_home_impl_card_perfect + PracticeRecordingOverallEvaluation.GOOD -> R.drawable.feature_home_impl_card_good + PracticeRecordingOverallEvaluation.TRY -> R.drawable.feature_home_impl_card_try + } + @Composable private fun PracticeAnalysisMetricLabel( text: String, @@ -217,6 +200,7 @@ private fun PracticeRecordingResultPerfectPagePreview() { PracticeRecordingResultPage( pronunciationScore = 96, speed = PracticeRecordingSpeed.ADEQUATE, + overallEvaluation = PracticeRecordingOverallEvaluation.PERFECT, onComplete = {}, ) } @@ -229,6 +213,7 @@ private fun PracticeRecordingResultGoodPagePreview() { PracticeRecordingResultPage( pronunciationScore = 90, speed = PracticeRecordingSpeed.ADEQUATE, + overallEvaluation = PracticeRecordingOverallEvaluation.GOOD, onComplete = {}, ) } @@ -241,6 +226,7 @@ private fun PracticeRecordingResultTryPagePreview() { PracticeRecordingResultPage( pronunciationScore = 58, speed = PracticeRecordingSpeed.FAST, + overallEvaluation = PracticeRecordingOverallEvaluation.TRY, onComplete = {}, ) } diff --git a/Prezel/feature/home/impl/src/main/res/drawable/feature_home_impl_card_good.xml b/Prezel/feature/home/impl/src/main/res/drawable/feature_home_impl_card_good.xml index 70de122f..a82572ee 100644 --- a/Prezel/feature/home/impl/src/main/res/drawable/feature_home_impl_card_good.xml +++ b/Prezel/feature/home/impl/src/main/res/drawable/feature_home_impl_card_good.xml @@ -5,17 +5,17 @@ android:viewportWidth="360" android:viewportHeight="438"> + android:fillColor="#E9FBFB" + android:pathData="M54,37.16C54.69,30.57 60.6,25.78 67.19,26.48L329.74,54.07C336.33,54.77 341.11,60.67 340.42,67.26L305.3,401.42C304.61,408.01 298.7,412.79 292.11,412.1L29.56,384.51C22.97,383.81 18.19,377.91 18.88,371.32L54,37.16Z" /> + android:pathData="M35.65,51.29C35.65,44.66 41.02,39.29 47.65,39.29H311.65C318.28,39.29 323.65,44.66 323.65,51.29V387.29C323.65,393.92 318.28,399.29 311.65,399.29H47.65C41.02,399.29 35.65,393.92 35.65,387.29V51.29Z" /> + android:pathData="M35.65,51.29C35.65,44.66 41.02,39.29 47.65,39.29H311.65C318.28,39.29 323.65,44.66 323.65,51.29V387.29C323.65,393.92 318.28,399.29 311.65,399.29H47.65C41.02,399.29 35.65,393.92 35.65,387.29V51.29Z"> + android:pathData="M35.65,51.29C35.65,44.66 41.02,39.29 47.65,39.29H311.65C318.28,39.29 323.65,44.66 323.65,51.29V387.29C323.65,393.92 318.28,399.29 311.65,399.29H47.65C41.02,399.29 35.65,393.92 35.65,387.29V51.29Z"> From 167fc1dd5091a2e5adb9aff9d6808830ee22179f Mon Sep 17 00:00:00 2001 From: Ham BeomJoon Date: Thu, 14 May 2026 01:04:13 +0900 Subject: [PATCH 21/27] =?UTF-8?q?refactor:=20=EC=97=B0=EC=8A=B5(Practice)?= =?UTF-8?q?=20=EA=B8=B0=EB=8A=A5=EC=9D=84=20=EB=B3=84=EB=8F=84=20=EB=AA=A8?= =?UTF-8?q?=EB=93=88=EB=A1=9C=20=EB=B6=84=EB=A6=AC=20=EB=B0=8F=20=EB=B6=84?= =?UTF-8?q?=EC=84=9D=20=EB=A1=9C=EC=A7=81=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * **feat: `feature:practice` 신규 모듈 생성 및 코드 이동** * 기존 `feature:home:impl` 내에 위치하던 연습 녹음 및 분석 기능을 `feature:practice:api`와 `feature:practice:impl` 모듈로 분리했습니다. * 연습 관련 UI 컴포넌트, ViewModel, 계약(Contract), 리소스(Strings, Drawables) 전체를 신규 모듈로 이관했습니다. * `PracticeRecordingNavKey`를 `feature:practice:api`의 `PracticeNavKey`로 대체하고 내비게이션 구조를 재설계했습니다. * `settings.gradle.kts` 및 각 모듈의 `build.gradle.kts`에 신규 모듈 의존성을 추가했습니다. * **refactor: 연습 분석 에러 처리 및 로직 최적화** * **에러 모델 확장**: `AppError`에 `VOICE_RECOGNITION_FAILED` 타입을 추가하고, 서버 에러 코드 매핑 로직(`AppErrorExt.kt`)에 반영했습니다. * **ViewModel 개선**: `PracticeRecordingViewModel`에서 분석 시 존재하던 인위적인 지연 시간(`ANALYSIS_LOADING_DELAY_MILLIS`)을 제거하고, 에러 발생 시 구체적인 에러 타입(`VOICE_RECOGNITION_FAILED` 등)을 구분하여 처리하도록 개선했습니다. * **안정성 강화**: 녹음 시작 시 대본 존재 여부를 확인하는 로직을 추가했습니다. * **refactor: `MediaRecordingAudioController` 코드 정리** * `playbackPositionSeconds` 메서드에서 불필요한 `runCatching`을 제거하고 가독성을 높였습니다. * **chore: 홈 모듈 및 앱 모듈 의존성 정리** * `feature:home:impl`에서 연습 관련 로직을 제거하고 `feature:practice:api`에 의존하도록 수정했습니다. * `HomeScreen`에서 연습하기 버튼 클릭 시 신규 내비게이션 키를 사용하도록 변경했습니다. --- Prezel/app/build.gradle.kts | 2 + .../audio/MediaRecordingAudioController.kt | 2 +- .../team/prezel/core/common/error/AppError.kt | 1 + .../prezel/core/data/error/AppErrorExt.kt | 6 +- Prezel/feature/home/impl/build.gradle.kts | 3 +- .../feature/home/impl/main/HomeScreen.kt | 4 +- .../home/impl/navigation/HomeEntryBuilder.kt | 11 --- .../navigation/PracticeRecordingNavKey.kt | 7 -- .../home/impl/src/main/res/values/strings.xml | 27 +------- Prezel/feature/practice/api/.gitignore | 1 + Prezel/feature/practice/api/build.gradle.kts | 7 ++ .../feature/practice/api/consumer-rules.pro | 0 .../feature/practice/api/proguard-rules.pro | 21 ++++++ .../feature/practice/api/PracticeNavKey.kt | 7 ++ Prezel/feature/practice/impl/.gitignore | 1 + Prezel/feature/practice/impl/build.gradle.kts | 15 ++++ .../feature/practice/impl/consumer-rules.pro | 0 .../feature/practice/impl/proguard-rules.pro | 21 ++++++ .../practice/impl}/PracticeRecordingScreen.kt | 43 +++++------- .../impl}/PracticeRecordingViewModel.kt | 68 ++++++++++++------- .../practice/impl}/RecordAudioPermission.kt | 2 +- .../component/PracticeRecordingContent.kt | 6 +- .../component/PracticeRecordingControl.kt | 2 +- .../component/PracticeRecordingTopAppBar.kt | 8 +-- .../contract/PracticeRecordingUiEffect.kt | 4 +- .../contract/PracticeRecordingUiIntent.kt | 2 +- .../contract/PracticeRecordingUiState.kt | 4 +- .../model/PracticeRecordingAnalysisStatus.kt | 2 +- .../impl}/model/PracticeRecordingUiMessage.kt | 2 +- .../impl/navigation/PracticeEntryBuilder.kt | 35 ++++++++++ .../result/PracticeRecordingResultScreen.kt | 10 +-- .../PracticeRecordingAnalysisFailurePage.kt | 12 ++-- .../PracticeRecordingAnalysisLoadingPage.kt | 8 +-- .../component/PracticeRecordingResultPage.kt | 28 ++++---- .../feature_practice_impl_card_good.xml} | 0 .../feature_practice_impl_card_perfect.xml} | 0 .../feature_practice_impl_card_try.xml} | 0 .../impl/src/main/res/values/strings.xml | 27 ++++++++ .../feature/terms/impl/TermsViewModel.kt | 1 + Prezel/settings.gradle.kts | 2 + 40 files changed, 255 insertions(+), 147 deletions(-) delete mode 100644 Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/navigation/PracticeRecordingNavKey.kt create mode 100644 Prezel/feature/practice/api/.gitignore create mode 100644 Prezel/feature/practice/api/build.gradle.kts create mode 100644 Prezel/feature/practice/api/consumer-rules.pro create mode 100644 Prezel/feature/practice/api/proguard-rules.pro create mode 100644 Prezel/feature/practice/api/src/main/java/com/team/prezel/feature/practice/api/PracticeNavKey.kt create mode 100644 Prezel/feature/practice/impl/.gitignore create mode 100644 Prezel/feature/practice/impl/build.gradle.kts create mode 100644 Prezel/feature/practice/impl/consumer-rules.pro create mode 100644 Prezel/feature/practice/impl/proguard-rules.pro rename Prezel/feature/{home/impl/src/main/java/com/team/prezel/feature/home/impl/practice => practice/impl/src/main/java/com/team/prezel/feature/practice/impl}/PracticeRecordingScreen.kt (83%) rename Prezel/feature/{home/impl/src/main/java/com/team/prezel/feature/home/impl/practice => practice/impl/src/main/java/com/team/prezel/feature/practice/impl}/PracticeRecordingViewModel.kt (65%) rename Prezel/feature/{home/impl/src/main/java/com/team/prezel/feature/home/impl/practice => practice/impl/src/main/java/com/team/prezel/feature/practice/impl}/RecordAudioPermission.kt (96%) rename Prezel/feature/{home/impl/src/main/java/com/team/prezel/feature/home/impl/practice => practice/impl/src/main/java/com/team/prezel/feature/practice/impl}/component/PracticeRecordingContent.kt (95%) rename Prezel/feature/{home/impl/src/main/java/com/team/prezel/feature/home/impl/practice => practice/impl/src/main/java/com/team/prezel/feature/practice/impl}/component/PracticeRecordingControl.kt (99%) rename Prezel/feature/{home/impl/src/main/java/com/team/prezel/feature/home/impl/practice => practice/impl/src/main/java/com/team/prezel/feature/practice/impl}/component/PracticeRecordingTopAppBar.kt (81%) rename Prezel/feature/{home/impl/src/main/java/com/team/prezel/feature/home/impl/practice => practice/impl/src/main/java/com/team/prezel/feature/practice/impl}/contract/PracticeRecordingUiEffect.kt (61%) rename Prezel/feature/{home/impl/src/main/java/com/team/prezel/feature/home/impl/practice => practice/impl/src/main/java/com/team/prezel/feature/practice/impl}/contract/PracticeRecordingUiIntent.kt (92%) rename Prezel/feature/{home/impl/src/main/java/com/team/prezel/feature/home/impl/practice => practice/impl/src/main/java/com/team/prezel/feature/practice/impl}/contract/PracticeRecordingUiState.kt (91%) rename Prezel/feature/{home/impl/src/main/java/com/team/prezel/feature/home/impl/practice => practice/impl/src/main/java/com/team/prezel/feature/practice/impl}/model/PracticeRecordingAnalysisStatus.kt (91%) rename Prezel/feature/{home/impl/src/main/java/com/team/prezel/feature/home/impl/practice => practice/impl/src/main/java/com/team/prezel/feature/practice/impl}/model/PracticeRecordingUiMessage.kt (81%) create mode 100644 Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/navigation/PracticeEntryBuilder.kt rename Prezel/feature/{home/impl/src/main/java/com/team/prezel/feature/home/impl/practice => practice/impl/src/main/java/com/team/prezel/feature/practice/impl}/result/PracticeRecordingResultScreen.kt (70%) rename Prezel/feature/{home/impl/src/main/java/com/team/prezel/feature/home/impl/practice => practice/impl/src/main/java/com/team/prezel/feature/practice/impl}/result/component/PracticeRecordingAnalysisFailurePage.kt (83%) rename Prezel/feature/{home/impl/src/main/java/com/team/prezel/feature/home/impl/practice => practice/impl/src/main/java/com/team/prezel/feature/practice/impl}/result/component/PracticeRecordingAnalysisLoadingPage.kt (74%) rename Prezel/feature/{home/impl/src/main/java/com/team/prezel/feature/home/impl/practice => practice/impl/src/main/java/com/team/prezel/feature/practice/impl}/result/component/PracticeRecordingResultPage.kt (88%) rename Prezel/feature/{home/impl/src/main/res/drawable/feature_home_impl_card_good.xml => practice/impl/src/main/res/drawable/feature_practice_impl_card_good.xml} (100%) rename Prezel/feature/{home/impl/src/main/res/drawable/feature_home_impl_card_perfect.xml => practice/impl/src/main/res/drawable/feature_practice_impl_card_perfect.xml} (100%) rename Prezel/feature/{home/impl/src/main/res/drawable/feature_home_impl_card_try.xml => practice/impl/src/main/res/drawable/feature_practice_impl_card_try.xml} (100%) create mode 100644 Prezel/feature/practice/impl/src/main/res/values/strings.xml diff --git a/Prezel/app/build.gradle.kts b/Prezel/app/build.gradle.kts index e99a933f..1f12fe8d 100644 --- a/Prezel/app/build.gradle.kts +++ b/Prezel/app/build.gradle.kts @@ -46,6 +46,8 @@ dependencies { implementation(projects.featureTermsImpl) implementation(projects.featureHomeApi) implementation(projects.featureHomeImpl) + implementation(projects.featurePracticeApi) + implementation(projects.featurePracticeImpl) implementation(projects.featureHistoryApi) implementation(projects.featureHistoryImpl) implementation(projects.featureMyApi) 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 b60dd8d6..d4c8d3ff 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 @@ -237,7 +237,7 @@ internal class MediaRecordingAudioController @Inject constructor( player = null } - private fun playbackPositionSeconds(): Int = runCatching { player?.currentPosition?.toSeconds() }.getOrNull() ?: 0 + private fun playbackPositionSeconds(): Int = player?.currentPosition?.toSeconds() ?: 0 private fun deleteCurrentAudioFile() { currentAudioFile?.delete() 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 9e707147..197bfbac 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 @@ -6,6 +6,7 @@ enum class AppError { SERVER_ERROR, NOT_FOUND, DUPLICATE, + VOICE_RECOGNITION_FAILED, NETWORK, UNKNOWN, } diff --git a/Prezel/core/data/src/main/java/com/team/prezel/core/data/error/AppErrorExt.kt b/Prezel/core/data/src/main/java/com/team/prezel/core/data/error/AppErrorExt.kt index ab0838e6..72b731d3 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 @@ -62,7 +62,7 @@ private fun ServerErrorCode.toDomainError(): AppError = ServerErrorCode.INVALID_ID_TOKEN, -> AppError.UNAUTHORIZED - ServerErrorCode.VOICE_RECOGNITION_FAILED, - ServerErrorCode.UNKNOWN, - -> AppError.UNKNOWN + ServerErrorCode.VOICE_RECOGNITION_FAILED -> AppError.VOICE_RECOGNITION_FAILED + + ServerErrorCode.UNKNOWN -> AppError.UNKNOWN } diff --git a/Prezel/feature/home/impl/build.gradle.kts b/Prezel/feature/home/impl/build.gradle.kts index 96efc948..42062125 100644 --- a/Prezel/feature/home/impl/build.gradle.kts +++ b/Prezel/feature/home/impl/build.gradle.kts @@ -7,10 +7,9 @@ android { } dependencies { - implementation(projects.coreAudio) - implementation(projects.coreDomain) implementation(projects.coreModel) implementation(projects.featureHomeApi) + implementation(projects.featurePracticeApi) implementation(libs.kotlinx.collections.immutable) implementation(libs.kotlinx.datetime) diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/HomeScreen.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/HomeScreen.kt index b4f66b6a..0371e337 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/HomeScreen.kt +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/HomeScreen.kt @@ -39,7 +39,7 @@ import com.team.prezel.feature.home.impl.main.contract.HomeUiIntent import com.team.prezel.feature.home.impl.main.contract.HomeUiState import com.team.prezel.feature.home.impl.main.model.HomeUiMessage import com.team.prezel.feature.home.impl.main.model.PresentationUiModel -import com.team.prezel.feature.home.impl.navigation.PracticeRecordingNavKey +import com.team.prezel.feature.practice.api.PracticeNavKey import kotlinx.collections.immutable.toPersistentList import kotlinx.coroutines.launch import kotlinx.datetime.LocalDate @@ -74,7 +74,7 @@ internal fun HomeScreen( uiState = uiState, pagerState = pagerState, onClickAddPresentation = { }, - onClickPracticeRecording = { navigator.navigate(PracticeRecordingNavKey) }, + onClickPracticeRecording = { navigator.navigate(PracticeNavKey) }, onClickAnalyzePresentation = { }, onClickWriteFeedback = { }, modifier = modifier, 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 a8938159..b84d5b82 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 @@ -2,10 +2,8 @@ package com.team.prezel.feature.home.impl.navigation import androidx.navigation3.runtime.EntryProviderScope import androidx.navigation3.runtime.NavKey -import com.team.prezel.core.navigation.LocalNavigator import com.team.prezel.feature.home.api.HomeNavKey import com.team.prezel.feature.home.impl.main.HomeScreen -import com.team.prezel.feature.home.impl.practice.PracticeRecordingScreen import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -16,15 +14,6 @@ internal fun EntryProviderScope.featureHomeEntryBuilder() { entry { HomeScreen() } - - entry { - val navigator = LocalNavigator.current - - PracticeRecordingScreen( - onBack = navigator::goBack, - navigateToHome = { navigator.replaceRoot(HomeNavKey) }, - ) - } } @Module diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/navigation/PracticeRecordingNavKey.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/navigation/PracticeRecordingNavKey.kt deleted file mode 100644 index 8e95d3a9..00000000 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/navigation/PracticeRecordingNavKey.kt +++ /dev/null @@ -1,7 +0,0 @@ -package com.team.prezel.feature.home.impl.navigation - -import androidx.navigation3.runtime.NavKey -import kotlinx.serialization.Serializable - -@Serializable -internal data object PracticeRecordingNavKey : NavKey diff --git a/Prezel/feature/home/impl/src/main/res/values/strings.xml b/Prezel/feature/home/impl/src/main/res/values/strings.xml index 8932898a..7e897498 100644 --- a/Prezel/feature/home/impl/src/main/res/values/strings.xml +++ b/Prezel/feature/home/impl/src/main/res/values/strings.xml @@ -18,33 +18,8 @@ 학술·교육 업무·보고 - 연습하기 - 연습 녹음 - 뒤로가기 - 아래 문장을 소리내어 읽어주세요. - 분석하기 - 대본을 불러오지 못했습니다. - 마이크 권한이 필요합니다. - 설정에서 마이크 권한을 허용해 주세요. - 녹음을 시작하지 못했습니다. - 녹음을 저장하지 못했습니다. - 녹음을 재생하지 못했습니다. - 분석중 - 잠시만 기다려주세요 - 분석에 실패했어요 - 음성이 작거나 주변 소음이 많았을 수 있어요.\n조용한 환경에서 다시 시도해 주세요. - 다시 시도하기 - 완료 - 발화 - 속도 - 느려요 - 적당해요 - 빨라요 - perfect - good - try - + 데이터를 불러오지 못했습니다. diff --git a/Prezel/feature/practice/api/.gitignore b/Prezel/feature/practice/api/.gitignore new file mode 100644 index 00000000..796b96d1 --- /dev/null +++ b/Prezel/feature/practice/api/.gitignore @@ -0,0 +1 @@ +/build diff --git a/Prezel/feature/practice/api/build.gradle.kts b/Prezel/feature/practice/api/build.gradle.kts new file mode 100644 index 00000000..5b34fdbb --- /dev/null +++ b/Prezel/feature/practice/api/build.gradle.kts @@ -0,0 +1,7 @@ +plugins { + alias(libs.plugins.prezel.android.feature.api) +} + +android { + namespace = "com.team.prezel.feature.practice.api" +} diff --git a/Prezel/feature/practice/api/consumer-rules.pro b/Prezel/feature/practice/api/consumer-rules.pro new file mode 100644 index 00000000..e69de29b diff --git a/Prezel/feature/practice/api/proguard-rules.pro b/Prezel/feature/practice/api/proguard-rules.pro new file mode 100644 index 00000000..f1b42451 --- /dev/null +++ b/Prezel/feature/practice/api/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile diff --git a/Prezel/feature/practice/api/src/main/java/com/team/prezel/feature/practice/api/PracticeNavKey.kt b/Prezel/feature/practice/api/src/main/java/com/team/prezel/feature/practice/api/PracticeNavKey.kt new file mode 100644 index 00000000..e9de678b --- /dev/null +++ b/Prezel/feature/practice/api/src/main/java/com/team/prezel/feature/practice/api/PracticeNavKey.kt @@ -0,0 +1,7 @@ +package com.team.prezel.feature.practice.api + +import androidx.navigation3.runtime.NavKey +import kotlinx.serialization.Serializable + +@Serializable +data object PracticeNavKey : NavKey diff --git a/Prezel/feature/practice/impl/.gitignore b/Prezel/feature/practice/impl/.gitignore new file mode 100644 index 00000000..796b96d1 --- /dev/null +++ b/Prezel/feature/practice/impl/.gitignore @@ -0,0 +1 @@ +/build diff --git a/Prezel/feature/practice/impl/build.gradle.kts b/Prezel/feature/practice/impl/build.gradle.kts new file mode 100644 index 00000000..519665ed --- /dev/null +++ b/Prezel/feature/practice/impl/build.gradle.kts @@ -0,0 +1,15 @@ +plugins { + alias(libs.plugins.prezel.android.feature.impl) +} + +android { + namespace = "com.team.prezel.feature.practice.impl" +} + +dependencies { + implementation(projects.coreAudio) + implementation(projects.coreDomain) + implementation(projects.coreModel) + implementation(projects.featureHomeApi) + implementation(projects.featurePracticeApi) +} diff --git a/Prezel/feature/practice/impl/consumer-rules.pro b/Prezel/feature/practice/impl/consumer-rules.pro new file mode 100644 index 00000000..e69de29b diff --git a/Prezel/feature/practice/impl/proguard-rules.pro b/Prezel/feature/practice/impl/proguard-rules.pro new file mode 100644 index 00000000..f1b42451 --- /dev/null +++ b/Prezel/feature/practice/impl/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/PracticeRecordingScreen.kt b/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/PracticeRecordingScreen.kt similarity index 83% rename from Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/PracticeRecordingScreen.kt rename to Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/PracticeRecordingScreen.kt index cde7dadf..da692605 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/PracticeRecordingScreen.kt +++ b/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/PracticeRecordingScreen.kt @@ -1,4 +1,4 @@ -package com.team.prezel.feature.home.impl.practice +package com.team.prezel.feature.practice.impl import androidx.activity.compose.BackHandler import androidx.compose.foundation.background @@ -20,16 +20,14 @@ import com.team.prezel.core.designsystem.component.feedback.snackbar.showPrezelS import com.team.prezel.core.designsystem.preview.BasicPreview import com.team.prezel.core.designsystem.theme.PrezelTheme import com.team.prezel.core.ui.state.LocalSnackbarHostState -import com.team.prezel.feature.home.impl.R -import com.team.prezel.feature.home.impl.practice.component.PracticeRecordingContent -import com.team.prezel.feature.home.impl.practice.component.PracticeRecordingTopAppBar -import com.team.prezel.feature.home.impl.practice.contract.PracticeRecordingUiEffect -import com.team.prezel.feature.home.impl.practice.contract.PracticeRecordingUiIntent -import com.team.prezel.feature.home.impl.practice.contract.PracticeRecordingUiState -import com.team.prezel.feature.home.impl.practice.model.PracticeRecordingAnalysisStatus -import com.team.prezel.feature.home.impl.practice.model.PracticeRecordingUiMessage -import com.team.prezel.feature.home.impl.practice.result.PracticeRecordingResultScreen -import kotlinx.coroutines.flow.collectLatest +import com.team.prezel.feature.practice.impl.component.PracticeRecordingContent +import com.team.prezel.feature.practice.impl.component.PracticeRecordingTopAppBar +import com.team.prezel.feature.practice.impl.contract.PracticeRecordingUiEffect +import com.team.prezel.feature.practice.impl.contract.PracticeRecordingUiIntent +import com.team.prezel.feature.practice.impl.contract.PracticeRecordingUiState +import com.team.prezel.feature.practice.impl.model.PracticeRecordingAnalysisStatus +import com.team.prezel.feature.practice.impl.model.PracticeRecordingUiMessage +import com.team.prezel.feature.practice.impl.result.PracticeRecordingResultScreen @Composable internal fun PracticeRecordingScreen( @@ -55,14 +53,11 @@ internal fun PracticeRecordingScreen( } LaunchedEffect(Unit) { - viewModel.uiEffect.collectLatest { effect -> + viewModel.uiEffect.collect { effect -> when (effect) { is PracticeRecordingUiEffect.ShowMessage -> { snackbarHostState.currentSnackbarData?.dismiss() - snackbarHostState.showPrezelSnackbar( - message = resources.getString(effect.message.resId), - useRaisedPosition = false, - ) + snackbarHostState.showPrezelSnackbar(message = resources.getString(effect.message.resId)) } } } @@ -135,8 +130,6 @@ private fun PracticeRecordingReadyScreen( onBack: () -> Unit, modifier: Modifier = Modifier, ) { - val analyzeLabel = stringResource(R.string.feature_home_impl_practice_recording_analyze) - Column( modifier = modifier .fillMaxSize() @@ -159,7 +152,7 @@ private fun PracticeRecordingReadyScreen( PrezelButtonArea( mainButton = { buttonModifier -> PrezelButton( - text = analyzeLabel, + text = stringResource(R.string.feature_practice_impl_practice_recording_analyze), modifier = buttonModifier, enabled = uiState.analyzeEnabled, onClick = onClickAnalyze, @@ -171,14 +164,14 @@ private fun PracticeRecordingReadyScreen( private val PracticeRecordingUiMessage.resId: Int get() = when (this) { - PracticeRecordingUiMessage.FETCH_PRACTICE_SCRIPT_FAILED -> R.string.feature_home_impl_practice_recording_fetch_script_failed - PracticeRecordingUiMessage.RECORD_AUDIO_PERMISSION_DENIED -> R.string.feature_home_impl_practice_recording_permission_denied + PracticeRecordingUiMessage.FETCH_PRACTICE_SCRIPT_FAILED -> R.string.feature_practice_impl_practice_recording_fetch_script_failed + PracticeRecordingUiMessage.RECORD_AUDIO_PERMISSION_DENIED -> R.string.feature_practice_impl_practice_recording_permission_denied PracticeRecordingUiMessage.RECORD_AUDIO_PERMISSION_PERMANENTLY_DENIED -> - R.string.feature_home_impl_practice_recording_permission_permanently_denied + R.string.feature_practice_impl_practice_recording_permission_permanently_denied - PracticeRecordingUiMessage.RECORDING_START_FAILED -> R.string.feature_home_impl_practice_recording_failed - PracticeRecordingUiMessage.RECORDING_STOP_FAILED -> R.string.feature_home_impl_practice_recording_stop_failed - PracticeRecordingUiMessage.PLAYBACK_START_FAILED -> R.string.feature_home_impl_practice_recording_playback_failed + PracticeRecordingUiMessage.RECORDING_START_FAILED -> R.string.feature_practice_impl_practice_recording_failed + PracticeRecordingUiMessage.RECORDING_STOP_FAILED -> R.string.feature_practice_impl_practice_recording_stop_failed + PracticeRecordingUiMessage.PLAYBACK_START_FAILED -> R.string.feature_practice_impl_practice_recording_playback_failed } @BasicPreview diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/PracticeRecordingViewModel.kt b/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/PracticeRecordingViewModel.kt similarity index 65% rename from Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/PracticeRecordingViewModel.kt rename to Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/PracticeRecordingViewModel.kt index 896a2e22..6546d0a0 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/PracticeRecordingViewModel.kt +++ b/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/PracticeRecordingViewModel.kt @@ -1,19 +1,21 @@ -package com.team.prezel.feature.home.impl.practice +package com.team.prezel.feature.practice.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.common.error.AppError +import com.team.prezel.core.common.error.AppException import com.team.prezel.core.domain.usecase.practice.AnalyzePracticeRecordingUseCase import com.team.prezel.core.domain.usecase.practice.FetchPracticeScriptUseCase import com.team.prezel.core.ui.base.BaseViewModel -import com.team.prezel.feature.home.impl.practice.contract.PracticeRecordingUiEffect -import com.team.prezel.feature.home.impl.practice.contract.PracticeRecordingUiIntent -import com.team.prezel.feature.home.impl.practice.contract.PracticeRecordingUiState -import com.team.prezel.feature.home.impl.practice.model.PracticeRecordingAnalysisErrorType -import com.team.prezel.feature.home.impl.practice.model.PracticeRecordingAnalysisStatus -import com.team.prezel.feature.home.impl.practice.model.PracticeRecordingUiMessage +import com.team.prezel.feature.practice.impl.contract.PracticeRecordingUiEffect +import com.team.prezel.feature.practice.impl.contract.PracticeRecordingUiIntent +import com.team.prezel.feature.practice.impl.contract.PracticeRecordingUiState +import com.team.prezel.feature.practice.impl.model.PracticeRecordingAnalysisErrorType +import com.team.prezel.feature.practice.impl.model.PracticeRecordingAnalysisStatus +import com.team.prezel.feature.practice.impl.model.PracticeRecordingUiMessage import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.delay import kotlinx.coroutines.launch import javax.inject.Inject @@ -37,7 +39,10 @@ internal class PracticeRecordingViewModel @Inject constructor( ) PracticeRecordingUiIntent.StartRecording -> { - updateState { copy(analysisStatus = PracticeRecordingAnalysisStatus.Ready) } + if (currentState.practiceScript.isBlank()) { + showMessage(PracticeRecordingUiMessage.FETCH_PRACTICE_SCRIPT_FAILED) + return + } audioController.startRecording() } @@ -53,7 +58,17 @@ internal class PracticeRecordingViewModel @Inject constructor( viewModelScope.launch { audioController.audioSessionState.collect { audioState -> updateState { - copy(recordingState = audioState) + copy( + recordingState = audioState, + analysisStatus = if ( + audioState is AudioSessionState.Recording && + recordingState !is AudioSessionState.Recording + ) { + PracticeRecordingAnalysisStatus.Ready + } else { + analysisStatus + }, + ) } } } @@ -90,8 +105,6 @@ internal class PracticeRecordingViewModel @Inject constructor( copy(analysisStatus = PracticeRecordingAnalysisStatus.Loading) } - delay(ANALYSIS_LOADING_DELAY_MILLIS) - analyzePracticeRecordingUseCase( recordingFilePath = filePath, referenceText = currentState.practiceScript, @@ -103,11 +116,11 @@ internal class PracticeRecordingViewModel @Inject constructor( ), ) } - }.onFailure { + }.onFailure { throwable -> updateState { copy( analysisStatus = PracticeRecordingAnalysisStatus.Error( - type = PracticeRecordingAnalysisErrorType.ANALYSIS_FAILED, + type = throwable.toPracticeRecordingAnalysisErrorType(), ), ) } @@ -128,19 +141,24 @@ internal class PracticeRecordingViewModel @Inject constructor( } } + private fun AudioSessionEffect.toUiMessage(): PracticeRecordingUiMessage = + when (this) { + AudioSessionEffect.RecordingStartFailed -> PracticeRecordingUiMessage.RECORDING_START_FAILED + AudioSessionEffect.RecordingStopFailed -> PracticeRecordingUiMessage.RECORDING_STOP_FAILED + AudioSessionEffect.PlaybackStartFailed -> PracticeRecordingUiMessage.PLAYBACK_START_FAILED + } + + private fun Throwable.toPracticeRecordingAnalysisErrorType(): PracticeRecordingAnalysisErrorType { + val error = (this as? AppException)?.error + + return when (error) { + AppError.VOICE_RECOGNITION_FAILED -> PracticeRecordingAnalysisErrorType.VOICE_RECOGNITION_FAILED + else -> PracticeRecordingAnalysisErrorType.ANALYSIS_FAILED + } + } + override fun onCleared() { audioController.release() super.onCleared() } - - private companion object { - const val ANALYSIS_LOADING_DELAY_MILLIS = 3_000L - } } - -private fun AudioSessionEffect.toUiMessage(): PracticeRecordingUiMessage = - when (this) { - AudioSessionEffect.RecordingStartFailed -> PracticeRecordingUiMessage.RECORDING_START_FAILED - AudioSessionEffect.RecordingStopFailed -> PracticeRecordingUiMessage.RECORDING_STOP_FAILED - AudioSessionEffect.PlaybackStartFailed -> PracticeRecordingUiMessage.PLAYBACK_START_FAILED - } diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/RecordAudioPermission.kt b/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/RecordAudioPermission.kt similarity index 96% rename from Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/RecordAudioPermission.kt rename to Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/RecordAudioPermission.kt index 561af186..1eae6cfb 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/RecordAudioPermission.kt +++ b/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/RecordAudioPermission.kt @@ -1,4 +1,4 @@ -package com.team.prezel.feature.home.impl.practice +package com.team.prezel.feature.practice.impl import android.Manifest import androidx.compose.runtime.Composable diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/component/PracticeRecordingContent.kt b/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/component/PracticeRecordingContent.kt similarity index 95% rename from Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/component/PracticeRecordingContent.kt rename to Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/component/PracticeRecordingContent.kt index 591bab34..6019775b 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/component/PracticeRecordingContent.kt +++ b/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/component/PracticeRecordingContent.kt @@ -1,4 +1,4 @@ -package com.team.prezel.feature.home.impl.practice.component +package com.team.prezel.feature.practice.impl.component import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box @@ -20,7 +20,7 @@ import com.team.prezel.core.audio.AudioSessionState import com.team.prezel.core.audio.AudioSource import com.team.prezel.core.designsystem.preview.BasicPreview import com.team.prezel.core.designsystem.theme.PrezelTheme -import com.team.prezel.feature.home.impl.R +import com.team.prezel.feature.practice.impl.R @Composable internal fun PracticeRecordingContent( @@ -40,7 +40,7 @@ internal fun PracticeRecordingContent( .padding(horizontal = PrezelTheme.spacing.V20, vertical = PrezelTheme.spacing.V16), ) { Text( - text = stringResource(R.string.feature_home_impl_practice_recording_instruction), + text = stringResource(R.string.feature_practice_impl_practice_recording_instruction), style = PrezelTheme.typography.title2Bold, color = PrezelTheme.colors.textLarge, ) diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/component/PracticeRecordingControl.kt b/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/component/PracticeRecordingControl.kt similarity index 99% rename from Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/component/PracticeRecordingControl.kt rename to Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/component/PracticeRecordingControl.kt index ab7efe61..82df1ff2 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/component/PracticeRecordingControl.kt +++ b/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/component/PracticeRecordingControl.kt @@ -1,4 +1,4 @@ -package com.team.prezel.feature.home.impl.practice.component +package com.team.prezel.feature.practice.impl.component import androidx.annotation.DrawableRes import androidx.compose.foundation.background diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/component/PracticeRecordingTopAppBar.kt b/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/component/PracticeRecordingTopAppBar.kt similarity index 81% rename from Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/component/PracticeRecordingTopAppBar.kt rename to Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/component/PracticeRecordingTopAppBar.kt index 6c113b14..988e7b7d 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/component/PracticeRecordingTopAppBar.kt +++ b/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/component/PracticeRecordingTopAppBar.kt @@ -1,4 +1,4 @@ -package com.team.prezel.feature.home.impl.practice.component +package com.team.prezel.feature.practice.impl.component import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon @@ -11,18 +11,18 @@ import com.team.prezel.core.designsystem.component.PrezelTopAppBar 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.home.impl.R +import com.team.prezel.feature.practice.impl.R @OptIn(ExperimentalMaterial3Api::class) @Composable internal fun PracticeRecordingTopAppBar(onBack: () -> Unit) { PrezelTopAppBar( - title = { Text(text = stringResource(R.string.feature_home_impl_practice_recording_title)) }, + title = { Text(text = stringResource(R.string.feature_practice_impl_practice_recording_title)) }, leadingIcon = { IconButton(onClick = onBack) { Icon( painter = painterResource(PrezelIcons.ArrowLeft), - contentDescription = stringResource(R.string.feature_home_impl_practice_recording_back), + contentDescription = stringResource(R.string.feature_practice_impl_practice_recording_back), ) } }, diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/contract/PracticeRecordingUiEffect.kt b/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/contract/PracticeRecordingUiEffect.kt similarity index 61% rename from Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/contract/PracticeRecordingUiEffect.kt rename to Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/contract/PracticeRecordingUiEffect.kt index 43fe18b5..20831a52 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/contract/PracticeRecordingUiEffect.kt +++ b/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/contract/PracticeRecordingUiEffect.kt @@ -1,7 +1,7 @@ -package com.team.prezel.feature.home.impl.practice.contract +package com.team.prezel.feature.practice.impl.contract import com.team.prezel.core.ui.base.UiEffect -import com.team.prezel.feature.home.impl.practice.model.PracticeRecordingUiMessage +import com.team.prezel.feature.practice.impl.model.PracticeRecordingUiMessage internal sealed interface PracticeRecordingUiEffect : UiEffect { data class ShowMessage( diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/contract/PracticeRecordingUiIntent.kt b/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/contract/PracticeRecordingUiIntent.kt similarity index 92% rename from Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/contract/PracticeRecordingUiIntent.kt rename to Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/contract/PracticeRecordingUiIntent.kt index e8fa26fa..5ecfc23f 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/contract/PracticeRecordingUiIntent.kt +++ b/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/contract/PracticeRecordingUiIntent.kt @@ -1,4 +1,4 @@ -package com.team.prezel.feature.home.impl.practice.contract +package com.team.prezel.feature.practice.impl.contract import com.team.prezel.core.ui.base.UiIntent diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/contract/PracticeRecordingUiState.kt b/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/contract/PracticeRecordingUiState.kt similarity index 91% rename from Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/contract/PracticeRecordingUiState.kt rename to Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/contract/PracticeRecordingUiState.kt index de015c13..adf00daa 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/contract/PracticeRecordingUiState.kt +++ b/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/contract/PracticeRecordingUiState.kt @@ -1,9 +1,9 @@ -package com.team.prezel.feature.home.impl.practice.contract +package com.team.prezel.feature.practice.impl.contract import androidx.compose.runtime.Immutable import com.team.prezel.core.audio.AudioSessionState import com.team.prezel.core.ui.base.UiState -import com.team.prezel.feature.home.impl.practice.model.PracticeRecordingAnalysisStatus +import com.team.prezel.feature.practice.impl.model.PracticeRecordingAnalysisStatus @Immutable internal data class PracticeRecordingUiState( diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/model/PracticeRecordingAnalysisStatus.kt b/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/model/PracticeRecordingAnalysisStatus.kt similarity index 91% rename from Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/model/PracticeRecordingAnalysisStatus.kt rename to Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/model/PracticeRecordingAnalysisStatus.kt index 515ef5ce..0ca228b9 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/model/PracticeRecordingAnalysisStatus.kt +++ b/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/model/PracticeRecordingAnalysisStatus.kt @@ -1,4 +1,4 @@ -package com.team.prezel.feature.home.impl.practice.model +package com.team.prezel.feature.practice.impl.model import androidx.compose.runtime.Immutable import com.team.prezel.core.model.practice.PracticeRecordingAnalysisResult diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/model/PracticeRecordingUiMessage.kt b/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/model/PracticeRecordingUiMessage.kt similarity index 81% rename from Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/model/PracticeRecordingUiMessage.kt rename to Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/model/PracticeRecordingUiMessage.kt index 619c4120..8a298ef6 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/model/PracticeRecordingUiMessage.kt +++ b/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/model/PracticeRecordingUiMessage.kt @@ -1,4 +1,4 @@ -package com.team.prezel.feature.home.impl.practice.model +package com.team.prezel.feature.practice.impl.model internal enum class PracticeRecordingUiMessage { FETCH_PRACTICE_SCRIPT_FAILED, diff --git a/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/navigation/PracticeEntryBuilder.kt b/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/navigation/PracticeEntryBuilder.kt new file mode 100644 index 00000000..ad8f4dc2 --- /dev/null +++ b/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/navigation/PracticeEntryBuilder.kt @@ -0,0 +1,35 @@ +package com.team.prezel.feature.practice.impl.navigation + +import androidx.navigation3.runtime.EntryProviderScope +import androidx.navigation3.runtime.NavKey +import com.team.prezel.core.navigation.LocalNavigator +import com.team.prezel.feature.home.api.HomeNavKey +import com.team.prezel.feature.practice.api.PracticeNavKey +import com.team.prezel.feature.practice.impl.PracticeRecordingScreen +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ActivityRetainedComponent +import dagger.multibindings.IntoSet + +internal fun EntryProviderScope.featurePracticeEntryBuilder() { + entry { + val navigator = LocalNavigator.current + + PracticeRecordingScreen( + onBack = navigator::goBack, + navigateToHome = { navigator.replaceRoot(HomeNavKey) }, + ) + } +} + +@Module +@InstallIn(ActivityRetainedComponent::class) +object FeaturePracticeModule { + @IntoSet + @Provides + fun provideFeaturePracticeEntryBuilder(): EntryProviderScope.() -> Unit = + { + featurePracticeEntryBuilder() + } +} diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/result/PracticeRecordingResultScreen.kt b/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/result/PracticeRecordingResultScreen.kt similarity index 70% rename from Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/result/PracticeRecordingResultScreen.kt rename to Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/result/PracticeRecordingResultScreen.kt index defd1afa..89ee4d00 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/result/PracticeRecordingResultScreen.kt +++ b/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/result/PracticeRecordingResultScreen.kt @@ -1,11 +1,11 @@ -package com.team.prezel.feature.home.impl.practice.result +package com.team.prezel.feature.practice.impl.result import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import com.team.prezel.feature.home.impl.practice.model.PracticeRecordingAnalysisStatus -import com.team.prezel.feature.home.impl.practice.result.component.PracticeRecordingAnalysisFailurePage -import com.team.prezel.feature.home.impl.practice.result.component.PracticeRecordingAnalysisLoadingPage -import com.team.prezel.feature.home.impl.practice.result.component.PracticeRecordingResultPage +import com.team.prezel.feature.practice.impl.model.PracticeRecordingAnalysisStatus +import com.team.prezel.feature.practice.impl.result.component.PracticeRecordingAnalysisFailurePage +import com.team.prezel.feature.practice.impl.result.component.PracticeRecordingAnalysisLoadingPage +import com.team.prezel.feature.practice.impl.result.component.PracticeRecordingResultPage @Composable internal fun PracticeRecordingResultScreen( diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/result/component/PracticeRecordingAnalysisFailurePage.kt b/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/result/component/PracticeRecordingAnalysisFailurePage.kt similarity index 83% rename from Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/result/component/PracticeRecordingAnalysisFailurePage.kt rename to Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/result/component/PracticeRecordingAnalysisFailurePage.kt index e216a03b..c7ae5e2d 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/result/component/PracticeRecordingAnalysisFailurePage.kt +++ b/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/result/component/PracticeRecordingAnalysisFailurePage.kt @@ -1,4 +1,4 @@ -package com.team.prezel.feature.home.impl.practice.result.component +package com.team.prezel.feature.practice.impl.result.component import androidx.annotation.DrawableRes import androidx.compose.foundation.Image @@ -16,8 +16,8 @@ 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.core.ui.component.StatusView -import com.team.prezel.feature.home.impl.R -import com.team.prezel.feature.home.impl.practice.model.PracticeRecordingAnalysisErrorType +import com.team.prezel.feature.practice.impl.R +import com.team.prezel.feature.practice.impl.model.PracticeRecordingAnalysisErrorType import com.team.prezel.core.ui.R as CoreUiR @Composable @@ -27,8 +27,8 @@ internal fun PracticeRecordingAnalysisFailurePage( modifier: Modifier = Modifier, ) { StatusView( - title = stringResource(R.string.feature_home_impl_practice_recording_analysis_error_title), - description = stringResource(R.string.feature_home_impl_practice_recording_analysis_error_description), + title = stringResource(R.string.feature_practice_impl_practice_recording_analysis_error_title), + description = stringResource(R.string.feature_practice_impl_practice_recording_analysis_error_description), modifier = modifier, visual = { Image( @@ -39,7 +39,7 @@ internal fun PracticeRecordingAnalysisFailurePage( }, action = { PrezelButton( - text = stringResource(R.string.feature_home_impl_practice_recording_analysis_retry), + text = stringResource(R.string.feature_practice_impl_practice_recording_analysis_retry), iconResId = PrezelIcons.Reset, type = ButtonType.FILLED, size = ButtonSize.SMALL, diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/result/component/PracticeRecordingAnalysisLoadingPage.kt b/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/result/component/PracticeRecordingAnalysisLoadingPage.kt similarity index 74% rename from Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/result/component/PracticeRecordingAnalysisLoadingPage.kt rename to Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/result/component/PracticeRecordingAnalysisLoadingPage.kt index cae12fb4..7601c70c 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/result/component/PracticeRecordingAnalysisLoadingPage.kt +++ b/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/result/component/PracticeRecordingAnalysisLoadingPage.kt @@ -1,4 +1,4 @@ -package com.team.prezel.feature.home.impl.practice.result.component +package com.team.prezel.feature.practice.impl.result.component import androidx.compose.foundation.layout.size import androidx.compose.runtime.Composable @@ -9,14 +9,14 @@ import com.team.prezel.core.designsystem.preview.BasicPreview import com.team.prezel.core.designsystem.theme.PrezelTheme import com.team.prezel.core.ui.component.PrezelLottie import com.team.prezel.core.ui.component.StatusView -import com.team.prezel.feature.home.impl.R +import com.team.prezel.feature.practice.impl.R import com.team.prezel.core.ui.R as CoreUiR @Composable internal fun PracticeRecordingAnalysisLoadingPage(modifier: Modifier = Modifier) { StatusView( - title = stringResource(R.string.feature_home_impl_practice_recording_analysis_loading_title), - description = stringResource(R.string.feature_home_impl_practice_recording_analysis_loading_description), + title = stringResource(R.string.feature_practice_impl_practice_recording_analysis_loading_title), + description = stringResource(R.string.feature_practice_impl_practice_recording_analysis_loading_description), modifier = modifier, visual = { PrezelLottie( diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/result/component/PracticeRecordingResultPage.kt b/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/result/component/PracticeRecordingResultPage.kt similarity index 88% rename from Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/result/component/PracticeRecordingResultPage.kt rename to Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/result/component/PracticeRecordingResultPage.kt index a931911c..80069652 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/result/component/PracticeRecordingResultPage.kt +++ b/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/result/component/PracticeRecordingResultPage.kt @@ -1,4 +1,4 @@ -package com.team.prezel.feature.home.impl.practice.result.component +package com.team.prezel.feature.practice.impl.result.component import androidx.annotation.DrawableRes import androidx.annotation.StringRes @@ -29,7 +29,7 @@ import com.team.prezel.core.designsystem.preview.BasicPreview import com.team.prezel.core.designsystem.theme.PrezelTheme import com.team.prezel.core.model.practice.PracticeRecordingOverallEvaluation import com.team.prezel.core.model.practice.PracticeRecordingSpeed -import com.team.prezel.feature.home.impl.R +import com.team.prezel.feature.practice.impl.R @Composable internal fun PracticeRecordingResultPage( @@ -92,7 +92,7 @@ private fun PracticeRecordingResultButtonArea( onComplete: () -> Unit, modifier: Modifier = Modifier, ) { - val completeLabel = stringResource(R.string.feature_home_impl_practice_recording_analysis_complete) + val completeLabel = stringResource(R.string.feature_practice_impl_practice_recording_analysis_complete) PrezelButtonArea( modifier = modifier, @@ -124,7 +124,7 @@ private fun PracticeAnalysisMetricRow( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween, ) { - PracticeAnalysisMetricLabel(text = stringResource(R.string.feature_home_impl_practice_recording_analysis_pronunciation)) + PracticeAnalysisMetricLabel(text = stringResource(R.string.feature_practice_impl_practice_recording_analysis_pronunciation)) Text( text = "$pronunciationScore%", style = PrezelTheme.typography.body1Medium, @@ -150,7 +150,7 @@ private fun PracticeAnalysisMetricRow( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween, ) { - PracticeAnalysisMetricLabel(text = stringResource(R.string.feature_home_impl_practice_recording_analysis_speed)) + PracticeAnalysisMetricLabel(text = stringResource(R.string.feature_practice_impl_practice_recording_analysis_speed)) PrezelChip(text = stringResource(speed.labelResId)) } } @@ -159,25 +159,25 @@ private fun PracticeAnalysisMetricRow( private val PracticeRecordingSpeed.labelResId: Int @StringRes get() = when (this) { - PracticeRecordingSpeed.SLOW -> R.string.feature_home_impl_practice_recording_analysis_speed_slow - PracticeRecordingSpeed.ADEQUATE -> R.string.feature_home_impl_practice_recording_analysis_speed_adequate - PracticeRecordingSpeed.FAST -> R.string.feature_home_impl_practice_recording_analysis_speed_fast + PracticeRecordingSpeed.SLOW -> R.string.feature_practice_impl_practice_recording_analysis_speed_slow + PracticeRecordingSpeed.ADEQUATE -> R.string.feature_practice_impl_practice_recording_analysis_speed_adequate + PracticeRecordingSpeed.FAST -> R.string.feature_practice_impl_practice_recording_analysis_speed_fast } private val PracticeRecordingOverallEvaluation.contentDescriptionResId: Int @StringRes get() = when (this) { - PracticeRecordingOverallEvaluation.PERFECT -> R.string.feature_home_impl_practice_recording_analysis_card_perfect - PracticeRecordingOverallEvaluation.GOOD -> R.string.feature_home_impl_practice_recording_analysis_card_good - PracticeRecordingOverallEvaluation.TRY -> R.string.feature_home_impl_practice_recording_analysis_card_try + PracticeRecordingOverallEvaluation.PERFECT -> R.string.feature_practice_impl_practice_recording_analysis_card_perfect + PracticeRecordingOverallEvaluation.GOOD -> R.string.feature_practice_impl_practice_recording_analysis_card_good + PracticeRecordingOverallEvaluation.TRY -> R.string.feature_practice_impl_practice_recording_analysis_card_try } private val PracticeRecordingOverallEvaluation.cardResId: Int @DrawableRes get() = when (this) { - PracticeRecordingOverallEvaluation.PERFECT -> R.drawable.feature_home_impl_card_perfect - PracticeRecordingOverallEvaluation.GOOD -> R.drawable.feature_home_impl_card_good - PracticeRecordingOverallEvaluation.TRY -> R.drawable.feature_home_impl_card_try + PracticeRecordingOverallEvaluation.PERFECT -> R.drawable.feature_practice_impl_card_perfect + PracticeRecordingOverallEvaluation.GOOD -> R.drawable.feature_practice_impl_card_good + PracticeRecordingOverallEvaluation.TRY -> R.drawable.feature_practice_impl_card_try } @Composable diff --git a/Prezel/feature/home/impl/src/main/res/drawable/feature_home_impl_card_good.xml b/Prezel/feature/practice/impl/src/main/res/drawable/feature_practice_impl_card_good.xml similarity index 100% rename from Prezel/feature/home/impl/src/main/res/drawable/feature_home_impl_card_good.xml rename to Prezel/feature/practice/impl/src/main/res/drawable/feature_practice_impl_card_good.xml diff --git a/Prezel/feature/home/impl/src/main/res/drawable/feature_home_impl_card_perfect.xml b/Prezel/feature/practice/impl/src/main/res/drawable/feature_practice_impl_card_perfect.xml similarity index 100% rename from Prezel/feature/home/impl/src/main/res/drawable/feature_home_impl_card_perfect.xml rename to Prezel/feature/practice/impl/src/main/res/drawable/feature_practice_impl_card_perfect.xml diff --git a/Prezel/feature/home/impl/src/main/res/drawable/feature_home_impl_card_try.xml b/Prezel/feature/practice/impl/src/main/res/drawable/feature_practice_impl_card_try.xml similarity index 100% rename from Prezel/feature/home/impl/src/main/res/drawable/feature_home_impl_card_try.xml rename to Prezel/feature/practice/impl/src/main/res/drawable/feature_practice_impl_card_try.xml diff --git a/Prezel/feature/practice/impl/src/main/res/values/strings.xml b/Prezel/feature/practice/impl/src/main/res/values/strings.xml new file mode 100644 index 00000000..272f590f --- /dev/null +++ b/Prezel/feature/practice/impl/src/main/res/values/strings.xml @@ -0,0 +1,27 @@ + + + 연습 녹음 + 뒤로가기 + 아래 문장을 소리내어 읽어주세요. + 분석하기 + 대본을 불러오지 못했습니다. + 마이크 권한이 필요합니다. + 설정에서 마이크 권한을 허용해 주세요. + 녹음을 시작하지 못했습니다. + 녹음을 저장하지 못했습니다. + 녹음을 재생하지 못했습니다. + 분석중 + 잠시만 기다려주세요 + 분석에 실패했어요 + 음성이 작거나 주변 소음이 많았을 수 있어요.\n조용한 환경에서 다시 시도해 주세요. + 다시 시도하기 + 완료 + 발화 + 속도 + 느려요 + 적당해요 + 빨라요 + perfect + good + try + diff --git a/Prezel/feature/terms/impl/src/main/java/com/team/prezel/feature/terms/impl/TermsViewModel.kt b/Prezel/feature/terms/impl/src/main/java/com/team/prezel/feature/terms/impl/TermsViewModel.kt index 9577e50d..fd8db9fa 100644 --- a/Prezel/feature/terms/impl/src/main/java/com/team/prezel/feature/terms/impl/TermsViewModel.kt +++ b/Prezel/feature/terms/impl/src/main/java/com/team/prezel/feature/terms/impl/TermsViewModel.kt @@ -90,6 +90,7 @@ internal class TermsViewModel @Inject constructor( AppError.SERVER_ERROR -> TermsUiMessage.AGREE_TERMS_FAILED_SERVER AppError.NOT_FOUND, AppError.DUPLICATE, + AppError.VOICE_RECOGNITION_FAILED, AppError.UNKNOWN, null, -> TermsUiMessage.AGREE_TERMS_FAILED_UNKNOWN diff --git a/Prezel/settings.gradle.kts b/Prezel/settings.gradle.kts index 1badd04e..41f5109a 100644 --- a/Prezel/settings.gradle.kts +++ b/Prezel/settings.gradle.kts @@ -52,6 +52,8 @@ includeAuto( ":core:ui", ":feature:home:api", ":feature:home:impl", + ":feature:practice:api", + ":feature:practice:impl", ":feature:history:api", ":feature:history:impl", ":feature:my:api", From 1ca917ae19be470e649c89fffa20c1d4c66a54b4 Mon Sep 17 00:00:00 2001 From: Ham BeomJoon Date: Thu, 14 May 2026 02:52:06 +0900 Subject: [PATCH 22/27] =?UTF-8?q?refactor:=20`PracticeRecordingUiState`=20?= =?UTF-8?q?=EA=B5=AC=EC=A1=B0=20=EA=B0=9C=EC=84=A0=20=EB=B0=8F=20=EC=98=A4?= =?UTF-8?q?=EB=94=94=EC=98=A4=20=EC=9E=AC=EC=83=9D=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EB=A6=AC=ED=8C=A9=ED=84=B0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * **feat: `PracticeRecordingUiState`를 Sealed Interface로 전환** * 기존 단일 데이터 클래스에서 `Ready`와 `Analysis` (Loading, Success, Error) 상태를 분리한 Sealed Interface 구조로 개편하여 상태 관리를 명확히 했습니다. * `PracticeRecordingAnalysisStatus` 모델을 제거하고 이를 `PracticeRecordingUiState.Analysis` 하위 클래스로 통합했습니다. * `PracticeRecordingViewModel` 및 Screen 컴포넌트들이 변경된 상태 구조를 반영하도록 업데이트했습니다. * **refactor: `MediaRecordingAudioController` 재생 로직 개선** * `startPlayback` 메서드의 거대했던 로직을 `preparePlayback`, `updatePlayingState`, `handlePlaybackCompleted` 등 기능별 프라이빗 메서드로 분리하여 가독성을 높였습니다. * `MediaPlayer` 설정 시 `runCatching`을 활용한 예외 처리와 자원 해제(`releasePlayer`) 로직을 강화했습니다. * **refactor: `PracticeRecordingViewModel` 내 연습 스크립트 관리 방식 변경** * `practiceScript`를 UI State에서 직접 관리하는 대신 클래스 멤버 변수로 분리하여, 녹음 상태 변경 시 불필요한 스크립트 데이터 재복사를 방지했습니다. * **chore: 모델 파일 정리** * `PracticeRecordingAnalysisErrorType`을 별도 파일로 분리했습니다. * `PracticeRecordingResultScreen`이 상태 기반으로 분기하여 각각의 결과 페이지를 렌더링하도록 수정했습니다. --- .../audio/MediaRecordingAudioController.kt | 114 +++++++++++++----- .../practice/impl/PracticeRecordingScreen.kt | 25 ++-- .../impl/PracticeRecordingViewModel.kt | 56 ++++----- .../impl/contract/PracticeRecordingUiState.kt | 85 +++++++------ .../PracticeRecordingAnalysisErrorType.kt | 6 + .../model/PracticeRecordingAnalysisStatus.kt | 24 ---- .../result/PracticeRecordingResultScreen.kt | 22 ++-- 7 files changed, 188 insertions(+), 144 deletions(-) create mode 100644 Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/model/PracticeRecordingAnalysisErrorType.kt delete mode 100644 Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/model/PracticeRecordingAnalysisStatus.kt diff --git a/Prezel/core/audio/src/main/java/com/team/prezel/core/audio/MediaRecordingAudioController.kt b/Prezel/core/audio/src/main/java/com/team/prezel/core/audio/MediaRecordingAudioController.kt index d4c8d3ff..d59dcd46 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 @@ -151,50 +151,98 @@ internal class MediaRecordingAudioController @Inject constructor( startPositionSeconds: Int, ) { runCatching { - releasePlayer() - - var pendingPlayer: MediaPlayer? = null - val newPlayer = runCatching { - val mediaPlayer = MediaPlayer() - pendingPlayer = mediaPlayer - mediaPlayer.apply { - setDataSource(source.filePath) - prepare() - if (startPositionSeconds > 0) { - seekTo(startPositionSeconds * MILLIS_PER_SECOND) - } - setOnCompletionListener { - releasePlayer() - _audioSessionState.value = AudioSessionState.ReadyToPlay( - source = source, - positionSeconds = durationSeconds, - durationSeconds = durationSeconds, - ) - } - start() - } - }.getOrElse { throwable -> - pendingPlayer?.release() - throw throwable - } - + preparePlayback( + source = source, + durationSeconds = durationSeconds, + startPositionSeconds = startPositionSeconds, + ) + }.onSuccess { newPlayer -> player = newPlayer - _audioSessionState.value = AudioSessionState.Playing( + updatePlayingState( source = source, - positionSeconds = startPositionSeconds, - durationSeconds = durationSeconds.coerceAtLeast(newPlayer.duration.toSeconds()), + durationSeconds = durationSeconds, + startPositionSeconds = startPositionSeconds, + playerDurationMillis = newPlayer.duration, ) startPlaybackTimer() }.onFailure { - releasePlayer() - _audioSessionState.value = AudioSessionState.ReadyToPlay( + handlePlaybackStartFailure( source = source, durationSeconds = durationSeconds, ) - emitEffect(AudioSessionEffect.PlaybackStartFailed) } } + private fun preparePlayback( + source: AudioSource, + durationSeconds: Int, + startPositionSeconds: Int, + ): MediaPlayer { + releasePlayer() + + var pendingPlayer: MediaPlayer? = null + return runCatching { + MediaPlayer().also { pendingPlayer = it }.apply { + setDataSource(source.filePath) + prepare() + seekToStartPosition(startPositionSeconds) + setOnCompletionListener { + handlePlaybackCompleted( + source = source, + durationSeconds = durationSeconds, + ) + } + start() + } + }.getOrElse { throwable -> + pendingPlayer?.release() + throw throwable + } + } + + private fun MediaPlayer.seekToStartPosition(startPositionSeconds: Int) { + if (startPositionSeconds > 0) { + seekTo(startPositionSeconds * MILLIS_PER_SECOND) + } + } + + private fun handlePlaybackCompleted( + source: AudioSource, + durationSeconds: Int, + ) { + releasePlayer() + _audioSessionState.value = AudioSessionState.ReadyToPlay( + source = source, + positionSeconds = durationSeconds, + durationSeconds = durationSeconds, + ) + } + + private fun updatePlayingState( + source: AudioSource, + durationSeconds: Int, + startPositionSeconds: Int, + playerDurationMillis: Int, + ) { + _audioSessionState.value = AudioSessionState.Playing( + source = source, + positionSeconds = startPositionSeconds, + durationSeconds = durationSeconds.coerceAtLeast(playerDurationMillis.toSeconds()), + ) + } + + private fun handlePlaybackStartFailure( + source: AudioSource, + durationSeconds: Int, + ) { + releasePlayer() + _audioSessionState.value = AudioSessionState.ReadyToPlay( + source = source, + durationSeconds = durationSeconds, + ) + emitEffect(AudioSessionEffect.PlaybackStartFailed) + } + private fun startRecordingTimer() { recordingTimerJob?.cancel() recordingTimerJob = controllerScope.launch { diff --git a/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/PracticeRecordingScreen.kt b/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/PracticeRecordingScreen.kt index da692605..77a8bd73 100644 --- a/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/PracticeRecordingScreen.kt +++ b/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/PracticeRecordingScreen.kt @@ -25,7 +25,6 @@ import com.team.prezel.feature.practice.impl.component.PracticeRecordingTopAppBa import com.team.prezel.feature.practice.impl.contract.PracticeRecordingUiEffect import com.team.prezel.feature.practice.impl.contract.PracticeRecordingUiIntent import com.team.prezel.feature.practice.impl.contract.PracticeRecordingUiState -import com.team.prezel.feature.practice.impl.model.PracticeRecordingAnalysisStatus import com.team.prezel.feature.practice.impl.model.PracticeRecordingUiMessage import com.team.prezel.feature.practice.impl.result.PracticeRecordingResultScreen @@ -40,7 +39,7 @@ internal fun PracticeRecordingScreen( val resources = LocalResources.current val snackbarHostState = LocalSnackbarHostState.current val onStartRecording = rememberRecordAudioPermissionControlClickHandler( - recordingState = uiState.recordingState, + recordingState = (uiState as? PracticeRecordingUiState.Ready)?.recordingState ?: AudioSessionState.Idle, onStartRecording = { viewModel.onIntent(PracticeRecordingUiIntent.StartRecording) }, onPermissionDenied = { viewModel.onIntent(PracticeRecordingUiIntent.RecordAudioPermissionDenied) }, onPermissionPermanentlyDenied = { @@ -92,14 +91,14 @@ private fun PracticeRecordingScreen( ) { BackHandler( onBack = { - if (uiState.analysisStatus == PracticeRecordingAnalysisStatus.Ready) { + if (uiState is PracticeRecordingUiState.Ready) { onBack() } }, ) - when (uiState.analysisStatus) { - PracticeRecordingAnalysisStatus.Ready -> PracticeRecordingReadyScreen( + when (uiState) { + is PracticeRecordingUiState.Ready -> PracticeRecordingReadyScreen( uiState = uiState, onStartRecording = onStartRecording, onStopRecording = onStopRecording, @@ -110,8 +109,8 @@ private fun PracticeRecordingScreen( modifier = modifier, ) - else -> PracticeRecordingResultScreen( - analysisStatus = uiState.analysisStatus, + is PracticeRecordingUiState.Analysis -> PracticeRecordingResultScreen( + uiState = uiState, onRetry = onRetryRecording, onComplete = navigateToHome, modifier = modifier, @@ -121,7 +120,7 @@ private fun PracticeRecordingScreen( @Composable private fun PracticeRecordingReadyScreen( - uiState: PracticeRecordingUiState, + uiState: PracticeRecordingUiState.Ready, onStartRecording: () -> Unit, onStopRecording: () -> Unit, onStartPlayback: () -> Unit, @@ -178,7 +177,7 @@ private val PracticeRecordingUiMessage.resId: Int @Composable private fun PracticeRecordingScreenIdlePreview() { PrezelTheme { - PracticeRecordingScreenPreviewContent(uiState = PracticeRecordingUiState()) + PracticeRecordingScreenPreviewContent(uiState = PracticeRecordingUiState.Ready()) } } @@ -187,7 +186,7 @@ private fun PracticeRecordingScreenIdlePreview() { private fun PracticeRecordingScreenRecordingPreview() { PrezelTheme { PracticeRecordingScreenPreviewContent( - uiState = PracticeRecordingUiState( + uiState = PracticeRecordingUiState.Ready( recordingState = AudioSessionState.Recording( elapsedSeconds = 12, ), @@ -201,7 +200,7 @@ private fun PracticeRecordingScreenRecordingPreview() { private fun PracticeRecordingScreenRecordedPreview() { PrezelTheme { PracticeRecordingScreenPreviewContent( - uiState = PracticeRecordingUiState( + uiState = PracticeRecordingUiState.Ready( recordingState = AudioSessionState.ReadyToPlay( source = AudioSource.RecordedFile(filePath = ""), durationSeconds = 32, @@ -216,7 +215,7 @@ private fun PracticeRecordingScreenRecordedPreview() { private fun PracticeRecordingScreenPlayingPreview() { PrezelTheme { PracticeRecordingScreenPreviewContent( - uiState = PracticeRecordingUiState( + uiState = PracticeRecordingUiState.Ready( recordingState = AudioSessionState.Playing( source = AudioSource.RecordedFile(filePath = ""), positionSeconds = 12, @@ -228,7 +227,7 @@ private fun PracticeRecordingScreenPlayingPreview() { } @Composable -private fun PracticeRecordingScreenPreviewContent(uiState: PracticeRecordingUiState) { +private fun PracticeRecordingScreenPreviewContent(uiState: PracticeRecordingUiState.Ready) { PracticeRecordingScreen( uiState = uiState.copy( practiceScript = "내가 그린 기린 그림은 잘 그린 기린 그림이고,\n네가 그린 기린 그림은 잘못 그린 기린 그림이다.", diff --git a/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/PracticeRecordingViewModel.kt b/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/PracticeRecordingViewModel.kt index 6546d0a0..ebc39cbd 100644 --- a/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/PracticeRecordingViewModel.kt +++ b/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/PracticeRecordingViewModel.kt @@ -13,7 +13,6 @@ import com.team.prezel.feature.practice.impl.contract.PracticeRecordingUiEffect import com.team.prezel.feature.practice.impl.contract.PracticeRecordingUiIntent import com.team.prezel.feature.practice.impl.contract.PracticeRecordingUiState import com.team.prezel.feature.practice.impl.model.PracticeRecordingAnalysisErrorType -import com.team.prezel.feature.practice.impl.model.PracticeRecordingAnalysisStatus import com.team.prezel.feature.practice.impl.model.PracticeRecordingUiMessage import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.launch @@ -24,7 +23,11 @@ internal class PracticeRecordingViewModel @Inject constructor( private val audioController: RecordingAudioController, private val fetchPracticeScriptUseCase: FetchPracticeScriptUseCase, private val analyzePracticeRecordingUseCase: AnalyzePracticeRecordingUseCase, -) : BaseViewModel(PracticeRecordingUiState()) { +) : BaseViewModel( + PracticeRecordingUiState.Ready(), + ) { + private var practiceScript: String = "" + init { collectAudioSessionState() collectAudioSessionEffect() @@ -39,7 +42,7 @@ internal class PracticeRecordingViewModel @Inject constructor( ) PracticeRecordingUiIntent.StartRecording -> { - if (currentState.practiceScript.isBlank()) { + if (practiceScript.isBlank()) { showMessage(PracticeRecordingUiMessage.FETCH_PRACTICE_SCRIPT_FAILED) return } @@ -58,17 +61,15 @@ internal class PracticeRecordingViewModel @Inject constructor( viewModelScope.launch { audioController.audioSessionState.collect { audioState -> updateState { - copy( - recordingState = audioState, - analysisStatus = if ( - audioState is AudioSessionState.Recording && - recordingState !is AudioSessionState.Recording - ) { - PracticeRecordingAnalysisStatus.Ready - } else { - analysisStatus - }, - ) + when { + audioState is AudioSessionState.Recording -> PracticeRecordingUiState.Ready( + practiceScript = this@PracticeRecordingViewModel.practiceScript, + recordingState = audioState, + ) + + this is PracticeRecordingUiState.Ready -> copy(recordingState = audioState) + else -> this + } } } } @@ -86,8 +87,9 @@ internal class PracticeRecordingViewModel @Inject constructor( viewModelScope.launch { fetchPracticeScriptUseCase() .onSuccess { script -> + practiceScript = script.content updateState { - copy(practiceScript = script.content) + (this as PracticeRecordingUiState.Ready).copy(practiceScript = script.content) } }.onFailure { showMessage(PracticeRecordingUiMessage.FETCH_PRACTICE_SCRIPT_FAILED) @@ -96,32 +98,30 @@ internal class PracticeRecordingViewModel @Inject constructor( } private fun startAnalysis() { - if (!currentState.analyzeEnabled) return - val filePath = currentState.recordingFilePath ?: return + val readyState = currentState as? PracticeRecordingUiState.Ready ?: return + if (!readyState.analyzeEnabled) return + val filePath = readyState.recordingFilePath ?: return + val referenceText = practiceScript audioController.stopPlayback() viewModelScope.launch { updateState { - copy(analysisStatus = PracticeRecordingAnalysisStatus.Loading) + PracticeRecordingUiState.Analysis.Loading } analyzePracticeRecordingUseCase( recordingFilePath = filePath, - referenceText = currentState.practiceScript, + referenceText = referenceText, ).onSuccess { result -> updateState { - copy( - analysisStatus = PracticeRecordingAnalysisStatus.Success( - result = result, - ), + PracticeRecordingUiState.Analysis.Success( + result = result, ) } }.onFailure { throwable -> updateState { - copy( - analysisStatus = PracticeRecordingAnalysisStatus.Error( - type = throwable.toPracticeRecordingAnalysisErrorType(), - ), + PracticeRecordingUiState.Analysis.Error( + type = throwable.toPracticeRecordingAnalysisErrorType(), ) } } @@ -131,7 +131,7 @@ internal class PracticeRecordingViewModel @Inject constructor( private fun resetPracticeRecording() { audioController.reset() updateState { - copy(analysisStatus = PracticeRecordingAnalysisStatus.Ready) + PracticeRecordingUiState.Ready(practiceScript = practiceScript) } } diff --git a/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/contract/PracticeRecordingUiState.kt b/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/contract/PracticeRecordingUiState.kt index adf00daa..0dc29943 100644 --- a/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/contract/PracticeRecordingUiState.kt +++ b/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/contract/PracticeRecordingUiState.kt @@ -2,41 +2,58 @@ package com.team.prezel.feature.practice.impl.contract import androidx.compose.runtime.Immutable import com.team.prezel.core.audio.AudioSessionState +import com.team.prezel.core.model.practice.PracticeRecordingAnalysisResult import com.team.prezel.core.ui.base.UiState -import com.team.prezel.feature.practice.impl.model.PracticeRecordingAnalysisStatus +import com.team.prezel.feature.practice.impl.model.PracticeRecordingAnalysisErrorType @Immutable -internal data class PracticeRecordingUiState( - val practiceScript: String = "", - val recordingState: AudioSessionState = AudioSessionState.Idle, - val analysisStatus: PracticeRecordingAnalysisStatus = PracticeRecordingAnalysisStatus.Ready, -) : UiState { - val currentSeconds: Int - get() = when (val state = recordingState) { - AudioSessionState.Idle -> 0 - is AudioSessionState.Recording -> state.elapsedSeconds - is AudioSessionState.ReadyToPlay -> state.positionSeconds - is AudioSessionState.Playing -> state.positionSeconds - } - - val totalSeconds: Int - get() = when (val state = recordingState) { - AudioSessionState.Idle, - is AudioSessionState.Recording, - -> 0 - - is AudioSessionState.ReadyToPlay -> state.durationSeconds - is AudioSessionState.Playing -> state.durationSeconds - } - - val recordingFilePath: String? - get() = when (val state = recordingState) { - is AudioSessionState.ReadyToPlay -> state.source.filePath - is AudioSessionState.Playing -> state.source.filePath - else -> null - } - - val analyzeEnabled: Boolean - get() = recordingState is AudioSessionState.ReadyToPlay && - analysisStatus !is PracticeRecordingAnalysisStatus.Loading +internal sealed interface PracticeRecordingUiState : UiState { + @Immutable + data class Ready( + val practiceScript: String = "", + val recordingState: AudioSessionState = AudioSessionState.Idle, + ) : PracticeRecordingUiState { + val currentSeconds: Int + get() = when (val state = recordingState) { + AudioSessionState.Idle -> 0 + is AudioSessionState.Recording -> state.elapsedSeconds + is AudioSessionState.ReadyToPlay -> state.positionSeconds + is AudioSessionState.Playing -> state.positionSeconds + } + + val totalSeconds: Int + get() = when (val state = recordingState) { + AudioSessionState.Idle, + is AudioSessionState.Recording, + -> 0 + + is AudioSessionState.ReadyToPlay -> state.durationSeconds + is AudioSessionState.Playing -> state.durationSeconds + } + + val recordingFilePath: String? + get() = when (val state = recordingState) { + is AudioSessionState.ReadyToPlay -> state.source.filePath + is AudioSessionState.Playing -> state.source.filePath + else -> null + } + + val analyzeEnabled: Boolean + get() = recordingState is AudioSessionState.ReadyToPlay + } + + sealed interface Analysis : PracticeRecordingUiState { + @Immutable + data object Loading : Analysis + + @Immutable + data class Success( + val result: PracticeRecordingAnalysisResult, + ) : Analysis + + @Immutable + data class Error( + val type: PracticeRecordingAnalysisErrorType, + ) : Analysis + } } diff --git a/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/model/PracticeRecordingAnalysisErrorType.kt b/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/model/PracticeRecordingAnalysisErrorType.kt new file mode 100644 index 00000000..ec2ee86b --- /dev/null +++ b/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/model/PracticeRecordingAnalysisErrorType.kt @@ -0,0 +1,6 @@ +package com.team.prezel.feature.practice.impl.model + +internal enum class PracticeRecordingAnalysisErrorType { + VOICE_RECOGNITION_FAILED, + ANALYSIS_FAILED, +} diff --git a/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/model/PracticeRecordingAnalysisStatus.kt b/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/model/PracticeRecordingAnalysisStatus.kt deleted file mode 100644 index 0ca228b9..00000000 --- a/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/model/PracticeRecordingAnalysisStatus.kt +++ /dev/null @@ -1,24 +0,0 @@ -package com.team.prezel.feature.practice.impl.model - -import androidx.compose.runtime.Immutable -import com.team.prezel.core.model.practice.PracticeRecordingAnalysisResult - -@Immutable -internal sealed interface PracticeRecordingAnalysisStatus { - data object Ready : PracticeRecordingAnalysisStatus - - data object Loading : PracticeRecordingAnalysisStatus - - data class Success( - val result: PracticeRecordingAnalysisResult, - ) : PracticeRecordingAnalysisStatus - - data class Error( - val type: PracticeRecordingAnalysisErrorType, - ) : PracticeRecordingAnalysisStatus -} - -internal enum class PracticeRecordingAnalysisErrorType { - VOICE_RECOGNITION_FAILED, - ANALYSIS_FAILED, -} diff --git a/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/result/PracticeRecordingResultScreen.kt b/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/result/PracticeRecordingResultScreen.kt index 89ee4d00..729cb930 100644 --- a/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/result/PracticeRecordingResultScreen.kt +++ b/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/result/PracticeRecordingResultScreen.kt @@ -2,34 +2,32 @@ package com.team.prezel.feature.practice.impl.result import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import com.team.prezel.feature.practice.impl.model.PracticeRecordingAnalysisStatus +import com.team.prezel.feature.practice.impl.contract.PracticeRecordingUiState import com.team.prezel.feature.practice.impl.result.component.PracticeRecordingAnalysisFailurePage import com.team.prezel.feature.practice.impl.result.component.PracticeRecordingAnalysisLoadingPage import com.team.prezel.feature.practice.impl.result.component.PracticeRecordingResultPage @Composable internal fun PracticeRecordingResultScreen( - analysisStatus: PracticeRecordingAnalysisStatus, + uiState: PracticeRecordingUiState.Analysis, onRetry: () -> Unit, onComplete: () -> Unit, modifier: Modifier = Modifier, ) { - when (analysisStatus) { - PracticeRecordingAnalysisStatus.Loading -> PracticeRecordingAnalysisLoadingPage(modifier = modifier) - is PracticeRecordingAnalysisStatus.Success -> PracticeRecordingResultPage( - pronunciationScore = analysisStatus.result.pronunciationScore, - speed = analysisStatus.result.speed, - overallEvaluation = analysisStatus.result.overallEvaluation, + when (uiState) { + is PracticeRecordingUiState.Analysis.Loading -> PracticeRecordingAnalysisLoadingPage(modifier = modifier) + is PracticeRecordingUiState.Analysis.Success -> PracticeRecordingResultPage( + pronunciationScore = uiState.result.pronunciationScore, + speed = uiState.result.speed, + overallEvaluation = uiState.result.overallEvaluation, onComplete = onComplete, modifier = modifier, ) - is PracticeRecordingAnalysisStatus.Error -> PracticeRecordingAnalysisFailurePage( - errorType = analysisStatus.type, + is PracticeRecordingUiState.Analysis.Error -> PracticeRecordingAnalysisFailurePage( + errorType = uiState.type, onRetry = onRetry, modifier = modifier, ) - - PracticeRecordingAnalysisStatus.Ready -> Unit } } From 594c28a2a3ba3d1fc67bb06445e2f91f08d361ef Mon Sep 17 00:00:00 2001 From: Ham BeomJoon Date: Thu, 14 May 2026 02:58:54 +0900 Subject: [PATCH 23/27] =?UTF-8?q?refactor:=20`MediaRecordingAudioControlle?= =?UTF-8?q?r`=20=EB=A6=AC=ED=8C=A9=ED=84=B0=EB=A7=81=20=EB=B0=8F=20?= =?UTF-8?q?=EC=98=A4=EB=94=94=EC=98=A4=20=EC=84=B8=EC=85=98=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * **feat: `MediaPlayerSession` 및 `MediaRecorderSession` 클래스 추가** * 기존 `MediaRecordingAudioController`에 집중되어 있던 `MediaPlayer` 및 `MediaRecorder` 제어 로직을 별도의 세션 클래스로 분리하여 캡슐화했습니다. * `MediaPlayerSession`: 오디오 재생, 재생 위치 조회, 리소스 해제 로직 담당 * `MediaRecorderSession`: 임시 파일 생성, 녹음 시작/중료, 리소스 정리 로직 담당 * **refactor: `MediaRecordingAudioController` 구조 개선** * 직접 관리하던 `MediaPlayer`, `MediaRecorder` 인스턴스를 새로 추가된 세션 클래스(`playerSession`, `recorderSession`)를 사용하도록 변경했습니다. * 녹음 및 재생 시작/정지 시의 상태 관리 로직을 세션 클래스의 결과(`Result`)를 처리하는 방식으로 개선하여 코드 가독성을 높였습니다. * 불필요한 private 메서드와 상수를 제거하고 세션 클래스로 책임을 전파했습니다. * **refactor: 오디오 관련 유틸리티 및 모델 정리** * 밀리초 단위를 초 단위로 변환하는 `toSeconds` 확장 함수를 `MediaPlayerSession.kt`로 이동했습니다. * 녹음된 오디오 정보를 전달하기 위한 `RecordedAudio` 데이터 클래스를 추가했습니다. --- .../prezel/core/audio/MediaPlayerSession.kt | 55 ++++++ .../prezel/core/audio/MediaRecorderSession.kt | 79 ++++++++ .../audio/MediaRecordingAudioController.kt | 178 ++++-------------- 3 files changed, 172 insertions(+), 140 deletions(-) create mode 100644 Prezel/core/audio/src/main/java/com/team/prezel/core/audio/MediaPlayerSession.kt create mode 100644 Prezel/core/audio/src/main/java/com/team/prezel/core/audio/MediaRecorderSession.kt diff --git a/Prezel/core/audio/src/main/java/com/team/prezel/core/audio/MediaPlayerSession.kt b/Prezel/core/audio/src/main/java/com/team/prezel/core/audio/MediaPlayerSession.kt new file mode 100644 index 00000000..17b8abc6 --- /dev/null +++ b/Prezel/core/audio/src/main/java/com/team/prezel/core/audio/MediaPlayerSession.kt @@ -0,0 +1,55 @@ +package com.team.prezel.core.audio + +import android.media.MediaPlayer + +internal class MediaPlayerSession { + private var player: MediaPlayer? = null + + fun start( + source: AudioSource, + startPositionSeconds: Int, + onCompleted: () -> Unit, + ): Result = + runCatching { + release() + + var pendingPlayer: MediaPlayer? = null + val newPlayer = runCatching { + MediaPlayer().also { pendingPlayer = it }.apply { + setDataSource(source.filePath) + prepare() + seekToStartPosition(startPositionSeconds) + setOnCompletionListener { onCompleted() } + start() + } + }.getOrElse { throwable -> + pendingPlayer?.release() + throw throwable + } + + player = newPlayer + newPlayer.duration + }.onFailure { + release() + } + + fun currentPositionSeconds(): Int = player?.currentPosition?.toSeconds() ?: 0 + + fun release() { + player?.runCatching { stop() } + player?.release() + player = null + } + + private fun MediaPlayer.seekToStartPosition(startPositionSeconds: Int) { + if (startPositionSeconds > 0) { + seekTo(startPositionSeconds * MILLIS_PER_SECOND) + } + } + + private companion object { + const val MILLIS_PER_SECOND = 1_000 + } +} + +internal fun Int.toSeconds(): Int = this / 1_000 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 new file mode 100644 index 00000000..c86b0817 --- /dev/null +++ b/Prezel/core/audio/src/main/java/com/team/prezel/core/audio/MediaRecorderSession.kt @@ -0,0 +1,79 @@ +package com.team.prezel.core.audio + +import android.content.Context +import android.media.MediaRecorder +import android.os.Build +import java.io.File +import kotlin.math.max + +internal class MediaRecorderSession( + private val context: Context, +) { + private var recorder: MediaRecorder? = null + private var currentAudioFile: File? = null + + fun start(): Result = + runCatching { + reset() + + val file = File.createTempFile("recording_", ".m4a", context.cacheDir) + var pendingRecorder: MediaRecorder? = null + val newRecorder = runCatching { + createMediaRecorder(context = context).also { pendingRecorder = it }.apply { + setAudioSource(MediaRecorder.AudioSource.MIC) + setOutputFormat(MediaRecorder.OutputFormat.MPEG_4) + setAudioEncoder(MediaRecorder.AudioEncoder.AAC) + setOutputFile(file.absolutePath) + prepare() + start() + } + }.getOrElse { throwable -> + pendingRecorder?.release() + file.delete() + throw throwable + } + + recorder = newRecorder + currentAudioFile = file + }.onFailure { + reset() + } + + fun stop(elapsedSeconds: Int): Result = + runCatching { + val file = currentAudioFile!! + recorder!!.stop() + RecordedAudio( + source = AudioSource.RecordedFile(filePath = file.absolutePath), + durationSeconds = max(elapsedSeconds, 0), + ) + }.onSuccess { + releaseRecorder() + }.onFailure { + reset() + } + + fun reset() { + releaseRecorder() + currentAudioFile?.delete() + currentAudioFile = null + } + + private fun releaseRecorder() { + recorder?.release() + recorder = null + } +} + +internal data class RecordedAudio( + val source: AudioSource.RecordedFile, + val durationSeconds: Int, +) + +@Suppress("DEPRECATION") +private fun createMediaRecorder(context: Context): MediaRecorder = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + MediaRecorder(context) + } else { + MediaRecorder() + } 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 d59dcd46..8776f1a9 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,9 +1,6 @@ package com.team.prezel.core.audio import android.content.Context -import android.media.MediaPlayer -import android.media.MediaRecorder -import android.os.Build import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -19,9 +16,7 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import java.io.File import javax.inject.Inject -import kotlin.math.max internal class MediaRecordingAudioController @Inject constructor( @param:ApplicationContext private val context: Context, @@ -34,44 +29,19 @@ internal class MediaRecordingAudioController @Inject constructor( private val _audioSessionEffect = Channel(capacity = Channel.BUFFERED) override val audioSessionEffect: Flow = _audioSessionEffect.receiveAsFlow() - private var recorder: MediaRecorder? = null - private var player: MediaPlayer? = null - private var currentAudioFile: File? = null + private val recorderSession = MediaRecorderSession(context = context) + private val playerSession = MediaPlayerSession() private var recordingTimerJob: Job? = null private var playbackTimerJob: Job? = null override fun startRecording() { runCatching { stopPlayback() - releaseRecorder() - deleteCurrentAudioFile() - - val file = File.createTempFile("recording_", ".m4a", context.cacheDir) - var pendingRecorder: MediaRecorder? = null - val newRecorder = runCatching { - val recorder = createMediaRecorder(context = context) - pendingRecorder = recorder - recorder.apply { - setAudioSource(MediaRecorder.AudioSource.MIC) - setOutputFormat(MediaRecorder.OutputFormat.MPEG_4) - setAudioEncoder(MediaRecorder.AudioEncoder.AAC) - setOutputFile(file.absolutePath) - prepare() - start() - } - }.getOrElse { throwable -> - pendingRecorder?.release() - file.delete() - throw throwable - } - - recorder = newRecorder - currentAudioFile = file + recorderSession.start().getOrThrow() _audioSessionState.value = AudioSessionState.Recording(elapsedSeconds = 0) startRecordingTimer() }.onFailure { - releaseRecorder() - deleteCurrentAudioFile() + recorderSession.reset() _audioSessionState.value = AudioSessionState.Idle emitEffect(AudioSessionEffect.RecordingStartFailed) } @@ -82,24 +52,19 @@ internal class MediaRecordingAudioController @Inject constructor( is AudioSessionState.Recording -> state.elapsedSeconds else -> return } - val file = currentAudioFile ?: return emitEffect(AudioSessionEffect.RecordingStopFailed) recordingTimerJob?.cancel() - runCatching { - recorder?.stop() ?: error("Recording is not active.") - max(elapsedSeconds, 0) - }.onSuccess { durationSeconds -> - releaseRecorder() - _audioSessionState.value = AudioSessionState.ReadyToPlay( - source = AudioSource.RecordedFile(filePath = file.absolutePath), - durationSeconds = durationSeconds, - ) - }.onFailure { - releaseRecorder() - deleteCurrentAudioFile() - _audioSessionState.value = AudioSessionState.Idle - emitEffect(AudioSessionEffect.RecordingStopFailed) - } + recorderSession + .stop(elapsedSeconds = elapsedSeconds) + .onSuccess { recordedAudio -> + _audioSessionState.value = AudioSessionState.ReadyToPlay( + source = recordedAudio.source, + durationSeconds = recordedAudio.durationSeconds, + ) + }.onFailure { + _audioSessionState.value = AudioSessionState.Idle + emitEffect(AudioSessionEffect.RecordingStopFailed) + } } override fun startPlayback() { @@ -118,7 +83,9 @@ internal class MediaRecordingAudioController @Inject constructor( val readyState = when (val state = audioSessionState.value) { is AudioSessionState.Playing -> AudioSessionState.ReadyToPlay( source = state.source, - positionSeconds = playbackPositionSeconds().coerceAtLeast(state.positionSeconds), + positionSeconds = playerSession + .currentPositionSeconds() + .coerceAtLeast(state.positionSeconds), durationSeconds = state.durationSeconds, ) @@ -134,9 +101,8 @@ internal class MediaRecordingAudioController @Inject constructor( override fun reset() { recordingTimerJob?.cancel() playbackTimerJob?.cancel() - releaseRecorder() + recorderSession.reset() releasePlayer() - deleteCurrentAudioFile() _audioSessionState.value = AudioSessionState.Idle } @@ -150,60 +116,29 @@ internal class MediaRecordingAudioController @Inject constructor( durationSeconds: Int, startPositionSeconds: Int, ) { - runCatching { - preparePlayback( - source = source, - durationSeconds = durationSeconds, - startPositionSeconds = startPositionSeconds, - ) - }.onSuccess { newPlayer -> - player = newPlayer - updatePlayingState( + playerSession + .start( source = source, - durationSeconds = durationSeconds, startPositionSeconds = startPositionSeconds, - playerDurationMillis = newPlayer.duration, - ) - startPlaybackTimer() - }.onFailure { - handlePlaybackStartFailure( - source = source, - durationSeconds = durationSeconds, - ) - } - } - - private fun preparePlayback( - source: AudioSource, - durationSeconds: Int, - startPositionSeconds: Int, - ): MediaPlayer { - releasePlayer() - - var pendingPlayer: MediaPlayer? = null - return runCatching { - MediaPlayer().also { pendingPlayer = it }.apply { - setDataSource(source.filePath) - prepare() - seekToStartPosition(startPositionSeconds) - setOnCompletionListener { + onCompleted = { handlePlaybackCompleted( source = source, durationSeconds = durationSeconds, ) - } - start() + }, + ).onSuccess { playerDurationMillis -> + _audioSessionState.value = AudioSessionState.Playing( + source = source, + positionSeconds = startPositionSeconds, + durationSeconds = durationSeconds.coerceAtLeast(playerDurationMillis.toSeconds()), + ) + startPlaybackTimer() + }.onFailure { + handlePlaybackStartFailure( + source = source, + durationSeconds = durationSeconds, + ) } - }.getOrElse { throwable -> - pendingPlayer?.release() - throw throwable - } - } - - private fun MediaPlayer.seekToStartPosition(startPositionSeconds: Int) { - if (startPositionSeconds > 0) { - seekTo(startPositionSeconds * MILLIS_PER_SECOND) - } } private fun handlePlaybackCompleted( @@ -218,19 +153,6 @@ internal class MediaRecordingAudioController @Inject constructor( ) } - private fun updatePlayingState( - source: AudioSource, - durationSeconds: Int, - startPositionSeconds: Int, - playerDurationMillis: Int, - ) { - _audioSessionState.value = AudioSessionState.Playing( - source = source, - positionSeconds = startPositionSeconds, - durationSeconds = durationSeconds.coerceAtLeast(playerDurationMillis.toSeconds()), - ) - } - private fun handlePlaybackStartFailure( source: AudioSource, durationSeconds: Int, @@ -263,9 +185,10 @@ internal class MediaRecordingAudioController @Inject constructor( delay(PLAYBACK_TIMER_DELAY_MILLIS) _audioSessionState.update { state -> if (state !is AudioSessionState.Playing) return@update state + AudioSessionState.Playing( source = state.source, - positionSeconds = playbackPositionSeconds(), + positionSeconds = playerSession.currentPositionSeconds(), durationSeconds = state.durationSeconds, ) } @@ -273,23 +196,9 @@ internal class MediaRecordingAudioController @Inject constructor( } } - private fun releaseRecorder() { - recorder?.release() - recorder = null - } - private fun releasePlayer() { playbackTimerJob?.cancel() - player?.runCatching { stop() } - player?.release() - player = null - } - - private fun playbackPositionSeconds(): Int = player?.currentPosition?.toSeconds() ?: 0 - - private fun deleteCurrentAudioFile() { - currentAudioFile?.delete() - currentAudioFile = null + playerSession.release() } private fun emitEffect(effect: AudioSessionEffect) { @@ -297,18 +206,7 @@ internal class MediaRecordingAudioController @Inject constructor( } private companion object { - const val MILLIS_PER_SECOND = 1_000 const val RECORDING_TIMER_DELAY_MILLIS = 1_000L const val PLAYBACK_TIMER_DELAY_MILLIS = 250L } } - -@Suppress("DEPRECATION") -private fun createMediaRecorder(context: Context): MediaRecorder = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - MediaRecorder(context) - } else { - MediaRecorder() - } - -private fun Int.toSeconds(): Int = this / 1_000 From ac9949de162dcabc5704d89ad701a433353b2d5a Mon Sep 17 00:00:00 2001 From: Ham BeomJoon Date: Thu, 14 May 2026 12:01:17 +0900 Subject: [PATCH 24/27] =?UTF-8?q?refactor:=20PracticeRecordingUiIntent=20?= =?UTF-8?q?=EC=A0=95=EB=A6=AC=20=EB=B0=8F=20=EC=8A=A4=ED=81=AC=EB=A6=BD?= =?UTF-8?q?=ED=8A=B8=20=EB=A1=9C=EB=93=9C=20=EB=A1=9C=EC=A7=81=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * **refactor: `PracticeRecordingUiIntent` 및 ViewModel 로직 단순화** * 불필요한 Intent(`LoadPracticeScript`, `RecordAudioPermissionDenied`, `RecordAudioPermissionPermanentlyDenied`)를 제거했습니다. * `AnalyzeClicked`를 `AnalyzeRecording`으로, `RetryRecordingClicked`를 `ResetRecording`으로 의도를 명확하게 변경했습니다. * `PracticeRecordingViewModel` 초기화 시(`init`) `fetchPracticeScript()`를 직접 호출하도록 수정하여 `LaunchedEffect` 의존성을 줄였습니다. * **refactor: Screen 레이어의 권한 거부 처리 방식 변경** * ViewModel을 거쳐서 처리하던 권한 거부 메시지 표시 로직을 `PracticeRecordingScreen`에서 직접 `snackbarHostState`를 사용하도록 변경했습니다. * 중복 스낵바 방지를 위해 새로운 메시지 표시 전 기존 스낵바를 dismiss하는 로직을 추가했습니다. * **style: 코드 포맷팅 수정** * `PracticeRecordingViewModel` 생성자 및 Intent 처리부의 코드 포맷을 정리했습니다. --- .../practice/impl/PracticeRecordingScreen.kt | 21 ++++++++++++------- .../impl/PracticeRecordingViewModel.kt | 15 ++++--------- .../contract/PracticeRecordingUiIntent.kt | 10 ++------- 3 files changed, 19 insertions(+), 27 deletions(-) diff --git a/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/PracticeRecordingScreen.kt b/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/PracticeRecordingScreen.kt index 77a8bd73..d3613035 100644 --- a/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/PracticeRecordingScreen.kt +++ b/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/PracticeRecordingScreen.kt @@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.res.stringResource @@ -27,6 +28,7 @@ import com.team.prezel.feature.practice.impl.contract.PracticeRecordingUiIntent import com.team.prezel.feature.practice.impl.contract.PracticeRecordingUiState import com.team.prezel.feature.practice.impl.model.PracticeRecordingUiMessage import com.team.prezel.feature.practice.impl.result.PracticeRecordingResultScreen +import kotlinx.coroutines.launch @Composable internal fun PracticeRecordingScreen( @@ -38,19 +40,22 @@ internal fun PracticeRecordingScreen( val uiState by viewModel.uiState.collectAsStateWithLifecycle() val resources = LocalResources.current val snackbarHostState = LocalSnackbarHostState.current + val coroutineScope = rememberCoroutineScope() + val showMessage: (PracticeRecordingUiMessage) -> Unit = { message -> + coroutineScope.launch { + snackbarHostState.currentSnackbarData?.dismiss() + snackbarHostState.showPrezelSnackbar(message = resources.getString(message.resId)) + } + } val onStartRecording = rememberRecordAudioPermissionControlClickHandler( recordingState = (uiState as? PracticeRecordingUiState.Ready)?.recordingState ?: AudioSessionState.Idle, onStartRecording = { viewModel.onIntent(PracticeRecordingUiIntent.StartRecording) }, - onPermissionDenied = { viewModel.onIntent(PracticeRecordingUiIntent.RecordAudioPermissionDenied) }, + onPermissionDenied = { showMessage(PracticeRecordingUiMessage.RECORD_AUDIO_PERMISSION_DENIED) }, onPermissionPermanentlyDenied = { - viewModel.onIntent(PracticeRecordingUiIntent.RecordAudioPermissionPermanentlyDenied) + showMessage(PracticeRecordingUiMessage.RECORD_AUDIO_PERMISSION_PERMANENTLY_DENIED) }, ) - LaunchedEffect(Unit) { - viewModel.onIntent(PracticeRecordingUiIntent.LoadPracticeScript) - } - LaunchedEffect(Unit) { viewModel.uiEffect.collect { effect -> when (effect) { @@ -68,8 +73,8 @@ internal fun PracticeRecordingScreen( onStopRecording = { viewModel.onIntent(PracticeRecordingUiIntent.StopRecording) }, onStartPlayback = { viewModel.onIntent(PracticeRecordingUiIntent.StartPlayback) }, onStopPlayback = { viewModel.onIntent(PracticeRecordingUiIntent.StopPlayback) }, - onClickAnalyze = { viewModel.onIntent(PracticeRecordingUiIntent.AnalyzeClicked) }, - onRetryRecording = { viewModel.onIntent(PracticeRecordingUiIntent.RetryRecordingClicked) }, + onClickAnalyze = { viewModel.onIntent(PracticeRecordingUiIntent.AnalyzeRecording) }, + onRetryRecording = { viewModel.onIntent(PracticeRecordingUiIntent.ResetRecording) }, onBack = onBack, navigateToHome = navigateToHome, modifier = modifier, diff --git a/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/PracticeRecordingViewModel.kt b/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/PracticeRecordingViewModel.kt index ebc39cbd..00c8d6cf 100644 --- a/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/PracticeRecordingViewModel.kt +++ b/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/PracticeRecordingViewModel.kt @@ -23,24 +23,17 @@ internal class PracticeRecordingViewModel @Inject constructor( private val audioController: RecordingAudioController, private val fetchPracticeScriptUseCase: FetchPracticeScriptUseCase, private val analyzePracticeRecordingUseCase: AnalyzePracticeRecordingUseCase, -) : BaseViewModel( - PracticeRecordingUiState.Ready(), - ) { +) : BaseViewModel(PracticeRecordingUiState.Ready()) { private var practiceScript: String = "" init { collectAudioSessionState() collectAudioSessionEffect() + fetchPracticeScript() } override fun onIntent(intent: PracticeRecordingUiIntent) { when (intent) { - PracticeRecordingUiIntent.LoadPracticeScript -> fetchPracticeScript() - PracticeRecordingUiIntent.RecordAudioPermissionDenied -> showMessage(PracticeRecordingUiMessage.RECORD_AUDIO_PERMISSION_DENIED) - PracticeRecordingUiIntent.RecordAudioPermissionPermanentlyDenied -> showMessage( - PracticeRecordingUiMessage.RECORD_AUDIO_PERMISSION_PERMANENTLY_DENIED, - ) - PracticeRecordingUiIntent.StartRecording -> { if (practiceScript.isBlank()) { showMessage(PracticeRecordingUiMessage.FETCH_PRACTICE_SCRIPT_FAILED) @@ -52,8 +45,8 @@ internal class PracticeRecordingViewModel @Inject constructor( PracticeRecordingUiIntent.StopRecording -> audioController.stopRecording() PracticeRecordingUiIntent.StartPlayback -> audioController.startPlayback() PracticeRecordingUiIntent.StopPlayback -> audioController.stopPlayback() - PracticeRecordingUiIntent.AnalyzeClicked -> startAnalysis() - PracticeRecordingUiIntent.RetryRecordingClicked -> resetPracticeRecording() + PracticeRecordingUiIntent.AnalyzeRecording -> startAnalysis() + PracticeRecordingUiIntent.ResetRecording -> resetPracticeRecording() } } diff --git a/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/contract/PracticeRecordingUiIntent.kt b/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/contract/PracticeRecordingUiIntent.kt index 5ecfc23f..87cc3409 100644 --- a/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/contract/PracticeRecordingUiIntent.kt +++ b/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/contract/PracticeRecordingUiIntent.kt @@ -3,12 +3,6 @@ package com.team.prezel.feature.practice.impl.contract import com.team.prezel.core.ui.base.UiIntent internal sealed interface PracticeRecordingUiIntent : UiIntent { - data object LoadPracticeScript : PracticeRecordingUiIntent - - data object RecordAudioPermissionDenied : PracticeRecordingUiIntent - - data object RecordAudioPermissionPermanentlyDenied : PracticeRecordingUiIntent - data object StartRecording : PracticeRecordingUiIntent data object StopRecording : PracticeRecordingUiIntent @@ -17,7 +11,7 @@ internal sealed interface PracticeRecordingUiIntent : UiIntent { data object StopPlayback : PracticeRecordingUiIntent - data object AnalyzeClicked : PracticeRecordingUiIntent + data object AnalyzeRecording : PracticeRecordingUiIntent - data object RetryRecordingClicked : PracticeRecordingUiIntent + data object ResetRecording : PracticeRecordingUiIntent } From ee8b7aaedf050f548e15690c184d954d94aa4f9c Mon Sep 17 00:00:00 2001 From: moondev03 Date: Thu, 14 May 2026 22:35:30 +0900 Subject: [PATCH 25/27] =?UTF-8?q?build:=20app=20=EB=AA=A8=EB=93=88=20?= =?UTF-8?q?=EB=82=B4=20=EB=B6=88=ED=95=84=EC=9A=94=ED=95=9C=20feature=20AP?= =?UTF-8?q?I=20=EC=9D=98=EC=A1=B4=EC=84=B1=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * **build: 불필요한 feature API 의존성 삭제 및 구성 정리** * `app` 모듈의 `build.gradle.kts`에서 직접적인 참조가 불필요한 `featureTermsApi`, `featurePracticeApi`, `featureSettingApi` 의존성을 제거했습니다. * `featureTermsImpl`, `featurePracticeImpl`, `featureSettingImpl`, `featureProfileImpl` 등 구현(Impl) 모듈들에 대한 의존성 선언 위치를 하단으로 모아 정리했습니다. --- Prezel/app/build.gradle.kts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/Prezel/app/build.gradle.kts b/Prezel/app/build.gradle.kts index 1f12fe8d..5cd625c3 100644 --- a/Prezel/app/build.gradle.kts +++ b/Prezel/app/build.gradle.kts @@ -42,17 +42,15 @@ dependencies { implementation(projects.featureSplashImpl) implementation(projects.featureLoginApi) implementation(projects.featureLoginImpl) - implementation(projects.featureTermsApi) - implementation(projects.featureTermsImpl) implementation(projects.featureHomeApi) implementation(projects.featureHomeImpl) - implementation(projects.featurePracticeApi) - implementation(projects.featurePracticeImpl) implementation(projects.featureHistoryApi) implementation(projects.featureHistoryImpl) implementation(projects.featureMyApi) implementation(projects.featureMyImpl) - implementation(projects.featureSettingApi) + + implementation(projects.featureTermsImpl) + implementation(projects.featurePracticeImpl) implementation(projects.featureSettingImpl) implementation(projects.featureProfileImpl) From 6e176e075d6c5bb3baa47c9988280d045199c8ce Mon Sep 17 00:00:00 2001 From: Ham BeomJoon Date: Fri, 15 May 2026 01:33:08 +0900 Subject: [PATCH 26/27] =?UTF-8?q?feat:=20=EC=97=B0=EC=8A=B5=20=EB=85=B9?= =?UTF-8?q?=EC=9D=8C=20=EB=B6=84=EC=84=9D=20=EA=B8=B0=EB=8A=A5=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC=20=EB=B0=8F=20=EC=95=84=ED=82=A4=ED=85=8D=EC=B2=98=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * **feat: 연습 녹음 분석(Analysis) 화면 및 로직 분리** * 기존 `PracticeRecordingScreen`에 포함되어 있던 분석 로직을 별도의 `PracticeRecordingAnalysisScreen` 및 `PracticeRecordingAnalysisViewModel`로 분리했습니다. * 분석 상태 관리(Loading, Success, Error)를 위한 전용 MVI 컨트랙트(`UiState`, `UiIntent`, `UiEffect`)를 추가했습니다. * 관련 컴포넌트(`LoadingPage`, `FailurePage`, `ResultPage`)를 `analysis` 패키지로 이동 및 재구성했습니다. * **refactor: 연습 녹음(Recording) 화면 로직 단순화** * `PracticeRecordingUiState`를 Sealed Interface에서 단일 Data Class로 변경하여 상태 관리를 간소화했습니다. * 녹음 제어 로직을 `ClickRecordingControl` 인텐트로 통합하고, 현재 `AudioSessionState`에 따라 적절한 동작(녹음 시작/중지, 재생 시작/중지)을 수행하도록 개선했습니다. * 분석 관련 UseCase 및 에러 처리 로직을 삭제했습니다. * **refactor: 녹음 컨트롤 컴포넌트 개선** * `PracticeRecordingControl`에서 다중 액션 리스트 대신 단일 액션 버튼을 노출하도록 변경했습니다. * `AudioSessionState`에 따른 아이콘 및 컬러 매핑 로직을 `PracticeRecordingControlAction`으로 통합했습니다. * **build: 네비게이션 구조 변경** * `PracticeNavKey`를 `Recording`과 `Analysis`를 포함하는 Sealed Interface로 확장했습니다. * 녹음 완료 시 분석 화면으로 이동할 수 있도록 네비게이션 경로를 추가했습니다. * `PracticeEntryBuilder`에 `PracticeNavKey.Analysis`에 대한 엔트리를 등록했습니다. * **etc: 권한 처리 로직 개선** * `rememberRecordAudioPermissionControlClickHandler`가 녹음 중이거나 재생 중일 때도 클릭 이벤트를 처리할 수 있도록 수정했습니다. --- .../feature/home/impl/main/HomeScreen.kt | 2 +- .../feature/practice/api/PracticeNavKey.kt | 11 +- .../practice/impl/PracticeRecordingScreen.kt | 89 ++++--------- .../impl/PracticeRecordingViewModel.kt | 87 ++----------- .../practice/impl/RecordAudioPermission.kt | 8 +- .../PracticeRecordingAnalysisScreen.kt | 66 ++++++++++ .../PracticeRecordingAnalysisViewModel.kt | 77 +++++++++++ .../PracticeRecordingAnalysisFailurePage.kt | 2 +- .../PracticeRecordingAnalysisLoadingPage.kt | 2 +- .../component/PracticeRecordingResultPage.kt | 2 +- .../PracticeRecordingAnalysisUiEffect.kt | 5 + .../PracticeRecordingAnalysisUiIntent.kt | 10 ++ .../PracticeRecordingAnalysisUiState.kt | 22 ++++ .../component/PracticeRecordingContent.kt | 20 +-- .../component/PracticeRecordingControl.kt | 120 ++++++------------ .../contract/PracticeRecordingUiIntent.kt | 12 +- .../impl/contract/PracticeRecordingUiState.kt | 82 +++++------- .../impl/navigation/PracticeEntryBuilder.kt | 23 +++- .../result/PracticeRecordingResultScreen.kt | 33 ----- 19 files changed, 334 insertions(+), 339 deletions(-) create mode 100644 Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/analysis/PracticeRecordingAnalysisScreen.kt create mode 100644 Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/analysis/PracticeRecordingAnalysisViewModel.kt rename Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/{result => analysis}/component/PracticeRecordingAnalysisFailurePage.kt (97%) rename Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/{result => analysis}/component/PracticeRecordingAnalysisLoadingPage.kt (95%) rename Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/{result => analysis}/component/PracticeRecordingResultPage.kt (99%) create mode 100644 Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/analysis/contract/PracticeRecordingAnalysisUiEffect.kt create mode 100644 Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/analysis/contract/PracticeRecordingAnalysisUiIntent.kt create mode 100644 Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/analysis/contract/PracticeRecordingAnalysisUiState.kt delete mode 100644 Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/result/PracticeRecordingResultScreen.kt diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/HomeScreen.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/HomeScreen.kt index 0371e337..f7231ebe 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/HomeScreen.kt +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/HomeScreen.kt @@ -74,7 +74,7 @@ internal fun HomeScreen( uiState = uiState, pagerState = pagerState, onClickAddPresentation = { }, - onClickPracticeRecording = { navigator.navigate(PracticeNavKey) }, + onClickPracticeRecording = { navigator.navigate(PracticeNavKey.Recording) }, onClickAnalyzePresentation = { }, onClickWriteFeedback = { }, modifier = modifier, diff --git a/Prezel/feature/practice/api/src/main/java/com/team/prezel/feature/practice/api/PracticeNavKey.kt b/Prezel/feature/practice/api/src/main/java/com/team/prezel/feature/practice/api/PracticeNavKey.kt index e9de678b..a90315b5 100644 --- a/Prezel/feature/practice/api/src/main/java/com/team/prezel/feature/practice/api/PracticeNavKey.kt +++ b/Prezel/feature/practice/api/src/main/java/com/team/prezel/feature/practice/api/PracticeNavKey.kt @@ -4,4 +4,13 @@ import androidx.navigation3.runtime.NavKey import kotlinx.serialization.Serializable @Serializable -data object PracticeNavKey : NavKey +sealed interface PracticeNavKey : NavKey { + @Serializable + data object Recording : PracticeNavKey + + @Serializable + data class Analysis( + val recordingFilePath: String, + val referenceText: String, + ) : PracticeNavKey +} diff --git a/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/PracticeRecordingScreen.kt b/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/PracticeRecordingScreen.kt index d3613035..58f598a6 100644 --- a/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/PracticeRecordingScreen.kt +++ b/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/PracticeRecordingScreen.kt @@ -27,13 +27,12 @@ import com.team.prezel.feature.practice.impl.contract.PracticeRecordingUiEffect import com.team.prezel.feature.practice.impl.contract.PracticeRecordingUiIntent import com.team.prezel.feature.practice.impl.contract.PracticeRecordingUiState import com.team.prezel.feature.practice.impl.model.PracticeRecordingUiMessage -import com.team.prezel.feature.practice.impl.result.PracticeRecordingResultScreen import kotlinx.coroutines.launch @Composable internal fun PracticeRecordingScreen( onBack: () -> Unit, - navigateToHome: () -> Unit, + navigateToAnalysis: (recordingFilePath: String, referenceText: String) -> Unit, modifier: Modifier = Modifier, viewModel: PracticeRecordingViewModel = hiltViewModel(), ) { @@ -47,9 +46,9 @@ internal fun PracticeRecordingScreen( snackbarHostState.showPrezelSnackbar(message = resources.getString(message.resId)) } } - val onStartRecording = rememberRecordAudioPermissionControlClickHandler( - recordingState = (uiState as? PracticeRecordingUiState.Ready)?.recordingState ?: AudioSessionState.Idle, - onStartRecording = { viewModel.onIntent(PracticeRecordingUiIntent.StartRecording) }, + val onClickRecordingControl = rememberRecordAudioPermissionControlClickHandler( + recordingState = uiState.recordingState, + onClickRecordingControl = { viewModel.onIntent(PracticeRecordingUiIntent.ClickRecordingControl) }, onPermissionDenied = { showMessage(PracticeRecordingUiMessage.RECORD_AUDIO_PERMISSION_DENIED) }, onPermissionPermanentlyDenied = { showMessage(PracticeRecordingUiMessage.RECORD_AUDIO_PERMISSION_PERMANENTLY_DENIED) @@ -69,14 +68,12 @@ internal fun PracticeRecordingScreen( PracticeRecordingScreen( uiState = uiState, - onStartRecording = onStartRecording, - onStopRecording = { viewModel.onIntent(PracticeRecordingUiIntent.StopRecording) }, - onStartPlayback = { viewModel.onIntent(PracticeRecordingUiIntent.StartPlayback) }, - onStopPlayback = { viewModel.onIntent(PracticeRecordingUiIntent.StopPlayback) }, - onClickAnalyze = { viewModel.onIntent(PracticeRecordingUiIntent.AnalyzeRecording) }, - onRetryRecording = { viewModel.onIntent(PracticeRecordingUiIntent.ResetRecording) }, + onClickRecordingControl = onClickRecordingControl, + onClickAnalyze = { + val recordingFilePath = uiState.recordingFilePath ?: return@PracticeRecordingScreen + navigateToAnalysis(recordingFilePath, uiState.practiceScript) + }, onBack = onBack, - navigateToHome = navigateToHome, modifier = modifier, ) } @@ -84,52 +81,28 @@ internal fun PracticeRecordingScreen( @Composable private fun PracticeRecordingScreen( uiState: PracticeRecordingUiState, - onStartRecording: () -> Unit, - onStopRecording: () -> Unit, - onStartPlayback: () -> Unit, - onStopPlayback: () -> Unit, + onClickRecordingControl: () -> Unit, onClickAnalyze: () -> Unit, - onRetryRecording: () -> Unit, onBack: () -> Unit, - navigateToHome: () -> Unit, modifier: Modifier = Modifier, ) { BackHandler( - onBack = { - if (uiState is PracticeRecordingUiState.Ready) { - onBack() - } - }, + onBack = onBack, ) - when (uiState) { - is PracticeRecordingUiState.Ready -> PracticeRecordingReadyScreen( - uiState = uiState, - onStartRecording = onStartRecording, - onStopRecording = onStopRecording, - onStartPlayback = onStartPlayback, - onStopPlayback = onStopPlayback, - onClickAnalyze = onClickAnalyze, - onBack = onBack, - modifier = modifier, - ) - - is PracticeRecordingUiState.Analysis -> PracticeRecordingResultScreen( - uiState = uiState, - onRetry = onRetryRecording, - onComplete = navigateToHome, - modifier = modifier, - ) - } + PracticeRecordingReadyScreen( + uiState = uiState, + onClickRecordingControl = onClickRecordingControl, + onClickAnalyze = onClickAnalyze, + onBack = onBack, + modifier = modifier, + ) } @Composable private fun PracticeRecordingReadyScreen( - uiState: PracticeRecordingUiState.Ready, - onStartRecording: () -> Unit, - onStopRecording: () -> Unit, - onStartPlayback: () -> Unit, - onStopPlayback: () -> Unit, + uiState: PracticeRecordingUiState, + onClickRecordingControl: () -> Unit, onClickAnalyze: () -> Unit, onBack: () -> Unit, modifier: Modifier = Modifier, @@ -146,10 +119,7 @@ private fun PracticeRecordingReadyScreen( currentSeconds = uiState.currentSeconds, totalSeconds = uiState.totalSeconds, recordingState = uiState.recordingState, - onStartRecording = onStartRecording, - onStopRecording = onStopRecording, - onStartPlayback = onStartPlayback, - onStopPlayback = onStopPlayback, + onClickRecordingControl = onClickRecordingControl, modifier = Modifier.weight(1f), ) @@ -182,7 +152,7 @@ private val PracticeRecordingUiMessage.resId: Int @Composable private fun PracticeRecordingScreenIdlePreview() { PrezelTheme { - PracticeRecordingScreenPreviewContent(uiState = PracticeRecordingUiState.Ready()) + PracticeRecordingScreenPreviewContent(uiState = PracticeRecordingUiState()) } } @@ -191,7 +161,7 @@ private fun PracticeRecordingScreenIdlePreview() { private fun PracticeRecordingScreenRecordingPreview() { PrezelTheme { PracticeRecordingScreenPreviewContent( - uiState = PracticeRecordingUiState.Ready( + uiState = PracticeRecordingUiState( recordingState = AudioSessionState.Recording( elapsedSeconds = 12, ), @@ -205,7 +175,7 @@ private fun PracticeRecordingScreenRecordingPreview() { private fun PracticeRecordingScreenRecordedPreview() { PrezelTheme { PracticeRecordingScreenPreviewContent( - uiState = PracticeRecordingUiState.Ready( + uiState = PracticeRecordingUiState( recordingState = AudioSessionState.ReadyToPlay( source = AudioSource.RecordedFile(filePath = ""), durationSeconds = 32, @@ -220,7 +190,7 @@ private fun PracticeRecordingScreenRecordedPreview() { private fun PracticeRecordingScreenPlayingPreview() { PrezelTheme { PracticeRecordingScreenPreviewContent( - uiState = PracticeRecordingUiState.Ready( + uiState = PracticeRecordingUiState( recordingState = AudioSessionState.Playing( source = AudioSource.RecordedFile(filePath = ""), positionSeconds = 12, @@ -232,18 +202,13 @@ private fun PracticeRecordingScreenPlayingPreview() { } @Composable -private fun PracticeRecordingScreenPreviewContent(uiState: PracticeRecordingUiState.Ready) { +private fun PracticeRecordingScreenPreviewContent(uiState: PracticeRecordingUiState) { PracticeRecordingScreen( uiState = uiState.copy( practiceScript = "내가 그린 기린 그림은 잘 그린 기린 그림이고,\n네가 그린 기린 그림은 잘못 그린 기린 그림이다.", ), - onStartRecording = {}, - onStopRecording = {}, - onStartPlayback = {}, - onStopPlayback = {}, + onClickRecordingControl = {}, onClickAnalyze = {}, - onRetryRecording = {}, onBack = {}, - navigateToHome = {}, ) } diff --git a/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/PracticeRecordingViewModel.kt b/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/PracticeRecordingViewModel.kt index 00c8d6cf..dae02013 100644 --- a/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/PracticeRecordingViewModel.kt +++ b/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/PracticeRecordingViewModel.kt @@ -4,15 +4,11 @@ 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.common.error.AppError -import com.team.prezel.core.common.error.AppException -import com.team.prezel.core.domain.usecase.practice.AnalyzePracticeRecordingUseCase import com.team.prezel.core.domain.usecase.practice.FetchPracticeScriptUseCase import com.team.prezel.core.ui.base.BaseViewModel import com.team.prezel.feature.practice.impl.contract.PracticeRecordingUiEffect import com.team.prezel.feature.practice.impl.contract.PracticeRecordingUiIntent import com.team.prezel.feature.practice.impl.contract.PracticeRecordingUiState -import com.team.prezel.feature.practice.impl.model.PracticeRecordingAnalysisErrorType import com.team.prezel.feature.practice.impl.model.PracticeRecordingUiMessage import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.launch @@ -22,10 +18,7 @@ import javax.inject.Inject internal class PracticeRecordingViewModel @Inject constructor( private val audioController: RecordingAudioController, private val fetchPracticeScriptUseCase: FetchPracticeScriptUseCase, - private val analyzePracticeRecordingUseCase: AnalyzePracticeRecordingUseCase, -) : BaseViewModel(PracticeRecordingUiState.Ready()) { - private var practiceScript: String = "" - +) : BaseViewModel(PracticeRecordingUiState()) { init { collectAudioSessionState() collectAudioSessionEffect() @@ -34,19 +27,23 @@ internal class PracticeRecordingViewModel @Inject constructor( override fun onIntent(intent: PracticeRecordingUiIntent) { when (intent) { - PracticeRecordingUiIntent.StartRecording -> { - if (practiceScript.isBlank()) { + PracticeRecordingUiIntent.ClickRecordingControl -> handleRecordingControlClick() + } + } + + private fun handleRecordingControlClick() { + when (currentState.recordingState) { + AudioSessionState.Idle -> { + if (currentState.practiceScript.isBlank()) { showMessage(PracticeRecordingUiMessage.FETCH_PRACTICE_SCRIPT_FAILED) return } audioController.startRecording() } - PracticeRecordingUiIntent.StopRecording -> audioController.stopRecording() - PracticeRecordingUiIntent.StartPlayback -> audioController.startPlayback() - PracticeRecordingUiIntent.StopPlayback -> audioController.stopPlayback() - PracticeRecordingUiIntent.AnalyzeRecording -> startAnalysis() - PracticeRecordingUiIntent.ResetRecording -> resetPracticeRecording() + is AudioSessionState.Recording -> audioController.stopRecording() + is AudioSessionState.ReadyToPlay -> audioController.startPlayback() + is AudioSessionState.Playing -> audioController.stopPlayback() } } @@ -54,15 +51,7 @@ internal class PracticeRecordingViewModel @Inject constructor( viewModelScope.launch { audioController.audioSessionState.collect { audioState -> updateState { - when { - audioState is AudioSessionState.Recording -> PracticeRecordingUiState.Ready( - practiceScript = this@PracticeRecordingViewModel.practiceScript, - recordingState = audioState, - ) - - this is PracticeRecordingUiState.Ready -> copy(recordingState = audioState) - else -> this - } + copy(recordingState = audioState) } } } @@ -80,9 +69,8 @@ internal class PracticeRecordingViewModel @Inject constructor( viewModelScope.launch { fetchPracticeScriptUseCase() .onSuccess { script -> - practiceScript = script.content updateState { - (this as PracticeRecordingUiState.Ready).copy(practiceScript = script.content) + copy(practiceScript = script.content) } }.onFailure { showMessage(PracticeRecordingUiMessage.FETCH_PRACTICE_SCRIPT_FAILED) @@ -90,44 +78,6 @@ internal class PracticeRecordingViewModel @Inject constructor( } } - private fun startAnalysis() { - val readyState = currentState as? PracticeRecordingUiState.Ready ?: return - if (!readyState.analyzeEnabled) return - val filePath = readyState.recordingFilePath ?: return - val referenceText = practiceScript - - audioController.stopPlayback() - viewModelScope.launch { - updateState { - PracticeRecordingUiState.Analysis.Loading - } - - analyzePracticeRecordingUseCase( - recordingFilePath = filePath, - referenceText = referenceText, - ).onSuccess { result -> - updateState { - PracticeRecordingUiState.Analysis.Success( - result = result, - ) - } - }.onFailure { throwable -> - updateState { - PracticeRecordingUiState.Analysis.Error( - type = throwable.toPracticeRecordingAnalysisErrorType(), - ) - } - } - } - } - - private fun resetPracticeRecording() { - audioController.reset() - updateState { - PracticeRecordingUiState.Ready(practiceScript = practiceScript) - } - } - private fun showMessage(message: PracticeRecordingUiMessage) { viewModelScope.launch { sendEffect(PracticeRecordingUiEffect.ShowMessage(message)) @@ -141,15 +91,6 @@ internal class PracticeRecordingViewModel @Inject constructor( AudioSessionEffect.PlaybackStartFailed -> PracticeRecordingUiMessage.PLAYBACK_START_FAILED } - private fun Throwable.toPracticeRecordingAnalysisErrorType(): PracticeRecordingAnalysisErrorType { - val error = (this as? AppException)?.error - - return when (error) { - AppError.VOICE_RECOGNITION_FAILED -> PracticeRecordingAnalysisErrorType.VOICE_RECOGNITION_FAILED - else -> PracticeRecordingAnalysisErrorType.ANALYSIS_FAILED - } - } - override fun onCleared() { audioController.release() super.onCleared() diff --git a/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/RecordAudioPermission.kt b/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/RecordAudioPermission.kt index 1eae6cfb..bc315f4b 100644 --- a/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/RecordAudioPermission.kt +++ b/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/RecordAudioPermission.kt @@ -8,13 +8,13 @@ import com.team.prezel.core.ui.util.rememberPermissionRequest @Composable internal fun rememberRecordAudioPermissionControlClickHandler( recordingState: AudioSessionState, - onStartRecording: () -> Unit, + onClickRecordingControl: () -> Unit, onPermissionDenied: () -> Unit, onPermissionPermanentlyDenied: () -> Unit, ): () -> Unit { val permissionRequest = rememberPermissionRequest( permission = Manifest.permission.RECORD_AUDIO, - onPermissionGranted = onStartRecording, + onPermissionGranted = onClickRecordingControl, onPermissionDenied = onPermissionDenied, onPermissionPermanentlyDenied = onPermissionPermanentlyDenied, ) @@ -23,7 +23,7 @@ internal fun rememberRecordAudioPermissionControlClickHandler( when (recordingState) { AudioSessionState.Idle -> { when { - permissionRequest.isGranted -> onStartRecording() + permissionRequest.isGranted -> onClickRecordingControl() permissionRequest.isPermanentlyDenied -> permissionRequest.onPermanentlyDenied() else -> permissionRequest.launch() } @@ -32,7 +32,7 @@ internal fun rememberRecordAudioPermissionControlClickHandler( is AudioSessionState.Recording, is AudioSessionState.ReadyToPlay, is AudioSessionState.Playing, - -> Unit + -> onClickRecordingControl() } } } diff --git a/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/analysis/PracticeRecordingAnalysisScreen.kt b/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/analysis/PracticeRecordingAnalysisScreen.kt new file mode 100644 index 00000000..2c949e39 --- /dev/null +++ b/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/analysis/PracticeRecordingAnalysisScreen.kt @@ -0,0 +1,66 @@ +package com.team.prezel.feature.practice.impl.analysis + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.team.prezel.feature.practice.impl.analysis.component.PracticeRecordingAnalysisFailurePage +import com.team.prezel.feature.practice.impl.analysis.component.PracticeRecordingAnalysisLoadingPage +import com.team.prezel.feature.practice.impl.analysis.component.PracticeRecordingResultPage +import com.team.prezel.feature.practice.impl.analysis.contract.PracticeRecordingAnalysisUiIntent +import com.team.prezel.feature.practice.impl.analysis.contract.PracticeRecordingAnalysisUiState + +@Composable +internal fun PracticeRecordingAnalysisScreen( + recordingFilePath: String, + referenceText: String, + onRetry: () -> Unit, + onComplete: () -> Unit, + modifier: Modifier = Modifier, + viewModel: PracticeRecordingAnalysisViewModel = hiltViewModel(), +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + LaunchedEffect(recordingFilePath, referenceText) { + viewModel.onIntent( + PracticeRecordingAnalysisUiIntent.Analyze( + recordingFilePath = recordingFilePath, + referenceText = referenceText, + ), + ) + } + + PracticeRecordingAnalysisScreen( + uiState = uiState, + onRetry = onRetry, + onComplete = onComplete, + modifier = modifier, + ) +} + +@Composable +private fun PracticeRecordingAnalysisScreen( + uiState: PracticeRecordingAnalysisUiState, + onRetry: () -> Unit, + onComplete: () -> Unit, + modifier: Modifier = Modifier, +) { + when (uiState) { + is PracticeRecordingAnalysisUiState.Loading -> PracticeRecordingAnalysisLoadingPage(modifier = modifier) + is PracticeRecordingAnalysisUiState.Success -> PracticeRecordingResultPage( + pronunciationScore = uiState.result.pronunciationScore, + speed = uiState.result.speed, + overallEvaluation = uiState.result.overallEvaluation, + onComplete = onComplete, + modifier = modifier, + ) + + is PracticeRecordingAnalysisUiState.Error -> PracticeRecordingAnalysisFailurePage( + errorType = uiState.type, + onRetry = onRetry, + modifier = modifier, + ) + } +} diff --git a/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/analysis/PracticeRecordingAnalysisViewModel.kt b/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/analysis/PracticeRecordingAnalysisViewModel.kt new file mode 100644 index 00000000..b961af8c --- /dev/null +++ b/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/analysis/PracticeRecordingAnalysisViewModel.kt @@ -0,0 +1,77 @@ +package com.team.prezel.feature.practice.impl.analysis + +import androidx.lifecycle.viewModelScope +import com.team.prezel.core.common.error.AppError +import com.team.prezel.core.common.error.AppException +import com.team.prezel.core.domain.usecase.practice.AnalyzePracticeRecordingUseCase +import com.team.prezel.core.ui.base.BaseViewModel +import com.team.prezel.feature.practice.impl.analysis.contract.PracticeRecordingAnalysisUiEffect +import com.team.prezel.feature.practice.impl.analysis.contract.PracticeRecordingAnalysisUiIntent +import com.team.prezel.feature.practice.impl.analysis.contract.PracticeRecordingAnalysisUiState +import com.team.prezel.feature.practice.impl.model.PracticeRecordingAnalysisErrorType +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +internal class PracticeRecordingAnalysisViewModel @Inject constructor( + private val analyzePracticeRecordingUseCase: AnalyzePracticeRecordingUseCase, +) : BaseViewModel( + PracticeRecordingAnalysisUiState.Loading, + ) { + private var analysisRequest: AnalysisRequest? = null + + override fun onIntent(intent: PracticeRecordingAnalysisUiIntent) { + when (intent) { + is PracticeRecordingAnalysisUiIntent.Analyze -> analyzeRecording( + recordingFilePath = intent.recordingFilePath, + referenceText = intent.referenceText, + ) + } + } + + private fun analyzeRecording( + recordingFilePath: String, + referenceText: String, + ) { + val request = AnalysisRequest( + recordingFilePath = recordingFilePath, + referenceText = referenceText, + ) + if (analysisRequest == request) return + + analysisRequest = request + viewModelScope.launch { + updateState { PracticeRecordingAnalysisUiState.Loading } + + analyzePracticeRecordingUseCase( + recordingFilePath = recordingFilePath, + referenceText = referenceText, + ).onSuccess { result -> + updateState { + PracticeRecordingAnalysisUiState.Success(result = result) + } + }.onFailure { throwable -> + updateState { + PracticeRecordingAnalysisUiState.Error( + type = throwable.toPracticeRecordingAnalysisErrorType(), + ) + } + } + } + } + + private data class AnalysisRequest( + val recordingFilePath: String, + val referenceText: String, + ) + + private fun Throwable.toPracticeRecordingAnalysisErrorType(): PracticeRecordingAnalysisErrorType { + val error = (this as? AppException)?.error + + return when (error) { + AppError.VOICE_RECOGNITION_FAILED -> PracticeRecordingAnalysisErrorType.VOICE_RECOGNITION_FAILED + else -> PracticeRecordingAnalysisErrorType.ANALYSIS_FAILED + } + } +} diff --git a/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/result/component/PracticeRecordingAnalysisFailurePage.kt b/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/analysis/component/PracticeRecordingAnalysisFailurePage.kt similarity index 97% rename from Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/result/component/PracticeRecordingAnalysisFailurePage.kt rename to Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/analysis/component/PracticeRecordingAnalysisFailurePage.kt index c7ae5e2d..7ae82e4c 100644 --- a/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/result/component/PracticeRecordingAnalysisFailurePage.kt +++ b/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/analysis/component/PracticeRecordingAnalysisFailurePage.kt @@ -1,4 +1,4 @@ -package com.team.prezel.feature.practice.impl.result.component +package com.team.prezel.feature.practice.impl.analysis.component import androidx.annotation.DrawableRes import androidx.compose.foundation.Image diff --git a/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/result/component/PracticeRecordingAnalysisLoadingPage.kt b/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/analysis/component/PracticeRecordingAnalysisLoadingPage.kt similarity index 95% rename from Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/result/component/PracticeRecordingAnalysisLoadingPage.kt rename to Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/analysis/component/PracticeRecordingAnalysisLoadingPage.kt index 7601c70c..eca3cd7e 100644 --- a/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/result/component/PracticeRecordingAnalysisLoadingPage.kt +++ b/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/analysis/component/PracticeRecordingAnalysisLoadingPage.kt @@ -1,4 +1,4 @@ -package com.team.prezel.feature.practice.impl.result.component +package com.team.prezel.feature.practice.impl.analysis.component import androidx.compose.foundation.layout.size import androidx.compose.runtime.Composable diff --git a/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/result/component/PracticeRecordingResultPage.kt b/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/analysis/component/PracticeRecordingResultPage.kt similarity index 99% rename from Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/result/component/PracticeRecordingResultPage.kt rename to Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/analysis/component/PracticeRecordingResultPage.kt index 80069652..a9e761a9 100644 --- a/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/result/component/PracticeRecordingResultPage.kt +++ b/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/analysis/component/PracticeRecordingResultPage.kt @@ -1,4 +1,4 @@ -package com.team.prezel.feature.practice.impl.result.component +package com.team.prezel.feature.practice.impl.analysis.component import androidx.annotation.DrawableRes import androidx.annotation.StringRes diff --git a/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/analysis/contract/PracticeRecordingAnalysisUiEffect.kt b/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/analysis/contract/PracticeRecordingAnalysisUiEffect.kt new file mode 100644 index 00000000..b99fb5c3 --- /dev/null +++ b/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/analysis/contract/PracticeRecordingAnalysisUiEffect.kt @@ -0,0 +1,5 @@ +package com.team.prezel.feature.practice.impl.analysis.contract + +import com.team.prezel.core.ui.base.UiEffect + +internal sealed interface PracticeRecordingAnalysisUiEffect : UiEffect diff --git a/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/analysis/contract/PracticeRecordingAnalysisUiIntent.kt b/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/analysis/contract/PracticeRecordingAnalysisUiIntent.kt new file mode 100644 index 00000000..f00b443d --- /dev/null +++ b/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/analysis/contract/PracticeRecordingAnalysisUiIntent.kt @@ -0,0 +1,10 @@ +package com.team.prezel.feature.practice.impl.analysis.contract + +import com.team.prezel.core.ui.base.UiIntent + +internal sealed interface PracticeRecordingAnalysisUiIntent : UiIntent { + data class Analyze( + val recordingFilePath: String, + val referenceText: String, + ) : PracticeRecordingAnalysisUiIntent +} diff --git a/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/analysis/contract/PracticeRecordingAnalysisUiState.kt b/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/analysis/contract/PracticeRecordingAnalysisUiState.kt new file mode 100644 index 00000000..40639207 --- /dev/null +++ b/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/analysis/contract/PracticeRecordingAnalysisUiState.kt @@ -0,0 +1,22 @@ +package com.team.prezel.feature.practice.impl.analysis.contract + +import androidx.compose.runtime.Immutable +import com.team.prezel.core.model.practice.PracticeRecordingAnalysisResult +import com.team.prezel.core.ui.base.UiState +import com.team.prezel.feature.practice.impl.model.PracticeRecordingAnalysisErrorType + +@Immutable +internal sealed interface PracticeRecordingAnalysisUiState : UiState { + @Immutable + data object Loading : PracticeRecordingAnalysisUiState + + @Immutable + data class Success( + val result: PracticeRecordingAnalysisResult, + ) : PracticeRecordingAnalysisUiState + + @Immutable + data class Error( + val type: PracticeRecordingAnalysisErrorType, + ) : PracticeRecordingAnalysisUiState +} diff --git a/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/component/PracticeRecordingContent.kt b/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/component/PracticeRecordingContent.kt index 6019775b..41f565a6 100644 --- a/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/component/PracticeRecordingContent.kt +++ b/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/component/PracticeRecordingContent.kt @@ -28,10 +28,7 @@ internal fun PracticeRecordingContent( currentSeconds: Int, totalSeconds: Int, recordingState: AudioSessionState, - onStartRecording: () -> Unit, - onStopRecording: () -> Unit, - onStartPlayback: () -> Unit, - onStopPlayback: () -> Unit, + onClickRecordingControl: () -> Unit, modifier: Modifier = Modifier, ) { Column( @@ -78,10 +75,7 @@ internal fun PracticeRecordingContent( currentSeconds = currentSeconds, totalSeconds = totalSeconds, audioSessionState = recordingState, - onStartRecording = onStartRecording, - onStopRecording = onStopRecording, - onStartPlayback = onStartPlayback, - onStopPlayback = onStopPlayback, + onClickRecordingControl = onClickRecordingControl, ) } } @@ -95,10 +89,7 @@ private fun PracticeRecordingContentReadyToRecordPreview() { currentSeconds = 0, totalSeconds = 0, recordingState = AudioSessionState.Idle, - onStartRecording = {}, - onStopRecording = {}, - onStartPlayback = {}, - onStopPlayback = {}, + onClickRecordingControl = {}, modifier = Modifier.height(520.dp), ) } @@ -117,10 +108,7 @@ private fun PracticeRecordingContentReadyToPlayPreview() { positionSeconds = 12, durationSeconds = 45, ), - onStartRecording = {}, - onStopRecording = {}, - onStartPlayback = {}, - onStopPlayback = {}, + onClickRecordingControl = {}, modifier = Modifier.height(520.dp), ) } diff --git a/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/component/PracticeRecordingControl.kt b/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/component/PracticeRecordingControl.kt index 82df1ff2..fd2cc00c 100644 --- a/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/component/PracticeRecordingControl.kt +++ b/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/component/PracticeRecordingControl.kt @@ -12,6 +12,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.withStyle @@ -32,18 +33,10 @@ internal fun PracticeRecordingControl( currentSeconds: Int, totalSeconds: Int, audioSessionState: AudioSessionState, - onStartRecording: () -> Unit, - onStopRecording: () -> Unit, - onStartPlayback: () -> Unit, - onStopPlayback: () -> Unit, + onClickRecordingControl: () -> Unit, modifier: Modifier = Modifier, ) { - val actions = audioSessionState.actions( - onStartRecording = onStartRecording, - onStopRecording = onStopRecording, - onStartPlayback = onStartPlayback, - onStopPlayback = onStopPlayback, - ) + val action = audioSessionState.action() Row( modifier = modifier.fillMaxWidth(), @@ -60,24 +53,22 @@ internal fun PracticeRecordingControl( horizontalArrangement = Arrangement.spacedBy(PrezelTheme.spacing.V8), verticalAlignment = Alignment.CenterVertically, ) { - actions.forEach { action -> - PrezelIconButton( - iconResId = action.iconResId, - modifier = Modifier.size(48.dp), + PrezelIconButton( + iconResId = action.iconResId, + modifier = Modifier.size(48.dp), + isRounded = true, + buttonDefault = PrezelButtonDefaults.getDefault( + isIconOnly = true, isRounded = true, - buttonDefault = PrezelButtonDefaults.getDefault( - isIconOnly = true, - isRounded = true, - type = ButtonType.FILLED, - size = ButtonSize.REGULAR, - hierarchy = ButtonHierarchy.SECONDARY, - contentColor = action.iconColor(), - backgroundColor = PrezelTheme.colors.bgLarge, - iconSize = 20.dp, - ), - onClick = action.onClick, - ) - } + type = ButtonType.FILLED, + size = ButtonSize.REGULAR, + hierarchy = ButtonHierarchy.SECONDARY, + contentColor = action.iconColor, + backgroundColor = PrezelTheme.colors.bgLarge, + iconSize = 20.dp, + ), + onClick = onClickRecordingControl, + ) } } } @@ -116,58 +107,35 @@ private fun PracticeRecordingTimeText( private data class PracticeRecordingControlAction( @param:DrawableRes val iconResId: Int, - val colorType: PracticeRecordingControlActionColorType, - val onClick: () -> Unit, + val iconColor: Color, ) -private enum class PracticeRecordingControlActionColorType { - RECORD, - REGULAR, -} - -private fun AudioSessionState.actions( - onStartRecording: () -> Unit, - onStopRecording: () -> Unit, - onStartPlayback: () -> Unit, - onStopPlayback: () -> Unit, -): List = +@Composable +private fun AudioSessionState.action(): PracticeRecordingControlAction = when (this) { - AudioSessionState.Idle -> listOf( + AudioSessionState.Idle -> PracticeRecordingControlAction( iconResId = PrezelIcons.Recording, - colorType = PracticeRecordingControlActionColorType.RECORD, - onClick = onStartRecording, - ), - ) + iconColor = PrezelTheme.colors.feedbackBadRegular, + ) - is AudioSessionState.Recording -> stopAction(onStop = onStopRecording) + is AudioSessionState.Recording -> stopAction() - is AudioSessionState.ReadyToPlay -> listOf( + is AudioSessionState.ReadyToPlay -> PracticeRecordingControlAction( iconResId = PrezelIcons.Play, - colorType = PracticeRecordingControlActionColorType.REGULAR, - onClick = onStartPlayback, - ), - ) + iconColor = PrezelTheme.colors.iconRegular, + ) - is AudioSessionState.Playing -> stopAction(onStop = onStopPlayback) + is AudioSessionState.Playing -> stopAction() } -private fun stopAction(onStop: () -> Unit): List = - listOf( - PracticeRecordingControlAction( - iconResId = PrezelIcons.Stop, - colorType = PracticeRecordingControlActionColorType.REGULAR, - onClick = onStop, - ), - ) - @Composable -private fun PracticeRecordingControlAction.iconColor() = - when (colorType) { - PracticeRecordingControlActionColorType.RECORD -> PrezelTheme.colors.feedbackBadRegular - PracticeRecordingControlActionColorType.REGULAR -> PrezelTheme.colors.iconRegular - } +private fun stopAction(): PracticeRecordingControlAction = + PracticeRecordingControlAction( + iconResId = PrezelIcons.Stop, + iconColor = PrezelTheme.colors.iconRegular, + ) private fun Int.toTimerText(): String { val minutes = this / 60 @@ -189,20 +157,14 @@ private fun PracticeRecordingControlPreview() { currentSeconds = 0, totalSeconds = 0, audioSessionState = AudioSessionState.Idle, - onStartRecording = {}, - onStopRecording = {}, - onStartPlayback = {}, - onStopPlayback = {}, + onClickRecordingControl = {}, ) PracticeRecordingControl( currentSeconds = 8, totalSeconds = 0, audioSessionState = AudioSessionState.Recording(elapsedSeconds = 8), - onStartRecording = {}, - onStopRecording = {}, - onStartPlayback = {}, - onStopPlayback = {}, + onClickRecordingControl = {}, ) PracticeRecordingControl( @@ -213,10 +175,7 @@ private fun PracticeRecordingControlPreview() { positionSeconds = 16, durationSeconds = 45, ), - onStartRecording = {}, - onStopRecording = {}, - onStartPlayback = {}, - onStopPlayback = {}, + onClickRecordingControl = {}, ) PracticeRecordingControl( @@ -227,10 +186,7 @@ private fun PracticeRecordingControlPreview() { positionSeconds = 24, durationSeconds = 45, ), - onStartRecording = {}, - onStopRecording = {}, - onStartPlayback = {}, - onStopPlayback = {}, + onClickRecordingControl = {}, ) } } diff --git a/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/contract/PracticeRecordingUiIntent.kt b/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/contract/PracticeRecordingUiIntent.kt index 87cc3409..0fefbd03 100644 --- a/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/contract/PracticeRecordingUiIntent.kt +++ b/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/contract/PracticeRecordingUiIntent.kt @@ -3,15 +3,5 @@ package com.team.prezel.feature.practice.impl.contract import com.team.prezel.core.ui.base.UiIntent internal sealed interface PracticeRecordingUiIntent : UiIntent { - data object StartRecording : PracticeRecordingUiIntent - - data object StopRecording : PracticeRecordingUiIntent - - data object StartPlayback : PracticeRecordingUiIntent - - data object StopPlayback : PracticeRecordingUiIntent - - data object AnalyzeRecording : PracticeRecordingUiIntent - - data object ResetRecording : PracticeRecordingUiIntent + data object ClickRecordingControl : PracticeRecordingUiIntent } diff --git a/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/contract/PracticeRecordingUiState.kt b/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/contract/PracticeRecordingUiState.kt index 0dc29943..fbb91641 100644 --- a/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/contract/PracticeRecordingUiState.kt +++ b/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/contract/PracticeRecordingUiState.kt @@ -2,58 +2,38 @@ package com.team.prezel.feature.practice.impl.contract import androidx.compose.runtime.Immutable import com.team.prezel.core.audio.AudioSessionState -import com.team.prezel.core.model.practice.PracticeRecordingAnalysisResult import com.team.prezel.core.ui.base.UiState -import com.team.prezel.feature.practice.impl.model.PracticeRecordingAnalysisErrorType @Immutable -internal sealed interface PracticeRecordingUiState : UiState { - @Immutable - data class Ready( - val practiceScript: String = "", - val recordingState: AudioSessionState = AudioSessionState.Idle, - ) : PracticeRecordingUiState { - val currentSeconds: Int - get() = when (val state = recordingState) { - AudioSessionState.Idle -> 0 - is AudioSessionState.Recording -> state.elapsedSeconds - is AudioSessionState.ReadyToPlay -> state.positionSeconds - is AudioSessionState.Playing -> state.positionSeconds - } - - val totalSeconds: Int - get() = when (val state = recordingState) { - AudioSessionState.Idle, - is AudioSessionState.Recording, - -> 0 - - is AudioSessionState.ReadyToPlay -> state.durationSeconds - is AudioSessionState.Playing -> state.durationSeconds - } - - val recordingFilePath: String? - get() = when (val state = recordingState) { - is AudioSessionState.ReadyToPlay -> state.source.filePath - is AudioSessionState.Playing -> state.source.filePath - else -> null - } - - val analyzeEnabled: Boolean - get() = recordingState is AudioSessionState.ReadyToPlay - } - - sealed interface Analysis : PracticeRecordingUiState { - @Immutable - data object Loading : Analysis - - @Immutable - data class Success( - val result: PracticeRecordingAnalysisResult, - ) : Analysis - - @Immutable - data class Error( - val type: PracticeRecordingAnalysisErrorType, - ) : Analysis - } +internal data class PracticeRecordingUiState( + val practiceScript: String = "", + val recordingState: AudioSessionState = AudioSessionState.Idle, +) : UiState { + val currentSeconds: Int + get() = when (val state = recordingState) { + AudioSessionState.Idle -> 0 + is AudioSessionState.Recording -> state.elapsedSeconds + is AudioSessionState.ReadyToPlay -> state.positionSeconds + is AudioSessionState.Playing -> state.positionSeconds + } + + val totalSeconds: Int + get() = when (val state = recordingState) { + AudioSessionState.Idle, + is AudioSessionState.Recording, + -> 0 + + is AudioSessionState.ReadyToPlay -> state.durationSeconds + is AudioSessionState.Playing -> state.durationSeconds + } + + val recordingFilePath: String? + get() = when (val state = recordingState) { + is AudioSessionState.ReadyToPlay -> state.source.filePath + is AudioSessionState.Playing -> state.source.filePath + else -> null + } + + val analyzeEnabled: Boolean + get() = recordingState is AudioSessionState.ReadyToPlay } diff --git a/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/navigation/PracticeEntryBuilder.kt b/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/navigation/PracticeEntryBuilder.kt index ad8f4dc2..d005e6f6 100644 --- a/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/navigation/PracticeEntryBuilder.kt +++ b/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/navigation/PracticeEntryBuilder.kt @@ -6,6 +6,7 @@ import com.team.prezel.core.navigation.LocalNavigator import com.team.prezel.feature.home.api.HomeNavKey import com.team.prezel.feature.practice.api.PracticeNavKey import com.team.prezel.feature.practice.impl.PracticeRecordingScreen +import com.team.prezel.feature.practice.impl.analysis.PracticeRecordingAnalysisScreen import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -13,12 +14,30 @@ import dagger.hilt.android.components.ActivityRetainedComponent import dagger.multibindings.IntoSet internal fun EntryProviderScope.featurePracticeEntryBuilder() { - entry { + entry { val navigator = LocalNavigator.current PracticeRecordingScreen( onBack = navigator::goBack, - navigateToHome = { navigator.replaceRoot(HomeNavKey) }, + navigateToAnalysis = { recordingFilePath, referenceText -> + navigator.navigate( + PracticeNavKey.Analysis( + recordingFilePath = recordingFilePath, + referenceText = referenceText, + ), + ) + }, + ) + } + + entry { key -> + val navigator = LocalNavigator.current + + PracticeRecordingAnalysisScreen( + recordingFilePath = key.recordingFilePath, + referenceText = key.referenceText, + onRetry = { navigator.navigate(PracticeNavKey.Recording) }, + onComplete = { navigator.replaceRoot(HomeNavKey) }, ) } } diff --git a/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/result/PracticeRecordingResultScreen.kt b/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/result/PracticeRecordingResultScreen.kt deleted file mode 100644 index 729cb930..00000000 --- a/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/result/PracticeRecordingResultScreen.kt +++ /dev/null @@ -1,33 +0,0 @@ -package com.team.prezel.feature.practice.impl.result - -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import com.team.prezel.feature.practice.impl.contract.PracticeRecordingUiState -import com.team.prezel.feature.practice.impl.result.component.PracticeRecordingAnalysisFailurePage -import com.team.prezel.feature.practice.impl.result.component.PracticeRecordingAnalysisLoadingPage -import com.team.prezel.feature.practice.impl.result.component.PracticeRecordingResultPage - -@Composable -internal fun PracticeRecordingResultScreen( - uiState: PracticeRecordingUiState.Analysis, - onRetry: () -> Unit, - onComplete: () -> Unit, - modifier: Modifier = Modifier, -) { - when (uiState) { - is PracticeRecordingUiState.Analysis.Loading -> PracticeRecordingAnalysisLoadingPage(modifier = modifier) - is PracticeRecordingUiState.Analysis.Success -> PracticeRecordingResultPage( - pronunciationScore = uiState.result.pronunciationScore, - speed = uiState.result.speed, - overallEvaluation = uiState.result.overallEvaluation, - onComplete = onComplete, - modifier = modifier, - ) - - is PracticeRecordingUiState.Analysis.Error -> PracticeRecordingAnalysisFailurePage( - errorType = uiState.type, - onRetry = onRetry, - modifier = modifier, - ) - } -} From bd8b829c67b839249158f8670b9f05ad15d6bba8 Mon Sep 17 00:00:00 2001 From: Ham BeomJoon Date: Fri, 15 May 2026 01:36:43 +0900 Subject: [PATCH 27/27] =?UTF-8?q?feat:=20Audio=20=EA=B4=80=EB=A0=A8=20?= =?UTF-8?q?=EC=84=B8=EC=85=98=20=EC=9D=B8=ED=84=B0=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=8A=A4=20=EB=8F=84=EC=9E=85=20=EB=B0=8F=20=EC=9D=98=EC=A1=B4?= =?UTF-8?q?=EC=84=B1=20=EC=A3=BC=EC=9E=85=20=EA=B5=AC=EC=A1=B0=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * **feat: Audio 세션 인터페이스 추가 및 구현체 연결** * `AudioPlayerSession` 및 `AudioRecorderSession` 인터페이스를 정의하여 오디오 재생 및 녹음 로직을 추상화했습니다. * 기존 `MediaPlayerSession`과 `MediaRecorderSession`이 해당 인터페이스를 구현하도록 수정하고, `@Inject` 어노테이션을 추가하여 Hilt를 통한 의존성 주입이 가능하도록 개선했습니다. * `RecordingAudioModule`에 `AudioRecorderSession` 및 `AudioPlayerSession`에 대한 Hilt 바인딩 설정을 추가했습니다. * **refactor: MediaRecordingAudioController 의존성 주입 방식 변경** * `MediaRecordingAudioController`에서 `Context`를 직접 참조하여 세션 객체를 생성하던 방식에서, 인터페이스(`AudioRecorderSession`, `AudioPlayerSession`)를 주입받는 방식으로 변경하여 결합도를 낮추고 테스트 용이성을 높였습니다. --- .../team/prezel/core/audio/AudioPlayerSession.kt | 13 +++++++++++++ .../team/prezel/core/audio/AudioRecorderSession.kt | 9 +++++++++ .../team/prezel/core/audio/MediaPlayerSession.kt | 9 +++++---- .../team/prezel/core/audio/MediaRecorderSession.kt | 14 ++++++++------ .../core/audio/MediaRecordingAudioController.kt | 7 ++----- .../team/prezel/core/audio/RecordingAudioModule.kt | 8 ++++++++ 6 files changed, 45 insertions(+), 15 deletions(-) create mode 100644 Prezel/core/audio/src/main/java/com/team/prezel/core/audio/AudioPlayerSession.kt create mode 100644 Prezel/core/audio/src/main/java/com/team/prezel/core/audio/AudioRecorderSession.kt diff --git a/Prezel/core/audio/src/main/java/com/team/prezel/core/audio/AudioPlayerSession.kt b/Prezel/core/audio/src/main/java/com/team/prezel/core/audio/AudioPlayerSession.kt new file mode 100644 index 00000000..76532b3f --- /dev/null +++ b/Prezel/core/audio/src/main/java/com/team/prezel/core/audio/AudioPlayerSession.kt @@ -0,0 +1,13 @@ +package com.team.prezel.core.audio + +internal interface AudioPlayerSession { + fun start( + source: AudioSource, + startPositionSeconds: Int, + onCompleted: () -> Unit, + ): Result + + fun currentPositionSeconds(): Int + + fun release() +} 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 new file mode 100644 index 00000000..dd05183f --- /dev/null +++ b/Prezel/core/audio/src/main/java/com/team/prezel/core/audio/AudioRecorderSession.kt @@ -0,0 +1,9 @@ +package com.team.prezel.core.audio + +internal interface AudioRecorderSession { + fun start(): Result + + fun stop(elapsedSeconds: Int): Result + + fun reset() +} diff --git a/Prezel/core/audio/src/main/java/com/team/prezel/core/audio/MediaPlayerSession.kt b/Prezel/core/audio/src/main/java/com/team/prezel/core/audio/MediaPlayerSession.kt index 17b8abc6..5bc1c184 100644 --- a/Prezel/core/audio/src/main/java/com/team/prezel/core/audio/MediaPlayerSession.kt +++ b/Prezel/core/audio/src/main/java/com/team/prezel/core/audio/MediaPlayerSession.kt @@ -1,11 +1,12 @@ package com.team.prezel.core.audio import android.media.MediaPlayer +import javax.inject.Inject -internal class MediaPlayerSession { +internal class MediaPlayerSession @Inject constructor() : AudioPlayerSession { private var player: MediaPlayer? = null - fun start( + override fun start( source: AudioSource, startPositionSeconds: Int, onCompleted: () -> Unit, @@ -33,9 +34,9 @@ internal class MediaPlayerSession { release() } - fun currentPositionSeconds(): Int = player?.currentPosition?.toSeconds() ?: 0 + override fun currentPositionSeconds(): Int = player?.currentPosition?.toSeconds() ?: 0 - fun release() { + override fun release() { player?.runCatching { stop() } player?.release() player = null 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 c86b0817..e715d3ea 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 @@ -3,16 +3,18 @@ package com.team.prezel.core.audio import android.content.Context import android.media.MediaRecorder import android.os.Build +import dagger.hilt.android.qualifiers.ApplicationContext import java.io.File +import javax.inject.Inject import kotlin.math.max -internal class MediaRecorderSession( - private val context: Context, -) { +internal class MediaRecorderSession @Inject constructor( + @param:ApplicationContext private val context: Context, +) : AudioRecorderSession { private var recorder: MediaRecorder? = null private var currentAudioFile: File? = null - fun start(): Result = + override fun start(): Result = runCatching { reset() @@ -39,7 +41,7 @@ internal class MediaRecorderSession( reset() } - fun stop(elapsedSeconds: Int): Result = + override fun stop(elapsedSeconds: Int): Result = runCatching { val file = currentAudioFile!! recorder!!.stop() @@ -53,7 +55,7 @@ internal class MediaRecorderSession( reset() } - fun reset() { + override fun reset() { releaseRecorder() currentAudioFile?.delete() currentAudioFile = null 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 8776f1a9..4e74fdda 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,7 +1,5 @@ package com.team.prezel.core.audio -import android.content.Context -import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -19,7 +17,8 @@ import kotlinx.coroutines.launch import javax.inject.Inject internal class MediaRecordingAudioController @Inject constructor( - @param:ApplicationContext private val context: Context, + private val recorderSession: AudioRecorderSession, + private val playerSession: AudioPlayerSession, ) : RecordingAudioController { private val controllerScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) @@ -29,8 +28,6 @@ internal class MediaRecordingAudioController @Inject constructor( private val _audioSessionEffect = Channel(capacity = Channel.BUFFERED) override val audioSessionEffect: Flow = _audioSessionEffect.receiveAsFlow() - private val recorderSession = MediaRecorderSession(context = context) - private val playerSession = MediaPlayerSession() private var recordingTimerJob: Job? = null private var playbackTimerJob: Job? = null diff --git a/Prezel/core/audio/src/main/java/com/team/prezel/core/audio/RecordingAudioModule.kt b/Prezel/core/audio/src/main/java/com/team/prezel/core/audio/RecordingAudioModule.kt index f91b3d42..77dfe070 100644 --- a/Prezel/core/audio/src/main/java/com/team/prezel/core/audio/RecordingAudioModule.kt +++ b/Prezel/core/audio/src/main/java/com/team/prezel/core/audio/RecordingAudioModule.kt @@ -12,4 +12,12 @@ internal abstract class RecordingAudioModule { @Binds @ViewModelScoped abstract fun bindRecordingAudioController(impl: MediaRecordingAudioController): RecordingAudioController + + @Binds + @ViewModelScoped + abstract fun bindAudioRecorderSession(impl: MediaRecorderSession): AudioRecorderSession + + @Binds + @ViewModelScoped + abstract fun bindAudioPlayerSession(impl: MediaPlayerSession): AudioPlayerSession }