diff --git a/.gitignore b/.gitignore index ea47a8a9..3104b4eb 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ specs/ .agent/ .agents/ .codex/ +.claude/ # Local configuration and secrets local.properties @@ -143,8 +144,9 @@ ehthumbs.db hs_err_pid* replay_pid* +# API Docs +api-docs.json + # Keep the Gradle wrapper JAR even when generic JAR files are ignored. !/gradle/wrapper/gradle-wrapper.jar -# API Docs -api-docs.json diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..43c994c2 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 6dc54ca8..2e013368 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -51,6 +51,7 @@ dependencies { implementation(project(":feature:main:impl")) implementation(project(":feature:mypage:impl")) implementation(project(":feature:onboarding:impl")) + implementation(project(":feature:interview:impl")) implementation(libs.androidx.activity.compose) implementation(libs.androidx.core.ktx) implementation(libs.androidx.lifecycle.runtime.ktx) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 6fed6641..32bd6e66 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,14 +2,14 @@ - - - - + + + + - D-14 - \ No newline at end of file + Hilit + diff --git a/build.gradle.kts b/build.gradle.kts index 56964e88..87e98a9f 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -5,6 +5,7 @@ plugins { alias(libs.plugins.kotlin.android) apply false alias(libs.plugins.kotlin.compose) apply false alias(libs.plugins.kotlin.jvm) apply false + alias(libs.plugins.kotlin.serialization) apply false alias(libs.plugins.kotlin.ksp) apply false alias(libs.plugins.dagger.hilt.android) apply false diff --git a/core/resources/src/commonMain/composeResources/drawable/alert_red.xml b/core/resources/src/commonMain/composeResources/drawable/alert_red.xml new file mode 100644 index 00000000..326dffe6 --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/alert_red.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/finish.xml b/core/resources/src/commonMain/composeResources/drawable/finish.xml new file mode 100644 index 00000000..f0c94cb7 --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/finish.xml @@ -0,0 +1,29 @@ + + + + + + + + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/mic.xml b/core/resources/src/commonMain/composeResources/drawable/mic.xml new file mode 100644 index 00000000..6441b04a --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/mic.xml @@ -0,0 +1,19 @@ + + + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/network.xml b/core/resources/src/commonMain/composeResources/drawable/network.xml new file mode 100644 index 00000000..941db5c0 --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/network.xml @@ -0,0 +1,13 @@ + + + + + + diff --git a/data/src/test/kotlin/com/dminus14/app/data/remote/datasource/InterviewRemoteDataSourceTest.kt b/data/src/test/kotlin/com/dminus14/app/data/remote/datasource/InterviewRemoteDataSourceTest.kt index 05f903ba..1e27f3d2 100644 --- a/data/src/test/kotlin/com/dminus14/app/data/remote/datasource/InterviewRemoteDataSourceTest.kt +++ b/data/src/test/kotlin/com/dminus14/app/data/remote/datasource/InterviewRemoteDataSourceTest.kt @@ -22,6 +22,7 @@ import com.dminus14.app.domain.exception.ServerException import kotlinx.coroutines.runBlocking import okhttp3.MultipartBody import okhttp3.ResponseBody +import okhttp3.ResponseBody.Companion.toResponseBody import org.junit.Assert.assertEquals import org.junit.Assert.assertNotNull import org.junit.Assert.assertSame @@ -614,7 +615,7 @@ class InterviewRemoteDataSourceTest { ): ResponseBody { requestedStreamSessionId = sessionId requestedStreamQuestionId = questionId - return ResponseBody.create(null, "") + return "".toResponseBody(null) } override suspend fun getResume( diff --git a/designsystem/src/commonMain/kotlin/com/dminus14/designsystem/component/button/HilitFixedBottomButton.kt b/designsystem/src/commonMain/kotlin/com/dminus14/designsystem/component/button/HilitFixedBottomButton.kt index 6d585534..435c3c87 100644 --- a/designsystem/src/commonMain/kotlin/com/dminus14/designsystem/component/button/HilitFixedBottomButton.kt +++ b/designsystem/src/commonMain/kotlin/com/dminus14/designsystem/component/button/HilitFixedBottomButton.kt @@ -78,8 +78,8 @@ internal fun hilitFixedBottomButtonColors( enabled: Boolean, colors: HilitColors, ): BtnColorSet = - when { - type == HilitButtonType.Light && enabled -> { + when (type) { + HilitButtonType.Light if enabled -> { BtnColorSet( contentColor = colors.hilitWhite, backgroundColor = colors.hilitBlack800, @@ -87,7 +87,7 @@ internal fun hilitFixedBottomButtonColors( ) } - type == HilitButtonType.Dark && enabled -> { + HilitButtonType.Dark if enabled -> { BtnColorSet( contentColor = colors.hilitBlack800, backgroundColor = colors.hilitWhite, @@ -95,7 +95,7 @@ internal fun hilitFixedBottomButtonColors( ) } - type == HilitButtonType.Light -> { + HilitButtonType.Light -> { BtnColorSet( contentColor = colors.gray300, backgroundColor = colors.gray50, diff --git a/designsystem/src/commonMain/kotlin/com/dminus14/designsystem/component/button/HilitMiniButton.kt b/designsystem/src/commonMain/kotlin/com/dminus14/designsystem/component/button/HilitMiniButton.kt index b40c4314..94a74c8d 100644 --- a/designsystem/src/commonMain/kotlin/com/dminus14/designsystem/component/button/HilitMiniButton.kt +++ b/designsystem/src/commonMain/kotlin/com/dminus14/designsystem/component/button/HilitMiniButton.kt @@ -41,10 +41,11 @@ enum class HilitMiniButtonColor { fun HilitMiniButton( onClick: () -> Unit, modifier: Modifier = Modifier, + enabled: Boolean = true, color: HilitMiniButtonColor = HilitMiniButtonColor.Light, content: @Composable () -> Unit, ) { - val style = hilitMiniButtonStyle(color = color, colors = HilitTheme.colors) + val style = hilitMiniButtonStyle(color = color, colors = HilitTheme.colors, enabled = enabled) val interactionSource = remember { MutableInteractionSource() } CompositionLocalProvider(LocalContentColor provides style.contentColor) { @@ -54,6 +55,7 @@ fun HilitMiniButton( modifier .background(style.backgroundColor) .clickable( + enabled = enabled, interactionSource = interactionSource, indication = null, role = Role.Button, @@ -78,9 +80,10 @@ internal data class HilitMiniButtonStyle( internal fun hilitMiniButtonStyle( color: HilitMiniButtonColor, colors: HilitColors, + enabled: Boolean, ): HilitMiniButtonStyle = when (color) { - HilitMiniButtonColor.Light -> { + HilitMiniButtonColor.Light if enabled -> { HilitMiniButtonStyle( backgroundColor = colors.gray100, contentColor = colors.hilitBlack800, @@ -89,7 +92,7 @@ internal fun hilitMiniButtonStyle( ) } - HilitMiniButtonColor.Dark -> { + HilitMiniButtonColor.Dark if enabled -> { HilitMiniButtonStyle( backgroundColor = colors.gray900, contentColor = colors.hilitWhite, @@ -106,6 +109,15 @@ internal fun hilitMiniButtonStyle( contentSpacing = 8.dp, ) } + + else -> { + HilitMiniButtonStyle( + backgroundColor = colors.hilitWhite, + contentColor = colors.gray300, + contentPadding = 8.dp, + contentSpacing = 8.dp, + ) + } } @Composable diff --git a/designsystem/src/commonMain/kotlin/com/dminus14/designsystem/component/icon/HilitIcon.kt b/designsystem/src/commonMain/kotlin/com/dminus14/designsystem/component/icon/HilitIcon.kt index 2d423b15..003f2cfa 100644 --- a/designsystem/src/commonMain/kotlin/com/dminus14/designsystem/component/icon/HilitIcon.kt +++ b/designsystem/src/commonMain/kotlin/com/dminus14/designsystem/component/icon/HilitIcon.kt @@ -9,6 +9,7 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.dminus14.app.core.resources.Res import com.dminus14.app.core.resources.ai_sparkle +import com.dminus14.app.core.resources.alert_red import com.dminus14.app.core.resources.apple_logo import com.dminus14.app.core.resources.book import com.dminus14.app.core.resources.cancel @@ -21,10 +22,13 @@ import com.dminus14.app.core.resources.edit import com.dminus14.app.core.resources.expand import com.dminus14.app.core.resources.file import com.dminus14.app.core.resources.fill_warning +import com.dminus14.app.core.resources.finish import com.dminus14.app.core.resources.hiiii_logo import com.dminus14.app.core.resources.info import com.dminus14.app.core.resources.kakao_logo import com.dminus14.app.core.resources.left +import com.dminus14.app.core.resources.mic +import com.dminus14.app.core.resources.network import com.dminus14.app.core.resources.pause import com.dminus14.app.core.resources.play import com.dminus14.app.core.resources.plus @@ -92,6 +96,10 @@ enum class HilitIconAsset( Undo("undo", 16.dp, Res.drawable.undo, preservesOriginalColors = true), Voice("voice", 28.dp, Res.drawable.voice, preservesOriginalColors = true), Book("book", 63.dp, Res.drawable.book, preservesOriginalColors = true), + Mic("mic", 24.dp, Res.drawable.mic), + Network("network", 24.dp, Res.drawable.network), + AlertRed("alert", 14.dp, Res.drawable.alert_red, preservesOriginalColors = true), + Finish("finish", 74.dp, Res.drawable.finish, preservesOriginalColors = true), } /** diff --git a/designsystem/src/commonMain/kotlin/com/dminus14/designsystem/component/modal/HilitModal.kt b/designsystem/src/commonMain/kotlin/com/dminus14/designsystem/component/modal/HilitModal.kt index 6b592a38..4ae99270 100644 --- a/designsystem/src/commonMain/kotlin/com/dminus14/designsystem/component/modal/HilitModal.kt +++ b/designsystem/src/commonMain/kotlin/com/dminus14/designsystem/component/modal/HilitModal.kt @@ -329,7 +329,7 @@ private fun HilitModalDualButtonPreview() { } @Composable -private fun HilitModalPreviewHost(content: @Composable () -> Unit) { +fun HilitModalPreviewHost(content: @Composable () -> Unit) { HilitTheme { Box(modifier = Modifier.fillMaxSize()) { content() diff --git a/designsystem/src/commonMain/kotlin/com/dminus14/designsystem/component/text/HilitTextHighlight.kt b/designsystem/src/commonMain/kotlin/com/dminus14/designsystem/component/text/HilitTextHighlight.kt index fed67a19..5c4c2d91 100644 --- a/designsystem/src/commonMain/kotlin/com/dminus14/designsystem/component/text/HilitTextHighlight.kt +++ b/designsystem/src/commonMain/kotlin/com/dminus14/designsystem/component/text/HilitTextHighlight.kt @@ -42,6 +42,7 @@ enum class HilitTextHighlightColor { Green, Red, Blue, + Black, } /** @@ -278,6 +279,13 @@ internal fun hilitTextHighlightStyle( contentColor = colors.positive800, ) } + + HilitTextHighlightColor.Black -> { + HilitTextHighlightStyle( + backgroundColor = colors.hilitBlack800, + contentColor = colors.hilitGreen500, + ) + } } internal fun normalizedHighlightRanges(text: AnnotatedString): List { diff --git a/feature/interview/api/build.gradle.kts b/feature/interview/api/build.gradle.kts new file mode 100644 index 00000000..7a7808cb --- /dev/null +++ b/feature/interview/api/build.gradle.kts @@ -0,0 +1,9 @@ +plugins { + alias(libs.plugins.dminus14.jvm.library) + alias(libs.plugins.dminus14.kotlin.quality) + alias(libs.plugins.kotlin.serialization) +} + +dependencies { + implementation(libs.androidx.navigation3.runtime) +} diff --git a/feature/interview/api/src/main/kotlin/com/dminus14/app/feature/interview/api/InterviewRoute.kt b/feature/interview/api/src/main/kotlin/com/dminus14/app/feature/interview/api/InterviewRoute.kt new file mode 100644 index 00000000..944ae78e --- /dev/null +++ b/feature/interview/api/src/main/kotlin/com/dminus14/app/feature/interview/api/InterviewRoute.kt @@ -0,0 +1,20 @@ +package com.dminus14.app.feature.interview.api + +import androidx.navigation3.runtime.NavKey +import kotlinx.serialization.Serializable + +/** 면접 진행 화면 route */ +@Serializable +data object InterviewRoute : NavKey + +/** 면접 오류 유형 */ +enum class InterviewErrorType { + MIC, + NETWORK, +} + +/** 면접 오류 화면 route */ +@Serializable +data class InterviewErrorRoute( + val errorType: InterviewErrorType, +) : NavKey diff --git a/feature/interview/impl/build.gradle.kts b/feature/interview/impl/build.gradle.kts new file mode 100644 index 00000000..8c4d805f --- /dev/null +++ b/feature/interview/impl/build.gradle.kts @@ -0,0 +1,20 @@ +plugins { + alias(libs.plugins.dminus14.android.feature) +} + +android { + namespace = "com.dminus14.app.feature.interview" +} + +dependencies { + api(project(":feature:interview:api")) + + implementation(project(":core:common")) + implementation(project(":core:permission")) + implementation(project(":designsystem")) + + implementation(libs.androidx.camera.core) + implementation(libs.androidx.camera.camera2) + implementation(libs.androidx.camera.lifecycle) + implementation(libs.androidx.camera.view) +} diff --git a/feature/interview/impl/src/main/kotlin/com/dminus14/app/feature/interview/InterviewConstants.kt b/feature/interview/impl/src/main/kotlin/com/dminus14/app/feature/interview/InterviewConstants.kt new file mode 100644 index 00000000..211c89b2 --- /dev/null +++ b/feature/interview/impl/src/main/kotlin/com/dminus14/app/feature/interview/InterviewConstants.kt @@ -0,0 +1,10 @@ +package com.dminus14.app.feature.interview + +/** + * `interview:impl` 내 둘 이상의 Screen이 공유하는 상수. + * 특정 Screen 하나에만 쓰이는 값은 해당 Contract나 사용처 파일에 둔다. + */ +object InterviewConstants { + const val MAX_INTERVIEW_SECONDS: Int = 720 + const val CAN_FINISH_INTERVIEW_SECONDS: Int = 480 +} diff --git a/feature/interview/impl/src/main/kotlin/com/dminus14/app/feature/interview/component/InterviewAbortModal.kt b/feature/interview/impl/src/main/kotlin/com/dminus14/app/feature/interview/component/InterviewAbortModal.kt new file mode 100644 index 00000000..c09e2fca --- /dev/null +++ b/feature/interview/impl/src/main/kotlin/com/dminus14/app/feature/interview/component/InterviewAbortModal.kt @@ -0,0 +1,42 @@ +package com.dminus14.app.feature.interview.component + +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import com.dminus14.designsystem.component.button.HilitFixedBottomDualButton +import com.dminus14.designsystem.component.button.HilitFixedBottomDualButtonType +import com.dminus14.designsystem.component.modal.HilitModal +import com.dminus14.designsystem.component.modal.HilitModalPreviewHost +import com.dminus14.designsystem.component.modal.HilitModalType + +@Composable +fun InterviewAbortModal( + onExitClick: () -> Unit, + onContinueClick: () -> Unit, +) { + HilitModal( + type = HilitModalType.Default, + title = "다음에 면접을 다시 진행할까요?", + subtitle = "지금 나가면 방금 쓴 이용권 한장이 사라져요.", + dismissible = false, + buttons = { + HilitFixedBottomDualButton( + leftText = "그대로 나가기", + rightText = "면접 계속하기", + type = HilitFixedBottomDualButtonType.TwoColor, + onLeftClick = onExitClick, + onRightClick = onContinueClick, + ) + }, + ) +} + +@Preview +@Composable +private fun InterviewAbortModalPreview() { + HilitModalPreviewHost { + InterviewAbortModal( + onExitClick = {}, + onContinueClick = {}, + ) + } +} diff --git a/feature/interview/impl/src/main/kotlin/com/dminus14/app/feature/interview/component/InterviewCameraFrame.kt b/feature/interview/impl/src/main/kotlin/com/dminus14/app/feature/interview/component/InterviewCameraFrame.kt new file mode 100644 index 00000000..65784268 --- /dev/null +++ b/feature/interview/impl/src/main/kotlin/com/dminus14/app/feature/interview/component/InterviewCameraFrame.kt @@ -0,0 +1,99 @@ +package com.dminus14.app.feature.interview.component + +import androidx.compose.foundation.Canvas +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.layout.size +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.dminus14.designsystem.theme.HilitTheme + +private val CORNER_DIRECTIONS = + listOf( + Offset(0f, 0f) to Offset(1f, 1f), + Offset(1f, 0f) to Offset(-1f, 1f), + Offset(0f, 1f) to Offset(1f, -1f), + Offset(1f, 1f) to Offset(-1f, -1f), + ) + +private fun DrawScope.drawCornerBrackets( + color: Color, + strokePx: Float, + cornerPx: Float, +) { + CORNER_DIRECTIONS.forEach { (originRatio, direction) -> + val origin = Offset(originRatio.x * size.width, originRatio.y * size.height) + drawLine( + color = color, + start = origin, + end = origin + Offset(direction.x * cornerPx, 0f), + strokeWidth = strokePx, + cap = StrokeCap.Square, + ) + drawLine( + color = color, + start = origin, + end = origin + Offset(0f, direction.y * cornerPx), + strokeWidth = strokePx, + cap = StrokeCap.Square, + ) + } +} + +/** + * Figma `camera-frame` preset (Figma Node: `683:9220`, `435:821`). + * + * 카메라 뷰 중앙에 위치하는 코너 브래킷 가이드 라인 및 가이드 텍스트. + */ +@Composable +fun InterviewCameraFrame( + modifier: Modifier = Modifier, + text: String = "얼굴을 여기에 맞춰주세요", + color: Color = HilitTheme.colors.gray300.copy(alpha = 0xAC / 255f), +) { + Box( + modifier = modifier.size(327.dp), + contentAlignment = Alignment.Center, + ) { + Canvas(modifier = Modifier.fillMaxSize()) { + drawCornerBrackets( + color = color, + strokePx = 5.dp.toPx(), + cornerPx = 35.dp.toPx(), + ) + } + + if (text.isNotEmpty()) { + Column( + modifier = Modifier.padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = text, + color = HilitTheme.colors.gray400.copy(alpha = 0x9C / 255f), + style = HilitTheme.typography.head4, + textAlign = TextAlign.Center, + ) + } + } + } +} + +@Preview +@Composable +private fun CameraFramePreview() { + HilitTheme { + InterviewCameraFrame() + } +} diff --git a/feature/interview/impl/src/main/kotlin/com/dminus14/app/feature/interview/component/InterviewCameraPreview.kt b/feature/interview/impl/src/main/kotlin/com/dminus14/app/feature/interview/component/InterviewCameraPreview.kt new file mode 100644 index 00000000..f570b656 --- /dev/null +++ b/feature/interview/impl/src/main/kotlin/com/dminus14/app/feature/interview/component/InterviewCameraPreview.kt @@ -0,0 +1,152 @@ +package com.dminus14.app.feature.interview.component + +import androidx.camera.core.CameraSelector +import androidx.camera.core.Preview +import androidx.camera.lifecycle.ProcessCameraProvider +import androidx.camera.view.PreviewView +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.content.ContextCompat +import androidx.lifecycle.compose.LocalLifecycleOwner +import com.dminus14.designsystem.theme.HilitTheme +import androidx.compose.ui.tooling.preview.Preview as ComposePreview + +private const val MOCK_TEXT_REPEAT_COUNT = 4 + +/** + * 전면 카메라 프리뷰 (Video-only, Portrait Fixed). + * + * `@Preview` 환경이나 카메라 미작동 시 Mock Placeholder UI를 노출하여 화면 렌더링을 보장한다. + */ +@Composable +fun InterviewCameraPreview( + isCameraPermissionGranted: Boolean, + modifier: Modifier = Modifier, + onCameraBindingFailed: () -> Unit = {}, +) { + val isPreview = LocalInspectionMode.current + + if (isPreview || !isCameraPermissionGranted) { + CameraPreviewMock(modifier = modifier) + } else { + CameraPreviewReal( + modifier = modifier, + onCameraBindingFailed = onCameraBindingFailed, + ) + } +} + +@Composable +private fun CameraPreviewReal( + modifier: Modifier = Modifier, + onCameraBindingFailed: () -> Unit, +) { + val context = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current + val currentOnCameraBindingFailed by rememberUpdatedState(onCameraBindingFailed) + val previewView = + remember { + PreviewView(context).apply { + scaleType = PreviewView.ScaleType.FILL_CENTER + // SurfaceView(PERFORMANCE 모드)는 Compose가 그리는 다른 레이어 위에 항상 + // 얹혀 그려져 카메라 위에 겹치는 오버레이(VideoOverlay, InterviewCameraFrame 등)를 + // 가리므로 TextureView 기반 COMPATIBLE 모드를 사용한다. + implementationMode = PreviewView.ImplementationMode.COMPATIBLE + } + } + + DisposableEffect(lifecycleOwner) { + val cameraProviderFuture = ProcessCameraProvider.getInstance(context) + val executor = ContextCompat.getMainExecutor(context) + val preview = + Preview.Builder().build().also { + it.surfaceProvider = previewView.surfaceProvider + } + var cameraProvider: ProcessCameraProvider? = null + var isDisposed = false + + cameraProviderFuture.addListener( + { + if (isDisposed) return@addListener + + try { + cameraProvider = cameraProviderFuture.get() + val cameraSelector = CameraSelector.DEFAULT_FRONT_CAMERA + + cameraProvider?.unbind(preview) + cameraProvider?.bindToLifecycle( + lifecycleOwner, + cameraSelector, + preview, + ) + } catch (_: Exception) { + currentOnCameraBindingFailed() + } + }, + executor, + ) + + onDispose { + isDisposed = true + try { + cameraProvider?.unbind(preview) + } catch (_: Exception) { + // Ignore cleanup error + } + } + } + + AndroidView( + factory = { previewView }, + modifier = modifier.fillMaxSize(), + ) +} + +@Composable +private fun CameraPreviewMock(modifier: Modifier = Modifier) { + Column( + modifier = + modifier + .fillMaxSize() + .background(color = HilitTheme.colors.hilitWhite) + .padding(vertical = 64.dp), + verticalArrangement = Arrangement.SpaceBetween, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + repeat(MOCK_TEXT_REPEAT_COUNT) { + Text( + text = "Camera Self-View (Preview Mode)", + color = HilitTheme.colors.gray300, + style = HilitTheme.typography.body6, + ) + } + } +} + +@ComposePreview( + showBackground = true, + backgroundColor = 0xFFFFFFFF, + widthDp = 375, + heightDp = 812, +) +@Composable +private fun CameraPreviewMockPreview() { + HilitTheme { + CameraPreviewMock() + } +} diff --git a/feature/interview/impl/src/main/kotlin/com/dminus14/app/feature/interview/component/InterviewCompletableBubble.kt b/feature/interview/impl/src/main/kotlin/com/dminus14/app/feature/interview/component/InterviewCompletableBubble.kt new file mode 100644 index 00000000..c7ff9805 --- /dev/null +++ b/feature/interview/impl/src/main/kotlin/com/dminus14/app/feature/interview/component/InterviewCompletableBubble.kt @@ -0,0 +1,31 @@ +package com.dminus14.app.feature.interview.component + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import com.dminus14.designsystem.component.bubblefield.BubbleField +import com.dminus14.designsystem.component.bubblefield.BubbleFieldTailAlign +import com.dminus14.designsystem.component.bubblefield.BubbleFieldTailEdge +import com.dminus14.designsystem.component.bubblefield.BubbleFieldTailShape +import com.dminus14.designsystem.component.bubblefield.BubbleFieldType +import com.dminus14.designsystem.theme.HilitTheme + +@Composable +fun InterviewCompletableBubble(modifier: Modifier = Modifier) { + BubbleField( + text = "8분이 경과되어 면접을 종료할 수 있어요", + type = BubbleFieldType.Big, + tailEdge = BubbleFieldTailEdge.Bottom, + tailAlign = BubbleFieldTailAlign.Right, + tailShape = BubbleFieldTailShape.Right, + modifier = modifier, + ) +} + +@Preview +@Composable +private fun InterviewCompletableBubblePreview() { + HilitTheme { + InterviewCompletableBubble() + } +} diff --git a/feature/interview/impl/src/main/kotlin/com/dminus14/app/feature/interview/component/InterviewFinishModal.kt b/feature/interview/impl/src/main/kotlin/com/dminus14/app/feature/interview/component/InterviewFinishModal.kt new file mode 100644 index 00000000..b09c17d9 --- /dev/null +++ b/feature/interview/impl/src/main/kotlin/com/dminus14/app/feature/interview/component/InterviewFinishModal.kt @@ -0,0 +1,50 @@ +package com.dminus14.app.feature.interview.component + +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import com.dminus14.designsystem.component.button.HilitFixedBottomDualButton +import com.dminus14.designsystem.component.button.HilitFixedBottomDualButtonType +import com.dminus14.designsystem.component.icon.HilitIcon +import com.dminus14.designsystem.component.icon.HilitIconAsset +import com.dminus14.designsystem.component.modal.HilitModal +import com.dminus14.designsystem.component.modal.HilitModalPreviewHost +import com.dminus14.designsystem.component.modal.HilitModalType + +@Composable +fun InterviewFinishModal( + onContinueClick: () -> Unit, + onFinishClick: () -> Unit, +) { + HilitModal( + type = HilitModalType.InvisibleInfo, + title = "면접을 마칠까요?", + subtitle = "마치기를 클릭하는 즉시 면접이 종료됩니다.\n지금까지 답변으로 분석을 시작해요.", + graphic = { + HilitIcon( + asset = HilitIconAsset.Finish, + contentDescription = "Finish", + ) + }, + dismissible = false, + buttons = { + HilitFixedBottomDualButton( + leftText = "면접 계속하기", + rightText = "바로 마치기", + type = HilitFixedBottomDualButtonType.TwoColor, + onLeftClick = onContinueClick, + onRightClick = onFinishClick, + ) + }, + ) +} + +@Preview +@Composable +private fun InterviewAbortModalPreview() { + HilitModalPreviewHost { + InterviewFinishModal( + onContinueClick = {}, + onFinishClick = {}, + ) + } +} diff --git a/feature/interview/impl/src/main/kotlin/com/dminus14/app/feature/interview/component/InterviewReadinessIndicator.kt b/feature/interview/impl/src/main/kotlin/com/dminus14/app/feature/interview/component/InterviewReadinessIndicator.kt new file mode 100644 index 00000000..f363fcc5 --- /dev/null +++ b/feature/interview/impl/src/main/kotlin/com/dminus14/app/feature/interview/component/InterviewReadinessIndicator.kt @@ -0,0 +1,171 @@ +package com.dminus14.app.feature.interview.component + +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.constrainWidth +import androidx.compose.ui.unit.dp +import com.dminus14.designsystem.theme.HilitTheme +import kotlin.math.roundToInt + +private const val READINESS_TEXT = "곧 면접이 시작됩니다" +private const val MARQUEE_DURATION_MILLIS = 6_000 +private val TEXT_GAP = 6.dp + +/** + * 면접 시작 전 대기 상태를 나타내는 3연속 텍스트 인디케이터. + * 좌/중/우 텍스트가 우측에서 좌측으로 무한히 흐르며, 부모 폭을 벗어난 부분은 잘린다. + * 중앙 텍스트만 상태에 따라 색상이 애니메이션으로 전환되고, 좌측에서 사라진 뒤 + * 다시 우측에서 나타난다. + * + * Figma Node: 683:9219 (준비 중), 683:9228 (준비 완료) + */ +@Composable +fun InterviewReadinessIndicator( + isReady: Boolean, + modifier: Modifier = Modifier, + text: String = READINESS_TEXT, +) { + val marqueeProgress = rememberMarqueeProgress() + val centerColor = rememberCenterColor(isReady) + + Layout( + modifier = modifier.fillMaxWidth().background(HilitTheme.colors.gray800).clipToBounds(), + content = { + Text( + text = text, + color = HilitTheme.colors.gray700, + style = HilitTheme.typography.sub7, + maxLines = 1, + softWrap = false, + overflow = TextOverflow.Clip, + ) + Text( + text = text, + color = centerColor, + style = HilitTheme.typography.sub7, + maxLines = 1, + softWrap = false, + overflow = TextOverflow.Clip, + ) + Text( + text = text, + color = HilitTheme.colors.gray700, + style = HilitTheme.typography.sub7, + maxLines = 1, + softWrap = false, + overflow = TextOverflow.Clip, + ) + }, + ) { measurables, constraints -> + val gapPx = TEXT_GAP.roundToPx() + val unboundedConstraints = + Constraints(maxWidth = Constraints.Infinity, maxHeight = constraints.maxHeight) + val placeables = measurables.map { it.measure(unboundedConstraints) } + + val rowWidth = constraints.constrainWidth(constraints.maxWidth) + val textWidth = placeables.first().width + val textStride = textWidth + gapPx + val cycleWidth = textStride * placeables.size + val height = placeables.maxOf { it.height } + + layout(rowWidth, height) { + val centerX = (rowWidth - textWidth) / 2f + val wrapStart = -textWidth.toFloat() + val offset = cycleWidth * marqueeProgress + + placeables.forEachIndexed { index, placeable -> + val initialX = centerX + (index - 1) * textStride + val distanceFromWrapStart = initialX - offset - wrapStart + val wrappedDistance = + ((distanceFromWrapStart % cycleWidth) + cycleWidth) % cycleWidth + val x = wrapStart + wrappedDistance + + placeable.placeRelative( + x.roundToInt(), + (height - placeable.height) / 2, + ) + } + } + } +} + +@Composable +private fun rememberMarqueeProgress(): Float { + val infiniteTransition = + rememberInfiniteTransition(label = "InterviewReadinessIndicatorMarquee") + val progress by + infiniteTransition.animateFloat( + initialValue = 0f, + targetValue = 1f, + animationSpec = + infiniteRepeatable( + animation = + tween( + durationMillis = MARQUEE_DURATION_MILLIS, + easing = LinearEasing, + ), + repeatMode = RepeatMode.Restart, + ), + label = "InterviewReadinessIndicatorMarqueeProgress", + ) + return progress +} + +@Composable +private fun rememberCenterColor(isReady: Boolean): Color { + val color by + animateColorAsState( + targetValue = + when (isReady) { + false -> HilitTheme.colors.gray600 + true -> HilitTheme.colors.gray50 + }, + label = "InterviewReadinessIndicatorCenterColor", + ) + return color +} + +@Preview +@Composable +private fun InterviewReadinessIndicatorPreparingPreview() { + HilitTheme { + InterviewReadinessIndicator(isReady = false) + } +} + +@Preview +@Composable +private fun InterviewReadinessIndicatorReadyPreview() { + HilitTheme { + InterviewReadinessIndicator(isReady = true) + } +} + +@Preview +@Composable +private fun InterviewReadinessIndicatorNarrowPreview() { + HilitTheme { + InterviewReadinessIndicator( + isReady = true, + modifier = Modifier.width(80.dp), + ) + } +} diff --git a/feature/interview/impl/src/main/kotlin/com/dminus14/app/feature/interview/component/InterviewSpeakerIndicator.kt b/feature/interview/impl/src/main/kotlin/com/dminus14/app/feature/interview/component/InterviewSpeakerIndicator.kt new file mode 100644 index 00000000..f5005208 --- /dev/null +++ b/feature/interview/impl/src/main/kotlin/com/dminus14/app/feature/interview/component/InterviewSpeakerIndicator.kt @@ -0,0 +1,81 @@ +package com.dminus14.app.feature.interview.component + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.heightIn +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.text.buildAnnotatedString +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.dminus14.app.feature.interview.interview.InterviewSpeaker +import com.dminus14.designsystem.component.text.HilitText +import com.dminus14.designsystem.component.text.HilitTextHighlightColor +import com.dminus14.designsystem.component.text.withHilitTextHighlight +import com.dminus14.designsystem.theme.HilitTheme + +@Composable +fun InterviewSpeakerIndicator( + speaker: InterviewSpeaker, + modifier: Modifier = Modifier, +) { + val highlightColor = + if (speaker == InterviewSpeaker.AI) { + HilitTextHighlightColor.Green + } else { + HilitTextHighlightColor.Black + } + val titleText = if (speaker == InterviewSpeaker.AI) "질문 듣는 중" else "답변 녹음 중" + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Top, + modifier = + Modifier + .then(modifier) + .heightIn(min = 60.dp), + ) { + HilitText( + text = + buildAnnotatedString { + withHilitTextHighlight { + append(titleText) + } + }, + style = HilitTheme.typography.body2, + highlightColor = highlightColor, + ) + + if (speaker == InterviewSpeaker.AI) { + Text( + text = "끝까지 듣고 대답해주세요", + style = HilitTheme.typography.sub7, + color = HilitTheme.colors.hilitWhite, + modifier = Modifier.padding(top = 10.dp), + ) + } + } +} + +@Preview +@Composable +private fun InterviewSpeakerIndicatorAIPreview() { + HilitTheme { + InterviewSpeakerIndicator( + speaker = InterviewSpeaker.AI, + ) + } +} + +@Preview +@Composable +private fun InterviewSpeakerIndicatorUserPreview() { + HilitTheme { + InterviewSpeakerIndicator( + speaker = InterviewSpeaker.User, + ) + } +} diff --git a/feature/interview/impl/src/main/kotlin/com/dminus14/app/feature/interview/component/InterviewStartButton.kt b/feature/interview/impl/src/main/kotlin/com/dminus14/app/feature/interview/component/InterviewStartButton.kt new file mode 100644 index 00000000..2f36299a --- /dev/null +++ b/feature/interview/impl/src/main/kotlin/com/dminus14/app/feature/interview/component/InterviewStartButton.kt @@ -0,0 +1,45 @@ +package com.dminus14.app.feature.interview.component + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import com.dminus14.designsystem.component.button.HilitButtonType +import com.dminus14.designsystem.component.button.HilitFixedBottomButton +import com.dminus14.designsystem.theme.HilitTheme + +@Composable +fun InterviewStartButton( + isReady: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + HilitFixedBottomButton( + type = HilitButtonType.Light, + text = "면접 시작하기", + enabled = isReady, + onClick = onClick, + modifier = modifier, + ) +} + +@Preview +@Composable +private fun InterviewStartButtonEnabledPreview() { + HilitTheme { + InterviewStartButton( + isReady = true, + onClick = {}, + ) + } +} + +@Preview +@Composable +private fun InterviewStartButtonDisabledPreview() { + HilitTheme { + InterviewStartButton( + isReady = false, + onClick = {}, + ) + } +} diff --git a/feature/interview/impl/src/main/kotlin/com/dminus14/app/feature/interview/component/InterviewTimer.kt b/feature/interview/impl/src/main/kotlin/com/dminus14/app/feature/interview/component/InterviewTimer.kt new file mode 100644 index 00000000..99f5ed86 --- /dev/null +++ b/feature/interview/impl/src/main/kotlin/com/dminus14/app/feature/interview/component/InterviewTimer.kt @@ -0,0 +1,86 @@ +package com.dminus14.app.feature.interview.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.Row +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.dminus14.designsystem.component.icon.HilitIcon +import com.dminus14.designsystem.component.icon.HilitIconAsset +import com.dminus14.designsystem.theme.HilitTheme +import java.util.Locale + +private const val MINUTE_SECONDS = 60 + +/** + * 면접 타이머. + * + * Figma 시안: + * - 60초 초과: 683:9276 + * - 60초 이하: 683:9308 + */ +@Composable +fun InterviewTimer( + remainingSeconds: Int, + modifier: Modifier = Modifier, +) { + val isWarning = (remainingSeconds <= MINUTE_SECONDS) + val bgColor = if (isWarning) HilitTheme.colors.error200 else HilitTheme.colors.gray50 + val textColor = if (isWarning) HilitTheme.colors.error500 else HilitTheme.colors.gray500 + + // 60초 이상일 땐 MM:SS, 이하일 땐 SS초 + val minutes = remainingSeconds / MINUTE_SECONDS + val seconds = remainingSeconds % MINUTE_SECONDS + val timeText = + if (remainingSeconds < MINUTE_SECONDS) { + "${remainingSeconds.coerceIn(0, MINUTE_SECONDS)}초" + } else { + String.format(Locale.US, "%d:%02d", minutes, seconds) + } + + Box( + modifier = + modifier + .size(width = 65.dp, height = 21.dp) + .background(color = bgColor, shape = TrapezoidShape()), + contentAlignment = Alignment.Center, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(2.dp), + ) { + HilitIcon( + asset = HilitIconAsset.Timer, + contentDescription = null, + tint = textColor, + modifier = Modifier.size(16.dp), + ) + Text( + text = timeText, + style = HilitTheme.typography.body2.copy(fontWeight = FontWeight.SemiBold), + color = textColor, + ) + } + } +} + +@Preview +@Composable +private fun InterviewTimerPreview() { + HilitTheme { + Column( + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + InterviewTimer(remainingSeconds = 80) // 1:20 + InterviewTimer(remainingSeconds = 10) // 0:10 (warning style) + } + } +} diff --git a/feature/interview/impl/src/main/kotlin/com/dminus14/app/feature/interview/component/TrapezoidShape.kt b/feature/interview/impl/src/main/kotlin/com/dminus14/app/feature/interview/component/TrapezoidShape.kt new file mode 100644 index 00000000..630ddff5 --- /dev/null +++ b/feature/interview/impl/src/main/kotlin/com/dminus14/app/feature/interview/component/TrapezoidShape.kt @@ -0,0 +1,31 @@ +package com.dminus14.app.feature.interview.component + +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Outline +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp + +class TrapezoidShape( + private val slant: Dp = 4.dp, +) : Shape { + override fun createOutline( + size: Size, + layoutDirection: LayoutDirection, + density: Density, + ): Outline { + val slantPx = with(density) { slant.toPx() } + val path = + Path().apply { + moveTo(slantPx, 0f) + lineTo(size.width, 0f) + lineTo(size.width - slantPx, size.height) + lineTo(0f, size.height) + close() + } + return Outline.Generic(path) + } +} diff --git a/feature/interview/impl/src/main/kotlin/com/dminus14/app/feature/interview/component/VideoOverlay.kt b/feature/interview/impl/src/main/kotlin/com/dminus14/app/feature/interview/component/VideoOverlay.kt new file mode 100644 index 00000000..33051bea --- /dev/null +++ b/feature/interview/impl/src/main/kotlin/com/dminus14/app/feature/interview/component/VideoOverlay.kt @@ -0,0 +1,75 @@ +package com.dminus14.app.feature.interview.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.dminus14.designsystem.theme.HilitTheme + +private const val GRADIENT_STOP = 0.91542f + +enum class VideoOverlayDirection { + TOP, + BOTTOM, +} + +/** + * 카메라 프리뷰 위에 얹는 검정 그라데이션 오버레이. + * + * Figma Node: 683:9224 + */ +@Composable +fun VideoOverlay( + modifier: Modifier = Modifier, + direction: VideoOverlayDirection = VideoOverlayDirection.BOTTOM, +) { + val black = HilitTheme.colors.hilitBlack900 + val brush = + when (direction) { + VideoOverlayDirection.BOTTOM -> { + Brush.verticalGradient( + 0f to Color.Transparent, + GRADIENT_STOP to black, + ) + } + + VideoOverlayDirection.TOP -> { + Brush.verticalGradient( + (1f - GRADIENT_STOP) to black, + 1f to Color.Transparent, + ) + } + } + + Box( + modifier = modifier.background(brush = brush), + ) +} + +@Preview +@Composable +private fun VideoOverlayBottomPreview() { + HilitTheme { + VideoOverlay( + modifier = Modifier.fillMaxSize().height(229.dp), + direction = VideoOverlayDirection.BOTTOM, + ) + } +} + +@Preview +@Composable +private fun VideoOverlayTopPreview() { + HilitTheme { + VideoOverlay( + modifier = Modifier.fillMaxSize().height(229.dp), + direction = VideoOverlayDirection.TOP, + ) + } +} diff --git a/feature/interview/impl/src/main/kotlin/com/dminus14/app/feature/interview/di/InterviewNavigationModule.kt b/feature/interview/impl/src/main/kotlin/com/dminus14/app/feature/interview/di/InterviewNavigationModule.kt new file mode 100644 index 00000000..cc9baf53 --- /dev/null +++ b/feature/interview/impl/src/main/kotlin/com/dminus14/app/feature/interview/di/InterviewNavigationModule.kt @@ -0,0 +1,23 @@ +package com.dminus14.app.feature.interview.di + +import androidx.navigation3.runtime.EntryProviderScope +import com.dminus14.app.feature.interview.navigation.interviewEntryBuilder +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ActivityRetainedComponent +import dagger.multibindings.IntoSet + +@Module +@InstallIn(ActivityRetainedComponent::class) +object InterviewNavigationModule { + @IntoSet + @Provides + fun provideInterviewEntryInstaller(): EntryProviderScope.() -> Unit = + { + interviewEntryBuilder( + onNavigateHome = {}, + onResumeInterview = {}, + ) + } +} diff --git a/feature/interview/impl/src/main/kotlin/com/dminus14/app/feature/interview/error/InterviewErrorContract.kt b/feature/interview/impl/src/main/kotlin/com/dminus14/app/feature/interview/error/InterviewErrorContract.kt new file mode 100644 index 00000000..49305b2f --- /dev/null +++ b/feature/interview/impl/src/main/kotlin/com/dminus14/app/feature/interview/error/InterviewErrorContract.kt @@ -0,0 +1,25 @@ +package com.dminus14.app.feature.interview.error + +import com.dminus14.app.core.common.mvi.MviEffect +import com.dminus14.app.core.common.mvi.MviIntent +import com.dminus14.app.core.common.mvi.MviState +import com.dminus14.app.feature.interview.api.InterviewErrorType + +/** 면접 오류 MVI Intent */ +sealed interface InterviewErrorIntent : MviIntent { + data object ClickAbort : InterviewErrorIntent + + data object ClickResume : InterviewErrorIntent +} + +/** 면접 오류 MVI State */ +data class InterviewErrorState( + val errorType: InterviewErrorType = InterviewErrorType.MIC, +) : MviState + +/** 면접 오류 MVI Effect */ +sealed interface InterviewErrorEffect : MviEffect { + data object NavigateToHome : InterviewErrorEffect + + data object ResumeInterview : InterviewErrorEffect +} diff --git a/feature/interview/impl/src/main/kotlin/com/dminus14/app/feature/interview/error/InterviewErrorScreen.kt b/feature/interview/impl/src/main/kotlin/com/dminus14/app/feature/interview/error/InterviewErrorScreen.kt new file mode 100644 index 00000000..ce32fa5f --- /dev/null +++ b/feature/interview/impl/src/main/kotlin/com/dminus14/app/feature/interview/error/InterviewErrorScreen.kt @@ -0,0 +1,228 @@ +@file:Suppress("LongMethod") + +package com.dminus14.app.feature.interview.error + +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.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.dminus14.app.feature.interview.api.InterviewErrorType +import com.dminus14.designsystem.component.button.HilitButtonType +import com.dminus14.designsystem.component.button.HilitFixedBottomButton +import com.dminus14.designsystem.component.button.HilitFixedBottomDualButton +import com.dminus14.designsystem.component.button.HilitFixedBottomDualButtonType +import com.dminus14.designsystem.component.icon.HilitIcon +import com.dminus14.designsystem.component.icon.HilitIconAsset +import com.dminus14.designsystem.component.text.HilitText +import com.dminus14.designsystem.component.text.HilitTextHighlightColor +import com.dminus14.designsystem.component.text.withHilitTextHighlight +import com.dminus14.designsystem.theme.HilitTheme + +/** + * 면접 오류 화면 (Page 2). + * + * Figma Nodes: + * - 609:9269 (마이크 상태 불량) + * - 609:9259 (네트워크 상태 불량) + */ +@Composable +fun InterviewErrorScreen( + errorType: InterviewErrorType, + onNavigateHome: () -> Unit, + onResumeInterview: () -> Unit, + modifier: Modifier = Modifier, + viewModel: InterviewErrorViewModel = hiltViewModel(), +) { + val state by viewModel.state.collectAsStateWithLifecycle() + + LaunchedEffect(errorType) { + viewModel.initErrorType(errorType) + } + + LaunchedEffect(Unit) { + viewModel.effect.collect { effect -> + when (effect) { + InterviewErrorEffect.NavigateToHome -> onNavigateHome() + InterviewErrorEffect.ResumeInterview -> onResumeInterview() + } + } + } + + InterviewErrorContent( + state = state, + onIntent = viewModel::onIntent, + modifier = modifier, + ) +} + +/** ViewModel-free 오류 UI Content */ +@Composable +fun InterviewErrorContent( + state: InterviewErrorState, + onIntent: (InterviewErrorIntent) -> Unit, + modifier: Modifier = Modifier, +) { + val isMicError = state.errorType == InterviewErrorType.MIC + val subtitleText = + if (isMicError) { + "마이크 상태를 확인하고 조용한 곳에서\n" + + "면접을 다시 시작해주세요." + } else { + "네트워크가 불안정해 면접을 이어갈 수 없어요.\n" + + "연결을 확인하고 면접을 다시 시작해주세요." + } + val descriptionText = + if (isMicError) { + "면접을 중단해도 이용권은 차감되지 않아요" + } else { + "중단하기를 선택할 경우에, 이용권은 차감되지 않아요" + } + + Column( + modifier = + Modifier + .fillMaxSize() + .background(HilitTheme.colors.hilitWhite) + .then(modifier), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + modifier = + Modifier + .fillMaxWidth() + .weight(1f) + .padding(horizontal = 20.dp), + ) { + Box( + contentAlignment = Alignment.Center, + modifier = + Modifier + .size(54.dp) + .background(color = HilitTheme.colors.hilitBlack800), + ) { + HilitIcon( + asset = if (isMicError) HilitIconAsset.Mic else HilitIconAsset.Network, + contentDescription = null, + tint = HilitTheme.colors.hilitGreen500, + modifier = Modifier.size(24.dp), + ) + HilitIcon( + asset = HilitIconAsset.AlertRed, + contentDescription = null, + modifier = Modifier.align(Alignment.TopEnd), + ) + } + + Spacer(modifier = Modifier.height(24.dp)) + + HilitText( + text = + buildAnnotatedString { + if (isMicError) { + withHilitTextHighlight { append("목소리") } + append("가 잘 들리지 않아요") + } else { + withHilitTextHighlight { append("연결") } + append("이 끊겼어요") + } + }, + highlightColor = HilitTextHighlightColor.Red, + color = HilitTheme.colors.hilitBlack800, + style = HilitTheme.typography.head3.copy(fontWeight = FontWeight.Bold), + ) + + Spacer(modifier = Modifier.height(24.dp)) + + Text( + text = subtitleText, + style = HilitTheme.typography.body4, + color = HilitTheme.colors.gray500, + textAlign = TextAlign.Center, + ) + + Spacer(modifier = Modifier.height(24.dp)) + + Row( + modifier = + Modifier + .fillMaxWidth() + .background(HilitTheme.colors.gray100) + .padding(14.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + HilitIcon( + asset = HilitIconAsset.Info, + contentDescription = null, + tint = HilitTheme.colors.hilitBlack800, + modifier = Modifier.size(16.dp), + ) + Text( + text = descriptionText, + style = HilitTheme.typography.body9, + color = HilitTheme.colors.gray700, + ) + } + } + + if (isMicError) { + HilitFixedBottomButton( + text = "중단하기", + type = HilitButtonType.Light, + onClick = { onIntent(InterviewErrorIntent.ClickAbort) }, + ) + } else { + HilitFixedBottomDualButton( + leftText = "중단하기", + rightText = "이어서 진행하기", + type = HilitFixedBottomDualButtonType.Default, + onLeftClick = { onIntent(InterviewErrorIntent.ClickAbort) }, + onRightClick = { onIntent(InterviewErrorIntent.ClickResume) }, + ) + } + } +} + +@Preview(name = "Interview Error Mic", widthDp = 375, heightDp = 812) +@Composable +private fun InterviewErrorMicPreview() { + HilitTheme { + InterviewErrorContent( + state = InterviewErrorState(errorType = InterviewErrorType.MIC), + onIntent = {}, + ) + } +} + +@Preview(name = "Interview Error Network", widthDp = 375, heightDp = 812) +@Composable +private fun InterviewErrorNetworkPreview() { + HilitTheme { + InterviewErrorContent( + state = InterviewErrorState(errorType = InterviewErrorType.NETWORK), + onIntent = {}, + ) + } +} diff --git a/feature/interview/impl/src/main/kotlin/com/dminus14/app/feature/interview/error/InterviewErrorViewModel.kt b/feature/interview/impl/src/main/kotlin/com/dminus14/app/feature/interview/error/InterviewErrorViewModel.kt new file mode 100644 index 00000000..5db602da --- /dev/null +++ b/feature/interview/impl/src/main/kotlin/com/dminus14/app/feature/interview/error/InterviewErrorViewModel.kt @@ -0,0 +1,25 @@ +package com.dminus14.app.feature.interview.error + +import com.dminus14.app.core.common.mvi.MviViewModel +import com.dminus14.app.feature.interview.api.InterviewErrorType +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject + +@HiltViewModel +class InterviewErrorViewModel + @Inject + constructor() : + MviViewModel( + InterviewErrorState(), + ) { + override fun onIntent(intent: InterviewErrorIntent) { + when (intent) { + InterviewErrorIntent.ClickAbort -> sendEffect(InterviewErrorEffect.NavigateToHome) + InterviewErrorIntent.ClickResume -> sendEffect(InterviewErrorEffect.ResumeInterview) + } + } + + fun initErrorType(errorType: InterviewErrorType) { + reduce { copy(errorType = errorType) } + } + } diff --git a/feature/interview/impl/src/main/kotlin/com/dminus14/app/feature/interview/interview/InterviewContract.kt b/feature/interview/impl/src/main/kotlin/com/dminus14/app/feature/interview/interview/InterviewContract.kt new file mode 100644 index 00000000..e0e86728 --- /dev/null +++ b/feature/interview/impl/src/main/kotlin/com/dminus14/app/feature/interview/interview/InterviewContract.kt @@ -0,0 +1,71 @@ +package com.dminus14.app.feature.interview.interview + +import com.dminus14.app.core.common.mvi.MviEffect +import com.dminus14.app.core.common.mvi.MviIntent +import com.dminus14.app.core.common.mvi.MviState +import com.dminus14.app.feature.interview.InterviewConstants + +/** 현재 발언자 */ +enum class InterviewSpeaker { + AI, + User, + ; + + operator fun not(): InterviewSpeaker = + when (this) { + InterviewSpeaker.AI -> InterviewSpeaker.User + InterviewSpeaker.User -> InterviewSpeaker.AI + } +} + +/** 면접 진행 단계 */ +enum class InterviewScreenState { + PREPARING, + ALMOST_PREPARED, + PREPARED, + ONGOING, + FINISHED, +} + +/** 면접 MVI Intent */ +sealed interface InterviewIntent : MviIntent { + data object CheckCameraPermission : InterviewIntent + + data object StartInterview : InterviewIntent + + data object ChangeSpeaker : InterviewIntent + + data object ReportCameraBindingFailure : InterviewIntent + + data object ClickPermissionDeniedBack : InterviewIntent + + data object ClickFinishInterview : InterviewIntent +} + +/** 면접 MVI State */ +data class InterviewState( + var interviewScreenState: InterviewScreenState = InterviewScreenState.PREPARING, + var isInterviewReady: Boolean = false, + var speaker: InterviewSpeaker = InterviewSpeaker.AI, + // 후속 구현: PermissionManager에서 CAMERA 권한의 실제 상태를 조회해 갱신한다. + val isCameraPermissionGranted: Boolean = false, + val elapsedSeconds: Int = 0, +) : MviState { + val remainingSeconds: Int + get() = InterviewConstants.MAX_INTERVIEW_SECONDS - elapsedSeconds + + val canFinishedEarly: Boolean + get() = elapsedSeconds >= InterviewConstants.CAN_FINISH_INTERVIEW_SECONDS + + val isInterviewOngoing: Boolean + get() = isInterviewReady && remainingSeconds > 0 +} + +/** 면접 MVI Effect */ +sealed interface InterviewEffect : MviEffect { + data object CameraBindingFailed : InterviewEffect + + data object PermissionDeniedExitRequested : InterviewEffect + + data object FinishRequested : InterviewEffect +} diff --git a/feature/interview/impl/src/main/kotlin/com/dminus14/app/feature/interview/interview/InterviewScreen.kt b/feature/interview/impl/src/main/kotlin/com/dminus14/app/feature/interview/interview/InterviewScreen.kt new file mode 100644 index 00000000..bcfcd3bb --- /dev/null +++ b/feature/interview/impl/src/main/kotlin/com/dminus14/app/feature/interview/interview/InterviewScreen.kt @@ -0,0 +1,159 @@ +@file:Suppress("TooManyFunctions", "LongMethod", "CyclomaticComplexMethod") + +package com.dminus14.app.feature.interview.interview + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.dminus14.app.feature.interview.interview.layer.InterviewScreenBaseLayer +import com.dminus14.app.feature.interview.interview.layer.InterviewScreenOngoingLayer +import com.dminus14.app.feature.interview.interview.layer.InterviewScreenPrepareLayer +import com.dminus14.designsystem.theme.HilitTheme + +@Composable +fun InterviewScreen( + modifier: Modifier = Modifier, + onNavigateHome: () -> Unit = {}, + onCameraBindingFailed: () -> Unit = {}, + onFinishRequested: () -> Unit = {}, + viewModel: InterviewViewModel = hiltViewModel(), +) { + val state by viewModel.state.collectAsStateWithLifecycle() + + LaunchedEffect(Unit) { + viewModel.onIntent(InterviewIntent.CheckCameraPermission) + viewModel.effect.collect { effect -> + when (effect) { + InterviewEffect.CameraBindingFailed -> { + onCameraBindingFailed() + } + + InterviewEffect.PermissionDeniedExitRequested -> { + onNavigateHome() + } + + InterviewEffect.FinishRequested -> { + // 후속 구현: 종료 확인 모달을 표시하고 확정 결과를 실제 면접 종료 로직에 연결한다. + onFinishRequested() + } + } + } + } + + InterviewContent( + state = state, + onIntent = viewModel::onIntent, + modifier = modifier.fillMaxSize(), + ) +} + +@Composable +fun InterviewContent( + state: InterviewState, + onIntent: (InterviewIntent) -> Unit, + modifier: Modifier = Modifier, +) { + Box(modifier = modifier) { + InterviewScreenBaseLayer( + isCameraPermissionGranted = state.isCameraPermissionGranted, + onCameraBindingFailed = { + onIntent(InterviewIntent.ReportCameraBindingFailure) + }, + modifier = Modifier.fillMaxSize(), + ) + + when (state.interviewScreenState) { + InterviewScreenState.PREPARING, + InterviewScreenState.ALMOST_PREPARED, + InterviewScreenState.PREPARED, + -> { + InterviewScreenPrepareLayer( + isReady = state.isInterviewReady, + isPermissionGranted = state.isCameraPermissionGranted, + interviewScreenState = state.interviewScreenState, + onInterviewStart = { onIntent(InterviewIntent.StartInterview) }, + onPermissionDeniedBack = { + onIntent(InterviewIntent.ClickPermissionDeniedBack) + }, + modifier = Modifier.fillMaxSize(), + ) + } + + else -> { + InterviewScreenOngoingLayer( + interviewSpeaker = state.speaker, + remainingSeconds = state.remainingSeconds, + canFinishedEarly = state.canFinishedEarly, + isInterviewOngoing = state.isInterviewOngoing, + onFinishRequest = { + onIntent(InterviewIntent.ClickFinishInterview) + }, + modifier = Modifier.fillMaxSize(), + ) + } + } + } +} + +@Preview( + showBackground = true, + backgroundColor = 0xFFFFFFFF, + widthDp = 375, + heightDp = 812, +) +@Composable +private fun InterviewScreenPreparingPreview() { + HilitTheme { + InterviewContent( + state = InterviewState(), + onIntent = {}, + ) + } +} + +@Preview( + showBackground = true, + backgroundColor = 0xFFFFFFFF, + widthDp = 375, + heightDp = 812, +) +@Composable +private fun InterviewScreenPreparedPreview() { + HilitTheme { + InterviewContent( + state = + InterviewState( + interviewScreenState = InterviewScreenState.PREPARED, + isInterviewReady = true, + ), + onIntent = {}, + ) + } +} + +@Preview( + showBackground = true, + backgroundColor = 0xFFFFFFFF, + widthDp = 375, + heightDp = 812, +) +@Composable +private fun InterviewScreenOngoingPreview() { + HilitTheme { + InterviewContent( + state = + InterviewState( + interviewScreenState = InterviewScreenState.ONGOING, + elapsedSeconds = 72, + isInterviewReady = true, + ), + onIntent = {}, + ) + } +} diff --git a/feature/interview/impl/src/main/kotlin/com/dminus14/app/feature/interview/interview/InterviewViewModel.kt b/feature/interview/impl/src/main/kotlin/com/dminus14/app/feature/interview/interview/InterviewViewModel.kt new file mode 100644 index 00000000..61fd6914 --- /dev/null +++ b/feature/interview/impl/src/main/kotlin/com/dminus14/app/feature/interview/interview/InterviewViewModel.kt @@ -0,0 +1,48 @@ +package com.dminus14.app.feature.interview.interview + +import com.dminus14.app.core.common.mvi.MviViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject + +@HiltViewModel +class InterviewViewModel + @Inject + constructor() : + MviViewModel(InterviewState()) { + @Suppress("CyclomaticComplexMethod") + override fun onIntent(intent: InterviewIntent) { + when (intent) { + InterviewIntent.CheckCameraPermission -> { + // 후속 구현: 기존 권한 처리 계층에서 CAMERA 권한 상태를 조회해 State에 반영한다. + } + + InterviewIntent.StartInterview -> { + reduce { + copy( + interviewScreenState = InterviewScreenState.ONGOING, + ) + } + } + + InterviewIntent.ChangeSpeaker -> { + reduce { + copy( + speaker = !speaker, + ) + } + } + + InterviewIntent.ReportCameraBindingFailure -> { + sendEffect(InterviewEffect.CameraBindingFailed) + } + + InterviewIntent.ClickPermissionDeniedBack -> { + sendEffect(InterviewEffect.PermissionDeniedExitRequested) + } + + InterviewIntent.ClickFinishInterview -> { + sendEffect(InterviewEffect.FinishRequested) + } + } + } + } diff --git a/feature/interview/impl/src/main/kotlin/com/dminus14/app/feature/interview/interview/layer/InterviewScreenBaseLayer.kt b/feature/interview/impl/src/main/kotlin/com/dminus14/app/feature/interview/interview/layer/InterviewScreenBaseLayer.kt new file mode 100644 index 00000000..62d7be54 --- /dev/null +++ b/feature/interview/impl/src/main/kotlin/com/dminus14/app/feature/interview/interview/layer/InterviewScreenBaseLayer.kt @@ -0,0 +1,61 @@ +package com.dminus14.app.feature.interview.interview.layer + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.dminus14.app.feature.interview.component.InterviewCameraFrame +import com.dminus14.app.feature.interview.component.InterviewCameraPreview +import com.dminus14.app.feature.interview.component.VideoOverlay +import com.dminus14.app.feature.interview.component.VideoOverlayDirection +import com.dminus14.designsystem.theme.HilitTheme + +@Composable +fun InterviewScreenBaseLayer( + isCameraPermissionGranted: Boolean, + modifier: Modifier = Modifier, + onCameraBindingFailed: () -> Unit = {}, +) { + Box( + modifier = modifier, + ) { + // Camera View + InterviewCameraPreview( + isCameraPermissionGranted = isCameraPermissionGranted, + onCameraBindingFailed = onCameraBindingFailed, + ) + + // Video Overlays + VideoOverlay( + direction = VideoOverlayDirection.TOP, + modifier = + Modifier + .fillMaxWidth() + .height(229.dp) + .align(Alignment.TopCenter), + ) + VideoOverlay( + direction = VideoOverlayDirection.BOTTOM, + modifier = + Modifier + .fillMaxWidth() + .height(229.dp) + .align(Alignment.BottomCenter), + ) + + // Camera Frame + InterviewCameraFrame(modifier = Modifier.align(Alignment.Center)) + } +} + +@Preview +@Composable +private fun InterviewScreenBaseLayerPreview() { + HilitTheme { + InterviewScreenBaseLayer(isCameraPermissionGranted = true) + } +} diff --git a/feature/interview/impl/src/main/kotlin/com/dminus14/app/feature/interview/interview/layer/InterviewScreenOngoingLayer.kt b/feature/interview/impl/src/main/kotlin/com/dminus14/app/feature/interview/interview/layer/InterviewScreenOngoingLayer.kt new file mode 100644 index 00000000..5a1622b4 --- /dev/null +++ b/feature/interview/impl/src/main/kotlin/com/dminus14/app/feature/interview/interview/layer/InterviewScreenOngoingLayer.kt @@ -0,0 +1,151 @@ +package com.dminus14.app.feature.interview.interview.layer + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +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.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.dminus14.app.feature.interview.component.InterviewSpeakerIndicator +import com.dminus14.app.feature.interview.component.InterviewTimer +import com.dminus14.app.feature.interview.interview.InterviewSpeaker +import com.dminus14.designsystem.component.button.HilitMiniButton +import com.dminus14.designsystem.component.button.HilitMiniButtonColor +import com.dminus14.designsystem.theme.HilitTheme + +@Suppress("LongParameterList") +@Composable +fun InterviewScreenOngoingLayer( + interviewSpeaker: InterviewSpeaker, + remainingSeconds: Int, + canFinishedEarly: Boolean, + isInterviewOngoing: Boolean, + modifier: Modifier = Modifier, + onFinishRequest: () -> Unit = {}, +) { + Box( + modifier = modifier, + ) { + // Timer + InterviewTimer( + remainingSeconds = remainingSeconds, + modifier = + Modifier + .align(Alignment.TopCenter) + .padding(top = 39.dp), + ) + + // Indicator + if (isInterviewOngoing) { + InterviewSpeakerIndicator( + speaker = interviewSpeaker, + modifier = + Modifier + .align(Alignment.BottomCenter) + .padding(bottom = 86.dp), + ) + } + + if (canFinishedEarly) { + HilitMiniButton( + color = HilitMiniButtonColor.Dark, + onClick = onFinishRequest, + enabled = isInterviewOngoing, + modifier = + Modifier + .align(Alignment.BottomEnd) + .padding(end = 20.dp, bottom = 10.dp), + ) { + Text(text = "면접 종료하기") + } + } + } +} + +@Preview(name = "시간이 충분하고 AI가 질문 중") +@Composable +private fun InterviewScreenOngoingLayerAIPreview() { + HilitTheme { + Box( + modifier = + Modifier + .fillMaxSize() + .background(color = HilitTheme.colors.gray300), + ) { + InterviewScreenOngoingLayer( + interviewSpeaker = InterviewSpeaker.AI, + remainingSeconds = 80, + isInterviewOngoing = true, + canFinishedEarly = false, + modifier = Modifier.fillMaxSize(), + ) + } + } +} + +@Preview(name = "시간이 충분하고 사용자가 답변 중") +@Composable +private fun InterviewScreenOngoingLayerUserPreview() { + HilitTheme { + Box( + modifier = + Modifier + .fillMaxSize() + .background(color = HilitTheme.colors.gray300), + ) { + InterviewScreenOngoingLayer( + interviewSpeaker = InterviewSpeaker.User, + remainingSeconds = 80, + isInterviewOngoing = true, + canFinishedEarly = false, + modifier = Modifier.fillMaxSize(), + ) + } + } +} + +@Preview(name = "시간이 부족하고 사용자가 답변 중") +@Composable +private fun InterviewScreenOngoingLayerNotEnoughTimePreview() { + HilitTheme { + Box( + modifier = + Modifier + .fillMaxSize() + .background(color = HilitTheme.colors.gray300), + ) { + InterviewScreenOngoingLayer( + interviewSpeaker = InterviewSpeaker.User, + remainingSeconds = 11, + isInterviewOngoing = true, + canFinishedEarly = false, + modifier = Modifier.fillMaxSize(), + ) + } + } +} + +@Preview(name = "시간이 12분을 넘어 면접 종료") +@Composable +private fun InterviewScreenOngoingLayerNoTimePreview() { + HilitTheme { + Box( + modifier = + Modifier + .fillMaxSize() + .background(color = HilitTheme.colors.gray300), + ) { + InterviewScreenOngoingLayer( + interviewSpeaker = InterviewSpeaker.User, + remainingSeconds = -11, + isInterviewOngoing = false, + canFinishedEarly = false, + modifier = Modifier.fillMaxSize(), + ) + } + } +} diff --git a/feature/interview/impl/src/main/kotlin/com/dminus14/app/feature/interview/interview/layer/InterviewScreenPrepareLayer.kt b/feature/interview/impl/src/main/kotlin/com/dminus14/app/feature/interview/interview/layer/InterviewScreenPrepareLayer.kt new file mode 100644 index 00000000..51e2975f --- /dev/null +++ b/feature/interview/impl/src/main/kotlin/com/dminus14/app/feature/interview/interview/layer/InterviewScreenPrepareLayer.kt @@ -0,0 +1,206 @@ +package com.dminus14.app.feature.interview.interview.layer + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.dminus14.app.feature.interview.component.InterviewReadinessIndicator +import com.dminus14.app.feature.interview.component.InterviewStartButton +import com.dminus14.app.feature.interview.interview.InterviewScreenState +import com.dminus14.designsystem.component.icon.HilitIcon +import com.dminus14.designsystem.component.icon.HilitIconAsset +import com.dminus14.designsystem.component.text.HilitText +import com.dminus14.designsystem.component.text.withHilitTextHighlight +import com.dminus14.designsystem.theme.HilitTheme + +@Suppress("LongParameterList") +@Composable +fun InterviewScreenPrepareLayer( + isReady: Boolean, + isPermissionGranted: Boolean, + interviewScreenState: InterviewScreenState, + onInterviewStart: () -> Unit, + onPermissionDeniedBack: () -> Unit, + modifier: Modifier = Modifier, +) { + val preparingText = buildAnnotatedString { append("면접을 준비하고 있어요") } + val soundGuideText = + buildAnnotatedString { + append("질문은 ") + withHilitTextHighlight { append("소리") } + append("로만 나와요") + } + /* + val timeGuideText = + buildAnnotatedString { + append("면접은 총 ") + withHilitTextHighlight { append("10분") } + append("으로 진행돼요") + } + */ + val titleText: AnnotatedString = + if (interviewScreenState == InterviewScreenState.PREPARED) { + soundGuideText + } else { + preparingText + } + + Box( + modifier = modifier, + ) { + // Title Text + HilitText( + text = titleText, + style = getTitleTextStyle(), + modifier = + Modifier + .fillMaxWidth() + .padding(top = 74.dp), + ) + + // Loading Indicator + if (interviewScreenState == InterviewScreenState.PREPARING || + interviewScreenState == InterviewScreenState.ALMOST_PREPARED + ) { + InterviewReadinessIndicator( + isReady = isReady, + modifier = + Modifier + .fillMaxWidth() + .align(Alignment.BottomCenter) + .padding(bottom = 94.dp), + ) + } + + // Back Button + if (!isPermissionGranted) { + HilitIcon( + asset = HilitIconAsset.Left, + contentDescription = "Go back", + tint = HilitTheme.colors.hilitWhite, + modifier = + Modifier + .align(Alignment.TopStart) + .clickable(onClick = onPermissionDeniedBack) + .padding(start = 20.dp, top = 10.dp), + ) + } + + // Bottom Button + if (interviewScreenState == InterviewScreenState.PREPARED) { + InterviewStartButton( + isReady = isReady, + onClick = onInterviewStart, + modifier = Modifier.align(Alignment.BottomCenter), + ) + } + } +} + +@Composable +private fun getTitleTextStyle(): TextStyle = + HilitTheme.typography.head3.copy( + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center, + color = HilitTheme.colors.hilitWhite, + ) + +@Preview +@Composable +private fun InterviewScreenPreparingLayerPreview() { + HilitTheme { + Box( + modifier = + Modifier + .fillMaxSize() + .background(color = HilitTheme.colors.gray300), + ) { + InterviewScreenPrepareLayer( + isReady = false, + isPermissionGranted = true, + interviewScreenState = InterviewScreenState.PREPARING, + onInterviewStart = {}, + onPermissionDeniedBack = {}, + modifier = Modifier.fillMaxSize(), + ) + } + } +} + +@Preview +@Composable +private fun InterviewScreenPrepareLayerAlmostPreparedPreview() { + HilitTheme { + Box( + modifier = + Modifier + .fillMaxSize() + .background(color = HilitTheme.colors.gray300), + ) { + InterviewScreenPrepareLayer( + isReady = true, + isPermissionGranted = true, + interviewScreenState = InterviewScreenState.ALMOST_PREPARED, + onInterviewStart = {}, + onPermissionDeniedBack = {}, + modifier = Modifier.fillMaxSize(), + ) + } + } +} + +@Preview +@Composable +private fun InterviewScreenPrepareLayerPermissionNotGrantedPreview() { + HilitTheme { + Box( + modifier = + Modifier + .fillMaxSize() + .background(color = HilitTheme.colors.gray300), + ) { + InterviewScreenPrepareLayer( + isReady = true, + isPermissionGranted = false, + interviewScreenState = InterviewScreenState.PREPARING, + onInterviewStart = {}, + onPermissionDeniedBack = {}, + modifier = Modifier.fillMaxSize(), + ) + } + } +} + +@Preview +@Composable +private fun InterviewScreenPrepareLayerPreparedPreview() { + HilitTheme { + Box( + modifier = + Modifier + .fillMaxSize() + .background(color = HilitTheme.colors.gray300), + ) { + InterviewScreenPrepareLayer( + isReady = true, + isPermissionGranted = true, + interviewScreenState = InterviewScreenState.PREPARED, + onInterviewStart = {}, + onPermissionDeniedBack = {}, + modifier = Modifier.fillMaxSize(), + ) + } + } +} diff --git a/feature/interview/impl/src/main/kotlin/com/dminus14/app/feature/interview/navigation/InterviewEntryBuilder.kt b/feature/interview/impl/src/main/kotlin/com/dminus14/app/feature/interview/navigation/InterviewEntryBuilder.kt new file mode 100644 index 00000000..f9166640 --- /dev/null +++ b/feature/interview/impl/src/main/kotlin/com/dminus14/app/feature/interview/navigation/InterviewEntryBuilder.kt @@ -0,0 +1,24 @@ +package com.dminus14.app.feature.interview.navigation + +import androidx.navigation3.runtime.EntryProviderScope +import com.dminus14.app.feature.interview.api.InterviewErrorRoute +import com.dminus14.app.feature.interview.api.InterviewRoute +import com.dminus14.app.feature.interview.error.InterviewErrorScreen +import com.dminus14.app.feature.interview.interview.InterviewScreen + +fun EntryProviderScope.interviewEntryBuilder( + onNavigateHome: () -> Unit, + onResumeInterview: () -> Unit, +) { + entry { + InterviewScreen(onNavigateHome = onNavigateHome) + } + + entry { key -> + InterviewErrorScreen( + errorType = key.errorType, + onNavigateHome = onNavigateHome, + onResumeInterview = onResumeInterview, + ) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 86af1a25..1e69fe50 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -14,6 +14,7 @@ composeBom = "2026.06.01" mockk = "1.14.11" pdfBoxAndroid = "2.0.27.0" media3 = "1.10.1" +camerax = "1.4.1" # Navigation navigation3 = "1.1.4" @@ -75,6 +76,10 @@ pdfbox-android = { group = "com.tom-roush", name = "pdfbox-android", version.ref androidx-media3-exoplayer = { group = "androidx.media3", name = "media3-exoplayer", version.ref = "media3" } androidx-media3-effect = { group = "androidx.media3", name = "media3-effect", version.ref = "media3" } androidx-media3-ui-compose = { group = "androidx.media3", name = "media3-ui-compose", version.ref = "media3" } +androidx-camera-core = { group = "androidx.camera", name = "camera-core", version.ref = "camerax" } +androidx-camera-camera2 = { group = "androidx.camera", name = "camera-camera2", version.ref = "camerax" } +androidx-camera-lifecycle = { group = "androidx.camera", name = "camera-lifecycle", version.ref = "camerax" } +androidx-camera-view = { group = "androidx.camera", name = "camera-view", version.ref = "camerax" } kotlinx-coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "coroutines" } kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutines" } @@ -142,6 +147,7 @@ android-library = { id = "com.android.library", version.ref = "agp" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } +kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } # Linting and Formatting spotless = { id = "com.diffplug.spotless", version.ref = "spotless" } diff --git a/settings.gradle.kts b/settings.gradle.kts index a078119d..bd9eb8f5 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -106,6 +106,8 @@ include(":feature:mypage:api") include(":feature:mypage:impl") include(":feature:onboarding:api") include(":feature:onboarding:impl") +include(":feature:interview:api") +include(":feature:interview:impl") include(":core:permission") include(":core:common") include(":designsystem")