From f9a4eef791a5f7e6c5c5a85bfa9139685a18b8a8 Mon Sep 17 00:00:00 2001 From: unam98 Date: Mon, 29 Jun 2026 08:26:02 +0900 Subject: [PATCH] =?UTF-8?q?GiveNicknameActivity=20Compose=20=EC=A0=84?= =?UTF-8?q?=ED=99=98=20=EB=B0=8F=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../login/GiveNicknameScreenTest.kt | 117 +++++++++ .../login/GiveNickNameViewModel.kt | 6 +- .../login/GiveNicknameActivity.kt | 76 ++---- .../presentation/login/GiveNicknameScreen.kt | 225 ++++++++++++++++++ .../res/layout/activity_give_nickname.xml | 119 --------- .../login/GiveNickNameViewModelTest.kt | 90 +++++++ 6 files changed, 460 insertions(+), 173 deletions(-) create mode 100644 app/src/androidTest/java/com/runnect/runnect/presentation/login/GiveNicknameScreenTest.kt create mode 100644 app/src/main/java/com/runnect/runnect/presentation/login/GiveNicknameScreen.kt delete mode 100644 app/src/main/res/layout/activity_give_nickname.xml create mode 100644 app/src/test/java/com/runnect/runnect/presentation/login/GiveNickNameViewModelTest.kt diff --git a/app/src/androidTest/java/com/runnect/runnect/presentation/login/GiveNicknameScreenTest.kt b/app/src/androidTest/java/com/runnect/runnect/presentation/login/GiveNicknameScreenTest.kt new file mode 100644 index 000000000..3c1c58aec --- /dev/null +++ b/app/src/androidTest/java/com/runnect/runnect/presentation/login/GiveNicknameScreenTest.kt @@ -0,0 +1,117 @@ +package com.runnect.runnect.presentation.login + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextInput +import com.runnect.runnect.presentation.ui.theme.RunnectTheme +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test + +class GiveNicknameScreenTest { + + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun 닉네임_입력_화면_요소가_노출된다() { + composeTestRule.setContent { + RunnectTheme { + GiveNicknameScreen( + state = GiveNicknameUiState(), + onNickNameChange = {}, + onStartClick = {} + ) + } + } + + composeTestRule.onNodeWithText("RUNNECT").assertIsDisplayed() + composeTestRule.onNodeWithText("에서 사용할").assertIsDisplayed() + composeTestRule.onNodeWithText("이름을 입력해주세요").assertIsDisplayed() + composeTestRule.onNodeWithText("닉네임을 입력해주세요").assertIsDisplayed() + composeTestRule.onNodeWithText("시작하기").assertIsDisplayed() + } + + @Test + fun 닉네임이_비어있으면_시작하기_버튼은_비활성화된다() { + composeTestRule.setContent { + RunnectTheme { + GiveNicknameScreen( + state = GiveNicknameUiState.from( + nickName = "", + uiState = com.runnect.runnect.presentation.state.UiState.Empty + ), + onNickNameChange = {}, + onStartClick = {} + ) + } + } + + composeTestRule.onNodeWithTag(GiveNicknameScreenTestTags.START_BUTTON).assertIsNotEnabled() + } + + @Test + fun 닉네임을_입력하면_변경_콜백이_호출된다() { + val inputs = mutableListOf() + + composeTestRule.setContent { + RunnectTheme { + GiveNicknameScreen( + state = GiveNicknameUiState(), + onNickNameChange = { inputs.add(it) }, + onStartClick = {} + ) + } + } + + composeTestRule.onNodeWithTag(GiveNicknameScreenTestTags.NICKNAME_INPUT) + .performTextInput("러너") + + assertEquals("러너", inputs.last()) + } + + @Test + fun 시작하기_버튼을_누르면_콜백이_호출된다() { + var clickedCount = 0 + + composeTestRule.setContent { + RunnectTheme { + GiveNicknameScreen( + state = GiveNicknameUiState.from( + nickName = "러너", + uiState = com.runnect.runnect.presentation.state.UiState.Empty + ), + onNickNameChange = {}, + onStartClick = { clickedCount += 1 } + ) + } + } + + composeTestRule.onNodeWithTag(GiveNicknameScreenTestTags.START_BUTTON).performClick() + + assertEquals(1, clickedCount) + } + + @Test + fun 로딩_상태면_인디케이터가_노출된다() { + composeTestRule.setContent { + RunnectTheme { + GiveNicknameScreen( + state = GiveNicknameUiState.from( + nickName = "러너", + uiState = com.runnect.runnect.presentation.state.UiState.Loading + ), + onNickNameChange = {}, + onStartClick = {} + ) + } + } + + composeTestRule.onNodeWithTag(GiveNicknameScreenTestTags.LOADING_INDICATOR) + .assertIsDisplayed() + } +} diff --git a/app/src/main/java/com/runnect/runnect/presentation/login/GiveNickNameViewModel.kt b/app/src/main/java/com/runnect/runnect/presentation/login/GiveNickNameViewModel.kt index 4bc3915eb..c0a6ab618 100644 --- a/app/src/main/java/com/runnect/runnect/presentation/login/GiveNickNameViewModel.kt +++ b/app/src/main/java/com/runnect/runnect/presentation/login/GiveNickNameViewModel.kt @@ -24,6 +24,10 @@ class GiveNickNameViewModel @Inject constructor( get() = _statusCode private val _statusCode = MutableLiveData() + fun updateNickNameInput(nickName: String) { + this.nickName.value = nickName + } + fun updateNickName() = launchWithHandler { val requestPatchNickName = RequestPatchNickName(nickName.value.toString()) @@ -44,4 +48,4 @@ class GiveNickNameViewModel @Inject constructor( companion object { const val REDUNDANT_NICKNAME_ERROR = 400 } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/runnect/runnect/presentation/login/GiveNicknameActivity.kt b/app/src/main/java/com/runnect/runnect/presentation/login/GiveNicknameActivity.kt index af8aa6f6e..35490c545 100644 --- a/app/src/main/java/com/runnect/runnect/presentation/login/GiveNicknameActivity.kt +++ b/app/src/main/java/com/runnect/runnect/presentation/login/GiveNicknameActivity.kt @@ -1,40 +1,46 @@ package com.runnect.runnect.presentation.login import android.content.Intent -import android.graphics.Rect import android.os.Bundle -import android.view.MotionEvent +import androidx.activity.compose.setContent import androidx.activity.viewModels -import androidx.core.view.isVisible +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState import com.runnect.runnect.R -import com.runnect.runnect.binding.BindingActivity -import com.runnect.runnect.databinding.ActivityGiveNicknameBinding import com.runnect.runnect.presentation.MainActivity import com.runnect.runnect.presentation.state.UiState +import com.runnect.runnect.presentation.ui.theme.RunnectTheme import com.runnect.runnect.util.analytics.Analytics import com.runnect.runnect.util.analytics.EventName import com.runnect.runnect.util.analytics.EventName.Param -import com.runnect.runnect.util.extension.hideKeyboard import com.runnect.runnect.util.extension.showToast import com.runnect.runnect.util.preference.AuthUtil.saveToken import dagger.hilt.android.AndroidEntryPoint @AndroidEntryPoint -class GiveNicknameActivity : - BindingActivity(R.layout.activity_give_nickname) { +class GiveNicknameActivity : AppCompatActivity() { private val viewModel: GiveNickNameViewModel by viewModels() + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - binding.vm = viewModel - binding.lifecycleOwner = this Analytics.logEvent(EventName.VIEW_GIVE_NICKNAME) - addListener() addObserver() - } - private fun addListener() { - binding.tvGiveNicknameFinish.setOnClickListener { - viewModel.updateNickName() + setContent { + val nickName by viewModel.nickName.observeAsState("") + val uiState by viewModel.uiState.observeAsState(UiState.Empty) + + RunnectTheme { + GiveNicknameScreen( + state = GiveNicknameUiState.from( + nickName = nickName, + uiState = uiState + ), + onNickNameChange = viewModel::updateNickNameInput, + onStartClick = viewModel::updateNickName + ) + } } } @@ -46,27 +52,10 @@ class GiveNicknameActivity : } private fun addObserver() { - viewModel.nickName.observe(this) { - with(binding.tvGiveNicknameFinish) { - if (it.isNullOrEmpty()) { - isActivated = false - isClickable = false - } else { - isActivated = true - isClickable = true - } - } - } viewModel.uiState.observe(this) { state -> when (state) { - UiState.Empty -> binding.indeterminateBar.isVisible = false - UiState.Loading -> { - with(binding) { - indeterminateBar.isVisible = true - tvGiveNicknameFinish.isClickable = false - } - } - + UiState.Empty, + UiState.Loading -> Unit UiState.Success -> handleSuccessfulSignup() UiState.Failure -> handleUnSuccessfulSignup() } @@ -80,7 +69,6 @@ class GiveNicknameActivity : ) saveSignTokenInfo() showToast("회원가입 되었습니다") - binding.indeterminateBar.isVisible = false moveToMain() } @@ -93,27 +81,9 @@ class GiveNicknameActivity : } private fun handleUnSuccessfulSignup() { - binding.indeterminateBar.isVisible = false if (viewModel.statusCode.value == 400) { showToast(getString(R.string.my_page_edit_name_redundant_warning)) } - binding.tvGiveNicknameFinish.isClickable = true - } - - //키보드 밖 터치 시, 키보드 내림 - override fun dispatchTouchEvent(ev: MotionEvent?): Boolean { - val focusView = currentFocus - if (focusView != null) { - val rect = Rect() - focusView.getGlobalVisibleRect(rect) - val x = ev!!.x.toInt() - val y = ev.y.toInt() - if (!rect.contains(x, y)) { - hideKeyboard(focusView) - } - } - return super.dispatchTouchEvent(ev) } } - diff --git a/app/src/main/java/com/runnect/runnect/presentation/login/GiveNicknameScreen.kt b/app/src/main/java/com/runnect/runnect/presentation/login/GiveNicknameScreen.kt new file mode 100644 index 000000000..e7e8763bc --- /dev/null +++ b/app/src/main/java/com/runnect/runnect/presentation/login/GiveNicknameScreen.kt @@ -0,0 +1,225 @@ +package com.runnect.runnect.presentation.login + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +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.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.CircularProgressIndicator +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.graphics.SolidColor +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.runnect.runnect.R +import com.runnect.runnect.presentation.state.UiState +import com.runnect.runnect.presentation.ui.theme.G1 +import com.runnect.runnect.presentation.ui.theme.G3 +import com.runnect.runnect.presentation.ui.theme.M1 +import com.runnect.runnect.presentation.ui.theme.RunnectTheme +import com.runnect.runnect.presentation.ui.theme.White + +object GiveNicknameScreenTestTags { + const val NICKNAME_INPUT = "give_nickname_input" + const val START_BUTTON = "give_nickname_start_button" + const val LOADING_INDICATOR = "give_nickname_loading_indicator" +} + +data class GiveNicknameUiState( + val nickName: String = "", + val isLoading: Boolean = false, + val isStartEnabled: Boolean = false, +) { + companion object { + fun from( + nickName: String?, + uiState: UiState, + ) = GiveNicknameUiState( + nickName = nickName.orEmpty(), + isLoading = uiState is UiState.Loading, + isStartEnabled = !nickName.isNullOrBlank() && uiState !is UiState.Loading + ) + } +} + +@Composable +fun GiveNicknameScreen( + state: GiveNicknameUiState, + onNickNameChange: (String) -> Unit, + onStartClick: () -> Unit, +) { + val focusManager = LocalFocusManager.current + + Box( + modifier = Modifier + .fillMaxSize() + .background(White) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = { focusManager.clearFocus() } + ) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 22.dp) + .padding(top = 83.dp) + ) { + GiveNicknameTitle() + Spacer(modifier = Modifier.height(90.dp)) + Image( + painter = painterResource(R.drawable.user_profile_basic), + contentDescription = null, + modifier = Modifier + .size(96.dp) + .align(Alignment.CenterHorizontally) + ) + Spacer(modifier = Modifier.height(48.dp)) + NicknameTextField( + nickName = state.nickName, + onNickNameChange = onNickNameChange, + onDone = { focusManager.clearFocus() } + ) + } + StartButton( + enabled = state.isStartEnabled, + onClick = onStartClick, + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(horizontal = 24.dp) + .padding(bottom = 8.dp) + ) + if (state.isLoading) { + CircularProgressIndicator( + color = G3, + modifier = Modifier + .align(Alignment.Center) + .testTag(GiveNicknameScreenTestTags.LOADING_INDICATOR) + ) + } + } +} + +@Composable +private fun GiveNicknameTitle() { + Column { + Row { + Text( + text = stringResource(R.string.give_nickname_title_1), + style = RunnectTheme.textStyle.bold22, + color = M1 + ) + Text( + text = stringResource(R.string.give_nickname_title_2), + style = RunnectTheme.textStyle.medium15.copy(fontSize = RunnectTheme.textStyle.bold22.fontSize), + color = G1 + ) + } + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(R.string.give_nickname_title_3), + style = RunnectTheme.textStyle.medium15.copy(fontSize = RunnectTheme.textStyle.bold22.fontSize), + color = G1 + ) + } +} + +@Composable +private fun NicknameTextField( + nickName: String, + onNickNameChange: (String) -> Unit, + onDone: () -> Unit, +) { + BasicTextField( + value = nickName, + onValueChange = { nextValue -> + if (nextValue.length <= NICKNAME_MAX_LENGTH) onNickNameChange(nextValue) + }, + modifier = Modifier.testTag(GiveNicknameScreenTestTags.NICKNAME_INPUT), + singleLine = true, + textStyle = RunnectTheme.textStyle.medium15.copy( + color = G1, + textAlign = TextAlign.Center + ), + cursorBrush = SolidColor(M1), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Text, + imeAction = ImeAction.Done + ), + keyboardActions = KeyboardActions(onDone = { onDone() }), + decorationBox = { innerTextField -> + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .fillMaxWidth() + .height(44.dp) + .border( + border = BorderStroke(1.dp, G3), + shape = RoundedCornerShape(10.dp) + ) + ) { + if (nickName.isEmpty()) { + Text( + text = stringResource(R.string.give_nickname_edit_input), + style = RunnectTheme.textStyle.medium15, + color = G3, + textAlign = TextAlign.Center + ) + } + innerTextField() + } + } + ) +} + +@Composable +private fun StartButton( + enabled: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Box( + contentAlignment = Alignment.Center, + modifier = modifier + .testTag(GiveNicknameScreenTestTags.START_BUTTON) + .fillMaxWidth() + .height(40.dp) + .background( + color = if (enabled) M1 else G3, + shape = RoundedCornerShape(10.dp) + ) + .clickable(enabled = enabled, onClick = onClick) + ) { + Text( + text = stringResource(R.string.give_nickname_finish), + style = RunnectTheme.textStyle.semiBold15, + color = White + ) + } +} + +private const val NICKNAME_MAX_LENGTH = 7 diff --git a/app/src/main/res/layout/activity_give_nickname.xml b/app/src/main/res/layout/activity_give_nickname.xml deleted file mode 100644 index a12080b01..000000000 --- a/app/src/main/res/layout/activity_give_nickname.xml +++ /dev/null @@ -1,119 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/test/java/com/runnect/runnect/presentation/login/GiveNickNameViewModelTest.kt b/app/src/test/java/com/runnect/runnect/presentation/login/GiveNickNameViewModelTest.kt new file mode 100644 index 000000000..374c597ac --- /dev/null +++ b/app/src/test/java/com/runnect/runnect/presentation/login/GiveNickNameViewModelTest.kt @@ -0,0 +1,90 @@ +package com.runnect.runnect.presentation.login + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import com.runnect.runnect.data.dto.request.RequestPatchNickName +import com.runnect.runnect.domain.repository.UserRepository +import com.runnect.runnect.presentation.state.UiState +import io.mockk.coEvery +import io.mockk.mockk +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class GiveNickNameViewModelTest { + + @get:Rule + val instantTaskExecutorRule = InstantTaskExecutorRule() + + private val testDispatcher = StandardTestDispatcher() + + private lateinit var userRepository: UserRepository + private lateinit var viewModel: GiveNickNameViewModel + + @Before + fun setUp() { + Dispatchers.setMain(testDispatcher) + userRepository = mockk() + viewModel = GiveNickNameViewModel(userRepository) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `updateNickNameInput은 닉네임 값을 갱신한다`() { + viewModel.updateNickNameInput("러넥트") + + assertEquals("러넥트", viewModel.nickName.value) + } + + @Test + fun `updateNickName 성공 시 Loading 이후 Success 상태로 갱신된다`() = runTest(testDispatcher) { + val request = RequestPatchNickName("러너") + viewModel.updateNickNameInput("러너") + coEvery { userRepository.updateNickName(request) } returns flow { + delay(1) + emit(Result.success(Unit)) + } + + val states = mutableListOf() + viewModel.uiState.observeForever { states.add(it) } + + viewModel.updateNickName() + advanceUntilIdle() + + assertEquals(listOf(UiState.Loading, UiState.Success), states) + } + + @Test + fun `updateNickName 실패 시 statusCode와 Failure 상태로 갱신된다`() = runTest(testDispatcher) { + val request = RequestPatchNickName("중복") + viewModel.updateNickNameInput("중복") + coEvery { userRepository.updateNickName(request) } returns flow { + delay(1) + emit(Result.failure(RuntimeException("duplicated nickname"))) + } + + val states = mutableListOf() + viewModel.uiState.observeForever { states.add(it) } + + viewModel.updateNickName() + advanceUntilIdle() + + assertEquals(listOf(UiState.Loading, UiState.Failure), states) + assertEquals(GiveNickNameViewModel.REDUNDANT_NICKNAME_ERROR, viewModel.statusCode.value) + } +}