diff --git a/data/src/main/java/com/nextroom/nextroom/data/db/GameStateDao.kt b/data/src/main/java/com/nextroom/nextroom/data/db/GameStateDao.kt index 578ad88d..5372a1e4 100644 --- a/data/src/main/java/com/nextroom/nextroom/data/db/GameStateDao.kt +++ b/data/src/main/java/com/nextroom/nextroom/data/db/GameStateDao.kt @@ -17,4 +17,7 @@ interface GameStateDao { @Query("DELETE FROM $GAME_STATE_TABLE") suspend fun deleteGameState() + + @Query("UPDATE $GAME_STATE_TABLE SET usedHints = :usedHints") + suspend fun updateUsedHints(usedHints: Set) } diff --git a/data/src/main/java/com/nextroom/nextroom/data/repository/GameStateRepositoryImpl.kt b/data/src/main/java/com/nextroom/nextroom/data/repository/GameStateRepositoryImpl.kt index 23ee1bdf..8eb51681 100644 --- a/data/src/main/java/com/nextroom/nextroom/data/repository/GameStateRepositoryImpl.kt +++ b/data/src/main/java/com/nextroom/nextroom/data/repository/GameStateRepositoryImpl.kt @@ -48,4 +48,8 @@ class GameStateRepositoryImpl @Inject constructor( override suspend fun getGameState(): GameState? { return gameStateDao.getGameState()?.toDomain() } + + override suspend fun updateUsedHints(usedHints: Set) { + gameStateDao.updateUsedHints(usedHints) + } } diff --git a/domain/src/main/java/com/nextroom/nextroom/domain/repository/GameStateRepository.kt b/domain/src/main/java/com/nextroom/nextroom/domain/repository/GameStateRepository.kt index 89e05293..4ebcd0cf 100644 --- a/domain/src/main/java/com/nextroom/nextroom/domain/repository/GameStateRepository.kt +++ b/domain/src/main/java/com/nextroom/nextroom/domain/repository/GameStateRepository.kt @@ -18,4 +18,6 @@ interface GameStateRepository { suspend fun finishGame(onFinished: () -> Unit = {}) suspend fun getGameState(): GameState? + + suspend fun updateUsedHints(usedHints: Set) } diff --git a/presentation/src/main/java/com/nextroom/nextroom/presentation/extension/Fragment.kt b/presentation/src/main/java/com/nextroom/nextroom/presentation/extension/Fragment.kt index dd9c91bf..6046c28b 100644 --- a/presentation/src/main/java/com/nextroom/nextroom/presentation/extension/Fragment.kt +++ b/presentation/src/main/java/com/nextroom/nextroom/presentation/extension/Fragment.kt @@ -6,7 +6,10 @@ import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.updatePadding import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels import androidx.lifecycle.Lifecycle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider import com.google.android.material.snackbar.Snackbar import com.nextroom.nextroom.presentation.common.NRSnackbar import com.nextroom.nextroom.presentation.util.Logger @@ -111,3 +114,34 @@ fun Fragment.enableFullScreen( fun Fragment.disableFullScreen() { (requireActivity() as? WindowInsetsManager)?.disableFullScreen() } + +/** + * AssistedInject를 사용하는 ViewModel을 쉽게 생성하기 위한 확장 함수 + * + * ## 사용 예시 + * ```kotlin + * @Inject + * lateinit var viewModelFactory: HintViewModel.Factory + * + * private val gameSharedViewModel: GameSharedViewModel by hiltNavGraphViewModels(R.id.game_navigation) + * + * override val viewModel: HintViewModel by assistedViewModel { + * viewModelFactory.create(gameSharedViewModel) + * } + * ``` + * + * @param factory ViewModel을 생성하는 람다 함수 + * @return ViewModel의 Lazy 인스턴스 + */ +inline fun Fragment.assistedViewModel( + crossinline factory: () -> VM +): Lazy { + return viewModels { + object : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + @Suppress("UNCHECKED_CAST") + return factory() as T + } + } + } +} diff --git a/presentation/src/main/java/com/nextroom/nextroom/presentation/model/Hint.kt b/presentation/src/main/java/com/nextroom/nextroom/presentation/model/Hint.kt index 7a1de7d3..436b36e3 100644 --- a/presentation/src/main/java/com/nextroom/nextroom/presentation/model/Hint.kt +++ b/presentation/src/main/java/com/nextroom/nextroom/presentation/model/Hint.kt @@ -7,7 +7,6 @@ data class Hint( val progress: Int = 0, val hint: String = "", val answer: String = "", - val answerOpened: Boolean = false, val hintImageUrlList: List = emptyList(), val answerImageUrlList: List = emptyList() ) : Serializable diff --git a/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/hint/HintEvent.kt b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/hint/HintEvent.kt index 78559112..4888f2fd 100644 --- a/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/hint/HintEvent.kt +++ b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/hint/HintEvent.kt @@ -1,8 +1,8 @@ package com.nextroom.nextroom.presentation.ui.hint sealed interface HintEvent { - data object OpenAnswer : HintEvent data object NetworkError : HintEvent data object UnknownError : HintEvent data class ClientError(val message: String) : HintEvent + data object HintLimitExceed : HintEvent } diff --git a/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/hint/HintFragment.kt b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/hint/HintFragment.kt index 68cb8c53..bc406f7a 100644 --- a/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/hint/HintFragment.kt +++ b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/hint/HintFragment.kt @@ -12,7 +12,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.core.os.bundleOf -import androidx.fragment.app.viewModels import androidx.hilt.navigation.fragment.hiltNavGraphViewModels import androidx.navigation.fragment.findNavController import com.google.firebase.analytics.FirebaseAnalytics @@ -20,6 +19,7 @@ import com.nextroom.nextroom.domain.model.SubscribeStatus import com.nextroom.nextroom.presentation.NavGraphDirections import com.nextroom.nextroom.presentation.R import com.nextroom.nextroom.presentation.base.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 @@ -30,13 +30,21 @@ import com.nextroom.nextroom.presentation.ui.hint.compose.HintTimerToolbar import com.nextroom.nextroom.presentation.ui.main.GameSharedViewModel import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.launch +import javax.inject.Inject @AndroidEntryPoint class HintFragment : ComposeBaseViewModelFragment() { override val screenName: String = "hint" - override val viewModel: HintViewModel by viewModels() + + @Inject + lateinit var viewModelFactory: HintViewModel.Factory + private val gameSharedViewModel: GameSharedViewModel by hiltNavGraphViewModels(R.id.game_navigation) + override val viewModel: HintViewModel by assistedViewModel { + viewModelFactory.create(gameSharedViewModel) + } + override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, @@ -58,9 +66,10 @@ class HintFragment : ComposeBaseViewModelFragment() { HintScreen( state = state, - onAnswerButtonClick = ::handleAnswerButton, onHintImageClick = ::navigateToHintImageViewer, - onAnswerImageClick = ::navigateToAnswerImageViewer + onAnswerImageClick = ::navigateToAnswerImageViewer, + onHintOpenClick = { viewModel.tryOpenHint(state.hint.id) }, + onAnswerOpenClick = { gameSharedViewModel.addOpenedAnswerId(state.hint.id) } ) } } @@ -76,30 +85,12 @@ class HintFragment : ComposeBaseViewModelFragment() { override fun initSubscribe() { viewLifecycleOwner.repeatOnStarted { - launch { - gameSharedViewModel.currentHint.collect { hint -> - hint?.let { viewModel.setHint(it) } - } - } - launch { - gameSharedViewModel.subscribeStatus.collect { subscribeStatus -> - viewModel.setSubscribeStatus(subscribeStatus) - } - } launch { viewModel.uiEvent.collect(::handleEvent) } } } - private fun handleAnswerButton() { - if (viewModel.uiState.value.hint.answerOpened) { - gotoHome() - } else { - viewModel.openAnswer() - } - } - private fun navigateToHintImageViewer(position: Int) { val state = viewModel.uiState.value if (state.userSubscribeStatus == SubscribeStatus.Subscribed) { @@ -135,10 +126,10 @@ class HintFragment : ComposeBaseViewModelFragment() { private fun handleEvent(event: HintEvent) { when (event) { - HintEvent.OpenAnswer -> viewModel.openAnswer() is HintEvent.NetworkError -> snackbar(R.string.error_network) is HintEvent.UnknownError -> snackbar(R.string.error_something) is HintEvent.ClientError -> snackbar(event.message) + is HintEvent.HintLimitExceed -> snackbar(R.string.game_hint_limit_exceed) } } diff --git a/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/hint/HintState.kt b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/hint/HintState.kt index 5fe399c4..b3a98634 100644 --- a/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/hint/HintState.kt +++ b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/hint/HintState.kt @@ -7,5 +7,8 @@ data class HintState( val loading: Boolean = false, val hint: Hint = Hint(), val userSubscribeStatus: SubscribeStatus = SubscribeStatus.Default, - val networkDisconnectedCount: Int = 0 + val networkDisconnectedCount: Int = 0, + val isHintOpened: Boolean = false, + val isAnswerOpened: Boolean = false, + val totalHintCount: Int = -1 ) diff --git a/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/hint/HintViewModel.kt b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/hint/HintViewModel.kt index a2268aab..a7d2d8ce 100644 --- a/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/hint/HintViewModel.kt +++ b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/hint/HintViewModel.kt @@ -1,26 +1,43 @@ package com.nextroom.nextroom.presentation.ui.hint -import com.nextroom.nextroom.domain.model.SubscribeStatus import com.nextroom.nextroom.domain.repository.DataStoreRepository import com.nextroom.nextroom.domain.repository.TimerRepository import com.nextroom.nextroom.presentation.base.NewBaseViewModel -import com.nextroom.nextroom.presentation.model.Hint -import dagger.hilt.android.lifecycle.HiltViewModel +import com.nextroom.nextroom.presentation.ui.main.GameSharedViewModel +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.asSharedFlow -import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch -import javax.inject.Inject -@HiltViewModel -class HintViewModel @Inject constructor( +class HintViewModel @AssistedInject constructor( private val timerRepository: TimerRepository, private val dataStoreRepository: DataStoreRepository, + @Assisted private val gameSharedViewModel: GameSharedViewModel ) : NewBaseViewModel() { private val _uiState = MutableStateFlow(HintState()) - val uiState = _uiState.asStateFlow() + val uiState = combine( + _uiState, + gameSharedViewModel.state + ) { state, gameSharedState -> + state.copy( + hint = gameSharedState.currentHint ?: state.hint, + userSubscribeStatus = gameSharedState.subscribeStatus, + isHintOpened = (gameSharedState.currentHint?.id ?: state.hint.id) in gameSharedState.openedHintIds, + isAnswerOpened = (gameSharedState.currentHint?.id ?: state.hint.id) in gameSharedState.openedAnswerIds, + totalHintCount = gameSharedState.totalHintCount + ) + }.stateIn( + baseViewModelScope, + SharingStarted.Lazily, + _uiState.value + ) private val _uiEvent = MutableSharedFlow(extraBufferCapacity = 1) val uiEvent = _uiEvent.asSharedFlow() @@ -41,19 +58,19 @@ class HintViewModel @Inject constructor( _uiState.value = _uiState.value.copy(networkDisconnectedCount = count) } - fun setHint(hint: Hint) { - _uiState.value = _uiState.value.copy( - hint = hint.copy(answerOpened = _uiState.value.hint.answerOpened) - ) - } + fun tryOpenHint(hintId: Int) { + val openedCount = gameSharedViewModel.getOpenedHintCount() + val openableHintCount = uiState.value.totalHintCount - fun setSubscribeStatus(subscribeStatus: SubscribeStatus) { - _uiState.value = _uiState.value.copy(userSubscribeStatus = subscribeStatus) + if (openableHintCount == -1 || openedCount < openableHintCount) { + gameSharedViewModel.addOpenedHintId(hintId) + } else { + _uiEvent.tryEmit(HintEvent.HintLimitExceed) + } } - fun openAnswer() { - _uiState.value = _uiState.value.copy( - hint = _uiState.value.hint.copy(answerOpened = true) - ) + @AssistedFactory + interface Factory { + fun create(gameSharedViewModel: GameSharedViewModel): HintViewModel } } 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 df80024d..be52fc4b 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 @@ -9,27 +9,24 @@ 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.material3.Button -import androidx.compose.material3.ButtonDefaults +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color +import androidx.compose.ui.draw.blur +import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview @@ -40,32 +37,22 @@ import com.nextroom.nextroom.presentation.common.compose.NRColor import com.nextroom.nextroom.presentation.common.compose.NRLoading import com.nextroom.nextroom.presentation.common.compose.NRToolbar import com.nextroom.nextroom.presentation.common.compose.NRTypo +import com.nextroom.nextroom.presentation.extension.throttleClick import com.nextroom.nextroom.presentation.extension.toTimerFormat import com.nextroom.nextroom.presentation.model.Hint import com.nextroom.nextroom.presentation.ui.hint.HintState -import kotlinx.coroutines.launch @OptIn(ExperimentalFoundationApi::class) @Composable fun HintScreen( state: HintState, - onAnswerButtonClick: () -> Unit, onHintImageClick: (Int) -> Unit, onAnswerImageClick: (Int) -> Unit, + onHintOpenClick: () -> Unit, + onAnswerOpenClick: () -> Unit, modifier: Modifier = Modifier ) { val listState = rememberLazyListState() - val coroutineScope = rememberCoroutineScope() - var hasScrolledToAnswer by rememberSaveable { mutableStateOf(false) } - - LaunchedEffect(state.hint.answerOpened) { - if (state.hint.answerOpened && !hasScrolledToAnswer) { - coroutineScope.launch { - listState.animateScrollToItem(index = 1) - hasScrolledToAnswer = true - } - } - } Box(modifier = modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize()) { @@ -115,28 +102,82 @@ fun HintScreen( modifier = Modifier.fillMaxWidth() ) - if (state.hint.hintImageUrlList.isNotEmpty()) { - ImagePager( - imageUrls = state.hint.hintImageUrlList, - subscribeStatus = state.userSubscribeStatus, - networkDisconnectedCount = state.networkDisconnectedCount, - onImageClick = onHintImageClick, - modifier = Modifier.padding(top = 12.dp) - ) - } - - Text( - text = state.hint.hint, - style = NRTypo.Pretendard.size20, - color = NRColor.Gray01, + Box( modifier = Modifier .fillMaxWidth() + .wrapContentHeight() + .heightIn(min = if (state.isHintOpened) 0.dp else 200.dp) .padding(top = 12.dp) - ) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .blur(if (state.isHintOpened) 0.dp else 10.dp) + ) { + if (state.hint.hintImageUrlList.isNotEmpty() && state.isHintOpened) { + ImagePager( + imageUrls = state.hint.hintImageUrlList, + subscribeStatus = state.userSubscribeStatus, + networkDisconnectedCount = state.networkDisconnectedCount, + onImageClick = onHintImageClick, + modifier = Modifier.padding(bottom = 20.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) + .throttleClick { onHintOpenClick() } + ) + 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.hint.answerOpened) { + if (state.isHintOpened) { item { Column( modifier = Modifier.fillMaxWidth(), @@ -154,73 +195,90 @@ fun HintScreen( modifier = Modifier.fillMaxWidth() ) - if (state.hint.answerImageUrlList.isNotEmpty()) { - ImagePager( - imageUrls = state.hint.answerImageUrlList, - subscribeStatus = state.userSubscribeStatus, - networkDisconnectedCount = state.networkDisconnectedCount, - onImageClick = onAnswerImageClick, - modifier = Modifier.padding(top = 12.dp) - ) - } - - Text( - text = state.hint.answer, - style = NRTypo.Pretendard.size20, - color = NRColor.Gray01, + 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) + ) { + if (state.hint.answerImageUrlList.isNotEmpty() && state.isAnswerOpened) { + ImagePager( + imageUrls = state.hint.answerImageUrlList, + subscribeStatus = state.userSubscribeStatus, + networkDisconnectedCount = state.networkDisconnectedCount, + onImageClick = onAnswerImageClick, + modifier = Modifier.padding(bottom = 20.dp) + ) + } - Spacer(modifier = Modifier.height(200.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) + .throttleClick { onAnswerOpenClick() } + ) + 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(200.dp)) + Spacer(modifier = Modifier.height(80.dp)) } } } } - Box( - modifier = Modifier - .align(Alignment.BottomCenter) - .fillMaxWidth() - .height(200.dp) - .background( - Brush.verticalGradient( - colors = listOf( - Color.Transparent, - NRColor.Black - ) - ) - ), - contentAlignment = Alignment.BottomCenter - ) { - Button( - onClick = onAnswerButtonClick, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 20.dp, vertical = 40.dp) - .height(56.dp), - colors = ButtonDefaults.buttonColors( - containerColor = NRColor.White - ) - ) { - Text( - text = if (state.hint.answerOpened) { - stringResource(R.string.game_hint_button_goto_home) - } else { - stringResource(R.string.game_hint_button_show_answer) - }, - color = NRColor.Black, - style = NRTypo.Pretendard.size16Bold - ) - } - } - NRLoading(isVisible = state.loading) } } @@ -236,16 +294,17 @@ private fun HintScreenWithNoImagesPreview() { progress = 45, hint = "이 힌트는 미로 출구를 찾는 데 도움이 됩니다. 벽의 패턴을 주의 깊게 살펴보세요.", answer = "미로의 출구는 북쪽 벽의 세 번째 문입니다. 빨간색 표시를 따라가세요.", - answerOpened = false, hintImageUrlList = emptyList(), answerImageUrlList = emptyList() ), userSubscribeStatus = SubscribeStatus.Subscribed, - networkDisconnectedCount = 0 + networkDisconnectedCount = 0, + isHintOpened = true ), - onAnswerButtonClick = {}, onHintImageClick = {}, - onAnswerImageClick = {} + onAnswerImageClick = {}, + onHintOpenClick = {}, + onAnswerOpenClick = {} ) } @@ -260,7 +319,6 @@ private fun HintScreenWithImagesPreview() { progress = 45, hint = "이 힌트는 미로 출구를 찾는 데 도움이 됩니다. 벽의 패턴을 주의 깊게 살펴보세요.", answer = "미로의 출구는 북쪽 벽의 세 번째 문입니다. 빨간색 표시를 따라가세요.", - answerOpened = false, hintImageUrlList = listOf( "https://example.com/hint1.jpg", "https://example.com/hint2.jpg" @@ -268,11 +326,13 @@ private fun HintScreenWithImagesPreview() { answerImageUrlList = emptyList() ), userSubscribeStatus = SubscribeStatus.Subscribed, - networkDisconnectedCount = 0 + networkDisconnectedCount = 0, + isHintOpened = false ), - onAnswerButtonClick = {}, onHintImageClick = {}, - onAnswerImageClick = {} + onAnswerImageClick = {}, + onHintOpenClick = {}, + onAnswerOpenClick = {} ) } @@ -287,16 +347,18 @@ private fun HintScreenAnswerOpenedPreview() { progress = 85, hint = "이 힌트는 미로 출구를 찾는 데 도움이 됩니다. 벽의 패턴을 주의 깊게 살펴보세요.", answer = "미로의 출구는 북쪽 벽의 세 번째 문입니다. 빨간색 표시를 따라가세요.", - answerOpened = true, hintImageUrlList = emptyList(), answerImageUrlList = emptyList(), ), userSubscribeStatus = SubscribeStatus.Subscribed, - networkDisconnectedCount = 0 + networkDisconnectedCount = 0, + isHintOpened = true, + isAnswerOpened = true ), - onAnswerButtonClick = {}, onHintImageClick = {}, - onAnswerImageClick = {} + onAnswerImageClick = {}, + onHintOpenClick = {}, + onAnswerOpenClick = {} ) } diff --git a/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/main/GameSharedViewModel.kt b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/main/GameSharedViewModel.kt index d7ba213b..f52e58ab 100644 --- a/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/main/GameSharedViewModel.kt +++ b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/main/GameSharedViewModel.kt @@ -2,28 +2,60 @@ package com.nextroom.nextroom.presentation.ui.main import androidx.lifecycle.SavedStateHandle import com.nextroom.nextroom.domain.model.SubscribeStatus +import com.nextroom.nextroom.domain.repository.GameStateRepository import com.nextroom.nextroom.presentation.base.NewBaseViewModel import com.nextroom.nextroom.presentation.model.Hint import dagger.hilt.android.lifecycle.HiltViewModel 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 GameSharedViewModel @Inject constructor( - savedStateHandle: SavedStateHandle + savedStateHandle: SavedStateHandle, + private val gameStateRepository: GameStateRepository ) : NewBaseViewModel() { - private val _subscribeStatus = MutableStateFlow( - TimerFragmentArgs.fromSavedStateHandle(savedStateHandle).subscribeStatus + private val _state = MutableStateFlow( + GameSharedState( + subscribeStatus = TimerFragmentArgs.fromSavedStateHandle(savedStateHandle).subscribeStatus + ) ) - val subscribeStatus: StateFlow = _subscribeStatus.asStateFlow() - - private val _currentHint = MutableStateFlow(null) - val currentHint: StateFlow = _currentHint.asStateFlow() + val state: StateFlow = _state.asStateFlow() fun setCurrentHint(hint: Hint) { - _currentHint.value = hint + _state.update { it.copy(currentHint = hint) } + } + + fun setTotalHintCount(count: Int) { + _state.update { it.copy(totalHintCount = count) } } -} \ No newline at end of file + + fun addOpenedHintId(hintId: Int) { + _state.update { it.copy(openedHintIds = it.openedHintIds + hintId) } + baseViewModelScope.launch { + gameStateRepository.updateUsedHints(_state.value.openedHintIds) + } + } + + fun updateOpenedHintIds(hintIds: Set) { + _state.update { it.copy(openedHintIds = hintIds) } + } + + fun getOpenedHintCount() = state.value.openedHintIds.size + + fun addOpenedAnswerId(hintId: Int) { + _state.update { it.copy(openedAnswerIds = it.openedAnswerIds + hintId) } + } +} + +data class GameSharedState( + val subscribeStatus: SubscribeStatus, + val currentHint: Hint? = null, + val openedHintIds: Set = emptySet(), + val openedAnswerIds: Set = emptySet(), + val totalHintCount: Int = -1 +) \ No newline at end of file diff --git a/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/main/TimerEvent.kt b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/main/TimerEvent.kt index 47d8e382..8137413c 100644 --- a/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/main/TimerEvent.kt +++ b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/main/TimerEvent.kt @@ -10,6 +10,5 @@ sealed interface TimerEvent { data object TimerFinish : TimerEvent data object ClearHintCode : TimerEvent - data object ShowAvailableHintExceedError : TimerEvent data object NewTimer : TimerEvent } 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 1f047e2c..1d417dad 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 @@ -11,7 +11,6 @@ import androidx.activity.OnBackPressedCallback import androidx.core.view.isVisible import androidx.fragment.app.activityViewModels import androidx.fragment.app.setFragmentResultListener -import androidx.fragment.app.viewModels import androidx.hilt.navigation.fragment.hiltNavGraphViewModels import androidx.navigation.fragment.findNavController import com.bumptech.glide.Glide @@ -27,10 +26,12 @@ import com.nextroom.nextroom.presentation.common.NRDialog import com.nextroom.nextroom.presentation.common.NROneButtonDialog import com.nextroom.nextroom.presentation.common.NRTwoButtonDialog import com.nextroom.nextroom.presentation.databinding.FragmentTimerBinding +import com.nextroom.nextroom.presentation.extension.assistedViewModel import com.nextroom.nextroom.presentation.extension.disableFullScreen import com.nextroom.nextroom.presentation.extension.enableFullScreen import com.nextroom.nextroom.presentation.extension.getResultData import com.nextroom.nextroom.presentation.extension.hasResultData +import com.nextroom.nextroom.presentation.extension.repeatOnStarted import com.nextroom.nextroom.presentation.extension.safeNavigate import com.nextroom.nextroom.presentation.extension.setOnLongClickListener import com.nextroom.nextroom.presentation.extension.snackbar @@ -42,14 +43,20 @@ import com.nextroom.nextroom.presentation.ui.main.ModifyTimeBottomSheet.Companio import com.nextroom.nextroom.presentation.ui.memo.PainterViewModel import com.nextroom.nextroom.presentation.util.Logger import dagger.hilt.android.AndroidEntryPoint -import org.orbitmvi.orbit.viewmodel.observe +import kotlinx.coroutines.launch import java.util.Locale +import javax.inject.Inject @AndroidEntryPoint class TimerFragment : BaseFragment(FragmentTimerBinding::inflate) { private lateinit var backCallback: OnBackPressedCallback - private val viewModel: TimerViewModel by viewModels() + @Inject + lateinit var viewModelFactory: TimerViewModel.Factory + + private val viewModel: TimerViewModel by assistedViewModel { + viewModelFactory.create(gameSharedViewModel) + } private val painterViewModel: PainterViewModel by activityViewModels() private val gameSharedViewModel: GameSharedViewModel by hiltNavGraphViewModels(R.id.game_navigation) @@ -72,11 +79,22 @@ class TimerFragment : BaseFragment(FragmentTimerBinding::i initViews() initListener() setFragmentResultListeners() - viewModel.observe(viewLifecycleOwner, state = ::render, sideEffect = ::handleEvent) + initObserve() enableFullScreen() setGameStartConfirmDialog() } + private fun initObserve() { + viewLifecycleOwner.repeatOnStarted { + launch { + viewModel.uiState.collect(::render) + } + launch { + viewModel.container.sideEffectFlow.collect(::handleEvent) + } + } + } + private fun setGameStartConfirmDialog() { gameStartConfirmDialog = NRDialog.Builder(requireContext()) .setTitle(R.string.dialog_title_game_start_confirm) @@ -238,7 +256,7 @@ class TimerFragment : BaseFragment(FragmentTimerBinding::i tvHintCount.text = String.format( Locale.getDefault(), "%d/%s", - state.usedHintsCount, + state.openedHintCount, if (state.totalHintCount == -1) "∞" else state.totalHintCount.toString(), ) @@ -276,7 +294,6 @@ class TimerFragment : BaseFragment(FragmentTimerBinding::i is TimerEvent.TimerFinish -> snackbar(R.string.game_finished) - TimerEvent.ShowAvailableHintExceedError -> snackbar(message = getString(R.string.game_hint_limit_exceed)) TimerEvent.NewTimer -> { gameStartConfirmDialog?.show(parentFragmentManager, "GameStartConfirmDialog") } diff --git a/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/main/TimerScreenState.kt b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/main/TimerScreenState.kt index 618fb91f..38014a4e 100644 --- a/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/main/TimerScreenState.kt +++ b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/main/TimerScreenState.kt @@ -8,14 +8,10 @@ data class TimerScreenState( val lastSeconds: Int = 0, val currentInput: String = "", val inputState: InputState = InputState.Empty, - val usedHints: Set = emptySet(), - val answerOpenedHints: Set = emptySet(), + val openedHintCount: Int = 0, val totalHintCount: Int = -1, val startTime: Long = -1, val themeImageUrl: String? = null, val themeImageCustomInfo: ThemeImageCustomInfo? = null, val themeImageEnabled: Boolean = false -) { - val usedHintsCount: Int - get() = usedHints.size -} +) diff --git a/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/main/TimerViewModel.kt b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/main/TimerViewModel.kt index de3e5907..48ad8c20 100644 --- a/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/main/TimerViewModel.kt +++ b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/main/TimerViewModel.kt @@ -1,23 +1,26 @@ package com.nextroom.nextroom.presentation.ui.main -import androidx.lifecycle.SavedStateHandle import com.nextroom.nextroom.domain.model.GameState import com.nextroom.nextroom.domain.model.ThemeImageCustomInfo import com.nextroom.nextroom.domain.model.ThemeInfo import com.nextroom.nextroom.domain.model.TimerState import com.nextroom.nextroom.domain.repository.GameStateRepository import com.nextroom.nextroom.domain.repository.HintRepository -import com.nextroom.nextroom.domain.repository.StatisticsRepository import com.nextroom.nextroom.domain.repository.ThemeRepository import com.nextroom.nextroom.domain.repository.TimerRepository import com.nextroom.nextroom.presentation.R import com.nextroom.nextroom.presentation.base.BaseViewModel import com.nextroom.nextroom.presentation.model.Hint import com.nextroom.nextroom.presentation.model.InputState -import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import org.orbitmvi.orbit.Container import org.orbitmvi.orbit.syntax.simple.intent @@ -25,20 +28,28 @@ import org.orbitmvi.orbit.syntax.simple.postSideEffect import org.orbitmvi.orbit.syntax.simple.reduce import org.orbitmvi.orbit.viewmodel.container import timber.log.Timber -import javax.inject.Inject -@HiltViewModel -class TimerViewModel @Inject constructor( - private val savedStateHandle: SavedStateHandle, +class TimerViewModel @AssistedInject constructor( private val themeRepository: ThemeRepository, private val timerRepository: TimerRepository, private val gameStateRepository: GameStateRepository, private val hintRepository: HintRepository, - private val statsRepository: StatisticsRepository, + @Assisted private val gameSharedViewModel: GameSharedViewModel ) : BaseViewModel() { override val container: Container = container(TimerScreenState()) + val uiState = combine( + container.stateFlow, + gameSharedViewModel.state + ) { state, gameSharedState -> + state.copy(openedHintCount = gameSharedState.openedHintIds.size) + }.stateIn( + baseViewModelScope, + SharingStarted.Lazily, + container.stateFlow.value + ) + init { baseViewModelScope.launch { timerRepository.lastSeconds.collect(::tick) @@ -193,10 +204,7 @@ class TimerViewModel @Inject constructor( private fun validateHintCode() = intent { suspend fun openHint(hint: com.nextroom.nextroom.domain.model.Hint) { reduce { - state.copy( - usedHints = state.usedHints + hint.id, - inputState = InputState.Ok, - ) + state.copy(inputState = InputState.Ok, openedHintCount = gameSharedViewModel.getOpenedHintCount()) } postSideEffect( TimerEvent.OnOpenHint( @@ -205,13 +213,11 @@ class TimerViewModel @Inject constructor( progress = hint.progress, hint = hint.description, answer = hint.answer, - answerOpened = state.answerOpenedHints.contains(hint.id), hintImageUrlList = hint.hintImageUrlList.toList(), answerImageUrlList = hint.answerImageUrlList.toList() ) ), ) - setGameState() } if (timerRepository.timerState.value is TimerState.Finished) { @@ -222,14 +228,7 @@ class TimerViewModel @Inject constructor( } hintRepository.getHint(state.currentInput)?.let { hint -> - if (state.usedHints.contains(hint.id)) { - openHint(hint) - } else if (state.usedHintsCount < state.totalHintCount) { - openHint(hint) - } else { - postSideEffect(TimerEvent.ShowAvailableHintExceedError) - reduce { state.copy(inputState = InputState.Typing, currentInput = "") } - } + openHint(hint) } ?: run { reduce { state.copy(inputState = InputState.Error(R.string.game_wrong_hint_code)) } delay(500) @@ -237,20 +236,6 @@ class TimerViewModel @Inject constructor( } } - private fun setGameState() = intent { - if (timerRepository.timerState.value !is TimerState.Finished) { - gameStateRepository.saveGameState( - timeLimitInMinute = state.totalSeconds / 60, - hintLimit = state.totalHintCount, - usedHints = state.usedHints, - startTime = state.startTime, - useTimerUrl = state.themeImageEnabled, - themeImageUrl = state.themeImageUrl, - themeImageCustomInfo = state.themeImageCustomInfo - ) - } - } - private fun startGame(endTimeMillis: Long) = intent { timerRepository.startTimerUntil(endTimeMillis) } @@ -275,12 +260,14 @@ class TimerViewModel @Inject constructor( themeImageCustomInfo: ThemeImageCustomInfo? = null, themeImageEnabled: Boolean, ) = intent { + gameSharedViewModel.updateOpenedHintIds(usedHints) + gameSharedViewModel.setTotalHintCount(hintLimit) reduce { state.copy( totalSeconds = seconds, totalHintCount = hintLimit, - usedHints = usedHints, lastSeconds = lastSeconds, + openedHintCount = usedHints.size, startTime = startTime, themeImageUrl = themeImageUrl, themeImageCustomInfo = themeImageCustomInfo, @@ -298,4 +285,9 @@ class TimerViewModel @Inject constructor( super.onCleared() Timber.d("onCleared: GameViewModel") } + + @AssistedFactory + interface Factory { + fun create(gameSharedViewModel: GameSharedViewModel): TimerViewModel + } } diff --git a/presentation/src/main/res/drawable/ic_lock.webp b/presentation/src/main/res/drawable/ic_lock.webp new file mode 100644 index 00000000..5a027009 Binary files /dev/null and b/presentation/src/main/res/drawable/ic_lock.webp differ diff --git a/presentation/src/main/res/values/strings.xml b/presentation/src/main/res/values/strings.xml index a0ce21fd..b60f96b4 100644 --- a/presentation/src/main/res/values/strings.xml +++ b/presentation/src/main/res/values/strings.xml @@ -83,6 +83,8 @@ 종료하시면 모든 기록은 초기화됩니다. 게임이 종료되었습니다 힌트 보기 + 정답 보기 + 정답을 보려면 클릭하세요! 힌트 코드를 잘못 입력했습니다 사용 가능한 힌트 개수를 초과했습니다. Hint Code @@ -230,4 +232,5 @@ 시간 수정 에러 코드: %d + 힌트를 보려면 클릭하세요! \ No newline at end of file