Skip to content

Commit 92b0427

Browse files
committed
feat: MVI 베이스 클래스 도입 및 MyPage 레퍼런스 구현
- MviViewModel<STATE, INTENT, EFFECT> 베이스 클래스 생성 (reduce, postEffect, collectFlow) - MyPageContract 정의 (MyPageUiState, MyPageIntent, MyPageEffect) - MyPageViewModel: LiveData 7개 → StateFlow 단일 UiState로 통합 - MyPageFragment: observe 2개 + XML 바인딩 5개 → collectLatest + bindState()로 전환 - fragment_my_page.xml: DataBinding 표현식 제거, 코드로 이동
1 parent 0c266e0 commit 92b0427

5 files changed

Lines changed: 177 additions & 86 deletions

File tree

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
package com.runnect.runnect.presentation.base
2+
3+
import androidx.lifecycle.ViewModel
4+
import androidx.lifecycle.viewModelScope
5+
import kotlinx.coroutines.CoroutineExceptionHandler
6+
import kotlinx.coroutines.flow.Flow
7+
import kotlinx.coroutines.flow.MutableSharedFlow
8+
import kotlinx.coroutines.flow.MutableStateFlow
9+
import kotlinx.coroutines.flow.SharedFlow
10+
import kotlinx.coroutines.flow.StateFlow
11+
import kotlinx.coroutines.flow.asSharedFlow
12+
import kotlinx.coroutines.flow.asStateFlow
13+
import kotlinx.coroutines.flow.catch
14+
import kotlinx.coroutines.flow.onStart
15+
import kotlinx.coroutines.launch
16+
import retrofit2.HttpException
17+
import timber.log.Timber
18+
import java.net.SocketException
19+
import java.net.UnknownHostException
20+
21+
abstract class MviViewModel<STATE, INTENT, EFFECT>(
22+
initialState: STATE
23+
) : ViewModel() {
24+
25+
private val _state = MutableStateFlow(initialState)
26+
val state: StateFlow<STATE> = _state.asStateFlow()
27+
28+
private val _effect = MutableSharedFlow<EFFECT>()
29+
val effect: SharedFlow<EFFECT> = _effect.asSharedFlow()
30+
31+
val currentState: STATE get() = _state.value
32+
33+
private val exceptionHandler = CoroutineExceptionHandler { _, throwable ->
34+
Timber.tag(throwable::class.java.simpleName).e(throwable)
35+
handleException(throwable)
36+
}
37+
38+
fun intent(intent: INTENT) {
39+
viewModelScope.launch(exceptionHandler) {
40+
handleIntent(intent)
41+
}
42+
}
43+
44+
protected abstract suspend fun handleIntent(intent: INTENT)
45+
46+
protected fun reduce(reducer: STATE.() -> STATE) {
47+
_state.value = currentState.reducer()
48+
}
49+
50+
protected fun postEffect(effect: EFFECT) {
51+
viewModelScope.launch {
52+
_effect.emit(effect)
53+
}
54+
}
55+
56+
protected fun <T> collectFlow(
57+
flow: suspend () -> Flow<Result<T>>,
58+
onLoading: () -> Unit = {},
59+
onSuccess: (T) -> Unit,
60+
onFailure: (Throwable) -> Unit
61+
) {
62+
viewModelScope.launch(exceptionHandler) {
63+
flow()
64+
.onStart { onLoading() }
65+
.catch { onFailure(it) }
66+
.collect { result ->
67+
result.fold(onSuccess, onFailure)
68+
}
69+
}
70+
}
71+
72+
protected open fun handleException(throwable: Throwable) {
73+
when (throwable) {
74+
is SocketException,
75+
is HttpException,
76+
is UnknownHostException -> Timber.e(throwable)
77+
else -> Timber.e(throwable)
78+
}
79+
}
80+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
package com.runnect.runnect.presentation.mypage
2+
3+
import com.runnect.runnect.R
4+
5+
data class MyPageUiState(
6+
val isLoading: Boolean = true,
7+
val nickname: String = "",
8+
val stampId: String = STAMP_LOCK,
9+
val profileImgResId: Int = R.drawable.user_profile_basic,
10+
val level: String = "",
11+
val levelPercent: Int = 0,
12+
val email: String = "",
13+
val error: String? = null
14+
) {
15+
companion object {
16+
const val STAMP_LOCK = "lock"
17+
}
18+
}
19+
20+
sealed interface MyPageIntent {
21+
data object LoadUserInfo : MyPageIntent
22+
data class UpdateNickname(val nickname: String) : MyPageIntent
23+
data class UpdateProfileImg(val resId: Int) : MyPageIntent
24+
}
25+
26+
sealed interface MyPageEffect {
27+
data class ShowError(val message: String) : MyPageEffect
28+
}

app/src/main/java/com/runnect/runnect/presentation/mypage/MyPageFragment.kt

Lines changed: 29 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import androidx.core.view.isVisible
1010
import androidx.fragment.app.activityViewModels
1111
import androidx.fragment.app.commit
1212
import androidx.fragment.app.replace
13+
import coil3.load
1314
import com.kakao.sdk.common.util.KakaoCustomTabsClient
1415
import com.kakao.sdk.talk.TalkApiClient
1516
import com.runnect.runnect.BuildConfig
@@ -21,13 +22,14 @@ import com.runnect.runnect.presentation.mypage.history.MyHistoryActivity
2122
import com.runnect.runnect.presentation.mypage.reward.MyRewardActivity
2223
import com.runnect.runnect.presentation.mypage.setting.MySettingFragment
2324
import com.runnect.runnect.presentation.mypage.upload.MyUploadActivity
24-
import com.runnect.runnect.presentation.state.UiState
2525
import com.runnect.runnect.util.analytics.Analytics
2626
import com.runnect.runnect.util.analytics.EventName.EVENT_CLICK_GOAL_REWARD
2727
import com.runnect.runnect.util.analytics.EventName.EVENT_CLICK_RUNNING_RECORD
2828
import com.runnect.runnect.util.analytics.EventName.EVENT_CLICK_UPLOADED_COURSE
2929
import com.runnect.runnect.util.extension.getStampResId
30+
import com.runnect.runnect.util.extension.repeatOnStarted
3031
import dagger.hilt.android.AndroidEntryPoint
32+
import kotlinx.coroutines.flow.collectLatest
3133

3234
@AndroidEntryPoint
3335
class MyPageFragment : BaseVisitorFragment<FragmentMyPageBinding>(R.layout.fragment_my_page) {
@@ -38,9 +40,8 @@ class MyPageFragment : BaseVisitorFragment<FragmentMyPageBinding>(R.layout.fragm
3840
override val contentViews by lazy { listOf(binding.constraintInside) }
3941

4042
override fun onContentModeInit() {
41-
binding.vm = viewModel
4243
binding.lifecycleOwner = this@MyPageFragment.viewLifecycleOwner
43-
viewModel.getUserInfo()
44+
viewModel.intent(MyPageIntent.LoadUserInfo)
4445
addListener()
4546
addObserver()
4647
setResultEditNameLauncher()
@@ -50,8 +51,9 @@ class MyPageFragment : BaseVisitorFragment<FragmentMyPageBinding>(R.layout.fragm
5051
resultEditNameLauncher =
5152
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
5253
if (result.resultCode == RESULT_OK) {
53-
val name = result.data?.getStringExtra(EXTRA_NICK_NAME) ?: viewModel.nickName.value
54-
viewModel.setNickName(name!!)
54+
val name = result.data?.getStringExtra(EXTRA_NICK_NAME)
55+
?: viewModel.currentState.nickname
56+
viewModel.intent(MyPageIntent.UpdateNickname(name))
5557
}
5658
}
5759
}
@@ -60,7 +62,7 @@ class MyPageFragment : BaseVisitorFragment<FragmentMyPageBinding>(R.layout.fragm
6062
with(binding) {
6163
ivMyPageEditFrame.setOnClickListener {
6264
val intent = Intent(requireContext(), MyPageEditNameActivity::class.java)
63-
intent.putExtra(EXTRA_NICK_NAME, "${viewModel.nickName.value}")
65+
intent.putExtra(EXTRA_NICK_NAME, viewModel.currentState.nickname)
6466
val stampResId = getStampResourceId()
6567
intent.putExtra(EXTRA_PROFILE, stampResId)
6668
resultEditNameLauncher.launch(intent)
@@ -89,29 +91,35 @@ class MyPageFragment : BaseVisitorFragment<FragmentMyPageBinding>(R.layout.fragm
8991
}
9092

9193
private fun moveToSettingFragment() {
92-
val bundle = Bundle().apply { putString(ACCOUNT_INFO_TAG, viewModel.email.value) }
94+
val bundle = Bundle().apply { putString(ACCOUNT_INFO_TAG, viewModel.currentState.email) }
9395
requireActivity().supportFragmentManager.commit {
9496
this.setCustomAnimations(R.anim.slide_in_right, R.anim.slide_out_left)
9597
replace<MySettingFragment>(R.id.fl_main, args = bundle)
9698
}
9799
}
98100

99101
private fun addObserver() {
100-
viewModel.nickName.observe(viewLifecycleOwner) { nickName ->
101-
binding.tvMyPageUserName.text = nickName.toString()
102+
repeatOnStarted {
103+
viewModel.state.collectLatest { state ->
104+
bindState(state)
105+
}
102106
}
107+
}
103108

104-
viewModel.userInfoState.observe(viewLifecycleOwner) {
105-
when (it) {
106-
UiState.Empty -> setLoadingState(false)
107-
UiState.Loading -> setLoadingState(true)
108-
UiState.Success -> {
109-
setLoadingState(false)
110-
val stampResId = getStampResourceId()
111-
viewModel.setProfileImg(stampResId)
112-
}
113-
UiState.Failure -> setLoadingState(false)
109+
private fun bindState(state: MyPageUiState) {
110+
setLoadingState(state.isLoading)
111+
112+
if (!state.isLoading) {
113+
with(binding) {
114+
tvMyPageUserName.text = state.nickname
115+
tvMyPageUserLv.text = state.level
116+
pbMyPageProgress.progress = state.levelPercent
117+
tvMyPageProgressCurrent.text = state.levelPercent.toString()
118+
ivMyPageProfile.load(state.profileImgResId)
114119
}
120+
121+
val stampResId = getStampResourceId()
122+
viewModel.intent(MyPageIntent.UpdateProfileImg(stampResId))
115123
}
116124
}
117125

@@ -122,7 +130,7 @@ class MyPageFragment : BaseVisitorFragment<FragmentMyPageBinding>(R.layout.fragm
122130

123131
private fun getStampResourceId(): Int {
124132
return requireContext().getStampResId(
125-
stampId = viewModel.stampId.value,
133+
stampId = viewModel.currentState.stampId,
126134
resNameParam = RES_NAME,
127135
resType = RES_STAMP_TYPE,
128136
packageName = requireContext().packageName
@@ -151,4 +159,4 @@ class MyPageFragment : BaseVisitorFragment<FragmentMyPageBinding>(R.layout.fragm
151159
const val EXTRA_PROFILE = "profile_img"
152160
const val ACCOUNT_INFO_TAG = "accountInfo"
153161
}
154-
}
162+
}
Lines changed: 34 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -1,66 +1,48 @@
11
package com.runnect.runnect.presentation.mypage
22

3-
import androidx.lifecycle.LiveData
4-
import androidx.lifecycle.MutableLiveData
5-
import com.runnect.runnect.R
63
import com.runnect.runnect.domain.common.toLog
74
import com.runnect.runnect.domain.repository.UserRepository
8-
import com.runnect.runnect.presentation.base.BaseViewModel
9-
import com.runnect.runnect.presentation.state.UiState
10-
import com.runnect.runnect.util.extension.collectResult
5+
import com.runnect.runnect.presentation.base.MviViewModel
116
import dagger.hilt.android.lifecycle.HiltViewModel
12-
import kotlinx.coroutines.flow.onStart
137
import javax.inject.Inject
148

159
@HiltViewModel
1610
class MyPageViewModel @Inject constructor(
1711
private val userRepository: UserRepository
18-
) : BaseViewModel() {
19-
20-
val nickName: MutableLiveData<String> = MutableLiveData<String>()
21-
val stampId: MutableLiveData<String> = MutableLiveData<String>(STAMP_LOCK)
22-
val profileImgResId: MutableLiveData<Int> = MutableLiveData<Int>(R.drawable.user_profile_basic)
23-
val level: MutableLiveData<String> = MutableLiveData<String>()
24-
val levelPercent: MutableLiveData<Int> = MutableLiveData<Int>()
25-
val email: MutableLiveData<String> = MutableLiveData<String>()
26-
27-
private val _userInfoState = MutableLiveData<UiState>(UiState.Loading)
28-
val userInfoState: LiveData<UiState>
29-
get() = _userInfoState
30-
31-
val errorMessage = MutableLiveData<String>()
32-
fun setNickName(nickName: String) {
33-
this.nickName.value = nickName
34-
}
35-
36-
fun setProfileImg(profileImgResId: Int) {
37-
this.profileImgResId.value = profileImgResId
12+
) : MviViewModel<MyPageUiState, MyPageIntent, MyPageEffect>(MyPageUiState()) {
13+
14+
override suspend fun handleIntent(intent: MyPageIntent) {
15+
when (intent) {
16+
is MyPageIntent.LoadUserInfo -> loadUserInfo()
17+
is MyPageIntent.UpdateNickname -> reduce { copy(nickname = intent.nickname) }
18+
is MyPageIntent.UpdateProfileImg -> reduce { copy(profileImgResId = intent.resId) }
19+
}
3820
}
3921

40-
fun getUserInfo() = launchWithHandler {
41-
userRepository.getUserInfo()
42-
.onStart {
43-
_userInfoState.value = UiState.Loading
44-
}.collectResult(
45-
onSuccess = { user ->
46-
user.let {
47-
level.value = it.level.toString()
48-
nickName.value = it.nickname
49-
stampId.value = it.latestStamp
50-
levelPercent.value = it.levelPercent
51-
email.value = it.email
52-
}
53-
54-
_userInfoState.value = UiState.Success
55-
},
56-
onFailure = {
57-
errorMessage.value = it.toLog()
58-
_userInfoState.value = UiState.Failure
22+
private fun loadUserInfo() {
23+
collectFlow(
24+
flow = { userRepository.getUserInfo() },
25+
onLoading = {
26+
reduce { copy(isLoading = true, error = null) }
27+
},
28+
onSuccess = { user ->
29+
reduce {
30+
copy(
31+
isLoading = false,
32+
nickname = user.nickname,
33+
stampId = user.latestStamp,
34+
level = user.level.toString(),
35+
levelPercent = user.levelPercent,
36+
email = user.email,
37+
error = null
38+
)
5939
}
60-
)
61-
}
62-
63-
companion object {
64-
const val STAMP_LOCK = "lock"
40+
},
41+
onFailure = { throwable ->
42+
val message = throwable.toLog()
43+
reduce { copy(isLoading = false, error = message) }
44+
postEffect(MyPageEffect.ShowError(message))
45+
}
46+
)
6547
}
66-
}
48+
}

0 commit comments

Comments
 (0)