From 5d6da25efbc87160029ea4703828897cad12f33b Mon Sep 17 00:00:00 2001 From: Ham BeomJoon Date: Sun, 3 May 2026 21:49:10 +0900 Subject: [PATCH 01/16] =?UTF-8?q?feat:=20=EB=B0=9C=ED=91=9C=20=EB=B6=84?= =?UTF-8?q?=EC=84=9D(Analysis)=20=EA=B8=B0=EB=8A=A5=20=EC=B4=88=EA=B8=B0?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84=20=EB=B0=8F=20=ED=99=88/=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=EC=9D=B8=20=ED=9D=90=EB=A6=84=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * **feat: 발표 분석 프로세스 단계별 UI 및 로직 구현** * 새로운 발표 분석을 위한 `AnalysisNavKey` 및 탐색 그래프(`AnalysisEntryBuilder`)를 추가했습니다. * `AnalysisFlowViewModel`을 통해 5단계의 분석 흐름(일정 입력 -> 상황 설정 -> 대본 입력 -> 음성 업로드 -> 분석 중)을 관리하도록 구현했습니다. * **일정 추가**: 발표 제목 및 `PrezelDatePicker`를 이용한 날짜 선택 기능을 구현했습니다. * **상황 설정**: `PrezelAccordion`과 칩/카드 UI를 사용하여 발표 유형, 목적, 스타일, 청중을 선택하는 기능을 추가했습니다. * **대본 입력**: `.txt` 파일 업로드와 직접 입력(`PrezelTextArea`) 방식을 모두 지원합니다. * **음성 업로드**: 오디오 파일(`mp3`, `wav`) 선택 및 업로드 상태를 표시하는 카드 UI를 추가했습니다. * **분석 중**: 가상의 딜레이와 함께 분석 진행 상태를 보여주는 로딩 화면을 구현했습니다. * **feat: 홈 화면 내 분석 진입점 추가** * 홈 화면에 `PrezelFloatingMenu`(FAB)를 도입하여 '음성 녹음' 및 '파일 업로드' 분석 모드로 진입할 수 있는 인터페이스를 추가했습니다. * FAB 확장 시 배경 오버레이 처리를 추가하여 사용성을 개선했습니다. * **refactor: 로그인 및 내비게이션 구조 단순화** * `LoginViewModel`에서 실제 카카오 로그인 로직을 임시로 제거하고, 로그인 클릭 시 바로 홈 화면으로 이동하도록 흐름을 단순화했습니다. * `feature:login:impl` 모듈의 불필요한 의존성(`AuthManager`, `LoginUseCase`)을 정리했습니다. * **build: 모듈 의존성 및 리소스 구성** * `feature:analysis` api/impl 모듈을 신규 생성하고 `app` 모듈 및 관련 기능 모듈에 의존성을 연결했습니다. * 분석 단계별 아이콘, 문자열 리소스 및 공통 레이아웃(`AnalysisStepLayout`)을 정의했습니다. --- Prezel/app/build.gradle.kts | 2 + Prezel/feature/analysis/api/build.gradle.kts | 7 + .../feature/analysis/api/AnalysisNavKey.kt | 10 + Prezel/feature/analysis/impl/build.gradle.kts | 16 + .../analysis/impl/AnalysisFlowViewModel.kt | 76 ++++ .../feature/analysis/impl/AnalysisScreen.kt | 82 +++++ .../feature/analysis/impl/AnalyzingScreen.kt | 69 ++++ .../analysis/impl/AudioUploadScreen.kt | 314 ++++++++++++++++ .../impl/PresentationScheduleScreen.kt | 183 ++++++++++ .../impl/PresentationSituationScreen.kt | 343 ++++++++++++++++++ .../analysis/impl/ScriptInputScreen.kt | 319 ++++++++++++++++ .../feature/analysis/impl/SituationOptions.kt | 122 +++++++ .../impl/component/AnalysisStepLayout.kt | 174 +++++++++ .../component/AnalysisUploadComponents.kt | 63 ++++ .../impl/contract/AnalysisFlowUiEffect.kt | 7 + .../impl/contract/AnalysisFlowUiIntent.kt | 55 +++ .../impl/contract/AnalysisFlowUiState.kt | 68 ++++ .../impl/navigation/AnalysisEntryBuilder.kt | 36 ++ .../feature_analysis_impl_no_voice.xml | 24 ++ .../impl/src/main/res/values/strings.xml | 68 ++++ Prezel/feature/home/impl/build.gradle.kts | 1 + .../prezel/feature/home/impl/HomeScreen.kt | 78 ++++ .../home/impl/navigation/HomeEntryBuilder.kt | 13 +- .../home/impl/src/main/res/values/strings.xml | 2 + .../feature/login/impl/landing/LoginScreen.kt | 26 -- .../login/impl/landing/LoginViewModel.kt | 27 +- .../impl/landing/contract/LoginUiEffect.kt | 9 - .../impl/landing/contract/LoginUiIntent.kt | 5 - .../impl/navigation/LoginEntryBuilder.kt | 11 +- Prezel/settings.gradle.kts | 2 + 30 files changed, 2138 insertions(+), 74 deletions(-) create mode 100644 Prezel/feature/analysis/api/build.gradle.kts create mode 100644 Prezel/feature/analysis/api/src/main/java/com/team/prezel/feature/analysis/api/AnalysisNavKey.kt create mode 100644 Prezel/feature/analysis/impl/build.gradle.kts create mode 100644 Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AnalysisFlowViewModel.kt create mode 100644 Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AnalysisScreen.kt create mode 100644 Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AnalyzingScreen.kt create mode 100644 Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AudioUploadScreen.kt create mode 100644 Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/PresentationScheduleScreen.kt create mode 100644 Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/PresentationSituationScreen.kt create mode 100644 Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/ScriptInputScreen.kt create mode 100644 Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/SituationOptions.kt create mode 100644 Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/component/AnalysisStepLayout.kt create mode 100644 Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/component/AnalysisUploadComponents.kt create mode 100644 Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/contract/AnalysisFlowUiEffect.kt create mode 100644 Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/contract/AnalysisFlowUiIntent.kt create mode 100644 Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/contract/AnalysisFlowUiState.kt create mode 100644 Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/navigation/AnalysisEntryBuilder.kt create mode 100644 Prezel/feature/analysis/impl/src/main/res/drawable/feature_analysis_impl_no_voice.xml create mode 100644 Prezel/feature/analysis/impl/src/main/res/values/strings.xml diff --git a/Prezel/app/build.gradle.kts b/Prezel/app/build.gradle.kts index 5237d05f..f3b51076 100644 --- a/Prezel/app/build.gradle.kts +++ b/Prezel/app/build.gradle.kts @@ -44,6 +44,8 @@ dependencies { implementation(projects.featureLoginImpl) implementation(projects.featureHomeApi) implementation(projects.featureHomeImpl) + implementation(projects.featureAnalysisApi) + implementation(projects.featureAnalysisImpl) implementation(projects.featureHistoryApi) implementation(projects.featureHistoryImpl) implementation(projects.featureMyApi) diff --git a/Prezel/feature/analysis/api/build.gradle.kts b/Prezel/feature/analysis/api/build.gradle.kts new file mode 100644 index 00000000..0f9cb9df --- /dev/null +++ b/Prezel/feature/analysis/api/build.gradle.kts @@ -0,0 +1,7 @@ +plugins { + alias(libs.plugins.prezel.android.feature.api) +} + +android { + namespace = "com.team.prezel.feature.analysis.api" +} diff --git a/Prezel/feature/analysis/api/src/main/java/com/team/prezel/feature/analysis/api/AnalysisNavKey.kt b/Prezel/feature/analysis/api/src/main/java/com/team/prezel/feature/analysis/api/AnalysisNavKey.kt new file mode 100644 index 00000000..75b780ec --- /dev/null +++ b/Prezel/feature/analysis/api/src/main/java/com/team/prezel/feature/analysis/api/AnalysisNavKey.kt @@ -0,0 +1,10 @@ +package com.team.prezel.feature.analysis.api + +import androidx.navigation3.runtime.NavKey +import kotlinx.serialization.Serializable + +@Serializable +sealed interface AnalysisNavKey : NavKey { + @Serializable + data object Create : AnalysisNavKey +} diff --git a/Prezel/feature/analysis/impl/build.gradle.kts b/Prezel/feature/analysis/impl/build.gradle.kts new file mode 100644 index 00000000..2e5ff7fe --- /dev/null +++ b/Prezel/feature/analysis/impl/build.gradle.kts @@ -0,0 +1,16 @@ +plugins { + alias(libs.plugins.prezel.android.feature.impl) +} + +android { + namespace = "com.team.prezel.feature.analysis.impl" +} + +dependencies { + implementation(projects.coreModel) + implementation(projects.featureAnalysisApi) + implementation(projects.featureHomeApi) + + implementation(libs.kotlinx.collections.immutable) + implementation(libs.kotlinx.datetime) +} diff --git a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AnalysisFlowViewModel.kt b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AnalysisFlowViewModel.kt new file mode 100644 index 00000000..562c961b --- /dev/null +++ b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AnalysisFlowViewModel.kt @@ -0,0 +1,76 @@ +package com.team.prezel.feature.analysis.impl + +import androidx.lifecycle.viewModelScope +import com.team.prezel.core.ui.base.BaseViewModel +import com.team.prezel.feature.analysis.impl.contract.AnalysisFlowStep +import com.team.prezel.feature.analysis.impl.contract.AnalysisFlowUiEffect +import com.team.prezel.feature.analysis.impl.contract.AnalysisFlowUiIntent +import com.team.prezel.feature.analysis.impl.contract.AnalysisFlowUiState +import com.team.prezel.feature.analysis.impl.contract.AnalysisForm +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +internal class AnalysisFlowViewModel @Inject constructor() : + BaseViewModel(AnalysisFlowUiState()) { + override fun onIntent(intent: AnalysisFlowUiIntent) { + when (intent) { + is AnalysisFlowUiIntent.UpdatePresentationTitle -> updateForm { copy(presentationTitle = intent.title) } + is AnalysisFlowUiIntent.UpdatePresentationDate -> updateForm { copy(presentationDate = intent.date) } + is AnalysisFlowUiIntent.SelectCategory -> updateForm { copy(category = intent.category) } + is AnalysisFlowUiIntent.SelectPurpose -> updateForm { copy(purpose = intent.purpose) } + is AnalysisFlowUiIntent.SelectStyle -> updateForm { copy(style = intent.style) } + is AnalysisFlowUiIntent.SelectAudience -> updateForm { copy(audience = intent.audience) } + is AnalysisFlowUiIntent.SelectScriptInputType -> updateForm { copy(scriptInputType = intent.inputType) } + is AnalysisFlowUiIntent.UpdateScript -> updateForm { copy(script = intent.script) } + is AnalysisFlowUiIntent.SelectScriptFile -> updateForm { copy(scriptFileUri = intent.fileUri) } + is AnalysisFlowUiIntent.SelectAudioFile -> updateForm { copy(audioFileUri = intent.fileUri) } + AnalysisFlowUiIntent.Next -> moveNext() + AnalysisFlowUiIntent.SkipScript -> skipScript() + AnalysisFlowUiIntent.Back -> moveBack() + } + } + + private fun moveNext() { + if (!currentState.canMoveNext && currentState.step != AnalysisFlowStep.ANALYZING) return + + updateState { + copy( + step = when (step) { + AnalysisFlowStep.PRESENTATION_SCHEDULE -> AnalysisFlowStep.PRESENTATION_SITUATION + AnalysisFlowStep.PRESENTATION_SITUATION -> AnalysisFlowStep.SCRIPT_INPUT + AnalysisFlowStep.SCRIPT_INPUT -> AnalysisFlowStep.AUDIO_UPLOAD + AnalysisFlowStep.AUDIO_UPLOAD -> AnalysisFlowStep.ANALYZING + AnalysisFlowStep.ANALYZING -> AnalysisFlowStep.ANALYZING + }, + ) + } + } + + private fun skipScript() { + if (currentState.step != AnalysisFlowStep.SCRIPT_INPUT) return + + updateState { copy(step = AnalysisFlowStep.AUDIO_UPLOAD) } + } + + private fun moveBack() { + val previousStep = when (currentState.step) { + AnalysisFlowStep.PRESENTATION_SCHEDULE -> null + AnalysisFlowStep.PRESENTATION_SITUATION -> AnalysisFlowStep.PRESENTATION_SCHEDULE + AnalysisFlowStep.SCRIPT_INPUT -> AnalysisFlowStep.PRESENTATION_SITUATION + AnalysisFlowStep.AUDIO_UPLOAD -> AnalysisFlowStep.SCRIPT_INPUT + AnalysisFlowStep.ANALYZING -> AnalysisFlowStep.AUDIO_UPLOAD + } + + if (previousStep == null) { + viewModelScope.launch { sendEffect(AnalysisFlowUiEffect.NavigateBack) } + } else { + updateState { copy(step = previousStep) } + } + } + + private fun updateForm(reducer: AnalysisForm.() -> AnalysisForm) { + updateState { copy(form = form.reducer()) } + } + } diff --git a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AnalysisScreen.kt b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AnalysisScreen.kt new file mode 100644 index 00000000..cde90d59 --- /dev/null +++ b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AnalysisScreen.kt @@ -0,0 +1,82 @@ +package com.team.prezel.feature.analysis.impl + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.team.prezel.feature.analysis.impl.contract.AnalysisFlowStep +import com.team.prezel.feature.analysis.impl.contract.AnalysisFlowUiEffect +import com.team.prezel.feature.analysis.impl.contract.AnalysisFlowUiIntent +import com.team.prezel.feature.analysis.impl.contract.AnalysisFlowUiState + +@Composable +internal fun AnalysisScreen( + onFinished: () -> Unit, + onBack: () -> Unit, + viewModel: AnalysisFlowViewModel = hiltViewModel(), +) { + val uiState = viewModel.uiState.collectAsStateWithLifecycle().value + + LaunchedEffect(Unit) { + viewModel.uiEffect.collect { effect -> + when (effect) { + AnalysisFlowUiEffect.NavigateBack -> onBack() + } + } + } + + AnalysisScreen( + uiState = uiState, + onIntent = viewModel::onIntent, + onFinished = onFinished, + ) +} + +@Composable +private fun AnalysisScreen( + uiState: AnalysisFlowUiState, + onIntent: (AnalysisFlowUiIntent) -> Unit, + onFinished: () -> Unit, +) { + when (uiState.step) { + AnalysisFlowStep.PRESENTATION_SCHEDULE -> PresentationScheduleScreen( + uiState = uiState, + onTitleChange = { onIntent(AnalysisFlowUiIntent.UpdatePresentationTitle(it)) }, + onDateChange = { onIntent(AnalysisFlowUiIntent.UpdatePresentationDate(it)) }, + onNext = { onIntent(AnalysisFlowUiIntent.Next) }, + onBack = { onIntent(AnalysisFlowUiIntent.Back) }, + ) + + AnalysisFlowStep.PRESENTATION_SITUATION -> PresentationSituationScreen( + uiState = uiState, + onSelectCategory = { onIntent(AnalysisFlowUiIntent.SelectCategory(it)) }, + onSelectPurpose = { onIntent(AnalysisFlowUiIntent.SelectPurpose(it)) }, + onSelectStyle = { onIntent(AnalysisFlowUiIntent.SelectStyle(it)) }, + onSelectAudience = { onIntent(AnalysisFlowUiIntent.SelectAudience(it)) }, + onNext = { onIntent(AnalysisFlowUiIntent.Next) }, + onBack = { onIntent(AnalysisFlowUiIntent.Back) }, + ) + + AnalysisFlowStep.SCRIPT_INPUT -> ScriptInputScreen( + uiState = uiState, + onSelectInputType = { onIntent(AnalysisFlowUiIntent.SelectScriptInputType(it)) }, + onScriptChange = { onIntent(AnalysisFlowUiIntent.UpdateScript(it)) }, + onScriptFileSelected = { onIntent(AnalysisFlowUiIntent.SelectScriptFile(it)) }, + onNext = { onIntent(AnalysisFlowUiIntent.Next) }, + onSkip = { onIntent(AnalysisFlowUiIntent.SkipScript) }, + onBack = { onIntent(AnalysisFlowUiIntent.Back) }, + ) + + AnalysisFlowStep.AUDIO_UPLOAD -> AudioUploadScreen( + uiState = uiState, + onAudioFileSelected = { onIntent(AnalysisFlowUiIntent.SelectAudioFile(it)) }, + onAnalyze = { onIntent(AnalysisFlowUiIntent.Next) }, + onBack = { onIntent(AnalysisFlowUiIntent.Back) }, + ) + + AnalysisFlowStep.ANALYZING -> AnalyzingScreen( + onFinished = onFinished, + onBack = { onIntent(AnalysisFlowUiIntent.Back) }, + ) + } +} diff --git a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AnalyzingScreen.kt b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AnalyzingScreen.kt new file mode 100644 index 00000000..e072a98e --- /dev/null +++ b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AnalyzingScreen.kt @@ -0,0 +1,69 @@ +package com.team.prezel.feature.analysis.impl + +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.material3.CircularProgressIndicator +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +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.feature.analysis.impl.component.AnalysisStepLayout +import kotlinx.coroutines.delay + +@Composable +internal fun AnalyzingScreen( + onFinished: () -> Unit, + onBack: () -> Unit, +) { + LaunchedEffect(Unit) { + delay(1_200) + onFinished() + } + + AnalyzingScreen(onBack = onBack) +} + +@Composable +private fun AnalyzingScreen(onBack: () -> Unit) { + AnalysisStepLayout( + title = stringResource(R.string.feature_analysis_impl_analyzing_title), + progress = 1f, + buttonText = stringResource(R.string.feature_analysis_impl_analyzing_title), + buttonEnabled = false, + onButtonClick = {}, + onBack = onBack, + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(320.dp), + contentAlignment = Alignment.Center, + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + CircularProgressIndicator(color = PrezelTheme.colors.interactiveRegular) + Spacer(modifier = Modifier.height(PrezelTheme.spacing.V20)) + Text( + text = stringResource(R.string.feature_analysis_impl_analyzing_headline), + color = PrezelTheme.colors.textLarge, + style = PrezelTheme.typography.title2Bold, + ) + } + } + } +} + +@BasicPreview +@Composable +private fun AnalyzingScreenPreview() { + PrezelTheme { + AnalyzingScreen(onBack = {}) + } +} diff --git a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AudioUploadScreen.kt b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AudioUploadScreen.kt new file mode 100644 index 00000000..1cc7257a --- /dev/null +++ b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AudioUploadScreen.kt @@ -0,0 +1,314 @@ +package com.team.prezel.feature.analysis.impl + +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Icon +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.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.ButtonSize +import com.team.prezel.core.designsystem.component.actions.button.config.ButtonType +import com.team.prezel.core.designsystem.component.base.PrezelTouchArea +import com.team.prezel.core.designsystem.component.navigations.PrezelTabSize +import com.team.prezel.core.designsystem.component.navigations.PrezelTabs +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.analysis.impl.component.AnalysisStepLayout +import com.team.prezel.feature.analysis.impl.component.AnalysisStepTitle +import com.team.prezel.feature.analysis.impl.component.toFileName +import com.team.prezel.feature.analysis.impl.contract.AnalysisFlowStep +import com.team.prezel.feature.analysis.impl.contract.AnalysisFlowUiState +import com.team.prezel.feature.analysis.impl.contract.AnalysisForm +import kotlinx.collections.immutable.persistentListOf + +private const val AUDIO_FILE_MIME_TYPE = "audio/*" +private const val AUDIO_PREVIEW_FILE_URI = "content://prezel/sample.wav" +private const val AUDIO_UPLOAD_TAB_COUNT = 1 +private const val UPLOADED_AUDIO_PROGRESS = 0f + +@Composable +internal fun AudioUploadScreen( + uiState: AnalysisFlowUiState, + onAudioFileSelected: (String?) -> Unit, + onAnalyze: () -> Unit, + onBack: () -> Unit, +) { + val audioPicker = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri -> + onAudioFileSelected(uri?.toString()) + } + + AudioUploadScreen( + form = uiState.form, + progress = uiState.progress, + buttonEnabled = uiState.canMoveNext, + onAudioFileUploadClick = { audioPicker.launch(AUDIO_FILE_MIME_TYPE) }, + onAudioFileClear = { onAudioFileSelected(null) }, + onAnalyze = onAnalyze, + onBack = onBack, + ) +} + +@Composable +private fun AudioUploadScreen( + form: AnalysisForm, + progress: Float, + buttonEnabled: Boolean, + onAudioFileUploadClick: () -> Unit, + onAudioFileClear: () -> Unit, + onAnalyze: () -> Unit, + onBack: () -> Unit, +) { + val pagerState = rememberPagerState { AUDIO_UPLOAD_TAB_COUNT } + val tabs = persistentListOf(stringResource(R.string.feature_analysis_impl_upload_title)) + + AnalysisStepLayout( + title = stringResource(R.string.feature_analysis_impl_audio_title), + progress = progress, + buttonText = stringResource(R.string.feature_analysis_impl_analyze), + buttonEnabled = buttonEnabled, + onButtonClick = onAnalyze, + onBack = onBack, + ) { + AnalysisStepTitle( + title = stringResource(R.string.feature_analysis_impl_audio_headline), + description = stringResource(R.string.feature_analysis_impl_audio_description), + ) + + Spacer(modifier = Modifier.height(PrezelTheme.spacing.V32)) + + PrezelTabs( + tabs = tabs, + pagerState = pagerState, + size = PrezelTabSize.MEDIUM, + onClickTab = {}, + ) + + AudioUploadContent( + fileUri = form.audioFileUri, + onUploadClick = onAudioFileUploadClick, + onClear = onAudioFileClear, + ) + } +} + +@Composable +private fun AudioUploadContent( + fileUri: String?, + onUploadClick: () -> Unit, + onClear: () -> Unit, +) { + if (fileUri == null) { + AudioUploadEmptyContent(onUploadClick = onUploadClick) + } else { + Spacer(modifier = Modifier.height(PrezelTheme.spacing.V16)) + UploadedAudioFileCard( + fileName = fileUri.toFileName(), + onClear = onClear, + ) + } +} + +@Composable +private fun AudioUploadEmptyContent(onUploadClick: () -> Unit) { + StatusView( + title = stringResource(R.string.feature_analysis_impl_audio_file_placeholder), + description = stringResource(R.string.feature_analysis_impl_audio_file_format), + modifier = Modifier + .fillMaxWidth() + .height(320.dp), + visual = { + Image( + painter = painterResource(R.drawable.feature_analysis_impl_no_voice), + contentDescription = null, + modifier = Modifier.size(120.dp), + ) + }, + action = { + PrezelButton( + text = stringResource(R.string.feature_analysis_impl_audio_upload_button), + iconResId = PrezelIcons.Plus, + type = ButtonType.OUTLINED, + size = ButtonSize.REGULAR, + isRounded = true, + onClick = onUploadClick, + ) + }, + ) +} + +@Composable +private fun UploadedAudioFileCard( + fileName: String, + onClear: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .border( + width = PrezelTheme.stroke.V1, + color = PrezelTheme.colors.borderSmall, + shape = PrezelTheme.shapes.V8, + ).padding( + horizontal = PrezelTheme.spacing.V16, + vertical = PrezelTheme.spacing.V16, + ), + verticalAlignment = Alignment.CenterVertically, + ) { + UploadedAudioFileInfo( + fileName = fileName, + modifier = Modifier.weight(1f), + ) + + Spacer(modifier = Modifier.size(PrezelTheme.spacing.V12)) + + PrezelTouchArea( + extraTouchPadding = PaddingValues(PrezelTheme.spacing.V8), + onClick = onClear, + ) { + Icon( + painter = painterResource(PrezelIcons.CancelCircleFilled), + contentDescription = stringResource(R.string.feature_analysis_impl_audio_file_remove), + modifier = Modifier.size(24.dp), + tint = PrezelTheme.colors.iconRegular, + ) + } + } +} + +@Composable +private fun UploadedAudioFileInfo( + fileName: String, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier) { + AudioFileTitleRow(fileName = fileName) + + Spacer(modifier = Modifier.height(PrezelTheme.spacing.V8)) + + AudioFileProgressRow() + } +} + +@Composable +private fun AudioFileTitleRow(fileName: String) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + painter = painterResource(PrezelIcons.Play), + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = PrezelTheme.colors.iconRegular, + ) + Spacer(modifier = Modifier.size(PrezelTheme.spacing.V8)) + Text( + text = fileName, + modifier = Modifier.weight(1f), + color = PrezelTheme.colors.textMedium, + style = PrezelTheme.typography.body3Medium, + ) + } +} + +@Composable +private fun AudioFileProgressRow() { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(R.string.feature_analysis_impl_audio_file_duration_placeholder), + color = PrezelTheme.colors.textSmall, + style = PrezelTheme.typography.caption2Regular, + ) + Spacer(modifier = Modifier.size(PrezelTheme.spacing.V8)) + AudioProgressTrack( + progress = UPLOADED_AUDIO_PROGRESS, + modifier = Modifier.weight(1f), + ) + } +} + +@Composable +private fun AudioProgressTrack( + progress: Float, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier.height(16.dp), + contentAlignment = Alignment.CenterStart, + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(4.dp) + .clip(CircleShape) + .background(PrezelTheme.colors.bgDisabled), + ) + Box( + modifier = Modifier + .fillMaxWidth(progress.coerceIn(0f, 1f)) + .height(6.dp) + .clip(CircleShape) + .background(PrezelTheme.colors.interactiveRegular), + ) + Box( + modifier = Modifier + .size(10.dp) + .clip(CircleShape) + .background(PrezelTheme.colors.interactiveRegular), + ) + } +} + +@BasicPreview +@Composable +private fun AudioUploadScreenPreview() { + PrezelTheme { + AudioUploadScreen( + uiState = AnalysisFlowUiState(step = AnalysisFlowStep.AUDIO_UPLOAD), + onAudioFileSelected = {}, + onAnalyze = {}, + onBack = {}, + ) + } +} + +@BasicPreview +@Composable +private fun AudioUploadScreenSelectedPreview() { + PrezelTheme { + AudioUploadScreen( + uiState = AnalysisFlowUiState( + step = AnalysisFlowStep.AUDIO_UPLOAD, + form = AnalysisForm(audioFileUri = AUDIO_PREVIEW_FILE_URI), + ), + onAudioFileSelected = {}, + onAnalyze = {}, + onBack = {}, + ) + } +} diff --git a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/PresentationScheduleScreen.kt b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/PresentationScheduleScreen.kt new file mode 100644 index 00000000..3aea41c3 --- /dev/null +++ b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/PresentationScheduleScreen.kt @@ -0,0 +1,183 @@ +package com.team.prezel.feature.analysis.impl + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +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.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import com.team.prezel.core.designsystem.component.datepicker.PrezelDatePicker +import com.team.prezel.core.designsystem.component.textfield.PrezelTextField +import com.team.prezel.core.designsystem.icon.PrezelIcons +import com.team.prezel.core.designsystem.preview.BasicPreview +import com.team.prezel.core.designsystem.theme.PrezelTheme +import com.team.prezel.feature.analysis.impl.component.AnalysisStepLayout +import com.team.prezel.feature.analysis.impl.component.AnalysisStepTitle +import com.team.prezel.feature.analysis.impl.contract.AnalysisFlowUiState +import com.team.prezel.feature.analysis.impl.contract.AnalysisForm +import kotlinx.datetime.LocalDate + +@Composable +internal fun PresentationScheduleScreen( + uiState: AnalysisFlowUiState, + onTitleChange: (String) -> Unit, + onDateChange: (String) -> Unit, + onNext: () -> Unit, + onBack: () -> Unit, +) { + PresentationScheduleScreen( + form = uiState.form, + progress = uiState.progress, + buttonEnabled = uiState.canMoveNext, + onTitleChange = onTitleChange, + onDateChange = onDateChange, + onNext = onNext, + onBack = onBack, + ) +} + +@Composable +private fun PresentationScheduleScreen( + form: AnalysisForm, + progress: Float, + buttonEnabled: Boolean, + onTitleChange: (String) -> Unit, + onDateChange: (String) -> Unit, + onNext: () -> Unit, + onBack: () -> Unit, +) { + val showDatePicker = rememberSaveable { mutableStateOf(false) } + val dateFieldInteractionSource = remember { MutableInteractionSource() } + val datePickerTitle = stringResource(R.string.feature_analysis_impl_presentation_date_label) + + if (showDatePicker.value) { + PrezelDatePicker( + title = datePickerTitle, + initialSelectedDate = form.presentationDate.toLocalDateOrNull(), + onClose = { showDatePicker.value = false }, + onConfirm = { selectedDate -> + onDateChange(selectedDate.toPresentationDateText()) + showDatePicker.value = false + }, + ) + return + } + + AnalysisStepLayout( + title = stringResource(R.string.feature_analysis_impl_schedule_title), + progress = progress, + buttonText = stringResource(R.string.feature_analysis_impl_next), + buttonEnabled = buttonEnabled, + onButtonClick = onNext, + onBack = onBack, + ) { + AnalysisStepTitle( + title = stringResource(R.string.feature_analysis_impl_schedule_headline), + description = stringResource(R.string.feature_analysis_impl_schedule_description), + ) + + Spacer(modifier = Modifier.height(PrezelTheme.spacing.V32)) + + PrezelTextField( + label = stringResource(R.string.feature_analysis_impl_presentation_name_label), + value = form.presentationTitle, + onValueChange = onTitleChange, + placeholder = stringResource(R.string.feature_analysis_impl_presentation_name_placeholder), + ) + + Spacer(modifier = Modifier.height(PrezelTheme.spacing.V32)) + + Column(modifier = Modifier.fillMaxWidth()) { + Text( + text = stringResource(R.string.feature_analysis_impl_presentation_date_label), + style = PrezelTheme.typography.body3Medium, + color = PrezelTheme.colors.textMedium, + maxLines = 1, + ) + + Spacer(modifier = Modifier.height(PrezelTheme.spacing.V8)) + + Box(modifier = Modifier.fillMaxWidth()) { + PrezelTextField( + value = form.presentationDate, + onValueChange = {}, + placeholder = stringResource(R.string.feature_analysis_impl_presentation_date_placeholder), + trailingIcon = { + Icon( + painter = painterResource(PrezelIcons.Calendar), + contentDescription = datePickerTitle, + ) + }, + ) + + Box( + modifier = Modifier + .matchParentSize() + .clickable( + interactionSource = dateFieldInteractionSource, + indication = null, + onClick = { showDatePicker.value = true }, + ), + ) + } + } + } +} + +private fun LocalDate.toPresentationDateText(): String = "%04d.%02d.%02d".format(year, month.ordinal + 1, day) + +private fun String.toLocalDateOrNull(): LocalDate? = + runCatching { + val (year, month, day) = split(".") + + LocalDate( + year = year.toInt(), + month = month.toInt(), + day = day.toInt(), + ) + }.getOrNull() + +@BasicPreview +@Composable +private fun PresentationScheduleScreenPreview() { + PrezelTheme { + PresentationScheduleScreen( + form = AnalysisForm(), + progress = 0.25f, + buttonEnabled = false, + onNext = {}, + onBack = {}, + onTitleChange = {}, + onDateChange = {}, + ) + } +} + +@BasicPreview +@Composable +private fun PresentationScheduleScreenDateSelectedPreview() { + PrezelTheme { + PresentationScheduleScreen( + form = AnalysisForm( + presentationTitle = "졸업 발표", + presentationDate = "2026.05.09", + ), + progress = 0.25f, + buttonEnabled = true, + onNext = {}, + onBack = {}, + onTitleChange = {}, + onDateChange = {}, + ) + } +} diff --git a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/PresentationSituationScreen.kt b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/PresentationSituationScreen.kt new file mode 100644 index 00000000..98f54cec --- /dev/null +++ b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/PresentationSituationScreen.kt @@ -0,0 +1,343 @@ +package com.team.prezel.feature.analysis.impl + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +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.PrezelAccordion +import com.team.prezel.core.designsystem.component.base.PrezelTouchArea +import com.team.prezel.core.designsystem.component.chip.PrezelChip +import com.team.prezel.core.designsystem.component.chip.config.PrezelChipInteraction +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.presentation.Audience +import com.team.prezel.core.model.presentation.Category +import com.team.prezel.core.model.presentation.Purpose +import com.team.prezel.core.model.presentation.Style +import com.team.prezel.feature.analysis.impl.component.AnalysisStepLayout +import com.team.prezel.feature.analysis.impl.component.AnalysisStepTitle +import com.team.prezel.feature.analysis.impl.contract.AnalysisFlowStep +import com.team.prezel.feature.analysis.impl.contract.AnalysisFlowUiState +import com.team.prezel.feature.analysis.impl.contract.AnalysisForm +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList + +@Composable +internal fun PresentationSituationScreen( + uiState: AnalysisFlowUiState, + onSelectCategory: (Category) -> Unit, + onSelectPurpose: (Purpose) -> Unit, + onSelectStyle: (Style) -> Unit, + onSelectAudience: (Audience) -> Unit, + onNext: () -> Unit, + onBack: () -> Unit, +) { + PresentationSituationScreen( + form = uiState.form, + progress = uiState.progress, + buttonEnabled = uiState.canMoveNext, + onSelectCategory = onSelectCategory, + onSelectPurpose = onSelectPurpose, + onSelectStyle = onSelectStyle, + onSelectAudience = onSelectAudience, + onNext = onNext, + onBack = onBack, + ) +} + +@Composable +private fun PresentationSituationScreen( + form: AnalysisForm, + progress: Float, + buttonEnabled: Boolean, + onSelectCategory: (Category) -> Unit, + onSelectPurpose: (Purpose) -> Unit, + onSelectStyle: (Style) -> Unit, + onSelectAudience: (Audience) -> Unit, + onNext: () -> Unit, + onBack: () -> Unit, +) { + val categoryOptions = categoryOptions() + val purposeOptions = purposeOptions() + val styleOptions = styleOptions() + val audienceOptions = audienceOptions() + + AnalysisStepLayout( + title = stringResource(R.string.feature_analysis_impl_situation_title), + progress = progress, + buttonText = stringResource(R.string.feature_analysis_impl_next), + buttonEnabled = buttonEnabled, + onButtonClick = onNext, + onBack = onBack, + ) { + AnalysisStepTitle( + title = stringResource(R.string.feature_analysis_impl_situation_headline), + description = stringResource(R.string.feature_analysis_impl_situation_description), + ) + + Spacer(modifier = Modifier.height(PrezelTheme.spacing.V32)) + + SituationAccordion( + title = stringResource(R.string.feature_analysis_impl_situation_category_label), + ) { + CategoryOptionGrid( + selectedValue = form.category, + options = categoryOptions, + onSelect = onSelectCategory, + ) + } + SituationAccordion( + title = stringResource(R.string.feature_analysis_impl_situation_purpose_label), + ) { + ChipOptionsContent( + options = purposeOptions.toChipContentOptions(form.purpose), + onSelect = { index -> onSelectPurpose(purposeOptions[index].value) }, + ) + } + SituationAccordion( + title = stringResource(R.string.feature_analysis_impl_situation_style_label), + ) { + ChipOptionsContent( + options = styleOptions.toChipContentOptions(form.style), + onSelect = { index -> onSelectStyle(styleOptions[index].value) }, + ) + } + SituationAccordion( + title = stringResource(R.string.feature_analysis_impl_situation_scale_label), + ) { + ChipOptionsContent( + options = audienceOptions.toChipContentOptions(form.audience), + onSelect = { index -> onSelectAudience(audienceOptions[index].value) }, + ) + } + } +} + +@Composable +private fun SituationAccordion( + title: String, + content: @Composable () -> Unit, +) { + PrezelAccordion( + title = title, + showDivider = true, + content = content, + ) +} + +@Composable +private fun CategoryOptionGrid( + selectedValue: Category?, + options: ImmutableList, + onSelect: (Category) -> Unit, +) { + Column(verticalArrangement = Arrangement.spacedBy(PrezelTheme.spacing.V16)) { + options.chunked(2).forEach { rowOptions -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(PrezelTheme.spacing.V16), + ) { + rowOptions.forEach { option -> + CategoryOptionCard( + option = option, + selected = selectedValue == option.value, + onClick = { onSelect(option.value) }, + modifier = Modifier.weight(1f), + ) + } + if (rowOptions.size == 1) { + Spacer(modifier = Modifier.weight(1f)) + } + } + } + } +} + +@Composable +private fun CategoryOptionCard( + option: SituationCategoryOption, + selected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val borderColor = if (selected) PrezelTheme.colors.interactiveRegular else PrezelTheme.colors.bgMedium + val backgroundColor = if (selected) PrezelTheme.colors.interactiveXSmall else PrezelTheme.colors.bgMedium + val iconColor = if (selected) PrezelTheme.colors.interactiveRegular else PrezelTheme.colors.iconRegular + + PrezelTouchArea( + onClick = onClick, + shape = PrezelTheme.shapes.V8, + modifier = modifier, + isUseRipple = false, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .background(backgroundColor, PrezelTheme.shapes.V8) + .border( + width = PrezelTheme.stroke.V1, + color = borderColor, + shape = PrezelTheme.shapes.V8, + ).padding(PrezelTheme.spacing.V12), + ) { + Icon( + painter = painterResource(option.iconResId), + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = iconColor, + ) + Spacer(modifier = Modifier.height(PrezelTheme.spacing.V4)) + Text( + text = option.title, + color = PrezelTheme.colors.textLarge, + style = PrezelTheme.typography.body2Medium, + ) + Spacer(modifier = Modifier.height(PrezelTheme.spacing.V4)) + Text( + text = option.description, + color = PrezelTheme.colors.textRegular, + style = PrezelTheme.typography.caption2Regular, + ) + } + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun ChipOptionsContent( + options: ImmutableList, + onSelect: (Int) -> Unit, +) { + FlowRow( + modifier = Modifier + .fillMaxWidth() + .background(PrezelTheme.colors.bgMedium) + .padding(horizontal = PrezelTheme.spacing.V12, vertical = PrezelTheme.spacing.V14), + horizontalArrangement = Arrangement.spacedBy(PrezelTheme.spacing.V10), + verticalArrangement = Arrangement.spacedBy(PrezelTheme.spacing.V10), + ) { + options.forEachIndexed { index, option -> + SituationChip( + text = option.text, + selected = option.selected, + onClick = { onSelect(index) }, + ) + } + } +} + +private fun > ImmutableList>.toChipContentOptions(selectedValue: T?): ImmutableList = + map { option -> + SituationChipContentOption( + text = option.text, + selected = selectedValue == option.value, + ) + }.toImmutableList() + +@Composable +private fun SituationChip( + text: String, + selected: Boolean, + onClick: () -> Unit, +) { + PrezelTouchArea( + onClick = onClick, + shape = PrezelTheme.shapes.V8, + isUseRipple = false, + ) { + PrezelChip( + text = text, + iconResId = if (selected) PrezelIcons.Check else null, + type = PrezelChipType.OUTLINED, + size = PrezelChipSize.REGULAR, + interaction = if (selected) PrezelChipInteraction.ACTIVE else PrezelChipInteraction.DEFAULT, + ) + } +} + +@BasicPreview +@Composable +private fun PresentationSituationScreenPreview() { + PrezelTheme { + PresentationSituationScreen( + uiState = AnalysisFlowUiState( + step = AnalysisFlowStep.PRESENTATION_SITUATION, + form = AnalysisForm( + category = Category.EDUCATION, + purpose = Purpose.CONTENT_DELIVERY, + style = Style.CALM, + audience = Audience.EXPERT, + ), + ), + onSelectCategory = {}, + onSelectPurpose = {}, + onSelectStyle = {}, + onSelectAudience = {}, + onNext = {}, + onBack = {}, + ) + } +} + +@BasicPreview +@Composable +private fun CategoryOptionGridPreview() { + PrezelTheme { + CategoryOptionGrid( + selectedValue = Category.EDUCATION, + options = categoryOptions(), + onSelect = {}, + ) + } +} + +@BasicPreview +@Composable +private fun PurposeOptionsPreview() { + PrezelTheme { + ChipOptionsContent( + options = purposeOptions().toChipContentOptions(Purpose.CONTENT_DELIVERY), + onSelect = {}, + ) + } +} + +@BasicPreview +@Composable +private fun StyleOptionsPreview() { + PrezelTheme { + ChipOptionsContent( + options = styleOptions().toChipContentOptions(Style.CALM), + onSelect = {}, + ) + } +} + +@BasicPreview +@Composable +private fun AudienceOptionsPreview() { + PrezelTheme { + ChipOptionsContent( + options = audienceOptions().toChipContentOptions(Audience.EXPERT), + onSelect = {}, + ) + } +} diff --git a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/ScriptInputScreen.kt b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/ScriptInputScreen.kt new file mode 100644 index 00000000..7dea7cc7 --- /dev/null +++ b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/ScriptInputScreen.kt @@ -0,0 +1,319 @@ +package com.team.prezel.feature.analysis.impl + +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.material3.Icon +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +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.ButtonSize +import com.team.prezel.core.designsystem.component.actions.button.config.ButtonType +import com.team.prezel.core.designsystem.component.base.PrezelTouchArea +import com.team.prezel.core.designsystem.component.list.PrezelList +import com.team.prezel.core.designsystem.component.list.PrezelListSize +import com.team.prezel.core.designsystem.component.navigations.PrezelTabSize +import com.team.prezel.core.designsystem.component.navigations.PrezelTabs +import com.team.prezel.core.designsystem.component.textfield.PrezelTextArea +import com.team.prezel.core.designsystem.icon.PrezelIcons +import com.team.prezel.core.designsystem.preview.BasicPreview +import com.team.prezel.core.designsystem.theme.PrezelTheme +import com.team.prezel.feature.analysis.impl.component.AnalysisStepLayout +import com.team.prezel.feature.analysis.impl.component.AnalysisStepTitle +import com.team.prezel.feature.analysis.impl.component.toFileName +import com.team.prezel.feature.analysis.impl.contract.AnalysisFlowStep +import com.team.prezel.feature.analysis.impl.contract.AnalysisFlowUiState +import com.team.prezel.feature.analysis.impl.contract.AnalysisForm +import com.team.prezel.feature.analysis.impl.contract.ScriptInputType +import kotlinx.collections.immutable.persistentListOf + +private const val SCRIPT_MAX_LENGTH = 5_000 +private const val SCRIPT_FILE_MIME_TYPE = "text/*" +private const val SCRIPT_INPUT_TAB_COUNT = 2 + +@Composable +internal fun ScriptInputScreen( + uiState: AnalysisFlowUiState, + onSelectInputType: (ScriptInputType) -> Unit, + onScriptChange: (String) -> Unit, + onScriptFileSelected: (String?) -> Unit, + onNext: () -> Unit, + onSkip: () -> Unit, + onBack: () -> Unit, +) { + val scriptPicker = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri -> + onScriptFileSelected(uri?.toString()) + } + + ScriptInputScreen( + form = uiState.form, + progress = uiState.progress, + buttonEnabled = uiState.canMoveNext, + onSelectInputType = onSelectInputType, + onScriptChange = onScriptChange, + onScriptFileUploadClick = { scriptPicker.launch(SCRIPT_FILE_MIME_TYPE) }, + onScriptFileClear = { onScriptFileSelected(null) }, + onNext = onNext, + onSkip = onSkip, + onBack = onBack, + ) +} + +@Composable +private fun ScriptInputScreen( + form: AnalysisForm, + progress: Float, + buttonEnabled: Boolean, + onSelectInputType: (ScriptInputType) -> Unit, + onScriptChange: (String) -> Unit, + onScriptFileUploadClick: () -> Unit, + onScriptFileClear: () -> Unit, + onNext: () -> Unit, + onSkip: () -> Unit, + onBack: () -> Unit, +) { + AnalysisStepLayout( + title = stringResource(R.string.feature_analysis_impl_script_title), + progress = progress, + buttonText = stringResource(R.string.feature_analysis_impl_next), + buttonEnabled = buttonEnabled, + onButtonClick = onNext, + onBack = onBack, + trailingText = stringResource(R.string.feature_analysis_impl_skip), + onTrailingTextClick = onSkip, + ) { + AnalysisStepTitle( + title = stringResource(R.string.feature_analysis_impl_script_headline), + description = stringResource(R.string.feature_analysis_impl_script_description), + ) + + Spacer(modifier = Modifier.height(PrezelTheme.spacing.V32)) + + ScriptInputTabs( + selectedType = form.scriptInputType, + onSelect = onSelectInputType, + ) + + Spacer(modifier = Modifier.height(PrezelTheme.spacing.V16)) + + when (form.scriptInputType) { + ScriptInputType.FILE_UPLOAD -> ScriptUploadCard( + fileUri = form.scriptFileUri, + onClick = onScriptFileUploadClick, + onClear = onScriptFileClear, + ) + + ScriptInputType.DIRECT_INPUT -> { + PrezelTextArea( + value = form.script, + onValueChange = onScriptChange, + placeholder = stringResource(R.string.feature_analysis_impl_script_placeholder), + maxLength = SCRIPT_MAX_LENGTH, + modifier = Modifier.fillMaxWidth(), + ) + } + } + } +} + +@Composable +private fun ScriptInputTabs( + selectedType: ScriptInputType, + onSelect: (ScriptInputType) -> Unit, +) { + val selectedPage = selectedType.toTabPage() + val pagerState = rememberPagerState(initialPage = selectedPage) { SCRIPT_INPUT_TAB_COUNT } + val tabs = persistentListOf( + stringResource(R.string.feature_analysis_impl_upload_title), + stringResource(R.string.feature_analysis_impl_direct_input_title), + ) + + LaunchedEffect(selectedPage) { + if (pagerState.currentPage != selectedPage) { + pagerState.requestScrollToPage(selectedPage) + } + } + + PrezelTabs( + tabs = tabs, + pagerState = pagerState, + size = PrezelTabSize.MEDIUM, + onClickTab = { page -> + pagerState.requestScrollToPage(page) + onSelect(page.toScriptInputType()) + }, + ) +} + +private fun ScriptInputType.toTabPage(): Int = + when (this) { + ScriptInputType.FILE_UPLOAD -> 0 + ScriptInputType.DIRECT_INPUT -> 1 + } + +private fun Int.toScriptInputType(): ScriptInputType = + when (this) { + 0 -> ScriptInputType.FILE_UPLOAD + else -> ScriptInputType.DIRECT_INPUT + } + +@Composable +private fun ScriptUploadCard( + fileUri: String?, + onClick: () -> Unit, + onClear: () -> Unit, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .background(PrezelTheme.colors.bgMedium, PrezelTheme.shapes.V8) + .padding(all = PrezelTheme.spacing.V20), + ) { + if (fileUri != null) { + UploadedScriptFileContent( + fileName = fileUri.toFileName(), + onClear = onClear, + ) + return@Column + } + + Text( + text = stringResource(R.string.feature_analysis_impl_script_file_placeholder), + color = PrezelTheme.colors.textLarge, + style = PrezelTheme.typography.body3Bold, + ) + Spacer(modifier = Modifier.height(PrezelTheme.spacing.V4)) + Text( + text = stringResource(R.string.feature_analysis_impl_script_file_format), + color = PrezelTheme.colors.textSmall, + style = PrezelTheme.typography.caption2Medium, + ) + Spacer(modifier = Modifier.height(PrezelTheme.spacing.V20)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + ) { + PrezelButton( + text = stringResource(R.string.feature_analysis_impl_script_upload_button), + iconResId = PrezelIcons.Plus, + type = ButtonType.OUTLINED, + size = ButtonSize.REGULAR, + onClick = onClick, + ) + } + } +} + +@Composable +private fun UploadedScriptFileContent( + fileName: String, + onClear: () -> Unit, +) { + Text( + text = stringResource(R.string.feature_analysis_impl_script_file_placeholder), + color = PrezelTheme.colors.textLarge, + style = PrezelTheme.typography.body3Bold, + ) + Spacer(modifier = Modifier.height(PrezelTheme.spacing.V16)) + CompositionLocalProvider(LocalContentColor provides PrezelTheme.colors.textLarge) { + PrezelList( + title = fileName, + modifier = Modifier + .fillMaxWidth() + .border( + width = PrezelTheme.stroke.V1, + color = PrezelTheme.colors.borderSmall, + shape = PrezelTheme.shapes.V8, + ), + size = PrezelListSize.SMALL, + trailingContent = { + PrezelTouchArea( + extraTouchPadding = PaddingValues(PrezelTheme.spacing.V8), + onClick = onClear, + ) { + Icon( + painter = painterResource(PrezelIcons.CancelCircleFilled), + contentDescription = stringResource(R.string.feature_analysis_impl_script_file_remove), + modifier = Modifier.size(24.dp), + tint = PrezelTheme.colors.iconRegular, + ) + } + }, + ) + } +} + +@BasicPreview +@Composable +private fun ScriptInputUploadScreenPreview() { + PrezelTheme { + ScriptInputScreen( + uiState = AnalysisFlowUiState(step = AnalysisFlowStep.SCRIPT_INPUT), + onSelectInputType = {}, + onScriptChange = {}, + onScriptFileSelected = {}, + onNext = {}, + onSkip = {}, + onBack = {}, + ) + } +} + +@BasicPreview +@Composable +private fun ScriptInputUploadedScreenPreview() { + PrezelTheme { + ScriptInputScreen( + uiState = AnalysisFlowUiState( + step = AnalysisFlowStep.SCRIPT_INPUT, + form = AnalysisForm(scriptFileUri = "content://prezel/25-2 컨셉발표회 대본.txt"), + ), + onSelectInputType = {}, + onScriptChange = {}, + onScriptFileSelected = {}, + onNext = {}, + onSkip = {}, + onBack = {}, + ) + } +} + +@BasicPreview +@Composable +private fun ScriptInputDirectScreenPreview() { + PrezelTheme { + ScriptInputScreen( + uiState = AnalysisFlowUiState( + step = AnalysisFlowStep.SCRIPT_INPUT, + form = AnalysisForm( + scriptInputType = ScriptInputType.DIRECT_INPUT, + script = stringResource(R.string.feature_analysis_impl_script_placeholder), + ), + ), + onSelectInputType = {}, + onScriptChange = {}, + onScriptFileSelected = {}, + onNext = {}, + onSkip = {}, + onBack = {}, + ) + } +} diff --git a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/SituationOptions.kt b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/SituationOptions.kt new file mode 100644 index 00000000..19ddec12 --- /dev/null +++ b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/SituationOptions.kt @@ -0,0 +1,122 @@ +package com.team.prezel.feature.analysis.impl + +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.ui.res.stringResource +import com.team.prezel.core.designsystem.icon.PrezelIcons +import com.team.prezel.core.model.presentation.Audience +import com.team.prezel.core.model.presentation.Category +import com.team.prezel.core.model.presentation.Purpose +import com.team.prezel.core.model.presentation.Style +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList + +@Immutable +internal data class SituationCategoryOption( + val value: Category, + val title: String, + val description: String, + @param:DrawableRes val iconResId: Int, +) + +@Immutable +internal data class SituationChipOption>( + val value: T, + val text: String, +) + +@Immutable +internal data class SituationChipContentOption( + val text: String, + val selected: Boolean, +) + +@Composable +internal fun categoryOptions(): ImmutableList = + Category.entries + .map { category -> + SituationCategoryOption( + value = category, + title = stringResource(category.titleResId), + description = stringResource(category.descriptionResId), + iconResId = category.iconResId, + ) + }.toImmutableList() + +@Composable +internal fun purposeOptions(): ImmutableList> = + Purpose.entries + .map { purpose -> + SituationChipOption( + value = purpose, + text = stringResource(purpose.titleResId), + ) + }.toImmutableList() + +@Composable +internal fun styleOptions(): ImmutableList> = + Style.entries + .map { style -> + SituationChipOption( + value = style, + text = stringResource(style.titleResId), + ) + }.toImmutableList() + +@Composable +internal fun audienceOptions(): ImmutableList> = + Audience.entries + .map { audience -> + SituationChipOption( + value = audience, + text = stringResource(audience.titleResId), + ) + }.toImmutableList() + +private val Category.titleResId: Int + @StringRes get() = when (this) { + Category.PERSUASION -> R.string.feature_analysis_impl_situation_category_persuasion + Category.EVENT -> R.string.feature_analysis_impl_situation_category_event + Category.EDUCATION -> R.string.feature_analysis_impl_situation_category_academic + Category.REPORT -> R.string.feature_analysis_impl_situation_category_business + } + +private val Category.descriptionResId: Int + @StringRes get() = when (this) { + Category.PERSUASION -> R.string.feature_analysis_impl_situation_category_persuasion_description + Category.EVENT -> R.string.feature_analysis_impl_situation_category_event_description + Category.EDUCATION -> R.string.feature_analysis_impl_situation_category_academic_description + Category.REPORT -> R.string.feature_analysis_impl_situation_category_business_description + } + +private val Category.iconResId: Int + @DrawableRes get() = when (this) { + Category.PERSUASION -> PrezelIcons.Hand + Category.EVENT -> PrezelIcons.Balloon + Category.EDUCATION -> PrezelIcons.College + Category.REPORT -> PrezelIcons.Company + } + +private val Purpose.titleResId: Int + @StringRes get() = when (this) { + Purpose.CONTENT_DELIVERY -> R.string.feature_analysis_impl_situation_purpose_information + Purpose.IMPROVE_UNDERSTANDING -> R.string.feature_analysis_impl_situation_purpose_understanding + Purpose.BUILD_EMPATHY -> R.string.feature_analysis_impl_situation_purpose_empathy + } + +private val Style.titleResId: Int + @StringRes get() = when (this) { + Style.PROFESSIONAL -> R.string.feature_analysis_impl_situation_style_professional + Style.FRIENDLY -> R.string.feature_analysis_impl_situation_style_friendly + Style.CALM -> R.string.feature_analysis_impl_situation_style_formal + Style.COMFORTABLE -> R.string.feature_analysis_impl_situation_style_casual + } + +private val Audience.titleResId: Int + @StringRes get() = when (this) { + Audience.GENERAL_AUDIENCE -> R.string.feature_analysis_impl_situation_audience_public + Audience.EXPERT -> R.string.feature_analysis_impl_situation_audience_teacher + Audience.TEAMMATES -> R.string.feature_analysis_impl_situation_audience_team + } diff --git a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/component/AnalysisStepLayout.kt b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/component/AnalysisStepLayout.kt new file mode 100644 index 00000000..2232c2e7 --- /dev/null +++ b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/component/AnalysisStepLayout.kt @@ -0,0 +1,174 @@ +package com.team.prezel.feature.analysis.impl.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.PaddingValues +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.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.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.PrezelTopAppBar +import com.team.prezel.core.designsystem.component.actions.area.PrezelButtonArea +import com.team.prezel.core.designsystem.component.base.PrezelTouchArea +import com.team.prezel.core.designsystem.icon.PrezelIcons +import com.team.prezel.core.designsystem.theme.PrezelTheme +import com.team.prezel.feature.analysis.impl.R + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun AnalysisStepLayout( + title: String, + progress: Float, + buttonText: String, + buttonEnabled: Boolean, + onButtonClick: () -> Unit, + onBack: () -> Unit, + modifier: Modifier = Modifier, + trailingText: String? = null, + onTrailingTextClick: (() -> Unit)? = null, + subButtonText: String? = null, + onSubButtonClick: (() -> Unit)? = null, + content: @Composable ColumnScope.() -> Unit, +) { + Column( + modifier = modifier + .fillMaxSize() + .background(PrezelTheme.colors.bgRegular), + ) { + PrezelTopAppBar( + title = { Text(text = title) }, + leadingIcon = { + IconButton(onClick = onBack) { + Icon( + painter = painterResource(PrezelIcons.ArrowLeft), + contentDescription = stringResource(R.string.feature_analysis_impl_back), + ) + } + }, + trailingIcons = { + AnalysisStepTrailingText( + text = trailingText, + onClick = onTrailingTextClick, + ) + }, + ) + + ProgressBar(progress = progress) + + AnalysisStepContent(content = content) + + AnalysisStepButtonArea( + buttonText = buttonText, + buttonEnabled = buttonEnabled, + onButtonClick = onButtonClick, + subButtonText = subButtonText, + onSubButtonClick = onSubButtonClick, + ) + } +} + +@Composable +private fun AnalysisStepTrailingText( + text: String?, + onClick: (() -> Unit)?, +) { + if (text == null || onClick == null) return + + PrezelTouchArea( + onClick = onClick, + extraTouchPadding = PaddingValues(PrezelTheme.spacing.V8), + ) { + Text( + text = text, + color = PrezelTheme.colors.textMedium, + style = PrezelTheme.typography.body3Medium, + ) + } + Spacer(modifier = Modifier.width(PrezelTheme.spacing.V8)) +} + +@Composable +private fun ColumnScope.AnalysisStepContent(content: @Composable ColumnScope.() -> Unit) { + Column( + modifier = Modifier + .weight(1f) + .verticalScroll(rememberScrollState()) + .padding(horizontal = PrezelTheme.spacing.V20) + .padding(top = PrezelTheme.spacing.V40), + ) { + content() + } +} + +@Composable +private fun AnalysisStepButtonArea( + buttonText: String, + buttonEnabled: Boolean, + onButtonClick: () -> Unit, + subButtonText: String?, + onSubButtonClick: (() -> Unit)?, +) { + PrezelButtonArea { + MainButton( + label = buttonText, + enabled = buttonEnabled, + onClick = onButtonClick, + ) + if (subButtonText != null && onSubButtonClick != null) { + SubButton( + label = subButtonText, + onClick = onSubButtonClick, + ) + } + } +} + +@Composable +internal fun AnalysisStepTitle( + title: String, + description: String, +) { + Text( + text = title, + color = PrezelTheme.colors.textLarge, + style = PrezelTheme.typography.title2Medium, + ) + Spacer(modifier = Modifier.height(PrezelTheme.spacing.V8)) + Text( + text = description, + color = PrezelTheme.colors.textRegular, + style = PrezelTheme.typography.body2Regular, + ) +} + +@Composable +private fun ProgressBar(progress: Float) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(4.dp) + .background(PrezelTheme.colors.bgLarge), + ) { + Box( + modifier = Modifier + .fillMaxWidth(progress.coerceIn(0f, 1f)) + .height(4.dp) + .background(PrezelTheme.colors.interactiveRegular), + ) + } +} diff --git a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/component/AnalysisUploadComponents.kt b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/component/AnalysisUploadComponents.kt new file mode 100644 index 00000000..68caf122 --- /dev/null +++ b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/component/AnalysisUploadComponents.kt @@ -0,0 +1,63 @@ +package com.team.prezel.feature.analysis.impl.component + +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Icon +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.painterResource +import androidx.compose.ui.unit.dp +import androidx.core.net.toUri +import com.team.prezel.core.designsystem.component.base.PrezelTouchArea +import com.team.prezel.core.designsystem.theme.PrezelTheme + +@Composable +internal fun OutlineActionButton( + text: String, + modifier: Modifier = Modifier, + iconResId: Int? = null, + onClick: () -> Unit, +) { + PrezelTouchArea( + onClick = onClick, + shape = PrezelTheme.shapes.V8, + modifier = modifier, + ) { + Row( + modifier = Modifier + .clip(PrezelTheme.shapes.V8) + .border(PrezelTheme.stroke.V1, PrezelTheme.colors.interactiveRegular, PrezelTheme.shapes.V8) + .padding(horizontal = PrezelTheme.spacing.V16, vertical = PrezelTheme.spacing.V12), + verticalAlignment = Alignment.CenterVertically, + ) { + if (iconResId != null) { + Icon( + painter = painterResource(iconResId), + contentDescription = null, + tint = PrezelTheme.colors.interactiveRegular, + modifier = Modifier.size(18.dp), + ) + Spacer(modifier = Modifier.width(PrezelTheme.spacing.V6)) + } + Text( + text = text, + color = PrezelTheme.colors.interactiveRegular, + style = PrezelTheme.typography.body2Medium, + ) + } + } +} + +internal fun String.toFileName(): String = + this + .toUri() + .lastPathSegment + .orEmpty() + .ifBlank { this } diff --git a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/contract/AnalysisFlowUiEffect.kt b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/contract/AnalysisFlowUiEffect.kt new file mode 100644 index 00000000..e926366a --- /dev/null +++ b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/contract/AnalysisFlowUiEffect.kt @@ -0,0 +1,7 @@ +package com.team.prezel.feature.analysis.impl.contract + +import com.team.prezel.core.ui.base.UiEffect + +internal sealed interface AnalysisFlowUiEffect : UiEffect { + data object NavigateBack : AnalysisFlowUiEffect +} diff --git a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/contract/AnalysisFlowUiIntent.kt b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/contract/AnalysisFlowUiIntent.kt new file mode 100644 index 00000000..b4a52820 --- /dev/null +++ b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/contract/AnalysisFlowUiIntent.kt @@ -0,0 +1,55 @@ +package com.team.prezel.feature.analysis.impl.contract + +import com.team.prezel.core.model.presentation.Audience +import com.team.prezel.core.model.presentation.Category +import com.team.prezel.core.model.presentation.Purpose +import com.team.prezel.core.model.presentation.Style +import com.team.prezel.core.ui.base.UiIntent + +internal sealed interface AnalysisFlowUiIntent : UiIntent { + data class UpdatePresentationTitle( + val title: String, + ) : AnalysisFlowUiIntent + + data class UpdatePresentationDate( + val date: String, + ) : AnalysisFlowUiIntent + + data class SelectCategory( + val category: Category, + ) : AnalysisFlowUiIntent + + data class SelectPurpose( + val purpose: Purpose, + ) : AnalysisFlowUiIntent + + data class SelectStyle( + val style: Style, + ) : AnalysisFlowUiIntent + + data class SelectAudience( + val audience: Audience, + ) : AnalysisFlowUiIntent + + data class SelectScriptInputType( + val inputType: ScriptInputType, + ) : AnalysisFlowUiIntent + + data class UpdateScript( + val script: String, + ) : AnalysisFlowUiIntent + + data class SelectScriptFile( + val fileUri: String?, + ) : AnalysisFlowUiIntent + + data class SelectAudioFile( + val fileUri: String?, + ) : AnalysisFlowUiIntent + + data object Next : AnalysisFlowUiIntent + + data object SkipScript : AnalysisFlowUiIntent + + data object Back : AnalysisFlowUiIntent +} diff --git a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/contract/AnalysisFlowUiState.kt b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/contract/AnalysisFlowUiState.kt new file mode 100644 index 00000000..891f26f9 --- /dev/null +++ b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/contract/AnalysisFlowUiState.kt @@ -0,0 +1,68 @@ +package com.team.prezel.feature.analysis.impl.contract + +import androidx.compose.runtime.Immutable +import com.team.prezel.core.model.presentation.Audience +import com.team.prezel.core.model.presentation.Category +import com.team.prezel.core.model.presentation.Purpose +import com.team.prezel.core.model.presentation.Style +import com.team.prezel.core.ui.base.UiState + +@Immutable +internal data class AnalysisFlowUiState( + val step: AnalysisFlowStep = AnalysisFlowStep.PRESENTATION_SCHEDULE, + val form: AnalysisForm = AnalysisForm(), +) : UiState { + val progress: Float + get() = when (step) { + AnalysisFlowStep.PRESENTATION_SCHEDULE -> 0.25f + AnalysisFlowStep.PRESENTATION_SITUATION, + AnalysisFlowStep.SCRIPT_INPUT, + -> 0.5f + + AnalysisFlowStep.AUDIO_UPLOAD -> 0.75f + AnalysisFlowStep.ANALYZING -> 1f + } + + val canMoveNext: Boolean + get() = when (step) { + AnalysisFlowStep.PRESENTATION_SCHEDULE -> form.presentationTitle.isNotBlank() && form.presentationDate.isNotBlank() + AnalysisFlowStep.PRESENTATION_SITUATION -> { + form.category != null && form.purpose != null && form.style != null && form.audience != null + } + + AnalysisFlowStep.SCRIPT_INPUT -> when (form.scriptInputType) { + ScriptInputType.FILE_UPLOAD -> form.scriptFileUri != null + ScriptInputType.DIRECT_INPUT -> form.script.isNotBlank() + } + + AnalysisFlowStep.AUDIO_UPLOAD -> form.audioFileUri != null + AnalysisFlowStep.ANALYZING -> false + } +} + +@Immutable +internal data class AnalysisForm( + val presentationTitle: String = "", + val presentationDate: String = "", + val category: Category? = null, + val purpose: Purpose? = null, + val style: Style? = null, + val audience: Audience? = null, + val scriptInputType: ScriptInputType = ScriptInputType.FILE_UPLOAD, + val script: String = "", + val scriptFileUri: String? = null, + val audioFileUri: String? = null, +) + +internal enum class ScriptInputType { + FILE_UPLOAD, + DIRECT_INPUT, +} + +internal enum class AnalysisFlowStep { + PRESENTATION_SCHEDULE, + PRESENTATION_SITUATION, + SCRIPT_INPUT, + AUDIO_UPLOAD, + ANALYZING, +} diff --git a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/navigation/AnalysisEntryBuilder.kt b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/navigation/AnalysisEntryBuilder.kt new file mode 100644 index 00000000..642b94b7 --- /dev/null +++ b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/navigation/AnalysisEntryBuilder.kt @@ -0,0 +1,36 @@ +package com.team.prezel.feature.analysis.impl.navigation + +import androidx.navigation3.runtime.EntryProviderScope +import androidx.navigation3.runtime.NavKey +import com.team.prezel.core.navigation.LocalNavigator +import com.team.prezel.feature.analysis.api.AnalysisNavKey +import com.team.prezel.feature.analysis.impl.AnalysisScreen +import com.team.prezel.feature.home.api.HomeNavKey +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ActivityRetainedComponent +import dagger.multibindings.IntoSet + +internal fun EntryProviderScope.featureAnalysisEntryBuilder() { + entry { + val navigator = LocalNavigator.current + val navigateToHome = { navigator.replaceRoot(HomeNavKey) } + + AnalysisScreen( + onFinished = navigateToHome, + onBack = navigateToHome, + ) + } +} + +@Module +@InstallIn(ActivityRetainedComponent::class) +object FeatureAnalysisModule { + @IntoSet + @Provides + fun provideFeatureAnalysisEntryBuilder(): EntryProviderScope.() -> Unit = + { + featureAnalysisEntryBuilder() + } +} diff --git a/Prezel/feature/analysis/impl/src/main/res/drawable/feature_analysis_impl_no_voice.xml b/Prezel/feature/analysis/impl/src/main/res/drawable/feature_analysis_impl_no_voice.xml new file mode 100644 index 00000000..d6fef9f1 --- /dev/null +++ b/Prezel/feature/analysis/impl/src/main/res/drawable/feature_analysis_impl_no_voice.xml @@ -0,0 +1,24 @@ + + + + + + + + diff --git a/Prezel/feature/analysis/impl/src/main/res/values/strings.xml b/Prezel/feature/analysis/impl/src/main/res/values/strings.xml new file mode 100644 index 00000000..7adcbd01 --- /dev/null +++ b/Prezel/feature/analysis/impl/src/main/res/values/strings.xml @@ -0,0 +1,68 @@ + + + 발표 일정 추가 + 새로운 일정을 만들어볼까요? + 발표 이름과 날짜를 입력해주세요. + 발표 이름 + 발표의 이름을 설정해주세요 + 발표 날짜 + 발표하는 날짜를 알려주세요 + + 발표 상황 설정 + 발표에 대해 알려주세요 + 발표의 성격을 선택해주세요. + 유형 + 목적 + 스타일 + 청중 + 학술·교육 + 정보 전달 + 전문적인 + 교수/교사/강사 + 학술·교육 + 배운 내용이나 분석 주제를 전달하는 발표예요. + 업무·보고 + 진행 중인 일이나 결과를 공유하는 발표예요. + 설득·제안 + 다른 사람의 선택을 이끄는 발표예요. + 행사·공개 + 여러 사람 앞에서 내용을 소개하는 발표예요. + 정보 전달 + 이해도 향상 + 공감대 형성 + 전문적인 + 친근한 + 격식있는 + 일상적인 + 대중 + 교수/교사/강사 + 팀/동료 + + 대본 입력 + 발표 대본을 입력해주세요. + 대본 파일을 업로드하거나, 직접 입력할 수 있어요. + 발표 대본을 입력해주세요 + 대본 파일을 업로드하세요 + 제공하는 파일 형식 : .txt + 파일 업로드하기 + 대본 파일 삭제 + 직접 입력 + + 음성 녹음 + 대본을 읽은 음성 파일을 업로드해주세요. + 분석을 위해 음성 파일이 필요해요. + 음성 파일을 업로드하세요 + 제공하는 파일 형식 : mp3, wav + 00:00/00:00 + 음성 파일 삭제 + 파일 추가하기 + + 파일 업로드 + 직접 입력 + 분석 중 + 발표 음성을 분석하고 있어요 + 뒤로가기 + 다음 + 건너뛰기 + 분석하기 + diff --git a/Prezel/feature/home/impl/build.gradle.kts b/Prezel/feature/home/impl/build.gradle.kts index 8e5ce3e3..9a08adac 100644 --- a/Prezel/feature/home/impl/build.gradle.kts +++ b/Prezel/feature/home/impl/build.gradle.kts @@ -8,6 +8,7 @@ android { dependencies { implementation(projects.coreModel) + implementation(projects.featureAnalysisApi) implementation(projects.featureHomeApi) implementation(libs.kotlinx.collections.immutable) 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/HomeScreen.kt index 1937c35a..83ff0df9 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/HomeScreen.kt @@ -1,8 +1,11 @@ package com.team.prezel.feature.home.impl +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.PagerState import androidx.compose.foundation.pager.rememberPagerState @@ -14,13 +17,20 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalResources +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +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.floating.PrezelFloatingMenu 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.model.presentation.Category @@ -43,6 +53,8 @@ import kotlinx.datetime.LocalDate @Composable internal fun HomeScreen( + navigateToFileUploadAnalysis: () -> Unit, + navigateToVoiceRecordingAnalysis: () -> Unit, modifier: Modifier = Modifier, viewModel: HomeViewModel = hiltViewModel(), ) { @@ -72,6 +84,8 @@ internal fun HomeScreen( onClickAddPresentation = { }, onClickAnalyzePresentation = { }, onClickWriteFeedback = { }, + onClickVoiceRecordingAnalysis = navigateToVoiceRecordingAnalysis, + onClickFileUploadAnalysis = navigateToFileUploadAnalysis, modifier = modifier, ) } @@ -84,9 +98,12 @@ private fun HomeScreen( onClickAddPresentation: () -> Unit, onClickAnalyzePresentation: (PresentationUiModel) -> Unit, onClickWriteFeedback: (PresentationUiModel) -> Unit, + onClickVoiceRecordingAnalysis: () -> Unit, + onClickFileUploadAnalysis: () -> Unit, modifier: Modifier = Modifier, ) { val scope = rememberCoroutineScope() + var isFabExpanded by remember { mutableStateOf(false) } BoxWithConstraints(modifier = modifier.fillMaxSize()) { val maxScreenHeight = maxHeight @@ -109,10 +126,65 @@ private fun HomeScreen( onClickTab = { pageIndex -> scope.launch { pagerState.scrollToPage(pageIndex) } }, modifier = Modifier.onHeightChanged { newHeight -> headerHeight = newHeight }, ) + + if (isFabExpanded) { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.32f)) + .clickable { isFabExpanded = false }, + ) + } + + HomeAnalysisFloatingMenu( + isExpanded = isFabExpanded, + onChangeExpanded = { isFabExpanded = it }, + onClickVoiceRecording = { + isFabExpanded = false + onClickVoiceRecordingAnalysis() + }, + onClickFileUpload = { + isFabExpanded = false + onClickFileUploadAnalysis() + }, + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(end = PrezelTheme.spacing.V24, bottom = PrezelTheme.spacing.V24), + ) } } } +@Composable +private fun HomeAnalysisFloatingMenu( + isExpanded: Boolean, + onChangeExpanded: (Boolean) -> Unit, + onClickVoiceRecording: () -> Unit, + onClickFileUpload: () -> Unit, + modifier: Modifier = Modifier, +) { + PrezelFloatingMenu( + isExpanded = isExpanded, + onChangeExpanded = onChangeExpanded, + iconResId = PrezelIcons.Plus, + openIconResId = PrezelIcons.Cancel, + size = ButtonSize.REGULAR, + hierarchy = ButtonHierarchy.PRIMARY, + modifier = modifier, + ) { + MenuItem( + label = stringResource(R.string.feature_home_impl_analysis_voice_recording), + iconResId = PrezelIcons.Mic, + onClick = onClickVoiceRecording, + ) + MenuItem( + label = stringResource(R.string.feature_home_impl_analysis_file_upload), + iconResId = PrezelIcons.Folder, + onClick = onClickFileUpload, + ) + } +} + @Composable private fun HomeContent( uiState: HomeUiState, @@ -245,6 +317,8 @@ private fun HomeScreenEmptyPreview() { onClickAddPresentation = { }, onClickAnalyzePresentation = { }, onClickWriteFeedback = { }, + onClickVoiceRecordingAnalysis = { }, + onClickFileUploadAnalysis = { }, ) } } @@ -268,6 +342,8 @@ private fun HomeScreenSinglePreview() { onClickAddPresentation = { }, onClickAnalyzePresentation = { }, onClickWriteFeedback = { }, + onClickVoiceRecordingAnalysis = { }, + onClickFileUploadAnalysis = { }, ) } } @@ -293,6 +369,8 @@ private fun HomeScreenMultiplePreview() { onClickAddPresentation = { }, onClickAnalyzePresentation = { }, onClickWriteFeedback = { }, + onClickVoiceRecordingAnalysis = { }, + onClickFileUploadAnalysis = { }, ) } } 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..37bdd6c9 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,6 +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.analysis.api.AnalysisNavKey import com.team.prezel.feature.home.api.HomeNavKey import com.team.prezel.feature.home.impl.HomeScreen import dagger.Module @@ -12,7 +14,16 @@ import dagger.multibindings.IntoSet internal fun EntryProviderScope.featureHomeEntryBuilder() { entry { - HomeScreen() + val navigator = LocalNavigator.current + + HomeScreen( + navigateToFileUploadAnalysis = { + navigator.navigate(AnalysisNavKey.Create) + }, + navigateToVoiceRecordingAnalysis = { + navigator.navigate(AnalysisNavKey.Create) + }, + ) } } 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..99e54d29 100644 --- a/Prezel/feature/home/impl/src/main/res/values/strings.xml +++ b/Prezel/feature/home/impl/src/main/res/values/strings.xml @@ -12,6 +12,8 @@ 발표 분석하기 \'%1$s\'는 어떠셨나요? 피드백 작성하기 + 음성 녹음 + 파일 업로드 설득·제안 행사·공개 diff --git a/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/LoginScreen.kt b/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/LoginScreen.kt index 0c7fbec3..5a132952 100644 --- a/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/LoginScreen.kt +++ b/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/LoginScreen.kt @@ -22,30 +22,24 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation3.ui.LocalNavAnimatedContentScope -import com.team.prezel.core.auth.AuthManager import com.team.prezel.core.designsystem.component.actions.area.PrezelButtonArea import com.team.prezel.core.designsystem.component.actions.button.config.ButtonHierarchy import com.team.prezel.core.designsystem.component.actions.button.config.ButtonSize import com.team.prezel.core.designsystem.component.actions.button.config.ButtonType import com.team.prezel.core.designsystem.component.actions.button.config.PrezelButtonDefaults -import com.team.prezel.core.designsystem.component.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.login.api.AUTH_LOGO_SHARED_ELEMENT_KEY import com.team.prezel.feature.login.impl.R import com.team.prezel.feature.login.impl.landing.contract.LoginUiEffect import com.team.prezel.feature.login.impl.landing.contract.LoginUiIntent import com.team.prezel.feature.login.impl.landing.contract.LoginUiState -import com.team.prezel.feature.login.impl.landing.model.LoginUiMessage import com.team.prezel.core.designsystem.R as DSR private const val AUTH_SHARED_ELEMENT_TRANSITION_DURATION = 300 @@ -53,36 +47,16 @@ private const val AUTH_SHARED_ELEMENT_TRANSITION_DELAY = 400 @Composable internal fun SharedTransitionScope.LoginScreen( - authManager: AuthManager, navigateToHome: () -> Unit, - navigateToTerms: () -> Unit, modifier: Modifier = Modifier, viewModel: LoginViewModel = hiltViewModel(), ) { - val context = LocalContext.current - val resources = LocalResources.current - val snackbarHostState = LocalSnackbarHostState.current val uiState by viewModel.uiState.collectAsStateWithLifecycle() LaunchedEffect(Unit) { viewModel.uiEffect.collect { effect -> when (effect) { - LoginUiEffect.LaunchLogin -> { - val result = authManager.login(context = context) - viewModel.onIntent(LoginUiIntent.OnLoginResult(result = result)) - } - LoginUiEffect.NavigateToHome -> navigateToHome() - - LoginUiEffect.NavigateToTerms -> navigateToTerms() - - is LoginUiEffect.ShowMessage -> { - val resId = when (effect.message) { - 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)) - } } } } diff --git a/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/LoginViewModel.kt b/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/LoginViewModel.kt index 4f71c871..45b3178d 100644 --- a/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/LoginViewModel.kt +++ b/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/LoginViewModel.kt @@ -1,25 +1,19 @@ package com.team.prezel.feature.login.impl.landing import androidx.lifecycle.viewModelScope -import com.team.prezel.core.auth.model.AuthResult -import com.team.prezel.core.domain.usecase.auth.LoginUseCase import com.team.prezel.core.ui.base.BaseViewModel import com.team.prezel.feature.login.impl.landing.contract.LoginUiEffect import com.team.prezel.feature.login.impl.landing.contract.LoginUiIntent import com.team.prezel.feature.login.impl.landing.contract.LoginUiState -import com.team.prezel.feature.login.impl.landing.model.LoginUiMessage import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel -internal class LoginViewModel @Inject constructor( - private val loginUseCase: LoginUseCase, -) : BaseViewModel(LoginUiState()) { +internal class LoginViewModel @Inject constructor() : BaseViewModel(LoginUiState()) { override fun onIntent(intent: LoginUiIntent) { when (intent) { LoginUiIntent.OnClickLogin -> handleClickLogin() - is LoginUiIntent.OnLoginResult -> handleLoginResult(result = intent.result) } } @@ -28,24 +22,7 @@ internal class LoginViewModel @Inject constructor( viewModelScope.launch { updateState { copy(isLoading = true) } - sendEffect(LoginUiEffect.LaunchLogin) + sendEffect(LoginUiEffect.NavigateToHome) } } - - private fun handleLoginResult(result: AuthResult) { - viewModelScope - .launch { - when (result) { - is AuthResult.Success -> handleServerLogin(idToken = result.idToken) - is AuthResult.Failure -> sendEffect(LoginUiEffect.ShowMessage(LoginUiMessage.LOGIN_FAILED_UNKNOWN)) - AuthResult.Cancelled -> sendEffect(LoginUiEffect.ShowMessage(LoginUiMessage.LOGIN_CANCELLED)) - } - }.invokeOnCompletion { updateState { copy(isLoading = false) } } - } - - private suspend fun handleServerLogin(idToken: String) { - loginUseCase(idToken = idToken) - .onSuccess { sendEffect(LoginUiEffect.NavigateToTerms) } - .onFailure { sendEffect(LoginUiEffect.ShowMessage(LoginUiMessage.LOGIN_FAILED_UNKNOWN)) } - } } diff --git a/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/contract/LoginUiEffect.kt b/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/contract/LoginUiEffect.kt index 31053d7b..63d90258 100644 --- a/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/contract/LoginUiEffect.kt +++ b/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/contract/LoginUiEffect.kt @@ -1,16 +1,7 @@ package com.team.prezel.feature.login.impl.landing.contract import com.team.prezel.core.ui.base.UiEffect -import com.team.prezel.feature.login.impl.landing.model.LoginUiMessage internal sealed interface LoginUiEffect : UiEffect { - data object LaunchLogin : LoginUiEffect - data object NavigateToHome : LoginUiEffect - - data object NavigateToTerms : LoginUiEffect - - data class ShowMessage( - val message: LoginUiMessage, - ) : LoginUiEffect } diff --git a/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/contract/LoginUiIntent.kt b/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/contract/LoginUiIntent.kt index e4a7033d..0a920494 100644 --- a/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/contract/LoginUiIntent.kt +++ b/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/contract/LoginUiIntent.kt @@ -1,12 +1,7 @@ package com.team.prezel.feature.login.impl.landing.contract -import com.team.prezel.core.auth.model.AuthResult import com.team.prezel.core.ui.base.UiIntent internal sealed interface LoginUiIntent : UiIntent { data object OnClickLogin : LoginUiIntent - - data class OnLoginResult( - val result: AuthResult, - ) : LoginUiIntent } diff --git a/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/navigation/LoginEntryBuilder.kt b/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/navigation/LoginEntryBuilder.kt index a826dc45..cc88f2ff 100644 --- a/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/navigation/LoginEntryBuilder.kt +++ b/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/navigation/LoginEntryBuilder.kt @@ -2,7 +2,6 @@ package com.team.prezel.feature.login.impl.navigation import androidx.navigation3.runtime.EntryProviderScope import androidx.navigation3.runtime.NavKey -import com.team.prezel.core.auth.AuthManager import com.team.prezel.core.navigation.LocalNavigator import com.team.prezel.core.navigation.LocalSharedTransitionScope import com.team.prezel.feature.home.api.HomeNavKey @@ -16,19 +15,15 @@ import dagger.hilt.InstallIn import dagger.hilt.android.components.ActivityRetainedComponent import dagger.multibindings.IntoSet -internal fun EntryProviderScope.featureLoginEntryBuilder(authManager: AuthManager) { +internal fun EntryProviderScope.featureLoginEntryBuilder() { entry { val navigator = LocalNavigator.current with(LocalSharedTransitionScope.current) { LoginScreen( - authManager = authManager, navigateToHome = { navigator.replaceRoot(HomeNavKey) }, - navigateToTerms = { - navigator.navigate(LoginTermsNavKey) - }, ) } } @@ -52,8 +47,8 @@ internal fun EntryProviderScope.featureLoginEntryBuilder(authManager: Au object FeatureLoginModule { @IntoSet @Provides - fun provideFeatureLoginEntryBuilder(authManager: AuthManager): EntryProviderScope.() -> Unit = + fun provideFeatureLoginEntryBuilder(): EntryProviderScope.() -> Unit = { - featureLoginEntryBuilder(authManager = authManager) + featureLoginEntryBuilder() } } diff --git a/Prezel/settings.gradle.kts b/Prezel/settings.gradle.kts index 22be6806..aece5f98 100644 --- a/Prezel/settings.gradle.kts +++ b/Prezel/settings.gradle.kts @@ -51,6 +51,8 @@ includeAuto( ":core:ui", ":feature:home:api", ":feature:home:impl", + ":feature:analysis:api", + ":feature:analysis:impl", ":feature:history:api", ":feature:history:impl", ":feature:my:api", From f446bc48b52a038f728f18673b52e1d246ac7d4b Mon Sep 17 00:00:00 2001 From: Ham BeomJoon Date: Tue, 5 May 2026 17:42:30 +0900 Subject: [PATCH 02/16] =?UTF-8?q?feat:=20=EB=B6=84=EC=84=9D(Analysis)=20?= =?UTF-8?q?=ED=9D=90=EB=A6=84=20=EB=82=B4=20=EB=8C=80=EB=B3=B8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20=EA=B8=B0=EB=8A=A5=20=EA=B0=9C=EC=84=A0=20=EB=B0=8F?= =?UTF-8?q?=20=EB=94=94=EC=9E=90=EC=9D=B8=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20?= =?UTF-8?q?=EB=B3=B4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * **feat: 대본 파일 업로드 기능 개선 및 지원 형식 확대** * 대본 파일 지원 형식을 기존 `.txt`에서 `pdf`, `.txt`로 확대하고 관련 안내 문구를 수정했습니다. * `ActivityResultContracts.OpenDocument`를 사용하여 다양한 문서 타입(`pdf`, `text/*` 등)을 선택할 수 있도록 개선했습니다. * 파일 업로드 화면을 `StatusView`와 커스텀 벡터 아이콘(`feature_analysis_impl_no_script`)을 사용하는 UI로 개편했습니다. * `ContentResolver`를 통해 URI로부터 실제 파일명을 추출하도록 `toFileName` 확장 함수를 개선했습니다. * **refactor: 발표 일정 및 입력 유효성 로직 수정** * 발표 제목 입력 시 공백 제외 최소 2자 이상 입력해야 다음 단계로 이동 가능하도록 검증 로직을 강화했습니다. * 발표 날짜 표시 형식을 `YYYY.MM.DD`에서 `YYYY년 MM월 DD일`로 변경하고 관련 파싱 로직을 업데이트했습니다. * **style: 디자인 시스템 컴포넌트 동작 및 UI 세부 조정** * `PrezelTabs`: 탭 클릭 시 리플 효과(Ripple)가 발생하지 않도록 `NoRippleInteractionSource`를 적용했습니다. * `PrezelAccordion`: 아코디언이 확장(`expanded`)된 상태에서는 하단 구분선(`showDivider`)이 노출되지 않도록 수정했습니다. * `DayCell`: 데이트 피커 내 날짜 선택 시 리플 효과를 제거했습니다. * `PresentationScheduleScreen`: 날짜 선택 필드의 상호작용 소스를 초기화하여 클릭 영역 동작을 개선했습니다. --- .../designsystem/component/PrezelAccordion.kt | 2 +- .../component/datepicker/config/DayCell.kt | 1 + .../component/navigations/PrezelTabs.kt | 2 + .../analysis/impl/AudioUploadScreen.kt | 6 +- .../impl/PresentationScheduleScreen.kt | 8 +- .../analysis/impl/ScriptInputScreen.kt | 141 +++++++++--------- .../component/AnalysisUploadComponents.kt | 28 +++- .../impl/contract/AnalysisFlowUiState.kt | 3 +- .../feature_analysis_impl_no_script.xml | 22 +++ .../impl/src/main/res/values/strings.xml | 10 +- 10 files changed, 134 insertions(+), 89 deletions(-) create mode 100644 Prezel/feature/analysis/impl/src/main/res/drawable/feature_analysis_impl_no_script.xml diff --git a/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/PrezelAccordion.kt b/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/PrezelAccordion.kt index 2130a344..c31d973a 100644 --- a/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/PrezelAccordion.kt +++ b/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/PrezelAccordion.kt @@ -83,7 +83,7 @@ fun PrezelAccordion( ) } - if (showDivider) { + if (showDivider && !expanded) { PrezelHorizontalDivider( type = PrezelDividerType.THICK, ) diff --git a/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/datepicker/config/DayCell.kt b/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/datepicker/config/DayCell.kt index f0e30e1d..2806636b 100644 --- a/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/datepicker/config/DayCell.kt +++ b/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/datepicker/config/DayCell.kt @@ -31,6 +31,7 @@ internal fun DayCell( .padding(PrezelTheme.spacing.V4) .clip(PrezelTheme.shapes.V1000) .background(color = config.dayContainerColor(dayCell = dayCell)), + isUseRipple = false, onClick = onClick, ) { if (dayCell == null) return@PrezelTouchArea diff --git a/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/navigations/PrezelTabs.kt b/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/navigations/PrezelTabs.kt index e5e79b5e..44247382 100644 --- a/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/navigations/PrezelTabs.kt +++ b/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/navigations/PrezelTabs.kt @@ -15,6 +15,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import com.team.prezel.core.designsystem.foundation.typography.PrezelTextStyles import com.team.prezel.core.designsystem.theme.PrezelTheme +import com.team.prezel.core.designsystem.util.NoRippleInteractionSource import kotlinx.collections.immutable.ImmutableList @Composable @@ -76,5 +77,6 @@ private fun PrezelTab( }, selectedContentColor = PrezelTheme.colors.solidBlack, unselectedContentColor = PrezelTheme.colors.textDisabled, + interactionSource = NoRippleInteractionSource, ) } diff --git a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AudioUploadScreen.kt b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AudioUploadScreen.kt index 1cc7257a..54fe992b 100644 --- a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AudioUploadScreen.kt +++ b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AudioUploadScreen.kt @@ -19,9 +19,11 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp @@ -122,9 +124,11 @@ private fun AudioUploadContent( if (fileUri == null) { AudioUploadEmptyContent(onUploadClick = onUploadClick) } else { + val context = LocalContext.current + Spacer(modifier = Modifier.height(PrezelTheme.spacing.V16)) UploadedAudioFileCard( - fileName = fileUri.toFileName(), + fileName = remember(context, fileUri) { fileUri.toFileName(context) }, onClear = onClear, ) } diff --git a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/PresentationScheduleScreen.kt b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/PresentationScheduleScreen.kt index 3aea41c3..31f516b7 100644 --- a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/PresentationScheduleScreen.kt +++ b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/PresentationScheduleScreen.kt @@ -124,7 +124,7 @@ private fun PresentationScheduleScreen( modifier = Modifier .matchParentSize() .clickable( - interactionSource = dateFieldInteractionSource, + interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = { showDatePicker.value = true }, ), @@ -134,11 +134,11 @@ private fun PresentationScheduleScreen( } } -private fun LocalDate.toPresentationDateText(): String = "%04d.%02d.%02d".format(year, month.ordinal + 1, day) +private fun LocalDate.toPresentationDateText(): String = "%04d년 %02d월 %02d일".format(year, month.ordinal + 1, day) private fun String.toLocalDateOrNull(): LocalDate? = runCatching { - val (year, month, day) = split(".") + val (year, month, day) = split("년 ", "월 ", "일") LocalDate( year = year.toInt(), @@ -170,7 +170,7 @@ private fun PresentationScheduleScreenDateSelectedPreview() { PresentationScheduleScreen( form = AnalysisForm( presentationTitle = "졸업 발표", - presentationDate = "2026.05.09", + presentationDate = "2026년 05월 09일", ), progress = 0.25f, buttonEnabled = true, diff --git a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/ScriptInputScreen.kt b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/ScriptInputScreen.kt index 7dea7cc7..48a82bee 100644 --- a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/ScriptInputScreen.kt +++ b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/ScriptInputScreen.kt @@ -2,10 +2,8 @@ package com.team.prezel.feature.analysis.impl import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.foundation.background +import androidx.compose.foundation.Image import androidx.compose.foundation.border -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -15,12 +13,13 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.material3.Icon -import androidx.compose.material3.LocalContentColor import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp @@ -28,14 +27,13 @@ import com.team.prezel.core.designsystem.component.actions.button.PrezelButton import com.team.prezel.core.designsystem.component.actions.button.config.ButtonSize import com.team.prezel.core.designsystem.component.actions.button.config.ButtonType import com.team.prezel.core.designsystem.component.base.PrezelTouchArea -import com.team.prezel.core.designsystem.component.list.PrezelList -import com.team.prezel.core.designsystem.component.list.PrezelListSize import com.team.prezel.core.designsystem.component.navigations.PrezelTabSize import com.team.prezel.core.designsystem.component.navigations.PrezelTabs import com.team.prezel.core.designsystem.component.textfield.PrezelTextArea import com.team.prezel.core.designsystem.icon.PrezelIcons 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.analysis.impl.component.AnalysisStepLayout import com.team.prezel.feature.analysis.impl.component.AnalysisStepTitle import com.team.prezel.feature.analysis.impl.component.toFileName @@ -46,7 +44,7 @@ import com.team.prezel.feature.analysis.impl.contract.ScriptInputType import kotlinx.collections.immutable.persistentListOf private const val SCRIPT_MAX_LENGTH = 5_000 -private const val SCRIPT_FILE_MIME_TYPE = "text/*" +private val SCRIPT_FILE_MIME_TYPES = arrayOf("application/pdf", "text/plain", "text/*", "application/octet-stream") private const val SCRIPT_INPUT_TAB_COUNT = 2 @Composable @@ -59,7 +57,7 @@ internal fun ScriptInputScreen( onSkip: () -> Unit, onBack: () -> Unit, ) { - val scriptPicker = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri -> + val scriptPicker = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> onScriptFileSelected(uri?.toString()) } @@ -69,7 +67,7 @@ internal fun ScriptInputScreen( buttonEnabled = uiState.canMoveNext, onSelectInputType = onSelectInputType, onScriptChange = onScriptChange, - onScriptFileUploadClick = { scriptPicker.launch(SCRIPT_FILE_MIME_TYPE) }, + onScriptFileUploadClick = { scriptPicker.launch(SCRIPT_FILE_MIME_TYPES) }, onScriptFileClear = { onScriptFileSelected(null) }, onNext = onNext, onSkip = onSkip, @@ -181,83 +179,84 @@ private fun ScriptUploadCard( onClick: () -> Unit, onClear: () -> Unit, ) { - Column( + if (fileUri == null) { + EmptyScriptUploadContent(onClick = onClick) + } else { + UploadedScriptFileCard(fileUri = fileUri, onClear = onClear) + } +} + +@Composable +private fun EmptyScriptUploadContent( + onClick: () -> Unit, +) { + StatusView( + title = stringResource(R.string.feature_analysis_impl_script_file_placeholder), + description = stringResource(R.string.feature_analysis_impl_script_file_format), modifier = Modifier .fillMaxWidth() - .background(PrezelTheme.colors.bgMedium, PrezelTheme.shapes.V8) - .padding(all = PrezelTheme.spacing.V20), - ) { - if (fileUri != null) { - UploadedScriptFileContent( - fileName = fileUri.toFileName(), - onClear = onClear, + .height(420.dp), + visual = { + Image( + painter = painterResource(R.drawable.feature_analysis_impl_no_script), + contentDescription = null, + modifier = Modifier.size(120.dp), ) - return@Column - } - - Text( - text = stringResource(R.string.feature_analysis_impl_script_file_placeholder), - color = PrezelTheme.colors.textLarge, - style = PrezelTheme.typography.body3Bold, - ) - Spacer(modifier = Modifier.height(PrezelTheme.spacing.V4)) - Text( - text = stringResource(R.string.feature_analysis_impl_script_file_format), - color = PrezelTheme.colors.textSmall, - style = PrezelTheme.typography.caption2Medium, - ) - Spacer(modifier = Modifier.height(PrezelTheme.spacing.V20)) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.Center, - ) { + }, + action = { PrezelButton( text = stringResource(R.string.feature_analysis_impl_script_upload_button), iconResId = PrezelIcons.Plus, type = ButtonType.OUTLINED, size = ButtonSize.REGULAR, + isRounded = true, onClick = onClick, ) - } - } + }, + ) } @Composable -private fun UploadedScriptFileContent( - fileName: String, +private fun UploadedScriptFileCard( + fileUri: String, onClear: () -> Unit, ) { - Text( - text = stringResource(R.string.feature_analysis_impl_script_file_placeholder), - color = PrezelTheme.colors.textLarge, - style = PrezelTheme.typography.body3Bold, - ) - Spacer(modifier = Modifier.height(PrezelTheme.spacing.V16)) - CompositionLocalProvider(LocalContentColor provides PrezelTheme.colors.textLarge) { - PrezelList( - title = fileName, - modifier = Modifier - .fillMaxWidth() - .border( - width = PrezelTheme.stroke.V1, - color = PrezelTheme.colors.borderSmall, - shape = PrezelTheme.shapes.V8, - ), - size = PrezelListSize.SMALL, - trailingContent = { - PrezelTouchArea( - extraTouchPadding = PaddingValues(PrezelTheme.spacing.V8), - onClick = onClear, - ) { - Icon( - painter = painterResource(PrezelIcons.CancelCircleFilled), - contentDescription = stringResource(R.string.feature_analysis_impl_script_file_remove), - modifier = Modifier.size(24.dp), - tint = PrezelTheme.colors.iconRegular, - ) - } - }, + val context = LocalContext.current + val fileName = remember(context, fileUri) { fileUri.toFileName(context) } + + Row( + modifier = Modifier + .fillMaxWidth() + .border( + width = PrezelTheme.stroke.V1, + color = PrezelTheme.colors.borderRegular, + shape = PrezelTheme.shapes.V8, + ) + .padding( + horizontal = PrezelTheme.spacing.V12, + vertical = PrezelTheme.spacing.V24, + ), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = fileName, + modifier = Modifier.weight(1f).padding(start = PrezelTheme.spacing.V4), + color = PrezelTheme.colors.textMedium, + style = PrezelTheme.typography.body3Medium, ) + + PrezelTouchArea( + extraTouchPadding = PaddingValues(PrezelTheme.spacing.V8), + isUseRipple = false, + onClick = onClear, + ) { + Icon( + painter = painterResource(PrezelIcons.CancelCircleFilled), + contentDescription = stringResource(R.string.feature_analysis_impl_script_file_remove), + modifier = Modifier.size(24.dp), + tint = PrezelTheme.colors.iconRegular, + ) + } } } diff --git a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/component/AnalysisUploadComponents.kt b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/component/AnalysisUploadComponents.kt index 68caf122..1d6d50a5 100644 --- a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/component/AnalysisUploadComponents.kt +++ b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/component/AnalysisUploadComponents.kt @@ -1,5 +1,7 @@ package com.team.prezel.feature.analysis.impl.component +import android.content.Context +import android.provider.OpenableColumns import androidx.compose.foundation.border import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -55,9 +57,23 @@ internal fun OutlineActionButton( } } -internal fun String.toFileName(): String = - this - .toUri() - .lastPathSegment - .orEmpty() - .ifBlank { this } +internal fun String.toFileName(context: Context): String { + val uri = toUri() + val displayName = runCatching { + context.contentResolver + .query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null) + ?.use { cursor -> + val displayNameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) + + if (displayNameIndex >= 0 && cursor.moveToFirst()) { + cursor.getString(displayNameIndex) + } else { + null + } + } + }.getOrNull() + + return displayName + ?.takeIf { it.isNotBlank() } + ?: uri.lastPathSegment.orEmpty().ifBlank { this } +} diff --git a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/contract/AnalysisFlowUiState.kt b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/contract/AnalysisFlowUiState.kt index 891f26f9..4ad33b87 100644 --- a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/contract/AnalysisFlowUiState.kt +++ b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/contract/AnalysisFlowUiState.kt @@ -25,7 +25,8 @@ internal data class AnalysisFlowUiState( val canMoveNext: Boolean get() = when (step) { - AnalysisFlowStep.PRESENTATION_SCHEDULE -> form.presentationTitle.isNotBlank() && form.presentationDate.isNotBlank() + AnalysisFlowStep.PRESENTATION_SCHEDULE -> + form.presentationTitle.trim().length >= 2 && form.presentationDate.isNotBlank() AnalysisFlowStep.PRESENTATION_SITUATION -> { form.category != null && form.purpose != null && form.style != null && form.audience != null } diff --git a/Prezel/feature/analysis/impl/src/main/res/drawable/feature_analysis_impl_no_script.xml b/Prezel/feature/analysis/impl/src/main/res/drawable/feature_analysis_impl_no_script.xml new file mode 100644 index 00000000..e17f0e68 --- /dev/null +++ b/Prezel/feature/analysis/impl/src/main/res/drawable/feature_analysis_impl_no_script.xml @@ -0,0 +1,22 @@ + + + + + + + diff --git a/Prezel/feature/analysis/impl/src/main/res/values/strings.xml b/Prezel/feature/analysis/impl/src/main/res/values/strings.xml index 7adcbd01..9382ef03 100644 --- a/Prezel/feature/analysis/impl/src/main/res/values/strings.xml +++ b/Prezel/feature/analysis/impl/src/main/res/values/strings.xml @@ -39,12 +39,12 @@ 팀/동료 대본 입력 - 발표 대본을 입력해주세요. - 대본 파일을 업로드하거나, 직접 입력할 수 있어요. + 발표에 사용할 대본을 추가해주세요. + 대본 파일을 업로드하거나 직접 입력할 수 있어요. 발표 대본을 입력해주세요 - 대본 파일을 업로드하세요 - 제공하는 파일 형식 : .txt - 파일 업로드하기 + 대본 파일을 추가해주세요. + 제공하는 파일 형식 : pdf, txt + 파일 추가하기 대본 파일 삭제 직접 입력 From 35e521850f546d2652bb5edf03835b09ba6f07e7 Mon Sep 17 00:00:00 2001 From: Ham BeomJoon Date: Thu, 14 May 2026 23:38:49 +0900 Subject: [PATCH 03/16] =?UTF-8?q?feat:=20=EB=B6=84=EC=84=9D(Analysis)=20?= =?UTF-8?q?=ED=94=8C=EB=A1=9C=EC=9A=B0=20=EA=B0=9C=EC=84=A0=20=EB=B0=8F=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=EC=97=85=EB=A1=9C=EB=93=9C=20=EC=8B=A4?= =?UTF-8?q?=ED=8C=A8=20=ED=99=94=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: 파일 인식 실패 화면 추가 및 탐색 로직 구현** * 음성 및 대본 파일 인식 실패 시 표시할 `FileRecognitionFailedScreen`, `ScriptFileRecognitionFailedScreen`을 추가했습니다. * `AnalysisFlowStep`에 실패 상태(`FILE_RECOGNITION_FAILED`, `SCRIPT_FILE_RECOGNITION_FAILED`)를 추가하고, 재시도(`RetryFileUpload`) 인텐트 처리를 구현했습니다. * **feat: 대본 및 음성 파일 업로드 진행 상태 표시 구현** * 파일 선택 시 즉시 완료되지 않고 시뮬레이션된 프로그레스 바를 표시하도록 개선했습니다 (`animateFloatAsState` 사용). * `ScriptInputScreen` 및 `AudioUploadScreen`에서 파일 업로드 중 진행률(%)과 상태 바를 시각화했습니다. * **refactor: 분석 설정(Situation) 인텐트 및 모델 구조 개선** * 개별 데이터 클래스로 분리되어 있던 카테고리, 목적, 스타일, 청중 선택 인텐트를 `SelectSituationOption` 추상화된 sealed interface 구조로 통합했습니다. * `PresentationSituationScreen` 내 아코디언 컴포넌트들을 하위 함수로 분리하여 가독성을 높였습니다. * **refactor: 디자인 시스템 컴포넌트 최신화 및 UI 코드 정리** * `AnalysisStepLayout`의 버튼 영역을 `PrezelButtonArea`의 최신 API(`mainButton`, `subButton` 슬롯 방식)에 맞게 리팩터링했습니다. * `PrezelChip`의 파라미터명을 최신 디자인 시스템 사양(`interaction` -> `state`)에 맞게 수정했습니다. * `PresentationScheduleScreen`에서 날짜 선택 필드와 텍스트 필드 로직을 별도 컴포넌트로 추출했습니다. * **fix: 지원 파일 형식 수정 및 리소스 추가** * 대본 파일 형식을 `txt`로 제한하고, 음성 파일 형식을 `m4a`, `mp4`, `mp3` 위주로 조정했습니다. * 인식 실패 화면에서 사용할 에러 아이콘(`feature_analysis_impl_error_voice.xml`)을 추가했습니다. * **etc: 개발용 임시 코드 추가** * `SplashViewModel`에서 로그인 여부와 관계없이 홈으로 바로 이동하도록 임시 로직을 추가했습니다. * `LoginEntryBuilder`에서 `AuthManager` 의존성을 주입받도록 수정했습니다. --- .../analysis/impl/AnalysisFlowViewModel.kt | 55 ++++++- .../feature/analysis/impl/AnalysisScreen.kt | 30 +++- .../analysis/impl/AudioUploadScreen.kt | 133 +++++++++++++-- .../impl/FileRecognitionFailedScreen.kt | 83 ++++++++++ .../impl/PresentationScheduleScreen.kt | 95 +++++++---- .../impl/PresentationSituationScreen.kt | 107 +++++++------ .../analysis/impl/ScriptInputScreen.kt | 151 ++++++++++++++++-- .../impl/component/AnalysisStepLayout.kt | 39 +++-- .../impl/contract/AnalysisFlowUiIntent.kt | 43 +++-- .../impl/contract/AnalysisFlowUiState.kt | 12 +- .../feature_analysis_impl_error_voice.xml | 18 +++ .../impl/src/main/res/values/strings.xml | 9 +- .../feature/analysis/impl/contract/.gitkeep | 0 .../login/impl/contract/LoginUiIntent.kt | 5 + .../impl/navigation/LoginEntryBuilder.kt | 4 +- .../feature/splash/impl/SplashViewModel.kt | 3 + 16 files changed, 635 insertions(+), 152 deletions(-) create mode 100644 Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/FileRecognitionFailedScreen.kt create mode 100644 Prezel/feature/analysis/impl/src/main/res/drawable/feature_analysis_impl_error_voice.xml create mode 100644 Prezel/feature/analysis/impl/src/test/java/com/team/prezel/feature/analysis/impl/contract/.gitkeep diff --git a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AnalysisFlowViewModel.kt b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AnalysisFlowViewModel.kt index 562c961b..e2c96aa7 100644 --- a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AnalysisFlowViewModel.kt +++ b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AnalysisFlowViewModel.kt @@ -7,6 +7,9 @@ import com.team.prezel.feature.analysis.impl.contract.AnalysisFlowUiEffect import com.team.prezel.feature.analysis.impl.contract.AnalysisFlowUiIntent import com.team.prezel.feature.analysis.impl.contract.AnalysisFlowUiState import com.team.prezel.feature.analysis.impl.contract.AnalysisForm +import com.team.prezel.feature.analysis.impl.contract.AnalysisSituationOption +import com.team.prezel.feature.analysis.impl.contract.AnalysisUploadType +import com.team.prezel.feature.analysis.impl.contract.ScriptInputType import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.launch import javax.inject.Inject @@ -18,20 +21,29 @@ internal class AnalysisFlowViewModel @Inject constructor() : when (intent) { is AnalysisFlowUiIntent.UpdatePresentationTitle -> updateForm { copy(presentationTitle = intent.title) } is AnalysisFlowUiIntent.UpdatePresentationDate -> updateForm { copy(presentationDate = intent.date) } - is AnalysisFlowUiIntent.SelectCategory -> updateForm { copy(category = intent.category) } - is AnalysisFlowUiIntent.SelectPurpose -> updateForm { copy(purpose = intent.purpose) } - is AnalysisFlowUiIntent.SelectStyle -> updateForm { copy(style = intent.style) } - is AnalysisFlowUiIntent.SelectAudience -> updateForm { copy(audience = intent.audience) } + is AnalysisFlowUiIntent.SelectSituationOption -> selectSituationOption(intent.option) is AnalysisFlowUiIntent.SelectScriptInputType -> updateForm { copy(scriptInputType = intent.inputType) } is AnalysisFlowUiIntent.UpdateScript -> updateForm { copy(script = intent.script) } is AnalysisFlowUiIntent.SelectScriptFile -> updateForm { copy(scriptFileUri = intent.fileUri) } is AnalysisFlowUiIntent.SelectAudioFile -> updateForm { copy(audioFileUri = intent.fileUri) } + is AnalysisFlowUiIntent.RetryFileUpload -> retryFileUpload(intent.uploadType) AnalysisFlowUiIntent.Next -> moveNext() AnalysisFlowUiIntent.SkipScript -> skipScript() AnalysisFlowUiIntent.Back -> moveBack() } } + private fun selectSituationOption(option: AnalysisSituationOption) { + updateForm { + when (option) { + is AnalysisSituationOption.CategoryOption -> copy(category = option.category) + is AnalysisSituationOption.PurposeOption -> copy(purpose = option.purpose) + is AnalysisSituationOption.StyleOption -> copy(style = option.style) + is AnalysisSituationOption.AudienceOption -> copy(audience = option.audience) + } + } + } + private fun moveNext() { if (!currentState.canMoveNext && currentState.step != AnalysisFlowStep.ANALYZING) return @@ -42,12 +54,43 @@ internal class AnalysisFlowViewModel @Inject constructor() : AnalysisFlowStep.PRESENTATION_SITUATION -> AnalysisFlowStep.SCRIPT_INPUT AnalysisFlowStep.SCRIPT_INPUT -> AnalysisFlowStep.AUDIO_UPLOAD AnalysisFlowStep.AUDIO_UPLOAD -> AnalysisFlowStep.ANALYZING - AnalysisFlowStep.ANALYZING -> AnalysisFlowStep.ANALYZING + AnalysisFlowStep.ANALYZING, + AnalysisFlowStep.FILE_RECOGNITION_FAILED, + AnalysisFlowStep.SCRIPT_FILE_RECOGNITION_FAILED, + -> AnalysisFlowStep.ANALYZING }, ) } } + private fun retryFileUpload(uploadType: AnalysisUploadType) { + when (uploadType) { + AnalysisUploadType.SCRIPT -> retryScriptFileUpload() + AnalysisUploadType.AUDIO -> retryAudioUpload() + } + } + + private fun retryAudioUpload() { + updateState { + copy( + step = AnalysisFlowStep.AUDIO_UPLOAD, + form = form.copy(audioFileUri = null), + ) + } + } + + private fun retryScriptFileUpload() { + updateState { + copy( + step = AnalysisFlowStep.SCRIPT_INPUT, + form = form.copy( + scriptInputType = ScriptInputType.FILE_UPLOAD, + scriptFileUri = null, + ), + ) + } + } + private fun skipScript() { if (currentState.step != AnalysisFlowStep.SCRIPT_INPUT) return @@ -61,6 +104,8 @@ internal class AnalysisFlowViewModel @Inject constructor() : AnalysisFlowStep.SCRIPT_INPUT -> AnalysisFlowStep.PRESENTATION_SITUATION AnalysisFlowStep.AUDIO_UPLOAD -> AnalysisFlowStep.SCRIPT_INPUT AnalysisFlowStep.ANALYZING -> AnalysisFlowStep.AUDIO_UPLOAD + AnalysisFlowStep.FILE_RECOGNITION_FAILED -> AnalysisFlowStep.AUDIO_UPLOAD + AnalysisFlowStep.SCRIPT_FILE_RECOGNITION_FAILED -> AnalysisFlowStep.SCRIPT_INPUT } if (previousStep == null) { diff --git a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AnalysisScreen.kt b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AnalysisScreen.kt index cde90d59..bdcebb64 100644 --- a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AnalysisScreen.kt +++ b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AnalysisScreen.kt @@ -8,6 +8,8 @@ import com.team.prezel.feature.analysis.impl.contract.AnalysisFlowStep import com.team.prezel.feature.analysis.impl.contract.AnalysisFlowUiEffect import com.team.prezel.feature.analysis.impl.contract.AnalysisFlowUiIntent import com.team.prezel.feature.analysis.impl.contract.AnalysisFlowUiState +import com.team.prezel.feature.analysis.impl.contract.AnalysisSituationOption +import com.team.prezel.feature.analysis.impl.contract.AnalysisUploadType @Composable internal fun AnalysisScreen( @@ -49,10 +51,10 @@ private fun AnalysisScreen( AnalysisFlowStep.PRESENTATION_SITUATION -> PresentationSituationScreen( uiState = uiState, - onSelectCategory = { onIntent(AnalysisFlowUiIntent.SelectCategory(it)) }, - onSelectPurpose = { onIntent(AnalysisFlowUiIntent.SelectPurpose(it)) }, - onSelectStyle = { onIntent(AnalysisFlowUiIntent.SelectStyle(it)) }, - onSelectAudience = { onIntent(AnalysisFlowUiIntent.SelectAudience(it)) }, + onSelectCategory = { onIntent(it.toSituationIntent()) }, + onSelectPurpose = { onIntent(it.toSituationIntent()) }, + onSelectStyle = { onIntent(it.toSituationIntent()) }, + onSelectAudience = { onIntent(it.toSituationIntent()) }, onNext = { onIntent(AnalysisFlowUiIntent.Next) }, onBack = { onIntent(AnalysisFlowUiIntent.Back) }, ) @@ -78,5 +80,25 @@ private fun AnalysisScreen( onFinished = onFinished, onBack = { onIntent(AnalysisFlowUiIntent.Back) }, ) + + AnalysisFlowStep.FILE_RECOGNITION_FAILED -> FileRecognitionFailedScreen( + onRetry = { onIntent(AnalysisFlowUiIntent.RetryFileUpload(AnalysisUploadType.AUDIO)) }, + ) + + AnalysisFlowStep.SCRIPT_FILE_RECOGNITION_FAILED -> ScriptFileRecognitionFailedScreen( + onRetry = { onIntent(AnalysisFlowUiIntent.RetryFileUpload(AnalysisUploadType.SCRIPT)) }, + ) } } + +private fun com.team.prezel.core.model.presentation.Category.toSituationIntent(): AnalysisFlowUiIntent = + AnalysisFlowUiIntent.SelectSituationOption(AnalysisSituationOption.CategoryOption(this)) + +private fun com.team.prezel.core.model.presentation.Purpose.toSituationIntent(): AnalysisFlowUiIntent = + AnalysisFlowUiIntent.SelectSituationOption(AnalysisSituationOption.PurposeOption(this)) + +private fun com.team.prezel.core.model.presentation.Style.toSituationIntent(): AnalysisFlowUiIntent = + AnalysisFlowUiIntent.SelectSituationOption(AnalysisSituationOption.StyleOption(this)) + +private fun com.team.prezel.core.model.presentation.Audience.toSituationIntent(): AnalysisFlowUiIntent = + AnalysisFlowUiIntent.SelectSituationOption(AnalysisSituationOption.AudienceOption(this)) diff --git a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AudioUploadScreen.kt b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AudioUploadScreen.kt index 54fe992b..429747e5 100644 --- a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AudioUploadScreen.kt +++ b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AudioUploadScreen.kt @@ -2,6 +2,9 @@ package com.team.prezel.feature.analysis.impl import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.border @@ -19,7 +22,11 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.Icon 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.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -44,9 +51,11 @@ import com.team.prezel.feature.analysis.impl.contract.AnalysisFlowStep import com.team.prezel.feature.analysis.impl.contract.AnalysisFlowUiState import com.team.prezel.feature.analysis.impl.contract.AnalysisForm import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.delay -private const val AUDIO_FILE_MIME_TYPE = "audio/*" -private const val AUDIO_PREVIEW_FILE_URI = "content://prezel/sample.wav" +private const val AUDIO_UPLOAD_PROGRESS_DURATION_MILLIS = 800 +private val AUDIO_FILE_MIME_TYPES = arrayOf("audio/m4a", "audio/x-m4a", "audio/mp4", "video/mp4", "audio/mpeg") +private const val AUDIO_PREVIEW_FILE_URI = "content://prezel/sample.m4a" private const val AUDIO_UPLOAD_TAB_COUNT = 1 private const val UPLOADED_AUDIO_PROGRESS = 0f @@ -57,16 +66,48 @@ internal fun AudioUploadScreen( onAnalyze: () -> Unit, onBack: () -> Unit, ) { - val audioPicker = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri -> - onAudioFileSelected(uri?.toString()) + var pendingAudioFileUri by remember { mutableStateOf(null) } + val uploadProgress by animateFloatAsState( + targetValue = if (pendingAudioFileUri != null) 1f else 0f, + animationSpec = tween( + durationMillis = AUDIO_UPLOAD_PROGRESS_DURATION_MILLIS, + easing = LinearEasing, + ), + label = "audio-upload-progress", + ) + + val audioPicker = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> + uri?.toString()?.let { fileUri -> + pendingAudioFileUri = fileUri + } + } + + LaunchedEffect(pendingAudioFileUri) { + val fileUri = pendingAudioFileUri ?: return@LaunchedEffect + delay(AUDIO_UPLOAD_PROGRESS_DURATION_MILLIS.toLong()) + onAudioFileSelected(fileUri) + } + + LaunchedEffect(uiState.form.audioFileUri, pendingAudioFileUri) { + if (pendingAudioFileUri != null && uiState.form.audioFileUri == pendingAudioFileUri) { + pendingAudioFileUri = null + } } AudioUploadScreen( form = uiState.form, + pendingAudioFileUri = pendingAudioFileUri, + uploadProgress = uploadProgress, progress = uiState.progress, buttonEnabled = uiState.canMoveNext, - onAudioFileUploadClick = { audioPicker.launch(AUDIO_FILE_MIME_TYPE) }, - onAudioFileClear = { onAudioFileSelected(null) }, + onAudioFileUploadClick = { audioPicker.launch(AUDIO_FILE_MIME_TYPES) }, + onAudioFileClear = { + if (pendingAudioFileUri != null) { + pendingAudioFileUri = null + } else { + onAudioFileSelected(null) + } + }, onAnalyze = onAnalyze, onBack = onBack, ) @@ -75,6 +116,8 @@ internal fun AudioUploadScreen( @Composable private fun AudioUploadScreen( form: AnalysisForm, + pendingAudioFileUri: String?, + uploadProgress: Float, progress: Float, buttonEnabled: Boolean, onAudioFileUploadClick: () -> Unit, @@ -108,7 +151,8 @@ private fun AudioUploadScreen( ) AudioUploadContent( - fileUri = form.audioFileUri, + fileUri = pendingAudioFileUri ?: form.audioFileUri, + uploadProgress = if (pendingAudioFileUri != null) uploadProgress else null, onUploadClick = onAudioFileUploadClick, onClear = onAudioFileClear, ) @@ -118,6 +162,7 @@ private fun AudioUploadScreen( @Composable private fun AudioUploadContent( fileUri: String?, + uploadProgress: Float?, onUploadClick: () -> Unit, onClear: () -> Unit, ) { @@ -129,6 +174,7 @@ private fun AudioUploadContent( Spacer(modifier = Modifier.height(PrezelTheme.spacing.V16)) UploadedAudioFileCard( fileName = remember(context, fileUri) { fileUri.toFileName(context) }, + uploadProgress = uploadProgress, onClear = onClear, ) } @@ -165,6 +211,7 @@ private fun AudioUploadEmptyContent(onUploadClick: () -> Unit) { @Composable private fun UploadedAudioFileCard( fileName: String, + uploadProgress: Float?, onClear: () -> Unit, ) { Row( @@ -175,13 +222,16 @@ private fun UploadedAudioFileCard( color = PrezelTheme.colors.borderSmall, shape = PrezelTheme.shapes.V8, ).padding( - horizontal = PrezelTheme.spacing.V16, - vertical = PrezelTheme.spacing.V16, + start = PrezelTheme.spacing.V16, + end = PrezelTheme.spacing.V12, + top = PrezelTheme.spacing.V16, + bottom = PrezelTheme.spacing.V16, ), verticalAlignment = Alignment.CenterVertically, ) { UploadedAudioFileInfo( fileName = fileName, + uploadProgress = uploadProgress, modifier = Modifier.weight(1f), ) @@ -204,6 +254,7 @@ private fun UploadedAudioFileCard( @Composable private fun UploadedAudioFileInfo( fileName: String, + uploadProgress: Float?, modifier: Modifier = Modifier, ) { Column(modifier = modifier) { @@ -211,7 +262,11 @@ private fun UploadedAudioFileInfo( Spacer(modifier = Modifier.height(PrezelTheme.spacing.V8)) - AudioFileProgressRow() + if (uploadProgress == null) { + AudioFileProgressRow() + } else { + AudioUploadProgressRow(progress = uploadProgress) + } } } @@ -256,10 +311,36 @@ private fun AudioFileProgressRow() { } } +@Composable +private fun AudioUploadProgressRow(progress: Float) { + val coercedProgress = progress.coerceIn(0f, 1f) + val progressPercent = (coercedProgress * 100).toInt() + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + AudioProgressTrack( + progress = coercedProgress, + showThumb = false, + modifier = Modifier.weight(1f), + ) + + Spacer(modifier = Modifier.size(PrezelTheme.spacing.V16)) + + Text( + text = "%02d%%".format(progressPercent), + color = PrezelTheme.colors.textSmall, + style = PrezelTheme.typography.body2Regular, + ) + } +} + @Composable private fun AudioProgressTrack( progress: Float, modifier: Modifier = Modifier, + showThumb: Boolean = true, ) { Box( modifier = modifier.height(16.dp), @@ -279,12 +360,14 @@ private fun AudioProgressTrack( .clip(CircleShape) .background(PrezelTheme.colors.interactiveRegular), ) - Box( - modifier = Modifier - .size(10.dp) - .clip(CircleShape) - .background(PrezelTheme.colors.interactiveRegular), - ) + if (showThumb) { + Box( + modifier = Modifier + .size(10.dp) + .clip(CircleShape) + .background(PrezelTheme.colors.interactiveRegular), + ) + } } } @@ -301,6 +384,24 @@ private fun AudioUploadScreenPreview() { } } +@BasicPreview +@Composable +private fun AudioUploadScreenProgressPreview() { + PrezelTheme { + AudioUploadScreen( + form = AnalysisForm(), + pendingAudioFileUri = AUDIO_PREVIEW_FILE_URI, + uploadProgress = 0.5f, + progress = AnalysisFlowUiState(step = AnalysisFlowStep.AUDIO_UPLOAD).progress, + buttonEnabled = false, + onAudioFileUploadClick = {}, + onAudioFileClear = {}, + onAnalyze = {}, + onBack = {}, + ) + } +} + @BasicPreview @Composable private fun AudioUploadScreenSelectedPreview() { diff --git a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/FileRecognitionFailedScreen.kt b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/FileRecognitionFailedScreen.kt new file mode 100644 index 00000000..eff9f44f --- /dev/null +++ b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/FileRecognitionFailedScreen.kt @@ -0,0 +1,83 @@ +package com.team.prezel.feature.analysis.impl + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.fillMaxSize +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.StatusView + +@Composable +internal fun FileRecognitionFailedScreen(onRetry: () -> Unit) { + FileRecognitionFailedStatusView( + title = stringResource(R.string.feature_analysis_impl_file_recognition_failed_title), + description = stringResource(R.string.feature_analysis_impl_file_recognition_failed_description), + onRetry = onRetry, + ) +} + +@Composable +internal fun ScriptFileRecognitionFailedScreen(onRetry: () -> Unit) { + FileRecognitionFailedStatusView( + title = stringResource(R.string.feature_analysis_impl_script_file_recognition_failed_title), + description = stringResource(R.string.feature_analysis_impl_script_file_recognition_failed_description), + onRetry = onRetry, + ) +} + +@Composable +private fun FileRecognitionFailedStatusView( + title: String, + description: String, + onRetry: () -> Unit, +) { + StatusView( + title = title, + description = description, + modifier = Modifier.fillMaxSize(), + visual = { + Image( + painter = painterResource(R.drawable.feature_analysis_impl_error_voice), + contentDescription = null, + modifier = Modifier.size(120.dp), + ) + }, + action = { + PrezelButton( + text = stringResource(R.string.feature_analysis_impl_retry), + iconResId = PrezelIcons.Reset, + type = ButtonType.FILLED, + size = ButtonSize.SMALL, + hierarchy = ButtonHierarchy.SECONDARY, + isRounded = true, + onClick = onRetry, + ) + }, + ) +} + +@BasicPreview +@Composable +private fun FileRecognitionFailedScreenPreview() { + PrezelTheme { + FileRecognitionFailedScreen(onRetry = {}) + } +} + +@BasicPreview +@Composable +private fun ScriptFileRecognitionFailedScreenPreview() { + PrezelTheme { + ScriptFileRecognitionFailedScreen(onRetry = {}) + } +} diff --git a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/PresentationScheduleScreen.kt b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/PresentationScheduleScreen.kt index 31f516b7..03ed6513 100644 --- a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/PresentationScheduleScreen.kt +++ b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/PresentationScheduleScreen.kt @@ -57,13 +57,12 @@ private fun PresentationScheduleScreen( onBack: () -> Unit, ) { val showDatePicker = rememberSaveable { mutableStateOf(false) } - val dateFieldInteractionSource = remember { MutableInteractionSource() } val datePickerTitle = stringResource(R.string.feature_analysis_impl_presentation_date_label) if (showDatePicker.value) { - PrezelDatePicker( + PresentationDatePicker( title = datePickerTitle, - initialSelectedDate = form.presentationDate.toLocalDateOrNull(), + selectedDateText = form.presentationDate, onClose = { showDatePicker.value = false }, onConfirm = { selectedDate -> onDateChange(selectedDate.toPresentationDateText()) @@ -97,39 +96,67 @@ private fun PresentationScheduleScreen( Spacer(modifier = Modifier.height(PrezelTheme.spacing.V32)) - Column(modifier = Modifier.fillMaxWidth()) { - Text( - text = stringResource(R.string.feature_analysis_impl_presentation_date_label), - style = PrezelTheme.typography.body3Medium, - color = PrezelTheme.colors.textMedium, - maxLines = 1, + PresentationDateField( + dateText = form.presentationDate, + datePickerTitle = datePickerTitle, + onClick = { showDatePicker.value = true }, + ) + } +} + +@Composable +private fun PresentationDatePicker( + title: String, + selectedDateText: String, + onClose: () -> Unit, + onConfirm: (LocalDate) -> Unit, +) { + PrezelDatePicker( + title = title, + initialSelectedDate = selectedDateText.toLocalDateOrNull(), + onClose = onClose, + onConfirm = onConfirm, + ) +} + +@Composable +private fun PresentationDateField( + dateText: String, + datePickerTitle: String, + onClick: () -> Unit, +) { + Column(modifier = Modifier.fillMaxWidth()) { + Text( + text = stringResource(R.string.feature_analysis_impl_presentation_date_label), + style = PrezelTheme.typography.body3Medium, + color = PrezelTheme.colors.textMedium, + maxLines = 1, + ) + + Spacer(modifier = Modifier.height(PrezelTheme.spacing.V8)) + + Box(modifier = Modifier.fillMaxWidth()) { + PrezelTextField( + value = dateText, + onValueChange = {}, + placeholder = stringResource(R.string.feature_analysis_impl_presentation_date_placeholder), + trailingIcon = { + Icon( + painter = painterResource(PrezelIcons.Calendar), + contentDescription = datePickerTitle, + ) + }, ) - Spacer(modifier = Modifier.height(PrezelTheme.spacing.V8)) - - Box(modifier = Modifier.fillMaxWidth()) { - PrezelTextField( - value = form.presentationDate, - onValueChange = {}, - placeholder = stringResource(R.string.feature_analysis_impl_presentation_date_placeholder), - trailingIcon = { - Icon( - painter = painterResource(PrezelIcons.Calendar), - contentDescription = datePickerTitle, - ) - }, - ) - - Box( - modifier = Modifier - .matchParentSize() - .clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = null, - onClick = { showDatePicker.value = true }, - ), - ) - } + Box( + modifier = Modifier + .matchParentSize() + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = onClick, + ), + ) } } } diff --git a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/PresentationSituationScreen.kt b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/PresentationSituationScreen.kt index 98f54cec..138c1af1 100644 --- a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/PresentationSituationScreen.kt +++ b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/PresentationSituationScreen.kt @@ -21,10 +21,10 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.team.prezel.core.designsystem.component.PrezelAccordion import com.team.prezel.core.designsystem.component.base.PrezelTouchArea -import com.team.prezel.core.designsystem.component.chip.PrezelChip -import com.team.prezel.core.designsystem.component.chip.config.PrezelChipInteraction -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.ChipSize +import com.team.prezel.core.designsystem.component.chip.chip.ChipState +import com.team.prezel.core.designsystem.component.chip.chip.ChipType +import com.team.prezel.core.designsystem.component.chip.chip.PrezelChip import com.team.prezel.core.designsystem.icon.PrezelIcons import com.team.prezel.core.designsystem.preview.BasicPreview import com.team.prezel.core.designsystem.theme.PrezelTheme @@ -75,11 +75,6 @@ private fun PresentationSituationScreen( onNext: () -> Unit, onBack: () -> Unit, ) { - val categoryOptions = categoryOptions() - val purposeOptions = purposeOptions() - val styleOptions = styleOptions() - val audienceOptions = audienceOptions() - AnalysisStepLayout( title = stringResource(R.string.feature_analysis_impl_situation_title), progress = progress, @@ -95,39 +90,61 @@ private fun PresentationSituationScreen( Spacer(modifier = Modifier.height(PrezelTheme.spacing.V32)) - SituationAccordion( - title = stringResource(R.string.feature_analysis_impl_situation_category_label), - ) { - CategoryOptionGrid( - selectedValue = form.category, - options = categoryOptions, - onSelect = onSelectCategory, - ) - } - SituationAccordion( - title = stringResource(R.string.feature_analysis_impl_situation_purpose_label), - ) { - ChipOptionsContent( - options = purposeOptions.toChipContentOptions(form.purpose), - onSelect = { index -> onSelectPurpose(purposeOptions[index].value) }, - ) - } - SituationAccordion( - title = stringResource(R.string.feature_analysis_impl_situation_style_label), - ) { - ChipOptionsContent( - options = styleOptions.toChipContentOptions(form.style), - onSelect = { index -> onSelectStyle(styleOptions[index].value) }, - ) - } - SituationAccordion( - title = stringResource(R.string.feature_analysis_impl_situation_scale_label), - ) { - ChipOptionsContent( - options = audienceOptions.toChipContentOptions(form.audience), - onSelect = { index -> onSelectAudience(audienceOptions[index].value) }, - ) - } + SituationAccordions( + form = form, + onSelectCategory = onSelectCategory, + onSelectPurpose = onSelectPurpose, + onSelectStyle = onSelectStyle, + onSelectAudience = onSelectAudience, + ) + } +} + +@Composable +private fun SituationAccordions( + form: AnalysisForm, + onSelectCategory: (Category) -> Unit, + onSelectPurpose: (Purpose) -> Unit, + onSelectStyle: (Style) -> Unit, + onSelectAudience: (Audience) -> Unit, +) { + val categoryOptions = categoryOptions() + val purposeOptions = purposeOptions() + val styleOptions = styleOptions() + val audienceOptions = audienceOptions() + + SituationAccordion( + title = stringResource(R.string.feature_analysis_impl_situation_category_label), + ) { + CategoryOptionGrid( + selectedValue = form.category, + options = categoryOptions, + onSelect = onSelectCategory, + ) + } + SituationAccordion( + title = stringResource(R.string.feature_analysis_impl_situation_purpose_label), + ) { + ChipOptionsContent( + options = purposeOptions.toChipContentOptions(form.purpose), + onSelect = { index -> onSelectPurpose(purposeOptions[index].value) }, + ) + } + SituationAccordion( + title = stringResource(R.string.feature_analysis_impl_situation_style_label), + ) { + ChipOptionsContent( + options = styleOptions.toChipContentOptions(form.style), + onSelect = { index -> onSelectStyle(styleOptions[index].value) }, + ) + } + SituationAccordion( + title = stringResource(R.string.feature_analysis_impl_situation_scale_label), + ) { + ChipOptionsContent( + options = audienceOptions.toChipContentOptions(form.audience), + onSelect = { index -> onSelectAudience(audienceOptions[index].value) }, + ) } } @@ -266,9 +283,9 @@ private fun SituationChip( PrezelChip( text = text, iconResId = if (selected) PrezelIcons.Check else null, - type = PrezelChipType.OUTLINED, - size = PrezelChipSize.REGULAR, - interaction = if (selected) PrezelChipInteraction.ACTIVE else PrezelChipInteraction.DEFAULT, + type = ChipType.OUTLINED, + size = ChipSize.REGULAR, + state = if (selected) ChipState.ACTIVE else ChipState.DEFAULT, ) } } diff --git a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/ScriptInputScreen.kt b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/ScriptInputScreen.kt index 48a82bee..834d72f2 100644 --- a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/ScriptInputScreen.kt +++ b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/ScriptInputScreen.kt @@ -2,8 +2,14 @@ package com.team.prezel.feature.analysis.impl import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween import androidx.compose.foundation.Image +import androidx.compose.foundation.background import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -16,7 +22,10 @@ import androidx.compose.material3.Icon 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.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext @@ -42,9 +51,11 @@ import com.team.prezel.feature.analysis.impl.contract.AnalysisFlowUiState import com.team.prezel.feature.analysis.impl.contract.AnalysisForm import com.team.prezel.feature.analysis.impl.contract.ScriptInputType import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.delay private const val SCRIPT_MAX_LENGTH = 5_000 -private val SCRIPT_FILE_MIME_TYPES = arrayOf("application/pdf", "text/plain", "text/*", "application/octet-stream") +private const val SCRIPT_UPLOAD_PROGRESS_DURATION_MILLIS = 800 +private val SCRIPT_FILE_MIME_TYPES = arrayOf("text/plain") private const val SCRIPT_INPUT_TAB_COUNT = 2 @Composable @@ -57,18 +68,50 @@ internal fun ScriptInputScreen( onSkip: () -> Unit, onBack: () -> Unit, ) { + var pendingScriptFileUri by remember { mutableStateOf(null) } + val uploadProgress by animateFloatAsState( + targetValue = if (pendingScriptFileUri != null) 1f else 0f, + animationSpec = tween( + durationMillis = SCRIPT_UPLOAD_PROGRESS_DURATION_MILLIS, + easing = LinearEasing, + ), + label = "script-upload-progress", + ) + val scriptPicker = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> - onScriptFileSelected(uri?.toString()) + uri?.toString()?.let { fileUri -> + pendingScriptFileUri = fileUri + } + } + + LaunchedEffect(pendingScriptFileUri) { + val fileUri = pendingScriptFileUri ?: return@LaunchedEffect + delay(SCRIPT_UPLOAD_PROGRESS_DURATION_MILLIS.toLong()) + onScriptFileSelected(fileUri) + } + + LaunchedEffect(uiState.form.scriptFileUri, pendingScriptFileUri) { + if (pendingScriptFileUri != null && uiState.form.scriptFileUri == pendingScriptFileUri) { + pendingScriptFileUri = null + } } ScriptInputScreen( form = uiState.form, + pendingScriptFileUri = pendingScriptFileUri, + uploadProgress = uploadProgress, progress = uiState.progress, buttonEnabled = uiState.canMoveNext, onSelectInputType = onSelectInputType, onScriptChange = onScriptChange, onScriptFileUploadClick = { scriptPicker.launch(SCRIPT_FILE_MIME_TYPES) }, - onScriptFileClear = { onScriptFileSelected(null) }, + onScriptFileClear = { + if (pendingScriptFileUri != null) { + pendingScriptFileUri = null + } else { + onScriptFileSelected(null) + } + }, onNext = onNext, onSkip = onSkip, onBack = onBack, @@ -78,6 +121,8 @@ internal fun ScriptInputScreen( @Composable private fun ScriptInputScreen( form: AnalysisForm, + pendingScriptFileUri: String?, + uploadProgress: Float, progress: Float, buttonEnabled: Boolean, onSelectInputType: (ScriptInputType) -> Unit, @@ -114,7 +159,8 @@ private fun ScriptInputScreen( when (form.scriptInputType) { ScriptInputType.FILE_UPLOAD -> ScriptUploadCard( - fileUri = form.scriptFileUri, + fileUri = pendingScriptFileUri ?: form.scriptFileUri, + uploadProgress = if (pendingScriptFileUri != null) uploadProgress else null, onClick = onScriptFileUploadClick, onClear = onScriptFileClear, ) @@ -176,20 +222,23 @@ private fun Int.toScriptInputType(): ScriptInputType = @Composable private fun ScriptUploadCard( fileUri: String?, + uploadProgress: Float?, onClick: () -> Unit, onClear: () -> Unit, ) { if (fileUri == null) { EmptyScriptUploadContent(onClick = onClick) } else { - UploadedScriptFileCard(fileUri = fileUri, onClear = onClear) + UploadedScriptFileCard( + fileUri = fileUri, + uploadProgress = uploadProgress, + onClear = onClear, + ) } } @Composable -private fun EmptyScriptUploadContent( - onClick: () -> Unit, -) { +private fun EmptyScriptUploadContent(onClick: () -> Unit) { StatusView( title = stringResource(R.string.feature_analysis_impl_script_file_placeholder), description = stringResource(R.string.feature_analysis_impl_script_file_format), @@ -219,6 +268,7 @@ private fun EmptyScriptUploadContent( @Composable private fun UploadedScriptFileCard( fileUri: String, + uploadProgress: Float?, onClear: () -> Unit, ) { val context = LocalContext.current @@ -231,19 +281,28 @@ private fun UploadedScriptFileCard( width = PrezelTheme.stroke.V1, color = PrezelTheme.colors.borderRegular, shape = PrezelTheme.shapes.V8, - ) - .padding( + ).padding( horizontal = PrezelTheme.spacing.V12, vertical = PrezelTheme.spacing.V24, ), verticalAlignment = Alignment.CenterVertically, ) { - Text( - text = fileName, - modifier = Modifier.weight(1f).padding(start = PrezelTheme.spacing.V4), - color = PrezelTheme.colors.textMedium, - style = PrezelTheme.typography.body3Medium, - ) + Column( + modifier = Modifier + .weight(1f) + .padding(start = PrezelTheme.spacing.V4), + ) { + Text( + text = fileName, + color = PrezelTheme.colors.textMedium, + style = PrezelTheme.typography.body3Medium, + ) + + if (uploadProgress != null) { + Spacer(modifier = Modifier.height(PrezelTheme.spacing.V16)) + ScriptUploadProgress(progress = uploadProgress) + } + } PrezelTouchArea( extraTouchPadding = PaddingValues(PrezelTheme.spacing.V8), @@ -260,6 +319,45 @@ private fun UploadedScriptFileCard( } } +@Composable +private fun ScriptUploadProgress(progress: Float) { + val coercedProgress = progress.coerceIn(0f, 1f) + val progressPercent = (coercedProgress * 100).toInt() + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier + .weight(1f) + .height(6.dp) + .background( + color = PrezelTheme.colors.bgLarge, + shape = PrezelTheme.shapes.V1000, + ), + ) { + Box( + modifier = Modifier + .fillMaxWidth(coercedProgress) + .height(6.dp) + .background( + color = PrezelTheme.colors.interactiveRegular, + shape = PrezelTheme.shapes.V1000, + ), + ) + } + + Spacer(modifier = Modifier.size(PrezelTheme.spacing.V16)) + + Text( + text = "%02d%%".format(progressPercent), + color = PrezelTheme.colors.textSmall, + style = PrezelTheme.typography.body2Regular, + ) + } +} + @BasicPreview @Composable private fun ScriptInputUploadScreenPreview() { @@ -276,6 +374,27 @@ private fun ScriptInputUploadScreenPreview() { } } +@BasicPreview +@Composable +private fun ScriptInputUploadProgressScreenPreview() { + PrezelTheme { + ScriptInputScreen( + form = AnalysisForm(), + pendingScriptFileUri = "content://prezel/25-2 컨셉발표회 대본.txt", + uploadProgress = 0.5f, + progress = AnalysisFlowUiState(step = AnalysisFlowStep.SCRIPT_INPUT).progress, + buttonEnabled = false, + onSelectInputType = {}, + onScriptChange = {}, + onScriptFileUploadClick = {}, + onScriptFileClear = {}, + onNext = {}, + onSkip = {}, + onBack = {}, + ) + } +} + @BasicPreview @Composable private fun ScriptInputUploadedScreenPreview() { diff --git a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/component/AnalysisStepLayout.kt b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/component/AnalysisStepLayout.kt index 2232c2e7..1a8c7580 100644 --- a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/component/AnalysisStepLayout.kt +++ b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/component/AnalysisStepLayout.kt @@ -24,6 +24,9 @@ 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.actions.button.config.ButtonHierarchy +import com.team.prezel.core.designsystem.component.actions.button.config.ButtonType import com.team.prezel.core.designsystem.component.base.PrezelTouchArea import com.team.prezel.core.designsystem.icon.PrezelIcons import com.team.prezel.core.designsystem.theme.PrezelTheme @@ -123,19 +126,31 @@ private fun AnalysisStepButtonArea( subButtonText: String?, onSubButtonClick: (() -> Unit)?, ) { - PrezelButtonArea { - MainButton( - label = buttonText, - enabled = buttonEnabled, - onClick = onButtonClick, - ) - if (subButtonText != null && onSubButtonClick != null) { - SubButton( - label = subButtonText, - onClick = onSubButtonClick, + PrezelButtonArea( + mainButton = { modifier -> + PrezelButton( + modifier = modifier, + text = buttonText, + enabled = buttonEnabled, + onClick = onButtonClick, + type = ButtonType.FILLED, + hierarchy = ButtonHierarchy.PRIMARY, ) - } - } + }, + subButton = if (subButtonText != null && onSubButtonClick != null) { + { modifier -> + PrezelButton( + modifier = modifier, + text = subButtonText, + onClick = onSubButtonClick, + type = ButtonType.FILLED, + hierarchy = ButtonHierarchy.SECONDARY, + ) + } + } else { + null + }, + ) } @Composable diff --git a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/contract/AnalysisFlowUiIntent.kt b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/contract/AnalysisFlowUiIntent.kt index b4a52820..fc3a8fd3 100644 --- a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/contract/AnalysisFlowUiIntent.kt +++ b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/contract/AnalysisFlowUiIntent.kt @@ -15,20 +15,8 @@ internal sealed interface AnalysisFlowUiIntent : UiIntent { val date: String, ) : AnalysisFlowUiIntent - data class SelectCategory( - val category: Category, - ) : AnalysisFlowUiIntent - - data class SelectPurpose( - val purpose: Purpose, - ) : AnalysisFlowUiIntent - - data class SelectStyle( - val style: Style, - ) : AnalysisFlowUiIntent - - data class SelectAudience( - val audience: Audience, + data class SelectSituationOption( + val option: AnalysisSituationOption, ) : AnalysisFlowUiIntent data class SelectScriptInputType( @@ -49,7 +37,34 @@ internal sealed interface AnalysisFlowUiIntent : UiIntent { data object Next : AnalysisFlowUiIntent + data class RetryFileUpload( + val uploadType: AnalysisUploadType, + ) : AnalysisFlowUiIntent + data object SkipScript : AnalysisFlowUiIntent data object Back : AnalysisFlowUiIntent } + +internal enum class AnalysisUploadType { + SCRIPT, + AUDIO, +} + +internal sealed interface AnalysisSituationOption { + data class CategoryOption( + val category: Category, + ) : AnalysisSituationOption + + data class PurposeOption( + val purpose: Purpose, + ) : AnalysisSituationOption + + data class StyleOption( + val style: Style, + ) : AnalysisSituationOption + + data class AudienceOption( + val audience: Audience, + ) : AnalysisSituationOption +} diff --git a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/contract/AnalysisFlowUiState.kt b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/contract/AnalysisFlowUiState.kt index 4ad33b87..0705533d 100644 --- a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/contract/AnalysisFlowUiState.kt +++ b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/contract/AnalysisFlowUiState.kt @@ -17,10 +17,13 @@ internal data class AnalysisFlowUiState( AnalysisFlowStep.PRESENTATION_SCHEDULE -> 0.25f AnalysisFlowStep.PRESENTATION_SITUATION, AnalysisFlowStep.SCRIPT_INPUT, + AnalysisFlowStep.SCRIPT_FILE_RECOGNITION_FAILED, -> 0.5f AnalysisFlowStep.AUDIO_UPLOAD -> 0.75f - AnalysisFlowStep.ANALYZING -> 1f + AnalysisFlowStep.ANALYZING, + AnalysisFlowStep.FILE_RECOGNITION_FAILED, + -> 1f } val canMoveNext: Boolean @@ -37,7 +40,10 @@ internal data class AnalysisFlowUiState( } AnalysisFlowStep.AUDIO_UPLOAD -> form.audioFileUri != null - AnalysisFlowStep.ANALYZING -> false + AnalysisFlowStep.ANALYZING, + AnalysisFlowStep.FILE_RECOGNITION_FAILED, + AnalysisFlowStep.SCRIPT_FILE_RECOGNITION_FAILED, + -> false } } @@ -66,4 +72,6 @@ internal enum class AnalysisFlowStep { SCRIPT_INPUT, AUDIO_UPLOAD, ANALYZING, + FILE_RECOGNITION_FAILED, + SCRIPT_FILE_RECOGNITION_FAILED, } diff --git a/Prezel/feature/analysis/impl/src/main/res/drawable/feature_analysis_impl_error_voice.xml b/Prezel/feature/analysis/impl/src/main/res/drawable/feature_analysis_impl_error_voice.xml new file mode 100644 index 00000000..e2f402b8 --- /dev/null +++ b/Prezel/feature/analysis/impl/src/main/res/drawable/feature_analysis_impl_error_voice.xml @@ -0,0 +1,18 @@ + + + + + + diff --git a/Prezel/feature/analysis/impl/src/main/res/values/strings.xml b/Prezel/feature/analysis/impl/src/main/res/values/strings.xml index 9382ef03..2ff9c191 100644 --- a/Prezel/feature/analysis/impl/src/main/res/values/strings.xml +++ b/Prezel/feature/analysis/impl/src/main/res/values/strings.xml @@ -43,7 +43,7 @@ 대본 파일을 업로드하거나 직접 입력할 수 있어요. 발표 대본을 입력해주세요 대본 파일을 추가해주세요. - 제공하는 파일 형식 : pdf, txt + 제공하는 파일 형식 : txt 파일 추가하기 대본 파일 삭제 직접 입력 @@ -52,7 +52,7 @@ 대본을 읽은 음성 파일을 업로드해주세요. 분석을 위해 음성 파일이 필요해요. 음성 파일을 업로드하세요 - 제공하는 파일 형식 : mp3, wav + 제공하는 파일 형식 : m4a, mp4, mp3 00:00/00:00 음성 파일 삭제 파일 추가하기 @@ -61,8 +61,13 @@ 직접 입력 분석 중 발표 음성을 분석하고 있어요 + 분석할 수 있는 음성 파일을 찾지 못했어요. + 다른 음성 파일로 다시 시도해 주세요. + 분석할 수 있는 텍스트 파일을 찾지 못했어요. + 다른 텍스트 파일로 다시 시도해 주세요. 뒤로가기 다음 건너뛰기 + 다시 시도하기 분석하기 diff --git a/Prezel/feature/analysis/impl/src/test/java/com/team/prezel/feature/analysis/impl/contract/.gitkeep b/Prezel/feature/analysis/impl/src/test/java/com/team/prezel/feature/analysis/impl/contract/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/contract/LoginUiIntent.kt b/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/contract/LoginUiIntent.kt index 7a6be2e4..673238ff 100644 --- a/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/contract/LoginUiIntent.kt +++ b/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/contract/LoginUiIntent.kt @@ -1,7 +1,12 @@ package com.team.prezel.feature.login.impl.contract +import com.team.prezel.core.auth.model.AuthResult import com.team.prezel.core.ui.base.UiIntent internal sealed interface LoginUiIntent : UiIntent { data object OnClickLogin : LoginUiIntent + + data class OnLoginResult( + val result: AuthResult, + ) : LoginUiIntent } diff --git a/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/navigation/LoginEntryBuilder.kt b/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/navigation/LoginEntryBuilder.kt index d8b12459..d9660fff 100644 --- a/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/navigation/LoginEntryBuilder.kt +++ b/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/navigation/LoginEntryBuilder.kt @@ -42,8 +42,8 @@ internal fun EntryProviderScope.featureLoginEntryBuilder(authManager: Au object FeatureLoginModule { @IntoSet @Provides - fun provideFeatureLoginEntryBuilder(): EntryProviderScope.() -> Unit = + fun provideFeatureLoginEntryBuilder(authManager: AuthManager): EntryProviderScope.() -> Unit = { - featureLoginEntryBuilder() + featureLoginEntryBuilder(authManager = authManager) } } diff --git a/Prezel/feature/splash/impl/src/main/java/com/team/prezel/feature/splash/impl/SplashViewModel.kt b/Prezel/feature/splash/impl/src/main/java/com/team/prezel/feature/splash/impl/SplashViewModel.kt index 462d093c..c76b6d03 100644 --- a/Prezel/feature/splash/impl/src/main/java/com/team/prezel/feature/splash/impl/SplashViewModel.kt +++ b/Prezel/feature/splash/impl/src/main/java/com/team/prezel/feature/splash/impl/SplashViewModel.kt @@ -23,6 +23,9 @@ internal class SplashViewModel @Inject constructor( } private fun checkLoginStatus() { + viewModelScope.launch { sendEffect(SplashUiEffect.NavigateToHome) } + return + updateState { copy(isLoading = true) } viewModelScope From 7298c6c7e8f03b24bf7d30bf7033f0b808f5f659 Mon Sep 17 00:00:00 2001 From: Ham BeomJoon Date: Fri, 22 May 2026 00:57:48 +0900 Subject: [PATCH 04/16] =?UTF-8?q?feat:=20=EB=B6=84=EC=84=9D=20=EB=8C=80?= =?UTF-8?q?=EA=B8=B0=20=EB=B0=8F=20=EB=A6=AC=ED=8F=AC=ED=8A=B8=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20=EA=B5=AC=ED=98=84=EA=B3=BC=20=EC=98=A4=EB=94=94?= =?UTF-8?q?=EC=98=A4=20=EC=9E=AC=EC=83=9D=20=EB=A1=9C=EC=A7=81=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 기존 `AnalyzingScreen`을 `AnalysisLoadingScreen`으로 변경하고 Lottie 애니메이션 적용 - 분석 완료 후 표시할 `AnalysisReportScreen` 추가 및 `AnalysisFlowStep.REPORT` 단계 정의 - `AudioUploadScreen`에 `MediaPlayer` 기반의 오디오 재생 및 탐색(Seek) 로직 구현 - `ScriptInputScreen`과 `AudioUploadScreen`의 파일 업로드 표시 로직을 공통 컴포넌트인 `FileUploader`로 리팩터링 - `AnalysisFlowViewModel` 내 단계 전환 로직에 `REPORT` 단계 추가 및 백버튼 동작 수정 - 분석 완료 시 홈으로 이동하던 `AnalysisEntryBuilder` 로직을 화면 내 상태 변화로 처리하도록 변경 --- .../analysis/impl/AnalysisFlowViewModel.kt | 6 +- .../analysis/impl/AnalysisLoadingScreen.kt | 47 +++ .../analysis/impl/AnalysisReportScreen.kt | 33 ++ .../feature/analysis/impl/AnalysisScreen.kt | 10 +- .../feature/analysis/impl/AnalyzingScreen.kt | 69 ---- .../analysis/impl/AudioUploadScreen.kt | 331 ++++++++---------- .../analysis/impl/ScriptInputScreen.kt | 105 +----- .../impl/contract/AnalysisFlowUiState.kt | 3 + .../impl/navigation/AnalysisEntryBuilder.kt | 1 - .../impl/src/main/res/values/strings.xml | 2 +- .../analysis/impl/{contract => }/.gitkeep | 0 11 files changed, 255 insertions(+), 352 deletions(-) create mode 100644 Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AnalysisLoadingScreen.kt create mode 100644 Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AnalysisReportScreen.kt delete mode 100644 Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AnalyzingScreen.kt rename Prezel/feature/analysis/impl/src/test/java/com/team/prezel/feature/analysis/impl/{contract => }/.gitkeep (100%) diff --git a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AnalysisFlowViewModel.kt b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AnalysisFlowViewModel.kt index e2c96aa7..37487346 100644 --- a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AnalysisFlowViewModel.kt +++ b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AnalysisFlowViewModel.kt @@ -54,10 +54,11 @@ internal class AnalysisFlowViewModel @Inject constructor() : AnalysisFlowStep.PRESENTATION_SITUATION -> AnalysisFlowStep.SCRIPT_INPUT AnalysisFlowStep.SCRIPT_INPUT -> AnalysisFlowStep.AUDIO_UPLOAD AnalysisFlowStep.AUDIO_UPLOAD -> AnalysisFlowStep.ANALYZING - AnalysisFlowStep.ANALYZING, + AnalysisFlowStep.ANALYZING -> AnalysisFlowStep.REPORT + AnalysisFlowStep.REPORT, AnalysisFlowStep.FILE_RECOGNITION_FAILED, AnalysisFlowStep.SCRIPT_FILE_RECOGNITION_FAILED, - -> AnalysisFlowStep.ANALYZING + -> AnalysisFlowStep.REPORT }, ) } @@ -104,6 +105,7 @@ internal class AnalysisFlowViewModel @Inject constructor() : AnalysisFlowStep.SCRIPT_INPUT -> AnalysisFlowStep.PRESENTATION_SITUATION AnalysisFlowStep.AUDIO_UPLOAD -> AnalysisFlowStep.SCRIPT_INPUT AnalysisFlowStep.ANALYZING -> AnalysisFlowStep.AUDIO_UPLOAD + AnalysisFlowStep.REPORT -> AnalysisFlowStep.AUDIO_UPLOAD AnalysisFlowStep.FILE_RECOGNITION_FAILED -> AnalysisFlowStep.AUDIO_UPLOAD AnalysisFlowStep.SCRIPT_FILE_RECOGNITION_FAILED -> AnalysisFlowStep.SCRIPT_INPUT } diff --git a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AnalysisLoadingScreen.kt b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AnalysisLoadingScreen.kt new file mode 100644 index 00000000..eacb5de8 --- /dev/null +++ b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AnalysisLoadingScreen.kt @@ -0,0 +1,47 @@ +package com.team.prezel.feature.analysis.impl + +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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 kotlinx.coroutines.delay +import com.team.prezel.core.ui.R as CoreUiR + +private const val ANALYSIS_LOADING_DURATION_MILLIS = 2_000L + +@Composable +internal fun AnalysisLoadingScreen( + onFinished: () -> Unit, + modifier: Modifier = Modifier, +) { + LaunchedEffect(Unit) { + delay(ANALYSIS_LOADING_DURATION_MILLIS) + onFinished() + } + + StatusView( + title = stringResource(R.string.feature_analysis_impl_analyzing_title), + description = stringResource(R.string.feature_analysis_impl_analyzing_headline), + modifier = modifier, + visual = { + PrezelLottie( + resId = CoreUiR.raw.core_ui_asset_loading, + modifier = Modifier.size(80.dp), + ) + }, + ) +} + +@BasicPreview +@Composable +private fun AnalysisLoadingScreenPreview() { + PrezelTheme { + AnalysisLoadingScreen(onFinished = {}) + } +} diff --git a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AnalysisReportScreen.kt b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AnalysisReportScreen.kt new file mode 100644 index 00000000..337eff90 --- /dev/null +++ b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AnalysisReportScreen.kt @@ -0,0 +1,33 @@ +package com.team.prezel.feature.analysis.impl + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import com.team.prezel.core.designsystem.preview.BasicPreview +import com.team.prezel.core.designsystem.theme.PrezelTheme + +@Composable +internal fun AnalysisReportScreen(modifier: Modifier = Modifier) { + Box( + modifier = modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + Text( + text = stringResource(R.string.feature_analysis_impl_report_placeholder), + color = PrezelTheme.colors.textLarge, + style = PrezelTheme.typography.title2Bold, + ) + } +} + +@BasicPreview +@Composable +private fun AnalysisReportScreenPreview() { + PrezelTheme { + AnalysisReportScreen() + } +} diff --git a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AnalysisScreen.kt b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AnalysisScreen.kt index bdcebb64..3039accc 100644 --- a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AnalysisScreen.kt +++ b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AnalysisScreen.kt @@ -13,7 +13,6 @@ import com.team.prezel.feature.analysis.impl.contract.AnalysisUploadType @Composable internal fun AnalysisScreen( - onFinished: () -> Unit, onBack: () -> Unit, viewModel: AnalysisFlowViewModel = hiltViewModel(), ) { @@ -30,7 +29,6 @@ internal fun AnalysisScreen( AnalysisScreen( uiState = uiState, onIntent = viewModel::onIntent, - onFinished = onFinished, ) } @@ -38,7 +36,6 @@ internal fun AnalysisScreen( private fun AnalysisScreen( uiState: AnalysisFlowUiState, onIntent: (AnalysisFlowUiIntent) -> Unit, - onFinished: () -> Unit, ) { when (uiState.step) { AnalysisFlowStep.PRESENTATION_SCHEDULE -> PresentationScheduleScreen( @@ -76,11 +73,12 @@ private fun AnalysisScreen( onBack = { onIntent(AnalysisFlowUiIntent.Back) }, ) - AnalysisFlowStep.ANALYZING -> AnalyzingScreen( - onFinished = onFinished, - onBack = { onIntent(AnalysisFlowUiIntent.Back) }, + AnalysisFlowStep.ANALYZING -> AnalysisLoadingScreen( + onFinished = { onIntent(AnalysisFlowUiIntent.Next) }, ) + AnalysisFlowStep.REPORT -> AnalysisReportScreen() + AnalysisFlowStep.FILE_RECOGNITION_FAILED -> FileRecognitionFailedScreen( onRetry = { onIntent(AnalysisFlowUiIntent.RetryFileUpload(AnalysisUploadType.AUDIO)) }, ) diff --git a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AnalyzingScreen.kt b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AnalyzingScreen.kt deleted file mode 100644 index e072a98e..00000000 --- a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AnalyzingScreen.kt +++ /dev/null @@ -1,69 +0,0 @@ -package com.team.prezel.feature.analysis.impl - -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.material3.CircularProgressIndicator -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.ui.Alignment -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.feature.analysis.impl.component.AnalysisStepLayout -import kotlinx.coroutines.delay - -@Composable -internal fun AnalyzingScreen( - onFinished: () -> Unit, - onBack: () -> Unit, -) { - LaunchedEffect(Unit) { - delay(1_200) - onFinished() - } - - AnalyzingScreen(onBack = onBack) -} - -@Composable -private fun AnalyzingScreen(onBack: () -> Unit) { - AnalysisStepLayout( - title = stringResource(R.string.feature_analysis_impl_analyzing_title), - progress = 1f, - buttonText = stringResource(R.string.feature_analysis_impl_analyzing_title), - buttonEnabled = false, - onButtonClick = {}, - onBack = onBack, - ) { - Box( - modifier = Modifier - .fillMaxWidth() - .height(320.dp), - contentAlignment = Alignment.Center, - ) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { - CircularProgressIndicator(color = PrezelTheme.colors.interactiveRegular) - Spacer(modifier = Modifier.height(PrezelTheme.spacing.V20)) - Text( - text = stringResource(R.string.feature_analysis_impl_analyzing_headline), - color = PrezelTheme.colors.textLarge, - style = PrezelTheme.typography.title2Bold, - ) - } - } - } -} - -@BasicPreview -@Composable -private fun AnalyzingScreenPreview() { - PrezelTheme { - AnalyzingScreen(onBack = {}) - } -} diff --git a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AudioUploadScreen.kt b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AudioUploadScreen.kt index 429747e5..8c52f989 100644 --- a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AudioUploadScreen.kt +++ b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AudioUploadScreen.kt @@ -1,35 +1,28 @@ package com.team.prezel.feature.analysis.impl +import android.content.Context +import android.media.MediaPlayer +import android.net.Uri import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.pager.rememberPagerState -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material3.Icon -import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource @@ -37,12 +30,13 @@ import androidx.compose.ui.unit.dp import com.team.prezel.core.designsystem.component.actions.button.PrezelButton import com.team.prezel.core.designsystem.component.actions.button.config.ButtonSize import com.team.prezel.core.designsystem.component.actions.button.config.ButtonType -import com.team.prezel.core.designsystem.component.base.PrezelTouchArea import com.team.prezel.core.designsystem.component.navigations.PrezelTabSize import com.team.prezel.core.designsystem.component.navigations.PrezelTabs 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.FileUploader +import com.team.prezel.core.ui.component.FileUploaderState import com.team.prezel.core.ui.component.StatusView import com.team.prezel.feature.analysis.impl.component.AnalysisStepLayout import com.team.prezel.feature.analysis.impl.component.AnalysisStepTitle @@ -52,12 +46,13 @@ import com.team.prezel.feature.analysis.impl.contract.AnalysisFlowUiState import com.team.prezel.feature.analysis.impl.contract.AnalysisForm import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.delay +import kotlin.math.roundToInt private const val AUDIO_UPLOAD_PROGRESS_DURATION_MILLIS = 800 +private const val AUDIO_PLAYBACK_PROGRESS_INTERVAL_MILLIS = 250L private val AUDIO_FILE_MIME_TYPES = arrayOf("audio/m4a", "audio/x-m4a", "audio/mp4", "video/mp4", "audio/mpeg") private const val AUDIO_PREVIEW_FILE_URI = "content://prezel/sample.m4a" private const val AUDIO_UPLOAD_TAB_COUNT = 1 -private const val UPLOADED_AUDIO_PROGRESS = 0f @Composable internal fun AudioUploadScreen( @@ -170,16 +165,155 @@ private fun AudioUploadContent( AudioUploadEmptyContent(onUploadClick = onUploadClick) } else { val context = LocalContext.current + val playbackState = rememberAudioUploadPlaybackState( + fileUri = fileUri, + enabled = uploadProgress == null, + ) Spacer(modifier = Modifier.height(PrezelTheme.spacing.V16)) - UploadedAudioFileCard( + FileUploader( fileName = remember(context, fileUri) { fileUri.toFileName(context) }, - uploadProgress = uploadProgress, - onClear = onClear, + state = when { + uploadProgress != null -> FileUploaderState.Audio.Loading + playbackState.playing -> FileUploaderState.Audio.Playing + else -> FileUploaderState.Audio.Paused + }, + progress = uploadProgress ?: audioPlaybackProgress( + currentPositionMillis = playbackState.currentPositionMillis, + durationMillis = playbackState.durationMillis, + ), + currentTimeText = playbackState.currentPositionMillis.toAudioPlaybackTimeText(), + durationTimeText = playbackState.durationMillis.toAudioPlaybackTimeText(), + onCancelClick = onClear, + onPlayClick = playbackState::play, + onPauseClick = playbackState::pause, + onSeek = playbackState::seekToProgress, ) } } +@Composable +private fun rememberAudioUploadPlaybackState( + fileUri: String, + enabled: Boolean, +): AudioUploadPlaybackState { + val context = LocalContext.current + val state = remember(context, fileUri) { + AudioUploadPlaybackState( + context = context.applicationContext, + fileUri = fileUri, + ) + } + + LaunchedEffect(state.playing) { + while (state.playing) { + delay(AUDIO_PLAYBACK_PROGRESS_INTERVAL_MILLIS) + state.syncPosition() + } + } + + LaunchedEffect(enabled) { + if (!enabled) state.release() + } + + DisposableEffect(state) { + onDispose { + state.release() + } + } + + return state +} + +private class AudioUploadPlaybackState( + private val context: Context, + private val fileUri: String, +) { + private var player: MediaPlayer? = null + + var playing by mutableStateOf(false) + private set + + var currentPositionMillis by mutableIntStateOf(0) + private set + + var durationMillis by mutableIntStateOf(0) + private set + + fun play() { + val mediaPlayer = player ?: preparePlayer() ?: return + runCatching { + mediaPlayer.start() + playing = true + syncPosition() + }.onFailure { + release() + } + } + + fun pause() { + player?.runCatching { + if (isPlaying) pause() + } + syncPosition() + playing = false + } + + fun seekToProgress(progress: Float) { + val mediaPlayer = player ?: preparePlayer() ?: return + val targetPositionMillis = (durationMillis * progress.coerceIn(0f, 1f)).roundToInt() + + runCatching { + mediaPlayer.seekTo(targetPositionMillis) + currentPositionMillis = targetPositionMillis + }.onFailure { + release() + } + } + + fun syncPosition() { + val mediaPlayer = player ?: return + currentPositionMillis = mediaPlayer.currentPosition.coerceAtLeast(0) + durationMillis = mediaPlayer.duration.coerceAtLeast(0) + } + + fun release() { + player?.release() + player = null + playing = false + currentPositionMillis = 0 + durationMillis = 0 + } + + private fun preparePlayer(): MediaPlayer? = + runCatching { + MediaPlayer.create(context, Uri.parse(fileUri))?.apply { + durationMillis = duration.coerceAtLeast(0) + setOnCompletionListener { + currentPositionMillis = durationMillis + playing = false + } + } + }.getOrNull() + ?.also { player = it } +} + +internal fun Int.toAudioPlaybackTimeText(): String { + val totalSeconds = coerceAtLeast(0) / 1_000 + val minutes = totalSeconds / 60 + val seconds = totalSeconds % 60 + + return "${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}" +} + +internal fun audioPlaybackProgress( + currentPositionMillis: Int, + durationMillis: Int, +): Float { + if (durationMillis <= 0) return 0f + return (currentPositionMillis.toFloat() / durationMillis).coerceIn(0f, 1f) +} + @Composable private fun AudioUploadEmptyContent(onUploadClick: () -> Unit) { StatusView( @@ -208,169 +342,6 @@ private fun AudioUploadEmptyContent(onUploadClick: () -> Unit) { ) } -@Composable -private fun UploadedAudioFileCard( - fileName: String, - uploadProgress: Float?, - onClear: () -> Unit, -) { - Row( - modifier = Modifier - .fillMaxWidth() - .border( - width = PrezelTheme.stroke.V1, - color = PrezelTheme.colors.borderSmall, - shape = PrezelTheme.shapes.V8, - ).padding( - start = PrezelTheme.spacing.V16, - end = PrezelTheme.spacing.V12, - top = PrezelTheme.spacing.V16, - bottom = PrezelTheme.spacing.V16, - ), - verticalAlignment = Alignment.CenterVertically, - ) { - UploadedAudioFileInfo( - fileName = fileName, - uploadProgress = uploadProgress, - modifier = Modifier.weight(1f), - ) - - Spacer(modifier = Modifier.size(PrezelTheme.spacing.V12)) - - PrezelTouchArea( - extraTouchPadding = PaddingValues(PrezelTheme.spacing.V8), - onClick = onClear, - ) { - Icon( - painter = painterResource(PrezelIcons.CancelCircleFilled), - contentDescription = stringResource(R.string.feature_analysis_impl_audio_file_remove), - modifier = Modifier.size(24.dp), - tint = PrezelTheme.colors.iconRegular, - ) - } - } -} - -@Composable -private fun UploadedAudioFileInfo( - fileName: String, - uploadProgress: Float?, - modifier: Modifier = Modifier, -) { - Column(modifier = modifier) { - AudioFileTitleRow(fileName = fileName) - - Spacer(modifier = Modifier.height(PrezelTheme.spacing.V8)) - - if (uploadProgress == null) { - AudioFileProgressRow() - } else { - AudioUploadProgressRow(progress = uploadProgress) - } - } -} - -@Composable -private fun AudioFileTitleRow(fileName: String) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - painter = painterResource(PrezelIcons.Play), - contentDescription = null, - modifier = Modifier.size(20.dp), - tint = PrezelTheme.colors.iconRegular, - ) - Spacer(modifier = Modifier.size(PrezelTheme.spacing.V8)) - Text( - text = fileName, - modifier = Modifier.weight(1f), - color = PrezelTheme.colors.textMedium, - style = PrezelTheme.typography.body3Medium, - ) - } -} - -@Composable -private fun AudioFileProgressRow() { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = stringResource(R.string.feature_analysis_impl_audio_file_duration_placeholder), - color = PrezelTheme.colors.textSmall, - style = PrezelTheme.typography.caption2Regular, - ) - Spacer(modifier = Modifier.size(PrezelTheme.spacing.V8)) - AudioProgressTrack( - progress = UPLOADED_AUDIO_PROGRESS, - modifier = Modifier.weight(1f), - ) - } -} - -@Composable -private fun AudioUploadProgressRow(progress: Float) { - val coercedProgress = progress.coerceIn(0f, 1f) - val progressPercent = (coercedProgress * 100).toInt() - - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - ) { - AudioProgressTrack( - progress = coercedProgress, - showThumb = false, - modifier = Modifier.weight(1f), - ) - - Spacer(modifier = Modifier.size(PrezelTheme.spacing.V16)) - - Text( - text = "%02d%%".format(progressPercent), - color = PrezelTheme.colors.textSmall, - style = PrezelTheme.typography.body2Regular, - ) - } -} - -@Composable -private fun AudioProgressTrack( - progress: Float, - modifier: Modifier = Modifier, - showThumb: Boolean = true, -) { - Box( - modifier = modifier.height(16.dp), - contentAlignment = Alignment.CenterStart, - ) { - Box( - modifier = Modifier - .fillMaxWidth() - .height(4.dp) - .clip(CircleShape) - .background(PrezelTheme.colors.bgDisabled), - ) - Box( - modifier = Modifier - .fillMaxWidth(progress.coerceIn(0f, 1f)) - .height(6.dp) - .clip(CircleShape) - .background(PrezelTheme.colors.interactiveRegular), - ) - if (showThumb) { - Box( - modifier = Modifier - .size(10.dp) - .clip(CircleShape) - .background(PrezelTheme.colors.interactiveRegular), - ) - } - } -} - @BasicPreview @Composable private fun AudioUploadScreenPreview() { diff --git a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/ScriptInputScreen.kt b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/ScriptInputScreen.kt index 834d72f2..a96df3a4 100644 --- a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/ScriptInputScreen.kt +++ b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/ScriptInputScreen.kt @@ -6,27 +6,17 @@ import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.pager.rememberPagerState -import androidx.compose.material3.Icon -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.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource @@ -35,13 +25,14 @@ import androidx.compose.ui.unit.dp import com.team.prezel.core.designsystem.component.actions.button.PrezelButton import com.team.prezel.core.designsystem.component.actions.button.config.ButtonSize import com.team.prezel.core.designsystem.component.actions.button.config.ButtonType -import com.team.prezel.core.designsystem.component.base.PrezelTouchArea import com.team.prezel.core.designsystem.component.navigations.PrezelTabSize import com.team.prezel.core.designsystem.component.navigations.PrezelTabs import com.team.prezel.core.designsystem.component.textfield.PrezelTextArea import com.team.prezel.core.designsystem.icon.PrezelIcons import com.team.prezel.core.designsystem.preview.BasicPreview import com.team.prezel.core.designsystem.theme.PrezelTheme +import com.team.prezel.core.ui.component.FileUploader +import com.team.prezel.core.ui.component.FileUploaderState import com.team.prezel.core.ui.component.StatusView import com.team.prezel.feature.analysis.impl.component.AnalysisStepLayout import com.team.prezel.feature.analysis.impl.component.AnalysisStepTitle @@ -274,88 +265,16 @@ private fun UploadedScriptFileCard( val context = LocalContext.current val fileName = remember(context, fileUri) { fileUri.toFileName(context) } - Row( - modifier = Modifier - .fillMaxWidth() - .border( - width = PrezelTheme.stroke.V1, - color = PrezelTheme.colors.borderRegular, - shape = PrezelTheme.shapes.V8, - ).padding( - horizontal = PrezelTheme.spacing.V12, - vertical = PrezelTheme.spacing.V24, - ), - verticalAlignment = Alignment.CenterVertically, - ) { - Column( - modifier = Modifier - .weight(1f) - .padding(start = PrezelTheme.spacing.V4), - ) { - Text( - text = fileName, - color = PrezelTheme.colors.textMedium, - style = PrezelTheme.typography.body3Medium, - ) - - if (uploadProgress != null) { - Spacer(modifier = Modifier.height(PrezelTheme.spacing.V16)) - ScriptUploadProgress(progress = uploadProgress) - } - } - - PrezelTouchArea( - extraTouchPadding = PaddingValues(PrezelTheme.spacing.V8), - isUseRipple = false, - onClick = onClear, - ) { - Icon( - painter = painterResource(PrezelIcons.CancelCircleFilled), - contentDescription = stringResource(R.string.feature_analysis_impl_script_file_remove), - modifier = Modifier.size(24.dp), - tint = PrezelTheme.colors.iconRegular, - ) - } - } -} - -@Composable -private fun ScriptUploadProgress(progress: Float) { - val coercedProgress = progress.coerceIn(0f, 1f) - val progressPercent = (coercedProgress * 100).toInt() - - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - ) { - Box( - modifier = Modifier - .weight(1f) - .height(6.dp) - .background( - color = PrezelTheme.colors.bgLarge, - shape = PrezelTheme.shapes.V1000, - ), - ) { - Box( - modifier = Modifier - .fillMaxWidth(coercedProgress) - .height(6.dp) - .background( - color = PrezelTheme.colors.interactiveRegular, - shape = PrezelTheme.shapes.V1000, - ), - ) - } - - Spacer(modifier = Modifier.size(PrezelTheme.spacing.V16)) - - Text( - text = "%02d%%".format(progressPercent), - color = PrezelTheme.colors.textSmall, - style = PrezelTheme.typography.body2Regular, - ) - } + FileUploader( + fileName = fileName, + state = if (uploadProgress == null) { + FileUploaderState.Script.Uploaded + } else { + FileUploaderState.Script.Loading + }, + progress = uploadProgress ?: 0f, + onCancelClick = onClear, + ) } @BasicPreview diff --git a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/contract/AnalysisFlowUiState.kt b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/contract/AnalysisFlowUiState.kt index 0705533d..68538ac5 100644 --- a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/contract/AnalysisFlowUiState.kt +++ b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/contract/AnalysisFlowUiState.kt @@ -22,6 +22,7 @@ internal data class AnalysisFlowUiState( AnalysisFlowStep.AUDIO_UPLOAD -> 0.75f AnalysisFlowStep.ANALYZING, + AnalysisFlowStep.REPORT, AnalysisFlowStep.FILE_RECOGNITION_FAILED, -> 1f } @@ -41,6 +42,7 @@ internal data class AnalysisFlowUiState( AnalysisFlowStep.AUDIO_UPLOAD -> form.audioFileUri != null AnalysisFlowStep.ANALYZING, + AnalysisFlowStep.REPORT, AnalysisFlowStep.FILE_RECOGNITION_FAILED, AnalysisFlowStep.SCRIPT_FILE_RECOGNITION_FAILED, -> false @@ -72,6 +74,7 @@ internal enum class AnalysisFlowStep { SCRIPT_INPUT, AUDIO_UPLOAD, ANALYZING, + REPORT, FILE_RECOGNITION_FAILED, SCRIPT_FILE_RECOGNITION_FAILED, } diff --git a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/navigation/AnalysisEntryBuilder.kt b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/navigation/AnalysisEntryBuilder.kt index 642b94b7..21c72624 100644 --- a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/navigation/AnalysisEntryBuilder.kt +++ b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/navigation/AnalysisEntryBuilder.kt @@ -18,7 +18,6 @@ internal fun EntryProviderScope.featureAnalysisEntryBuilder() { val navigateToHome = { navigator.replaceRoot(HomeNavKey) } AnalysisScreen( - onFinished = navigateToHome, onBack = navigateToHome, ) } diff --git a/Prezel/feature/analysis/impl/src/main/res/values/strings.xml b/Prezel/feature/analysis/impl/src/main/res/values/strings.xml index 2ff9c191..8ce37c62 100644 --- a/Prezel/feature/analysis/impl/src/main/res/values/strings.xml +++ b/Prezel/feature/analysis/impl/src/main/res/values/strings.xml @@ -53,7 +53,6 @@ 분석을 위해 음성 파일이 필요해요. 음성 파일을 업로드하세요 제공하는 파일 형식 : m4a, mp4, mp3 - 00:00/00:00 음성 파일 삭제 파일 추가하기 @@ -61,6 +60,7 @@ 직접 입력 분석 중 발표 음성을 분석하고 있어요 + 분석 리포트 화면 분석할 수 있는 음성 파일을 찾지 못했어요. 다른 음성 파일로 다시 시도해 주세요. 분석할 수 있는 텍스트 파일을 찾지 못했어요. diff --git a/Prezel/feature/analysis/impl/src/test/java/com/team/prezel/feature/analysis/impl/contract/.gitkeep b/Prezel/feature/analysis/impl/src/test/java/com/team/prezel/feature/analysis/impl/.gitkeep similarity index 100% rename from Prezel/feature/analysis/impl/src/test/java/com/team/prezel/feature/analysis/impl/contract/.gitkeep rename to Prezel/feature/analysis/impl/src/test/java/com/team/prezel/feature/analysis/impl/.gitkeep From a325dffcc78ff21dec4a49344e3dc66774dda25f Mon Sep 17 00:00:00 2001 From: Ham BeomJoon Date: Sat, 23 May 2026 10:51:53 +0900 Subject: [PATCH 05/16] =?UTF-8?q?fix:=20=EC=8A=A4=ED=94=8C=EB=9E=98?= =?UTF-8?q?=EC=8B=9C=20=ED=99=94=EB=A9=B4=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=20=ED=99=95=EC=9D=B8=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EB=B3=B5=EA=B5=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `SplashViewModel`에서 홈 화면으로 즉시 이동하던 임시 코드 제거 - 실제 로그인 상태를 확인하고 로딩 상태를 업데이트하는 로직 활성화 --- .../com/team/prezel/feature/splash/impl/SplashViewModel.kt | 3 --- 1 file changed, 3 deletions(-) diff --git a/Prezel/feature/splash/impl/src/main/java/com/team/prezel/feature/splash/impl/SplashViewModel.kt b/Prezel/feature/splash/impl/src/main/java/com/team/prezel/feature/splash/impl/SplashViewModel.kt index c76b6d03..462d093c 100644 --- a/Prezel/feature/splash/impl/src/main/java/com/team/prezel/feature/splash/impl/SplashViewModel.kt +++ b/Prezel/feature/splash/impl/src/main/java/com/team/prezel/feature/splash/impl/SplashViewModel.kt @@ -23,9 +23,6 @@ internal class SplashViewModel @Inject constructor( } private fun checkLoginStatus() { - viewModelScope.launch { sendEffect(SplashUiEffect.NavigateToHome) } - return - updateState { copy(isLoading = true) } viewModelScope From 5ce18c7dc7d35039a1e88cc3ad7c0adceebf3ac6 Mon Sep 17 00:00:00 2001 From: Ham BeomJoon Date: Sun, 24 May 2026 00:29:51 +0900 Subject: [PATCH 06/16] =?UTF-8?q?feat:=20=EB=B6=84=EC=84=9D=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=ED=99=94=EB=A9=B4=20=EB=A6=AC=ED=8C=A9=ED=84=B0?= =?UTF-8?q?=EB=A7=81=20=EB=B0=8F=20UI=20=EC=BB=B4=ED=8F=AC=EB=84=8C?= =?UTF-8?q?=ED=8A=B8=20=EA=B5=AC=EC=A1=B0=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `feature:analysis:impl` 모듈 내 화면 컴포넌트들을 도메인별 패키지(`audio`, `result`, `schedule`, `script`, `situation`)로 분리하여 재구성 - `PresentationSituationScreen`의 아코디언 UI에 선택된 옵션 표시 기능 추가 및 카드 디자인 개선 - `AnalysisStepLayout` 내 버튼 영역의 배경 표시 설정 및 제목 텍스트 스타일을 `title2Bold`로 변경 - `AnalysisFlowUiIntent`에 상황 설정 옵션 선택을 위한 헬퍼 함수 추가 및 `AnalysisScreen` 내 인텐트 전달 로직 단순화 - `strings.xml` 내 분석 관련 문구 및 상황 설정 옵션 명칭(차분한, 편안한 등)을 기획안에 맞춰 수정 - `MediaPlayer` 생성 시 `uri` 처리를 `toUri()`를 사용하도록 개선하여 안정성 확보 - `core:model`의 `Category` enum 순서 조정 및 상황 설정 관련 리소스 매핑 로직 업데이트 --- .../core/model/presentation/Category.kt | 4 +- .../feature/analysis/impl/AnalysisScreen.kt | 29 +++++------ .../impl/{ => audio}/AudioUploadScreen.kt | 13 +++-- .../impl/component/AnalysisStepLayout.kt | 3 +- .../impl/contract/AnalysisFlowUiIntent.kt | 10 ++++ .../{ => result}/AnalysisLoadingScreen.kt | 3 +- .../impl/{ => result}/AnalysisReportScreen.kt | 3 +- .../FileRecognitionFailedScreen.kt | 3 +- .../PresentationScheduleScreen.kt | 3 +- .../impl/{ => script}/ScriptInputScreen.kt | 3 +- .../PresentationSituationScreen.kt | 52 ++++++++++++++----- .../impl/{ => situation}/SituationOptions.kt | 17 +++--- .../impl/src/main/res/values/strings.xml | 26 +++++----- 13 files changed, 105 insertions(+), 64 deletions(-) rename Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/{ => audio}/AudioUploadScreen.kt (97%) rename Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/{ => result}/AnalysisLoadingScreen.kt (93%) rename Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/{ => result}/AnalysisReportScreen.kt (90%) rename Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/{ => result}/FileRecognitionFailedScreen.kt (96%) rename Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/{ => schedule}/PresentationScheduleScreen.kt (98%) rename Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/{ => script}/ScriptInputScreen.kt (99%) rename Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/{ => situation}/PresentationSituationScreen.kt (89%) rename Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/{ => situation}/SituationOptions.kt (93%) diff --git a/Prezel/core/model/src/main/java/com/team/prezel/core/model/presentation/Category.kt b/Prezel/core/model/src/main/java/com/team/prezel/core/model/presentation/Category.kt index 0dfd03da..de597a57 100644 --- a/Prezel/core/model/src/main/java/com/team/prezel/core/model/presentation/Category.kt +++ b/Prezel/core/model/src/main/java/com/team/prezel/core/model/presentation/Category.kt @@ -1,8 +1,8 @@ package com.team.prezel.core.model.presentation enum class Category { - PERSUASION, - EVENT, EDUCATION, REPORT, + PERSUASION, + EVENT, } diff --git a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AnalysisScreen.kt b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AnalysisScreen.kt index 3039accc..4d76e3da 100644 --- a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AnalysisScreen.kt +++ b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AnalysisScreen.kt @@ -4,12 +4,19 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.team.prezel.feature.analysis.impl.audio.AudioUploadScreen import com.team.prezel.feature.analysis.impl.contract.AnalysisFlowStep import com.team.prezel.feature.analysis.impl.contract.AnalysisFlowUiEffect import com.team.prezel.feature.analysis.impl.contract.AnalysisFlowUiIntent import com.team.prezel.feature.analysis.impl.contract.AnalysisFlowUiState -import com.team.prezel.feature.analysis.impl.contract.AnalysisSituationOption import com.team.prezel.feature.analysis.impl.contract.AnalysisUploadType +import com.team.prezel.feature.analysis.impl.result.AnalysisLoadingScreen +import com.team.prezel.feature.analysis.impl.result.AnalysisReportScreen +import com.team.prezel.feature.analysis.impl.result.FileRecognitionFailedScreen +import com.team.prezel.feature.analysis.impl.result.ScriptFileRecognitionFailedScreen +import com.team.prezel.feature.analysis.impl.schedule.PresentationScheduleScreen +import com.team.prezel.feature.analysis.impl.script.ScriptInputScreen +import com.team.prezel.feature.analysis.impl.situation.PresentationSituationScreen @Composable internal fun AnalysisScreen( @@ -48,10 +55,10 @@ private fun AnalysisScreen( AnalysisFlowStep.PRESENTATION_SITUATION -> PresentationSituationScreen( uiState = uiState, - onSelectCategory = { onIntent(it.toSituationIntent()) }, - onSelectPurpose = { onIntent(it.toSituationIntent()) }, - onSelectStyle = { onIntent(it.toSituationIntent()) }, - onSelectAudience = { onIntent(it.toSituationIntent()) }, + onSelectCategory = { onIntent(AnalysisFlowUiIntent.selectSituationOption(it)) }, + onSelectPurpose = { onIntent(AnalysisFlowUiIntent.selectSituationOption(it)) }, + onSelectStyle = { onIntent(AnalysisFlowUiIntent.selectSituationOption(it)) }, + onSelectAudience = { onIntent(AnalysisFlowUiIntent.selectSituationOption(it)) }, onNext = { onIntent(AnalysisFlowUiIntent.Next) }, onBack = { onIntent(AnalysisFlowUiIntent.Back) }, ) @@ -88,15 +95,3 @@ private fun AnalysisScreen( ) } } - -private fun com.team.prezel.core.model.presentation.Category.toSituationIntent(): AnalysisFlowUiIntent = - AnalysisFlowUiIntent.SelectSituationOption(AnalysisSituationOption.CategoryOption(this)) - -private fun com.team.prezel.core.model.presentation.Purpose.toSituationIntent(): AnalysisFlowUiIntent = - AnalysisFlowUiIntent.SelectSituationOption(AnalysisSituationOption.PurposeOption(this)) - -private fun com.team.prezel.core.model.presentation.Style.toSituationIntent(): AnalysisFlowUiIntent = - AnalysisFlowUiIntent.SelectSituationOption(AnalysisSituationOption.StyleOption(this)) - -private fun com.team.prezel.core.model.presentation.Audience.toSituationIntent(): AnalysisFlowUiIntent = - AnalysisFlowUiIntent.SelectSituationOption(AnalysisSituationOption.AudienceOption(this)) diff --git a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AudioUploadScreen.kt b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/audio/AudioUploadScreen.kt similarity index 97% rename from Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AudioUploadScreen.kt rename to Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/audio/AudioUploadScreen.kt index 8c52f989..45e279c7 100644 --- a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AudioUploadScreen.kt +++ b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/audio/AudioUploadScreen.kt @@ -1,8 +1,7 @@ -package com.team.prezel.feature.analysis.impl +package com.team.prezel.feature.analysis.impl.audio import android.content.Context import android.media.MediaPlayer -import android.net.Uri import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.animation.core.LinearEasing @@ -27,6 +26,7 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import androidx.core.net.toUri import com.team.prezel.core.designsystem.component.actions.button.PrezelButton import com.team.prezel.core.designsystem.component.actions.button.config.ButtonSize import com.team.prezel.core.designsystem.component.actions.button.config.ButtonType @@ -38,6 +38,7 @@ import com.team.prezel.core.designsystem.theme.PrezelTheme import com.team.prezel.core.ui.component.FileUploader import com.team.prezel.core.ui.component.FileUploaderState import com.team.prezel.core.ui.component.StatusView +import com.team.prezel.feature.analysis.impl.R import com.team.prezel.feature.analysis.impl.component.AnalysisStepLayout import com.team.prezel.feature.analysis.impl.component.AnalysisStepTitle import com.team.prezel.feature.analysis.impl.component.toFileName @@ -50,7 +51,11 @@ import kotlin.math.roundToInt private const val AUDIO_UPLOAD_PROGRESS_DURATION_MILLIS = 800 private const val AUDIO_PLAYBACK_PROGRESS_INTERVAL_MILLIS = 250L -private val AUDIO_FILE_MIME_TYPES = arrayOf("audio/m4a", "audio/x-m4a", "audio/mp4", "video/mp4", "audio/mpeg") +private val AUDIO_FILE_MIME_TYPES = arrayOf( + "audio/mpeg", // mp3 + "audio/mp4", // mp4, m4a + "audio/x-m4a", // m4a +) private const val AUDIO_PREVIEW_FILE_URI = "content://prezel/sample.m4a" private const val AUDIO_UPLOAD_TAB_COUNT = 1 @@ -287,7 +292,7 @@ private class AudioUploadPlaybackState( private fun preparePlayer(): MediaPlayer? = runCatching { - MediaPlayer.create(context, Uri.parse(fileUri))?.apply { + MediaPlayer.create(context, fileUri.toUri())?.apply { durationMillis = duration.coerceAtLeast(0) setOnCompletionListener { currentPositionMillis = durationMillis diff --git a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/component/AnalysisStepLayout.kt b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/component/AnalysisStepLayout.kt index 1a8c7580..feab4c3b 100644 --- a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/component/AnalysisStepLayout.kt +++ b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/component/AnalysisStepLayout.kt @@ -127,6 +127,7 @@ private fun AnalysisStepButtonArea( onSubButtonClick: (() -> Unit)?, ) { PrezelButtonArea( + showBackground = true, mainButton = { modifier -> PrezelButton( modifier = modifier, @@ -161,7 +162,7 @@ internal fun AnalysisStepTitle( Text( text = title, color = PrezelTheme.colors.textLarge, - style = PrezelTheme.typography.title2Medium, + style = PrezelTheme.typography.title2Bold, ) Spacer(modifier = Modifier.height(PrezelTheme.spacing.V8)) Text( diff --git a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/contract/AnalysisFlowUiIntent.kt b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/contract/AnalysisFlowUiIntent.kt index fc3a8fd3..e60b5f9a 100644 --- a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/contract/AnalysisFlowUiIntent.kt +++ b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/contract/AnalysisFlowUiIntent.kt @@ -44,6 +44,16 @@ internal sealed interface AnalysisFlowUiIntent : UiIntent { data object SkipScript : AnalysisFlowUiIntent data object Back : AnalysisFlowUiIntent + + companion object { + fun selectSituationOption(category: Category): AnalysisFlowUiIntent = SelectSituationOption(AnalysisSituationOption.CategoryOption(category)) + + fun selectSituationOption(purpose: Purpose): AnalysisFlowUiIntent = SelectSituationOption(AnalysisSituationOption.PurposeOption(purpose)) + + fun selectSituationOption(style: Style): AnalysisFlowUiIntent = SelectSituationOption(AnalysisSituationOption.StyleOption(style)) + + fun selectSituationOption(audience: Audience): AnalysisFlowUiIntent = SelectSituationOption(AnalysisSituationOption.AudienceOption(audience)) + } } internal enum class AnalysisUploadType { diff --git a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AnalysisLoadingScreen.kt b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/result/AnalysisLoadingScreen.kt similarity index 93% rename from Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AnalysisLoadingScreen.kt rename to Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/result/AnalysisLoadingScreen.kt index eacb5de8..8da9c611 100644 --- a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AnalysisLoadingScreen.kt +++ b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/result/AnalysisLoadingScreen.kt @@ -1,4 +1,4 @@ -package com.team.prezel.feature.analysis.impl +package com.team.prezel.feature.analysis.impl.result import androidx.compose.foundation.layout.size import androidx.compose.runtime.Composable @@ -10,6 +10,7 @@ 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.analysis.impl.R import kotlinx.coroutines.delay import com.team.prezel.core.ui.R as CoreUiR diff --git a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AnalysisReportScreen.kt b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/result/AnalysisReportScreen.kt similarity index 90% rename from Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AnalysisReportScreen.kt rename to Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/result/AnalysisReportScreen.kt index 337eff90..33a97f9e 100644 --- a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AnalysisReportScreen.kt +++ b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/result/AnalysisReportScreen.kt @@ -1,4 +1,4 @@ -package com.team.prezel.feature.analysis.impl +package com.team.prezel.feature.analysis.impl.result import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize @@ -9,6 +9,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import com.team.prezel.core.designsystem.preview.BasicPreview import com.team.prezel.core.designsystem.theme.PrezelTheme +import com.team.prezel.feature.analysis.impl.R @Composable internal fun AnalysisReportScreen(modifier: Modifier = Modifier) { diff --git a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/FileRecognitionFailedScreen.kt b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/result/FileRecognitionFailedScreen.kt similarity index 96% rename from Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/FileRecognitionFailedScreen.kt rename to Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/result/FileRecognitionFailedScreen.kt index eff9f44f..50f6ce1f 100644 --- a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/FileRecognitionFailedScreen.kt +++ b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/result/FileRecognitionFailedScreen.kt @@ -1,4 +1,4 @@ -package com.team.prezel.feature.analysis.impl +package com.team.prezel.feature.analysis.impl.result import androidx.compose.foundation.Image import androidx.compose.foundation.layout.fillMaxSize @@ -16,6 +16,7 @@ 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.analysis.impl.R @Composable internal fun FileRecognitionFailedScreen(onRetry: () -> Unit) { diff --git a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/PresentationScheduleScreen.kt b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/schedule/PresentationScheduleScreen.kt similarity index 98% rename from Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/PresentationScheduleScreen.kt rename to Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/schedule/PresentationScheduleScreen.kt index 03ed6513..2c5373fc 100644 --- a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/PresentationScheduleScreen.kt +++ b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/schedule/PresentationScheduleScreen.kt @@ -1,4 +1,4 @@ -package com.team.prezel.feature.analysis.impl +package com.team.prezel.feature.analysis.impl.schedule import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource @@ -21,6 +21,7 @@ import com.team.prezel.core.designsystem.component.textfield.PrezelTextField import com.team.prezel.core.designsystem.icon.PrezelIcons import com.team.prezel.core.designsystem.preview.BasicPreview import com.team.prezel.core.designsystem.theme.PrezelTheme +import com.team.prezel.feature.analysis.impl.R import com.team.prezel.feature.analysis.impl.component.AnalysisStepLayout import com.team.prezel.feature.analysis.impl.component.AnalysisStepTitle import com.team.prezel.feature.analysis.impl.contract.AnalysisFlowUiState diff --git a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/ScriptInputScreen.kt b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/script/ScriptInputScreen.kt similarity index 99% rename from Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/ScriptInputScreen.kt rename to Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/script/ScriptInputScreen.kt index a96df3a4..bb84cd1c 100644 --- a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/ScriptInputScreen.kt +++ b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/script/ScriptInputScreen.kt @@ -1,4 +1,4 @@ -package com.team.prezel.feature.analysis.impl +package com.team.prezel.feature.analysis.impl.script import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts @@ -34,6 +34,7 @@ import com.team.prezel.core.designsystem.theme.PrezelTheme import com.team.prezel.core.ui.component.FileUploader import com.team.prezel.core.ui.component.FileUploaderState import com.team.prezel.core.ui.component.StatusView +import com.team.prezel.feature.analysis.impl.R import com.team.prezel.feature.analysis.impl.component.AnalysisStepLayout import com.team.prezel.feature.analysis.impl.component.AnalysisStepTitle import com.team.prezel.feature.analysis.impl.component.toFileName diff --git a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/PresentationSituationScreen.kt b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/situation/PresentationSituationScreen.kt similarity index 89% rename from Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/PresentationSituationScreen.kt rename to Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/situation/PresentationSituationScreen.kt index 138c1af1..7587c6a8 100644 --- a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/PresentationSituationScreen.kt +++ b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/situation/PresentationSituationScreen.kt @@ -1,4 +1,4 @@ -package com.team.prezel.feature.analysis.impl +package com.team.prezel.feature.analysis.impl.situation import androidx.compose.foundation.background import androidx.compose.foundation.border @@ -13,8 +13,11 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.material3.Icon +import androidx.compose.material3.LocalContentColor import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource @@ -32,6 +35,7 @@ import com.team.prezel.core.model.presentation.Audience import com.team.prezel.core.model.presentation.Category import com.team.prezel.core.model.presentation.Purpose import com.team.prezel.core.model.presentation.Style +import com.team.prezel.feature.analysis.impl.R import com.team.prezel.feature.analysis.impl.component.AnalysisStepLayout import com.team.prezel.feature.analysis.impl.component.AnalysisStepTitle import com.team.prezel.feature.analysis.impl.contract.AnalysisFlowStep @@ -115,6 +119,7 @@ private fun SituationAccordions( SituationAccordion( title = stringResource(R.string.feature_analysis_impl_situation_category_label), + selectedText = categoryOptions.firstOrNull { it.value == form.category }?.title, ) { CategoryOptionGrid( selectedValue = form.category, @@ -124,6 +129,7 @@ private fun SituationAccordions( } SituationAccordion( title = stringResource(R.string.feature_analysis_impl_situation_purpose_label), + selectedText = purposeOptions.firstOrNull { it.value == form.purpose }?.text, ) { ChipOptionsContent( options = purposeOptions.toChipContentOptions(form.purpose), @@ -132,6 +138,7 @@ private fun SituationAccordions( } SituationAccordion( title = stringResource(R.string.feature_analysis_impl_situation_style_label), + selectedText = styleOptions.firstOrNull { it.value == form.style }?.text, ) { ChipOptionsContent( options = styleOptions.toChipContentOptions(form.style), @@ -140,6 +147,7 @@ private fun SituationAccordions( } SituationAccordion( title = stringResource(R.string.feature_analysis_impl_situation_scale_label), + selectedText = audienceOptions.firstOrNull { it.value == form.audience }?.text, ) { ChipOptionsContent( options = audienceOptions.toChipContentOptions(form.audience), @@ -151,13 +159,27 @@ private fun SituationAccordions( @Composable private fun SituationAccordion( title: String, + selectedText: String?, content: @Composable () -> Unit, ) { - PrezelAccordion( - title = title, - showDivider = true, - content = content, - ) + CompositionLocalProvider( + LocalContentColor provides PrezelTheme.colors.textLarge, + ) { + PrezelAccordion( + title = title, + showDivider = true, + trailingContent = selectedText?.let { text -> + { + Text( + text = text, + color = PrezelTheme.colors.interactiveRegular, + style = PrezelTheme.typography.body2Bold, + ) + } + }, + content = content, + ) + } } @Composable @@ -215,17 +237,10 @@ private fun CategoryOptionCard( shape = PrezelTheme.shapes.V8, ).padding(PrezelTheme.spacing.V12), ) { - Icon( - painter = painterResource(option.iconResId), - contentDescription = null, - modifier = Modifier.size(24.dp), - tint = iconColor, - ) - Spacer(modifier = Modifier.height(PrezelTheme.spacing.V4)) Text( text = option.title, color = PrezelTheme.colors.textLarge, - style = PrezelTheme.typography.body2Medium, + style = PrezelTheme.typography.body2Bold, ) Spacer(modifier = Modifier.height(PrezelTheme.spacing.V4)) Text( @@ -233,6 +248,15 @@ private fun CategoryOptionCard( color = PrezelTheme.colors.textRegular, style = PrezelTheme.typography.caption2Regular, ) + Spacer(modifier = Modifier.height(PrezelTheme.spacing.V4)) + Icon( + painter = painterResource(option.iconResId), + contentDescription = null, + modifier = Modifier + .align(Alignment.End) + .size(64.dp), + tint = iconColor, + ) } } } diff --git a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/SituationOptions.kt b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/situation/SituationOptions.kt similarity index 93% rename from Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/SituationOptions.kt rename to Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/situation/SituationOptions.kt index 19ddec12..5f1e8371 100644 --- a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/SituationOptions.kt +++ b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/situation/SituationOptions.kt @@ -1,4 +1,4 @@ -package com.team.prezel.feature.analysis.impl +package com.team.prezel.feature.analysis.impl.situation import androidx.annotation.DrawableRes import androidx.annotation.StringRes @@ -10,6 +10,7 @@ import com.team.prezel.core.model.presentation.Audience import com.team.prezel.core.model.presentation.Category import com.team.prezel.core.model.presentation.Purpose import com.team.prezel.core.model.presentation.Style +import com.team.prezel.feature.analysis.impl.R import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList @@ -101,22 +102,22 @@ private val Category.iconResId: Int private val Purpose.titleResId: Int @StringRes get() = when (this) { - Purpose.CONTENT_DELIVERY -> R.string.feature_analysis_impl_situation_purpose_information - Purpose.IMPROVE_UNDERSTANDING -> R.string.feature_analysis_impl_situation_purpose_understanding - Purpose.BUILD_EMPATHY -> R.string.feature_analysis_impl_situation_purpose_empathy + Purpose.CONTENT_DELIVERY -> R.string.feature_analysis_impl_situation_purpose_content_delivery + Purpose.IMPROVE_UNDERSTANDING -> R.string.feature_analysis_impl_situation_purpose_improve_understanding + Purpose.BUILD_EMPATHY -> R.string.feature_analysis_impl_situation_purpose_build_empathy } private val Style.titleResId: Int @StringRes get() = when (this) { Style.PROFESSIONAL -> R.string.feature_analysis_impl_situation_style_professional Style.FRIENDLY -> R.string.feature_analysis_impl_situation_style_friendly - Style.CALM -> R.string.feature_analysis_impl_situation_style_formal - Style.COMFORTABLE -> R.string.feature_analysis_impl_situation_style_casual + Style.CALM -> R.string.feature_analysis_impl_situation_style_calm + Style.COMFORTABLE -> R.string.feature_analysis_impl_situation_style_comfortable } private val Audience.titleResId: Int @StringRes get() = when (this) { - Audience.GENERAL_AUDIENCE -> R.string.feature_analysis_impl_situation_audience_public - Audience.EXPERT -> R.string.feature_analysis_impl_situation_audience_teacher + Audience.GENERAL_AUDIENCE -> R.string.feature_analysis_impl_situation_audience_general + Audience.EXPERT -> R.string.feature_analysis_impl_situation_audience_expert Audience.TEAMMATES -> R.string.feature_analysis_impl_situation_audience_team } diff --git a/Prezel/feature/analysis/impl/src/main/res/values/strings.xml b/Prezel/feature/analysis/impl/src/main/res/values/strings.xml index 8ce37c62..fae79aa8 100644 --- a/Prezel/feature/analysis/impl/src/main/res/values/strings.xml +++ b/Prezel/feature/analysis/impl/src/main/res/values/strings.xml @@ -1,7 +1,7 @@ - 발표 일정 추가 - 새로운 일정을 만들어볼까요? + 발표 일정 설정 + 새로운 발표 일정을 만들어주세요. 발표 이름과 날짜를 입력해주세요. 발표 이름 발표의 이름을 설정해주세요 @@ -9,16 +9,16 @@ 발표하는 날짜를 알려주세요 발표 상황 설정 - 발표에 대해 알려주세요 - 발표의 성격을 선택해주세요. + 발표 상황을 선택해주세요. + 어떤 성격의 발표인지 알려주세요. 유형 목적 스타일 청중 학술·교육 - 정보 전달 + 내용 전달 전문적인 - 교수/교사/강사 + 전문가 학술·교육 배운 내용이나 분석 주제를 전달하는 발표예요. 업무·보고 @@ -27,15 +27,15 @@ 다른 사람의 선택을 이끄는 발표예요. 행사·공개 여러 사람 앞에서 내용을 소개하는 발표예요. - 정보 전달 - 이해도 향상 - 공감대 형성 + 내용 전달 + 이해 증진 + 공감 형성 전문적인 친근한 - 격식있는 - 일상적인 - 대중 - 교수/교사/강사 + 차분한 + 편안한 + 일반 청중 + 전문가 팀/동료 대본 입력 From 5b63a7c2660f622be5e70bbdb3bc0166e2698a01 Mon Sep 17 00:00:00 2001 From: Ham BeomJoon Date: Sun, 24 May 2026 01:14:40 +0900 Subject: [PATCH 07/16] =?UTF-8?q?feat:=20=EB=B0=9C=ED=91=9C=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=20API=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `AnalyzePresentationRecordingUseCase` 추가 및 도메인 모델 정의 - `PracticeRepository` 및 구현체에 발표 녹음 분석 로직(`analyzePresentationRecording`) 추가 - `PracticeRemoteDataSource` 및 Ktor 기반 멀티파트 데이터 전송 로직 구현 - 발표 분석 요청/응답을 위한 네트워크 DTO(`PresentationRecordingAnalysisResponse` 등) 추가 - `AnalysisFlowViewModel`에서 파일 캐시 복사 및 분석 API 호출 로직 구현 - 분석 중 로딩 화면 및 결과 데이터 상태 관리 추가 - `PracticeRepositoryImplTest`에 발표 분석 시나리오 테스트 케이스 추가 --- .../data/repository/PracticeRepositoryImpl.kt | 106 +++++++ .../practice/PracticeRepositoryImplTest.kt | 127 ++++++++ .../repository/practice/PracticeRepository.kt | 17 ++ .../AnalyzePresentationRecordingUseCase.kt | 36 +++ .../PresentationRecordingAnalysisResult.kt | 35 +++ .../datasource/PracticeRemoteDataSource.kt | 17 ++ .../PracticeRemoteDataSourceImpl.kt | 53 ++++ .../practice/PresentationAnalysisRequest.kt | 35 +++ .../PresentationRecordingAnalysisResponse.kt | 66 +++++ .../core/network/service/PracticeService.kt | 6 + Prezel/feature/analysis/impl/build.gradle.kts | 1 + .../analysis/impl/AnalysisFlowViewModel.kt | 271 +++++++++++++----- .../feature/analysis/impl/AnalysisScreen.kt | 4 +- .../impl/contract/AnalysisFlowUiState.kt | 2 + .../impl/result/AnalysisLoadingScreen.kt | 12 +- 15 files changed, 701 insertions(+), 87 deletions(-) create mode 100644 Prezel/core/domain/src/main/kotlin/com/team/prezel/core/domain/usecase/practice/AnalyzePresentationRecordingUseCase.kt create mode 100644 Prezel/core/model/src/main/java/com/team/prezel/core/model/presentation/PresentationRecordingAnalysisResult.kt create mode 100644 Prezel/core/network/src/main/java/com/team/prezel/core/network/model/practice/PresentationAnalysisRequest.kt create mode 100644 Prezel/core/network/src/main/java/com/team/prezel/core/network/model/practice/PresentationRecordingAnalysisResponse.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 73795356..c5919592 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 @@ -6,9 +6,21 @@ 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.model.presentation.Audience +import com.team.prezel.core.model.presentation.Category +import com.team.prezel.core.model.presentation.PresentationExpectedQuestion +import com.team.prezel.core.model.presentation.PresentationGrowthGraph +import com.team.prezel.core.model.presentation.PresentationRecordingAnalysisResult +import com.team.prezel.core.model.presentation.Purpose +import com.team.prezel.core.model.presentation.Style 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 com.team.prezel.core.network.model.practice.PresentationAnalysisAudience +import com.team.prezel.core.network.model.practice.PresentationAnalysisPurpose +import com.team.prezel.core.network.model.practice.PresentationAnalysisStyle +import com.team.prezel.core.network.model.practice.PresentationAnalysisType +import com.team.prezel.core.network.model.practice.PresentationRecordingAnalysisResponse import javax.inject.Inject import kotlin.math.roundToInt @@ -22,6 +34,33 @@ internal class PracticeRepositoryImpl @Inject constructor( response.toDomain() }.mapDomainFailure() + override suspend fun analyzePresentationRecording( + name: String, + date: String, + category: Category, + purpose: Purpose, + style: Style, + audience: Audience, + script: String?, + scriptFilePath: String?, + audioFilePath: String, + ): Result = + runCatching { + practiceRemoteDataSource.analyzePresentationRecording( + name = name, + date = date, + type = category.toRequestType(), + purpose = purpose.toRequestPurpose(), + style = style.toRequestStyle(), + audience = audience.toRequestAudience(), + script = script, + scriptFilePath = scriptFilePath, + audioFilePath = audioFilePath, + ) + }.mapCatching { response -> + response.toDomain() + }.mapDomainFailure() + override suspend fun analyzePracticeRecording( recordingFilePath: String, referenceText: String, @@ -48,6 +87,73 @@ internal class PracticeRepositoryImpl @Inject constructor( overallEvaluation = overallEvaluation.toPracticeRecordingOverallEvaluation(), ) + private fun PresentationRecordingAnalysisResponse.toDomain(): PresentationRecordingAnalysisResult = + PresentationRecordingAnalysisResult( + presentationId = presentationId, + analysisResultId = analysisResultId, + name = name, + type = type, + purpose = purpose, + style = style, + audience = audience, + analysisDate = analysisDate, + durationSeconds = durationSeconds, + formattedDuration = formattedDuration, + spm = spm, + speedEval = speedEval, + summaryFeedback = summaryFeedback, + accuracyScore = accuracyScore, + scriptMatchRate = scriptMatchRate, + spellErrorCount = spellErrorCount, + grammarErrorCount = grammarErrorCount, + totalErrorCount = totalErrorCount, + growthGraph = growthGraph.map { it.toDomain() }, + expectedQuestions = expectedQuestions.map { it.toDomain() }, + ) + + private fun PresentationRecordingAnalysisResponse.GrowthGraph.toDomain(): PresentationGrowthGraph = + PresentationGrowthGraph( + attempt = attempt, + accuracyScore = accuracyScore, + scriptMatchRate = scriptMatchRate, + ) + + private fun PresentationRecordingAnalysisResponse.ExpectedQuestion.toDomain(): PresentationExpectedQuestion = + PresentationExpectedQuestion( + question = question, + answer = answer, + ) + + private fun Category.toRequestType(): PresentationAnalysisType = + when (this) { + Category.EDUCATION -> PresentationAnalysisType.EDUCATION + Category.REPORT -> PresentationAnalysisType.WORK + Category.PERSUASION -> PresentationAnalysisType.OFFER + Category.EVENT -> PresentationAnalysisType.EVENT + } + + private fun Purpose.toRequestPurpose(): PresentationAnalysisPurpose = + when (this) { + Purpose.CONTENT_DELIVERY -> PresentationAnalysisPurpose.INFO + Purpose.IMPROVE_UNDERSTANDING -> PresentationAnalysisPurpose.UNDERSTANDING + Purpose.BUILD_EMPATHY -> PresentationAnalysisPurpose.EMPATHY + } + + private fun Style.toRequestStyle(): PresentationAnalysisStyle = + when (this) { + Style.PROFESSIONAL -> PresentationAnalysisStyle.FORMAL + Style.FRIENDLY -> PresentationAnalysisStyle.FRIENDLY + Style.CALM -> PresentationAnalysisStyle.CALM + Style.COMFORTABLE -> PresentationAnalysisStyle.CASUAL + } + + private fun Audience.toRequestAudience(): PresentationAnalysisAudience = + when (this) { + Audience.GENERAL_AUDIENCE -> PresentationAnalysisAudience.GENERAL + Audience.EXPERT -> PresentationAnalysisAudience.PROFESSIONAL + Audience.TEAMMATES -> PresentationAnalysisAudience.TEAMMATE + } + private fun String.toPracticeRecordingSpeed(): PracticeRecordingSpeed = when { contains("느려요") -> PracticeRecordingSpeed.SLOW 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 785093e6..6070929b 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 @@ -3,9 +3,18 @@ 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.model.presentation.Audience +import com.team.prezel.core.model.presentation.Category +import com.team.prezel.core.model.presentation.Purpose +import com.team.prezel.core.model.presentation.Style import com.team.prezel.core.network.datasource.PracticeRemoteDataSource 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.practice.PresentationAnalysisAudience +import com.team.prezel.core.network.model.practice.PresentationAnalysisPurpose +import com.team.prezel.core.network.model.practice.PresentationAnalysisStyle +import com.team.prezel.core.network.model.practice.PresentationAnalysisType +import com.team.prezel.core.network.model.practice.PresentationRecordingAnalysisResponse import kotlinx.coroutines.runBlocking import kotlin.test.Test import kotlin.test.assertEquals @@ -49,6 +58,50 @@ class PracticeRepositoryImplTest { assertEquals(PracticeRecordingOverallEvaluation.GOOD, result.overallEvaluation) } + @Test + fun `발표 녹음 분석을 요청하고 분석 결과로 변환한다`() = + runBlocking { + val remoteDataSource = FakePracticeRemoteDataSource( + sentence = "간장 공장 공장장은 강 공장장이다.", + ) + val repository = PracticeRepositoryImpl( + practiceRemoteDataSource = remoteDataSource, + ) + + val result = repository + .analyzePresentationRecording( + name = "중간 발표", + date = "2026-05-24", + category = Category.REPORT, + purpose = Purpose.IMPROVE_UNDERSTANDING, + style = Style.COMFORTABLE, + audience = Audience.TEAMMATES, + script = "발표 대본입니다.", + scriptFilePath = "/tmp/script.txt", + audioFilePath = "/tmp/audio.m4a", + ).getOrThrow() + + assertEquals("중간 발표", remoteDataSource.presentationAnalysisName) + assertEquals("2026-05-24", remoteDataSource.presentationAnalysisDate) + assertEquals(PresentationAnalysisType.WORK, remoteDataSource.presentationAnalysisType) + assertEquals(PresentationAnalysisPurpose.UNDERSTANDING, remoteDataSource.presentationAnalysisPurpose) + assertEquals(PresentationAnalysisStyle.CASUAL, remoteDataSource.presentationAnalysisStyle) + assertEquals(PresentationAnalysisAudience.TEAMMATE, remoteDataSource.presentationAnalysisAudience) + assertEquals("발표 대본입니다.", remoteDataSource.presentationAnalysisScript) + assertEquals("/tmp/script.txt", remoteDataSource.presentationAnalysisScriptFilePath) + assertEquals("/tmp/audio.m4a", remoteDataSource.presentationAnalysisAudioFilePath) + assertEquals(1L, result.presentationId) + assertEquals(2L, result.analysisResultId) + assertEquals("중간 발표", result.name) + assertEquals(123, result.durationSeconds) + assertEquals(145, result.spm) + assertEquals(92.5, result.accuracyScore) + assertEquals(88.0, result.scriptMatchRate) + assertEquals(3, result.totalErrorCount) + assertEquals("요약 피드백", result.summaryFeedback) + assertEquals("예상 질문", result.expectedQuestions.single().question) + } + private class FakePracticeRemoteDataSource( private val sentence: String, ) : PracticeRemoteDataSource { @@ -56,6 +109,24 @@ class PracticeRepositoryImplTest { private set var analyzeReferenceText: String? = null private set + var presentationAnalysisName: String? = null + private set + var presentationAnalysisDate: String? = null + private set + var presentationAnalysisType: PresentationAnalysisType? = null + private set + var presentationAnalysisPurpose: PresentationAnalysisPurpose? = null + private set + var presentationAnalysisStyle: PresentationAnalysisStyle? = null + private set + var presentationAnalysisAudience: PresentationAnalysisAudience? = null + private set + var presentationAnalysisScript: String? = null + private set + var presentationAnalysisScriptFilePath: String? = null + private set + var presentationAnalysisAudioFilePath: String? = null + private set override suspend fun getPracticeSentence(): PracticeSentenceResponse = PracticeSentenceResponse(sentence = sentence) @@ -72,5 +143,61 @@ class PracticeRepositoryImplTest { overallEvaluation = "Good", ) } + + override suspend fun analyzePresentationRecording( + name: String, + date: String, + type: PresentationAnalysisType, + purpose: PresentationAnalysisPurpose, + style: PresentationAnalysisStyle, + audience: PresentationAnalysisAudience, + script: String?, + scriptFilePath: String?, + audioFilePath: String, + ): PresentationRecordingAnalysisResponse { + presentationAnalysisName = name + presentationAnalysisDate = date + presentationAnalysisType = type + presentationAnalysisPurpose = purpose + presentationAnalysisStyle = style + presentationAnalysisAudience = audience + presentationAnalysisScript = script + presentationAnalysisScriptFilePath = scriptFilePath + presentationAnalysisAudioFilePath = audioFilePath + + return PresentationRecordingAnalysisResponse( + presentationId = 1, + analysisResultId = 2, + name = "중간 발표", + type = "WORK", + purpose = "UNDERSTANDING", + style = "CASUAL", + audience = "TEAMMATE", + analysisDate = "2026-05-24", + durationSeconds = 123, + formattedDuration = "02:03", + spm = 145, + speedEval = "적당해요", + summaryFeedback = "요약 피드백", + accuracyScore = 92.5, + scriptMatchRate = 88.0, + spellErrorCount = 1, + grammarErrorCount = 2, + totalErrorCount = 3, + growthGraph = listOf( + PresentationRecordingAnalysisResponse.GrowthGraph( + attempt = 1, + accuracyScore = 92.5, + scriptMatchRate = 88.0, + ), + ), + expectedQuestions = listOf( + PresentationRecordingAnalysisResponse.ExpectedQuestion( + question = "예상 질문", + answer = "예상 답변", + ), + ), + ) + } } } diff --git a/Prezel/core/domain/src/main/kotlin/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 index 340ccf6a..391192f4 100644 --- a/Prezel/core/domain/src/main/kotlin/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 @@ -2,6 +2,11 @@ package com.team.prezel.core.domain.repository.practice import com.team.prezel.core.model.practice.PracticeRecordingAnalysisResult import com.team.prezel.core.model.practice.PracticeScript +import com.team.prezel.core.model.presentation.Audience +import com.team.prezel.core.model.presentation.Category +import com.team.prezel.core.model.presentation.PresentationRecordingAnalysisResult +import com.team.prezel.core.model.presentation.Purpose +import com.team.prezel.core.model.presentation.Style interface PracticeRepository { suspend fun fetchPracticeScript(): Result @@ -10,4 +15,16 @@ interface PracticeRepository { recordingFilePath: String, referenceText: String, ): Result + + suspend fun analyzePresentationRecording( + name: String, + date: String, + category: Category, + purpose: Purpose, + style: Style, + audience: Audience, + script: String?, + scriptFilePath: String?, + audioFilePath: String, + ): Result } diff --git a/Prezel/core/domain/src/main/kotlin/com/team/prezel/core/domain/usecase/practice/AnalyzePresentationRecordingUseCase.kt b/Prezel/core/domain/src/main/kotlin/com/team/prezel/core/domain/usecase/practice/AnalyzePresentationRecordingUseCase.kt new file mode 100644 index 00000000..ad34abd4 --- /dev/null +++ b/Prezel/core/domain/src/main/kotlin/com/team/prezel/core/domain/usecase/practice/AnalyzePresentationRecordingUseCase.kt @@ -0,0 +1,36 @@ +package com.team.prezel.core.domain.usecase.practice + +import com.team.prezel.core.domain.repository.practice.PracticeRepository +import com.team.prezel.core.model.presentation.Audience +import com.team.prezel.core.model.presentation.Category +import com.team.prezel.core.model.presentation.PresentationRecordingAnalysisResult +import com.team.prezel.core.model.presentation.Purpose +import com.team.prezel.core.model.presentation.Style +import javax.inject.Inject + +class AnalyzePresentationRecordingUseCase @Inject constructor( + private val practiceRepository: PracticeRepository, +) { + suspend operator fun invoke( + name: String, + date: String, + category: Category, + purpose: Purpose, + style: Style, + audience: Audience, + script: String?, + scriptFilePath: String?, + audioFilePath: String, + ): Result = + practiceRepository.analyzePresentationRecording( + name = name, + date = date, + category = category, + purpose = purpose, + style = style, + audience = audience, + script = script, + scriptFilePath = scriptFilePath, + audioFilePath = audioFilePath, + ) +} diff --git a/Prezel/core/model/src/main/java/com/team/prezel/core/model/presentation/PresentationRecordingAnalysisResult.kt b/Prezel/core/model/src/main/java/com/team/prezel/core/model/presentation/PresentationRecordingAnalysisResult.kt new file mode 100644 index 00000000..c600cf53 --- /dev/null +++ b/Prezel/core/model/src/main/java/com/team/prezel/core/model/presentation/PresentationRecordingAnalysisResult.kt @@ -0,0 +1,35 @@ +package com.team.prezel.core.model.presentation + +data class PresentationRecordingAnalysisResult( + val presentationId: Long, + val analysisResultId: Long, + val name: String, + val type: String, + val purpose: String, + val style: String, + val audience: String, + val analysisDate: String, + val durationSeconds: Int, + val formattedDuration: String, + val spm: Int, + val speedEval: String, + val summaryFeedback: String, + val accuracyScore: Double, + val scriptMatchRate: Double, + val spellErrorCount: Int, + val grammarErrorCount: Int, + val totalErrorCount: Int, + val growthGraph: List, + val expectedQuestions: List, +) + +data class PresentationGrowthGraph( + val attempt: Int, + val accuracyScore: Double, + val scriptMatchRate: Double, +) + +data class PresentationExpectedQuestion( + val question: String, + val answer: String, +) 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 index 1619412d..dc2218cd 100644 --- 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 @@ -2,6 +2,11 @@ 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.practice.PresentationAnalysisAudience +import com.team.prezel.core.network.model.practice.PresentationAnalysisPurpose +import com.team.prezel.core.network.model.practice.PresentationAnalysisStyle +import com.team.prezel.core.network.model.practice.PresentationAnalysisType +import com.team.prezel.core.network.model.practice.PresentationRecordingAnalysisResponse interface PracticeRemoteDataSource { suspend fun getPracticeSentence(): PracticeSentenceResponse @@ -10,4 +15,16 @@ interface PracticeRemoteDataSource { recordingFilePath: String, referenceText: String, ): AnalyzePracticeRecordingResponse + + suspend fun analyzePresentationRecording( + name: String, + date: String, + type: PresentationAnalysisType, + purpose: PresentationAnalysisPurpose, + style: PresentationAnalysisStyle, + audience: PresentationAnalysisAudience, + script: String?, + scriptFilePath: String?, + audioFilePath: String, + ): PresentationRecordingAnalysisResponse } 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 index 93075ad3..c3a29f46 100644 --- 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 @@ -2,6 +2,11 @@ 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.practice.PresentationAnalysisAudience +import com.team.prezel.core.network.model.practice.PresentationAnalysisPurpose +import com.team.prezel.core.network.model.practice.PresentationAnalysisStyle +import com.team.prezel.core.network.model.practice.PresentationAnalysisType +import com.team.prezel.core.network.model.practice.PresentationRecordingAnalysisResponse import com.team.prezel.core.network.model.requireData import com.team.prezel.core.network.service.PracticeService import io.ktor.client.request.forms.MultiPartFormDataContent @@ -40,4 +45,52 @@ internal class PracticeRemoteDataSourceImpl @Inject constructor( audio = multipart, ).requireData() } + + override suspend fun analyzePresentationRecording( + name: String, + date: String, + type: PresentationAnalysisType, + purpose: PresentationAnalysisPurpose, + style: PresentationAnalysisStyle, + audience: PresentationAnalysisAudience, + script: String?, + scriptFilePath: String?, + audioFilePath: String, + ): PresentationRecordingAnalysisResponse { + val audioFile = File(audioFilePath) + val scriptFile = scriptFilePath?.let(::File) + val multipart = MultiPartFormDataContent( + formData { + append("name", name) + append("date", date) + append("type", type.value) + append("purpose", purpose.value) + append("style", style.value) + append("audience", audience.value) + script?.takeIf(String::isNotBlank)?.let { append("script", it) } + scriptFile?.let { file -> + append( + key = "scriptFile", + value = file.readBytes(), + headers = Headers.build { + append(HttpHeaders.ContentType, "text/${file.extension}") + append(HttpHeaders.ContentDisposition, "filename=\"${file.name}\"") + }, + ) + } + append( + key = "audio", + value = audioFile.readBytes(), + headers = Headers.build { + append(HttpHeaders.ContentType, "audio/${audioFile.extension}") + append(HttpHeaders.ContentDisposition, "filename=\"${audioFile.name}\"") + }, + ) + }, + ) + + return practiceService + .analyzePresentationRecording(multipart = multipart) + .requireData() + } } diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/model/practice/PresentationAnalysisRequest.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/model/practice/PresentationAnalysisRequest.kt new file mode 100644 index 00000000..0e411cad --- /dev/null +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/model/practice/PresentationAnalysisRequest.kt @@ -0,0 +1,35 @@ +package com.team.prezel.core.network.model.practice + +enum class PresentationAnalysisType( + val value: String, +) { + EDUCATION("EDUCATION"), + WORK("WORK"), + OFFER("OFFER"), + EVENT("EVENT"), +} + +enum class PresentationAnalysisPurpose( + val value: String, +) { + INFO("INFO"), + UNDERSTANDING("UNDERSTANDING"), + EMPATHY("EMPATHY"), +} + +enum class PresentationAnalysisStyle( + val value: String, +) { + FORMAL("FORMAL"), + FRIENDLY("FRIENDLY"), + CALM("CALM"), + CASUAL("CASUAL"), +} + +enum class PresentationAnalysisAudience( + val value: String, +) { + GENERAL("GENERAL"), + PROFESSIONAL("PROFESSIONAL"), + TEAMMATE("TEAMMATE"), +} diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/model/practice/PresentationRecordingAnalysisResponse.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/model/practice/PresentationRecordingAnalysisResponse.kt new file mode 100644 index 00000000..f9413105 --- /dev/null +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/model/practice/PresentationRecordingAnalysisResponse.kt @@ -0,0 +1,66 @@ +package com.team.prezel.core.network.model.practice + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class PresentationRecordingAnalysisResponse( + @SerialName("presentationId") + val presentationId: Long, + @SerialName("analysisResultId") + val analysisResultId: Long, + @SerialName("name") + val name: String, + @SerialName("type") + val type: String, + @SerialName("purpose") + val purpose: String, + @SerialName("style") + val style: String, + @SerialName("audience") + val audience: String, + @SerialName("analysisDate") + val analysisDate: String, + @SerialName("durationSeconds") + val durationSeconds: Int, + @SerialName("formattedDuration") + val formattedDuration: String, + @SerialName("spm") + val spm: Int, + @SerialName("speedEval") + val speedEval: String, + @SerialName("summaryFeedback") + val summaryFeedback: String, + @SerialName("accuracyScore") + val accuracyScore: Double, + @SerialName("scriptMatchRate") + val scriptMatchRate: Double, + @SerialName("spellErrorCount") + val spellErrorCount: Int, + @SerialName("grammarErrorCount") + val grammarErrorCount: Int, + @SerialName("totalErrorCount") + val totalErrorCount: Int, + @SerialName("growthGraph") + val growthGraph: List, + @SerialName("expectedQuestions") + val expectedQuestions: List, +) { + @Serializable + data class GrowthGraph( + @SerialName("attempt") + val attempt: Int, + @SerialName("accuracyScore") + val accuracyScore: Double, + @SerialName("scriptMatchRate") + val scriptMatchRate: Double, + ) + + @Serializable + data class ExpectedQuestion( + @SerialName("question") + val question: String, + @SerialName("answer") + val answer: 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 index e0c1fa8a..656530aa 100644 --- 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 @@ -3,6 +3,7 @@ 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 com.team.prezel.core.network.model.practice.PresentationRecordingAnalysisResponse import de.jensklingenberg.ktorfit.http.Body import de.jensklingenberg.ktorfit.http.GET import de.jensklingenberg.ktorfit.http.POST @@ -18,4 +19,9 @@ interface PracticeService { @Query("referenceText") referenceText: String, @Body audio: MultiPartFormDataContent, ): BaseResponse + + @POST("recording/analyze") + suspend fun analyzePresentationRecording( + @Body multipart: MultiPartFormDataContent, + ): BaseResponse } diff --git a/Prezel/feature/analysis/impl/build.gradle.kts b/Prezel/feature/analysis/impl/build.gradle.kts index 2e5ff7fe..0cf92b9b 100644 --- a/Prezel/feature/analysis/impl/build.gradle.kts +++ b/Prezel/feature/analysis/impl/build.gradle.kts @@ -7,6 +7,7 @@ android { } dependencies { + implementation(projects.coreDomain) implementation(projects.coreModel) implementation(projects.featureAnalysisApi) implementation(projects.featureHomeApi) diff --git a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AnalysisFlowViewModel.kt b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AnalysisFlowViewModel.kt index 37487346..24e2b3c3 100644 --- a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AnalysisFlowViewModel.kt +++ b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AnalysisFlowViewModel.kt @@ -1,6 +1,10 @@ package com.team.prezel.feature.analysis.impl +import android.content.Context +import android.net.Uri +import android.provider.OpenableColumns import androidx.lifecycle.viewModelScope +import com.team.prezel.core.domain.usecase.practice.AnalyzePresentationRecordingUseCase import com.team.prezel.core.ui.base.BaseViewModel import com.team.prezel.feature.analysis.impl.contract.AnalysisFlowStep import com.team.prezel.feature.analysis.impl.contract.AnalysisFlowUiEffect @@ -11,113 +15,222 @@ import com.team.prezel.feature.analysis.impl.contract.AnalysisSituationOption import com.team.prezel.feature.analysis.impl.contract.AnalysisUploadType import com.team.prezel.feature.analysis.impl.contract.ScriptInputType import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.launch +import kotlinx.datetime.LocalDate +import java.io.File import javax.inject.Inject @HiltViewModel -internal class AnalysisFlowViewModel @Inject constructor() : - BaseViewModel(AnalysisFlowUiState()) { - override fun onIntent(intent: AnalysisFlowUiIntent) { - when (intent) { - is AnalysisFlowUiIntent.UpdatePresentationTitle -> updateForm { copy(presentationTitle = intent.title) } - is AnalysisFlowUiIntent.UpdatePresentationDate -> updateForm { copy(presentationDate = intent.date) } - is AnalysisFlowUiIntent.SelectSituationOption -> selectSituationOption(intent.option) - is AnalysisFlowUiIntent.SelectScriptInputType -> updateForm { copy(scriptInputType = intent.inputType) } - is AnalysisFlowUiIntent.UpdateScript -> updateForm { copy(script = intent.script) } - is AnalysisFlowUiIntent.SelectScriptFile -> updateForm { copy(scriptFileUri = intent.fileUri) } - is AnalysisFlowUiIntent.SelectAudioFile -> updateForm { copy(audioFileUri = intent.fileUri) } - is AnalysisFlowUiIntent.RetryFileUpload -> retryFileUpload(intent.uploadType) - AnalysisFlowUiIntent.Next -> moveNext() - AnalysisFlowUiIntent.SkipScript -> skipScript() - AnalysisFlowUiIntent.Back -> moveBack() - } +internal class AnalysisFlowViewModel @Inject constructor( + private val analyzePresentationRecordingUseCase: AnalyzePresentationRecordingUseCase, + @param:ApplicationContext private val context: Context, +) : BaseViewModel(AnalysisFlowUiState()) { + override fun onIntent(intent: AnalysisFlowUiIntent) { + when (intent) { + is AnalysisFlowUiIntent.UpdatePresentationTitle -> updateForm { copy(presentationTitle = intent.title) } + is AnalysisFlowUiIntent.UpdatePresentationDate -> updateForm { copy(presentationDate = intent.date) } + is AnalysisFlowUiIntent.SelectSituationOption -> selectSituationOption(intent.option) + is AnalysisFlowUiIntent.SelectScriptInputType -> updateForm { copy(scriptInputType = intent.inputType) } + is AnalysisFlowUiIntent.UpdateScript -> updateForm { copy(script = intent.script) } + is AnalysisFlowUiIntent.SelectScriptFile -> updateForm { copy(scriptFileUri = intent.fileUri) } + is AnalysisFlowUiIntent.SelectAudioFile -> updateForm { copy(audioFileUri = intent.fileUri) } + is AnalysisFlowUiIntent.RetryFileUpload -> retryFileUpload(intent.uploadType) + AnalysisFlowUiIntent.Next -> moveNext() + AnalysisFlowUiIntent.SkipScript -> skipScript() + AnalysisFlowUiIntent.Back -> moveBack() } + } - private fun selectSituationOption(option: AnalysisSituationOption) { - updateForm { - when (option) { - is AnalysisSituationOption.CategoryOption -> copy(category = option.category) - is AnalysisSituationOption.PurposeOption -> copy(purpose = option.purpose) - is AnalysisSituationOption.StyleOption -> copy(style = option.style) - is AnalysisSituationOption.AudienceOption -> copy(audience = option.audience) - } + private fun selectSituationOption(option: AnalysisSituationOption) { + updateForm { + when (option) { + is AnalysisSituationOption.CategoryOption -> copy(category = option.category) + is AnalysisSituationOption.PurposeOption -> copy(purpose = option.purpose) + is AnalysisSituationOption.StyleOption -> copy(style = option.style) + is AnalysisSituationOption.AudienceOption -> copy(audience = option.audience) } } + } - private fun moveNext() { - if (!currentState.canMoveNext && currentState.step != AnalysisFlowStep.ANALYZING) return + private fun moveNext() { + if (!currentState.canMoveNext && currentState.step != AnalysisFlowStep.ANALYZING) return - updateState { - copy( - step = when (step) { - AnalysisFlowStep.PRESENTATION_SCHEDULE -> AnalysisFlowStep.PRESENTATION_SITUATION - AnalysisFlowStep.PRESENTATION_SITUATION -> AnalysisFlowStep.SCRIPT_INPUT - AnalysisFlowStep.SCRIPT_INPUT -> AnalysisFlowStep.AUDIO_UPLOAD - AnalysisFlowStep.AUDIO_UPLOAD -> AnalysisFlowStep.ANALYZING - AnalysisFlowStep.ANALYZING -> AnalysisFlowStep.REPORT - AnalysisFlowStep.REPORT, - AnalysisFlowStep.FILE_RECOGNITION_FAILED, - AnalysisFlowStep.SCRIPT_FILE_RECOGNITION_FAILED, - -> AnalysisFlowStep.REPORT - }, - ) - } + if (currentState.step == AnalysisFlowStep.AUDIO_UPLOAD) { + analyzePresentation() + return } - private fun retryFileUpload(uploadType: AnalysisUploadType) { - when (uploadType) { - AnalysisUploadType.SCRIPT -> retryScriptFileUpload() - AnalysisUploadType.AUDIO -> retryAudioUpload() - } + updateState { + copy( + step = when (step) { + AnalysisFlowStep.PRESENTATION_SCHEDULE -> AnalysisFlowStep.PRESENTATION_SITUATION + AnalysisFlowStep.PRESENTATION_SITUATION -> AnalysisFlowStep.SCRIPT_INPUT + AnalysisFlowStep.SCRIPT_INPUT -> AnalysisFlowStep.AUDIO_UPLOAD + AnalysisFlowStep.AUDIO_UPLOAD -> AnalysisFlowStep.ANALYZING + AnalysisFlowStep.ANALYZING -> AnalysisFlowStep.REPORT + AnalysisFlowStep.REPORT, + AnalysisFlowStep.FILE_RECOGNITION_FAILED, + AnalysisFlowStep.SCRIPT_FILE_RECOGNITION_FAILED, + -> AnalysisFlowStep.REPORT + }, + ) } + } - private fun retryAudioUpload() { - updateState { - copy( - step = AnalysisFlowStep.AUDIO_UPLOAD, - form = form.copy(audioFileUri = null), + private fun analyzePresentation() { + val form = currentState.form + val category = form.category ?: return + val purpose = form.purpose ?: return + val style = form.style ?: return + val audience = form.audience ?: return + val audioFileUri = form.audioFileUri ?: return + + updateState { copy(step = AnalysisFlowStep.ANALYZING) } + + viewModelScope.launch { + val result = runCatching { + val audioFile = context.copyUriToAnalysisCache( + uriString = audioFileUri, + prefix = "audio", ) + val scriptFile = if (form.scriptInputType == ScriptInputType.FILE_UPLOAD) { + form.scriptFileUri?.let { scriptFileUri -> + context.copyUriToAnalysisCache( + uriString = scriptFileUri, + prefix = "script", + ) + } + } else { + null + } + + analyzePresentationRecordingUseCase( + name = form.presentationTitle.trim(), + date = form.presentationDate.toRequestDate(), + category = category, + purpose = purpose, + style = style, + audience = audience, + script = form.script.takeIf(String::isNotBlank), + scriptFilePath = scriptFile?.absolutePath, + audioFilePath = audioFile.absolutePath, + ).getOrThrow() } - } - private fun retryScriptFileUpload() { updateState { - copy( - step = AnalysisFlowStep.SCRIPT_INPUT, - form = form.copy( - scriptInputType = ScriptInputType.FILE_UPLOAD, - scriptFileUri = null, - ), + result.fold( + onSuccess = { analysisResult -> + copy( + step = AnalysisFlowStep.REPORT, + analysisResult = analysisResult, + ) + }, + onFailure = { + copy(step = AnalysisFlowStep.FILE_RECOGNITION_FAILED) + }, ) } } + } - private fun skipScript() { - if (currentState.step != AnalysisFlowStep.SCRIPT_INPUT) return + private fun retryFileUpload(uploadType: AnalysisUploadType) { + when (uploadType) { + AnalysisUploadType.SCRIPT -> retryScriptFileUpload() + AnalysisUploadType.AUDIO -> retryAudioUpload() + } + } - updateState { copy(step = AnalysisFlowStep.AUDIO_UPLOAD) } + private fun retryAudioUpload() { + updateState { + copy( + step = AnalysisFlowStep.AUDIO_UPLOAD, + form = form.copy(audioFileUri = null), + ) } + } - private fun moveBack() { - val previousStep = when (currentState.step) { - AnalysisFlowStep.PRESENTATION_SCHEDULE -> null - AnalysisFlowStep.PRESENTATION_SITUATION -> AnalysisFlowStep.PRESENTATION_SCHEDULE - AnalysisFlowStep.SCRIPT_INPUT -> AnalysisFlowStep.PRESENTATION_SITUATION - AnalysisFlowStep.AUDIO_UPLOAD -> AnalysisFlowStep.SCRIPT_INPUT - AnalysisFlowStep.ANALYZING -> AnalysisFlowStep.AUDIO_UPLOAD - AnalysisFlowStep.REPORT -> AnalysisFlowStep.AUDIO_UPLOAD - AnalysisFlowStep.FILE_RECOGNITION_FAILED -> AnalysisFlowStep.AUDIO_UPLOAD - AnalysisFlowStep.SCRIPT_FILE_RECOGNITION_FAILED -> AnalysisFlowStep.SCRIPT_INPUT - } + private fun retryScriptFileUpload() { + updateState { + copy( + step = AnalysisFlowStep.SCRIPT_INPUT, + form = form.copy( + scriptInputType = ScriptInputType.FILE_UPLOAD, + scriptFileUri = null, + ), + ) + } + } + + private fun skipScript() { + if (currentState.step != AnalysisFlowStep.SCRIPT_INPUT) return + + updateState { copy(step = AnalysisFlowStep.AUDIO_UPLOAD) } + } + + private fun moveBack() { + val previousStep = when (currentState.step) { + AnalysisFlowStep.PRESENTATION_SCHEDULE -> null + AnalysisFlowStep.PRESENTATION_SITUATION -> AnalysisFlowStep.PRESENTATION_SCHEDULE + AnalysisFlowStep.SCRIPT_INPUT -> AnalysisFlowStep.PRESENTATION_SITUATION + AnalysisFlowStep.AUDIO_UPLOAD -> AnalysisFlowStep.SCRIPT_INPUT + AnalysisFlowStep.ANALYZING -> AnalysisFlowStep.AUDIO_UPLOAD + AnalysisFlowStep.REPORT -> AnalysisFlowStep.AUDIO_UPLOAD + AnalysisFlowStep.FILE_RECOGNITION_FAILED -> AnalysisFlowStep.AUDIO_UPLOAD + AnalysisFlowStep.SCRIPT_FILE_RECOGNITION_FAILED -> AnalysisFlowStep.SCRIPT_INPUT + } - if (previousStep == null) { - viewModelScope.launch { sendEffect(AnalysisFlowUiEffect.NavigateBack) } + if (previousStep == null) { + viewModelScope.launch { sendEffect(AnalysisFlowUiEffect.NavigateBack) } + } else { + updateState { copy(step = previousStep) } + } + } + + private fun updateForm(reducer: AnalysisForm.() -> AnalysisForm) { + updateState { copy(form = form.reducer()) } + } +} + +private fun Context.copyUriToAnalysisCache( + uriString: String, + prefix: String, +): File { + val uri = Uri.parse(uriString) + val displayName = contentResolver + .query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null) + ?.use { cursor -> + val displayNameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) + if (displayNameIndex != -1 && cursor.moveToFirst()) { + cursor.getString(displayNameIndex) } else { - updateState { copy(step = previousStep) } + null } } + val extension = displayName + ?.substringAfterLast('.', missingDelimiterValue = "") + ?.takeIf(String::isNotBlank) + ?: uri.lastPathSegment + ?.substringAfterLast('.', missingDelimiterValue = "") + ?.takeIf(String::isNotBlank) + ?: "tmp" + val target = File.createTempFile(prefix, ".$extension", cacheDir) - private fun updateForm(reducer: AnalysisForm.() -> AnalysisForm) { - updateState { copy(form = form.reducer()) } - } + contentResolver.openInputStream(uri).use { input -> + requireNotNull(input) { "Cannot open uri: $uriString" } + target.outputStream().use { output -> input.copyTo(output) } } + + return target +} + +private fun String.toRequestDate(): String = + runCatching { + val (year, month, day) = split("년 ", "월 ", "일") + + LocalDate( + year = year.toInt(), + month = month.toInt(), + day = day.toInt(), + ).toString() + }.getOrDefault(this) diff --git a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AnalysisScreen.kt b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AnalysisScreen.kt index 4d76e3da..d71b9cca 100644 --- a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AnalysisScreen.kt +++ b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AnalysisScreen.kt @@ -80,9 +80,7 @@ private fun AnalysisScreen( onBack = { onIntent(AnalysisFlowUiIntent.Back) }, ) - AnalysisFlowStep.ANALYZING -> AnalysisLoadingScreen( - onFinished = { onIntent(AnalysisFlowUiIntent.Next) }, - ) + AnalysisFlowStep.ANALYZING -> AnalysisLoadingScreen() AnalysisFlowStep.REPORT -> AnalysisReportScreen() diff --git a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/contract/AnalysisFlowUiState.kt b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/contract/AnalysisFlowUiState.kt index 68538ac5..9957c3e1 100644 --- a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/contract/AnalysisFlowUiState.kt +++ b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/contract/AnalysisFlowUiState.kt @@ -3,6 +3,7 @@ package com.team.prezel.feature.analysis.impl.contract import androidx.compose.runtime.Immutable import com.team.prezel.core.model.presentation.Audience import com.team.prezel.core.model.presentation.Category +import com.team.prezel.core.model.presentation.PresentationRecordingAnalysisResult import com.team.prezel.core.model.presentation.Purpose import com.team.prezel.core.model.presentation.Style import com.team.prezel.core.ui.base.UiState @@ -11,6 +12,7 @@ import com.team.prezel.core.ui.base.UiState internal data class AnalysisFlowUiState( val step: AnalysisFlowStep = AnalysisFlowStep.PRESENTATION_SCHEDULE, val form: AnalysisForm = AnalysisForm(), + val analysisResult: PresentationRecordingAnalysisResult? = null, ) : UiState { val progress: Float get() = when (step) { diff --git a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/result/AnalysisLoadingScreen.kt b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/result/AnalysisLoadingScreen.kt index 8da9c611..5d709251 100644 --- a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/result/AnalysisLoadingScreen.kt +++ b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/result/AnalysisLoadingScreen.kt @@ -18,12 +18,14 @@ private const val ANALYSIS_LOADING_DURATION_MILLIS = 2_000L @Composable internal fun AnalysisLoadingScreen( - onFinished: () -> Unit, modifier: Modifier = Modifier, + onFinished: (() -> Unit)? = null, ) { - LaunchedEffect(Unit) { - delay(ANALYSIS_LOADING_DURATION_MILLIS) - onFinished() + if (onFinished != null) { + LaunchedEffect(Unit) { + delay(ANALYSIS_LOADING_DURATION_MILLIS) + onFinished() + } } StatusView( @@ -43,6 +45,6 @@ internal fun AnalysisLoadingScreen( @Composable private fun AnalysisLoadingScreenPreview() { PrezelTheme { - AnalysisLoadingScreen(onFinished = {}) + AnalysisLoadingScreen() } } From c6730a4393f693bbc7227f0deb87d199a4d18824 Mon Sep 17 00:00:00 2001 From: Ham BeomJoon Date: Sun, 24 May 2026 01:36:42 +0900 Subject: [PATCH 08/16] =?UTF-8?q?feat:=20=EB=B0=9C=ED=91=9C=20=EB=B6=84?= =?UTF-8?q?=EC=84=9D=20=EA=B8=B0=EB=8A=A5=EC=9D=98=20=EC=98=A4=EB=A5=98=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC=20=EB=A1=9C=EC=A7=81=20=EA=B0=95=ED=99=94=20?= =?UTF-8?q?=EB=B0=8F=20UI=20=ED=94=BC=EB=93=9C=EB=B0=B1=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `AnalysisFailureHandler`를 추가하여 에러 유형별(인증, 서버, 네트워크 등) 처리 로직 구현 - 분석 실패 시 재시도 또는 스낵바 메시지 표시를 위한 `AnalysisFailureAction` 및 `AnalysisUiMessage` 정의 - `AnalysisFlowViewModel`에서 분석 요청 로직을 `PresentationAnalysisRequest` 데이터 클래스로 캡슐화하고 리팩터링 - 분석 실패 대응 테스트 코드(`AnalysisFailureHandlerTest`) 추가 - `AppErrorExt`에서 `FILE_UPLOAD_FAILED` 에러를 `INVALID_REQUEST`로 매핑하도록 수정 및 관련 테스트 추가 - 에러 메시지 표시를 위한 문자열 리소스 및 `AnalysisFlowUiEffect.ShowMessage` 추가 --- .../prezel/core/data/error/AppErrorExt.kt | 7 +- .../prezel/core/data/error/AppErrorExtTest.kt | 27 ++++ .../analysis/impl/AnalysisFailureHandler.kt | 36 +++++ .../analysis/impl/AnalysisFlowViewModel.kt | 135 ++++++++++++------ .../feature/analysis/impl/AnalysisScreen.kt | 15 ++ .../impl/contract/AnalysisFlowUiEffect.kt | 5 + .../analysis/impl/model/AnalysisUiMessage.kt | 8 ++ .../impl/src/main/res/values/strings.xml | 4 + .../impl/AnalysisFailureHandlerTest.kt | 49 +++++++ 9 files changed, 239 insertions(+), 47 deletions(-) create mode 100644 Prezel/core/data/src/test/java/com/team/prezel/core/data/error/AppErrorExtTest.kt create mode 100644 Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AnalysisFailureHandler.kt create mode 100644 Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/model/AnalysisUiMessage.kt create mode 100644 Prezel/feature/analysis/impl/src/test/java/com/team/prezel/feature/analysis/impl/AnalysisFailureHandlerTest.kt 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 72b731d3..1ca329af 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 @@ -41,16 +41,15 @@ private fun ServerErrorCode.toDomainError(): AppError = ServerErrorCode.INVALID_REQUEST, ServerErrorCode.REQUIRED_TERMS_DISAGREED, ServerErrorCode.FILE_IS_EMPTY, + ServerErrorCode.FILE_UPLOAD_FAILED, -> AppError.INVALID_REQUEST ServerErrorCode.SERVER_ERROR, - ServerErrorCode.FILE_UPLOAD_FAILED, + ServerErrorCode.SENTENCE_NOT_FOUND, ServerErrorCode.VOICE_ANALYSIS_FAILED, -> AppError.SERVER_ERROR - ServerErrorCode.TERMS_NOT_FOUND, - ServerErrorCode.SENTENCE_NOT_FOUND, - -> AppError.NOT_FOUND + ServerErrorCode.TERMS_NOT_FOUND -> AppError.NOT_FOUND ServerErrorCode.DUPLICATE_NICKNAME -> AppError.DUPLICATE diff --git a/Prezel/core/data/src/test/java/com/team/prezel/core/data/error/AppErrorExtTest.kt b/Prezel/core/data/src/test/java/com/team/prezel/core/data/error/AppErrorExtTest.kt new file mode 100644 index 00000000..87e74854 --- /dev/null +++ b/Prezel/core/data/src/test/java/com/team/prezel/core/data/error/AppErrorExtTest.kt @@ -0,0 +1,27 @@ +package com.team.prezel.core.data.error + +import com.team.prezel.core.common.error.AppError +import com.team.prezel.core.common.error.AppException +import com.team.prezel.core.network.model.ApiException +import com.team.prezel.core.network.model.ServerErrorCode +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs + +class AppErrorExtTest { + @Test + fun `지원하지 않는 파일 형식 에러는 잘못된 요청으로 변환한다`() { + val result = Result + .failure( + ApiException( + status = 400, + errorCode = ServerErrorCode.FILE_UPLOAD_FAILED, + message = "지원하지 않는 오디오 파일 형식입니다.", + ), + ).mapDomainFailure() + + val exception = assertIs(result.exceptionOrNull()) + assertEquals(AppError.INVALID_REQUEST, exception.error) + assertEquals("지원하지 않는 오디오 파일 형식입니다.", exception.message) + } +} diff --git a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AnalysisFailureHandler.kt b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AnalysisFailureHandler.kt new file mode 100644 index 00000000..a64bd67f --- /dev/null +++ b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AnalysisFailureHandler.kt @@ -0,0 +1,36 @@ +package com.team.prezel.feature.analysis.impl + +import com.team.prezel.core.common.error.AppError +import com.team.prezel.core.common.error.AppException +import com.team.prezel.feature.analysis.impl.contract.AnalysisUploadType +import com.team.prezel.feature.analysis.impl.model.AnalysisUiMessage + +internal sealed interface AnalysisFailureAction { + data class RetryFileUpload( + val uploadType: AnalysisUploadType, + ) : AnalysisFailureAction + + data class ShowMessage( + val message: AnalysisUiMessage, + ) : AnalysisFailureAction +} + +internal fun Throwable.toAnalysisFailureAction(): AnalysisFailureAction { + val error = (this as? AppException)?.error + + return when (error) { + AppError.INVALID_REQUEST, + AppError.VOICE_RECOGNITION_FAILED, + -> AnalysisFailureAction.RetryFileUpload(uploadType = AnalysisUploadType.AUDIO) + + AppError.UNAUTHORIZED -> AnalysisFailureAction.ShowMessage(message = AnalysisUiMessage.AUTH_EXPIRED) + AppError.SERVER_ERROR -> AnalysisFailureAction.ShowMessage(message = AnalysisUiMessage.ANALYSIS_FAILED) + AppError.NETWORK -> AnalysisFailureAction.ShowMessage(message = AnalysisUiMessage.NETWORK_FAILED) + + AppError.NOT_FOUND, + AppError.DUPLICATE, + AppError.UNKNOWN, + null, + -> AnalysisFailureAction.ShowMessage(message = AnalysisUiMessage.UNKNOWN_FAILED) + } +} diff --git a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AnalysisFlowViewModel.kt b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AnalysisFlowViewModel.kt index 24e2b3c3..dc5e602c 100644 --- a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AnalysisFlowViewModel.kt +++ b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AnalysisFlowViewModel.kt @@ -5,6 +5,11 @@ import android.net.Uri import android.provider.OpenableColumns import androidx.lifecycle.viewModelScope import com.team.prezel.core.domain.usecase.practice.AnalyzePresentationRecordingUseCase +import com.team.prezel.core.model.presentation.Audience +import com.team.prezel.core.model.presentation.Category +import com.team.prezel.core.model.presentation.PresentationRecordingAnalysisResult +import com.team.prezel.core.model.presentation.Purpose +import com.team.prezel.core.model.presentation.Style import com.team.prezel.core.ui.base.BaseViewModel import com.team.prezel.feature.analysis.impl.contract.AnalysisFlowStep import com.team.prezel.feature.analysis.impl.contract.AnalysisFlowUiEffect @@ -79,57 +84,69 @@ internal class AnalysisFlowViewModel @Inject constructor( } private fun analyzePresentation() { - val form = currentState.form - val category = form.category ?: return - val purpose = form.purpose ?: return - val style = form.style ?: return - val audience = form.audience ?: return - val audioFileUri = form.audioFileUri ?: return + val request = currentState.form.toPresentationAnalysisRequestOrNull() ?: return updateState { copy(step = AnalysisFlowStep.ANALYZING) } viewModelScope.launch { - val result = runCatching { - val audioFile = context.copyUriToAnalysisCache( - uriString = audioFileUri, - prefix = "audio", + request + .analyzePresentationRecording() + .onSuccess(::handleAnalysisSuccess) + .onFailure { throwable -> handleAnalysisFailure(throwable.toAnalysisFailureAction()) } + } + } + + private suspend fun PresentationAnalysisRequest.analyzePresentationRecording(): Result = + runCatching { + val audioFile = context.copyUriToAnalysisCache( + uriString = audioFileUri, + prefix = "audio", + ) + val scriptFile = scriptFileUri?.let { uri -> + context.copyUriToAnalysisCache( + uriString = uri, + prefix = "script", ) - val scriptFile = if (form.scriptInputType == ScriptInputType.FILE_UPLOAD) { - form.scriptFileUri?.let { scriptFileUri -> - context.copyUriToAnalysisCache( - uriString = scriptFileUri, - prefix = "script", - ) - } - } else { - null - } + } + + analyzePresentationRecordingUseCase( + name = name, + date = date.toRequestDate(), + category = category, + purpose = purpose, + style = style, + audience = audience, + script = script, + scriptFilePath = scriptFile?.absolutePath, + audioFilePath = audioFile.absolutePath, + ).getOrThrow() + } + + private fun handleAnalysisSuccess(analysisResult: PresentationRecordingAnalysisResult) { + updateState { + copy( + step = AnalysisFlowStep.REPORT, + analysisResult = analysisResult, + ) + } + } - analyzePresentationRecordingUseCase( - name = form.presentationTitle.trim(), - date = form.presentationDate.toRequestDate(), - category = category, - purpose = purpose, - style = style, - audience = audience, - script = form.script.takeIf(String::isNotBlank), - scriptFilePath = scriptFile?.absolutePath, - audioFilePath = audioFile.absolutePath, - ).getOrThrow() + private fun handleAnalysisFailure(action: AnalysisFailureAction) { + when (action) { + is AnalysisFailureAction.RetryFileUpload -> { + updateState { + copy( + step = when (action.uploadType) { + AnalysisUploadType.AUDIO -> AnalysisFlowStep.FILE_RECOGNITION_FAILED + AnalysisUploadType.SCRIPT -> AnalysisFlowStep.SCRIPT_FILE_RECOGNITION_FAILED + }, + ) + } } - updateState { - result.fold( - onSuccess = { analysisResult -> - copy( - step = AnalysisFlowStep.REPORT, - analysisResult = analysisResult, - ) - }, - onFailure = { - copy(step = AnalysisFlowStep.FILE_RECOGNITION_FAILED) - }, - ) + is AnalysisFailureAction.ShowMessage -> { + viewModelScope.launch { sendEffect(AnalysisFlowUiEffect.ShowMessage(action.message)) } + updateState { copy(step = AnalysisFlowStep.AUDIO_UPLOAD) } } } } @@ -224,6 +241,38 @@ private fun Context.copyUriToAnalysisCache( return target } +private data class PresentationAnalysisRequest( + val name: String, + val date: String, + val category: Category, + val purpose: Purpose, + val style: Style, + val audience: Audience, + val script: String?, + val scriptFileUri: String?, + val audioFileUri: String, +) + +private fun AnalysisForm.toPresentationAnalysisRequestOrNull(): PresentationAnalysisRequest? { + val category = category ?: return null + val purpose = purpose ?: return null + val style = style ?: return null + val audience = audience ?: return null + val audioFileUri = audioFileUri ?: return null + + return PresentationAnalysisRequest( + name = presentationTitle.trim(), + date = presentationDate, + category = category, + purpose = purpose, + style = style, + audience = audience, + script = script.takeIf(String::isNotBlank), + scriptFileUri = scriptFileUri.takeIf { scriptInputType == ScriptInputType.FILE_UPLOAD }, + audioFileUri = audioFileUri, + ) +} + private fun String.toRequestDate(): String = runCatching { val (year, month, day) = split("년 ", "월 ", "일") diff --git a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AnalysisScreen.kt b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AnalysisScreen.kt index d71b9cca..a04522b9 100644 --- a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AnalysisScreen.kt +++ b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AnalysisScreen.kt @@ -2,14 +2,18 @@ package com.team.prezel.feature.analysis.impl import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.platform.LocalResources import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.team.prezel.core.designsystem.component.feedback.snackbar.showPrezelSnackbar +import com.team.prezel.core.ui.state.LocalSnackbarHostState import com.team.prezel.feature.analysis.impl.audio.AudioUploadScreen import com.team.prezel.feature.analysis.impl.contract.AnalysisFlowStep import com.team.prezel.feature.analysis.impl.contract.AnalysisFlowUiEffect import com.team.prezel.feature.analysis.impl.contract.AnalysisFlowUiIntent import com.team.prezel.feature.analysis.impl.contract.AnalysisFlowUiState import com.team.prezel.feature.analysis.impl.contract.AnalysisUploadType +import com.team.prezel.feature.analysis.impl.model.AnalysisUiMessage import com.team.prezel.feature.analysis.impl.result.AnalysisLoadingScreen import com.team.prezel.feature.analysis.impl.result.AnalysisReportScreen import com.team.prezel.feature.analysis.impl.result.FileRecognitionFailedScreen @@ -24,11 +28,22 @@ internal fun AnalysisScreen( viewModel: AnalysisFlowViewModel = hiltViewModel(), ) { val uiState = viewModel.uiState.collectAsStateWithLifecycle().value + val resources = LocalResources.current + val snackbarHostState = LocalSnackbarHostState.current LaunchedEffect(Unit) { viewModel.uiEffect.collect { effect -> when (effect) { AnalysisFlowUiEffect.NavigateBack -> onBack() + is AnalysisFlowUiEffect.ShowMessage -> { + val resId = when (effect.message) { + AnalysisUiMessage.AUTH_EXPIRED -> R.string.feature_analysis_impl_error_auth_expired + AnalysisUiMessage.ANALYSIS_FAILED -> R.string.feature_analysis_impl_error_analysis_failed + AnalysisUiMessage.NETWORK_FAILED -> R.string.feature_analysis_impl_error_network_failed + AnalysisUiMessage.UNKNOWN_FAILED -> R.string.feature_analysis_impl_error_unknown_failed + } + snackbarHostState.showPrezelSnackbar(message = resources.getString(resId)) + } } } } diff --git a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/contract/AnalysisFlowUiEffect.kt b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/contract/AnalysisFlowUiEffect.kt index e926366a..6ea86141 100644 --- a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/contract/AnalysisFlowUiEffect.kt +++ b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/contract/AnalysisFlowUiEffect.kt @@ -1,7 +1,12 @@ package com.team.prezel.feature.analysis.impl.contract import com.team.prezel.core.ui.base.UiEffect +import com.team.prezel.feature.analysis.impl.model.AnalysisUiMessage internal sealed interface AnalysisFlowUiEffect : UiEffect { data object NavigateBack : AnalysisFlowUiEffect + + data class ShowMessage( + val message: AnalysisUiMessage, + ) : AnalysisFlowUiEffect } diff --git a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/model/AnalysisUiMessage.kt b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/model/AnalysisUiMessage.kt new file mode 100644 index 00000000..6202fea0 --- /dev/null +++ b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/model/AnalysisUiMessage.kt @@ -0,0 +1,8 @@ +package com.team.prezel.feature.analysis.impl.model + +internal enum class AnalysisUiMessage { + AUTH_EXPIRED, + ANALYSIS_FAILED, + NETWORK_FAILED, + UNKNOWN_FAILED, +} diff --git a/Prezel/feature/analysis/impl/src/main/res/values/strings.xml b/Prezel/feature/analysis/impl/src/main/res/values/strings.xml index fae79aa8..e6570fdc 100644 --- a/Prezel/feature/analysis/impl/src/main/res/values/strings.xml +++ b/Prezel/feature/analysis/impl/src/main/res/values/strings.xml @@ -65,6 +65,10 @@ 다른 음성 파일로 다시 시도해 주세요. 분석할 수 있는 텍스트 파일을 찾지 못했어요. 다른 텍스트 파일로 다시 시도해 주세요. + 로그인이 만료되었어요. 다시 로그인해 주세요. + 분석 중 오류가 발생했어요. 잠시 후 다시 시도해 주세요. + 네트워크 연결을 확인해 주세요. + 알 수 없는 오류가 발생했어요. 뒤로가기 다음 건너뛰기 diff --git a/Prezel/feature/analysis/impl/src/test/java/com/team/prezel/feature/analysis/impl/AnalysisFailureHandlerTest.kt b/Prezel/feature/analysis/impl/src/test/java/com/team/prezel/feature/analysis/impl/AnalysisFailureHandlerTest.kt new file mode 100644 index 00000000..ea305ae4 --- /dev/null +++ b/Prezel/feature/analysis/impl/src/test/java/com/team/prezel/feature/analysis/impl/AnalysisFailureHandlerTest.kt @@ -0,0 +1,49 @@ +package com.team.prezel.feature.analysis.impl + +import com.team.prezel.core.common.error.AppError +import com.team.prezel.core.common.error.AppException +import com.team.prezel.feature.analysis.impl.contract.AnalysisUploadType +import com.team.prezel.feature.analysis.impl.model.AnalysisUiMessage +import kotlin.test.Test +import kotlin.test.assertEquals + +class AnalysisFailureHandlerTest { + @Test + fun `잘못된 요청은 음성 파일 재업로드 화면으로 처리한다`() { + val action = AppException( + error = AppError.INVALID_REQUEST, + message = "지원하지 않는 오디오 파일 형식입니다.", + ).toAnalysisFailureAction() + + assertEquals( + AnalysisFailureAction.RetryFileUpload(uploadType = AnalysisUploadType.AUDIO), + action, + ) + } + + @Test + fun `인증 에러는 인증 만료 메시지로 처리한다`() { + val action = AppException( + error = AppError.UNAUTHORIZED, + message = "인증 자격 증명이 유효하지 않습니다.", + ).toAnalysisFailureAction() + + assertEquals( + AnalysisFailureAction.ShowMessage(message = AnalysisUiMessage.AUTH_EXPIRED), + action, + ) + } + + @Test + fun `서버 에러는 분석 실패 메시지로 처리한다`() { + val action = AppException( + error = AppError.SERVER_ERROR, + message = "AI 분석 서버와의 통신 중 오류가 발생했습니다.", + ).toAnalysisFailureAction() + + assertEquals( + AnalysisFailureAction.ShowMessage(message = AnalysisUiMessage.ANALYSIS_FAILED), + action, + ) + } +} From 1fd8c754ec5ed2ef5335797f26e7ea75250469de Mon Sep 17 00:00:00 2001 From: Ham BeomJoon Date: Sun, 24 May 2026 01:57:08 +0900 Subject: [PATCH 09/16] =?UTF-8?q?feat:=20=EB=B6=84=EC=84=9D=EC=9A=A9=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=EC=BA=90=EC=8B=9C=20=EA=B4=80=EB=A6=AC=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EC=B6=94=EC=B6=9C=20=EB=B0=8F=20=EC=B6=94?= =?UTF-8?q?=EC=83=81=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `AnalysisFileCache` 인터페이스 및 `AnalysisFileCacheImpl` 구현체 추가 - `AnalysisFlowViewModel` 내부에 존재하던 파일 복사 로직을 `AnalysisFileCache`로 분리 - Content Uri로부터 임시 파일을 생성하고 앱 캐시 디렉토리로 복사하는 기능 모듈화 - `PresentationAnalysisRequest`를 `PresentationAnalysisSubmission`으로 네이밍 변경 - Hilt를 사용하여 `AnalysisFileCache` 의존성 주입 설정 추가 (`AnalysisModule`) --- .../analysis/impl/AnalysisFlowViewModel.kt | 56 ++++--------------- .../analysis/impl/cache/AnalysisFileCache.kt | 21 +++++++ .../impl/cache/AnalysisFileCacheImpl.kt | 53 ++++++++++++++++++ .../analysis/impl/di/AnalysisModule.kt | 15 +++++ 4 files changed, 99 insertions(+), 46 deletions(-) create mode 100644 Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/cache/AnalysisFileCache.kt create mode 100644 Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/cache/AnalysisFileCacheImpl.kt create mode 100644 Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/di/AnalysisModule.kt diff --git a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AnalysisFlowViewModel.kt b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AnalysisFlowViewModel.kt index dc5e602c..470f556b 100644 --- a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AnalysisFlowViewModel.kt +++ b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AnalysisFlowViewModel.kt @@ -1,8 +1,5 @@ package com.team.prezel.feature.analysis.impl -import android.content.Context -import android.net.Uri -import android.provider.OpenableColumns import androidx.lifecycle.viewModelScope import com.team.prezel.core.domain.usecase.practice.AnalyzePresentationRecordingUseCase import com.team.prezel.core.model.presentation.Audience @@ -11,6 +8,7 @@ import com.team.prezel.core.model.presentation.PresentationRecordingAnalysisResu import com.team.prezel.core.model.presentation.Purpose import com.team.prezel.core.model.presentation.Style import com.team.prezel.core.ui.base.BaseViewModel +import com.team.prezel.feature.analysis.impl.cache.AnalysisFileCache import com.team.prezel.feature.analysis.impl.contract.AnalysisFlowStep import com.team.prezel.feature.analysis.impl.contract.AnalysisFlowUiEffect import com.team.prezel.feature.analysis.impl.contract.AnalysisFlowUiIntent @@ -20,16 +18,14 @@ import com.team.prezel.feature.analysis.impl.contract.AnalysisSituationOption import com.team.prezel.feature.analysis.impl.contract.AnalysisUploadType import com.team.prezel.feature.analysis.impl.contract.ScriptInputType import dagger.hilt.android.lifecycle.HiltViewModel -import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.launch import kotlinx.datetime.LocalDate -import java.io.File import javax.inject.Inject @HiltViewModel internal class AnalysisFlowViewModel @Inject constructor( private val analyzePresentationRecordingUseCase: AnalyzePresentationRecordingUseCase, - @param:ApplicationContext private val context: Context, + private val analysisFileCache: AnalysisFileCache, ) : BaseViewModel(AnalysisFlowUiState()) { override fun onIntent(intent: AnalysisFlowUiIntent) { when (intent) { @@ -84,26 +80,26 @@ internal class AnalysisFlowViewModel @Inject constructor( } private fun analyzePresentation() { - val request = currentState.form.toPresentationAnalysisRequestOrNull() ?: return + val submission = currentState.form.toPresentationAnalysisSubmissionOrNull() ?: return updateState { copy(step = AnalysisFlowStep.ANALYZING) } viewModelScope.launch { - request + submission .analyzePresentationRecording() .onSuccess(::handleAnalysisSuccess) .onFailure { throwable -> handleAnalysisFailure(throwable.toAnalysisFailureAction()) } } } - private suspend fun PresentationAnalysisRequest.analyzePresentationRecording(): Result = + private suspend fun PresentationAnalysisSubmission.analyzePresentationRecording(): Result = runCatching { - val audioFile = context.copyUriToAnalysisCache( + val audioFile = analysisFileCache.copyUriToCache( uriString = audioFileUri, prefix = "audio", ) val scriptFile = scriptFileUri?.let { uri -> - context.copyUriToAnalysisCache( + analysisFileCache.copyUriToCache( uriString = uri, prefix = "script", ) @@ -209,39 +205,7 @@ internal class AnalysisFlowViewModel @Inject constructor( } } -private fun Context.copyUriToAnalysisCache( - uriString: String, - prefix: String, -): File { - val uri = Uri.parse(uriString) - val displayName = contentResolver - .query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null) - ?.use { cursor -> - val displayNameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) - if (displayNameIndex != -1 && cursor.moveToFirst()) { - cursor.getString(displayNameIndex) - } else { - null - } - } - val extension = displayName - ?.substringAfterLast('.', missingDelimiterValue = "") - ?.takeIf(String::isNotBlank) - ?: uri.lastPathSegment - ?.substringAfterLast('.', missingDelimiterValue = "") - ?.takeIf(String::isNotBlank) - ?: "tmp" - val target = File.createTempFile(prefix, ".$extension", cacheDir) - - contentResolver.openInputStream(uri).use { input -> - requireNotNull(input) { "Cannot open uri: $uriString" } - target.outputStream().use { output -> input.copyTo(output) } - } - - return target -} - -private data class PresentationAnalysisRequest( +private data class PresentationAnalysisSubmission( val name: String, val date: String, val category: Category, @@ -253,14 +217,14 @@ private data class PresentationAnalysisRequest( val audioFileUri: String, ) -private fun AnalysisForm.toPresentationAnalysisRequestOrNull(): PresentationAnalysisRequest? { +private fun AnalysisForm.toPresentationAnalysisSubmissionOrNull(): PresentationAnalysisSubmission? { val category = category ?: return null val purpose = purpose ?: return null val style = style ?: return null val audience = audience ?: return null val audioFileUri = audioFileUri ?: return null - return PresentationAnalysisRequest( + return PresentationAnalysisSubmission( name = presentationTitle.trim(), date = presentationDate, category = category, diff --git a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/cache/AnalysisFileCache.kt b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/cache/AnalysisFileCache.kt new file mode 100644 index 00000000..3c82b202 --- /dev/null +++ b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/cache/AnalysisFileCache.kt @@ -0,0 +1,21 @@ +package com.team.prezel.feature.analysis.impl.cache + +import java.io.File + +/** + * 발표 분석에 업로드할 파일 Uri를 앱 내부 캐시 파일로 복사한다. + * + * 파일 피커에서 받은 content Uri는 네트워크 multipart 업로드에서 바로 File로 다루기 어렵기 때문에, + * ViewModel은 이 인터페이스를 통해 Android 파일 접근 세부사항을 숨기고 캐시된 File만 전달받는다. + */ +internal interface AnalysisFileCache { + /** + * [uriString]이 가리키는 파일 내용을 cacheDir의 임시 파일로 복사하고, 생성된 File을 반환한다. + * + * [prefix]는 임시 파일 이름 구분용으로 사용된다. 예: audio, script. + */ + fun copyUriToCache( + uriString: String, + prefix: String, + ): File +} diff --git a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/cache/AnalysisFileCacheImpl.kt b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/cache/AnalysisFileCacheImpl.kt new file mode 100644 index 00000000..1c5fe795 --- /dev/null +++ b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/cache/AnalysisFileCacheImpl.kt @@ -0,0 +1,53 @@ +package com.team.prezel.feature.analysis.impl.cache + +import android.content.Context +import android.provider.OpenableColumns +import androidx.core.net.toUri +import dagger.hilt.android.qualifiers.ApplicationContext +import java.io.File +import javax.inject.Inject + +/** + * Android ContentResolver를 사용해 content Uri의 원본 파일을 앱 cacheDir에 임시 파일로 복사한다. + * + * 원본 파일명을 조회할 수 있으면 확장자를 유지하고, 조회할 수 없는 경우 Uri path 또는 기본 확장자를 사용한다. + */ +internal class AnalysisFileCacheImpl @Inject constructor( + @param:ApplicationContext private val context: Context, +) : AnalysisFileCache { + override fun copyUriToCache( + uriString: String, + prefix: String, + ): File { + val uri = uriString.toUri() + val displayName = context.contentResolver + .query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null) + ?.use { cursor -> + val displayNameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) + if (displayNameIndex != -1 && cursor.moveToFirst()) { + cursor.getString(displayNameIndex) + } else { + null + } + } + val extension = displayName + ?.substringAfterLast('.', missingDelimiterValue = "") + ?.takeIf(String::isNotBlank) + ?: uri.lastPathSegment + ?.substringAfterLast('.', missingDelimiterValue = "") + ?.takeIf(String::isNotBlank) + ?: DEFAULT_EXTENSION + val target = File.createTempFile(prefix, ".$extension", context.cacheDir) + + context.contentResolver.openInputStream(uri).use { input -> + requireNotNull(input) { "Cannot open uri: $uriString" } + target.outputStream().use { output -> input.copyTo(output) } + } + + return target + } + + private companion object { + const val DEFAULT_EXTENSION = "tmp" + } +} diff --git a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/di/AnalysisModule.kt b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/di/AnalysisModule.kt new file mode 100644 index 00000000..c7320d63 --- /dev/null +++ b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/di/AnalysisModule.kt @@ -0,0 +1,15 @@ +package com.team.prezel.feature.analysis.impl.di + +import com.team.prezel.feature.analysis.impl.cache.AnalysisFileCache +import com.team.prezel.feature.analysis.impl.cache.AnalysisFileCacheImpl +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ViewModelComponent + +@Module +@InstallIn(ViewModelComponent::class) +internal interface AnalysisModule { + @Binds + fun bindAnalysisFileCache(impl: AnalysisFileCacheImpl): AnalysisFileCache +} From b5d86023eb80e4e42a99f4801324e7a071fba703 Mon Sep 17 00:00:00 2001 From: Ham BeomJoon Date: Sun, 24 May 2026 02:13:03 +0900 Subject: [PATCH 10/16] =?UTF-8?q?fix:=20=EC=8A=A4=ED=81=AC=EB=A6=BD?= =?UTF-8?q?=ED=8A=B8=20=ED=8C=8C=EC=9D=BC=20=EC=9D=B8=EC=8B=9D=20=EC=98=A4?= =?UTF-8?q?=EB=A5=98=20=EC=B2=98=EB=A6=AC=20=EB=B0=8F=20=ED=8C=8C=EC=9D=BC?= =?UTF-8?q?=20=EC=BA=90=EC=8B=9C=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 - `PracticeRemoteDataSourceImpl`에서 `scriptFilePath`가 빈 문자열인 경우 파일로 처리하지 않도록 개선 - `AnalysisFileCacheImpl`에서 파일 복사 중 예외 발생 시 생성 중이던 임시 파일을 삭제하도록 수정 - 서버의 `FILE_IS_EMPTY` 에러를 도메인의 `SCRIPT_FILE_RECOGNITION_FAILED` 에러로 매핑하는 로직 추가 - `AnalysisFailureHandler`에 스크립트 인식 실패 시 재업로드를 유도하는 액션 추가 - 스크립트 파일 인식 실패 및 에러 변환 로직에 대한 단위 테스트 추가 --- .../team/prezel/core/common/error/AppError.kt | 1 + .../team/prezel/core/data/error/AppErrorExt.kt | 3 ++- .../prezel/core/data/error/AppErrorExtTest.kt | 16 ++++++++++++++++ .../datasource/PracticeRemoteDataSourceImpl.kt | 4 +++- .../analysis/impl/AnalysisFailureHandler.kt | 2 ++ .../analysis/impl/cache/AnalysisFileCacheImpl.kt | 15 +++++++++------ .../analysis/impl/AnalysisFailureHandlerTest.kt | 13 +++++++++++++ .../prezel/feature/terms/impl/TermsViewModel.kt | 1 + 8 files changed, 47 insertions(+), 8 deletions(-) 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 197bfbac..d34b8651 100644 --- a/Prezel/core/common/src/main/kotlin/com/team/prezel/core/common/error/AppError.kt +++ b/Prezel/core/common/src/main/kotlin/com/team/prezel/core/common/error/AppError.kt @@ -7,6 +7,7 @@ enum class AppError { NOT_FOUND, DUPLICATE, VOICE_RECOGNITION_FAILED, + SCRIPT_FILE_RECOGNITION_FAILED, NETWORK, UNKNOWN, } diff --git a/Prezel/core/data/src/main/java/com/team/prezel/core/data/error/AppErrorExt.kt b/Prezel/core/data/src/main/java/com/team/prezel/core/data/error/AppErrorExt.kt index 1ca329af..fee9a799 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 @@ -40,10 +40,11 @@ private fun ServerErrorCode.toDomainError(): AppError = when (this) { ServerErrorCode.INVALID_REQUEST, ServerErrorCode.REQUIRED_TERMS_DISAGREED, - ServerErrorCode.FILE_IS_EMPTY, ServerErrorCode.FILE_UPLOAD_FAILED, -> AppError.INVALID_REQUEST + ServerErrorCode.FILE_IS_EMPTY -> AppError.SCRIPT_FILE_RECOGNITION_FAILED + ServerErrorCode.SERVER_ERROR, ServerErrorCode.SENTENCE_NOT_FOUND, ServerErrorCode.VOICE_ANALYSIS_FAILED, diff --git a/Prezel/core/data/src/test/java/com/team/prezel/core/data/error/AppErrorExtTest.kt b/Prezel/core/data/src/test/java/com/team/prezel/core/data/error/AppErrorExtTest.kt index 87e74854..163d31b7 100644 --- a/Prezel/core/data/src/test/java/com/team/prezel/core/data/error/AppErrorExtTest.kt +++ b/Prezel/core/data/src/test/java/com/team/prezel/core/data/error/AppErrorExtTest.kt @@ -24,4 +24,20 @@ class AppErrorExtTest { assertEquals(AppError.INVALID_REQUEST, exception.error) assertEquals("지원하지 않는 오디오 파일 형식입니다.", exception.message) } + + @Test + fun `빈 파일 에러는 스크립트 파일 인식 실패로 변환한다`() { + val result = Result + .failure( + ApiException( + status = 400, + errorCode = ServerErrorCode.FILE_IS_EMPTY, + message = "분석할 수 있는 파일 내용이 없습니다.", + ), + ).mapDomainFailure() + + val exception = assertIs(result.exceptionOrNull()) + assertEquals(AppError.SCRIPT_FILE_RECOGNITION_FAILED, exception.error) + assertEquals("분석할 수 있는 파일 내용이 없습니다.", exception.message) + } } 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 index c3a29f46..26ad0cfe 100644 --- 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 @@ -58,7 +58,9 @@ internal class PracticeRemoteDataSourceImpl @Inject constructor( audioFilePath: String, ): PresentationRecordingAnalysisResponse { val audioFile = File(audioFilePath) - val scriptFile = scriptFilePath?.let(::File) + val scriptFile = scriptFilePath + ?.takeIf(String::isNotBlank) + ?.let(::File) val multipart = MultiPartFormDataContent( formData { append("name", name) diff --git a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AnalysisFailureHandler.kt b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AnalysisFailureHandler.kt index a64bd67f..1bda155d 100644 --- a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AnalysisFailureHandler.kt +++ b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AnalysisFailureHandler.kt @@ -23,6 +23,8 @@ internal fun Throwable.toAnalysisFailureAction(): AnalysisFailureAction { AppError.VOICE_RECOGNITION_FAILED, -> AnalysisFailureAction.RetryFileUpload(uploadType = AnalysisUploadType.AUDIO) + AppError.SCRIPT_FILE_RECOGNITION_FAILED -> AnalysisFailureAction.RetryFileUpload(uploadType = AnalysisUploadType.SCRIPT) + AppError.UNAUTHORIZED -> AnalysisFailureAction.ShowMessage(message = AnalysisUiMessage.AUTH_EXPIRED) AppError.SERVER_ERROR -> AnalysisFailureAction.ShowMessage(message = AnalysisUiMessage.ANALYSIS_FAILED) AppError.NETWORK -> AnalysisFailureAction.ShowMessage(message = AnalysisUiMessage.NETWORK_FAILED) diff --git a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/cache/AnalysisFileCacheImpl.kt b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/cache/AnalysisFileCacheImpl.kt index 1c5fe795..4d096445 100644 --- a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/cache/AnalysisFileCacheImpl.kt +++ b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/cache/AnalysisFileCacheImpl.kt @@ -38,13 +38,16 @@ internal class AnalysisFileCacheImpl @Inject constructor( ?.takeIf(String::isNotBlank) ?: DEFAULT_EXTENSION val target = File.createTempFile(prefix, ".$extension", context.cacheDir) - - context.contentResolver.openInputStream(uri).use { input -> - requireNotNull(input) { "Cannot open uri: $uriString" } - target.outputStream().use { output -> input.copyTo(output) } + return runCatching { + context.contentResolver.openInputStream(uri).use { input -> + requireNotNull(input) { "Cannot open uri: $uriString" } + target.outputStream().use { output -> input.copyTo(output) } + } + target + }.getOrElse { throwable -> + target.delete() + throw throwable } - - return target } private companion object { diff --git a/Prezel/feature/analysis/impl/src/test/java/com/team/prezel/feature/analysis/impl/AnalysisFailureHandlerTest.kt b/Prezel/feature/analysis/impl/src/test/java/com/team/prezel/feature/analysis/impl/AnalysisFailureHandlerTest.kt index ea305ae4..3e21ae04 100644 --- a/Prezel/feature/analysis/impl/src/test/java/com/team/prezel/feature/analysis/impl/AnalysisFailureHandlerTest.kt +++ b/Prezel/feature/analysis/impl/src/test/java/com/team/prezel/feature/analysis/impl/AnalysisFailureHandlerTest.kt @@ -21,6 +21,19 @@ class AnalysisFailureHandlerTest { ) } + @Test + fun `스크립트 파일 인식 실패는 스크립트 파일 재업로드 화면으로 처리한다`() { + val action = AppException( + error = AppError.SCRIPT_FILE_RECOGNITION_FAILED, + message = "분석할 수 있는 텍스트 파일을 찾지 못했습니다.", + ).toAnalysisFailureAction() + + assertEquals( + AnalysisFailureAction.RetryFileUpload(uploadType = AnalysisUploadType.SCRIPT), + action, + ) + } + @Test fun `인증 에러는 인증 만료 메시지로 처리한다`() { val action = AppException( 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 fd8db9fa..40ded8ab 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 @@ -91,6 +91,7 @@ internal class TermsViewModel @Inject constructor( AppError.NOT_FOUND, AppError.DUPLICATE, AppError.VOICE_RECOGNITION_FAILED, + AppError.SCRIPT_FILE_RECOGNITION_FAILED, AppError.UNKNOWN, null, -> TermsUiMessage.AGREE_TERMS_FAILED_UNKNOWN From 3e9feb90c710bff6fae9c84552efa803ec337dc6 Mon Sep 17 00:00:00 2001 From: Ham BeomJoon Date: Sun, 24 May 2026 02:15:15 +0900 Subject: [PATCH 11/16] =?UTF-8?q?refactor:=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EC=97=85=EB=A1=9C=EB=93=9C=20=EB=B0=A9=EC=8B=9D=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0=20=EB=B0=8F=20=EB=B6=84=EC=84=9D=20=ED=94=8C=EB=A1=9C?= =?UTF-8?q?=EC=9A=B0=20=EC=83=81=ED=83=9C=20=EC=A0=84=EC=9D=B4=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 대용량 파일 업로드 시 메모리 효율을 위해 `readBytes()` 대신 `ChannelProvider`를 사용하도록 변경 - `PracticeRemoteDataSourceImpl`에서 파일 경로에 대한 유효성 검사(`require`) 추가 - `AnalysisFlowViewModel`에서 분석 중(`ANALYZING`) 단계의 불필요한 이동 로직 제거 및 상태 전이 방식 개선 - 파일 인식 실패 또는 리포트 단계에서 잘못된 단계로 전환되지 않도록 방어 로직 적용 --- .../datasource/PracticeRemoteDataSourceImpl.kt | 17 ++++++++++++++--- .../analysis/impl/AnalysisFlowViewModel.kt | 8 ++++---- 2 files changed, 18 insertions(+), 7 deletions(-) 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 index 26ad0cfe..1c2a3b11 100644 --- 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 @@ -9,10 +9,12 @@ import com.team.prezel.core.network.model.practice.PresentationAnalysisType import com.team.prezel.core.network.model.practice.PresentationRecordingAnalysisResponse import com.team.prezel.core.network.model.requireData import com.team.prezel.core.network.service.PracticeService +import io.ktor.client.request.forms.ChannelProvider 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 io.ktor.utils.io.jvm.javaio.toByteReadChannel import java.io.File import javax.inject.Inject @@ -25,12 +27,14 @@ internal class PracticeRemoteDataSourceImpl @Inject constructor( recordingFilePath: String, referenceText: String, ): AnalyzePracticeRecordingResponse { + require(recordingFilePath.isNotBlank()) { "녹음 파일 경로가 비어 있습니다." } + val audioFile = File(recordingFilePath) val multipart = MultiPartFormDataContent( formData { append( key = "audio", - value = audioFile.readBytes(), + value = audioFile.toChannelProvider(), headers = Headers.build { append(HttpHeaders.ContentType, "audio/${audioFile.extension}") append(HttpHeaders.ContentDisposition, "filename=\"${audioFile.name}\"") @@ -57,6 +61,8 @@ internal class PracticeRemoteDataSourceImpl @Inject constructor( scriptFilePath: String?, audioFilePath: String, ): PresentationRecordingAnalysisResponse { + require(audioFilePath.isNotBlank()) { "발표 음성 파일 경로가 비어 있습니다." } + val audioFile = File(audioFilePath) val scriptFile = scriptFilePath ?.takeIf(String::isNotBlank) @@ -73,7 +79,7 @@ internal class PracticeRemoteDataSourceImpl @Inject constructor( scriptFile?.let { file -> append( key = "scriptFile", - value = file.readBytes(), + value = file.toChannelProvider(), headers = Headers.build { append(HttpHeaders.ContentType, "text/${file.extension}") append(HttpHeaders.ContentDisposition, "filename=\"${file.name}\"") @@ -82,7 +88,7 @@ internal class PracticeRemoteDataSourceImpl @Inject constructor( } append( key = "audio", - value = audioFile.readBytes(), + value = audioFile.toChannelProvider(), headers = Headers.build { append(HttpHeaders.ContentType, "audio/${audioFile.extension}") append(HttpHeaders.ContentDisposition, "filename=\"${audioFile.name}\"") @@ -96,3 +102,8 @@ internal class PracticeRemoteDataSourceImpl @Inject constructor( .requireData() } } + +private fun File.toChannelProvider(): ChannelProvider = + ChannelProvider(size = length()) { + inputStream().toByteReadChannel() + } diff --git a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AnalysisFlowViewModel.kt b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AnalysisFlowViewModel.kt index 470f556b..31ff945a 100644 --- a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AnalysisFlowViewModel.kt +++ b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AnalysisFlowViewModel.kt @@ -55,7 +55,7 @@ internal class AnalysisFlowViewModel @Inject constructor( } private fun moveNext() { - if (!currentState.canMoveNext && currentState.step != AnalysisFlowStep.ANALYZING) return + if (!currentState.canMoveNext) return if (currentState.step == AnalysisFlowStep.AUDIO_UPLOAD) { analyzePresentation() @@ -68,12 +68,12 @@ internal class AnalysisFlowViewModel @Inject constructor( AnalysisFlowStep.PRESENTATION_SCHEDULE -> AnalysisFlowStep.PRESENTATION_SITUATION AnalysisFlowStep.PRESENTATION_SITUATION -> AnalysisFlowStep.SCRIPT_INPUT AnalysisFlowStep.SCRIPT_INPUT -> AnalysisFlowStep.AUDIO_UPLOAD - AnalysisFlowStep.AUDIO_UPLOAD -> AnalysisFlowStep.ANALYZING - AnalysisFlowStep.ANALYZING -> AnalysisFlowStep.REPORT + AnalysisFlowStep.AUDIO_UPLOAD, + AnalysisFlowStep.ANALYZING, AnalysisFlowStep.REPORT, AnalysisFlowStep.FILE_RECOGNITION_FAILED, AnalysisFlowStep.SCRIPT_FILE_RECOGNITION_FAILED, - -> AnalysisFlowStep.REPORT + -> step }, ) } From b8fc011bb482b21e81e2223387962262ab5df20d Mon Sep 17 00:00:00 2001 From: Ham BeomJoon Date: Sun, 24 May 2026 17:55:25 +0900 Subject: [PATCH 12/16] =?UTF-8?q?fix:=20=EB=B6=84=EC=84=9D=20=EB=8B=A8?= =?UTF-8?q?=EA=B3=84=20=EC=9C=A0=ED=9A=A8=EC=84=B1=20=EA=B2=80=EC=A6=9D=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0=20=EB=B0=8F=20=EC=97=90=EB=9F=AC=20=EB=A7=A4?= =?UTF-8?q?=ED=95=91=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `AnalysisFlowUiState`: `scriptFileUri` 및 `audioFileUri` 유효성 검사 시 `isNullOrBlank()`를 사용하여 빈 값 체크 강화 - `AppErrorExtTest`: `VOICE_ANALYSIS_FAILED` 에러가 `AppError.SERVER_ERROR`로 올바르게 매핑되는지 확인하는 테스트 추가 --- .../prezel/core/data/error/AppErrorExtTest.kt | 16 ++++++++++++++++ .../impl/contract/AnalysisFlowUiState.kt | 5 +++-- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/Prezel/core/data/src/test/java/com/team/prezel/core/data/error/AppErrorExtTest.kt b/Prezel/core/data/src/test/java/com/team/prezel/core/data/error/AppErrorExtTest.kt index 163d31b7..b6bd6cd8 100644 --- a/Prezel/core/data/src/test/java/com/team/prezel/core/data/error/AppErrorExtTest.kt +++ b/Prezel/core/data/src/test/java/com/team/prezel/core/data/error/AppErrorExtTest.kt @@ -40,4 +40,20 @@ class AppErrorExtTest { assertEquals(AppError.SCRIPT_FILE_RECOGNITION_FAILED, exception.error) assertEquals("분석할 수 있는 파일 내용이 없습니다.", exception.message) } + + @Test + fun `AI 엔진 분석 실패 에러는 서버 에러로 변환한다`() { + val result = Result + .failure( + ApiException( + status = 500, + errorCode = ServerErrorCode.VOICE_ANALYSIS_FAILED, + message = "분석 중 문제가 발생했어요.", + ), + ).mapDomainFailure() + + val exception = assertIs(result.exceptionOrNull()) + assertEquals(AppError.SERVER_ERROR, exception.error) + assertEquals("분석 중 문제가 발생했어요.", exception.message) + } } diff --git a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/contract/AnalysisFlowUiState.kt b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/contract/AnalysisFlowUiState.kt index 9957c3e1..737ccb0a 100644 --- a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/contract/AnalysisFlowUiState.kt +++ b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/contract/AnalysisFlowUiState.kt @@ -33,16 +33,17 @@ internal data class AnalysisFlowUiState( get() = when (step) { AnalysisFlowStep.PRESENTATION_SCHEDULE -> form.presentationTitle.trim().length >= 2 && form.presentationDate.isNotBlank() + AnalysisFlowStep.PRESENTATION_SITUATION -> { form.category != null && form.purpose != null && form.style != null && form.audience != null } AnalysisFlowStep.SCRIPT_INPUT -> when (form.scriptInputType) { - ScriptInputType.FILE_UPLOAD -> form.scriptFileUri != null + ScriptInputType.FILE_UPLOAD -> !form.scriptFileUri.isNullOrBlank() ScriptInputType.DIRECT_INPUT -> form.script.isNotBlank() } - AnalysisFlowStep.AUDIO_UPLOAD -> form.audioFileUri != null + AnalysisFlowStep.AUDIO_UPLOAD -> !form.audioFileUri.isNullOrBlank() AnalysisFlowStep.ANALYZING, AnalysisFlowStep.REPORT, AnalysisFlowStep.FILE_RECOGNITION_FAILED, From cceee72dc5f899759e3aeab95f180719e2eafc69 Mon Sep 17 00:00:00 2001 From: Ham BeomJoon Date: Sun, 24 May 2026 19:14:42 +0900 Subject: [PATCH 13/16] =?UTF-8?q?fix:=20=EB=B6=84=EC=84=9D=20=EB=8B=A8?= =?UTF-8?q?=EA=B3=84=20=EC=9C=A0=ED=9A=A8=EC=84=B1=20=EA=B2=80=EC=A6=9D=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0=20=EB=B0=8F=20=EC=97=90=EB=9F=AC=20=EB=A7=A4?= =?UTF-8?q?=ED=95=91=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `AnalysisFlowUiState`: `scriptFileUri` 및 `audioFileUri` 유효성 검사 시 `isNullOrBlank()`를 사용하여 빈 값 체크 강화 - `AppErrorExtTest`: `VOICE_ANALYSIS_FAILED` 에러가 `AppError.SERVER_ERROR`로 올바르게 매핑되는지 확인하는 테스트 추가 --- Prezel/app/build.gradle.kts | 3 +- .../prezel/core/data/mapper/PracticeMapper.kt | 96 +++++++++ .../data/repository/PracticeRepositoryImpl.kt | 116 +--------- .../prezel/core/data/error/AppErrorExtTest.kt | 59 ----- .../practice/PracticeRepositoryImplTest.kt | 203 ------------------ .../core/model/presentation/Audience.kt | 6 +- .../core/model/presentation/Category.kt | 4 +- .../prezel/core/model/presentation/Purpose.kt | 6 +- .../prezel/core/model/presentation/Style.kt | 4 +- .../analysis/impl/audio/AudioUploadScreen.kt | 5 +- .../impl/component/AnalysisFileNameExt.kt | 26 +++ .../component/AnalysisUploadComponents.kt | 79 ------- .../situation/PresentationSituationScreen.kt | 8 +- .../impl/situation/SituationOptions.kt | 28 +-- .../impl/AnalysisFailureHandlerTest.kt | 62 ------ .../feature/history/impl/HistoryScreen.kt | 12 +- .../feature/history/impl/HistoryViewModel.kt | 24 +-- .../history/impl/component/HistoryItemList.kt | 12 +- .../impl/component/HistoryPresentationCard.kt | 26 +-- .../feature/home/impl/main/HomeScreen.kt | 2 +- .../feature/home/impl/main/HomeViewModel.kt | 16 +- .../main/component/body/PresentationSheet.kt | 2 +- .../main/component/title/PresentationHero.kt | 10 +- 23 files changed, 210 insertions(+), 599 deletions(-) create mode 100644 Prezel/core/data/src/main/java/com/team/prezel/core/data/mapper/PracticeMapper.kt delete mode 100644 Prezel/core/data/src/test/java/com/team/prezel/core/data/error/AppErrorExtTest.kt delete mode 100644 Prezel/core/data/src/test/java/com/team/prezel/core/data/repository/practice/PracticeRepositoryImplTest.kt create mode 100644 Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/component/AnalysisFileNameExt.kt delete mode 100644 Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/component/AnalysisUploadComponents.kt delete mode 100644 Prezel/feature/analysis/impl/src/test/java/com/team/prezel/feature/analysis/impl/AnalysisFailureHandlerTest.kt diff --git a/Prezel/app/build.gradle.kts b/Prezel/app/build.gradle.kts index ec255ed7..8c91c85a 100644 --- a/Prezel/app/build.gradle.kts +++ b/Prezel/app/build.gradle.kts @@ -44,8 +44,6 @@ dependencies { implementation(projects.featureLoginImpl) implementation(projects.featureHomeApi) implementation(projects.featureHomeImpl) - implementation(projects.featureAnalysisApi) - implementation(projects.featureAnalysisImpl) implementation(projects.featureHistoryApi) implementation(projects.featureHistoryImpl) implementation(projects.featureMyApi) @@ -53,6 +51,7 @@ dependencies { implementation(projects.featureTermsImpl) implementation(projects.featurePracticeImpl) + implementation(projects.featureAnalysisImpl) implementation(projects.featureSettingImpl) implementation(projects.featureProfileImpl) diff --git a/Prezel/core/data/src/main/java/com/team/prezel/core/data/mapper/PracticeMapper.kt b/Prezel/core/data/src/main/java/com/team/prezel/core/data/mapper/PracticeMapper.kt new file mode 100644 index 00000000..56aac3c4 --- /dev/null +++ b/Prezel/core/data/src/main/java/com/team/prezel/core/data/mapper/PracticeMapper.kt @@ -0,0 +1,96 @@ +package com.team.prezel.core.data.mapper + +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.model.presentation.Audience +import com.team.prezel.core.model.presentation.Category +import com.team.prezel.core.model.presentation.PresentationExpectedQuestion +import com.team.prezel.core.model.presentation.PresentationGrowthGraph +import com.team.prezel.core.model.presentation.PresentationRecordingAnalysisResult +import com.team.prezel.core.model.presentation.Purpose +import com.team.prezel.core.model.presentation.Style +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.practice.PresentationAnalysisAudience +import com.team.prezel.core.network.model.practice.PresentationAnalysisPurpose +import com.team.prezel.core.network.model.practice.PresentationAnalysisStyle +import com.team.prezel.core.network.model.practice.PresentationAnalysisType +import com.team.prezel.core.network.model.practice.PresentationRecordingAnalysisResponse +import kotlin.math.roundToInt + +internal fun PracticeSentenceResponse.toDomain(): PracticeScript = + PracticeScript( + id = PRACTICE_SCRIPT_ID, + content = sentence, + ) + +internal fun AnalyzePracticeRecordingResponse.toDomain(): PracticeRecordingAnalysisResult = + PracticeRecordingAnalysisResult( + pronunciationScore = accuracyScore.roundToInt(), + speed = speedEvaluation.toPracticeRecordingSpeed(), + overallEvaluation = overallEvaluation.toPracticeRecordingOverallEvaluation(), + ) + +internal fun PresentationRecordingAnalysisResponse.toDomain(): PresentationRecordingAnalysisResult = + PresentationRecordingAnalysisResult( + presentationId = presentationId, + analysisResultId = analysisResultId, + name = name, + type = type, + purpose = purpose, + style = style, + audience = audience, + analysisDate = analysisDate, + durationSeconds = durationSeconds, + formattedDuration = formattedDuration, + spm = spm, + speedEval = speedEval, + summaryFeedback = summaryFeedback, + accuracyScore = accuracyScore, + scriptMatchRate = scriptMatchRate, + spellErrorCount = spellErrorCount, + grammarErrorCount = grammarErrorCount, + totalErrorCount = totalErrorCount, + growthGraph = growthGraph.map { it.toDomain() }, + expectedQuestions = expectedQuestions.map { it.toDomain() }, + ) + +internal fun Category.toRequestType(): PresentationAnalysisType = PresentationAnalysisType.valueOf(name) + +internal fun Purpose.toRequestPurpose(): PresentationAnalysisPurpose = PresentationAnalysisPurpose.valueOf(name) + +internal fun Style.toRequestStyle(): PresentationAnalysisStyle = PresentationAnalysisStyle.valueOf(name) + +internal fun Audience.toRequestAudience(): PresentationAnalysisAudience = PresentationAnalysisAudience.valueOf(name) + +internal fun PresentationRecordingAnalysisResponse.GrowthGraph.toDomain(): PresentationGrowthGraph = + PresentationGrowthGraph( + attempt = attempt, + accuracyScore = accuracyScore, + scriptMatchRate = scriptMatchRate, + ) + +internal fun PresentationRecordingAnalysisResponse.ExpectedQuestion.toDomain(): PresentationExpectedQuestion = + PresentationExpectedQuestion( + question = question, + answer = answer, + ) + +internal fun String.toPracticeRecordingSpeed(): PracticeRecordingSpeed = + when { + contains("느려요") -> PracticeRecordingSpeed.SLOW + contains("빨라요") -> PracticeRecordingSpeed.FAST + else -> PracticeRecordingSpeed.ADEQUATE + } + +internal fun String.toPracticeRecordingOverallEvaluation(): PracticeRecordingOverallEvaluation = + when (this) { + "Perfect" -> PracticeRecordingOverallEvaluation.PERFECT + "Good" -> PracticeRecordingOverallEvaluation.GOOD + "Try" -> PracticeRecordingOverallEvaluation.TRY + else -> PracticeRecordingOverallEvaluation.TRY + } + +private const val PRACTICE_SCRIPT_ID = 0L 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 c5919592..5508b826 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 @@ -1,28 +1,21 @@ package com.team.prezel.core.data.repository import com.team.prezel.core.data.error.mapDomainFailure +import com.team.prezel.core.data.mapper.toDomain +import com.team.prezel.core.data.mapper.toRequestAudience +import com.team.prezel.core.data.mapper.toRequestPurpose +import com.team.prezel.core.data.mapper.toRequestStyle +import com.team.prezel.core.data.mapper.toRequestType 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.model.presentation.Audience import com.team.prezel.core.model.presentation.Category -import com.team.prezel.core.model.presentation.PresentationExpectedQuestion -import com.team.prezel.core.model.presentation.PresentationGrowthGraph import com.team.prezel.core.model.presentation.PresentationRecordingAnalysisResult import com.team.prezel.core.model.presentation.Purpose import com.team.prezel.core.model.presentation.Style 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 com.team.prezel.core.network.model.practice.PresentationAnalysisAudience -import com.team.prezel.core.network.model.practice.PresentationAnalysisPurpose -import com.team.prezel.core.network.model.practice.PresentationAnalysisStyle -import com.team.prezel.core.network.model.practice.PresentationAnalysisType -import com.team.prezel.core.network.model.practice.PresentationRecordingAnalysisResponse import javax.inject.Inject -import kotlin.math.roundToInt internal class PracticeRepositoryImpl @Inject constructor( private val practiceRemoteDataSource: PracticeRemoteDataSource, @@ -73,103 +66,4 @@ internal class PracticeRepositoryImpl @Inject constructor( }.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(), - overallEvaluation = overallEvaluation.toPracticeRecordingOverallEvaluation(), - ) - - private fun PresentationRecordingAnalysisResponse.toDomain(): PresentationRecordingAnalysisResult = - PresentationRecordingAnalysisResult( - presentationId = presentationId, - analysisResultId = analysisResultId, - name = name, - type = type, - purpose = purpose, - style = style, - audience = audience, - analysisDate = analysisDate, - durationSeconds = durationSeconds, - formattedDuration = formattedDuration, - spm = spm, - speedEval = speedEval, - summaryFeedback = summaryFeedback, - accuracyScore = accuracyScore, - scriptMatchRate = scriptMatchRate, - spellErrorCount = spellErrorCount, - grammarErrorCount = grammarErrorCount, - totalErrorCount = totalErrorCount, - growthGraph = growthGraph.map { it.toDomain() }, - expectedQuestions = expectedQuestions.map { it.toDomain() }, - ) - - private fun PresentationRecordingAnalysisResponse.GrowthGraph.toDomain(): PresentationGrowthGraph = - PresentationGrowthGraph( - attempt = attempt, - accuracyScore = accuracyScore, - scriptMatchRate = scriptMatchRate, - ) - - private fun PresentationRecordingAnalysisResponse.ExpectedQuestion.toDomain(): PresentationExpectedQuestion = - PresentationExpectedQuestion( - question = question, - answer = answer, - ) - - private fun Category.toRequestType(): PresentationAnalysisType = - when (this) { - Category.EDUCATION -> PresentationAnalysisType.EDUCATION - Category.REPORT -> PresentationAnalysisType.WORK - Category.PERSUASION -> PresentationAnalysisType.OFFER - Category.EVENT -> PresentationAnalysisType.EVENT - } - - private fun Purpose.toRequestPurpose(): PresentationAnalysisPurpose = - when (this) { - Purpose.CONTENT_DELIVERY -> PresentationAnalysisPurpose.INFO - Purpose.IMPROVE_UNDERSTANDING -> PresentationAnalysisPurpose.UNDERSTANDING - Purpose.BUILD_EMPATHY -> PresentationAnalysisPurpose.EMPATHY - } - - private fun Style.toRequestStyle(): PresentationAnalysisStyle = - when (this) { - Style.PROFESSIONAL -> PresentationAnalysisStyle.FORMAL - Style.FRIENDLY -> PresentationAnalysisStyle.FRIENDLY - Style.CALM -> PresentationAnalysisStyle.CALM - Style.COMFORTABLE -> PresentationAnalysisStyle.CASUAL - } - - private fun Audience.toRequestAudience(): PresentationAnalysisAudience = - when (this) { - Audience.GENERAL_AUDIENCE -> PresentationAnalysisAudience.GENERAL - Audience.EXPERT -> PresentationAnalysisAudience.PROFESSIONAL - Audience.TEAMMATES -> PresentationAnalysisAudience.TEAMMATE - } - - private fun String.toPracticeRecordingSpeed(): PracticeRecordingSpeed = - when { - contains("느려요") -> PracticeRecordingSpeed.SLOW - contains("빨라요") -> PracticeRecordingSpeed.FAST - 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/error/AppErrorExtTest.kt b/Prezel/core/data/src/test/java/com/team/prezel/core/data/error/AppErrorExtTest.kt deleted file mode 100644 index b6bd6cd8..00000000 --- a/Prezel/core/data/src/test/java/com/team/prezel/core/data/error/AppErrorExtTest.kt +++ /dev/null @@ -1,59 +0,0 @@ -package com.team.prezel.core.data.error - -import com.team.prezel.core.common.error.AppError -import com.team.prezel.core.common.error.AppException -import com.team.prezel.core.network.model.ApiException -import com.team.prezel.core.network.model.ServerErrorCode -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertIs - -class AppErrorExtTest { - @Test - fun `지원하지 않는 파일 형식 에러는 잘못된 요청으로 변환한다`() { - val result = Result - .failure( - ApiException( - status = 400, - errorCode = ServerErrorCode.FILE_UPLOAD_FAILED, - message = "지원하지 않는 오디오 파일 형식입니다.", - ), - ).mapDomainFailure() - - val exception = assertIs(result.exceptionOrNull()) - assertEquals(AppError.INVALID_REQUEST, exception.error) - assertEquals("지원하지 않는 오디오 파일 형식입니다.", exception.message) - } - - @Test - fun `빈 파일 에러는 스크립트 파일 인식 실패로 변환한다`() { - val result = Result - .failure( - ApiException( - status = 400, - errorCode = ServerErrorCode.FILE_IS_EMPTY, - message = "분석할 수 있는 파일 내용이 없습니다.", - ), - ).mapDomainFailure() - - val exception = assertIs(result.exceptionOrNull()) - assertEquals(AppError.SCRIPT_FILE_RECOGNITION_FAILED, exception.error) - assertEquals("분석할 수 있는 파일 내용이 없습니다.", exception.message) - } - - @Test - fun `AI 엔진 분석 실패 에러는 서버 에러로 변환한다`() { - val result = Result - .failure( - ApiException( - status = 500, - errorCode = ServerErrorCode.VOICE_ANALYSIS_FAILED, - message = "분석 중 문제가 발생했어요.", - ), - ).mapDomainFailure() - - val exception = assertIs(result.exceptionOrNull()) - assertEquals(AppError.SERVER_ERROR, exception.error) - assertEquals("분석 중 문제가 발생했어요.", exception.message) - } -} 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 deleted file mode 100644 index 6070929b..00000000 --- a/Prezel/core/data/src/test/java/com/team/prezel/core/data/repository/practice/PracticeRepositoryImplTest.kt +++ /dev/null @@ -1,203 +0,0 @@ -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.model.presentation.Audience -import com.team.prezel.core.model.presentation.Category -import com.team.prezel.core.model.presentation.Purpose -import com.team.prezel.core.model.presentation.Style -import com.team.prezel.core.network.datasource.PracticeRemoteDataSource -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.practice.PresentationAnalysisAudience -import com.team.prezel.core.network.model.practice.PresentationAnalysisPurpose -import com.team.prezel.core.network.model.practice.PresentationAnalysisStyle -import com.team.prezel.core.network.model.practice.PresentationAnalysisType -import com.team.prezel.core.network.model.practice.PresentationRecordingAnalysisResponse -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) - assertEquals(PracticeRecordingOverallEvaluation.GOOD, result.overallEvaluation) - } - - @Test - fun `발표 녹음 분석을 요청하고 분석 결과로 변환한다`() = - runBlocking { - val remoteDataSource = FakePracticeRemoteDataSource( - sentence = "간장 공장 공장장은 강 공장장이다.", - ) - val repository = PracticeRepositoryImpl( - practiceRemoteDataSource = remoteDataSource, - ) - - val result = repository - .analyzePresentationRecording( - name = "중간 발표", - date = "2026-05-24", - category = Category.REPORT, - purpose = Purpose.IMPROVE_UNDERSTANDING, - style = Style.COMFORTABLE, - audience = Audience.TEAMMATES, - script = "발표 대본입니다.", - scriptFilePath = "/tmp/script.txt", - audioFilePath = "/tmp/audio.m4a", - ).getOrThrow() - - assertEquals("중간 발표", remoteDataSource.presentationAnalysisName) - assertEquals("2026-05-24", remoteDataSource.presentationAnalysisDate) - assertEquals(PresentationAnalysisType.WORK, remoteDataSource.presentationAnalysisType) - assertEquals(PresentationAnalysisPurpose.UNDERSTANDING, remoteDataSource.presentationAnalysisPurpose) - assertEquals(PresentationAnalysisStyle.CASUAL, remoteDataSource.presentationAnalysisStyle) - assertEquals(PresentationAnalysisAudience.TEAMMATE, remoteDataSource.presentationAnalysisAudience) - assertEquals("발표 대본입니다.", remoteDataSource.presentationAnalysisScript) - assertEquals("/tmp/script.txt", remoteDataSource.presentationAnalysisScriptFilePath) - assertEquals("/tmp/audio.m4a", remoteDataSource.presentationAnalysisAudioFilePath) - assertEquals(1L, result.presentationId) - assertEquals(2L, result.analysisResultId) - assertEquals("중간 발표", result.name) - assertEquals(123, result.durationSeconds) - assertEquals(145, result.spm) - assertEquals(92.5, result.accuracyScore) - assertEquals(88.0, result.scriptMatchRate) - assertEquals(3, result.totalErrorCount) - assertEquals("요약 피드백", result.summaryFeedback) - assertEquals("예상 질문", result.expectedQuestions.single().question) - } - - private class FakePracticeRemoteDataSource( - private val sentence: String, - ) : PracticeRemoteDataSource { - var analyzeRecordingFilePath: String? = null - private set - var analyzeReferenceText: String? = null - private set - var presentationAnalysisName: String? = null - private set - var presentationAnalysisDate: String? = null - private set - var presentationAnalysisType: PresentationAnalysisType? = null - private set - var presentationAnalysisPurpose: PresentationAnalysisPurpose? = null - private set - var presentationAnalysisStyle: PresentationAnalysisStyle? = null - private set - var presentationAnalysisAudience: PresentationAnalysisAudience? = null - private set - var presentationAnalysisScript: String? = null - private set - var presentationAnalysisScriptFilePath: String? = null - private set - var presentationAnalysisAudioFilePath: 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", - ) - } - - override suspend fun analyzePresentationRecording( - name: String, - date: String, - type: PresentationAnalysisType, - purpose: PresentationAnalysisPurpose, - style: PresentationAnalysisStyle, - audience: PresentationAnalysisAudience, - script: String?, - scriptFilePath: String?, - audioFilePath: String, - ): PresentationRecordingAnalysisResponse { - presentationAnalysisName = name - presentationAnalysisDate = date - presentationAnalysisType = type - presentationAnalysisPurpose = purpose - presentationAnalysisStyle = style - presentationAnalysisAudience = audience - presentationAnalysisScript = script - presentationAnalysisScriptFilePath = scriptFilePath - presentationAnalysisAudioFilePath = audioFilePath - - return PresentationRecordingAnalysisResponse( - presentationId = 1, - analysisResultId = 2, - name = "중간 발표", - type = "WORK", - purpose = "UNDERSTANDING", - style = "CASUAL", - audience = "TEAMMATE", - analysisDate = "2026-05-24", - durationSeconds = 123, - formattedDuration = "02:03", - spm = 145, - speedEval = "적당해요", - summaryFeedback = "요약 피드백", - accuracyScore = 92.5, - scriptMatchRate = 88.0, - spellErrorCount = 1, - grammarErrorCount = 2, - totalErrorCount = 3, - growthGraph = listOf( - PresentationRecordingAnalysisResponse.GrowthGraph( - attempt = 1, - accuracyScore = 92.5, - scriptMatchRate = 88.0, - ), - ), - expectedQuestions = listOf( - PresentationRecordingAnalysisResponse.ExpectedQuestion( - question = "예상 질문", - answer = "예상 답변", - ), - ), - ) - } - } -} diff --git a/Prezel/core/model/src/main/java/com/team/prezel/core/model/presentation/Audience.kt b/Prezel/core/model/src/main/java/com/team/prezel/core/model/presentation/Audience.kt index 42fa1de9..fc56a0af 100644 --- a/Prezel/core/model/src/main/java/com/team/prezel/core/model/presentation/Audience.kt +++ b/Prezel/core/model/src/main/java/com/team/prezel/core/model/presentation/Audience.kt @@ -1,7 +1,7 @@ package com.team.prezel.core.model.presentation enum class Audience { - GENERAL_AUDIENCE, - EXPERT, - TEAMMATES, + GENERAL, + PROFESSIONAL, + TEAMMATE, } diff --git a/Prezel/core/model/src/main/java/com/team/prezel/core/model/presentation/Category.kt b/Prezel/core/model/src/main/java/com/team/prezel/core/model/presentation/Category.kt index de597a57..cba443dd 100644 --- a/Prezel/core/model/src/main/java/com/team/prezel/core/model/presentation/Category.kt +++ b/Prezel/core/model/src/main/java/com/team/prezel/core/model/presentation/Category.kt @@ -2,7 +2,7 @@ package com.team.prezel.core.model.presentation enum class Category { EDUCATION, - REPORT, - PERSUASION, + WORK, + OFFER, EVENT, } diff --git a/Prezel/core/model/src/main/java/com/team/prezel/core/model/presentation/Purpose.kt b/Prezel/core/model/src/main/java/com/team/prezel/core/model/presentation/Purpose.kt index 5937ded9..4d6514d1 100644 --- a/Prezel/core/model/src/main/java/com/team/prezel/core/model/presentation/Purpose.kt +++ b/Prezel/core/model/src/main/java/com/team/prezel/core/model/presentation/Purpose.kt @@ -1,7 +1,7 @@ package com.team.prezel.core.model.presentation enum class Purpose { - CONTENT_DELIVERY, - IMPROVE_UNDERSTANDING, - BUILD_EMPATHY, + INFO, + UNDERSTANDING, + EMPATHY, } diff --git a/Prezel/core/model/src/main/java/com/team/prezel/core/model/presentation/Style.kt b/Prezel/core/model/src/main/java/com/team/prezel/core/model/presentation/Style.kt index dbcc159b..600cf72e 100644 --- a/Prezel/core/model/src/main/java/com/team/prezel/core/model/presentation/Style.kt +++ b/Prezel/core/model/src/main/java/com/team/prezel/core/model/presentation/Style.kt @@ -1,8 +1,8 @@ package com.team.prezel.core.model.presentation enum class Style { - PROFESSIONAL, + FORMAL, FRIENDLY, CALM, - COMFORTABLE, + CASUAL, } diff --git a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/audio/AudioUploadScreen.kt b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/audio/AudioUploadScreen.kt index 45e279c7..fc93d065 100644 --- a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/audio/AudioUploadScreen.kt +++ b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/audio/AudioUploadScreen.kt @@ -56,7 +56,6 @@ private val AUDIO_FILE_MIME_TYPES = arrayOf( "audio/mp4", // mp4, m4a "audio/x-m4a", // m4a ) -private const val AUDIO_PREVIEW_FILE_URI = "content://prezel/sample.m4a" private const val AUDIO_UPLOAD_TAB_COUNT = 1 @Composable @@ -366,7 +365,7 @@ private fun AudioUploadScreenProgressPreview() { PrezelTheme { AudioUploadScreen( form = AnalysisForm(), - pendingAudioFileUri = AUDIO_PREVIEW_FILE_URI, + pendingAudioFileUri = "content://prezel/sample.m4a", uploadProgress = 0.5f, progress = AnalysisFlowUiState(step = AnalysisFlowStep.AUDIO_UPLOAD).progress, buttonEnabled = false, @@ -385,7 +384,7 @@ private fun AudioUploadScreenSelectedPreview() { AudioUploadScreen( uiState = AnalysisFlowUiState( step = AnalysisFlowStep.AUDIO_UPLOAD, - form = AnalysisForm(audioFileUri = AUDIO_PREVIEW_FILE_URI), + form = AnalysisForm(audioFileUri = "content://prezel/sample.m4a"), ), onAudioFileSelected = {}, onAnalyze = {}, diff --git a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/component/AnalysisFileNameExt.kt b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/component/AnalysisFileNameExt.kt new file mode 100644 index 00000000..37bb93a2 --- /dev/null +++ b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/component/AnalysisFileNameExt.kt @@ -0,0 +1,26 @@ +package com.team.prezel.feature.analysis.impl.component + +import android.content.Context +import android.provider.OpenableColumns +import androidx.core.net.toUri + +internal fun String.toFileName(context: Context): String { + val uri = toUri() + val displayName = runCatching { + context.contentResolver + .query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null) + ?.use { cursor -> + val displayNameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) + + if (displayNameIndex >= 0 && cursor.moveToFirst()) { + cursor.getString(displayNameIndex) + } else { + null + } + } + }.getOrNull() + + return displayName + ?.takeIf { it.isNotBlank() } + ?: uri.lastPathSegment.orEmpty().ifBlank { this } +} diff --git a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/component/AnalysisUploadComponents.kt b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/component/AnalysisUploadComponents.kt deleted file mode 100644 index 1d6d50a5..00000000 --- a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/component/AnalysisUploadComponents.kt +++ /dev/null @@ -1,79 +0,0 @@ -package com.team.prezel.feature.analysis.impl.component - -import android.content.Context -import android.provider.OpenableColumns -import androidx.compose.foundation.border -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.material3.Icon -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.painterResource -import androidx.compose.ui.unit.dp -import androidx.core.net.toUri -import com.team.prezel.core.designsystem.component.base.PrezelTouchArea -import com.team.prezel.core.designsystem.theme.PrezelTheme - -@Composable -internal fun OutlineActionButton( - text: String, - modifier: Modifier = Modifier, - iconResId: Int? = null, - onClick: () -> Unit, -) { - PrezelTouchArea( - onClick = onClick, - shape = PrezelTheme.shapes.V8, - modifier = modifier, - ) { - Row( - modifier = Modifier - .clip(PrezelTheme.shapes.V8) - .border(PrezelTheme.stroke.V1, PrezelTheme.colors.interactiveRegular, PrezelTheme.shapes.V8) - .padding(horizontal = PrezelTheme.spacing.V16, vertical = PrezelTheme.spacing.V12), - verticalAlignment = Alignment.CenterVertically, - ) { - if (iconResId != null) { - Icon( - painter = painterResource(iconResId), - contentDescription = null, - tint = PrezelTheme.colors.interactiveRegular, - modifier = Modifier.size(18.dp), - ) - Spacer(modifier = Modifier.width(PrezelTheme.spacing.V6)) - } - Text( - text = text, - color = PrezelTheme.colors.interactiveRegular, - style = PrezelTheme.typography.body2Medium, - ) - } - } -} - -internal fun String.toFileName(context: Context): String { - val uri = toUri() - val displayName = runCatching { - context.contentResolver - .query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null) - ?.use { cursor -> - val displayNameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) - - if (displayNameIndex >= 0 && cursor.moveToFirst()) { - cursor.getString(displayNameIndex) - } else { - null - } - } - }.getOrNull() - - return displayName - ?.takeIf { it.isNotBlank() } - ?: uri.lastPathSegment.orEmpty().ifBlank { this } -} diff --git a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/situation/PresentationSituationScreen.kt b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/situation/PresentationSituationScreen.kt index 7587c6a8..aa6775dd 100644 --- a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/situation/PresentationSituationScreen.kt +++ b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/situation/PresentationSituationScreen.kt @@ -323,9 +323,9 @@ private fun PresentationSituationScreenPreview() { step = AnalysisFlowStep.PRESENTATION_SITUATION, form = AnalysisForm( category = Category.EDUCATION, - purpose = Purpose.CONTENT_DELIVERY, + purpose = Purpose.INFO, style = Style.CALM, - audience = Audience.EXPERT, + audience = Audience.PROFESSIONAL, ), ), onSelectCategory = {}, @@ -355,7 +355,7 @@ private fun CategoryOptionGridPreview() { private fun PurposeOptionsPreview() { PrezelTheme { ChipOptionsContent( - options = purposeOptions().toChipContentOptions(Purpose.CONTENT_DELIVERY), + options = purposeOptions().toChipContentOptions(Purpose.INFO), onSelect = {}, ) } @@ -377,7 +377,7 @@ private fun StyleOptionsPreview() { private fun AudienceOptionsPreview() { PrezelTheme { ChipOptionsContent( - options = audienceOptions().toChipContentOptions(Audience.EXPERT), + options = audienceOptions().toChipContentOptions(Audience.PROFESSIONAL), onSelect = {}, ) } diff --git a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/situation/SituationOptions.kt b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/situation/SituationOptions.kt index 5f1e8371..027cecc9 100644 --- a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/situation/SituationOptions.kt +++ b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/situation/SituationOptions.kt @@ -78,46 +78,46 @@ internal fun audienceOptions(): ImmutableList> = private val Category.titleResId: Int @StringRes get() = when (this) { - Category.PERSUASION -> R.string.feature_analysis_impl_situation_category_persuasion + Category.OFFER -> R.string.feature_analysis_impl_situation_category_persuasion Category.EVENT -> R.string.feature_analysis_impl_situation_category_event Category.EDUCATION -> R.string.feature_analysis_impl_situation_category_academic - Category.REPORT -> R.string.feature_analysis_impl_situation_category_business + Category.WORK -> R.string.feature_analysis_impl_situation_category_business } private val Category.descriptionResId: Int @StringRes get() = when (this) { - Category.PERSUASION -> R.string.feature_analysis_impl_situation_category_persuasion_description + Category.OFFER -> R.string.feature_analysis_impl_situation_category_persuasion_description Category.EVENT -> R.string.feature_analysis_impl_situation_category_event_description Category.EDUCATION -> R.string.feature_analysis_impl_situation_category_academic_description - Category.REPORT -> R.string.feature_analysis_impl_situation_category_business_description + Category.WORK -> R.string.feature_analysis_impl_situation_category_business_description } private val Category.iconResId: Int @DrawableRes get() = when (this) { - Category.PERSUASION -> PrezelIcons.Hand + Category.OFFER -> PrezelIcons.Hand Category.EVENT -> PrezelIcons.Balloon Category.EDUCATION -> PrezelIcons.College - Category.REPORT -> PrezelIcons.Company + Category.WORK -> PrezelIcons.Company } private val Purpose.titleResId: Int @StringRes get() = when (this) { - Purpose.CONTENT_DELIVERY -> R.string.feature_analysis_impl_situation_purpose_content_delivery - Purpose.IMPROVE_UNDERSTANDING -> R.string.feature_analysis_impl_situation_purpose_improve_understanding - Purpose.BUILD_EMPATHY -> R.string.feature_analysis_impl_situation_purpose_build_empathy + Purpose.INFO -> R.string.feature_analysis_impl_situation_purpose_content_delivery + Purpose.UNDERSTANDING -> R.string.feature_analysis_impl_situation_purpose_improve_understanding + Purpose.EMPATHY -> R.string.feature_analysis_impl_situation_purpose_build_empathy } private val Style.titleResId: Int @StringRes get() = when (this) { - Style.PROFESSIONAL -> R.string.feature_analysis_impl_situation_style_professional + Style.FORMAL -> R.string.feature_analysis_impl_situation_style_professional Style.FRIENDLY -> R.string.feature_analysis_impl_situation_style_friendly Style.CALM -> R.string.feature_analysis_impl_situation_style_calm - Style.COMFORTABLE -> R.string.feature_analysis_impl_situation_style_comfortable + Style.CASUAL -> R.string.feature_analysis_impl_situation_style_comfortable } private val Audience.titleResId: Int @StringRes get() = when (this) { - Audience.GENERAL_AUDIENCE -> R.string.feature_analysis_impl_situation_audience_general - Audience.EXPERT -> R.string.feature_analysis_impl_situation_audience_expert - Audience.TEAMMATES -> R.string.feature_analysis_impl_situation_audience_team + Audience.GENERAL -> R.string.feature_analysis_impl_situation_audience_general + Audience.PROFESSIONAL -> R.string.feature_analysis_impl_situation_audience_expert + Audience.TEAMMATE -> R.string.feature_analysis_impl_situation_audience_team } diff --git a/Prezel/feature/analysis/impl/src/test/java/com/team/prezel/feature/analysis/impl/AnalysisFailureHandlerTest.kt b/Prezel/feature/analysis/impl/src/test/java/com/team/prezel/feature/analysis/impl/AnalysisFailureHandlerTest.kt deleted file mode 100644 index 3e21ae04..00000000 --- a/Prezel/feature/analysis/impl/src/test/java/com/team/prezel/feature/analysis/impl/AnalysisFailureHandlerTest.kt +++ /dev/null @@ -1,62 +0,0 @@ -package com.team.prezel.feature.analysis.impl - -import com.team.prezel.core.common.error.AppError -import com.team.prezel.core.common.error.AppException -import com.team.prezel.feature.analysis.impl.contract.AnalysisUploadType -import com.team.prezel.feature.analysis.impl.model.AnalysisUiMessage -import kotlin.test.Test -import kotlin.test.assertEquals - -class AnalysisFailureHandlerTest { - @Test - fun `잘못된 요청은 음성 파일 재업로드 화면으로 처리한다`() { - val action = AppException( - error = AppError.INVALID_REQUEST, - message = "지원하지 않는 오디오 파일 형식입니다.", - ).toAnalysisFailureAction() - - assertEquals( - AnalysisFailureAction.RetryFileUpload(uploadType = AnalysisUploadType.AUDIO), - action, - ) - } - - @Test - fun `스크립트 파일 인식 실패는 스크립트 파일 재업로드 화면으로 처리한다`() { - val action = AppException( - error = AppError.SCRIPT_FILE_RECOGNITION_FAILED, - message = "분석할 수 있는 텍스트 파일을 찾지 못했습니다.", - ).toAnalysisFailureAction() - - assertEquals( - AnalysisFailureAction.RetryFileUpload(uploadType = AnalysisUploadType.SCRIPT), - action, - ) - } - - @Test - fun `인증 에러는 인증 만료 메시지로 처리한다`() { - val action = AppException( - error = AppError.UNAUTHORIZED, - message = "인증 자격 증명이 유효하지 않습니다.", - ).toAnalysisFailureAction() - - assertEquals( - AnalysisFailureAction.ShowMessage(message = AnalysisUiMessage.AUTH_EXPIRED), - action, - ) - } - - @Test - fun `서버 에러는 분석 실패 메시지로 처리한다`() { - val action = AppException( - error = AppError.SERVER_ERROR, - message = "AI 분석 서버와의 통신 중 오류가 발생했습니다.", - ).toAnalysisFailureAction() - - assertEquals( - AnalysisFailureAction.ShowMessage(message = AnalysisUiMessage.ANALYSIS_FAILED), - action, - ) - } -} diff --git a/Prezel/feature/history/impl/src/main/java/com/team/prezel/feature/history/impl/HistoryScreen.kt b/Prezel/feature/history/impl/src/main/java/com/team/prezel/feature/history/impl/HistoryScreen.kt index 73c8efb6..e2e5eb82 100644 --- a/Prezel/feature/history/impl/src/main/java/com/team/prezel/feature/history/impl/HistoryScreen.kt +++ b/Prezel/feature/history/impl/src/main/java/com/team/prezel/feature/history/impl/HistoryScreen.kt @@ -179,9 +179,9 @@ private fun HistoryScreenPreview() { date = LocalDate(2026, 4, 19), title = "캡스톤서비스기획 중간고사 발표", category = Category.EDUCATION, - purpose = Purpose.CONTENT_DELIVERY, - style = Style.PROFESSIONAL, - audience = Audience.EXPERT, + purpose = Purpose.INFO, + style = Style.FORMAL, + audience = Audience.PROFESSIONAL, ), ), ), @@ -193,10 +193,10 @@ private fun HistoryScreenPreview() { dDay = -1, date = LocalDate(2026, 4, 12), title = "서비스 런칭 회고 발표", - category = Category.PERSUASION, - purpose = Purpose.BUILD_EMPATHY, + category = Category.OFFER, + purpose = Purpose.EMPATHY, style = Style.CALM, - audience = Audience.GENERAL_AUDIENCE, + audience = Audience.GENERAL, ), ), ), diff --git a/Prezel/feature/history/impl/src/main/java/com/team/prezel/feature/history/impl/HistoryViewModel.kt b/Prezel/feature/history/impl/src/main/java/com/team/prezel/feature/history/impl/HistoryViewModel.kt index 4fcec31a..885d9966 100644 --- a/Prezel/feature/history/impl/src/main/java/com/team/prezel/feature/history/impl/HistoryViewModel.kt +++ b/Prezel/feature/history/impl/src/main/java/com/team/prezel/feature/history/impl/HistoryViewModel.kt @@ -60,36 +60,36 @@ internal class HistoryViewModel @Inject constructor() : BaseViewModel R.string.feature_history_impl_category_persuasion + Category.OFFER -> R.string.feature_history_impl_category_persuasion Category.EVENT -> R.string.feature_history_impl_category_event Category.EDUCATION -> R.string.feature_history_impl_category_education - Category.REPORT -> R.string.feature_history_impl_category_report + Category.WORK -> R.string.feature_history_impl_category_report } @StringRes private fun Purpose.labelResId(): Int = when (this) { - Purpose.CONTENT_DELIVERY -> R.string.feature_history_impl_purpose_content_delivery - Purpose.IMPROVE_UNDERSTANDING -> R.string.feature_history_impl_purpose_improve_understanding - Purpose.BUILD_EMPATHY -> R.string.feature_history_impl_purpose_build_empathy + Purpose.INFO -> R.string.feature_history_impl_purpose_content_delivery + Purpose.UNDERSTANDING -> R.string.feature_history_impl_purpose_improve_understanding + Purpose.EMPATHY -> R.string.feature_history_impl_purpose_build_empathy } @StringRes private fun Style.labelResId(): Int = when (this) { - Style.PROFESSIONAL -> R.string.feature_history_impl_style_professional + Style.FORMAL -> R.string.feature_history_impl_style_professional Style.FRIENDLY -> R.string.feature_history_impl_style_friendly Style.CALM -> R.string.feature_history_impl_style_calm - Style.COMFORTABLE -> R.string.feature_history_impl_style_comfortable + Style.CASUAL -> R.string.feature_history_impl_style_comfortable } @StringRes private fun Audience.labelResId(): Int = when (this) { - Audience.GENERAL_AUDIENCE -> R.string.feature_history_impl_audience_general - Audience.EXPERT -> R.string.feature_history_impl_audience_expert - Audience.TEAMMATES -> R.string.feature_history_impl_audience_teammates + Audience.GENERAL -> R.string.feature_history_impl_audience_general + Audience.PROFESSIONAL -> R.string.feature_history_impl_audience_expert + Audience.TEAMMATE -> R.string.feature_history_impl_audience_teammates } @BasicPreview @@ -200,9 +200,9 @@ private fun HistoryPresentationCardPreview() { date = LocalDate(2025, 10, 20), title = "캡스톤서비스기획 중간고사 발표", category = Category.EDUCATION, - purpose = Purpose.CONTENT_DELIVERY, - style = Style.PROFESSIONAL, - audience = Audience.EXPERT, + purpose = Purpose.INFO, + style = Style.FORMAL, + audience = Audience.PROFESSIONAL, ), onClick = { }, ) 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 35a38d09..8022c87d 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 @@ -354,7 +354,7 @@ private fun HomeScreenSinglePreview() { val uiState = HomeUiState.SingleContent( presentation = PresentationUiModel( id = 1L, - category = Category.PERSUASION, + category = Category.OFFER, title = "날짜 지난 발표제목", date = LocalDate(2026, 4, 3), dDay = -1, diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/HomeViewModel.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/HomeViewModel.kt index 5f314636..1be8bd97 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/HomeViewModel.kt +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/HomeViewModel.kt @@ -45,19 +45,19 @@ internal class HomeViewModel @Inject constructor() : BaseViewModel presentation.toUiModel() } } diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/body/PresentationSheet.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/body/PresentationSheet.kt index a98c7247..f450f286 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/body/PresentationSheet.kt +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/body/PresentationSheet.kt @@ -48,7 +48,7 @@ private fun PresentationContentPreview() { PrezelTheme { val presentation = PresentationUiModel( id = 1L, - category = Category.PERSUASION, + category = Category.OFFER, title = "설득하는 발표", date = LocalDate(2026, 10, 1), dDay = 3, diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/title/PresentationHero.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/title/PresentationHero.kt index a9934a01..b921f0a1 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/title/PresentationHero.kt +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/title/PresentationHero.kt @@ -101,19 +101,19 @@ private fun HomePresentationTitleRow(presentation: PresentationUiModel) { @StringRes private fun Category.labelResId(): Int = when (this) { - Category.PERSUASION -> R.string.feature_home_impl_category_persuasion + Category.OFFER -> R.string.feature_home_impl_category_persuasion Category.EVENT -> R.string.feature_home_impl_category_event Category.EDUCATION -> R.string.feature_home_impl_category_education - Category.REPORT -> R.string.feature_home_impl_category_report + Category.WORK -> R.string.feature_home_impl_category_report } @DrawableRes private fun Category.backgroundResId(): Int = when (this) { - Category.PERSUASION -> R.drawable.feature_home_impl_section_title_hand + Category.OFFER -> R.drawable.feature_home_impl_section_title_hand Category.EVENT -> R.drawable.feature_home_impl_section_title_event Category.EDUCATION -> R.drawable.feature_home_impl_section_title_college - Category.REPORT -> R.drawable.feature_home_impl_section_title_company + Category.WORK -> R.drawable.feature_home_impl_section_title_company } @BasicPreview @@ -123,7 +123,7 @@ private fun HomePresentationPagePreview() { PresentationHero( presentation = PresentationUiModel( id = 1L, - category = Category.PERSUASION, + category = Category.OFFER, title = "설득하는 발표", date = LocalDate(2026, 10, 1), dDay = 3, From 396c225adee55dc806bd3ae38c1a6fbb4ef32a36 Mon Sep 17 00:00:00 2001 From: Ham BeomJoon Date: Sun, 24 May 2026 20:18:26 +0900 Subject: [PATCH 14/16] =?UTF-8?q?refactor:=20=EB=B0=9C=ED=91=9C=20?= =?UTF-8?q?=EB=B6=84=EC=84=9D=20=EC=9A=94=EC=B2=AD=20=ED=8C=8C=EB=9D=BC?= =?UTF-8?q?=EB=AF=B8=ED=84=B0=20=ED=83=80=EC=9E=85=20=EB=B3=80=EA=B2=BD=20?= =?UTF-8?q?=EB=B0=8F=20=EA=B4=80=EB=A0=A8=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 - `PresentationAnalysisRequest.kt` 내 선언된 Enum 클래스들을 제거하고 `String` 타입으로 대체 - `PracticeRemoteDataSource` 인터페이스 및 구현체의 파라미터 타입을 `String`으로 변경 - `PracticeMapper`에서 도메인 모델을 `String` 타입의 요청 값으로 매핑하도록 수정 - `AnalysisFlowViewModel`에서 스크립트 파일 URI 처리 시 Null 또는 공백 체크 로직 강화 - `TermsViewModel` 내 에러 처리 로직의 불필요한 Case 제거 및 `else` 분기 추가 - `AnalysisEntryBuilder` 내 불필요한 지역 변수 제거 및 람다식 정리 --- .../prezel/core/data/mapper/PracticeMapper.kt | 12 +++---- .../datasource/PracticeRemoteDataSource.kt | 12 +++---- .../PracticeRemoteDataSourceImpl.kt | 20 +++++------ .../practice/PresentationAnalysisRequest.kt | 35 ------------------- .../analysis/impl/AnalysisFlowViewModel.kt | 2 +- .../impl/navigation/AnalysisEntryBuilder.kt | 3 +- .../feature/terms/impl/TermsViewModel.kt | 4 +-- 7 files changed, 20 insertions(+), 68 deletions(-) delete mode 100644 Prezel/core/network/src/main/java/com/team/prezel/core/network/model/practice/PresentationAnalysisRequest.kt diff --git a/Prezel/core/data/src/main/java/com/team/prezel/core/data/mapper/PracticeMapper.kt b/Prezel/core/data/src/main/java/com/team/prezel/core/data/mapper/PracticeMapper.kt index 56aac3c4..dd727b7b 100644 --- a/Prezel/core/data/src/main/java/com/team/prezel/core/data/mapper/PracticeMapper.kt +++ b/Prezel/core/data/src/main/java/com/team/prezel/core/data/mapper/PracticeMapper.kt @@ -13,10 +13,6 @@ import com.team.prezel.core.model.presentation.Purpose import com.team.prezel.core.model.presentation.Style 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.practice.PresentationAnalysisAudience -import com.team.prezel.core.network.model.practice.PresentationAnalysisPurpose -import com.team.prezel.core.network.model.practice.PresentationAnalysisStyle -import com.team.prezel.core.network.model.practice.PresentationAnalysisType import com.team.prezel.core.network.model.practice.PresentationRecordingAnalysisResponse import kotlin.math.roundToInt @@ -57,13 +53,13 @@ internal fun PresentationRecordingAnalysisResponse.toDomain(): PresentationRecor expectedQuestions = expectedQuestions.map { it.toDomain() }, ) -internal fun Category.toRequestType(): PresentationAnalysisType = PresentationAnalysisType.valueOf(name) +internal fun Category.toRequestType(): String = name -internal fun Purpose.toRequestPurpose(): PresentationAnalysisPurpose = PresentationAnalysisPurpose.valueOf(name) +internal fun Purpose.toRequestPurpose(): String = name -internal fun Style.toRequestStyle(): PresentationAnalysisStyle = PresentationAnalysisStyle.valueOf(name) +internal fun Style.toRequestStyle(): String = name -internal fun Audience.toRequestAudience(): PresentationAnalysisAudience = PresentationAnalysisAudience.valueOf(name) +internal fun Audience.toRequestAudience(): String = name internal fun PresentationRecordingAnalysisResponse.GrowthGraph.toDomain(): PresentationGrowthGraph = PresentationGrowthGraph( 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 index dc2218cd..7352889a 100644 --- 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 @@ -2,10 +2,6 @@ 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.practice.PresentationAnalysisAudience -import com.team.prezel.core.network.model.practice.PresentationAnalysisPurpose -import com.team.prezel.core.network.model.practice.PresentationAnalysisStyle -import com.team.prezel.core.network.model.practice.PresentationAnalysisType import com.team.prezel.core.network.model.practice.PresentationRecordingAnalysisResponse interface PracticeRemoteDataSource { @@ -19,10 +15,10 @@ interface PracticeRemoteDataSource { suspend fun analyzePresentationRecording( name: String, date: String, - type: PresentationAnalysisType, - purpose: PresentationAnalysisPurpose, - style: PresentationAnalysisStyle, - audience: PresentationAnalysisAudience, + type: String, + purpose: String, + style: String, + audience: String, script: String?, scriptFilePath: String?, audioFilePath: String, 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 index 1c2a3b11..98bfc746 100644 --- 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 @@ -2,10 +2,6 @@ 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.practice.PresentationAnalysisAudience -import com.team.prezel.core.network.model.practice.PresentationAnalysisPurpose -import com.team.prezel.core.network.model.practice.PresentationAnalysisStyle -import com.team.prezel.core.network.model.practice.PresentationAnalysisType import com.team.prezel.core.network.model.practice.PresentationRecordingAnalysisResponse import com.team.prezel.core.network.model.requireData import com.team.prezel.core.network.service.PracticeService @@ -53,10 +49,10 @@ internal class PracticeRemoteDataSourceImpl @Inject constructor( override suspend fun analyzePresentationRecording( name: String, date: String, - type: PresentationAnalysisType, - purpose: PresentationAnalysisPurpose, - style: PresentationAnalysisStyle, - audience: PresentationAnalysisAudience, + type: String, + purpose: String, + style: String, + audience: String, script: String?, scriptFilePath: String?, audioFilePath: String, @@ -71,10 +67,10 @@ internal class PracticeRemoteDataSourceImpl @Inject constructor( formData { append("name", name) append("date", date) - append("type", type.value) - append("purpose", purpose.value) - append("style", style.value) - append("audience", audience.value) + append("type", type) + append("purpose", purpose) + append("style", style) + append("audience", audience) script?.takeIf(String::isNotBlank)?.let { append("script", it) } scriptFile?.let { file -> append( diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/model/practice/PresentationAnalysisRequest.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/model/practice/PresentationAnalysisRequest.kt deleted file mode 100644 index 0e411cad..00000000 --- a/Prezel/core/network/src/main/java/com/team/prezel/core/network/model/practice/PresentationAnalysisRequest.kt +++ /dev/null @@ -1,35 +0,0 @@ -package com.team.prezel.core.network.model.practice - -enum class PresentationAnalysisType( - val value: String, -) { - EDUCATION("EDUCATION"), - WORK("WORK"), - OFFER("OFFER"), - EVENT("EVENT"), -} - -enum class PresentationAnalysisPurpose( - val value: String, -) { - INFO("INFO"), - UNDERSTANDING("UNDERSTANDING"), - EMPATHY("EMPATHY"), -} - -enum class PresentationAnalysisStyle( - val value: String, -) { - FORMAL("FORMAL"), - FRIENDLY("FRIENDLY"), - CALM("CALM"), - CASUAL("CASUAL"), -} - -enum class PresentationAnalysisAudience( - val value: String, -) { - GENERAL("GENERAL"), - PROFESSIONAL("PROFESSIONAL"), - TEAMMATE("TEAMMATE"), -} diff --git a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AnalysisFlowViewModel.kt b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AnalysisFlowViewModel.kt index 31ff945a..b29ddd2d 100644 --- a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AnalysisFlowViewModel.kt +++ b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AnalysisFlowViewModel.kt @@ -232,7 +232,7 @@ private fun AnalysisForm.toPresentationAnalysisSubmissionOrNull(): PresentationA style = style, audience = audience, script = script.takeIf(String::isNotBlank), - scriptFileUri = scriptFileUri.takeIf { scriptInputType == ScriptInputType.FILE_UPLOAD }, + scriptFileUri = scriptFileUri.takeIf { scriptInputType == ScriptInputType.FILE_UPLOAD && !it.isNullOrBlank() }, audioFileUri = audioFileUri, ) } diff --git a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/navigation/AnalysisEntryBuilder.kt b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/navigation/AnalysisEntryBuilder.kt index 21c72624..bb3a8670 100644 --- a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/navigation/AnalysisEntryBuilder.kt +++ b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/navigation/AnalysisEntryBuilder.kt @@ -15,10 +15,9 @@ import dagger.multibindings.IntoSet internal fun EntryProviderScope.featureAnalysisEntryBuilder() { entry { val navigator = LocalNavigator.current - val navigateToHome = { navigator.replaceRoot(HomeNavKey) } AnalysisScreen( - onBack = navigateToHome, + onBack = { navigator.replaceRoot(HomeNavKey) }, ) } } 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 40ded8ab..e57f2dc8 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,11 +90,11 @@ internal class TermsViewModel @Inject constructor( AppError.SERVER_ERROR -> TermsUiMessage.AGREE_TERMS_FAILED_SERVER AppError.NOT_FOUND, AppError.DUPLICATE, - AppError.VOICE_RECOGNITION_FAILED, - AppError.SCRIPT_FILE_RECOGNITION_FAILED, AppError.UNKNOWN, null, -> TermsUiMessage.AGREE_TERMS_FAILED_UNKNOWN + + else -> TermsUiMessage.AGREE_TERMS_FAILED_UNKNOWN } } } From aa7029417b1b4427e9eec0ba37b1fb490c30e636 Mon Sep 17 00:00:00 2001 From: Ham BeomJoon Date: Sun, 24 May 2026 20:44:44 +0900 Subject: [PATCH 15/16] =?UTF-8?q?refactor:=20=EB=A6=AC=EC=86=8C=EC=8A=A4?= =?UTF-8?q?=20ID=20=EB=AA=85=EB=AA=85=20=EA=B7=9C=EC=B9=99=20=ED=86=B5?= =?UTF-8?q?=EC=9D=BC=20=EB=B0=8F=20=EB=B6=84=EC=84=9D=20=EB=A1=9C=EC=A7=81?= =?UTF-8?q?=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `strings.xml` 내 발표 관련 칩(Category, Purpose, Style, Audience)의 리소스 ID를 도메인 모델 키워드에 맞춰 변경 - `HistoryPresentationCard`에서 변경된 리소스 ID를 참조하도록 수정 - `AnalysisFlowViewModel`에서 분석 요청 시 중복 요청 방지 및 취소 로직 추가 (`analyzeJob` 관리) - 분석 중 뒤로 가기 시 진행 중인 분석 작업(`Job`)을 취소하도록 개선 - `PresentationAnalysisSubmission` 생성 시 스크립트 입력 타입(`ScriptInputType`)에 따른 데이터 매핑 로직 정교화 --- .../analysis/impl/AnalysisFlowViewModel.kt | 20 ++++++++++++++----- .../impl/component/HistoryPresentationCard.kt | 20 +++++++++---------- .../impl/src/main/res/values/strings.xml | 20 +++++++++---------- 3 files changed, 35 insertions(+), 25 deletions(-) diff --git a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AnalysisFlowViewModel.kt b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AnalysisFlowViewModel.kt index b29ddd2d..4904267c 100644 --- a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AnalysisFlowViewModel.kt +++ b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AnalysisFlowViewModel.kt @@ -18,6 +18,7 @@ import com.team.prezel.feature.analysis.impl.contract.AnalysisSituationOption import com.team.prezel.feature.analysis.impl.contract.AnalysisUploadType import com.team.prezel.feature.analysis.impl.contract.ScriptInputType import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Job import kotlinx.coroutines.launch import kotlinx.datetime.LocalDate import javax.inject.Inject @@ -27,6 +28,8 @@ internal class AnalysisFlowViewModel @Inject constructor( private val analyzePresentationRecordingUseCase: AnalyzePresentationRecordingUseCase, private val analysisFileCache: AnalysisFileCache, ) : BaseViewModel(AnalysisFlowUiState()) { + private var analyzeJob: Job? = null + override fun onIntent(intent: AnalysisFlowUiIntent) { when (intent) { is AnalysisFlowUiIntent.UpdatePresentationTitle -> updateForm { copy(presentationTitle = intent.title) } @@ -84,11 +87,15 @@ internal class AnalysisFlowViewModel @Inject constructor( updateState { copy(step = AnalysisFlowStep.ANALYZING) } - viewModelScope.launch { + analyzeJob?.cancel() + analyzeJob = viewModelScope.launch { submission .analyzePresentationRecording() - .onSuccess(::handleAnalysisSuccess) - .onFailure { throwable -> handleAnalysisFailure(throwable.toAnalysisFailureAction()) } + .onSuccess { result -> + if (currentState.step == AnalysisFlowStep.ANALYZING) { + handleAnalysisSuccess(result) + } + }.onFailure { throwable -> handleAnalysisFailure(throwable.toAnalysisFailureAction()) } } } @@ -182,6 +189,8 @@ internal class AnalysisFlowViewModel @Inject constructor( } private fun moveBack() { + if (currentState.step == AnalysisFlowStep.ANALYZING) analyzeJob?.cancel() + val previousStep = when (currentState.step) { AnalysisFlowStep.PRESENTATION_SCHEDULE -> null AnalysisFlowStep.PRESENTATION_SITUATION -> AnalysisFlowStep.PRESENTATION_SCHEDULE @@ -223,6 +232,7 @@ private fun AnalysisForm.toPresentationAnalysisSubmissionOrNull(): PresentationA val style = style ?: return null val audience = audience ?: return null val audioFileUri = audioFileUri ?: return null + val isFileUpload = scriptInputType == ScriptInputType.FILE_UPLOAD return PresentationAnalysisSubmission( name = presentationTitle.trim(), @@ -231,8 +241,8 @@ private fun AnalysisForm.toPresentationAnalysisSubmissionOrNull(): PresentationA purpose = purpose, style = style, audience = audience, - script = script.takeIf(String::isNotBlank), - scriptFileUri = scriptFileUri.takeIf { scriptInputType == ScriptInputType.FILE_UPLOAD && !it.isNullOrBlank() }, + script = script.takeIf { !isFileUpload && it.isNotBlank() }, + scriptFileUri = scriptFileUri.takeIf { isFileUpload && !it.isNullOrBlank() }, audioFileUri = audioFileUri, ) } diff --git a/Prezel/feature/history/impl/src/main/java/com/team/prezel/feature/history/impl/component/HistoryPresentationCard.kt b/Prezel/feature/history/impl/src/main/java/com/team/prezel/feature/history/impl/component/HistoryPresentationCard.kt index db814696..10728ea2 100644 --- a/Prezel/feature/history/impl/src/main/java/com/team/prezel/feature/history/impl/component/HistoryPresentationCard.kt +++ b/Prezel/feature/history/impl/src/main/java/com/team/prezel/feature/history/impl/component/HistoryPresentationCard.kt @@ -153,35 +153,35 @@ private fun HistoryMetaChip( @StringRes private fun Category.labelResId(): Int = when (this) { - Category.OFFER -> R.string.feature_history_impl_category_persuasion - Category.EVENT -> R.string.feature_history_impl_category_event Category.EDUCATION -> R.string.feature_history_impl_category_education - Category.WORK -> R.string.feature_history_impl_category_report + Category.WORK -> R.string.feature_history_impl_category_work + Category.OFFER -> R.string.feature_history_impl_category_offer + Category.EVENT -> R.string.feature_history_impl_category_event } @StringRes private fun Purpose.labelResId(): Int = when (this) { - Purpose.INFO -> R.string.feature_history_impl_purpose_content_delivery - Purpose.UNDERSTANDING -> R.string.feature_history_impl_purpose_improve_understanding - Purpose.EMPATHY -> R.string.feature_history_impl_purpose_build_empathy + Purpose.INFO -> R.string.feature_history_impl_purpose_info + Purpose.UNDERSTANDING -> R.string.feature_history_impl_purpose_understanding + Purpose.EMPATHY -> R.string.feature_history_impl_purpose_empathy } @StringRes private fun Style.labelResId(): Int = when (this) { - Style.FORMAL -> R.string.feature_history_impl_style_professional + Style.FORMAL -> R.string.feature_history_impl_style_formal Style.FRIENDLY -> R.string.feature_history_impl_style_friendly Style.CALM -> R.string.feature_history_impl_style_calm - Style.CASUAL -> R.string.feature_history_impl_style_comfortable + Style.CASUAL -> R.string.feature_history_impl_style_casual } @StringRes private fun Audience.labelResId(): Int = when (this) { Audience.GENERAL -> R.string.feature_history_impl_audience_general - Audience.PROFESSIONAL -> R.string.feature_history_impl_audience_expert - Audience.TEAMMATE -> R.string.feature_history_impl_audience_teammates + Audience.PROFESSIONAL -> R.string.feature_history_impl_audience_professional + Audience.TEAMMATE -> R.string.feature_history_impl_audience_teammate } @BasicPreview diff --git a/Prezel/feature/history/impl/src/main/res/values/strings.xml b/Prezel/feature/history/impl/src/main/res/values/strings.xml index f8800ebb..57e189ba 100644 --- a/Prezel/feature/history/impl/src/main/res/values/strings.xml +++ b/Prezel/feature/history/impl/src/main/res/values/strings.xml @@ -8,20 +8,20 @@ 발표 추가하기 - 설득·제안 - 행사·공개 학술·교육 - 업무·보고 - 내용 전달 - 이해 증진 - 공감 형성 - 전문적인 + 업무·보고 + 설득·제안 + 행사·공개 + 내용 전달 + 이해 증진 + 공감 형성 + 전문적인 친근한 차분한 - 편안한 + 편안한 일반 청중 - 전문가 - 팀/동료 + 전문가 + 팀/동료 데이터를 불러오지 못했습니다. From 89ef4c47d11162d52944efea5238cb495a259ad4 Mon Sep 17 00:00:00 2001 From: Ham BeomJoon Date: Sun, 24 May 2026 21:00:36 +0900 Subject: [PATCH 16/16] =?UTF-8?q?refactor:=20=EB=B0=9C=ED=91=9C=20?= =?UTF-8?q?=EC=83=81=ED=99=A9=20=EC=84=A0=ED=83=9D=20=EC=98=B5=EC=85=98=20?= =?UTF-8?q?=EC=95=84=EC=9D=B4=ED=85=9C=20=EB=A0=88=EC=9D=B4=EC=95=84?= =?UTF-8?q?=EC=9B=83=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `aspectRatio(1f)`를 추가하여 선택 옵션 아이템이 정방형 비율을 유지하도록 수정 - 아이템 내부 `Column`에 `fillMaxSize`를 적용하여 가용 영역을 전체로 확장 - 고정 높이 `Spacer`를 가변 가중치(`weight(1f)`)로 변경하여 내부 요소 배치 최적화 --- .../impl/situation/PresentationSituationScreen.kt | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/situation/PresentationSituationScreen.kt b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/situation/PresentationSituationScreen.kt index aa6775dd..b7d5f15e 100644 --- a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/situation/PresentationSituationScreen.kt +++ b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/situation/PresentationSituationScreen.kt @@ -8,6 +8,8 @@ import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding @@ -199,7 +201,9 @@ private fun CategoryOptionGrid( option = option, selected = selectedValue == option.value, onClick = { onSelect(option.value) }, - modifier = Modifier.weight(1f), + modifier = Modifier + .weight(1f) + .aspectRatio(1f), ) } if (rowOptions.size == 1) { @@ -229,7 +233,7 @@ private fun CategoryOptionCard( ) { Column( modifier = Modifier - .fillMaxWidth() + .fillMaxSize() .background(backgroundColor, PrezelTheme.shapes.V8) .border( width = PrezelTheme.stroke.V1, @@ -248,7 +252,7 @@ private fun CategoryOptionCard( color = PrezelTheme.colors.textRegular, style = PrezelTheme.typography.caption2Regular, ) - Spacer(modifier = Modifier.height(PrezelTheme.spacing.V4)) + Spacer(modifier = Modifier.weight(1f)) Icon( painter = painterResource(option.iconResId), contentDescription = null,