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 c124a6dc..29b87287 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/common/compose/NRTooltip.kt b/presentation/src/main/java/com/nextroom/nextroom/presentation/common/compose/NRTooltip.kt new file mode 100644 index 00000000..94b29eec --- /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) +} 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 2832c31e..706118dc 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 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 be52fc4b..f3bc1bd0 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 ) } 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 1d417dad..0e69f2f5 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/java/com/nextroom/nextroom/presentation/ui/onboarding/LoginFragment.kt b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/onboarding/LoginFragment.kt index 6037b8b4..da2a87eb 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/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 00000000..d7f38524 --- /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 00000000..a79bb5f0 --- /dev/null +++ b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/tutorial/TutorialSharedViewModel.kt @@ -0,0 +1,79 @@ +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 markMemoTooltipShown() { + _state.update { it.copy(memoTooltipShown = true) } + } + + fun markHintTooltipShown() { + _state.update { it.copy(hintTooltipShown = true) } + } + + 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, + 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 new file mode 100644 index 00000000..60404fcf --- /dev/null +++ b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/tutorial/hint/TutorialHintFragment.kt @@ -0,0 +1,116 @@ +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.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 +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 com.nextroom.nextroom.presentation.ui.tutorial.hint.compose.TutorialHintTooltipOverlay +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) + } + var hintAreaCoords by remember { mutableStateOf(null) } + + Box( + modifier = Modifier + .fillMaxSize() + .background(NRColor.Dark01) + ) { + 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() }, + onHintAreaPositioned = { hintAreaCoords = it } + ) + } + + if (state.showTooltip) { + TutorialHintTooltipOverlay( + hintAreaCoords = hintAreaCoords, + onDismiss = { viewModel.dismissTooltip() } + ) + } + } + } + } + } + + 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 00000000..fbec3ff9 --- /dev/null +++ b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/tutorial/hint/TutorialHintState.kt @@ -0,0 +1,13 @@ +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, + 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 new file mode 100644 index 00000000..c4fb5c21 --- /dev/null +++ b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/tutorial/hint/TutorialHintViewModel.kt @@ -0,0 +1,52 @@ +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, + showTooltip = !sharedState.hintTooltipShown + ) + }.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) + } + } + + 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 new file mode 100644 index 00000000..407e7f32 --- /dev/null +++ b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/tutorial/hint/compose/TutorialHintScreen.kt @@ -0,0 +1,290 @@ +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.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 +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, + onHintAreaPositioned: (LayoutCoordinates) -> 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) + .onGloballyPositioned { onHintAreaPositioned(it) } + ) { + 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/hint/compose/TutorialHintTooltipOverlay.kt b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/tutorial/hint/compose/TutorialHintTooltipOverlay.kt new file mode 100644 index 00000000..26a83c2b --- /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/TutorialMemoFragment.kt b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/tutorial/memo/TutorialMemoFragment.kt new file mode 100644 index 00000000..eb2416f0 --- /dev/null +++ b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/tutorial/memo/TutorialMemoFragment.kt @@ -0,0 +1,95 @@ +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, + onDismissTooltips = viewModel::dismissTooltips + ) + } + } + } + + 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 00000000..6a339072 --- /dev/null +++ b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/tutorial/memo/TutorialMemoState.kt @@ -0,0 +1,23 @@ +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, + val showTooltips: 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 00000000..1ead2c22 --- /dev/null +++ b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/tutorial/memo/TutorialMemoViewModel.kt @@ -0,0 +1,53 @@ +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, + showTooltips = !sharedState.memoTooltipShown, + ) + }.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) } + } + + 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 new file mode 100644 index 00000000..ada74b86 --- /dev/null +++ b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/tutorial/memo/compose/TutorialMemoScreen.kt @@ -0,0 +1,314 @@ +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.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 +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, + onDismissTooltips: () -> Unit, + modifier: Modifier = Modifier +) { + var penCoords by remember { mutableStateOf(null) } + var eraserCoords by remember { mutableStateOf(null) } + var deleteCoords by remember { mutableStateOf(null) } + + 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 + ) + + // 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 + ) + } + } +} + +@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, + modifier: Modifier = Modifier, + 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, + showTooltips = false, + ), + fromHint = false, + onBackClick = {}, + onHintClick = {}, + onPenClick = {}, + onEraserClick = {}, + onEraseAllClick = {}, + onPathsChanged = {}, + onDismissTooltips = {} + ) +} + +@Preview(showBackground = true, backgroundColor = 0xFF151516) +@Composable +private fun TutorialMemoScreenEraserPreview() { + TutorialMemoScreen( + state = TutorialMemoState( + lastSeconds = 1800, + currentTool = TutorialDrawingTool.Eraser, + showTooltips = false, + ), + fromHint = false, + onBackClick = {}, + onHintClick = {}, + onPenClick = {}, + onEraserClick = {}, + onEraseAllClick = {}, + onPathsChanged = {}, + onDismissTooltips = {} + ) +} + +@Preview(showBackground = true, backgroundColor = 0xFF151516) +@Composable +private fun TutorialMemoFromHintPreview() { + TutorialMemoScreen( + state = TutorialMemoState( + lastSeconds = 1800, + currentTool = TutorialDrawingTool.Eraser, + showTooltips = false, + ), + fromHint = true, + onBackClick = {}, + onHintClick = {}, + onPenClick = {}, + onEraserClick = {}, + onEraseAllClick = {}, + onPathsChanged = {}, + onDismissTooltips = {} + ) +} 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 00000000..201d5f1c --- /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/TutorialTimerEvent.kt b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/tutorial/timer/TutorialTimerEvent.kt new file mode 100644 index 00000000..0df95cdc --- /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 00000000..3dbe7814 --- /dev/null +++ b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/tutorial/timer/TutorialTimerFragment.kt @@ -0,0 +1,144 @@ +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.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, + onTimerLongPress = ::showModifyTimeBottomSheet, + onDismissTooltips = viewModel::dismissTooltips, + onExitConfirmed = viewModel::exitTutorial, + ) + } + } + } + + 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 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_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_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 00000000..dc82fc2a --- /dev/null +++ b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/tutorial/timer/TutorialTimerState.kt @@ -0,0 +1,14 @@ +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, + val showTooltips: Boolean = true +) 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 00000000..e8864ea6 --- /dev/null +++ b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/tutorial/timer/TutorialTimerViewModel.kt @@ -0,0 +1,108 @@ +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 dismissTooltips() { + _uiState.update { it.copy(showTooltips = false) } + } + + 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 00000000..5e84c03e --- /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 00000000..29cc0bb9 --- /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 00000000..87ac228a --- /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/TutorialExitBottomSheet.kt b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/tutorial/timer/compose/TutorialExitBottomSheet.kt new file mode 100644 index 00000000..2c8c5236 --- /dev/null +++ b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/tutorial/timer/compose/TutorialExitBottomSheet.kt @@ -0,0 +1,101 @@ +package com.nextroom.nextroom.presentation.ui.tutorial.timer.compose + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +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.draw.clip +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +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 + +@Composable +fun TutorialExitBottomSheetContent( + onExitClick: () -> 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 new file mode 100644 index 00000000..bfd75247 --- /dev/null +++ b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/tutorial/timer/compose/TutorialTimerScreen.kt @@ -0,0 +1,297 @@ +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.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 +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +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.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 +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, ExperimentalMaterial3Api::class) +@Composable +fun TutorialTimerScreen( + state: TutorialTimerState, + onKeyInput: (Int) -> Unit, + onBackspace: () -> Unit, + onMemoClick: () -> Unit, + onTimerLongPress: () -> Unit, + onDismissTooltips: () -> Unit, + 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 + .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 = { showExitBottomSheet = true }, + onMemoClick = onMemoClick, + onBackPositioned = { backCoords = it }, + onMemoPositioned = { memoCoords = it } + ) + + // Arc + timer text + hint info section (overlaid) + Box( + modifier = Modifier + .size(arcSize) + .onGloballyPositioned { arcCoords = it } + ) { + 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 = stringResource(R.string.common_hint_eng), + 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) + .onGloballyPositioned { keypadCoords = it } + ) + } + + if (state.showTooltips) { + TutorialTimerTooltipOverlay( + arcCoords = arcCoords, + memoCoords = memoCoords, + keypadCoords = keypadCoords, + backCoords = backCoords, + 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) + } + } + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun TutorialToolbar( + onBackLongPress: () -> Unit, + onMemoClick: () -> Unit, + onBackPositioned: (LayoutCoordinates) -> Unit = {}, + onMemoPositioned: (LayoutCoordinates) -> 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) + .onGloballyPositioned { onBackPositioned(it) }, + 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) + .onGloballyPositioned { onMemoPositioned(it) }, + 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, + showTooltips = false, + ), + onKeyInput = {}, + onBackspace = {}, + onMemoClick = {}, + onTimerLongPress = {}, + onDismissTooltips = {}, + onExitConfirmed = {}, + ) +} + +@Preview(showBackground = true, backgroundColor = 0xFF151516) +@Composable +private fun TutorialTimerScreenWithInputPreview() { + TutorialTimerScreen( + state = TutorialTimerState( + totalSeconds = 3600, + lastSeconds = 1800, + currentInput = "12", + openedHintCount = 1, + totalHintCount = 3, + showTooltips = false, + ), + onKeyInput = {}, + onBackspace = {}, + onMemoClick = {}, + onTimerLongPress = {}, + onDismissTooltips = {}, + onExitConfirmed = {}, + ) +} 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 00000000..67f5c5ed --- /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/layout/fragment_login.xml b/presentation/src/main/res/layout/fragment_login.xml index 581408dc..a8747778 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" /> + + diff --git a/presentation/src/main/res/navigation/game_navigation.xml b/presentation/src/main/res/navigation/game_navigation.xml index 46fcd5c3..38f7b60e 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 48d60313..bd8b2ebd 100644 --- a/presentation/src/main/res/navigation/nav_graph.xml +++ b/presentation/src/main/res/navigation/nav_graph.xml @@ -9,6 +9,7 @@ + @@ -106,6 +107,21 @@ app:argType="com.nextroom.nextroom.presentation.model.SelectItemBottomSheetArg" /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/presentation/src/main/res/values/strings.xml b/presentation/src/main/res/values/strings.xml index b60f96b4..4a1aede4 100644 --- a/presentation/src/main/res/values/strings.xml +++ b/presentation/src/main/res/values/strings.xml @@ -233,4 +233,21 @@ 에러 코드: %d 힌트를 보려면 클릭하세요! + + + 로그인 없이 체험하기 + 남은 시간과 힌트 수 표시 + 메모 하러 이동 + 힌트 코드 입력 + 길게 꾹 눌러 종료 + 그리기 + 지우기 + 전체 삭제 + 힌트는 열람했을 때만\n차감됩니다 + 지금 가입하고\n더 다양한 기능을 사용해보세요! + 체험 모드에서는 사용할 수 없는\n기능들을 만나보세요 + 힌트 이미지 첨부 + 우리 매장만의 화면 커스텀 + 오프라인 환경 플레이 등 + 로그인 화면으로 돌아가기 \ No newline at end of file