diff --git a/app/src/main/kotlin/com/gamss/android/app/main/MainScreen.kt b/app/src/main/kotlin/com/gamss/android/app/main/MainScreen.kt index a9d9836a..aa4f5291 100644 --- a/app/src/main/kotlin/com/gamss/android/app/main/MainScreen.kt +++ b/app/src/main/kotlin/com/gamss/android/app/main/MainScreen.kt @@ -129,6 +129,7 @@ private fun mainEntryProvider(navigator: Navigator) = entryProvider { HomeScreen( onNavigateToSetting = { navigator.navigate(SettingKey) }, onOpenConversation = { conversationId -> navigator.navigate(ChatRoomKey(conversationId)) }, + isActive = navigator.state.currentKey == HomeKey, ) } entry { diff --git a/feature/home/src/main/java/com/gamss/android/feature/home/HomeScreen.kt b/feature/home/src/main/java/com/gamss/android/feature/home/HomeScreen.kt index bc9d57e9..fd11108e 100644 --- a/feature/home/src/main/java/com/gamss/android/feature/home/HomeScreen.kt +++ b/feature/home/src/main/java/com/gamss/android/feature/home/HomeScreen.kt @@ -22,6 +22,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.remember @@ -64,16 +65,26 @@ import kotlin.math.roundToInt fun HomeScreen( onNavigateToSetting: () -> Unit, onOpenConversation: (Long) -> Unit, + isActive: Boolean = true, modifier: Modifier = Modifier, viewModel: HomeViewModel = hiltViewModel(), ) { val state by viewModel.collectAsState() val context = LocalContext.current + // 닉네임 변경 화면에서 저장하고 돌아왔을 때 최신 정보를 다시 불러온다. + LaunchedEffect(Unit) { viewModel.loadUserInfo() } + + LaunchedEffect(isActive) { + if (!isActive) return@LaunchedEffect + viewModel.openConversationEvents.collect { conversationId -> + onOpenConversation(conversationId) + } + } + viewModel.collectSideEffect { sideEffect -> when (sideEffect) { is HomeSideEffect.NavigateToSetting -> onNavigateToSetting() - is HomeSideEffect.OpenConversation -> onOpenConversation(sideEffect.conversationId) is HomeSideEffect.ShowToast -> Toast.makeText(context, sideEffect.message, Toast.LENGTH_SHORT).show() } diff --git a/feature/home/src/main/java/com/gamss/android/feature/home/HomeSideEffect.kt b/feature/home/src/main/java/com/gamss/android/feature/home/HomeSideEffect.kt index 7483b02c..e5b4faf5 100644 --- a/feature/home/src/main/java/com/gamss/android/feature/home/HomeSideEffect.kt +++ b/feature/home/src/main/java/com/gamss/android/feature/home/HomeSideEffect.kt @@ -3,8 +3,5 @@ package com.gamss.android.feature.home sealed interface HomeSideEffect { data object NavigateToSetting : HomeSideEffect - /** 대화는 이미 만들어졌다. 대화방은 이 id 로 조회만 한다. */ - data class OpenConversation(val conversationId: Long) : HomeSideEffect - data class ShowToast(val message: String) : HomeSideEffect } diff --git a/feature/home/src/main/java/com/gamss/android/feature/home/HomeViewModel.kt b/feature/home/src/main/java/com/gamss/android/feature/home/HomeViewModel.kt index 39504024..a606ead0 100644 --- a/feature/home/src/main/java/com/gamss/android/feature/home/HomeViewModel.kt +++ b/feature/home/src/main/java/com/gamss/android/feature/home/HomeViewModel.kt @@ -9,10 +9,11 @@ import com.gamss.android.domain.conversation.takeWithinMessageLimit import com.gamss.android.domain.emotion.EmotionCharacter import com.gamss.android.domain.user.GetUserInfoUseCase import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.launch import org.orbitmvi.orbit.ContainerHost import org.orbitmvi.orbit.blockingIntent -import org.orbitmvi.orbit.syntax.Syntax import org.orbitmvi.orbit.viewmodel.container import javax.inject.Inject @@ -26,8 +27,18 @@ class HomeViewModel @Inject constructor( private val session: ConversationSession, ) : ViewModel(), ContainerHost { - // init 대신 onCreate 를 쓴다. 구독 시점에 한 번 돌고, 테스트에서 실행 시점을 잡을 수 있다. - override val container = container(HomeState()) { loadUserInfo() } + private val _openConversationEvents = MutableSharedFlow(replay = 0) + val openConversationEvents = _openConversationEvents.asSharedFlow() + + override val container = container(HomeState()) + + fun loadUserInfo() = intent { + // 실패해도 기존 닉네임은 지우지 않는다. 갱신 시도가 화면에 이미 보이던 값을 날리면 안 된다. + when (val result = getUserInfoUseCase()) { + is AppResult.Success -> reduce { state.copy(isLoading = false, nickname = result.data.nickname) } + is AppResult.Failure -> reduce { state.copy(isLoading = false) } + } + } fun navigateToSetting() = intent { postSideEffect(HomeSideEffect.NavigateToSetting) @@ -96,16 +107,10 @@ class HomeViewModel @Inject constructor( // applicationScope 로 돌려서 이 화면을 벗어나도 끊기지 않는다. viewModelScope.launch { session.finishSend() } reduce { state.copy(input = "") } - postSideEffect(HomeSideEffect.OpenConversation(result.data.message.conversationId)) + _openConversationEvents.emit(result.data.message.conversationId) } // 입력은 남겨 둔다. 실패한 문구를 다시 치게 하면 안 된다. is AppResult.Failure -> postSideEffect(HomeSideEffect.ShowToast(SEND_FAILED)) } } - - private suspend fun Syntax.loadUserInfo() { - // 닉네임을 못 받아도 화면은 성립한다. 세션이 끊긴 경우는 AuthRepository 가 로그인으로 되돌린다. - val nickname = (getUserInfoUseCase() as? AppResult.Success)?.data?.nickname - reduce { state.copy(isLoading = false, nickname = nickname) } - } } diff --git a/feature/home/src/test/java/com/gamss/android/feature/home/HomeViewModelTest.kt b/feature/home/src/test/java/com/gamss/android/feature/home/HomeViewModelTest.kt index e8ef53dd..0226134c 100644 --- a/feature/home/src/test/java/com/gamss/android/feature/home/HomeViewModelTest.kt +++ b/feature/home/src/test/java/com/gamss/android/feature/home/HomeViewModelTest.kt @@ -9,8 +9,12 @@ import com.gamss.android.domain.user.UserProfile import io.mockk.coEvery import io.mockk.mockk import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.withTimeoutOrNull import org.junit.Assert.assertEquals import org.junit.Assert.assertNull import org.junit.Test @@ -30,7 +34,7 @@ class HomeViewModelTest { givenUserInfo(nickname = "이소연") viewModel().test(this) { - runOnCreate() + containerHost.loadUserInfo() expectState { copy(isLoading = false, nickname = "이소연") } } } @@ -40,14 +44,48 @@ class HomeViewModelTest { coEvery { getUserInfoUseCase() } returns AppResult.Failure(IllegalStateException("boom")) viewModel().test(this) { - runOnCreate() + containerHost.loadUserInfo() expectState { copy(isLoading = false, nickname = null) } } } + // 닉네임 변경 화면에서 돌아오면 HomeScreen이 loadUserInfo를 다시 호출한다. 그 경로를 흉내낸다. @Test - fun `걱정을 적고 보내면 대화를 만들고 그 방을 연다`() = runTest { + fun `닉네임 변경 후 돌아와 다시 불러오면 바뀐 값으로 갱신된다`() = runTest { + givenUserInfo(nickname = "이소연") + viewModel().test(this) { + containerHost.loadUserInfo() + expectState { copy(isLoading = false, nickname = "이소연") } + + givenUserInfo(nickname = "소연이") + containerHost.loadUserInfo() + expectState { copy(nickname = "소연이") } + } + } + + @Test + fun `다시 불러오다 실패해도 이미 보이던 닉네임은 남는다`() = runTest { + givenUserInfo(nickname = "이소연") + + viewModel().test(this) { + containerHost.loadUserInfo() + expectState { copy(isLoading = false, nickname = "이소연") } + + coEvery { getUserInfoUseCase() } returns AppResult.Failure(IllegalStateException("boom")) + containerHost.loadUserInfo() + expectNoItems() + } + } + + @Test + fun `걱정을 적고 보내면 대화를 만들고 그 방을 연다`() = runTest { + val homeViewModel = viewModel() + val openConversation = async(start = CoroutineStart.UNDISPATCHED) { + homeViewModel.openConversationEvents.first() + } + + homeViewModel.test(this) { containerHost.onInputChange(WORRY) expectState { copy(input = WORRY) } @@ -55,8 +93,9 @@ class HomeViewModelTest { expectState { copy(isSending = true) } expectState { copy(isSending = false) } expectState { copy(input = "") } - expectSideEffect(HomeSideEffect.OpenConversation(NEW_ROOM_ID)) + expectNoItems() } + assertEquals(NEW_ROOM_ID, openConversation.await()) assertEquals(WORRY, repository.sentContent) assertNull(repository.sentConversationId) } @@ -88,7 +127,7 @@ class HomeViewModelTest { expectState { copy(isSending = true) } expectState { copy(isSending = false) } expectState { copy(input = "") } - expectSideEffect(HomeSideEffect.OpenConversation(NEW_ROOM_ID)) + expectNoItems() } assertEquals(setOf(EmotionCharacter.SADNESS), repository.sentExcludeCharacters) } @@ -147,7 +186,7 @@ class HomeViewModelTest { expectState { copy(isSending = true, isEmotionPickerExpanded = false) } expectState { copy(isSending = false) } expectState { copy(input = "") } - expectSideEffect(HomeSideEffect.OpenConversation(NEW_ROOM_ID)) + expectNoItems() } } @@ -203,7 +242,7 @@ class HomeViewModelTest { expectState { copy(isSending = true) } expectState { copy(isSending = false) } expectState { copy(input = "") } - expectSideEffect(HomeSideEffect.OpenConversation(NEW_ROOM_ID)) + expectNoItems() containerHost.onInputChange(filled + "나") expectState { copy(input = filled) } @@ -232,7 +271,7 @@ class HomeViewModelTest { expectState { copy(isSending = true) } expectState { copy(isSending = false) } expectState { copy(input = "") } - expectSideEffect(HomeSideEffect.OpenConversation(NEW_ROOM_ID)) + expectNoItems() containerHost.onSubmit() expectNoItems() @@ -248,7 +287,7 @@ class HomeViewModelTest { expectState { copy(isSending = true) } expectState { copy(isSending = false) } expectState { copy(input = "") } - expectSideEffect(HomeSideEffect.OpenConversation(NEW_ROOM_ID)) + expectNoItems() containerHost.onInputChange(SECOND_WORRY) expectState { copy(input = SECOND_WORRY) } @@ -256,7 +295,7 @@ class HomeViewModelTest { expectState { copy(isSending = true) } expectState { copy(isSending = false) } expectState { copy(input = "") } - expectSideEffect(HomeSideEffect.OpenConversation(NEW_ROOM_ID)) + expectNoItems() } assertEquals(listOf(null, null), repository.sentContextSummaries) } @@ -277,11 +316,58 @@ class HomeViewModelTest { gate.complete(Unit) expectState { copy(isSending = false) } expectState { copy(input = "") } - expectSideEffect(HomeSideEffect.OpenConversation(NEW_ROOM_ID)) + expectNoItems() } assertEquals(1, gated.sendCount) } + @Test + fun `대화 생성이 완료되면 이동 이벤트를 발행한다`() = runTest { + val homeViewModel = viewModel() + val openConversation = async(start = CoroutineStart.UNDISPATCHED) { + homeViewModel.openConversationEvents.first() + } + + homeViewModel.test(this) { + containerHost.onInputChange(WORRY) + expectState { copy(input = WORRY) } + + containerHost.onSubmit() + expectState { copy(isSending = true) } + expectState { copy(isSending = false) } + expectState { copy(input = "") } + expectNoItems() + } + + assertEquals(NEW_ROOM_ID, openConversation.await()) + } + + @Test + fun `이동 이벤트를 수집하는 화면이 없으면 대화방 이동이 재생되지 않는다`() = runTest { + val gate = CompletableDeferred() + val gated = RecordingConversationRepository(gate = gate) + val homeViewModel = viewModel(gated) + + homeViewModel.test(this) { + containerHost.onInputChange(WORRY) + expectState { copy(input = WORRY) } + + containerHost.onSubmit() + expectState { copy(isSending = true) } + expectNoItems() + + gate.complete(Unit) + expectState { copy(isSending = false) } + expectState { copy(input = "") } + expectNoItems() + } + + val replayedEvent = withTimeoutOrNull(1) { + homeViewModel.openConversationEvents.first() + } + assertNull(replayedEvent) + } + @Test fun `설정 아이콘을 누르면 설정으로 이동한다`() = runTest { viewModel().test(this) {