From 8441d9cdfa665293e5d69777fa0b73007e843362 Mon Sep 17 00:00:00 2001 From: Shawn Kang Date: Thu, 6 Aug 2026 23:39:32 +0900 Subject: [PATCH 01/17] =?UTF-8?q?design:=20=EC=83=88=EB=A1=9C=EC=9A=B4=20?= =?UTF-8?q?=EB=B2=A1=ED=84=B0=20=EC=97=90=EC=85=8B=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../composeResources/drawable/alert_red.xml | 15 ++++++++++ .../composeResources/drawable/finish.xml | 29 +++++++++++++++++++ .../composeResources/drawable/mic.xml | 19 ++++++++++++ .../composeResources/drawable/network.xml | 13 +++++++++ .../designsystem/component/icon/HilitIcon.kt | 8 +++++ 5 files changed, 84 insertions(+) create mode 100644 core/resources/src/commonMain/composeResources/drawable/alert_red.xml create mode 100644 core/resources/src/commonMain/composeResources/drawable/finish.xml create mode 100644 core/resources/src/commonMain/composeResources/drawable/mic.xml create mode 100644 core/resources/src/commonMain/composeResources/drawable/network.xml 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/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), } /** From ab2d04827acaec3fe68ccea9f5684a937dc6cd1b Mon Sep 17 00:00:00 2001 From: Shawn Kang Date: Thu, 6 Aug 2026 23:41:47 +0900 Subject: [PATCH 02/17] =?UTF-8?q?design:=20=EA=B8=B0=EC=A1=B4=20=EA=B3=B5?= =?UTF-8?q?=EC=9A=A9=20Composable=20=EC=9D=BC=EB=B6=80=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../component/button/HilitFixedBottomButton.kt | 8 ++++---- .../component/button/HilitMiniButton.kt | 18 +++++++++++++++--- .../designsystem/component/modal/HilitModal.kt | 2 +- .../component/text/HilitTextHighlight.kt | 8 ++++++++ 4 files changed, 28 insertions(+), 8 deletions(-) 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/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 { From 52f7dff11c713149a20691263992ef5baf18d663 Mon Sep 17 00:00:00 2001 From: Shawn Kang Date: Thu, 6 Aug 2026 23:46:13 +0900 Subject: [PATCH 03/17] =?UTF-8?q?chore:=20=EC=9D=98=EC=A1=B4=EC=84=B1=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20=EB=B0=8F=20CLAUDE=20=EB=8C=80=EC=9D=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 6 ++++-- CLAUDE.md | 1 + gradle/libs.versions.toml | 5 +++++ 3 files changed, 10 insertions(+), 2 deletions(-) create mode 100644 CLAUDE.md 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/gradle/libs.versions.toml b/gradle/libs.versions.toml index 86af1a25..6e91b51a 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" } From 3b8942ed4137b31587770f135c80b2d7f3aad0b1 Mon Sep 17 00:00:00 2001 From: Shawn Kang Date: Thu, 6 Aug 2026 23:49:21 +0900 Subject: [PATCH 04/17] =?UTF-8?q?feat:=20`:feature:interview:api`=20?= =?UTF-8?q?=EB=AA=A8=EB=93=88=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- feature/interview/api/build.gradle.kts | 4 ++++ .../app/feature/interview/api/InterviewRoute.kt | 15 +++++++++++++++ settings.gradle.kts | 1 + 3 files changed, 20 insertions(+) create mode 100644 feature/interview/api/build.gradle.kts create mode 100644 feature/interview/api/src/main/kotlin/com/dminus14/app/feature/interview/api/InterviewRoute.kt diff --git a/feature/interview/api/build.gradle.kts b/feature/interview/api/build.gradle.kts new file mode 100644 index 00000000..18c58e09 --- /dev/null +++ b/feature/interview/api/build.gradle.kts @@ -0,0 +1,4 @@ +plugins { + alias(libs.plugins.dminus14.jvm.library) + alias(libs.plugins.dminus14.kotlin.quality) +} 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..3fe2ba37 --- /dev/null +++ b/feature/interview/api/src/main/kotlin/com/dminus14/app/feature/interview/api/InterviewRoute.kt @@ -0,0 +1,15 @@ +package com.dminus14.app.feature.interview.api + +/** 면접 진행 화면 route */ +data object InterviewRoute + +/** 면접 오류 유형 */ +enum class InterviewErrorType { + MIC, + NETWORK, +} + +/** 면접 오류 화면 route */ +data class InterviewErrorRoute( + val errorType: InterviewErrorType, +) diff --git a/settings.gradle.kts b/settings.gradle.kts index a078119d..922858d9 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -106,6 +106,7 @@ include(":feature:mypage:api") include(":feature:mypage:impl") include(":feature:onboarding:api") include(":feature:onboarding:impl") +include(":feature:interview:api") include(":core:permission") include(":core:common") include(":designsystem") From f7347c46f365f4dd58bc4f37bf8b235d96d05ce6 Mon Sep 17 00:00:00 2001 From: Shawn Kang Date: Thu, 6 Aug 2026 23:51:02 +0900 Subject: [PATCH 05/17] =?UTF-8?q?feat:=20`:feature:interview:impl`=20?= =?UTF-8?q?=EB=AA=A8=EB=93=88=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- feature/interview/impl/build.gradle.kts | 20 ++++++++++++++ .../feature/interview/InterviewConstants.kt | 10 +++++++ .../interview/di/InterviewNavigationModule.kt | 25 +++++++++++++++++ .../navigation/InterviewEntryBuilder.kt | 27 +++++++++++++++++++ settings.gradle.kts | 1 + 5 files changed, 83 insertions(+) create mode 100644 feature/interview/impl/build.gradle.kts create mode 100644 feature/interview/impl/src/main/kotlin/com/dminus14/app/feature/interview/InterviewConstants.kt create mode 100644 feature/interview/impl/src/main/kotlin/com/dminus14/app/feature/interview/di/InterviewNavigationModule.kt create mode 100644 feature/interview/impl/src/main/kotlin/com/dminus14/app/feature/interview/navigation/InterviewEntryBuilder.kt 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/di/InterviewNavigationModule.kt b/feature/interview/impl/src/main/kotlin/com/dminus14/app/feature/interview/di/InterviewNavigationModule.kt new file mode 100644 index 00000000..d2b6c641 --- /dev/null +++ b/feature/interview/impl/src/main/kotlin/com/dminus14/app/feature/interview/di/InterviewNavigationModule.kt @@ -0,0 +1,25 @@ +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 = {}, + onOpenAppSettings = {}, + onNavigateError = {}, + onResumeInterview = {}, + ) + } +} 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..bb530fb4 --- /dev/null +++ b/feature/interview/impl/src/main/kotlin/com/dminus14/app/feature/interview/navigation/InterviewEntryBuilder.kt @@ -0,0 +1,27 @@ +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.InterviewErrorType +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, + onOpenAppSettings: () -> Unit, + onNavigateError: (InterviewErrorType) -> Unit, + onResumeInterview: () -> Unit, +) { + entry { + InterviewScreen() + } + + entry { key -> + InterviewErrorScreen( + errorType = key.errorType, + onNavigateHome = onNavigateHome, + onResumeInterview = onResumeInterview, + ) + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 922858d9..bd9eb8f5 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -107,6 +107,7 @@ 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") From dfdccf3f366946f367182ae17e4a754c9f7f683f Mon Sep 17 00:00:00 2001 From: Shawn Kang Date: Thu, 6 Aug 2026 23:52:39 +0900 Subject: [PATCH 06/17] =?UTF-8?q?design:=20=ED=95=98=EC=9C=84=20Composable?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../component/InterviewAbortModal.kt | 42 ++++++ .../component/InterviewCameraFrame.kt | 134 +++++++++++++++++ .../component/InterviewCameraPreview.kt | 136 ++++++++++++++++++ .../component/InterviewCompletableBubble.kt | 31 ++++ .../component/InterviewFinishModal.kt | 50 +++++++ .../component/InterviewReadinessIndicator.kt | 120 ++++++++++++++++ .../component/InterviewSpeakerIndicator.kt | 81 +++++++++++ .../component/InterviewStartButton.kt | 45 ++++++ .../interview/component/InterviewTimer.kt | 80 +++++++++++ .../interview/component/TrapezoidShape.kt | 31 ++++ .../interview/component/VideoOverlay.kt | 75 ++++++++++ 11 files changed, 825 insertions(+) create mode 100644 feature/interview/impl/src/main/kotlin/com/dminus14/app/feature/interview/component/InterviewAbortModal.kt create mode 100644 feature/interview/impl/src/main/kotlin/com/dminus14/app/feature/interview/component/InterviewCameraFrame.kt create mode 100644 feature/interview/impl/src/main/kotlin/com/dminus14/app/feature/interview/component/InterviewCameraPreview.kt create mode 100644 feature/interview/impl/src/main/kotlin/com/dminus14/app/feature/interview/component/InterviewCompletableBubble.kt create mode 100644 feature/interview/impl/src/main/kotlin/com/dminus14/app/feature/interview/component/InterviewFinishModal.kt create mode 100644 feature/interview/impl/src/main/kotlin/com/dminus14/app/feature/interview/component/InterviewReadinessIndicator.kt create mode 100644 feature/interview/impl/src/main/kotlin/com/dminus14/app/feature/interview/component/InterviewSpeakerIndicator.kt create mode 100644 feature/interview/impl/src/main/kotlin/com/dminus14/app/feature/interview/component/InterviewStartButton.kt create mode 100644 feature/interview/impl/src/main/kotlin/com/dminus14/app/feature/interview/component/InterviewTimer.kt create mode 100644 feature/interview/impl/src/main/kotlin/com/dminus14/app/feature/interview/component/TrapezoidShape.kt create mode 100644 feature/interview/impl/src/main/kotlin/com/dminus14/app/feature/interview/component/VideoOverlay.kt 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..783a74ed --- /dev/null +++ b/feature/interview/impl/src/main/kotlin/com/dminus14/app/feature/interview/component/InterviewCameraFrame.kt @@ -0,0 +1,134 @@ +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.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.dminus14.designsystem.theme.HilitTheme + +/** + * Figma `camera-frame` preset (Figma Node: `683:9220`, `435:821`). + * + * 카메라 뷰 중앙에 위치하는 코너 브래킷 가이드 라인 및 가이드 텍스트. + */ +@Composable +fun InterviewCameraFrame( + modifier: Modifier = Modifier, + text: String = "얼굴을 여기에 맞춰주세요", + showText: Boolean = true, + frameSize: Dp = 327.dp, + cornerLength: Dp = 35.dp, + strokeWidth: Dp = 5.dp, + color: Color = HilitTheme.colors.gray300.copy(alpha = 0xAC / 255f), +) { + Box( + modifier = modifier.size(frameSize), + contentAlignment = Alignment.Center, + ) { + Canvas(modifier = Modifier.fillMaxSize()) { + val strokePx = strokeWidth.toPx() + val cornerPx = cornerLength.toPx() + val width = size.width + val height = size.height + + // Top-Left corner + drawLine( + color = color, + start = Offset(0f, 0f), + end = Offset(cornerPx, 0f), + strokeWidth = strokePx, + cap = StrokeCap.Square, + ) + drawLine( + color = color, + start = Offset(0f, 0f), + end = Offset(0f, cornerPx), + strokeWidth = strokePx, + cap = StrokeCap.Square, + ) + + // Top-Right corner + drawLine( + color = color, + start = Offset(width, 0f), + end = Offset(width - cornerPx, 0f), + strokeWidth = strokePx, + cap = StrokeCap.Square, + ) + drawLine( + color = color, + start = Offset(width, 0f), + end = Offset(width, cornerPx), + strokeWidth = strokePx, + cap = StrokeCap.Square, + ) + + // Bottom-Left corner + drawLine( + color = color, + start = Offset(0f, height), + end = Offset(cornerPx, height), + strokeWidth = strokePx, + cap = StrokeCap.Square, + ) + drawLine( + color = color, + start = Offset(0f, height), + end = Offset(0f, height - cornerPx), + strokeWidth = strokePx, + cap = StrokeCap.Square, + ) + + // Bottom-Right corner + drawLine( + color = color, + start = Offset(width, height), + end = Offset(width - cornerPx, height), + strokeWidth = strokePx, + cap = StrokeCap.Square, + ) + drawLine( + color = color, + start = Offset(width, height), + end = Offset(width, height - cornerPx), + strokeWidth = strokePx, + cap = StrokeCap.Square, + ) + } + + if (showText && 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..1e572111 --- /dev/null +++ b/feature/interview/impl/src/main/kotlin/com/dminus14/app/feature/interview/component/InterviewCameraPreview.kt @@ -0,0 +1,136 @@ +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.remember +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 + +/** + * 전면 카메라 프리뷰 (Video-only, Portrait Fixed). + * + * `@Preview` 환경이나 카메라 미작동 시 Mock Placeholder UI를 노출하여 화면 렌더링을 보장한다. + */ +@Composable +fun InterviewCameraPreview( + modifier: Modifier = Modifier, + isCameraPermissionGranted: Boolean = true, +) { + val isPreview = LocalInspectionMode.current + + if (isPreview || !isCameraPermissionGranted) { + CameraPreviewMock(modifier = modifier) + } else { + CameraPreviewReal(modifier = modifier) + } +} + +@Composable +private fun CameraPreviewReal(modifier: Modifier = Modifier) { + val context = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current + 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) + + cameraProviderFuture.addListener( + { + val cameraProvider = cameraProviderFuture.get() + val preview = + Preview.Builder().build().also { + it.surfaceProvider = previewView.surfaceProvider + } + val cameraSelector = CameraSelector.DEFAULT_FRONT_CAMERA + + try { + cameraProvider.unbindAll() + cameraProvider.bindToLifecycle( + lifecycleOwner, + cameraSelector, + preview, + ) + } catch (_: Exception) { + // Camera binding fallback + } + }, + executor, + ) + + onDispose { + try { + val cameraProvider = cameraProviderFuture.get() + cameraProvider.unbindAll() + } 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(4) { + 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..eb158940 --- /dev/null +++ b/feature/interview/impl/src/main/kotlin/com/dminus14/app/feature/interview/component/InterviewReadinessIndicator.kt @@ -0,0 +1,120 @@ +package com.dminus14.app.feature.interview.component + +import androidx.compose.animation.animateColorAsState +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.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 + +private const val READINESS_TEXT = "곧 면접이 시작됩니다" +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 centerColor by + animateColorAsState( + targetValue = + when (isReady) { + false -> HilitTheme.colors.gray600 + true -> HilitTheme.colors.gray50 + }, + label = "InterviewReadinessIndicatorCenterColor", + ) + + 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 contentWidth = placeables.sumOf { it.width } + gapPx * (placeables.size - 1) + val height = placeables.maxOf { it.height } + + layout(rowWidth, height) { + var x = (rowWidth - contentWidth) / 2 + placeables.forEach { placeable -> + placeable.placeRelative(x, (height - placeable.height) / 2) + x += placeable.width + gapPx + } + } + } +} + +@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..458641f9 --- /dev/null +++ b/feature/interview/impl/src/main/kotlin/com/dminus14/app/feature/interview/component/InterviewTimer.kt @@ -0,0 +1,80 @@ +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 + +/** + * 면접 타이머. + * + * Figma 시안: + * - 60초 초과: 683:9276 + * - 60초 이하: 683:9308 + */ +@Composable +fun InterviewTimer( + remainingSeconds: Int, + modifier: Modifier = Modifier, +) { + val isWarning = (remainingSeconds <= 60) + val bgColor = if (isWarning) HilitTheme.colors.error200 else HilitTheme.colors.gray50 + val textColor = if (isWarning) HilitTheme.colors.error500 else HilitTheme.colors.gray500 + + // Format MM:SS + val minutes = remainingSeconds / 60 + val seconds = remainingSeconds % 60 + val format = if (remainingSeconds <= 60) "%d초" else "%d:%02d" + val timeText = String.format(Locale.US, format, 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, + ) + } +} From 08404204a8f4e286f253aefe82b19b3a537df89c Mon Sep 17 00:00:00 2001 From: Shawn Kang Date: Thu, 6 Aug 2026 23:56:36 +0900 Subject: [PATCH 07/17] =?UTF-8?q?feat:=20=EB=A9=B4=EC=A0=91=20=EC=98=A4?= =?UTF-8?q?=EB=A5=98=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../interview/error/InterviewErrorContract.kt | 25 ++ .../interview/error/InterviewErrorScreen.kt | 223 ++++++++++++++++++ .../error/InterviewErrorViewModel.kt | 25 ++ 3 files changed, 273 insertions(+) create mode 100644 feature/interview/impl/src/main/kotlin/com/dminus14/app/feature/interview/error/InterviewErrorContract.kt create mode 100644 feature/interview/impl/src/main/kotlin/com/dminus14/app/feature/interview/error/InterviewErrorScreen.kt create mode 100644 feature/interview/impl/src/main/kotlin/com/dminus14/app/feature/interview/error/InterviewErrorViewModel.kt 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..0e4d9d86 --- /dev/null +++ b/feature/interview/impl/src/main/kotlin/com/dminus14/app/feature/interview/error/InterviewErrorScreen.kt @@ -0,0 +1,223 @@ +@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, + viewModel: InterviewErrorViewModel = hiltViewModel(), +) { + val state by viewModel.state.collectAsStateWithLifecycle() + + LaunchedEffect(errorType) { + viewModel.initErrorType(errorType) + viewModel.effect.collect { effect -> + when (effect) { + InterviewErrorEffect.NavigateToHome -> onNavigateHome() + InterviewErrorEffect.ResumeInterview -> onResumeInterview() + } + } + } + + InterviewErrorContent( + state = state, + onIntent = viewModel::onIntent, + ) +} + +/** 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) } + } + } From 1d15c3533053bfe7262ef893441db6dfdd9bbecf Mon Sep 17 00:00:00 2001 From: Shawn Kang Date: Thu, 6 Aug 2026 23:58:29 +0900 Subject: [PATCH 08/17] =?UTF-8?q?feat:=20=EB=A9=B4=EC=A0=91=20=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../interview/interview/InterviewContract.kt | 55 +++++ .../interview/interview/InterviewScreen.kt | 123 +++++++++++ .../interview/interview/InterviewViewModel.kt | 37 ++++ .../layer/InterviewScreenBaseLayer.kt | 54 +++++ .../layer/InterviewScreenOngoingLayer.kt | 149 +++++++++++++ .../layer/InterviewScreenPrepareLayer.kt | 196 ++++++++++++++++++ 6 files changed, 614 insertions(+) create mode 100644 feature/interview/impl/src/main/kotlin/com/dminus14/app/feature/interview/interview/InterviewContract.kt create mode 100644 feature/interview/impl/src/main/kotlin/com/dminus14/app/feature/interview/interview/InterviewScreen.kt create mode 100644 feature/interview/impl/src/main/kotlin/com/dminus14/app/feature/interview/interview/InterviewViewModel.kt create mode 100644 feature/interview/impl/src/main/kotlin/com/dminus14/app/feature/interview/layer/InterviewScreenBaseLayer.kt create mode 100644 feature/interview/impl/src/main/kotlin/com/dminus14/app/feature/interview/layer/InterviewScreenOngoingLayer.kt create mode 100644 feature/interview/impl/src/main/kotlin/com/dminus14/app/feature/interview/layer/InterviewScreenPrepareLayer.kt 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..79d3f893 --- /dev/null +++ b/feature/interview/impl/src/main/kotlin/com/dminus14/app/feature/interview/interview/InterviewContract.kt @@ -0,0 +1,55 @@ +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.AI + InterviewSpeaker.User -> InterviewSpeaker.User + } +} + +/** 면접 진행 단계 */ +enum class InterviewScreenState { + PREPARING, + ALMOST_PREPARED, + PREPARED, + ONGOING, + FINISHED, +} + +/** 면접 MVI Intent */ +sealed interface InterviewIntent : MviIntent { + data object StartInterview : InterviewIntent + + data object ChangeSpeaker : InterviewIntent +} + +/** 면접 MVI State */ +data class InterviewState( + var interviewScreenState: InterviewScreenState = InterviewScreenState.PREPARING, + var isInterviewReady: Boolean = false, + var speaker: InterviewSpeaker = InterviewSpeaker.AI, + val elapsedSeconds: Int = 0, +) : MviState { + val remainingSeconds: Int + get() = InterviewConstants.MAX_INTERVIEW_SECONDS - elapsedSeconds + + val canFinishedEarly: Boolean + get() = InterviewConstants.CAN_FINISH_INTERVIEW_SECONDS - elapsedSeconds < 0 + + val isInterviewOngoing: Boolean + get() = isInterviewReady && remainingSeconds > 0 +} + +/** 면접 MVI Effect */ +sealed interface InterviewEffect : MviEffect 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..c4c99531 --- /dev/null +++ b/feature/interview/impl/src/main/kotlin/com/dminus14/app/feature/interview/interview/InterviewScreen.kt @@ -0,0 +1,123 @@ +@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.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.layer.InterviewScreenBaseLayer +import com.dminus14.app.feature.interview.layer.InterviewScreenOngoingLayer +import com.dminus14.app.feature.interview.layer.InterviewScreenPrepareLayer +import com.dminus14.designsystem.theme.HilitTheme + +@Composable +fun InterviewScreen( + modifier: Modifier = Modifier, + viewModel: InterviewViewModel = hiltViewModel(), +) { + val state by viewModel.state.collectAsStateWithLifecycle() + + InterviewContent( + state = state, + onIntent = viewModel::onIntent, + modifier = modifier.fillMaxSize(), + ) +} + +@Composable +fun InterviewContent( + state: InterviewState, + onIntent: (InterviewIntent) -> Unit, + modifier: Modifier = Modifier, +) { + Box(modifier = modifier) { + InterviewScreenBaseLayer(modifier = Modifier.fillMaxSize()) + + when (state.interviewScreenState) { + InterviewScreenState.PREPARING, + InterviewScreenState.ALMOST_PREPARED, + InterviewScreenState.PREPARED, + -> { + InterviewScreenPrepareLayer( + isReady = state.isInterviewReady, + isPermissionGranted = true, + interviewScreenState = state.interviewScreenState, + onInterviewStart = { onIntent(InterviewIntent.StartInterview) }, + modifier = Modifier.fillMaxSize(), + ) + } + + else -> { + InterviewScreenOngoingLayer( + interviewSpeaker = state.speaker, + remainingSeconds = state.remainingSeconds, + canFinishedEarly = state.canFinishedEarly, + isInterviewOngoing = state.isInterviewOngoing, + 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..7ceca594 --- /dev/null +++ b/feature/interview/impl/src/main/kotlin/com/dminus14/app/feature/interview/interview/InterviewViewModel.kt @@ -0,0 +1,37 @@ +package com.dminus14.app.feature.interview.interview + +import com.dminus14.app.core.common.mvi.MviViewModel +import com.dminus14.app.core.permission.PermissionManager +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject + +private const val TOAST_8MIN_DURATION_MS = 5000L +private const val TOAST_12MIN_DURATION_MS = 3000L + +@HiltViewModel +class InterviewViewModel + @Inject + constructor( + private val permissionManager: PermissionManager, + ) : MviViewModel(InterviewState()) { + @Suppress("CyclomaticComplexMethod") + override fun onIntent(intent: InterviewIntent) { + when (intent) { + InterviewIntent.StartInterview -> { + reduce { + copy( + interviewScreenState = InterviewScreenState.ONGOING, + ) + } + } + + InterviewIntent.ChangeSpeaker -> { + reduce { + copy( + speaker = !speaker, + ) + } + } + } + } + } diff --git a/feature/interview/impl/src/main/kotlin/com/dminus14/app/feature/interview/layer/InterviewScreenBaseLayer.kt b/feature/interview/impl/src/main/kotlin/com/dminus14/app/feature/interview/layer/InterviewScreenBaseLayer.kt new file mode 100644 index 00000000..b9af26c6 --- /dev/null +++ b/feature/interview/impl/src/main/kotlin/com/dminus14/app/feature/interview/layer/InterviewScreenBaseLayer.kt @@ -0,0 +1,54 @@ +package com.dminus14.app.feature.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(modifier: Modifier = Modifier) { + Box( + modifier = modifier, + ) { + // Camera View + InterviewCameraPreview() + + // 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() + } +} diff --git a/feature/interview/impl/src/main/kotlin/com/dminus14/app/feature/interview/layer/InterviewScreenOngoingLayer.kt b/feature/interview/impl/src/main/kotlin/com/dminus14/app/feature/interview/layer/InterviewScreenOngoingLayer.kt new file mode 100644 index 00000000..7df2c3ae --- /dev/null +++ b/feature/interview/impl/src/main/kotlin/com/dminus14/app/feature/interview/layer/InterviewScreenOngoingLayer.kt @@ -0,0 +1,149 @@ +package com.dminus14.app.feature.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 + +@Composable +fun InterviewScreenOngoingLayer( + interviewSpeaker: InterviewSpeaker, + remainingSeconds: Int, + canFinishedEarly: Boolean, + isInterviewOngoing: Boolean, + modifier: Modifier = Modifier, +) { + 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 = {}, + 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/layer/InterviewScreenPrepareLayer.kt b/feature/interview/impl/src/main/kotlin/com/dminus14/app/feature/interview/layer/InterviewScreenPrepareLayer.kt new file mode 100644 index 00000000..844d05b6 --- /dev/null +++ b/feature/interview/impl/src/main/kotlin/com/dminus14/app/feature/interview/layer/InterviewScreenPrepareLayer.kt @@ -0,0 +1,196 @@ +package com.dminus14.app.feature.interview.layer + +import androidx.compose.foundation.background +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 + +@Composable +fun InterviewScreenPrepareLayer( + isReady: Boolean, + isPermissionGranted: Boolean, + interviewScreenState: InterviewScreenState, + onInterviewStart: () -> 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) + .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 = {}, + 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 = {}, + 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 = {}, + 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 = {}, + modifier = Modifier.fillMaxSize(), + ) + } + } +} From 6121da2aca13f928f8cb386f99161ad1d594ef2a Mon Sep 17 00:00:00 2001 From: Shawn Kang Date: Fri, 7 Aug 2026 00:29:23 +0900 Subject: [PATCH 09/17] =?UTF-8?q?fix:=20CI=20=EC=98=A4=EB=A5=98=20?= =?UTF-8?q?=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../InterviewRemoteDataSourceTest.kt | 3 +- .../component/InterviewCameraFrame.kt | 111 ++++++------------ .../component/InterviewCameraPreview.kt | 4 +- .../interview/component/InterviewTimer.kt | 10 +- .../interview/di/InterviewNavigationModule.kt | 2 - .../interview/interview/InterviewViewModel.kt | 9 +- .../layer/InterviewScreenPrepareLayer.kt | 4 +- .../navigation/InterviewEntryBuilder.kt | 2 - 8 files changed, 54 insertions(+), 91 deletions(-) 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/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 index 783a74ed..65784268 100644 --- 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 @@ -13,12 +13,44 @@ 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 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`). * @@ -28,88 +60,21 @@ import com.dminus14.designsystem.theme.HilitTheme fun InterviewCameraFrame( modifier: Modifier = Modifier, text: String = "얼굴을 여기에 맞춰주세요", - showText: Boolean = true, - frameSize: Dp = 327.dp, - cornerLength: Dp = 35.dp, - strokeWidth: Dp = 5.dp, color: Color = HilitTheme.colors.gray300.copy(alpha = 0xAC / 255f), ) { Box( - modifier = modifier.size(frameSize), + modifier = modifier.size(327.dp), contentAlignment = Alignment.Center, ) { Canvas(modifier = Modifier.fillMaxSize()) { - val strokePx = strokeWidth.toPx() - val cornerPx = cornerLength.toPx() - val width = size.width - val height = size.height - - // Top-Left corner - drawLine( - color = color, - start = Offset(0f, 0f), - end = Offset(cornerPx, 0f), - strokeWidth = strokePx, - cap = StrokeCap.Square, - ) - drawLine( - color = color, - start = Offset(0f, 0f), - end = Offset(0f, cornerPx), - strokeWidth = strokePx, - cap = StrokeCap.Square, - ) - - // Top-Right corner - drawLine( - color = color, - start = Offset(width, 0f), - end = Offset(width - cornerPx, 0f), - strokeWidth = strokePx, - cap = StrokeCap.Square, - ) - drawLine( - color = color, - start = Offset(width, 0f), - end = Offset(width, cornerPx), - strokeWidth = strokePx, - cap = StrokeCap.Square, - ) - - // Bottom-Left corner - drawLine( - color = color, - start = Offset(0f, height), - end = Offset(cornerPx, height), - strokeWidth = strokePx, - cap = StrokeCap.Square, - ) - drawLine( - color = color, - start = Offset(0f, height), - end = Offset(0f, height - cornerPx), - strokeWidth = strokePx, - cap = StrokeCap.Square, - ) - - // Bottom-Right corner - drawLine( - color = color, - start = Offset(width, height), - end = Offset(width - cornerPx, height), - strokeWidth = strokePx, - cap = StrokeCap.Square, - ) - drawLine( + drawCornerBrackets( color = color, - start = Offset(width, height), - end = Offset(width, height - cornerPx), - strokeWidth = strokePx, - cap = StrokeCap.Square, + strokePx = 5.dp.toPx(), + cornerPx = 35.dp.toPx(), ) } - if (showText && text.isNotEmpty()) { + if (text.isNotEmpty()) { Column( modifier = Modifier.padding(24.dp), horizontalAlignment = Alignment.CenterHorizontally, 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 index 1e572111..e69ea29c 100644 --- 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 @@ -24,6 +24,8 @@ 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). * @@ -112,7 +114,7 @@ private fun CameraPreviewMock(modifier: Modifier = Modifier) { verticalArrangement = Arrangement.SpaceBetween, horizontalAlignment = Alignment.CenterHorizontally, ) { - repeat(4) { + repeat(MOCK_TEXT_REPEAT_COUNT) { Text( text = "Camera Self-View (Preview Mode)", color = HilitTheme.colors.gray300, 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 index 458641f9..1621b5b1 100644 --- 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 @@ -18,6 +18,8 @@ import com.dminus14.designsystem.component.icon.HilitIconAsset import com.dminus14.designsystem.theme.HilitTheme import java.util.Locale +private const val MINUTE_SECONDS = 60 + /** * 면접 타이머. * @@ -30,14 +32,14 @@ fun InterviewTimer( remainingSeconds: Int, modifier: Modifier = Modifier, ) { - val isWarning = (remainingSeconds <= 60) + 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 // Format MM:SS - val minutes = remainingSeconds / 60 - val seconds = remainingSeconds % 60 - val format = if (remainingSeconds <= 60) "%d초" else "%d:%02d" + val minutes = remainingSeconds / MINUTE_SECONDS + val seconds = remainingSeconds % MINUTE_SECONDS + val format = if (remainingSeconds <= MINUTE_SECONDS) "%d초" else "%d:%02d" val timeText = String.format(Locale.US, format, minutes, seconds) Box( 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 index d2b6c641..cc9baf53 100644 --- 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 @@ -17,8 +17,6 @@ object InterviewNavigationModule { { interviewEntryBuilder( onNavigateHome = {}, - onOpenAppSettings = {}, - onNavigateError = {}, onResumeInterview = {}, ) } 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 index 7ceca594..c3e2070c 100644 --- 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 @@ -1,19 +1,14 @@ package com.dminus14.app.feature.interview.interview import com.dminus14.app.core.common.mvi.MviViewModel -import com.dminus14.app.core.permission.PermissionManager import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject -private const val TOAST_8MIN_DURATION_MS = 5000L -private const val TOAST_12MIN_DURATION_MS = 3000L - @HiltViewModel class InterviewViewModel @Inject - constructor( - private val permissionManager: PermissionManager, - ) : MviViewModel(InterviewState()) { + constructor() : + MviViewModel(InterviewState()) { @Suppress("CyclomaticComplexMethod") override fun onIntent(intent: InterviewIntent) { when (intent) { diff --git a/feature/interview/impl/src/main/kotlin/com/dminus14/app/feature/interview/layer/InterviewScreenPrepareLayer.kt b/feature/interview/impl/src/main/kotlin/com/dminus14/app/feature/interview/layer/InterviewScreenPrepareLayer.kt index 844d05b6..d363d407 100644 --- a/feature/interview/impl/src/main/kotlin/com/dminus14/app/feature/interview/layer/InterviewScreenPrepareLayer.kt +++ b/feature/interview/impl/src/main/kotlin/com/dminus14/app/feature/interview/layer/InterviewScreenPrepareLayer.kt @@ -39,12 +39,14 @@ fun InterviewScreenPrepareLayer( withHilitTextHighlight { append("소리") } append("로만 나와요") } + /* val timeGuideText = buildAnnotatedString { append("면접은 총 ") withHilitTextHighlight { append("10분") } append("으로 진행돼요") } + */ val titleText: AnnotatedString = if (interviewScreenState == InterviewScreenState.PREPARED) { soundGuideText @@ -60,7 +62,7 @@ fun InterviewScreenPrepareLayer( text = titleText, style = getTitleTextStyle(), modifier = - modifier + Modifier .fillMaxWidth() .padding(top = 74.dp), ) 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 index bb530fb4..a5f5b538 100644 --- 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 @@ -9,8 +9,6 @@ import com.dminus14.app.feature.interview.interview.InterviewScreen fun EntryProviderScope.interviewEntryBuilder( onNavigateHome: () -> Unit, - onOpenAppSettings: () -> Unit, - onNavigateError: (InterviewErrorType) -> Unit, onResumeInterview: () -> Unit, ) { entry { From 47d448a39bc3f5457ef92d2b33b516ed364235be Mon Sep 17 00:00:00 2001 From: Shawn Kang Date: Fri, 7 Aug 2026 00:44:45 +0900 Subject: [PATCH 10/17] =?UTF-8?q?fix:=20=EC=9E=90=EC=B2=B4=20=EB=A6=AC?= =?UTF-8?q?=EB=B7=B0=20=EC=82=AC=ED=95=AD=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/feature/interview/component/InterviewTimer.kt | 8 ++++++-- .../app/feature/interview/error/InterviewErrorScreen.kt | 3 +++ .../app/feature/interview/interview/InterviewContract.kt | 4 ++-- 3 files changed, 11 insertions(+), 4 deletions(-) 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 index 1621b5b1..f726a13d 100644 --- 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 @@ -39,8 +39,12 @@ fun InterviewTimer( // Format MM:SS val minutes = remainingSeconds / MINUTE_SECONDS val seconds = remainingSeconds % MINUTE_SECONDS - val format = if (remainingSeconds <= MINUTE_SECONDS) "%d초" else "%d:%02d" - val timeText = String.format(Locale.US, format, minutes, seconds) + val timeText = + if (remainingSeconds < 60) { + "${remainingSeconds.coerceIn(0..59)}초" + } else { + String.format(Locale.US, "%d:%02d", minutes, seconds) + } Box( modifier = 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 index 0e4d9d86..7421e260 100644 --- 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 @@ -56,6 +56,9 @@ fun InterviewErrorScreen( LaunchedEffect(errorType) { viewModel.initErrorType(errorType) + } + + LaunchedEffect(Unit) { viewModel.effect.collect { effect -> when (effect) { InterviewErrorEffect.NavigateToHome -> onNavigateHome() 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 index 79d3f893..67d7c1da 100644 --- 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 @@ -13,8 +13,8 @@ enum class InterviewSpeaker { operator fun not(): InterviewSpeaker = when (this) { - InterviewSpeaker.AI -> InterviewSpeaker.AI - InterviewSpeaker.User -> InterviewSpeaker.User + InterviewSpeaker.AI -> InterviewSpeaker.User + InterviewSpeaker.User -> InterviewSpeaker.AI } } From e43ce15592bad25c706e2be2072b40041afd2922 Mon Sep 17 00:00:00 2001 From: Shawn Kang Date: Fri, 7 Aug 2026 01:08:21 +0900 Subject: [PATCH 11/17] =?UTF-8?q?chore:=20=EC=95=B1=20=EB=AA=A8=EB=93=88?= =?UTF-8?q?=EC=97=90=20=EB=A9=B4=EC=A0=91=20=EB=AA=A8=EB=93=88=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/build.gradle.kts | 1 + 1 file changed, 1 insertion(+) 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) From 2650af75ae2d9e0798d444150ab8005ba58b0aaf Mon Sep 17 00:00:00 2001 From: Shawn Kang Date: Fri, 7 Aug 2026 01:11:15 +0900 Subject: [PATCH 12/17] =?UTF-8?q?chore:=20=EB=A7=88=EC=9D=B4=ED=81=AC=20?= =?UTF-8?q?=EB=B0=8F=20=EC=B9=B4=EB=A9=94=EB=9D=BC=20=EA=B6=8C=ED=95=9C=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/src/main/AndroidManifest.xml | 6 ++++++ app/src/main/res/values/strings.xml | 4 ++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index dca163ed..32bd6e66 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,7 +2,13 @@ + + + + - D-14 - \ No newline at end of file + Hilit + From 13827eb570f8fbead42033fd93953faa241e8fdd Mon Sep 17 00:00:00 2001 From: Shawn Kang Date: Sat, 8 Aug 2026 17:12:30 +0900 Subject: [PATCH 13/17] =?UTF-8?q?fix:=20=EB=A7=A4=EC=A7=81=20=EB=84=98?= =?UTF-8?q?=EB=B2=84=20=EB=A6=B0=ED=8C=85=20=EA=B2=BD=EA=B3=A0=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/feature/interview/component/InterviewTimer.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 index f726a13d..282241ec 100644 --- 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 @@ -40,8 +40,8 @@ fun InterviewTimer( val minutes = remainingSeconds / MINUTE_SECONDS val seconds = remainingSeconds % MINUTE_SECONDS val timeText = - if (remainingSeconds < 60) { - "${remainingSeconds.coerceIn(0..59)}초" + if (remainingSeconds < MINUTE_SECONDS) { + "${remainingSeconds.coerceIn(0, MINUTE_SECONDS)}초" } else { String.format(Locale.US, "%d:%02d", minutes, seconds) } From d222faca44f9f832867044cb9b576a337b985e9e Mon Sep 17 00:00:00 2001 From: Shawn Kang Date: Sat, 8 Aug 2026 17:14:42 +0900 Subject: [PATCH 14/17] =?UTF-8?q?fix:=20=EC=B9=B4=EB=A9=94=EB=9D=BC=20?= =?UTF-8?q?=EB=A6=AC=EC=86=8C=EC=8A=A4=20=EC=A0=84=EC=97=AD=20=ED=95=B4?= =?UTF-8?q?=EC=A0=9C=20=EC=BD=94=EB=93=9C=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../component/InterviewCameraPreview.kt | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) 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 index e69ea29c..257c3847 100644 --- 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 @@ -63,19 +63,20 @@ private fun CameraPreviewReal(modifier: Modifier = Modifier) { 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 cameraProviderFuture.addListener( { - val cameraProvider = cameraProviderFuture.get() - val preview = - Preview.Builder().build().also { - it.surfaceProvider = previewView.surfaceProvider - } + cameraProvider = cameraProviderFuture.get() val cameraSelector = CameraSelector.DEFAULT_FRONT_CAMERA try { - cameraProvider.unbindAll() - cameraProvider.bindToLifecycle( + cameraProvider?.unbind(preview) + cameraProvider?.bindToLifecycle( lifecycleOwner, cameraSelector, preview, @@ -89,8 +90,7 @@ private fun CameraPreviewReal(modifier: Modifier = Modifier) { onDispose { try { - val cameraProvider = cameraProviderFuture.get() - cameraProvider.unbindAll() + cameraProvider?.unbind(preview) } catch (_: Exception) { // Ignore cleanup error } From b25b118e3946b97d7250ea17334d1c9e96ccecf4 Mon Sep 17 00:00:00 2001 From: Shawn Kang Date: Sat, 8 Aug 2026 20:23:25 +0900 Subject: [PATCH 15/17] =?UTF-8?q?fix:=20CI,=20=EB=A6=B0=ED=8C=85=20?= =?UTF-8?q?=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95=20=EB=B0=8F=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EB=A6=AC=EB=B7=B0=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle.kts | 1 + feature/interview/api/build.gradle.kts | 5 ++ .../feature/interview/api/InterviewRoute.kt | 9 +- .../component/InterviewCameraPreview.kt | 18 +++- .../component/InterviewReadinessIndicator.kt | 85 +++++++++++++++---- .../interview/component/InterviewTimer.kt | 2 +- .../interview/error/InterviewErrorScreen.kt | 2 + .../interview/interview/InterviewContract.kt | 20 ++++- .../interview/interview/InterviewScreen.kt | 46 ++++++++-- .../interview/interview/InterviewViewModel.kt | 16 ++++ .../navigation/InterviewEntryBuilder.kt | 3 +- gradle/libs.versions.toml | 1 + 12 files changed, 175 insertions(+), 33 deletions(-) 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/feature/interview/api/build.gradle.kts b/feature/interview/api/build.gradle.kts index 18c58e09..7a7808cb 100644 --- a/feature/interview/api/build.gradle.kts +++ b/feature/interview/api/build.gradle.kts @@ -1,4 +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 index 3fe2ba37..944ae78e 100644 --- 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 @@ -1,7 +1,11 @@ package com.dminus14.app.feature.interview.api +import androidx.navigation3.runtime.NavKey +import kotlinx.serialization.Serializable + /** 면접 진행 화면 route */ -data object InterviewRoute +@Serializable +data object InterviewRoute : NavKey /** 면접 오류 유형 */ enum class InterviewErrorType { @@ -10,6 +14,7 @@ enum class InterviewErrorType { } /** 면접 오류 화면 route */ +@Serializable data class InterviewErrorRoute( val errorType: InterviewErrorType, -) +) : NavKey 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 index 257c3847..7020a56d 100644 --- 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 @@ -12,7 +12,9 @@ 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 @@ -33,22 +35,30 @@ private const val MOCK_TEXT_REPEAT_COUNT = 4 */ @Composable fun InterviewCameraPreview( + isCameraPermissionGranted: Boolean, modifier: Modifier = Modifier, - isCameraPermissionGranted: Boolean = true, + onCameraBindingFailed: () -> Unit = {}, ) { val isPreview = LocalInspectionMode.current if (isPreview || !isCameraPermissionGranted) { CameraPreviewMock(modifier = modifier) } else { - CameraPreviewReal(modifier = modifier) + CameraPreviewReal( + modifier = modifier, + onCameraBindingFailed = onCameraBindingFailed, + ) } } @Composable -private fun CameraPreviewReal(modifier: Modifier = Modifier) { +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 { @@ -82,7 +92,7 @@ private fun CameraPreviewReal(modifier: Modifier = Modifier) { preview, ) } catch (_: Exception) { - // Camera binding fallback + currentOnCameraBindingFailed() } }, executor, 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 index eb158940..f363fcc5 100644 --- 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 @@ -1,6 +1,12 @@ 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 @@ -9,6 +15,7 @@ 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 @@ -16,15 +23,17 @@ 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 (준비 완료) */ @@ -34,15 +43,8 @@ fun InterviewReadinessIndicator( modifier: Modifier = Modifier, text: String = READINESS_TEXT, ) { - val centerColor by - animateColorAsState( - targetValue = - when (isReady) { - false -> HilitTheme.colors.gray600 - true -> HilitTheme.colors.gray50 - }, - label = "InterviewReadinessIndicatorCenterColor", - ) + val marqueeProgress = rememberMarqueeProgress() + val centerColor = rememberCenterColor(isReady) Layout( modifier = modifier.fillMaxWidth().background(HilitTheme.colors.gray800).clipToBounds(), @@ -79,19 +81,68 @@ fun InterviewReadinessIndicator( val placeables = measurables.map { it.measure(unboundedConstraints) } val rowWidth = constraints.constrainWidth(constraints.maxWidth) - val contentWidth = placeables.sumOf { it.width } + gapPx * (placeables.size - 1) + val textWidth = placeables.first().width + val textStride = textWidth + gapPx + val cycleWidth = textStride * placeables.size val height = placeables.maxOf { it.height } layout(rowWidth, height) { - var x = (rowWidth - contentWidth) / 2 - placeables.forEach { placeable -> - placeable.placeRelative(x, (height - placeable.height) / 2) - x += placeable.width + gapPx + 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() { 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 index 282241ec..99f5ed86 100644 --- 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 @@ -36,7 +36,7 @@ fun InterviewTimer( val bgColor = if (isWarning) HilitTheme.colors.error200 else HilitTheme.colors.gray50 val textColor = if (isWarning) HilitTheme.colors.error500 else HilitTheme.colors.gray500 - // Format MM:SS + // 60초 이상일 땐 MM:SS, 이하일 땐 SS초 val minutes = remainingSeconds / MINUTE_SECONDS val seconds = remainingSeconds % MINUTE_SECONDS val timeText = 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 index 7421e260..ce32fa5f 100644 --- 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 @@ -50,6 +50,7 @@ fun InterviewErrorScreen( errorType: InterviewErrorType, onNavigateHome: () -> Unit, onResumeInterview: () -> Unit, + modifier: Modifier = Modifier, viewModel: InterviewErrorViewModel = hiltViewModel(), ) { val state by viewModel.state.collectAsStateWithLifecycle() @@ -70,6 +71,7 @@ fun InterviewErrorScreen( InterviewErrorContent( state = state, onIntent = viewModel::onIntent, + modifier = modifier, ) } 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 index 67d7c1da..e0e86728 100644 --- 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 @@ -29,9 +29,17 @@ enum class InterviewScreenState { /** 면접 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 */ @@ -39,17 +47,25 @@ 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() = InterviewConstants.CAN_FINISH_INTERVIEW_SECONDS - elapsedSeconds < 0 + get() = elapsedSeconds >= InterviewConstants.CAN_FINISH_INTERVIEW_SECONDS val isInterviewOngoing: Boolean get() = isInterviewReady && remainingSeconds > 0 } /** 면접 MVI Effect */ -sealed interface InterviewEffect : MviEffect +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 index c4c99531..bcfcd3bb 100644 --- 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 @@ -5,23 +5,47 @@ 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.layer.InterviewScreenBaseLayer -import com.dminus14.app.feature.interview.layer.InterviewScreenOngoingLayer -import com.dminus14.app.feature.interview.layer.InterviewScreenPrepareLayer +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, @@ -36,7 +60,13 @@ fun InterviewContent( modifier: Modifier = Modifier, ) { Box(modifier = modifier) { - InterviewScreenBaseLayer(modifier = Modifier.fillMaxSize()) + InterviewScreenBaseLayer( + isCameraPermissionGranted = state.isCameraPermissionGranted, + onCameraBindingFailed = { + onIntent(InterviewIntent.ReportCameraBindingFailure) + }, + modifier = Modifier.fillMaxSize(), + ) when (state.interviewScreenState) { InterviewScreenState.PREPARING, @@ -45,9 +75,12 @@ fun InterviewContent( -> { InterviewScreenPrepareLayer( isReady = state.isInterviewReady, - isPermissionGranted = true, + isPermissionGranted = state.isCameraPermissionGranted, interviewScreenState = state.interviewScreenState, onInterviewStart = { onIntent(InterviewIntent.StartInterview) }, + onPermissionDeniedBack = { + onIntent(InterviewIntent.ClickPermissionDeniedBack) + }, modifier = Modifier.fillMaxSize(), ) } @@ -58,6 +91,9 @@ fun InterviewContent( remainingSeconds = state.remainingSeconds, canFinishedEarly = state.canFinishedEarly, isInterviewOngoing = state.isInterviewOngoing, + onFinishRequest = { + onIntent(InterviewIntent.ClickFinishInterview) + }, modifier = Modifier.fillMaxSize(), ) } 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 index c3e2070c..61fd6914 100644 --- 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 @@ -12,6 +12,10 @@ class InterviewViewModel @Suppress("CyclomaticComplexMethod") override fun onIntent(intent: InterviewIntent) { when (intent) { + InterviewIntent.CheckCameraPermission -> { + // 후속 구현: 기존 권한 처리 계층에서 CAMERA 권한 상태를 조회해 State에 반영한다. + } + InterviewIntent.StartInterview -> { reduce { copy( @@ -27,6 +31,18 @@ class InterviewViewModel ) } } + + 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/navigation/InterviewEntryBuilder.kt b/feature/interview/impl/src/main/kotlin/com/dminus14/app/feature/interview/navigation/InterviewEntryBuilder.kt index a5f5b538..f9166640 100644 --- 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 @@ -2,7 +2,6 @@ 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.InterviewErrorType import com.dminus14.app.feature.interview.api.InterviewRoute import com.dminus14.app.feature.interview.error.InterviewErrorScreen import com.dminus14.app.feature.interview.interview.InterviewScreen @@ -12,7 +11,7 @@ fun EntryProviderScope.interviewEntryBuilder( onResumeInterview: () -> Unit, ) { entry { - InterviewScreen() + InterviewScreen(onNavigateHome = onNavigateHome) } entry { key -> diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6e91b51a..1e69fe50 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -147,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" } From 513920f4ea8a6123bc4b0781cacbc0adc2b5dbad Mon Sep 17 00:00:00 2001 From: Shawn Kang Date: Sat, 8 Aug 2026 20:25:40 +0900 Subject: [PATCH 16/17] =?UTF-8?q?chore:=20=ED=8C=8C=EC=9D=BC=20=EA=B2=BD?= =?UTF-8?q?=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../layer/InterviewScreenBaseLayer.kt | 15 +++++++++++---- .../layer/InterviewScreenOngoingLayer.kt | 6 ++++-- .../layer/InterviewScreenPrepareLayer.kt | 10 +++++++++- 3 files changed, 24 insertions(+), 7 deletions(-) rename feature/interview/impl/src/main/kotlin/com/dminus14/app/feature/interview/{ => interview}/layer/InterviewScreenBaseLayer.kt (77%) rename feature/interview/impl/src/main/kotlin/com/dminus14/app/feature/interview/{ => interview}/layer/InterviewScreenOngoingLayer.kt (96%) rename feature/interview/impl/src/main/kotlin/com/dminus14/app/feature/interview/{ => interview}/layer/InterviewScreenPrepareLayer.kt (93%) diff --git a/feature/interview/impl/src/main/kotlin/com/dminus14/app/feature/interview/layer/InterviewScreenBaseLayer.kt b/feature/interview/impl/src/main/kotlin/com/dminus14/app/feature/interview/interview/layer/InterviewScreenBaseLayer.kt similarity index 77% rename from feature/interview/impl/src/main/kotlin/com/dminus14/app/feature/interview/layer/InterviewScreenBaseLayer.kt rename to feature/interview/impl/src/main/kotlin/com/dminus14/app/feature/interview/interview/layer/InterviewScreenBaseLayer.kt index b9af26c6..62d7be54 100644 --- a/feature/interview/impl/src/main/kotlin/com/dminus14/app/feature/interview/layer/InterviewScreenBaseLayer.kt +++ b/feature/interview/impl/src/main/kotlin/com/dminus14/app/feature/interview/interview/layer/InterviewScreenBaseLayer.kt @@ -1,4 +1,4 @@ -package com.dminus14.app.feature.interview.layer +package com.dminus14.app.feature.interview.interview.layer import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxWidth @@ -15,12 +15,19 @@ import com.dminus14.app.feature.interview.component.VideoOverlayDirection import com.dminus14.designsystem.theme.HilitTheme @Composable -fun InterviewScreenBaseLayer(modifier: Modifier = Modifier) { +fun InterviewScreenBaseLayer( + isCameraPermissionGranted: Boolean, + modifier: Modifier = Modifier, + onCameraBindingFailed: () -> Unit = {}, +) { Box( modifier = modifier, ) { // Camera View - InterviewCameraPreview() + InterviewCameraPreview( + isCameraPermissionGranted = isCameraPermissionGranted, + onCameraBindingFailed = onCameraBindingFailed, + ) // Video Overlays VideoOverlay( @@ -49,6 +56,6 @@ fun InterviewScreenBaseLayer(modifier: Modifier = Modifier) { @Composable private fun InterviewScreenBaseLayerPreview() { HilitTheme { - InterviewScreenBaseLayer() + InterviewScreenBaseLayer(isCameraPermissionGranted = true) } } diff --git a/feature/interview/impl/src/main/kotlin/com/dminus14/app/feature/interview/layer/InterviewScreenOngoingLayer.kt b/feature/interview/impl/src/main/kotlin/com/dminus14/app/feature/interview/interview/layer/InterviewScreenOngoingLayer.kt similarity index 96% rename from feature/interview/impl/src/main/kotlin/com/dminus14/app/feature/interview/layer/InterviewScreenOngoingLayer.kt rename to feature/interview/impl/src/main/kotlin/com/dminus14/app/feature/interview/interview/layer/InterviewScreenOngoingLayer.kt index 7df2c3ae..5a1622b4 100644 --- a/feature/interview/impl/src/main/kotlin/com/dminus14/app/feature/interview/layer/InterviewScreenOngoingLayer.kt +++ b/feature/interview/impl/src/main/kotlin/com/dminus14/app/feature/interview/interview/layer/InterviewScreenOngoingLayer.kt @@ -1,4 +1,4 @@ -package com.dminus14.app.feature.interview.layer +package com.dminus14.app.feature.interview.interview.layer import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box @@ -17,6 +17,7 @@ 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, @@ -24,6 +25,7 @@ fun InterviewScreenOngoingLayer( canFinishedEarly: Boolean, isInterviewOngoing: Boolean, modifier: Modifier = Modifier, + onFinishRequest: () -> Unit = {}, ) { Box( modifier = modifier, @@ -51,7 +53,7 @@ fun InterviewScreenOngoingLayer( if (canFinishedEarly) { HilitMiniButton( color = HilitMiniButtonColor.Dark, - onClick = {}, + onClick = onFinishRequest, enabled = isInterviewOngoing, modifier = Modifier diff --git a/feature/interview/impl/src/main/kotlin/com/dminus14/app/feature/interview/layer/InterviewScreenPrepareLayer.kt b/feature/interview/impl/src/main/kotlin/com/dminus14/app/feature/interview/interview/layer/InterviewScreenPrepareLayer.kt similarity index 93% rename from feature/interview/impl/src/main/kotlin/com/dminus14/app/feature/interview/layer/InterviewScreenPrepareLayer.kt rename to feature/interview/impl/src/main/kotlin/com/dminus14/app/feature/interview/interview/layer/InterviewScreenPrepareLayer.kt index d363d407..51e2975f 100644 --- a/feature/interview/impl/src/main/kotlin/com/dminus14/app/feature/interview/layer/InterviewScreenPrepareLayer.kt +++ b/feature/interview/impl/src/main/kotlin/com/dminus14/app/feature/interview/interview/layer/InterviewScreenPrepareLayer.kt @@ -1,6 +1,7 @@ -package com.dminus14.app.feature.interview.layer +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 @@ -24,12 +25,14 @@ 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("면접을 준비하고 있어요") } @@ -90,6 +93,7 @@ fun InterviewScreenPrepareLayer( modifier = Modifier .align(Alignment.TopStart) + .clickable(onClick = onPermissionDeniedBack) .padding(start = 20.dp, top = 10.dp), ) } @@ -128,6 +132,7 @@ private fun InterviewScreenPreparingLayerPreview() { isPermissionGranted = true, interviewScreenState = InterviewScreenState.PREPARING, onInterviewStart = {}, + onPermissionDeniedBack = {}, modifier = Modifier.fillMaxSize(), ) } @@ -149,6 +154,7 @@ private fun InterviewScreenPrepareLayerAlmostPreparedPreview() { isPermissionGranted = true, interviewScreenState = InterviewScreenState.ALMOST_PREPARED, onInterviewStart = {}, + onPermissionDeniedBack = {}, modifier = Modifier.fillMaxSize(), ) } @@ -170,6 +176,7 @@ private fun InterviewScreenPrepareLayerPermissionNotGrantedPreview() { isPermissionGranted = false, interviewScreenState = InterviewScreenState.PREPARING, onInterviewStart = {}, + onPermissionDeniedBack = {}, modifier = Modifier.fillMaxSize(), ) } @@ -191,6 +198,7 @@ private fun InterviewScreenPrepareLayerPreparedPreview() { isPermissionGranted = true, interviewScreenState = InterviewScreenState.PREPARED, onInterviewStart = {}, + onPermissionDeniedBack = {}, modifier = Modifier.fillMaxSize(), ) } From 5e05ad581738dfafa75f2b5d4555d90879e51683 Mon Sep 17 00:00:00 2001 From: Shawn Kang Date: Sat, 8 Aug 2026 20:42:01 +0900 Subject: [PATCH 17/17] =?UTF-8?q?fix:=20=EC=B9=B4=EB=A9=94=EB=9D=BC=20?= =?UTF-8?q?=ED=94=84=EB=A6=AC=EB=B7=B0=20=EB=AC=B8=EC=A0=9C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../feature/interview/component/InterviewCameraPreview.kt | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) 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 index 7020a56d..f570b656 100644 --- 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 @@ -78,13 +78,16 @@ private fun CameraPreviewReal( it.surfaceProvider = previewView.surfaceProvider } var cameraProvider: ProcessCameraProvider? = null + var isDisposed = false cameraProviderFuture.addListener( { - cameraProvider = cameraProviderFuture.get() - val cameraSelector = CameraSelector.DEFAULT_FRONT_CAMERA + if (isDisposed) return@addListener try { + cameraProvider = cameraProviderFuture.get() + val cameraSelector = CameraSelector.DEFAULT_FRONT_CAMERA + cameraProvider?.unbind(preview) cameraProvider?.bindToLifecycle( lifecycleOwner, @@ -99,6 +102,7 @@ private fun CameraPreviewReal( ) onDispose { + isDisposed = true try { cameraProvider?.unbind(preview) } catch (_: Exception) {