Skip to content

Commit 9c62c23

Browse files
authored
Merge pull request #373 from Runnect/feature/wave1-mvi-pattern
MVI 베이스 클래스 도입 및 MyPage 레퍼런스 구현
2 parents 0c266e0 + c8394ec commit 9c62c23

5 files changed

Lines changed: 174 additions & 86 deletions

File tree

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
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 timber.log.Timber
17+
18+
abstract class MviViewModel<State, Intent, Effect>(
19+
initialState: State
20+
) : ViewModel() {
21+
22+
private val _state = MutableStateFlow(initialState)
23+
val state: StateFlow<State> = _state.asStateFlow()
24+
25+
private val _effect = MutableSharedFlow<Effect>()
26+
val effect: SharedFlow<Effect> = _effect.asSharedFlow()
27+
28+
val currentState: State get() = _state.value
29+
30+
private val exceptionHandler = CoroutineExceptionHandler { _, throwable ->
31+
Timber.tag(throwable::class.java.simpleName).e(throwable)
32+
handleException(throwable)
33+
}
34+
35+
fun intent(intent: Intent) {
36+
viewModelScope.launch(exceptionHandler) {
37+
handleIntent(intent)
38+
}
39+
}
40+
41+
protected abstract suspend fun handleIntent(intent: Intent)
42+
43+
protected fun reduce(reducer: State.() -> State) {
44+
_state.value = currentState.reducer()
45+
}
46+
47+
protected fun postEffect(effect: Effect) {
48+
viewModelScope.launch {
49+
_effect.emit(effect)
50+
}
51+
}
52+
53+
protected fun <T> collectFlow(
54+
flow: suspend () -> Flow<Result<T>>,
55+
onLoading: () -> Unit = {},
56+
onSuccess: (T) -> Unit,
57+
onFailure: (Throwable) -> Unit
58+
) {
59+
viewModelScope.launch(exceptionHandler) {
60+
flow()
61+
.onStart { onLoading() }
62+
.catch { onFailure(it) }
63+
.collect { result ->
64+
result.fold(onSuccess, onFailure)
65+
}
66+
}
67+
}
68+
69+
protected open fun handleException(throwable: Throwable) {
70+
Timber.e(throwable)
71+
}
72+
}
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: 34 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,15 @@ 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
31+
import com.runnect.runnect.util.extension.showSnackbar
3032
import dagger.hilt.android.AndroidEntryPoint
33+
import kotlinx.coroutines.flow.collectLatest
3134

