From a427f3c7b366f9217b3288d40ffe892afcd84e2c Mon Sep 17 00:00:00 2001 From: juhwankim-dev Date: Tue, 17 Feb 2026 01:00:53 +0900 Subject: [PATCH 01/10] =?UTF-8?q?NR-133=20NRToolbar=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8=20=ED=8C=8C=EB=9D=BC=EB=AF=B8=ED=84=B0=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 왜? 다른 screen에서 재사용하기 위해 필요한 파라미터를 추가하고 기존 파라미터를 optional로 변경함 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- .../presentation/common/compose/NRToolbar.kt | 58 ++++++++++--------- .../ui/hint/compose/HintScreen.kt | 1 + 2 files changed, 33 insertions(+), 26 deletions(-) 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 index c124a6d..29b8728 100644 --- 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 @@ -15,7 +15,6 @@ 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 @@ -24,23 +23,27 @@ import com.nextroom.nextroom.presentation.extension.throttleClick @Composable fun NRToolbar( - title: String, - onBackClick: () -> Unit, - onRightButtonClick: () -> Unit, + title: String? = null, + onBackClick: (() -> Unit)? = null, + rightButtonText: String? = null, + onRightButtonClick: (() -> Unit)? = null, modifier: Modifier = Modifier ) { Box( + modifier = modifier, contentAlignment = Alignment.Center ) { - Text( - text = title, - style = NRTypo.Poppins.size20, - color = NRColor.White, - textAlign = TextAlign.Center, - ) + if (title != null) { + Text( + text = title, + style = NRTypo.Poppins.size20, + color = NRColor.White, + textAlign = TextAlign.Center, + ) + } Row( - modifier = modifier + modifier = Modifier .fillMaxWidth() .height(64.dp), verticalAlignment = Alignment.CenterVertically, @@ -48,26 +51,28 @@ fun NRToolbar( ) { Image( painter = painterResource(R.drawable.ic_navigate_back_24), - modifier = modifier + modifier = Modifier .size(64.dp) - .throttleClick { onBackClick() } + .throttleClick { onBackClick?.invoke() } .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() } - ) + if (rightButtonText != null) { + Text( + text = rightButtonText, + 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?.invoke() } + ) + } } } } @@ -78,6 +83,7 @@ private fun NRToolbarPreview() { NRToolbar( title = "01:23:45", onBackClick = {}, + rightButtonText = "MEMO", onRightButtonClick = {} ) } 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 index be52fc4..f3bc1bd 100644 --- 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 @@ -377,6 +377,7 @@ fun HintTimerToolbar( NRToolbar( title = timerText, onBackClick = onBackClick, + rightButtonText = stringResource(R.string.memo_button), onRightButtonClick = onMemoClick ) } From d3daf777b947b0b06583d9c9ca82db3baa5bbb03 Mon Sep 17 00:00:00 2001 From: juhwankim-dev Date: Tue, 17 Feb 2026 01:03:26 +0900 Subject: [PATCH 02/10] =?UTF-8?q?NR-133=20NotoSans=20font=20style=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 --- .../presentation/common/compose/NRTypo.kt | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) 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 index 2832c31..706118d 100644 --- 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 @@ -1,6 +1,7 @@ package com.nextroom.nextroom.presentation.common.compose import androidx.compose.runtime.Composable +import androidx.compose.ui.text.PlatformTextStyle import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.FontFamily @@ -20,6 +21,10 @@ object NextRoomFontFamily { Font(R.font.poppins_medium, FontWeight.Medium), Font(R.font.poppins_semi_bold, FontWeight.SemiBold) ) + + val NotoSansMono = FontFamily( + Font(R.font.notosansmono_semibold, FontWeight.SemiBold) + ) } object NRTypo { @@ -280,6 +285,18 @@ object NRTypo { ) } + // ===== NotoSansMono Styles ===== + object NotoSansMono { + val size54: TextStyle + @Composable get() = TextStyle( + fontFamily = NextRoomFontFamily.NotoSansMono, + fontWeight = FontWeight.SemiBold, + fontSize = 54.sp, + platformStyle = PlatformTextStyle(includeFontPadding = false), + color = NRColor.White + ) + } + // ===== Poppins Styles ===== object Poppins { val size14: TextStyle From 67950171ac7b35189138fd6729562604f6fe28f0 Mon Sep 17 00:00:00 2001 From: juhwankim-dev Date: Tue, 17 Feb 2026 01:05:23 +0900 Subject: [PATCH 03/10] =?UTF-8?q?NR-133=20=ED=8A=9C=ED=86=A0=EB=A6=AC?= =?UTF-8?q?=EC=96=BC=20=EA=B4=80=EB=A0=A8=20=EB=AC=B8=EC=9E=90=EC=97=B4=20?= =?UTF-8?q?=EB=A6=AC=EC=86=8C=EC=8A=A4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- presentation/src/main/res/values/strings.xml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/presentation/src/main/res/values/strings.xml b/presentation/src/main/res/values/strings.xml index b60f96b..ccbde36 100644 --- a/presentation/src/main/res/values/strings.xml +++ b/presentation/src/main/res/values/strings.xml @@ -233,4 +233,9 @@ 에러 코드: %d 힌트를 보려면 클릭하세요! + + + 로그인 없이 체험하기 + 체험 종료 + 체험 모드를 종료하시겠습니까? \ No newline at end of file From 91527e817c995c9c590dd780e717a8c9ca77cbfa Mon Sep 17 00:00:00 2001 From: juhwankim-dev Date: Tue, 17 Feb 2026 01:09:27 +0900 Subject: [PATCH 04/10] =?UTF-8?q?NR-133=20=EC=8B=9C=EA=B0=84=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20=EB=B0=94=ED=85=80=EC=8B=9C=ED=8A=B8=EB=A5=BC=20roo?= =?UTF-8?q?t=EB=A1=9C=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 왜? 튜토리얼 플로우에서도 사용할 예정이기 때문에 nav_graph로 이동함 --- .../presentation/ui/main/TimerFragment.kt | 2 +- .../main/res/navigation/game_navigation.xml | 16 ------------- .../src/main/res/navigation/nav_graph.xml | 23 +++++++++++++++++++ 3 files changed, 24 insertions(+), 17 deletions(-) diff --git a/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/main/TimerFragment.kt b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/main/TimerFragment.kt index 1d417da..0e69f2f 100644 --- a/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/main/TimerFragment.kt +++ b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/main/TimerFragment.kt @@ -315,7 +315,7 @@ class TimerFragment : BaseFragment(FragmentTimerBinding::i } private fun showModifyTimeBottomSheet(timeLimitInMinute: Int) { - TimerFragmentDirections + NavGraphDirections .showModifyTimeBottomSheet( requestKey = REQUEST_KEY_MODIFY_TIME, timeLimitInMinute = timeLimitInMinute diff --git a/presentation/src/main/res/navigation/game_navigation.xml b/presentation/src/main/res/navigation/game_navigation.xml index 46fcd5c..38f7b60 100644 --- a/presentation/src/main/res/navigation/game_navigation.xml +++ b/presentation/src/main/res/navigation/game_navigation.xml @@ -16,9 +16,6 @@ - - - - - - - \ No newline at end of file diff --git a/presentation/src/main/res/navigation/nav_graph.xml b/presentation/src/main/res/navigation/nav_graph.xml index 48d6031..0e33676 100644 --- a/presentation/src/main/res/navigation/nav_graph.xml +++ b/presentation/src/main/res/navigation/nav_graph.xml @@ -106,6 +106,21 @@ app:argType="com.nextroom.nextroom.presentation.model.SelectItemBottomSheetArg" /> + + + + + + + + + + + + Date: Tue, 17 Feb 2026 01:11:01 +0900 Subject: [PATCH 05/10] =?UTF-8?q?NR-133=20=ED=8A=9C=ED=86=A0=EB=A6=AC?= =?UTF-8?q?=EC=96=BC=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 왜? 로그인 없이 앱을 체험할 수 있는 기능이 필요하여 기능을 추가함 기존의 화면과 똑같이 생겼지만 별도의 파일로 화면들을 구성함 --- .../presentation/ui/tutorial/TutorialData.kt | 51 ++++ .../ui/tutorial/TutorialSharedViewModel.kt | 69 +++++ .../ui/tutorial/hint/TutorialHintFragment.kt | 100 ++++++ .../ui/tutorial/hint/TutorialHintState.kt | 12 + .../ui/tutorial/hint/TutorialHintViewModel.kt | 47 +++ .../hint/compose/TutorialHintScreen.kt | 286 ++++++++++++++++++ .../ui/tutorial/memo/TutorialMemoFragment.kt | 94 ++++++ .../ui/tutorial/memo/TutorialMemoState.kt | 22 ++ .../ui/tutorial/memo/TutorialMemoViewModel.kt | 46 +++ .../memo/compose/TutorialMemoScreen.kt | 286 ++++++++++++++++++ .../ui/tutorial/timer/TutorialTimerEvent.kt | 9 + .../tutorial/timer/TutorialTimerFragment.kt | 160 ++++++++++ .../ui/tutorial/timer/TutorialTimerState.kt | 13 + .../tutorial/timer/TutorialTimerViewModel.kt | 104 +++++++ .../timer/compose/ArcProgressCompose.kt | 142 +++++++++ .../timer/compose/CodeInputSection.kt | 121 ++++++++ .../tutorial/timer/compose/KeypadSection.kt | 130 ++++++++ .../timer/compose/TutorialTimerScreen.kt | 247 +++++++++++++++ .../src/main/res/navigation/nav_graph.xml | 1 + .../res/navigation/tutorial_navigation.xml | 41 +++ 20 files changed, 1981 insertions(+) create mode 100644 presentation/src/main/java/com/nextroom/nextroom/presentation/ui/tutorial/TutorialData.kt create mode 100644 presentation/src/main/java/com/nextroom/nextroom/presentation/ui/tutorial/TutorialSharedViewModel.kt create mode 100644 presentation/src/main/java/com/nextroom/nextroom/presentation/ui/tutorial/hint/TutorialHintFragment.kt create mode 100644 presentation/src/main/java/com/nextroom/nextroom/presentation/ui/tutorial/hint/TutorialHintState.kt create mode 100644 presentation/src/main/java/com/nextroom/nextroom/presentation/ui/tutorial/hint/TutorialHintViewModel.kt create mode 100644 presentation/src/main/java/com/nextroom/nextroom/presentation/ui/tutorial/hint/compose/TutorialHintScreen.kt create mode 100644 presentation/src/main/java/com/nextroom/nextroom/presentation/ui/tutorial/memo/TutorialMemoFragment.kt create mode 100644 presentation/src/main/java/com/nextroom/nextroom/presentation/ui/tutorial/memo/TutorialMemoState.kt create mode 100644 presentation/src/main/java/com/nextroom/nextroom/presentation/ui/tutorial/memo/TutorialMemoViewModel.kt create mode 100644 presentation/src/main/java/com/nextroom/nextroom/presentation/ui/tutorial/memo/compose/TutorialMemoScreen.kt create mode 100644 presentation/src/main/java/com/nextroom/nextroom/presentation/ui/tutorial/timer/TutorialTimerEvent.kt create mode 100644 presentation/src/main/java/com/nextroom/nextroom/presentation/ui/tutorial/timer/TutorialTimerFragment.kt create mode 100644 presentation/src/main/java/com/nextroom/nextroom/presentation/ui/tutorial/timer/TutorialTimerState.kt create mode 100644 presentation/src/main/java/com/nextroom/nextroom/presentation/ui/tutorial/timer/TutorialTimerViewModel.kt create mode 100644 presentation/src/main/java/com/nextroom/nextroom/presentation/ui/tutorial/timer/compose/ArcProgressCompose.kt create mode 100644 presentation/src/main/java/com/nextroom/nextroom/presentation/ui/tutorial/timer/compose/CodeInputSection.kt create mode 100644 presentation/src/main/java/com/nextroom/nextroom/presentation/ui/tutorial/timer/compose/KeypadSection.kt create mode 100644 presentation/src/main/java/com/nextroom/nextroom/presentation/ui/tutorial/timer/compose/TutorialTimerScreen.kt create mode 100644 presentation/src/main/res/navigation/tutorial_navigation.xml diff --git a/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/tutorial/TutorialData.kt b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/tutorial/TutorialData.kt new file mode 100644 index 0000000..d7f3852 --- /dev/null +++ b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/tutorial/TutorialData.kt @@ -0,0 +1,51 @@ +package com.nextroom.nextroom.presentation.ui.tutorial + +import java.io.Serializable + +object TutorialData { + const val DEFAULT_TIME_LIMIT_SECONDS = 3600 // 1시간 + + val hints = listOf( + TutorialHint( + id = 1, + code = "1111", + progress = 33, + hint = "벽에 걸린 시계를 자세히 살펴보세요. 시계 바늘이 가리키는 숫자들의 합이 중요한 단서입니다.", + answer = "시계의 시침은 3, 분침은 9를 가리키고 있습니다. 3+9=12가 첫 번째 비밀번호입니다." + ), + TutorialHint( + id = 2, + code = "2222", + progress = 66, + hint = "책장에 있는 책들 중 색상이 다른 책이 하나 있습니다. 그 책의 페이지 번호를 확인하세요.", + answer = "빨간색 책의 27페이지에 금고 비밀번호 '4821'이 적혀있습니다. 금고를 열어 열쇠를 획득하세요." + ), + TutorialHint( + id = 3, + code = "3333", + progress = 100, + hint = "마지막 문을 열기 위해서는 방 안의 모든 거울에 비친 글자들을 순서대로 읽어야 합니다.", + answer = "거울에 비친 글자는 'ESCAPE'입니다. 문 앞의 키패드에 이 단어를 입력하면 탈출에 성공합니다!" + ) + ) + + // 랜덤한 힌트를 리턴한다. + fun getRandomHint(code: String): TutorialHint? { + return if (code.length == 4 && code.all { it.isDigit() }) { + val index = code.toInt() % hints.size + hints[index] + } else { + null + } + } +} + +data class TutorialHint( + val id: Int, + val code: String, + val progress: Int, + val hint: String, + val answer: String, + val hintImageUrlList: List = emptyList(), + val answerImageUrlList: List = emptyList() +) : Serializable diff --git a/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/tutorial/TutorialSharedViewModel.kt b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/tutorial/TutorialSharedViewModel.kt new file mode 100644 index 0000000..783165d --- /dev/null +++ b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/tutorial/TutorialSharedViewModel.kt @@ -0,0 +1,69 @@ +package com.nextroom.nextroom.presentation.ui.tutorial + +import com.nextroom.nextroom.presentation.base.NewBaseViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class TutorialSharedViewModel @Inject constructor() : NewBaseViewModel() { + + private val _state = MutableStateFlow(TutorialSharedState()) + val state: StateFlow = _state.asStateFlow() + + private var timerJob: Job? = null + + fun startTimer(totalSeconds: Int = TutorialData.DEFAULT_TIME_LIMIT_SECONDS) { + _state.update { it.copy(totalSeconds = totalSeconds, lastSeconds = totalSeconds) } + + timerJob?.cancel() + timerJob = baseViewModelScope.launch { + while (_state.value.lastSeconds > 0) { + delay(1000) + _state.update { it.copy(lastSeconds = it.lastSeconds - 1) } + } + } + } + + fun modifyTime(newTotalMinutes: Int) { + val newTotalSeconds = newTotalMinutes * 60 + startTimer(newTotalSeconds) + } + + fun setCurrentHint(hint: TutorialHint) { + _state.update { it.copy(currentHint = hint) } + } + + fun addOpenedHintId(hintId: Int) { + _state.update { it.copy(openedHintIds = it.openedHintIds + hintId) } + } + + fun addOpenedAnswerId(hintId: Int) { + _state.update { it.copy(openedAnswerIds = it.openedAnswerIds + hintId) } + } + + fun finishTutorial() { + timerJob?.cancel() + _state.value = TutorialSharedState() + } + + override fun onCleared() { + timerJob?.cancel() + super.onCleared() + } +} + +data class TutorialSharedState( + val totalSeconds: Int = TutorialData.DEFAULT_TIME_LIMIT_SECONDS, + val lastSeconds: Int = TutorialData.DEFAULT_TIME_LIMIT_SECONDS, + val currentHint: TutorialHint? = null, + val openedHintIds: Set = emptySet(), + val openedAnswerIds: Set = emptySet(), + val totalHintCount: Int = 3 +) diff --git a/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/tutorial/hint/TutorialHintFragment.kt b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/tutorial/hint/TutorialHintFragment.kt new file mode 100644 index 0000000..ce3bac8 --- /dev/null +++ b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/tutorial/hint/TutorialHintFragment.kt @@ -0,0 +1,100 @@ +package com.nextroom.nextroom.presentation.ui.tutorial.hint + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.compose.foundation.background +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.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.compose.ui.res.stringResource +import androidx.hilt.navigation.fragment.hiltNavGraphViewModels +import androidx.navigation.fragment.findNavController +import com.nextroom.nextroom.presentation.R +import com.nextroom.nextroom.presentation.base.ComposeBaseViewModelFragment +import com.nextroom.nextroom.presentation.common.compose.NRColor +import com.nextroom.nextroom.presentation.common.compose.NRToolbar +import com.nextroom.nextroom.presentation.extension.assistedViewModel +import com.nextroom.nextroom.presentation.extension.enableFullScreen +import com.nextroom.nextroom.presentation.extension.safeNavigate +import com.nextroom.nextroom.presentation.extension.updateSystemPadding +import com.nextroom.nextroom.presentation.ui.tutorial.TutorialSharedViewModel +import com.nextroom.nextroom.presentation.ui.tutorial.hint.compose.TutorialHintScreen +import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject + +@AndroidEntryPoint +class TutorialHintFragment : ComposeBaseViewModelFragment() { + override val screenName: String = "tutorial_hint" + + @Inject + lateinit var viewModelFactory: TutorialHintViewModel.Factory + + private val tutorialSharedViewModel: TutorialSharedViewModel by hiltNavGraphViewModels(R.id.tutorial_navigation) + + override val viewModel: TutorialHintViewModel by assistedViewModel { + viewModelFactory.create(tutorialSharedViewModel) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + return ComposeView(requireContext()).apply { + setViewCompositionStrategy( + ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed + ) + setContent { + val state by viewModel.uiState.collectAsState() + val timerText = remember(state.lastSeconds) { + val minutes = state.lastSeconds / 60 + val seconds = state.lastSeconds % 60 + "%02d:%02d".format(minutes, seconds) + } + + Column( + modifier = Modifier + .fillMaxSize() + .background(NRColor.Dark01) + ) { + NRToolbar( + title = timerText, + onBackClick = ::goBack, + rightButtonText = stringResource(R.string.memo_button), + onRightButtonClick = ::navigateToMemo + ) + + TutorialHintScreen( + state = state, + onHintOpenClick = { viewModel.openHint() }, + onAnswerOpenClick = { viewModel.openAnswer() } + ) + } + } + } + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + enableFullScreen() + updateSystemPadding(false) + } + + private fun goBack() { + findNavController().popBackStack(R.id.tutorial_timer_fragment, false) + } + + private fun navigateToMemo() { + findNavController().safeNavigate( + TutorialHintFragmentDirections.moveToTutorialMemoFragment(fromHint = true) + ) + } +} diff --git a/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/tutorial/hint/TutorialHintState.kt b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/tutorial/hint/TutorialHintState.kt new file mode 100644 index 0000000..c9da494 --- /dev/null +++ b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/tutorial/hint/TutorialHintState.kt @@ -0,0 +1,12 @@ +package com.nextroom.nextroom.presentation.ui.tutorial.hint + +import com.nextroom.nextroom.presentation.ui.tutorial.TutorialData +import com.nextroom.nextroom.presentation.ui.tutorial.TutorialHint + +data class TutorialHintState( + val hint: TutorialHint = TutorialData.hints.first(), + val isHintOpened: Boolean = false, + val isAnswerOpened: Boolean = false, + val totalHintCount: Int = 3, + val lastSeconds: Int = 0 +) diff --git a/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/tutorial/hint/TutorialHintViewModel.kt b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/tutorial/hint/TutorialHintViewModel.kt new file mode 100644 index 0000000..5029e88 --- /dev/null +++ b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/tutorial/hint/TutorialHintViewModel.kt @@ -0,0 +1,47 @@ +package com.nextroom.nextroom.presentation.ui.tutorial.hint + +import com.nextroom.nextroom.presentation.base.NewBaseViewModel +import com.nextroom.nextroom.presentation.ui.tutorial.TutorialSharedViewModel +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn + +class TutorialHintViewModel @AssistedInject constructor( + @Assisted private val tutorialSharedViewModel: TutorialSharedViewModel +) : NewBaseViewModel() { + + private val _uiState = MutableStateFlow(TutorialHintState()) + val uiState = combine( + _uiState, + tutorialSharedViewModel.state + ) { state, sharedState -> + state.copy( + hint = sharedState.currentHint ?: state.hint, + isHintOpened = (sharedState.currentHint?.id ?: 0) in sharedState.openedHintIds, + isAnswerOpened = (sharedState.currentHint?.id ?: 0) in sharedState.openedAnswerIds, + totalHintCount = sharedState.totalHintCount, + lastSeconds = sharedState.lastSeconds + ) + }.stateIn(baseViewModelScope, SharingStarted.Lazily, _uiState.value) + + fun openHint() { + tutorialSharedViewModel.state.value.currentHint?.let { hint -> + tutorialSharedViewModel.addOpenedHintId(hint.id) + } + } + + fun openAnswer() { + tutorialSharedViewModel.state.value.currentHint?.let { hint -> + tutorialSharedViewModel.addOpenedAnswerId(hint.id) + } + } + + @AssistedFactory + interface Factory { + fun create(tutorialSharedViewModel: TutorialSharedViewModel): TutorialHintViewModel + } +} diff --git a/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/tutorial/hint/compose/TutorialHintScreen.kt b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/tutorial/hint/compose/TutorialHintScreen.kt new file mode 100644 index 0000000..61562af --- /dev/null +++ b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/tutorial/hint/compose/TutorialHintScreen.kt @@ -0,0 +1,286 @@ +package com.nextroom.nextroom.presentation.ui.tutorial.hint.compose + +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.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.blur +import androidx.compose.ui.graphics.ColorFilter +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.presentation.R +import com.nextroom.nextroom.presentation.common.compose.NRColor +import com.nextroom.nextroom.presentation.common.compose.NRTypo +import com.nextroom.nextroom.presentation.extension.throttleClick +import com.nextroom.nextroom.presentation.ui.tutorial.TutorialData +import com.nextroom.nextroom.presentation.ui.tutorial.hint.TutorialHintState + +@Composable +fun TutorialHintScreen( + state: TutorialHintState, + onHintOpenClick: () -> Unit, + onAnswerOpenClick: () -> Unit, + modifier: Modifier = Modifier +) { + val listState = rememberLazyListState() + + Box(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() + ) + + Box( + modifier = Modifier + .fillMaxWidth() + .wrapContentHeight() + .heightIn(min = if (state.isHintOpened) 0.dp else 200.dp) + .padding(top = 12.dp) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .blur(if (state.isHintOpened) 0.dp else 10.dp) + ) { + Text( + text = state.hint.hint, + style = NRTypo.Pretendard.size20, + color = NRColor.Gray01, + modifier = Modifier.fillMaxWidth() + ) + } + + if (!state.isHintOpened) { + Box( + modifier = Modifier + .matchParentSize() + .background(NRColor.Black.copy(alpha = 0.1f)) + .throttleClick { onHintOpenClick() }, + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.padding(horizontal = 20.dp) + ) { + Image( + modifier = Modifier.size(20.dp), + painter = painterResource(R.drawable.ic_lock), + colorFilter = ColorFilter.tint(NRColor.White), + contentDescription = null, + ) + Text( + text = stringResource(R.string.text_open_hint_guide_message), + color = NRColor.White, + style = NRTypo.Pretendard.size14SemiBold, + modifier = Modifier.padding(top = 10.dp) + ) + Text( + text = stringResource(R.string.game_view_hint), + color = NRColor.Black, + style = NRTypo.Pretendard.size16Bold, + modifier = Modifier + .padding(top = 20.dp) + .background( + color = NRColor.White, + shape = RoundedCornerShape(8.dp) + ) + .padding(vertical = 8.dp, horizontal = 12.dp) + ) + } + } + } + } + } + } + + if (state.isHintOpened) { + 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() + ) + + Box( + modifier = Modifier + .fillMaxWidth() + .wrapContentHeight() + .heightIn(min = if (state.isAnswerOpened) 0.dp else 200.dp) + .padding(top = 12.dp) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .blur(if (state.isAnswerOpened) 0.dp else 10.dp) + ) { + Text( + text = state.hint.answer, + style = NRTypo.Pretendard.size20, + color = NRColor.Gray01, + modifier = Modifier.fillMaxWidth() + ) + } + + if (!state.isAnswerOpened) { + Box( + modifier = Modifier + .matchParentSize() + .background(NRColor.Black.copy(alpha = 0.1f)) + .throttleClick { onAnswerOpenClick() }, + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.padding(horizontal = 20.dp) + ) { + Image( + modifier = Modifier.size(20.dp), + painter = painterResource(R.drawable.ic_lock), + colorFilter = ColorFilter.tint(NRColor.White), + contentDescription = null, + ) + Text( + text = stringResource(R.string.text_open_answer_guide_message), + color = NRColor.White, + style = NRTypo.Pretendard.size14SemiBold, + modifier = Modifier.padding(top = 10.dp) + ) + Text( + text = stringResource(R.string.game_view_answer), + color = NRColor.Black, + style = NRTypo.Pretendard.size16Bold, + modifier = Modifier + .padding(top = 20.dp) + .background( + color = NRColor.White, + shape = RoundedCornerShape(8.dp) + ) + .padding(vertical = 8.dp, horizontal = 12.dp) + ) + } + } + } + } + + Spacer(modifier = Modifier.height(80.dp)) + } + } + } else { + item { + Spacer(modifier = Modifier.height(80.dp)) + } + } + } + } +} + +@Preview(showBackground = true, backgroundColor = 0xFF151516) +@Composable +private fun TutorialHintScreenHintClosedPreview() { + TutorialHintScreen( + state = TutorialHintState( + hint = TutorialData.hints[0], + isHintOpened = false, + isAnswerOpened = false + ), + onHintOpenClick = {}, + onAnswerOpenClick = {} + ) +} + +@Preview(showBackground = true, backgroundColor = 0xFF151516) +@Composable +private fun TutorialHintScreenHintOpenedPreview() { + TutorialHintScreen( + state = TutorialHintState( + hint = TutorialData.hints[1], + isHintOpened = true, + isAnswerOpened = false + ), + onHintOpenClick = {}, + onAnswerOpenClick = {} + ) +} + +@Preview(showBackground = true, backgroundColor = 0xFF151516) +@Composable +private fun TutorialHintScreenAnswerOpenedPreview() { + TutorialHintScreen( + state = TutorialHintState( + hint = TutorialData.hints[2], + isHintOpened = true, + isAnswerOpened = true + ), + onHintOpenClick = {}, + onAnswerOpenClick = {} + ) +} diff --git a/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/tutorial/memo/TutorialMemoFragment.kt b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/tutorial/memo/TutorialMemoFragment.kt new file mode 100644 index 0000000..01bb684 --- /dev/null +++ b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/tutorial/memo/TutorialMemoFragment.kt @@ -0,0 +1,94 @@ +package com.nextroom.nextroom.presentation.ui.tutorial.memo + +import android.content.Context +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.activity.OnBackPressedCallback +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.hilt.navigation.fragment.hiltNavGraphViewModels +import androidx.navigation.fragment.findNavController +import androidx.navigation.fragment.navArgs +import com.nextroom.nextroom.presentation.R +import com.nextroom.nextroom.presentation.base.ComposeBaseViewModelFragment +import com.nextroom.nextroom.presentation.extension.assistedViewModel +import com.nextroom.nextroom.presentation.extension.enableFullScreen +import com.nextroom.nextroom.presentation.extension.updateSystemPadding +import com.nextroom.nextroom.presentation.ui.tutorial.TutorialSharedViewModel +import com.nextroom.nextroom.presentation.ui.tutorial.memo.compose.TutorialMemoScreen +import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject + +@AndroidEntryPoint +class TutorialMemoFragment : ComposeBaseViewModelFragment() { + override val screenName: String = "tutorial_memo" + + @Inject + lateinit var viewModelFactory: TutorialMemoViewModel.Factory + + private val tutorialSharedViewModel: TutorialSharedViewModel by hiltNavGraphViewModels(R.id.tutorial_navigation) + + override val viewModel: TutorialMemoViewModel by assistedViewModel { + viewModelFactory.create(tutorialSharedViewModel) + } + + private val args: TutorialMemoFragmentArgs by navArgs() + + private lateinit var backCallback: OnBackPressedCallback + + override fun onAttach(context: Context) { + super.onAttach(context) + backCallback = object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() {} // Block back press + } + requireActivity().onBackPressedDispatcher.addCallback(this, backCallback) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + return ComposeView(requireContext()).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + val state by viewModel.uiState.collectAsState() + + TutorialMemoScreen( + state = state, + fromHint = args.fromHint, + onBackClick = ::goBack, + onHintClick = ::goToHint, + onPenClick = viewModel::pickPen, + onEraserClick = viewModel::pickEraser, + onEraseAllClick = viewModel::eraseAll, + onPathsChanged = viewModel::updatePaths + ) + } + } + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + enableFullScreen() + updateSystemPadding(false) + } + + private fun goBack() { + findNavController().popBackStack() + } + + private fun goToHint() { + findNavController().popBackStack(R.id.tutorial_hint_fragment, false) + } + + override fun onDetach() { + backCallback.remove() + super.onDetach() + } +} diff --git a/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/tutorial/memo/TutorialMemoState.kt b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/tutorial/memo/TutorialMemoState.kt new file mode 100644 index 0000000..f8b8828 --- /dev/null +++ b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/tutorial/memo/TutorialMemoState.kt @@ -0,0 +1,22 @@ +package com.nextroom.nextroom.presentation.ui.tutorial.memo + +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color + +sealed interface TutorialDrawingTool { + object Pen : TutorialDrawingTool + object Eraser : TutorialDrawingTool +} + +data class TutorialMemoState( + val lastSeconds: Int = 0, + val currentTool: TutorialDrawingTool = TutorialDrawingTool.Pen, + val paths: List = emptyList(), + val clearCanvas: Boolean = false +) + +data class PathData( + val points: List, + val color: Color = Color.White, + val strokeWidth: Float = 5f +) diff --git a/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/tutorial/memo/TutorialMemoViewModel.kt b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/tutorial/memo/TutorialMemoViewModel.kt new file mode 100644 index 0000000..06bd62c --- /dev/null +++ b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/tutorial/memo/TutorialMemoViewModel.kt @@ -0,0 +1,46 @@ +package com.nextroom.nextroom.presentation.ui.tutorial.memo + +import com.nextroom.nextroom.presentation.base.NewBaseViewModel +import com.nextroom.nextroom.presentation.ui.tutorial.TutorialSharedViewModel +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update + +class TutorialMemoViewModel @AssistedInject constructor( + @Assisted private val tutorialSharedViewModel: TutorialSharedViewModel +) : NewBaseViewModel() { + + private val _uiState = MutableStateFlow(TutorialMemoState()) + val uiState = combine( + _uiState, + tutorialSharedViewModel.state + ) { state, sharedState -> + state.copy(lastSeconds = sharedState.lastSeconds) + }.stateIn(baseViewModelScope, SharingStarted.Lazily, _uiState.value) + + fun pickPen() { + _uiState.update { it.copy(currentTool = TutorialDrawingTool.Pen) } + } + + fun pickEraser() { + _uiState.update { it.copy(currentTool = TutorialDrawingTool.Eraser) } + } + + fun eraseAll() { + _uiState.update { it.copy(paths = emptyList(), clearCanvas = true) } + } + + fun updatePaths(paths: List) { + _uiState.update { it.copy(paths = paths, clearCanvas = false) } + } + + @AssistedFactory + interface Factory { + fun create(tutorialSharedViewModel: TutorialSharedViewModel): TutorialMemoViewModel + } +} diff --git a/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/tutorial/memo/compose/TutorialMemoScreen.kt b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/tutorial/memo/compose/TutorialMemoScreen.kt new file mode 100644 index 0000000..d0af611 --- /dev/null +++ b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/tutorial/memo/compose/TutorialMemoScreen.kt @@ -0,0 +1,286 @@ +package com.nextroom.nextroom.presentation.ui.tutorial.memo.compose + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.input.pointer.pointerInput +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.presentation.R +import com.nextroom.nextroom.presentation.common.compose.NRColor +import com.nextroom.nextroom.presentation.common.compose.NRToolbar +import com.nextroom.nextroom.presentation.ui.tutorial.memo.PathData +import com.nextroom.nextroom.presentation.ui.tutorial.memo.TutorialDrawingTool +import com.nextroom.nextroom.presentation.ui.tutorial.memo.TutorialMemoState + +@Composable +fun TutorialMemoScreen( + state: TutorialMemoState, + fromHint: Boolean, + onBackClick: () -> Unit, + onHintClick: () -> Unit, + onPenClick: () -> Unit, + onEraserClick: () -> Unit, + onEraseAllClick: () -> Unit, + onPathsChanged: (List) -> Unit, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier + .fillMaxSize() + .background(NRColor.Dark01) + ) { + NRToolbar( + title = state.lastSeconds.toTimerFormat(), + onBackClick = onBackClick, + rightButtonText = if (fromHint) stringResource(R.string.common_hint_eng) else null, + onRightButtonClick = if (fromHint) onHintClick else null + ) + + // Drawing area with tool buttons overlaid at bottom-right + Box(modifier = Modifier.weight(1f)) { + DrawingCanvas( + paths = state.paths, + currentTool = state.currentTool, + clearCanvas = state.clearCanvas, + onPathsChanged = onPathsChanged, + modifier = Modifier.fillMaxSize() + ) + + // Tool buttons: vertically stacked at bottom-right + // Matches fragment_memo.xml: guide_end=20dp, marginBottom=60dp for last button + Column( + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(end = 20.dp, bottom = 60.dp), + verticalArrangement = Arrangement.spacedBy(24.dp), + horizontalAlignment = Alignment.End + ) { + ToolButton( + iconRes = if (state.currentTool == TutorialDrawingTool.Pen) { + R.drawable.ic_pen_selected + } else { + R.drawable.ic_pen_normal + }, + onClick = onPenClick, + ) + ToolButton( + iconRes = if (state.currentTool == TutorialDrawingTool.Eraser) { + R.drawable.ic_eraser_selected + } else { + R.drawable.ic_eraser_normal + }, + onClick = onEraserClick, + ) + ToolButton( + iconRes = R.drawable.ic_delete_normal, + onClick = onEraseAllClick, + ) + } + } + } +} + +@Composable +private fun DrawingCanvas( + paths: List, + currentTool: TutorialDrawingTool, + clearCanvas: Boolean, + onPathsChanged: (List) -> Unit, + modifier: Modifier = Modifier +) { + var currentPath by remember { mutableStateOf(null) } + var localPaths by remember { mutableStateOf(paths) } + + LaunchedEffect(clearCanvas) { + if (clearCanvas) { + localPaths = emptyList() + currentPath = null + } + } + + LaunchedEffect(paths) { + if (paths != localPaths && !clearCanvas) { + localPaths = paths + } + } + + Canvas( + modifier = modifier + .fillMaxWidth() + .pointerInput(currentTool) { + detectDragGestures( + onDragStart = { offset -> + if (currentTool == TutorialDrawingTool.Pen) { + currentPath = PathData(points = listOf(offset)) + } + }, + onDrag = { change, _ -> + change.consume() + if (currentTool == TutorialDrawingTool.Pen) { + currentPath = currentPath?.copy( + points = currentPath!!.points + change.position + ) + } else { + val touchPoint = change.position + val eraserRadius = 30f + localPaths = localPaths.filter { pathData -> + !pathData.points.any { point -> + (point - touchPoint).getDistance() < eraserRadius + } + } + onPathsChanged(localPaths) + } + }, + onDragEnd = { + currentPath?.let { + if (it.points.size > 1) { + localPaths = localPaths + it + onPathsChanged(localPaths) + } + } + currentPath = null + }, + onDragCancel = { + currentPath = null + } + ) + } + ) { + // Draw saved path + localPaths.forEach { pathData -> + if (pathData.points.size > 1) { + val path = Path().apply { + moveTo(pathData.points.first().x, pathData.points.first().y) + pathData.points.drop(1).forEach { point -> + lineTo(point.x, point.y) + } + } + drawPath( + path = path, + color = pathData.color, + style = Stroke(width = pathData.strokeWidth, cap = StrokeCap.Round) + ) + } + } + + // Draw current path + currentPath?.let { pathData -> + if (pathData.points.size > 1) { + val path = Path().apply { + moveTo(pathData.points.first().x, pathData.points.first().y) + pathData.points.drop(1).forEach { point -> + lineTo(point.x, point.y) + } + } + drawPath( + path = path, + color = pathData.color, + style = Stroke(width = pathData.strokeWidth, cap = StrokeCap.Round) + ) + } + } + } +} + +@Composable +private fun ToolButton( + iconRes: Int, + onClick: () -> Unit, + tint: Color = Color.Unspecified +) { + IconButton( + onClick = onClick, + modifier = Modifier.size(50.dp) + ) { + Icon( + painter = painterResource(iconRes), + contentDescription = null, + tint = tint, + modifier = Modifier.size(50.dp) + ) + } +} + +private fun Int.toTimerFormat(): String { + val minutes = this / 60 + val seconds = this % 60 + return "%02d:%02d".format(minutes, seconds) +} + +@Preview(showBackground = true, backgroundColor = 0xFF151516) +@Composable +private fun TutorialMemoScreenPenPreview() { + TutorialMemoScreen( + state = TutorialMemoState( + lastSeconds = 1800, + currentTool = TutorialDrawingTool.Pen + ), + fromHint = false, + onBackClick = {}, + onHintClick = {}, + onPenClick = {}, + onEraserClick = {}, + onEraseAllClick = {}, + onPathsChanged = {} + ) +} + +@Preview(showBackground = true, backgroundColor = 0xFF151516) +@Composable +private fun TutorialMemoScreenEraserPreview() { + TutorialMemoScreen( + state = TutorialMemoState( + lastSeconds = 1800, + currentTool = TutorialDrawingTool.Eraser + ), + fromHint = false, + onBackClick = {}, + onHintClick = {}, + onPenClick = {}, + onEraserClick = {}, + onEraseAllClick = {}, + onPathsChanged = {} + ) +} + +@Preview(showBackground = true, backgroundColor = 0xFF151516) +@Composable +private fun TutorialMemoFromHintPreview() { + TutorialMemoScreen( + state = TutorialMemoState( + lastSeconds = 1800, + currentTool = TutorialDrawingTool.Eraser + ), + fromHint = true, + onBackClick = {}, + onHintClick = {}, + onPenClick = {}, + onEraserClick = {}, + onEraseAllClick = {}, + onPathsChanged = {} + ) +} \ No newline at end of file diff --git a/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/tutorial/timer/TutorialTimerEvent.kt b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/tutorial/timer/TutorialTimerEvent.kt new file mode 100644 index 0000000..0df95cd --- /dev/null +++ b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/tutorial/timer/TutorialTimerEvent.kt @@ -0,0 +1,9 @@ +package com.nextroom.nextroom.presentation.ui.tutorial.timer + +import com.nextroom.nextroom.presentation.ui.tutorial.TutorialHint + +sealed interface TutorialTimerEvent { + data class OpenHint(val hint: TutorialHint) : TutorialTimerEvent + object TimerFinished : TutorialTimerEvent + object ExitTutorial : TutorialTimerEvent +} diff --git a/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/tutorial/timer/TutorialTimerFragment.kt b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/tutorial/timer/TutorialTimerFragment.kt new file mode 100644 index 0000000..d296d76 --- /dev/null +++ b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/tutorial/timer/TutorialTimerFragment.kt @@ -0,0 +1,160 @@ +package com.nextroom.nextroom.presentation.ui.tutorial.timer + +import android.content.Context +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.activity.OnBackPressedCallback +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.fragment.app.setFragmentResultListener +import androidx.hilt.navigation.fragment.hiltNavGraphViewModels +import androidx.navigation.fragment.findNavController +import com.nextroom.nextroom.presentation.NavGraphDirections +import com.nextroom.nextroom.presentation.R +import com.nextroom.nextroom.presentation.base.ComposeBaseViewModelFragment +import com.nextroom.nextroom.presentation.common.NRTwoButtonDialog +import com.nextroom.nextroom.presentation.extension.assistedViewModel +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.updateSystemPadding +import com.nextroom.nextroom.presentation.ui.main.ModifyTimeBottomSheet +import com.nextroom.nextroom.presentation.ui.tutorial.TutorialSharedViewModel +import com.nextroom.nextroom.presentation.ui.tutorial.timer.compose.TutorialTimerScreen +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.launch +import javax.inject.Inject + +@AndroidEntryPoint +class TutorialTimerFragment : ComposeBaseViewModelFragment() { + override val screenName: String = "tutorial_timer" + + @Inject + lateinit var viewModelFactory: TutorialTimerViewModel.Factory + + private val tutorialSharedViewModel: TutorialSharedViewModel by hiltNavGraphViewModels(R.id.tutorial_navigation) + + override val viewModel: TutorialTimerViewModel by assistedViewModel { + viewModelFactory.create(tutorialSharedViewModel) + } + + private lateinit var backCallback: OnBackPressedCallback + + override fun onAttach(context: Context) { + super.onAttach(context) + backCallback = object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + // Block back press during game, require long-press exit + } + } + requireActivity().onBackPressedDispatcher.addCallback(this, backCallback) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + return ComposeView(requireContext()).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + val state by viewModel.uiState.collectAsState() + + TutorialTimerScreen( + state = state, + onKeyInput = viewModel::inputHintCode, + onBackspace = viewModel::backspaceHintCode, + onMemoClick = ::navigateToMemo, + onExitLongPress = ::showExitDialog, + onTimerLongPress = ::showModifyTimeBottomSheet + ) + } + } + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + enableFullScreen() + updateSystemPadding(false) + } + + override fun initSubscribe() { + viewLifecycleOwner.repeatOnStarted { + launch { + viewModel.uiEvent.collect(::handleEvent) + } + } + } + + private fun handleEvent(event: TutorialTimerEvent) { + when (event) { + is TutorialTimerEvent.OpenHint -> { + tutorialSharedViewModel.setCurrentHint(event.hint) + findNavController().safeNavigate( + TutorialTimerFragmentDirections.moveToTutorialHintFragment() + ) + } + + is TutorialTimerEvent.TimerFinished -> { + snackbar(R.string.game_finished) + } + + is TutorialTimerEvent.ExitTutorial -> { + tutorialSharedViewModel.finishTutorial() + findNavController().popBackStack(R.id.login_fragment, false) + } + } + } + + private fun navigateToMemo() { + findNavController().safeNavigate( + TutorialTimerFragmentDirections.moveToTutorialMemoFragment() + ) + } + + private fun showExitDialog() { + NavGraphDirections.moveToNrTwoButtonDialog( + NRTwoButtonDialog.NRTwoButtonArgument( + title = getString(R.string.text_tutorial_exit_dialog_title), + message = getString(R.string.text_tutorial_exit_dialog_message), + posBtnText = getString(R.string.dialog_yes), + negBtnText = getString(R.string.dialog_no), + dialogKey = REQUEST_KEY_EXIT_TUTORIAL + ) + ).also { findNavController().safeNavigate(it) } + } + + private fun showModifyTimeBottomSheet() { + val currentMinutes = viewModel.uiState.value.totalSeconds / 60 + NavGraphDirections.showModifyTimeBottomSheet( + requestKey = REQUEST_KEY_MODIFY_TIME, + timeLimitInMinute = currentMinutes + ).also { findNavController().safeNavigate(it) } + } + + override fun setFragmentResultListeners() { + setFragmentResultListener(REQUEST_KEY_EXIT_TUTORIAL) { _, _ -> + viewModel.exitTutorial() + } + setFragmentResultListener(REQUEST_KEY_MODIFY_TIME) { _, bundle -> + val modifiedTime = bundle.getInt(ModifyTimeBottomSheet.BUNDLE_KEY_MODIFIED_TIME) + tutorialSharedViewModel.modifyTime(modifiedTime) + } + } + + override fun onDetach() { + backCallback.remove() + super.onDetach() + } + + companion object { + const val REQUEST_KEY_EXIT_TUTORIAL = "REQUEST_KEY_EXIT_TUTORIAL" + const val REQUEST_KEY_MODIFY_TIME = "REQUEST_KEY_MODIFY_TIME" + } +} diff --git a/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/tutorial/timer/TutorialTimerState.kt b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/tutorial/timer/TutorialTimerState.kt new file mode 100644 index 0000000..35cf8e7 --- /dev/null +++ b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/tutorial/timer/TutorialTimerState.kt @@ -0,0 +1,13 @@ +package com.nextroom.nextroom.presentation.ui.tutorial.timer + +import com.nextroom.nextroom.presentation.model.InputState +import com.nextroom.nextroom.presentation.ui.tutorial.TutorialData + +data class TutorialTimerState( + val totalSeconds: Int = TutorialData.DEFAULT_TIME_LIMIT_SECONDS, + val lastSeconds: Int = TutorialData.DEFAULT_TIME_LIMIT_SECONDS, + val currentInput: String = "", + val inputState: InputState = InputState.Empty, + val openedHintCount: Int = 0, + val totalHintCount: Int = 3 +) diff --git a/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/tutorial/timer/TutorialTimerViewModel.kt b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/tutorial/timer/TutorialTimerViewModel.kt new file mode 100644 index 0000000..444d7ef --- /dev/null +++ b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/tutorial/timer/TutorialTimerViewModel.kt @@ -0,0 +1,104 @@ +package com.nextroom.nextroom.presentation.ui.tutorial.timer + +import com.nextroom.nextroom.presentation.R +import com.nextroom.nextroom.presentation.base.NewBaseViewModel +import com.nextroom.nextroom.presentation.model.InputState +import com.nextroom.nextroom.presentation.ui.tutorial.TutorialData +import com.nextroom.nextroom.presentation.ui.tutorial.TutorialSharedViewModel +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +class TutorialTimerViewModel @AssistedInject constructor( + @Assisted private val tutorialSharedViewModel: TutorialSharedViewModel +) : NewBaseViewModel() { + + private val _uiState = MutableStateFlow(TutorialTimerState()) + val uiState = combine( + _uiState, + tutorialSharedViewModel.state + ) { state, sharedState -> + state.copy( + totalSeconds = sharedState.totalSeconds, + lastSeconds = sharedState.lastSeconds, + openedHintCount = sharedState.openedHintIds.size, + totalHintCount = sharedState.totalHintCount + ) + }.stateIn(baseViewModelScope, SharingStarted.Lazily, _uiState.value) + + private val _uiEvent = MutableSharedFlow(extraBufferCapacity = 1) + val uiEvent = _uiEvent.asSharedFlow() + + init { + tutorialSharedViewModel.startTimer() + } + + fun inputHintCode(key: Int) { + val current = _uiState.value.currentInput + if (current.length >= 4) return + + val newCode = current + key.toString() + _uiState.update { it.copy(currentInput = newCode, inputState = InputState.Typing) } + + if (newCode.length == 4) { + validateHintCode(newCode) + } + } + + fun backspaceHintCode() { + val current = _uiState.value.currentInput + if (current.isEmpty()) return + + _uiState.update { + it.copy( + currentInput = current.dropLast(1), + inputState = if (current.length <= 1) InputState.Empty else InputState.Typing + ) + } + } + + private fun validateHintCode(code: String) { + baseViewModelScope.launch { + if (tutorialSharedViewModel.state.value.lastSeconds <= 0) { + _uiEvent.emit(TutorialTimerEvent.TimerFinished) + clearInput() + return@launch + } + + val hint = TutorialData.getRandomHint(code) + if (hint != null) { + _uiState.update { it.copy(inputState = InputState.Ok) } + delay(200) + _uiEvent.emit(TutorialTimerEvent.OpenHint(hint)) + clearInput() + } else { + _uiState.update { it.copy(inputState = InputState.Error(R.string.game_wrong_hint_code)) } + delay(500) + clearInput() + } + } + } + + private fun clearInput() { + _uiState.update { it.copy(currentInput = "", inputState = InputState.Empty) } + } + + fun exitTutorial() { + tutorialSharedViewModel.finishTutorial() + _uiEvent.tryEmit(TutorialTimerEvent.ExitTutorial) + } + + @AssistedFactory + interface Factory { + fun create(tutorialSharedViewModel: TutorialSharedViewModel): TutorialTimerViewModel + } +} diff --git a/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/tutorial/timer/compose/ArcProgressCompose.kt b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/tutorial/timer/compose/ArcProgressCompose.kt new file mode 100644 index 0000000..5e84c03 --- /dev/null +++ b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/tutorial/timer/compose/ArcProgressCompose.kt @@ -0,0 +1,142 @@ +package com.nextroom.nextroom.presentation.ui.tutorial.timer.compose + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.drawText +import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.nextroom.nextroom.presentation.common.compose.NRColor +import kotlin.math.cos +import kotlin.math.sin + +@Composable +fun ArcProgressCompose( + totalSeconds: Int, + lastSeconds: Int, + modifier: Modifier = Modifier +) { + val textMeasurer = rememberTextMeasurer() + val arcAngle = 270f + val strokeWidthDp = 6.dp + + val textStyle = TextStyle( + color = NRColor.Gray04, + fontSize = 14.sp + ) + + Canvas(modifier = modifier.fillMaxSize()) { + val strokeWidth = strokeWidthDp.toPx() + + val padding = strokeWidth / 2 + 12.dp.toPx() + val rectWidth = size.width - padding * 2 + val rectHeight = size.height - padding * 2 - strokeWidth / 2 + val rectTopLeft = Offset(padding, padding) + val rectSize = Size(rectWidth, rectHeight) + + val centerX = rectTopLeft.x + rectWidth / 2 + val centerY = rectTopLeft.y + rectHeight / 2 + val radius = rectWidth / 2 + + val startAngle = 270f - arcAngle / 2f // 135° + val endAngle = 270f + arcAngle / 2f // 405° (= 45°) + val progress = if (totalSeconds > 0) lastSeconds.toFloat() / totalSeconds else 0f + val sweepStartAngle = startAngle + arcAngle * (1 - progress) + + // 지나간 progress track + drawArc( + color = NRColor.Gray01, + startAngle = startAngle, + sweepAngle = arcAngle, + useCenter = false, + topLeft = rectTopLeft, + size = rectSize, + style = Stroke(width = strokeWidth, cap = StrokeCap.Round) + ) + + // 남아있는 progress track + if (totalSeconds > 0) { + drawArc( + color = NRColor.White, + startAngle = sweepStartAngle, + sweepAngle = arcAngle * progress, + useCenter = false, + topLeft = rectTopLeft, + size = rectSize, + style = Stroke(width = strokeWidth, cap = StrokeCap.Round) + ) + } + + // 현재 progress 위치에 handle을 그린다. + val handleAngle = Math.toRadians((360 - sweepStartAngle).toDouble()).toFloat() + val handleX = centerX + cos(handleAngle) * radius + val handleY = centerY - sin(handleAngle) * radius + drawCircle( + color = NRColor.White, + radius = 20f, + center = Offset(handleX, handleY) + ) + + val startTextLayout = textMeasurer.measure("Start", textStyle) + val endTextLayout = textMeasurer.measure("End", textStyle) + + val edgeStartAngle = Math.toRadians(startAngle.toDouble()).toFloat() + val edgeEndAngle = Math.toRadians(endAngle.toDouble()).toFloat() + + val textSpace = 16.dp.toPx() + val textY = centerY + sin(edgeStartAngle) * radius + startTextLayout.size.height + + // Start 텍스트 + val startX = centerX + cos(edgeStartAngle) * radius + textSpace + drawText( + textLayoutResult = startTextLayout, + topLeft = Offset(startX, textY - startTextLayout.size.height) + ) + + // End 텍스트 + val endX = centerX + cos(edgeEndAngle) * radius - endTextLayout.size.width - textSpace + drawText( + textLayoutResult = endTextLayout, + topLeft = Offset(endX, textY - endTextLayout.size.height) + ) + } +} + +@Preview(showBackground = true, backgroundColor = 0xFF151516) +@Composable +private fun ArcProgressFullPreview() { + ArcProgressCompose( + totalSeconds = 3600, + lastSeconds = 3600, + modifier = Modifier.size(300.dp) + ) +} + +@Preview(showBackground = true, backgroundColor = 0xFF151516) +@Composable +private fun ArcProgressHalfPreview() { + ArcProgressCompose( + totalSeconds = 3600, + lastSeconds = 1800, + modifier = Modifier.size(300.dp) + ) +} + +@Preview(showBackground = true, backgroundColor = 0xFF151516) +@Composable +private fun ArcProgressEmptyPreview() { + ArcProgressCompose( + totalSeconds = 3600, + lastSeconds = 0, + modifier = Modifier.size(300.dp) + ) +} diff --git a/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/tutorial/timer/compose/CodeInputSection.kt b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/tutorial/timer/compose/CodeInputSection.kt new file mode 100644 index 0000000..29cc0bb --- /dev/null +++ b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/tutorial/timer/compose/CodeInputSection.kt @@ -0,0 +1,121 @@ +package com.nextroom.nextroom.presentation.ui.tutorial.timer.compose + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.keyframes +import androidx.compose.animation.core.tween +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.offset +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.runtime.getValue +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.nextroom.nextroom.presentation.common.compose.NRColor +import com.nextroom.nextroom.presentation.common.compose.NRTypo +import com.nextroom.nextroom.presentation.model.InputState + +@Composable +fun CodeInputSection( + code: String, + inputState: InputState, + modifier: Modifier = Modifier +) { + val isError = inputState is InputState.Error + val shakeOffset by animateFloatAsState( + targetValue = if (isError) 1f else 0f, + animationSpec = if (isError) { + keyframes { + durationMillis = 300 + 0f at 0 + -5f at 50 + 5f at 100 + -5f at 150 + 5f at 200 + 0f at 300 + } + } else { + tween(0) + }, + label = "shakeAnimation" + ) + + Row( + modifier = modifier.offset(x = shakeOffset.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + repeat(4) { index -> + val char = code.getOrNull(index)?.toString() ?: "" + val isFocused = index == code.length && code.length < 4 + + CodeBox( + char = char, + isError = isError, + isFocused = isFocused + ) + } + } +} + +@Composable +private fun CodeBox( + char: String, + isError: Boolean, + isFocused: Boolean +) { + val borderColor = when { + isError -> NRColor.Red + isFocused -> NRColor.White + else -> NRColor.Gray01 + } + + Box( + modifier = Modifier + .size(48.dp) + .border( + width = 2.dp, + color = borderColor, + shape = RoundedCornerShape(12.dp) + ), + contentAlignment = Alignment.Center + ) { + Text( + text = char, + style = NRTypo.Poppins.size24, + color = NRColor.White + ) + } +} + +@Preview(showBackground = true, backgroundColor = 0xFF151516) +@Composable +private fun CodeInputEmptyPreview() { + CodeInputSection( + code = "", + inputState = InputState.Empty + ) +} + +@Preview(showBackground = true, backgroundColor = 0xFF151516) +@Composable +private fun CodeInputTypingPreview() { + CodeInputSection( + code = "12", + inputState = InputState.Typing + ) +} + +@Preview(showBackground = true, backgroundColor = 0xFF151516) +@Composable +private fun CodeInputErrorPreview() { + CodeInputSection( + code = "1234", + inputState = InputState.Error(0) + ) +} \ No newline at end of file diff --git a/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/tutorial/timer/compose/KeypadSection.kt b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/tutorial/timer/compose/KeypadSection.kt new file mode 100644 index 0000000..87ac228 --- /dev/null +++ b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/tutorial/timer/compose/KeypadSection.kt @@ -0,0 +1,130 @@ +package com.nextroom.nextroom.presentation.ui.tutorial.timer.compose + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.material.ripple.rememberRipple +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.nextroom.nextroom.presentation.R +import com.nextroom.nextroom.presentation.common.compose.NRColor +import com.nextroom.nextroom.presentation.common.compose.NRTypo + +private val KEY_HEIGHT = 64.dp + +@Composable +fun KeypadSection( + onKeyClick: (Int) -> Unit, + onBackspaceClick: () -> Unit, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier.fillMaxWidth() + ) { + KeypadRow(keys = listOf(1, 2, 3), onKeyClick = onKeyClick) + KeypadRow(keys = listOf(4, 5, 6), onKeyClick = onKeyClick) + KeypadRow(keys = listOf(7, 8, 9), onKeyClick = onKeyClick) + // 마지막 줄 + Row(modifier = Modifier.fillMaxWidth()) { + Box( + modifier = Modifier + .weight(1f) + .height(KEY_HEIGHT) + ) + KeypadButton( + key = 0, + onClick = { onKeyClick(0) }, + modifier = Modifier.weight(1f) + ) + BackspaceButton( + onClick = onBackspaceClick, + modifier = Modifier.weight(1f) + ) + } + } +} + +@Composable +private fun KeypadRow( + keys: List, + onKeyClick: (Int) -> Unit +) { + Row(modifier = Modifier.fillMaxWidth()) { + keys.forEach { key -> + KeypadButton( + key = key, + onClick = { onKeyClick(key) }, + modifier = Modifier.weight(1f) + ) + } + } +} + +@Composable +private fun KeypadButton( + key: Int, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + val interactionSource = remember { MutableInteractionSource() } + + Box( + modifier = modifier + .height(KEY_HEIGHT) + .clickable( + interactionSource = interactionSource, + indication = rememberRipple(bounded = false) + ) { onClick() }, + contentAlignment = Alignment.Center + ) { + Text( + text = key.toString(), + style = NRTypo.Poppins.size24, + color = NRColor.White + ) + } +} + +@Composable +private fun BackspaceButton( + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + val interactionSource = remember { MutableInteractionSource() } + + Box( + modifier = modifier + .height(KEY_HEIGHT) + .clickable( + interactionSource = interactionSource, + indication = rememberRipple(bounded = false) + ) { onClick() }, + contentAlignment = Alignment.Center + ) { + Icon( + painter = painterResource(R.drawable.ic_backspace), + contentDescription = null, + tint = NRColor.White + ) + } +} + +@Preview(showBackground = true, backgroundColor = 0xFF151516) +@Composable +private fun KeypadPreview() { + KeypadSection( + onKeyClick = {}, + onBackspaceClick = {} + ) +} diff --git a/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/tutorial/timer/compose/TutorialTimerScreen.kt b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/tutorial/timer/compose/TutorialTimerScreen.kt new file mode 100644 index 0000000..56105ec --- /dev/null +++ b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/tutorial/timer/compose/TutorialTimerScreen.kt @@ -0,0 +1,247 @@ +package com.nextroom.nextroom.presentation.ui.tutorial.timer.compose + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +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.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +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.presentation.R +import com.nextroom.nextroom.presentation.common.compose.NRColor +import com.nextroom.nextroom.presentation.common.compose.NRTypo +import com.nextroom.nextroom.presentation.ui.tutorial.timer.TutorialTimerState + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun TutorialTimerScreen( + state: TutorialTimerState, + onKeyInput: (Int) -> Unit, + onBackspace: () -> Unit, + onMemoClick: () -> Unit, + onExitLongPress: () -> Unit, + onTimerLongPress: () -> Unit, + modifier: Modifier = Modifier +) { + BoxWithConstraints( + modifier = modifier + .fillMaxSize() + .background(NRColor.Dark01) + ) { + // 배경 이미지 + Image( + painter = painterResource(R.drawable.bg), + contentDescription = null, + contentScale = ContentScale.FillWidth, + modifier = Modifier + .fillMaxWidth() + .wrapContentHeight() + .offset(y = (-41).dp) + .alpha(0.15f) + ) + + val arcMaxHeight = maxHeight * 0.48f - 64.dp - 8.dp + val arcSize = if (maxWidth < arcMaxHeight) maxWidth else arcMaxHeight + val gapAfterArc = maxHeight * 0.50f - 64.dp - arcSize + + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + TutorialToolbar( + onBackLongPress = onExitLongPress, + onMemoClick = onMemoClick + ) + + // Arc + timer text + hint info section (overlaid) + Box( + modifier = Modifier.size(arcSize) + ) { + val interactionSource = remember { MutableInteractionSource() } + + ArcProgressCompose( + totalSeconds = state.totalSeconds, + lastSeconds = state.lastSeconds, + modifier = Modifier + .fillMaxSize() + .combinedClickable( + interactionSource = interactionSource, + indication = null, + onLongClick = { onTimerLongPress() }, + onClick = {} + ) + ) + + // Timer text + HINT + count vertically centered inside the arc + Column( + modifier = Modifier + .align(Alignment.Center) + .wrapContentHeight(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = state.lastSeconds.toTimerFormat(), + style = NRTypo.NotoSansMono.size54, + color = NRColor.White + ) + Spacer(modifier = Modifier.height(6.dp)) + Text( + text = "HINT", + style = NRTypo.Poppins.size20, + color = NRColor.White + ) + Spacer(modifier = Modifier.height(6.dp)) + Text( + text = "${state.openedHintCount}/${state.totalHintCount}", + style = NRTypo.Poppins.size14, + color = NRColor.Gray04 + ) + } + } + + Spacer(modifier = Modifier.height(gapAfterArc)) + + Text( + text = stringResource(R.string.game_hint_code_label), + style = NRTypo.Poppins.size24, + color = NRColor.White + ) + + Spacer(modifier = Modifier.height(20.dp)) + + CodeInputSection( + code = state.currentInput, + inputState = state.inputState, + modifier = Modifier.padding(horizontal = 20.dp) + ) + + Spacer(modifier = Modifier.weight(1f)) + + KeypadSection( + onKeyClick = onKeyInput, + onBackspaceClick = onBackspace, + modifier = Modifier + .fillMaxWidth() + .padding(start = 20.dp, end = 20.dp, bottom = 42.dp) + ) + } + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun TutorialToolbar( + onBackLongPress: () -> Unit, + onMemoClick: () -> Unit +) { + val interactionSource = remember { MutableInteractionSource() } + + Row( + modifier = Modifier + .fillMaxWidth() + .height(64.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Box( + modifier = Modifier + .size(64.dp) + .combinedClickable( + interactionSource = interactionSource, + indication = null, + onLongClick = { onBackLongPress() }, + onClick = {} + ) + .padding(20.dp), + contentAlignment = Alignment.Center + ) { + Icon( + painter = painterResource(R.drawable.ic_navigate_back_24), + contentDescription = null, + tint = NRColor.White + ) + } + + Text( + modifier = Modifier + .padding(end = 20.dp) + .clip(RoundedCornerShape(50.dp)) + .background(NRColor.White) + .clickable { onMemoClick() } + .padding(horizontal = 16.dp, vertical = 6.dp), + text = stringResource(R.string.memo_button), + style = NRTypo.Poppins.size14, + color = NRColor.Dark01 + ) + } +} + +private fun Int.toTimerFormat(): String { + val minutes = this / 60 + val seconds = this % 60 + return "%02d:%02d".format(minutes, seconds) +} + +@Preview(showBackground = true, backgroundColor = 0xFF151516) +@Composable +private fun TutorialTimerScreenPreview() { + TutorialTimerScreen( + state = TutorialTimerState( + totalSeconds = 3600, + lastSeconds = 3600, + currentInput = "", + openedHintCount = 0, + totalHintCount = 3 + ), + onKeyInput = {}, + onBackspace = {}, + onMemoClick = {}, + onExitLongPress = {}, + onTimerLongPress = {} + ) +} + +@Preview(showBackground = true, backgroundColor = 0xFF151516) +@Composable +private fun TutorialTimerScreenWithInputPreview() { + TutorialTimerScreen( + state = TutorialTimerState( + totalSeconds = 3600, + lastSeconds = 1800, + currentInput = "12", + openedHintCount = 1, + totalHintCount = 3 + ), + onKeyInput = {}, + onBackspace = {}, + onMemoClick = {}, + onExitLongPress = {}, + onTimerLongPress = {} + ) +} diff --git a/presentation/src/main/res/navigation/nav_graph.xml b/presentation/src/main/res/navigation/nav_graph.xml index 0e33676..bd8b2eb 100644 --- a/presentation/src/main/res/navigation/nav_graph.xml +++ b/presentation/src/main/res/navigation/nav_graph.xml @@ -9,6 +9,7 @@ + diff --git a/presentation/src/main/res/navigation/tutorial_navigation.xml b/presentation/src/main/res/navigation/tutorial_navigation.xml new file mode 100644 index 0000000..b066dc5 --- /dev/null +++ b/presentation/src/main/res/navigation/tutorial_navigation.xml @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + From f50d45cf3de8cc3f42b492224b00f93c89e11b94 Mon Sep 17 00:00:00 2001 From: juhwankim-dev Date: Tue, 17 Feb 2026 01:12:10 +0900 Subject: [PATCH 06/10] =?UTF-8?q?NR-133=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=EC=97=90=20=EC=B2=B4=ED=97=98=ED=95=98?= =?UTF-8?q?=EA=B8=B0=20=EB=B2=84=ED=8A=BC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../presentation/ui/onboarding/LoginFragment.kt | 6 ++++++ .../src/main/res/layout/fragment_login.xml | 14 +++++++++++++- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/onboarding/LoginFragment.kt b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/onboarding/LoginFragment.kt index 6037b8b..da2a87e 100644 --- a/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/onboarding/LoginFragment.kt +++ b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/onboarding/LoginFragment.kt @@ -23,6 +23,7 @@ class LoginFragment : BaseViewModelFragment moveToEmailLogin() binding.llStartWithGoogle -> viewModel.requestGoogleAuth() + binding.tvTryWithoutLogin -> moveToTutorial() } } diff --git a/presentation/src/main/res/layout/fragment_login.xml b/presentation/src/main/res/layout/fragment_login.xml index 581408d..a874777 100644 --- a/presentation/src/main/res/layout/fragment_login.xml +++ b/presentation/src/main/res/layout/fragment_login.xml @@ -114,12 +114,24 @@ android:layout_width="0dp" android:layout_height="wrap_content" android:layout_marginHorizontal="20dp" - android:layout_marginBottom="64dp" + android:layout_marginBottom="20dp" android:background="@drawable/bg_black_border_white20_r_100" android:gravity="center" android:paddingVertical="20dp" android:text="@string/text_start_with_email" android:textColor="@color/white" + app:layout_constraintBottom_toTopOf="@id/tv_try_without_login" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" /> + + From 426d56ae94b62c33fd98e26a8b22881eda454347 Mon Sep 17 00:00:00 2001 From: juhwankim-dev Date: Wed, 18 Feb 2026 21:25:19 +0900 Subject: [PATCH 07/10] =?UTF-8?q?NR-133=20NRTooltip=20=EC=A0=9C=EC=9E=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../presentation/common/compose/NRTooltip.kt | 155 ++++++++++++++++++ 1 file changed, 155 insertions(+) create mode 100644 presentation/src/main/java/com/nextroom/nextroom/presentation/common/compose/NRTooltip.kt diff --git a/presentation/src/main/java/com/nextroom/nextroom/presentation/common/compose/NRTooltip.kt b/presentation/src/main/java/com/nextroom/nextroom/presentation/common/compose/NRTooltip.kt new file mode 100644 index 0000000..94b29ee --- /dev/null +++ b/presentation/src/main/java/com/nextroom/nextroom/presentation/common/compose/NRTooltip.kt @@ -0,0 +1,155 @@ +package com.nextroom.nextroom.presentation.common.compose + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.wrapContentSize +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.graphics.Path +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.sp + +enum class TooltipArrowPosition { Top, TopLeft, TopRight, Bottom, Right } + +@Composable +fun NRTooltip( + text: String, + arrowPosition: TooltipArrowPosition, + modifier: Modifier = Modifier +) { + val tooltipColor = NRColor.White + val arrowWidthDp = 16.dp + val arrowHeightDp = 8.dp + + if (arrowPosition == TooltipArrowPosition.Right) { + Row( + modifier = modifier.wrapContentSize(), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .background(color = tooltipColor, shape = RoundedCornerShape(8.dp)) + .padding(horizontal = 12.dp, vertical = 8.dp) + ) { + Text(text = text, style = NRTypo.Body.size14Medium, color = NRColor.Dark01) + } + Canvas(modifier = Modifier.size(arrowHeightDp, arrowWidthDp)) { + drawPath( + path = Path().apply { + moveTo(0f, 0f) + lineTo(0f, size.height) + lineTo(size.width, size.height / 2f) + close() + }, + color = tooltipColor + ) + } + } + } else { + val horizontalAlignment = when (arrowPosition) { + TooltipArrowPosition.TopLeft -> Alignment.Start + TooltipArrowPosition.TopRight -> Alignment.End + else -> Alignment.CenterHorizontally + } + + Column( + modifier = modifier.wrapContentSize(), + horizontalAlignment = horizontalAlignment + ) { + if (arrowPosition == TooltipArrowPosition.Top || + arrowPosition == TooltipArrowPosition.TopLeft || + arrowPosition == TooltipArrowPosition.TopRight + ) { + val arrowModifier = when (arrowPosition) { + TooltipArrowPosition.TopLeft -> Modifier + .size(arrowWidthDp, arrowHeightDp) + .offset(x = 8.dp) + TooltipArrowPosition.TopRight -> Modifier + .size(arrowWidthDp, arrowHeightDp) + .offset(x = (-8).dp) + else -> Modifier.size(arrowWidthDp, arrowHeightDp) + } + Canvas(modifier = arrowModifier) { + drawPath( + path = Path().apply { + moveTo(size.width / 2f, 0f) + lineTo(size.width, size.height) + lineTo(0f, size.height) + close() + }, + color = tooltipColor + ) + } + } + + Box( + modifier = Modifier + .background(color = tooltipColor, shape = RoundedCornerShape(8.dp)) + .padding(horizontal = 12.dp, vertical = 8.dp), + ) { + Text( + text = text, + style = NRTypo.Body.size14Medium, + color = NRColor.Dark01, + textAlign = TextAlign.Center, + lineHeight = 18.sp, + ) + } + + if (arrowPosition == TooltipArrowPosition.Bottom) { + Canvas(modifier = Modifier.size(arrowWidthDp, arrowHeightDp)) { + drawPath( + path = Path().apply { + moveTo(0f, 0f) + lineTo(size.width, 0f) + lineTo(size.width / 2f, size.height) + close() + }, + color = tooltipColor + ) + } + } + } + } +} + +@Preview(showBackground = true, backgroundColor = 0xFF151516) +@Composable +private fun NRTooltipTopPreview() { + NRTooltip(text = "남은 시간과 힌트 수 표시", arrowPosition = TooltipArrowPosition.Top) +} + +@Preview(showBackground = true, backgroundColor = 0xFF151516) +@Composable +private fun NRTooltipTopLeftPreview() { + NRTooltip(text = "길게 꾹 눌러 종료", arrowPosition = TooltipArrowPosition.TopLeft) +} + +@Preview(showBackground = true, backgroundColor = 0xFF151516) +@Composable +private fun NRTooltipTopRightPreview() { + NRTooltip(text = "메모 하러 이동", arrowPosition = TooltipArrowPosition.TopRight) +} + +@Preview(showBackground = true, backgroundColor = 0xFF151516) +@Composable +private fun NRTooltipBottomPreview() { + NRTooltip(text = "힌트 코드 입력", arrowPosition = TooltipArrowPosition.Bottom) +} + +@Preview(showBackground = true, backgroundColor = 0xFF151516) +@Composable +private fun NRTooltipRightPreview() { + NRTooltip(text = "그리기", arrowPosition = TooltipArrowPosition.Right) +} From 5a3204f2cf6d3ed934bb42c93e6a6350a5a81924 Mon Sep 17 00:00:00 2001 From: juhwankim-dev Date: Wed, 18 Feb 2026 21:26:07 +0900 Subject: [PATCH 08/10] =?UTF-8?q?NR-133=20=ED=88=B4=ED=8C=81=20=EC=98=A4?= =?UTF-8?q?=EB=B2=84=EB=A0=88=EC=9D=B4=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../compose/TutorialHintTooltipOverlay.kt | 74 ++++++++++ .../compose/TutorialMemoTooltipOverlay.kt | 119 +++++++++++++++ .../compose/TutorialTimerTooltipOverlay.kt | 138 ++++++++++++++++++ presentation/src/main/res/values/strings.xml | 8 + 4 files changed, 339 insertions(+) create mode 100644 presentation/src/main/java/com/nextroom/nextroom/presentation/ui/tutorial/hint/compose/TutorialHintTooltipOverlay.kt create mode 100644 presentation/src/main/java/com/nextroom/nextroom/presentation/ui/tutorial/memo/compose/TutorialMemoTooltipOverlay.kt create mode 100644 presentation/src/main/java/com/nextroom/nextroom/presentation/ui/tutorial/timer/compose/TutorialTimerTooltipOverlay.kt diff --git a/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/tutorial/hint/compose/TutorialHintTooltipOverlay.kt b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/tutorial/hint/compose/TutorialHintTooltipOverlay.kt new file mode 100644 index 0000000..26a83c2 --- /dev/null +++ b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/tutorial/hint/compose/TutorialHintTooltipOverlay.kt @@ -0,0 +1,74 @@ +package com.nextroom.nextroom.presentation.ui.tutorial.hint.compose + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.offset +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.LayoutCoordinates +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.layout.positionInRoot +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize +import com.nextroom.nextroom.presentation.R +import com.nextroom.nextroom.presentation.common.compose.NRTooltip +import com.nextroom.nextroom.presentation.common.compose.TooltipArrowPosition +import com.nextroom.nextroom.presentation.extension.dp +import kotlin.math.roundToInt + +@Composable +fun TutorialHintTooltipOverlay( + hintAreaCoords: LayoutCoordinates?, + onDismiss: () -> Unit +) { + var tooltipSize by remember { mutableStateOf(IntSize.Zero) } + + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.6f)) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = onDismiss + ) + ) { + hintAreaCoords?.let { coords -> + val pos = coords.positionInRoot() + val hintAreaCenterX = pos.x + coords.size.width / 2f + val hintAreaTopY = pos.y + + NRTooltip( + text = stringResource(R.string.text_tutorial_tooltip_hint), + arrowPosition = TooltipArrowPosition.Bottom, + modifier = Modifier + .offset { + IntOffset( + x = (hintAreaCenterX - tooltipSize.width / 2f).roundToInt(), + y = (hintAreaTopY - tooltipSize.height - 8.dp).roundToInt() + ) + } + .onSizeChanged { tooltipSize = it } + ) + } + } +} + +@Preview(showBackground = true, backgroundColor = 0xFF151516) +@Composable +private fun TutorialHintTooltipOverlayPreview() { + TutorialHintTooltipOverlay( + hintAreaCoords = null, + onDismiss = {} + ) +} diff --git a/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/tutorial/memo/compose/TutorialMemoTooltipOverlay.kt b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/tutorial/memo/compose/TutorialMemoTooltipOverlay.kt new file mode 100644 index 0000000..201d5f1 --- /dev/null +++ b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/tutorial/memo/compose/TutorialMemoTooltipOverlay.kt @@ -0,0 +1,119 @@ +package com.nextroom.nextroom.presentation.ui.tutorial.memo.compose + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.offset +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.LayoutCoordinates +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.layout.positionInRoot +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize +import com.nextroom.nextroom.presentation.R +import com.nextroom.nextroom.presentation.common.compose.NRTooltip +import com.nextroom.nextroom.presentation.common.compose.TooltipArrowPosition +import com.nextroom.nextroom.presentation.extension.dp +import kotlin.math.roundToInt + +@Composable +fun TutorialMemoTooltipOverlay( + penCoords: LayoutCoordinates?, + eraserCoords: LayoutCoordinates?, + deleteCoords: LayoutCoordinates?, + onDismiss: () -> Unit +) { + var penTooltipSize by remember { mutableStateOf(IntSize.Zero) } + var eraserTooltipSize by remember { mutableStateOf(IntSize.Zero) } + var deleteTooltipSize by remember { mutableStateOf(IntSize.Zero) } + + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.6f)) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = onDismiss + ) + ) { + // 툴팁 1: 펜 버튼 왼쪽에 "그리기" + penCoords?.let { coords -> + val pos = coords.positionInRoot() + val buttonLeftX = pos.x - 16.dp + val buttonCenterY = pos.y + coords.size.height / 2f + NRTooltip( + text = stringResource(R.string.text_tutorial_tooltip_pen), + arrowPosition = TooltipArrowPosition.Right, + modifier = Modifier + .offset { + IntOffset( + x = (buttonLeftX - penTooltipSize.width).roundToInt(), + y = (buttonCenterY - penTooltipSize.height / 2f).roundToInt() + ) + } + .onSizeChanged { penTooltipSize = it } + ) + } + + // 툴팁 2: 지우개 버튼 왼쪽에 "지우기" + eraserCoords?.let { coords -> + val pos = coords.positionInRoot() + val buttonLeftX = pos.x - 16.dp + val buttonCenterY = pos.y + coords.size.height / 2f + NRTooltip( + text = stringResource(R.string.text_tutorial_tooltip_eraser), + arrowPosition = TooltipArrowPosition.Right, + modifier = Modifier + .offset { + IntOffset( + x = (buttonLeftX - eraserTooltipSize.width).roundToInt(), + y = (buttonCenterY - eraserTooltipSize.height / 2f).roundToInt() + ) + } + .onSizeChanged { eraserTooltipSize = it } + ) + } + + // 툴팁 3: 삭제 버튼 왼쪽에 "전체 삭제" + deleteCoords?.let { coords -> + val pos = coords.positionInRoot() + val buttonLeftX = pos.x - 16.dp + val buttonCenterY = pos.y + coords.size.height / 2f + NRTooltip( + text = stringResource(R.string.text_tutorial_tooltip_erase_all), + arrowPosition = TooltipArrowPosition.Right, + modifier = Modifier + .offset { + IntOffset( + x = (buttonLeftX - deleteTooltipSize.width).roundToInt(), + y = (buttonCenterY - deleteTooltipSize.height / 2f).roundToInt() + ) + } + .onSizeChanged { deleteTooltipSize = it } + ) + } + } +} + +// LayoutCoordinates는 Preview에서 인스턴스 생성이 불가능 +@Preview(showBackground = true, backgroundColor = 0xFF151516) +@Composable +private fun TutorialMemoTooltipOverlayPreview() { + TutorialMemoTooltipOverlay( + penCoords = null, + eraserCoords = null, + deleteCoords = null, + onDismiss = {} + ) +} diff --git a/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/tutorial/timer/compose/TutorialTimerTooltipOverlay.kt b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/tutorial/timer/compose/TutorialTimerTooltipOverlay.kt new file mode 100644 index 0000000..67f5c5e --- /dev/null +++ b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/tutorial/timer/compose/TutorialTimerTooltipOverlay.kt @@ -0,0 +1,138 @@ +package com.nextroom.nextroom.presentation.ui.tutorial.timer.compose + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.offset +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.LayoutCoordinates +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.layout.positionInRoot +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import com.nextroom.nextroom.presentation.R +import com.nextroom.nextroom.presentation.common.compose.NRTooltip +import com.nextroom.nextroom.presentation.common.compose.TooltipArrowPosition +import kotlin.math.roundToInt + +@Composable +fun TutorialTimerTooltipOverlay( + arcCoords: LayoutCoordinates?, + memoCoords: LayoutCoordinates?, + keypadCoords: LayoutCoordinates?, + backCoords: LayoutCoordinates?, + onDismiss: () -> Unit +) { + var arcTooltipWidth by remember { mutableIntStateOf(0) } + var memoTooltipWidth by remember { mutableIntStateOf(0) } + var keypadTooltipWidth by remember { mutableIntStateOf(0) } + + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.6f)) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = onDismiss + ) + ) { + // 툴팁 1: Arc Progress - 아크 아래 중앙에 표시 + arcCoords?.let { coords -> + val pos = coords.positionInRoot() + val arcCenterX = pos.x + coords.size.width / 2f + val arcBottomY = pos.y + coords.size.height * 0.72f + NRTooltip( + text = stringResource(R.string.text_tutorial_tooltip_progress), + arrowPosition = TooltipArrowPosition.Top, + modifier = Modifier + .offset { + IntOffset( + x = (arcCenterX - arcTooltipWidth / 2f).roundToInt(), + y = arcBottomY.roundToInt() + ) + } + .onSizeChanged { arcTooltipWidth = it.width } + ) + } + + // 툴팁 2: Memo 버튼 - 메모 버튼 아래에 표시, 버블 우측을 버튼 우측에 맞춤 + memoCoords?.let { coords -> + val pos = coords.positionInRoot() + val memoRightX = pos.x + coords.size.width + val memoBottomY = pos.y + coords.size.height + NRTooltip( + text = stringResource(R.string.text_tutorial_tooltip_memo), + arrowPosition = TooltipArrowPosition.TopRight, + modifier = Modifier + .offset { + IntOffset( + x = (memoRightX - memoTooltipWidth).roundToInt(), + y = (memoBottomY + 4.dp.toPx()).roundToInt() + ) + } + .onSizeChanged { memoTooltipWidth = it.width } + ) + } + + // 툴팁 3: 키패드 - 키패드 위 중앙에 표시 + keypadCoords?.let { coords -> + val pos = coords.positionInRoot() + val keypadCenterX = pos.x + coords.size.width / 2f + val keypadTopY = pos.y + NRTooltip( + text = stringResource(R.string.text_tutorial_tooltip_keypad), + arrowPosition = TooltipArrowPosition.Bottom, + modifier = Modifier + .offset { + IntOffset( + x = (keypadCenterX - keypadTooltipWidth / 2f).roundToInt(), + y = (keypadTopY - 52.dp.toPx()).roundToInt() + ) + } + .onSizeChanged { keypadTooltipWidth = it.width } + ) + } + + // 툴팁 4: 뒤로가기 버튼 - 버튼 아래에 표시, 버블 좌측을 버튼 좌측에 맞춤 + backCoords?.let { coords -> + val pos = coords.positionInRoot() + val backLeftX = pos.x + val backBottomY = pos.y + coords.size.height + NRTooltip( + text = stringResource(R.string.text_tutorial_tooltip_back), + arrowPosition = TooltipArrowPosition.TopLeft, + modifier = Modifier + .offset { + IntOffset( + x = backLeftX.roundToInt(), + y = (backBottomY + 4.dp.toPx()).roundToInt() + ) + } + ) + } + } +} + +// LayoutCoordinates는 Preview에서 인스턴스 생성이 불가능 +@Preview(showBackground = true, backgroundColor = 0xFF151516) +@Composable +private fun TutorialTimerTooltipOverlayPreview() { + TutorialTimerTooltipOverlay( + arcCoords = null, + memoCoords = null, + keypadCoords = null, + backCoords = null, + onDismiss = {} + ) +} diff --git a/presentation/src/main/res/values/strings.xml b/presentation/src/main/res/values/strings.xml index ccbde36..8fffe95 100644 --- a/presentation/src/main/res/values/strings.xml +++ b/presentation/src/main/res/values/strings.xml @@ -238,4 +238,12 @@ 로그인 없이 체험하기 체험 종료 체험 모드를 종료하시겠습니까? + 남은 시간과 힌트 수 표시 + 메모 하러 이동 + 힌트 코드 입력 + 길게 꾹 눌러 종료 + 그리기 + 지우기 + 전체 삭제 + 힌트는 열람했을 때만\n차감됩니다 \ No newline at end of file From 200c960e5e0d623712d85ab2d0f36133dbe51d9e Mon Sep 17 00:00:00 2001 From: juhwankim-dev Date: Wed, 18 Feb 2026 21:26:59 +0900 Subject: [PATCH 09/10] =?UTF-8?q?NR-133=20=ED=83=80=EC=9D=B4=EB=A8=B8,=20?= =?UTF-8?q?=ED=9E=8C=ED=8A=B8,=20=EB=A9=94=EB=AA=A8=20=ED=99=94=EB=A9=B4?= =?UTF-8?q?=EC=97=90=20=ED=88=B4=ED=8C=81=20=EC=98=A4=EB=B2=84=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=EB=A5=BC=20=EC=96=B9=EB=8A=94=EB=8B=A4.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ui/tutorial/TutorialSharedViewModel.kt | 12 +- .../ui/tutorial/hint/TutorialHintFragment.kt | 40 +++-- .../ui/tutorial/hint/TutorialHintState.kt | 3 +- .../ui/tutorial/hint/TutorialHintViewModel.kt | 7 +- .../hint/compose/TutorialHintScreen.kt | 4 + .../ui/tutorial/memo/TutorialMemoFragment.kt | 3 +- .../ui/tutorial/memo/TutorialMemoState.kt | 3 +- .../ui/tutorial/memo/TutorialMemoViewModel.kt | 9 +- .../memo/compose/TutorialMemoScreen.kt | 138 +++++++++++------- .../tutorial/timer/TutorialTimerFragment.kt | 3 +- .../ui/tutorial/timer/TutorialTimerState.kt | 3 +- .../tutorial/timer/TutorialTimerViewModel.kt | 4 + .../timer/compose/TutorialTimerScreen.kt | 54 +++++-- 13 files changed, 198 insertions(+), 85 deletions(-) diff --git a/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/tutorial/TutorialSharedViewModel.kt b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/tutorial/TutorialSharedViewModel.kt index 783165d..a79bb5f 100644 --- a/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/tutorial/TutorialSharedViewModel.kt +++ b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/tutorial/TutorialSharedViewModel.kt @@ -48,6 +48,14 @@ class TutorialSharedViewModel @Inject constructor() : NewBaseViewModel() { _state.update { it.copy(openedAnswerIds = it.openedAnswerIds + hintId) } } + fun markMemoTooltipShown() { + _state.update { it.copy(memoTooltipShown = true) } + } + + fun markHintTooltipShown() { + _state.update { it.copy(hintTooltipShown = true) } + } + fun finishTutorial() { timerJob?.cancel() _state.value = TutorialSharedState() @@ -65,5 +73,7 @@ data class TutorialSharedState( val currentHint: TutorialHint? = null, val openedHintIds: Set = emptySet(), val openedAnswerIds: Set = emptySet(), - val totalHintCount: Int = 3 + val totalHintCount: Int = 3, + val memoTooltipShown: Boolean = false, + val hintTooltipShown: Boolean = false, ) diff --git a/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/tutorial/hint/TutorialHintFragment.kt b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/tutorial/hint/TutorialHintFragment.kt index ce3bac8..60404fc 100644 --- a/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/tutorial/hint/TutorialHintFragment.kt +++ b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/tutorial/hint/TutorialHintFragment.kt @@ -5,12 +5,16 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box 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.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.LayoutCoordinates import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.compose.ui.res.stringResource @@ -26,6 +30,7 @@ import com.nextroom.nextroom.presentation.extension.safeNavigate import com.nextroom.nextroom.presentation.extension.updateSystemPadding import com.nextroom.nextroom.presentation.ui.tutorial.TutorialSharedViewModel import com.nextroom.nextroom.presentation.ui.tutorial.hint.compose.TutorialHintScreen +import com.nextroom.nextroom.presentation.ui.tutorial.hint.compose.TutorialHintTooltipOverlay import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject @@ -58,24 +63,35 @@ class TutorialHintFragment : ComposeBaseViewModelFragment val seconds = state.lastSeconds % 60 "%02d:%02d".format(minutes, seconds) } + var hintAreaCoords by remember { mutableStateOf(null) } - Column( + Box( modifier = Modifier .fillMaxSize() .background(NRColor.Dark01) ) { - NRToolbar( - title = timerText, - onBackClick = ::goBack, - rightButtonText = stringResource(R.string.memo_button), - onRightButtonClick = ::navigateToMemo - ) + Column(modifier = Modifier.fillMaxSize()) { + NRToolbar( + title = timerText, + onBackClick = ::goBack, + rightButtonText = stringResource(R.string.memo_button), + onRightButtonClick = ::navigateToMemo + ) - TutorialHintScreen( - state = state, - onHintOpenClick = { viewModel.openHint() }, - onAnswerOpenClick = { viewModel.openAnswer() } - ) + TutorialHintScreen( + state = state, + onHintOpenClick = { viewModel.openHint() }, + onAnswerOpenClick = { viewModel.openAnswer() }, + onHintAreaPositioned = { hintAreaCoords = it } + ) + } + + if (state.showTooltip) { + TutorialHintTooltipOverlay( + hintAreaCoords = hintAreaCoords, + onDismiss = { viewModel.dismissTooltip() } + ) + } } } } diff --git a/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/tutorial/hint/TutorialHintState.kt b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/tutorial/hint/TutorialHintState.kt index c9da494..fbec3ff 100644 --- a/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/tutorial/hint/TutorialHintState.kt +++ b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/tutorial/hint/TutorialHintState.kt @@ -8,5 +8,6 @@ data class TutorialHintState( val isHintOpened: Boolean = false, val isAnswerOpened: Boolean = false, val totalHintCount: Int = 3, - val lastSeconds: Int = 0 + val lastSeconds: Int = 0, + val showTooltip: Boolean = false ) diff --git a/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/tutorial/hint/TutorialHintViewModel.kt b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/tutorial/hint/TutorialHintViewModel.kt index 5029e88..c4fb5c2 100644 --- a/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/tutorial/hint/TutorialHintViewModel.kt +++ b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/tutorial/hint/TutorialHintViewModel.kt @@ -24,7 +24,8 @@ class TutorialHintViewModel @AssistedInject constructor( isHintOpened = (sharedState.currentHint?.id ?: 0) in sharedState.openedHintIds, isAnswerOpened = (sharedState.currentHint?.id ?: 0) in sharedState.openedAnswerIds, totalHintCount = sharedState.totalHintCount, - lastSeconds = sharedState.lastSeconds + lastSeconds = sharedState.lastSeconds, + showTooltip = !sharedState.hintTooltipShown ) }.stateIn(baseViewModelScope, SharingStarted.Lazily, _uiState.value) @@ -40,6 +41,10 @@ class TutorialHintViewModel @AssistedInject constructor( } } + fun dismissTooltip() { + tutorialSharedViewModel.markHintTooltipShown() + } + @AssistedFactory interface Factory { fun create(tutorialSharedViewModel: TutorialSharedViewModel): TutorialHintViewModel diff --git a/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/tutorial/hint/compose/TutorialHintScreen.kt b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/tutorial/hint/compose/TutorialHintScreen.kt index 61562af..407e7f3 100644 --- a/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/tutorial/hint/compose/TutorialHintScreen.kt +++ b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/tutorial/hint/compose/TutorialHintScreen.kt @@ -23,6 +23,8 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.blur import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.layout.LayoutCoordinates +import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview @@ -39,6 +41,7 @@ fun TutorialHintScreen( state: TutorialHintState, onHintOpenClick: () -> Unit, onAnswerOpenClick: () -> Unit, + onHintAreaPositioned: (LayoutCoordinates) -> Unit = {}, modifier: Modifier = Modifier ) { val listState = rememberLazyListState() @@ -95,6 +98,7 @@ fun TutorialHintScreen( .wrapContentHeight() .heightIn(min = if (state.isHintOpened) 0.dp else 200.dp) .padding(top = 12.dp) + .onGloballyPositioned { onHintAreaPositioned(it) } ) { Column( modifier = Modifier diff --git a/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/tutorial/memo/TutorialMemoFragment.kt b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/tutorial/memo/TutorialMemoFragment.kt index 01bb684..eb2416f 100644 --- a/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/tutorial/memo/TutorialMemoFragment.kt +++ b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/tutorial/memo/TutorialMemoFragment.kt @@ -66,7 +66,8 @@ class TutorialMemoFragment : ComposeBaseViewModelFragment onPenClick = viewModel::pickPen, onEraserClick = viewModel::pickEraser, onEraseAllClick = viewModel::eraseAll, - onPathsChanged = viewModel::updatePaths + onPathsChanged = viewModel::updatePaths, + onDismissTooltips = viewModel::dismissTooltips ) } } diff --git a/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/tutorial/memo/TutorialMemoState.kt b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/tutorial/memo/TutorialMemoState.kt index f8b8828..6a33907 100644 --- a/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/tutorial/memo/TutorialMemoState.kt +++ b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/tutorial/memo/TutorialMemoState.kt @@ -12,7 +12,8 @@ data class TutorialMemoState( val lastSeconds: Int = 0, val currentTool: TutorialDrawingTool = TutorialDrawingTool.Pen, val paths: List = emptyList(), - val clearCanvas: Boolean = false + val clearCanvas: Boolean = false, + val showTooltips: Boolean = false, ) data class PathData( diff --git a/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/tutorial/memo/TutorialMemoViewModel.kt b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/tutorial/memo/TutorialMemoViewModel.kt index 06bd62c..1ead2c2 100644 --- a/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/tutorial/memo/TutorialMemoViewModel.kt +++ b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/tutorial/memo/TutorialMemoViewModel.kt @@ -20,7 +20,10 @@ class TutorialMemoViewModel @AssistedInject constructor( _uiState, tutorialSharedViewModel.state ) { state, sharedState -> - state.copy(lastSeconds = sharedState.lastSeconds) + state.copy( + lastSeconds = sharedState.lastSeconds, + showTooltips = !sharedState.memoTooltipShown, + ) }.stateIn(baseViewModelScope, SharingStarted.Lazily, _uiState.value) fun pickPen() { @@ -39,6 +42,10 @@ class TutorialMemoViewModel @AssistedInject constructor( _uiState.update { it.copy(paths = paths, clearCanvas = false) } } + fun dismissTooltips() { + tutorialSharedViewModel.markMemoTooltipShown() + } + @AssistedFactory interface Factory { fun create(tutorialSharedViewModel: TutorialSharedViewModel): TutorialMemoViewModel diff --git a/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/tutorial/memo/compose/TutorialMemoScreen.kt b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/tutorial/memo/compose/TutorialMemoScreen.kt index d0af611..ada74b8 100644 --- a/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/tutorial/memo/compose/TutorialMemoScreen.kt +++ b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/tutorial/memo/compose/TutorialMemoScreen.kt @@ -25,6 +25,8 @@ import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.LayoutCoordinates +import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview @@ -46,61 +48,80 @@ fun TutorialMemoScreen( onEraserClick: () -> Unit, onEraseAllClick: () -> Unit, onPathsChanged: (List) -> Unit, + onDismissTooltips: () -> Unit, modifier: Modifier = Modifier ) { - Column( - modifier = modifier - .fillMaxSize() - .background(NRColor.Dark01) - ) { - NRToolbar( - title = state.lastSeconds.toTimerFormat(), - onBackClick = onBackClick, - rightButtonText = if (fromHint) stringResource(R.string.common_hint_eng) else null, - onRightButtonClick = if (fromHint) onHintClick else null - ) + var penCoords by remember { mutableStateOf(null) } + var eraserCoords by remember { mutableStateOf(null) } + var deleteCoords by remember { mutableStateOf(null) } - // Drawing area with tool buttons overlaid at bottom-right - Box(modifier = Modifier.weight(1f)) { - DrawingCanvas( - paths = state.paths, - currentTool = state.currentTool, - clearCanvas = state.clearCanvas, - onPathsChanged = onPathsChanged, - modifier = Modifier.fillMaxSize() + Box(modifier = modifier.fillMaxSize()) { + Column( + modifier = Modifier + .fillMaxSize() + .background(NRColor.Dark01) + ) { + NRToolbar( + title = state.lastSeconds.toTimerFormat(), + onBackClick = onBackClick, + rightButtonText = if (fromHint) stringResource(R.string.common_hint_eng) else null, + onRightButtonClick = if (fromHint) onHintClick else null ) - // Tool buttons: vertically stacked at bottom-right - // Matches fragment_memo.xml: guide_end=20dp, marginBottom=60dp for last button - Column( - modifier = Modifier - .align(Alignment.BottomEnd) - .padding(end = 20.dp, bottom = 60.dp), - verticalArrangement = Arrangement.spacedBy(24.dp), - horizontalAlignment = Alignment.End - ) { - ToolButton( - iconRes = if (state.currentTool == TutorialDrawingTool.Pen) { - R.drawable.ic_pen_selected - } else { - R.drawable.ic_pen_normal - }, - onClick = onPenClick, - ) - ToolButton( - iconRes = if (state.currentTool == TutorialDrawingTool.Eraser) { - R.drawable.ic_eraser_selected - } else { - R.drawable.ic_eraser_normal - }, - onClick = onEraserClick, - ) - ToolButton( - iconRes = R.drawable.ic_delete_normal, - onClick = onEraseAllClick, + // Drawing area with tool buttons overlaid at bottom-right + Box(modifier = Modifier.weight(1f)) { + DrawingCanvas( + paths = state.paths, + currentTool = state.currentTool, + clearCanvas = state.clearCanvas, + onPathsChanged = onPathsChanged, + modifier = Modifier.fillMaxSize() ) + + // Tool buttons: vertically stacked at bottom-right + // Matches fragment_memo.xml: guide_end=20dp, marginBottom=60dp for last button + Column( + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(end = 20.dp, bottom = 60.dp), + verticalArrangement = Arrangement.spacedBy(24.dp), + horizontalAlignment = Alignment.End + ) { + ToolButton( + iconRes = if (state.currentTool == TutorialDrawingTool.Pen) { + R.drawable.ic_pen_selected + } else { + R.drawable.ic_pen_normal + }, + onClick = onPenClick, + modifier = Modifier.onGloballyPositioned { penCoords = it } + ) + ToolButton( + iconRes = if (state.currentTool == TutorialDrawingTool.Eraser) { + R.drawable.ic_eraser_selected + } else { + R.drawable.ic_eraser_normal + }, + onClick = onEraserClick, + modifier = Modifier.onGloballyPositioned { eraserCoords = it } + ) + ToolButton( + iconRes = R.drawable.ic_delete_normal, + onClick = onEraseAllClick, + modifier = Modifier.onGloballyPositioned { deleteCoords = it } + ) + } } } + + if (state.showTooltips) { + TutorialMemoTooltipOverlay( + penCoords = penCoords, + eraserCoords = eraserCoords, + deleteCoords = deleteCoords, + onDismiss = onDismissTooltips + ) + } } } @@ -210,11 +231,12 @@ private fun DrawingCanvas( private fun ToolButton( iconRes: Int, onClick: () -> Unit, + modifier: Modifier = Modifier, tint: Color = Color.Unspecified ) { IconButton( onClick = onClick, - modifier = Modifier.size(50.dp) + modifier = modifier.size(50.dp) ) { Icon( painter = painterResource(iconRes), @@ -237,7 +259,8 @@ private fun TutorialMemoScreenPenPreview() { TutorialMemoScreen( state = TutorialMemoState( lastSeconds = 1800, - currentTool = TutorialDrawingTool.Pen + currentTool = TutorialDrawingTool.Pen, + showTooltips = false, ), fromHint = false, onBackClick = {}, @@ -245,7 +268,8 @@ private fun TutorialMemoScreenPenPreview() { onPenClick = {}, onEraserClick = {}, onEraseAllClick = {}, - onPathsChanged = {} + onPathsChanged = {}, + onDismissTooltips = {} ) } @@ -255,7 +279,8 @@ private fun TutorialMemoScreenEraserPreview() { TutorialMemoScreen( state = TutorialMemoState( lastSeconds = 1800, - currentTool = TutorialDrawingTool.Eraser + currentTool = TutorialDrawingTool.Eraser, + showTooltips = false, ), fromHint = false, onBackClick = {}, @@ -263,7 +288,8 @@ private fun TutorialMemoScreenEraserPreview() { onPenClick = {}, onEraserClick = {}, onEraseAllClick = {}, - onPathsChanged = {} + onPathsChanged = {}, + onDismissTooltips = {} ) } @@ -273,7 +299,8 @@ private fun TutorialMemoFromHintPreview() { TutorialMemoScreen( state = TutorialMemoState( lastSeconds = 1800, - currentTool = TutorialDrawingTool.Eraser + currentTool = TutorialDrawingTool.Eraser, + showTooltips = false, ), fromHint = true, onBackClick = {}, @@ -281,6 +308,7 @@ private fun TutorialMemoFromHintPreview() { onPenClick = {}, onEraserClick = {}, onEraseAllClick = {}, - onPathsChanged = {} + onPathsChanged = {}, + onDismissTooltips = {} ) -} \ No newline at end of file +} diff --git a/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/tutorial/timer/TutorialTimerFragment.kt b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/tutorial/timer/TutorialTimerFragment.kt index d296d76..221c2e7 100644 --- a/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/tutorial/timer/TutorialTimerFragment.kt +++ b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/tutorial/timer/TutorialTimerFragment.kt @@ -71,7 +71,8 @@ class TutorialTimerFragment : ComposeBaseViewModelFragment Unit, onExitLongPress: () -> Unit, onTimerLongPress: () -> Unit, + onDismissTooltips: () -> Unit, modifier: Modifier = Modifier ) { + var arcCoords by remember { mutableStateOf(null) } + var memoCoords by remember { mutableStateOf(null) } + var keypadCoords by remember { mutableStateOf(null) } + var backCoords by remember { mutableStateOf(null) } + BoxWithConstraints( modifier = modifier .fillMaxSize() @@ -76,12 +87,16 @@ fun TutorialTimerScreen( ) { TutorialToolbar( onBackLongPress = onExitLongPress, - onMemoClick = onMemoClick + onMemoClick = onMemoClick, + onBackPositioned = { backCoords = it }, + onMemoPositioned = { memoCoords = it } ) // Arc + timer text + hint info section (overlaid) Box( - modifier = Modifier.size(arcSize) + modifier = Modifier + .size(arcSize) + .onGloballyPositioned { arcCoords = it } ) { val interactionSource = remember { MutableInteractionSource() } @@ -112,7 +127,7 @@ fun TutorialTimerScreen( ) Spacer(modifier = Modifier.height(6.dp)) Text( - text = "HINT", + text = stringResource(R.string.common_hint_eng), style = NRTypo.Poppins.size20, color = NRColor.White ) @@ -149,6 +164,17 @@ fun TutorialTimerScreen( modifier = Modifier .fillMaxWidth() .padding(start = 20.dp, end = 20.dp, bottom = 42.dp) + .onGloballyPositioned { keypadCoords = it } + ) + } + + if (state.showTooltips) { + TutorialTimerTooltipOverlay( + arcCoords = arcCoords, + memoCoords = memoCoords, + keypadCoords = keypadCoords, + backCoords = backCoords, + onDismiss = onDismissTooltips ) } } @@ -158,7 +184,9 @@ fun TutorialTimerScreen( @Composable private fun TutorialToolbar( onBackLongPress: () -> Unit, - onMemoClick: () -> Unit + onMemoClick: () -> Unit, + onBackPositioned: (LayoutCoordinates) -> Unit = {}, + onMemoPositioned: (LayoutCoordinates) -> Unit = {} ) { val interactionSource = remember { MutableInteractionSource() } @@ -178,7 +206,8 @@ private fun TutorialToolbar( onLongClick = { onBackLongPress() }, onClick = {} ) - .padding(20.dp), + .padding(20.dp) + .onGloballyPositioned { onBackPositioned(it) }, contentAlignment = Alignment.Center ) { Icon( @@ -194,7 +223,8 @@ private fun TutorialToolbar( .clip(RoundedCornerShape(50.dp)) .background(NRColor.White) .clickable { onMemoClick() } - .padding(horizontal = 16.dp, vertical = 6.dp), + .padding(horizontal = 16.dp, vertical = 6.dp) + .onGloballyPositioned { onMemoPositioned(it) }, text = stringResource(R.string.memo_button), style = NRTypo.Poppins.size14, color = NRColor.Dark01 @@ -217,13 +247,15 @@ private fun TutorialTimerScreenPreview() { lastSeconds = 3600, currentInput = "", openedHintCount = 0, - totalHintCount = 3 + totalHintCount = 3, + showTooltips = false, ), onKeyInput = {}, onBackspace = {}, onMemoClick = {}, onExitLongPress = {}, - onTimerLongPress = {} + onTimerLongPress = {}, + onDismissTooltips = {} ) } @@ -236,12 +268,14 @@ private fun TutorialTimerScreenWithInputPreview() { lastSeconds = 1800, currentInput = "12", openedHintCount = 1, - totalHintCount = 3 + totalHintCount = 3, + showTooltips = false, ), onKeyInput = {}, onBackspace = {}, onMemoClick = {}, onExitLongPress = {}, - onTimerLongPress = {} + onTimerLongPress = {}, + onDismissTooltips = {} ) } From 94a4232ef9d7fd393093390c1822d11554f4096f Mon Sep 17 00:00:00 2001 From: juhwankim-dev Date: Thu, 19 Feb 2026 00:42:24 +0900 Subject: [PATCH 10/10] =?UTF-8?q?NR-133=20=ED=8A=9C=ED=86=A0=EB=A6=AC?= =?UTF-8?q?=EC=96=BC=20=EC=A2=85=EB=A3=8C=20=EB=8B=A4=EC=9D=B4=EC=96=BC?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EB=A5=BC=20=EB=B0=94=ED=85=80=EC=8B=9C?= =?UTF-8?q?=ED=8A=B8=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 왜? 튜토리얼 종료 타이밍에 넛지를 주고 싶은데 다이얼로그보단 바텀시트가 안에 넛지 컨텐츠를 넣기가 더 용도가 적절한 듯 하여 변경함 --- .../tutorial/timer/TutorialTimerFragment.kt | 21 +--- .../timer/compose/TutorialExitBottomSheet.kt | 101 ++++++++++++++++++ .../timer/compose/TutorialTimerScreen.kt | 32 ++++-- presentation/src/main/res/values/strings.xml | 8 +- 4 files changed, 133 insertions(+), 29 deletions(-) create mode 100644 presentation/src/main/java/com/nextroom/nextroom/presentation/ui/tutorial/timer/compose/TutorialExitBottomSheet.kt diff --git a/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/tutorial/timer/TutorialTimerFragment.kt b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/tutorial/timer/TutorialTimerFragment.kt index 221c2e7..3dbe781 100644 --- a/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/tutorial/timer/TutorialTimerFragment.kt +++ b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/tutorial/timer/TutorialTimerFragment.kt @@ -16,7 +16,6 @@ import androidx.navigation.fragment.findNavController import com.nextroom.nextroom.presentation.NavGraphDirections import com.nextroom.nextroom.presentation.R import com.nextroom.nextroom.presentation.base.ComposeBaseViewModelFragment -import com.nextroom.nextroom.presentation.common.NRTwoButtonDialog import com.nextroom.nextroom.presentation.extension.assistedViewModel import com.nextroom.nextroom.presentation.extension.enableFullScreen import com.nextroom.nextroom.presentation.extension.repeatOnStarted @@ -70,9 +69,9 @@ class TutorialTimerFragment : ComposeBaseViewModelFragment - viewModel.exitTutorial() - } setFragmentResultListener(REQUEST_KEY_MODIFY_TIME) { _, bundle -> val modifiedTime = bundle.getInt(ModifyTimeBottomSheet.BUNDLE_KEY_MODIFIED_TIME) tutorialSharedViewModel.modifyTime(modifiedTime) @@ -155,7 +139,6 @@ class TutorialTimerFragment : ComposeBaseViewModelFragment Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 24.dp) + .padding(bottom = 36.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = stringResource(R.string.tutorial_exit_sheet_title), + style = NRTypo.Pretendard.size20SemiBold, + color = NRColor.White, + textAlign = TextAlign.Center, + lineHeight = 28.sp, + ) + + Spacer(modifier = Modifier.height(46.dp)) + + Column(verticalArrangement = Arrangement.spacedBy(14.dp)) { + FeatureItem(text = stringResource(R.string.tutorial_exit_sheet_feature_image)) + FeatureItem(text = stringResource(R.string.tutorial_exit_sheet_feature_background)) + FeatureItem(text = stringResource(R.string.tutorial_exit_sheet_feature_manage)) + } + + Spacer(modifier = Modifier.height(32.dp)) + + Text( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .background(NRColor.White20) + .throttleClick { onExitClick() } + .padding(vertical = 16.dp), + text = stringResource(R.string.tutorial_exit_sheet_exit_button), + style = NRTypo.Pretendard.size16SemiBold, + color = NRColor.White, + textAlign = TextAlign.Center, + ) + } +} + +@Composable +private fun FeatureItem(text: String) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier + .size(6.dp) + .background(NRColor.Blue, CircleShape), + ) + Text( + text = text, + style = NRTypo.Body.size14Regular, + color = NRColor.ColorSurface, + ) + } +} + +@Preview(showBackground = true, backgroundColor = 0xFF1F2023) +@Composable +private fun TutorialExitBottomSheetPreview() { + TutorialExitBottomSheetContent(onExitClick = {}) +} diff --git a/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/tutorial/timer/compose/TutorialTimerScreen.kt b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/tutorial/timer/compose/TutorialTimerScreen.kt index 2bd604d..bfd7524 100644 --- a/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/tutorial/timer/compose/TutorialTimerScreen.kt +++ b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/tutorial/timer/compose/TutorialTimerScreen.kt @@ -20,8 +20,11 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon +import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -43,22 +46,23 @@ import com.nextroom.nextroom.presentation.common.compose.NRColor import com.nextroom.nextroom.presentation.common.compose.NRTypo import com.nextroom.nextroom.presentation.ui.tutorial.timer.TutorialTimerState -@OptIn(ExperimentalFoundationApi::class) +@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) @Composable fun TutorialTimerScreen( state: TutorialTimerState, onKeyInput: (Int) -> Unit, onBackspace: () -> Unit, onMemoClick: () -> Unit, - onExitLongPress: () -> Unit, onTimerLongPress: () -> Unit, onDismissTooltips: () -> Unit, - modifier: Modifier = Modifier + onExitConfirmed: () -> Unit, + modifier: Modifier = Modifier, ) { var arcCoords by remember { mutableStateOf(null) } var memoCoords by remember { mutableStateOf(null) } var keypadCoords by remember { mutableStateOf(null) } var backCoords by remember { mutableStateOf(null) } + var showExitBottomSheet by remember { mutableStateOf(false) } BoxWithConstraints( modifier = modifier @@ -86,7 +90,7 @@ fun TutorialTimerScreen( horizontalAlignment = Alignment.CenterHorizontally ) { TutorialToolbar( - onBackLongPress = onExitLongPress, + onBackLongPress = { showExitBottomSheet = true }, onMemoClick = onMemoClick, onBackPositioned = { backCoords = it }, onMemoPositioned = { memoCoords = it } @@ -177,6 +181,18 @@ fun TutorialTimerScreen( onDismiss = onDismissTooltips ) } + + if (showExitBottomSheet) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + ModalBottomSheet( + onDismissRequest = { showExitBottomSheet = false }, + sheetState = sheetState, + containerColor = NRColor.Sub1, + shape = RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp), + ) { + TutorialExitBottomSheetContent(onExitClick = onExitConfirmed) + } + } } } @@ -253,9 +269,9 @@ private fun TutorialTimerScreenPreview() { onKeyInput = {}, onBackspace = {}, onMemoClick = {}, - onExitLongPress = {}, onTimerLongPress = {}, - onDismissTooltips = {} + onDismissTooltips = {}, + onExitConfirmed = {}, ) } @@ -274,8 +290,8 @@ private fun TutorialTimerScreenWithInputPreview() { onKeyInput = {}, onBackspace = {}, onMemoClick = {}, - onExitLongPress = {}, onTimerLongPress = {}, - onDismissTooltips = {} + onDismissTooltips = {}, + onExitConfirmed = {}, ) } diff --git a/presentation/src/main/res/values/strings.xml b/presentation/src/main/res/values/strings.xml index 8fffe95..4a1aede 100644 --- a/presentation/src/main/res/values/strings.xml +++ b/presentation/src/main/res/values/strings.xml @@ -236,8 +236,6 @@ 로그인 없이 체험하기 - 체험 종료 - 체험 모드를 종료하시겠습니까? 남은 시간과 힌트 수 표시 메모 하러 이동 힌트 코드 입력 @@ -246,4 +244,10 @@ 지우기 전체 삭제 힌트는 열람했을 때만\n차감됩니다 + 지금 가입하고\n더 다양한 기능을 사용해보세요! + 체험 모드에서는 사용할 수 없는\n기능들을 만나보세요 + 힌트 이미지 첨부 + 우리 매장만의 화면 커스텀 + 오프라인 환경 플레이 등 + 로그인 화면으로 돌아가기 \ No newline at end of file