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 54e71082..a4a744e4 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 @@ -74,8 +74,9 @@ private fun PresentationExpectedQuestionResponse.toDomain(): ExpectedQuestion = internal fun PresentationScriptDetailResponse.toDomain(): PresentationScriptDetail = PresentationScriptDetail( + presentationId = presentationId, originalScript = originalScript, - scriptCorrections = scriptDetails.map { item -> item.toDomain() }, + scriptCorrections = scriptDetails?.map { item -> item.toDomain() }.orEmpty(), ) internal fun PresentationScriptAnalysisResponse.toDomain(): ScriptCorrection = diff --git a/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/AuthRepositoryImpl.kt b/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/AuthRepositoryImpl.kt index dc432761..561b4aa5 100644 --- a/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/AuthRepositoryImpl.kt +++ b/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/AuthRepositoryImpl.kt @@ -32,6 +32,16 @@ internal class AuthRepositoryImpl @Inject constructor( authSessionCache.clear() }.mapDomainFailure() + override suspend fun loginAdmin(): Result = + runCatching { + val response = authRemoteDataSource.loginAdmin() + authLocalDataSource.saveTokens( + accessToken = response.accessToken, + refreshToken = response.refreshToken, + ) + authSessionCache.clear() + }.mapDomainFailure() + override suspend fun clearSession(): Result = runCatching { clearLocalSession() }.mapDomainFailure() override suspend fun withdraw(reason: WithdrawReason): Result = diff --git a/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/PresentationRepositoryImpl.kt b/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/PresentationRepositoryImpl.kt index 7f957791..6d50bf30 100644 --- a/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/PresentationRepositoryImpl.kt +++ b/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/PresentationRepositoryImpl.kt @@ -74,6 +74,21 @@ internal class PresentationRepositoryImpl @Inject constructor( response.toDomain() }.mapDomainFailure() + override suspend fun correctScript( + analysisResultId: Long, + finalScript: String, + correctedIndices: List, + ): Result = + runCatching { + presentationRemoteDataSource.correctScript( + analysisResultId = analysisResultId, + finalScript = finalScript, + correctedIndices = correctedIndices, + ) + }.mapCatching { response -> + response.toDomain() + }.mapDomainFailure() + override suspend fun fetchWordDetail(analysisResultId: Long): Result = runCatching { presentationRemoteDataSource.getWordDetail(analysisResultId = analysisResultId) diff --git a/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/feedback/dialog/PrezelDialog.kt b/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/feedback/dialog/PrezelDialog.kt index 8366fa84..e4d9de68 100644 --- a/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/feedback/dialog/PrezelDialog.kt +++ b/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/feedback/dialog/PrezelDialog.kt @@ -44,11 +44,12 @@ fun PrezelDialog( .padding(horizontal = PrezelTheme.spacing.V20) .clip(shape = PrezelTheme.shapes.V12) .background(color = PrezelTheme.colors.bgRegular) - .padding(horizontal = PrezelTheme.spacing.V24), + .padding(horizontal = PrezelTheme.spacing.V16), ) { DialogContent( title = title, description = description, + modifier = Modifier.padding(horizontal = PrezelTheme.spacing.V8), ) ActionSection { scope.content() } diff --git a/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/feedback/tooltip/PrezelTooltipBox.kt b/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/feedback/tooltip/PrezelTooltipBox.kt index 2ee04914..69978d4e 100644 --- a/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/feedback/tooltip/PrezelTooltipBox.kt +++ b/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/feedback/tooltip/PrezelTooltipBox.kt @@ -30,6 +30,7 @@ import androidx.compose.ui.graphics.toComposeRect import androidx.compose.ui.layout.boundsInWindow import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.platform.LocalWindowInfo import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp @@ -46,6 +47,7 @@ import com.team.prezel.core.designsystem.preview.BasicPreview import com.team.prezel.core.designsystem.preview.PreviewScaffold import com.team.prezel.core.designsystem.theme.PrezelColorScheme import com.team.prezel.core.designsystem.theme.PrezelTheme +import kotlin.math.roundToInt @Composable fun PrezelTooltipBox( @@ -87,8 +89,18 @@ fun PrezelTooltipBox( } @Composable -private fun rememberBalloonBuilder(showArrow: Boolean): Balloon.Builder = - rememberBalloonBuilder { +private fun rememberBalloonBuilder(showArrow: Boolean): Balloon.Builder { + val horizontalMarginDp = PrezelTheme.spacing.V20.value + .roundToInt() + val maxWidthDp = (LocalWindowInfo.current.containerSize.width - (horizontalMarginDp * 2)).coerceAtLeast(0) + + return rememberBalloonBuilder( + key = Triple( + showArrow, + horizontalMarginDp, + maxWidthDp, + ), + ) { setIsVisibleArrow(showArrow) setArrowWidth(12) setArrowHeight(6) @@ -101,7 +113,10 @@ private fun rememberBalloonBuilder(showArrow: Boolean): Balloon.Builder = setDismissWhenTouchOutside(false) setBalloonAnimation(BalloonAnimation.NONE) setBackgroundColor(PrezelColorScheme.Dark.bgMedium) + setMarginHorizontal(horizontalMarginDp) + if (maxWidthDp > 0) setMaxWidth(maxWidthDp) } +} private fun Rect.intersects(other: Rect): Boolean = left < other.right && right > other.left && top < other.bottom && bottom > other.top diff --git a/Prezel/core/domain/src/main/kotlin/com/team/prezel/core/domain/repository/auth/AuthRepository.kt b/Prezel/core/domain/src/main/kotlin/com/team/prezel/core/domain/repository/auth/AuthRepository.kt index b0914c98..83a52cfb 100644 --- a/Prezel/core/domain/src/main/kotlin/com/team/prezel/core/domain/repository/auth/AuthRepository.kt +++ b/Prezel/core/domain/src/main/kotlin/com/team/prezel/core/domain/repository/auth/AuthRepository.kt @@ -9,6 +9,8 @@ interface AuthRepository { suspend fun login(idToken: String): Result + suspend fun loginAdmin(): Result + suspend fun clearSession(): Result suspend fun withdraw(reason: WithdrawReason): Result diff --git a/Prezel/core/domain/src/main/kotlin/com/team/prezel/core/domain/repository/presentation/PresentationRepository.kt b/Prezel/core/domain/src/main/kotlin/com/team/prezel/core/domain/repository/presentation/PresentationRepository.kt index 80a52187..7559f5f8 100644 --- a/Prezel/core/domain/src/main/kotlin/com/team/prezel/core/domain/repository/presentation/PresentationRepository.kt +++ b/Prezel/core/domain/src/main/kotlin/com/team/prezel/core/domain/repository/presentation/PresentationRepository.kt @@ -34,6 +34,12 @@ interface PresentationRepository { suspend fun fetchScriptDetail(analysisResultId: Long): Result + suspend fun correctScript( + analysisResultId: Long, + finalScript: String, + correctedIndices: List, + ): Result + suspend fun fetchWordDetail(analysisResultId: Long): Result suspend fun deleteAnalysis(analysisResultId: Long): Result diff --git a/Prezel/core/domain/src/main/kotlin/com/team/prezel/core/domain/usecase/auth/LoginUseCase.kt b/Prezel/core/domain/src/main/kotlin/com/team/prezel/core/domain/usecase/auth/LoginUseCase.kt index 804ebcf1..07cf6d1b 100644 --- a/Prezel/core/domain/src/main/kotlin/com/team/prezel/core/domain/usecase/auth/LoginUseCase.kt +++ b/Prezel/core/domain/src/main/kotlin/com/team/prezel/core/domain/usecase/auth/LoginUseCase.kt @@ -14,4 +14,10 @@ class LoginUseCase @Inject constructor( onSuccess = { userRepository.fetchUserInfo() }, onFailure = { exception -> Result.failure(exception) }, ) + + suspend fun loginAdmin(): Result = + authRepository.loginAdmin().fold( + onSuccess = { userRepository.fetchUserInfo() }, + onFailure = { exception -> Result.failure(exception) }, + ) } diff --git a/Prezel/core/domain/src/main/kotlin/com/team/prezel/core/domain/usecase/presentation/CorrectPresentationScriptUseCase.kt b/Prezel/core/domain/src/main/kotlin/com/team/prezel/core/domain/usecase/presentation/CorrectPresentationScriptUseCase.kt new file mode 100644 index 00000000..200c340b --- /dev/null +++ b/Prezel/core/domain/src/main/kotlin/com/team/prezel/core/domain/usecase/presentation/CorrectPresentationScriptUseCase.kt @@ -0,0 +1,20 @@ +package com.team.prezel.core.domain.usecase.presentation + +import com.team.prezel.core.domain.repository.presentation.PresentationRepository +import com.team.prezel.core.model.presentation.PresentationScriptDetail +import javax.inject.Inject + +class CorrectPresentationScriptUseCase @Inject constructor( + private val presentationRepository: PresentationRepository, +) { + suspend operator fun invoke( + analysisResultId: Long, + finalScript: String, + correctedIndices: List, + ): Result = + presentationRepository.correctScript( + analysisResultId = analysisResultId, + finalScript = finalScript, + correctedIndices = correctedIndices, + ) +} diff --git a/Prezel/core/model/src/main/java/com/team/prezel/core/model/presentation/PresentationScriptDetail.kt b/Prezel/core/model/src/main/java/com/team/prezel/core/model/presentation/PresentationScriptDetail.kt index bd2c6039..206cf316 100644 --- a/Prezel/core/model/src/main/java/com/team/prezel/core/model/presentation/PresentationScriptDetail.kt +++ b/Prezel/core/model/src/main/java/com/team/prezel/core/model/presentation/PresentationScriptDetail.kt @@ -1,6 +1,7 @@ package com.team.prezel.core.model.presentation data class PresentationScriptDetail( + val presentationId: Long, val originalScript: String, val scriptCorrections: List, ) diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/AuthRemoteDataSource.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/AuthRemoteDataSource.kt index b72c01b5..b13000ce 100644 --- a/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/AuthRemoteDataSource.kt +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/AuthRemoteDataSource.kt @@ -8,6 +8,8 @@ interface AuthRemoteDataSource { suspend fun login(idToken: String): LoginResponse + suspend fun loginAdmin(): LoginResponse + suspend fun reissue(refreshToken: String): ReissueResponse suspend fun withdraw( diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/AuthRemoteDataSourceImpl.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/AuthRemoteDataSourceImpl.kt index 41ddf90a..326b77a2 100644 --- a/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/AuthRemoteDataSourceImpl.kt +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/AuthRemoteDataSourceImpl.kt @@ -19,6 +19,8 @@ internal class AuthRemoteDataSourceImpl @Inject constructor( override suspend fun login(idToken: String): LoginResponse = authService.login(request = LoginRequest(idToken = idToken)).requireData() + override suspend fun loginAdmin(): LoginResponse = authService.loginAdmin().requireData() + override suspend fun reissue(refreshToken: String): ReissueResponse = authService.reissue(request = ReissueRequest(refreshToken = refreshToken)).requireData() diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/PresentationRemoteDataSource.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/PresentationRemoteDataSource.kt index 8ed9f78a..eeaa4d06 100644 --- a/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/PresentationRemoteDataSource.kt +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/PresentationRemoteDataSource.kt @@ -30,6 +30,12 @@ interface PresentationRemoteDataSource { suspend fun getScriptDetail(analysisResultId: Long): PresentationScriptDetailResponse + suspend fun correctScript( + analysisResultId: Long, + finalScript: String, + correctedIndices: List, + ): PresentationScriptDetailResponse + suspend fun getWordDetail(analysisResultId: Long): PresentationWordDetailResponse suspend fun deleteAnalysis(analysisResultId: Long) diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/PresentationRemoteDataSourceImpl.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/PresentationRemoteDataSourceImpl.kt index 4439631d..121b5595 100644 --- a/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/PresentationRemoteDataSourceImpl.kt +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/PresentationRemoteDataSourceImpl.kt @@ -1,5 +1,6 @@ package com.team.prezel.core.network.datasource +import com.team.prezel.core.network.model.BaseResponse import com.team.prezel.core.network.model.presentation.GetCurationResponse import com.team.prezel.core.network.model.presentation.GetMainDataResponse import com.team.prezel.core.network.model.presentation.GetPracticeRecordsResponse @@ -8,14 +9,20 @@ import com.team.prezel.core.network.model.presentation.GetPresentationsResponse import com.team.prezel.core.network.model.presentation.PresentationScriptDetailResponse import com.team.prezel.core.network.model.presentation.PresentationSummaryResponse import com.team.prezel.core.network.model.presentation.PresentationWordDetailResponse +import com.team.prezel.core.network.model.presentation.ScriptCorrectionRequest import com.team.prezel.core.network.model.presentation.review.SelfFeedbackRequest import com.team.prezel.core.network.model.requireData import com.team.prezel.core.network.model.requireSuccess import com.team.prezel.core.network.service.PresentationService +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.plugins.timeout import io.ktor.client.request.forms.ChannelProvider import io.ktor.client.request.forms.FormBuilder import io.ktor.client.request.forms.MultiPartFormDataContent import io.ktor.client.request.forms.formData +import io.ktor.client.request.post +import io.ktor.client.request.setBody import io.ktor.http.Headers import io.ktor.http.HttpHeaders import io.ktor.utils.io.jvm.javaio.toByteReadChannel @@ -24,6 +31,7 @@ import javax.inject.Inject internal class PresentationRemoteDataSourceImpl @Inject constructor( private val presentationService: PresentationService, + private val httpClient: HttpClient, ) : PresentationRemoteDataSource { override suspend fun analyzePresentation( name: String, @@ -54,8 +62,14 @@ internal class PresentationRemoteDataSourceImpl @Inject constructor( }, ) - return presentationService - .analyzePresentation(multipart = multipart) + return httpClient + .post("recording/analyze") { + timeout { + requestTimeoutMillis = ANALYSIS_TIMEOUT_MILLIS + socketTimeoutMillis = ANALYSIS_TIMEOUT_MILLIS + } + setBody(multipart) + }.body>() .requireData() } @@ -77,16 +91,34 @@ internal class PresentationRemoteDataSourceImpl @Inject constructor( }, ) - return presentationService - .reAnalyzePresentation( - presentationId = presentationId, - multipart = multipart, - ).requireData() + return httpClient + .post("recording/$presentationId/re-analyze") { + timeout { + requestTimeoutMillis = ANALYSIS_TIMEOUT_MILLIS + socketTimeoutMillis = ANALYSIS_TIMEOUT_MILLIS + } + setBody(multipart) + }.body>() + .requireData() } override suspend fun getScriptDetail(analysisResultId: Long): PresentationScriptDetailResponse = presentationService.getScriptDetail(analysisResultId = analysisResultId).requireData() + override suspend fun correctScript( + analysisResultId: Long, + finalScript: String, + correctedIndices: List, + ): PresentationScriptDetailResponse = + presentationService + .correctScript( + analysisResultId = analysisResultId, + request = ScriptCorrectionRequest( + finalScript = finalScript, + correctedIndices = correctedIndices, + ), + ).requireData() + override suspend fun getWordDetail(analysisResultId: Long): PresentationWordDetailResponse = presentationService.getWordDetail(analysisResultId = analysisResultId).requireData() @@ -160,3 +192,5 @@ private fun File.toChannelProvider(): ChannelProvider = require(canRead()) { "파일을 읽을 수 없습니다: $path" } inputStream().toByteReadChannel() } + +private const val ANALYSIS_TIMEOUT_MILLIS = 600_000L diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/model/presentation/PresentationScriptDetailResponse.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/model/presentation/PresentationScriptDetailResponse.kt index 56701d06..869fa177 100644 --- a/Prezel/core/network/src/main/java/com/team/prezel/core/network/model/presentation/PresentationScriptDetailResponse.kt +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/model/presentation/PresentationScriptDetailResponse.kt @@ -12,7 +12,7 @@ data class PresentationScriptDetailResponse( @SerialName("originalScript") val originalScript: String, @SerialName("scriptDetails") - val scriptDetails: List, + val scriptDetails: List?, ) @Serializable diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/model/presentation/ScriptCorrectionRequest.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/model/presentation/ScriptCorrectionRequest.kt new file mode 100644 index 00000000..fca41608 --- /dev/null +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/model/presentation/ScriptCorrectionRequest.kt @@ -0,0 +1,12 @@ +package com.team.prezel.core.network.model.presentation + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class ScriptCorrectionRequest( + @SerialName("finalScript") + val finalScript: String, + @SerialName("correctedIndices") + val correctedIndices: List, +) diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/service/AuthService.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/service/AuthService.kt index b5eab043..9a266be5 100644 --- a/Prezel/core/network/src/main/java/com/team/prezel/core/network/service/AuthService.kt +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/service/AuthService.kt @@ -9,6 +9,7 @@ import com.team.prezel.core.network.model.auth.reissue.ReissueRequest import com.team.prezel.core.network.model.auth.reissue.ReissueResponse import de.jensklingenberg.ktorfit.http.Body import de.jensklingenberg.ktorfit.http.DELETE +import de.jensklingenberg.ktorfit.http.GET import de.jensklingenberg.ktorfit.http.POST import de.jensklingenberg.ktorfit.http.Tag @@ -22,6 +23,11 @@ internal interface AuthService { @Tag(AuthRequestAttributes.SKIP_AUTH) skipAuth: Boolean = true, ): BaseResponse + @GET("admin/login") + suspend fun loginAdmin( + @Tag(AuthRequestAttributes.SKIP_AUTH) skipAuth: Boolean = true, + ): BaseResponse + @DELETE("auth/withdraw") suspend fun withdraw( @Body request: WithdrawRequest, diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/service/PresentationService.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/service/PresentationService.kt index c18a4c40..d2518ce0 100644 --- a/Prezel/core/network/src/main/java/com/team/prezel/core/network/service/PresentationService.kt +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/service/PresentationService.kt @@ -9,10 +9,12 @@ import com.team.prezel.core.network.model.presentation.GetPresentationsResponse import com.team.prezel.core.network.model.presentation.PresentationScriptDetailResponse import com.team.prezel.core.network.model.presentation.PresentationSummaryResponse import com.team.prezel.core.network.model.presentation.PresentationWordDetailResponse +import com.team.prezel.core.network.model.presentation.ScriptCorrectionRequest import com.team.prezel.core.network.model.presentation.review.SelfFeedbackRequest import de.jensklingenberg.ktorfit.http.Body import de.jensklingenberg.ktorfit.http.DELETE import de.jensklingenberg.ktorfit.http.GET +import de.jensklingenberg.ktorfit.http.PATCH import de.jensklingenberg.ktorfit.http.POST import de.jensklingenberg.ktorfit.http.Path import io.ktor.client.request.forms.MultiPartFormDataContent @@ -34,6 +36,12 @@ interface PresentationService { @Path("analysisResultId") analysisResultId: Long, ): BaseResponse + @PATCH("recording/analyze/{analysisResultId}/scripts/correct") + suspend fun correctScript( + @Path("analysisResultId") analysisResultId: Long, + @Body request: ScriptCorrectionRequest, + ): BaseResponse + @GET("recording/analyze/{analysisResultId}/words") suspend fun getWordDetail( @Path("analysisResultId") analysisResultId: Long, diff --git a/Prezel/core/ui/src/main/java/com/team/prezel/core/ui/component/PracticeCard.kt b/Prezel/core/ui/src/main/java/com/team/prezel/core/ui/component/PracticeCard.kt index 03b5c336..579579e3 100644 --- a/Prezel/core/ui/src/main/java/com/team/prezel/core/ui/component/PracticeCard.kt +++ b/Prezel/core/ui/src/main/java/com/team/prezel/core/ui/component/PracticeCard.kt @@ -175,7 +175,10 @@ private fun PaginationRow( painter = painterResource(PrezelIcons.ChevronLeft), contentDescription = stringResource(R.string.core_ui_impl_practice_card_prev_page), tint = chevronIconTintColor(enabled = hasPreviousPage), - modifier = Modifier.noRippleClickable(onClickLeft), + modifier = Modifier.noRippleClickable( + enabled = hasPreviousPage, + onClick = onClickLeft, + ), ) } @@ -190,7 +193,10 @@ private fun PaginationRow( painter = painterResource(PrezelIcons.ChevronRight), contentDescription = stringResource(R.string.core_ui_impl_practice_card_next_page), tint = chevronIconTintColor(enabled = hasNextPage), - modifier = Modifier.noRippleClickable(onClickRight), + modifier = Modifier.noRippleClickable( + enabled = hasNextPage, + onClick = onClickRight, + ), ) } } diff --git a/Prezel/core/ui/src/main/java/com/team/prezel/core/ui/util/NoRippleClickable.kt b/Prezel/core/ui/src/main/java/com/team/prezel/core/ui/util/NoRippleClickable.kt index 30716e9b..3e4a1ba0 100644 --- a/Prezel/core/ui/src/main/java/com/team/prezel/core/ui/util/NoRippleClickable.kt +++ b/Prezel/core/ui/src/main/java/com/team/prezel/core/ui/util/NoRippleClickable.kt @@ -6,9 +6,13 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.composed -fun Modifier.noRippleClickable(onClick: () -> Unit): Modifier = +fun Modifier.noRippleClickable( + enabled: Boolean = true, + onClick: () -> Unit, +): Modifier = composed { clickable( + enabled = enabled, indication = null, interactionSource = remember { MutableInteractionSource() }, onClick = onClick, diff --git a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AnalysisFailureHandler.kt b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AnalysisFailureHandler.kt index 6dcdeb54..598c172c 100644 --- a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AnalysisFailureHandler.kt +++ b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AnalysisFailureHandler.kt @@ -6,10 +6,13 @@ import com.team.prezel.feature.analysis.impl.contract.AnalysisUploadType import com.team.prezel.feature.analysis.impl.model.AnalysisUiMessage import kotlinx.coroutines.CancellationException import kotlinx.coroutines.TimeoutCancellationException +import java.net.SocketTimeoutException internal sealed interface AnalysisFailureAction { data object RetryAnalysis : AnalysisFailureAction + data object NavigateHome : AnalysisFailureAction + data class RetryFileUpload( val uploadType: AnalysisUploadType, ) : AnalysisFailureAction @@ -20,8 +23,8 @@ internal sealed interface AnalysisFailureAction { } internal fun Throwable.toAnalysisFailureAction(): AnalysisFailureAction { - if (this is TimeoutCancellationException) { - return AnalysisFailureAction.RetryAnalysis + if (isTimeoutException()) { + return AnalysisFailureAction.NavigateHome } if (this is CancellationException) { @@ -51,3 +54,9 @@ internal fun Throwable.toAnalysisFailureAction(): AnalysisFailureAction { -> AnalysisFailureAction.ShowMessage(message = AnalysisUiMessage.UNKNOWN_FAILED) } } + +private fun Throwable.isTimeoutException(): Boolean = + this is TimeoutCancellationException || + this is SocketTimeoutException || + generateSequence(this) { throwable -> throwable.cause } + .any { throwable -> throwable::class.java.simpleName == "HttpRequestTimeoutException" } diff --git a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AnalysisFlowViewModel.kt b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AnalysisFlowViewModel.kt index 23e88f4e..b8d2f356 100644 --- a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AnalysisFlowViewModel.kt +++ b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AnalysisFlowViewModel.kt @@ -319,6 +319,13 @@ internal class AnalysisFlowViewModel @AssistedInject constructor( private fun handleAnalysisFailure(action: AnalysisFailureAction) { when (action) { + AnalysisFailureAction.NavigateHome -> { + audioController.reset() + viewModelScope.launch { + sendEffect(AnalysisFlowUiEffect.NavigateHome) + } + } + AnalysisFailureAction.RetryAnalysis -> { updateState { copy(step = AnalysisFlowStep.ANALYSIS_FAILED) } } diff --git a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AnalysisScreen.kt b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AnalysisScreen.kt index 5c7fa220..7c4cb2bf 100644 --- a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AnalysisScreen.kt +++ b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AnalysisScreen.kt @@ -49,6 +49,7 @@ private const val LOW_AVERAGE_VOLUME_THRESHOLD = 0.25f @Composable internal fun AnalysisScreen( onBack: () -> Unit, + navigateToHome: () -> Unit, navigateToStep: (step: AnalysisFlowStep, clearStack: Boolean) -> Unit, navigateToReport: (presentationId: Long) -> Unit, viewModel: AnalysisFlowViewModel, @@ -67,6 +68,7 @@ internal fun AnalysisScreen( viewModel.uiEffect.collect { effect -> when (effect) { AnalysisFlowUiEffect.NavigateBack -> onBack() + AnalysisFlowUiEffect.NavigateHome -> navigateToHome() is AnalysisFlowUiEffect.NavigateToStep -> navigateToStep(effect.step, effect.clearStack) is AnalysisFlowUiEffect.NavigateToReport -> { navigateToReport(effect.presentationId) diff --git a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/contract/AnalysisFlowUiEffect.kt b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/contract/AnalysisFlowUiEffect.kt index 2f8a818d..f3774612 100644 --- a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/contract/AnalysisFlowUiEffect.kt +++ b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/contract/AnalysisFlowUiEffect.kt @@ -6,6 +6,8 @@ import com.team.prezel.feature.analysis.impl.model.AnalysisUiMessage internal sealed interface AnalysisFlowUiEffect : UiEffect { data object NavigateBack : AnalysisFlowUiEffect + data object NavigateHome : AnalysisFlowUiEffect + data class NavigateToStep( val step: AnalysisFlowStep, val clearStack: Boolean = false, diff --git a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/navigation/AnalysisEntryBuilder.kt b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/navigation/AnalysisEntryBuilder.kt index caf334e4..933a6b02 100644 --- a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/navigation/AnalysisEntryBuilder.kt +++ b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/navigation/AnalysisEntryBuilder.kt @@ -15,6 +15,8 @@ import com.team.prezel.feature.analysis.impl.AnalysisFlowViewModel import com.team.prezel.feature.analysis.impl.AnalysisScreen import com.team.prezel.feature.analysis.impl.contract.AnalysisFlowStep import com.team.prezel.feature.analysis.impl.contract.AnalysisFlowUiIntent +import com.team.prezel.feature.home.api.HomeNavKey +import com.team.prezel.feature.report.api.ReportEntrySource import com.team.prezel.feature.report.api.ReportNavKey import dagger.Module import dagger.Provides @@ -89,10 +91,14 @@ private fun AnalysisRoute( navigator.navigate( key = ReportNavKey( presentationId = presentationId, + entrySource = ReportEntrySource.ANALYSIS, ), clearStack = true, ) }, + navigateToHome = { + navigator.replaceRoot(HomeNavKey) + }, viewModel = viewModel, ) } diff --git a/Prezel/feature/history/impl/src/main/java/com/team/prezel/feature/history/impl/navigation/HistoryEntryBuilder.kt b/Prezel/feature/history/impl/src/main/java/com/team/prezel/feature/history/impl/navigation/HistoryEntryBuilder.kt index ee2532e2..815c8729 100644 --- a/Prezel/feature/history/impl/src/main/java/com/team/prezel/feature/history/impl/navigation/HistoryEntryBuilder.kt +++ b/Prezel/feature/history/impl/src/main/java/com/team/prezel/feature/history/impl/navigation/HistoryEntryBuilder.kt @@ -6,6 +6,7 @@ import com.team.prezel.core.navigation.LocalNavigator import com.team.prezel.feature.analysis.api.AnalysisNavKey import com.team.prezel.feature.history.api.HistoryNavKey import com.team.prezel.feature.history.impl.HistoryScreen +import com.team.prezel.feature.report.api.ReportEntrySource import com.team.prezel.feature.report.api.ReportNavKey import dagger.Module import dagger.Provides @@ -23,6 +24,7 @@ internal fun EntryProviderScope.featureHistoryEntryBuilder() { ReportNavKey( presentationId = presentationId, isPast = isPast, + entrySource = ReportEntrySource.HISTORY, ), ) }, diff --git a/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/LoginScreen.kt b/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/LoginScreen.kt index b54077b7..0159c086 100644 --- a/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/LoginScreen.kt +++ b/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/LoginScreen.kt @@ -1,5 +1,6 @@ package com.team.prezel.feature.login.impl +import android.os.SystemClock import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibilityScope import androidx.compose.animation.ExitTransition @@ -41,6 +42,7 @@ import com.team.prezel.core.designsystem.icon.PrezelIcons import com.team.prezel.core.designsystem.preview.BasicPreview import com.team.prezel.core.designsystem.theme.PrezelTheme import com.team.prezel.core.ui.state.LocalSnackbarHostState +import com.team.prezel.core.ui.util.noRippleClickable import com.team.prezel.feature.login.api.AUTH_LOGO_SHARED_ELEMENT_KEY import com.team.prezel.feature.login.impl.contract.LoginUiEffect import com.team.prezel.feature.login.impl.contract.LoginUiIntent @@ -50,6 +52,7 @@ import com.team.prezel.core.designsystem.R as DSR private const val AUTH_SHARED_ELEMENT_TRANSITION_DURATION = 300 private const val AUTH_SHARED_ELEMENT_TRANSITION_DELAY = 400 +private const val AUTH_ADMIN_DOUBLE_TAP_TIMEOUT_MILLIS = 500L @Composable internal fun SharedTransitionScope.LoginScreen( @@ -93,7 +96,9 @@ internal fun SharedTransitionScope.LoginScreen( LoginScreen( uiState = uiState, animatedVisibilityScope = LocalNavAnimatedContentScope.current, - onLogin = { viewModel.onIntent(LoginUiIntent.OnClickLogin) }, + onLogin = { + viewModel.onIntent(if (it) LoginUiIntent.OnClickLoginAdmin else LoginUiIntent.OnClickLogin) + }, modifier = modifier, ) } @@ -102,9 +107,11 @@ internal fun SharedTransitionScope.LoginScreen( private fun SharedTransitionScope.LoginScreen( uiState: LoginUiState, animatedVisibilityScope: AnimatedVisibilityScope, - onLogin: () -> Unit, + onLogin: (isAdmin: Boolean) -> Unit, modifier: Modifier = Modifier, ) { + var lastLogoTapAtMillis by remember { mutableStateOf(0L) } + Column( modifier = modifier .fillMaxSize() @@ -113,12 +120,24 @@ private fun SharedTransitionScope.LoginScreen( ) { LogoImage( animatedVisibilityScope = animatedVisibilityScope, - modifier = Modifier.weight(1f), + modifier = Modifier + .weight(1f) + .noRippleClickable { + if (uiState.isLoading) return@noRippleClickable + + val tapMillis = SystemClock.elapsedRealtime() + if (tapMillis - lastLogoTapAtMillis <= AUTH_ADMIN_DOUBLE_TAP_TIMEOUT_MILLIS) { + onLogin(true) + lastLogoTapAtMillis = 0L + } else { + lastLogoTapAtMillis = tapMillis + } + }, ) LoginFooter( enabled = !uiState.isLoading, - onLogin = onLogin, + onLogin = { onLogin(false) }, ) } } diff --git a/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/LoginViewModel.kt b/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/LoginViewModel.kt index 13035f0f..ecde2b6d 100644 --- a/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/LoginViewModel.kt +++ b/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/LoginViewModel.kt @@ -21,6 +21,7 @@ internal class LoginViewModel @Inject constructor( override fun onIntent(intent: LoginUiIntent) { when (intent) { LoginUiIntent.OnClickLogin -> handleClickLogin() + LoginUiIntent.OnClickLoginAdmin -> handleClickLoginAdmin() is LoginUiIntent.OnLoginResult -> handleLoginResult(result = intent.result) } } @@ -34,6 +35,19 @@ internal class LoginViewModel @Inject constructor( } } + private fun handleClickLoginAdmin() { + if (currentState.isLoading) return + + viewModelScope.launch { + updateState { copy(isLoading = true) } + loginUseCase + .loginAdmin() + .onSuccess { user -> routeUser(user) } + .onFailure { exception -> Timber.e(exception) } + updateState { copy(isLoading = false) } + } + } + private fun handleLoginResult(result: AuthResult) { viewModelScope .launch { diff --git a/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/contract/LoginUiIntent.kt b/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/contract/LoginUiIntent.kt index 673238ff..97ead1c4 100644 --- a/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/contract/LoginUiIntent.kt +++ b/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/contract/LoginUiIntent.kt @@ -9,4 +9,6 @@ internal sealed interface LoginUiIntent : UiIntent { data class OnLoginResult( val result: AuthResult, ) : LoginUiIntent + + data object OnClickLoginAdmin : LoginUiIntent // 임시 로직임 } diff --git a/Prezel/feature/report/api/src/main/java/com/team/prezel/feature/report/api/ReportNavKey.kt b/Prezel/feature/report/api/src/main/java/com/team/prezel/feature/report/api/ReportNavKey.kt index 5cb4de82..e1de926d 100644 --- a/Prezel/feature/report/api/src/main/java/com/team/prezel/feature/report/api/ReportNavKey.kt +++ b/Prezel/feature/report/api/src/main/java/com/team/prezel/feature/report/api/ReportNavKey.kt @@ -7,4 +7,11 @@ import kotlinx.serialization.Serializable data class ReportNavKey( val presentationId: Long, val isPast: Boolean = false, + val entrySource: ReportEntrySource, ) : NavKey + +@Serializable +enum class ReportEntrySource { + ANALYSIS, + HISTORY, +} 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 d7b1f5f4..73d8bd14 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 @@ -32,6 +32,7 @@ private fun EntryProviderScope.reportEntry() { val navigator = LocalNavigator.current AnalysisReportScreen( + entrySource = key.entrySource, onBack = { navigator.goBack() }, navigateToAnalysisScript = { presentationId, isPast -> navigator.navigateToAnalysisScript( diff --git a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/refresh/ReportRefreshNotifier.kt b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/refresh/ReportRefreshNotifier.kt new file mode 100644 index 00000000..2053d512 --- /dev/null +++ b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/refresh/ReportRefreshNotifier.kt @@ -0,0 +1,20 @@ +package com.team.prezel.feature.report.impl.refresh + +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +internal class ReportRefreshNotifier @Inject constructor() { + private val _events = MutableSharedFlow( + extraBufferCapacity = 1, + ) + + val events: SharedFlow = _events.asSharedFlow() + + fun requestRefresh(presentationId: Long) { + _events.tryEmit(presentationId) + } +} 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 d3a173ae..f1d6b897 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 @@ -14,6 +14,7 @@ import com.team.prezel.core.designsystem.icon.PrezelIcons import com.team.prezel.core.designsystem.preview.BasicPreview import com.team.prezel.core.designsystem.theme.PrezelTheme import com.team.prezel.core.ui.state.LocalSnackbarHostState +import com.team.prezel.feature.report.api.ReportEntrySource 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 @@ -28,6 +29,7 @@ import com.team.prezel.feature.report.impl.report.preview.ReportPreviewUpcomingU @Composable internal fun AnalysisReportScreen( + entrySource: ReportEntrySource, onBack: () -> Unit, navigateToAnalysisScript: (presentationId: Long, isPast: Boolean) -> Unit, navigateToAnalysisRecording: (presentationId: Long, isPast: Boolean) -> Unit, @@ -87,6 +89,7 @@ internal fun AnalysisReportScreen( AnalysisReportScreen( uiState = uiState, + entrySource = entrySource, onBackClick = onBack, onDeleteClick = { viewModel.onIntent(AnalysisReportUiIntent.ClickDelete) }, onImprovementCardIndexChange = { index -> viewModel.onIntent(AnalysisReportUiIntent.ClickGrowthGraphItem(index)) }, @@ -105,6 +108,7 @@ internal fun AnalysisReportScreen( @Composable internal fun AnalysisReportScreen( uiState: AnalysisReportUiState, + entrySource: ReportEntrySource, onBackClick: () -> Unit, onDeleteClick: () -> Unit, onImprovementCardIndexChange: (index: Int) -> Unit, @@ -122,6 +126,7 @@ internal fun AnalysisReportScreen( is AnalysisReportUiState.Content -> { AnalysisReportScreenContent( uiState = uiState, + entrySource = entrySource, onBackClick = onBackClick, onDeleteClick = onDeleteClick, modifier = modifier, @@ -144,6 +149,7 @@ internal fun AnalysisReportScreen( @Composable private fun AnalysisReportScreenContent( uiState: AnalysisReportUiState.Content, + entrySource: ReportEntrySource, onBackClick: () -> Unit, onDeleteClick: () -> Unit, onImprovementCardIndexChange: (index: Int) -> Unit, @@ -167,12 +173,17 @@ private fun AnalysisReportScreenContent( ReportScreenLayout( appBarTitle = uiState.presentationInfo.title, + initiallyVisibleTopAppBar = entrySource != ReportEntrySource.ANALYSIS, + topAppBarVisibleAtTop = entrySource != ReportEntrySource.ANALYSIS, + reserveTopAppBarSpace = entrySource != ReportEntrySource.ANALYSIS, topAppBarContent = { - LeadingIcon( - iconResId = PrezelIcons.ArrowLeft, - contentDescription = stringResource(R.string.feature_report_impl_back), - onClick = onBackClick, - ) + if (entrySource == ReportEntrySource.HISTORY) { + LeadingIcon( + iconResId = PrezelIcons.ArrowLeft, + contentDescription = stringResource(R.string.feature_report_impl_back), + onClick = onBackClick, + ) + } }, headerContent = { titleModifier -> ReportHeaderContent( @@ -225,6 +236,7 @@ private fun UpcomingAnalysisReportScreenPreview() { PrezelTheme { AnalysisReportScreen( uiState = ReportPreviewUpcomingUiState, + entrySource = ReportEntrySource.HISTORY, onBackClick = { }, onDeleteClick = { }, onImprovementCardIndexChange = {}, @@ -246,6 +258,7 @@ private fun PastAnalysisReportScreenPreview() { PrezelTheme { AnalysisReportScreen( uiState = ReportPreviewPastUiState, + entrySource = ReportEntrySource.HISTORY, onBackClick = { }, onDeleteClick = { }, onImprovementCardIndexChange = {}, @@ -267,6 +280,7 @@ private fun AnalysisReportScreenLoadingPreview() { PrezelTheme { AnalysisReportScreen( uiState = AnalysisReportUiState.Loading, + entrySource = ReportEntrySource.HISTORY, onBackClick = { }, onDeleteClick = { }, onImprovementCardIndexChange = {}, 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 f62cdf2e..31a5120f 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 @@ -5,6 +5,7 @@ import com.team.prezel.core.domain.usecase.presentation.DeletePresentationAnalys 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.refresh.ReportRefreshNotifier 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 @@ -22,6 +23,7 @@ internal class AnalysisReportViewModel @AssistedInject constructor( @Assisted navKey: ReportNavKey, private val fetchPresentationDetailUseCase: FetchPresentationDetailUseCase, private val deletePresentationAnalysisUseCase: DeletePresentationAnalysisUseCase, + private val reportRefreshNotifier: ReportRefreshNotifier, ) : BaseViewModel(AnalysisReportUiState.Loading) { @AssistedFactory interface Factory { @@ -33,6 +35,16 @@ internal class AnalysisReportViewModel @AssistedInject constructor( private var analysisResultId: Long? = null private val isPast: Boolean = navKey.isPast + init { + viewModelScope.launch { + reportRefreshNotifier.events.collect { refreshedPresentationId -> + if (refreshedPresentationId == requestedPresentationId) { + fetchData() + } + } + } + } + override fun onIntent(intent: AnalysisReportUiIntent) { when (intent) { AnalysisReportUiIntent.FetchData -> fetchData() @@ -133,7 +145,7 @@ internal class AnalysisReportViewModel @AssistedInject constructor( private fun navigateToSpeechAccuracy() { if (contentState?.accuracyScore == null) { viewModelScope.launch { - sendEffect(AnalysisReportUiEffect.ShowMessage(AnalysisReportUiMessage.SCRIPT_MATCH_ANALYSIS_UNAVAILABLE)) + sendEffect(AnalysisReportUiEffect.ShowMessage(AnalysisReportUiMessage.SPEECH_ANALYSIS_UNAVAILABLE)) } return } @@ -144,7 +156,7 @@ internal class AnalysisReportViewModel @AssistedInject constructor( private fun navigateToScriptMatch() { if (contentState?.scriptMatchRate == null) { viewModelScope.launch { - sendEffect(AnalysisReportUiEffect.ShowMessage(AnalysisReportUiMessage.SPEECH_ANALYSIS_UNAVAILABLE)) + sendEffect(AnalysisReportUiEffect.ShowMessage(AnalysisReportUiMessage.SCRIPT_MATCH_ANALYSIS_UNAVAILABLE)) } return } 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 912b2438..bb0bb4ce 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 @@ -1,8 +1,14 @@ package com.team.prezel.feature.report.impl.report.component +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.ScrollState 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.ColumnScope import androidx.compose.foundation.layout.fillMaxSize @@ -12,15 +18,21 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.positionInRoot +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex import com.team.prezel.core.designsystem.component.PrezelTopAppBar import com.team.prezel.core.designsystem.component.PrezelTopAppBarScope import com.team.prezel.core.designsystem.preview.BasicPreview @@ -35,32 +47,60 @@ private data class ReportTopBarState( get() = headerTitleBottom <= appBarHeight } +private const val TOP_APPBAR_VISIBILITY_SCROLL_THRESHOLD = 48 +private val DefaultTopAppBarHeight = 56.dp + @OptIn(ExperimentalMaterial3Api::class) @Composable internal fun ReportScreenLayout( appBarTitle: String, modifier: Modifier = Modifier, + initiallyVisibleTopAppBar: Boolean = true, + topAppBarVisibleAtTop: Boolean = true, + reserveTopAppBarSpace: Boolean = true, topAppBarContent: @Composable PrezelTopAppBarScope.() -> Unit = {}, headerContent: @Composable ColumnScope.(Modifier) -> Unit, bodyContent: @Composable ColumnScope.() -> Unit, ) { val scrollState = rememberScrollState() val (topBarState, updateAppBarHeight, updateHeaderTitleBottom) = rememberReportTopBarState() + val isTopAppBarVisible = rememberTopAppBarVisibility( + scrollState = scrollState, + initiallyVisible = initiallyVisibleTopAppBar, + visibleAtTop = topAppBarVisibleAtTop, + ) + val density = LocalDensity.current + val contentTopPadding = with(density) { + when { + !reserveTopAppBarSpace -> 0.dp + else -> topBarState.appBarHeight.takeIf { it > 0f }?.toDp() ?: DefaultTopAppBarHeight + } + } - Column(modifier = modifier.fillMaxSize()) { - ReportDetailTopAppBar( - appBarTitle = appBarTitle, - topBarState = topBarState, - onAppBarMeasured = updateAppBarHeight, - content = topAppBarContent, - ) - + Box(modifier = modifier.fillMaxSize()) { ReportDetailScrollContent( scrollState = scrollState, + topPadding = contentTopPadding, onHeaderMeasured = updateHeaderTitleBottom, headerContent = headerContent, bodyContent = bodyContent, ) + + AnimatedVisibility( + visible = isTopAppBarVisible, + enter = slideInVertically(initialOffsetY = { -it / 2 }) + fadeIn(), + exit = slideOutVertically(targetOffsetY = { -it / 2 }) + fadeOut(), + modifier = Modifier + .align(Alignment.TopCenter) + .zIndex(1f), + ) { + ReportDetailTopAppBar( + appBarTitle = appBarTitle, + topBarState = topBarState, + onAppBarMeasured = updateAppBarHeight, + content = topAppBarContent, + ) + } } } @@ -84,6 +124,51 @@ private fun rememberReportTopBarState(): Triple Un ) } +@Composable +private fun rememberTopAppBarVisibility( + scrollState: ScrollState, + initiallyVisible: Boolean, + visibleAtTop: Boolean, +): Boolean { + var isVisible by remember(initiallyVisible) { mutableStateOf(initiallyVisible) } + var previousScrollOffset by remember { mutableIntStateOf(0) } + var accumulatedScrollDelta by remember { mutableIntStateOf(0) } + + LaunchedEffect(scrollState.value) { + val currentScrollOffset = scrollState.value + val delta = currentScrollOffset - previousScrollOffset + + when { + currentScrollOffset <= 0 -> { + isVisible = visibleAtTop + accumulatedScrollDelta = 0 + } + delta == 0 -> Unit + accumulatedScrollDelta == 0 || (accumulatedScrollDelta > 0) == (delta > 0) -> { + accumulatedScrollDelta += delta + } + else -> { + accumulatedScrollDelta = delta + } + } + + when { + accumulatedScrollDelta >= TOP_APPBAR_VISIBILITY_SCROLL_THRESHOLD -> { + isVisible = false + accumulatedScrollDelta = 0 + } + accumulatedScrollDelta <= -TOP_APPBAR_VISIBILITY_SCROLL_THRESHOLD -> { + isVisible = true + accumulatedScrollDelta = 0 + } + } + + previousScrollOffset = currentScrollOffset + } + + return isVisible +} + @OptIn(ExperimentalMaterial3Api::class) @Composable private fun ReportDetailTopAppBar( @@ -106,12 +191,18 @@ private fun ReportDetailTopAppBar( @Composable private fun ReportDetailScrollContent( scrollState: ScrollState, + topPadding: androidx.compose.ui.unit.Dp, onHeaderMeasured: (Float) -> Unit, headerContent: @Composable ColumnScope.(Modifier) -> Unit, bodyContent: @Composable ColumnScope.() -> Unit, ) { - Column(modifier = Modifier.verticalScroll(scrollState)) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(scrollState), + ) { HeaderContentContainer( + topPadding = topPadding, onHeaderMeasured = onHeaderMeasured, headerContent = headerContent, ) @@ -121,13 +212,19 @@ private fun ReportDetailScrollContent( @Composable private fun HeaderContentContainer( + topPadding: androidx.compose.ui.unit.Dp, onHeaderMeasured: (Float) -> Unit, headerContent: @Composable ColumnScope.(Modifier) -> Unit, ) { Column( modifier = Modifier .fillMaxWidth() - .padding(PrezelTheme.spacing.V20), + .padding( + start = PrezelTheme.spacing.V20, + end = PrezelTheme.spacing.V20, + top = topPadding + PrezelTheme.spacing.V20, + bottom = PrezelTheme.spacing.V20, + ), verticalArrangement = Arrangement.spacedBy(PrezelTheme.spacing.V16), ) { headerContent( 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 f8c4a1e6..1f867c6d 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,8 +1,9 @@ package com.team.prezel.feature.report.impl.report.component.common import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement 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 @@ -39,12 +40,14 @@ internal fun MetricResultCard( ) { Column( modifier = Modifier - .align(Alignment.TopStart) + .fillMaxHeight() + .align(Alignment.CenterStart) .padding( start = PrezelTheme.spacing.V12, bottom = PrezelTheme.spacing.V12, top = PrezelTheme.spacing.V14, ), + verticalArrangement = Arrangement.SpaceBetween, ) { Text( text = title, @@ -52,8 +55,6 @@ internal fun MetricResultCard( color = PrezelTheme.colors.textRegular, ) - Spacer(modifier = Modifier.height(PrezelTheme.spacing.V2)) - Text( text = value, style = PrezelTheme.typography.title1Bold, diff --git a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/script/ScriptScreen.kt b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/script/ScriptScreen.kt index 27937eb4..909b522a 100644 --- a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/script/ScriptScreen.kt +++ b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/script/ScriptScreen.kt @@ -67,7 +67,7 @@ internal fun ScriptScreen( ScriptScreen( uiState = uiState, - onClose = onClose, + onClose = { viewModel.onIntent(ScriptUiIntent.ClickClose) }, onClickCopy = { viewModel.onIntent(ScriptUiIntent.ClickCopy) }, onClickCorrection = { correctionId, popupY -> viewModel.onIntent(ScriptUiIntent.ClickCorrection(correctionId = correctionId, popupY = popupY)) @@ -114,6 +114,7 @@ internal fun ScriptScreen( private val ScriptUiMessage.resId: Int get() = when (this) { ScriptUiMessage.FETCH_SCRIPT_DETAIL_FAILED -> R.string.feature_report_impl_fetch_script_detail_failed + ScriptUiMessage.CORRECT_SCRIPT_FAILED -> R.string.feature_report_impl_correct_script_failed } @Composable @@ -138,6 +139,7 @@ private fun ScriptScreenContent( ScriptTextContent( script = uiState.currentScript, corrections = uiState.scriptDetails, + isCorrectionClickable = !uiState.isSubmittingCorrection, onClickCorrection = onClickCorrection, modifier = Modifier .padding(horizontal = PrezelTheme.spacing.V20) @@ -151,6 +153,7 @@ private fun ScriptScreenContent( ) ScriptActionBar( isApplyAllEnabled = uiState.enabledAllCorrectionButton, + isCopyEnabled = !uiState.isSubmittingCorrection, onCopyClick = onClickCopy, onApplyAllClick = onApplyAllCorrections, ) @@ -176,6 +179,7 @@ private fun ScriptCorrectionPopup( ) { ScriptCorrectionPopup( correction = correction, + isApplyEnabled = !uiState.isSubmittingCorrection, onDismiss = onDismissCorrectionPopup, onApplyCorrection = { onApplyCorrection(correction.id) }, ) diff --git a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/script/ScriptViewModel.kt b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/script/ScriptViewModel.kt index f8a22534..614baae0 100644 --- a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/script/ScriptViewModel.kt +++ b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/script/ScriptViewModel.kt @@ -1,10 +1,12 @@ package com.team.prezel.feature.report.impl.script import androidx.lifecycle.viewModelScope +import com.team.prezel.core.domain.usecase.presentation.CorrectPresentationScriptUseCase import com.team.prezel.core.domain.usecase.presentation.FetchPresentationScriptDetailUseCase import com.team.prezel.core.model.presentation.PresentationScriptDetail import com.team.prezel.core.model.presentation.ScriptCorrection import com.team.prezel.core.ui.base.BaseViewModel +import com.team.prezel.feature.report.impl.refresh.ReportRefreshNotifier import com.team.prezel.feature.report.impl.script.contract.ScriptUiEffect import com.team.prezel.feature.report.impl.script.contract.ScriptUiIntent import com.team.prezel.feature.report.impl.script.contract.ScriptUiState @@ -22,6 +24,8 @@ import kotlinx.coroutines.launch internal class ScriptViewModel @AssistedInject constructor( @Assisted private val analysisResultId: Long, private val fetchPresentationScriptDetailUseCase: FetchPresentationScriptDetailUseCase, + private val correctPresentationScriptUseCase: CorrectPresentationScriptUseCase, + private val reportRefreshNotifier: ReportRefreshNotifier, ) : BaseViewModel(ScriptUiState()) { @AssistedFactory interface Factory { @@ -30,6 +34,9 @@ internal class ScriptViewModel @AssistedInject constructor( ): ScriptViewModel } + private var presentationId: Long? = null + private var hasScriptChanged: Boolean = false + init { fetchData(analysisResultId = analysisResultId) } @@ -37,6 +44,7 @@ internal class ScriptViewModel @AssistedInject constructor( override fun onIntent(intent: ScriptUiIntent) { when (intent) { ScriptUiIntent.ApplyAllCorrections -> applyAllCorrections() + ScriptUiIntent.ClickClose -> handleCloseClick() ScriptUiIntent.ClickCopy -> copyCurrentScriptToClipboard() ScriptUiIntent.DismissCorrectionPopup -> dismissCorrectionPopup() @@ -55,6 +63,8 @@ internal class ScriptViewModel @AssistedInject constructor( correctionId: Long, popupY: Int, ) { + if (currentState.isSubmittingCorrection) return + updateState { copy( selectedCorrectionId = correctionId, @@ -74,14 +84,8 @@ internal class ScriptViewModel @AssistedInject constructor( updateState { copy(isLoading = true) } fetchPresentationScriptDetailUseCase(analysisResultId = analysisResultId) .onSuccess { detail -> - updateState { - copy( - isLoading = false, - originalScript = detail.originalScript, - currentScript = detail.originalScript, - scriptDetails = detail.toCorrectionUiModels(), - ) - } + updateDetail(detail = detail) + updateState { copy(isLoading = false) } }.onFailure { updateState { copy(isLoading = false) } sendEffect(ScriptUiEffect.ShowMessage(ScriptUiMessage.FETCH_SCRIPT_DETAIL_FAILED)) @@ -91,45 +95,32 @@ internal class ScriptViewModel @AssistedInject constructor( } private fun applyCorrection(correctionId: Long) { - val updatedCorrections = currentState.scriptDetails - .map { correction -> - if (correction.id != correctionId) return@map correction - if (correction.isApplied) return@map correction - - correction.copy(isApplied = true) - }.toImmutableList() - - val updatedScript = rebuildScript( - originalScript = currentState.originalScript, - corrections = updatedCorrections, + if (currentState.isSubmittingCorrection) return + + val targetCorrection = currentState.scriptDetails.firstOrNull { correction -> + correction.id == correctionId + } ?: return + + submitCorrection( + correctedIndices = listOf(targetCorrection.id.toInt()), + finalScript = rebuildScript( + originalScript = currentState.originalScript, + corrections = listOf(targetCorrection), + ), ) - - updateState { - copy( - currentScript = updatedScript, - scriptDetails = updatedCorrections, - selectedCorrectionId = null, - ) - } } private fun applyAllCorrections() { - val updatedCorrections = currentState.scriptDetails - .map { correction -> - correction.copy(isApplied = true) - }.toImmutableList() - - val updatedScript = rebuildScript( - originalScript = currentState.originalScript, - corrections = updatedCorrections, + if (currentState.isSubmittingCorrection) return + if (currentState.scriptDetails.isEmpty()) return + + submitCorrection( + correctedIndices = currentState.scriptDetails.map { correction -> correction.id.toInt() }, + finalScript = rebuildScript( + originalScript = currentState.originalScript, + corrections = currentState.scriptDetails, + ), ) - - updateState { - copy( - currentScript = updatedScript, - scriptDetails = updatedCorrections, - ) - } } private fun copyCurrentScriptToClipboard() { @@ -142,18 +133,56 @@ internal class ScriptViewModel @AssistedInject constructor( } } + private fun handleCloseClick() { + if (currentState.isSubmittingCorrection) return + + if (hasScriptChanged) { + presentationId?.let(reportRefreshNotifier::requestRefresh) + } + + viewModelScope.launch { + sendEffect(ScriptUiEffect.NavigateToBack) + } + } + + private fun submitCorrection( + correctedIndices: List, + finalScript: String, + ) { + viewModelScope.launch { + updateState { + copy( + isSubmittingCorrection = true, + selectedCorrectionId = null, + currentScript = finalScript, + ) + } + + correctPresentationScriptUseCase( + analysisResultId = analysisResultId, + finalScript = finalScript, + correctedIndices = correctedIndices, + ).onSuccess { detail -> + hasScriptChanged = true + updateDetail(detail = detail) + updateState { copy(isSubmittingCorrection = false) } + }.onFailure { + updateState { copy(isSubmittingCorrection = false, currentScript = originalScript) } + sendEffect(ScriptUiEffect.ShowMessage(ScriptUiMessage.CORRECT_SCRIPT_FAILED)) + } + } + } + private fun rebuildScript( originalScript: String, corrections: List, ): String { - val appliedCorrections = corrections - .filter { correction -> correction.isApplied } - .sortedBy { correction -> correction.originalRange.first } + val sortedCorrections = corrections.sortedBy { correction -> correction.originalRange.first } val builder = StringBuilder(originalScript) var offset = 0 - appliedCorrections.forEach { correction -> + sortedCorrections.forEach { correction -> val startIndex = correction.originalRange.first + offset val endIndex = correction.originalRange.last + 1 + offset @@ -168,6 +197,18 @@ internal class ScriptViewModel @AssistedInject constructor( return builder.toString() } + + private fun updateDetail(detail: PresentationScriptDetail) { + presentationId = detail.presentationId + updateState { + copy( + originalScript = detail.originalScript, + currentScript = detail.originalScript, + scriptDetails = detail.toCorrectionUiModels(), + selectedCorrectionId = null, + ) + } + } } internal fun PresentationScriptDetail.toCorrectionUiModels(): ImmutableList { diff --git a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/script/component/ScriptActionBar.kt b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/script/component/ScriptActionBar.kt index bf220d80..bde407ec 100644 --- a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/script/component/ScriptActionBar.kt +++ b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/script/component/ScriptActionBar.kt @@ -15,6 +15,7 @@ import com.team.prezel.feature.report.impl.R @Composable internal fun ScriptActionBar( isApplyAllEnabled: Boolean, + isCopyEnabled: Boolean, onCopyClick: () -> Unit, onApplyAllClick: () -> Unit, modifier: Modifier = Modifier, @@ -35,6 +36,7 @@ internal fun ScriptActionBar( PrezelButton( text = stringResource(R.string.feature_report_impl_script_copy_all), modifier = modifier, + enabled = isCopyEnabled, type = ButtonType.GHOST, hierarchy = ButtonHierarchy.SECONDARY, onClick = onCopyClick, @@ -49,6 +51,7 @@ private fun ScriptActionBarPreview() { PrezelTheme { ScriptActionBar( isApplyAllEnabled = true, + isCopyEnabled = true, onCopyClick = {}, onApplyAllClick = {}, ) diff --git a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/script/component/ScriptCorrectionPopup.kt b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/script/component/ScriptCorrectionPopup.kt index 807cb6cb..21526819 100644 --- a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/script/component/ScriptCorrectionPopup.kt +++ b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/script/component/ScriptCorrectionPopup.kt @@ -26,6 +26,7 @@ import com.team.prezel.feature.report.impl.script.model.ScriptCorrectionUiModel @Composable internal fun ScriptCorrectionPopup( correction: ScriptCorrectionUiModel, + isApplyEnabled: Boolean, onDismiss: () -> Unit, onApplyCorrection: () -> Unit, modifier: Modifier = Modifier, @@ -42,9 +43,9 @@ internal fun ScriptCorrectionPopup( backgroundColor = PrezelTheme.colors.bgRegular, token = PrezelDropShadowDefaults.PrezelShadowToken( offsetX = 0.dp, - offsetY = 8.dp, + offsetY = 0.dp, blurRadius = popupRadius, - spreadRadius = 0.dp, + spreadRadius = 8.dp, color = PrezelTheme.colors.solidBlack.copy(alpha = 0.12f), ), ), @@ -55,7 +56,11 @@ internal fun ScriptCorrectionPopup( ) { PopupContent(correction = correction) - PopupActions(onDismiss = onDismiss, onApplyCorrection = onApplyCorrection) + PopupActions( + isApplyEnabled = isApplyEnabled, + onDismiss = onDismiss, + onApplyCorrection = onApplyCorrection, + ) } } @@ -85,6 +90,7 @@ private fun PopupContent( @Composable private fun PopupActions( + isApplyEnabled: Boolean, onDismiss: () -> Unit, onApplyCorrection: () -> Unit, modifier: Modifier = Modifier, @@ -104,8 +110,9 @@ private fun PopupActions( PopupActionButton( text = stringResource(R.string.feature_report_impl_script_apply), - textColor = PrezelTheme.colors.textMedium, + textColor = if (isApplyEnabled) PrezelTheme.colors.textMedium else PrezelTheme.colors.textDisabled, onClick = onApplyCorrection, + enabled = isApplyEnabled, ) } } @@ -115,6 +122,7 @@ private fun PopupActionButton( text: String, textColor: Color, onClick: () -> Unit, + enabled: Boolean = true, ) { Text( text = text, @@ -122,7 +130,10 @@ private fun PopupActionButton( color = textColor, modifier = Modifier .padding(vertical = PrezelTheme.spacing.V8, horizontal = PrezelTheme.spacing.V12) - .noRippleClickable(onClick = onClick), + .noRippleClickable( + enabled = enabled, + onClick = onClick, + ), ) } @@ -140,6 +151,7 @@ private fun ScriptCorrectionPopupPreview() { reason = "표준어는 '기다리다'를 활용한 표현이에요.", originalRange = 30 until 34, ), + isApplyEnabled = true, onDismiss = {}, onApplyCorrection = {}, ) diff --git a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/script/component/ScriptTextContent.kt b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/script/component/ScriptTextContent.kt index 509badc5..fc8344c9 100644 --- a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/script/component/ScriptTextContent.kt +++ b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/script/component/ScriptTextContent.kt @@ -36,6 +36,7 @@ private val POPUP_Y_OFFSET = 42.dp internal fun ScriptTextContent( script: String, corrections: ImmutableList, + isCorrectionClickable: Boolean, onClickCorrection: (correctionId: Long, popupY: Int) -> Unit, modifier: Modifier = Modifier, spellHighlightStyle: SpanStyle = SpanStyle( @@ -71,13 +72,19 @@ internal fun ScriptTextContent( .fillMaxWidth() .onGloballyPositioned { coordinates -> textPositionInWindow = coordinates.positionInWindow() - }.correctionTapGesture( - annotatedText = annotatedText, - textLayoutResult = textLayoutResult, - textPositionInWindow = textPositionInWindow, - popupYOffset = popupYOffset, - onClickCorrection = onClickCorrection, - ), + }.run { + if (isCorrectionClickable) { + correctionTapGesture( + annotatedText = annotatedText, + textLayoutResult = textLayoutResult, + textPositionInWindow = textPositionInWindow, + popupYOffset = popupYOffset, + onClickCorrection = onClickCorrection, + ) + } else { + this + } + }, onTextLayout = { result -> textLayoutResult = result }, ) } @@ -178,33 +185,16 @@ private fun findClickedCorrectionId( ?.toLongOrNull() } -private fun calculateHighlightCorrections(corrections: List): List { - val sortedCorrections = corrections.sortedBy { correction -> - correction.originalRange.first - } - - var offset = 0 - val highlightCorrections = mutableListOf() - - sortedCorrections.forEach { correction -> - val currentStartIndex = correction.originalRange.first + offset - val currentEndIndex = correction.originalRange.last + offset - - if (!correction.isApplied) { - highlightCorrections += HighlightCorrectionUiModel( +private fun calculateHighlightCorrections(corrections: List): List = + corrections + .sortedBy { correction -> correction.originalRange.first } + .map { correction -> + HighlightCorrectionUiModel( correctionId = correction.id, - range = currentStartIndex..currentEndIndex, + range = correction.originalRange, ) } - if (correction.isApplied) { - offset += correction.correctedText.length - correction.originalText.length - } - } - - return highlightCorrections -} - @BasicPreview @Composable private fun ScriptTextContentPreview() { @@ -236,6 +226,7 @@ private fun ScriptTextContentPreview() { originalRange = 56 until 60, ), ).toPersistentList(), + isCorrectionClickable = true, onClickCorrection = { _, _ -> }, ) } diff --git a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/script/contract/ScriptUiIntent.kt b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/script/contract/ScriptUiIntent.kt index ea344127..81829601 100644 --- a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/script/contract/ScriptUiIntent.kt +++ b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/script/contract/ScriptUiIntent.kt @@ -3,6 +3,8 @@ package com.team.prezel.feature.report.impl.script.contract import com.team.prezel.core.ui.base.UiIntent internal sealed interface ScriptUiIntent : UiIntent { + data object ClickClose : ScriptUiIntent + data object ClickCopy : ScriptUiIntent data class ClickCorrection( diff --git a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/script/contract/ScriptUiState.kt b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/script/contract/ScriptUiState.kt index 19ee0618..be138600 100644 --- a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/script/contract/ScriptUiState.kt +++ b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/script/contract/ScriptUiState.kt @@ -10,6 +10,7 @@ import kotlinx.collections.immutable.persistentListOf @Immutable internal data class ScriptUiState( val isLoading: Boolean = false, + val isSubmittingCorrection: Boolean = false, val originalScript: String = "", val currentScript: String = "", val selectedCorrectionId: Long? = null, @@ -18,12 +19,12 @@ internal data class ScriptUiState( ) : UiState { val unappliedSpellingErrors: Int = scriptDetails.count { detail -> - detail.errorType == ScriptErrorType.SPELLING && !detail.isApplied + detail.errorType == ScriptErrorType.SPELLING } val unappliedGrammarErrors: Int = scriptDetails.count { detail -> - detail.errorType == ScriptErrorType.GRAMMAR && !detail.isApplied + detail.errorType == ScriptErrorType.GRAMMAR } val selectedCorrection: ScriptCorrectionUiModel? = @@ -32,5 +33,5 @@ internal data class ScriptUiState( } val enabledAllCorrectionButton: Boolean = - unappliedGrammarErrors + unappliedSpellingErrors > 0 + !isSubmittingCorrection && unappliedGrammarErrors + unappliedSpellingErrors > 0 } diff --git a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/script/model/ScriptCorrectionUiModel.kt b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/script/model/ScriptCorrectionUiModel.kt index f47f9bd9..8c15cf59 100644 --- a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/script/model/ScriptCorrectionUiModel.kt +++ b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/script/model/ScriptCorrectionUiModel.kt @@ -10,5 +10,4 @@ data class ScriptCorrectionUiModel( val correctedText: String, val reason: String, val originalRange: IntRange, - val isApplied: Boolean = false, ) diff --git a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/script/model/ScriptUiMessage.kt b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/script/model/ScriptUiMessage.kt index 0590cb00..7fb624a3 100644 --- a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/script/model/ScriptUiMessage.kt +++ b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/script/model/ScriptUiMessage.kt @@ -2,4 +2,5 @@ package com.team.prezel.feature.report.impl.script.model internal enum class ScriptUiMessage { FETCH_SCRIPT_DETAIL_FAILED, + CORRECT_SCRIPT_FAILED, } 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 8cd86ed3..dd850205 100644 --- a/Prezel/feature/report/impl/src/main/res/values/strings.xml +++ b/Prezel/feature/report/impl/src/main/res/values/strings.xml @@ -60,6 +60,7 @@ 대본을 입력하고\n맞춤법과 주술호응을 분석해보세요. 리포트를 불러오지 못했습니다. 대본 분석 결과를 불러오지 못했습니다. + 대본 교정을 반영하지 못했습니다. 리포트를 삭제하지 못했습니다. 입력하기 대본을 입력하지 않아 대본 일치율을 분석할 수 없어요.