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..4ac34f5f --- /dev/null +++ b/app/src/main/java/com/runnect/runnect/presentation/base/MviViewModel.kt @@ -0,0 +1,72 @@ +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 timber.log.Timber + +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) { + 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..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 @@ -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,15 @@ 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 com.runnect.runnect.util.extension.showSnackbar import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.flow.collectLatest @AndroidEntryPoint class MyPageFragment : BaseVisitorFragment(R.layout.fragment_my_page) { @@ -38,9 +41,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 +52,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 +63,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 +92,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 +100,31 @@ 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 && state.error == null) { + 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)) + } + + state.error?.let { + context?.showSnackbar(anchorView = binding.root, message = it) } } @@ -122,7 +135,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 +164,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" />