From 92b042714fb047f88e5002c977721045481c197b Mon Sep 17 00:00:00 2001 From: unam98 Date: Fri, 3 Apr 2026 02:40:31 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20MVI=20=EB=B2=A0=EC=9D=B4=EC=8A=A4?= =?UTF-8?q?=20=ED=81=B4=EB=9E=98=EC=8A=A4=20=EB=8F=84=EC=9E=85=20=EB=B0=8F?= =?UTF-8?q?=20MyPage=20=EB=A0=88=ED=8D=BC=EB=9F=B0=EC=8A=A4=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MviViewModel 베이스 클래스 생성 (reduce, postEffect, collectFlow) - MyPageContract 정의 (MyPageUiState, MyPageIntent, MyPageEffect) - MyPageViewModel: LiveData 7개 → StateFlow 단일 UiState로 통합 - MyPageFragment: observe 2개 + XML 바인딩 5개 → collectLatest + bindState()로 전환 - fragment_my_page.xml: DataBinding 표현식 제거, 코드로 이동 --- .../runnect/presentation/base/MviViewModel.kt | 80 +++++++++++++++++ .../presentation/mypage/MyPageContract.kt | 28 ++++++ .../presentation/mypage/MyPageFragment.kt | 50 ++++++----- .../presentation/mypage/MyPageViewModel.kt | 86 ++++++++----------- app/src/main/res/layout/fragment_my_page.xml | 19 ++-- 5 files changed, 177 insertions(+), 86 deletions(-) create mode 100644 app/src/main/java/com/runnect/runnect/presentation/base/MviViewModel.kt create mode 100644 app/src/main/java/com/runnect/runnect/presentation/mypage/MyPageContract.kt diff --git a/app/src/main/java/com/runnect/runnect/presentation/base/MviViewModel.kt b/app/src/main/java/com/runnect/runnect/presentation/base/MviViewModel.kt new file mode 100644 index 00000000..82109568 --- /dev/null +++ b/app/src/main/java/com/runnect/runnect/presentation/base/MviViewModel.kt @@ -0,0 +1,80 @@ +package com.runnect.runnect.presentation.base + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.launch +import retrofit2.HttpException +import timber.log.Timber +import java.net.SocketException +import java.net.UnknownHostException + +abstract class MviViewModel( + initialState: STATE +) : ViewModel() { + + private val _state = MutableStateFlow(initialState) + val state: StateFlow = _state.asStateFlow() + + private val _effect = MutableSharedFlow() + val effect: SharedFlow = _effect.asSharedFlow() + + val currentState: STATE get() = _state.value + + private val exceptionHandler = CoroutineExceptionHandler { _, throwable -> + Timber.tag(throwable::class.java.simpleName).e(throwable) + handleException(throwable) + } + + fun intent(intent: INTENT) { + viewModelScope.launch(exceptionHandler) { + handleIntent(intent) + } + } + + protected abstract suspend fun handleIntent(intent: INTENT) + + protected fun reduce(reducer: STATE.() -> STATE) { + _state.value = currentState.reducer() + } + + protected fun postEffect(effect: EFFECT) { + viewModelScope.launch { + _effect.emit(effect) + } + } + + protected fun collectFlow( + flow: suspend () -> Flow>, + onLoading: () -> Unit = {}, + onSuccess: (T) -> Unit, + onFailure: (Throwable) -> Unit + ) { + viewModelScope.launch(exceptionHandler) { + flow() + .onStart { onLoading() } + .catch { onFailure(it) } + .collect { result -> + result.fold(onSuccess, onFailure) + } + } + } + + protected open fun handleException(throwable: Throwable) { + when (throwable) { + is SocketException, + is HttpException, + is UnknownHostException -> Timber.e(throwable) + else -> Timber.e(throwable) + } + } +} diff --git a/app/src/main/java/com/runnect/runnect/presentation/mypage/MyPageContract.kt b/app/src/main/java/com/runnect/runnect/presentation/mypage/MyPageContract.kt new file mode 100644 index 00000000..1d3072e9 --- /dev/null +++ b/app/src/main/java/com/runnect/runnect/presentation/mypage/MyPageContract.kt @@ -0,0 +1,28 @@ +package com.runnect.runnect.presentation.mypage + +import com.runnect.runnect.R + +data class MyPageUiState( + val isLoading: Boolean = true, + val nickname: String = "", + val stampId: String = STAMP_LOCK, + val profileImgResId: Int = R.drawable.user_profile_basic, + val level: String = "", + val levelPercent: Int = 0, + val email: String = "", + val error: String? = null +) { + companion object { + const val STAMP_LOCK = "lock" + } +} + +sealed interface MyPageIntent { + data object LoadUserInfo : MyPageIntent + data class UpdateNickname(val nickname: String) : MyPageIntent + data class UpdateProfileImg(val resId: Int) : MyPageIntent +} + +sealed interface MyPageEffect { + data class ShowError(val message: String) : MyPageEffect +} diff --git a/app/src/main/java/com/runnect/runnect/presentation/mypage/MyPageFragment.kt b/app/src/main/java/com/runnect/runnect/presentation/mypage/MyPageFragment.kt index a062677f..128fcecc 100644 --- a/app/src/main/java/com/runnect/runnect/presentation/mypage/MyPageFragment.kt +++ b/app/src/main/java/com/runnect/runnect/presentation/mypage/MyPageFragment.kt @@ -10,6 +10,7 @@ import androidx.core.view.isVisible import androidx.fragment.app.activityViewModels import androidx.fragment.app.commit import androidx.fragment.app.replace +import coil3.load import com.kakao.sdk.common.util.KakaoCustomTabsClient import com.kakao.sdk.talk.TalkApiClient import com.runnect.runnect.BuildConfig @@ -21,13 +22,14 @@ import com.runnect.runnect.presentation.mypage.history.MyHistoryActivity import com.runnect.runnect.presentation.mypage.reward.MyRewardActivity import com.runnect.runnect.presentation.mypage.setting.MySettingFragment import com.runnect.runnect.presentation.mypage.upload.MyUploadActivity -import com.runnect.runnect.presentation.state.UiState import com.runnect.runnect.util.analytics.Analytics import com.runnect.runnect.util.analytics.EventName.EVENT_CLICK_GOAL_REWARD import com.runnect.runnect.util.analytics.EventName.EVENT_CLICK_RUNNING_RECORD import com.runnect.runnect.util.analytics.EventName.EVENT_CLICK_UPLOADED_COURSE import com.runnect.runnect.util.extension.getStampResId +import com.runnect.runnect.util.extension.repeatOnStarted import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.flow.collectLatest @AndroidEntryPoint class MyPageFragment : BaseVisitorFragment(R.layout.fragment_my_page) { @@ -38,9 +40,8 @@ class MyPageFragment : BaseVisitorFragment(R.layout.fragm override val contentViews by lazy { listOf(binding.constraintInside) } override fun onContentModeInit() { - binding.vm = viewModel binding.lifecycleOwner = this@MyPageFragment.viewLifecycleOwner - viewModel.getUserInfo() + viewModel.intent(MyPageIntent.LoadUserInfo) addListener() addObserver() setResultEditNameLauncher() @@ -50,8 +51,9 @@ class MyPageFragment : BaseVisitorFragment(R.layout.fragm resultEditNameLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> if (result.resultCode == RESULT_OK) { - val name = result.data?.getStringExtra(EXTRA_NICK_NAME) ?: viewModel.nickName.value - viewModel.setNickName(name!!) + val name = result.data?.getStringExtra(EXTRA_NICK_NAME) + ?: viewModel.currentState.nickname + viewModel.intent(MyPageIntent.UpdateNickname(name)) } } } @@ -60,7 +62,7 @@ class MyPageFragment : BaseVisitorFragment(R.layout.fragm with(binding) { ivMyPageEditFrame.setOnClickListener { val intent = Intent(requireContext(), MyPageEditNameActivity::class.java) - intent.putExtra(EXTRA_NICK_NAME, "${viewModel.nickName.value}") + intent.putExtra(EXTRA_NICK_NAME, viewModel.currentState.nickname) val stampResId = getStampResourceId() intent.putExtra(EXTRA_PROFILE, stampResId) resultEditNameLauncher.launch(intent) @@ -89,7 +91,7 @@ class MyPageFragment : BaseVisitorFragment(R.layout.fragm } private fun moveToSettingFragment() { - val bundle = Bundle().apply { putString(ACCOUNT_INFO_TAG, viewModel.email.value) } + val bundle = Bundle().apply { putString(ACCOUNT_INFO_TAG, viewModel.currentState.email) } requireActivity().supportFragmentManager.commit { this.setCustomAnimations(R.anim.slide_in_right, R.anim.slide_out_left) replace(R.id.fl_main, args = bundle) @@ -97,21 +99,27 @@ class MyPageFragment : BaseVisitorFragment(R.layout.fragm } private fun addObserver() { - viewModel.nickName.observe(viewLifecycleOwner) { nickName -> - binding.tvMyPageUserName.text = nickName.toString() + repeatOnStarted { + viewModel.state.collectLatest { state -> + bindState(state) + } } + } - viewModel.userInfoState.observe(viewLifecycleOwner) { - when (it) { - UiState.Empty -> setLoadingState(false) - UiState.Loading -> setLoadingState(true) - UiState.Success -> { - setLoadingState(false) - val stampResId = getStampResourceId() - viewModel.setProfileImg(stampResId) - } - UiState.Failure -> setLoadingState(false) + private fun bindState(state: MyPageUiState) { + setLoadingState(state.isLoading) + + if (!state.isLoading) { + with(binding) { + tvMyPageUserName.text = state.nickname + tvMyPageUserLv.text = state.level + pbMyPageProgress.progress = state.levelPercent + tvMyPageProgressCurrent.text = state.levelPercent.toString() + ivMyPageProfile.load(state.profileImgResId) } + + val stampResId = getStampResourceId() + viewModel.intent(MyPageIntent.UpdateProfileImg(stampResId)) } } @@ -122,7 +130,7 @@ class MyPageFragment : BaseVisitorFragment(R.layout.fragm private fun getStampResourceId(): Int { return requireContext().getStampResId( - stampId = viewModel.stampId.value, + stampId = viewModel.currentState.stampId, resNameParam = RES_NAME, resType = RES_STAMP_TYPE, packageName = requireContext().packageName @@ -151,4 +159,4 @@ class MyPageFragment : BaseVisitorFragment(R.layout.fragm const val EXTRA_PROFILE = "profile_img" const val ACCOUNT_INFO_TAG = "accountInfo" } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/runnect/runnect/presentation/mypage/MyPageViewModel.kt b/app/src/main/java/com/runnect/runnect/presentation/mypage/MyPageViewModel.kt index 59b47976..34c73574 100644 --- a/app/src/main/java/com/runnect/runnect/presentation/mypage/MyPageViewModel.kt +++ b/app/src/main/java/com/runnect/runnect/presentation/mypage/MyPageViewModel.kt @@ -1,66 +1,48 @@ package com.runnect.runnect.presentation.mypage -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData -import com.runnect.runnect.R import com.runnect.runnect.domain.common.toLog 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 MyPageViewModel @Inject constructor( private val userRepository: UserRepository -) : BaseViewModel() { - - val nickName: MutableLiveData = MutableLiveData() - val stampId: MutableLiveData = MutableLiveData(STAMP_LOCK) - val profileImgResId: MutableLiveData = MutableLiveData(R.drawable.user_profile_basic) - val level: MutableLiveData = MutableLiveData() - val levelPercent: MutableLiveData = MutableLiveData() - val email: MutableLiveData = MutableLiveData() - - private val _userInfoState = MutableLiveData(UiState.Loading) - val userInfoState: LiveData - get() = _userInfoState - - val errorMessage = MutableLiveData() - fun setNickName(nickName: String) { - this.nickName.value = nickName - } - - fun setProfileImg(profileImgResId: Int) { - this.profileImgResId.value = profileImgResId +) : MviViewModel(MyPageUiState()) { + + override suspend fun handleIntent(intent: MyPageIntent) { + when (intent) { + is MyPageIntent.LoadUserInfo -> loadUserInfo() + is MyPageIntent.UpdateNickname -> reduce { copy(nickname = intent.nickname) } + is MyPageIntent.UpdateProfileImg -> reduce { copy(profileImgResId = intent.resId) } + } } - fun getUserInfo() = launchWithHandler { - userRepository.getUserInfo() - .onStart { - _userInfoState.value = UiState.Loading - }.collectResult( - onSuccess = { user -> - user.let { - level.value = it.level.toString() - nickName.value = it.nickname - stampId.value = it.latestStamp - levelPercent.value = it.levelPercent - email.value = it.email - } - - _userInfoState.value = UiState.Success - }, - onFailure = { - errorMessage.value = it.toLog() - _userInfoState.value = UiState.Failure + private fun loadUserInfo() { + collectFlow( + flow = { userRepository.getUserInfo() }, + onLoading = { + reduce { copy(isLoading = true, error = null) } + }, + onSuccess = { user -> + reduce { + copy( + isLoading = false, + nickname = user.nickname, + stampId = user.latestStamp, + level = user.level.toString(), + levelPercent = user.levelPercent, + email = user.email, + error = null + ) } - ) - } - - companion object { - const val STAMP_LOCK = "lock" + }, + onFailure = { throwable -> + val message = throwable.toLog() + reduce { copy(isLoading = false, error = message) } + postEffect(MyPageEffect.ShowError(message)) + } + ) } -} \ No newline at end of file +} diff --git a/app/src/main/res/layout/fragment_my_page.xml b/app/src/main/res/layout/fragment_my_page.xml index 1f3da6ec..d32f0bbc 100644 --- a/app/src/main/res/layout/fragment_my_page.xml +++ b/app/src/main/res/layout/fragment_my_page.xml @@ -4,10 +4,6 @@ xmlns:tools="http://schemas.android.com/tools"> - - + app:layout_constraintTop_toTopOf="@id/tv_my_page_user_lv_indicator" /> + app:layout_constraintTop_toTopOf="@id/tv_my_page_progress_max" /> Date: Fri, 3 Apr 2026 02:57:50 +0900 Subject: [PATCH 2/3] =?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=20Exception=20=EB=B6=84=EA=B8=B0=20?= =?UTF-8?q?=EB=8B=A8=EC=88=9C=ED=99=94,=20error=20=EC=83=81=ED=83=9C=20UI?= =?UTF-8?q?=20=EC=B2=98=EB=A6=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - handleException: 불필요한 when 분기 제거, Timber.e() 단일 호출로 통합 - MyPageFragment.bindState(): error 상태 시 Snackbar 표시 추가 --- .../runnect/runnect/presentation/base/MviViewModel.kt | 10 +--------- .../runnect/presentation/mypage/MyPageFragment.kt | 7 ++++++- 2 files changed, 7 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/com/runnect/runnect/presentation/base/MviViewModel.kt b/app/src/main/java/com/runnect/runnect/presentation/base/MviViewModel.kt index 82109568..a039eba5 100644 --- a/app/src/main/java/com/runnect/runnect/presentation/base/MviViewModel.kt +++ b/app/src/main/java/com/runnect/runnect/presentation/base/MviViewModel.kt @@ -13,10 +13,7 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.launch -import retrofit2.HttpException import timber.log.Timber -import java.net.SocketException -import java.net.UnknownHostException abstract class MviViewModel( initialState: STATE @@ -70,11 +67,6 @@ abstract class MviViewModel( } protected open fun handleException(throwable: Throwable) { - when (throwable) { - is SocketException, - is HttpException, - is UnknownHostException -> Timber.e(throwable) - else -> Timber.e(throwable) - } + Timber.e(throwable) } } diff --git a/app/src/main/java/com/runnect/runnect/presentation/mypage/MyPageFragment.kt b/app/src/main/java/com/runnect/runnect/presentation/mypage/MyPageFragment.kt index 128fcecc..08c0633b 100644 --- a/app/src/main/java/com/runnect/runnect/presentation/mypage/MyPageFragment.kt +++ b/app/src/main/java/com/runnect/runnect/presentation/mypage/MyPageFragment.kt @@ -28,6 +28,7 @@ import com.runnect.runnect.util.analytics.EventName.EVENT_CLICK_RUNNING_RECORD import com.runnect.runnect.util.analytics.EventName.EVENT_CLICK_UPLOADED_COURSE import com.runnect.runnect.util.extension.getStampResId import com.runnect.runnect.util.extension.repeatOnStarted +import com.runnect.runnect.util.extension.showSnackbar import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.flow.collectLatest @@ -109,7 +110,7 @@ class MyPageFragment : BaseVisitorFragment(R.layout.fragm private fun bindState(state: MyPageUiState) { setLoadingState(state.isLoading) - if (!state.isLoading) { + if (!state.isLoading && state.error == null) { with(binding) { tvMyPageUserName.text = state.nickname tvMyPageUserLv.text = state.level @@ -121,6 +122,10 @@ class MyPageFragment : BaseVisitorFragment(R.layout.fragm val stampResId = getStampResourceId() viewModel.intent(MyPageIntent.UpdateProfileImg(stampResId)) } + + state.error?.let { + context?.showSnackbar(anchorView = binding.root, message = it) + } } private fun inquiryKakao() { From c8394ec879eb1c4d3c70be74c72318af31dcea1a Mon Sep 17 00:00:00 2001 From: unam98 Date: Fri, 3 Apr 2026 03:15:11 +0900 Subject: [PATCH 3/3] =?UTF-8?q?refactor:=20=EC=A0=9C=EB=84=A4=EB=A6=AD=20?= =?UTF-8?q?=ED=83=80=EC=9E=85=20=ED=8C=8C=EB=9D=BC=EB=AF=B8=ED=84=B0=20?= =?UTF-8?q?=EB=84=A4=EC=9D=B4=EB=B0=8D=20=EC=BB=A8=EB=B2=A4=EC=85=98=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=20(STATE=20=E2=86=92=20State)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Google Android 공식 샘플 컨벤션에 맞춰 PascalCase로 변경 - STATE → State, INTENT → Intent, EFFECT → Effect --- .../runnect/presentation/base/MviViewModel.kt | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/com/runnect/runnect/presentation/base/MviViewModel.kt b/app/src/main/java/com/runnect/runnect/presentation/base/MviViewModel.kt index a039eba5..4ac34f5f 100644 --- a/app/src/main/java/com/runnect/runnect/presentation/base/MviViewModel.kt +++ b/app/src/main/java/com/runnect/runnect/presentation/base/MviViewModel.kt @@ -15,36 +15,36 @@ import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.launch import timber.log.Timber -abstract class MviViewModel( - initialState: STATE +abstract class MviViewModel( + initialState: State ) : ViewModel() { private val _state = MutableStateFlow(initialState) - val state: StateFlow = _state.asStateFlow() + val state: StateFlow = _state.asStateFlow() - private val _effect = MutableSharedFlow() - val effect: SharedFlow = _effect.asSharedFlow() + private val _effect = MutableSharedFlow() + val effect: SharedFlow = _effect.asSharedFlow() - val currentState: STATE get() = _state.value + val currentState: State get() = _state.value private val exceptionHandler = CoroutineExceptionHandler { _, throwable -> Timber.tag(throwable::class.java.simpleName).e(throwable) handleException(throwable) } - fun intent(intent: INTENT) { + fun intent(intent: Intent) { viewModelScope.launch(exceptionHandler) { handleIntent(intent) } } - protected abstract suspend fun handleIntent(intent: INTENT) + protected abstract suspend fun handleIntent(intent: Intent) - protected fun reduce(reducer: STATE.() -> STATE) { + protected fun reduce(reducer: State.() -> State) { _state.value = currentState.reducer() } - protected fun postEffect(effect: EFFECT) { + protected fun postEffect(effect: Effect) { viewModelScope.launch { _effect.emit(effect) }