From bbb07e077bfb6fd80621f6ab4d0c00c7d632b3a2 Mon Sep 17 00:00:00 2001 From: Ham BeomJoon Date: Mon, 1 Jun 2026 01:44:41 +0900 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20=EB=B0=9C=ED=99=94=20=EC=A0=95?= =?UTF-8?q?=ED=99=95=EB=8F=84=20=EC=83=81=EC=84=B8=20=ED=99=94=EB=A9=B4=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20=EB=B0=8F=20=EB=A6=AC=ED=8F=AC=ED=8A=B8=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 발화 정확도 및 대본 일치율 상세 분석을 위한 `AccuracyDetailScreen` 추가 - 오디오 재생과 연동된 단어별 분석 결과 표시 기능 구현 (MediaPlayer 사용) - 리포트 화면의 정확도 지표 카드 클릭 시 상세 화면으로 이동하는 로직 추가 - `WordAnalysisStatus` enum 도입 및 네트워크 응답 매퍼 수정 - 기존 리포트 관련 컴포넌트들을 `report` 패키지로 이동 및 구조 정리 --- .../core/data/mapper/PresentationMapper.kt | 3 +- .../presentation/PresentationWordDetail.kt | 19 +- Prezel/feature/report/impl/build.gradle.kts | 1 + .../accuracydetail/AccuracyDetailScreen.kt | 411 ++++++++++++++++++ .../accuracydetail/AccuracyDetailViewModel.kt | 45 ++ .../component/AccuracyDetailPlayerSheet.kt | 257 +++++++++++ .../component/AccuracyDetailTopAppBar.kt | 44 ++ .../component/ScriptDetailList.kt | 127 ++++++ .../component/WordAnalysisDetailUi.kt | 208 +++++++++ .../contract/AccuracyDetailUiEffect.kt | 5 + .../contract/AccuracyDetailUiIntent.kt | 5 + .../contract/AccuracyDetailUiState.kt | 16 + .../impl/navigation/ReportEntryBuilder.kt | 42 +- .../impl/navigation/ReportInnerNavKey.kt | 16 + .../impl/{ => report}/AnalysisReportScreen.kt | 43 +- .../{ => report}/AnalysisReportViewModel.kt | 26 +- .../component/ReportBodyContent.kt | 26 +- .../component/ReportHeaderContent.kt | 6 +- .../component/ReportScreenLayout.kt | 6 +- .../component/body/AccuracySection.kt | 24 +- .../body/ExpectedQuestionsSection.kt | 10 +- .../component/body/GrowthGraphSection.kt | 12 +- .../component/body/PracticeHistorySection.kt | 6 +- .../component/body/ScriptAnalysisSection.kt | 12 +- .../component/body/SelfFeedbackSection.kt | 6 +- .../component/body/SummaryFeedbackSection.kt | 6 +- .../component/common/EmptyStateCard.kt | 2 +- .../component/common/ReportMetricCards.kt | 28 +- .../component/common/ReportSection.kt | 2 +- .../component/modal/ReportDialog.kt | 4 +- .../contract/AnalysisReportUiEffect.kt | 12 +- .../contract/AnalysisReportUiIntent.kt | 6 +- .../contract/AnalysisReportUiState.kt | 16 +- .../contract/AnalysisReportUiStateMapper.kt | 16 +- .../model/AnalysisReportDialog.kt | 2 +- .../model/AnalysisReportUiMessage.kt | 2 +- .../{ => report}/model/GrowthGraphData.kt | 2 +- .../model/PracticeRecordsUiModel.kt | 2 +- .../model/PresentationInfoUiModel.kt | 2 +- .../{ => report}/model/QuestionUiModel.kt | 2 +- .../model/ScriptAnalysisGraphData.kt | 2 +- .../impl/{ => report}/model/SpeedGraphData.kt | 2 +- .../preview/ReportPreviewUiState.kt | 18 +- .../impl/src/main/res/values/strings.xml | 11 +- 44 files changed, 1393 insertions(+), 120 deletions(-) create mode 100644 Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/accuracydetail/AccuracyDetailScreen.kt create mode 100644 Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/accuracydetail/AccuracyDetailViewModel.kt create mode 100644 Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/accuracydetail/component/AccuracyDetailPlayerSheet.kt create mode 100644 Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/accuracydetail/component/AccuracyDetailTopAppBar.kt create mode 100644 Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/accuracydetail/component/ScriptDetailList.kt create mode 100644 Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/accuracydetail/component/WordAnalysisDetailUi.kt create mode 100644 Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/accuracydetail/contract/AccuracyDetailUiEffect.kt create mode 100644 Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/accuracydetail/contract/AccuracyDetailUiIntent.kt create mode 100644 Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/accuracydetail/contract/AccuracyDetailUiState.kt create mode 100644 Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/navigation/ReportInnerNavKey.kt rename Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/{ => report}/AnalysisReportScreen.kt (78%) rename Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/{ => report}/AnalysisReportViewModel.kt (82%) rename Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/{ => report}/component/ReportBodyContent.kt (78%) rename Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/{ => report}/component/ReportHeaderContent.kt (97%) rename Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/{ => report}/component/ReportScreenLayout.kt (96%) rename Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/{ => report}/component/body/AccuracySection.kt (84%) rename Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/{ => report}/component/body/ExpectedQuestionsSection.kt (89%) rename Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/{ => report}/component/body/GrowthGraphSection.kt (94%) rename Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/{ => report}/component/body/PracticeHistorySection.kt (87%) rename Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/{ => report}/component/body/ScriptAnalysisSection.kt (92%) rename Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/{ => report}/component/body/SelfFeedbackSection.kt (92%) rename Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/{ => report}/component/body/SummaryFeedbackSection.kt (86%) rename Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/{ => report}/component/common/EmptyStateCard.kt (95%) rename Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/{ => report}/component/common/ReportMetricCards.kt (77%) rename Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/{ => report}/component/common/ReportSection.kt (95%) rename Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/{ => report}/component/modal/ReportDialog.kt (96%) rename Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/{ => report}/contract/AnalysisReportUiEffect.kt (64%) rename Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/{ => report}/contract/AnalysisReportUiIntent.kt (75%) rename Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/{ => report}/contract/AnalysisReportUiState.kt (62%) rename Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/{ => report}/contract/AnalysisReportUiStateMapper.kt (79%) rename Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/{ => report}/model/AnalysisReportDialog.kt (62%) rename Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/{ => report}/model/AnalysisReportUiMessage.kt (63%) rename Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/{ => report}/model/GrowthGraphData.kt (95%) rename Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/{ => report}/model/PracticeRecordsUiModel.kt (95%) rename Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/{ => report}/model/PresentationInfoUiModel.kt (89%) rename Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/{ => report}/model/QuestionUiModel.kt (58%) rename Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/{ => report}/model/ScriptAnalysisGraphData.kt (70%) rename Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/{ => report}/model/SpeedGraphData.kt (72%) rename Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/{ => report}/preview/ReportPreviewUiState.kt (82%) diff --git a/Prezel/core/data/src/main/java/com/team/prezel/core/data/mapper/PresentationMapper.kt b/Prezel/core/data/src/main/java/com/team/prezel/core/data/mapper/PresentationMapper.kt index 085758ec..330fe25d 100644 --- a/Prezel/core/data/src/main/java/com/team/prezel/core/data/mapper/PresentationMapper.kt +++ b/Prezel/core/data/src/main/java/com/team/prezel/core/data/mapper/PresentationMapper.kt @@ -15,6 +15,7 @@ import com.team.prezel.core.model.presentation.Purpose import com.team.prezel.core.model.presentation.ScriptCorrection import com.team.prezel.core.model.presentation.Style import com.team.prezel.core.model.presentation.WordAnalysisDetail +import com.team.prezel.core.model.presentation.WordAnalysisStatus import com.team.prezel.core.network.model.presentation.GetMainDataResponse import com.team.prezel.core.network.model.presentation.GetPracticeRecordsResponse import com.team.prezel.core.network.model.presentation.GetPresentationsResponse @@ -92,7 +93,7 @@ internal fun PresentationWordDetailResponse.toDomain(): PresentationWordDetail = internal fun PresentationWordAnalysisResponse.toDomain(): WordAnalysisDetail = WordAnalysisDetail( word = word, - status = status, + status = WordAnalysisStatus.from(value = status), description = description, accuracy = accuracy, startTimeMs = startTimeMs, diff --git a/Prezel/core/model/src/main/java/com/team/prezel/core/model/presentation/PresentationWordDetail.kt b/Prezel/core/model/src/main/java/com/team/prezel/core/model/presentation/PresentationWordDetail.kt index e0a62011..3d91ef4b 100644 --- a/Prezel/core/model/src/main/java/com/team/prezel/core/model/presentation/PresentationWordDetail.kt +++ b/Prezel/core/model/src/main/java/com/team/prezel/core/model/presentation/PresentationWordDetail.kt @@ -8,9 +8,26 @@ data class PresentationWordDetail( data class WordAnalysisDetail( val word: String, - val status: String, + val status: WordAnalysisStatus, val description: String, val accuracy: Double, val startTimeMs: Long, val endTimeMs: Long, ) + +enum class WordAnalysisStatus( + val value: String, +) { + EXCELLENT("Excellent"), + GOOD("Good"), + STUTTER("Stutter"), + INSERTION("Insertion"), + OMISSION("Omission"), + MISPRONUNCIATION("Mispronunciation"), + UNKNOWN("Unknown"), + ; + + companion object { + fun from(value: String): WordAnalysisStatus = entries.firstOrNull { status -> status.value == value } ?: UNKNOWN + } +} diff --git a/Prezel/feature/report/impl/build.gradle.kts b/Prezel/feature/report/impl/build.gradle.kts index 9b714254..bbd6dd3d 100644 --- a/Prezel/feature/report/impl/build.gradle.kts +++ b/Prezel/feature/report/impl/build.gradle.kts @@ -1,5 +1,6 @@ plugins { alias(libs.plugins.prezel.android.feature.impl) + alias(libs.plugins.kotlinx.serialization) } android { diff --git a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/accuracydetail/AccuracyDetailScreen.kt b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/accuracydetail/AccuracyDetailScreen.kt new file mode 100644 index 00000000..315178d0 --- /dev/null +++ b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/accuracydetail/AccuracyDetailScreen.kt @@ -0,0 +1,411 @@ +package com.team.prezel.feature.report.impl.accuracydetail + +import android.media.MediaPlayer +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.pager.PagerState +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.BottomSheetScaffold +import androidx.compose.material3.BottomSheetScaffoldState +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.SheetValue +import androidx.compose.material3.Text +import androidx.compose.material3.rememberBottomSheetScaffoldState +import androidx.compose.material3.rememberStandardBottomSheetState +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.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +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.player.PrezelPlayerItem +import com.team.prezel.core.designsystem.component.player.PrezelPlayerState +import com.team.prezel.core.designsystem.component.player.rememberPrezelPlayerState +import com.team.prezel.core.designsystem.preview.BasicPreview +import com.team.prezel.core.designsystem.theme.PrezelTheme +import com.team.prezel.core.model.presentation.PresentationWordDetail +import com.team.prezel.core.model.presentation.WordAnalysisDetail +import com.team.prezel.core.model.presentation.WordAnalysisStatus +import com.team.prezel.feature.report.impl.R +import com.team.prezel.feature.report.impl.accuracydetail.component.AccuracyDetailPlayerSheet +import com.team.prezel.feature.report.impl.accuracydetail.component.AccuracyDetailTopAppBar +import com.team.prezel.feature.report.impl.accuracydetail.component.ScriptDetailList +import com.team.prezel.feature.report.impl.accuracydetail.component.isScriptMatchIssue +import com.team.prezel.feature.report.impl.accuracydetail.component.isSpeechAccuracySheetIssue +import com.team.prezel.feature.report.impl.accuracydetail.component.toMarkerType +import com.team.prezel.feature.report.impl.accuracydetail.contract.AccuracyDetailUiState +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.delay +import kotlinx.serialization.Serializable + +private const val PLAYER_TICK_MILLIS = 250L +private val AccuracyDetailSheetPeekHeight = 276.dp + +@Serializable +internal enum class AccuracyDetailTab { + SPEECH, + SCRIPT_MATCH, +} + +@Composable +internal fun AccuracyDetailScreen( + onClose: () -> Unit, + initialTab: AccuracyDetailTab, + viewModel: AccuracyDetailViewModel, +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + when (val state = uiState) { + AccuracyDetailUiState.Loading -> Unit + AccuracyDetailUiState.Error -> AccuracyDetailErrorScreen(onClose = onClose) + is AccuracyDetailUiState.Content -> AccuracyDetailScreenContent( + uiState = state, + initialTab = initialTab, + onClose = onClose, + ) + } +} + +@Composable +private fun AccuracyDetailErrorScreen(onClose: () -> Unit) { + Column( + modifier = Modifier.fillMaxSize(), + ) { + AccuracyDetailTopAppBar(onClose = onClose) + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + Text( + text = stringResource(R.string.feature_report_impl_script_detail_load_failed), + style = PrezelTheme.typography.body2Regular, + color = PrezelTheme.colors.textRegular, + ) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun AccuracyDetailScreenContent( + uiState: AccuracyDetailUiState.Content, + initialTab: AccuracyDetailTab, + onClose: () -> Unit, + expandedSheet: Boolean = false, +) { + val tabs = rememberAccuracyDetailTabs() + val tabLabels = listOf( + stringResource(R.string.feature_report_impl_label_speech), + stringResource(R.string.feature_report_impl_label_script_match), + ).toImmutableList() + val pagerState = rememberPagerState(initialPage = initialTab.ordinal) { tabs.size } + val selectedTab = tabs[pagerState.currentPage] + val wordDetails = uiState.wordDetail.wordDetails + val playerMarkerWordDetails = remember(selectedTab, wordDetails) { + wordDetails.playerMarkerDetailsFor(selectedTab) + } + val playerState = rememberDetailPlayerState( + wordDetails = wordDetails, + markerWordDetails = playerMarkerWordDetails, + ) + val playbackState = rememberRemoteAudioPlaybackState(audioUrl = uiState.wordDetail.audioUrl) + val selectedWord = remember(wordDetails, playerState.currentMillis) { + wordDetails.currentDetailOrNull(currentMillis = playerState.currentMillis) + } + val scaffoldState = rememberDetailScaffoldState(expandedSheet = expandedSheet) + val isSheetExpanded = scaffoldState.isSheetExpanded + + PlaybackEffect( + playerState = playerState, + playbackState = playbackState, + ) + + AccuracyDetailScaffold( + scaffoldState = scaffoldState, + selectedTab = selectedTab, + selectedWord = selectedWord, + wordDetails = wordDetails, + playerState = playerState, + expanded = isSheetExpanded, + onClose = onClose, + tabLabels = tabLabels, + onClickTab = { index -> pagerState.requestScrollToPage(index) }, + pagerState = pagerState, + ) +} + +@Composable +private fun rememberAccuracyDetailTabs(): List = + remember { + listOf( + AccuracyDetailTab.SPEECH, + AccuracyDetailTab.SCRIPT_MATCH, + ) + } + +@Composable +private fun rememberDetailPlayerState( + wordDetails: List, + markerWordDetails: List, +) = rememberPrezelPlayerState( + durationMillis = remember(wordDetails) { + wordDetails.maxOfOrNull { it.endTimeMs }?.coerceAtLeast(1L) ?: 1L + }, + initialItems = remember(markerWordDetails) { + markerWordDetails + .map { detail -> + PrezelPlayerItem.Marker( + timeMillis = detail.startTimeMs, + markerType = detail.toMarkerType(), + ) + }.toImmutableList() + }, +) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun rememberDetailScaffoldState(expandedSheet: Boolean): BottomSheetScaffoldState = + rememberBottomSheetScaffoldState( + bottomSheetState = rememberStandardBottomSheetState( + initialValue = if (expandedSheet) { + SheetValue.Expanded + } else { + SheetValue.PartiallyExpanded + }, + ), + ) + +@OptIn(ExperimentalMaterial3Api::class) +private val BottomSheetScaffoldState.isSheetExpanded: Boolean + get() = bottomSheetState.currentValue == SheetValue.Expanded || + bottomSheetState.targetValue == SheetValue.Expanded + +@Composable +private fun PlaybackEffect( + playerState: PrezelPlayerState, + playbackState: RemoteAudioPlaybackState, +) { + LaunchedEffect(playerState.playing) { + if (playerState.playing) { + playbackState.play(startPositionMillis = playerState.currentMillis.toInt()) + } else { + playbackState.pause() + } + } + + LaunchedEffect(playerState.playing) { + while (playerState.playing) { + delay(PLAYER_TICK_MILLIS) + playbackState.currentPositionMillis + .takeIf { it > 0 } + ?.let { playerState.updateCurrentMillis(it.toLong()) } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun AccuracyDetailScaffold( + scaffoldState: BottomSheetScaffoldState, + selectedTab: AccuracyDetailTab, + selectedWord: WordAnalysisDetail?, + wordDetails: List, + playerState: PrezelPlayerState, + expanded: Boolean, + onClose: () -> Unit, + tabLabels: ImmutableList, + onClickTab: (Int) -> Unit, + pagerState: PagerState, +) { + BottomSheetScaffold( + modifier = Modifier.fillMaxSize(), + scaffoldState = scaffoldState, + sheetPeekHeight = AccuracyDetailSheetPeekHeight, + sheetShape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp), + sheetContainerColor = PrezelTheme.colors.solidWhite, + sheetShadowElevation = 12.dp, + sheetContent = { + AccuracyDetailPlayerSheet( + selectedTab = selectedTab, + selectedWord = selectedWord, + wordDetails = wordDetails, + playerState = playerState, + expanded = expanded, + ) + }, + sheetDragHandle = null, + containerColor = PrezelTheme.colors.bgRegular, + ) { innerPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + ) { + AccuracyDetailTopAppBar(onClose = onClose) + PrezelTabs( + tabs = tabLabels, + pagerState = pagerState, + size = PrezelTabSize.MEDIUM, + onClickTab = onClickTab, + ) + ScriptDetailList( + selectedTab = selectedTab, + selectedWord = selectedWord, + wordDetails = wordDetails, + ) + } + } +} + +@Composable +private fun rememberRemoteAudioPlaybackState(audioUrl: String): RemoteAudioPlaybackState { + val state = remember(audioUrl) { RemoteAudioPlaybackState(audioUrl = audioUrl) } + + DisposableEffect(state) { + onDispose { state.release() } + } + + return state +} + +private class RemoteAudioPlaybackState( + private val audioUrl: String, +) { + private var mediaPlayer: MediaPlayer? = null + + private var lastKnownPositionMillis by mutableIntStateOf(0) + + val currentPositionMillis: Int + get() = mediaPlayer + ?.currentPosition + ?.coerceAtLeast(0) + ?: lastKnownPositionMillis + + fun play(startPositionMillis: Int) { + val player = mediaPlayer ?: preparePlayer() ?: return + + runCatching { + player.seekTo(startPositionMillis.coerceAtLeast(0)) + player.start() + lastKnownPositionMillis = player.currentPosition.coerceAtLeast(0) + }.onFailure { + release() + } + } + + fun pause() { + mediaPlayer?.runCatching { + if (isPlaying) pause() + lastKnownPositionMillis = currentPosition.coerceAtLeast(0) + } + } + + fun release() { + mediaPlayer?.release() + mediaPlayer = null + lastKnownPositionMillis = 0 + } + + private fun preparePlayer(): MediaPlayer? = + runCatching { + MediaPlayer().apply { + setDataSource(audioUrl) + prepare() + setOnCompletionListener { + lastKnownPositionMillis = duration.coerceAtLeast(0) + } + } + }.getOrNull() + ?.also { mediaPlayer = it } +} + +private fun List.currentDetailOrNull(currentMillis: Long): WordAnalysisDetail? = + lastOrNull { detail -> currentMillis >= detail.startTimeMs } ?: firstOrNull() + +private fun List.playerMarkerDetailsFor(tab: AccuracyDetailTab): List = + when (tab) { + AccuracyDetailTab.SPEECH -> filter { detail -> detail.isSpeechAccuracySheetIssue } + AccuracyDetailTab.SCRIPT_MATCH -> filter { detail -> detail.isScriptMatchIssue } + } + +@BasicPreview +@Composable +private fun AccuracyDetailSpeechPreview() { + PrezelTheme { + AccuracyDetailScreenContent( + uiState = AccuracyDetailPreviewUiState, + initialTab = AccuracyDetailTab.SPEECH, + onClose = {}, + ) + } +} + +@BasicPreview +@Composable +private fun AccuracyDetailScriptMatchPreview() { + PrezelTheme { + AccuracyDetailScreenContent( + uiState = AccuracyDetailPreviewUiState, + initialTab = AccuracyDetailTab.SCRIPT_MATCH, + onClose = {}, + ) + } +} + +@BasicPreview +@Composable +private fun AccuracyDetailExpandedBottomSheetPreview() { + PrezelTheme { + AccuracyDetailScreenContent( + uiState = AccuracyDetailPreviewUiState, + initialTab = AccuracyDetailTab.SPEECH, + expandedSheet = true, + onClose = {}, + ) + } +} + +private val AccuracyDetailPreviewUiState = AccuracyDetailUiState.Content( + wordDetail = PresentationWordDetail( + presentationId = 1L, + audioUrl = "https://example.com/audio.mp3", + wordDetails = listOf( + WordAnalysisDetail( + word = "문장의 흐름이 깔끔했어요", + status = WordAnalysisStatus.EXCELLENT, + description = "지금처럼 또렷한 말하기를 유지해주세요.", + accuracy = 96.0, + startTimeMs = 0L, + endTimeMs = 1_800L, + ), + WordAnalysisDetail( + word = "같은 말을 반복하고 있어요.", + status = WordAnalysisStatus.INSERTION, + description = "앞에서 했던 말은 반복하지 않는 것이 좋아요.", + accuracy = 42.0, + startTimeMs = 7_230L, + endTimeMs = 8_700L, + ), + WordAnalysisDetail( + word = "오늘도 다들 긴장되는 마음으로 오셨을 것 같습니다.", + status = WordAnalysisStatus.OMISSION, + description = "대본에 있으나 읽지 않은 구간이에요.", + accuracy = 0.0, + startTimeMs = 9_400L, + endTimeMs = 11_300L, + ), + ), + ), +) diff --git a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/accuracydetail/AccuracyDetailViewModel.kt b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/accuracydetail/AccuracyDetailViewModel.kt new file mode 100644 index 00000000..e69dacb5 --- /dev/null +++ b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/accuracydetail/AccuracyDetailViewModel.kt @@ -0,0 +1,45 @@ +package com.team.prezel.feature.report.impl.accuracydetail + +import androidx.lifecycle.viewModelScope +import com.team.prezel.core.domain.usecase.presentation.FetchPresentationWordDetailUseCase +import com.team.prezel.core.ui.base.BaseViewModel +import com.team.prezel.feature.report.impl.accuracydetail.contract.AccuracyDetailUiEffect +import com.team.prezel.feature.report.impl.accuracydetail.contract.AccuracyDetailUiIntent +import com.team.prezel.feature.report.impl.accuracydetail.contract.AccuracyDetailUiState +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.launch + +@HiltViewModel(assistedFactory = AccuracyDetailViewModel.Factory::class) +internal class AccuracyDetailViewModel @AssistedInject constructor( + @Assisted private val analysisResultId: Long, + private val fetchPresentationWordDetailUseCase: FetchPresentationWordDetailUseCase, +) : BaseViewModel( + AccuracyDetailUiState.Loading, + ) { + @AssistedFactory + interface Factory { + fun create(analysisResultId: Long): AccuracyDetailViewModel + } + + init { + fetchDetails() + } + + override fun onIntent(intent: AccuracyDetailUiIntent) = Unit + + private fun fetchDetails() { + viewModelScope.launch { + val nextState = runCatching { + AccuracyDetailUiState.Content( + wordDetail = fetchPresentationWordDetailUseCase(analysisResultId).getOrThrow(), + ) + }.getOrElse { + AccuracyDetailUiState.Error + } + updateState { nextState } + } + } +} diff --git a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/accuracydetail/component/AccuracyDetailPlayerSheet.kt b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/accuracydetail/component/AccuracyDetailPlayerSheet.kt new file mode 100644 index 00000000..b86cf2d8 --- /dev/null +++ b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/accuracydetail/component/AccuracyDetailPlayerSheet.kt @@ -0,0 +1,257 @@ +package com.team.prezel.feature.report.impl.accuracydetail.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.team.prezel.core.designsystem.component.player.PrezelPlayer +import com.team.prezel.core.designsystem.component.player.PrezelPlayerItem +import com.team.prezel.core.designsystem.component.player.PrezelPlayerState +import com.team.prezel.core.designsystem.component.player.rememberPrezelPlayerState +import com.team.prezel.core.designsystem.preview.BasicPreview +import com.team.prezel.core.designsystem.theme.PrezelTheme +import com.team.prezel.core.model.presentation.WordAnalysisDetail +import com.team.prezel.core.model.presentation.WordAnalysisStatus +import com.team.prezel.feature.report.impl.R +import com.team.prezel.feature.report.impl.accuracydetail.AccuracyDetailTab +import kotlinx.collections.immutable.toImmutableList + +@Composable +internal fun AccuracyDetailPlayerSheet( + selectedTab: AccuracyDetailTab, + selectedWord: WordAnalysisDetail?, + wordDetails: List, + playerState: PrezelPlayerState, + expanded: Boolean, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .then(if (expanded) Modifier.fillMaxHeight() else Modifier), + ) { + SheetHandle() + SheetDetailContent( + selectedTab = selectedTab, + selectedWord = selectedWord, + wordDetails = wordDetails, + expanded = expanded, + modifier = if (expanded) Modifier.weight(1f) else Modifier, + ) + PrezelPlayer( + state = playerState, + trackContentDescription = stringResource(R.string.feature_report_impl_script_detail_player_track_desc), + ) + } +} + +@Composable +internal fun SheetHandle() { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = PrezelTheme.spacing.V16), + contentAlignment = Alignment.Center, + ) { + Box( + modifier = Modifier + .size(width = 32.dp, height = 4.dp) + .clip(PrezelTheme.shapes.V1000) + .background(PrezelTheme.colors.borderMedium), + ) + } +} + +@Composable +private fun SheetDetailContent( + selectedTab: AccuracyDetailTab, + selectedWord: WordAnalysisDetail?, + wordDetails: List, + expanded: Boolean, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxWidth() + .then(if (expanded) Modifier else Modifier.heightIn(max = 96.dp)) + .padding(horizontal = PrezelTheme.spacing.V20) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(PrezelTheme.spacing.V16), + ) { + when (selectedTab) { + AccuracyDetailTab.SPEECH -> SpeechDetailContent( + selectedWord = selectedWord, + wordDetails = wordDetails, + expanded = expanded, + ) + + AccuracyDetailTab.SCRIPT_MATCH -> ScriptMatchDetailContent( + selectedWord = selectedWord, + wordDetails = wordDetails, + expanded = expanded, + ) + } + } + Spacer(modifier = Modifier.height(PrezelTheme.spacing.V12)) +} + +@Composable +private fun SpeechDetailContent( + selectedWord: WordAnalysisDetail?, + wordDetails: List, + expanded: Boolean, +) { + val accuracyDetails = wordDetails.filter { it.isSpeechAccuracySheetIssue } + val visibleAccuracyDetails = if (expanded) { + accuracyDetails + } else { + listOfNotNull(selectedWord?.takeIf { it.isSpeechAccuracySheetIssue } ?: accuracyDetails.firstOrNull()) + } + + if (visibleAccuracyDetails.isEmpty()) { + EmptyDetailText(text = stringResource(R.string.feature_report_impl_accuracy_detail_sheet_empty_speech)) + } else { + visibleAccuracyDetails.forEach { detail -> + WordDetailCard( + detail = detail, + highlighted = detail == selectedWord, + text = detail.description, + useStatusTextColor = false, + ) + } + } +} + +@Composable +private fun ScriptMatchDetailContent( + selectedWord: WordAnalysisDetail?, + wordDetails: List, + expanded: Boolean, +) { + val visibleMismatchDetails = wordDetails.visibleScriptMatchDetails( + selectedWord = selectedWord, + expanded = expanded, + ) + + if (visibleMismatchDetails.isEmpty()) { + EmptyDetailText(text = stringResource(R.string.feature_report_impl_accuracy_detail_sheet_empty_script)) + } + + visibleMismatchDetails.forEach { detail -> + WordDetailCard( + detail = detail, + highlighted = detail == selectedWord, + text = detail.description, + useStatusTextColor = false, + ) + } +} + +private fun List.visibleScriptMatchDetails( + selectedWord: WordAnalysisDetail?, + expanded: Boolean, +): List { + val mismatchDetails = filter { it.isScriptMatchIssue } + return if (expanded) { + mismatchDetails + } else { + listOfNotNull(selectedWord?.takeIf { it.isScriptMatchIssue } ?: mismatchDetails.firstOrNull()) + } +} + +@BasicPreview +@Composable +private fun AccuracyDetailPlayerSheetSpeechPreview() { + PrezelTheme { + AccuracyDetailPlayerSheet( + selectedTab = AccuracyDetailTab.SPEECH, + selectedWord = PreviewWordDetails.first(), + wordDetails = PreviewWordDetails, + playerState = rememberPreviewPlayerState(), + expanded = false, + ) + } +} + +@BasicPreview +@Composable +private fun AccuracyDetailPlayerSheetScriptMatchPreview() { + PrezelTheme { + AccuracyDetailPlayerSheet( + selectedTab = AccuracyDetailTab.SCRIPT_MATCH, + selectedWord = PreviewWordDetails[1], + wordDetails = PreviewWordDetails, + playerState = rememberPreviewPlayerState(), + expanded = false, + ) + } +} + +@BasicPreview +@Composable +private fun AccuracyDetailPlayerSheetExpandedPreview() { + PrezelTheme { + AccuracyDetailPlayerSheet( + selectedTab = AccuracyDetailTab.SPEECH, + selectedWord = PreviewWordDetails[1], + wordDetails = PreviewWordDetails, + playerState = rememberPreviewPlayerState(currentMillis = 7_230L), + expanded = true, + ) + } +} + +@Composable +private fun rememberPreviewPlayerState(currentMillis: Long = 0L): PrezelPlayerState = + rememberPrezelPlayerState( + durationMillis = 11_300L, + currentMillis = currentMillis, + initialItems = PreviewWordDetails + .map { detail -> + PrezelPlayerItem.Marker( + timeMillis = detail.startTimeMs, + markerType = detail.toMarkerType(), + ) + }.toImmutableList(), + ) + +private val PreviewWordDetails = listOf( + WordAnalysisDetail( + word = "문장의 흐름이 깔끔했어요", + status = WordAnalysisStatus.EXCELLENT, + description = "지금처럼 또렷한 말하기를 유지해주세요.", + accuracy = 96.0, + startTimeMs = 0L, + endTimeMs = 1_800L, + ), + WordAnalysisDetail( + word = "같은 말을 반복하고 있어요.", + status = WordAnalysisStatus.INSERTION, + description = "앞에서 했던 말은 반복하지 않는 것이 좋아요.", + accuracy = 42.0, + startTimeMs = 7_230L, + endTimeMs = 8_700L, + ), + WordAnalysisDetail( + word = "오늘도 다들 긴장되는 마음으로 오셨을 것 같습니다.", + status = WordAnalysisStatus.OMISSION, + description = "대본에 있으나 읽지 않은 구간이에요.", + accuracy = 0.0, + startTimeMs = 9_400L, + endTimeMs = 11_300L, + ), +) diff --git a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/accuracydetail/component/AccuracyDetailTopAppBar.kt b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/accuracydetail/component/AccuracyDetailTopAppBar.kt new file mode 100644 index 00000000..fee5f18f --- /dev/null +++ b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/accuracydetail/component/AccuracyDetailTopAppBar.kt @@ -0,0 +1,44 @@ +package com.team.prezel.feature.report.impl.accuracydetail.component + +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import com.team.prezel.core.designsystem.component.PrezelTopAppBar +import com.team.prezel.core.designsystem.icon.PrezelIcons +import com.team.prezel.core.designsystem.preview.BasicPreview +import com.team.prezel.core.designsystem.theme.PrezelTheme +import com.team.prezel.feature.report.impl.R + +@Composable +@OptIn(ExperimentalMaterial3Api::class) +internal fun AccuracyDetailTopAppBar(onClose: () -> Unit) { + PrezelTopAppBar( + title = { + Text( + text = stringResource(R.string.feature_report_impl_section_accuracy), + color = PrezelTheme.colors.textLarge, + ) + }, + trailingIcons = { + IconButton(onClick = onClose) { + Icon( + painter = painterResource(PrezelIcons.Cancel), + contentDescription = stringResource(R.string.feature_report_impl_close), + tint = PrezelTheme.colors.iconRegular, + ) + } + }, + ) +} + +@BasicPreview +@Composable +private fun AccuracyDetailTopAppBarPreview() { + PrezelTheme { + AccuracyDetailTopAppBar(onClose = {}) + } +} diff --git a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/accuracydetail/component/ScriptDetailList.kt b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/accuracydetail/component/ScriptDetailList.kt new file mode 100644 index 00000000..c77a2147 --- /dev/null +++ b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/accuracydetail/component/ScriptDetailList.kt @@ -0,0 +1,127 @@ +package com.team.prezel.feature.report.impl.accuracydetail.component + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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.core.model.presentation.WordAnalysisDetail +import com.team.prezel.core.model.presentation.WordAnalysisStatus +import com.team.prezel.feature.report.impl.R +import com.team.prezel.feature.report.impl.accuracydetail.AccuracyDetailTab + +@Composable +internal fun ScriptDetailList( + selectedTab: AccuracyDetailTab, + selectedWord: WordAnalysisDetail?, + wordDetails: List, +) { + val scrollState = rememberScrollState() + + LaunchedEffect(selectedTab) { + scrollState.scrollTo(0) + } + + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(scrollState) + .padding(all = PrezelTheme.spacing.V20), + verticalArrangement = Arrangement.spacedBy(PrezelTheme.spacing.V16), + ) { + if (wordDetails.isNotEmpty()) { + wordDetails.forEach { detail -> + WordDetailCard( + detail = detail, + highlighted = detail == selectedWord, + useStatusTextColor = detail.usesStatusTextColor(selectedTab), + showStatusChip = detail.showsStatusChip(selectedTab), + ) + } + } else { + EmptyDetailText(text = stringResource(R.string.feature_report_impl_script_detail_empty_speech)) + } + } +} + +private fun WordAnalysisDetail.usesStatusTextColor(selectedTab: AccuracyDetailTab): Boolean = + when (selectedTab) { + AccuracyDetailTab.SPEECH -> isSpeechAccuracySheetIssue + AccuracyDetailTab.SCRIPT_MATCH -> isScriptMatchIssue + } + +private fun WordAnalysisDetail.showsStatusChip(selectedTab: AccuracyDetailTab): Boolean = + when (selectedTab) { + AccuracyDetailTab.SPEECH -> isSpeechAccuracySheetIssue + AccuracyDetailTab.SCRIPT_MATCH -> isScriptMatchIssue + } + +@Composable +internal fun EmptyDetailText(text: String) { + Text( + text = text, + style = PrezelTheme.typography.body2Regular, + color = PrezelTheme.colors.textRegular, + ) +} + +@BasicPreview +@Composable +private fun ScriptDetailListPreview() { + PrezelTheme { + ScriptDetailList( + selectedTab = AccuracyDetailTab.SPEECH, + selectedWord = PreviewWordDetail, + wordDetails = PreviewWordDetails, + ) + } +} + +@BasicPreview +@Composable +private fun ScriptDetailListEmptyPreview() { + PrezelTheme { + ScriptDetailList( + selectedTab = AccuracyDetailTab.SPEECH, + selectedWord = null, + wordDetails = emptyList(), + ) + } +} + +private val PreviewWordDetail = WordAnalysisDetail( + word = "내가", + status = WordAnalysisStatus.EXCELLENT, + description = "매우 또렷하고 훌륭한 발음", + accuracy = 98.0, + startTimeMs = 1_490L, + endTimeMs = 1_980L, +) + +private val PreviewWordDetails = listOf( + PreviewWordDetail, + WordAnalysisDetail( + word = "그린", + status = WordAnalysisStatus.EXCELLENT, + description = "매우 또렷하고 훌륭한 발음", + accuracy = 98.0, + startTimeMs = 1_990L, + endTimeMs = 2_400L, + ), + WordAnalysisDetail( + word = "기린", + status = WordAnalysisStatus.EXCELLENT, + description = "매우 또렷하고 훌륭한 발음", + accuracy = 100.0, + startTimeMs = 2_410L, + endTimeMs = 2_820L, + ), +) diff --git a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/accuracydetail/component/WordAnalysisDetailUi.kt b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/accuracydetail/component/WordAnalysisDetailUi.kt new file mode 100644 index 00000000..8bf5e0d1 --- /dev/null +++ b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/accuracydetail/component/WordAnalysisDetailUi.kt @@ -0,0 +1,208 @@ +package com.team.prezel.feature.report.impl.accuracydetail.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import com.team.prezel.core.designsystem.component.player.PrezelPlayerMarkerType +import com.team.prezel.core.designsystem.preview.BasicPreview +import com.team.prezel.core.designsystem.theme.PrezelTheme +import com.team.prezel.core.model.presentation.WordAnalysisDetail +import com.team.prezel.core.model.presentation.WordAnalysisStatus +import com.team.prezel.feature.report.impl.R + +internal val WordAnalysisDetail.isScriptMatchIssue: Boolean + get() = status == WordAnalysisStatus.OMISSION || status == WordAnalysisStatus.MISPRONUNCIATION + +internal val WordAnalysisDetail.isSpeechAccuracyIssue: Boolean + get() = status == WordAnalysisStatus.EXCELLENT || + status == WordAnalysisStatus.GOOD || + status == WordAnalysisStatus.STUTTER || + status == WordAnalysisStatus.INSERTION + +internal val WordAnalysisDetail.isSpeechAccuracySheetIssue: Boolean + get() = status == WordAnalysisStatus.EXCELLENT || + status == WordAnalysisStatus.STUTTER || + status == WordAnalysisStatus.INSERTION + +internal fun WordAnalysisDetail.toMarkerType(): PrezelPlayerMarkerType = + when (status) { + WordAnalysisStatus.EXCELLENT, + WordAnalysisStatus.GOOD, + -> PrezelPlayerMarkerType.GOOD + + WordAnalysisStatus.OMISSION -> PrezelPlayerMarkerType.NEUTRAL + + else -> PrezelPlayerMarkerType.WARNING + } + +@Composable +internal fun WordAnalysisDetail.textColor(): Color = + when (status) { + WordAnalysisStatus.EXCELLENT -> PrezelTheme.colors.interactiveRegular + else -> PrezelTheme.colors.textLarge + } + +@Composable +internal fun WordDetailCard( + detail: WordAnalysisDetail, + highlighted: Boolean, + modifier: Modifier = Modifier, + text: String = detail.word, + useStatusTextColor: Boolean = true, + showStatusChip: Boolean = true, +) { + Column( + modifier = modifier + .fillMaxWidth() + .clip(PrezelTheme.shapes.V8) + .background(if (highlighted) PrezelTheme.colors.bgMedium else Color.Transparent) + .padding(PrezelTheme.spacing.V12), + verticalArrangement = Arrangement.spacedBy(PrezelTheme.spacing.V8), + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(PrezelTheme.spacing.V8), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = detail.startTimeMs.toPlayerTimeText(), + style = PrezelTheme.typography.caption1Regular, + color = PrezelTheme.colors.textRegular, + ) + if (showStatusChip) { + StatusChip(detail = detail) + } + } + Text( + text = text, + style = PrezelTheme.typography.body2Medium, + color = if (useStatusTextColor) detail.textColor() else PrezelTheme.colors.textLarge, + ) + } +} + +@Composable +internal fun StatusChip(detail: WordAnalysisDetail) { + Text( + text = detail.statusLabel(), + style = PrezelTheme.typography.body3Medium, + color = detail.chipTextColor(), + modifier = Modifier + .clip(PrezelTheme.shapes.V4) + .background(detail.chipBackgroundColor()) + .padding(horizontal = PrezelTheme.spacing.V6, vertical = PrezelTheme.spacing.V2), + ) +} + +@Composable +private fun WordAnalysisDetail.statusLabel(): String = + when (status) { + WordAnalysisStatus.EXCELLENT, + WordAnalysisStatus.GOOD, + WordAnalysisStatus.STUTTER, + -> stringResource(R.string.feature_report_impl_script_detail_status_pronunciation) + + WordAnalysisStatus.INSERTION -> stringResource(R.string.feature_report_impl_script_detail_status_insertion) + WordAnalysisStatus.OMISSION -> stringResource(R.string.feature_report_impl_script_detail_status_omission) + WordAnalysisStatus.MISPRONUNCIATION -> stringResource(R.string.feature_report_impl_script_detail_status_mismatch) + WordAnalysisStatus.UNKNOWN -> status.value + } + +@Composable +private fun WordAnalysisDetail.chipTextColor(): Color = + when (status) { + WordAnalysisStatus.INSERTION, + WordAnalysisStatus.MISPRONUNCIATION, + -> PrezelTheme.colors.feedbackWarningRegular + + WordAnalysisStatus.OMISSION -> PrezelTheme.colors.textRegular + else -> PrezelTheme.colors.interactiveRegular + } + +@Composable +private fun WordAnalysisDetail.chipBackgroundColor(): Color = + when (status) { + WordAnalysisStatus.INSERTION, + WordAnalysisStatus.MISPRONUNCIATION, + -> PrezelTheme.colors.feedbackWarningSmall + + WordAnalysisStatus.OMISSION -> PrezelTheme.colors.bgLarge + else -> PrezelTheme.colors.interactiveXSmall + } + +internal fun Long.toPlayerTimeText(): String { + val totalSeconds = coerceAtLeast(0L) / 1_000 + val minutes = totalSeconds / 60 + val seconds = totalSeconds % 60 + + return "${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}" +} + +@BasicPreview +@Composable +private fun StatusChipPreview() { + PrezelTheme { + Row(horizontalArrangement = Arrangement.spacedBy(PrezelTheme.spacing.V8)) { + StatusChip(detail = PreviewExcellentWord) + StatusChip(detail = PreviewInsertionWord) + StatusChip(detail = PreviewOmissionWord) + } + } +} + +@BasicPreview +@Composable +private fun WordDetailCardPreview() { + PrezelTheme { + Column( + verticalArrangement = Arrangement.spacedBy(PrezelTheme.spacing.V8), + modifier = Modifier.padding(PrezelTheme.spacing.V16), + ) { + WordDetailCard( + detail = PreviewExcellentWord, + highlighted = true, + ) + WordDetailCard( + detail = PreviewInsertionWord, + highlighted = false, + text = PreviewInsertionWord.description, + ) + } + } +} + +private val PreviewExcellentWord = WordAnalysisDetail( + word = "문장의 흐름이 깔끔했어요", + status = WordAnalysisStatus.EXCELLENT, + description = "지금처럼 또렷한 말하기를 유지해주세요.", + accuracy = 96.0, + startTimeMs = 0L, + endTimeMs = 1_800L, +) + +private val PreviewInsertionWord = WordAnalysisDetail( + word = "같은 말을 반복하고 있어요.", + status = WordAnalysisStatus.INSERTION, + description = "앞에서 했던 말은 반복하지 않는 것이 좋아요.", + accuracy = 42.0, + startTimeMs = 7_230L, + endTimeMs = 8_700L, +) + +private val PreviewOmissionWord = WordAnalysisDetail( + word = "오늘도 다들 긴장되는 마음으로 오셨을 것 같습니다.", + status = WordAnalysisStatus.OMISSION, + description = "대본에 있으나 읽지 않은 구간이에요.", + accuracy = 0.0, + startTimeMs = 9_400L, + endTimeMs = 11_300L, +) diff --git a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/accuracydetail/contract/AccuracyDetailUiEffect.kt b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/accuracydetail/contract/AccuracyDetailUiEffect.kt new file mode 100644 index 00000000..bbfda853 --- /dev/null +++ b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/accuracydetail/contract/AccuracyDetailUiEffect.kt @@ -0,0 +1,5 @@ +package com.team.prezel.feature.report.impl.accuracydetail.contract + +import com.team.prezel.core.ui.base.UiEffect + +internal sealed interface AccuracyDetailUiEffect : UiEffect diff --git a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/accuracydetail/contract/AccuracyDetailUiIntent.kt b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/accuracydetail/contract/AccuracyDetailUiIntent.kt new file mode 100644 index 00000000..45d7200c --- /dev/null +++ b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/accuracydetail/contract/AccuracyDetailUiIntent.kt @@ -0,0 +1,5 @@ +package com.team.prezel.feature.report.impl.accuracydetail.contract + +import com.team.prezel.core.ui.base.UiIntent + +internal sealed interface AccuracyDetailUiIntent : UiIntent diff --git a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/accuracydetail/contract/AccuracyDetailUiState.kt b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/accuracydetail/contract/AccuracyDetailUiState.kt new file mode 100644 index 00000000..ed1c92e1 --- /dev/null +++ b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/accuracydetail/contract/AccuracyDetailUiState.kt @@ -0,0 +1,16 @@ +package com.team.prezel.feature.report.impl.accuracydetail.contract + +import androidx.compose.runtime.Immutable +import com.team.prezel.core.model.presentation.PresentationWordDetail +import com.team.prezel.core.ui.base.UiState + +@Immutable +internal sealed interface AccuracyDetailUiState : UiState { + data object Loading : AccuracyDetailUiState + + data object Error : AccuracyDetailUiState + + data class Content( + val wordDetail: PresentationWordDetail, + ) : AccuracyDetailUiState +} diff --git a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/navigation/ReportEntryBuilder.kt b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/navigation/ReportEntryBuilder.kt index 9bf39fc6..143cce49 100644 --- a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/navigation/ReportEntryBuilder.kt +++ b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/navigation/ReportEntryBuilder.kt @@ -6,8 +6,11 @@ import androidx.navigation3.runtime.NavKey import com.team.prezel.core.navigation.LocalNavigator import com.team.prezel.feature.analysis.api.AnalysisNavKey import com.team.prezel.feature.report.api.ReportNavKey -import com.team.prezel.feature.report.impl.AnalysisReportScreen -import com.team.prezel.feature.report.impl.AnalysisReportViewModel +import com.team.prezel.feature.report.impl.accuracydetail.AccuracyDetailScreen +import com.team.prezel.feature.report.impl.accuracydetail.AccuracyDetailTab +import com.team.prezel.feature.report.impl.accuracydetail.AccuracyDetailViewModel +import com.team.prezel.feature.report.impl.report.AnalysisReportScreen +import com.team.prezel.feature.report.impl.report.AnalysisReportViewModel import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -15,6 +18,11 @@ import dagger.hilt.android.components.ActivityRetainedComponent import dagger.multibindings.IntoSet internal fun EntryProviderScope.featureAnalysisReportEntryBuilder() { + reportEntry() + accuracyDetailEntry() +} + +private fun EntryProviderScope.reportEntry() { entry { key -> val navigator = LocalNavigator.current @@ -37,6 +45,22 @@ internal fun EntryProviderScope.featureAnalysisReportEntryBuilder() { ) }, navigateToSelfFeedbackWrite = {}, + navigateToSpeechAccuracy = { analysisResultId -> + navigator.navigate( + ReportInnerNavKey.AccuracyDetail( + analysisResultId = analysisResultId, + initialTab = AccuracyDetailTab.SPEECH, + ), + ) + }, + navigateToScriptMatch = { analysisResultId -> + navigator.navigate( + ReportInnerNavKey.AccuracyDetail( + analysisResultId = analysisResultId, + initialTab = AccuracyDetailTab.SCRIPT_MATCH, + ), + ) + }, viewModel = hiltViewModel( creationCallback = { factory -> factory.create(key) }, ), @@ -44,6 +68,20 @@ internal fun EntryProviderScope.featureAnalysisReportEntryBuilder() { } } +private fun EntryProviderScope.accuracyDetailEntry() { + entry { key -> + val navigator = LocalNavigator.current + + AccuracyDetailScreen( + onClose = { navigator.goBack() }, + initialTab = key.initialTab, + viewModel = hiltViewModel( + creationCallback = { factory -> factory.create(analysisResultId = key.analysisResultId) }, + ), + ) + } +} + @Module @InstallIn(ActivityRetainedComponent::class) object FeatureReportModule { diff --git a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/navigation/ReportInnerNavKey.kt b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/navigation/ReportInnerNavKey.kt new file mode 100644 index 00000000..c6a970b9 --- /dev/null +++ b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/navigation/ReportInnerNavKey.kt @@ -0,0 +1,16 @@ +package com.team.prezel.feature.report.impl.navigation + +import androidx.navigation3.runtime.NavKey +import com.team.prezel.feature.report.impl.accuracydetail.AccuracyDetailTab +import kotlinx.serialization.Serializable + +@Serializable +internal sealed interface ReportInnerNavKey : NavKey { + val analysisResultId: Long + + @Serializable + data class AccuracyDetail( + override val analysisResultId: Long, + val initialTab: AccuracyDetailTab, + ) : ReportInnerNavKey +} diff --git a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/AnalysisReportScreen.kt b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/report/AnalysisReportScreen.kt similarity index 78% rename from Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/AnalysisReportScreen.kt rename to Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/report/AnalysisReportScreen.kt index d6b531af..02b86651 100644 --- a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/AnalysisReportScreen.kt +++ b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/report/AnalysisReportScreen.kt @@ -1,4 +1,4 @@ -package com.team.prezel.feature.report.impl +package com.team.prezel.feature.report.impl.report import androidx.annotation.StringRes import androidx.compose.material3.Icon @@ -17,16 +17,17 @@ import com.team.prezel.core.designsystem.icon.PrezelIcons import com.team.prezel.core.designsystem.preview.BasicPreview import com.team.prezel.core.designsystem.theme.PrezelTheme import com.team.prezel.core.ui.state.LocalSnackbarHostState -import com.team.prezel.feature.report.impl.component.ReportBodyContent -import com.team.prezel.feature.report.impl.component.ReportHeaderContent -import com.team.prezel.feature.report.impl.component.ReportScreenLayout -import com.team.prezel.feature.report.impl.component.modal.ReportDialog -import com.team.prezel.feature.report.impl.contract.AnalysisReportUiEffect -import com.team.prezel.feature.report.impl.contract.AnalysisReportUiIntent -import com.team.prezel.feature.report.impl.contract.AnalysisReportUiState -import com.team.prezel.feature.report.impl.model.AnalysisReportUiMessage -import com.team.prezel.feature.report.impl.preview.ReportPreviewPastUiState -import com.team.prezel.feature.report.impl.preview.ReportPreviewUpcomingUiState +import com.team.prezel.feature.report.impl.R +import com.team.prezel.feature.report.impl.report.component.ReportBodyContent +import com.team.prezel.feature.report.impl.report.component.ReportHeaderContent +import com.team.prezel.feature.report.impl.report.component.ReportScreenLayout +import com.team.prezel.feature.report.impl.report.component.modal.ReportDialog +import com.team.prezel.feature.report.impl.report.contract.AnalysisReportUiEffect +import com.team.prezel.feature.report.impl.report.contract.AnalysisReportUiIntent +import com.team.prezel.feature.report.impl.report.contract.AnalysisReportUiState +import com.team.prezel.feature.report.impl.report.model.AnalysisReportUiMessage +import com.team.prezel.feature.report.impl.report.preview.ReportPreviewPastUiState +import com.team.prezel.feature.report.impl.report.preview.ReportPreviewUpcomingUiState @Composable internal fun AnalysisReportScreen( @@ -34,6 +35,8 @@ internal fun AnalysisReportScreen( navigateToAnalysisScript: (presentationId: Long, isPast: Boolean) -> Unit, navigateToAnalysisRecording: (presentationId: Long, isPast: Boolean) -> Unit, navigateToSelfFeedbackWrite: (presentationId: Long) -> Unit, + navigateToSpeechAccuracy: (analysisResultId: Long) -> Unit, + navigateToScriptMatch: (analysisResultId: Long) -> Unit, modifier: Modifier = Modifier, viewModel: AnalysisReportViewModel = hiltViewModel(), ) { @@ -55,6 +58,8 @@ internal fun AnalysisReportScreen( is AnalysisReportUiEffect.NavigateToAnalysisScript -> navigateToAnalysisScript(effect.presentationId, effect.isPast) is AnalysisReportUiEffect.NavigateToAnalysisRecording -> navigateToAnalysisRecording(effect.presentationId, effect.isPast) is AnalysisReportUiEffect.NavigateToSelfFeedbackWrite -> navigateToSelfFeedbackWrite(effect.presentationId) + is AnalysisReportUiEffect.NavigateToSpeechAccuracy -> navigateToSpeechAccuracy(effect.analysisResultId) + is AnalysisReportUiEffect.NavigateToScriptMatch -> navigateToScriptMatch(effect.analysisResultId) } } } @@ -69,6 +74,8 @@ internal fun AnalysisReportScreen( onReWriteScriptClick = { viewModel.onIntent(AnalysisReportUiIntent.ClickReWriteScript) }, onReRecordingClick = { viewModel.onIntent(AnalysisReportUiIntent.ClickReRecording) }, onFeedBackWriteClick = { viewModel.onIntent(AnalysisReportUiIntent.ClickFeedbackWrite) }, + onSpeechAccuracyClick = { viewModel.onIntent(AnalysisReportUiIntent.ClickSpeechAccuracy) }, + onScriptMatchClick = { viewModel.onIntent(AnalysisReportUiIntent.ClickScriptMatch) }, modifier = modifier, ) } @@ -84,6 +91,8 @@ internal fun AnalysisReportScreen( onReWriteScriptClick: () -> Unit, onReRecordingClick: () -> Unit, onFeedBackWriteClick: () -> Unit, + onSpeechAccuracyClick: () -> Unit, + onScriptMatchClick: () -> Unit, modifier: Modifier = Modifier, ) { when (uiState) { @@ -99,6 +108,8 @@ internal fun AnalysisReportScreen( onReWriteScriptClick = onReWriteScriptClick, onReRecordingClick = onReRecordingClick, onFeedBackWriteClick = onFeedBackWriteClick, + onSpeechAccuracyClick = onSpeechAccuracyClick, + onScriptMatchClick = onScriptMatchClick, ) } @@ -117,6 +128,8 @@ private fun AnalysisReportScreenContent( onReWriteScriptClick: () -> Unit, onReRecordingClick: () -> Unit, onFeedBackWriteClick: () -> Unit, + onSpeechAccuracyClick: () -> Unit, + onScriptMatchClick: () -> Unit, modifier: Modifier, ) { uiState.reportDialog?.let { type -> @@ -152,6 +165,8 @@ private fun AnalysisReportScreenContent( onReWriteScriptClick = onReWriteScriptClick, onReRecordingClick = onReRecordingClick, onFeedBackWriteClick = onFeedBackWriteClick, + onSpeechAccuracyClick = onSpeechAccuracyClick, + onScriptMatchClick = onScriptMatchClick, ) }, modifier = modifier, @@ -178,6 +193,8 @@ private fun UpcomingAnalysisReportScreenPreview() { onReWriteScriptClick = {}, onReRecordingClick = {}, onFeedBackWriteClick = {}, + onSpeechAccuracyClick = {}, + onScriptMatchClick = {}, ) } } @@ -196,6 +213,8 @@ private fun PastAnalysisReportScreenPreview() { onReWriteScriptClick = { }, onReRecordingClick = {}, onFeedBackWriteClick = {}, + onSpeechAccuracyClick = {}, + onScriptMatchClick = {}, ) } } @@ -214,6 +233,8 @@ private fun AnalysisReportScreenLoadingPreview() { onReWriteScriptClick = { }, onReRecordingClick = {}, onFeedBackWriteClick = {}, + onSpeechAccuracyClick = {}, + onScriptMatchClick = {}, ) } } diff --git a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/AnalysisReportViewModel.kt b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/report/AnalysisReportViewModel.kt similarity index 82% rename from Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/AnalysisReportViewModel.kt rename to Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/report/AnalysisReportViewModel.kt index 8a5e3b62..3186d7b7 100644 --- a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/AnalysisReportViewModel.kt +++ b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/report/AnalysisReportViewModel.kt @@ -1,16 +1,16 @@ -package com.team.prezel.feature.report.impl +package com.team.prezel.feature.report.impl.report import androidx.lifecycle.viewModelScope import com.team.prezel.core.domain.usecase.presentation.DeletePresentationAnalysisUseCase import com.team.prezel.core.domain.usecase.presentation.FetchPresentationDetailUseCase import com.team.prezel.core.ui.base.BaseViewModel import com.team.prezel.feature.report.api.ReportNavKey -import com.team.prezel.feature.report.impl.contract.AnalysisReportUiEffect -import com.team.prezel.feature.report.impl.contract.AnalysisReportUiIntent -import com.team.prezel.feature.report.impl.contract.AnalysisReportUiState -import com.team.prezel.feature.report.impl.contract.toAnalysisReportUiState -import com.team.prezel.feature.report.impl.model.AnalysisReportDialog -import com.team.prezel.feature.report.impl.model.AnalysisReportUiMessage +import com.team.prezel.feature.report.impl.report.contract.AnalysisReportUiEffect +import com.team.prezel.feature.report.impl.report.contract.AnalysisReportUiIntent +import com.team.prezel.feature.report.impl.report.contract.AnalysisReportUiState +import com.team.prezel.feature.report.impl.report.contract.toAnalysisReportUiState +import com.team.prezel.feature.report.impl.report.model.AnalysisReportDialog +import com.team.prezel.feature.report.impl.report.model.AnalysisReportUiMessage import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -45,6 +45,8 @@ internal class AnalysisReportViewModel @AssistedInject constructor( AnalysisReportUiIntent.ClickReRecording -> updateContent { copy(reportDialog = AnalysisReportDialog.RE_RECORDING) } AnalysisReportUiIntent.ClickReWriteScript -> handleReWriteScriptClick() AnalysisReportUiIntent.ClickFeedbackWrite -> navigateToSelfFeedbackWrite() + AnalysisReportUiIntent.ClickSpeechAccuracy -> navigateToSpeechAccuracy() + AnalysisReportUiIntent.ClickScriptMatch -> navigateToScriptMatch() } } @@ -121,6 +123,16 @@ internal class AnalysisReportViewModel @AssistedInject constructor( viewModelScope.launch { sendEffect(effect) } } + private fun navigateToSpeechAccuracy() { + val effect = analysisResultId?.let(AnalysisReportUiEffect::NavigateToSpeechAccuracy) ?: return + viewModelScope.launch { sendEffect(effect) } + } + + private fun navigateToScriptMatch() { + val effect = analysisResultId?.let(AnalysisReportUiEffect::NavigateToScriptMatch) ?: return + viewModelScope.launch { sendEffect(effect) } + } + private val contentState: AnalysisReportUiState.Content? get() = (currentState as? AnalysisReportUiState.Content) private fun updateContent(transform: AnalysisReportUiState.Content.() -> AnalysisReportUiState.Content) { diff --git a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/component/ReportBodyContent.kt b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/report/component/ReportBodyContent.kt similarity index 78% rename from Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/component/ReportBodyContent.kt rename to Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/report/component/ReportBodyContent.kt index 5e403e42..b83356f2 100644 --- a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/component/ReportBodyContent.kt +++ b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/report/component/ReportBodyContent.kt @@ -1,4 +1,4 @@ -package com.team.prezel.feature.report.impl.component +package com.team.prezel.feature.report.impl.report.component import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -21,15 +21,15 @@ import com.team.prezel.core.designsystem.component.actions.button.config.ButtonT import com.team.prezel.core.designsystem.preview.BasicPreview import com.team.prezel.core.designsystem.theme.PrezelTheme import com.team.prezel.feature.report.impl.R -import com.team.prezel.feature.report.impl.component.body.AccuracySection -import com.team.prezel.feature.report.impl.component.body.ExpectedQuestionsSection -import com.team.prezel.feature.report.impl.component.body.GrowthGraphSection -import com.team.prezel.feature.report.impl.component.body.PracticeHistorySection -import com.team.prezel.feature.report.impl.component.body.ScriptAnalysisSection -import com.team.prezel.feature.report.impl.component.body.SelfFeedbackSection -import com.team.prezel.feature.report.impl.component.body.SummarySection -import com.team.prezel.feature.report.impl.contract.AnalysisReportUiState -import com.team.prezel.feature.report.impl.preview.ReportPreviewUpcomingUiState +import com.team.prezel.feature.report.impl.report.component.body.AccuracySection +import com.team.prezel.feature.report.impl.report.component.body.ExpectedQuestionsSection +import com.team.prezel.feature.report.impl.report.component.body.GrowthGraphSection +import com.team.prezel.feature.report.impl.report.component.body.PracticeHistorySection +import com.team.prezel.feature.report.impl.report.component.body.ScriptAnalysisSection +import com.team.prezel.feature.report.impl.report.component.body.SelfFeedbackSection +import com.team.prezel.feature.report.impl.report.component.body.SummarySection +import com.team.prezel.feature.report.impl.report.contract.AnalysisReportUiState +import com.team.prezel.feature.report.impl.report.preview.ReportPreviewUpcomingUiState import kotlinx.collections.immutable.persistentListOf @Composable @@ -40,6 +40,8 @@ internal fun ReportBodyContent( onReWriteScriptClick: () -> Unit, onReRecordingClick: () -> Unit, onFeedBackWriteClick: () -> Unit, + onSpeechAccuracyClick: () -> Unit, + onScriptMatchClick: () -> Unit, ) { if (uiState.isPast) { SelfFeedbackSection( @@ -54,6 +56,8 @@ internal fun ReportBodyContent( accuracyScore = uiState.accuracyScore, scriptMatchRate = uiState.scriptMatchRate, speedGraphData = uiState.speedGraphData, + onSpeechAccuracyClick = onSpeechAccuracyClick, + onScriptMatchClick = onScriptMatchClick, ) GrowthGraphSection( growthGraphData = uiState.growthGraphData, @@ -96,6 +100,8 @@ private fun ReportBodyContentPreview() { onReWriteScriptClick = {}, onReRecordingClick = {}, onFeedBackWriteClick = {}, + onSpeechAccuracyClick = {}, + onScriptMatchClick = {}, ) } } diff --git a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/component/ReportHeaderContent.kt b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/report/component/ReportHeaderContent.kt similarity index 97% rename from Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/component/ReportHeaderContent.kt rename to Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/report/component/ReportHeaderContent.kt index 28736451..0eae170e 100644 --- a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/component/ReportHeaderContent.kt +++ b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/report/component/ReportHeaderContent.kt @@ -1,4 +1,4 @@ -package com.team.prezel.feature.report.impl.component +package com.team.prezel.feature.report.impl.report.component import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.expandVertically @@ -42,8 +42,8 @@ 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.report.impl.R -import com.team.prezel.feature.report.impl.model.PresentationInfoUiModel -import com.team.prezel.feature.report.impl.preview.ReportPreviewUpcomingUiState +import com.team.prezel.feature.report.impl.report.model.PresentationInfoUiModel +import com.team.prezel.feature.report.impl.report.preview.ReportPreviewUpcomingUiState @Composable internal fun ReportHeaderContent( diff --git a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/component/ReportScreenLayout.kt b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/report/component/ReportScreenLayout.kt similarity index 96% rename from Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/component/ReportScreenLayout.kt rename to Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/report/component/ReportScreenLayout.kt index 1c4a0e37..4460c153 100644 --- a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/component/ReportScreenLayout.kt +++ b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/report/component/ReportScreenLayout.kt @@ -1,4 +1,4 @@ -package com.team.prezel.feature.report.impl.component +package com.team.prezel.feature.report.impl.report.component import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn @@ -29,7 +29,7 @@ import androidx.compose.ui.unit.dp import com.team.prezel.core.designsystem.component.PrezelTopAppBar import com.team.prezel.core.designsystem.preview.BasicPreview import com.team.prezel.core.designsystem.theme.PrezelTheme -import com.team.prezel.feature.report.impl.preview.ReportPreviewUpcomingUiState +import com.team.prezel.feature.report.impl.report.preview.ReportPreviewUpcomingUiState private data class ReportTopBarState( val appBarHeight: Float, @@ -195,6 +195,8 @@ private fun ReportScreenLayoutPreview() { onReWriteScriptClick = {}, onReRecordingClick = {}, onFeedBackWriteClick = {}, + onSpeechAccuracyClick = {}, + onScriptMatchClick = {}, ) }, ) diff --git a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/component/body/AccuracySection.kt b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/report/component/body/AccuracySection.kt similarity index 84% rename from Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/component/body/AccuracySection.kt rename to Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/report/component/body/AccuracySection.kt index 1c5a2652..bc019a7a 100644 --- a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/component/body/AccuracySection.kt +++ b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/report/component/body/AccuracySection.kt @@ -1,4 +1,4 @@ -package com.team.prezel.feature.report.impl.component.body +package com.team.prezel.feature.report.impl.report.component.body import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -24,17 +24,19 @@ import com.team.prezel.core.designsystem.theme.PrezelTheme import com.team.prezel.core.model.practice.RecordingSpeed import com.team.prezel.core.ui.component.graph.SpeedGraph import com.team.prezel.feature.report.impl.R -import com.team.prezel.feature.report.impl.component.common.MetricResultCard -import com.team.prezel.feature.report.impl.component.common.ReportSection -import com.team.prezel.feature.report.impl.component.common.toPercentLabel -import com.team.prezel.feature.report.impl.model.SpeedGraphData -import com.team.prezel.feature.report.impl.preview.ReportPreviewUpcomingUiState +import com.team.prezel.feature.report.impl.report.component.common.MetricResultCard +import com.team.prezel.feature.report.impl.report.component.common.ReportSection +import com.team.prezel.feature.report.impl.report.component.common.toPercentLabel +import com.team.prezel.feature.report.impl.report.model.SpeedGraphData +import com.team.prezel.feature.report.impl.report.preview.ReportPreviewUpcomingUiState @Composable internal fun AccuracySection( accuracyScore: Double?, scriptMatchRate: Double?, speedGraphData: SpeedGraphData, + onSpeechAccuracyClick: () -> Unit, + onScriptMatchClick: () -> Unit, ) { ReportSection( title = { AccuracySectionTitle() }, @@ -44,6 +46,8 @@ internal fun AccuracySection( AccuracyMetricCards( accuracyScore = accuracyScore, scriptMatchRate = scriptMatchRate, + onSpeechAccuracyClick = onSpeechAccuracyClick, + onScriptMatchClick = onScriptMatchClick, ) } } @@ -80,6 +84,8 @@ private fun SpeedMetricRow(speedGraphData: SpeedGraphData) { private fun AccuracyMetricCards( accuracyScore: Double?, scriptMatchRate: Double?, + onSpeechAccuracyClick: () -> Unit, + onScriptMatchClick: () -> Unit, ) { Row( modifier = Modifier.fillMaxWidth(), @@ -89,11 +95,15 @@ private fun AccuracyMetricCards( modifier = Modifier.weight(1f), title = stringResource(R.string.feature_report_impl_label_speech), value = accuracyScore.toPercentLabel(), + enabled = accuracyScore != null, + onClick = onSpeechAccuracyClick, ) MetricResultCard( modifier = Modifier.weight(1f), title = stringResource(R.string.feature_report_impl_label_script_match), value = scriptMatchRate.toPercentLabel(), + enabled = scriptMatchRate != null, + onClick = onScriptMatchClick, ) } } @@ -164,6 +174,8 @@ private fun AccuracySectionPreview() { accuracyScore = ReportPreviewUpcomingUiState.accuracyScore, scriptMatchRate = ReportPreviewUpcomingUiState.scriptMatchRate, speedGraphData = ReportPreviewUpcomingUiState.speedGraphData, + onSpeechAccuracyClick = {}, + onScriptMatchClick = {}, ) } } diff --git a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/component/body/ExpectedQuestionsSection.kt b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/report/component/body/ExpectedQuestionsSection.kt similarity index 89% rename from Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/component/body/ExpectedQuestionsSection.kt rename to Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/report/component/body/ExpectedQuestionsSection.kt index a3b3e4b7..91734044 100644 --- a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/component/body/ExpectedQuestionsSection.kt +++ b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/report/component/body/ExpectedQuestionsSection.kt @@ -1,4 +1,4 @@ -package com.team.prezel.feature.report.impl.component.body +package com.team.prezel.feature.report.impl.report.component.body import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement @@ -15,10 +15,10 @@ import com.team.prezel.core.designsystem.component.PrezelAccordion import com.team.prezel.core.designsystem.preview.BasicPreview import com.team.prezel.core.designsystem.theme.PrezelTheme import com.team.prezel.feature.report.impl.R -import com.team.prezel.feature.report.impl.component.common.EmptyStateCard -import com.team.prezel.feature.report.impl.component.common.ReportSection -import com.team.prezel.feature.report.impl.model.QuestionUiModel -import com.team.prezel.feature.report.impl.preview.ReportPreviewUpcomingUiState +import com.team.prezel.feature.report.impl.report.component.common.EmptyStateCard +import com.team.prezel.feature.report.impl.report.component.common.ReportSection +import com.team.prezel.feature.report.impl.report.model.QuestionUiModel +import com.team.prezel.feature.report.impl.report.preview.ReportPreviewUpcomingUiState import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf diff --git a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/component/body/GrowthGraphSection.kt b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/report/component/body/GrowthGraphSection.kt similarity index 94% rename from Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/component/body/GrowthGraphSection.kt rename to Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/report/component/body/GrowthGraphSection.kt index c2cee7b5..6bd43026 100644 --- a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/component/body/GrowthGraphSection.kt +++ b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/report/component/body/GrowthGraphSection.kt @@ -1,4 +1,4 @@ -package com.team.prezel.feature.report.impl.component.body +package com.team.prezel.feature.report.impl.report.component.body import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement @@ -36,11 +36,11 @@ import com.team.prezel.core.designsystem.preview.BasicPreview import com.team.prezel.core.designsystem.theme.PrezelTheme import com.team.prezel.core.ui.component.graph.CardGraph import com.team.prezel.feature.report.impl.R -import com.team.prezel.feature.report.impl.component.common.ReportSection -import com.team.prezel.feature.report.impl.component.common.toPercentLabel -import com.team.prezel.feature.report.impl.model.GrowthGraphData -import com.team.prezel.feature.report.impl.model.GrowthGraphItemUiModel -import com.team.prezel.feature.report.impl.preview.ReportPreviewUpcomingUiState +import com.team.prezel.feature.report.impl.report.component.common.ReportSection +import com.team.prezel.feature.report.impl.report.component.common.toPercentLabel +import com.team.prezel.feature.report.impl.report.model.GrowthGraphData +import com.team.prezel.feature.report.impl.report.model.GrowthGraphItemUiModel +import com.team.prezel.feature.report.impl.report.preview.ReportPreviewUpcomingUiState import kotlinx.collections.immutable.persistentListOf @Composable diff --git a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/component/body/PracticeHistorySection.kt b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/report/component/body/PracticeHistorySection.kt similarity index 87% rename from Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/component/body/PracticeHistorySection.kt rename to Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/report/component/body/PracticeHistorySection.kt index 9d1d11b1..c95a6e77 100644 --- a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/component/body/PracticeHistorySection.kt +++ b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/report/component/body/PracticeHistorySection.kt @@ -1,4 +1,4 @@ -package com.team.prezel.feature.report.impl.component.body +package com.team.prezel.feature.report.impl.report.component.body import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -7,8 +7,8 @@ import com.team.prezel.core.designsystem.preview.BasicPreview import com.team.prezel.core.designsystem.theme.PrezelTheme import com.team.prezel.core.ui.component.PracticeCard import com.team.prezel.feature.report.impl.R -import com.team.prezel.feature.report.impl.component.common.ReportSection -import com.team.prezel.feature.report.impl.model.PracticeRecordsUiModel +import com.team.prezel.feature.report.impl.report.component.common.ReportSection +import com.team.prezel.feature.report.impl.report.model.PracticeRecordsUiModel import kotlinx.datetime.DateTimeUnit import kotlinx.datetime.LocalDate import kotlinx.datetime.plus diff --git a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/component/body/ScriptAnalysisSection.kt b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/report/component/body/ScriptAnalysisSection.kt similarity index 92% rename from Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/component/body/ScriptAnalysisSection.kt rename to Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/report/component/body/ScriptAnalysisSection.kt index 96fd48cb..76da1113 100644 --- a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/component/body/ScriptAnalysisSection.kt +++ b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/report/component/body/ScriptAnalysisSection.kt @@ -1,4 +1,4 @@ -package com.team.prezel.feature.report.impl.component.body +package com.team.prezel.feature.report.impl.report.component.body import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -23,11 +23,11 @@ import com.team.prezel.core.designsystem.theme.PrezelTheme import com.team.prezel.core.ui.component.graph.StickGraph import com.team.prezel.core.ui.component.graph.StickGraphItemType import com.team.prezel.feature.report.impl.R -import com.team.prezel.feature.report.impl.component.common.EmptyStateCard -import com.team.prezel.feature.report.impl.component.common.MetricResultCard -import com.team.prezel.feature.report.impl.component.common.ReportSection -import com.team.prezel.feature.report.impl.model.ScriptAnalysisGraphData -import com.team.prezel.feature.report.impl.preview.ReportPreviewUpcomingUiState +import com.team.prezel.feature.report.impl.report.component.common.EmptyStateCard +import com.team.prezel.feature.report.impl.report.component.common.MetricResultCard +import com.team.prezel.feature.report.impl.report.component.common.ReportSection +import com.team.prezel.feature.report.impl.report.model.ScriptAnalysisGraphData +import com.team.prezel.feature.report.impl.report.preview.ReportPreviewUpcomingUiState import kotlinx.collections.immutable.ImmutableMap import kotlinx.collections.immutable.toPersistentMap diff --git a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/component/body/SelfFeedbackSection.kt b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/report/component/body/SelfFeedbackSection.kt similarity index 92% rename from Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/component/body/SelfFeedbackSection.kt rename to Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/report/component/body/SelfFeedbackSection.kt index 15dfafcb..96bbef72 100644 --- a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/component/body/SelfFeedbackSection.kt +++ b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/report/component/body/SelfFeedbackSection.kt @@ -1,4 +1,4 @@ -package com.team.prezel.feature.report.impl.component.body +package com.team.prezel.feature.report.impl.report.component.body import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box @@ -16,8 +16,8 @@ import com.team.prezel.core.designsystem.component.actions.button.config.ButtonT import com.team.prezel.core.designsystem.preview.BasicPreview import com.team.prezel.core.designsystem.theme.PrezelTheme import com.team.prezel.feature.report.impl.R -import com.team.prezel.feature.report.impl.component.common.ReportSection -import com.team.prezel.feature.report.impl.preview.ReportPreviewPastUiState +import com.team.prezel.feature.report.impl.report.component.common.ReportSection +import com.team.prezel.feature.report.impl.report.preview.ReportPreviewPastUiState @Composable internal fun SelfFeedbackSection( diff --git a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/component/body/SummaryFeedbackSection.kt b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/report/component/body/SummaryFeedbackSection.kt similarity index 86% rename from Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/component/body/SummaryFeedbackSection.kt rename to Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/report/component/body/SummaryFeedbackSection.kt index 2d33c8ca..0ac39574 100644 --- a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/component/body/SummaryFeedbackSection.kt +++ b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/report/component/body/SummaryFeedbackSection.kt @@ -1,4 +1,4 @@ -package com.team.prezel.feature.report.impl.component.body +package com.team.prezel.feature.report.impl.report.component.body import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box @@ -12,8 +12,8 @@ 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.report.impl.R -import com.team.prezel.feature.report.impl.component.common.ReportSection -import com.team.prezel.feature.report.impl.preview.ReportPreviewUpcomingUiState +import com.team.prezel.feature.report.impl.report.component.common.ReportSection +import com.team.prezel.feature.report.impl.report.preview.ReportPreviewUpcomingUiState @Composable internal fun SummarySection(summary: String) { diff --git a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/component/common/EmptyStateCard.kt b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/report/component/common/EmptyStateCard.kt similarity index 95% rename from Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/component/common/EmptyStateCard.kt rename to Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/report/component/common/EmptyStateCard.kt index ebf6a004..dcc9214d 100644 --- a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/component/common/EmptyStateCard.kt +++ b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/report/component/common/EmptyStateCard.kt @@ -1,4 +1,4 @@ -package com.team.prezel.feature.report.impl.component.common +package com.team.prezel.feature.report.impl.report.component.common import androidx.compose.foundation.border import androidx.compose.foundation.layout.Box diff --git a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/component/common/ReportMetricCards.kt b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/report/component/common/ReportMetricCards.kt similarity index 77% rename from Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/component/common/ReportMetricCards.kt rename to Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/report/component/common/ReportMetricCards.kt index 18fd0a41..e8c6e008 100644 --- a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/component/common/ReportMetricCards.kt +++ b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/report/component/common/ReportMetricCards.kt @@ -1,7 +1,6 @@ -package com.team.prezel.feature.report.impl.component.common +package com.team.prezel.feature.report.impl.report.component.common import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth @@ -16,6 +15,7 @@ import com.team.prezel.core.designsystem.component.actions.button.PrezelIconButt import com.team.prezel.core.designsystem.component.actions.button.config.ButtonHierarchy import com.team.prezel.core.designsystem.component.actions.button.config.ButtonSize import com.team.prezel.core.designsystem.component.actions.button.config.ButtonType +import com.team.prezel.core.designsystem.component.base.PrezelTouchArea import com.team.prezel.core.designsystem.icon.PrezelIcons import com.team.prezel.core.designsystem.preview.BasicPreview import com.team.prezel.core.designsystem.theme.PrezelTheme @@ -26,18 +26,25 @@ internal fun MetricResultCard( title: String, value: String, modifier: Modifier = Modifier, + enabled: Boolean = false, + onClick: () -> Unit = {}, ) { - Box( + PrezelTouchArea( modifier = modifier .clip(shape = PrezelTheme.shapes.V8) .background(color = PrezelTheme.colors.bgMedium), + enabled = enabled, + shape = PrezelTheme.shapes.V8, + onClick = onClick, ) { Column( - modifier = Modifier.padding( - start = PrezelTheme.spacing.V12, - bottom = PrezelTheme.spacing.V12, - top = PrezelTheme.spacing.V14, - ), + modifier = Modifier + .align(Alignment.TopStart) + .padding( + start = PrezelTheme.spacing.V12, + bottom = PrezelTheme.spacing.V12, + top = PrezelTheme.spacing.V14, + ), ) { Text( text = title, @@ -59,8 +66,9 @@ internal fun MetricResultCard( size = ButtonSize.SMALL, hierarchy = ButtonHierarchy.SECONDARY, modifier = Modifier.align(Alignment.TopEnd), - enabled = false, - onClick = { }, + enabled = enabled, + isUseRipple = false, + onClick = onClick, ) } } diff --git a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/component/common/ReportSection.kt b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/report/component/common/ReportSection.kt similarity index 95% rename from Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/component/common/ReportSection.kt rename to Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/report/component/common/ReportSection.kt index 529b7927..8e372b0c 100644 --- a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/component/common/ReportSection.kt +++ b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/report/component/common/ReportSection.kt @@ -1,4 +1,4 @@ -package com.team.prezel.feature.report.impl.component.common +package com.team.prezel.feature.report.impl.report.component.common import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer diff --git a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/component/modal/ReportDialog.kt b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/report/component/modal/ReportDialog.kt similarity index 96% rename from Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/component/modal/ReportDialog.kt rename to Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/report/component/modal/ReportDialog.kt index 06d4bbc9..fb46a3b4 100644 --- a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/component/modal/ReportDialog.kt +++ b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/report/component/modal/ReportDialog.kt @@ -1,4 +1,4 @@ -package com.team.prezel.feature.report.impl.component.modal +package com.team.prezel.feature.report.impl.report.component.modal import androidx.annotation.StringRes import androidx.compose.foundation.layout.Box @@ -11,7 +11,7 @@ import com.team.prezel.core.designsystem.component.feedback.dialog.PrezelDialogS import com.team.prezel.core.designsystem.preview.BasicPreview import com.team.prezel.core.designsystem.theme.PrezelTheme import com.team.prezel.feature.report.impl.R -import com.team.prezel.feature.report.impl.model.AnalysisReportDialog +import com.team.prezel.feature.report.impl.report.model.AnalysisReportDialog @Composable internal fun ReportDialog( diff --git a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/contract/AnalysisReportUiEffect.kt b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/report/contract/AnalysisReportUiEffect.kt similarity index 64% rename from Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/contract/AnalysisReportUiEffect.kt rename to Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/report/contract/AnalysisReportUiEffect.kt index c5b26bde..a1e13654 100644 --- a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/contract/AnalysisReportUiEffect.kt +++ b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/report/contract/AnalysisReportUiEffect.kt @@ -1,7 +1,7 @@ -package com.team.prezel.feature.report.impl.contract +package com.team.prezel.feature.report.impl.report.contract import com.team.prezel.core.ui.base.UiEffect -import com.team.prezel.feature.report.impl.model.AnalysisReportUiMessage +import com.team.prezel.feature.report.impl.report.model.AnalysisReportUiMessage internal sealed interface AnalysisReportUiEffect : UiEffect { data object NavigateToBack : AnalysisReportUiEffect @@ -23,4 +23,12 @@ internal sealed interface AnalysisReportUiEffect : UiEffect { data class NavigateToSelfFeedbackWrite( val presentationId: Long, ) : AnalysisReportUiEffect + + data class NavigateToSpeechAccuracy( + val analysisResultId: Long, + ) : AnalysisReportUiEffect + + data class NavigateToScriptMatch( + val analysisResultId: Long, + ) : AnalysisReportUiEffect } diff --git a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/contract/AnalysisReportUiIntent.kt b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/report/contract/AnalysisReportUiIntent.kt similarity index 75% rename from Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/contract/AnalysisReportUiIntent.kt rename to Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/report/contract/AnalysisReportUiIntent.kt index 85f3480f..6ec45f2d 100644 --- a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/contract/AnalysisReportUiIntent.kt +++ b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/report/contract/AnalysisReportUiIntent.kt @@ -1,4 +1,4 @@ -package com.team.prezel.feature.report.impl.contract +package com.team.prezel.feature.report.impl.report.contract import com.team.prezel.core.ui.base.UiIntent @@ -18,4 +18,8 @@ internal sealed interface AnalysisReportUiIntent : UiIntent { data object ClickReRecording : AnalysisReportUiIntent data object ClickFeedbackWrite : AnalysisReportUiIntent + + data object ClickSpeechAccuracy : AnalysisReportUiIntent + + data object ClickScriptMatch : AnalysisReportUiIntent } diff --git a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/contract/AnalysisReportUiState.kt b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/report/contract/AnalysisReportUiState.kt similarity index 62% rename from Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/contract/AnalysisReportUiState.kt rename to Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/report/contract/AnalysisReportUiState.kt index ffbd03b5..c33c94a2 100644 --- a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/contract/AnalysisReportUiState.kt +++ b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/report/contract/AnalysisReportUiState.kt @@ -1,14 +1,14 @@ -package com.team.prezel.feature.report.impl.contract +package com.team.prezel.feature.report.impl.report.contract import androidx.compose.runtime.Immutable import com.team.prezel.core.ui.base.UiState -import com.team.prezel.feature.report.impl.model.AnalysisReportDialog -import com.team.prezel.feature.report.impl.model.GrowthGraphData -import com.team.prezel.feature.report.impl.model.PracticeRecordsUiModel -import com.team.prezel.feature.report.impl.model.PresentationInfoUiModel -import com.team.prezel.feature.report.impl.model.QuestionUiModel -import com.team.prezel.feature.report.impl.model.ScriptAnalysisGraphData -import com.team.prezel.feature.report.impl.model.SpeedGraphData +import com.team.prezel.feature.report.impl.report.model.AnalysisReportDialog +import com.team.prezel.feature.report.impl.report.model.GrowthGraphData +import com.team.prezel.feature.report.impl.report.model.PracticeRecordsUiModel +import com.team.prezel.feature.report.impl.report.model.PresentationInfoUiModel +import com.team.prezel.feature.report.impl.report.model.QuestionUiModel +import com.team.prezel.feature.report.impl.report.model.ScriptAnalysisGraphData +import com.team.prezel.feature.report.impl.report.model.SpeedGraphData import kotlinx.collections.immutable.ImmutableList @Immutable diff --git a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/contract/AnalysisReportUiStateMapper.kt b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/report/contract/AnalysisReportUiStateMapper.kt similarity index 79% rename from Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/contract/AnalysisReportUiStateMapper.kt rename to Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/report/contract/AnalysisReportUiStateMapper.kt index f1d909a5..7eb8398c 100644 --- a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/contract/AnalysisReportUiStateMapper.kt +++ b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/report/contract/AnalysisReportUiStateMapper.kt @@ -1,15 +1,15 @@ -package com.team.prezel.feature.report.impl.contract +package com.team.prezel.feature.report.impl.report.contract import com.team.prezel.core.model.presentation.ExpectedQuestion import com.team.prezel.core.model.presentation.PresentationDetailWithPracticeRecords import com.team.prezel.core.model.presentation.PresentationGrowthPoint -import com.team.prezel.feature.report.impl.model.GrowthGraphData -import com.team.prezel.feature.report.impl.model.GrowthGraphItemUiModel -import com.team.prezel.feature.report.impl.model.PracticeRecordsUiModel.Companion.toUiModel -import com.team.prezel.feature.report.impl.model.PresentationInfoUiModel -import com.team.prezel.feature.report.impl.model.QuestionUiModel -import com.team.prezel.feature.report.impl.model.ScriptAnalysisGraphData -import com.team.prezel.feature.report.impl.model.SpeedGraphData +import com.team.prezel.feature.report.impl.report.model.GrowthGraphData +import com.team.prezel.feature.report.impl.report.model.GrowthGraphItemUiModel +import com.team.prezel.feature.report.impl.report.model.PracticeRecordsUiModel.Companion.toUiModel +import com.team.prezel.feature.report.impl.report.model.PresentationInfoUiModel +import com.team.prezel.feature.report.impl.report.model.QuestionUiModel +import com.team.prezel.feature.report.impl.report.model.ScriptAnalysisGraphData +import com.team.prezel.feature.report.impl.report.model.SpeedGraphData import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList diff --git a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/model/AnalysisReportDialog.kt b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/report/model/AnalysisReportDialog.kt similarity index 62% rename from Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/model/AnalysisReportDialog.kt rename to Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/report/model/AnalysisReportDialog.kt index c7eb2ef1..3c716b13 100644 --- a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/model/AnalysisReportDialog.kt +++ b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/report/model/AnalysisReportDialog.kt @@ -1,4 +1,4 @@ -package com.team.prezel.feature.report.impl.model +package com.team.prezel.feature.report.impl.report.model enum class AnalysisReportDialog { RE_RECORDING, diff --git a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/model/AnalysisReportUiMessage.kt b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/report/model/AnalysisReportUiMessage.kt similarity index 63% rename from Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/model/AnalysisReportUiMessage.kt rename to Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/report/model/AnalysisReportUiMessage.kt index 6e1cdfff..1180f3df 100644 --- a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/model/AnalysisReportUiMessage.kt +++ b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/report/model/AnalysisReportUiMessage.kt @@ -1,4 +1,4 @@ -package com.team.prezel.feature.report.impl.model +package com.team.prezel.feature.report.impl.report.model internal enum class AnalysisReportUiMessage { FETCH_REPORT_FAILED, diff --git a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/model/GrowthGraphData.kt b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/report/model/GrowthGraphData.kt similarity index 95% rename from Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/model/GrowthGraphData.kt rename to Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/report/model/GrowthGraphData.kt index 726374f7..ddc66bb8 100644 --- a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/model/GrowthGraphData.kt +++ b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/report/model/GrowthGraphData.kt @@ -1,4 +1,4 @@ -package com.team.prezel.feature.report.impl.model +package com.team.prezel.feature.report.impl.report.model import androidx.compose.runtime.Immutable import com.team.prezel.core.ui.component.graph.CardGraphItem diff --git a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/model/PracticeRecordsUiModel.kt b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/report/model/PracticeRecordsUiModel.kt similarity index 95% rename from Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/model/PracticeRecordsUiModel.kt rename to Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/report/model/PracticeRecordsUiModel.kt index 4d2d204f..ae38024b 100644 --- a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/model/PracticeRecordsUiModel.kt +++ b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/report/model/PracticeRecordsUiModel.kt @@ -1,4 +1,4 @@ -package com.team.prezel.feature.report.impl.model +package com.team.prezel.feature.report.impl.report.model import androidx.compose.runtime.Immutable import com.team.prezel.core.model.presentation.PracticeRecords diff --git a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/model/PresentationInfoUiModel.kt b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/report/model/PresentationInfoUiModel.kt similarity index 89% rename from Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/model/PresentationInfoUiModel.kt rename to Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/report/model/PresentationInfoUiModel.kt index 5b8b902d..96324994 100644 --- a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/model/PresentationInfoUiModel.kt +++ b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/report/model/PresentationInfoUiModel.kt @@ -1,4 +1,4 @@ -package com.team.prezel.feature.report.impl.model +package com.team.prezel.feature.report.impl.report.model import androidx.compose.runtime.Immutable import com.team.prezel.core.model.presentation.Audience diff --git a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/model/QuestionUiModel.kt b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/report/model/QuestionUiModel.kt similarity index 58% rename from Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/model/QuestionUiModel.kt rename to Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/report/model/QuestionUiModel.kt index 528458dd..23fa2ba8 100644 --- a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/model/QuestionUiModel.kt +++ b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/report/model/QuestionUiModel.kt @@ -1,4 +1,4 @@ -package com.team.prezel.feature.report.impl.model +package com.team.prezel.feature.report.impl.report.model data class QuestionUiModel( val question: String, diff --git a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/model/ScriptAnalysisGraphData.kt b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/report/model/ScriptAnalysisGraphData.kt similarity index 70% rename from Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/model/ScriptAnalysisGraphData.kt rename to Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/report/model/ScriptAnalysisGraphData.kt index e0514955..7590c8f7 100644 --- a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/model/ScriptAnalysisGraphData.kt +++ b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/report/model/ScriptAnalysisGraphData.kt @@ -1,4 +1,4 @@ -package com.team.prezel.feature.report.impl.model +package com.team.prezel.feature.report.impl.report.model internal data class ScriptAnalysisGraphData( val spellingCount: Int, diff --git a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/model/SpeedGraphData.kt b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/report/model/SpeedGraphData.kt similarity index 72% rename from Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/model/SpeedGraphData.kt rename to Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/report/model/SpeedGraphData.kt index c0a66937..5ba162d6 100644 --- a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/model/SpeedGraphData.kt +++ b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/report/model/SpeedGraphData.kt @@ -1,4 +1,4 @@ -package com.team.prezel.feature.report.impl.model +package com.team.prezel.feature.report.impl.report.model import com.team.prezel.core.model.practice.RecordingSpeed diff --git a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/preview/ReportPreviewUiState.kt b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/report/preview/ReportPreviewUiState.kt similarity index 82% rename from Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/preview/ReportPreviewUiState.kt rename to Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/report/preview/ReportPreviewUiState.kt index 1dbc44df..d0c9a381 100644 --- a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/preview/ReportPreviewUiState.kt +++ b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/report/preview/ReportPreviewUiState.kt @@ -1,18 +1,18 @@ -package com.team.prezel.feature.report.impl.preview +package com.team.prezel.feature.report.impl.report.preview import com.team.prezel.core.model.practice.RecordingSpeed 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.report.impl.contract.AnalysisReportUiState -import com.team.prezel.feature.report.impl.model.GrowthGraphData -import com.team.prezel.feature.report.impl.model.GrowthGraphItemUiModel -import com.team.prezel.feature.report.impl.model.PracticeRecordsUiModel -import com.team.prezel.feature.report.impl.model.PresentationInfoUiModel -import com.team.prezel.feature.report.impl.model.QuestionUiModel -import com.team.prezel.feature.report.impl.model.ScriptAnalysisGraphData -import com.team.prezel.feature.report.impl.model.SpeedGraphData +import com.team.prezel.feature.report.impl.report.contract.AnalysisReportUiState +import com.team.prezel.feature.report.impl.report.model.GrowthGraphData +import com.team.prezel.feature.report.impl.report.model.GrowthGraphItemUiModel +import com.team.prezel.feature.report.impl.report.model.PracticeRecordsUiModel +import com.team.prezel.feature.report.impl.report.model.PresentationInfoUiModel +import com.team.prezel.feature.report.impl.report.model.QuestionUiModel +import com.team.prezel.feature.report.impl.report.model.ScriptAnalysisGraphData +import com.team.prezel.feature.report.impl.report.model.SpeedGraphData import kotlinx.collections.immutable.persistentListOf import kotlinx.datetime.LocalDate diff --git a/Prezel/feature/report/impl/src/main/res/values/strings.xml b/Prezel/feature/report/impl/src/main/res/values/strings.xml index a2e44f6d..42904179 100644 --- a/Prezel/feature/report/impl/src/main/res/values/strings.xml +++ b/Prezel/feature/report/impl/src/main/res/values/strings.xml @@ -41,7 +41,6 @@ 피드백 작성하기 대본을 입력하고 실제 발화를 비교해\n발음과 대본 일치율을 분석해보세요. 대본을 입력하고 예상 질문을 받아보세요. - 연습 기록이 아직 없어요. 취소 @@ -59,4 +58,14 @@ 대본을 입력하고\n맞춤법과 주술호응을 분석해보세요. 리포트를 불러오지 못했습니다. 리포트를 삭제하지 못했습니다. + 닫기 + 정확도 상세를 불러오지 못했습니다. + 발화 분석 상세가 없어요. + 모든 단어가 정확하게 발음되었어요. + 대본과 일치하지 않는 부분이 없어요. + 정확도 상세 오디오 트랙 + 발음 + 불필요한 표현 + 누락 + 불일치 From 92e7f248ed7cbc03af607f8ec1084cbad9afd797 Mon Sep 17 00:00:00 2001 From: Ham BeomJoon Date: Mon, 1 Jun 2026 20:42:49 +0900 Subject: [PATCH 2/5] =?UTF-8?q?feat:=20=EB=B6=84=EC=84=9D=20=EB=A6=AC?= =?UTF-8?q?=ED=8F=AC=ED=8A=B8=20=EC=83=81=EC=84=B8=20=ED=99=94=EB=A9=B4=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EA=B0=9C=EC=84=A0=20=EB=B0=8F=20=EB=82=B4?= =?UTF-8?q?=EB=B9=84=EA=B2=8C=EC=9D=B4=EC=85=98=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EB=A6=AC=ED=8C=A9=ED=84=B0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `AccuracyDetail` 화면의 데이터 로드 실패 시 스낵바 메시지 표시 로직 추가 - `ReportEntryBuilder` 내 내비게이션 호출 로직을 확장 함수로 분리하여 가독성 개선 - `ReportInnerNavKey.ScriptCorrection` 클래스에 `analysisResultId` 공통 인터페이스 적용 및 `Serializable` 어노테이션 추가 - `ReportMetricCards`의 불필요한 `noRippleClickable` 임포트 제거 - `AccuracyDetailUiEffect` 및 `AccuracyDetailUiMessage` 정의를 통한 에러 핸들링 구조화 --- .../accuracydetail/AccuracyDetailScreen.kt | 19 +++ .../accuracydetail/AccuracyDetailViewModel.kt | 2 + .../contract/AccuracyDetailUiEffect.kt | 7 +- .../model/AccuracyDetailUiMessage.kt | 5 + .../impl/navigation/ReportEntryBuilder.kt | 109 +++++++++++++----- .../impl/navigation/ReportInnerNavKey.kt | 5 +- .../component/common/ReportMetricCards.kt | 1 - 7 files changed, 114 insertions(+), 34 deletions(-) create mode 100644 Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/accuracydetail/model/AccuracyDetailUiMessage.kt diff --git a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/accuracydetail/AccuracyDetailScreen.kt b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/accuracydetail/AccuracyDetailScreen.kt index 315178d0..1474fe3d 100644 --- a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/accuracydetail/AccuracyDetailScreen.kt +++ b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/accuracydetail/AccuracyDetailScreen.kt @@ -24,9 +24,11 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.team.prezel.core.designsystem.component.feedback.snackbar.showPrezelSnackbar 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.player.PrezelPlayerItem @@ -37,6 +39,7 @@ import com.team.prezel.core.designsystem.theme.PrezelTheme import com.team.prezel.core.model.presentation.PresentationWordDetail import com.team.prezel.core.model.presentation.WordAnalysisDetail import com.team.prezel.core.model.presentation.WordAnalysisStatus +import com.team.prezel.core.ui.state.LocalSnackbarHostState import com.team.prezel.feature.report.impl.R import com.team.prezel.feature.report.impl.accuracydetail.component.AccuracyDetailPlayerSheet import com.team.prezel.feature.report.impl.accuracydetail.component.AccuracyDetailTopAppBar @@ -44,6 +47,7 @@ import com.team.prezel.feature.report.impl.accuracydetail.component.ScriptDetail import com.team.prezel.feature.report.impl.accuracydetail.component.isScriptMatchIssue import com.team.prezel.feature.report.impl.accuracydetail.component.isSpeechAccuracySheetIssue import com.team.prezel.feature.report.impl.accuracydetail.component.toMarkerType +import com.team.prezel.feature.report.impl.accuracydetail.contract.AccuracyDetailUiEffect import com.team.prezel.feature.report.impl.accuracydetail.contract.AccuracyDetailUiState import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList @@ -66,6 +70,21 @@ internal fun AccuracyDetailScreen( viewModel: AccuracyDetailViewModel, ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val snackbarHostState = LocalSnackbarHostState.current + val resources = LocalResources.current + + LaunchedEffect(viewModel) { + viewModel.uiEffect.collect { effect -> + when (effect) { + is AccuracyDetailUiEffect.ShowMessage -> { + snackbarHostState.showPrezelSnackbar( + message = resources.getString(R.string.feature_report_impl_script_detail_load_failed), + useRaisedPosition = false, + ) + } + } + } + } when (val state = uiState) { AccuracyDetailUiState.Loading -> Unit diff --git a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/accuracydetail/AccuracyDetailViewModel.kt b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/accuracydetail/AccuracyDetailViewModel.kt index e69dacb5..be544e2d 100644 --- a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/accuracydetail/AccuracyDetailViewModel.kt +++ b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/accuracydetail/AccuracyDetailViewModel.kt @@ -6,6 +6,7 @@ import com.team.prezel.core.ui.base.BaseViewModel import com.team.prezel.feature.report.impl.accuracydetail.contract.AccuracyDetailUiEffect import com.team.prezel.feature.report.impl.accuracydetail.contract.AccuracyDetailUiIntent import com.team.prezel.feature.report.impl.accuracydetail.contract.AccuracyDetailUiState +import com.team.prezel.feature.report.impl.accuracydetail.model.AccuracyDetailUiMessage import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -37,6 +38,7 @@ internal class AccuracyDetailViewModel @AssistedInject constructor( wordDetail = fetchPresentationWordDetailUseCase(analysisResultId).getOrThrow(), ) }.getOrElse { + sendEffect(AccuracyDetailUiEffect.ShowMessage(AccuracyDetailUiMessage.FetchDetailFailed)) AccuracyDetailUiState.Error } updateState { nextState } diff --git a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/accuracydetail/contract/AccuracyDetailUiEffect.kt b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/accuracydetail/contract/AccuracyDetailUiEffect.kt index bbfda853..38d80158 100644 --- a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/accuracydetail/contract/AccuracyDetailUiEffect.kt +++ b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/accuracydetail/contract/AccuracyDetailUiEffect.kt @@ -1,5 +1,10 @@ package com.team.prezel.feature.report.impl.accuracydetail.contract import com.team.prezel.core.ui.base.UiEffect +import com.team.prezel.feature.report.impl.accuracydetail.model.AccuracyDetailUiMessage -internal sealed interface AccuracyDetailUiEffect : UiEffect +internal sealed interface AccuracyDetailUiEffect : UiEffect { + data class ShowMessage( + val message: AccuracyDetailUiMessage, + ) : AccuracyDetailUiEffect +} diff --git a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/accuracydetail/model/AccuracyDetailUiMessage.kt b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/accuracydetail/model/AccuracyDetailUiMessage.kt new file mode 100644 index 00000000..dfc11a7e --- /dev/null +++ b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/accuracydetail/model/AccuracyDetailUiMessage.kt @@ -0,0 +1,5 @@ +package com.team.prezel.feature.report.impl.accuracydetail.model + +internal sealed interface AccuracyDetailUiMessage { + data object FetchDetailFailed : AccuracyDetailUiMessage +} diff --git a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/navigation/ReportEntryBuilder.kt b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/navigation/ReportEntryBuilder.kt index 9997c54f..d7b1f5f4 100644 --- a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/navigation/ReportEntryBuilder.kt +++ b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/navigation/ReportEntryBuilder.kt @@ -4,18 +4,17 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.navigation3.runtime.EntryProviderScope import androidx.navigation3.runtime.NavKey import com.team.prezel.core.navigation.LocalNavigator +import com.team.prezel.core.navigation.Navigator import com.team.prezel.feature.analysis.api.AnalysisNavKey import com.team.prezel.feature.feedback.api.FeedbackNavKey import com.team.prezel.feature.report.api.ReportNavKey -import com.team.prezel.feature.report.impl.report.AnalysisReportScreen -import com.team.prezel.feature.report.impl.report.AnalysisReportViewModel -import com.team.prezel.feature.report.impl.script.ScriptScreen -import com.team.prezel.feature.report.impl.script.ScriptViewModel import com.team.prezel.feature.report.impl.accuracydetail.AccuracyDetailScreen import com.team.prezel.feature.report.impl.accuracydetail.AccuracyDetailTab import com.team.prezel.feature.report.impl.accuracydetail.AccuracyDetailViewModel import com.team.prezel.feature.report.impl.report.AnalysisReportScreen import com.team.prezel.feature.report.impl.report.AnalysisReportViewModel +import com.team.prezel.feature.report.impl.script.ScriptScreen +import com.team.prezel.feature.report.impl.script.ScriptViewModel import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -24,6 +23,7 @@ import dagger.multibindings.IntoSet internal fun EntryProviderScope.featureAnalysisReportEntryBuilder() { reportEntry() + scriptCorrectionEntry() accuracyDetailEntry() } @@ -34,54 +34,47 @@ private fun EntryProviderScope.reportEntry() { AnalysisReportScreen( onBack = { navigator.goBack() }, navigateToAnalysisScript = { presentationId, isPast -> - navigator.navigate( - AnalysisNavKey.ReWritingScript( - presentationId = presentationId, - isPast = isPast, - ), + navigator.navigateToAnalysisScript( + presentationId = presentationId, + isPast = isPast, ) }, navigateToAnalysisRecording = { presentationId, isPast -> - navigator.navigate( - AnalysisNavKey.ReRecording( - presentationId = presentationId, - isPast = isPast, - ), + navigator.navigateToAnalysisRecording( + presentationId = presentationId, + isPast = isPast, ) }, navigateToSpeechAccuracy = { analysisResultId -> - navigator.navigate( - ReportInnerNavKey.AccuracyDetail( - analysisResultId = analysisResultId, - initialTab = AccuracyDetailTab.SPEECH, - ), + navigator.navigateToAccuracyDetail( + analysisResultId = analysisResultId, + initialTab = AccuracyDetailTab.SPEECH, ) }, navigateToScriptMatch = { analysisResultId -> - navigator.navigate( - ReportInnerNavKey.AccuracyDetail( - analysisResultId = analysisResultId, - initialTab = AccuracyDetailTab.SCRIPT_MATCH, - ), + navigator.navigateToAccuracyDetail( + analysisResultId = analysisResultId, + initialTab = AccuracyDetailTab.SCRIPT_MATCH, ) }, navigateToSelfFeedbackWrite = { presentationId, title, isPast -> - navigator.navigate( - FeedbackNavKey( - presentationId = presentationId, - title = title, - isPast = isPast, - ), + navigator.navigateToSelfFeedbackWrite( + presentationId = presentationId, + title = title, + isPast = isPast, ) }, navigateToScriptAnalysis = { analysisResultId -> - navigator.navigate(ReportInnerNavKey.ScriptCorrection(analysisResultId = analysisResultId)) + navigator.navigateToScriptAnalysis(analysisResultId = analysisResultId) }, viewModel = hiltViewModel( creationCallback = { factory -> factory.create(key) }, ), ) } +} + +private fun EntryProviderScope.scriptCorrectionEntry() { entry { key -> val navigator = LocalNavigator.current @@ -94,6 +87,60 @@ private fun EntryProviderScope.reportEntry() { } } +private fun Navigator.navigateToAnalysisScript( + presentationId: Long, + isPast: Boolean, +) { + navigate( + AnalysisNavKey.ReWritingScript( + presentationId = presentationId, + isPast = isPast, + ), + ) +} + +private fun Navigator.navigateToAnalysisRecording( + presentationId: Long, + isPast: Boolean, +) { + navigate( + AnalysisNavKey.ReRecording( + presentationId = presentationId, + isPast = isPast, + ), + ) +} + +private fun Navigator.navigateToAccuracyDetail( + analysisResultId: Long, + initialTab: AccuracyDetailTab, +) { + navigate( + ReportInnerNavKey.AccuracyDetail( + analysisResultId = analysisResultId, + initialTab = initialTab, + ), + ) +} + +private fun Navigator.navigateToSelfFeedbackWrite( + presentationId: Long, + title: String, + isPast: Boolean, +) { + navigate( + FeedbackNavKey( + presentationId = presentationId, + title = title, + isPast = isPast, + ), + ) +} + +private fun Navigator.navigateToScriptAnalysis(analysisResultId: Long) { + navigate(ReportInnerNavKey.ScriptCorrection(analysisResultId = analysisResultId)) +} + private fun EntryProviderScope.accuracyDetailEntry() { entry { key -> val navigator = LocalNavigator.current diff --git a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/navigation/ReportInnerNavKey.kt b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/navigation/ReportInnerNavKey.kt index d800c10e..09da303b 100644 --- a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/navigation/ReportInnerNavKey.kt +++ b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/navigation/ReportInnerNavKey.kt @@ -12,7 +12,10 @@ internal sealed interface ReportInnerNavKey : NavKey { data class AccuracyDetail( override val analysisResultId: Long, val initialTab: AccuracyDetailTab, + ) : ReportInnerNavKey + + @Serializable data class ScriptCorrection( - val analysisResultId: Long, + override val analysisResultId: Long, ) : ReportInnerNavKey } diff --git a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/report/component/common/ReportMetricCards.kt b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/report/component/common/ReportMetricCards.kt index a0adbca4..1de73dbc 100644 --- a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/report/component/common/ReportMetricCards.kt +++ b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/report/component/common/ReportMetricCards.kt @@ -19,7 +19,6 @@ import com.team.prezel.core.designsystem.component.base.PrezelTouchArea 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.util.noRippleClickable import kotlin.math.roundToInt @Composable From cefce57c77dafb6d5b022a2adc01850ec45522c0 Mon Sep 17 00:00:00 2001 From: Ham BeomJoon Date: Thu, 11 Jun 2026 00:45:26 +0900 Subject: [PATCH 3/5] =?UTF-8?q?feat:=20=EB=B0=9C=ED=99=94=20=EC=A0=95?= =?UTF-8?q?=ED=99=95=EB=8F=84=20=EC=83=81=EC=84=B8=20=EB=B6=84=EC=84=9D=20?= =?UTF-8?q?=EB=8B=A8=EC=9C=84=EB=A5=BC=20=EB=AC=B8=EC=9E=A5=20=EA=B8=B0?= =?UTF-8?q?=EB=B0=98=EC=9C=BC=EB=A1=9C=20=EA=B3=A0=EB=8F=84=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 분석 단위를 `WordAnalysisDetail`에서 `SentenceAnalysisDetail`로 변경하여 문장 단위 피드백 및 UI 구현 - 문장 내 개별 단어의 분석 상태를 하이라이트하여 표시하는 `SentenceAnalysisCard` 컴포넌트 추가 - UI 상태 관리를 위한 `SentenceAnalysisUiModel` 및 `WordAnalysisUiModel` 정의 - 도메인 모델 및 네트워크 응답 DTO에 문장 상세 정보(`sentenceDetails`) 필드 추가 - `WordAnalysisDetail`에서 더 이상 사용하지 않는 `description` 필드 제거 - 컬렉션의 안정성을 위해 `kotlinx.collections.immutable` 의존성 추가 및 `ImmutableList` 적용 - `AccuracyDetailScreen` 및 관련 컴포넌트의 데이터 흐름을 문장 모델 중심으로 리팩터링 --- Prezel/core/data/build.gradle.kts | 1 + .../core/data/mapper/PresentationMapper.kt | 18 +- Prezel/core/model/build.gradle.kts | 1 + .../presentation/PresentationWordDetail.kt | 19 +- .../PresentationWordDetailResponse.kt | 22 +- .../accuracydetail/AccuracyDetailScreen.kt | 105 ++++--- .../component/AccuracyDetailPlayerSheet.kt | 133 +++++--- .../component/ScriptDetailList.kt | 65 ++-- .../component/SentenceAnalysisCard.kt | 289 ++++++++++++++++++ .../component/WordAnalysisDetailUi.kt | 208 ------------- .../model/SentenceAnalysisUiModel.kt | 75 +++++ 11 files changed, 596 insertions(+), 340 deletions(-) create mode 100644 Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/accuracydetail/component/SentenceAnalysisCard.kt delete mode 100644 Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/accuracydetail/component/WordAnalysisDetailUi.kt create mode 100644 Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/accuracydetail/model/SentenceAnalysisUiModel.kt diff --git a/Prezel/core/data/build.gradle.kts b/Prezel/core/data/build.gradle.kts index 4f0e19fc..78066ab5 100644 --- a/Prezel/core/data/build.gradle.kts +++ b/Prezel/core/data/build.gradle.kts @@ -15,5 +15,6 @@ dependencies { implementation(projects.coreNetwork) implementation(libs.kotlinx.coroutines.core) + implementation(libs.kotlinx.collections.immutable) implementation(libs.kotlinx.datetime) } diff --git a/Prezel/core/data/src/main/java/com/team/prezel/core/data/mapper/PresentationMapper.kt b/Prezel/core/data/src/main/java/com/team/prezel/core/data/mapper/PresentationMapper.kt index 03ace3e4..f2ff612e 100644 --- a/Prezel/core/data/src/main/java/com/team/prezel/core/data/mapper/PresentationMapper.kt +++ b/Prezel/core/data/src/main/java/com/team/prezel/core/data/mapper/PresentationMapper.kt @@ -14,6 +14,7 @@ import com.team.prezel.core.model.presentation.PresentationWordDetail import com.team.prezel.core.model.presentation.Purpose import com.team.prezel.core.model.presentation.ScriptCorrection import com.team.prezel.core.model.presentation.ScriptErrorType +import com.team.prezel.core.model.presentation.SentenceAnalysisDetail import com.team.prezel.core.model.presentation.Style import com.team.prezel.core.model.presentation.WordAnalysisDetail import com.team.prezel.core.model.presentation.WordAnalysisStatus @@ -24,9 +25,11 @@ import com.team.prezel.core.network.model.presentation.PresentationExpectedQuest import com.team.prezel.core.network.model.presentation.PresentationGrowthResponse import com.team.prezel.core.network.model.presentation.PresentationScriptAnalysisResponse import com.team.prezel.core.network.model.presentation.PresentationScriptDetailResponse +import com.team.prezel.core.network.model.presentation.PresentationSentenceAnalysisResponse import com.team.prezel.core.network.model.presentation.PresentationSummaryResponse import com.team.prezel.core.network.model.presentation.PresentationWordAnalysisResponse import com.team.prezel.core.network.model.presentation.PresentationWordDetailResponse +import kotlinx.collections.immutable.toImmutableList import kotlinx.datetime.LocalDate internal fun PresentationSummaryResponse.toDomain(): PresentationAnalysisSummary = @@ -88,14 +91,25 @@ internal fun PresentationWordDetailResponse.toDomain(): PresentationWordDetail = PresentationWordDetail( presentationId = presentationId, audioUrl = audioUrl, - wordDetails = wordDetails.map { item -> item.toDomain() }, + sentenceDetails = sentenceDetails.map { sentence -> sentence.toDomain() }.toImmutableList(), + ) + +internal fun PresentationSentenceAnalysisResponse.toDomain(): SentenceAnalysisDetail = + SentenceAnalysisDetail( + sentence = sentence, + status = WordAnalysisStatus.from(value = status), + mainFeedback = mainFeedback, + subFeedback = subFeedback, + accuracy = accuracy, + startTimeMs = startTimeMs, + endTimeMs = endTimeMs, + wordDetails = wordDetails.map { word -> word.toDomain() }.toImmutableList(), ) internal fun PresentationWordAnalysisResponse.toDomain(): WordAnalysisDetail = WordAnalysisDetail( word = word, status = WordAnalysisStatus.from(value = status), - description = description, accuracy = accuracy, startTimeMs = startTimeMs, endTimeMs = endTimeMs, diff --git a/Prezel/core/model/build.gradle.kts b/Prezel/core/model/build.gradle.kts index ec4f50d9..cd8c7182 100644 --- a/Prezel/core/model/build.gradle.kts +++ b/Prezel/core/model/build.gradle.kts @@ -3,5 +3,6 @@ plugins { } dependencies { + implementation(libs.kotlinx.collections.immutable) implementation(libs.kotlinx.datetime) } diff --git a/Prezel/core/model/src/main/java/com/team/prezel/core/model/presentation/PresentationWordDetail.kt b/Prezel/core/model/src/main/java/com/team/prezel/core/model/presentation/PresentationWordDetail.kt index 3d91ef4b..1497b74a 100644 --- a/Prezel/core/model/src/main/java/com/team/prezel/core/model/presentation/PresentationWordDetail.kt +++ b/Prezel/core/model/src/main/java/com/team/prezel/core/model/presentation/PresentationWordDetail.kt @@ -1,15 +1,27 @@ package com.team.prezel.core.model.presentation +import kotlinx.collections.immutable.ImmutableList + data class PresentationWordDetail( val presentationId: Long, val audioUrl: String, - val wordDetails: List, + val sentenceDetails: ImmutableList, +) + +data class SentenceAnalysisDetail( + val sentence: String, + val status: WordAnalysisStatus, + val mainFeedback: String, + val subFeedback: String, + val accuracy: Double, + val startTimeMs: Long, + val endTimeMs: Long, + val wordDetails: ImmutableList, ) data class WordAnalysisDetail( val word: String, val status: WordAnalysisStatus, - val description: String, val accuracy: Double, val startTimeMs: Long, val endTimeMs: Long, @@ -28,6 +40,7 @@ enum class WordAnalysisStatus( ; companion object { - fun from(value: String): WordAnalysisStatus = entries.firstOrNull { status -> status.value == value } ?: UNKNOWN + fun from(value: String): WordAnalysisStatus = + entries.firstOrNull { status -> status.value.equals(value.trim(), ignoreCase = true) } ?: UNKNOWN } } diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/model/presentation/PresentationWordDetailResponse.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/model/presentation/PresentationWordDetailResponse.kt index 495ba58d..47327b5a 100644 --- a/Prezel/core/network/src/main/java/com/team/prezel/core/network/model/presentation/PresentationWordDetailResponse.kt +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/model/presentation/PresentationWordDetailResponse.kt @@ -9,6 +9,26 @@ data class PresentationWordDetailResponse( val presentationId: Long, @SerialName("audioUrl") val audioUrl: String, + @SerialName("sentenceDetails") + val sentenceDetails: List, +) + +@Serializable +data class PresentationSentenceAnalysisResponse( + @SerialName("sentence") + val sentence: String, + @SerialName("status") + val status: String, + @SerialName("mainFeedback") + val mainFeedback: String, + @SerialName("subFeedback") + val subFeedback: String, + @SerialName("accuracy") + val accuracy: Double, + @SerialName("startTimeMs") + val startTimeMs: Long, + @SerialName("endTimeMs") + val endTimeMs: Long, @SerialName("wordDetails") val wordDetails: List, ) @@ -19,8 +39,6 @@ data class PresentationWordAnalysisResponse( val word: String, @SerialName("status") val status: String, - @SerialName("description") - val description: String, @SerialName("accuracy") val accuracy: Double, @SerialName("startTimeMs") diff --git a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/accuracydetail/AccuracyDetailScreen.kt b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/accuracydetail/AccuracyDetailScreen.kt index 1474fe3d..902c4f74 100644 --- a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/accuracydetail/AccuracyDetailScreen.kt +++ b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/accuracydetail/AccuracyDetailScreen.kt @@ -37,26 +37,24 @@ import com.team.prezel.core.designsystem.component.player.rememberPrezelPlayerSt import com.team.prezel.core.designsystem.preview.BasicPreview import com.team.prezel.core.designsystem.theme.PrezelTheme import com.team.prezel.core.model.presentation.PresentationWordDetail -import com.team.prezel.core.model.presentation.WordAnalysisDetail +import com.team.prezel.core.model.presentation.SentenceAnalysisDetail import com.team.prezel.core.model.presentation.WordAnalysisStatus import com.team.prezel.core.ui.state.LocalSnackbarHostState import com.team.prezel.feature.report.impl.R import com.team.prezel.feature.report.impl.accuracydetail.component.AccuracyDetailPlayerSheet import com.team.prezel.feature.report.impl.accuracydetail.component.AccuracyDetailTopAppBar import com.team.prezel.feature.report.impl.accuracydetail.component.ScriptDetailList -import com.team.prezel.feature.report.impl.accuracydetail.component.isScriptMatchIssue -import com.team.prezel.feature.report.impl.accuracydetail.component.isSpeechAccuracySheetIssue import com.team.prezel.feature.report.impl.accuracydetail.component.toMarkerType import com.team.prezel.feature.report.impl.accuracydetail.contract.AccuracyDetailUiEffect import com.team.prezel.feature.report.impl.accuracydetail.contract.AccuracyDetailUiState +import com.team.prezel.feature.report.impl.accuracydetail.model.SentenceAnalysisUiModel +import com.team.prezel.feature.report.impl.accuracydetail.model.toUiModels import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.delay import kotlinx.serialization.Serializable -private const val PLAYER_TICK_MILLIS = 250L -private val AccuracyDetailSheetPeekHeight = 276.dp - @Serializable internal enum class AccuracyDetailTab { SPEECH, @@ -131,17 +129,25 @@ private fun AccuracyDetailScreenContent( ).toImmutableList() val pagerState = rememberPagerState(initialPage = initialTab.ordinal) { tabs.size } val selectedTab = tabs[pagerState.currentPage] - val wordDetails = uiState.wordDetail.wordDetails - val playerMarkerWordDetails = remember(selectedTab, wordDetails) { - wordDetails.playerMarkerDetailsFor(selectedTab) + val sentenceDetails = remember(uiState.wordDetail.sentenceDetails) { + uiState.wordDetail.sentenceDetails.toUiModels() + } + val playerMarkerSentenceDetails = remember(selectedTab, sentenceDetails) { + when (selectedTab) { + AccuracyDetailTab.SPEECH -> sentenceDetails.filter { detail -> detail.isSpeechAccuracyIssue } + AccuracyDetailTab.SCRIPT_MATCH -> sentenceDetails.filter { detail -> detail.isScriptMatchIssue } + }.toImmutableList() } val playerState = rememberDetailPlayerState( - wordDetails = wordDetails, - markerWordDetails = playerMarkerWordDetails, + selectedTab = selectedTab, + sentenceDetails = sentenceDetails, + markerSentenceDetails = playerMarkerSentenceDetails, ) val playbackState = rememberRemoteAudioPlaybackState(audioUrl = uiState.wordDetail.audioUrl) - val selectedWord = remember(wordDetails, playerState.currentMillis) { - wordDetails.currentDetailOrNull(currentMillis = playerState.currentMillis) + val selectedSentence = remember(sentenceDetails, playerState.currentMillis) { + sentenceDetails.firstOrNull { detail -> + playerState.currentMillis in detail.startTimeMs..detail.endTimeMs + } } val scaffoldState = rememberDetailScaffoldState(expandedSheet = expandedSheet) val isSheetExpanded = scaffoldState.isSheetExpanded @@ -154,8 +160,8 @@ private fun AccuracyDetailScreenContent( AccuracyDetailScaffold( scaffoldState = scaffoldState, selectedTab = selectedTab, - selectedWord = selectedWord, - wordDetails = wordDetails, + selectedSentence = selectedSentence, + sentenceDetails = sentenceDetails, playerState = playerState, expanded = isSheetExpanded, onClose = onClose, @@ -176,18 +182,22 @@ private fun rememberAccuracyDetailTabs(): List = @Composable private fun rememberDetailPlayerState( - wordDetails: List, - markerWordDetails: List, + selectedTab: AccuracyDetailTab, + sentenceDetails: ImmutableList, + markerSentenceDetails: ImmutableList, ) = rememberPrezelPlayerState( - durationMillis = remember(wordDetails) { - wordDetails.maxOfOrNull { it.endTimeMs }?.coerceAtLeast(1L) ?: 1L + durationMillis = remember(sentenceDetails) { + sentenceDetails.maxOfOrNull { it.endTimeMs }?.coerceAtLeast(1L) ?: 1L }, - initialItems = remember(markerWordDetails) { - markerWordDetails + initialItems = remember(markerSentenceDetails) { + markerSentenceDetails .map { detail -> PrezelPlayerItem.Marker( timeMillis = detail.startTimeMs, - markerType = detail.toMarkerType(), + markerType = when (selectedTab) { + AccuracyDetailTab.SPEECH -> detail.speechAccuracyStatus.toMarkerType() + AccuracyDetailTab.SCRIPT_MATCH -> detail.scriptMatchStatus.toMarkerType() + }, ) }.toImmutableList() }, @@ -226,7 +236,7 @@ private fun PlaybackEffect( LaunchedEffect(playerState.playing) { while (playerState.playing) { - delay(PLAYER_TICK_MILLIS) + delay(250L) playbackState.currentPositionMillis .takeIf { it > 0 } ?.let { playerState.updateCurrentMillis(it.toLong()) } @@ -239,8 +249,8 @@ private fun PlaybackEffect( private fun AccuracyDetailScaffold( scaffoldState: BottomSheetScaffoldState, selectedTab: AccuracyDetailTab, - selectedWord: WordAnalysisDetail?, - wordDetails: List, + selectedSentence: SentenceAnalysisUiModel?, + sentenceDetails: ImmutableList, playerState: PrezelPlayerState, expanded: Boolean, onClose: () -> Unit, @@ -251,15 +261,15 @@ private fun AccuracyDetailScaffold( BottomSheetScaffold( modifier = Modifier.fillMaxSize(), scaffoldState = scaffoldState, - sheetPeekHeight = AccuracyDetailSheetPeekHeight, + sheetPeekHeight = 276.dp, sheetShape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp), sheetContainerColor = PrezelTheme.colors.solidWhite, sheetShadowElevation = 12.dp, sheetContent = { AccuracyDetailPlayerSheet( selectedTab = selectedTab, - selectedWord = selectedWord, - wordDetails = wordDetails, + selectedSentence = selectedSentence, + sentenceDetails = sentenceDetails, playerState = playerState, expanded = expanded, ) @@ -281,8 +291,8 @@ private fun AccuracyDetailScaffold( ) ScriptDetailList( selectedTab = selectedTab, - selectedWord = selectedWord, - wordDetails = wordDetails, + selectedSentence = selectedSentence, + sentenceDetails = sentenceDetails, ) } } @@ -350,15 +360,6 @@ private class RemoteAudioPlaybackState( ?.also { mediaPlayer = it } } -private fun List.currentDetailOrNull(currentMillis: Long): WordAnalysisDetail? = - lastOrNull { detail -> currentMillis >= detail.startTimeMs } ?: firstOrNull() - -private fun List.playerMarkerDetailsFor(tab: AccuracyDetailTab): List = - when (tab) { - AccuracyDetailTab.SPEECH -> filter { detail -> detail.isSpeechAccuracySheetIssue } - AccuracyDetailTab.SCRIPT_MATCH -> filter { detail -> detail.isScriptMatchIssue } - } - @BasicPreview @Composable private fun AccuracyDetailSpeechPreview() { @@ -400,30 +401,36 @@ private val AccuracyDetailPreviewUiState = AccuracyDetailUiState.Content( wordDetail = PresentationWordDetail( presentationId = 1L, audioUrl = "https://example.com/audio.mp3", - wordDetails = listOf( - WordAnalysisDetail( - word = "문장의 흐름이 깔끔했어요", + sentenceDetails = persistentListOf( + SentenceAnalysisDetail( + sentence = "오늘도 다들 긴장되는 마음으로 오셨을 것 같습니다.", status = WordAnalysisStatus.EXCELLENT, - description = "지금처럼 또렷한 말하기를 유지해주세요.", + mainFeedback = "문장의 흐름이 깔끔했어요", + subFeedback = "지금처럼 또렷한 말하기를 유지해주세요.", accuracy = 96.0, startTimeMs = 0L, endTimeMs = 1_800L, + wordDetails = persistentListOf(), ), - WordAnalysisDetail( - word = "같은 말을 반복하고 있어요.", + SentenceAnalysisDetail( + sentence = "오늘도 다들 긴장되는 마음으로 오셨을 것 같습니다.", status = WordAnalysisStatus.INSERTION, - description = "앞에서 했던 말은 반복하지 않는 것이 좋아요.", + mainFeedback = "같은 말을 반복하고 있어요.", + subFeedback = "앞에서 했던 말은 반복하지 않는 것이 좋아요.", accuracy = 42.0, startTimeMs = 7_230L, endTimeMs = 8_700L, + wordDetails = persistentListOf(), ), - WordAnalysisDetail( - word = "오늘도 다들 긴장되는 마음으로 오셨을 것 같습니다.", + SentenceAnalysisDetail( + sentence = "오늘도 다들 긴장되는 마음으로 오셨을 것 같습니다.", status = WordAnalysisStatus.OMISSION, - description = "대본에 있으나 읽지 않은 구간이에요.", + mainFeedback = "오늘도 다들 긴장되는 마음으로 오셨을 것 같습니다.", + subFeedback = "대본에 있으나 읽지 않은 구간이에요.", accuracy = 0.0, startTimeMs = 9_400L, endTimeMs = 11_300L, + wordDetails = persistentListOf(), ), ), ), diff --git a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/accuracydetail/component/AccuracyDetailPlayerSheet.kt b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/accuracydetail/component/AccuracyDetailPlayerSheet.kt index b86cf2d8..e62f23fe 100644 --- a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/accuracydetail/component/AccuracyDetailPlayerSheet.kt +++ b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/accuracydetail/component/AccuracyDetailPlayerSheet.kt @@ -25,17 +25,20 @@ import com.team.prezel.core.designsystem.component.player.PrezelPlayerState import com.team.prezel.core.designsystem.component.player.rememberPrezelPlayerState import com.team.prezel.core.designsystem.preview.BasicPreview import com.team.prezel.core.designsystem.theme.PrezelTheme -import com.team.prezel.core.model.presentation.WordAnalysisDetail import com.team.prezel.core.model.presentation.WordAnalysisStatus import com.team.prezel.feature.report.impl.R import com.team.prezel.feature.report.impl.accuracydetail.AccuracyDetailTab +import com.team.prezel.feature.report.impl.accuracydetail.model.SentenceAnalysisUiModel +import com.team.prezel.feature.report.impl.accuracydetail.model.WordAnalysisUiModel +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList @Composable internal fun AccuracyDetailPlayerSheet( selectedTab: AccuracyDetailTab, - selectedWord: WordAnalysisDetail?, - wordDetails: List, + selectedSentence: SentenceAnalysisUiModel?, + sentenceDetails: ImmutableList, playerState: PrezelPlayerState, expanded: Boolean, ) { @@ -47,8 +50,8 @@ internal fun AccuracyDetailPlayerSheet( SheetHandle() SheetDetailContent( selectedTab = selectedTab, - selectedWord = selectedWord, - wordDetails = wordDetails, + selectedSentence = selectedSentence, + sentenceDetails = sentenceDetails, expanded = expanded, modifier = if (expanded) Modifier.weight(1f) else Modifier, ) @@ -79,8 +82,8 @@ internal fun SheetHandle() { @Composable private fun SheetDetailContent( selectedTab: AccuracyDetailTab, - selectedWord: WordAnalysisDetail?, - wordDetails: List, + selectedSentence: SentenceAnalysisUiModel?, + sentenceDetails: ImmutableList, expanded: Boolean, modifier: Modifier = Modifier, ) { @@ -94,14 +97,14 @@ private fun SheetDetailContent( ) { when (selectedTab) { AccuracyDetailTab.SPEECH -> SpeechDetailContent( - selectedWord = selectedWord, - wordDetails = wordDetails, + selectedSentence = selectedSentence, + sentenceDetails = sentenceDetails, expanded = expanded, ) AccuracyDetailTab.SCRIPT_MATCH -> ScriptMatchDetailContent( - selectedWord = selectedWord, - wordDetails = wordDetails, + selectedSentence = selectedSentence, + sentenceDetails = sentenceDetails, expanded = expanded, ) } @@ -111,26 +114,28 @@ private fun SheetDetailContent( @Composable private fun SpeechDetailContent( - selectedWord: WordAnalysisDetail?, - wordDetails: List, + selectedSentence: SentenceAnalysisUiModel?, + sentenceDetails: ImmutableList, expanded: Boolean, ) { - val accuracyDetails = wordDetails.filter { it.isSpeechAccuracySheetIssue } + val accuracyDetails = sentenceDetails.filter { it.isSpeechAccuracyIssue }.toImmutableList() val visibleAccuracyDetails = if (expanded) { accuracyDetails } else { - listOfNotNull(selectedWord?.takeIf { it.isSpeechAccuracySheetIssue } ?: accuracyDetails.firstOrNull()) + listOfNotNull(selectedSentence?.takeIf { it.isSpeechAccuracyIssue } ?: accuracyDetails.firstOrNull()) } if (visibleAccuracyDetails.isEmpty()) { EmptyDetailText(text = stringResource(R.string.feature_report_impl_accuracy_detail_sheet_empty_speech)) } else { visibleAccuracyDetails.forEach { detail -> - WordDetailCard( + SentenceAnalysisCard( detail = detail, - highlighted = detail == selectedWord, - text = detail.description, + highlighted = detail == selectedSentence, + text = detail.mainFeedback, + subText = detail.subFeedback, useStatusTextColor = false, + status = detail.speechAccuracyStatus, ) } } @@ -138,12 +143,12 @@ private fun SpeechDetailContent( @Composable private fun ScriptMatchDetailContent( - selectedWord: WordAnalysisDetail?, - wordDetails: List, + selectedSentence: SentenceAnalysisUiModel?, + sentenceDetails: ImmutableList, expanded: Boolean, ) { - val visibleMismatchDetails = wordDetails.visibleScriptMatchDetails( - selectedWord = selectedWord, + val visibleMismatchDetails = sentenceDetails.visibleScriptMatchDetails( + selectedSentence = selectedSentence, expanded = expanded, ) @@ -152,24 +157,26 @@ private fun ScriptMatchDetailContent( } visibleMismatchDetails.forEach { detail -> - WordDetailCard( + SentenceAnalysisCard( detail = detail, - highlighted = detail == selectedWord, - text = detail.description, + highlighted = detail == selectedSentence, + text = detail.mainFeedback, + subText = detail.subFeedback, useStatusTextColor = false, + status = detail.scriptMatchStatus, ) } } -private fun List.visibleScriptMatchDetails( - selectedWord: WordAnalysisDetail?, +private fun ImmutableList.visibleScriptMatchDetails( + selectedSentence: SentenceAnalysisUiModel?, expanded: Boolean, -): List { - val mismatchDetails = filter { it.isScriptMatchIssue } +): ImmutableList { + val mismatchDetails = filter { it.isScriptMatchIssue }.toImmutableList() return if (expanded) { mismatchDetails } else { - listOfNotNull(selectedWord?.takeIf { it.isScriptMatchIssue } ?: mismatchDetails.firstOrNull()) + listOfNotNull(selectedSentence?.takeIf { it.isScriptMatchIssue } ?: mismatchDetails.firstOrNull()).toImmutableList() } } @@ -179,8 +186,8 @@ private fun AccuracyDetailPlayerSheetSpeechPreview() { PrezelTheme { AccuracyDetailPlayerSheet( selectedTab = AccuracyDetailTab.SPEECH, - selectedWord = PreviewWordDetails.first(), - wordDetails = PreviewWordDetails, + selectedSentence = PreviewSentenceDetails.first(), + sentenceDetails = PreviewSentenceDetails, playerState = rememberPreviewPlayerState(), expanded = false, ) @@ -193,8 +200,8 @@ private fun AccuracyDetailPlayerSheetScriptMatchPreview() { PrezelTheme { AccuracyDetailPlayerSheet( selectedTab = AccuracyDetailTab.SCRIPT_MATCH, - selectedWord = PreviewWordDetails[1], - wordDetails = PreviewWordDetails, + selectedSentence = PreviewSentenceDetails[1], + sentenceDetails = PreviewSentenceDetails, playerState = rememberPreviewPlayerState(), expanded = false, ) @@ -207,8 +214,8 @@ private fun AccuracyDetailPlayerSheetExpandedPreview() { PrezelTheme { AccuracyDetailPlayerSheet( selectedTab = AccuracyDetailTab.SPEECH, - selectedWord = PreviewWordDetails[1], - wordDetails = PreviewWordDetails, + selectedSentence = PreviewSentenceDetails[1], + sentenceDetails = PreviewSentenceDetails, playerState = rememberPreviewPlayerState(currentMillis = 7_230L), expanded = true, ) @@ -220,38 +227,68 @@ private fun rememberPreviewPlayerState(currentMillis: Long = 0L): PrezelPlayerSt rememberPrezelPlayerState( durationMillis = 11_300L, currentMillis = currentMillis, - initialItems = PreviewWordDetails + initialItems = PreviewSentenceDetails .map { detail -> PrezelPlayerItem.Marker( timeMillis = detail.startTimeMs, - markerType = detail.toMarkerType(), + markerType = detail.speechAccuracyStatus.toMarkerType(), ) }.toImmutableList(), ) -private val PreviewWordDetails = listOf( - WordAnalysisDetail( - word = "문장의 흐름이 깔끔했어요", +private val PreviewSentenceDetails = persistentListOf( + SentenceAnalysisUiModel( + sentence = "문장의 흐름이 깔끔했어요", status = WordAnalysisStatus.EXCELLENT, - description = "지금처럼 또렷한 말하기를 유지해주세요.", + mainFeedback = "문장의 흐름이 깔끔했어요", + subFeedback = "지금처럼 또렷한 말하기를 유지해주세요.", accuracy = 96.0, startTimeMs = 0L, endTimeMs = 1_800L, + wordDetails = persistentListOf( + WordAnalysisUiModel( + word = "흐름이", + status = WordAnalysisStatus.EXCELLENT, + accuracy = 96.0, + startTimeMs = 320L, + endTimeMs = 780L, + ), + ), ), - WordAnalysisDetail( - word = "같은 말을 반복하고 있어요.", + SentenceAnalysisUiModel( + sentence = "같은 말을 반복하고 있어요.", status = WordAnalysisStatus.INSERTION, - description = "앞에서 했던 말은 반복하지 않는 것이 좋아요.", + mainFeedback = "같은 말을 반복하고 있어요.", + subFeedback = "앞에서 했던 말은 반복하지 않는 것이 좋아요.", accuracy = 42.0, startTimeMs = 7_230L, endTimeMs = 8_700L, + wordDetails = persistentListOf( + WordAnalysisUiModel( + word = "반복하고", + status = WordAnalysisStatus.INSERTION, + accuracy = 42.0, + startTimeMs = 7_230L, + endTimeMs = 7_900L, + ), + ), ), - WordAnalysisDetail( - word = "오늘도 다들 긴장되는 마음으로 오셨을 것 같습니다.", + SentenceAnalysisUiModel( + sentence = "오늘도 다들 긴장되는 마음으로 오셨을 것 같습니다.", status = WordAnalysisStatus.OMISSION, - description = "대본에 있으나 읽지 않은 구간이에요.", + mainFeedback = "오늘도 다들 긴장되는 마음으로 오셨을 것 같습니다.", + subFeedback = "대본에 있으나 읽지 않은 구간이에요.", accuracy = 0.0, startTimeMs = 9_400L, endTimeMs = 11_300L, + wordDetails = persistentListOf( + WordAnalysisUiModel( + word = "오늘도", + status = WordAnalysisStatus.OMISSION, + accuracy = 0.0, + startTimeMs = 9_400L, + endTimeMs = 10_100L, + ), + ), ), ) diff --git a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/accuracydetail/component/ScriptDetailList.kt b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/accuracydetail/component/ScriptDetailList.kt index c77a2147..fa8b9512 100644 --- a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/accuracydetail/component/ScriptDetailList.kt +++ b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/accuracydetail/component/ScriptDetailList.kt @@ -13,16 +13,18 @@ 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.core.model.presentation.WordAnalysisDetail import com.team.prezel.core.model.presentation.WordAnalysisStatus import com.team.prezel.feature.report.impl.R import com.team.prezel.feature.report.impl.accuracydetail.AccuracyDetailTab +import com.team.prezel.feature.report.impl.accuracydetail.model.SentenceAnalysisUiModel +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf @Composable internal fun ScriptDetailList( selectedTab: AccuracyDetailTab, - selectedWord: WordAnalysisDetail?, - wordDetails: List, + selectedSentence: SentenceAnalysisUiModel?, + sentenceDetails: ImmutableList, ) { val scrollState = rememberScrollState() @@ -37,13 +39,14 @@ internal fun ScriptDetailList( .padding(all = PrezelTheme.spacing.V20), verticalArrangement = Arrangement.spacedBy(PrezelTheme.spacing.V16), ) { - if (wordDetails.isNotEmpty()) { - wordDetails.forEach { detail -> - WordDetailCard( + if (sentenceDetails.isNotEmpty()) { + sentenceDetails.forEach { detail -> + SentenceAnalysisCard( detail = detail, - highlighted = detail == selectedWord, - useStatusTextColor = detail.usesStatusTextColor(selectedTab), + highlighted = detail == selectedSentence, showStatusChip = detail.showsStatusChip(selectedTab), + highlightWordDetails = true, + status = detail.statusFor(selectedTab), ) } } else { @@ -52,16 +55,16 @@ internal fun ScriptDetailList( } } -private fun WordAnalysisDetail.usesStatusTextColor(selectedTab: AccuracyDetailTab): Boolean = +private fun SentenceAnalysisUiModel.showsStatusChip(selectedTab: AccuracyDetailTab): Boolean = when (selectedTab) { - AccuracyDetailTab.SPEECH -> isSpeechAccuracySheetIssue + AccuracyDetailTab.SPEECH -> isSpeechAccuracyIssue AccuracyDetailTab.SCRIPT_MATCH -> isScriptMatchIssue } -private fun WordAnalysisDetail.showsStatusChip(selectedTab: AccuracyDetailTab): Boolean = +private fun SentenceAnalysisUiModel.statusFor(selectedTab: AccuracyDetailTab): WordAnalysisStatus = when (selectedTab) { - AccuracyDetailTab.SPEECH -> isSpeechAccuracySheetIssue - AccuracyDetailTab.SCRIPT_MATCH -> isScriptMatchIssue + AccuracyDetailTab.SPEECH -> speechAccuracyStatus + AccuracyDetailTab.SCRIPT_MATCH -> scriptMatchStatus } @Composable @@ -79,8 +82,8 @@ private fun ScriptDetailListPreview() { PrezelTheme { ScriptDetailList( selectedTab = AccuracyDetailTab.SPEECH, - selectedWord = PreviewWordDetail, - wordDetails = PreviewWordDetails, + selectedSentence = PreviewSentenceDetail, + sentenceDetails = PreviewSentenceDetails, ) } } @@ -91,37 +94,43 @@ private fun ScriptDetailListEmptyPreview() { PrezelTheme { ScriptDetailList( selectedTab = AccuracyDetailTab.SPEECH, - selectedWord = null, - wordDetails = emptyList(), + selectedSentence = null, + sentenceDetails = persistentListOf(), ) } } -private val PreviewWordDetail = WordAnalysisDetail( - word = "내가", +private val PreviewSentenceDetail = SentenceAnalysisUiModel( + sentence = "문장의 흐름이 깔끔했어요.", status = WordAnalysisStatus.EXCELLENT, - description = "매우 또렷하고 훌륭한 발음", + mainFeedback = "문장의 흐름이 깔끔했어요.", + subFeedback = "지금처럼 또렷한 말하기를 유지해주세요.", accuracy = 98.0, startTimeMs = 1_490L, endTimeMs = 1_980L, + wordDetails = persistentListOf(), ) -private val PreviewWordDetails = listOf( - PreviewWordDetail, - WordAnalysisDetail( - word = "그린", +private val PreviewSentenceDetails = persistentListOf( + PreviewSentenceDetail, + SentenceAnalysisUiModel( + sentence = "같은 말을 반복하고 있어요.", status = WordAnalysisStatus.EXCELLENT, - description = "매우 또렷하고 훌륭한 발음", + mainFeedback = "같은 말을 반복하고 있어요.", + subFeedback = "앞에서 했던 말은 반복하지 않는 것이 좋아요.", accuracy = 98.0, startTimeMs = 1_990L, endTimeMs = 2_400L, + wordDetails = persistentListOf(), ), - WordAnalysisDetail( - word = "기린", + SentenceAnalysisUiModel( + sentence = "문장이 끝까지 정확하게 전달돼요.", status = WordAnalysisStatus.EXCELLENT, - description = "매우 또렷하고 훌륭한 발음", + mainFeedback = "문장이 끝까지 정확하게 전달돼요.", + subFeedback = "말의 마무리가 깔끔해 신뢰감이 높게 들려요.", accuracy = 100.0, startTimeMs = 2_410L, endTimeMs = 2_820L, + wordDetails = persistentListOf(), ), ) diff --git a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/accuracydetail/component/SentenceAnalysisCard.kt b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/accuracydetail/component/SentenceAnalysisCard.kt new file mode 100644 index 00000000..2b89de3d --- /dev/null +++ b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/accuracydetail/component/SentenceAnalysisCard.kt @@ -0,0 +1,289 @@ +package com.team.prezel.feature.report.impl.accuracydetail.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.withStyle +import com.team.prezel.core.designsystem.component.player.PrezelPlayerMarkerType +import com.team.prezel.core.designsystem.preview.BasicPreview +import com.team.prezel.core.designsystem.theme.PrezelTheme +import com.team.prezel.core.model.presentation.WordAnalysisStatus +import com.team.prezel.feature.report.impl.R +import com.team.prezel.feature.report.impl.accuracydetail.model.SentenceAnalysisUiModel +import kotlinx.collections.immutable.persistentListOf + +internal fun WordAnalysisStatus.toMarkerType(): PrezelPlayerMarkerType = + when (this) { + WordAnalysisStatus.EXCELLENT, + WordAnalysisStatus.GOOD, + -> PrezelPlayerMarkerType.GOOD + + WordAnalysisStatus.OMISSION -> PrezelPlayerMarkerType.NEUTRAL + + else -> PrezelPlayerMarkerType.WARNING + } + +@Composable +private fun WordAnalysisStatus.textColor(): Color = + when (this) { + WordAnalysisStatus.EXCELLENT -> PrezelTheme.colors.interactiveRegular + else -> PrezelTheme.colors.textLarge + } + +@Composable +internal fun SentenceAnalysisCard( + detail: SentenceAnalysisUiModel, + highlighted: Boolean, + modifier: Modifier = Modifier, + text: String = detail.sentence, + useStatusTextColor: Boolean = true, + showStatusChip: Boolean = true, + subText: String? = null, + highlightWordDetails: Boolean = false, + status: WordAnalysisStatus = detail.status, +) { + Column( + modifier = modifier + .fillMaxWidth() + .clip(PrezelTheme.shapes.V8) + .background(if (highlighted) PrezelTheme.colors.bgMedium else Color.Transparent) + .padding(PrezelTheme.spacing.V12), + verticalArrangement = Arrangement.spacedBy(PrezelTheme.spacing.V8), + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(PrezelTheme.spacing.V8), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = detail.startTimeMs.toPlayerTimeText(), + style = PrezelTheme.typography.caption1Regular, + color = PrezelTheme.colors.textRegular, + ) + if (showStatusChip) { + StatusChip(status = status) + } + } + if (highlightWordDetails) { + SentenceText(detail = detail, text = text) + } else { + Text( + text = text, + style = PrezelTheme.typography.body2Medium, + color = if (useStatusTextColor) status.textColor() else PrezelTheme.colors.textLarge, + ) + } + subText?.takeIf { it.isNotBlank() }?.let { feedback -> + Text( + text = feedback, + style = PrezelTheme.typography.body3Regular, + color = PrezelTheme.colors.textRegular, + ) + } + } +} + +@Composable +private fun SentenceText( + detail: SentenceAnalysisUiModel, + text: String, +) { + val defaultColor = PrezelTheme.colors.textLarge + val successColor = PrezelTheme.colors.interactiveRegular + val warningColor = PrezelTheme.colors.feedbackWarningRegular + val neutralColor = PrezelTheme.colors.textRegular + var searchFrom = 0 + val spans = detail.wordDetails.mapNotNull { word -> + text + .indexOf(word.word, startIndex = searchFrom) + .takeIf { start -> start >= 0 } + ?.let { start -> + searchFrom = start + word.word.length + WordSpan( + start = start, + end = searchFrom, + color = word.status.textColor( + successColor = successColor, + warningColor = warningColor, + neutralColor = neutralColor, + ), + ) + } + } + + Text( + text = buildAnnotatedString { + var cursor = 0 + spans.forEach { span -> + if (span.start < cursor) return@forEach + append(text.substring(cursor, span.start)) + withStyle(SpanStyle(color = span.color)) { + append(text.substring(span.start, span.end)) + } + cursor = span.end + } + append(text.substring(cursor)) + }, + style = PrezelTheme.typography.body2Medium, + color = defaultColor, + ) +} + +private data class WordSpan( + val start: Int, + val end: Int, + val color: Color, +) + +private fun WordAnalysisStatus.textColor( + successColor: Color, + warningColor: Color, + neutralColor: Color, +): Color = + when (this) { + WordAnalysisStatus.EXCELLENT, + WordAnalysisStatus.GOOD, + -> successColor + + WordAnalysisStatus.INSERTION, + WordAnalysisStatus.MISPRONUNCIATION, + WordAnalysisStatus.STUTTER, + -> warningColor + + else -> neutralColor + } + +@Composable +internal fun StatusChip(status: WordAnalysisStatus) { + Text( + text = status.statusLabel(), + style = PrezelTheme.typography.body3Medium, + color = status.chipTextColor(), + modifier = Modifier + .clip(PrezelTheme.shapes.V4) + .background(status.chipBackgroundColor()) + .padding(horizontal = PrezelTheme.spacing.V6, vertical = PrezelTheme.spacing.V2), + ) +} + +@Composable +private fun WordAnalysisStatus.statusLabel(): String = + when (this) { + WordAnalysisStatus.EXCELLENT, + WordAnalysisStatus.GOOD, + WordAnalysisStatus.STUTTER, + -> stringResource(R.string.feature_report_impl_script_detail_status_pronunciation) + + WordAnalysisStatus.INSERTION -> stringResource(R.string.feature_report_impl_script_detail_status_insertion) + WordAnalysisStatus.OMISSION -> stringResource(R.string.feature_report_impl_script_detail_status_omission) + WordAnalysisStatus.MISPRONUNCIATION -> stringResource(R.string.feature_report_impl_script_detail_status_mismatch) + WordAnalysisStatus.UNKNOWN -> value + } + +@Composable +private fun WordAnalysisStatus.chipTextColor(): Color = + when (this) { + WordAnalysisStatus.INSERTION, + WordAnalysisStatus.MISPRONUNCIATION, + -> PrezelTheme.colors.feedbackWarningRegular + + WordAnalysisStatus.OMISSION -> PrezelTheme.colors.textRegular + else -> PrezelTheme.colors.interactiveRegular + } + +@Composable +private fun WordAnalysisStatus.chipBackgroundColor(): Color = + when (this) { + WordAnalysisStatus.INSERTION, + WordAnalysisStatus.MISPRONUNCIATION, + -> PrezelTheme.colors.feedbackWarningSmall + + WordAnalysisStatus.OMISSION -> PrezelTheme.colors.bgLarge + else -> PrezelTheme.colors.interactiveXSmall + } + +internal fun Long.toPlayerTimeText(): String { + val totalSeconds = coerceAtLeast(0L) / 1_000 + val minutes = totalSeconds / 60 + val seconds = totalSeconds % 60 + + return "${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}" +} + +@BasicPreview +@Composable +private fun StatusChipPreview() { + PrezelTheme { + Row(horizontalArrangement = Arrangement.spacedBy(PrezelTheme.spacing.V8)) { + StatusChip(status = PreviewExcellentWord.speechAccuracyStatus) + StatusChip(status = PreviewInsertionWord.scriptMatchStatus) + StatusChip( + status = SentenceAnalysisUiModel( + sentence = "오늘도 다들 긴장되는 마음으로 오셨을 것 같습니다.", + status = WordAnalysisStatus.OMISSION, + mainFeedback = "오늘도 다들 긴장되는 마음으로 오셨을 것 같습니다.", + subFeedback = "대본에 있으나 읽지 않은 구간이에요.", + accuracy = 0.0, + startTimeMs = 9_400L, + endTimeMs = 11_300L, + wordDetails = persistentListOf(), + ).scriptMatchStatus, + ) + } + } +} + +@BasicPreview +@Composable +private fun SentenceAnalysisCardPreview() { + PrezelTheme { + Column( + verticalArrangement = Arrangement.spacedBy(PrezelTheme.spacing.V8), + modifier = Modifier.padding(PrezelTheme.spacing.V16), + ) { + SentenceAnalysisCard( + detail = PreviewExcellentWord, + highlighted = true, + ) + SentenceAnalysisCard( + detail = PreviewInsertionWord, + highlighted = false, + text = PreviewInsertionWord.mainFeedback, + subText = PreviewInsertionWord.subFeedback, + ) + } + } +} + +private val PreviewExcellentWord = SentenceAnalysisUiModel( + sentence = "문장의 흐름이 깔끔했어요", + status = WordAnalysisStatus.EXCELLENT, + mainFeedback = "문장의 흐름이 깔끔했어요", + subFeedback = "지금처럼 또렷한 말하기를 유지해주세요.", + accuracy = 96.0, + startTimeMs = 0L, + endTimeMs = 1_800L, + wordDetails = persistentListOf(), +) + +private val PreviewInsertionWord = SentenceAnalysisUiModel( + sentence = "같은 말을 반복하고 있어요.", + status = WordAnalysisStatus.INSERTION, + mainFeedback = "같은 말을 반복하고 있어요.", + subFeedback = "앞에서 했던 말은 반복하지 않는 것이 좋아요.", + accuracy = 42.0, + startTimeMs = 7_230L, + endTimeMs = 8_700L, + wordDetails = persistentListOf(), +) diff --git a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/accuracydetail/component/WordAnalysisDetailUi.kt b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/accuracydetail/component/WordAnalysisDetailUi.kt deleted file mode 100644 index 8bf5e0d1..00000000 --- a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/accuracydetail/component/WordAnalysisDetailUi.kt +++ /dev/null @@ -1,208 +0,0 @@ -package com.team.prezel.feature.report.impl.accuracydetail.component - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.res.stringResource -import com.team.prezel.core.designsystem.component.player.PrezelPlayerMarkerType -import com.team.prezel.core.designsystem.preview.BasicPreview -import com.team.prezel.core.designsystem.theme.PrezelTheme -import com.team.prezel.core.model.presentation.WordAnalysisDetail -import com.team.prezel.core.model.presentation.WordAnalysisStatus -import com.team.prezel.feature.report.impl.R - -internal val WordAnalysisDetail.isScriptMatchIssue: Boolean - get() = status == WordAnalysisStatus.OMISSION || status == WordAnalysisStatus.MISPRONUNCIATION - -internal val WordAnalysisDetail.isSpeechAccuracyIssue: Boolean - get() = status == WordAnalysisStatus.EXCELLENT || - status == WordAnalysisStatus.GOOD || - status == WordAnalysisStatus.STUTTER || - status == WordAnalysisStatus.INSERTION - -internal val WordAnalysisDetail.isSpeechAccuracySheetIssue: Boolean - get() = status == WordAnalysisStatus.EXCELLENT || - status == WordAnalysisStatus.STUTTER || - status == WordAnalysisStatus.INSERTION - -internal fun WordAnalysisDetail.toMarkerType(): PrezelPlayerMarkerType = - when (status) { - WordAnalysisStatus.EXCELLENT, - WordAnalysisStatus.GOOD, - -> PrezelPlayerMarkerType.GOOD - - WordAnalysisStatus.OMISSION -> PrezelPlayerMarkerType.NEUTRAL - - else -> PrezelPlayerMarkerType.WARNING - } - -@Composable -internal fun WordAnalysisDetail.textColor(): Color = - when (status) { - WordAnalysisStatus.EXCELLENT -> PrezelTheme.colors.interactiveRegular - else -> PrezelTheme.colors.textLarge - } - -@Composable -internal fun WordDetailCard( - detail: WordAnalysisDetail, - highlighted: Boolean, - modifier: Modifier = Modifier, - text: String = detail.word, - useStatusTextColor: Boolean = true, - showStatusChip: Boolean = true, -) { - Column( - modifier = modifier - .fillMaxWidth() - .clip(PrezelTheme.shapes.V8) - .background(if (highlighted) PrezelTheme.colors.bgMedium else Color.Transparent) - .padding(PrezelTheme.spacing.V12), - verticalArrangement = Arrangement.spacedBy(PrezelTheme.spacing.V8), - ) { - Row( - horizontalArrangement = Arrangement.spacedBy(PrezelTheme.spacing.V8), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = detail.startTimeMs.toPlayerTimeText(), - style = PrezelTheme.typography.caption1Regular, - color = PrezelTheme.colors.textRegular, - ) - if (showStatusChip) { - StatusChip(detail = detail) - } - } - Text( - text = text, - style = PrezelTheme.typography.body2Medium, - color = if (useStatusTextColor) detail.textColor() else PrezelTheme.colors.textLarge, - ) - } -} - -@Composable -internal fun StatusChip(detail: WordAnalysisDetail) { - Text( - text = detail.statusLabel(), - style = PrezelTheme.typography.body3Medium, - color = detail.chipTextColor(), - modifier = Modifier - .clip(PrezelTheme.shapes.V4) - .background(detail.chipBackgroundColor()) - .padding(horizontal = PrezelTheme.spacing.V6, vertical = PrezelTheme.spacing.V2), - ) -} - -@Composable -private fun WordAnalysisDetail.statusLabel(): String = - when (status) { - WordAnalysisStatus.EXCELLENT, - WordAnalysisStatus.GOOD, - WordAnalysisStatus.STUTTER, - -> stringResource(R.string.feature_report_impl_script_detail_status_pronunciation) - - WordAnalysisStatus.INSERTION -> stringResource(R.string.feature_report_impl_script_detail_status_insertion) - WordAnalysisStatus.OMISSION -> stringResource(R.string.feature_report_impl_script_detail_status_omission) - WordAnalysisStatus.MISPRONUNCIATION -> stringResource(R.string.feature_report_impl_script_detail_status_mismatch) - WordAnalysisStatus.UNKNOWN -> status.value - } - -@Composable -private fun WordAnalysisDetail.chipTextColor(): Color = - when (status) { - WordAnalysisStatus.INSERTION, - WordAnalysisStatus.MISPRONUNCIATION, - -> PrezelTheme.colors.feedbackWarningRegular - - WordAnalysisStatus.OMISSION -> PrezelTheme.colors.textRegular - else -> PrezelTheme.colors.interactiveRegular - } - -@Composable -private fun WordAnalysisDetail.chipBackgroundColor(): Color = - when (status) { - WordAnalysisStatus.INSERTION, - WordAnalysisStatus.MISPRONUNCIATION, - -> PrezelTheme.colors.feedbackWarningSmall - - WordAnalysisStatus.OMISSION -> PrezelTheme.colors.bgLarge - else -> PrezelTheme.colors.interactiveXSmall - } - -internal fun Long.toPlayerTimeText(): String { - val totalSeconds = coerceAtLeast(0L) / 1_000 - val minutes = totalSeconds / 60 - val seconds = totalSeconds % 60 - - return "${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}" -} - -@BasicPreview -@Composable -private fun StatusChipPreview() { - PrezelTheme { - Row(horizontalArrangement = Arrangement.spacedBy(PrezelTheme.spacing.V8)) { - StatusChip(detail = PreviewExcellentWord) - StatusChip(detail = PreviewInsertionWord) - StatusChip(detail = PreviewOmissionWord) - } - } -} - -@BasicPreview -@Composable -private fun WordDetailCardPreview() { - PrezelTheme { - Column( - verticalArrangement = Arrangement.spacedBy(PrezelTheme.spacing.V8), - modifier = Modifier.padding(PrezelTheme.spacing.V16), - ) { - WordDetailCard( - detail = PreviewExcellentWord, - highlighted = true, - ) - WordDetailCard( - detail = PreviewInsertionWord, - highlighted = false, - text = PreviewInsertionWord.description, - ) - } - } -} - -private val PreviewExcellentWord = WordAnalysisDetail( - word = "문장의 흐름이 깔끔했어요", - status = WordAnalysisStatus.EXCELLENT, - description = "지금처럼 또렷한 말하기를 유지해주세요.", - accuracy = 96.0, - startTimeMs = 0L, - endTimeMs = 1_800L, -) - -private val PreviewInsertionWord = WordAnalysisDetail( - word = "같은 말을 반복하고 있어요.", - status = WordAnalysisStatus.INSERTION, - description = "앞에서 했던 말은 반복하지 않는 것이 좋아요.", - accuracy = 42.0, - startTimeMs = 7_230L, - endTimeMs = 8_700L, -) - -private val PreviewOmissionWord = WordAnalysisDetail( - word = "오늘도 다들 긴장되는 마음으로 오셨을 것 같습니다.", - status = WordAnalysisStatus.OMISSION, - description = "대본에 있으나 읽지 않은 구간이에요.", - accuracy = 0.0, - startTimeMs = 9_400L, - endTimeMs = 11_300L, -) diff --git a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/accuracydetail/model/SentenceAnalysisUiModel.kt b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/accuracydetail/model/SentenceAnalysisUiModel.kt new file mode 100644 index 00000000..2eb8081a --- /dev/null +++ b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/accuracydetail/model/SentenceAnalysisUiModel.kt @@ -0,0 +1,75 @@ +package com.team.prezel.feature.report.impl.accuracydetail.model + +import androidx.compose.runtime.Immutable +import com.team.prezel.core.model.presentation.SentenceAnalysisDetail +import com.team.prezel.core.model.presentation.WordAnalysisDetail +import com.team.prezel.core.model.presentation.WordAnalysisStatus +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList + +@Immutable +internal data class SentenceAnalysisUiModel( + val sentence: String, + val status: WordAnalysisStatus, + val mainFeedback: String, + val subFeedback: String, + val accuracy: Double, + val startTimeMs: Long, + val endTimeMs: Long, + val wordDetails: ImmutableList, +) { + val isScriptMatchIssue: Boolean + get() = wordDetails.any { word -> word.status.isScriptMatchIssue } + + val isSpeechAccuracyIssue: Boolean + get() = wordDetails.any { word -> word.status.isSpeechAccuracyIssue } + + val scriptMatchStatus: WordAnalysisStatus + get() = wordDetails.firstOrNull { word -> word.status.isScriptMatchIssue }?.status ?: status + + val speechAccuracyStatus: WordAnalysisStatus + get() = wordDetails.firstOrNull { word -> word.status.isSpeechAccuracyIssue }?.status ?: status +} + +@Immutable +internal data class WordAnalysisUiModel( + val word: String, + val status: WordAnalysisStatus, + val accuracy: Double, + val startTimeMs: Long, + val endTimeMs: Long, +) + +private val WordAnalysisStatus.isScriptMatchIssue: Boolean + get() = this == WordAnalysisStatus.INSERTION || + this == WordAnalysisStatus.OMISSION || + this == WordAnalysisStatus.MISPRONUNCIATION + +private val WordAnalysisStatus.isSpeechAccuracyIssue: Boolean + get() = this == WordAnalysisStatus.EXCELLENT || + this == WordAnalysisStatus.GOOD || + this == WordAnalysisStatus.STUTTER + +internal fun ImmutableList.toUiModels(): ImmutableList = + map { detail -> detail.toUiModel() }.toImmutableList() + +private fun SentenceAnalysisDetail.toUiModel(): SentenceAnalysisUiModel = + SentenceAnalysisUiModel( + sentence = sentence, + status = status, + mainFeedback = mainFeedback, + subFeedback = subFeedback, + accuracy = accuracy, + startTimeMs = startTimeMs, + endTimeMs = endTimeMs, + wordDetails = wordDetails.map { word -> word.toUiModel() }.toImmutableList(), + ) + +private fun WordAnalysisDetail.toUiModel(): WordAnalysisUiModel = + WordAnalysisUiModel( + word = word, + status = status, + accuracy = accuracy, + startTimeMs = startTimeMs, + endTimeMs = endTimeMs, + ) From 7cd0ec7929ae5e5b803f1c1a7a9454d29f8da443 Mon Sep 17 00:00:00 2001 From: Ham BeomJoon Date: Sat, 13 Jun 2026 19:34:47 +0900 Subject: [PATCH 4/5] =?UTF-8?q?feat:=20=EB=B0=9C=ED=99=94=20=EC=A0=95?= =?UTF-8?q?=ED=99=95=EB=8F=84=20=EC=83=81=EC=84=B8=20=ED=99=94=EB=A9=B4=20?= =?UTF-8?q?=EC=98=A4=EB=94=94=EC=98=A4=20=EC=9E=AC=EC=83=9D=20=EB=8F=99?= =?UTF-8?q?=EA=B8=B0=ED=99=94=20=EB=B0=8F=20UI=20=EA=B3=A0=EB=8F=84?= =?UTF-8?q?=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `RemoteAudioPlaybackState` 구현을 통해 `MediaPlayer`의 준비 상태 및 에러 핸들링 로직 강화 - UI 플레이어 상태와 실제 오디오 재생 위치 간의 동기화 로직 추가 (`SEEK_SYNC_THRESHOLD_MILLIS` 기준) - 분석 데이터 유무에 따라 바텀 시트의 `peekHeight`를 동적으로 조절하는 기능 구현 - `SentenceAnalysisUiModel`의 상태 판정 로직 개선 및 `hasSpeechAccuracyStatus` 등 신규 필드 추가 - `AccuracyDetailViewModel`의 데이터 요청 로직을 `onSuccess`/`onFailure` 구조로 리팩터링 - 바텀 시트 내부 레이아웃 구성 및 스크롤 동작 최적화 --- .../accuracydetail/AccuracyDetailScreen.kt | 98 ++++-------- .../accuracydetail/AccuracyDetailViewModel.kt | 16 +- .../RemoteAudioPlaybackState.kt | 146 ++++++++++++++++++ .../component/AccuracyDetailPlayerSheet.kt | 6 +- .../component/ScriptDetailList.kt | 2 +- .../model/SentenceAnalysisUiModel.kt | 12 +- 6 files changed, 197 insertions(+), 83 deletions(-) create mode 100644 Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/accuracydetail/RemoteAudioPlaybackState.kt diff --git a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/accuracydetail/AccuracyDetailScreen.kt b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/accuracydetail/AccuracyDetailScreen.kt index 902c4f74..112c045a 100644 --- a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/accuracydetail/AccuracyDetailScreen.kt +++ b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/accuracydetail/AccuracyDetailScreen.kt @@ -1,6 +1,5 @@ package com.team.prezel.feature.report.impl.accuracydetail -import android.media.MediaPlayer import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize @@ -16,16 +15,14 @@ import androidx.compose.material3.Text import androidx.compose.material3.rememberBottomSheetScaffoldState import androidx.compose.material3.rememberStandardBottomSheetState 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.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier 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.lifecycle.compose.collectAsStateWithLifecycle import com.team.prezel.core.designsystem.component.feedback.snackbar.showPrezelSnackbar @@ -54,6 +51,7 @@ import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.delay import kotlinx.serialization.Serializable +import kotlin.math.absoluteValue @Serializable internal enum class AccuracyDetailTab { @@ -138,6 +136,7 @@ private fun AccuracyDetailScreenContent( AccuracyDetailTab.SCRIPT_MATCH -> sentenceDetails.filter { detail -> detail.isScriptMatchIssue } }.toImmutableList() } + val sheetPeekHeight = rememberPlayerSheetPeekHeight(markerSentenceDetails = playerMarkerSentenceDetails) val playerState = rememberDetailPlayerState( selectedTab = selectedTab, sentenceDetails = sentenceDetails, @@ -164,6 +163,7 @@ private fun AccuracyDetailScreenContent( sentenceDetails = sentenceDetails, playerState = playerState, expanded = isSheetExpanded, + sheetPeekHeight = sheetPeekHeight, onClose = onClose, tabLabels = tabLabels, onClickTab = { index -> pagerState.requestScrollToPage(index) }, @@ -180,6 +180,16 @@ private fun rememberAccuracyDetailTabs(): List = ) } +@Composable +private fun rememberPlayerSheetPeekHeight(markerSentenceDetails: ImmutableList): Dp = + remember(markerSentenceDetails) { + if (markerSentenceDetails.isEmpty()) { + AccuracyDetailPlayerSheetDefaultPeekHeight + } else { + AccuracyDetailPlayerSheetLargePeekHeight + } + } + @Composable private fun rememberDetailPlayerState( selectedTab: AccuracyDetailTab, @@ -234,6 +244,15 @@ private fun PlaybackEffect( } } + LaunchedEffect(playerState.currentMillis, playerState.playing) { + if (!playerState.playing) return@LaunchedEffect + + val positionGap = (playerState.currentMillis - playbackState.currentPositionMillis).absoluteValue + if (positionGap > SEEK_SYNC_THRESHOLD_MILLIS) { + playbackState.seekTo(positionMillis = playerState.currentMillis.toInt()) + } + } + LaunchedEffect(playerState.playing) { while (playerState.playing) { delay(250L) @@ -242,6 +261,10 @@ private fun PlaybackEffect( ?.let { playerState.updateCurrentMillis(it.toLong()) } } } + + LaunchedEffect(playbackState.playbackError) { + if (playbackState.playbackError) playerState.pause() + } } @OptIn(ExperimentalMaterial3Api::class) @@ -253,6 +276,7 @@ private fun AccuracyDetailScaffold( sentenceDetails: ImmutableList, playerState: PrezelPlayerState, expanded: Boolean, + sheetPeekHeight: Dp, onClose: () -> Unit, tabLabels: ImmutableList, onClickTab: (Int) -> Unit, @@ -261,7 +285,7 @@ private fun AccuracyDetailScaffold( BottomSheetScaffold( modifier = Modifier.fillMaxSize(), scaffoldState = scaffoldState, - sheetPeekHeight = 276.dp, + sheetPeekHeight = sheetPeekHeight, sheetShape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp), sheetContainerColor = PrezelTheme.colors.solidWhite, sheetShadowElevation = 12.dp, @@ -298,67 +322,9 @@ private fun AccuracyDetailScaffold( } } -@Composable -private fun rememberRemoteAudioPlaybackState(audioUrl: String): RemoteAudioPlaybackState { - val state = remember(audioUrl) { RemoteAudioPlaybackState(audioUrl = audioUrl) } - - DisposableEffect(state) { - onDispose { state.release() } - } - - return state -} - -private class RemoteAudioPlaybackState( - private val audioUrl: String, -) { - private var mediaPlayer: MediaPlayer? = null - - private var lastKnownPositionMillis by mutableIntStateOf(0) - - val currentPositionMillis: Int - get() = mediaPlayer - ?.currentPosition - ?.coerceAtLeast(0) - ?: lastKnownPositionMillis - - fun play(startPositionMillis: Int) { - val player = mediaPlayer ?: preparePlayer() ?: return - - runCatching { - player.seekTo(startPositionMillis.coerceAtLeast(0)) - player.start() - lastKnownPositionMillis = player.currentPosition.coerceAtLeast(0) - }.onFailure { - release() - } - } - - fun pause() { - mediaPlayer?.runCatching { - if (isPlaying) pause() - lastKnownPositionMillis = currentPosition.coerceAtLeast(0) - } - } - - fun release() { - mediaPlayer?.release() - mediaPlayer = null - lastKnownPositionMillis = 0 - } - - private fun preparePlayer(): MediaPlayer? = - runCatching { - MediaPlayer().apply { - setDataSource(audioUrl) - prepare() - setOnCompletionListener { - lastKnownPositionMillis = duration.coerceAtLeast(0) - } - } - }.getOrNull() - ?.also { mediaPlayer = it } -} +private const val SEEK_SYNC_THRESHOLD_MILLIS = 750L +private val AccuracyDetailPlayerSheetDefaultPeekHeight = 220.dp +private val AccuracyDetailPlayerSheetLargePeekHeight = 336.dp @BasicPreview @Composable diff --git a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/accuracydetail/AccuracyDetailViewModel.kt b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/accuracydetail/AccuracyDetailViewModel.kt index be544e2d..fa8e8115 100644 --- a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/accuracydetail/AccuracyDetailViewModel.kt +++ b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/accuracydetail/AccuracyDetailViewModel.kt @@ -33,15 +33,13 @@ internal class AccuracyDetailViewModel @AssistedInject constructor( private fun fetchDetails() { viewModelScope.launch { - val nextState = runCatching { - AccuracyDetailUiState.Content( - wordDetail = fetchPresentationWordDetailUseCase(analysisResultId).getOrThrow(), - ) - }.getOrElse { - sendEffect(AccuracyDetailUiEffect.ShowMessage(AccuracyDetailUiMessage.FetchDetailFailed)) - AccuracyDetailUiState.Error - } - updateState { nextState } + fetchPresentationWordDetailUseCase(analysisResultId) + .onSuccess { wordDetail -> + updateState { AccuracyDetailUiState.Content(wordDetail = wordDetail) } + }.onFailure { + sendEffect(AccuracyDetailUiEffect.ShowMessage(AccuracyDetailUiMessage.FetchDetailFailed)) + updateState { AccuracyDetailUiState.Error } + } } } } diff --git a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/accuracydetail/RemoteAudioPlaybackState.kt b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/accuracydetail/RemoteAudioPlaybackState.kt new file mode 100644 index 00000000..7c8b27b3 --- /dev/null +++ b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/accuracydetail/RemoteAudioPlaybackState.kt @@ -0,0 +1,146 @@ +package com.team.prezel.feature.report.impl.accuracydetail + +import android.media.MediaPlayer +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.Stable +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 + +@Composable +internal fun rememberRemoteAudioPlaybackState(audioUrl: String): RemoteAudioPlaybackState { + val state = remember(audioUrl) { RemoteAudioPlaybackState(audioUrl = audioUrl) } + + DisposableEffect(state) { + onDispose { state.release() } + } + + return state +} + +@Stable +internal class RemoteAudioPlaybackState( + private val audioUrl: String, +) { + private var mediaPlayer: MediaPlayer? = null + private var lastKnownPositionMillis by mutableIntStateOf(0) + private var pendingPositionMillis: Int? = null + private var prepared by mutableStateOf(false) + private var playWhenPrepared = false + + var playbackError by mutableStateOf(false) + private set + + val currentPositionMillis: Int + get() = if (prepared) { + mediaPlayer + ?.currentPosition + ?.coerceAtLeast(0) + ?: lastKnownPositionMillis + } else { + lastKnownPositionMillis + } + + fun play(startPositionMillis: Int) { + playbackError = false + playWhenPrepared = true + + val player = mediaPlayer ?: preparePlayer() ?: return + val positionMillis = startPositionMillis.coerceAtLeast(0) + + if (prepared) { + startPreparedPlayer(player = player, positionMillis = positionMillis) + } else { + pendingPositionMillis = positionMillis + lastKnownPositionMillis = positionMillis + } + } + + fun pause() { + playWhenPrepared = false + mediaPlayer?.runCatching { + if (prepared && isPlaying) pause() + lastKnownPositionMillis = currentPositionMillis + } + } + + fun seekTo(positionMillis: Int) { + val targetPositionMillis = positionMillis.coerceAtLeast(0) + pendingPositionMillis = targetPositionMillis + lastKnownPositionMillis = targetPositionMillis + + val player = mediaPlayer ?: return + if (prepared) seekPreparedPlayer(player = player, positionMillis = targetPositionMillis) + } + + fun release() { + playWhenPrepared = false + prepared = false + pendingPositionMillis = null + mediaPlayer?.release() + mediaPlayer = null + lastKnownPositionMillis = 0 + } + + private fun preparePlayer(): MediaPlayer? = + runCatching { + MediaPlayer().apply { + setOnPreparedListener { player -> + prepared = true + val positionMillis = pendingPositionMillis ?: lastKnownPositionMillis + pendingPositionMillis = null + if (playWhenPrepared) { + startPreparedPlayer(player = player, positionMillis = positionMillis) + } else { + seekPreparedPlayer(player = player, positionMillis = positionMillis) + } + } + setOnErrorListener { _, _, _ -> + handlePlaybackFailure() + true + } + setDataSource(audioUrl) + prepareAsync() + setOnCompletionListener { + lastKnownPositionMillis = duration.coerceAtLeast(0) + playWhenPrepared = false + } + } + }.onFailure { + handlePlaybackFailure() + }.getOrNull() + ?.also { mediaPlayer = it } + + private fun startPreparedPlayer( + player: MediaPlayer, + positionMillis: Int, + ) { + runCatching { + player.seekTo(positionMillis) + player.start() + lastKnownPositionMillis = player.currentPosition.coerceAtLeast(0) + }.onFailure { + handlePlaybackFailure() + } + } + + private fun seekPreparedPlayer( + player: MediaPlayer, + positionMillis: Int, + ) { + runCatching { + player.seekTo(positionMillis) + lastKnownPositionMillis = positionMillis + }.onFailure { + handlePlaybackFailure() + } + } + + private fun handlePlaybackFailure() { + release() + playbackError = true + } +} diff --git a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/accuracydetail/component/AccuracyDetailPlayerSheet.kt b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/accuracydetail/component/AccuracyDetailPlayerSheet.kt index e62f23fe..c795eb80 100644 --- a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/accuracydetail/component/AccuracyDetailPlayerSheet.kt +++ b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/accuracydetail/component/AccuracyDetailPlayerSheet.kt @@ -8,7 +8,6 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState @@ -45,7 +44,7 @@ internal fun AccuracyDetailPlayerSheet( Column( modifier = Modifier .fillMaxWidth() - .then(if (expanded) Modifier.fillMaxHeight() else Modifier), + .fillMaxHeight(), ) { SheetHandle() SheetDetailContent( @@ -90,7 +89,6 @@ private fun SheetDetailContent( Column( modifier = modifier .fillMaxWidth() - .then(if (expanded) Modifier else Modifier.heightIn(max = 96.dp)) .padding(horizontal = PrezelTheme.spacing.V20) .verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(PrezelTheme.spacing.V16), @@ -259,7 +257,7 @@ private val PreviewSentenceDetails = persistentListOf( sentence = "같은 말을 반복하고 있어요.", status = WordAnalysisStatus.INSERTION, mainFeedback = "같은 말을 반복하고 있어요.", - subFeedback = "앞에서 했던 말은 반복하지 않는 것이 좋아요.", + subFeedback = "앞에서 했던 말은 반복하지 않는 것이 좋아요. 다시 한 번 또박또박 연습해보세요.", accuracy = 42.0, startTimeMs = 7_230L, endTimeMs = 8_700L, diff --git a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/accuracydetail/component/ScriptDetailList.kt b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/accuracydetail/component/ScriptDetailList.kt index fa8b9512..1f7f6db2 100644 --- a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/accuracydetail/component/ScriptDetailList.kt +++ b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/accuracydetail/component/ScriptDetailList.kt @@ -57,7 +57,7 @@ internal fun ScriptDetailList( private fun SentenceAnalysisUiModel.showsStatusChip(selectedTab: AccuracyDetailTab): Boolean = when (selectedTab) { - AccuracyDetailTab.SPEECH -> isSpeechAccuracyIssue + AccuracyDetailTab.SPEECH -> hasSpeechAccuracyStatus AccuracyDetailTab.SCRIPT_MATCH -> isScriptMatchIssue } diff --git a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/accuracydetail/model/SentenceAnalysisUiModel.kt b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/accuracydetail/model/SentenceAnalysisUiModel.kt index 2eb8081a..00a39e64 100644 --- a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/accuracydetail/model/SentenceAnalysisUiModel.kt +++ b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/accuracydetail/model/SentenceAnalysisUiModel.kt @@ -19,16 +19,19 @@ internal data class SentenceAnalysisUiModel( val wordDetails: ImmutableList, ) { val isScriptMatchIssue: Boolean - get() = wordDetails.any { word -> word.status.isScriptMatchIssue } + get() = status.isScriptMatchIssue || wordDetails.any { word -> word.status.isScriptMatchIssue } val isSpeechAccuracyIssue: Boolean - get() = wordDetails.any { word -> word.status.isSpeechAccuracyIssue } + get() = status.isSpeechAccuracyIssue || wordDetails.any { word -> word.status.isSpeechAccuracyIssue } + + val hasSpeechAccuracyStatus: Boolean + get() = status.isSpeechAccuracyStatus || wordDetails.any { word -> word.status.isSpeechAccuracyStatus } val scriptMatchStatus: WordAnalysisStatus get() = wordDetails.firstOrNull { word -> word.status.isScriptMatchIssue }?.status ?: status val speechAccuracyStatus: WordAnalysisStatus - get() = wordDetails.firstOrNull { word -> word.status.isSpeechAccuracyIssue }?.status ?: status + get() = wordDetails.firstOrNull { word -> word.status.isSpeechAccuracyStatus }?.status ?: status } @Immutable @@ -46,6 +49,9 @@ private val WordAnalysisStatus.isScriptMatchIssue: Boolean this == WordAnalysisStatus.MISPRONUNCIATION private val WordAnalysisStatus.isSpeechAccuracyIssue: Boolean + get() = this == WordAnalysisStatus.STUTTER + +private val WordAnalysisStatus.isSpeechAccuracyStatus: Boolean get() = this == WordAnalysisStatus.EXCELLENT || this == WordAnalysisStatus.GOOD || this == WordAnalysisStatus.STUTTER From 0461f249c98ecdc0b4dc2be502e44c250c6aac1f Mon Sep 17 00:00:00 2001 From: Ham BeomJoon Date: Sat, 13 Jun 2026 20:00:44 +0900 Subject: [PATCH 5/5] =?UTF-8?q?feat:=20=EB=B6=84=EC=84=9D=20=EB=A6=AC?= =?UTF-8?q?=ED=8F=AC=ED=8A=B8=20=EC=A0=95=ED=99=95=EB=8F=84=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=20=ED=99=94=EB=A9=B4=20=EB=A1=9C=EC=A7=81=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0=20=EB=B0=8F=20=EC=95=88=EC=A0=95=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `WordAnalysisStatus.MISPRONUNCIATION`을 대본 일치 이슈에서 발음 정확도 이슈로 재분류 - 선택된 탭 종류에 따라 빈 화면 메시지가 다르게 표시되도록 `emptyDetailTextResId` 로직 추가 - `AccuracyDetailScreen`에서 탭 전환 시 플레이어 마커가 정상적으로 갱신되도록 `remember` 종속성 추가 - `WordAnalysisStatus.UNKNOWN` 상태에 하드코딩된 값 대신 문자열 리소스 적용 - `RemoteAudioPlaybackState`에서 `MediaPlayer` 초기화 실패 시 `release()`를 호출하여 자원 누수 방지 및 안정성 강화 --- .../report/impl/accuracydetail/AccuracyDetailScreen.kt | 2 +- .../impl/accuracydetail/RemoteAudioPlaybackState.kt | 10 +++++++--- .../impl/accuracydetail/component/ScriptDetailList.kt | 8 +++++++- .../accuracydetail/component/SentenceAnalysisCard.kt | 2 +- .../accuracydetail/model/SentenceAnalysisUiModel.kt | 6 +++--- .../report/impl/src/main/res/values/strings.xml | 1 + 6 files changed, 20 insertions(+), 9 deletions(-) diff --git a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/accuracydetail/AccuracyDetailScreen.kt b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/accuracydetail/AccuracyDetailScreen.kt index 112c045a..7d3a2f17 100644 --- a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/accuracydetail/AccuracyDetailScreen.kt +++ b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/accuracydetail/AccuracyDetailScreen.kt @@ -199,7 +199,7 @@ private fun rememberDetailPlayerState( durationMillis = remember(sentenceDetails) { sentenceDetails.maxOfOrNull { it.endTimeMs }?.coerceAtLeast(1L) ?: 1L }, - initialItems = remember(markerSentenceDetails) { + initialItems = remember(selectedTab, markerSentenceDetails) { markerSentenceDetails .map { detail -> PrezelPlayerItem.Marker( diff --git a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/accuracydetail/RemoteAudioPlaybackState.kt b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/accuracydetail/RemoteAudioPlaybackState.kt index 7c8b27b3..a8704685 100644 --- a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/accuracydetail/RemoteAudioPlaybackState.kt +++ b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/accuracydetail/RemoteAudioPlaybackState.kt @@ -85,9 +85,11 @@ internal class RemoteAudioPlaybackState( lastKnownPositionMillis = 0 } - private fun preparePlayer(): MediaPlayer? = - runCatching { - MediaPlayer().apply { + private fun preparePlayer(): MediaPlayer? { + val player = MediaPlayer() + + return runCatching { + player.apply { setOnPreparedListener { player -> prepared = true val positionMillis = pendingPositionMillis ?: lastKnownPositionMillis @@ -110,9 +112,11 @@ internal class RemoteAudioPlaybackState( } } }.onFailure { + runCatching { player.release() } handlePlaybackFailure() }.getOrNull() ?.also { mediaPlayer = it } + } private fun startPreparedPlayer( player: MediaPlayer, diff --git a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/accuracydetail/component/ScriptDetailList.kt b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/accuracydetail/component/ScriptDetailList.kt index 1f7f6db2..bbd02dc6 100644 --- a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/accuracydetail/component/ScriptDetailList.kt +++ b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/accuracydetail/component/ScriptDetailList.kt @@ -50,11 +50,17 @@ internal fun ScriptDetailList( ) } } else { - EmptyDetailText(text = stringResource(R.string.feature_report_impl_script_detail_empty_speech)) + EmptyDetailText(text = stringResource(selectedTab.emptyDetailTextResId)) } } } +private val AccuracyDetailTab.emptyDetailTextResId: Int + get() = when (this) { + AccuracyDetailTab.SPEECH -> R.string.feature_report_impl_accuracy_detail_sheet_empty_speech + AccuracyDetailTab.SCRIPT_MATCH -> R.string.feature_report_impl_accuracy_detail_sheet_empty_script + } + private fun SentenceAnalysisUiModel.showsStatusChip(selectedTab: AccuracyDetailTab): Boolean = when (selectedTab) { AccuracyDetailTab.SPEECH -> hasSpeechAccuracyStatus diff --git a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/accuracydetail/component/SentenceAnalysisCard.kt b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/accuracydetail/component/SentenceAnalysisCard.kt index 2b89de3d..ee36acc9 100644 --- a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/accuracydetail/component/SentenceAnalysisCard.kt +++ b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/accuracydetail/component/SentenceAnalysisCard.kt @@ -188,7 +188,7 @@ private fun WordAnalysisStatus.statusLabel(): String = WordAnalysisStatus.INSERTION -> stringResource(R.string.feature_report_impl_script_detail_status_insertion) WordAnalysisStatus.OMISSION -> stringResource(R.string.feature_report_impl_script_detail_status_omission) WordAnalysisStatus.MISPRONUNCIATION -> stringResource(R.string.feature_report_impl_script_detail_status_mismatch) - WordAnalysisStatus.UNKNOWN -> value + WordAnalysisStatus.UNKNOWN -> stringResource(R.string.feature_report_impl_script_detail_status_unknown) } @Composable diff --git a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/accuracydetail/model/SentenceAnalysisUiModel.kt b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/accuracydetail/model/SentenceAnalysisUiModel.kt index 00a39e64..5134a9ce 100644 --- a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/accuracydetail/model/SentenceAnalysisUiModel.kt +++ b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/accuracydetail/model/SentenceAnalysisUiModel.kt @@ -45,11 +45,11 @@ internal data class WordAnalysisUiModel( private val WordAnalysisStatus.isScriptMatchIssue: Boolean get() = this == WordAnalysisStatus.INSERTION || - this == WordAnalysisStatus.OMISSION || - this == WordAnalysisStatus.MISPRONUNCIATION + this == WordAnalysisStatus.OMISSION private val WordAnalysisStatus.isSpeechAccuracyIssue: Boolean - get() = this == WordAnalysisStatus.STUTTER + get() = this == WordAnalysisStatus.STUTTER || + this == WordAnalysisStatus.MISPRONUNCIATION private val WordAnalysisStatus.isSpeechAccuracyStatus: Boolean get() = this == WordAnalysisStatus.EXCELLENT || diff --git a/Prezel/feature/report/impl/src/main/res/values/strings.xml b/Prezel/feature/report/impl/src/main/res/values/strings.xml index a00e2b3f..c7364a23 100644 --- a/Prezel/feature/report/impl/src/main/res/values/strings.xml +++ b/Prezel/feature/report/impl/src/main/res/values/strings.xml @@ -70,6 +70,7 @@ 불필요한 표현 누락 불일치 + 알 수 없음 맞춤법