From 711855e8b7f5f513dec07e6063e1ba960f22db60 Mon Sep 17 00:00:00 2001 From: unam98 Date: Fri, 26 Jun 2026 23:54:46 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20MyPageEditName=20=ED=99=94=EB=A9=B4?= =?UTF-8?q?=20Compose=20=EB=A7=88=EC=9D=B4=EA=B7=B8=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=EC=85=98=20+=20MVI=20=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MyPageEditNameViewModel: BaseViewModel+LiveData → MviViewModel+StateFlow/Effect - MyPageEditNameScreen.kt: Compose 화면 신규 작성 (XML 레이아웃 제거) - MyPageEditNameActivity: BindingActivity → AppCompatActivity, setContent {} 적용 - MyPageEditNameViewModelTest: Init/UpdateNickname/Submit 성공·실패 4케이스 단위 테스트 작성 --- .../mypage/editname/MyPageEditNameActivity.kt | 143 ++++++---------- .../mypage/editname/MyPageEditNameContract.kt | 20 +++ .../mypage/editname/MyPageEditNameScreen.kt | 156 ++++++++++++++++++ .../editname/MyPageEditNameViewModel.kt | 75 ++++----- .../res/layout/activity_my_page_edit_name.xml | 101 ------------ .../editname/MyPageEditNameViewModelTest.kt | 140 ++++++++++++++++ 6 files changed, 394 insertions(+), 241 deletions(-) create mode 100644 app/src/main/java/com/runnect/runnect/presentation/mypage/editname/MyPageEditNameContract.kt create mode 100644 app/src/main/java/com/runnect/runnect/presentation/mypage/editname/MyPageEditNameScreen.kt delete mode 100644 app/src/main/res/layout/activity_my_page_edit_name.xml create mode 100644 app/src/test/java/com/runnect/runnect/presentation/mypage/editname/MyPageEditNameViewModelTest.kt diff --git a/app/src/main/java/com/runnect/runnect/presentation/mypage/editname/MyPageEditNameActivity.kt b/app/src/main/java/com/runnect/runnect/presentation/mypage/editname/MyPageEditNameActivity.kt index 39619c38e..90e94b797 100644 --- a/app/src/main/java/com/runnect/runnect/presentation/mypage/editname/MyPageEditNameActivity.kt +++ b/app/src/main/java/com/runnect/runnect/presentation/mypage/editname/MyPageEditNameActivity.kt @@ -1,124 +1,81 @@ package com.runnect.runnect.presentation.mypage.editname import android.content.Intent -import android.graphics.Rect import android.os.Bundle -import android.view.KeyEvent -import android.view.MotionEvent -import android.view.inputmethod.EditorInfo -import android.widget.TextView +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge import androidx.activity.viewModels -import androidx.core.view.isVisible +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle import com.runnect.runnect.R -import com.runnect.runnect.binding.BindingActivity -import com.runnect.runnect.databinding.ActivityMyPageEditNameBinding -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 dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.launch @AndroidEntryPoint -class MyPageEditNameActivity : - BindingActivity(R.layout.activity_my_page_edit_name) { +class MyPageEditNameActivity : AppCompatActivity() { private val viewModel: MyPageEditNameViewModel by viewModels() + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - binding.vm = viewModel - binding.lifecycleOwner = this + enableEdgeToEdge() Analytics.logEvent(EventName.VIEW_EDIT_PROFILE) - initLayout() - addListener() - addObserver() - } - private fun initLayout() { - val nickName = intent.getStringExtra(EXTRA_NICK_NAME) + val nickname = intent.getStringExtra(EXTRA_NICK_NAME) ?: "" val profileImgResId = intent.getIntExtra(EXTRA_PROFILE, R.drawable.user_profile_basic) - viewModel.setNickName(nickName = nickName!!) - viewModel.setProfileImg(profileImgResId = profileImgResId) - } - - private fun addListener() { - binding.ivMyPageEditNameBack.setOnClickListener { - setResult(RESULT_CANCELED) - finish() - } + viewModel.intent(EditNameIntent.Init(nickname, profileImgResId)) - binding.tvMyPageEditNameFinish.setOnClickListener { - viewModel.updateNickName() - } - - binding.etMyPageEditName.setOnEditorActionListener(object : - TextView.OnEditorActionListener { - override fun onEditorAction(v: TextView?, actionId: Int, event: KeyEvent?): Boolean { - if (actionId == EditorInfo.IME_ACTION_DONE) { - hideKeyboard(binding.etMyPageEditName) - return true - } - return false + setContent { + RunnectTheme { + val state by viewModel.state.collectAsState() + MyPageEditNameScreen( + state = state, + onBackClick = { + setResult(RESULT_CANCELED) + finish() + }, + onNicknameChange = { viewModel.intent(EditNameIntent.UpdateNickname(it)) }, + onSubmitClick = { viewModel.intent(EditNameIntent.Submit) }, + ) } - }) - } - - private fun addObserver() { - viewModel.uiState.observe(this) { - when (it) { - UiState.Empty -> binding.indeterminateBar.isVisible = false - UiState.Loading -> binding.indeterminateBar.isVisible = true - UiState.Success -> { - binding.indeterminateBar.isVisible = false - Analytics.logEvent( - EventName.ACTION_EDIT_PROFILE_COMPLETE, - Param.CHANGED_FIELDS to "nickname" - ) - setResult( - RESULT_OK, - Intent().putExtra(EXTRA_NICK_NAME, viewModel.nickName.value) - ) - finish() - } + } - UiState.Failure -> { - binding.indeterminateBar.isVisible = false - if (viewModel.statusCode.value == 400) { - showToast(getString(R.string.my_page_edit_name_redundant_warning)) + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.effect.collect { effect -> + when (effect) { + is EditNameEffect.NavigateSuccess -> { + Analytics.logEvent( + EventName.ACTION_EDIT_PROFILE_COMPLETE, + Param.CHANGED_FIELDS to "nickname" + ) + setResult( + RESULT_OK, + Intent().putExtra(EXTRA_NICK_NAME, effect.newNickname) + ) + finish() + } + EditNameEffect.ShowDuplicateError -> { + showToast(getString(R.string.my_page_edit_name_redundant_warning)) + } } } } } - viewModel.nickName.observe(this) { - with(binding.tvMyPageEditNameFinish) { - if (it.isNullOrEmpty()) { - isActivated = false - isClickable = false - } else { - isActivated = true - 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) } + @Deprecated("Use onBackPressedDispatcher") override fun onBackPressed() { - finish() + @Suppress("DEPRECATION") + super.onBackPressed() overridePendingTransition(R.anim.slide_in_left, R.anim.slide_out_right) } @@ -126,4 +83,4 @@ class MyPageEditNameActivity : const val EXTRA_NICK_NAME = "nickname" const val EXTRA_PROFILE = "profile_img" } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/runnect/runnect/presentation/mypage/editname/MyPageEditNameContract.kt b/app/src/main/java/com/runnect/runnect/presentation/mypage/editname/MyPageEditNameContract.kt new file mode 100644 index 000000000..b85efd5d2 --- /dev/null +++ b/app/src/main/java/com/runnect/runnect/presentation/mypage/editname/MyPageEditNameContract.kt @@ -0,0 +1,20 @@ +package com.runnect.runnect.presentation.mypage.editname + +import com.runnect.runnect.R + +data class EditNameUiState( + val nickname: String = "", + val profileImgResId: Int = R.drawable.user_profile_basic, + val isLoading: Boolean = false, +) + +sealed interface EditNameIntent { + data class Init(val nickname: String, val profileImgResId: Int) : EditNameIntent + data class UpdateNickname(val name: String) : EditNameIntent + data object Submit : EditNameIntent +} + +sealed interface EditNameEffect { + data class NavigateSuccess(val newNickname: String) : EditNameEffect + data object ShowDuplicateError : EditNameEffect +} diff --git a/app/src/main/java/com/runnect/runnect/presentation/mypage/editname/MyPageEditNameScreen.kt b/app/src/main/java/com/runnect/runnect/presentation/mypage/editname/MyPageEditNameScreen.kt new file mode 100644 index 000000000..8f8a820be --- /dev/null +++ b/app/src/main/java/com/runnect/runnect/presentation/mypage/editname/MyPageEditNameScreen.kt @@ -0,0 +1,156 @@ +package com.runnect.runnect.presentation.mypage.editname + +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +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.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import coil3.compose.AsyncImage +import com.runnect.runnect.R +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 + +@Composable +fun MyPageEditNameScreen( + state: EditNameUiState, + onBackClick: () -> Unit, + onNicknameChange: (String) -> Unit, + onSubmitClick: () -> Unit, +) { + val focusManager = LocalFocusManager.current + + Box(modifier = Modifier.fillMaxSize()) { + Column(modifier = Modifier.fillMaxSize()) { + EditNameToolbar( + onBackClick = onBackClick, + onSubmitClick = onSubmitClick, + submitEnabled = state.nickname.isNotEmpty(), + ) + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 22.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Spacer(modifier = Modifier.height(147.dp)) + AsyncImage( + model = state.profileImgResId, + contentDescription = null, + modifier = Modifier.size(96.dp), + ) + Spacer(modifier = Modifier.height(48.dp)) + NicknameTextField( + value = state.nickname, + onValueChange = onNicknameChange, + onDone = { focusManager.clearFocus() }, + ) + } + } + + if (state.isLoading) { + CircularProgressIndicator( + color = G3, + modifier = Modifier.align(Alignment.Center), + ) + } + } +} + +@Composable +private fun EditNameToolbar( + onBackClick: () -> Unit, + onSubmitClick: () -> Unit, + submitEnabled: Boolean, +) { + val textStyle = RunnectTheme.textStyle + Row( + modifier = Modifier + .fillMaxWidth() + .height(56.dp) + .padding(horizontal = 15.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Image( + painter = painterResource(R.drawable.all_back_arrow), + contentDescription = null, + modifier = Modifier + .size(24.dp) + .clickable(onClick = onBackClick), + ) + Spacer(modifier = Modifier.width(24.dp)) + Text( + text = stringResource(R.string.my_page_edit_name_title), + style = textStyle.bold17.copy(fontSize = 18.sp), + color = G1, + modifier = Modifier.weight(1f), + ) + Text( + text = stringResource(R.string.my_page_edit_name_finish), + style = textStyle.bold15.copy(fontSize = 16.sp), + color = if (submitEnabled) M1 else G3, + modifier = Modifier + .clickable(enabled = submitEnabled, onClick = onSubmitClick) + .padding(8.dp), + ) + } +} + +@Composable +private fun NicknameTextField( + value: String, + onValueChange: (String) -> Unit, + onDone: () -> Unit, +) { + val textStyle = RunnectTheme.textStyle + OutlinedTextField( + value = value, + onValueChange = { if (it.length <= 7) onValueChange(it) }, + modifier = Modifier + .fillMaxWidth() + .height(44.dp), + textStyle = textStyle.semiBold15.copy(textAlign = TextAlign.Center, color = G1), + placeholder = { + Text( + text = stringResource(R.string.my_page_edit_name_guide), + style = textStyle.semiBold15.copy(textAlign = TextAlign.Center), + color = G3, + modifier = Modifier.fillMaxWidth(), + ) + }, + singleLine = true, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + keyboardActions = KeyboardActions(onDone = { onDone() }), + shape = RoundedCornerShape(8.dp), + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = M1, + unfocusedBorderColor = G3, + cursorColor = M1, + ), + ) +} diff --git a/app/src/main/java/com/runnect/runnect/presentation/mypage/editname/MyPageEditNameViewModel.kt b/app/src/main/java/com/runnect/runnect/presentation/mypage/editname/MyPageEditNameViewModel.kt index aa45685fa..6c18ac2f1 100644 --- a/app/src/main/java/com/runnect/runnect/presentation/mypage/editname/MyPageEditNameViewModel.kt +++ b/app/src/main/java/com/runnect/runnect/presentation/mypage/editname/MyPageEditNameViewModel.kt @@ -1,61 +1,42 @@ package com.runnect.runnect.presentation.mypage.editname -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData -import com.runnect.runnect.R import com.runnect.runnect.data.dto.request.RequestPatchNickName import com.runnect.runnect.domain.repository.UserRepository -import com.runnect.runnect.presentation.base.BaseViewModel -import com.runnect.runnect.presentation.state.UiState -import com.runnect.runnect.util.extension.collectResult +import com.runnect.runnect.presentation.base.MviViewModel import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.flow.onStart import javax.inject.Inject @HiltViewModel class MyPageEditNameViewModel @Inject constructor( private val userRepository: UserRepository -) : BaseViewModel() { - val nickName = MutableLiveData() - - private val _uiState = MutableLiveData() - val uiState: LiveData - get() = _uiState - - val profileImgResId: MutableLiveData = MutableLiveData(R.drawable.user_profile_basic) - - val statusCode: LiveData - get() = _statusCode - private val _statusCode = MutableLiveData() - - fun setNickName(nickName: String) { - this.nickName.value = nickName +) : MviViewModel(EditNameUiState()) { + + override suspend fun handleIntent(intent: EditNameIntent) { + when (intent) { + is EditNameIntent.Init -> reduce { + copy(nickname = intent.nickname, profileImgResId = intent.profileImgResId) + } + is EditNameIntent.UpdateNickname -> reduce { copy(nickname = intent.name) } + is EditNameIntent.Submit -> submitNickname() + } } - fun setProfileImg(profileImgResId: Int) { - this.profileImgResId.value = profileImgResId - } - - fun updateNickName() = launchWithHandler { - val requestPatchNickName = RequestPatchNickName( - nickname = nickName.value.toString() + private fun submitNickname() { + collectFlow( + flow = { + userRepository.updateNickName( + RequestPatchNickName(nickname = currentState.nickname) + ) + }, + onLoading = { reduce { copy(isLoading = true) } }, + onSuccess = { + reduce { copy(isLoading = false) } + postEffect(EditNameEffect.NavigateSuccess(currentState.nickname)) + }, + onFailure = { + reduce { copy(isLoading = false) } + postEffect(EditNameEffect.ShowDuplicateError) + } ) - - userRepository.updateNickName(requestPatchNickName) - .onStart { - _uiState.value = UiState.Loading - }.collectResult( - onSuccess = { - _uiState.value = UiState.Success - }, - onFailure = { - _uiState.value = UiState.Failure - _statusCode.value = REDUNDANT_NICKNAME_ERROR - } - ) - } - - companion object { - const val REDUNDANT_NICKNAME_ERROR = 400 } -} \ No newline at end of file +} diff --git a/app/src/main/res/layout/activity_my_page_edit_name.xml b/app/src/main/res/layout/activity_my_page_edit_name.xml deleted file mode 100644 index 62b47c900..000000000 --- a/app/src/main/res/layout/activity_my_page_edit_name.xml +++ /dev/null @@ -1,101 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/test/java/com/runnect/runnect/presentation/mypage/editname/MyPageEditNameViewModelTest.kt b/app/src/test/java/com/runnect/runnect/presentation/mypage/editname/MyPageEditNameViewModelTest.kt new file mode 100644 index 000000000..8e5c0cea5 --- /dev/null +++ b/app/src/test/java/com/runnect/runnect/presentation/mypage/editname/MyPageEditNameViewModelTest.kt @@ -0,0 +1,140 @@ +package com.runnect.runnect.presentation.mypage.editname + +import app.cash.turbine.test +import app.cash.turbine.turbineScope +import com.runnect.runnect.domain.repository.UserRepository +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.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class MyPageEditNameViewModelTest { + + private val testDispatcher = StandardTestDispatcher() + private lateinit var userRepository: UserRepository + private lateinit var viewModel: MyPageEditNameViewModel + + @Before + fun setUp() { + Dispatchers.setMain(testDispatcher) + userRepository = mockk() + viewModel = MyPageEditNameViewModel(userRepository) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `Init 인텐트는 닉네임과 프로필 이미지를 초기화한다`() = runTest(testDispatcher) { + viewModel.state.test { + awaitItem() // 초기 상태 + + viewModel.intent(EditNameIntent.Init("러너", PROFILE_RES_ID)) + + val updated = awaitItem() + assertEquals("러너", updated.nickname) + assertEquals(PROFILE_RES_ID, updated.profileImgResId) + + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `UpdateNickname 인텐트는 닉네임 상태를 갱신한다`() = runTest(testDispatcher) { + viewModel.state.test { + awaitItem() + + viewModel.intent(EditNameIntent.UpdateNickname("새닉네임")) + + assertEquals("새닉네임", awaitItem().nickname) + + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `Submit 성공 시 isLoading이 false로 복원되고 NavigateSuccess 이펙트가 발생한다`() = + runTest(testDispatcher) { + coEvery { userRepository.updateNickName(any()) } returns flow { + delay(1) + emit(Result.success(Unit)) + } + + turbineScope { + val stateTurbine = viewModel.state.testIn(backgroundScope) + val effectTurbine = viewModel.effect.testIn(backgroundScope) + + assertEquals(EditNameUiState(), stateTurbine.awaitItem()) + + viewModel.intent(EditNameIntent.Init("러너", PROFILE_RES_ID)) + val initState = stateTurbine.awaitItem() + assertEquals("러너", initState.nickname) + + viewModel.intent(EditNameIntent.Submit) + + val loading = stateTurbine.awaitItem() + assertTrue(loading.isLoading) + + val done = stateTurbine.awaitItem() + assertFalse(done.isLoading) + + val effect = effectTurbine.awaitItem() + assertTrue(effect is EditNameEffect.NavigateSuccess) + assertEquals("러너", (effect as EditNameEffect.NavigateSuccess).newNickname) + + stateTurbine.cancelAndIgnoreRemainingEvents() + effectTurbine.cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `Submit 실패 시 isLoading이 false로 복원되고 ShowDuplicateError 이펙트가 발생한다`() = + runTest(testDispatcher) { + coEvery { userRepository.updateNickName(any()) } returns flow { + delay(1) + throw RuntimeException("닉네임 중복") + } + + turbineScope { + val stateTurbine = viewModel.state.testIn(backgroundScope) + val effectTurbine = viewModel.effect.testIn(backgroundScope) + + assertEquals(EditNameUiState(), stateTurbine.awaitItem()) + + viewModel.intent(EditNameIntent.Init("중복닉", PROFILE_RES_ID)) + stateTurbine.awaitItem() // Init 상태 + + viewModel.intent(EditNameIntent.Submit) + + val loading = stateTurbine.awaitItem() + assertTrue(loading.isLoading) + + val done = stateTurbine.awaitItem() + assertFalse(done.isLoading) + + assertTrue(effectTurbine.awaitItem() is EditNameEffect.ShowDuplicateError) + + stateTurbine.cancelAndIgnoreRemainingEvents() + effectTurbine.cancelAndIgnoreRemainingEvents() + } + } + + companion object { + private const val PROFILE_RES_ID = 1234 + } +} From ebc6326ee36021b904d2dbd2dcf228791d0e4e21 Mon Sep 17 00:00:00 2001 From: unam98 Date: Sat, 27 Jun 2026 00:02:52 +0900 Subject: [PATCH 2/4] =?UTF-8?q?fix:=20EditNameScreen=20=EC=83=81=ED=83=9C?= =?UTF-8?q?=EB=B0=94=20=ED=8C=A8=EB=94=A9=20+=20=ED=85=8C=EB=91=90?= =?UTF-8?q?=EB=A6=AC=20=EC=83=89=20Before=20=EC=9D=BC=EC=B9=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - statusBarsPadding() 추가: edge-to-edge 적용 시 툴바가 상태바에 겹치던 문제 수정 - border: unfocused/focused 모두 M2(#7E71FF) + radius 10dp (XML과 동일하게) --- .../mypage/editname/MyPageEditNameScreen.kt | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/runnect/runnect/presentation/mypage/editname/MyPageEditNameScreen.kt b/app/src/main/java/com/runnect/runnect/presentation/mypage/editname/MyPageEditNameScreen.kt index 8f8a820be..08cb8d8a5 100644 --- a/app/src/main/java/com/runnect/runnect/presentation/mypage/editname/MyPageEditNameScreen.kt +++ b/app/src/main/java/com/runnect/runnect/presentation/mypage/editname/MyPageEditNameScreen.kt @@ -11,6 +11,7 @@ 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.layout.statusBarsPadding import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardActions @@ -34,6 +35,7 @@ import com.runnect.runnect.R 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.M2 import com.runnect.runnect.presentation.ui.theme.RunnectTheme @Composable @@ -45,7 +47,7 @@ fun MyPageEditNameScreen( ) { val focusManager = LocalFocusManager.current - Box(modifier = Modifier.fillMaxSize()) { + Box(modifier = Modifier.fillMaxSize().statusBarsPadding()) { Column(modifier = Modifier.fillMaxSize()) { EditNameToolbar( onBackClick = onBackClick, @@ -146,10 +148,10 @@ private fun NicknameTextField( singleLine = true, keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), keyboardActions = KeyboardActions(onDone = { onDone() }), - shape = RoundedCornerShape(8.dp), + shape = RoundedCornerShape(10.dp), colors = OutlinedTextFieldDefaults.colors( - focusedBorderColor = M1, - unfocusedBorderColor = G3, + focusedBorderColor = M2, + unfocusedBorderColor = M2, cursorColor = M1, ), ) From 0afd0d9350a09d8879080c275d5c1fbac5d09f2f Mon Sep 17 00:00:00 2001 From: unam98 Date: Sat, 27 Jun 2026 17:07:47 +0900 Subject: [PATCH 3/4] =?UTF-8?q?fix:=20NicknameTextField=20OutlinedTextFiel?= =?UTF-8?q?d=20=E2=86=92=20BasicTextField=EB=A1=9C=20=EA=B5=90=EC=B2=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OutlinedTextField는 M3 내부 contentPadding으로 최소 높이가 56dp이지만 원본 XML AppCompatEditText는 커스텀 background로 44dp를 사용했음. height(44.dp) 강제 시 텍스트가 세로 클리핑되는 버그 수정. BasicTextField + decorationBox로 XML과 동일한 레이아웃 재현. --- .../mypage/editname/MyPageEditNameScreen.kt | 42 +++++++++++-------- 1 file changed, 25 insertions(+), 17 deletions(-) diff --git a/app/src/main/java/com/runnect/runnect/presentation/mypage/editname/MyPageEditNameScreen.kt b/app/src/main/java/com/runnect/runnect/presentation/mypage/editname/MyPageEditNameScreen.kt index 08cb8d8a5..3ac86e3f7 100644 --- a/app/src/main/java/com/runnect/runnect/presentation/mypage/editname/MyPageEditNameScreen.kt +++ b/app/src/main/java/com/runnect/runnect/presentation/mypage/editname/MyPageEditNameScreen.kt @@ -1,6 +1,7 @@ package com.runnect.runnect.presentation.mypage.editname import androidx.compose.foundation.Image +import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -14,11 +15,10 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.width 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.OutlinedTextField -import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment @@ -130,29 +130,37 @@ private fun NicknameTextField( onDone: () -> Unit, ) { val textStyle = RunnectTheme.textStyle - OutlinedTextField( + // OutlinedTextField enforces a 56dp minimum height (M3 internal contentPadding), + // which clips text inside the 44dp height the XML version used. + // BasicTextField + decorationBox gives identical layout to the original AppCompatEditText. + BasicTextField( value = value, onValueChange = { if (it.length <= 7) onValueChange(it) }, modifier = Modifier .fillMaxWidth() .height(44.dp), textStyle = textStyle.semiBold15.copy(textAlign = TextAlign.Center, color = G1), - placeholder = { - Text( - text = stringResource(R.string.my_page_edit_name_guide), - style = textStyle.semiBold15.copy(textAlign = TextAlign.Center), - color = G3, - modifier = Modifier.fillMaxWidth(), - ) - }, singleLine = true, keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), keyboardActions = KeyboardActions(onDone = { onDone() }), - shape = RoundedCornerShape(10.dp), - colors = OutlinedTextFieldDefaults.colors( - focusedBorderColor = M2, - unfocusedBorderColor = M2, - cursorColor = M1, - ), + decorationBox = { innerTextField -> + Box( + modifier = Modifier + .fillMaxSize() + .border(width = 1.dp, color = M2, shape = RoundedCornerShape(10.dp)) + .padding(horizontal = 16.dp), + contentAlignment = Alignment.Center, + ) { + if (value.isEmpty()) { + Text( + text = stringResource(R.string.my_page_edit_name_guide), + style = textStyle.semiBold15.copy(textAlign = TextAlign.Center), + color = G3, + modifier = Modifier.fillMaxWidth(), + ) + } + innerTextField() + } + }, ) } From 5c5faccc911872acedb231344e01833caed14aff Mon Sep 17 00:00:00 2001 From: unam98 Date: Sat, 27 Jun 2026 20:29:53 +0900 Subject: [PATCH 4/4] =?UTF-8?q?fix:=20=EB=A6=AC=EB=B7=B0=20=EB=B0=98?= =?UTF-8?q?=EC=98=81=20=E2=80=94=20Init=20=EC=9E=AC=EC=B4=88=EA=B8=B0?= =?UTF-8?q?=ED=99=94=20=EB=B0=A9=EC=A7=80=C2=B7=EB=92=A4=EB=A1=9C=20?= =?UTF-8?q?=EC=95=A0=EB=8B=88=EB=A9=94=EC=9D=B4=EC=85=98=C2=B7=ED=8F=BC=20?= =?UTF-8?q?=ED=94=84=EB=A6=AC=EC=A6=88=C2=B7=EC=A0=91=EA=B7=BC=EC=84=B1?= =?UTF-8?q?=C2=B7=EC=97=90=EB=9F=AC=20=EB=B6=84=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - savedInstanceState == null 가드로 회전 시 편집 중 닉네임 초기화 방지 - 툴바 뒤로 버튼에 overridePendingTransition 적용(시스템 뒤로와 동일) - isLoading 중 submitEnabled/NicknameTextField.enabled = false로 폼 잠금 - 뒤로 버튼 Image contentDescription 추가(스크린리더 대응) - onFailure에서 getCode() == 400일 때만 ShowDuplicateError 발행 (네트워크·서버 오류를 중복 닉네임 오류로 오표기하던 문제 수정) --- .../mypage/editname/MyPageEditNameActivity.kt | 9 ++++++--- .../presentation/mypage/editname/MyPageEditNameScreen.kt | 7 +++++-- .../mypage/editname/MyPageEditNameViewModel.kt | 9 +++++++-- app/src/main/res/values/strings.xml | 1 + .../mypage/editname/MyPageEditNameViewModelTest.kt | 3 ++- 5 files changed, 21 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/com/runnect/runnect/presentation/mypage/editname/MyPageEditNameActivity.kt b/app/src/main/java/com/runnect/runnect/presentation/mypage/editname/MyPageEditNameActivity.kt index 90e94b797..3a42098ca 100644 --- a/app/src/main/java/com/runnect/runnect/presentation/mypage/editname/MyPageEditNameActivity.kt +++ b/app/src/main/java/com/runnect/runnect/presentation/mypage/editname/MyPageEditNameActivity.kt @@ -29,9 +29,11 @@ class MyPageEditNameActivity : AppCompatActivity() { enableEdgeToEdge() Analytics.logEvent(EventName.VIEW_EDIT_PROFILE) - val nickname = intent.getStringExtra(EXTRA_NICK_NAME) ?: "" - val profileImgResId = intent.getIntExtra(EXTRA_PROFILE, R.drawable.user_profile_basic) - viewModel.intent(EditNameIntent.Init(nickname, profileImgResId)) + if (savedInstanceState == null) { + val nickname = intent.getStringExtra(EXTRA_NICK_NAME) ?: "" + val profileImgResId = intent.getIntExtra(EXTRA_PROFILE, R.drawable.user_profile_basic) + viewModel.intent(EditNameIntent.Init(nickname, profileImgResId)) + } setContent { RunnectTheme { @@ -41,6 +43,7 @@ class MyPageEditNameActivity : AppCompatActivity() { onBackClick = { setResult(RESULT_CANCELED) finish() + overridePendingTransition(R.anim.slide_in_left, R.anim.slide_out_right) }, onNicknameChange = { viewModel.intent(EditNameIntent.UpdateNickname(it)) }, onSubmitClick = { viewModel.intent(EditNameIntent.Submit) }, diff --git a/app/src/main/java/com/runnect/runnect/presentation/mypage/editname/MyPageEditNameScreen.kt b/app/src/main/java/com/runnect/runnect/presentation/mypage/editname/MyPageEditNameScreen.kt index 3ac86e3f7..559caa27f 100644 --- a/app/src/main/java/com/runnect/runnect/presentation/mypage/editname/MyPageEditNameScreen.kt +++ b/app/src/main/java/com/runnect/runnect/presentation/mypage/editname/MyPageEditNameScreen.kt @@ -52,7 +52,7 @@ fun MyPageEditNameScreen( EditNameToolbar( onBackClick = onBackClick, onSubmitClick = onSubmitClick, - submitEnabled = state.nickname.isNotEmpty(), + submitEnabled = state.nickname.isNotEmpty() && !state.isLoading, ) Column( modifier = Modifier @@ -71,6 +71,7 @@ fun MyPageEditNameScreen( value = state.nickname, onValueChange = onNicknameChange, onDone = { focusManager.clearFocus() }, + enabled = !state.isLoading, ) } } @@ -100,7 +101,7 @@ private fun EditNameToolbar( ) { Image( painter = painterResource(R.drawable.all_back_arrow), - contentDescription = null, + contentDescription = stringResource(R.string.my_page_edit_name_back), modifier = Modifier .size(24.dp) .clickable(onClick = onBackClick), @@ -128,6 +129,7 @@ private fun NicknameTextField( value: String, onValueChange: (String) -> Unit, onDone: () -> Unit, + enabled: Boolean = true, ) { val textStyle = RunnectTheme.textStyle // OutlinedTextField enforces a 56dp minimum height (M3 internal contentPadding), @@ -139,6 +141,7 @@ private fun NicknameTextField( modifier = Modifier .fillMaxWidth() .height(44.dp), + enabled = enabled, textStyle = textStyle.semiBold15.copy(textAlign = TextAlign.Center, color = G1), singleLine = true, keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), diff --git a/app/src/main/java/com/runnect/runnect/presentation/mypage/editname/MyPageEditNameViewModel.kt b/app/src/main/java/com/runnect/runnect/presentation/mypage/editname/MyPageEditNameViewModel.kt index 6c18ac2f1..d7b248878 100644 --- a/app/src/main/java/com/runnect/runnect/presentation/mypage/editname/MyPageEditNameViewModel.kt +++ b/app/src/main/java/com/runnect/runnect/presentation/mypage/editname/MyPageEditNameViewModel.kt @@ -1,11 +1,14 @@ package com.runnect.runnect.presentation.mypage.editname import com.runnect.runnect.data.dto.request.RequestPatchNickName +import com.runnect.runnect.domain.common.getCode import com.runnect.runnect.domain.repository.UserRepository import com.runnect.runnect.presentation.base.MviViewModel import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject +private const val HTTP_DUPLICATE_NICKNAME = 400 + @HiltViewModel class MyPageEditNameViewModel @Inject constructor( private val userRepository: UserRepository @@ -33,9 +36,11 @@ class MyPageEditNameViewModel @Inject constructor( reduce { copy(isLoading = false) } postEffect(EditNameEffect.NavigateSuccess(currentState.nickname)) }, - onFailure = { + onFailure = { throwable -> reduce { copy(isLoading = false) } - postEffect(EditNameEffect.ShowDuplicateError) + if (throwable.getCode() == HTTP_DUPLICATE_NICKNAME) { + postEffect(EditNameEffect.ShowDuplicateError) + } } ) } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index eed4c21b8..7fbacbf8f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -83,6 +83,7 @@ 설정 버전 정보 + 뒤로 가기 닉네임 수정 완료 닉네임을 입력하세요 diff --git a/app/src/test/java/com/runnect/runnect/presentation/mypage/editname/MyPageEditNameViewModelTest.kt b/app/src/test/java/com/runnect/runnect/presentation/mypage/editname/MyPageEditNameViewModelTest.kt index 8e5c0cea5..1986b3b29 100644 --- a/app/src/test/java/com/runnect/runnect/presentation/mypage/editname/MyPageEditNameViewModelTest.kt +++ b/app/src/test/java/com/runnect/runnect/presentation/mypage/editname/MyPageEditNameViewModelTest.kt @@ -2,6 +2,7 @@ package com.runnect.runnect.presentation.mypage.editname import app.cash.turbine.test import app.cash.turbine.turbineScope +import com.runnect.runnect.domain.common.RunnectException import com.runnect.runnect.domain.repository.UserRepository import io.mockk.coEvery import io.mockk.mockk @@ -107,7 +108,7 @@ class MyPageEditNameViewModelTest { runTest(testDispatcher) { coEvery { userRepository.updateNickName(any()) } returns flow { delay(1) - throw RuntimeException("닉네임 중복") + throw RunnectException(code = 400, message = "닉네임 중복") } turbineScope {