From c60e34199533434156d50aff327a9d4f0e84c45f Mon Sep 17 00:00:00 2001 From: juhwankim-dev Date: Sun, 28 Dec 2025 01:56:28 +0900 Subject: [PATCH 1/7] =?UTF-8?q?NR-128=20compose=20=EB=94=94=ED=8E=9C?= =?UTF-8?q?=EB=8D=98=EC=8B=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- gradle/libs.versions.toml | 25 +++++++++++++++++++++++++ presentation/build.gradle.kts | 9 ++++++++- 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 669c5e3..0bc23e4 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -52,6 +52,8 @@ biometric = "1.1.0" flipper = "0.200.0" soloader = "0.10.4" leak-canary = "2.14" +compose-bom = "2024.02.00" +coil = "2.5.0" [libraries] androidx-activity-ktx = { module = "androidx.activity:activity-ktx", version.ref = "activity-ktx" } @@ -113,6 +115,19 @@ credentials-auth = { module = "androidx.credentials:credentials-play-services-au google-identity = { module = "com.google.android.libraries.identity.googleid:googleid", version.ref = "google-identity" } leak-canary = { module = "com.squareup.leakcanary:leakcanary-android", version.ref = "leak-canary" } +# Compose +androidx-compose-bom = { module = "androidx.compose:compose-bom", version.ref = "compose-bom" } +androidx-compose-ui = { module = "androidx.compose.ui:ui" } +androidx-compose-ui-graphics = { module = "androidx.compose.ui:ui-graphics" } +androidx-compose-foundation = { module = "androidx.compose.foundation:foundation" } +androidx-compose-material3 = { module = "androidx.compose.material3:material3" } +androidx-compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" } +androidx-compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" } +androidx-activity-compose = { module = "androidx.activity:activity-compose", version = "1.8.2" } +androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle" } +androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycle" } +coil-compose = { module = "io.coil-kt:coil-compose", version.ref = "coil" } + [plugins] android-application = { id = "com.android.application", version.ref = "android" } android-library = { id = "com.android.library", version.ref = "android" } @@ -134,3 +149,13 @@ network = ["retrofit", "converter-gson", "logging-interceptor"] db = ["androidx-datastore", "androidx-datastore-preferences-core", "androidx-room-runtime", "androidx-room-ktx"] firebase = ["firebase-analytics-ktx", "firebase-crashlytics-ktx"] flipper = ["flipper", "soloader", "flipper-network-plugin"] +compose = [ + "androidx-compose-ui", + "androidx-compose-ui-graphics", + "androidx-compose-foundation", + "androidx-compose-ui-tooling-preview", + "androidx-compose-material3", + "androidx-activity-compose", + "androidx-lifecycle-viewmodel-compose", + "androidx-lifecycle-runtime-compose" +] diff --git a/presentation/build.gradle.kts b/presentation/build.gradle.kts index c6327d8..a62c167 100644 --- a/presentation/build.gradle.kts +++ b/presentation/build.gradle.kts @@ -38,9 +38,10 @@ android { buildFeatures { viewBinding = true buildConfig = true + compose = true } composeOptions { - kotlinCompilerExtensionVersion = "1.5.2" + kotlinCompilerExtensionVersion = "1.5.3" // Kotlin 1.9.10과 호환 } } @@ -75,6 +76,12 @@ dependencies { implementation(libs.photoview) implementation(libs.biometric) debugImplementation(libs.leak.canary) + + // Compose + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.bundles.compose) + debugImplementation(libs.androidx.compose.ui.tooling) + implementation(libs.coil.compose) implementation(platform(libs.firebase.bom)) implementation(libs.firebase.analytics.ktx) From 83120d33be389271e009f547e8fbe29bcc1456e1 Mon Sep 17 00:00:00 2001 From: juhwankim-dev Date: Sun, 28 Dec 2025 01:56:46 +0900 Subject: [PATCH 2/7] =?UTF-8?q?NR-128=20compose=20=EB=A6=AC=EC=86=8C?= =?UTF-8?q?=EC=8A=A4=20=ED=8C=8C=EC=9D=BC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../presentation/common/compose/NRColor.kt | 53 +++ .../presentation/common/compose/NRTypo.kt | 339 ++++++++++++++++++ 2 files changed, 392 insertions(+) create mode 100644 presentation/src/main/java/com/nextroom/nextroom/presentation/common/compose/NRColor.kt create mode 100644 presentation/src/main/java/com/nextroom/nextroom/presentation/common/compose/NRTypo.kt diff --git a/presentation/src/main/java/com/nextroom/nextroom/presentation/common/compose/NRColor.kt b/presentation/src/main/java/com/nextroom/nextroom/presentation/common/compose/NRColor.kt new file mode 100644 index 0000000..3b4b7f2 --- /dev/null +++ b/presentation/src/main/java/com/nextroom/nextroom/presentation/common/compose/NRColor.kt @@ -0,0 +1,53 @@ +package com.nextroom.nextroom.presentation.common.compose + +import androidx.compose.ui.graphics.Color + +object NRColor { + // Basic Colors + val White = Color(0xFFFFFFFF) + val White5 = Color(0x0DFFFFFF) + val White12 = Color(0x1FFFFFFF) + val White20 = Color(0x33FFFFFF) + val White50 = Color(0x80FFFFFF) + val White70 = Color(0xB3FFFFFF) + + val Black = Color(0xFF1D1B20) + val Black60 = Color(0x99000000) + val OnSurface = Color(0xFF1D1B20) + val Surface = Color(0xFFFEF7FF) + + // Material Colors + val ColorPrimary = Color(0xFF6750A4) + val ColorSurface = Color(0xFFFEF7FF) + val ColorSecondarySurface = Color(0x146750A4) + val ColorLightPrimary = Color(0x146750A4) + + // Blue + val Blue = Color(0xFF378EFF) + val Blue15 = Color(0x26378EFF) + + // Gray Scale + val Dark01 = Color(0xFF151516) + val Gray01 = Color(0xFF9898A0) + val Gray02 = Color(0xFF47474E) + val Gray03 = Color(0xFF222223) + val Gray04 = Color(0xFF999999) + val Gray05 = Color(0xFF46454A) + + // Red + val Red = Color(0xFFFF5065) + val Red02 = Color(0xFFF04438) + + // Sub Colors + val Sub1 = Color(0xFF1F2023) + val Sub2 = Color(0xFF3A3B3D) + + // Background + val BgMain = Color(0xFF131417) + + // Button Colors + val PrimaryButtonBackground = Color(0xFFFFFFFF) + val DisabledButtonBackground = Color(0xFF2D2D2D) + val PrimaryButtonText = Color(0xFF000000) + val DisabledButtonText = Color(0xFF555555) +} diff --git a/presentation/src/main/java/com/nextroom/nextroom/presentation/common/compose/NRTypo.kt b/presentation/src/main/java/com/nextroom/nextroom/presentation/common/compose/NRTypo.kt new file mode 100644 index 0000000..2832c31 --- /dev/null +++ b/presentation/src/main/java/com/nextroom/nextroom/presentation/common/compose/NRTypo.kt @@ -0,0 +1,339 @@ +package com.nextroom.nextroom.presentation.common.compose + +import androidx.compose.runtime.Composable +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp +import com.nextroom.nextroom.presentation.R + +object NextRoomFontFamily { + val Pretendard = FontFamily( + Font(R.font.pretendard_regular, FontWeight.Normal), + Font(R.font.pretendard_medium, FontWeight.Medium), + Font(R.font.pretendard_semi_bold, FontWeight.SemiBold), + Font(R.font.pretendard_bold, FontWeight.Bold) + ) + + val Poppins = FontFamily( + Font(R.font.poppins_medium, FontWeight.Medium), + Font(R.font.poppins_semi_bold, FontWeight.SemiBold) + ) +} + +object NRTypo { + object Title { + val size24SemiBold: TextStyle + @Composable get() = TextStyle( + fontFamily = NextRoomFontFamily.Pretendard, + fontWeight = FontWeight.SemiBold, + fontSize = 24.sp, + color = NRColor.White + ) + + val size24Medium: TextStyle + @Composable get() = TextStyle( + fontFamily = NextRoomFontFamily.Pretendard, + fontWeight = FontWeight.Medium, + fontSize = 24.sp, + color = NRColor.White + ) + + val size20SemiBold: TextStyle + @Composable get() = TextStyle( + fontFamily = NextRoomFontFamily.Pretendard, + fontWeight = FontWeight.SemiBold, + fontSize = 20.sp, + color = NRColor.White + ) + + val size20Medium: TextStyle + @Composable get() = TextStyle( + fontFamily = NextRoomFontFamily.Pretendard, + fontWeight = FontWeight.Medium, + fontSize = 20.sp, + color = NRColor.White + ) + + val size18SemiBold: TextStyle + @Composable get() = TextStyle( + fontFamily = NextRoomFontFamily.Pretendard, + fontWeight = FontWeight.SemiBold, + fontSize = 18.sp, + color = NRColor.White + ) + + val size16SemiBold: TextStyle + @Composable get() = TextStyle( + fontFamily = NextRoomFontFamily.Pretendard, + fontWeight = FontWeight.SemiBold, + fontSize = 16.sp, + color = NRColor.White + ) + + val size16Medium: TextStyle + @Composable get() = TextStyle( + fontFamily = NextRoomFontFamily.Pretendard, + fontWeight = FontWeight.Medium, + fontSize = 16.sp, + color = NRColor.White + ) + } + + // ===== Body Styles ===== + object Body { + val size16Medium: TextStyle + @Composable get() = TextStyle( + fontFamily = NextRoomFontFamily.Pretendard, + fontWeight = FontWeight.Medium, + fontSize = 16.sp, + color = NRColor.White + ) + + val size16Regular: TextStyle + @Composable get() = TextStyle( + fontFamily = NextRoomFontFamily.Pretendard, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + color = NRColor.White + ) + + val size14Medium: TextStyle + @Composable get() = TextStyle( + fontFamily = NextRoomFontFamily.Pretendard, + fontWeight = FontWeight.Medium, + fontSize = 14.sp, + color = NRColor.White + ) + + val size14Regular: TextStyle + @Composable get() = TextStyle( + fontFamily = NextRoomFontFamily.Pretendard, + fontWeight = FontWeight.Normal, + fontSize = 14.sp, + color = NRColor.White + ) + + val size12Medium: TextStyle + @Composable get() = TextStyle( + fontFamily = NextRoomFontFamily.Pretendard, + fontWeight = FontWeight.Medium, + fontSize = 12.sp, + color = NRColor.White + ) + + val size12Regular: TextStyle + @Composable get() = TextStyle( + fontFamily = NextRoomFontFamily.Pretendard, + fontWeight = FontWeight.Normal, + fontSize = 12.sp, + color = NRColor.White + ) + } + + // ===== Caption Styles ===== + object Caption { + val size12SemiBold: TextStyle + @Composable get() = TextStyle( + fontFamily = NextRoomFontFamily.Pretendard, + fontWeight = FontWeight.SemiBold, + fontSize = 12.sp, + color = NRColor.White + ) + + val size12Medium: TextStyle + @Composable get() = TextStyle( + fontFamily = NextRoomFontFamily.Pretendard, + fontWeight = FontWeight.Medium, + fontSize = 12.sp, + color = NRColor.White + ) + } + + // ===== Legacy Pretendard Styles ===== + object Pretendard { + val size12: TextStyle + @Composable get() = TextStyle( + fontFamily = NextRoomFontFamily.Pretendard, + fontWeight = FontWeight.Medium, + fontSize = 12.sp, + lineHeight = 14.sp, + color = NRColor.White + ) + + val size14: TextStyle + @Composable get() = TextStyle( + fontFamily = NextRoomFontFamily.Pretendard, + fontWeight = FontWeight.Normal, + fontSize = 14.sp, + lineHeight = 17.sp, + color = NRColor.White + ) + + val size14SemiBold: TextStyle + @Composable get() = TextStyle( + fontFamily = NextRoomFontFamily.Pretendard, + fontWeight = FontWeight.SemiBold, + fontSize = 14.sp, + lineHeight = 17.sp, + color = NRColor.White + ) + + val size16: TextStyle + @Composable get() = TextStyle( + fontFamily = NextRoomFontFamily.Pretendard, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 19.sp, + color = NRColor.White + ) + + val size16Bold: TextStyle + @Composable get() = TextStyle( + fontFamily = NextRoomFontFamily.Pretendard, + fontWeight = FontWeight.Bold, + fontSize = 16.sp, + lineHeight = 19.sp, + color = NRColor.White + ) + + val size16SemiBold: TextStyle + @Composable get() = TextStyle( + fontFamily = NextRoomFontFamily.Pretendard, + fontWeight = FontWeight.SemiBold, + fontSize = 16.sp, + lineHeight = 19.sp, + color = NRColor.White + ) + + val size18: TextStyle + @Composable get() = TextStyle( + fontFamily = NextRoomFontFamily.Pretendard, + fontWeight = FontWeight.Bold, + fontSize = 18.sp, + lineHeight = 22.sp, + color = NRColor.White + ) + + val size18SemiBold: TextStyle + @Composable get() = TextStyle( + fontFamily = NextRoomFontFamily.Pretendard, + fontWeight = FontWeight.SemiBold, + fontSize = 18.sp, + lineHeight = 22.sp, + color = NRColor.White + ) + + val size20: TextStyle + @Composable get() = TextStyle( + fontFamily = NextRoomFontFamily.Pretendard, + fontWeight = FontWeight.Normal, + fontSize = 20.sp, + lineHeight = 24.sp, + color = NRColor.White + ) + + val size20SemiBold: TextStyle + @Composable get() = TextStyle( + fontFamily = NextRoomFontFamily.Pretendard, + fontWeight = FontWeight.SemiBold, + fontSize = 20.sp, + lineHeight = 24.sp, + color = NRColor.White + ) + + val size24: TextStyle + @Composable get() = TextStyle( + fontFamily = NextRoomFontFamily.Pretendard, + fontWeight = FontWeight.SemiBold, + fontSize = 24.sp, + lineHeight = 28.sp, + color = NRColor.White + ) + + val size24Bold: TextStyle + @Composable get() = TextStyle( + fontFamily = NextRoomFontFamily.Pretendard, + fontWeight = FontWeight.Bold, + fontSize = 24.sp, + lineHeight = 28.sp, + color = NRColor.White + ) + + val size32: TextStyle + @Composable get() = TextStyle( + fontFamily = NextRoomFontFamily.Pretendard, + fontWeight = FontWeight.Normal, + fontSize = 32.sp, + lineHeight = 38.sp, + color = NRColor.White + ) + + val size32Bold: TextStyle + @Composable get() = TextStyle( + fontFamily = NextRoomFontFamily.Pretendard, + fontWeight = FontWeight.Bold, + fontSize = 32.sp, + lineHeight = 38.sp, + color = NRColor.White + ) + } + + // ===== Poppins Styles ===== + object Poppins { + val size14: TextStyle + @Composable get() = TextStyle( + fontFamily = NextRoomFontFamily.Poppins, + fontWeight = FontWeight.Medium, + fontSize = 14.sp, + lineHeight = 13.sp, + color = NRColor.White + ) + + val size16: TextStyle + @Composable get() = TextStyle( + fontFamily = NextRoomFontFamily.Poppins, + fontWeight = FontWeight.Medium, + fontSize = 16.sp, + lineHeight = 14.sp, + color = NRColor.White + ) + + val size18: TextStyle + @Composable get() = TextStyle( + fontFamily = NextRoomFontFamily.Poppins, + fontWeight = FontWeight.SemiBold, + fontSize = 18.sp, + lineHeight = 16.sp, + color = NRColor.White + ) + + val size20: TextStyle + @Composable get() = TextStyle( + fontFamily = NextRoomFontFamily.Poppins, + fontWeight = FontWeight.SemiBold, + fontSize = 20.sp, + lineHeight = 18.sp, + color = NRColor.White + ) + + val size24: TextStyle + @Composable get() = TextStyle( + fontFamily = NextRoomFontFamily.Poppins, + fontWeight = FontWeight.SemiBold, + fontSize = 24.sp, + lineHeight = 22.sp, + color = NRColor.White + ) + + val size54: TextStyle + @Composable get() = TextStyle( + fontFamily = NextRoomFontFamily.Poppins, + fontWeight = FontWeight.SemiBold, + fontSize = 54.sp, + lineHeight = 49.sp, + color = NRColor.White + ) + } +} From 58e71c6c56338ca07b01d1beb95a13701f6c7cb1 Mon Sep 17 00:00:00 2001 From: juhwankim-dev Date: Sun, 28 Dec 2025 01:58:01 +0900 Subject: [PATCH 3/7] =?UTF-8?q?NR-128=20throttleClick=20=ED=99=95=EC=9E=A5?= =?UTF-8?q?=ED=95=A8=EC=88=98=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../presentation/extension/Modifier.kt | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 presentation/src/main/java/com/nextroom/nextroom/presentation/extension/Modifier.kt diff --git a/presentation/src/main/java/com/nextroom/nextroom/presentation/extension/Modifier.kt b/presentation/src/main/java/com/nextroom/nextroom/presentation/extension/Modifier.kt new file mode 100644 index 0000000..ea34fb4 --- /dev/null +++ b/presentation/src/main/java/com/nextroom/nextroom/presentation/extension/Modifier.kt @@ -0,0 +1,26 @@ +package com.nextroom.nextroom.presentation.extension + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier + +@Composable +fun Modifier.throttleClick( + throttleDelayMillis: Long = 400L, + onClick: () -> Unit +): Modifier { + val lastClickTime = remember { mutableLongStateOf(0L) } + return clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() } + ) { + val currentTime = System.currentTimeMillis() + if (currentTime - lastClickTime.longValue > throttleDelayMillis) { + lastClickTime.longValue = currentTime + onClick() + } + } +} \ No newline at end of file From 5156ea92b286faf2094510e2dfdab45e401e05d5 Mon Sep 17 00:00:00 2001 From: juhwankim-dev Date: Sun, 28 Dec 2025 01:58:20 +0900 Subject: [PATCH 4/7] =?UTF-8?q?NR-128=20compose=20=EA=B3=B5=ED=86=B5=20?= =?UTF-8?q?=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8(=EB=A1=9C=EB=94=A9,=20?= =?UTF-8?q?=ED=88=B4=EB=B0=94)=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../presentation/common/compose/NRLoading.kt | 41 +++++++++ .../presentation/common/compose/NRToolbar.kt | 83 +++++++++++++++++++ 2 files changed, 124 insertions(+) create mode 100644 presentation/src/main/java/com/nextroom/nextroom/presentation/common/compose/NRLoading.kt create mode 100644 presentation/src/main/java/com/nextroom/nextroom/presentation/common/compose/NRToolbar.kt diff --git a/presentation/src/main/java/com/nextroom/nextroom/presentation/common/compose/NRLoading.kt b/presentation/src/main/java/com/nextroom/nextroom/presentation/common/compose/NRLoading.kt new file mode 100644 index 0000000..93fef00 --- /dev/null +++ b/presentation/src/main/java/com/nextroom/nextroom/presentation/common/compose/NRLoading.kt @@ -0,0 +1,41 @@ +package com.nextroom.nextroom.presentation.common.compose + +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.tooling.preview.Preview + +@Composable +fun NRLoading( + isVisible: Boolean, + modifier: Modifier = Modifier +) { + if (!isVisible) return + + Box( + modifier = modifier + .fillMaxSize() + .background(Color.Transparent) + .pointerInput(Unit) { + detectTapGestures { } + }, + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator(color = NRColor.White) + } +} + +@Preview(showBackground = true, backgroundColor = 0xFF151516) +@Composable +private fun NRLoadingPreview() { + Box(modifier = Modifier.fillMaxSize()) { + NRLoading(isVisible = true) + } +} diff --git a/presentation/src/main/java/com/nextroom/nextroom/presentation/common/compose/NRToolbar.kt b/presentation/src/main/java/com/nextroom/nextroom/presentation/common/compose/NRToolbar.kt new file mode 100644 index 0000000..c124a6d --- /dev/null +++ b/presentation/src/main/java/com/nextroom/nextroom/presentation/common/compose/NRToolbar.kt @@ -0,0 +1,83 @@ +package com.nextroom.nextroom.presentation.common.compose + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.nextroom.nextroom.presentation.R +import com.nextroom.nextroom.presentation.extension.throttleClick + +@Composable +fun NRToolbar( + title: String, + onBackClick: () -> Unit, + onRightButtonClick: () -> Unit, + modifier: Modifier = Modifier +) { + Box( + contentAlignment = Alignment.Center + ) { + Text( + text = title, + style = NRTypo.Poppins.size20, + color = NRColor.White, + textAlign = TextAlign.Center, + ) + + Row( + modifier = modifier + .fillMaxWidth() + .height(64.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Image( + painter = painterResource(R.drawable.ic_navigate_back_24), + modifier = modifier + .size(64.dp) + .throttleClick { onBackClick() } + .padding(20.dp), + contentDescription = null, + ) + + Text( + text = stringResource(R.string.memo_button), + color = NRColor.Dark01, + style = NRTypo.Poppins.size14, + modifier = modifier + .padding(end = 20.dp) + .background( + color = NRColor.White, + shape = RoundedCornerShape(size = 50.dp) + ) + .padding(vertical = 6.dp, horizontal = 16.dp) + .throttleClick { onRightButtonClick() } + ) + } + } +} + +@Preview(showBackground = true, backgroundColor = 0xFF1D1B20) +@Composable +private fun NRToolbarPreview() { + NRToolbar( + title = "01:23:45", + onBackClick = {}, + onRightButtonClick = {} + ) +} From 8e64f354e8fb1d2e73e593f0538a2a845455896e Mon Sep 17 00:00:00 2001 From: juhwankim-dev Date: Sun, 28 Dec 2025 02:26:46 +0900 Subject: [PATCH 5/7] =?UTF-8?q?NR-128=20=ED=9E=8C=ED=8A=B8=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=EC=9D=84=20compose=EB=A1=9C=20=EB=A7=88=EC=9D=B4?= =?UTF-8?q?=EA=B7=B8=EB=A0=88=EC=9D=B4=EC=85=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../presentation/ui/hint/HintFragment.kt | 264 +++++---------- .../presentation/ui/hint/HintState.kt | 1 - .../presentation/ui/hint/HintViewModel.kt | 18 +- .../ui/hint/compose/HintScreen.kt | 320 ++++++++++++++++++ .../ui/hint/compose/ImagePager.kt | 146 ++++++++ 5 files changed, 547 insertions(+), 202 deletions(-) create mode 100644 presentation/src/main/java/com/nextroom/nextroom/presentation/ui/hint/compose/HintScreen.kt create mode 100644 presentation/src/main/java/com/nextroom/nextroom/presentation/ui/hint/compose/ImagePager.kt diff --git a/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/hint/HintFragment.kt b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/hint/HintFragment.kt index 526915d..ea26d57 100644 --- a/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/hint/HintFragment.kt +++ b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/hint/HintFragment.kt @@ -1,78 +1,82 @@ package com.nextroom.nextroom.presentation.ui.hint import android.os.Bundle +import android.view.LayoutInflater import android.view.View +import android.view.ViewGroup +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.core.os.bundleOf -import androidx.core.view.isVisible import androidx.fragment.app.viewModels import androidx.hilt.navigation.fragment.hiltNavGraphViewModels import androidx.navigation.fragment.findNavController -import androidx.viewpager2.widget.ViewPager2 import com.google.firebase.analytics.FirebaseAnalytics import com.nextroom.nextroom.domain.model.SubscribeStatus import com.nextroom.nextroom.presentation.NavGraphDirections import com.nextroom.nextroom.presentation.R import com.nextroom.nextroom.presentation.base.BaseFragment -import com.nextroom.nextroom.presentation.common.ImageAdapter import com.nextroom.nextroom.presentation.databinding.FragmentHintBinding import com.nextroom.nextroom.presentation.extension.enableFullScreen import com.nextroom.nextroom.presentation.extension.repeatOnStarted import com.nextroom.nextroom.presentation.extension.safeNavigate import com.nextroom.nextroom.presentation.extension.snackbar -import com.nextroom.nextroom.presentation.extension.toTimerFormat import com.nextroom.nextroom.presentation.extension.updateSystemPadding +import com.nextroom.nextroom.presentation.ui.hint.compose.HintScreen +import com.nextroom.nextroom.presentation.ui.hint.compose.HintTimerToolbar import com.nextroom.nextroom.presentation.ui.main.GameSharedViewModel import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.delay import kotlinx.coroutines.launch -import org.orbitmvi.orbit.viewmodel.observe -import timber.log.Timber @AndroidEntryPoint class HintFragment : BaseFragment(FragmentHintBinding::inflate) { - private val viewModel: HintViewModel by viewModels() private val gameSharedViewModel: GameSharedViewModel by hiltNavGraphViewModels(R.id.game_navigation) - private val state: HintState - get() = viewModel.container.stateFlow.value - - private var scrolled: Boolean = false - //timer가 있어서 계속해서 render를 호출함. 이에 있어서 반드시 한번만 불려야 하는 ui를 위해 이 flag가 필요 - private var hintPagerInitialised = false - private var hintAnswerPagerInitialised = false - private var hintImageAdapter: ImageAdapter? = null - private var answerImageAdapter: ImageAdapter? = null + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + return ComposeView(requireContext()).apply { + setViewCompositionStrategy( + ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed + ) + setContent { + val state by viewModel.container.stateFlow.collectAsState() + + Column(modifier = Modifier.fillMaxSize()) { + HintTimerToolbar( + lastSecondsFlow = viewModel.lastSeconds, + onBackClick = ::gotoHome, + onMemoClick = ::navigateToMemo + ) + + HintScreen( + state = state, + onAnswerButtonClick = ::handleAnswerButton, + onHintImageClick = ::navigateToHintImageViewer, + onAnswerImageClick = ::navigateToAnswerImageViewer + ) + } + } + } + } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - FirebaseAnalytics.getInstance(requireContext()).logEvent("screen_view", bundleOf("screen_name" to "hint")) - initViews() - initSubscribe() - viewModel.observe(viewLifecycleOwner, state = ::render, sideEffect = ::handleEvent) - } - private fun initViews() = with(binding) { + FirebaseAnalytics.getInstance(requireContext()) + .logEvent("screen_view", bundleOf("screen_name" to "hint")) + enableFullScreen() updateSystemPadding(false) - tbHint.apply { - tvButton.text = getString(R.string.memo_button) - tvButton.setOnClickListener { - val action = HintFragmentDirections.moveToMemoFragment(true) - findNavController().safeNavigate(action) - } - ivBack.setOnClickListener { gotoHome() } - } - - btnAction.setOnClickListener { - if (state.hint.answerOpened) { - gotoHome() - } else { - // 정답 보기 - viewModel.openAnswer() - } - } + initSubscribe() } private fun initSubscribe() { @@ -87,152 +91,51 @@ class HintFragment : BaseFragment(FragmentHintBinding::infl viewModel.setSubscribeStatus(subscribeStatus) } } + launch { + viewModel.container.sideEffectFlow.collect(::handleEvent) + } } } - private fun render(state: HintState) = with(binding) { - pbLoading.isVisible = state.loading - tbHint.tvTitle.text = state.lastSeconds.toTimerFormat() - groupAnswer.isVisible = state.hint.answerOpened - btnAction.text = if (!state.hint.answerOpened) { - getString(R.string.game_hint_button_show_answer) + private fun handleAnswerButton() { + if (viewModel.container.stateFlow.value.hint.answerOpened) { + gotoHome() } else { - getString(R.string.game_hint_button_goto_home) - } - - tvProgress.text = String.format("%d%%", state.hint.progress) - tvHint.text = state.hint.hint - if (state.hint.answerOpened) { - tvAnswer.text = state.hint.answer - if (!scrolled) { - scrolled = true - - viewLifecycleOwner.repeatOnStarted { - delay(500) - svContents.smoothScrollTo(0, tvAnswerLabel.top) - } - } - - if (!hintAnswerPagerInitialised) { - hintAnswerPagerInitialised = true - setUpHintAnswerImage(binding, state) - } - } - - if (!hintPagerInitialised) { - hintPagerInitialised = true - setUpHintImage(binding, state) + viewModel.openAnswer() } } - private fun setUpHintImage(binding: FragmentHintBinding, hintState: HintState) = with(binding) { - when (hintState.userSubscribeStatus) { - SubscribeStatus.Default -> { - vpHintImage.isVisible = false - indicator.isVisible = false - } - - SubscribeStatus.SUBSCRIPTION_EXPIRATION, - SubscribeStatus.Subscribed -> { - vpHintImage.isVisible = hintState.hint.hintImageUrlList.isNotEmpty() - indicator.isVisible = hintState.hint.hintImageUrlList.isNotEmpty() - } + private fun navigateToHintImageViewer(position: Int) { + val state = viewModel.container.stateFlow.value + if (state.userSubscribeStatus == SubscribeStatus.Subscribed) { + NavGraphDirections.moveToImageViewerFragment( + imageUrlList = state.hint.hintImageUrlList.toTypedArray(), + position = position + ).also { + findNavController().safeNavigate(it) + } + FirebaseAnalytics.getInstance(requireContext()) + .logEvent("btn_click", bundleOf("btn_name" to "hint_image")) } - - hintImageAdapter = null - hintImageAdapter = ImageAdapter( - onImageClicked = { - if (hintState.userSubscribeStatus == SubscribeStatus.Subscribed) { - NavGraphDirections.moveToImageViewerFragment( - imageUrlList = hintState.hint.hintImageUrlList.toTypedArray(), - position = vpHintImage.currentItem - ).also { - findNavController().safeNavigate(it) - } - - FirebaseAnalytics.getInstance(requireContext()) - .logEvent("btn_click", bundleOf("btn_name" to "hint_image")) - } - } - ) - binding.vpHintImage.adapter = hintImageAdapter - hintState.hint.hintImageUrlList.map { imageUrl -> - when (hintState.userSubscribeStatus) { - SubscribeStatus.Default -> ImageAdapter.Image.None - SubscribeStatus.Subscribed -> { - if (hintState.networkDisconnectedCount > NETWORK_DISCONNECT_LIMIT) { - ImageAdapter.Image.Drawable(R.drawable.img_error) - } else { - ImageAdapter.Image.Url(imageUrl) - } - } - - SubscribeStatus.SUBSCRIPTION_EXPIRATION -> ImageAdapter.Image.Drawable(R.drawable.img_error) - } - }.also { hintImageAdapter?.setList(it) } - - vpHintImage.registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() { - override fun onPageSelected(position: Int) { - super.onPageSelected(position) - indicator.select(position) - } - }) - indicator.withViewPager(vpHintImage) } - private fun setUpHintAnswerImage(binding: FragmentHintBinding, hintState: HintState) = with(binding) { - when (hintState.userSubscribeStatus) { - SubscribeStatus.Default -> { - vpHintAnswerImage.isVisible = false - indicatorAnswer.isVisible = false - } - - SubscribeStatus.SUBSCRIPTION_EXPIRATION, - SubscribeStatus.Subscribed -> { - vpHintAnswerImage.isVisible = hintState.hint.answerImageUrlList.isNotEmpty() - indicatorAnswer.isVisible = hintState.hint.answerImageUrlList.isNotEmpty() - } + private fun navigateToAnswerImageViewer(position: Int) { + val state = viewModel.container.stateFlow.value + if (state.userSubscribeStatus == SubscribeStatus.Subscribed) { + NavGraphDirections.moveToImageViewerFragment( + imageUrlList = state.hint.answerImageUrlList.toTypedArray(), + position = position + ).also { + findNavController().safeNavigate(it) + } + FirebaseAnalytics.getInstance(requireContext()) + .logEvent("btn_click", bundleOf("btn_name" to "answer_image")) } + } - answerImageAdapter = null - answerImageAdapter = ImageAdapter( - onImageClicked = { - if (hintState.userSubscribeStatus == SubscribeStatus.Subscribed) { - NavGraphDirections.moveToImageViewerFragment( - imageUrlList = hintState.hint.answerImageUrlList.toTypedArray(), - position = vpHintAnswerImage.currentItem - ).also { - findNavController().safeNavigate(it) - } - - FirebaseAnalytics.getInstance(requireContext()) - .logEvent("btn_click", bundleOf("btn_name" to "answer_image")) - } - } - ) - binding.vpHintAnswerImage.adapter = answerImageAdapter - hintState.hint.answerImageUrlList.map { imageUrl -> - when (hintState.userSubscribeStatus) { - SubscribeStatus.Default -> ImageAdapter.Image.None - SubscribeStatus.Subscribed -> { - if (hintState.networkDisconnectedCount > NETWORK_DISCONNECT_LIMIT) { - ImageAdapter.Image.Drawable(R.drawable.img_error) - } else { - ImageAdapter.Image.Url(imageUrl) - } - } - - SubscribeStatus.SUBSCRIPTION_EXPIRATION -> ImageAdapter.Image.Drawable(R.drawable.img_error) - } - }.also { answerImageAdapter?.setList(it) } - - vpHintAnswerImage.registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() { - override fun onPageSelected(position: Int) { - super.onPageSelected(position) - indicatorAnswer.select(position) - } - }) - indicatorAnswer.withViewPager(vpHintAnswerImage) + private fun navigateToMemo() { + val action = HintFragmentDirections.moveToMemoFragment(true) + findNavController().safeNavigate(action) } private fun handleEvent(event: HintEvent) { @@ -245,19 +148,6 @@ class HintFragment : BaseFragment(FragmentHintBinding::infl } private fun gotoHome() { - Timber.d("gotoHome") findNavController().popBackStack(R.id.timer_fragment, false) } - - override fun onDestroyView() { - hintPagerInitialised = false - hintAnswerPagerInitialised = false - hintImageAdapter = null - answerImageAdapter = null - super.onDestroyView() - } - - companion object { - private const val NETWORK_DISCONNECT_LIMIT = 3 - } } diff --git a/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/hint/HintState.kt b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/hint/HintState.kt index 816d943..5fe399c 100644 --- a/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/hint/HintState.kt +++ b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/hint/HintState.kt @@ -6,7 +6,6 @@ import com.nextroom.nextroom.presentation.model.Hint data class HintState( val loading: Boolean = false, val hint: Hint = Hint(), - val lastSeconds: Int = 0, val userSubscribeStatus: SubscribeStatus = SubscribeStatus.Default, val networkDisconnectedCount: Int = 0 ) diff --git a/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/hint/HintViewModel.kt b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/hint/HintViewModel.kt index 2ecfb19..c2ffc13 100644 --- a/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/hint/HintViewModel.kt +++ b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/hint/HintViewModel.kt @@ -1,6 +1,5 @@ package com.nextroom.nextroom.presentation.ui.hint -import com.mangbaam.commonutil.DateTimeUtil import com.nextroom.nextroom.domain.model.SubscribeStatus import com.nextroom.nextroom.domain.repository.DataStoreRepository import com.nextroom.nextroom.domain.repository.TimerRepository @@ -23,16 +22,9 @@ class HintViewModel @Inject constructor( override val container: Container = container(HintState()) - private val state: HintState - get() = container.stateFlow.value - - private val dateTimeUtil: DateTimeUtil by lazy { DateTimeUtil() } + val lastSeconds = timerRepository.lastSeconds init { - baseViewModelScope.launch { - timerRepository.lastSeconds.collect(::tick) - } - baseViewModelScope.launch { dataStoreRepository .getNetworkDisconnectedCount() @@ -47,7 +39,9 @@ class HintViewModel @Inject constructor( } fun setHint(hint: Hint) = intent { - reduce { state.copy(hint = hint) } + reduce { + state.copy(hint = hint.copy(answerOpened = state.hint.answerOpened)) + } } fun setSubscribeStatus(subscribeStatus: SubscribeStatus) = intent { @@ -57,8 +51,4 @@ class HintViewModel @Inject constructor( fun openAnswer() = intent { reduce { state.copy(hint = state.hint.copy(answerOpened = true)) } } - - private fun tick(lastSeconds: Int) = intent { - reduce { state.copy(lastSeconds = lastSeconds) } - } } diff --git a/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/hint/compose/HintScreen.kt b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/hint/compose/HintScreen.kt new file mode 100644 index 0000000..df80024 --- /dev/null +++ b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/hint/compose/HintScreen.kt @@ -0,0 +1,320 @@ +package com.nextroom.nextroom.presentation.ui.hint.compose + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.nextroom.nextroom.domain.model.SubscribeStatus +import com.nextroom.nextroom.presentation.R +import com.nextroom.nextroom.presentation.common.compose.NRColor +import com.nextroom.nextroom.presentation.common.compose.NRLoading +import com.nextroom.nextroom.presentation.common.compose.NRToolbar +import com.nextroom.nextroom.presentation.common.compose.NRTypo +import com.nextroom.nextroom.presentation.extension.toTimerFormat +import com.nextroom.nextroom.presentation.model.Hint +import com.nextroom.nextroom.presentation.ui.hint.HintState +import kotlinx.coroutines.launch + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun HintScreen( + state: HintState, + onAnswerButtonClick: () -> Unit, + onHintImageClick: (Int) -> Unit, + onAnswerImageClick: (Int) -> Unit, + modifier: Modifier = Modifier +) { + val listState = rememberLazyListState() + val coroutineScope = rememberCoroutineScope() + var hasScrolledToAnswer by rememberSaveable { mutableStateOf(false) } + + LaunchedEffect(state.hint.answerOpened) { + if (state.hint.answerOpened && !hasScrolledToAnswer) { + coroutineScope.launch { + listState.animateScrollToItem(index = 1) + hasScrolledToAnswer = true + } + } + } + + Box(modifier = modifier.fillMaxSize()) { + Column(modifier = Modifier.fillMaxSize()) { + LazyColumn( + state = listState, + modifier = modifier.padding(horizontal = 20.dp) + ) { + item { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Image( + painter = painterResource(R.drawable.hint), + contentDescription = null, + modifier = Modifier + .padding(top = 30.dp) + .width(274.dp) + .height(164.dp) + ) + + Column( + modifier = Modifier + .fillMaxWidth() + ) { + Text( + text = stringResource(R.string.game_progress_rate), + style = NRTypo.Pretendard.size24, + ) + Text( + text = "${state.hint.progress}%", + style = NRTypo.Pretendard.size32, + color = NRColor.Gray01, + modifier = Modifier.padding(top = 12.dp) + ) + } + + HorizontalDivider( + color = NRColor.Gray02, + thickness = 1.dp, + modifier = Modifier.padding(vertical = 28.dp) + ) + + Text( + text = stringResource(R.string.common_hint), + style = NRTypo.Pretendard.size20, + modifier = Modifier.fillMaxWidth() + ) + + if (state.hint.hintImageUrlList.isNotEmpty()) { + ImagePager( + imageUrls = state.hint.hintImageUrlList, + subscribeStatus = state.userSubscribeStatus, + networkDisconnectedCount = state.networkDisconnectedCount, + onImageClick = onHintImageClick, + modifier = Modifier.padding(top = 12.dp) + ) + } + + Text( + text = state.hint.hint, + style = NRTypo.Pretendard.size20, + color = NRColor.Gray01, + modifier = Modifier + .fillMaxWidth() + .padding(top = 12.dp) + ) + } + } + + if (state.hint.answerOpened) { + item { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + HorizontalDivider( + color = NRColor.Gray02, + thickness = 1.dp, + modifier = Modifier.padding(vertical = 28.dp) + ) + + Text( + text = stringResource(R.string.game_answer), + style = NRTypo.Pretendard.size20, + modifier = Modifier.fillMaxWidth() + ) + + if (state.hint.answerImageUrlList.isNotEmpty()) { + ImagePager( + imageUrls = state.hint.answerImageUrlList, + subscribeStatus = state.userSubscribeStatus, + networkDisconnectedCount = state.networkDisconnectedCount, + onImageClick = onAnswerImageClick, + modifier = Modifier.padding(top = 12.dp) + ) + } + + Text( + text = state.hint.answer, + style = NRTypo.Pretendard.size20, + color = NRColor.Gray01, + modifier = Modifier + .fillMaxWidth() + .padding(top = 12.dp) + ) + + Spacer(modifier = Modifier.height(200.dp)) + } + } + } else { + item { + Spacer(modifier = Modifier.height(200.dp)) + } + } + } + } + + Box( + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .height(200.dp) + .background( + Brush.verticalGradient( + colors = listOf( + Color.Transparent, + NRColor.Black + ) + ) + ), + contentAlignment = Alignment.BottomCenter + ) { + Button( + onClick = onAnswerButtonClick, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 40.dp) + .height(56.dp), + colors = ButtonDefaults.buttonColors( + containerColor = NRColor.White + ) + ) { + Text( + text = if (state.hint.answerOpened) { + stringResource(R.string.game_hint_button_goto_home) + } else { + stringResource(R.string.game_hint_button_show_answer) + }, + color = NRColor.Black, + style = NRTypo.Pretendard.size16Bold + ) + } + } + + NRLoading(isVisible = state.loading) + } +} + +@Preview(showBackground = true, backgroundColor = 0xFF151516) +@Composable +private fun HintScreenWithNoImagesPreview() { + HintScreen( + state = HintState( + loading = false, + hint = Hint( + id = 1, + progress = 45, + hint = "이 힌트는 미로 출구를 찾는 데 도움이 됩니다. 벽의 패턴을 주의 깊게 살펴보세요.", + answer = "미로의 출구는 북쪽 벽의 세 번째 문입니다. 빨간색 표시를 따라가세요.", + answerOpened = false, + hintImageUrlList = emptyList(), + answerImageUrlList = emptyList() + ), + userSubscribeStatus = SubscribeStatus.Subscribed, + networkDisconnectedCount = 0 + ), + onAnswerButtonClick = {}, + onHintImageClick = {}, + onAnswerImageClick = {} + ) +} + +@Preview(showBackground = true, backgroundColor = 0xFF151516) +@Composable +private fun HintScreenWithImagesPreview() { + HintScreen( + state = HintState( + loading = false, + hint = Hint( + id = 1, + progress = 45, + hint = "이 힌트는 미로 출구를 찾는 데 도움이 됩니다. 벽의 패턴을 주의 깊게 살펴보세요.", + answer = "미로의 출구는 북쪽 벽의 세 번째 문입니다. 빨간색 표시를 따라가세요.", + answerOpened = false, + hintImageUrlList = listOf( + "https://example.com/hint1.jpg", + "https://example.com/hint2.jpg" + ), + answerImageUrlList = emptyList() + ), + userSubscribeStatus = SubscribeStatus.Subscribed, + networkDisconnectedCount = 0 + ), + onAnswerButtonClick = {}, + onHintImageClick = {}, + onAnswerImageClick = {} + ) +} + +@Preview(showBackground = true, backgroundColor = 0xFF151516) +@Composable +private fun HintScreenAnswerOpenedPreview() { + HintScreen( + state = HintState( + loading = false, + hint = Hint( + id = 1, + progress = 85, + hint = "이 힌트는 미로 출구를 찾는 데 도움이 됩니다. 벽의 패턴을 주의 깊게 살펴보세요.", + answer = "미로의 출구는 북쪽 벽의 세 번째 문입니다. 빨간색 표시를 따라가세요.", + answerOpened = true, + hintImageUrlList = emptyList(), + answerImageUrlList = emptyList(), + ), + userSubscribeStatus = SubscribeStatus.Subscribed, + networkDisconnectedCount = 0 + ), + onAnswerButtonClick = {}, + onHintImageClick = {}, + onAnswerImageClick = {} + ) +} + +@Composable +fun HintTimerToolbar( + lastSecondsFlow: kotlinx.coroutines.flow.Flow, + onBackClick: () -> Unit, + onMemoClick: () -> Unit +) { + val lastSeconds by lastSecondsFlow.collectAsState(initial = 0) + + val timerText = remember(lastSeconds) { + lastSeconds.toTimerFormat() + } + + NRToolbar( + title = timerText, + onBackClick = onBackClick, + onRightButtonClick = onMemoClick + ) +} diff --git a/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/hint/compose/ImagePager.kt b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/hint/compose/ImagePager.kt new file mode 100644 index 0000000..840eb4c --- /dev/null +++ b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/hint/compose/ImagePager.kt @@ -0,0 +1,146 @@ +package com.nextroom.nextroom.presentation.ui.hint.compose + +import androidx.compose.foundation.ExperimentalFoundationApi +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.aspectRatio +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.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import com.nextroom.nextroom.domain.model.SubscribeStatus +import com.nextroom.nextroom.presentation.R +import com.nextroom.nextroom.presentation.common.compose.NRColor +import com.nextroom.nextroom.presentation.extension.throttleClick + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun ImagePager( + imageUrls: List, + subscribeStatus: SubscribeStatus, + networkDisconnectedCount: Int, + onImageClick: (Int) -> Unit, + modifier: Modifier = Modifier +) { + if (imageUrls.isEmpty()) return + + val pagerState = rememberPagerState(pageCount = { imageUrls.size }) + + Column(modifier = modifier) { + HorizontalPager( + state = pagerState, + modifier = Modifier + .fillMaxWidth() + .aspectRatio(1f) + .clip(RoundedCornerShape(12.dp)) + ) { page -> + val imageModel = when { + subscribeStatus == SubscribeStatus.SUBSCRIPTION_EXPIRATION + || subscribeStatus == SubscribeStatus.Default -> + R.drawable.img_error + + networkDisconnectedCount > SUBSCRIBE_CHECK_LIMIT -> + R.drawable.img_error + + subscribeStatus == SubscribeStatus.Subscribed -> + imageUrls[page] + + else -> null + } + + AsyncImage( + model = imageModel, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .fillMaxSize() + .clip(RoundedCornerShape(12.dp)) + .throttleClick { + onImageClick(page) + } + ) + } + + Spacer(modifier = Modifier.height(12.dp)) + + PagerIndicator( + pageCount = imageUrls.size, + currentPage = pagerState.currentPage, + modifier = Modifier.align(Alignment.CenterHorizontally) + ) + } +} + +@Composable +private fun PagerIndicator( + pageCount: Int, + currentPage: Int, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier, + horizontalArrangement = Arrangement.Center + ) { + repeat(pageCount) { index -> + val color = if (index == currentPage) { + NRColor.White + } else { + NRColor.Gray05 + } + + Box( + modifier = Modifier + .padding(horizontal = 2.dp) + .size(8.dp) + .background(color, CircleShape) + ) + } + } +} + +@Preview(showBackground = true, backgroundColor = 0xFF151516) +@Composable +private fun ImagePagerPreview() { + ImagePager( + imageUrls = listOf( + "https://example.com/image1.jpg", + "https://example.com/image2.jpg", + "https://example.com/image3.jpg" + ), + subscribeStatus = SubscribeStatus.Subscribed, + networkDisconnectedCount = 0, + onImageClick = {} + ) +} + +@Preview(showBackground = true, backgroundColor = 0xFF151516) +@Composable +private fun ImagePagerExpiredPreview() { + ImagePager( + imageUrls = listOf( + "https://example.com/image1.jpg" + ), + subscribeStatus = SubscribeStatus.SUBSCRIPTION_EXPIRATION, + networkDisconnectedCount = 0, + onImageClick = {} + ) +} + +private const val SUBSCRIBE_CHECK_LIMIT = 3 \ No newline at end of file From 09630e8b9127bfe06c9836cd3ce1a3ae74c7003a Mon Sep 17 00:00:00 2001 From: juhwankim-dev Date: Sun, 28 Dec 2025 02:55:43 +0900 Subject: [PATCH 6/7] =?UTF-8?q?NR-128=20compose=EC=9A=A9=20baseFragment=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../presentation/base/ComposeBaseFragment.kt | 34 +++++++++++++++++++ .../base/ComposeBaseViewModelFragment.kt | 32 +++++++++++++++++ 2 files changed, 66 insertions(+) create mode 100644 presentation/src/main/java/com/nextroom/nextroom/presentation/base/ComposeBaseFragment.kt create mode 100644 presentation/src/main/java/com/nextroom/nextroom/presentation/base/ComposeBaseViewModelFragment.kt diff --git a/presentation/src/main/java/com/nextroom/nextroom/presentation/base/ComposeBaseFragment.kt b/presentation/src/main/java/com/nextroom/nextroom/presentation/base/ComposeBaseFragment.kt new file mode 100644 index 0000000..9d20a17 --- /dev/null +++ b/presentation/src/main/java/com/nextroom/nextroom/presentation/base/ComposeBaseFragment.kt @@ -0,0 +1,34 @@ +package com.nextroom.nextroom.presentation.base + +import android.os.Bundle +import android.view.View +import androidx.core.os.bundleOf +import androidx.fragment.app.Fragment +import com.google.firebase.analytics.FirebaseAnalytics +import com.nextroom.nextroom.presentation.extension.updateSystemPadding + +abstract class ComposeBaseFragment : Fragment() { + abstract val screenName: String + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + updateSystemPadding() + + initSubscribe() + initObserve() + setFragmentResultListeners() + initViews() + } + + open fun initSubscribe() {} + open fun initObserve() {} + open fun setFragmentResultListeners() {} + open fun initViews() {} + + override fun onResume() { + super.onResume() + + FirebaseAnalytics.getInstance(requireContext()) + .logEvent("screen_view", bundleOf("screen_name" to screenName)) + } +} \ No newline at end of file diff --git a/presentation/src/main/java/com/nextroom/nextroom/presentation/base/ComposeBaseViewModelFragment.kt b/presentation/src/main/java/com/nextroom/nextroom/presentation/base/ComposeBaseViewModelFragment.kt new file mode 100644 index 0000000..5b41dca --- /dev/null +++ b/presentation/src/main/java/com/nextroom/nextroom/presentation/base/ComposeBaseViewModelFragment.kt @@ -0,0 +1,32 @@ +package com.nextroom.nextroom.presentation.base + +import androidx.navigation.fragment.findNavController +import com.nextroom.nextroom.presentation.NavGraphDirections +import com.nextroom.nextroom.presentation.R +import com.nextroom.nextroom.presentation.common.NROneButtonDialog +import com.nextroom.nextroom.presentation.extension.repeatOnStarted +import com.nextroom.nextroom.presentation.extension.safeNavigate +import kotlinx.coroutines.launch + +abstract class ComposeBaseViewModelFragment : ComposeBaseFragment() { + abstract val viewModel: VM + + override fun initObserve() { + super.initObserve() + + viewLifecycleOwner.repeatOnStarted { + launch { + viewModel.errorFlow.collect { + NavGraphDirections.moveToNrOneButtonDialog( + NROneButtonDialog.NROneButtonArgument( + title = getString(R.string.dialog_noti), + message = getString(R.string.error_something), + btnText = getString(R.string.text_confirm), + errorText = it.message, + ) + ).also { findNavController().safeNavigate(it) } + } + } + } + } +} \ No newline at end of file From 4640c205549fb5427600fa5478be0faf06ff1b0e Mon Sep 17 00:00:00 2001 From: juhwankim-dev Date: Sun, 28 Dec 2025 02:56:34 +0900 Subject: [PATCH 7/7] =?UTF-8?q?NR-128=20=ED=9E=8C=ED=8A=B8=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=EC=97=90=20compose=EC=9A=A9=20baseFragment=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9=20=EB=B0=8F=20orbit=20=EA=B1=B7=EC=96=B4?= =?UTF-8?q?=EB=82=B4=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../presentation/ui/hint/HintFragment.kt | 25 +++++------ .../presentation/ui/hint/HintViewModel.kt | 41 +++++++++++-------- 2 files changed, 33 insertions(+), 33 deletions(-) diff --git a/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/hint/HintFragment.kt b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/hint/HintFragment.kt index ea26d57..68cb8c5 100644 --- a/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/hint/HintFragment.kt +++ b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/hint/HintFragment.kt @@ -19,8 +19,7 @@ import com.google.firebase.analytics.FirebaseAnalytics import com.nextroom.nextroom.domain.model.SubscribeStatus import com.nextroom.nextroom.presentation.NavGraphDirections import com.nextroom.nextroom.presentation.R -import com.nextroom.nextroom.presentation.base.BaseFragment -import com.nextroom.nextroom.presentation.databinding.FragmentHintBinding +import com.nextroom.nextroom.presentation.base.ComposeBaseViewModelFragment import com.nextroom.nextroom.presentation.extension.enableFullScreen import com.nextroom.nextroom.presentation.extension.repeatOnStarted import com.nextroom.nextroom.presentation.extension.safeNavigate @@ -33,8 +32,9 @@ import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.launch @AndroidEntryPoint -class HintFragment : BaseFragment(FragmentHintBinding::inflate) { - private val viewModel: HintViewModel by viewModels() +class HintFragment : ComposeBaseViewModelFragment() { + override val screenName: String = "hint" + override val viewModel: HintViewModel by viewModels() private val gameSharedViewModel: GameSharedViewModel by hiltNavGraphViewModels(R.id.game_navigation) override fun onCreateView( @@ -47,7 +47,7 @@ class HintFragment : BaseFragment(FragmentHintBinding::infl ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed ) setContent { - val state by viewModel.container.stateFlow.collectAsState() + val state by viewModel.uiState.collectAsState() Column(modifier = Modifier.fillMaxSize()) { HintTimerToolbar( @@ -70,16 +70,11 @@ class HintFragment : BaseFragment(FragmentHintBinding::infl override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - FirebaseAnalytics.getInstance(requireContext()) - .logEvent("screen_view", bundleOf("screen_name" to "hint")) - enableFullScreen() updateSystemPadding(false) - - initSubscribe() } - private fun initSubscribe() { + override fun initSubscribe() { viewLifecycleOwner.repeatOnStarted { launch { gameSharedViewModel.currentHint.collect { hint -> @@ -92,13 +87,13 @@ class HintFragment : BaseFragment(FragmentHintBinding::infl } } launch { - viewModel.container.sideEffectFlow.collect(::handleEvent) + viewModel.uiEvent.collect(::handleEvent) } } } private fun handleAnswerButton() { - if (viewModel.container.stateFlow.value.hint.answerOpened) { + if (viewModel.uiState.value.hint.answerOpened) { gotoHome() } else { viewModel.openAnswer() @@ -106,7 +101,7 @@ class HintFragment : BaseFragment(FragmentHintBinding::infl } private fun navigateToHintImageViewer(position: Int) { - val state = viewModel.container.stateFlow.value + val state = viewModel.uiState.value if (state.userSubscribeStatus == SubscribeStatus.Subscribed) { NavGraphDirections.moveToImageViewerFragment( imageUrlList = state.hint.hintImageUrlList.toTypedArray(), @@ -120,7 +115,7 @@ class HintFragment : BaseFragment(FragmentHintBinding::infl } private fun navigateToAnswerImageViewer(position: Int) { - val state = viewModel.container.stateFlow.value + val state = viewModel.uiState.value if (state.userSubscribeStatus == SubscribeStatus.Subscribed) { NavGraphDirections.moveToImageViewerFragment( imageUrlList = state.hint.answerImageUrlList.toTypedArray(), diff --git a/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/hint/HintViewModel.kt b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/hint/HintViewModel.kt index c2ffc13..a2268aa 100644 --- a/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/hint/HintViewModel.kt +++ b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/hint/HintViewModel.kt @@ -3,24 +3,27 @@ package com.nextroom.nextroom.presentation.ui.hint import com.nextroom.nextroom.domain.model.SubscribeStatus import com.nextroom.nextroom.domain.repository.DataStoreRepository import com.nextroom.nextroom.domain.repository.TimerRepository -import com.nextroom.nextroom.presentation.base.BaseViewModel +import com.nextroom.nextroom.presentation.base.NewBaseViewModel import com.nextroom.nextroom.presentation.model.Hint import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch -import org.orbitmvi.orbit.Container -import org.orbitmvi.orbit.syntax.simple.intent -import org.orbitmvi.orbit.syntax.simple.reduce -import org.orbitmvi.orbit.viewmodel.container import javax.inject.Inject @HiltViewModel class HintViewModel @Inject constructor( private val timerRepository: TimerRepository, private val dataStoreRepository: DataStoreRepository, -) : BaseViewModel() { +) : NewBaseViewModel() { - override val container: Container = - container(HintState()) + private val _uiState = MutableStateFlow(HintState()) + val uiState = _uiState.asStateFlow() + + private val _uiEvent = MutableSharedFlow(extraBufferCapacity = 1) + val uiEvent = _uiEvent.asSharedFlow() val lastSeconds = timerRepository.lastSeconds @@ -34,21 +37,23 @@ class HintViewModel @Inject constructor( } } - private fun updateNetworkDisconnectedCount(count: Int) = intent { - reduce { state.copy(networkDisconnectedCount = count) } + private fun updateNetworkDisconnectedCount(count: Int) { + _uiState.value = _uiState.value.copy(networkDisconnectedCount = count) } - fun setHint(hint: Hint) = intent { - reduce { - state.copy(hint = hint.copy(answerOpened = state.hint.answerOpened)) - } + fun setHint(hint: Hint) { + _uiState.value = _uiState.value.copy( + hint = hint.copy(answerOpened = _uiState.value.hint.answerOpened) + ) } - fun setSubscribeStatus(subscribeStatus: SubscribeStatus) = intent { - reduce { state.copy(userSubscribeStatus = subscribeStatus) } + fun setSubscribeStatus(subscribeStatus: SubscribeStatus) { + _uiState.value = _uiState.value.copy(userSubscribeStatus = subscribeStatus) } - fun openAnswer() = intent { - reduce { state.copy(hint = state.hint.copy(answerOpened = true)) } + fun openAnswer() { + _uiState.value = _uiState.value.copy( + hint = _uiState.value.hint.copy(answerOpened = true) + ) } }