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 8b0ce966..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,8 +14,10 @@ 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 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 @@ -23,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 = @@ -87,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 = status, - description = description, + status = WordAnalysisStatus.from(value = status), 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 e0a62011..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,16 +1,46 @@ 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: String, - val description: String, + val status: WordAnalysisStatus, 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.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 new file mode 100644 index 00000000..7d3a2f17 --- /dev/null +++ b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/accuracydetail/AccuracyDetailScreen.kt @@ -0,0 +1,403 @@ +package com.team.prezel.feature.report.impl.accuracydetail + +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.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +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 +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.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.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 +import kotlin.math.absoluteValue + +@Serializable +internal enum class AccuracyDetailTab { + SPEECH, + SCRIPT_MATCH, +} + +@Composable +internal fun AccuracyDetailScreen( + onClose: () -> Unit, + initialTab: AccuracyDetailTab, + 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 + 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 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 sheetPeekHeight = rememberPlayerSheetPeekHeight(markerSentenceDetails = playerMarkerSentenceDetails) + val playerState = rememberDetailPlayerState( + selectedTab = selectedTab, + sentenceDetails = sentenceDetails, + markerSentenceDetails = playerMarkerSentenceDetails, + ) + val playbackState = rememberRemoteAudioPlaybackState(audioUrl = uiState.wordDetail.audioUrl) + 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 + + PlaybackEffect( + playerState = playerState, + playbackState = playbackState, + ) + + AccuracyDetailScaffold( + scaffoldState = scaffoldState, + selectedTab = selectedTab, + selectedSentence = selectedSentence, + sentenceDetails = sentenceDetails, + playerState = playerState, + expanded = isSheetExpanded, + sheetPeekHeight = sheetPeekHeight, + 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 rememberPlayerSheetPeekHeight(markerSentenceDetails: ImmutableList): Dp = + remember(markerSentenceDetails) { + if (markerSentenceDetails.isEmpty()) { + AccuracyDetailPlayerSheetDefaultPeekHeight + } else { + AccuracyDetailPlayerSheetLargePeekHeight + } + } + +@Composable +private fun rememberDetailPlayerState( + selectedTab: AccuracyDetailTab, + sentenceDetails: ImmutableList, + markerSentenceDetails: ImmutableList, +) = rememberPrezelPlayerState( + durationMillis = remember(sentenceDetails) { + sentenceDetails.maxOfOrNull { it.endTimeMs }?.coerceAtLeast(1L) ?: 1L + }, + initialItems = remember(selectedTab, markerSentenceDetails) { + markerSentenceDetails + .map { detail -> + PrezelPlayerItem.Marker( + timeMillis = detail.startTimeMs, + markerType = when (selectedTab) { + AccuracyDetailTab.SPEECH -> detail.speechAccuracyStatus.toMarkerType() + AccuracyDetailTab.SCRIPT_MATCH -> detail.scriptMatchStatus.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.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) + playbackState.currentPositionMillis + .takeIf { it > 0 } + ?.let { playerState.updateCurrentMillis(it.toLong()) } + } + } + + LaunchedEffect(playbackState.playbackError) { + if (playbackState.playbackError) playerState.pause() + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun AccuracyDetailScaffold( + scaffoldState: BottomSheetScaffoldState, + selectedTab: AccuracyDetailTab, + selectedSentence: SentenceAnalysisUiModel?, + sentenceDetails: ImmutableList, + playerState: PrezelPlayerState, + expanded: Boolean, + sheetPeekHeight: Dp, + onClose: () -> Unit, + tabLabels: ImmutableList, + onClickTab: (Int) -> Unit, + pagerState: PagerState, +) { + BottomSheetScaffold( + modifier = Modifier.fillMaxSize(), + scaffoldState = scaffoldState, + sheetPeekHeight = sheetPeekHeight, + sheetShape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp), + sheetContainerColor = PrezelTheme.colors.solidWhite, + sheetShadowElevation = 12.dp, + sheetContent = { + AccuracyDetailPlayerSheet( + selectedTab = selectedTab, + selectedSentence = selectedSentence, + sentenceDetails = sentenceDetails, + 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, + selectedSentence = selectedSentence, + sentenceDetails = sentenceDetails, + ) + } + } +} + +private const val SEEK_SYNC_THRESHOLD_MILLIS = 750L +private val AccuracyDetailPlayerSheetDefaultPeekHeight = 220.dp +private val AccuracyDetailPlayerSheetLargePeekHeight = 336.dp + +@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", + sentenceDetails = persistentListOf( + SentenceAnalysisDetail( + sentence = "오늘도 다들 긴장되는 마음으로 오셨을 것 같습니다.", + status = WordAnalysisStatus.EXCELLENT, + mainFeedback = "문장의 흐름이 깔끔했어요", + subFeedback = "지금처럼 또렷한 말하기를 유지해주세요.", + accuracy = 96.0, + startTimeMs = 0L, + endTimeMs = 1_800L, + wordDetails = persistentListOf(), + ), + SentenceAnalysisDetail( + sentence = "오늘도 다들 긴장되는 마음으로 오셨을 것 같습니다.", + status = WordAnalysisStatus.INSERTION, + mainFeedback = "같은 말을 반복하고 있어요.", + subFeedback = "앞에서 했던 말은 반복하지 않는 것이 좋아요.", + accuracy = 42.0, + startTimeMs = 7_230L, + endTimeMs = 8_700L, + wordDetails = persistentListOf(), + ), + SentenceAnalysisDetail( + sentence = "오늘도 다들 긴장되는 마음으로 오셨을 것 같습니다.", + status = WordAnalysisStatus.OMISSION, + 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/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..fa8e8115 --- /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 com.team.prezel.feature.report.impl.accuracydetail.model.AccuracyDetailUiMessage +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 { + 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..a8704685 --- /dev/null +++ b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/accuracydetail/RemoteAudioPlaybackState.kt @@ -0,0 +1,150 @@ +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? { + val player = MediaPlayer() + + return runCatching { + player.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 { + runCatching { player.release() } + 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 new file mode 100644 index 00000000..c795eb80 --- /dev/null +++ b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/accuracydetail/component/AccuracyDetailPlayerSheet.kt @@ -0,0 +1,292 @@ +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.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.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, + selectedSentence: SentenceAnalysisUiModel?, + sentenceDetails: ImmutableList, + playerState: PrezelPlayerState, + expanded: Boolean, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight(), + ) { + SheetHandle() + SheetDetailContent( + selectedTab = selectedTab, + selectedSentence = selectedSentence, + sentenceDetails = sentenceDetails, + 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, + selectedSentence: SentenceAnalysisUiModel?, + sentenceDetails: ImmutableList, + expanded: Boolean, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = PrezelTheme.spacing.V20) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(PrezelTheme.spacing.V16), + ) { + when (selectedTab) { + AccuracyDetailTab.SPEECH -> SpeechDetailContent( + selectedSentence = selectedSentence, + sentenceDetails = sentenceDetails, + expanded = expanded, + ) + + AccuracyDetailTab.SCRIPT_MATCH -> ScriptMatchDetailContent( + selectedSentence = selectedSentence, + sentenceDetails = sentenceDetails, + expanded = expanded, + ) + } + } + Spacer(modifier = Modifier.height(PrezelTheme.spacing.V12)) +} + +@Composable +private fun SpeechDetailContent( + selectedSentence: SentenceAnalysisUiModel?, + sentenceDetails: ImmutableList, + expanded: Boolean, +) { + val accuracyDetails = sentenceDetails.filter { it.isSpeechAccuracyIssue }.toImmutableList() + val visibleAccuracyDetails = if (expanded) { + accuracyDetails + } else { + 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 -> + SentenceAnalysisCard( + detail = detail, + highlighted = detail == selectedSentence, + text = detail.mainFeedback, + subText = detail.subFeedback, + useStatusTextColor = false, + status = detail.speechAccuracyStatus, + ) + } + } +} + +@Composable +private fun ScriptMatchDetailContent( + selectedSentence: SentenceAnalysisUiModel?, + sentenceDetails: ImmutableList, + expanded: Boolean, +) { + val visibleMismatchDetails = sentenceDetails.visibleScriptMatchDetails( + selectedSentence = selectedSentence, + expanded = expanded, + ) + + if (visibleMismatchDetails.isEmpty()) { + EmptyDetailText(text = stringResource(R.string.feature_report_impl_accuracy_detail_sheet_empty_script)) + } + + visibleMismatchDetails.forEach { detail -> + SentenceAnalysisCard( + detail = detail, + highlighted = detail == selectedSentence, + text = detail.mainFeedback, + subText = detail.subFeedback, + useStatusTextColor = false, + status = detail.scriptMatchStatus, + ) + } +} + +private fun ImmutableList.visibleScriptMatchDetails( + selectedSentence: SentenceAnalysisUiModel?, + expanded: Boolean, +): ImmutableList { + val mismatchDetails = filter { it.isScriptMatchIssue }.toImmutableList() + return if (expanded) { + mismatchDetails + } else { + listOfNotNull(selectedSentence?.takeIf { it.isScriptMatchIssue } ?: mismatchDetails.firstOrNull()).toImmutableList() + } +} + +@BasicPreview +@Composable +private fun AccuracyDetailPlayerSheetSpeechPreview() { + PrezelTheme { + AccuracyDetailPlayerSheet( + selectedTab = AccuracyDetailTab.SPEECH, + selectedSentence = PreviewSentenceDetails.first(), + sentenceDetails = PreviewSentenceDetails, + playerState = rememberPreviewPlayerState(), + expanded = false, + ) + } +} + +@BasicPreview +@Composable +private fun AccuracyDetailPlayerSheetScriptMatchPreview() { + PrezelTheme { + AccuracyDetailPlayerSheet( + selectedTab = AccuracyDetailTab.SCRIPT_MATCH, + selectedSentence = PreviewSentenceDetails[1], + sentenceDetails = PreviewSentenceDetails, + playerState = rememberPreviewPlayerState(), + expanded = false, + ) + } +} + +@BasicPreview +@Composable +private fun AccuracyDetailPlayerSheetExpandedPreview() { + PrezelTheme { + AccuracyDetailPlayerSheet( + selectedTab = AccuracyDetailTab.SPEECH, + selectedSentence = PreviewSentenceDetails[1], + sentenceDetails = PreviewSentenceDetails, + playerState = rememberPreviewPlayerState(currentMillis = 7_230L), + expanded = true, + ) + } +} + +@Composable +private fun rememberPreviewPlayerState(currentMillis: Long = 0L): PrezelPlayerState = + rememberPrezelPlayerState( + durationMillis = 11_300L, + currentMillis = currentMillis, + initialItems = PreviewSentenceDetails + .map { detail -> + PrezelPlayerItem.Marker( + timeMillis = detail.startTimeMs, + markerType = detail.speechAccuracyStatus.toMarkerType(), + ) + }.toImmutableList(), + ) + +private val PreviewSentenceDetails = persistentListOf( + SentenceAnalysisUiModel( + sentence = "문장의 흐름이 깔끔했어요", + status = WordAnalysisStatus.EXCELLENT, + mainFeedback = "문장의 흐름이 깔끔했어요", + subFeedback = "지금처럼 또렷한 말하기를 유지해주세요.", + accuracy = 96.0, + startTimeMs = 0L, + endTimeMs = 1_800L, + wordDetails = persistentListOf( + WordAnalysisUiModel( + word = "흐름이", + status = WordAnalysisStatus.EXCELLENT, + accuracy = 96.0, + startTimeMs = 320L, + endTimeMs = 780L, + ), + ), + ), + SentenceAnalysisUiModel( + sentence = "같은 말을 반복하고 있어요.", + status = WordAnalysisStatus.INSERTION, + 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, + ), + ), + ), + SentenceAnalysisUiModel( + sentence = "오늘도 다들 긴장되는 마음으로 오셨을 것 같습니다.", + status = WordAnalysisStatus.OMISSION, + 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/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..bbd02dc6 --- /dev/null +++ b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/accuracydetail/component/ScriptDetailList.kt @@ -0,0 +1,142 @@ +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.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, + selectedSentence: SentenceAnalysisUiModel?, + sentenceDetails: ImmutableList, +) { + 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 (sentenceDetails.isNotEmpty()) { + sentenceDetails.forEach { detail -> + SentenceAnalysisCard( + detail = detail, + highlighted = detail == selectedSentence, + showStatusChip = detail.showsStatusChip(selectedTab), + highlightWordDetails = true, + status = detail.statusFor(selectedTab), + ) + } + } else { + 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 + AccuracyDetailTab.SCRIPT_MATCH -> isScriptMatchIssue + } + +private fun SentenceAnalysisUiModel.statusFor(selectedTab: AccuracyDetailTab): WordAnalysisStatus = + when (selectedTab) { + AccuracyDetailTab.SPEECH -> speechAccuracyStatus + AccuracyDetailTab.SCRIPT_MATCH -> scriptMatchStatus + } + +@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, + selectedSentence = PreviewSentenceDetail, + sentenceDetails = PreviewSentenceDetails, + ) + } +} + +@BasicPreview +@Composable +private fun ScriptDetailListEmptyPreview() { + PrezelTheme { + ScriptDetailList( + selectedTab = AccuracyDetailTab.SPEECH, + selectedSentence = null, + sentenceDetails = persistentListOf(), + ) + } +} + +private val PreviewSentenceDetail = SentenceAnalysisUiModel( + sentence = "문장의 흐름이 깔끔했어요.", + status = WordAnalysisStatus.EXCELLENT, + mainFeedback = "문장의 흐름이 깔끔했어요.", + subFeedback = "지금처럼 또렷한 말하기를 유지해주세요.", + accuracy = 98.0, + startTimeMs = 1_490L, + endTimeMs = 1_980L, + wordDetails = persistentListOf(), +) + +private val PreviewSentenceDetails = persistentListOf( + PreviewSentenceDetail, + SentenceAnalysisUiModel( + sentence = "같은 말을 반복하고 있어요.", + status = WordAnalysisStatus.EXCELLENT, + mainFeedback = "같은 말을 반복하고 있어요.", + subFeedback = "앞에서 했던 말은 반복하지 않는 것이 좋아요.", + accuracy = 98.0, + startTimeMs = 1_990L, + endTimeMs = 2_400L, + wordDetails = persistentListOf(), + ), + SentenceAnalysisUiModel( + sentence = "문장이 끝까지 정확하게 전달돼요.", + status = WordAnalysisStatus.EXCELLENT, + 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..ee36acc9 --- /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 -> stringResource(R.string.feature_report_impl_script_detail_status_unknown) + } + +@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/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..38d80158 --- /dev/null +++ b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/accuracydetail/contract/AccuracyDetailUiEffect.kt @@ -0,0 +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 { + data class ShowMessage( + val message: AccuracyDetailUiMessage, + ) : AccuracyDetailUiEffect +} 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/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/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..5134a9ce --- /dev/null +++ b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/accuracydetail/model/SentenceAnalysisUiModel.kt @@ -0,0 +1,81 @@ +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() = status.isScriptMatchIssue || wordDetails.any { word -> word.status.isScriptMatchIssue } + + val isSpeechAccuracyIssue: Boolean + 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.isSpeechAccuracyStatus }?.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 + +private val WordAnalysisStatus.isSpeechAccuracyIssue: Boolean + get() = this == WordAnalysisStatus.STUTTER || + this == WordAnalysisStatus.MISPRONUNCIATION + +private val WordAnalysisStatus.isSpeechAccuracyStatus: 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, + ) 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 07ac800b..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,9 +4,13 @@ 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.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 @@ -18,44 +22,59 @@ import dagger.hilt.android.components.ActivityRetainedComponent import dagger.multibindings.IntoSet internal fun EntryProviderScope.featureAnalysisReportEntryBuilder() { + reportEntry() + scriptCorrectionEntry() + accuracyDetailEntry() +} + +private fun EntryProviderScope.reportEntry() { entry { key -> val navigator = LocalNavigator.current 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.navigateToAccuracyDetail( + analysisResultId = analysisResultId, + initialTab = AccuracyDetailTab.SPEECH, + ) + }, + navigateToScriptMatch = { analysisResultId -> + 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 @@ -68,6 +87,74 @@ internal fun EntryProviderScope.featureAnalysisReportEntryBuilder() { } } +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 + + 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 index 7f2ad066..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 @@ -1,12 +1,21 @@ 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 -sealed interface ReportInnerNavKey : NavKey { +internal sealed interface ReportInnerNavKey : NavKey { + val analysisResultId: Long + + @Serializable + 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/AnalysisReportScreen.kt b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/report/AnalysisReportScreen.kt index 8f6be541..9e60faa5 100644 --- a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/report/AnalysisReportScreen.kt +++ b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/report/AnalysisReportScreen.kt @@ -36,6 +36,8 @@ internal fun AnalysisReportScreen( navigateToAnalysisRecording: (presentationId: Long, isPast: Boolean) -> Unit, navigateToScriptAnalysis: (analysisResultId: Long) -> Unit, navigateToSelfFeedbackWrite: (presentationId: Long, title: String, isPast: Boolean) -> Unit, + navigateToSpeechAccuracy: (analysisResultId: Long) -> Unit, + navigateToScriptMatch: (analysisResultId: Long) -> Unit, modifier: Modifier = Modifier, viewModel: AnalysisReportViewModel = hiltViewModel(), ) { @@ -64,6 +66,8 @@ internal fun AnalysisReportScreen( is AnalysisReportUiEffect.NavigateToSelfFeedbackWrite -> { navigateToSelfFeedbackWrite(effect.presentationId, effect.title, effect.isPast) } + is AnalysisReportUiEffect.NavigateToSpeechAccuracy -> navigateToSpeechAccuracy(effect.analysisResultId) + is AnalysisReportUiEffect.NavigateToScriptMatch -> navigateToScriptMatch(effect.analysisResultId) } } } @@ -79,6 +83,8 @@ internal fun AnalysisReportScreen( onReRecordingClick = { viewModel.onIntent(AnalysisReportUiIntent.ClickReRecording) }, onFeedBackWriteClick = { viewModel.onIntent(AnalysisReportUiIntent.ClickFeedbackWrite) }, onScriptAnalysisClick = { viewModel.onIntent(AnalysisReportUiIntent.ClickScriptAnalysis) }, + onSpeechAccuracyClick = { viewModel.onIntent(AnalysisReportUiIntent.ClickSpeechAccuracy) }, + onScriptMatchClick = { viewModel.onIntent(AnalysisReportUiIntent.ClickScriptMatch) }, modifier = modifier, ) } @@ -95,6 +101,8 @@ internal fun AnalysisReportScreen( onReRecordingClick: () -> Unit, onFeedBackWriteClick: () -> Unit, onScriptAnalysisClick: () -> Unit, + onSpeechAccuracyClick: () -> Unit, + onScriptMatchClick: () -> Unit, modifier: Modifier = Modifier, ) { when (uiState) { @@ -111,6 +119,8 @@ internal fun AnalysisReportScreen( onReRecordingClick = onReRecordingClick, onFeedBackWriteClick = onFeedBackWriteClick, onScriptAnalysisClick = onScriptAnalysisClick, + onSpeechAccuracyClick = onSpeechAccuracyClick, + onScriptMatchClick = onScriptMatchClick, ) } @@ -130,6 +140,8 @@ private fun AnalysisReportScreenContent( onReRecordingClick: () -> Unit, onFeedBackWriteClick: () -> Unit, onScriptAnalysisClick: () -> Unit, + onSpeechAccuracyClick: () -> Unit, + onScriptMatchClick: () -> Unit, modifier: Modifier, ) { uiState.reportDialog?.let { type -> @@ -165,6 +177,8 @@ private fun AnalysisReportScreenContent( onReWriteScriptClick = onReWriteScriptClick, onReRecordingClick = onReRecordingClick, onFeedBackWriteClick = onFeedBackWriteClick, + onSpeechAccuracyClick = onSpeechAccuracyClick, + onScriptMatchClick = onScriptMatchClick, onScriptAnalysisClick = onScriptAnalysisClick, ) }, @@ -193,6 +207,8 @@ private fun UpcomingAnalysisReportScreenPreview() { onReRecordingClick = {}, onFeedBackWriteClick = {}, onScriptAnalysisClick = {}, + onSpeechAccuracyClick = {}, + onScriptMatchClick = {}, ) } } @@ -212,6 +228,8 @@ private fun PastAnalysisReportScreenPreview() { onReRecordingClick = {}, onFeedBackWriteClick = {}, onScriptAnalysisClick = {}, + onSpeechAccuracyClick = {}, + onScriptMatchClick = {}, ) } } @@ -231,6 +249,8 @@ private fun AnalysisReportScreenLoadingPreview() { onReRecordingClick = {}, onFeedBackWriteClick = {}, onScriptAnalysisClick = {}, + onSpeechAccuracyClick = {}, + onScriptMatchClick = {}, ) } } diff --git a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/report/AnalysisReportViewModel.kt b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/report/AnalysisReportViewModel.kt index 37780890..048903e3 100644 --- a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/report/AnalysisReportViewModel.kt +++ b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/report/AnalysisReportViewModel.kt @@ -44,6 +44,8 @@ internal class AnalysisReportViewModel @AssistedInject constructor( AnalysisReportUiIntent.ClickReWriteScript -> handleReWriteScriptClick() AnalysisReportUiIntent.ClickFeedbackWrite -> navigateToSelfFeedbackWrite() AnalysisReportUiIntent.ClickScriptAnalysis -> navigateToScriptAnalysis() + AnalysisReportUiIntent.ClickSpeechAccuracy -> navigateToSpeechAccuracy() + AnalysisReportUiIntent.ClickScriptMatch -> navigateToScriptMatch() } } @@ -128,6 +130,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/report/component/ReportBodyContent.kt b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/report/component/ReportBodyContent.kt index 6cc08d00..ff5a9bd7 100644 --- a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/report/component/ReportBodyContent.kt +++ b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/report/component/ReportBodyContent.kt @@ -41,6 +41,8 @@ internal fun ReportBodyContent( onReRecordingClick: () -> Unit, onFeedBackWriteClick: () -> Unit, onScriptAnalysisClick: () -> Unit, + onSpeechAccuracyClick: () -> Unit, + onScriptMatchClick: () -> Unit, ) { if (uiState.isPast && uiState.practiceRecords != null) { SelfFeedbackSection( @@ -55,6 +57,8 @@ internal fun ReportBodyContent( accuracyScore = uiState.accuracyScore, scriptMatchRate = uiState.scriptMatchRate, speedGraphData = uiState.speedGraphData, + onSpeechAccuracyClick = onSpeechAccuracyClick, + onScriptMatchClick = onScriptMatchClick, ) GrowthGraphSection( growthGraphData = uiState.growthGraphData, @@ -99,6 +103,8 @@ private fun ReportBodyContentPreview() { onReRecordingClick = {}, onFeedBackWriteClick = {}, onScriptAnalysisClick = {}, + onSpeechAccuracyClick = {}, + onScriptMatchClick = {}, ) } } diff --git a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/report/component/ReportScreenLayout.kt b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/report/component/ReportScreenLayout.kt index fae124fd..9249db5b 100644 --- a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/report/component/ReportScreenLayout.kt +++ b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/report/component/ReportScreenLayout.kt @@ -197,6 +197,8 @@ private fun ReportScreenLayoutPreview() { onReRecordingClick = {}, onFeedBackWriteClick = {}, onScriptAnalysisClick = {}, + onSpeechAccuracyClick = {}, + onScriptMatchClick = {}, ) }, ) diff --git a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/report/component/body/AccuracySection.kt b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/report/component/body/AccuracySection.kt index 04226d01..bc019a7a 100644 --- a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/report/component/body/AccuracySection.kt +++ b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/report/component/body/AccuracySection.kt @@ -35,6 +35,8 @@ 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,13 +95,15 @@ private fun AccuracyMetricCards( modifier = Modifier.weight(1f), title = stringResource(R.string.feature_report_impl_label_speech), value = accuracyScore.toPercentLabel(), - onClick = {}, + enabled = accuracyScore != null, + onClick = onSpeechAccuracyClick, ) MetricResultCard( modifier = Modifier.weight(1f), title = stringResource(R.string.feature_report_impl_label_script_match), value = scriptMatchRate.toPercentLabel(), - onClick = {}, + enabled = scriptMatchRate != null, + onClick = onScriptMatchClick, ) } } @@ -166,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/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 b0b2c6be..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 @@ -1,7 +1,6 @@ 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,10 +15,10 @@ 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 -import com.team.prezel.core.ui.util.noRippleClickable import kotlin.math.roundToInt @Composable @@ -28,19 +27,24 @@ internal fun MetricResultCard( value: String, onClick: () -> Unit, modifier: Modifier = Modifier, + enabled: Boolean = false, ) { - Box( + PrezelTouchArea( modifier = modifier .clip(shape = PrezelTheme.shapes.V8) - .background(color = PrezelTheme.colors.bgMedium) - .noRippleClickable(onClick = onClick), + .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, @@ -62,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/report/contract/AnalysisReportUiEffect.kt b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/report/contract/AnalysisReportUiEffect.kt index b65ddf3c..1ba5fb6d 100644 --- a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/report/contract/AnalysisReportUiEffect.kt +++ b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/report/contract/AnalysisReportUiEffect.kt @@ -29,4 +29,12 @@ internal sealed interface AnalysisReportUiEffect : UiEffect { data class NavigateToScriptAnalysis( val analysisResultId: 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/report/contract/AnalysisReportUiIntent.kt b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/report/contract/AnalysisReportUiIntent.kt index 8ee6dd92..1e52ae1d 100644 --- a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/report/contract/AnalysisReportUiIntent.kt +++ b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/report/contract/AnalysisReportUiIntent.kt @@ -22,4 +22,8 @@ internal sealed interface AnalysisReportUiIntent : UiIntent { data object ClickFeedbackWrite : AnalysisReportUiIntent data object ClickScriptAnalysis : AnalysisReportUiIntent + + data object ClickSpeechAccuracy : AnalysisReportUiIntent + + data object ClickScriptMatch : AnalysisReportUiIntent } 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 2fd0f8c8..c7364a23 100644 --- a/Prezel/feature/report/impl/src/main/res/values/strings.xml +++ b/Prezel/feature/report/impl/src/main/res/values/strings.xml @@ -60,9 +60,19 @@ 리포트를 불러오지 못했습니다. 대본 분석 결과를 불러오지 못했습니다. 리포트를 삭제하지 못했습니다. + 닫기 + 정확도 상세를 불러오지 못했습니다. + 발화 분석 상세가 없어요. + 모든 단어가 정확하게 발음되었어요. + 대본과 일치하지 않는 부분이 없어요. + 정확도 상세 오디오 트랙 + 발음 + 불필요한 표현 + 누락 + 불일치 + 알 수 없음 - 닫기 맞춤법 주술호응 %1$d개