diff --git a/Prezel/app/build.gradle.kts b/Prezel/app/build.gradle.kts index 5cd625c3..8c91c85a 100644 --- a/Prezel/app/build.gradle.kts +++ b/Prezel/app/build.gradle.kts @@ -51,6 +51,7 @@ dependencies { implementation(projects.featureTermsImpl) implementation(projects.featurePracticeImpl) + implementation(projects.featureAnalysisImpl) implementation(projects.featureSettingImpl) implementation(projects.featureProfileImpl) 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 72b731d3..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,17 +40,17 @@ 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.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/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..dd727b7b --- /dev/null +++ b/Prezel/core/data/src/main/java/com/team/prezel/core/data/mapper/PracticeMapper.kt @@ -0,0 +1,92 @@ +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.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(): String = name + +internal fun Purpose.toRequestPurpose(): String = name + +internal fun Style.toRequestStyle(): String = name + +internal fun Audience.toRequestAudience(): String = 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 73795356..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,16 +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.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 javax.inject.Inject -import kotlin.math.roundToInt internal class PracticeRepositoryImpl @Inject constructor( private val practiceRemoteDataSource: PracticeRemoteDataSource, @@ -22,6 +27,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, @@ -34,36 +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 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/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 785093e6..00000000 --- a/Prezel/core/data/src/test/java/com/team/prezel/core/data/repository/practice/PracticeRepositoryImplTest.kt +++ /dev/null @@ -1,76 +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.network.datasource.PracticeRemoteDataSource -import com.team.prezel.core.network.model.practice.AnalyzePracticeRecordingResponse -import com.team.prezel.core.network.model.practice.PracticeSentenceResponse -import kotlinx.coroutines.runBlocking -import kotlin.test.Test -import kotlin.test.assertEquals - -class PracticeRepositoryImplTest { - @Test - fun `연습 녹음 대본을 조회한다`() = - runBlocking { - val repository = PracticeRepositoryImpl( - practiceRemoteDataSource = FakePracticeRemoteDataSource( - sentence = "간장 공장 공장장은 강 공장장이다.", - ), - ) - - val result = repository.fetchPracticeScript().getOrThrow() - - assertEquals(0L, result.id) - assertEquals("간장 공장 공장장은 강 공장장이다.", result.content) - } - - @Test - fun `연습 녹음 분석을 요청하고 분석 결과로 변환한다`() = - runBlocking { - val remoteDataSource = FakePracticeRemoteDataSource( - sentence = "간장 공장 공장장은 강 공장장이다.", - ) - val repository = PracticeRepositoryImpl( - practiceRemoteDataSource = remoteDataSource, - ) - - val result = repository - .analyzePracticeRecording( - recordingFilePath = "/tmp/practice.wav", - referenceText = "간장 공장 공장장은 강 공장장이다.", - ).getOrThrow() - - assertEquals("/tmp/practice.wav", remoteDataSource.analyzeRecordingFilePath) - assertEquals("간장 공장 공장장은 강 공장장이다.", remoteDataSource.analyzeReferenceText) - assertEquals(86, result.pronunciationScore) - assertEquals(PracticeRecordingSpeed.ADEQUATE, result.speed) - assertEquals(PracticeRecordingOverallEvaluation.GOOD, result.overallEvaluation) - } - - private class FakePracticeRemoteDataSource( - private val sentence: String, - ) : PracticeRemoteDataSource { - var analyzeRecordingFilePath: String? = null - private set - var analyzeReferenceText: String? = null - private set - - override suspend fun getPracticeSentence(): PracticeSentenceResponse = PracticeSentenceResponse(sentence = sentence) - - override suspend fun analyzePracticeRecording( - recordingFilePath: String, - referenceText: String, - ): AnalyzePracticeRecordingResponse { - analyzeRecordingFilePath = recordingFilePath - analyzeReferenceText = referenceText - - return AnalyzePracticeRecordingResponse( - accuracyScore = 85.5, - speedEvaluation = "적당해요", - overallEvaluation = "Good", - ) - } - } -} diff --git a/Prezel/core/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/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/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 0dfd03da..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 @@ -1,8 +1,8 @@ package com.team.prezel.core.model.presentation enum class Category { - PERSUASION, - EVENT, EDUCATION, - REPORT, + WORK, + OFFER, + EVENT, } 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/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/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..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,6 +2,7 @@ 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.PresentationRecordingAnalysisResponse interface PracticeRemoteDataSource { suspend fun getPracticeSentence(): PracticeSentenceResponse @@ -10,4 +11,16 @@ interface PracticeRemoteDataSource { recordingFilePath: String, referenceText: String, ): AnalyzePracticeRecordingResponse + + suspend fun analyzePresentationRecording( + name: String, + date: String, + type: String, + purpose: String, + style: String, + audience: String, + 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..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,12 +2,15 @@ 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.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 @@ -20,12 +23,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}\"") @@ -40,4 +45,61 @@ internal class PracticeRemoteDataSourceImpl @Inject constructor( audio = multipart, ).requireData() } + + override suspend fun analyzePresentationRecording( + name: String, + date: String, + type: String, + purpose: String, + style: String, + audience: String, + script: String?, + scriptFilePath: String?, + audioFilePath: String, + ): PresentationRecordingAnalysisResponse { + require(audioFilePath.isNotBlank()) { "발표 음성 파일 경로가 비어 있습니다." } + + val audioFile = File(audioFilePath) + val scriptFile = scriptFilePath + ?.takeIf(String::isNotBlank) + ?.let(::File) + val multipart = MultiPartFormDataContent( + formData { + append("name", name) + append("date", date) + append("type", type) + append("purpose", purpose) + append("style", style) + append("audience", audience) + script?.takeIf(String::isNotBlank)?.let { append("script", it) } + scriptFile?.let { file -> + append( + key = "scriptFile", + value = file.toChannelProvider(), + headers = Headers.build { + append(HttpHeaders.ContentType, "text/${file.extension}") + append(HttpHeaders.ContentDisposition, "filename=\"${file.name}\"") + }, + ) + } + append( + key = "audio", + value = audioFile.toChannelProvider(), + headers = Headers.build { + append(HttpHeaders.ContentType, "audio/${audioFile.extension}") + append(HttpHeaders.ContentDisposition, "filename=\"${audioFile.name}\"") + }, + ) + }, + ) + + return practiceService + .analyzePresentationRecording(multipart = multipart) + .requireData() + } } + +private fun File.toChannelProvider(): ChannelProvider = + ChannelProvider(size = length()) { + inputStream().toByteReadChannel() + } 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/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..0cf92b9b --- /dev/null +++ b/Prezel/feature/analysis/impl/build.gradle.kts @@ -0,0 +1,17 @@ +plugins { + alias(libs.plugins.prezel.android.feature.impl) +} + +android { + namespace = "com.team.prezel.feature.analysis.impl" +} + +dependencies { + implementation(projects.coreDomain) + 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/AnalysisFailureHandler.kt b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AnalysisFailureHandler.kt new file mode 100644 index 00000000..1bda155d --- /dev/null +++ b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AnalysisFailureHandler.kt @@ -0,0 +1,38 @@ +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.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) + + 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 new file mode 100644 index 00000000..4904267c --- /dev/null +++ b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AnalysisFlowViewModel.kt @@ -0,0 +1,259 @@ +package com.team.prezel.feature.analysis.impl + +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.cache.AnalysisFileCache +import com.team.prezel.feature.analysis.impl.contract.AnalysisFlowStep +import com.team.prezel.feature.analysis.impl.contract.AnalysisFlowUiEffect +import com.team.prezel.feature.analysis.impl.contract.AnalysisFlowUiIntent +import com.team.prezel.feature.analysis.impl.contract.AnalysisFlowUiState +import com.team.prezel.feature.analysis.impl.contract.AnalysisForm +import com.team.prezel.feature.analysis.impl.contract.AnalysisSituationOption +import com.team.prezel.feature.analysis.impl.contract.AnalysisUploadType +import com.team.prezel.feature.analysis.impl.contract.ScriptInputType +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import kotlinx.datetime.LocalDate +import javax.inject.Inject + +@HiltViewModel +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) } + 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 moveNext() { + if (!currentState.canMoveNext) return + + if (currentState.step == AnalysisFlowStep.AUDIO_UPLOAD) { + analyzePresentation() + return + } + + updateState { + copy( + step = when (step) { + AnalysisFlowStep.PRESENTATION_SCHEDULE -> AnalysisFlowStep.PRESENTATION_SITUATION + AnalysisFlowStep.PRESENTATION_SITUATION -> AnalysisFlowStep.SCRIPT_INPUT + AnalysisFlowStep.SCRIPT_INPUT -> AnalysisFlowStep.AUDIO_UPLOAD + AnalysisFlowStep.AUDIO_UPLOAD, + AnalysisFlowStep.ANALYZING, + AnalysisFlowStep.REPORT, + AnalysisFlowStep.FILE_RECOGNITION_FAILED, + AnalysisFlowStep.SCRIPT_FILE_RECOGNITION_FAILED, + -> step + }, + ) + } + } + + private fun analyzePresentation() { + val submission = currentState.form.toPresentationAnalysisSubmissionOrNull() ?: return + + updateState { copy(step = AnalysisFlowStep.ANALYZING) } + + analyzeJob?.cancel() + analyzeJob = viewModelScope.launch { + submission + .analyzePresentationRecording() + .onSuccess { result -> + if (currentState.step == AnalysisFlowStep.ANALYZING) { + handleAnalysisSuccess(result) + } + }.onFailure { throwable -> handleAnalysisFailure(throwable.toAnalysisFailureAction()) } + } + } + + private suspend fun PresentationAnalysisSubmission.analyzePresentationRecording(): Result = + runCatching { + val audioFile = analysisFileCache.copyUriToCache( + uriString = audioFileUri, + prefix = "audio", + ) + val scriptFile = scriptFileUri?.let { uri -> + analysisFileCache.copyUriToCache( + uriString = uri, + prefix = "script", + ) + } + + 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, + ) + } + } + + 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 + }, + ) + } + } + + is AnalysisFailureAction.ShowMessage -> { + viewModelScope.launch { sendEffect(AnalysisFlowUiEffect.ShowMessage(action.message)) } + updateState { copy(step = AnalysisFlowStep.AUDIO_UPLOAD) } + } + } + } + + 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 + + updateState { copy(step = AnalysisFlowStep.AUDIO_UPLOAD) } + } + + private fun moveBack() { + if (currentState.step == AnalysisFlowStep.ANALYZING) analyzeJob?.cancel() + + val previousStep = when (currentState.step) { + AnalysisFlowStep.PRESENTATION_SCHEDULE -> null + AnalysisFlowStep.PRESENTATION_SITUATION -> AnalysisFlowStep.PRESENTATION_SCHEDULE + AnalysisFlowStep.SCRIPT_INPUT -> AnalysisFlowStep.PRESENTATION_SITUATION + AnalysisFlowStep.AUDIO_UPLOAD -> AnalysisFlowStep.SCRIPT_INPUT + AnalysisFlowStep.ANALYZING -> AnalysisFlowStep.AUDIO_UPLOAD + AnalysisFlowStep.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) } + } else { + updateState { copy(step = previousStep) } + } + } + + private fun updateForm(reducer: AnalysisForm.() -> AnalysisForm) { + updateState { copy(form = form.reducer()) } + } +} + +private data class PresentationAnalysisSubmission( + val name: String, + val date: String, + val category: Category, + val purpose: Purpose, + val style: Style, + val audience: Audience, + val script: String?, + val scriptFileUri: String?, + val audioFileUri: String, +) + +private fun AnalysisForm.toPresentationAnalysisSubmissionOrNull(): PresentationAnalysisSubmission? { + val category = category ?: return null + val purpose = purpose ?: return null + val style = style ?: return null + val audience = audience ?: return null + val audioFileUri = audioFileUri ?: return null + val isFileUpload = scriptInputType == ScriptInputType.FILE_UPLOAD + + return PresentationAnalysisSubmission( + name = presentationTitle.trim(), + date = presentationDate, + category = category, + purpose = purpose, + style = style, + audience = audience, + script = script.takeIf { !isFileUpload && it.isNotBlank() }, + scriptFileUri = scriptFileUri.takeIf { isFileUpload && !it.isNullOrBlank() }, + audioFileUri = audioFileUri, + ) +} + +private 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 new file mode 100644 index 00000000..a04522b9 --- /dev/null +++ b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AnalysisScreen.kt @@ -0,0 +1,110 @@ +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 +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( + onBack: () -> Unit, + 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)) + } + } + } + } + + AnalysisScreen( + uiState = uiState, + onIntent = viewModel::onIntent, + ) +} + +@Composable +private fun AnalysisScreen( + uiState: AnalysisFlowUiState, + onIntent: (AnalysisFlowUiIntent) -> 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.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) }, + ) + + 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 -> AnalysisLoadingScreen() + + AnalysisFlowStep.REPORT -> AnalysisReportScreen() + + AnalysisFlowStep.FILE_RECOGNITION_FAILED -> FileRecognitionFailedScreen( + onRetry = { onIntent(AnalysisFlowUiIntent.RetryFileUpload(AnalysisUploadType.AUDIO)) }, + ) + + AnalysisFlowStep.SCRIPT_FILE_RECOGNITION_FAILED -> ScriptFileRecognitionFailedScreen( + onRetry = { onIntent(AnalysisFlowUiIntent.RetryFileUpload(AnalysisUploadType.SCRIPT)) }, + ) + } +} 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 new file mode 100644 index 00000000..fc93d065 --- /dev/null +++ b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/audio/AudioUploadScreen.kt @@ -0,0 +1,394 @@ +package com.team.prezel.feature.analysis.impl.audio + +import android.content.Context +import android.media.MediaPlayer +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.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.pager.rememberPagerState +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.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 +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 +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.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 +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 +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/mpeg", // mp3 + "audio/mp4", // mp4, m4a + "audio/x-m4a", // m4a +) +private const val AUDIO_UPLOAD_TAB_COUNT = 1 + +@Composable +internal fun AudioUploadScreen( + uiState: AnalysisFlowUiState, + onAudioFileSelected: (String?) -> Unit, + onAnalyze: () -> Unit, + onBack: () -> Unit, +) { + 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_TYPES) }, + onAudioFileClear = { + if (pendingAudioFileUri != null) { + pendingAudioFileUri = null + } else { + onAudioFileSelected(null) + } + }, + onAnalyze = onAnalyze, + onBack = onBack, + ) +} + +@Composable +private fun AudioUploadScreen( + form: AnalysisForm, + pendingAudioFileUri: String?, + uploadProgress: Float, + 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 = pendingAudioFileUri ?: form.audioFileUri, + uploadProgress = if (pendingAudioFileUri != null) uploadProgress else null, + onUploadClick = onAudioFileUploadClick, + onClear = onAudioFileClear, + ) + } +} + +@Composable +private fun AudioUploadContent( + fileUri: String?, + uploadProgress: Float?, + onUploadClick: () -> Unit, + onClear: () -> Unit, +) { + if (fileUri == null) { + AudioUploadEmptyContent(onUploadClick = onUploadClick) + } else { + val context = LocalContext.current + val playbackState = rememberAudioUploadPlaybackState( + fileUri = fileUri, + enabled = uploadProgress == null, + ) + + Spacer(modifier = Modifier.height(PrezelTheme.spacing.V16)) + FileUploader( + fileName = remember(context, fileUri) { fileUri.toFileName(context) }, + 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, fileUri.toUri())?.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( + 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, + ) + }, + ) +} + +@BasicPreview +@Composable +private fun AudioUploadScreenPreview() { + PrezelTheme { + AudioUploadScreen( + uiState = AnalysisFlowUiState(step = AnalysisFlowStep.AUDIO_UPLOAD), + onAudioFileSelected = {}, + onAnalyze = {}, + onBack = {}, + ) + } +} + +@BasicPreview +@Composable +private fun AudioUploadScreenProgressPreview() { + PrezelTheme { + AudioUploadScreen( + form = AnalysisForm(), + pendingAudioFileUri = "content://prezel/sample.m4a", + uploadProgress = 0.5f, + progress = AnalysisFlowUiState(step = AnalysisFlowStep.AUDIO_UPLOAD).progress, + buttonEnabled = false, + onAudioFileUploadClick = {}, + onAudioFileClear = {}, + onAnalyze = {}, + onBack = {}, + ) + } +} + +@BasicPreview +@Composable +private fun AudioUploadScreenSelectedPreview() { + PrezelTheme { + AudioUploadScreen( + uiState = AnalysisFlowUiState( + step = AnalysisFlowStep.AUDIO_UPLOAD, + form = AnalysisForm(audioFileUri = "content://prezel/sample.m4a"), + ), + onAudioFileSelected = {}, + onAnalyze = {}, + onBack = {}, + ) + } +} 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..4d096445 --- /dev/null +++ b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/cache/AnalysisFileCacheImpl.kt @@ -0,0 +1,56 @@ +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) + 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 + } + } + + private companion object { + const val DEFAULT_EXTENSION = "tmp" + } +} 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/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..feab4c3b --- /dev/null +++ b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/component/AnalysisStepLayout.kt @@ -0,0 +1,190 @@ +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.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 +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( + showBackground = true, + 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 +internal fun AnalysisStepTitle( + title: String, + description: String, +) { + Text( + text = title, + color = PrezelTheme.colors.textLarge, + style = PrezelTheme.typography.title2Bold, + ) + 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/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..6ea86141 --- /dev/null +++ b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/contract/AnalysisFlowUiEffect.kt @@ -0,0 +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/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..e60b5f9a --- /dev/null +++ b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/contract/AnalysisFlowUiIntent.kt @@ -0,0 +1,80 @@ +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 SelectSituationOption( + val option: AnalysisSituationOption, + ) : 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 class RetryFileUpload( + val uploadType: AnalysisUploadType, + ) : AnalysisFlowUiIntent + + 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 { + 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 new file mode 100644 index 00000000..737ccb0a --- /dev/null +++ b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/contract/AnalysisFlowUiState.kt @@ -0,0 +1,83 @@ +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 + +@Immutable +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) { + 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, + AnalysisFlowStep.REPORT, + AnalysisFlowStep.FILE_RECOGNITION_FAILED, + -> 1f + } + + val canMoveNext: Boolean + 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.isNullOrBlank() + ScriptInputType.DIRECT_INPUT -> form.script.isNotBlank() + } + + AnalysisFlowStep.AUDIO_UPLOAD -> !form.audioFileUri.isNullOrBlank() + AnalysisFlowStep.ANALYZING, + AnalysisFlowStep.REPORT, + AnalysisFlowStep.FILE_RECOGNITION_FAILED, + AnalysisFlowStep.SCRIPT_FILE_RECOGNITION_FAILED, + -> 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, + REPORT, + FILE_RECOGNITION_FAILED, + SCRIPT_FILE_RECOGNITION_FAILED, +} 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 +} 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/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..bb3a8670 --- /dev/null +++ b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/navigation/AnalysisEntryBuilder.kt @@ -0,0 +1,34 @@ +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 + + AnalysisScreen( + onBack = { navigator.replaceRoot(HomeNavKey) }, + ) + } +} + +@Module +@InstallIn(ActivityRetainedComponent::class) +object FeatureAnalysisModule { + @IntoSet + @Provides + fun provideFeatureAnalysisEntryBuilder(): EntryProviderScope.() -> Unit = + { + featureAnalysisEntryBuilder() + } +} 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 new file mode 100644 index 00000000..5d709251 --- /dev/null +++ b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/result/AnalysisLoadingScreen.kt @@ -0,0 +1,50 @@ +package com.team.prezel.feature.analysis.impl.result + +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 com.team.prezel.feature.analysis.impl.R +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( + modifier: Modifier = Modifier, + onFinished: (() -> Unit)? = null, +) { + if (onFinished != null) { + 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() + } +} diff --git a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/result/AnalysisReportScreen.kt b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/result/AnalysisReportScreen.kt new file mode 100644 index 00000000..33a97f9e --- /dev/null +++ b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/result/AnalysisReportScreen.kt @@ -0,0 +1,34 @@ +package com.team.prezel.feature.analysis.impl.result + +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 +import com.team.prezel.feature.analysis.impl.R + +@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/result/FileRecognitionFailedScreen.kt b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/result/FileRecognitionFailedScreen.kt new file mode 100644 index 00000000..50f6ce1f --- /dev/null +++ b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/result/FileRecognitionFailedScreen.kt @@ -0,0 +1,84 @@ +package com.team.prezel.feature.analysis.impl.result + +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 +import com.team.prezel.feature.analysis.impl.R + +@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/schedule/PresentationScheduleScreen.kt b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/schedule/PresentationScheduleScreen.kt new file mode 100644 index 00000000..2c5373fc --- /dev/null +++ b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/schedule/PresentationScheduleScreen.kt @@ -0,0 +1,211 @@ +package com.team.prezel.feature.analysis.impl.schedule + +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.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 +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 datePickerTitle = stringResource(R.string.feature_analysis_impl_presentation_date_label) + + if (showDatePicker.value) { + PresentationDatePicker( + title = datePickerTitle, + selectedDateText = form.presentationDate, + 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)) + + 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, + ) + }, + ) + + Box( + modifier = Modifier + .matchParentSize() + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = onClick, + ), + ) + } + } +} + +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/script/ScriptInputScreen.kt b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/script/ScriptInputScreen.kt new file mode 100644 index 00000000..bb84cd1c --- /dev/null +++ b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/script/ScriptInputScreen.kt @@ -0,0 +1,357 @@ +package com.team.prezel.feature.analysis.impl.script + +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.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.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.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.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 +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 +import kotlinx.coroutines.delay + +private const val SCRIPT_MAX_LENGTH = 5_000 +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 +internal fun ScriptInputScreen( + uiState: AnalysisFlowUiState, + onSelectInputType: (ScriptInputType) -> Unit, + onScriptChange: (String) -> Unit, + onScriptFileSelected: (String?) -> Unit, + onNext: () -> Unit, + 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 -> + 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 = { + if (pendingScriptFileUri != null) { + pendingScriptFileUri = null + } else { + onScriptFileSelected(null) + } + }, + onNext = onNext, + onSkip = onSkip, + onBack = onBack, + ) +} + +@Composable +private fun ScriptInputScreen( + form: AnalysisForm, + pendingScriptFileUri: String?, + uploadProgress: Float, + 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 = pendingScriptFileUri ?: form.scriptFileUri, + uploadProgress = if (pendingScriptFileUri != null) uploadProgress else null, + 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?, + uploadProgress: Float?, + onClick: () -> Unit, + onClear: () -> Unit, +) { + if (fileUri == null) { + EmptyScriptUploadContent(onClick = onClick) + } else { + UploadedScriptFileCard( + fileUri = fileUri, + uploadProgress = uploadProgress, + 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() + .height(420.dp), + visual = { + Image( + painter = painterResource(R.drawable.feature_analysis_impl_no_script), + contentDescription = null, + modifier = Modifier.size(120.dp), + ) + }, + 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 UploadedScriptFileCard( + fileUri: String, + uploadProgress: Float?, + onClear: () -> Unit, +) { + val context = LocalContext.current + val fileName = remember(context, fileUri) { fileUri.toFileName(context) } + + FileUploader( + fileName = fileName, + state = if (uploadProgress == null) { + FileUploaderState.Script.Uploaded + } else { + FileUploaderState.Script.Loading + }, + progress = uploadProgress ?: 0f, + onCancelClick = onClear, + ) +} + +@BasicPreview +@Composable +private fun ScriptInputUploadScreenPreview() { + PrezelTheme { + ScriptInputScreen( + uiState = AnalysisFlowUiState(step = AnalysisFlowStep.SCRIPT_INPUT), + onSelectInputType = {}, + onScriptChange = {}, + onScriptFileSelected = {}, + onNext = {}, + onSkip = {}, + onBack = {}, + ) + } +} + +@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() { + 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/situation/PresentationSituationScreen.kt b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/situation/PresentationSituationScreen.kt new file mode 100644 index 00000000..b7d5f15e --- /dev/null +++ b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/situation/PresentationSituationScreen.kt @@ -0,0 +1,388 @@ +package com.team.prezel.feature.analysis.impl.situation + +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.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 +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 +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.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 +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 +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, +) { + 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)) + + 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), + selectedText = categoryOptions.firstOrNull { it.value == form.category }?.title, + ) { + CategoryOptionGrid( + selectedValue = form.category, + options = categoryOptions, + onSelect = onSelectCategory, + ) + } + 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), + onSelect = { index -> onSelectPurpose(purposeOptions[index].value) }, + ) + } + 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), + onSelect = { index -> onSelectStyle(styleOptions[index].value) }, + ) + } + 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), + onSelect = { index -> onSelectAudience(audienceOptions[index].value) }, + ) + } +} + +@Composable +private fun SituationAccordion( + title: String, + selectedText: String?, + content: @Composable () -> Unit, +) { + 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 +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) + .aspectRatio(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 + .fillMaxSize() + .background(backgroundColor, PrezelTheme.shapes.V8) + .border( + width = PrezelTheme.stroke.V1, + color = borderColor, + shape = PrezelTheme.shapes.V8, + ).padding(PrezelTheme.spacing.V12), + ) { + Text( + text = option.title, + color = PrezelTheme.colors.textLarge, + style = PrezelTheme.typography.body2Bold, + ) + Spacer(modifier = Modifier.height(PrezelTheme.spacing.V4)) + Text( + text = option.description, + color = PrezelTheme.colors.textRegular, + style = PrezelTheme.typography.caption2Regular, + ) + Spacer(modifier = Modifier.weight(1f)) + Icon( + painter = painterResource(option.iconResId), + contentDescription = null, + modifier = Modifier + .align(Alignment.End) + .size(64.dp), + tint = iconColor, + ) + } + } +} + +@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 = ChipType.OUTLINED, + size = ChipSize.REGULAR, + state = if (selected) ChipState.ACTIVE else ChipState.DEFAULT, + ) + } +} + +@BasicPreview +@Composable +private fun PresentationSituationScreenPreview() { + PrezelTheme { + PresentationSituationScreen( + uiState = AnalysisFlowUiState( + step = AnalysisFlowStep.PRESENTATION_SITUATION, + form = AnalysisForm( + category = Category.EDUCATION, + purpose = Purpose.INFO, + style = Style.CALM, + audience = Audience.PROFESSIONAL, + ), + ), + 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.INFO), + 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.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 new file mode 100644 index 00000000..027cecc9 --- /dev/null +++ b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/situation/SituationOptions.kt @@ -0,0 +1,123 @@ +package com.team.prezel.feature.analysis.impl.situation + +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 com.team.prezel.feature.analysis.impl.R +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.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.WORK -> R.string.feature_analysis_impl_situation_category_business + } + +private val Category.descriptionResId: Int + @StringRes get() = when (this) { + 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.WORK -> R.string.feature_analysis_impl_situation_category_business_description + } + +private val Category.iconResId: Int + @DrawableRes get() = when (this) { + Category.OFFER -> PrezelIcons.Hand + Category.EVENT -> PrezelIcons.Balloon + Category.EDUCATION -> PrezelIcons.College + Category.WORK -> PrezelIcons.Company + } + +private val Purpose.titleResId: Int + @StringRes get() = when (this) { + 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.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.CASUAL -> R.string.feature_analysis_impl_situation_style_comfortable + } + +private val Audience.titleResId: Int + @StringRes get() = when (this) { + 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/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/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/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..e6570fdc --- /dev/null +++ b/Prezel/feature/analysis/impl/src/main/res/values/strings.xml @@ -0,0 +1,77 @@ + + + 발표 일정 설정 + 새로운 발표 일정을 만들어주세요. + 발표 이름과 날짜를 입력해주세요. + 발표 이름 + 발표의 이름을 설정해주세요 + 발표 날짜 + 발표하는 날짜를 알려주세요 + + 발표 상황 설정 + 발표 상황을 선택해주세요. + 어떤 성격의 발표인지 알려주세요. + 유형 + 목적 + 스타일 + 청중 + 학술·교육 + 내용 전달 + 전문적인 + 전문가 + 학술·교육 + 배운 내용이나 분석 주제를 전달하는 발표예요. + 업무·보고 + 진행 중인 일이나 결과를 공유하는 발표예요. + 설득·제안 + 다른 사람의 선택을 이끄는 발표예요. + 행사·공개 + 여러 사람 앞에서 내용을 소개하는 발표예요. + 내용 전달 + 이해 증진 + 공감 형성 + 전문적인 + 친근한 + 차분한 + 편안한 + 일반 청중 + 전문가 + 팀/동료 + + 대본 입력 + 발표에 사용할 대본을 추가해주세요. + 대본 파일을 업로드하거나 직접 입력할 수 있어요. + 발표 대본을 입력해주세요 + 대본 파일을 추가해주세요. + 제공하는 파일 형식 : txt + 파일 추가하기 + 대본 파일 삭제 + 직접 입력 + + 음성 녹음 + 대본을 읽은 음성 파일을 업로드해주세요. + 분석을 위해 음성 파일이 필요해요. + 음성 파일을 업로드하세요 + 제공하는 파일 형식 : m4a, mp4, mp3 + 음성 파일 삭제 + 파일 추가하기 + + 파일 업로드 + 직접 입력 + 분석 중 + 발표 음성을 분석하고 있어요 + 분석 리포트 화면 + 분석할 수 있는 음성 파일을 찾지 못했어요. + 다른 음성 파일로 다시 시도해 주세요. + 분석할 수 있는 텍스트 파일을 찾지 못했어요. + 다른 텍스트 파일로 다시 시도해 주세요. + 로그인이 만료되었어요. 다시 로그인해 주세요. + 분석 중 오류가 발생했어요. 잠시 후 다시 시도해 주세요. + 네트워크 연결을 확인해 주세요. + 알 수 없는 오류가 발생했어요. + 뒤로가기 + 다음 + 건너뛰기 + 다시 시도하기 + 분석하기 + diff --git a/Prezel/feature/analysis/impl/src/test/java/com/team/prezel/feature/analysis/impl/.gitkeep b/Prezel/feature/analysis/impl/src/test/java/com/team/prezel/feature/analysis/impl/.gitkeep new file mode 100644 index 00000000..e69de29b 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.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_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.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_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.PROFESSIONAL -> 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.COMFORTABLE -> 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_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_professional + Audience.TEAMMATE -> R.string.feature_history_impl_audience_teammate } @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/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 @@ 발표 추가하기 - 설득·제안 - 행사·공개 학술·교육 - 업무·보고 - 내용 전달 - 이해 증진 - 공감 형성 - 전문적인 + 업무·보고 + 설득·제안 + 행사·공개 + 내용 전달 + 이해 증진 + 공감 형성 + 전문적인 친근한 차분한 - 편안한 + 편안한 일반 청중 - 전문가 - 팀/동료 + 전문가 + 팀/동료 데이터를 불러오지 못했습니다. diff --git a/Prezel/feature/home/impl/build.gradle.kts b/Prezel/feature/home/impl/build.gradle.kts index 42062125..b8e7db04 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(projects.featurePracticeApi) diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/HomeScreen.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/HomeScreen.kt index 0371e337..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 @@ -1,8 +1,11 @@ package com.team.prezel.feature.home.impl.main +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 @@ -46,6 +56,8 @@ import kotlinx.datetime.LocalDate @Composable internal fun HomeScreen( + navigateToFileUploadAnalysis: () -> Unit, + navigateToVoiceRecordingAnalysis: () -> Unit, modifier: Modifier = Modifier, viewModel: HomeViewModel = hiltViewModel(), ) { @@ -77,6 +89,8 @@ internal fun HomeScreen( onClickPracticeRecording = { navigator.navigate(PracticeNavKey) }, onClickAnalyzePresentation = { }, onClickWriteFeedback = { }, + onClickVoiceRecordingAnalysis = navigateToVoiceRecordingAnalysis, + onClickFileUploadAnalysis = navigateToFileUploadAnalysis, modifier = modifier, ) } @@ -90,9 +104,12 @@ private fun HomeScreen( onClickPracticeRecording: () -> 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 @@ -116,10 +133,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, @@ -270,6 +342,8 @@ private fun HomeScreenEmptyPreview() { onClickPracticeRecording = { }, onClickAnalyzePresentation = { }, onClickWriteFeedback = { }, + onClickVoiceRecordingAnalysis = { }, + onClickFileUploadAnalysis = { }, ) } } @@ -280,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, @@ -294,6 +368,8 @@ private fun HomeScreenSinglePreview() { onClickPracticeRecording = { }, onClickAnalyzePresentation = { }, onClickWriteFeedback = { }, + onClickVoiceRecordingAnalysis = { }, + onClickFileUploadAnalysis = { }, ) } } @@ -320,6 +396,8 @@ private fun HomeScreenMultiplePreview() { onClickPracticeRecording = { }, onClickAnalyzePresentation = { }, onClickWriteFeedback = { }, + onClickVoiceRecordingAnalysis = { }, + onClickFileUploadAnalysis = { }, ) } } 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, 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 b84d5b82..89450139 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.main.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 7e897498..4a7d40fc 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/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..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,10 +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.UNKNOWN, null, -> TermsUiMessage.AGREE_TERMS_FAILED_UNKNOWN + + else -> TermsUiMessage.AGREE_TERMS_FAILED_UNKNOWN } } } diff --git a/Prezel/settings.gradle.kts b/Prezel/settings.gradle.kts index 876acc79..83ede2fb 100644 --- a/Prezel/settings.gradle.kts +++ b/Prezel/settings.gradle.kts @@ -54,6 +54,8 @@ includeAuto( ":feature:home:impl", ":feature:practice:api", ":feature:practice:impl", + ":feature:analysis:api", + ":feature:analysis:impl", ":feature:history:api", ":feature:history:impl", ":feature:my:api",