3235
@AndroidEntryPoint
3336
class MyPageFragment : BaseVisitorFragment<FragmentMyPageBinding>(R.layout.fragment_my_page) {
@@ -38,9 +41,8 @@ class MyPageFragment : BaseVisitorFragment<FragmentMyPageBinding>(R.layout.fragm
3841
override val contentViews by lazy { listOf(binding.constraintInside) }
3942

4043
override fun onContentModeInit() {
41-
binding.vm = viewModel
4244
binding.lifecycleOwner = this@MyPageFragment.viewLifecycleOwner
43-
viewModel.getUserInfo()
45+
viewModel.intent(MyPageIntent.LoadUserInfo)
4446
addListener()
4547
addObserver()
4648
setResultEditNameLauncher()
@@ -50,8 +52,9 @@ class MyPageFragment : BaseVisitorFragment<FragmentMyPageBinding>(R.layout.fragm
5052
resultEditNameLauncher =
5153
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
5254
if (result.resultCode == RESULT_OK) {
53-
val name = result.data?.getStringExtra(EXTRA_NICK_NAME) ?: viewModel.nickName.value
54-
viewModel.setNickName(name!!)
55+
val name = result.data?.getStringExtra(EXTRA_NICK_NAME)
56+
?: viewModel.currentState.nickname
57+
viewModel.intent(MyPageIntent.UpdateNickname(name))
5558
}
5659
}
5760
}
@@ -60,7 +63,7 @@ class MyPageFragment : BaseVisitorFragment<FragmentMyPageBinding>(R.layout.fragm
6063
with(binding) {
6164
ivMyPageEditFrame.setOnClickListener {
6265
val intent = Intent(requireContext(), MyPageEditNameActivity::class.java)
63-
intent.putExtra(EXTRA_NICK_NAME, "${viewModel.nickName.value}")
66+
intent.putExtra(EXTRA_NICK_NAME, viewModel.currentState.nickname)
6467
val stampResId = getStampResourceId()
6568
intent.putExtra(EXTRA_PROFILE, stampResId)
6669
resultEditNameLauncher.launch(intent)
@@ -89,29 +92,39 @@ class MyPageFragment : BaseVisitorFragment<FragmentMyPageBinding>(R.layout.fragm
8992
}
9093

9194
private fun moveToSettingFragment() {
92-
val bundle = Bundle().apply { putString(ACCOUNT_INFO_TAG, viewModel.email.value) }
95+
val bundle = Bundle().apply { putString(ACCOUNT_INFO_TAG, viewModel.currentState.email) }
9396
requireActivity().supportFragmentManager.commit {
9497
this.setCustomAnimations(R.anim.slide_in_right, R.anim.slide_out_left)
9598
replace<MySettingFragment>(R.id.fl_main, args = bundle)
9699
}
97100
}
98101

99102
private fun addObserver() {
100-
viewModel.nickName.observe(viewLifecycleOwner) { nickName ->
101-
binding.tvMyPageUserName.text = nickName.toString()
103+
repeatOnStarted {
104+
viewModel.state.collectLatest { state ->
105+
bindState(state)
106+
}
102107
}
108+
}
103109

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)
110+
private fun bindState(state: MyPageUiState) {
111+
setLoadingState(state.isLoading)
112+
113+
if (!state.isLoading && state.error == null) {
114+
with(binding) {
115+
tvMyPageUserName.text = state.nickname
116+
tvMyPageUserLv.text = state.level
117+
pbMyPageProgress.progress = state.levelPercent
118+
tvMyPageProgressCurrent.text = state.levelPercent.toString()
119+
ivMyPageProfile.load(state.profileImgResId)
114120
}
121+
122+
val stampResId = getStampResourceId()
123+
viewModel.intent(MyPageIntent.UpdateProfileImg(stampResId))
124+
}
125+
126+
state.error?.let {
127+
context?.showSnackbar(anchorView = binding.root, message = it)
115128
}
116129
}
117130

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

123136
private fun getStampResourceId(): Int {
124137
return requireContext().getStampResId(
125-
stampId = viewModel.stampId.value,
138+
stampId = viewModel.currentState.stampId,
126139
resNameParam = RES_NAME,
127140
resType = RES_STAMP_TYPE,
128141
packageName = requireContext().packageName
@@ -151,4 +164,4 @@ class MyPageFragment : BaseVisitorFragment<FragmentMyPageBinding>(R.layout.fragm
151164
const val EXTRA_PROFILE = "profile_img"
152165
const val ACCOUNT_INFO_TAG = "accountInfo"
153166
}
154-
}
167+
}
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+
}

app/src/main/res/layout/fragment_my_page.xml

Lines changed: 6 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,6 @@
44
xmlns:tools="http://schemas.android.com/tools">
55

66
<data>
7-
8-
<variable
9-
name="vm"
10-
type="com.runnect.runnect.presentation.mypage.MyPageViewModel" />
117
</data>
128

139
<androidx.constraintlayout.widget.ConstraintLayout
@@ -130,7 +126,6 @@
130126
android:layout_width="63dp"
131127
android:layout_height="0dp"
132128
android:layout_marginStart="23dp"
133-
setLocalImageByResourceId="@{vm.profileImgResId}"
134129
app:layout_constraintBottom_toBottomOf="@id/view_my_page_profile_frame"
135130
app:layout_constraintDimensionRatio="1:1"
136131
app:layout_constraintStart_toStartOf="@id/view_my_page_profile_frame"
@@ -142,7 +137,7 @@
142137
android:layout_height="wrap_content"
143138
android:layout_marginStart="10dp"
144139
android:fontFamily="@font/pretendard_bold"
145-
android:text="@{vm.nickName}"
140+
tools:text="닉네임"
146141
android:textColor="@color/M1"
147142
android:textSize="17sp"
148143
app:layout_constraintBottom_toBottomOf="@id/iv_my_page_profile"
@@ -217,13 +212,12 @@
217212
android:height="22dp"
218213
android:fontFamily="@font/pretendard_bold"
219214
android:gravity="center"
220-
android:text="@{vm.level}"
215+
tools:text="3"
221216
android:textColor="@color/G1"
222217
android:textSize="15sp"
223218
app:layout_constraintBottom_toBottomOf="@id/tv_my_page_user_lv_indicator"
224219
app:layout_constraintStart_toEndOf="@id/tv_my_page_user_lv_indicator"
225-
app:layout_constraintTop_toTopOf="@id/tv_my_page_user_lv_indicator"
226-
tools:text="3" />
220+
app:layout_constraintTop_toTopOf="@id/tv_my_page_user_lv_indicator" />
227221

228222
<ProgressBar
229223
android:id="@+id/pb_my_page_progress"
@@ -233,7 +227,7 @@
233227
android:layout_marginHorizontal="22dp"
234228
android:layout_marginTop="6dp"
235229
android:max="100"
236-
android:progress="@{vm.levelPercent}"
230+
tools:progress="50"
237231
android:progressDrawable="@drawable/progressbar_custom"
238232
app:layout_constraintEnd_toEndOf="parent"
239233
app:layout_constraintStart_toStartOf="parent"
@@ -245,13 +239,12 @@
245239
android:layout_height="wrap_content"
246240
android:layout_marginEnd="1dp"
247241
android:fontFamily="@font/pretendard_semibold"
248-
android:text="@{vm.levelPercent.toString()}"
242+
tools:text="50"
249243
android:textColor="@color/G1"
250244
android:textSize="13sp"
251245
app:layout_constraintBottom_toBottomOf="@id/tv_my_page_progress_max"
252246
app:layout_constraintEnd_toStartOf="@id/tv_my_page_progress_max"
253-
app:layout_constraintTop_toTopOf="@id/tv_my_page_progress_max"
254-
tools:text="50" />
247+
app:layout_constraintTop_toTopOf="@id/tv_my_page_progress_max" />
255248

256249
<androidx.appcompat.widget.AppCompatTextView
257250
android:id="@+id/tv_my_page_progress_max"

0 commit comments

Comments
 (0)